# 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": "", "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__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": "", "refreshToken": "", "refresh": { "expiresAt": "2026-05-26T12:00:00.000Z" } } ``` Success response for browser-completion fallback still includes: ```json { "completion": { "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": "", "refreshToken": "", "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": "" } ``` 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": "", "refreshToken": "", "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": "", "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": "", "refreshToken": "", "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": "", "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`