Deployment

Serving with authentication

A Hypermake project served on a public origin needs a boundary. Hypermake ships one: a deny-by-default request admission layer that a project turns on by declaring policy files, not by writing middleware. This page documents that layer as it exists in code — what a project declares, what refuses, and how the first session is minted — so you can protect your own served site. Every value shown is a placeholder; no real deployment's identifiers, key paths, or domains appear here.

What turns it on

There is no --auth flag. Serve reads dev.auth from hypermake.json; if the block is present, the boundary is installed in front of every request, and if any part of it is unusable, serve does not start. That is the whole activation contract, and it is deliberately one-way: you cannot half-enable it, and you cannot leave it running degraded.

Startup, in order — any failure exits before the listener opens

StepRefuses when
Decode dev.auth (strict; unknown fields rejected)A misspelled field, a non-canonical project-relative path, an ingress outside application-unix-socket / control-unix-socket / private-probe, a loginRedirects entry that is not a clean absolute path, or exchangeDirectory declared without loginRedirects (they must be declared together).
Load the runtime bundle from policyThe deployment policy, its route policy, its cookie policy, or its keyring is missing, malformed, or fails validation.
Check the ingress bindingThe configured ingress is not listed in the deployment policy's trustedIngresses. Your project and your deployment must agree on how requests arrive.
Acquire the session storeAnother writer holds it. The serve process is the only writer of a session store; a second one fails closed rather than racing.
Load projectPolicy (optional)The project authorization policy is malformed.
Prepare device login (optional)The named environment variable does not hold an absolute path to a usable signer key.

The four policy files

The dev.auth.policy path points at a deployment policy; that file references the other three plus the session store by relative path. All five live wherever you put them — the substrate has no opinion about layout, only about content.

Deployment policy — hypermake.auth.deployment-policy.v1

FieldConstraint enforced at load
modeExactly local-owner or production-owner.
issuer, audience, deploymentInstanceNon-empty, and bound into every token. Local and production deployments must use distinct values even when they share public-key generations — this is what stops a laptop session from being replayed against a public instance.
algorithmEdDSA. There is no algorithm negotiation.
allowedOriginsAt least one, each a bare https origin: no userinfo, no path, no query, no fragment. Plain-HTTP origins are refused.
trustedIngressesSubset of application-unix-socket, control-unix-socket, private-probe.
maxSessionTtlSeconds1 to 43200 (12 hours), the substrate's hard ceiling.
keyringRef, routePolicyRef, cookieRef, sessionStoreRefAll required, resolved relative to the policy file.
Placeholder deployment policy — not a live configuration auth/example/deployment.json
{
  "schemaVersion": "hypermake.auth.deployment-policy.v1",
  "policyId": "example-deployment",
  "generation": 1,
  "mode": "production-owner",
  "issuer": "example-auth-issuer",
  "audience": "example-site",
  "deploymentInstance": "example-site-prod",
  "algorithm": "EdDSA",
  "keyringRef": "keyring.json",
  "routePolicyRef": "routes.json",
  "cookieRef": "cookie.json",
  "sessionStoreRef": "sessions.jsonl",
  "allowedOrigins": ["https://site.example"],
  "trustedIngresses": ["application-unix-socket"],
  "maxSessionTtlSeconds": 43200
}
		

Cookie policy — every field is a checked posture, not a preference

FieldAccepted value
nameMust begin with __Host-. The browser then enforces the host binding for you.
path/ exactly.
secure / httpOnlyBoth true. There is no way to declare a script-readable session cookie.
domainAttribute"absent". Cross-subdomain session sharing is not expressible.
sameSiteStrict or Lax.
ttlSeconds1 to 43200, and never above the deployment policy's maxSessionTtlSeconds.
clearOnRefusalNamed causes the boundary may use when it clears the cookie on refusal.

The keyring (hypermake.auth.keyring.v1) publishes public material only: one or more root keys, and session-signing keys each carrying a delegation signed by a root key with its own expiry and per-session TTL ceiling. Verification code never loads a private key. The session store is an append-oriented file of secret-free session records — subject, instance, signing key id, status, lifetimes, optional parent session — owned exclusively by the serve process.

Route policy: classify before you dispatch

Every request is matched against the route policy before the graph or any handler sees it, so a refusal has no side effects. defaultDecision must be deny; an undeclared route is a 404-shaped refusal, not a fallthrough.

Access classes

accessBehavior
publicAdmitted with no session. Still subject to CSRF evidence when the route is a mutation.
authenticatedRequires exactly one session cookie, a verified token, a live durable session, and — if a project policy is loaded — an explicit grant.
internal-onlyAdmitted only when the request arrived over an ingress the route and the deployment policy both trust. Otherwise it reports as unreachable, not as forbidden.
unreachableDeclared and permanently refused — the way to retire a path without deleting the row.
device-subtreeThe one delegating class: the row declares the device prefix once and the canonical dispatcher decides each leaf. It must be a prefix row, a mutation, with exact-origin CSRF.

