Soul S4: the Bifrost handoff notes, the Bifrost profile and the service contract still named plaintext secret variables; each now names the _FILE credential path. .env.example and the Spotiverse profile taught GC_ACCESS_APP_SPOTIVERSE_SHARED_SECRET_FILE, which nothing reads: spotiverse is not in appSlugs. Both lines are removed rather than adding the slug. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
17 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:
discordpatreongithubtwitchyoutubespotify
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_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
Launch note:
- Discord and Patreon are the implemented provider runtimes for Repixelizer
- Twitch and YouTube now 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_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>"
}
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 uses the stored provider refresh token and persists any rotation before evaluating entitlements
- 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
- 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