From erun-tools
Build a multi-tenant Go HTTP API service following ERun's blueprint — OIDC bearer authentication, tenant resolution from the token issuer, layered model / repository / service / routes structure, transaction-scoped PostgreSQL security context, identity resolution cache, and audit logging. Captures the patterns that erun-backend-api packages. Use when the user says "build a multi-tenant http api", "create an erun-backend-api-shaped service", "I need a multi-tenant Go api with oidc auth and tenant rls", "build a multi-tenant backend api", or any similar request for a new multi-tenant Go API.
How this skill is triggered — by the user, by Claude, or both
Slash command
/erun-tools:erun-blueprint-apiThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Produce a Go HTTP API service following ERun's blueprint — the same
templates/AGENTS.md.tmpltemplates/api_path.go.tmpltemplates/audit.go.tmpltemplates/auth.go.tmpltemplates/cmd_main.go.tmpltemplates/go.mod.tmpltemplates/identity_cache.go.tmpltemplates/model_entity.go.tmpltemplates/model_user.go.tmpltemplates/oidc.go.tmpltemplates/repository_audit_event.go.tmpltemplates/repository_entity.go.tmpltemplates/repository_identity.go.tmpltemplates/repository_permission_authorizer.go.tmpltemplates/repository_tx.go.tmpltemplates/repository_user.go.tmpltemplates/routes_entity.go.tmpltemplates/routes_protected.go.tmpltemplates/routes_whoami.go.tmpltemplates/server.go.tmplProduce a Go HTTP API service following ERun's blueprint — the same
shape erun-backend-api captures: OIDC bearer authentication, tenant
resolution from the token iss claim, layered internal/model →
internal/repository → internal/service → internal/routes
structure, transaction-scoped PostgreSQL security context
(SET LOCAL ROLE erun_tenant / SET LOCAL erun.tenant_id), bounded
identity-resolution cache, and audit logging on successfully authorized
requests.
This skill packages ERun's accumulated best practices for multi-tenant HTTP APIs. Do not freelance the patterns; the conventions encoded here are the contract.
Trigger on user phrasings such as:
This skill assumes the database side already exists or is being built
in parallel via the erun-blueprint-rls-db skill. The API expects:
erun_tenant and erun_operations roles.erun_current_tenant_id() function backed by session setting
erun.tenant_id.tenants, tenant_issuers, users,
user_external_ids.audit_events table for API/MCP/CLI audit (add via the db skill
if missing).If the database side is not present, run erun-blueprint-rls-db first
or confirm with the user that they have an equivalent.
acme-api). Used as the Go module name and
directory name.github.com/acme/acme-api). Used in
go.mod and import paths.invoices, customers). For each: route prefix (e.g.
/v1/invoices), minimal columns. Empty list is fine — the produced
module still has a working whoami endpoint.Ask once, then proceed.
<module-name>/
├── AGENTS.md
├── go.mod
├── cmd/
│ └── <module-name>/
│ └── main.go # process entrypoint
├── server.go # HandlerOptions + NewHandler composition
├── auth.go # AuthMiddleware
├── oidc.go # OIDC TokenVerifier (issuer + JWKS)
├── identity_cache.go # Identity resolution cache
├── audit.go # AuditLogger interface
├── api_path.go # canonical API path helpers
└── internal/
├── model/
│ ├── user.go
│ └── <user-supplied entities>.go
├── repository/
│ ├── tx.go # TxManager — security context wiring
│ ├── identity.go # IdentityRepository — issuer/subject resolution + bootstrap
│ ├── user.go # UserRepository — non-bootstrap user CRUD
│ ├── audit_event.go # AuditEventRepository
│ ├── permission_authorizer.go # role_permissions matcher
│ └── <user-supplied entities>.go
├── service/
│ └── (add a file only when a workflow has real logic)
└── routes/
├── protected.go # ProtectedRouteRegistrar type
├── whoami.go # /v1/whoami
└── <user-supplied entities>.go
Reference files for the canonical blueprint ship alongside this
SKILL.md under templates/. Substitute placeholders, then expand for
the user's domain entities.
These come from erun-backend/erun-backend-api/AGENTS.md. Apply every
one.
internal/model/ — DB-mapped entity structs. Used as the shared
entity language across all layers. No DTOs, no service entities, no
route response types that mirror the same fields.internal/repository/ — SQL persistence via Bun. CRUD only. No
workflows. Create/Get/List/Update; add Delete only when the
product hard-deletes.internal/service/ — workflow orchestration only when a workflow
has real logic beyond calling one repository method. Do not create
the directory until a real service exists.internal/routes/ — HTTP adaptation: path values, query parsing,
body decoding, status codes, JSON responses.server.go is the composition boundary — constructs repos, optional
services, routes, middleware, wires them.model must not import anything from the API layers.cmd/<name>/main.go wires the TokenVerifier (JWKS-backed,
multi-issuer).iss → look up user from (iss, sub) → populate request-scoped
security context → call next.(iss, sub) on an empty database may bootstrap the
OPERATIONS tenant and first user with ReadAll + WriteAll. Once
any tenant exists, unknown identities are rejected, no implicit user
creation.Repositories never accept tenant_id as an argument. Instead,
TxManager opens a transaction and sets the PostgreSQL security
context from the authenticated context:
// Sketch — see templates/repository/tx.go.tmpl for the full version.
func (tm *TxManager) WithTx(ctx context.Context, fn func(*bun.Tx) error) error {
sec, err := SecurityFromContext(ctx)
if err != nil {
return fmt.Errorf("backend: missing security context: %w", err)
}
return tm.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
role := "erun_tenant"
if sec.IsOperations {
role = "erun_operations"
}
if _, err := tx.ExecContext(ctx, "SET LOCAL ROLE "+role); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, "SELECT set_config('erun.tenant_id', ?, true)", sec.TenantID.String()); err != nil {
return err
}
if sec.UserID != uuid.Nil {
if _, err := tx.ExecContext(ctx, "SELECT set_config('erun.user_id', ?, true)", sec.UserID.String()); err != nil {
return err
}
}
return fn(&tx)
})
}
Use Bun ? placeholders inside set_config(...). Do not use $1
PostgreSQL placeholders in Bun-managed queries.
role_permissions as exact (method, path)
pairs or regex (method_pattern, path_pattern) patterns./v1/invoices/{invoice_id}) — not
the concrete request URL.ReadAll predefined role grants all read-style methods across
all paths; WriteAll grants all write-style methods. Route handlers
never check role names directly.(issuer, external_subject). Never by subject alone.tenant_id, erun_user_id, external_user_id,
external_issuer_id, type = 'API', api_method, api_path
(canonical template, not concrete URL), created_at./v1/invoices/{invoice_id}) for permission
matching and audit logging — never the concrete request URL.api_path.go wires the canonical path into request context at
registration time, so authentication/authorization/audit middleware
can read it.uuidv7()), not application code. Create routes and
create repository methods must not accept or generate IDs.Read back: module name, Go module path, target dir, OIDC issuers, initial entities. Ask before producing if anything is unclear.
mkdir -p "${target_dir}/${module}"/{cmd/${module},internal/model,internal/repository,internal/routes}
Skip internal/service/ until a real service exists.
Copy the reference files verbatim, substituting __MODULE__ (e.g.
acme-api) and __MODULE_PATH__ (e.g. github.com/acme/acme-api):
templates/go.mod.tmpl → ${module}/go.modtemplates/cmd_main.go.tmpl → ${module}/cmd/${module}/main.gotemplates/server.go.tmpl → ${module}/server.gotemplates/auth.go.tmpl → ${module}/auth.gotemplates/oidc.go.tmpl → ${module}/oidc.gotemplates/identity_cache.go.tmpl → ${module}/identity_cache.gotemplates/audit.go.tmpl → ${module}/audit.gotemplates/api_path.go.tmpl → ${module}/api_path.gotemplates/repository_tx.go.tmpl → ${module}/internal/repository/tx.gotemplates/repository_identity.go.tmpl → ${module}/internal/repository/identity.gotemplates/repository_user.go.tmpl → ${module}/internal/repository/user.gotemplates/repository_audit_event.go.tmpl → ${module}/internal/repository/audit_event.gotemplates/repository_permission_authorizer.go.tmpl → ${module}/internal/repository/permission_authorizer.gotemplates/routes_protected.go.tmpl → ${module}/internal/routes/protected.gotemplates/routes_whoami.go.tmpl → ${module}/internal/routes/whoami.gotemplates/model_user.go.tmpl → ${module}/internal/model/user.gotemplates/AGENTS.md.tmpl → ${module}/AGENTS.mdFor each entity E the user supplied (e.g. invoice, plural
invoices, route prefix /v1/invoices):
internal/model/<entity>.go — Bun-tagged struct with read-only
EntityID, TenantID, CreatedAt, UpdatedAt, plus the user's
domain columns. Use templates/model_entity.go.tmpl as the shape.internal/repository/<entity>.go — Create/Get/List/Update
methods using Bun via TxManager.WithTx. Use
templates/repository_entity.go.tmpl.internal/routes/<entity>.go — RegisterEntityRoutes that wires
GET /v1/<entity-plural>, POST /v1/<entity-plural>,
GET /v1/<entity-plural>/{<entity>_id},
PATCH /v1/<entity-plural>/{<entity>_id} through the
ProtectedRouteRegistrar. Use templates/routes_entity.go.tmpl.Only add internal/service/<entity>.go when a workflow exists beyond
single-repository CRUD.
server.goEdit the produced server.go NewHandler to construct the entity
repository and register its routes — follow the pattern of
RegisterWhoamiRoute already present.
cd "${target_dir}/${module}"
go mod tidy
go build ./...
go test ./...
Run a smoke test against a local PostgreSQL instance configured with
the matching erun-backend-db-shaped schema. Confirm:
GET /healthz returns 204 without a token.GET /v1/whoami with no token returns 401.GET /v1/whoami with a valid OIDC token returns the resolved user.(iss, sub) on a freshly
bootstrapped database is rejected (no implicit user creation).| Failure mode | Recovery |
|---|---|
Target dir already contains a go.mod | Stop. Offer --force (rewrite) or a new module name. Do not silently overwrite. |
| User-supplied entity name is singular plural-form ambiguous | Ask for both singular (invoice) and plural (invoices) explicitly. Do not guess. |
User-supplied route prefix doesn't start with /v1/ | Surface the convention and ask the user to confirm or change. |
| OIDC issuer list is empty | Stop. Ask for at least one issuer; no point producing an unauthenticated API. |
go build fails after generation | Surface the compiler output. Most common cause is module path mismatch — confirm __MODULE_PATH__ substitution. |
| The matching database doesn't have the bootstrap tables | Surface the missing tables and offer to run erun-blueprint-rls-db first. |
UpdateStatus,
AdvanceMergeQueue). Repository = CRUD; service = workflow.role_permissions.TxManager.WithTx. Direct db.Exec calls skip the
security context setup and break RLS.npx claudepluginhub sophium/erun --plugin erun-toolsGuides creation, editing, and verification of skills for AI coding agents using test-driven development with subagent scenarios. Use when authoring or debugging skills.