Skip to main content

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 /api prefix. The host already says it's the API; /api only 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:
NamespaceAudiencePolicy
/v1/**first-party authed (default)JWT-protected
/auth/v1/**auth / OIDC flowspublic (per-endpoint)
/webhooks/**signed inbound (WorkOS/Stripe)HMAC verify
/public/v1/** (reserved)unauth public resourcespublic, 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 transportapi-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/Buf PACKAGE_VERSION_SUFFIX norm so v1 and v2 types coexist.
  • URL: a single global /v1 for 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 paths are resource-relative (/org, not /v1/org), and specs omit servers — a base URL is a deploy/runtime concern, not part of the contract. The single global /v1 is applied once at app wiring (route("/v1") { … }) and owned by the gateway; generators emit routes from the bare paths keys. 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 @Public opt-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.aiapi.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_token set with Domain=.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 omit servers, security, and securitySchemes — base URL, version, and auth are deploy / app-wiring / gateway concerns, not the contract (the redocly no-empty-servers + security-defined rules are disabled accordingly). auth.yaml keeps its own /auth/v1/** paths (auth is NOT folded under /v1) and its functional response headers (Set-Cookie, Cache-Control). common.yaml is schema-only (no paths).
  • /v1 applied once at wiring. Application.configureRouting() wraps the generated authenticated routes in a single route("/v1") { … } block, yielding /v1/org, /v1/users/me, etc. Outside /v1: healthRoutes() (unprefixed /health, /ready, /health/detailed), AuthRoutes (/auth/v1/**), and PaymentWebhookRoutes (/webhooks/payment). No /api anywhere.
  • 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 with 401 {"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_token is set with SameSite=None; Secure; Domain=<COOKIE_DOMAIN, default .aucert.ai> in prod (so console.aucert.aiapi.aucert.ai works), and SameSite=Lax with no Domain / no Secure on 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/org in 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 in servers.
  • 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 stray security: [] 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.

References

  • ADR-020 — contract tiers, structure, naming, layering
  • ADR-018 — OpenAPI-first contracts + codegen
  • [SPEC-022] — Kong gateway / ingress (kong-ingress.yaml, network-policy.yaml)
  • SPEC-039 — codegen design + layout