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=Lax; Secure. (SameSite=Nonehere originally — amended by ADR-030 layer 2.console.aucert.aiandapi.aucert.aiare different hosts but the same site:SameSiteis evaluated on the registrable domain,aucert.ai, soLaxis already sent between them andNonegave away CSRF protection for a cross-site case that does not exist.Domain=.aucert.aiis the attribute that makes cross-subdomain delivery work, and it is unchanged.)
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-subdomain refresh cookie.
refresh_tokenis set withSameSite=Laxeverywhere, plusSecure; Domain=<COOKIE_DOMAIN, default .aucert.ai>in prod (soconsole.aucert.ai→api.aucert.aiworks); on http localhost dev there is noDomainand noSecure. Amended by ADR-030 layer 2 — this saidSameSite=Nonein prod, on the premise that the two hosts are "cross-site". They are not:SameSiteis evaluated on the registrable domain, and both areaucert.ai.Laxcovers these requests already. The prod/dev split also meant the attribute exercised locally was never the attribute that shipped. Applies to set-cookie (callback/refresh) and the logout clear-cookie;HttpOnly+path=/auth/v1+ maxAge unchanged. - Rover REST folded under
/v1(last first-party exception removed). The rover REST surface (ScanService+GraphProjection→ generatedRoverApi/roverRoutes, plus the hand-writtenroverScreenshotRoutes) was previously mounted at a bare/rover/**own-namespace — a SPEC-038 pre-/v1-consolidation legacy choice. It is fully JWT-authed, console-only (no@Public, not inPublicPaths.ALLOWLIST, no external consumer), so per §1 it belongs on the first-party authed default surface like every other domain.Application.configureRouting()now mountsroverRoutes(...)/roverScreenshotRoutes(...)inside the singleroute("/v1") { … }block (the specpathsstay resource-relative/rover/**, so the served URLs are/v1/rover/**); the emitted screenshot URL (GraphProjectionImpl.SCREENSHOT_PATH_PREFIX) carries/v1to match. No gateway change —/v1/rover/**is already covered by the catch-all default-deny (path: /, JWT-protected). This removes the last bare first-party namespace, so §1's "bare/v1/for the authenticated first-party surface" now holds without exception.
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.