Skip to content

SCRUM-211 — Config: namespaces list and create

Plan ref: CFG-B1 (docs/11-admin-plane-plan.md). First real Config handler; also lands the handler seam every later Config ticket plugs into.

What changed

Change Where
Handler seam: api.Handlers{Store, Log}.For(route) returns the implemented handler for a route pattern, or the 501 NotImplemented stub. server.Deps.Handlers wired in main. A nil Handlers keeps every route at 501, so existing tests are unchanged internal/api/handlers.go, internal/server/server.go, config.go
GET /api/admin/config/namespaces (viewer) → {"namespaces":[…]} sorted by name, never null internal/api/namespaces.go, internal/store/namespaces.go
POST /api/admin/config/namespaces (admin) → 201 item. One transaction: namespace row + schema v1 {} + empty draft (revision 1) + audit namespace.create same
Optional name claim on staff tokens; Claims.ActorName() = name, else sub (used for audit_log.actor_name) internal/auth/claims.go

Item shape (matches doc 10 §9 row 6):

{"name":"balance.weapons","audience":"client","description":"weapon balance",
 "latest_version":null,"created_at":"…",
 "draft":{"revision":1,"updated_at":"…","has_unpublished_changes":false}}

has_unpublished_changes: no version yet → revision > 1; otherwise the draft body differs (jsonb equality) from the latest version's body.

POST validation (400 validation_failed): name required, ≤64, ^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$; audience ∈ client|server; description ≤500 chars. Malformed JSON, unknown field, trailing data, >64 KiB → 400 invalid_body. Duplicate name → 409 already_exists. DB errors → 500 internal_error (cause logged with request_id, never returned).

How to verify

cd services/config
go vet ./... && go test -race -count=1 ./...          # DB tests skip without the env var

# DB-backed (local Postgres; see handoff/pg-env.sh on the dev box, or any PG 16+)
export CONFIG_TEST_DATABASE_URL='postgres://USER:PASS@127.0.0.1:5433/config_test?sslmode=disable'
go test -race -count=1 -v -run Namespace ./internal/store/ ./internal/server/
Test Proves
api.TestValidateCreateNamespace every field rule, both sides of each boundary
api.TestCreateNamespaceRejectsBadBodies unknown field, trailing data, >64 KiB, not JSON → 400 invalid_body
api.TestNilHandlersAnswer501, TestForFallsBackToNotImplemented unimplemented routes stay 501
store.TestCreateNamespaceThenList create → list shows it with latest_version nil, revision 1, no unpublished changes, exactly one namespace.create audit row
store.TestCreateNamespaceDuplicate second create → ErrNamespaceExists, no second audit row
server.TestNamespacesRoutesThroughTheLiveServer real listener + real JWT guard: viewer GET 200, live_ops POST 403, admin POST 201, then visible in GET

By hand against a running Config (staff token in $T, admin role):

curl -s -H "Authorization: Bearer $T" localhost:8080/api/admin/config/namespaces
curl -s -H "Authorization: Bearer $T" -d '{"name":"ui.motd","audience":"client"}' \
     localhost:8080/api/admin/config/namespaces          # 201
# again → 409 already_exists; {"name":"Bad Name","audience":"client"} → 400

Results at time of writing

  • go vet, go test -race ./...: pass (6 packages).
  • DB-backed namespace tests against local Postgres 16: 4/4 pass.

Follow-ups

  • SCRUM-199 (admin-auth signing) should emit a name claim so audit rows show a human name; until then actor_name falls back to sub.

How it was built

DeepSeek run scoped (Landlock) to services/config (161 s, ~29k output tokens). Claude review: package-doc comments detached, a misleading comment on For corrected, "PHP" → admin-auth; tests re-run with -race against Postgres.