Skip to content

The HTTP API surface

A single Henceforth server process serves everything over HTTP: the versioned API, the published contract artifacts that describe it, and the optional Studio, App, and Login experience surfaces. There is no separate API gateway or docs server – one binary, one listening socket, one surface.

Every route in the API lives under a single version prefix, /v1/, applied once at router composition rather than edited into individual route strings. A prefix scoped per family instead – /v1/data/... alongside /v1/metadata@main/... – would collide with the metadata@{branchId} first path segment that cart-branch routes already use, so the version has to sit outside that segment, at the root.

A fixed set of paths is exempt from the prefix, and nothing else is:

  • /health, /ready and /status – operational probes, not contract. /health is liveness; /ready answers 200 only on the active instance, so a standby process answers 503 there.
  • /.well-known/jwks.json and /.well-known/henceforth/schema/{file} – RFC 8615 well-known URIs are unversioned by definition.
  • /app/, /studio/, /login/ – the SPA mounts. These are experience surfaces, not API. /studio/ exists only in a build with the studio cargo feature, which is on by default.
  • The bare redirects into those mounts: / and /login redirect to /login/, /app redirects to /app/, and (in a build with Studio) /studio redirects to /studio/.

GET /v1/surfaces is API, not a surface: it is the transport-neutral surface hub, the list of surfaces the caller may use, which the sign-in page and hf surface list both read (see Experience surfaces).

An unprefixed request whose first path segment is one of the routes the binary served, unprefixed, before the version prefix landed (auth, data, metadata, iam, hfx, surfaces, plan, apply) is a hard cut, not a soft fallback: it answers a typed 404 carrying the error code API_VERSION_REQUIRED, the same structured error envelope every other API error uses – it is very likely an old client that never learned about the prefix. Every other unmatched path, including a typo’d version segment (/v2/data, /vl/data) or a route that never existed (/dashboard), gets a plain NOT_FOUND instead: a blanket API_VERSION_REQUIRED for any unmatched path was rejected (#915 Amendment 5) because it would mislabel an ordinary typo as a version problem. Source: #915 Amendment 5, docs/standards/api-conventions.md.

A running server publishes the exact documents it was built from, not a live introspection of its current state:

  • /.well-known/henceforth/openapi.json – the committed OpenAPI document.
  • /.well-known/henceforth/schema/{type}.json – the committed JSON Schema for each metadata document type.

Both are public, need no credential, and are cached for an hour. A module pins the contract version it targets through its manifest’s platform.apiVersion, and the compiler rejects a module built against a version the running server does not support – the module and the server it runs against never silently disagree about the shape of the contract between them.

See Fetch the API contract from a running server for the exact requests.

Henceforth’s own credential API – issuing the JWTs that authenticate every other request – lives under /v1/auth/, and the public keys used to verify those JWTs are published at /.well-known/jwks.json. There is no refresh-token route in v0: a JWT is valid until it expires (tokenExpiryMinutes in Settings), and getting a new one means authenticating again with a password or an SSH signature. A session code does not do that: POST /v1/auth/session itself requires a valid JWT, and the code it returns only delegates that existing session to a browser: GET /v1/auth/session/exchange shows which account the code signs in as, and POST /v1/auth/session/redeem consumes it. It cannot revive an expired one.

The root segment /oauth2/ is reserved, unused today, for a possible future OAuth 2.0 / OpenID Connect authorization server discoverable via RFC 8414. /v1/auth/ is a credential API that borrows OAuth’s vocabulary (tokens, grants, expiry) without implementing an OAuth authorization server’s protocol – a vocabulary choice, not a protocol commitment, and the reason it lives under the version prefix like any other route rather than at the reserved root.

Cross-origin access is off by default: a server with no api.cors.allowedOrigins configured emits no access-control-* response header at all, on any route.

Setting api.cors.allowedOrigins installs one policy that covers every response the server produces – there is no per-route CORS configuration. Matching is exact on scheme, host, and port: no wildcard origin, and no wildcard subdomain. http:// is accepted only for loopback hosts, because browsers treat a loopback origin as potentially trustworthy even without TLS; every other origin must use https://.

There is no credentialed CORS mode yet. A bearer token travels in the Authorization header, which CORS’s credentialed-request rules do not gate; a cookie-based session, which those rules would gate, is a later decision.

settings.json5 is read once, at server startup. Changing it – adding an allowed origin, changing a token’s expiry – takes effect only after a restart.

  • Settings – every settings.json5 key, including api.cors.allowedOrigins and its validation rules.
  • HTTP API reference – every route this surface serves, generated from the committed OpenAPI document.
  • Error codes – every code the typed error envelope can carry, including API_VERSION_REQUIRED.