Day 16 of 100·Project Deep-Dive Day·1173 words

Zoth's API surface — routes, DTOs, and generated docs

The complete route map, the middleware order, and the pieces interviewers probe.

Know your own system

Yesterday was theory; today is your API, cold. The route map as registered in code (internal/router/router.go):

Public:
  GET  /api/v1/healthz            always 200 (liveness)
  GET  /api/v1/readyz             503 if Postgres or RabbitMQ down (readiness)
  GET  /api/v1/pods/readyz        503 if emitter/alerting/RabbitMQ down (aggregate)
  POST /api/v1/auth/login         JWT issuance
  POST /api/v1/auth/refresh       token refresh
  POST /api/v1/webhooks/workos    HMAC-SHA256 verified IdP sync (only mounted
                                  if WORKOS_WEBHOOK_SECRET set)

Protected (JWT or API key + tenant):
  GET    /auth/me
  POST   /auth/api-keys                     [ADMIN]
  GET    /users, /users/{id}, /users/{id}/groups
  GET    /groups, /groups/{id}, /groups/{id}/members
  GET    /policies, /policies/{id}
  POST/PUT/DELETE /policies, PATCH /policies/{id}/active   [ADMIN]
  POST/DELETE /policies/documents, GET /policies/documents [ADMIN on writes]
  POST/GET /access-requests, GET /access-requests/{id}
  GET  /access-requests/{id}/approvals
  POST /access-requests/{id}/approve | deny | revoke
  GET  /access-requests/{id}/rag-advisory       (RAG-gated)
  GET  /audit-logs (action, actor_id, from, to, limit≤100)
  GET  /dashboard/stats
  GET  /notifications…, POST /notifications/{id}/read, /read-all
  GET  /integrations, /integrations/{id}/resources
  POST /integrations, DELETE /integrations/{id},
  POST /integrations/{id}/discover            [ADMIN]

The middleware chain (order is semantics)

Recovery (panics → 500, never a dropped connection) → otelhttp (trace span per request) → Logging (slog JSON with OTel bridge) → CORS (allowlist, no credentials) → auth (JWT/API-key) → Tenant → route-specific RequireRole(ADMIN). Explain the order as protection layers: recover before trace (so panics still emit), auth before tenant (tenant comes from the token), role gates on the sub-router not per-handler.

DTO discipline

internal/dto/request and internal/dto/response — handlers never bind into models. The response envelope nests errors ({"error":{"code":"...","message":"..."}}), and apperrors.WriteJSON is the only renderer. Why care: one shape = one client-side parser = one test suite. Also pkg/validator does structural validation before services run.

The doc generator (underrated talking point)

cmd/apidocgen walks the Chi route table at build time and emits openapi/openapi.json + Postman collection. The spec cannot contain a route the router doesn't have — unlike hand-written specs, which rot. Known staleness in the opposite direction (routes that exist but were added after the last generation run: /auth/me, /notifications*, /integrations*) is a maintenance-discipline issue (run apidocgen in CI), not a design flaw — and saying that shows you understand generated-artifact lifecycles.

What interviewers will probe

  1. Why is /audit-logs/verify documented but not mounted? — audit.Service.VerifyChain exists but no route registers it. Honest answer: planned, unexposed; would mount it admin-only.
  2. Why three health endpoints? — healthz answers "is the process alive" (liveness), readyz answers "can I serve" (Postgres + RabbitMQ — readiness gates Service endpoints), pods/readyz answers "is the system healthy" (checks the two other binaries over HTTP + RabbitMQ). One endpoint per consumer: kubelet, kubelet, you.
  3. Why does RAG advisory GET exist but only when Gemini is configured? — route mounted conditionally; unconfigured returns fallback. Contract honesty: capability advertising follows configuration.

The request lifecycle, traced end to end

