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

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

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