2026-04-26 12:25:11 +01:00
|
|
|
# Heimdall Service Contract
|
|
|
|
|
|
2026-04-26 12:50:07 +01:00
|
|
|
This file describes the first landed Heimdall service slice.
|
2026-04-26 12:25:11 +01:00
|
|
|
|
|
|
|
|
It is the concrete HTTP/JWKS contract that now exists in the repo. It is not a
|
2026-04-26 12:50:07 +01:00
|
|
|
claim that every provider, consumer binding, or production-hardening concern is
|
|
|
|
|
done yet.
|
2026-04-26 12:25:11 +01:00
|
|
|
|
|
|
|
|
## 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",
|
2026-04-26 12:50:07 +01:00
|
|
|
"storageBackend": "memory",
|
2026-04-26 16:23:29 +01:00
|
|
|
"signingKeySource": "configured_file",
|
|
|
|
|
"tokenCustodySource": "configured",
|
2026-04-26 12:25:11 +01:00
|
|
|
"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
|
2026-04-26 16:23:29 +01:00
|
|
|
- `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
|
2026-09-30 12:26:56 +02:00
|
|
|
- if no path is configured, the service still generates an
|
2026-04-26 16:23:29 +01:00
|
|
|
ephemeral dev key at startup and deserves no sympathy when the `kid` changes
|
2026-04-26 12:25:11 +01:00
|
|
|
|
|
|
|
|
### `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`
|
2026-06-03 03:21:31 +01:00
|
|
|
- `spotiverse`
|
2026-04-26 12:25:11 +01:00
|
|
|
|
|
|
|
|
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`
|
2026-06-02 16:25:38 +01:00
|
|
|
- `spotify`
|
2026-10-19 11:15:42 +02:00
|
|
|
- `joystick`
|
2026-04-26 12:25:11 +01:00
|
|
|
|
|
|
|
|
Request body:
|
|
|
|
|
|
|
|
|
|
```json
|
|
|
|
|
{
|
|
|
|
|
"appSlug": "repixelizer",
|
|
|
|
|
"mode": "sign_in",
|
|
|
|
|
"returnTo": "https://repixelizer.gamecult.org/app/",
|
2026-04-26 17:00:57 +01:00
|
|
|
"handoff": {
|
|
|
|
|
"kind": "backend_callback",
|
|
|
|
|
"attemptId": "auth-attempt-123",
|
|
|
|
|
"callbackUrl": "https://repixelizer.gamecult.org/api/auth/heimdall/callback"
|
|
|
|
|
},
|
2026-04-26 12:25:11 +01:00
|
|
|
"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": "<signed-jwt>",
|
|
|
|
|
"stateExpiresAt": "2026-04-26T12:10:00.000Z"
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
2026-09-08 13:33:45 +02:00
|
|
|
Failure modes:
|
2026-04-26 12:25:11 +01:00
|
|
|
|
|
|
|
|
- `503 provider_not_configured` when the provider client id is missing
|
2026-09-08 13:33:45 +02:00
|
|
|
- `400 untrusted_backend_callback` when `handoff.kind=backend_callback` and
|
|
|
|
|
`callbackUrl` is not in that app's `GC_ACCESS_APP_<SLUG>_BACKEND_CALLBACK_URLS`
|
2026-09-08 14:23:04 +02:00
|
|
|
- `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
|
2026-09-08 13:33:45 +02:00
|
|
|
- `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
|
2026-04-26 12:25:11 +01:00
|
|
|
|
2026-04-29 11:51:29 +01:00
|
|
|
Launch note:
|
|
|
|
|
|
2026-04-29 14:39:11 +01:00
|
|
|
- Discord and Patreon are the implemented provider runtimes for Repixelizer
|
2026-10-19 15:49:34 +02:00
|
|
|
- Twitch, YouTube and Joystick have generic OAuth exchange and identity runtimes for
|
2026-04-29 15:00:55 +01:00
|
|
|
StreamPixels viewer auth and managed creator connections
|
|
|
|
|
- GitHub is still catalogued for start URLs, but its callback runtime is not
|
|
|
|
|
implemented yet
|
2026-04-29 14:39:11 +01:00
|
|
|
- Repixelizer launch can configure Discord and Patreon on its provider buttons
|
2026-04-29 11:51:29 +01:00
|
|
|
|
2026-04-26 12:25:11 +01:00
|
|
|
### `GET /v1/oauth/{provider}/callback`
|
|
|
|
|
|
|
|
|
|
Purpose:
|
|
|
|
|
|
|
|
|
|
- validate signed OAuth `state`
|
2026-04-26 15:23:04 +01:00
|
|
|
- complete the provider callback flow
|
|
|
|
|
- hand browser callers back to the app through a one-time completion exchange
|
2026-04-26 12:25:11 +01:00
|
|
|
|
|
|
|
|
Current status:
|
|
|
|
|
|
|
|
|
|
- validates signed `state`
|
|
|
|
|
- returns provider errors cleanly
|
2026-04-26 12:50:07 +01:00
|
|
|
- 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
|
2026-04-26 17:00:57 +01:00
|
|
|
- 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
|
2026-04-29 15:00:55 +01:00
|
|
|
- GitHub still returns "not implemented" at the runtime adapter layer until its
|
|
|
|
|
callback path is added
|
2026-04-26 12:25:11 +01:00
|
|
|
|
2026-04-26 17:00:57 +01:00
|
|
|
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,
|
2026-05-20 13:43:53 +01:00
|
|
|
"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"
|
2026-04-26 17:00:57 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Success response for browser-completion fallback still includes:
|
2026-04-26 15:23:04 +01:00
|
|
|
|
|
|
|
|
```json
|
|
|
|
|
{
|
|
|
|
|
"completion": {
|
|
|
|
|
"code": "<one-time-code>",
|
|
|
|
|
"expiresAt": "2026-04-26T12:05:00.000Z",
|
|
|
|
|
"redeemEndpoint": "https://heimdall.gamecult.org/v1/apps/repixelizer/auth-completions/redeem"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
2026-04-26 17:00:57 +01:00
|
|
|
### 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/",
|
2026-04-29 15:00:55 +01:00
|
|
|
"connection": null,
|
2026-04-26 17:00:57 +01:00
|
|
|
"account": {
|
|
|
|
|
"id": "acct_repixelizer_001",
|
|
|
|
|
"displayName": "Meta"
|
|
|
|
|
},
|
|
|
|
|
"session": {},
|
|
|
|
|
"accessToken": "<signed-jwt>",
|
2026-05-20 13:43:53 +01:00
|
|
|
"refreshToken": "<signed-refresh-jwt>",
|
|
|
|
|
"refresh": {
|
|
|
|
|
"expiresAt": "2026-05-26T12:00:00.000Z"
|
|
|
|
|
},
|
2026-04-26 17:00:57 +01:00
|
|
|
"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/",
|
2026-04-29 15:00:55 +01:00
|
|
|
"connection": null,
|
2026-04-26 17:00:57 +01:00
|
|
|
"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
|
|
|
|
|
|
2026-04-26 15:23:04 +01:00
|
|
|
### `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
|
|
|
|
|
|
2026-04-26 17:00:57 +01:00
|
|
|
Current role:
|
|
|
|
|
|
|
|
|
|
- fallback path for looser integrations, manual testing, or opener-based flows
|
|
|
|
|
- not the preferred same-host consumer path anymore
|
|
|
|
|
|
2026-04-26 15:23:04 +01:00
|
|
|
Request body:
|
|
|
|
|
|
|
|
|
|
```json
|
|
|
|
|
{
|
|
|
|
|
"completionCode": "<one-time-code>"
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
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,
|
2026-05-20 13:43:53 +01:00
|
|
|
"expiresAt": "2026-05-26T12:00:00.000Z"
|
2026-04-26 15:23:04 +01:00
|
|
|
},
|
|
|
|
|
"accessToken": "<signed-jwt>",
|
2026-05-20 13:43:53 +01:00
|
|
|
"refreshToken": "<signed-refresh-jwt>",
|
|
|
|
|
"refresh": {
|
|
|
|
|
"expiresAt": "2026-05-26T12:00:00.000Z"
|
|
|
|
|
},
|
2026-04-26 15:23:04 +01:00
|
|
|
"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
|
|
|
|
|
|
2026-05-20 13:43:53 +01:00
|
|
|
### `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": "<signed-refresh-jwt>",
|
|
|
|
|
"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": "<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
|
2026-10-19 11:15:42 +02:00
|
|
|
- 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
|
2026-09-08 13:33:45 +02:00
|
|
|
- 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
|
2026-05-20 13:43:53 +01:00
|
|
|
- the endpoint is not a provider OAuth start path and should not produce a
|
|
|
|
|
provider authorization URL
|
|
|
|
|
|
2026-04-29 15:00:55 +01:00
|
|
|
### `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`
|
2026-09-30 14:40:21 +02:00
|
|
|
- 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
|
2026-04-29 15:00:55 +01:00
|
|
|
|
|
|
|
|
Request body:
|
|
|
|
|
|
|
|
|
|
```json
|
|
|
|
|
{
|
2026-06-02 16:25:38 +01:00
|
|
|
"accountId": "acct_spotiverse_001",
|
|
|
|
|
"provider": "spotify"
|
2026-04-29 15:00:55 +01:00
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Response:
|
|
|
|
|
|
|
|
|
|
```json
|
|
|
|
|
{
|
2026-06-02 16:25:38 +01:00
|
|
|
"accountId": "acct_spotiverse_001",
|
|
|
|
|
"provider": "spotify",
|
|
|
|
|
"providerUserId": "spotify-user-123",
|
2026-04-29 15:00:55 +01:00
|
|
|
"accessToken": "<provider-access-token>",
|
2026-06-02 16:25:38 +01:00
|
|
|
"tokenExpiresAt": "2026-06-02T13:00:00.000Z",
|
|
|
|
|
"scopes": ["user-read-playback-state", "user-modify-playback-state"]
|
2026-04-29 15:00:55 +01:00
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
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
|
2026-04-29 17:24:44 +01:00
|
|
|
- when a stored managed credential is expired or within the refresh window,
|
|
|
|
|
Heimdall refreshes it before returning the access token and persists rotated
|
2026-10-19 11:15:42 +02:00
|
|
|
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
|
2026-10-19 12:34:39 +02:00
|
|
|
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
|
2026-10-19 13:24:21 +02:00
|
|
|
risk, tracked as follow-up `joystick:follow_up:cross-process-failed-refresh-marker`:
|
|
|
|
|
the guard is one presentation per process per burst, so N
|
2026-10-19 12:34:39 +02:00
|
|
|
Heimdall processes can present one token N times after a transient failure.
|
|
|
|
|
The provider call is bounded at 10 seconds
|
2026-10-19 11:15:42 +02:00
|
|
|
- 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
|
2026-10-19 12:34:17 +02:00
|
|
|
rejected the grant (HTTP 400 whose OAuth `error` is exactly `invalid_grant`),
|
|
|
|
|
or the identity holds no access token.
|
2026-10-19 11:15:42 +02:00
|
|
|
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,
|
2026-10-19 12:34:17 +02:00
|
|
|
a 401 or 5xx whatever its body says, timeout, a 2xx without a string
|
2026-10-19 13:24:21 +02:00
|
|
|
`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
|
2026-04-29 15:00:55 +01:00
|
|
|
- app-local binding, diagnostics, subscription setup, and runtime behavior stay
|
|
|
|
|
app-owned
|
|
|
|
|
|
2026-04-26 16:23:29 +01:00
|
|
|
### 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.
|
|
|
|
|
|
2026-04-26 12:25:11 +01:00
|
|
|
## 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
|
2026-04-26 14:58:58 +01:00
|
|
|
- claim facts should prefer app-facing entitlement names over provider-branded
|
|
|
|
|
trivia when a provider-specific detail is not actually needed by the
|
|
|
|
|
consumer
|
2026-04-26 12:25:11 +01:00
|
|
|
|
|
|
|
|
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:
|
|
|
|
|
|
2026-04-26 12:50:07 +01:00
|
|
|
- admin/grant management surfaces
|
2026-04-29 15:00:55 +01:00
|
|
|
- GitHub callback runtime
|
2026-08-22 00:44:57 +02:00
|
|
|
- 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`
|
2026-08-22 01:53:21 +02:00
|
|
|
- operations: `heimdall.auth.begin`, `heimdall.auth.complete`,
|
|
|
|
|
`heimdall.auth.refresh`, `heimdall.auth.logout`
|
2026-08-22 00:44:57 +02:00
|
|
|
- request envelope: `heimdall.private_command_envelope.v1`
|
|
|
|
|
- begin result: `heimdall.auth_begin_receipt.v1`
|
|
|
|
|
- completion result: `heimdall.auth_completion_receipt.v1`
|
2026-08-22 01:53:21 +02:00
|
|
|
- refresh result: `heimdall.auth_refresh_receipt.v1`
|
|
|
|
|
- logout result: `heimdall.auth_logout_receipt.v1`
|
2026-08-22 00:44:57 +02:00
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
2026-08-22 01:53:21 +02:00
|
|
|
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.
|
|
|
|
|
|
2026-08-22 00:44:57 +02:00
|
|
|
`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.
|
2026-06-03 03:21:31 +01:00
|
|
|
|
|
|
|
|
## Odin/Eve provider advertisement
|
|
|
|
|
|
|
|
|
|
Purpose:
|
|
|
|
|
|
2026-08-22 00:44:57 +02:00
|
|
|
- advertise Heimdall's redacted provider, plugin, and command-boundary surfaces
|
|
|
|
|
for Odin discovery
|
2026-06-03 03:21:31 +01:00
|
|
|
- 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
|
2026-04-26 12:25:11 +01:00
|
|
|
|
|
|
|
|
## Next implementation move
|
|
|
|
|
|
2026-04-29 11:51:29 +01:00
|
|
|
Deploy and verify the hardened slice in the first real app binding:
|
2026-04-26 12:25:11 +01:00
|
|
|
|
2026-04-29 14:39:11 +01:00
|
|
|
- run Discord and Patreon Repixelizer auth with real provider credentials
|
2026-04-29 11:51:29 +01:00
|
|
|
- confirm direct backend callback delivery to Repixelizer
|
|
|
|
|
- confirm Repixelizer verifies the Heimdall access token locally and gates
|
|
|
|
|
hosted routes from the adopted session
|
2026-04-29 14:39:11 +01:00
|
|
|
- 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`
|