Rendered from this repo's docs/gateway/CONTRACT.md.
This is the authoritative wire contract between the edge (cmd/gateway) and
the control plane (cmd/controlplane), and between the control plane and any
application that registers with it. Both binaries live in this repo; this
document exists anyway because they are still two independently deployable
processes that must agree on wire shape without sharing Go types across a
process boundary (they share Go types only where genuinely safe to —
internal/verify.Claims/JWK/JWKS, used by both the edge's Verify and
the control plane's Sign).
anoorehr-backend-staging. Single implicit upstream; no application
registration; Methods overrode only a route's required scope, not its
class.upstream_url; Methods overrides both class and scope
per HTTP method. Routes were namespaced under /<owner-slug><path> only
when machine-class; pure passthrough stayed at its raw, unnamespaced path,
enforced unique across applications at registration time.CP1004) with no real fix short of one app
changing its entire route surface. Full rationale in §5.3.| Actor | Authenticates via | Verified by |
|---|---|---|
| A registered application (machine client) | POST /oauth/token → EdDSA JWT |
The edge, entirely offline, against the JWKS in its config snapshot |
| Browser / console session traffic | Whatever the destination application's own session mechanism is | The destination application itself — the edge does not authenticate passthrough traffic |
| The edge, pulling config/pushing usage | Static bearer GATEWAY_INTERNAL_TOKEN |
The control plane |
| Admin tooling / developers, registering/granting/minting | Static bearer CONTROLPLANE_ADMIN_TOKEN, or a per-developer/console token minted via controlplane token create (§13) |
The control plane |
The control plane has no user/session system of its own by design — admin
tooling is trusted to report who is acting via each mutating request's
optional actor field, recorded as a free-text label (created_by/
granted_by), not a foreign key to any user table.
Every application that wants to plug into the gateway exposes:
GET /.well-known/gateway-manifest
{
"appId": "hr-service",
"name": "HR Service",
"description": "Employee management system",
"version": "1.0.0",
"baseUrl": "https://hr.internal",
"health": "https://hr.internal/.well-known/health",
"documentation": "https://hr.internal/.well-known/openapi.json",
"owner": "HR Team",
"contact": "hr@company.com",
"scopes": [
{ "key": "employee.read", "description": "Read employee records" }
],
"routes": [
{ "path": "/employees", "method": "GET", "scope": "employee.read" },
{ "path": "/status" }
],
"allowedOrigins": ["https://console.etdevops.io"]
}
appId must be a lowercase-alphanumeric-with-hyphens slug, unique across
the gateway.baseUrl must be an absolute http(s) URL — this becomes the target every
matching route proxies to.routes[].method empty means "all methods" (a catch-all for that path).
routes[].scope empty means no application has declared a specific
need for this route yet — it does NOT mean machine callers are refused
here. Per ADR-GW-3 (§5.2), the edge always attempts to verify the caller
as a genuine machine token, on every route regardless of scope; empty
scope just means there's nothing to check the token's scopes against,
so any verified machine caller is accepted (X-Gateway-Client-Id
stamped, empty X-Gateway-Scopes). Non-empty means the route declares a
required scope: the edge additionally enforces that the presented token
actually carries it. Either way, a request that doesn't present a
verifying machine token falls through to ordinary passthrough
(rate-limited by caller IP, Authorization forwarded unchanged, the
destination app remains the authenticator) rather than being rejected —
see ADR-GW-2/ADR-GW-3 (§5.2) for the full per-request dispatch. The scope
must reference a key in scopes[].health is reserved for future health-status polling — stored, not yet
polled by anything.documentation, if present, must point at the application's own OpenAPI
document (any format its own server returns — the control plane passes
the response through content-type and all, no parsing or validation of
it). Powers the developer portal, §11 below.allowedOrigins, if present, lists the exact browser origins (scheme +
host, no path/query/fragment) this app's own frontend(s) legitimately run
on. Only relevant to passthrough traffic that authenticates with a
cookie: it's what the edge checks a request's Origin against before
granting Access-Control-Allow-Credentials (carried in the config
snapshot as routes[].allowed_origins, §5) — never a blanket grant for
every passthrough caller, since that would let any site make
authenticated calls on a logged-in user's behalf using nothing but the
victim's own browser. A request that verifies as a genuine machine caller
never gets this, on any route, regardless of what's declared — it
authenticates with a bearer token, not a cookie, so credentialed CORS is
meaningless to it (ADR-GW-3, §5.2: this is now decided by the actual
per-request outcome, not by whether the route happens to have a scope).The control plane never invents a scope, route, or allowed origin: everything it stores about an application comes from what that application declared here.
POST /applications {"manifestUrl": "...", "actor": "..."} — fetches and
validates the manifest, stores the application + its declared scopes and
routes in one transaction. 409 on a duplicate appId. As of ADR-GW-2
(v3, §5.1), a route's raw path never conflicts across applications
regardless of class — every application's entire route surface is
namespaced under its own slug, so two applications declaring the
identical raw path (even a whole shared monolith surface) both register
cleanly and land at distinct public prefixes. (Pre-ADR-GW-2 this returned
422 for a passthrough/blocked path collision — that check no longer
exists because nothing can collide on a raw path anymore.)POST /applications/{id}/refresh — re-fetches from the same manifest URL,
upserts scopes/routes. Never deletes a scope/route the manifest no longer
declares (a destructive action requires an explicit future admin action,
not an automatic side effect of a refresh).POST /applications/{id}/suspend {"reason": "...", "actor": "..."} —
reversibly takes an application off the gateway: BuildSnapshot drops its
routes entirely, so it stops being reachable through the edge as soon as
the edge next polls its config. Only valid from active; suspending an
already-suspended application is 404, not a silent no-op. Before this
endpoint existed, the only way to do this was a hand-run SQL UPDATE
against production.POST /applications/{id}/reactivate {"actor": "..."} — only valid from
suspended; a revoked application can never be reactivated through this
endpoint (404).POST /applications/{id}/revoke {"reason": "...", "actor": "..."} —
permanent, valid from either active or suspended. There is no path back
from revoked.POST /applications/{consumerId}/grants {"providerAppSlug": "...", "scopeKey": "...", "actor": "..."}
— the explicit "consumer may use this scope of provider" step. Registering
two applications never implies this. Rejects a self-grant (an app can't be
granted its own scope) and duplicate grants (409).POST /grants/{id}/revoke {"reason": "...", "actor": "..."} — soft-revoke:
status flips to revoked with revoked_at/revoked_reason/revoked_by
recorded (the same audit shape credential revocation already has), rather
than deleting the row — GET /applications/{id}/grants still returns a
revoked grant, since that history is the point. Its scope drops out of the
consumer's next-issued token immediately (previously issued tokens still
carry it until they expire, ≤15 minutes). Revoking an already-revoked grant
is 404, not a silent no-op.POST /applications/{id}/credentials {"actor": "..."} →
{"clientId", "clientSecret", "sharedHeaderSecret"?} — the raw secret is
returned here and only here; the database keeps only its sha256 hash.
sharedHeaderSecret is present only when the control plane is configured
with GATEWAY_SHARED_HEADER_SECRET (README.md "The shared header
secret") — it's the same gateway-wide value on every response, not
something generated per credential/application; omitted entirely, not
returned empty, when unconfigured.GET /applications/{id}/credentials — every credential ever minted for
this application, active and revoked alike (same audit-preserving shape as
GET /applications/{id}/grants, §3 above) — never the secret or its hash,
only id, clientId, secretPrefix, status, and the created/last-used/
expires/revoked timestamps.POST /credentials/{id}/revoke {"reason": "..."} — idempotent.GET /applications/{id}/scopes — this application's own declared scope
catalog ({id, ownerApplicationId, key, description, createdAt}[]),
refreshed only via POST /applications/{id}/refresh above, never
hand-edited through this endpoint.GET /applications/{id}/routes — this application's own declared routes
({id, prefix, method, class, scope, isActive}[]), active only. scope
is the resolved scope key string (e.g. "employee.read"), not the
opaque gateway_scopes.id — a caller composing the qualified
"<ownerSlug>:<scopeKey>" form (§2, Scope.QualifiedKey) needs the key,
not an id it would have to look up separately. scope: null means a
passthrough route (the manifest declared no scope for it). Added for an
admin-side endpoint picker (a consumer building a Mode-3-style config
against this app's own manifest, e.g. Console's Data Connections screen)
— distinct from §5's edge-facing config snapshot, which needs an internal
token and is shaped for routing, not picking.POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=...&client_secret=...
Returns {"access_token": "...", "token_type": "Bearer", "expires_in": 900}.
The JWT's scopes claim is exactly the set of <providerSlug>:<scopeKey>
strings the consumer application currently holds via gateway_scope_grants
— computed fresh at issuance, not cached. sub is app:<applicationId>,
iss/aud are etg-gateway-control/etg-gateway — opaque internal claim
identifiers, not a reference to any specific business application.
GET /.well-known/jwks.json — public, unauthenticated, includes active and
retiring signing keys (retiring keys stay published so tokens they already
signed keep verifying until they expire).
GET /api/v1/gateway/internal/config
Authorization: Bearer <GATEWAY_INTERNAL_TOKEN>
If-None-Match: <previous config_etag>
304 Not Modified on an unchanged etag; otherwise 200 with:
{
"contract_version": 3,
"generated_at": "2026-07-28T00:00:00Z",
"config_etag": "<sha256 hex>",
"default_route_class": "passthrough",
"routes": [
{
"prefix": "/hr-service/employees",
"class": "blocked",
"required_scope": "",
"service": "hr-service",
"upstream_url": "https://hr.internal",
"strip_prefix": "/hr-service",
"allowed_origins": ["https://console.etdevops.io"],
"methods": {
"GET": { "class": "machine", "required_scope": "hr-service:employee.read" }
}
}
],
"rate_limits": { "per_client_rpm": 600, "per_tenant_rpm": 1200, "passthrough_ip_rpm": 1200 },
"jwks": { "keys": [ { "kty": "OKP", "crv": "Ed25519", "kid": "...", "x": "...", "use": "sig", "alg": "EdDSA" } ] }
}
prefix-wins over routes[]; unmatched paths get
default_route_class with no upstream_url, which the edge treats as
"nothing registered here" — 404 GW1006, not a leftover default backend.methods[METHOD] carries its own class and
required_scope. A prefix with only method-specific rows (no catch-all
route for that path) gets top-level class: "blocked" — an undeclared
method on that path is blocked, never silently inheriting another
method's authorization. A catch-all route (no method in the manifest)
sets the real top-level default instead.upstream_url is the owning application's own
baseUrl — the edge has no other source of truth for where to proxy.allowed_origins mirrors the manifest's
allowedOrigins verbatim (§2) — the edge grants
Access-Control-Allow-Credentials on a passthrough response only when the
request's Origin exactly matches an entry here, never as a blanket
grant, and never for class: "machine" regardless of what's declared.Every route is published under /<owner-slug><raw-path> — e.g. hr-service's
manifest declares /employees; the public path is /hr-service/employees —
regardless of class. The edge strips strip_prefix (/hr-service) from
the request path before forwarding, since the application itself only knows
/employees.
This is what lets two different applications each declare /employees
without colliding: they land at different public paths by construction,
whether either one is machine-class or not. Before ADR-GW-2 (v2, §0), only
machine-touched routes were namespaced this way — a pure passthrough/blocked
route (typically one application's entire browser-facing surface) stayed at
its raw, unnamespaced path, uniqueness enforced across applications at
registration time. That meant two applications could never both register if
they shared any overlapping raw passthrough surface — the common case for a
monolith mid-split, where a newly-split-out service still carries most of the
original app's routes. Universal namespacing removes that ceiling entirely:
raw-path collision across applications is now structurally impossible, so
there's nothing left to reject at registration time.
The cost: a caller that used to reach an application directly at its own raw
path (e.g. https://api.appa.com/api/customers) now reaches it at
https://<gateway-host>/appa/api/customers once that application is
registered and the caller's base URL is repointed at the gateway — the path
itself is unchanged, but every caller (human session or machine) now goes
through the same namespaced URL, no raw alias. See
APP_INTEGRATION_GUIDE.md's ADR-GW-2 section for what this means in
practice for a frontend that used to call its backend directly.
Because every route lives at one URL for every kind of caller now, the class
declared in the manifest no longer picks which URL a request has to hit to
be treated as machine vs. passthrough — it's decided per request, by
whether the presented credential actually verifies. ADR-GW-3 removes the one
place that per-request dispatch didn't yet reach: previously, a route with
required_scope empty (class: "passthrough") never even attempted
verification, so a genuine machine caller hitting a route nobody had
declared a specific scope for was silently treated as anonymous. That's
gone — verification is attempted on every non-blocked route now,
regardless of required_scope:
class: "blocked": rejected outright (403 GW1004), unconditionally,
regardless of any credential presented. Unchanged.class: "machine" or "passthrough" —
required_scope non-empty or empty): the edge always looks for a
Bearer token and tries to verify it as one of its own EdDSA-signed
machine tokens, whether or not this specific route has ever had a scope
declared for it.
kid, expired, garbage, or — the expected common case — the
caller's own application-issued session JWT, which was never meant to
satisfy this check) — this is not treated as an error. The request
falls through to ordinary passthrough handling: Authorization
forwarded unchanged, IP rate-limited, destination app remains the
authenticator. This is the mechanism that lets a route serve both a
machine caller and an ordinary human session at the one URL — no
per-route "compose both" application code required; the edge does it
uniformly, on every route.required_scope is non-empty and
the token lacks it — a genuine authorization failure, not an
ambiguous "maybe not a machine call" case: the edge knows for certain
this is a real, identified machine caller who simply isn't authorized
for this route. Rejected outright (403 GW1003), never silently
downgraded to passthrough.required_scope is empty — ADR-GW-3:
accepted. No application has declared a specific need for this route,
but a verified machine caller is still a verified machine caller —
X-Gateway-Client-Id/X-Gateway-Scopes (empty list) are stamped the
same as any other machine call. An application whose route must reject
every machine caller outright, specific-scope-holder or not, enforces
that itself downstream by checking for X-Gateway-Client-Id's absence
— the edge no longer makes that decision by omission at the route-class
level, matching rule 4 in api-gateway.md: identity and authorization
are two separate checks, and "no declared scope" was never meant to
silently answer the authorization one.Authorization stripped,
X-Gateway-Client-Id/X-Gateway-Scopes/X-Gateway-Tenant-Id stamped,
rate-limited by client:<id>, forwarded.One consequence worth naming explicitly (carried over from ADR-GW-2, still
true under ADR-GW-3): a route with required_scope set can express "must be
authorized to reach this on a machine's behalf" but never "must ONLY ever be
reached by a machine, reject an ordinary unauthenticated request outright" —
there is no edge-level way to say that for any route class. A route that
declares required_scope is always machine-preferred-with-passthrough-
fallback, never machine-only; a route with required_scope empty is always
machine-accepted-with-passthrough-fallback, never machine-required. An
application that genuinely needs "reject unless X-Gateway-Client-Id is
present" enforcement for a specific route has to do that check itself,
downstream — the same "compose, don't replace" pattern every application
already needs for a route that serves both a human session and a machine
caller (APP_INTEGRATION_GUIDE.md).
CORS note (ADR-GW-3): Access-Control-Allow-Credentials eligibility now
follows the actual per-request outcome, not the route's static
class — a required_scope-declared route that falls back to passthrough for
a given request (no verifying bearer presented) is genuinely being served as
passthrough for that request, and gets the same declared-origin credential
grant a passthrough-classed route would. Previously this was gated on the
static class alone, so a real browser session hitting a scoped route never
got credentialed CORS treatment even though it was, in fact, being served as
passthrough underneath.
Observability note: because a failed machine-auth attempt now falls
through to passthrough rather than being rejected, it's logged as an
ordinary passthrough request (§6) with whatever status the destination app
happened to return — not as a distinct machine-auth-failure event the way
v2's GW1001/GW1002 responses were. GW1001 and GW1002 are retired as
of v3: the edge no longer emits either (§8).
v2's design assumed passthrough was rare and narrow — "one application's
whole browser-facing surface, typically a catch-all /." In practice, an
application mid-split from a shared monolith (the common real case, not a
hypothetical) carries most of its sibling's routes for a long transitional
period, and every one of those overlapping routes is genuinely passthrough
(each app's own frontend calls its own copy directly) until the split
finishes. Under v2, the second such application could never register at
all — not "some of its routes conflict," all registration failed outright
the moment its manifest declared one path collision (CP1004), since
POST /applications validates the whole manifest transactionally. The only
v2-compatible fixes were: don't register the second app at all (defeats the
purpose), or mark the shared routes machine-class to dodge the raw-path
collision (actively wrong — see api-gateway.md rule 2: machine-class is
for another application calling on its own behalf, and marking a
frontend's own session traffic machine-class gets it rejected at the edge
with GW1002 instead, the single most common integration bug this contract
warns about). Universal namespacing (§5.1) removes the underlying
constraint that forced that choice; dynamic per-request dispatch (§5.2) is
what makes namespacing-everything actually workable without regressing the
"human vs. machine" auth story a static per-route class used to provide.
POST /api/v1/gateway/internal/usage
Authorization: Bearer <GATEWAY_INTERNAL_TOKEN>
{"events": [{"request_id": "...", "client_id": null, "tenant_id": null,
"route_prefix": "/employees", "method": "GET", "status": 200,
"duration_ms": 12, "route_class": "machine", "service": "hr-service"}]}
202 Accepted. Idempotent on request_id (ON CONFLICT DO NOTHING) — the
edge's usage sink is fire-and-forget, at-least-once delivery; this is
aggregate metering, not a billing-of-record system.
service is the owning application's slug (Route.Service, §5) — empty/
absent when no route matched at all (a true GW1006). Optional on the wire
so an edge binary predating this field still ingests cleanly. Unlike
client_id (only ever known for machine-class, caller-authenticated
requests), service is set for every route class the edge actually matched,
including passthrough — it's what makes §14's per-app request log group
passthrough (human/browser) traffic by app too, not just machine calls.
The edge strips any inbound X-Gateway-* header before setting its own
(clients cannot forge trusted identity), then stamps:
X-Gateway-Request-Id — every request the edge actually classified, both
classes.X-Gateway-Auth: <GATEWAY_SHARED_HEADER_SECRET> if configured — on
every forwarded request, both classes, not just verified machine calls.
This signals "this arrived via the gateway" broadly; it is not, on its
own, a signal that the caller is a verified machine — see the next point.X-Gateway-Client-Id, X-Gateway-Scopes (space-joined),
X-Gateway-Tenant-Id (if present) — only on a request the edge
actually verified as a genuine machine token (§5.2). This is the header an
application must check for "is this a verified machine caller," never
X-Gateway-Auth alone (which passthrough traffic carries too).For a request that falls through to passthrough — whether because the route
is passthrough-class, or because it's machine-class but the presented
credential didn't verify (§5.2) — the client's own Authorization header is
forwarded unchanged, and no X-Gateway-Client-Id/X-Gateway-Scopes are set.
The destination application remains its own authenticator for that request.
The correct application-side check, composing with (not replacing) your
own session auth: if X-Gateway-Client-Id is absent, run your normal
session/Bearer check, unmodified. If it's present, verify X-Gateway-Auth
against your configured GATEWAY_SHARED_HEADER_SECRET with a constant-time
compare before trusting it — a present-but-mismatched value is always a
reject, never a fall-through to session auth (this is the one case where a
direct caller, bypassing the gateway entirely, could try to forge
X-Gateway-Client-Id themselves — the shared-secret check is what makes
that fail).
{"error": {"code": "GW1003", "message": "...", "request_id": "..."}}
| Code | Status | Meaning |
|---|---|---|
GW1000 |
503 / 502 | No config loaded, or invalid/unreachable upstream for a matched route |
GW1001 |
Retired as of v3 (ADR-GW-2) — a missing/malformed/expired bearer token on a machine-class route no longer rejects; it falls through to passthrough (§5.2). Reserved, never emitted by the edge. | |
GW1002 |
Retired as of v3 (ADR-GW-2) — same fallback as GW1001 for an unknown kid/signature failure. Reserved, never emitted by the edge. |
|
GW1003 |
403 | Valid, verified machine token, insufficient scope — still a hard reject, unchanged from v2 |
GW1004 |
403 | Route explicitly blocked |
GW1005 |
429 | Rate limit exceeded (Retry-After set) |
GW1006 |
404 | No application registered a route for this path |
The control plane uses its own CP1xxx codes for its admin/public API
(internal/controlplane/api.go) — a separate namespace, since it's a
different HTTP surface with different failure modes (validation, conflict,
not-found) than the edge's hot-path codes above.
Two paths are always forced to passthrough, regardless of what the config
snapshot says, so an operator can never be locked out of fixing a bad
routing rule by that same rule:
/api/v1/platform-auth (and children) — password/TOTP/refresh/SSO sign-in/api/v1/platform/identity/webauthn (and children) — passkey sign-inThis is deliberately narrow and lives only in internal/gwconfig/config.go
— it does not depend on the control plane refusing to create a bad rule
(a migration, a restore, or a future control-plane bug bypasses that guard
entirely; see the 2026-07-25 incident documented in that file). A third
prefix, /api/v1/platform/gateway (the admin API), was part of this floor
under v1 — it's gone under v2 because that API no longer flows through the
edge's route table at all; it's reached directly, on its own address, and
so can no longer be locked out by anything in this table.
In-memory, single-instance (internal/ratelimit), token bucket per
client:<id> (machine) or ip:<addr> (passthrough). ratelimit.Limiter is
the seam for a shared (e.g. Redis) implementation if the edge is ever
horizontally scaled — not built yet because it isn't, today.
Matches ETOPS Integration Standard §8 ("the Gateway never writes API documentation. Instead it indexes it"):
GET /apps — JSON array of every active registered Application.GET /developer-portal — the same, as a browsable HTML page, linking to
each application's docs page if it declared one.GET /docs / GET /openapi.json — this control plane's own API, browsable
via Swagger UI (loaded from a CDN in the visitor's browser — nothing
vendored into the binary) or the raw OpenAPI 3.0 document.GET /apps/{slug}/openapi.json — fetches the named application's own
OpenAPI document (from the documentation URL in its manifest), rewrites
its servers array to [{"url": "<GATEWAY_PUBLIC_BASE_URL>/<slug>"}] —
the application's own real address (https://hr.internal, say) is not
something a docs viewer should ever see, both because it may not even be
reachable outside the shared network, and because calling it directly
would bypass every check the gateway exists to perform — and serves the
result. A live passthrough, not a cache: always current, at the cost of
one fetch per view. 404 if the application is unknown, inactive, or
declared no documentation URL; 502 if that URL is unreachable, doesn't
return 200, or isn't parseable JSON (a YAML spec, say — refused rather
than served un-sanitized).GET /apps/{slug}/docs — Swagger UI for that application, pointed at the
proxied+rewritten copy above rather than the application's raw external
URL, so both "try it out" and the page itself work even for an
application whose docs endpoint isn't reachable from a visitor's own
network, and every documented call goes through the gateway like any
other client.None of this is reverse-proxied through the edge (like the rest of the
admin surface, §9) — reached directly, on the control plane's own loopback
port in development (docs/gateway/DEV_ROLLOUT.md) or however staging/
production choose to route it.
api.*)Superseded design (v2, until now): a dedicated docs.* host proxied
every request straight to the control plane, with a short-path alias
(/{slug} → /apps/{slug}/docs) that only fired on that host. That's gone.
Current design: the control plane and the edge now share the same
public hostname (api.gateway.etdevops.io / gateway.etdevops.io) — there
is no docs.* host anymore. A reverse proxy in front of both (real nginx in
production, tools/devproxy locally) splits every request by path, not
Host:
| Path | Routes to |
|---|---|
/, /docs, /openapi.json, /apps, /apps/{slug}/..., /developer-portal, /oauth/token, /applications/..., /credentials/..., /grants/..., /.well-known/jwks.json, /getting-started, /contract, /claude/... |
The control plane |
Everything else — i.e. /<app-slug>/<path> |
The edge (proxying to that application, §5) |
This works because reservedAppSlugs (internal/controlplane/manifest.go)
already forbids an application from registering under any of the control
plane's own top-level path names — Manifest.Validate() rejects appId
values in docs, apps, developer-portal, oauth, applications,
credentials, grants, healthz, api, getting-started, contract,
claude at registration time. So the path split above is exhaustive and
never ambiguous: a first path segment is either one of those reserved names
(→ control plane) or it's a real, registered application's slug (→ edge),
never both.
There is no shorter alias for an application's docs page anymore — a bare
/{slug} now always means "call that application's real API" (via the
edge), matching the same shape every other proxied call already uses
(/hr-service/employees, §5). An application's docs stay reachable at the
existing long form, /apps/{slug}/docs / /apps/{slug}/openapi.json, on
the same hostname as everything else.
/api/v1/gateway/internal/* (§5's edge⇄control-plane contract) is
deliberately not part of the public split above — it stays reachable
only over the shared Docker network / loopback, authenticated separately by
GATEWAY_INTERNAL_TOKEN, exactly as before (docs/gateway/DEV_ROLLOUT.md
"What's deliberately NOT exposed").
Unlike the old docs.*-only posture, the admin API (/applications,
/credentials, /grants, and friends — §3) is now reachable on this same
public hostname, not loopback-only. See §13 for the credential model that
makes that safe to expose, and internal/controlplane/api.go's
withAdminIPRateLimit / withSecurityHeaders for the accompanying
hardening (per-IP rate limiting, standard security response headers) that
came with making a mutating admin surface public.
Every admin-API request (§1, §3) authenticates with a bearer token that is either:
CONTROLPLANE_ADMIN_TOKEN env var — one shared
bootstrap/automation credential, unchanged from v2's original design.gateway_admin_tokens, a per-developer/console credential
independently revocable from the static one and from every other dynamic
token.controlplane token create -name "alice@company.com" [-actor "who ran this"] [-ttl 720h]
controlplane token list
controlplane token revoke [-reason "..."] [-actor "..."] <id>
controlplane token rm <id>
CONTROLPLANE_DATABASE_URL directly (same as -migrate), so minting a new
admin credential always requires whoever runs it to already have direct
database access, never just an existing bearer token over the network.token_hash (sha256 hex) and token_prefix (first 8 hex
chars of the raw token, display-only) — same shape as
gateway_client_credentials. The raw token (gwadm_<64 hex chars>) is
shown exactly once, at create time, and is never retrievable again.-ttl 0 (the default) never expires; any other duration sets
expires_at, checked on every authentication attempt alongside status.revoke soft-revokes — the row stays, revoked_at/
revoked_reason/revoked_by recorded, matching how credential and grant
revocation already work (.claude/rules/security.md's audit-trail
requirement). rm hard-deletes the row with no audit trail — for cleaning
up a token that never needed to exist (a test/typo token), not the normal
way to take a real credential out of service.withAdminAuth, internal/controlplane/api.go): the
static token is checked first via constant-time compare (so a deployment
that has minted no dynamic tokens keeps working exactly as before); a
dynamic token is checked via a hashed-column lookup
(gateway_admin_tokens.token_hash), not a loop of per-token compares.Every request the edge actually classified (i.e. everything except a request
that arrived while no config snapshot was loaded at all) reaches
gateway_usage_log via §6, grouped by owning application via service.
Two admin-authed read endpoints expose it — same bearer as every other
admin-API call (§13), no new auth mechanism:
GET /applications/{id}/requests?limit=&afterId=&status=&method= — one
app's own request-log page (both machine and passthrough traffic to that
app), newest first. limit defaults to and caps at 100.
Keyset-paginated on the log's own id (not OFFSET, since this table only
grows and OFFSET re-scans and can skip/duplicate rows under concurrent
inserts): pass the response's nextCursor back as afterId to fetch the
next page; nextCursor: null means there's nothing further back to fetch
at this page size. status/method filter the returned page after it's
fetched, not via a second index — this endpoint is a human debugging/
status view, not an analytics query surface. Response:
{"events": [{"id", "requestId", "clientId", "tenantId", "routePrefix", "method", "status", "durationMs", "routeClass", "service", "occurredAt"}], "nextCursor": <id>|null}.GET /requests/summary?sinceMinutes= — counts and average latency grouped
by service over the trailing window (sinceMinutes defaults to 1440 =
24h). Rows with no service (no route matched — a GW1006) are excluded:
they belong to no application, so there's nothing to group them under.
Response: {"sinceMinutes": <n>, "summary": [{"service", "total", "success2xx", "clientError4xx", "serverError5xx", "avgDurationMs"}]}.Neither endpoint is a substitute for the org-wide structured logging/error-
tracking conventions in .claude/rules/observability.md — it's a
gateway-specific, per-app status view, scoped to what the edge itself saw
(status code, method, timing, route class), not a general log search or
error-tracking tool.