Heimdall/docs/architecture.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

783 lines
32 KiB
Markdown

# Heimdall Architecture
## What this file is
This file is the shared architecture note for Heimdall, GameCult's auth
authority service for hosted experiments.
Its basic promise is:
- let people sign in through app-relevant providers such as Discord, Patreon,
GitHub, Twitch, or YouTube
- decide access from provider entitlements, local grants, and per-app policy
- issue signed local claims that hosted apps can verify without trusting raw
provider tokens
Implementation and live-release detail belongs in the handoff and evidence
ledger; this file owns the authority model.
## Problem
If every experiment rebuilds:
- provider OAuth
- identity linking
- entitlement refresh
- session cookies
- grant handling
- provider-specific gate logic
then we get five near-identical auth pits and deserve what happens next.
The reusable move is to build one Heimdall authority service and let each app
bind its own policy and runtime seams onto it.
## Design goals
- one reusable local account/session model across hosted experiments
- provider login and linking owned by one shared authority
- per-app capability policies instead of hard-coded one-off gate checks
- host runtimes consume signed local claims, not raw provider tokens
- routine route auth stays local to the host app after claim verification
- keep app-domain data separate unless there is a deliberate reason to share it
- fit the current GameCult topology: Heimdall on Yggdrasil behind nginx, with
hosted apps either on the same host or verifying over normal HTTPS/JWKS
## Non-goals
- becoming a general public identity provider
- building multi-tenant SaaS auth machinery
- inventing global billing analytics because we got bored
- forcing app-domain data into a shared auth store
- requiring every guarded route in every app to call Heimdall synchronously
## Core concepts
- `Account`
one GameCult-local person record independent of external providers
- `LinkedIdentity`
one external provider identity attached to exactly one local account
- `EntitlementSnapshot`
cached result from an entitlement source saying whether some claim is
currently true
- `CapabilityGrant`
a reusable local grant such as `global_member`, `app_access`, or
`admin_access`, scoped either globally or to one app
- `AppAccessProfile`
the per-app binding that names capabilities, relevant identity providers,
entitlement sources, and policy rules
- `Session`
signed local app/browser session; the browser gets this, not provider tokens
- `AccessClaim`
signed app-facing claim set derived from the account, grants, and app policy
- `AuditEvent`
durable record for login, link, unlink, refresh, denial, and admin overrides
- `AuthAttempt`
Heimdall-owned, expiring OAuth lifecycle record addressed by an opaque handle;
browsers may carry the handle but cannot complete or redeem it themselves
## Eve access plugin and private command plane
`gamecult.heimdall.access` is Heimdall's reusable Eve representation boundary.
It projects access gates, identity summaries, and access status, and declares
the `auth.begin`, `auth.complete`, and `auth.logout` capabilities. Its
executable `describe`, `validate`, and `project` ABI cannot grant access or
mutate provider state.
App backends call `heimdall.auth.begin` and `heimdall.auth.complete` over the
loopback CultNet operation service at `127.0.0.1:4101`. Every private request is
bound to the configured app secret, source runtime, operation, expiry, nonce,
and idempotency key. The MessagePack payload is AES-256-GCM encrypted; the full
envelope is HMAC authenticated. Completion claims are returned only inside the
same encrypted app-bound envelope.
The browser adapter stores one opaque attempt handle and follows only HTTPS
navigation receipts whose origin appears in both the plugin advertisement and
the validated receipt. It never receives access or refresh claims. The host app
redeems the handle, verifies the Heimdall claim, and owns its local session.
Heimdall publishes four redacted discovery documents to Odin: provider
advertisement, command boundary, plugin advertisement, and transport profile.
Their record keys are globally unique within Odin's catalog. Odin persists and
returns these documents but does not proxy private auth commands. Consumers
resolve the route through Odin, validate the advertised loopback/HMAC/AES
contract, and talk directly to Heimdall. Claims, completions, provider tokens,
app secrets, and account state never enter Odin or public CultMesh.
## Provider taxonomy
Heimdall should classify outside systems by what role they play, not by whether
their brand name appears somewhere in an app.
- `Identity provider`
proves who the user is and can supply one `LinkedIdentity`
- `Entitlement source`
answers whether some access-relevant claim is currently true
- `Managed provider credential`
a user- or account-authorized third-party credential whose OAuth dance, token
custody, refresh, and revocation Heimdall can own on behalf of an app
- `Local grant source`
records app or operator-issued overrides inside Heimdall
Default rule:
- if a future app needs user-facing or account-linked OAuth, Heimdall should
own the ugly parts by default
- the app should only need to describe what the resulting connection means in
its own world
One provider can play different roles in different apps:
- Repixelizer can treat Discord and Patreon as both identity and entitlement
sources
- Bifrost can treat GitHub as identity proof while keeping membership approval,
roles, and governance state app-local
- StreamPixels can reuse shared viewer identity patterns and even shared
OAuth/token custody for creator-side Twitch/YouTube connections while keeping
creator bindings, subscriptions, diagnostics, and runtime plumbing
StreamPixels-owned
## System shape
```mermaid
flowchart TD
A["Browser / app web UI"] --> B["Hosted experiment web surface"]
B -->|start auth / link| C["Heimdall"]
C --> D["Shared identity providers"]
C --> E["Shared entitlement sources"]
C --> F["Local auth/control-plane store"]
C --> G["Signed sessions / claims"]
B -->|verify claims locally| H["Hosted experiment runtime"]
```
The important split:
- Heimdall owns provider OAuth, identity linking, entitlement refresh, signed
sessions, claims, grants, audit surfaces, and managed provider credential
custody
- the host experiment owns its workload, queue, editor, render path, viewer
profile model, creator model, or other product-specific machinery
- the host experiment also owns which local entity a connection attaches to,
what permissions it implies, and what runtime behavior consumes it
Do not smear provider logic directly through every host runtime.
## Backbone reuse is not data merging
Using the same auth backbone does **not** mean all hosted experiments should
pour their user data into one communal bucket.
The reusable thing is:
- provider OAuth plumbing
- signed session and claim mechanics
- linked-identity primitives
- entitlement refresh
- capability evaluation
- local grant and audit surfaces
The non-reusable-by-default thing is:
- app-domain user data
- creator data
- audience data
- queue/job payloads
- inventories, scenes, outputs, or other product state
Recommended default:
- shared auth/access **authority**
- separate app databases or at least separate app-owned schemas/tables for
domain data
- if a shared Heimdall store exists at all, keep it limited to auth/control
plane truth such as identities, grants, entitlement snapshots, sessions,
claims metadata, and audit events
Do not let "same auth backbone" quietly mutate into "same user-data swamp."
## Reusable data model
Suggested durable core:
- `accounts`
- `id`
- `created_at`
- `last_seen_at`
- `display_name`
- `primary_email` nullable
- `linked_identities`
- `id`
- `account_id`
- `provider`
- `provider_user_id`
- `username`
- `access_token_encrypted`
- `refresh_token_encrypted`
- `token_expires_at`
- `profile_json`
- unique on `(provider, provider_user_id)`
- `entitlement_snapshots`
- `id`
- `account_id`
- `provider`
- `scope`
- `evaluated_at`
- `is_allowed`
- `reason_code`
- `reason_detail`
- `raw_summary_json`
- `capability_grants`
- `id`
- `account_id`
- `scope_type` enum: `global`, `app`
- `scope_id` nullable app slug
- `capability`
- `source` enum: `manual`, `invite`, `migration`, `operator`
- `status`
- `expires_at` nullable
- `note`
- `sessions`
- `id`
- `account_id`
- `app_slug`
- `created_at`
- `last_seen_at`
- `expires_at`
- `claims_json`
- `access_revision`
- `audit_events`
- `id`
- `account_id` nullable
- `session_id` nullable
- `app_slug` nullable
- `event_type`
- `event_payload_json`
- `created_at`
Important invariants:
- one provider identity belongs to one local account only
- provider tokens stay server-side only
- session cookies represent local sessions, not provider trust directly
- app-specific capability checks read signed local claims, not upstream
providers on every route
## Provider adapters
Shared adapters should be boring and reusable:
- `DiscordIdentityProvider`
- login via Discord OAuth
- `PatreonIdentityProvider`
- login via Patreon OAuth
- `GitHubIdentityProvider`
- login via GitHub OAuth for apps that use GitHub identity as the local
person-key
- `TwitchIdentityProvider`
- optional shared login identity when an app wants Twitch-backed sign-in
- `YouTubeIdentityProvider`
- optional shared login identity when an app wants YouTube-backed sign-in
- `DiscordRoleEntitlementProvider`
- entitlement check via guild membership and role ids, ideally through the
GameCult bot token server-side
- `PatreonMembershipEntitlementProvider`
- entitlement check via campaign membership and tier ids
- `ManualGrantProvider`
- local override for staff, migrations, experiments, or emergency access
- `InviteGrantProvider`
- reusable invite-issued app or role grants where that pattern makes sense
Shared connection handling can also cover app integrations when the credential
is user- or account-authorized and worth centralizing:
- Heimdall can own OAuth start/callback, token exchange, encrypted storage,
refresh, and revocation
- the app still owns binding the connection to a local entity such as
`creator_id`, `workspace_id`, `project_id`, or `channel_id`
- the app still owns sync jobs, subscriptions, diagnostics, feature toggles,
and the domain-specific consequences of that connection
Important rule:
- a provider being used anywhere in an app does not automatically make all uses
of that provider Heimdall-owned
- provider OAuth and token custody should default to Heimdall when the
connection is tied to a user or account rather than a purely app-local secret
- app-specific binding semantics and runtime behavior still stay app-owned
## Managed credential custody
Source-grounded as of branch `eureka/joystick-h1` at 6db84bf (PR
GameCult/Heimdall#1, unmerged). Nothing here is deployed.
### One owner: `refreshManagedCredential`
`src/managed-credential.ts` owns every decision to present a stored refresh
token to a provider. The entitlement path (`refreshAppSession`), the Bifrost
Patreon sync and `managed-credentials/resolve` all call it; none of them reads
`tokenExpiresAt` or a provider token endpoint itself. The earlier
`refreshLinkedIdentityCredentialIfNeeded` is deleted, and callers no longer
write refreshed tokens through `upsertLinkedIdentity`: a refresh reaches the row
only through the store port below. The only other writer of credential columns
is the OAuth callback's grant write (`app.ts`, once).
| | |
|---|---|
| Input | a `StoredLinkedIdentity` snapshot, the config, the token custody (AES-256-GCM seal), the provider runtimes, an injectable clock and timeout |
| Output | `{kind: "current" or "revoked", identity}`, or a thrown `ManagedCredentialRefreshFailed` (carries the provider and nothing the provider said) |
| Forbidden writers | any caller writing token columns or `token_expires_at` outside `updateLinkedIdentityCredential` and the callback's grant write; any caller computing "is a refresh due" |
The snapshot is a hint. It may answer without a lock when the identity holds no
access token (`revoked`) or no refresh is due (`current`; due means a known
expiry within 5 minutes, a stored refresh token, and a runtime that refreshes).
It never decides that a refresh happens.
### Three nested guards
1. **Single flight, per process.** `refreshesInFlight` is keyed by store, then
linked identity id. A caller arriving while an attempt is in flight joins it
and receives its outcome, success or failure, so nobody queues behind a
failed holder and re-presents the same token. The entry ends with the
attempt; a later request retries.
2. **Store-port lock, across processes.**
`HeimdallStore.updateLinkedIdentityCredential(id, decide)` runs `decide`
against the row as read under the lock. `PostgresStore` takes
`SELECT ... FOR UPDATE` inside a transaction; `InMemoryStore` serialises per
id on a promise chain that deletes its entry only when it is still the tail.
`decide` re-checks `dueForRefresh` on the locked row, so a process that
waited finds the token already rotated and returns `keep`. The provider call
is aborted at 10 seconds (`MANAGED_CREDENTIAL_REFRESH_TIMEOUT_MS`), which
bounds how long any waiter holds a pooled connection.
3. **Decision, not mutation.** `decide` returns a `CredentialDecision`: `keep`,
`revoke`, or `rotate` with the access token, refresh token and
`tokenExpiresAt` all required. The store applies it and returns the row. The
new pair is persisted before the access token is returned. A store that
keeps the old expiry on rotate is a type error, not a test.
### Typed answers
- **Revoked** (`410 managed_credential_revoked`, audit
`managed_credential_revoked {provider, reason}`, all three token columns
cleared). Two reasons only: `provider_revoked`, an HTTP 400 whose OAuth
`error` is exactly `invalid_grant`; and `rotation_missing`, a well-formed
refresh answer without a refresh token from a provider whose descriptor says
`rotatesRefreshTokens`. An identity with no access token is also `revoked`.
`404` means no linked identity exists. On the entitlement path a revoked
identity is skipped; on the Bifrost sync it is `404
patreon_identity_not_linked` with Bifrost uncalled.
- **Refresh failed** (`502 managed_credential_refresh_failed`, tokens
untouched, retry allowed): invalid client, a 401 or 5xx whatever its body,
timeout, a 2xx without a string `access_token` or carrying `error`, a
`refresh_token` present but not a string, an empty or non-JSON body.
`ProviderResponseError.oauthError` is drawn only from the closed RFC 6749 code
set and is read only from a 400; no provider body reaches a message, stack or
audit payload.
- An empty-string `refresh_token` counts as missing.
### Provider descriptor and the one token request
`providerCatalog` (`src/providers.ts`) is the only place a provider's token
behaviour is written down. Fields beyond the authorize side:
- `tokenEndpoint`: where both grants go.
- `tokenEndpointAuthMethod`: `client_secret_basic` or `client_secret_post`
(RFC 7591 names); exactly one client-auth method per request.
- `rotatesRefreshTokens`: true only for Joystick. Drives fail-closed on
refresh (`rotation_missing`) and a failed callback on exchange when the grant
carries no refresh token.
- `defaultAccessTokenLifetimeSeconds`: 3600 for all seven providers. Applied
when an answer has no usable `expires_in`; unusable means absent, non-numeric,
not a positive number or positive-integer string, or above 365 days. An
unknown expiry is therefore never stored, so every credential comes due.
`requestProviderTokens(provider, config, grant)` in `src/oauth.ts` replaces the
ten per-provider exchange and refresh functions. Authorization-code and refresh
grants share it, it returns an `ExpiringTokenSet` (`expiresAt` required), and it
rejects a malformed 2xx typed as `provider_body_not_token_set` on both grants.
The per-provider runtimes keep only identity resolution.
### Mode x role at the gates
`src/app-profiles.ts` holds both questions, and the profile's role lists are
their only input:
- `admitsProviderForMode(profile, provider, mode)`, the start gate: `sign_in`
and `link` take `identityProviders` only; `connect` takes
`managedConnectionProviders` or `identityProviders` (Bifrost connects its
identity providers). `supportsProvider` is deleted.
- `callbackMint(profile, provider, mode, connection)`, decided at the callback
from the signed state and asked again at start, so an unbound connect never
reaches a redirect:
- `viewer_session`: the provider is in `identityProviders`; a normal session
with the profile's capabilities.
- `custody_only`: a `connect` for a provider that is only a managed
connection (Joystick on StreamPixels) with `connection.kind = "creator"` and
a non-empty `targetId`.
- `refused`: everything else, before the code exchange.
A custody-only completion stores the credential and issues a claim with no
facts, no capabilities and no hybrid capabilities (`withoutCapabilities` in
`src/claims.ts`, the one owner of a capability-free claim). The completion
payload and the `backend_callback` handoff carry no `refreshToken` or
`refresh`, because the refresh route recomputes facts from the account and would
turn that session into a viewer session. A session row still exists, and
`BackendHandoffSuccessPayload` types both fields optional for this reason. The
consumer's redeem reads only account id, provider, mode, connection and claim
identities, so the claim form is unchanged for it.
### Verification layer
The rules above are pinned by `tests/managed-credential.test.ts` on three rigs
(memory, Postgres with one app, Postgres with two pools),
`tests/provider-token-request.test.ts`, `tests/app.test.ts` (a matrix over all
profiles x providers x modes, callback mint, handoff strip) and
`tests/recipe-secrets.test.ts`. Evidence is the Yggdrasil `ygg-verify` run on
Linux (node:24, postgres:16, `HEIMDALL_REQUIRE_LIVE_PG=1`; 600 passed, 8
skipped, none a live-Postgres suite), hand and StrykerJS mutants over the diff,
and Soul's pass 4 verdict `joystick:verdict:cut-h1.s4`. The deploy path's own
builder was not used.
### Open follow-ups
- `joystick:follow_up:cross-process-failed-refresh-marker`: single flight is per
process, so N Heimdall processes can present one token N times after a
transient failure. Needs a failed-attempt marker on the row, read under the
lock. Must land before or with Idunn onboarding.
- `joystick:follow_up:connect-start-needs-app-auth`: connect starts are
unauthenticated, so anyone can name `{creator, targetId}`; the callback
re-check proves a binding exists, not that the caller owns it. It also owns
the whitespace-only `targetId` that passes both gates and the callback's
fail-open `''` fallback for a vanished profile (finding
`joystick:finding:cut-h1.s4.whitespace-targetid-passes-custody-gate`). Must
land before the public Joystick bot or a second creator.
- `joystick:follow_up:heimdall-idunn-onboarding` (gamecult-ops): live Heimdall
is a hand-copied release under `heimdall.service`, not an Idunn-admitted
generation; the recipe's Joystick lines are declared but unproven by a
deploy.
- `joystick:follow_up:joystick-viewer-signin-public-bot`: Joystick stays out of
StreamPixels `identityProviders` until the public bot is registered; viewer
sign-in must not rewrite a creator's credential row.
## Policy model
The shared authority should not hard-code one app's access rules. It should
evaluate capabilities against an app profile.
Suggested app-profile shape:
```text
AppAccessProfile {
appSlug: string
displayName: string
capabilities: string[]
policyRules: CapabilityRule[]
publicRoutes: string[]
explanationStrings: map[string]string
}
```
Example rule style:
```text
app_access = entitlement.app_access || grant.global_member || grant.app_access
queue_submit = app_access
admin_access = grant.operator || grant.admin_access
```
The important part is not naming a provider in every rule. It is that app
profiles choose which identity facts, app-facing entitlement facts, snapshots,
and local grants feed each capability.
Preferred fact-vocabulary rule:
- signed claim facts should prefer app-facing names such as
`entitlement.app_access`
- provider-specific evidence such as Discord role ids or Patreon tiers should
usually stay in entitlement snapshots and linked-identity metadata unless a
consumer truly needs that exact upstream detail in the claim
## Request path discipline
This is where shared authority stays useful instead of becoming a nuisance.
Heimdall should be in the loop for:
- OAuth start and callback flows
- explicit identity linking and unlinking
- session issuance and refresh
- entitlement refresh
- grant administration
- occasional explicit introspection or revocation checks
Host apps should stay in the loop for:
- routine route authorization after claim verification
- resource ownership checks
- domain-specific permission decisions that depend on app-local data
Normal guarded routes should **not** need to round-trip to Heimdall every time.
## Auth and linking flow
The generic flow should be the same across hosted experiments:
1. user starts OAuth from the host app UI
2. host app redirects to Heimdall or calls Heimdall to begin the flow
3. Heimdall signs OAuth `state`
4. provider callback resolves the provider identity through Heimdall
5. Heimdall finds or creates the local account
6. if the user was already signed in locally, the identity may link onto the
existing account instead of creating a new one
7. Heimdall refreshes entitlements and capability claims
8. Heimdall issues or updates the local session / claim artifacts consumed by
the host app
The browser should never be the custodian of provider tokens.
Preferred same-host or Yggdrasil-reachable handoff for hosted apps:
1. the app keeps its main page open
2. auth starts in a script-opened new browser context
3. Heimdall callback completes the provider flow server-side
4. Heimdall delivers the auth result directly to the host app backend over an
internal backend channel
5. the host app backend establishes the app's local trusted auth state
6. the callback page only needs to signal completion status, try to close
itself, and offer a dead-simple return path
Recommended browser role in that flow:
- the main app page tracks its own auth attempt locally
- the main app page learns completion from its own backend by polling, SSE, or
websocket
- the browser should not be the primary carrier of the final auth result
Fallback flow for looser integrations:
- Heimdall can still mint a short-lived one-time completion artifact
- the callback page can `postMessage` that artifact to the opener
- the host app backend can redeem it with Heimdall
- keep this as the portable escape hatch, not the preferred same-host path
This keeps the real auth handoff on the server side while still giving the
user the familiar "log in over there, keep working over here" flow.
## App integration contract
Every hosted experiment should integrate through the same seams:
- define an `app_slug`
- define the app capability profile
- verify Heimdall-issued claims locally
- mark which routes are public, authenticated, or capability-gated
- attach `account_id`, `session_id`, and `access_revision` to owned resources
such as jobs, drafts, uploads, or queue entries
- if the app uses Heimdall-managed provider connections, bind those connections
onto app-local entities and keep the downstream runtime behavior local
- require resource-owner checks on private read/update/delete paths
If an app has a queue or job system:
- queue entries belong to local account/session ids, not provider ids
- entitlement refresh should block new submissions after access loss
- active-job cancellation policy is app-specific, but the recommended first cut
is still "let the active job finish, block new work"
## Deployment topology
Current intended first deployment shape:
- Heimdall runs on `yggdrasil.gamecult.org`
- Heimdall binds a localhost port behind nginx
- public auth hostname is expected to become `heimdall.gamecult.org`
- same-host app stacks such as StreamPixels and Repixelizer call Heimdall over
localhost or normal private routing when they need auth-authority work done
- app backends verify signed claims locally for routine guarded requests
This matches the host pattern already used by other GameCult app workloads.
## Public surface boundary
**Status: proposed, 2026-09-06. Not implemented.**
The topology above already says what should be true — same-host apps reach
Heimdall over localhost or private routing, and app backends verify claims
locally. The nginx vhost never implemented it. `heimdall.gamecult.org.conf` is a
single `location / { proxy_pass http://heimdall_app; }`, so every route Fastify
registers is on the open web. The boundary exists in this document and nowhere
in the running system.
### What actually has to be public
Only the browser-driven OAuth flow:
| Path | Why it must be public |
|---|---|
| `/v1/oauth/{provider}/start` | the user's browser goes here |
| `/v1/oauth/{provider}/callback` | the provider redirects here; one-shot and not retryable |
| `/.well-known/jwks.json` | *nothing external consumes it today* — see below |
Everything else is internal by the design already written down:
| Path | Consumer |
|---|---|
| `/.well-known/heimdall-configuration` | GC apps, mesh-reachable |
| `/v1/apps` | GC apps, mesh-reachable |
| `/healthz` | local probes |
The private command plane is not part of this question. It is
`startCultNetOperationServer` and never touches nginx.
### JWKS: keep it public, deliberately
No OAuth provider fetches it — every adapter in `src/oauth.ts` authenticates
with `client_secret`, not `private_key_jwt`. Every relying party today is
GameCult-owned and mesh-reachable, so nothing outside needs it.
Keep it public anyway. Publishing verification keys is cryptographically
harmless, and the failure mode of hiding it is asymmetric: the first relying
party hosted outside this mesh will be integrated with a standard OIDC library,
and a `jwks_uri` that resolves only on the mesh fails confusingly at integration
time rather than obviously at design time. This is the one endpoint where the
cost of being wrong is higher in the closed direction.
### Discovery and `/v1/apps`: move them internal
These carry no keys and no cryptographic obligation, and they publish a product
inventory. For every app, `serializeAppProfile` emits `slug`, `displayName`,
`profileVersion`, `identityProviders`, `entitlementSources`,
`managedConnectionProviders` and `capabilities`, alongside `supportedProviders`
and their roles. Since the private command plane's record kinds are
`discord_role_access` and `patreon_membership_access`, the entitlement and
monetization model is legible to anyone who fetches it.
Both routes must move together. Trimming the discovery document alone leaves
`/v1/apps` serving the same list.
### Consequence for the nginx vhost
The vhost stops being one `location /` and becomes an explicit allowlist:
`/v1/oauth/`, `/.well-known/jwks.json`, and nothing else by default. A path
added to Fastify should not become public by having been added — that is the
property the current catch-all lacks.
The vhost stays operator-owned and static regardless of who deploys Heimdall,
because `publicBaseUrl` builds the OAuth callback URL that is **registered with
each provider**. That URL cannot move on deploy.
### Finding: the `spotify` provider outlived its consumer
`spotiverse` was the only profile naming `spotify`, and Spotiverse was lost with
the `E:` drive. The profile has since been removed from the registry
(`docs/app-profiles/spotiverse.md` is the restore spec), and
`admitsProviderForMode` refuses a provider no profile lists, so
`/v1/oauth/spotify/start` no longer reaches a handshake.
What survives: the `spotify` catalog entry and runtime, and, wherever an
operator env still holds them, `GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID` and
`GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET_FILE`. A live credential kept for a
consumer that does not exist is the shape that becomes an incident later.
Retire the provider config, or record why Spotiverse is expected back.
## Deployment modes
### Mode 1: Same-host shared access service
Recommended first cut.
- one Heimdall service on Yggdrasil
- each hosted experiment keeps its own runtime and route integration
- each app keeps its own domain-data store
- apps verify signed claims locally after issuance
Why this is the right first cut:
- fits the current host topology
- avoids cross-runtime shared-library nonsense
- keeps OAuth and provider sludge in one place
- keeps routine guarded requests local inside the app
### Mode 2: Multi-host shared access service
Use this when multiple app stacks across hosts still want one shared auth
authority, but the same claim-verification discipline continues.
### Mode 3: Wider cross-app session platform
Only do this when independently deployed apps genuinely need:
- shared cross-app sessions
- central grant administration
- one callback origin for all providers
- enough auth churn that Heimdall is clearly a long-lived platform surface
Do not build this first because it sounds important.
Even in this mode, Heimdall should own auth/control-plane data, not absorb
every app's audience or product data.
## Configuration split
Heimdall reads plain settings from its environment and every secret from a
file. A secret input `NAME` is configured as `NAME_FILE`, the absolute path of
a file holding only that value; under Idunn the binding binds it in
`[workload.secret_files]` to a `root:root 0400` credential. A blank file is
refused at startup, and so is setting both `NAME` and `NAME_FILE`.
Plain settings:
- `GC_ACCESS_BASE_URL=https://heimdall.gamecult.org`
- `GC_ACCESS_ISSUER=https://heimdall.gamecult.org`
- `GC_ACCESS_STORAGE_BACKEND=postgres`
- `GC_ACCESS_SIGNING_PRIVATE_KEY_PATH=/etc/gamecult/heimdall/credentials/signing-key.pem`
- `GC_ACCESS_SIGNING_PRIVATE_KEY_BOOTSTRAP=1`
- `GC_ACCESS_SIGNING_KEY_ID=...`
- `GC_ACCESS_SESSION_TTL_SECONDS=3600`
- `GC_ACCESS_STATE_TTL_SECONDS=600`
- `GC_ACCESS_PROVIDER_{DISCORD,PATREON,GITHUB,TWITCH,YOUTUBE,SPOTIFY,JOYSTICK}_CLIENT_ID=...`
- `GC_ACCESS_APP_{APP_SLUG}_BACKEND_CALLBACK_URLS=...`
Secret files:
- `GC_ACCESS_DATABASE_URL_FILE=/etc/gamecult/heimdall/credentials/GC_ACCESS_DATABASE_URL`
- `GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE=...`
- `GC_ACCESS_PROVIDER_{DISCORD,PATREON,GITHUB,TWITCH,YOUTUBE,SPOTIFY,JOYSTICK}_CLIENT_SECRET_FILE=...`
- `GC_ACCESS_APP_{APP_SLUG}_SHARED_SECRET_FILE=...`
- `GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET_FILE=...`
`src/config.ts` is the complete list; `deployment/idunn/recipe.toml` declares
the subset an Idunn binding may set.
Only configure the providers Heimdall actually owns for a given deployment.
The signing key is always a file: `GC_ACCESS_SIGNING_PRIVATE_KEY_PATH` names
it, and Heimdall can bootstrap that file on first boot when
`GC_ACCESS_SIGNING_PRIVATE_KEY_BOOTSTRAP=1`. If no path is configured, the current skeleton falls back to an
ephemeral dev signing key. That is acceptable for local iteration and
absolutely not acceptable for real deployment.
Managed provider tokens should be sealed at rest with a stable
`GC_ACCESS_TOKEN_ENCRYPTION_KEY_BASE64_FILE` secret. The current runtime will still
mint an ephemeral dev token key for in-memory local work, but persistent
Postgres deployments should treat a missing token-encryption key as a hard
misconfiguration, not a vibe.
Per-app binding should live in code or profile config, not in cloned provider
env namespaces. App-local binding and runtime config such as creator
associations, webhook subscriptions, sync toggles, or workspace/project
attachment rules should stay under the app's own config/state even when the
underlying OAuth credential is Heimdall-managed.
## Audit Result
The correct reusable seam is:
- one shared identity authority service
- local verification of signed claims by each host app
- thin per-app access profiles and runtime bindings
The incorrect reusable seam is:
- cloning one app's auth blob into every future experiment
- or turning shared auth into shared domain-data soup
- or forcing every guarded app route to make a Heimdall network hop