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

20 KiB

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:

{
  "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:

{
  "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:

{
  "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:

{
  "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:

{
  "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:

{
  "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:

{
  "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:

{
  "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:

{
  "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:

{
  "refreshToken": "<signed-refresh-jwt>",
  "entitlementPolicies": [
    {
      "kind": "discord_role_access",
      "guildId": "gamecult-guild",
      "allowedRoleIds": ["role-repixelizer"]
    },
    {
      "kind": "patreon_membership_access",
      "requiredTierTitle": "Inner Sanctum"
    }
  ]
}

Response:

{
  "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:

{
  "accountId": "acct_spotiverse_001",
  "provider": "spotify"
}

Response:

{
  "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:

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