Heimdall/docs/service-contract.md
Meta Vens 778eabaedc Docs: map managed credential custody, the callback mint and the open follow-ups
architecture.md gains a Managed credential custody section: the single
refresh owner, the store-port lock, per-process single flight, typed
revoked and refresh-failed answers, the provider descriptor token fields
and the one token request, the mode x role gates, custody-only
completions, verification layer and follow-ups by id. The spotify finding
now matches the removed spiverse profile; JOYSTICK joins the provider env
lists. implementation-plan and service-contract lose two stale lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:49:34 +02:00

669 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>"
}
```
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`