Heimdall/docs/architecture.md
Meta Vens fe26208f05 Docs and .env.example teach secrets as NAME_FILE credential files
Soul F4: the architecture configuration list, the Spotiverse restore steps and
.env.example still taught plaintext secret variables. The architecture list now
names only what src/config.ts reads, plain settings apart from secret files,
and drops the names nothing reads (GC_ACCESS_SESSION_SECRET, GC_ACCESS_ENABLED,
GC_ACCESS_INTERNAL_URL, the Discord bot token, the Patreon campaign id and
two TTLs). .env.example comments out the _FILE lines, because an empty
NAME_FILE= is refused at startup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:52:11 +02:00

24 KiB

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

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

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:

AppAccessProfile {
  appSlug: string
  displayName: string
  capabilities: string[]
  policyRules: CapabilityRule[]
  publicRoutes: string[]
  explanationStrings: map[string]string
}

Example rule style:

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 is the only profile naming spotify, and Spotiverse was lost with the E: drive — it was a small daemon exposing the Spotify API to agents, and it no longer exists in the swarm.

What survives it: GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_ID and GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET are configured in /srv/heimdall/env/service.env, the profile is advertised in the public discovery document, and /v1/oauth/spotify/start is reachable from the open web. That path would run a real OAuth handshake against a real client secret and take custody of a user's Spotify refresh token on behalf of a consumer that cannot use it.

This is not an active compromise. It is a live credential and a token-custody code path kept alive for nothing, which is exactly the shape that becomes an incident later. Retire the profile and the provider config together, or record why Spotiverse is expected back. Retiring is a change to src/app-profiles.ts and the provider catalog and should be decided on its own, not folded into the boundary change.

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