Follow one call — POST /api/v1/access-requests/{id}/approve — through every layer, because "walk me through what happens when someone approves a request" is the single most common question about Zoth:

  1. Ingress: routed through Service zoth-api to Chi. Recovery wraps everything, so a panic becomes a 500 with a logged stack rather than a dropped connection.
  2. Trace: otelhttp opens a server span; the W3C traceparent from the caller (or a fresh root) becomes the parent context, and every span below is a child of it.
  3. Auth: the auth middleware validates the JWT or hashes the API key and looks it up, producing an actor identity. Failure is a 401 in the standard error envelope, with no hint about whether the account exists.
  4. Tenant: the Tenant middleware derives tenant_id from the token, never from the body or a header. This is the most important line of the chain — if tenant came from the client, every query below would be attacker-controlled.
  5. Role: RequireRole(ADMIN) is mounted on the sub-router, not sprinkled per handler, so a forgotten check on one route is structurally harder to ship.
  6. Handler: binds into a DTO (internal/dto/request), never into a model; pkg/validator does structural validation; then the service runs.
  7. Service + DB: loads the request, checks the self-approval rules (C-2/C-3), checks the approver's authority against the policy's approval_config, then performs a conditional transition (CAS) — UPDATE ... WHERE id = ? AND state = 'pending'. Zero rows affected means someone else already decided: a 409, not a silent overwrite.
  8. Side effects in the same transaction: the audit row (hash-chained) and the outbox event are written alongside the state change. That atomicity is the entire reason the platform can promise "no access change without a matching audit record."
  9. After commit: the outbox relay publishes access.approved to the zoth.access topic exchange; the emitter consumes it and provisions against the connector; the alerting service reacts to the suspicious-activity signal.
  10. Observability: the handler span contains a child span per auto-instrumented DB call plus explicit business spans for policy evaluation. The relay creates a new span whose parent is the original trace, propagated through the message header — which is what makes the approval and the provisioning appear as one trace in Better Stack instead of two unrelated ones.

Four API decisions worth defending

  • limit≤100 on /audit-logs. An uncapped list endpoint is a denial-of-service you ship to yourself: one client asking for a million rows holds a connection, a cursor, and a sort. Caps belong in the contract, and exceeding one must be an explicit 400 rather than a silent truncation — silent limits produce bug reports you cannot reproduce.
  • API keys for machines, JWT for humans. A browser session needs short-lived, refreshable, revocable credentials; a CI job or connector needs a long-lived secret a human rotates deliberately. JWTs for machines force refresh logic into a cron job; API keys for browsers put a long-lived secret somewhere you cannot revoke quickly. Two audiences, two mechanisms — and the keys are stored hashed, exactly like passwords.
  • Idempotency on the write that matters. POST /access-requests is the endpoint a flaky connection retries. Without an idempotency key a retry becomes a second access request — meaning a second approval workflow and a second audit trail for one human intention. The key makes retry safe, which makes the client's retry logic simple.
  • One error renderer. apperrors.WriteJSON being the only path that writes an error body means the client has one parser and the security review has one place to check for leaks (Day 98). Two renderers is how stack traces reach production.

The failure mode of this surface

Name it before you are asked: the route table and the generated OpenAPI can drift, because generation is a manual step — three route families were added after the last run. The fix is not "regenerate more often" but "make generation part of CI, so drift is a failed build rather than a discovery." That is the same class of fix as replaying real migrations in tests (Day 36): turn a discipline into a gate.

Bulletproof your resume

Say these answers aloud before moving on

These are the follow-ups an interviewer will actually ask about your projects.

01

How do your API keys differ from JWTs in capability?

JWTs carry user identity, tenant, roles (re-resolved from DB each request); API keys are tenant-scoped machine credentials — 32 random bytes, stored only as SHA-256 hash with an 8-char prefix for lookup hinting, checked for is_active and expiry, and enforced write-scope on non-GET methods. Crucially, API-key principals get a tenant but no user and no role, so RequireRole-gated routes are unreachable with keys — the credential type structurally can't impersonate an admin.

02

What does the WorkOS webhook endpoint do and how is it secured?

It's the IdP sync ingress: user/group lifecycle events populate users, groups, and user_groups (first user in a tenant becomes ADMIN). Security: HMAC-SHA256 signature over the raw body with a shared secret, timestamp tolerance of ±5 minutes against replay, and the route isn't mounted at all unless the secret is configured — a fail-closed pattern.

03

Why generate OpenAPI from the router instead of writing the spec?

Generated specs can't describe endpoints that don't exist — the router is ground truth. The cost is the inverse: new routes need a regeneration run, and my current openapi.json predates the notification/integration routes. The fix is running apidocgen as a CI step diffing the output, which I'd add next.