Launch hop, signed per-flow cookie and one-use claims are merged with main's link-initiator binding and Google OIDC (secrets now derived at launch). A connect start carrying a connection target now needs the app's own secret. Google, link and managed-credential tests go through the launch helper. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
25 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_PATHlets 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
kidchanges
GET /.well-known/heimdall-configuration
Purpose:
- publish discovery metadata for app backends and integration code
Current shape includes:
issuerjwksUriconfigurationUrioauthStartEndpointoauthCallbackEndpointsupportedProvidersapps
GET /v1/apps
Purpose:
- list known app profiles and their capability surfaces
GET /v1/apps/{appSlug}
Purpose:
- return one app profile
Current app slugs:
repixelizerstreampixelsspotiverse
Profile fields:
slugdisplayNameprofileVersionidentityProvidersentitlementSourcesmanagedConnectionProviderscapabilities
Capability modes:
sharedfully evaluated by Heimdall from signed facts/grantshybridrequires 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:
discordpatreongithubtwitchyoutubespotifyjoystickgoogle
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://heimdall.gamecult.org/v1/oauth/discord/launch?state=<signed-jwt>",
"stateExpiresAt": "2026-04-26T12:10:00.000Z"
}
Send the user's browser to authorizationUrl unchanged. It is Heimdall's launch
hop, not the provider's URL.
GET /v1/oauth/{provider}/launch?state=<signed-jwt>
The first thing the user's browser does with a state. Heimdall verifies the
state (signature, issuer, provider, nbf/exp with 30 s of skew), claims the
state's single launch, sets the binding cookie
__Host-heimdall-oauth-<jti> (Path=/, Secure, HttpOnly, SameSite=Lax,
Max-Age until the state expires; the value is a Heimdall-signed binding for
that state) and answers 302 to the provider's authorize URL, with
Cache-Control: no-store and Referrer-Policy: no-referrer. A second launch of
the same state is 400 state_already_launched and sets no cookie. The state is
refused with 400 invalid_state, state_provider_mismatch or state_expired.
The binding cookie needs an https public base URL (or localhost). A browser
must finish the flow in the browser that launched it.
Failure modes:
503 provider_not_configuredwhen the provider client id is missing400 untrusted_backend_callbackwhenhandoff.kind=backend_callbackandcallbackUrlis not in that app'sGC_ACCESS_APP_<SLUG>_BACKEND_CALLBACK_URLS400 return_to_not_allowedwhenreturnTo's origin is not in that app'sallowedReturnOrigins(src/app-profiles.ts) --returnTois where the browser handoff page postMessages the completion payload (src/browser-handoff.ts), so this check runs for every caller, not only an authenticated one401 app_auth_requiredwhen the request body carriesentitlementPolicyand the caller did not authenticate as that app viax-heimdall-app-secret(resolveAppCaller,src/app-caller.ts) -- this applies regardless ofhandoff.kindor app slug; a caller with no policy to attach needs no secret, unless the app's profile setsstartRequiresAppCaller(Bifrost only): then every start for that app, with or without a policy, needs that app's secret. Bifrost binds browser completion to a nonce that rides readable in the signed state'sreturn_to, so only Bifrost may mint a Bifrost state; apps whose browser starts are public by design leave the flag unset401 app_auth_requiredalso whenmodeisconnectand the body carries aconnection(a creator or account target): a connection target is app-backend authority, so only the authenticated app named byappSlugmay start it. No secret, or another app's, is refused before any state is signed.sign_instarts carrying aconnectionare400 connection_not_allowed_in_sign_in
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
Google (OpenID Connect, link-only)
google is an identity provider that never mints an account. It is a second
door into a person who already has one.
- Callback redirect URI (register exactly this in the Google Cloud OAuth
client):
{GC_ACCESS_BASE_URL}/v1/oauth/google/callback, which ishttps://heimdall.gamecult.org/v1/oauth/google/callbackin production. - Scopes
openid email profile. The authorization request carriesstate(signed, Heimdall's), anonce, and a PKCES256challenge, all built at the launch hop, which is where the browser first meets the provider URL. The verifier and the nonce are derived from the state'sjtiand Heimdall's signing key, so nothing is stored between start and callback. - The callback verifies the
id_token(RS256 against Google's published keys;issis Google,audis this client,exp,iat,nonce) and requires asuband a verified email. The account key issub; the email is a display and contact field and never a lookup key. mode: "link"needsAuthorization: Bearer <Heimdall access token>for the same app; the identity attaches to that token's account and to no other.mode: "sign_in"signs in an account thesubis already linked to.- A
subno account holds, arriving withsign_in, is refused and mints nothing. Asublinked to another account is refused and never moved. Both audit asoauth_callback_failedwith error classLinkRefusaland the codeidentity_not_linkedorlink_initiator_mismatch. - Link is two-phase. The callback verifies the identity and records a pending
link (TTL
completionTtlSeconds), answeringstatus: "link_pending"with apendingLinkIdand no session; it writes no linked identity. The app callsPOST /v1/apps/:appSlug/links/confirmwith{ pendingLinkId }and the Heimdall access token of the account that began the link; only that call attaches the identity. A different account, an expired, used or unknown id answers 410; asubanother account holds by then answers 409. A refused confirm does not consume the link. - A sign-in callback evaluates every linked identity of the account with the start-time entitlement policy, so a member returning through Google keeps what Discord or Patreon grant.
- Configure with
GC_ACCESS_PROVIDER_GOOGLE_CLIENT_IDandGC_ACCESS_PROVIDER_GOOGLE_CLIENT_SECRET_FILE. Admitted to thebifrostapp profile only.
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(signature, issuer, provider, expiry), requires the binding cookie the launch hop set, and consumes the state: a second callback is400 state_already_used, a missing or foreign cookie400 state_not_bound, an expired state400 state_expired; the binding is checked before the claim, so a browser without the cookie cannot burn the launching browser's 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_completionor 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
accessTokenlocally - 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.targetIdin the signed state) is redeemed only withtargetIdequal to it; absent or different answers403 completion_target_mismatchand leaves the code unconsumed. - A
linkcompletion is redeemed only by the authenticated app backend (x-heimdall-app-secret); otherwise401 app_auth_required, code unconsumed.linkstart itself needs that authentication andinitiatingAccountId; the callback refuses with403 link_initiator_mismatchwhen 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 sendentitlementPolicies; Heimdall matches each stored identity to the policy for that provider. Sending policy without authenticating is401 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-upjoystick: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 OAutherroris exactlyinvalid_grant), or the identity holds no access token. The stored tokens are cleared and an audit eventmanaged_credential_revokedis written; the owner must reconnect.404 managed_credential_not_foundmeans no linked identity exists502 {"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 stringaccess_tokenor carrying anerror, arefresh_tokenthat is present but not a string, an empty or non-JSON body). Stored tokens are untouched and a later request may succeed. An empty-stringrefresh_tokencounts as missing, so a rotating provider revokes (rotation_missing). A token answer without a usableexpires_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, wrongaud, malformed claim shape,nbfviolations, 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:
issaudsubsidjtiiatnbfexptypaccount_idaccess_revisiondisplay_nameoptionalapp.slugapp.profile_versionfactscapabilitiesidentities
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_accessandqueue_submitare shared - Repixelizer
job_read_ownstill needs local ownership - StreamPixels
viewer_accesscan be shared - StreamPixels
creator_accessstill 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
.ccpaths - 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.v1providerId:heimdallstatus: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
.ccexporter 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
KLTSTor the Patreon-syncedInner Sanctumrole, and Patreon should accept the currently entitled tier titleInner Sanctum