matchKind is exact, prefix, or regex, and matching happens on the canonical path only: if the raw path differs from its canonical form in any way — dot segments, encoding differences between the decoded and escaped path — the request is refused as non-canonical before any row is consulted. Method lists are explicit; GET does not imply HEAD unless you say so.

Placeholder route policy (excerpt) auth/example/routes.json
{
  "schemaVersion": "hypermake.auth.route-policy.v1",
  "policyId": "example-routes",
  "generation": 1,
  "defaultDecision": "deny",
  "routes": [
    { "id": "public-site", "matchKind": "prefix", "path": "/",
      "methods": ["GET", "HEAD"], "access": "public",
      "mutation": false, "csrf": "none",
      "authorizationClass": "site.read" },
    { "id": "admin-console", "matchKind": "prefix", "path": "/admin/",
      "methods": ["GET", "HEAD"], "access": "authenticated",
      "mutation": false, "csrf": "none",
      "authorizationClass": "admin.read" },
    { "id": "admin-mutate", "matchKind": "exact", "path": "/admin/apply",
      "methods": ["POST"], "access": "authenticated",
      "mutation": true, "csrf": "exact-origin-and-same-origin",
      "authorizationClass": "admin.write" }
  ]
}
		

For any mutation route the boundary demands two independent pieces of browser evidence before it looks at the session at all: an Origin header that is exactly one of the deployment policy's allowedOrigins, and Sec-Fetch-Site: same-origin. Host headers, forwarded headers, source IP, and the spelling of localhost are never inputs to any of this.

Project authorization: who, not just whether

Authentication answers "is this a real session". The optional projectPolicy answers "may this subject use this route class". It is a default-deny list of grants mapping subjects to the authorizationClass values your route policy already declares — exact string matching, no wildcards, no roles baked into the token. Project vocabulary stays in project policy; the session claims stay generic.

Placeholder project authorization policy auth/example/project-policy.json
{
  "schemaVersion": "hypermake.auth.project-authorization-policy.v1",
  "policyId": "example-grants",
  "generation": 1,
  "defaultDecision": "deny",
  "grants": [
    { "id": "owner-full",
      "subjects": ["owner@example"],
      "authorizationClasses": ["admin.read", "admin.write"] }
  ]
}
		

Before a request reaches your handler the boundary also strips any client-supplied identity headers — Authorization, X-Forwarded-User, X-Authenticated-User, X-Hypermake-Auth-Subject — and attaches the verified identity to the request context instead. Nothing downstream can be fooled by a header it did not earn.

Minting the first session

There is no signup form and no password. Authority starts with one owner-confirmed ceremony run on the host, and reaches the browser through a short-lived, single-use exchange.

The ceremony (placeholder request path and env var) shell
EXAMPLE_AUTH_KEYS=/absolute/owner-only/key/dir \
hypermake auth owner-ceremony \
  --request auth/example/ceremony-request.json \
  --confirm-owner-ceremony
		

The confirmation flag is mandatory: without it the command refuses with auth.ceremony_owner_confirmation_required and does nothing. The request file names the deployment policy, the keyring output, the exchange directory, the subject, the post-login redirect path, and four lifetimes — root, delegation, session, and exchange — each bounded, with delegation never outliving root and session never outliving delegation or the policy ceiling. The exchange lifetime is capped at 300 seconds.

What one ceremony produces

ArtifactWhere and who may read it
Root private key, session-signer private keyThe owner-only key directory named by the environment variable. Never referenced from config, never loaded by verification code.
Root public key, signer public key, keyring JSONBeside the deployment policy. Public material, published deliberately.
One pending exchange artifactMode-0600 in the exchange directory: the minted token plus a SHA-256 digest of the proof — never the proof itself.
stdoutExactly two values: exchangeId and proof. No key, no token, no path, no instance metadata ever reaches stdout.

You then hand those two values to the running deployment as a form post from your own browser:

Redeeming the exchange POST /auth/session/exchange
Content-Type: application/x-www-form-urlencoded

exchangeId=exchange_EXAMPLE&proof=EXAMPLE_PROOF&returnTo=/admin/
		

Exactly three form fields, no query string, an 8 KiB body cap, and returnTo must be one of the loginRedirects you declared. On success the serve process installs the durable session, writes a secret-free receipt, deletes the pending artifact, and sets the session cookie — the token itself never becomes browser-visible state, and the exchange cannot be redeemed twice. A crash mid-flight can orphan a revocable session, but it cannot permit a replay.

