ADR-021: API URL scheme, versioning & gateway authentication
Companion to ADR-020 (which covers the contract tiers, structure, naming, and layering). This ADR covers the HTTP surface: URLs, how things are versioned, and how authentication is enforced.
Context
The API is served from a dedicated host (api.aucert.ai); the frontend is a separate host
(console.aucert.ai). The platform is a set of domains heading toward independent service extraction.
We need a URL + versioning + auth scheme that fits that destiny and is fail-closed.
Decision
1. URL scheme — api.aucert.ai/v1/<resource>
- No
/apiprefix. The host already says it's the API;/apionly earns its keep when one host serves both app and API by path. We don't — so drop it. - Bare
/v1/for the authenticated first-party surface (Stripe shape: the default surface is unqualified; special surfaces are named). e.g./v1/org,/v1/users/me,/v1/subscription. - Audience namespaces are top-level siblings, each with one gateway policy:
| Namespace | Audience | Policy |
|---|---|---|
/v1/** | first-party authed (default) | JWT-protected |
/auth/v1/** | auth / OIDC flows | public (per-endpoint) |
/webhooks/** | signed inbound (WorkOS/Stripe) | HMAC verify |
/public/v1/** (reserved) | unauth public resources | public, rate-limited |
/admin/v1/** (reserved) | back-office (e.g. CatalogAuthoring) | JWT + admin scope |
Auth is not folded under /v1 — it is a distinct namespace (see §3).
2. Versioning — three places, none of them per-path
- Folder: version sits under the transport —
api-spec/<domain>/rest/v1/,…/grpc/v1/. REST and gRPC version independently. - Package: the version rides into the generated package (
ai.aucert.<domain>.…v1…), matching the gRPC/BufPACKAGE_VERSION_SUFFIXnorm so v1 and v2 types coexist. - URL: a single global
/v1for MVP, frozen (Stripe/GitHub lesson: don't bump a URL major version casually). When a domain genuinely needs a breaking v2, adopt per-domain path versioning or dated headers then — not pre-emptively. - Spec
pathsare resource-relative (/org, not/v1/org), and specs omitservers— a base URL is a deploy/runtime concern, not part of the contract. The single global/v1is applied once at app wiring (route("/v1") { … }) and owned by the gateway; generators emit routes from the barepathskeys. So a v2 fork is a folder copy, not a per-path edit.
3. Authentication — fail-closed, defense-in-depth
- Default-deny at the gateway, version-agnostic. Kong applies JWT validation (+ claim extraction, rate-limit, CORS) on a catch-all route, so any path not explicitly allowlisted is JWT-protected — including a future
/v2/**with no gateway change. - Public access is an explicit, exact-match, reviewed allowlist (
pathType: Exact, never a prefix), each entry justified. Public/auth/webhook/probe endpoints live under their own namespaces (§1), never inside the authenticated tree. - The app is also authed-by-default, with an explicit
@Publicopt-out per route, and rejects unauthenticated calls to anything not opted out. The gateway is an optimization, not the sole control — never trust it alone. - The catch-all design is why a version bump never touches the most dangerous config; the security rule keys on "allowlisted-public vs everything-else," not on the version.
4. Two-host topology consequences (deploy checklist)
console.aucert.ai → api.aucert.ai is cross-origin, and auth uses an HttpOnly refresh cookie, so:
- CORS on the backend: specific origin (
https://console.aucert.ai) +Allow-Credentials: true(never*). - Cookie scope:
refresh_tokenset withDomain=.aucert.ai; SameSite=None; Secure.
Current state / migration
Backend cutover: DONE (clean cutover — no /api prefix, no dual-prefix, no back-compat; the app
is not yet live).
- Resource-relative specs; no transport/auth boilerplate. Every REST spec under
api-spec/<domain>/rest/v1/(organization, user, catalog, subscription, hangar, onboarding) uses bare resource paths (/org,/users/me,/plans,/subscription,/onboarding,/hangar/**). Specs omitservers,security, andsecuritySchemes— base URL, version, and auth are deploy / app-wiring / gateway concerns, not the contract (the redoclyno-empty-servers+security-definedrules are disabled accordingly).auth.yamlkeeps its own/auth/v1/**paths (auth is NOT folded under/v1) and its functional response headers (Set-Cookie,Cache-Control).common.yamlis schema-only (no paths). /v1applied once at wiring.Application.configureRouting()wraps the generated authenticated routes in a singleroute("/v1") { … }block, yielding/v1/org,/v1/users/me, etc. Outside/v1:healthRoutes()(unprefixed/health,/ready,/health/detailed),AuthRoutes(/auth/v1/**), andPaymentWebhookRoutes(/webhooks/payment). No/apianywhere.- App-level default-deny.
configurePrincipalPlugin(anonymousActorId, PublicPaths.ALLOWLIST)rejects any unauthenticated call (anonymous/system fallback principal) to a path not on an explicit, human-reviewed, exact-match allowlist with401 {"error":"unauthorized",…}. The allowlist (backend/shared/auth/.../PublicPaths.kt) mirrors Kong and is manually maintained, never generated:/health,/ready,/health/detailed,/auth/v1/callback,/auth/v1/refresh,/auth/v1/logout,/auth/v1/jwks,/webhooks/payment. Fail-closed. - Cross-site refresh cookie.
refresh_tokenis set withSameSite=None; Secure; Domain=<COOKIE_DOMAIN, default .aucert.ai>in prod (soconsole.aucert.ai→api.aucert.aiworks), andSameSite=Laxwith noDomain/ noSecureon http localhost dev. Applies to set-cookie (callback/refresh) and the logout clear-cookie;HttpOnly+path=/auth/v1+ maxAge unchanged.
Gateway cutover: DONE (config) — staging validation pending. kong-ingress.yaml now uses the
catch-all default-deny route (path: /, Prefix, carrying strip-aucert-headers, cors-api, jwt-validation, extract-aucert-claims; rate-limit-api added when Redis is wired) plus an
exact-match public allowlist (/health, /ready, /health/detailed, /auth/v1/callback|refresh| logout|jwks, /webhooks/payment) that mirrors PublicPaths.kt. The /api/v1 prefix route, the
non-existent /auth/v1/sync + /auth/v1/webhooks routes, and the orphaned ip-restrict-sync plugin
were removed. Precedence (allowlist over catch-all) is pinned via konghq.com/priority (1000 vs 1)
and Kong path specificity. This is security-relevant: it must be reviewed and run through the
staging validation checklist in SPEC-022 before production promotion (no cluster available to test
it here).
Alternatives rejected
- Keep
/api/v1— redundant on a dedicated API host; the segment's only real job (security class) is better handled by the catch-all default-deny. - Fold auth under
/v1/auth— collapses the public/protected partition into one prefix; even with an allowlist it loses the clean "own-namespace" auditability and invites fail-open mistakes. - Per-path URL version (
/v1/orgin every spec path) — makes a v2 fork a find-replace instead of a folder copy; the version belongs in app wiring (+ the gateway), applied once, not restated in every path or inservers. - Stripe-style dated header versioning now — premature machinery (per-account pinning, transform layers) pre-PMF; revisit when a real v2 forces it.
- Auto-generating the public allowlist from spec
security: []— rejected as a security risk: it widens who can make an endpoint public to anyone editing a spec, so a straysecurity: []silently exposes a route. The allowlist (Kong + app@Public) stays manually managed and human-reviewed — fail-closed (spec-says-public-but-not-allowlisted → endpoint stays protected, a visible 401, never the reverse). A CI check that detects spec↔allowlist drift (advisory, never authoritative) is acceptable; generation is not.