feat(api): M4.2 — full REST surface + pgx-backed Postgres #7
Reference in New Issue
Block a user
Delete Branch "feat/m4.2-api"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
M4.2 in full: every endpoint in
IMPLEMENTATION_PLAN.md §3 M4.2, the OpenAPI 3.1 spec atopenapi.yaml, a contract test, and a pgx-backed Postgres store that's a drop-in for the in-memory one.11 endpoints (16 with health probes and entitlements list):
POST /v1/tenants·GET /v1/tenants/{id}·GET /v1/tenants/by-slug/{slug}·POST /v1/tenants/{id}/activate·POST /v1/tenants/{id}/cancel·GET /v1/entitlements?tenant_id=·GET /v1/catalog·POST /v1/catalog/request·POST /v1/catalog/trial-request·POST /v1/api-keys·GET /v1/api-keys?tenant_id=·DELETE /v1/api-keys/{id}·POST /v1/internal/api-keys/verify·POST /v1/audit·GET /v1/audit(filterable, cursor-paginated).Auth: still none in this PR. M4.3 wires Keycloak JWT validation on the public surface and a bearer-only check on
/v1/internal/*.Why
Without M4.2 the portal can do nothing useful past loading the seeded acme tenant. Once this lands, every M5.2 surface (settings, billing, audit, API keys page) and every product-uplift milestone (M6/M7) has a real backend to call.
Linked milestone: M4.2
How
internal/store/store.go): two implementations (Memoryfor dev convenience,Postgresfor stage/prod). Handlers depend on the interface — never on a concrete type. Same test harness runs both viaeachStoreso any divergence shows up in CI.pgxpooldirectly (notdatabase/sql) for native types + better performance. Errors mapped viapgerrcode: unique →ErrConflict, FK violation →ErrNotFound, check violation →ErrInvalidInput. Mapping happens in one place (helpers.go mapStoreError).bp_<22 base64url chars>. Hash is argon2id with format-tagged encoding (argon2id|<salt>|<hash>) so we can rotate parameters without re-keying. Verify is constant-time. Plaintext is returned ONCE on create + never echoed in the list endpoint.s.emitAudit()(fire-and-forget — failures logged, not returned to the caller, since the user-facing operation already succeeded).audit_logusesON DELETE SET NULLso forensic history outlives tenant deletes./v1/tenants/{id}/productsfrom/v1/tenants/by-slug/{slug=products}(the catch-all router panics at registration). Per-tenant sub-resources moved to query-param top-level paths:/v1/entitlements?tenant_id=…,/v1/api-keys?tenant_id=…. Documented inline.openapi.yamlvia kin-openapi v0.138, validates the spec, and confirms every listed path resolves against the gorillamux-shaped router. Mismatch between handlers and spec breaks CI.Test plan
go test -short ./...— 4 packages, all greengo test ./...— full suite with Postgres testcontainers (~2 min)make build+make build-migrate— both static binariesmake devagainst the dev stack,curl http://localhost:8090/v1/catalogreturns 2 entries; full CRUD via curl exercisederrorenumRisk
Blast radius: repo-local. No prod consumer yet (portal still calls only
GET /v1/tenants/by-slug/…).What could break:
/api-keys/verifyendpoint is also unauthenticated — fine on the cluster-internal network, but stage/prod manifests should not expose it.Rollback plan: revert the PR. The schema (M4.1) is unchanged so the in-memory store keeps working downstream.
Checklist
DATABASE_URLis the only secret-shaped env;.env.exampledocuments itReplaces the M5.1-skeleton handler set with the M4.2 spec from IMPLEMENTATION_PLAN.md: Endpoints (authoritative shape in openapi.yaml): POST /v1/tenants GET /v1/tenants/{id} GET /v1/tenants/by-slug/{slug} POST /v1/tenants/{id}/activate POST /v1/tenants/{id}/cancel GET /v1/entitlements?tenant_id=... GET /v1/catalog POST /v1/catalog/request POST /v1/catalog/trial-request POST /v1/api-keys returns plaintext ONCE GET /v1/api-keys?tenant_id=... DELETE /v1/api-keys/{id} POST /v1/internal/api-keys/verify always 200; valid: bool POST /v1/audit GET /v1/audit?{tenant_id,product,actor_id,action,since,until,limit,cursor} Architecture: internal/store/store.go Store interface (CRUD + audit + ping) internal/store/memory.go in-process impl, used when DATABASE_URL is empty (seed acme tenant, no migrations) internal/store/postgres.go pgxpool impl against the M4.1 schema internal/server/server.go router + healthz/readyz internal/server/{tenants,catalog,apikeys,audit}.go per-concern handlers (≤250 LoC each) internal/server/helpers.go writeJSON/writeError/error mapping/log mw openapi.yaml 3.1 spec; openapi_test.go is the contract gate API keys: Plaintext format 'bp_<22-char base64>'. Prefix bp_<8> stored for UI. Hash is argon2id(salt, time=1, mem=64MB, threads=4, len=32) encoded as 'argon2id|<salt-b64>|<hash-b64>'. Format-tagged so we can rotate parameters without re-keying. Verify is constant-time. Store selection: cmd/server picks Postgres when DATABASE_URL is set, otherwise Memory. Both implementations are exercised by the same eachStore test harness — parity is enforced. Audit: Every state-changing endpoint emits via s.emitAudit() (fire-and-forget). audit_log uses ON DELETE SET NULL on tenant_id so forensic history outlives tenant deletes (per M4.1 schema). Routing constraint: Go 1.22 ServeMux can't disambiguate /v1/tenants/{id}/products from /v1/tenants/by-slug/{slug=products}. Per-tenant subresources moved to query-param top-level paths: /v1/entitlements?tenant_id=… and /v1/api-keys?tenant_id=…. Tests: Every endpoint exercised against both Memory and Postgres via the eachStore harness. Includes happy paths, validation errors, conflicts, 404s, auto-audit-emit assertion. testcontainers-go for the postgres harness; gated by -short. TestOpenAPISpec is the contract gate: every documented operation must resolve against the router. (kin-openapi v0.138.0.) Refs: M4.2