Later, while the delegation is still valid, hypermake auth owner-exchange-refresh --confirm-owner-exchange-refresh stages a fresh five-minute exchange against the standing signer: no key rotation, no serve restart, no session-store write, and any previously pending window is atomically replaced. It requires the same owner-only custody, and it never loads the root key.

Runtime endpoints the boundary owns

Reserved paths on an auth-enabled deployment

PathMethodBehavior
/auth/session/exchangePOSTRedeems one owner exchange, sets the cookie, redirects to returnTo with Cache-Control: no-store and Referrer-Policy: no-referrer.
/auth/sessions/currentGETSecret-free session status: session id, subject, deployment instance, issued/expires. Declare it as an authenticated route.
/auth/logoutPOSTRevokes the durable session and clears the cookie. Requires an admitted session and verified CSRF evidence.
/auth/device/…variesThe QR device subtree, when dev.auth.device is declared: pairing start / status / finalize are public but same-origin; approve, deny, list, and revoke require an admitted owner session.

Device login exists so a second device can be admitted by an already-trusted one instead of by copying a token around. The flow is bounded on purpose: a pairing request lives 1–600 seconds (ten minutes is the hard ceiling), session TTL stays under the policy ceiling, at most 32 requests may be pending at once, and finalize is atomic and rechecks the approving parent. A revoked approver or a signer error is terminal. Revoking a child never touches its parent.

The refusal contract

Refusals are data, not prose. Every one is a JSON envelope with a stable reason code and a message that deliberately says less than the code does — the code is for you, the message is for the browser.

Refusal envelope response body
{
  "schemaVersion": "hypermake.auth.refusal.v1",
  "requestId": "auth-example-serve-id-1",
  "reasonCode": "auth.token_missing",
  "stage": "authentication",
  "httpStatus": 401,
  "retryable": false,
  "message": "Authentication proof is invalid."
}
		

Reading the codes

FamilyMeaning and typical cause
auth.route_path_noncanonical, auth.route_undeclared, auth.route_unreachableCanonicalization stage. The path was not canonical, no row matched, or the row is unreachable from this ingress. Nothing was authenticated yet.
auth.token_missing, auth.claims_invalid, auth.claim_binding_invalid, auth.claim_lifetime_invalidAuthentication stage. Zero or multiple session cookies, a token that fails strict verification, or claims bound to a different issuer/audience/instance/key generation.
auth.session_unknown, auth.session_expired, auth.session_revokedThe token verified but the durable session does not admit it. These four causes also clear the cookie — expired, revoked, unknown-session, wrong-instance — so a stale browser stops resending a dead credential.
auth.origin_invalidCSRF stage: the mutation lacked an allowed Origin or same-origin fetch evidence.
auth.ceremony_*, auth.exchange_*, auth.device_*Ceremony, one-time exchange, and device-flow refusals. auth.exchange_consumed in particular is the correct, expected answer to a replayed login link.
auth.policy_unavailable, auth.*_unavailableThe only retryable: true family — status 503, meaning the boundary could not evaluate, so it refused rather than guessed.

Admitted authenticated responses also carry Cache-Control: no-store and Vary: Cookie, plus Vary: Origin, Sec-Fetch-Site on mutations, so no shared cache can serve one session's page to another visitor.

Protecting your own served site

Order of operations

#Step
1Choose an issuer, audience, and deployment instance that no other deployment of yours uses — including your laptop's.
2Write the deployment, route, and cookie policies. Start with everything authenticated and open exactly the routes you mean to be public.
3Create an owner-only, non-symlinked key directory with no group or world permissions, and export its absolute path as the environment variable your ceremony request names.
4Run hypermake auth owner-ceremony with the confirmation flag. Keep the printed proof out of shell history and logs; it is single-use and expires within five minutes.
5Add dev.auth to hypermake.jsonpolicy, ingress, and (if you want browser login) exchangeDirectory plus loginRedirects together. See the hypermake.json reference for the exact field shape.
6Start serve. If it exits, read the message: a refusing boundary is the feature. Redeem the exchange from your browser, then confirm with /auth/sessions/current.
7Add projectPolicy once more than one subject exists, and use owner-exchange-refresh rather than a second ceremony when you simply need a new login link.
Verify this yourself shell
hypermake auth owner-ceremony
hypermake explain concept://protocol
hypermake resolve src://docs/serve-auth.html
hypermake affected-by src/docs/serve-auth.html
		

The first command is worth running before anything is configured: with no confirmation flag it refuses immediately with auth.ceremony_owner_confirmation_required, which is the shortest possible demonstration of how this whole layer behaves.