Heimdall/docs/service-contract.md
Heimdall 9f5a0e6938 Bind link mode to its initiator and redeem to its target
Link start needs an authenticated app and an initiatingAccountId, carried in
the signed state. The callback refuses an identity held by another account
(link_initiator_mismatch) and attaches an unlinked one to the initiator.
auth_completions stores target_id; redeem compares it and answers
completion_target_mismatch without consuming the code. Link completions are
redeemed only by the authenticated app.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 13:38:51 +00:00

674 lines
20 KiB
Markdown

# Heimdall Service Contract
This file describes the first landed Heimdall service slice.
It is the concrete HTTP/JWKS contract that now exists in the repo. It is not a
claim that every provider, consumer binding, or production-hardening concern is
done yet.
## Current stack
- runtime: Node.js `24+`
- service framework: Fastify
- language: TypeScript
- signing: Ed25519 JWTs with JWKS exposure
## Current routes
### `GET /healthz`
Purpose:
- basic liveness probe
Response:
```json
{
"ok": true,
"service": "heimdall",
"issuer": "https://heimdall.gamecult.org",
"storageBackend": "memory",
"signingKeySource": "configured_file",
"tokenCustodySource": "configured",
"now": "2026-04-26T12:00:00.000Z"
}
```
### `GET /.well-known/jwks.json`
Purpose:
- publish the public signing key that app backends use to verify Heimdall JWTs
Notes:
- current skeleton signs with Ed25519
- `GC_ACCESS_SIGNING_PRIVATE_KEY_PATH` lets the service load a persisted key
from disk
- if the path is missing and `GC_ACCESS_SIGNING_PRIVATE_KEY_BOOTSTRAP=1`, the
service bootstraps a new key file on first start
- if no path is configured, the service still generates an
ephemeral dev key at startup and deserves no sympathy when the `kid` changes
### `GET /.well-known/heimdall-configuration`
Purpose:
- publish discovery metadata for app backends and integration code
Current shape includes:
- `issuer`
- `jwksUri`
- `configurationUri`
- `oauthStartEndpoint`
- `oauthCallbackEndpoint`
- `supportedProviders`
- `apps`
### `GET /v1/apps`
Purpose:
- list known app profiles and their capability surfaces
### `GET /v1/apps/{appSlug}`
Purpose:
- return one app profile
Current app slugs:
- `repixelizer`
- `streampixels`
- `spotiverse`
Profile fields:
- `slug`
- `displayName`
- `profileVersion`
- `identityProviders`
- `entitlementSources`
- `managedConnectionProviders`
- `capabilities`
Capability modes:
- `shared`
fully evaluated by Heimdall from signed facts/grants
- `hybrid`
requires app-local state after claim verification
### `POST /v1/oauth/{provider}/start`
Purpose:
- begin a provider OAuth flow
- mint signed OAuth `state`
- return the provider authorization URL
Supported providers in the skeleton:
- `discord`
- `patreon`
- `github`
- `twitch`
- `youtube`
- `spotify`
- `joystick`
Request body:
```json
{
"appSlug": "repixelizer",
"mode": "sign_in",
"returnTo": "https://repixelizer.gamecult.org/app/",
"handoff": {
"kind": "backend_callback",
"attemptId": "auth-attempt-123",
"callbackUrl": "https://repixelizer.gamecult.org/api/auth/heimdall/callback"
},
"connection": {
"kind": "creator",
"targetId": "creator:alpha",
"summary": "Optional app-local binding hint"
},
"requestedScopes": ["identify", "guilds"]
}
```
Response:
```json
{
"provider": "discord",
"appSlug": "repixelizer",
"mode": "sign_in",
"callbackUrl": "https://heimdall.gamecult.org/v1/oauth/discord/callback",
"authorizationUrl": "https://discord.com/oauth2/authorize?...",
"stateToken": "<signed-jwt>",
"stateExpiresAt": "2026-04-26T12:10:00.000Z"
}
```
Failure modes:
- `503 provider_not_configured` when the provider client id is missing
- `400 untrusted_backend_callback` when `handoff.kind=backend_callback` and
`callbackUrl` is not in that app's `GC_ACCESS_APP_<SLUG>_BACKEND_CALLBACK_URLS`
- `400 return_to_not_allowed` when `returnTo`'s origin is not in that app's
`allowedReturnOrigins` (`src/app-profiles.ts`) -- `returnTo` is where the
browser handoff page postMessages the completion payload
(`src/browser-handoff.ts`), so this check runs for every caller, not only an
authenticated one
- `401 app_auth_required` when the request body carries `entitlementPolicy` and
the caller did not authenticate as that app via `x-heimdall-app-secret`
(`resolveAppCaller`, `src/app-caller.ts`) -- this applies regardless of
`handoff.kind` or app slug; a caller with no policy to attach needs no secret
Launch note:
- Discord and Patreon are the implemented provider runtimes for Repixelizer
- Twitch, YouTube and Joystick have generic OAuth exchange and identity runtimes for
StreamPixels viewer auth and managed creator connections
- GitHub is still catalogued for start URLs, but its callback runtime is not
implemented yet
- Repixelizer launch can configure Discord and Patreon on its provider buttons
### `GET /v1/oauth/{provider}/callback`
Purpose:
- validate signed OAuth `state`
- complete the provider callback flow
- hand browser callers back to the app through a one-time completion exchange
Current status:
- validates signed `state`
- returns provider errors cleanly
- for Discord, exchanges the authorization code, resolves the provider
identity, persists the local account/link/session/audit records, evaluates
Repixelizer entitlement facts, and issues a signed Heimdall access claim
- when `handoff.kind=backend_callback`, delivers the auth result directly to
the app backend and renders a tiny browser status page that can close itself
- when `handoff.kind=browser_completion` or handoff is omitted, keeps the older
one-time completion-code flow as a fallback path
- if opener handoff is used and fails, the completion page still offers a
fallback return link carrying only the one-time completion code, not the
access token
- GitHub still returns "not implemented" at the runtime adapter layer until its
callback path is added
Success response for backend-callback handoff now includes:
```json
{
"delivery": {
"kind": "backend_callback",
"attemptId": "auth-attempt-123",
"callbackUrl": "https://repixelizer.gamecult.org/api/auth/heimdall/callback",
"status": "delivered"
},
"account": {
"id": "acct_repixelizer_001",
"displayName": "Meta"
},
"session": {
"accountId": "acct_repixelizer_001",
"sessionId": "uuid",
"appSlug": "repixelizer",
"accessRevision": 1,
"expiresAt": "2026-05-26T12:00:00.000Z"
},
"accessToken": "<short-lived-signed-jwt>",
"refreshToken": "<longer-lived-signed-jwt>",
"refresh": {
"expiresAt": "2026-05-26T12:00:00.000Z"
}
}
```
Success response for browser-completion fallback still includes:
```json
{
"completion": {
"code": "<one-time-code>",
"expiresAt": "2026-04-26T12:05:00.000Z",
"redeemEndpoint": "https://heimdall.gamecult.org/v1/apps/repixelizer/auth-completions/redeem"
}
}
```
### Backend callback delivery payload
Purpose:
- push the final auth result directly into the app backend without asking the
browser to courier it
Current payload shape on success:
```json
{
"source": "heimdall",
"kind": "oauth_result",
"handoffKind": "backend_callback",
"attemptId": "auth-attempt-123",
"status": "success",
"provider": "discord",
"appSlug": "repixelizer",
"mode": "sign_in",
"returnTo": "https://repixelizer.gamecult.org/app/",
"connection": null,
"account": {
"id": "acct_repixelizer_001",
"displayName": "Meta"
},
"session": {},
"accessToken": "<signed-jwt>",
"refreshToken": "<signed-refresh-jwt>",
"refresh": {
"expiresAt": "2026-05-26T12:00:00.000Z"
},
"claimSet": {},
"verification": {},
"sharedCapabilities": ["app_access", "queue_submit"],
"hybridCapabilities": [],
"entitlements": {
"facts": ["entitlement.app_access"],
"snapshots": []
}
}
```
Current payload shape on failure:
```json
{
"source": "heimdall",
"kind": "oauth_result",
"handoffKind": "backend_callback",
"attemptId": "auth-attempt-123",
"status": "error",
"provider": "discord",
"appSlug": "repixelizer",
"mode": "sign_in",
"returnTo": "https://repixelizer.gamecult.org/app/",
"connection": null,
"error": "oauth_callback_failed",
"errorDescription": "..."
}
```
Important behavior:
- app backends should treat this as the preferred same-host or
Yggdrasil-reachable integration path
- app backends should still verify the included Heimdall `accessToken` locally
- the browser only needs to learn attempt completion, not carry the final auth
result
### `POST /v1/apps/{appSlug}/auth-completions/redeem`
Purpose:
- redeem a short-lived one-time browser completion code
- return the trusted Heimdall auth result to the app backend
Current role:
- fallback path for looser integrations, manual testing, or opener-based flows
- not the preferred same-host consumer path anymore
Request body:
```json
{
"completionCode": "<one-time-code>"
}
```
Binding rules:
- A completion minted for a connection target (`connection.targetId` in the signed state) is redeemed only with `targetId` equal to it; absent or different answers `403 completion_target_mismatch` and leaves the code unconsumed.
- A `link` completion is redeemed only by the authenticated app backend (`x-heimdall-app-secret`); otherwise `401 app_auth_required`, code unconsumed. `link` start itself needs that authentication and `initiatingAccountId`; the callback refuses with `403 link_initiator_mismatch` when the provider identity belongs to a different account, and attaches an unlinked identity to the initiator.
Response:
```json
{
"status": "success",
"provider": "discord",
"mode": "sign_in",
"appSlug": "repixelizer",
"account": {
"id": "acct_repixelizer_001",
"displayName": "Meta"
},
"session": {
"accountId": "acct_repixelizer_001",
"sessionId": "uuid",
"appSlug": "repixelizer",
"accessRevision": 1,
"expiresAt": "2026-05-26T12:00:00.000Z"
},
"accessToken": "<signed-jwt>",
"refreshToken": "<signed-refresh-jwt>",
"refresh": {
"expiresAt": "2026-05-26T12:00:00.000Z"
},
"claimSet": {},
"verification": {
"issuer": "https://heimdall.gamecult.org",
"jwksUri": "https://heimdall.gamecult.org/.well-known/jwks.json",
"alg": "EdDSA",
"kid": "ed25519-..."
},
"sharedCapabilities": ["app_access", "queue_submit"],
"hybridCapabilities": []
}
```
Important behavior:
- completion codes are one-time use
- completion codes are short-lived
- app backends should redeem them server-side
- the browser should not keep the final access token in callback URL fragments
### `POST /v1/apps/{appSlug}/sessions/refresh`
Purpose:
- reissue a short-lived app access claim from a longer-lived Heimdall refresh
token
- let app backends recover from an expired local access cookie without sending
the user through provider OAuth again
- keep provider access/refresh custody and entitlement refresh inside Heimdall
Request body for Repixelizer:
```json
{
"refreshToken": "<signed-refresh-jwt>",
"entitlementPolicies": [
{
"kind": "discord_role_access",
"guildId": "gamecult-guild",
"allowedRoleIds": ["role-repixelizer"]
},
{
"kind": "patreon_membership_access",
"requiredTierTitle": "Inner Sanctum"
}
]
}
```
Response:
```json
{
"session": {
"accountId": "acct_repixelizer_001",
"sessionId": "uuid",
"appSlug": "repixelizer",
"accessRevision": 1,
"expiresAt": "2026-05-26T12:00:00.000Z"
},
"accessToken": "<fresh-signed-access-jwt>",
"refreshToken": "<fresh-signed-refresh-jwt>",
"refresh": {
"expiresAt": "2026-05-26T12:00:00.000Z"
},
"sharedCapabilities": ["app_access", "queue_submit"],
"hybridCapabilities": []
}
```
Important behavior:
- the refresh token is signed by Heimdall and scoped to one app session
- the access token remains short-lived; app backends still verify it locally
- if a stored provider access token is expired or near expiry, Heimdall takes
a row lock on the linked identity, re-reads it, uses the stored provider
refresh token and persists any rotation before evaluating entitlements; an
identity whose grant the provider has revoked (no stored access token)
contributes no entitlement
- any app authenticated via `x-heimdall-app-secret` (`resolveAppCaller`) may
send `entitlementPolicies`; Heimdall matches each stored identity to the
policy for that provider. Sending policy without authenticating is
`401 app_auth_required` -- there is no per-slug allow-list for this anymore
- the endpoint is not a provider OAuth start path and should not produce a
provider authorization URL
### `POST /v1/apps/{appSlug}/managed-credentials/resolve`
Purpose:
- let an app backend retrieve the current Heimdall-custodied access token for
a managed provider connection
- keep refresh-token custody inside Heimdall instead of app persistence
Authentication:
- requires `x-heimdall-app-secret`
- the configured secret comes from the credential file named by
`GC_ACCESS_APP_{APP_SLUG}_SHARED_SECRET_FILE`, per app; there is no generic
fallback secret shared across apps
Request body:
```json
{
"accountId": "acct_spotiverse_001",
"provider": "spotify"
}
```
Response:
```json
{
"accountId": "acct_spotiverse_001",
"provider": "spotify",
"providerUserId": "spotify-user-123",
"accessToken": "<provider-access-token>",
"tokenExpiresAt": "2026-06-02T13:00:00.000Z",
"scopes": ["user-read-playback-state", "user-modify-playback-state"]
}
```
Important behavior:
- this endpoint only works for providers listed in the app profile's
`managedConnectionProviders`
- it returns access tokens only; refresh tokens remain in Heimdall custody
- when a stored managed credential is expired or within the refresh window,
Heimdall refreshes it before returning the access token and persists rotated
access/refresh tokens. The refresh runs under a row lock on the linked
identity (`SELECT ... FOR UPDATE`), so a given refresh token is presented to
the provider once across concurrent requests and across two Heimdall
processes sharing one database. Within one process, concurrent refreshes of
one identity share a single attempt and its outcome (success or failure), so
nobody re-presents a token behind a failed holder in that burst. Residual
risk, tracked as follow-up `joystick:follow_up:cross-process-failed-refresh-marker`:
the guard is one presentation per process per burst, so N
Heimdall processes can present one token N times after a transient failure.
The provider call is bounded at 10 seconds
- a provider whose refresh tokens rotate (Joystick) fails closed: a refresh
response without a new refresh token revokes the credential instead of
keeping the old token
- `410 {"error":"managed_credential_revoked","provider":...}`: the provider
rejected the grant (HTTP 400 whose OAuth `error` is exactly `invalid_grant`),
or the identity holds no access token.
The stored tokens are cleared and an audit event `managed_credential_revoked`
is written; the owner must reconnect. `404 managed_credential_not_found`
means no linked identity exists
- `502 {"error":"managed_credential_refresh_failed","provider":...}`: the
refresh failed for any reason that is not a grant verdict (invalid client,
a 401 or 5xx whatever its body says, timeout, a 2xx without a string
`access_token` or carrying an `error`, a `refresh_token` that is present but
not a string, an empty or non-JSON body). Stored tokens are untouched and a
later request may succeed. An empty-string `refresh_token` counts as missing,
so a rotating provider revokes (`rotation_missing`). A token answer without a
usable `expires_in` (absent, non-numeric, or not a positive number or
positive-integer string) stores the expiry as now plus the provider's default
access-token lifetime (3600 seconds), so an expiry is never unknown and the
credential always comes due for refresh
- app-local binding, diagnostics, subscription setup, and runtime behavior stay
app-owned
### Reference local verifier seam
Purpose:
- give app backends a tiny local verification helper instead of encouraging
per-request Heimdall callbacks
Current reference helper:
- `src/verifier.ts`
Current helper contract:
- build the verifier once from Heimdall `issuer`, app slug, and JWKS
- verify Ed25519 signature locally by `kid`
- reject wrong `iss`, wrong `aud`, malformed claim shape, `nbf` violations,
and expired tokens
- accept a small clock-skew window for normal same-host reality
This is a reference seam, not a magical shared middleware product yet.
## Access claim shape
Current claim payload fields:
- `iss`
- `aud`
- `sub`
- `sid`
- `jti`
- `iat`
- `nbf`
- `exp`
- `typ`
- `account_id`
- `access_revision`
- `display_name` optional
- `app.slug`
- `app.profile_version`
- `facts`
- `capabilities`
- `identities`
Important current rule:
- Heimdall only grants fully shared capabilities directly
- hybrid capabilities still require app-local checks after claim verification
- claim facts should prefer app-facing entitlement names over provider-branded
trivia when a provider-specific detail is not actually needed by the
consumer
Examples:
- Repixelizer `app_access` and `queue_submit` are shared
- Repixelizer `job_read_own` still needs local ownership
- StreamPixels `viewer_access` can be shared
- StreamPixels `creator_access` still needs app-local creator membership
## Current gaps
The following are not landed yet:
- admin/grant management surfaces
- GitHub callback runtime
- redacted per-account auth witness export and Odin/CultMesh consumption beyond
the landed boundary/plugin advertisement store
## Private CultNet authentication commands
The live daemon also binds a loopback-only CultNet/RUDP operation service at
`127.0.0.1:4101` by default. Odin may advertise the route, but never proxies or
persists its payloads.
- service: `heimdall.private.commands`
- operations: `heimdall.auth.begin`, `heimdall.auth.complete`,
`heimdall.auth.refresh`, `heimdall.auth.logout`
- request envelope: `heimdall.private_command_envelope.v1`
- begin result: `heimdall.auth_begin_receipt.v1`
- completion result: `heimdall.auth_completion_receipt.v1`
- refresh result: `heimdall.auth_refresh_receipt.v1`
- logout result: `heimdall.auth_logout_receipt.v1`
Requests are accepted only from a configured app runtime and use the existing
per-app shared secret. Expiry, nonce, operation, app, and idempotency identity
are authenticated. MessagePack payloads and sensitive completion results are
AES-256-GCM wrapped with a fresh nonce. A repeated idempotency key returns the
same result only for the exact same authenticated request; content changes are
denied. Completion consumption remains single-use in Heimdall's canonical
store.
Refresh claims are admitted only while their exact persisted session,
app/account custody, access revision, and refresh expiry still match. Logout
uses the refresh claim as custody proof and atomically advances the persisted
access revision while expiring the session. A stale or concurrently superseded
refresh cannot recreate the revoked session.
`gamecult.heimdall.access` exposes pure plugin ABI operations on the same
loopback listener. Those operations describe, validate, or project access UI;
they cannot create attempts, mint claims, or establish an app session.
## Odin/Eve provider advertisement
Purpose:
- advertise Heimdall's redacted provider, plugin, and command-boundary surfaces
for Odin discovery
- name the redacted CultCache witness document schemas and future `.cc` paths
- avoid touching provider secrets, token custody, store migrations, or runtime
mutation paths
Command:
```powershell
pnpm export:provider-advertisement
```
Current output shape:
- `schemaVersion`: `gamecult.eve.provider_advertisement.v1`
- `providerId`: `heimdall`
- `status`: `read_only_witness_planned`
- provider catalog entries for Discord, Patreon, GitHub, Twitch, YouTube, and
Spotify
- app profile projections for Repixelizer, StreamPixels, and Spotiverse
- redacted witness descriptors for accounts, linked identities, grants,
sessions, entitlement snapshots, auth completions, audit events, app profiles,
and managed credential projections
Important behavior:
- this is a projection/export command, not a writer
- advertised witness paths are future CultCache targets, not proof that a live
`.cc` exporter is already running
- exported descriptors forbid raw provider tokens, encrypted token blobs,
completion codes, callback secrets, and app-domain state
## Next implementation move
Deploy and verify the hardened slice in the first real app binding:
- run Discord and Patreon Repixelizer auth with real provider credentials
- confirm direct backend callback delivery to Repixelizer
- confirm Repixelizer verifies the Heimdall access token locally and gates
hosted routes from the adopted session
- configure Repixelizer access policy in Repixelizer's own runtime env:
Discord should accept `KLTST` or the Patreon-synced `Inner Sanctum` role,
and Patreon should accept the currently entitled tier title `Inner Sanctum`