Add Eve access plugin and private auth plane

This commit is contained in:
Meta Vens 2026-08-22 00:44:57 +02:00
commit 19e5109cf4
47 changed files with 1781 additions and 35 deletions

View file

@ -1,5 +1,7 @@
HOST=127.0.0.1
PORT=4100
GC_ACCESS_PRIVATE_COMMAND_HOST=127.0.0.1
GC_ACCESS_PRIVATE_COMMAND_PORT=4101
GC_ACCESS_BASE_URL=http://127.0.0.1:4100
GC_ACCESS_ISSUER=http://127.0.0.1:4100
GC_ACCESS_STORAGE_BACKEND=memory
@ -29,3 +31,5 @@ GC_ACCESS_PROVIDER_SPOTIFY_CLIENT_SECRET=
GC_ACCESS_APP_SPOTIVERSE_SHARED_SECRET=
GC_ACCESS_APP_SPOTIVERSE_BACKEND_CALLBACK_URLS=
GC_ACCESS_APP_GHOSTLIGHT_SHARED_SECRET=
GC_ACCESS_APP_GHOSTLIGHT_RUNTIME_IDS=yggdrasil-ghostlight

View file

@ -25,7 +25,8 @@ Landed right now:
- signed OAuth start-state scaffolding for Discord, Patreon, GitHub, Twitch,
YouTube, and Spotify
- durable auth/control-plane storage with in-memory and Postgres backends
- account, linked-identity, session, grant, entitlement snapshot, and audit
- account, linked-identity, session, persistent OAuth attempt, grant,
entitlement snapshot, and audit
persistence
- a real Discord OAuth callback path for the first Repixelizer access flow
- direct backend callback handoff for same-host or Yggdrasil-reachable app
@ -44,10 +45,16 @@ Landed right now:
profile, and Idunn daemon-health summary
- optional Idunn daemon-health publication over `cultnet.transport.rudp.v0`
when `GC_ACCESS_IDUNN_RUDP_HEALTH` is configured
- the `gamecult.heimdall.access` Eve plugin with executable `describe`,
`validate`, and `project` operations plus a browser reference adapter
- a loopback-only CultNet operation boundary on port `4101` for authenticated
`heimdall.auth.begin` and `heimdall.auth.complete`; app payloads and completion
claims are AES-256-GCM wrapped and HMAC-bound to expiry, nonce, operation,
runtime, and idempotency identity
Still not landed:
- end-to-end app integrations in consumer repos
- the Ghostlight Eve-host consumer migration and deployed two-account canary
- refresh/revocation flows and admin surfaces
- full redacted per-document Heimdall auth witness export or CultMesh publication

View file

@ -1,17 +1,20 @@
# Ghostlight Dungeon Access Profile
Ghostlight Dungeon reuses Heimdall's browser-completion Discord sign-in path.
Heimdall owns provider OAuth, local account identity, signed access claims, and
refresh. Ghostlight verifies claims locally and owns campaigns, campaign
membership, current campaign selection, and every world mutation.
Ghostlight Dungeon uses Heimdall's private CultNet authentication commands and
the `gamecult.heimdall.access` Eve plugin. Heimdall owns provider OAuth,
persistent attempts, local account identity, entitlement evaluation, signed
access claims, completion redemption, and refresh. Ghostlight verifies claims
locally and owns app sessions, campaigns, campaign membership, current campaign
selection, and every world mutation.
## App identity
- `app_slug`: `ghostlight`
- public URL: `https://yggdrasil.gamecult.org/ghostlight/`
- identity provider: Discord
- public-demo rule: any Heimdall-authenticated Discord identity receives
`app_access`; no GameCult guild role is required
- access rule: Ghostlight supplies its configured KLTST Discord-role policy on
the app-authenticated private boundary; Heimdall evaluates the policy and
grants `app_access` only when it succeeds
## Capabilities
@ -19,7 +22,7 @@ membership, current campaign selection, and every world mutation.
- `campaign_play`: hybrid capability; Ghostlight must also prove that the
authenticated account owns the selected campaign
The browser never carries provider tokens. It receives a one-time completion
code, Ghostlight redeems it server-to-server, verifies the resulting
`aud=ghostlight` claim against Heimdall JWKS, and establishes app-local campaign
authority.
The browser never carries provider tokens or a completion code. The access
plugin stores only an opaque attempt handle. Ghostlight redeems that handle over
the encrypted loopback command plane, verifies the resulting `aud=ghostlight`
claim against Heimdall JWKS, and establishes app-local campaign authority.

View file

@ -71,6 +71,29 @@ bind its own policy and runtime seams onto it.
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.
## Provider taxonomy

32
docs/eve-access-plugin.md Normal file
View file

@ -0,0 +1,32 @@
# Heimdall Eve Access Boundary
## Objective
Let Eve applications request authentication without learning provider OAuth,
claim, entitlement, or completion mechanics. The browser can render the access
gate and carry one opaque attempt handle; it cannot grant access.
## Authority map
- **Owner:** Heimdall owns OAuth attempts, provider callbacks, entitlement
evaluation, completion redemption, and claim issuance.
- **Inputs:** a signed private CultNet operation from an allowlisted app binding,
provider callback evidence, and Heimdall-owned auth/control-plane state.
- **Outputs:** a navigation receipt for `heimdall.auth.begin`, or an encrypted
single-use completion for `heimdall.auth.complete`.
- **Derived state:** Eve access-gate components, plugin projections, Odin route
metadata, and browser status are projections. They never grant access.
- **Forbidden writers:** browsers, Eve lowerers, Odin, Hermodr, and host apps may
not create or complete Heimdall attempts, evaluate entitlements, or mint
claims. Host apps may create only their own local sessions after verification.
- **Shared paths:** HTTP OAuth starts and private CultNet starts use the same
state-token builder; all provider callbacks use the same callback handler and
completion store; all redemption consumes the same single-use completion.
- **Cut line:** the private command plane is loopback-only and authenticated by
the existing app secret. Public CultMesh receives plugin and route metadata,
never claims, tokens, nonces, secrets, or attempt contents.
The access plugin owns representation and browser navigation semantics. It does
not accept provider commands or mutate authentication state. Its `describe`,
`validate`, and `project` ABI is pure; app commands still cross the provider's
advertised command boundary.

View file

@ -60,6 +60,8 @@ What exists right now is:
- a daemon-owned boundary witness store plus optional Idunn RUDP health pulse
so Heimdall can advertise provider/boundary truth to Odin and Idunn without
pretending HTTP/status checks own the daemon
- the `gamecult.heimdall.access` Eve plugin, persistent auth attempts, and a
loopback-only authenticated CultNet command boundary for Ghostlight
## Primary objective
@ -73,6 +75,25 @@ Build Heimdall as a shared auth authority service that can:
## Current priorities
### 0. Complete the Ghostlight Eve access cut
Landed in Heimdall:
- public plugin manifest, advertisement, state schemas, fixtures, pure ABI, and
browser reference adapter
- persistent attempt lifecycle in memory and Postgres
- encrypted/HMAC-bound `heimdall.auth.begin` and `heimdall.auth.complete`
- exact source-runtime admission, nonce/expiry checks, idempotent retries, and
single-use completion redemption
- Odin-safe plugin and private-route metadata without secret-bearing state
Remaining outside this repo:
- Ghostlight must consume the plugin, verify returned claims, and own its local
sessions
- Idunn must deploy the new release and Odin must witness the plugin and route
metadata
### 1. Lock the service boundary
Goals:

View file

@ -598,13 +598,39 @@ The following are not landed yet:
- revocation flows
- admin/grant management surfaces
- GitHub callback runtime
- runtime CultCache `.cc` witness writing or CultMesh publication
- 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`
- request envelope: `heimdall.private_command_envelope.v1`
- begin result: `heimdall.auth_begin_receipt.v1`
- completion result: `heimdall.auth_completion_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.
`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 as a read-only Eve provider surface for Odin discovery
- advertise Heimdall's redacted provider, plugin, and command-boundary surfaces
for Odin discovery
- name the redacted CultCache witness document schemas and future `.cc` paths
- avoid touching provider secrets, token custody, store migrations, or runtime
mutation paths

View file

@ -11,14 +11,14 @@ evidence only.
## Owner Map
- Owner: Heimdall owns provider identity/linking, signed session and claim
- Owner: Heimdall owns provider identity/linking, OAuth attempts, signed session and claim
issuance, entitlement refresh, managed provider token custody, grants,
auth-completion handoffs, refresh sessions, and audit events.
- Inputs: provider OAuth callbacks, app profile requests, caller-supplied
entitlement policy, provider API responses, configured signing/encryption
keys, app backend callback receipts, and local Postgres/in-memory store
records.
- Outputs: signed access claims, refresh claims, JWKS, discovery documents,
- Outputs: signed access claims, refresh claims, typed private-command receipts, JWKS, discovery documents,
backend handoff payloads, managed credential projections, audit records, and
app-local verification material.
- Derived state: health/discovery/JWKS responses, browser completion pages,
@ -120,6 +120,9 @@ Authority map:
a runtime-owned `GC_ACCESS_CULTCACHE_PATH` store containing
`gamecult.eve.provider_advertisement.v1`, `heimdall.command_boundary.v1`,
`heimdall.transport_profile.v1`, and `idunn.daemon_health.v1` summary state.
It also publishes the redacted `gamecult.heimdall.access` plugin
advertisement and loopback command-route metadata. No claim, token, secret,
nonce, or opaque attempt handle enters that store.
- Derived state: `/healthz`, JWKS, discovery, systemd, nginx routing, and the
boundary store are projections. They must not mutate auth truth or replace
explicit Heimdall API ownership.
@ -146,6 +149,8 @@ redacted auth witness cut should add these `.cc` targets:
`cultcache/heimdall/entitlements/{accountId}/{provider}/{scope}.cc`
- `heimdall.auth_completion.v0` at
`cultcache/heimdall/auth-completions/{completionCodeHash}.cc`
- `heimdall.auth_attempt.v1` at
`cultcache/heimdall/auth-attempts/{attemptHandleHash}.cc`
- `heimdall.audit_event.v0` at `cultcache/heimdall/audit/{eventId}.cc`
- `heimdall.app_profile.v0` at
`cultcache/heimdall/app-profiles/{appSlug}.cc`

View file

@ -27,6 +27,19 @@ Do not trust this file for the exact live HEAD. Always check git.
## Current Orientation
- `gamecult.heimdall.access` now has a manifest, advertisement, schemas,
fixtures, executable `describe`/`validate`/`project` ABI, and browser
reference adapter
- Heimdall persists OAuth attempts and serves authenticated
`heimdall.auth.begin` / `heimdall.auth.complete` through loopback
CultNet/RUDP on port `4101`
- private commands are source-runtime, app-secret, expiry, nonce, and
idempotency bound; their MessagePack payloads and completion claims are
AES-256-GCM wrapped
- the Ghostlight app profile now requires its caller-owned Discord role policy;
Heimdall evaluates it and Ghostlight remains owner of local sessions,
memberships, and campaigns
- this repo now has a first stateful Heimdall service slice
- the canonical shared architecture lives in `docs/architecture.md`
- the concrete HTTP/JWKS contract lives in `docs/service-contract.md`
@ -152,6 +165,11 @@ unsigned `idunn.daemon_health`, merge its private key into Idunn, or make HTTP
health an authority. The next product work is the Bifrost and remaining browser
verification listed below.
For the active Ghostlight cut, first consume the access plugin and private
command envelope in Ghostlight, then deploy Heimdall and Ghostlight through
Idunn. Verify Odin sees the redacted plugin/route records while port `4101`
remains loopback-only and no token appears in CultMesh, logs, or browser state.
If the user asks to continue on Spotiverse, the current next move is the real
browser `/auth/login` flow through public Heimdall. The Yggdrasil SSH tunnel
must include reverse forward

View file

@ -0,0 +1,29 @@
{
"schema": "gamecult.eve.plugin_advertisement.v1",
"pluginId": "gamecult.heimdall.access",
"ownerService": "asgard.heimdall",
"version": "0.1.0",
"manifestAddress": "cultmesh://gamecult/heimdall/plugins/access/manifest",
"runtime": {
"invocationModel": "executable-sidecar",
"contract": "gamecult.eve.plugin_abi.v1",
"transports": ["cultnet.operation.v0", "in-process-reference"],
"authority": ["renderer-independent", "no-access-grant", "no-provider-state-mutation"],
"sidecar": {
"processKind": "heimdall-daemon-plugin-organ",
"protocol": "cultnet.operation.v0",
"requestSchema": "gamecult.eve.plugin_abi.request.v1",
"responseSchema": "gamecult.eve.plugin_abi.response.v1",
"operations": ["describe", "validate", "project"],
"commandEnvelope": "gamecult.eve.command_invocation.v1",
"receiptSchema": "gamecult.eve.command_result.v1",
"stateAuthority": "Heimdall projects access state; the plugin cannot grant access.",
"endpoint": "rudp://127.0.0.1:4101"
}
},
"schemas": ["heimdall.access_gate_state.v1", "heimdall.auth_attempt.v1", "heimdall.auth_begin_receipt.v1", "heimdall.auth_completion_receipt.v1", "heimdall.auth_navigation_receipt.v1", "heimdall.auth_completion_status.v1"],
"componentKinds": ["heimdall.access_gate", "heimdall.identity", "heimdall.access_status"],
"commands": ["heimdall.auth.begin", "heimdall.auth.complete", "app.auth.logout"],
"navigationOrigins": ["https://discord.com", "https://heimdall.gamecult.org"],
"fixtures": ["anonymous", "pending", "denied", "authenticated", "expired", "unavailable"]
}

View file

@ -0,0 +1,80 @@
const ATTEMPT_KEY = "gamecult.heimdall.access.attempt";
export interface HeimdallAccessResumeTarget {
complete(handle: string): Promise<void>;
}
export function createHeimdallAccessBrowserAdapter(options: { advertisedOrigins: readonly string[] }) {
const advertisedOrigins = new Set(options.advertisedOrigins.map(value => new URL(value).origin));
return {
pluginId: "gamecult.heimdall.access",
capabilities: ["auth.gate", "auth.begin", "auth.complete", "auth.logout"],
componentKinds: ["heimdall.access_gate", "heimdall.identity", "heimdall.access_status"],
schemas: ["heimdall.access_gate_state.v1"],
normalizeDocument(_schemaId: string | undefined, value: unknown): unknown {
return value;
},
renderComponent(
component: { id?: string },
props: Record<string, unknown>,
): HTMLElement {
const view = document.createElement("section");
view.className = `heimdall-access heimdall-access-${String(props.state || "unknown")}`;
if (component.id) view.id = component.id;
view.setAttribute("role", "status");
view.setAttribute("aria-live", "polite");
const title = document.createElement("h2");
title.textContent = String(props.title || "Access");
const detail = document.createElement("p");
detail.textContent = String(props.detail || "");
view.append(title, detail);
const displayName = String(props.displayName || "");
if (displayName) {
const identity = document.createElement("p");
identity.className = "heimdall-identity";
identity.textContent = displayName;
view.append(identity);
}
return view;
},
async consumeCommandResult(
pluginPayload: { schemaId: string; payload: Record<string, unknown> },
): Promise<void> {
if (pluginPayload.schemaId === "heimdall.auth_navigation_receipt.v1") {
const handle = String(pluginPayload.payload.handle || "");
const navigation = object(pluginPayload.payload.navigation);
const url = new URL(String(navigation.url || ""));
const allowedOrigins = Array.isArray(navigation.allowedOrigins)
? navigation.allowedOrigins.filter((value): value is string => typeof value === "string")
: [];
if (!handle || url.protocol !== "https:" || !advertisedOrigins.has(url.origin) || !allowedOrigins.includes(url.origin)) {
throw new Error("Heimdall returned an unsafe authentication navigation receipt.");
}
sessionStorage.setItem(ATTEMPT_KEY, handle);
window.location.assign(url.toString());
return;
}
if (pluginPayload.schemaId === "heimdall.auth_completion_status.v1") {
const status = String(pluginPayload.payload.status || "");
if (status === "authenticated" || status === "denied" || status === "expired") {
sessionStorage.removeItem(ATTEMPT_KEY);
}
}
},
};
}
export const heimdallAccessBrowserAdapter = createHeimdallAccessBrowserAdapter({
advertisedOrigins: ["https://discord.com", "https://heimdall.gamecult.org"],
});
export async function resumeHeimdallAccess(target: HeimdallAccessResumeTarget): Promise<boolean> {
const handle = sessionStorage.getItem(ATTEMPT_KEY);
if (!handle) return false;
await target.complete(handle);
return true;
}
function object(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}

View file

@ -0,0 +1 @@
{"schema":"heimdall.access_gate_state.v1","state":"anonymous","title":"Sign in to Ghostlight Dungeon","detail":"Use your GameCult Discord identity to continue.","canBegin":true,"canComplete":false}

View file

@ -0,0 +1 @@
{"schema":"heimdall.access_gate_state.v1","state":"authenticated","title":"Signed in","detail":"Heimdall verified this app session.","canBegin":false,"canComplete":false,"displayName":"Tester"}

View file

@ -0,0 +1 @@
{"schema":"heimdall.access_gate_state.v1","state":"denied","title":"Access denied","detail":"This Discord identity does not currently satisfy Ghostlight's access policy.","canBegin":true,"canComplete":false}

View file

@ -0,0 +1 @@
{"schema":"heimdall.access_gate_state.v1","state":"expired","title":"Sign-in expired","detail":"Begin a fresh authentication attempt.","canBegin":true,"canComplete":false}

View file

@ -0,0 +1 @@
{"schema":"heimdall.access_gate_state.v1","state":"pending","title":"Authentication pending","detail":"Finish signing in with Discord, then return here.","canBegin":false,"canComplete":true}

View file

@ -0,0 +1 @@
{"schema":"heimdall.access_gate_state.v1","state":"unavailable","title":"Authentication unavailable","detail":"Existing verified sessions continue until expiry; new sign-in is temporarily unavailable.","canBegin":false,"canComplete":false}

View file

@ -0,0 +1,49 @@
{
"schema": "gamecult.eve.plugin.v1",
"pluginId": "gamecult.heimdall.access",
"title": "Heimdall Access",
"owner": "heimdall",
"version": "0.1.0",
"componentKinds": ["heimdall.access_gate", "heimdall.identity", "heimdall.access_status"],
"commands": [
{ "command": "heimdall.auth.begin", "schema": "heimdall.auth_begin_command.v1", "effect": "request-auth-navigation" },
{ "command": "heimdall.auth.complete", "schema": "heimdall.auth_complete_command.v1", "effect": "redeem-auth-completion" },
{ "command": "app.auth.logout", "schema": "heimdall.auth_logout_command.v1", "effect": "request-app-local-session-revocation" }
],
"runtime": {
"invocationModel": "executable-sidecar",
"contract": "gamecult.eve.plugin_abi.v1",
"transports": ["cultnet.operation.v0", "in-process-reference"],
"authority": ["renderer-independent", "no-access-grant", "no-provider-state-mutation"],
"sidecar": {
"processKind": "heimdall-daemon-plugin-organ",
"protocol": "cultnet.operation.v0",
"requestSchema": "gamecult.eve.plugin_abi.request.v1",
"responseSchema": "gamecult.eve.plugin_abi.response.v1",
"operations": ["describe", "validate", "project"],
"commandEnvelope": "gamecult.eve.command_invocation.v1",
"receiptSchema": "gamecult.eve.command_result.v1",
"stateAuthority": "Heimdall projects access state; host apps accept commands and create only their own local sessions.",
"endpoint": "rudp://127.0.0.1:4101"
}
},
"stateSchemas": [
"heimdall.access_gate_state.v1",
"heimdall.auth_attempt.v1",
"heimdall.auth_begin_receipt.v1",
"heimdall.auth_completion_receipt.v1",
"heimdall.auth_navigation_receipt.v1",
"heimdall.auth_completion_status.v1"
],
"fixtures": [
{ "fixtureId": "heimdall.access.anonymous", "schema": "heimdall.access_gate_state.v1", "path": "fixtures/anonymous.json" },
{ "fixtureId": "heimdall.access.pending", "schema": "heimdall.access_gate_state.v1", "path": "fixtures/pending.json" },
{ "fixtureId": "heimdall.access.denied", "schema": "heimdall.access_gate_state.v1", "path": "fixtures/denied.json" },
{ "fixtureId": "heimdall.access.authenticated", "schema": "heimdall.access_gate_state.v1", "path": "fixtures/authenticated.json" },
{ "fixtureId": "heimdall.access.expired", "schema": "heimdall.access_gate_state.v1", "path": "fixtures/expired.json" },
{ "fixtureId": "heimdall.access.unavailable", "schema": "heimdall.access_gate_state.v1", "path": "fixtures/unavailable.json" }
],
"capabilities": {
"requires": ["auth.gate", "auth.begin", "auth.complete", "auth.logout", "control.button", "text.body"]
}
}

View file

@ -0,0 +1,16 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "heimdall.access_gate_state.v1",
"type": "object",
"required": ["schema", "state", "title", "detail", "canBegin", "canComplete"],
"properties": {
"schema": { "const": "heimdall.access_gate_state.v1" },
"state": { "enum": ["anonymous", "pending", "denied", "authenticated", "expired", "unavailable"] },
"title": { "type": "string", "minLength": 1 },
"detail": { "type": "string" },
"canBegin": { "type": "boolean" },
"canComplete": { "type": "boolean" },
"displayName": { "type": "string" }
},
"additionalProperties": false
}

View file

@ -0,0 +1,21 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "heimdall.auth_attempt.v1",
"type": "object",
"required": ["schema", "handle", "appSlug", "provider", "mode", "returnTo", "status", "createdAt", "expiresAt"],
"properties": {
"schema": { "const": "heimdall.auth_attempt.v1" },
"handle": { "type": "string", "minLength": 1 },
"appSlug": { "type": "string", "minLength": 1 },
"provider": { "type": "string", "minLength": 1 },
"mode": { "type": "string", "minLength": 1 },
"returnTo": { "type": "string", "format": "uri" },
"status": { "enum": ["pending", "completed", "denied", "consumed", "expired"] },
"createdAt": { "type": "string", "format": "date-time" },
"expiresAt": { "type": "string", "format": "date-time" },
"completedAt": { "type": "string", "format": "date-time" },
"consumedAt": { "type": "string", "format": "date-time" },
"denialCode": { "type": "string" }
},
"additionalProperties": false
}

View file

@ -0,0 +1,35 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "heimdall.auth_begin_command.v1",
"type": "object",
"required": ["provider", "mode", "returnTo", "entitlementPolicy"],
"properties": {
"provider": { "enum": ["discord", "patreon", "github", "twitch", "youtube", "spotify"] },
"mode": { "enum": ["sign_in", "link", "connect"] },
"returnTo": { "type": "string", "format": "uri" },
"entitlementPolicy": {
"oneOf": [
{
"type": "object",
"required": ["kind", "guildId", "allowedRoleIds"],
"properties": {
"kind": { "const": "discord_role_access" },
"guildId": { "type": "string", "minLength": 1 },
"allowedRoleIds": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }
},
"additionalProperties": false
},
{
"type": "object",
"required": ["kind", "requiredTierTitle"],
"properties": {
"kind": { "const": "patreon_membership_access" },
"requiredTierTitle": { "type": "string", "minLength": 1 }
},
"additionalProperties": false
}
]
}
},
"additionalProperties": false
}

View file

@ -0,0 +1,22 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "heimdall.auth_begin_receipt.v1",
"type": "object",
"required": ["schema", "status", "handle", "expiresAt", "navigation"],
"properties": {
"schema": { "const": "heimdall.auth_begin_receipt.v1" },
"status": { "const": "pending" },
"handle": { "type": "string", "minLength": 1 },
"expiresAt": { "type": "string", "format": "date-time" },
"navigation": {
"type": "object",
"required": ["url", "allowedOrigins"],
"properties": {
"url": { "type": "string", "format": "uri", "pattern": "^https://" },
"allowedOrigins": { "type": "array", "minItems": 1, "items": { "type": "string", "format": "uri" }, "uniqueItems": true }
},
"additionalProperties": false
}
},
"additionalProperties": false
}

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "heimdall.auth_complete_command.v1",
"type": "object",
"required": ["handle"],
"properties": { "handle": { "type": "string", "minLength": 1 } },
"additionalProperties": false
}

View file

@ -0,0 +1,23 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "heimdall.auth_completion_receipt.v1",
"type": "object",
"required": ["schema", "status"],
"properties": {
"schema": { "const": "heimdall.auth_completion_receipt.v1" },
"status": { "enum": ["pending", "authenticated", "denied"] },
"handle": { "type": "string" },
"error": { "type": "string" },
"account": { "type": "object" },
"session": { "type": "object" },
"accessToken": { "type": "string" },
"refreshToken": { "type": "string" },
"refresh": { "type": "object" },
"claimSet": { "type": "object" },
"verification": { "type": "object" },
"sharedCapabilities": { "type": "array", "items": { "type": "string" } },
"hybridCapabilities": { "type": "array", "items": { "type": "string" } },
"entitlements": { "type": "object" }
},
"additionalProperties": true
}

View file

@ -0,0 +1,12 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "heimdall.auth_completion_status.v1",
"type": "object",
"required": ["schema", "status"],
"properties": {
"schema": { "const": "heimdall.auth_completion_status.v1" },
"status": { "enum": ["pending", "authenticated", "denied", "expired"] },
"detail": { "type": "string" }
},
"additionalProperties": false
}

View file

@ -0,0 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "heimdall.auth_logout_command.v1",
"type": "object",
"properties": {},
"additionalProperties": false
}

View file

@ -0,0 +1,20 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "heimdall.auth_navigation_receipt.v1",
"type": "object",
"required": ["schema", "handle", "navigation"],
"properties": {
"schema": { "const": "heimdall.auth_navigation_receipt.v1" },
"handle": { "type": "string", "minLength": 1 },
"navigation": {
"type": "object",
"required": ["url", "allowedOrigins"],
"properties": {
"url": { "type": "string", "format": "uri", "pattern": "^https://" },
"allowedOrigins": { "type": "array", "items": { "type": "string", "format": "uri" }, "minItems": 1, "uniqueItems": true }
},
"additionalProperties": false
}
},
"additionalProperties": false
}

View file

@ -0,0 +1,21 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "heimdall.private_command_envelope.v1",
"type": "object",
"required": ["schema", "appSlug", "operation", "contentSchema", "issuedAt", "expiresAt", "nonce", "idempotencyKey", "iv", "ciphertext", "authTag", "signature"],
"properties": {
"schema": { "const": "heimdall.private_command_envelope.v1" },
"appSlug": { "type": "string", "minLength": 1 },
"operation": { "type": "string", "minLength": 1 },
"contentSchema": { "type": "string", "minLength": 1 },
"issuedAt": { "type": "string", "format": "date-time" },
"expiresAt": { "type": "string", "format": "date-time" },
"nonce": { "type": "string", "minLength": 16 },
"idempotencyKey": { "type": "string", "minLength": 1 },
"iv": { "type": "string", "minLength": 16 },
"ciphertext": { "type": "string" },
"authTag": { "type": "string", "minLength": 16 },
"signature": { "type": "string", "minLength": 32 }
},
"additionalProperties": false
}

127
src/access-plugin.ts Normal file
View file

@ -0,0 +1,127 @@
export const HEIMDALL_ACCESS_PLUGIN_ID = "gamecult.heimdall.access";
export interface EvePluginAbiRequest {
schema: "gamecult.eve.plugin_abi.request.v1";
pluginId: string;
operation: string;
requestId: string;
input: Record<string, unknown>;
}
export interface EvePluginAbiResponse {
schema: "gamecult.eve.plugin_abi.response.v1";
pluginId: string;
operation: string;
requestId: string;
status: "ok" | "invalid" | "unsupported";
output: Record<string, unknown>;
diagnostics?: Array<Record<string, unknown>>;
}
const accessStates = new Set(["anonymous", "pending", "denied", "authenticated", "expired", "unavailable"]);
export function buildHeimdallAccessPluginAdvertisement() {
return {
schema: "gamecult.eve.plugin_advertisement.v1",
pluginId: HEIMDALL_ACCESS_PLUGIN_ID,
ownerService: "asgard.heimdall",
version: "0.1.0",
manifestAddress: "cultmesh://gamecult/heimdall/plugins/access/manifest",
runtime: {
invocationModel: "executable-sidecar",
contract: "gamecult.eve.plugin_abi.v1",
transports: ["cultnet.operation.v0", "in-process-reference"],
authority: ["renderer-independent", "no-access-grant", "no-provider-state-mutation"],
sidecar: {
processKind: "heimdall-daemon-plugin-organ",
protocol: "cultnet.operation.v0",
requestSchema: "gamecult.eve.plugin_abi.request.v1",
responseSchema: "gamecult.eve.plugin_abi.response.v1",
operations: ["describe", "validate", "project"],
commandEnvelope: "gamecult.eve.command_invocation.v1",
receiptSchema: "gamecult.eve.command_result.v1",
stateAuthority: "Heimdall projects access state; the plugin cannot grant access.",
endpoint: "rudp://127.0.0.1:4101",
},
},
schemas: [
"heimdall.access_gate_state.v1",
"heimdall.auth_attempt.v1",
"heimdall.auth_begin_receipt.v1",
"heimdall.auth_completion_receipt.v1",
"heimdall.auth_navigation_receipt.v1",
"heimdall.auth_completion_status.v1",
],
componentKinds: ["heimdall.access_gate", "heimdall.identity", "heimdall.access_status"],
commands: ["heimdall.auth.begin", "heimdall.auth.complete", "app.auth.logout"],
fixtures: ["anonymous", "pending", "denied", "authenticated", "expired", "unavailable"],
navigationOrigins: ["https://discord.com", "https://heimdall.gamecult.org"],
};
}
export function executeHeimdallAccessPlugin(request: EvePluginAbiRequest): EvePluginAbiResponse {
if (request.schema !== "gamecult.eve.plugin_abi.request.v1" || request.pluginId !== HEIMDALL_ACCESS_PLUGIN_ID) {
return response(request, "invalid", {}, "Request does not target the Heimdall access plugin.");
}
if (request.operation === "describe") {
return response(request, "ok", {
pluginId: HEIMDALL_ACCESS_PLUGIN_ID,
version: "0.1.0",
operations: ["describe", "validate", "project"],
componentKinds: ["heimdall.access_gate", "heimdall.identity", "heimdall.access_status"],
commands: ["heimdall.auth.begin", "heimdall.auth.complete", "app.auth.logout"],
capabilities: ["auth.gate", "auth.begin", "auth.complete", "auth.logout"],
authority: ["no-access-grant", "no-provider-state-mutation"],
});
}
const state = record(request.input.state);
const diagnostic = validateState(state);
if (request.operation === "validate") {
return diagnostic ? response(request, "invalid", { valid: false }, diagnostic) : response(request, "ok", { valid: true });
}
if (request.operation === "project") {
if (diagnostic) return response(request, "invalid", {}, diagnostic);
return response(request, "ok", {
component: {
kind: "heimdall.access_gate",
props: {
state: state.state,
title: state.title,
detail: state.detail,
displayName: state.displayName ?? "",
},
},
});
}
return response(request, "unsupported", {}, `Unsupported operation '${request.operation}'.`);
}
function validateState(state: Record<string, unknown>): string | undefined {
if (state.schema !== "heimdall.access_gate_state.v1") return "Access projection requires heimdall.access_gate_state.v1.";
if (typeof state.state !== "string" || !accessStates.has(state.state)) return "Access state is invalid.";
if (typeof state.title !== "string" || !state.title) return "Access title is required.";
if (typeof state.detail !== "string") return "Access detail is required.";
if (typeof state.canBegin !== "boolean" || typeof state.canComplete !== "boolean") return "Access command availability is required.";
return undefined;
}
function response(
request: EvePluginAbiRequest,
status: EvePluginAbiResponse["status"],
output: Record<string, unknown>,
diagnostic?: string,
): EvePluginAbiResponse {
return {
schema: "gamecult.eve.plugin_abi.response.v1",
pluginId: HEIMDALL_ACCESS_PLUGIN_ID,
operation: request.operation,
requestId: request.requestId,
status,
output,
...(diagnostic ? { diagnostics: [{ code: status, message: diagnostic }] } : {}),
};
}
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}

View file

@ -200,16 +200,16 @@ const bifrostProfile: AppProfile = {
const ghostlightProfile: AppProfile = {
slug: "ghostlight",
displayName: "Ghostlight Dungeon",
profileVersion: "2026-08-19",
profileVersion: "2026-08-22",
identityProviders: ["discord"],
entitlementSources: [],
entitlementSources: ["discord"],
managedConnectionProviders: [],
capabilities: [
{
key: "app_access",
mode: "shared",
summary: "May enter the public Ghostlight Dungeon demo.",
sharedRule: identityFacts.authenticated,
summary: "May enter Ghostlight Dungeon after Heimdall verifies the app-supplied GameCult Discord role policy.",
sharedRule: "entitlement.app_access || grant.global_member || grant.app_access",
},
{
key: "campaign_play",
@ -219,7 +219,11 @@ const ghostlightProfile: AppProfile = {
},
],
evaluateSharedCapabilities(context) {
return context.facts.has(identityFacts.authenticated) ? ["app_access"] : [];
return hasAnyFact(context.facts, [
entitlementFacts.appAccess,
grantFacts.globalMember,
grantFacts.appAccess,
]) ? ["app_access"] : [];
},
};

View file

@ -44,13 +44,25 @@ import {
const MANAGED_CREDENTIAL_REFRESH_SKEW_MS = 5 * 60 * 1000;
interface BuildAppOptions {
export interface BuildAppOptions {
config?: HeimdallConfig;
store?: HeimdallStore;
oauthRuntimes?: Partial<OAuthRuntimeRegistry>;
tokenCustody?: TokenCustody;
}
export interface HeimdallRuntimeContext {
config: HeimdallConfig;
keys: ReturnType<typeof createRuntimeKeyMaterial>;
store: HeimdallStore;
oauthRuntimes: OAuthRuntimeRegistry;
tokenCustody: TokenCustody;
}
export function getHeimdallRuntimeContext(app: FastifyInstance): HeimdallRuntimeContext {
return (app as FastifyInstance & { heimdallContext: HeimdallRuntimeContext }).heimdallContext;
}
function buildDiscovery(config: HeimdallConfig) {
return {
issuer: config.issuer,
@ -117,7 +129,9 @@ function parseOAuthStatePayload(payload: Record<string, unknown>): OAuthStatePay
handoff === undefined ||
(typeof handoff === "object" &&
handoff !== null &&
((handoff as Record<string, unknown>).kind === "browser_completion" ||
(((handoff as Record<string, unknown>).kind === "browser_completion" &&
((handoff as Record<string, unknown>).attemptId === undefined ||
typeof (handoff as Record<string, unknown>).attemptId === "string")) ||
((handoff as Record<string, unknown>).kind === "backend_callback" &&
typeof (handoff as Record<string, unknown>).attemptId === "string" &&
typeof (handoff as Record<string, unknown>).callbackUrl === "string")));
@ -237,7 +251,8 @@ function normalizeOAuthHandoff(value: OAuthStartRequest["handoff"]): OAuthHandof
}
if (value.kind === "browser_completion") {
return value;
const attemptId = value.attemptId?.trim();
return attemptId ? { kind: "browser_completion", attemptId } : { kind: "browser_completion" };
}
if (
@ -678,7 +693,8 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
detail: "Backend callback handoffs are only accepted for configured app callback URLs.",
};
}
if (entitlementPolicy && handoff.kind !== "backend_callback") {
const trustedAppCaller = secretMatches(config.appSharedSecrets[profile.slug], getSharedSecret(request));
if (entitlementPolicy && handoff.kind !== "backend_callback" && !trustedAppCaller) {
reply.code(400);
return {
error: "untrusted_entitlement_policy",
@ -774,8 +790,17 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
return { error: "state_provider_mismatch" };
}
const handoff = statePayload.handoff ?? { kind: "browser_completion" };
const denyBrowserAttempt = async (denialCode: string): Promise<void> => {
if (handoff.kind !== "browser_completion" || !handoff.attemptId) return;
await store.updateAuthAttempt(statePayload.app_slug, handoff.attemptId, {
status: "denied",
at: new Date().toISOString(),
denialCode,
});
};
if (request.query.error) {
await denyBrowserAttempt("provider_error");
if (handoff.kind === "backend_callback") {
await maybeDeliverBackendHandoff(handoff, {
source: "heimdall",
@ -821,6 +846,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
}
if (!request.query.code) {
await denyBrowserAttempt("missing_code");
if (handoff.kind === "backend_callback") {
await maybeDeliverBackendHandoff(handoff, {
source: "heimdall",
@ -1061,6 +1087,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
const completionExpiresAt = new Date(Date.now() + config.completionTtlSeconds * 1000).toISOString();
const completion = await store.createAuthCompletion({
...(handoff.kind === "browser_completion" && handoff.attemptId ? { code: handoff.attemptId } : {}),
appSlug: statePayload.app_slug,
provider: request.params.provider,
mode: statePayload.mode,
@ -1071,6 +1098,12 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
expiresAt: completionExpiresAt,
payloadJson: completionPayload as unknown as Record<string, unknown>,
});
if (handoff.kind === "browser_completion" && handoff.attemptId) {
await store.updateAuthAttempt(statePayload.app_slug, handoff.attemptId, {
status: "completed",
at: nowIso,
});
}
await store.createAuditEvent({
accountId: account.id,
@ -1097,6 +1130,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
mode: statePayload.mode,
returnTo: statePayload.return_to,
completionCode: completion.code,
...(handoff.kind === "browser_completion" && handoff.attemptId
? { attemptId: handoff.attemptId }
: {}),
})
);
}
@ -1113,6 +1149,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
} catch (error) {
const message = error instanceof Error ? error.message : "OAuth callback failed.";
const nowIso = new Date().toISOString();
await denyBrowserAttempt("oauth_callback_failed");
await store.createAuditEvent({
appSlug: statePayload.app_slug,
eventType: "oauth_callback_failed",
@ -1215,6 +1252,10 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
},
createdAt: nowIso,
});
await store.updateAuthAttempt(request.params.appSlug, request.body.completionCode, {
status: "consumed",
at: nowIso,
});
reply.code(201);
return completion.payloadJson;

View file

@ -17,6 +17,8 @@ export interface HeimdallConfig {
serviceName: string;
host: string;
port: number;
privateCommandHost?: string;
privateCommandPort?: number;
workspaceRoot: string;
dataRoot: string;
cultCachePath: string;
@ -36,6 +38,7 @@ export interface HeimdallConfig {
signingKeyId?: string;
tokenEncryptionKeyBase64?: string;
appSharedSecrets: Partial<Record<AppSlug, string>>;
appRuntimeIds?: Partial<Record<AppSlug, string[]>>;
appBackendCallbacks: Partial<Record<AppSlug, string[]>>;
bifrostPatronSupportEndpoint?: string;
bifrostPatronSupportSecret?: string;
@ -128,11 +131,21 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): HeimdallConfig
})
.filter(([, value]) => Array.isArray(value) && value.length > 0)
) as Partial<Record<AppSlug, string[]>>;
const appRuntimeIds = Object.fromEntries(
appSlugs
.map((appSlug) => {
const envKey = `GC_ACCESS_APP_${appSlug.toUpperCase()}_RUNTIME_IDS`;
return [appSlug, readList(env[envKey])];
})
.filter(([, value]) => Array.isArray(value) && value.length > 0)
) as Partial<Record<AppSlug, string[]>>;
const config: HeimdallConfig = {
serviceName: "heimdall",
host,
port,
privateCommandHost: env.GC_ACCESS_PRIVATE_COMMAND_HOST ?? "127.0.0.1",
privateCommandPort: readInt(env.GC_ACCESS_PRIVATE_COMMAND_PORT, 4101),
workspaceRoot,
dataRoot,
cultCachePath: env.GC_ACCESS_CULTCACHE_PATH ?? path.join(dataRoot, "cultcache", "heimdall.service.cc"),
@ -149,6 +162,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): HeimdallConfig
completionTtlSeconds: readInt(env.GC_ACCESS_COMPLETION_TTL_SECONDS, 300),
bootstrapSigningPrivateKeyOnMissing: readBoolean(env.GC_ACCESS_SIGNING_PRIVATE_KEY_BOOTSTRAP, false),
appSharedSecrets,
appRuntimeIds,
appBackendCallbacks,
storage: {
backend: storageBackend,

View file

@ -26,6 +26,7 @@ export interface OAuthConnectionBinding {
export interface OAuthBrowserCompletionHandoff {
kind: "browser_completion";
attemptId?: string;
}
export interface OAuthBackendCallbackHandoff {
@ -98,6 +99,23 @@ export interface RedeemAuthCompletionRequest {
completionCode: string;
}
export type HeimdallAuthAttemptStatus = "pending" | "completed" | "denied" | "consumed" | "expired";
export interface HeimdallAuthAttempt {
schema: "heimdall.auth_attempt.v1";
handle: string;
appSlug: AppSlug;
provider: Provider;
mode: OAuthMode;
returnTo: string;
status: HeimdallAuthAttemptStatus;
createdAt: string;
expiresAt: string;
completedAt?: string;
consumedAt?: string;
denialCode?: string;
}
export interface RefreshSessionRequest {
refreshToken: string;
entitlementPolicy?: OAuthEntitlementPolicy;

View file

@ -1,16 +1,22 @@
import { buildApp } from "./app.js";
import { loadConfig } from "./config.js";
import { publishIdunnRudpHealth } from "./idunn-rudp-health.js";
import { startHeimdallPrivateCommandPlane } from "./private-command-plane.js";
import type { CultNetOperationServer } from "cultnet-ts";
import { createHeimdallRuntimePulse, buildHeimdallHealthDetail, publishHeimdallVerseState } from "./verse-state.js";
const config = loadConfig();
const app = await buildApp({ config });
const versePulseIntervalMs = 60_000;
const healthPulseIntervalMs = 10_000;
let privateCommands: CultNetOperationServer | undefined;
try {
privateCommands = await startHeimdallPrivateCommandPlane(app, config);
app.addHook("onClose", async () => privateCommands?.close());
await app.listen({ host: config.host, port: config.port });
app.log.info(`Heimdall listening on ${config.host}:${config.port}`);
app.log.info(`Heimdall private CultNet commands listening at ${privateCommands.endpoint}`);
await publishVerseState().catch((error) => {
console.error("Heimdall verse publication failed on startup:", error);
});
@ -25,6 +31,7 @@ try {
void runHealthPulseLoop();
verseTimer.unref?.();
} catch (error) {
await privateCommands?.close().catch(() => undefined);
app.log.error(error);
process.exitCode = 1;
}

View file

@ -0,0 +1,292 @@
import { createHash, randomUUID } from "node:crypto";
import { decode, encode } from "@msgpack/msgpack";
import {
startCultNetOperationServer,
type CultNetOperationRequestMessage,
type CultNetOperationResponseMessage,
type CultNetOperationServer,
} from "cultnet-ts";
import { type FastifyInstance } from "fastify";
import { getHeimdallRuntimeContext } from "./app.js";
import { executeHeimdallAccessPlugin, HEIMDALL_ACCESS_PLUGIN_ID, type EvePluginAbiRequest } from "./access-plugin.js";
import { appSlugs, oauthModes, providers, type AppSlug, type OAuthEntitlementPolicy, type OAuthMode, type Provider } from "./contracts.js";
import { type HeimdallConfig } from "./config.js";
import { openPrivateEnvelope, sealPrivateEnvelope, type HeimdallPrivateEnvelope } from "./private-command-security.js";
export const HEIMDALL_PRIVATE_COMMAND_SERVICE = "heimdall.private.commands";
export const HEIMDALL_PRIVATE_ENVELOPE_SCHEMA = "heimdall.private_command_envelope.v1";
interface CachedResult {
fingerprint: string;
status: string;
envelope: HeimdallPrivateEnvelope;
}
export async function startHeimdallPrivateCommandPlane(
app: FastifyInstance,
config: HeimdallConfig,
): Promise<CultNetOperationServer> {
const seenNonces = new Map<string, number>();
const inFlight = new Map<string, Promise<CachedResult>>();
return await startCultNetOperationServer({
runtimeId: config.daemonId,
host: config.privateCommandHost ?? "127.0.0.1",
port: config.privateCommandPort ?? 4101,
handler: async (request) => {
const now = Date.now();
for (const [nonce, expiry] of seenNonces) if (expiry <= now) seenNonces.delete(nonce);
try {
if (request.serviceId === HEIMDALL_ACCESS_PLUGIN_ID) {
if (request.payloadSchema !== "gamecult.eve.plugin_abi.request.v1" || request.payloadEncoding !== "messagepack-base64") {
throw new Error("Heimdall access plugin requires the Eve plugin ABI request schema.");
}
const pluginRequest = decode(Buffer.from(request.payload, "base64")) as EvePluginAbiRequest;
if (pluginRequest.operation !== request.operation) throw new Error("Plugin ABI operation disagrees with CultNet routing.");
const pluginResponse = executeHeimdallAccessPlugin(pluginRequest);
return {
schemaVersion: "cultnet.operation_response.v0",
messageId: request.messageId,
serviceId: request.serviceId,
operation: request.operation,
status: pluginResponse.status,
payloadSchema: "gamecult.eve.plugin_abi.response.v1",
payloadEncoding: "messagepack-base64",
payload: Buffer.from(encode(pluginResponse)).toString("base64"),
diagnostics: [],
sourceRuntimeId: config.daemonId,
};
}
if (request.serviceId !== HEIMDALL_PRIVATE_COMMAND_SERVICE) throw new Error("Unknown Heimdall private command service.");
if (request.payloadSchema !== HEIMDALL_PRIVATE_ENVELOPE_SCHEMA || request.payloadEncoding !== "messagepack-base64") {
throw new Error("Heimdall private commands require the typed encrypted MessagePack envelope.");
}
const envelope = decode(Buffer.from(request.payload, "base64")) as HeimdallPrivateEnvelope;
if (!appSlugs.includes(envelope.appSlug as AppSlug)) throw new Error("Unknown Heimdall app binding.");
const appSlug = envelope.appSlug as AppSlug;
const secret = config.appSharedSecrets[appSlug];
if (!secret) throw new Error("Heimdall app binding has no private command secret.");
if (envelope.operation !== request.operation) throw new Error("Private envelope operation disagrees with CultNet routing.");
const expectedContentSchema = request.operation === "heimdall.auth.begin"
? "heimdall.auth_begin_command.v1"
: request.operation === "heimdall.auth.complete"
? "heimdall.auth_complete_command.v1"
: undefined;
if (!expectedContentSchema || envelope.contentSchema !== expectedContentSchema) {
throw new Error("Private command content schema does not match its operation.");
}
const allowedRuntimeIds = config.appRuntimeIds?.[appSlug] ?? [`yggdrasil-${appSlug}`];
if (!request.sourceRuntimeId || !allowedRuntimeIds.includes(request.sourceRuntimeId)) {
throw new Error("Private command caller is not the configured app runtime.");
}
const payload = openPrivateEnvelope(envelope, secret, now);
const fingerprint = createHash("sha256").update(request.payload).digest("hex");
const resultKey = `${appSlug}:${envelope.idempotencyKey}`;
const store = getHeimdallRuntimeContext(app).store;
const persisted = await store.findPrivateCommandReceipt(appSlug, envelope.idempotencyKey);
if (persisted) {
if (persisted.requestFingerprint !== fingerprint) throw new Error("Idempotency key was reused with different command content.");
const cachedEnvelope = decode(Buffer.from(persisted.envelopeBase64, "base64")) as HeimdallPrivateEnvelope;
return response(request, persisted.status, cachedEnvelope, config.daemonId);
}
const pending = inFlight.get(resultKey);
if (pending) {
const cached = await pending;
if (cached.fingerprint !== fingerprint) throw new Error("Idempotency key was reused with different command content.");
return response(request, cached.status, cached.envelope, config.daemonId);
}
if (seenNonces.has(`${appSlug}:${envelope.nonce}`)) throw new Error("Private command nonce was already used.");
seenNonces.set(`${appSlug}:${envelope.nonce}`, Date.parse(envelope.expiresAt));
const execution = (async (): Promise<CachedResult> => {
const output = await executePrivateCommand(app, config, appSlug, request.operation, payload);
const sealed = sealPrivateEnvelope({
appSlug,
operation: request.operation,
contentSchema: output.payloadSchema,
idempotencyKey: envelope.idempotencyKey,
secret,
payload: output.payload,
});
const stored = await store.createPrivateCommandReceipt({
appSlug,
idempotencyKey: envelope.idempotencyKey,
requestFingerprint: fingerprint,
status: output.status,
contentSchema: output.payloadSchema,
envelopeBase64: Buffer.from(encode(sealed)).toString("base64"),
createdAt: sealed.issuedAt,
expiresAt: sealed.expiresAt,
});
return {
fingerprint: stored.requestFingerprint,
status: stored.status,
envelope: decode(Buffer.from(stored.envelopeBase64, "base64")) as HeimdallPrivateEnvelope,
};
})();
inFlight.set(resultKey, execution);
let cachedResult: CachedResult;
try {
cachedResult = await execution;
} finally {
if (inFlight.get(resultKey) === execution) inFlight.delete(resultKey);
}
return response(request, cachedResult.status, cachedResult.envelope, config.daemonId);
} catch (error) {
return {
schemaVersion: "cultnet.operation_response.v0",
messageId: request.messageId,
serviceId: request.serviceId,
operation: request.operation,
status: "denied",
payloadSchema: "gamecult.cultnet.operation_failure.v1",
payloadEncoding: "messagepack-base64",
payload: Buffer.from(encode({ code: "private-command-denied", message: "Heimdall denied the private command." })).toString("base64"),
diagnostics: [error instanceof Error ? error.message : "Private command failed."],
sourceRuntimeId: config.daemonId,
};
}
},
});
}
async function executePrivateCommand(
app: FastifyInstance,
config: HeimdallConfig,
appSlug: AppSlug,
operation: string,
payload: Record<string, unknown>,
): Promise<{ status: string; payloadSchema: string; payload: Record<string, unknown> }> {
if (operation === "heimdall.auth.begin") return await beginAuth(app, config, appSlug, payload);
if (operation === "heimdall.auth.complete") return await completeAuth(app, appSlug, payload);
throw new Error(`Unsupported Heimdall private operation '${operation}'.`);
}
async function beginAuth(
app: FastifyInstance,
config: HeimdallConfig,
appSlug: AppSlug,
payload: Record<string, unknown>,
): Promise<{ status: string; payloadSchema: string; payload: Record<string, unknown> }> {
const provider = String(payload.provider ?? "discord") as Provider;
const mode = String(payload.mode ?? "sign_in") as OAuthMode;
const returnTo = String(payload.returnTo ?? "");
if (!providers.includes(provider) || !oauthModes.includes(mode) || !returnTo) throw new Error("Auth begin payload is incomplete.");
const entitlementPolicy = parseEntitlementPolicy(payload.entitlementPolicy);
if (appSlug === "ghostlight" && (!entitlementPolicy || entitlementPolicy.kind !== "discord_role_access")) {
throw new Error("Ghostlight authentication requires its caller-owned Discord role policy.");
}
const now = new Date();
const attempt = await getHeimdallRuntimeContext(app).store.createAuthAttempt({
handle: randomUUID(),
appSlug,
provider,
mode,
returnTo,
createdAt: now.toISOString(),
expiresAt: new Date(now.getTime() + config.stateTtlSeconds * 1000).toISOString(),
});
const start = await app.inject({
method: "POST",
url: `/v1/oauth/${provider}/start`,
headers: { "x-heimdall-app-secret": config.appSharedSecrets[appSlug] ?? "" },
payload: {
appSlug,
mode,
returnTo,
handoff: { kind: "browser_completion", attemptId: attempt.handle },
...(entitlementPolicy ? { entitlementPolicy } : {}),
},
});
const startPayload = start.json<Record<string, unknown>>();
if (start.statusCode !== 201) {
await getHeimdallRuntimeContext(app).store.updateAuthAttempt(appSlug, attempt.handle, {
status: "denied",
at: new Date().toISOString(),
denialCode: String(startPayload.error ?? "oauth_start_denied"),
});
throw new Error(String(startPayload.detail ?? startPayload.error ?? "OAuth start was denied."));
}
const authorizationUrl = String(startPayload.authorizationUrl ?? "");
const authorizationOrigin = new URL(authorizationUrl).origin;
return {
status: "accepted",
payloadSchema: "heimdall.auth_begin_receipt.v1",
payload: {
schema: "heimdall.auth_begin_receipt.v1",
status: "pending",
handle: attempt.handle,
expiresAt: attempt.expiresAt,
navigation: {
url: authorizationUrl,
allowedOrigins: [authorizationOrigin, new URL(config.publicBaseUrl).origin],
},
},
};
}
async function completeAuth(
app: FastifyInstance,
appSlug: AppSlug,
payload: Record<string, unknown>,
): Promise<{ status: string; payloadSchema: string; payload: Record<string, unknown> }> {
const handle = String(payload.handle ?? "");
if (!handle) throw new Error("Auth completion requires an opaque attempt handle.");
const store = getHeimdallRuntimeContext(app).store;
const attempt = await store.findAuthAttempt(appSlug, handle);
if (!attempt) throw new Error("Auth attempt is unknown for this app.");
const now = new Date().toISOString();
if (attempt.expiresAt <= now && attempt.status !== "completed") {
await store.updateAuthAttempt(appSlug, handle, { status: "expired", at: now });
return authCompletion("denied", { error: "expired_attempt" });
}
if (attempt.status === "pending") return authCompletion("pending", { handle });
if (attempt.status !== "completed") return authCompletion("denied", { error: attempt.denialCode ?? `attempt_${attempt.status}` });
const redemption = await app.inject({
method: "POST",
url: `/v1/apps/${appSlug}/auth-completions/redeem`,
payload: { completionCode: handle },
});
if (redemption.statusCode !== 201) return authCompletion("denied", { error: "invalid_or_expired_completion" });
return authCompletion("authenticated", redemption.json<Record<string, unknown>>());
}
function authCompletion(status: string, values: Record<string, unknown>) {
return {
status: status === "authenticated" ? "accepted" : status,
payloadSchema: "heimdall.auth_completion_receipt.v1",
payload: { ...values, schema: "heimdall.auth_completion_receipt.v1", status },
};
}
function parseEntitlementPolicy(value: unknown): OAuthEntitlementPolicy | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const record = value as Record<string, unknown>;
if (record.kind === "discord_role_access" && typeof record.guildId === "string" && Array.isArray(record.allowedRoleIds)) {
const allowedRoleIds = record.allowedRoleIds.filter((item): item is string => typeof item === "string" && Boolean(item));
return allowedRoleIds.length ? { kind: "discord_role_access", guildId: record.guildId, allowedRoleIds } : undefined;
}
if (record.kind === "patreon_membership_access" && typeof record.requiredTierTitle === "string") {
return { kind: "patreon_membership_access", requiredTierTitle: record.requiredTierTitle };
}
return undefined;
}
function response(
request: CultNetOperationRequestMessage,
status: string,
envelope: HeimdallPrivateEnvelope,
runtimeId: string,
): CultNetOperationResponseMessage {
return {
schemaVersion: "cultnet.operation_response.v0",
messageId: request.messageId,
serviceId: request.serviceId,
operation: request.operation,
status,
payloadSchema: HEIMDALL_PRIVATE_ENVELOPE_SCHEMA,
payloadEncoding: "messagepack-base64",
payload: Buffer.from(encode(envelope)).toString("base64"),
diagnostics: [],
sourceRuntimeId: runtimeId,
};
}

View file

@ -0,0 +1,129 @@
import {
createCipheriv,
createDecipheriv,
createHash,
createHmac,
randomBytes,
timingSafeEqual,
} from "node:crypto";
import { decode, encode } from "@msgpack/msgpack";
export interface HeimdallPrivateEnvelope {
schema: "heimdall.private_command_envelope.v1";
appSlug: string;
operation: string;
contentSchema: string;
issuedAt: string;
expiresAt: string;
nonce: string;
idempotencyKey: string;
iv: string;
ciphertext: string;
authTag: string;
signature: string;
}
function keyFor(secret: string): Buffer {
return createHash("sha256").update("heimdall.private-command.v1\0").update(secret).digest();
}
function authenticatedFields(envelope: Omit<HeimdallPrivateEnvelope, "signature">): string {
return [
envelope.schema,
envelope.appSlug,
envelope.operation,
envelope.contentSchema,
envelope.issuedAt,
envelope.expiresAt,
envelope.nonce,
envelope.idempotencyKey,
envelope.iv,
envelope.ciphertext,
envelope.authTag,
].join("\n");
}
function aad(envelope: Pick<HeimdallPrivateEnvelope, "schema" | "appSlug" | "operation" | "contentSchema" | "issuedAt" | "expiresAt" | "nonce" | "idempotencyKey">): Buffer {
return Buffer.from([
envelope.schema,
envelope.appSlug,
envelope.operation,
envelope.contentSchema,
envelope.issuedAt,
envelope.expiresAt,
envelope.nonce,
envelope.idempotencyKey,
].join("\n"), "utf8");
}
export function sealPrivateEnvelope(options: {
appSlug: string;
operation: string;
contentSchema: string;
idempotencyKey: string;
secret: string;
payload: Record<string, unknown>;
issuedAt?: string;
expiresAt?: string;
nonce?: string;
}): HeimdallPrivateEnvelope {
const issuedAt = options.issuedAt ?? new Date().toISOString();
const expiresAt = options.expiresAt ?? new Date(Date.parse(issuedAt) + 30_000).toISOString();
const nonce = options.nonce ?? randomBytes(16).toString("base64url");
const iv = randomBytes(12);
const partial = {
schema: "heimdall.private_command_envelope.v1" as const,
appSlug: options.appSlug,
operation: options.operation,
contentSchema: options.contentSchema,
issuedAt,
expiresAt,
nonce,
idempotencyKey: options.idempotencyKey,
iv: iv.toString("base64url"),
ciphertext: "",
authTag: "",
};
const cipher = createCipheriv("aes-256-gcm", keyFor(options.secret), iv);
cipher.setAAD(aad(partial));
const ciphertext = Buffer.concat([
cipher.update(encode(options.payload)),
cipher.final(),
]);
partial.ciphertext = ciphertext.toString("base64url");
partial.authTag = cipher.getAuthTag().toString("base64url");
const signature = createHmac("sha256", options.secret).update(authenticatedFields(partial)).digest("base64url");
return { ...partial, signature };
}
export function openPrivateEnvelope(
envelope: HeimdallPrivateEnvelope,
secret: string,
now = Date.now(),
): Record<string, unknown> {
if (envelope.schema !== "heimdall.private_command_envelope.v1") throw new Error("Unsupported private command envelope.");
const issuedAt = Date.parse(envelope.issuedAt);
const expiresAt = Date.parse(envelope.expiresAt);
if (!Number.isFinite(issuedAt) || !Number.isFinite(expiresAt) || expiresAt <= issuedAt) {
throw new Error("Private command has invalid time bounds.");
}
if (issuedAt > now + 30_000 || expiresAt <= now || expiresAt - issuedAt > 120_000) {
throw new Error("Private command is expired or outside the accepted clock window.");
}
const { signature, ...unsigned } = envelope;
const expected = createHmac("sha256", secret).update(authenticatedFields(unsigned)).digest();
const provided = Buffer.from(signature, "base64url");
if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) {
throw new Error("Private command signature is invalid.");
}
const decipher = createDecipheriv("aes-256-gcm", keyFor(secret), Buffer.from(envelope.iv, "base64url"));
decipher.setAAD(aad(envelope));
decipher.setAuthTag(Buffer.from(envelope.authTag, "base64url"));
const plaintext = Buffer.concat([
decipher.update(Buffer.from(envelope.ciphertext, "base64url")),
decipher.final(),
]);
const payload = decode(plaintext) as unknown;
if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error("Private command payload is not an object.");
return payload as Record<string, unknown>;
}

View file

@ -2,17 +2,21 @@ import { randomUUID } from "node:crypto";
import { type AppSlug, type LinkedIdentityInput, type Provider } from "../contracts.js";
import {
type CreateAccountInput,
type CreateAuthAttemptInput,
type CreateAuthCompletionInput,
type CreateAuditEventInput,
type CreateCapabilityGrantInput,
type CreateEntitlementSnapshotInput,
type CreateSessionInput,
type CreatePrivateCommandReceiptInput,
type HeimdallStore,
type StoredAccount,
type StoredAuthAttempt,
type StoredAuthCompletion,
type StoredCapabilityGrant,
type StoredLinkedIdentity,
type StoredSession,
type StoredPrivateCommandReceipt,
type UpsertLinkedIdentityInput,
} from "./types.js";
@ -37,6 +41,8 @@ export class InMemoryStore implements HeimdallStore {
private readonly linkedIdentities = new Map<string, StoredLinkedIdentity>();
private readonly grants = new Map<string, StoredCapabilityGrant>();
private readonly sessions = new Map<string, StoredSession>();
private readonly authAttempts = new Map<string, StoredAuthAttempt>();
private readonly privateCommandReceipts = new Map<string, StoredPrivateCommandReceipt>();
private readonly authCompletions = new Map<string, StoredAuthCompletion>();
private readonly entitlementSnapshots = new Map<string, CreateEntitlementSnapshotInput>();
private readonly auditEvents = new Map<string, CreateAuditEventInput>();
@ -228,6 +234,64 @@ export class InMemoryStore implements HeimdallStore {
return clone(session);
}
async createAuthAttempt(input: CreateAuthAttemptInput): Promise<StoredAuthAttempt> {
const attempt: StoredAuthAttempt = {
handle: input.handle ?? randomUUID(),
appSlug: input.appSlug,
provider: input.provider,
mode: input.mode,
returnTo: input.returnTo,
status: "pending",
createdAt: input.createdAt,
expiresAt: input.expiresAt,
};
if (this.authAttempts.has(attempt.handle)) throw new Error("Auth attempt handle already exists.");
this.authAttempts.set(attempt.handle, clone(attempt));
return clone(attempt);
}
async findAuthAttempt(appSlug: AppSlug, handle: string): Promise<StoredAuthAttempt | null> {
const attempt = this.authAttempts.get(handle);
return attempt?.appSlug === appSlug ? clone(attempt) : null;
}
async updateAuthAttempt(
appSlug: AppSlug,
handle: string,
update: { status: import("../contracts.js").HeimdallAuthAttemptStatus; at: string; denialCode?: string },
): Promise<StoredAuthAttempt | null> {
const attempt = this.authAttempts.get(handle);
if (!attempt || attempt.appSlug !== appSlug) return null;
const allowed = attempt.status === update.status ||
(attempt.status === "pending" && ["completed", "denied", "expired"].includes(update.status)) ||
(attempt.status === "completed" && update.status === "consumed");
if (!allowed) return clone(attempt);
attempt.status = update.status;
if (update.status === "completed") attempt.completedAt = update.at;
if (update.status === "consumed") attempt.consumedAt = update.at;
if (update.denialCode !== undefined) attempt.denialCode = update.denialCode;
this.authAttempts.set(handle, clone(attempt));
return clone(attempt);
}
async createPrivateCommandReceipt(input: CreatePrivateCommandReceiptInput): Promise<StoredPrivateCommandReceipt> {
const key = `${input.appSlug}:${input.idempotencyKey}`;
const existing = this.privateCommandReceipts.get(key);
if (existing) {
if (existing.requestFingerprint !== input.requestFingerprint) {
throw new Error("Idempotency key was reused with different command content.");
}
return clone(existing);
}
this.privateCommandReceipts.set(key, clone(input));
return clone(input);
}
async findPrivateCommandReceipt(appSlug: AppSlug, idempotencyKey: string): Promise<StoredPrivateCommandReceipt | null> {
const receipt = this.privateCommandReceipts.get(`${appSlug}:${idempotencyKey}`);
return receipt ? clone(receipt) : null;
}
async createAuthCompletion(input: CreateAuthCompletionInput): Promise<StoredAuthCompletion> {
const completion: StoredAuthCompletion = {
code: input.code ?? randomUUID(),

View file

@ -1,20 +1,24 @@
import { randomUUID } from "node:crypto";
import { Pool, type PoolClient, type QueryResultRow } from "pg";
import { type AppSlug, type LinkedIdentityInput, type Provider } from "../contracts.js";
import { type AppSlug, type HeimdallAuthAttemptStatus, type LinkedIdentityInput, type Provider } from "../contracts.js";
import { CREATE_SCHEMA_SQL } from "./schema.js";
import {
type CreateAccountInput,
type CreateAuthAttemptInput,
type CreateAuthCompletionInput,
type CreateAuditEventInput,
type CreateCapabilityGrantInput,
type CreateEntitlementSnapshotInput,
type CreateSessionInput,
type CreatePrivateCommandReceiptInput,
type HeimdallStore,
type StoredAccount,
type StoredAuthAttempt,
type StoredAuthCompletion,
type StoredCapabilityGrant,
type StoredLinkedIdentity,
type StoredSession,
type StoredPrivateCommandReceipt,
type UpsertLinkedIdentityInput,
} from "./types.js";
@ -82,6 +86,31 @@ interface AuthCompletionRow extends QueryResultRow {
consumed_at: string | Date | null;
}
interface AuthAttemptRow extends QueryResultRow {
handle: string;
app_slug: AppSlug;
provider: Provider;
mode: "sign_in" | "link" | "connect";
return_to: string;
status: HeimdallAuthAttemptStatus;
created_at: string | Date;
expires_at: string | Date;
completed_at: string | Date | null;
consumed_at: string | Date | null;
denial_code: string | null;
}
interface PrivateCommandReceiptRow extends QueryResultRow {
app_slug: AppSlug;
idempotency_key: string;
request_fingerprint: string;
status: string;
content_schema: string;
envelope_base64: string;
created_at: string | Date;
expires_at: string | Date;
}
function expectRow<T>(row: T | undefined, label: string): T {
if (!row) {
throw new Error(`${label} query returned no rows.`);
@ -220,6 +249,36 @@ function mapAuthCompletionRow(row: AuthCompletionRow): StoredAuthCompletion {
return completion;
}
function mapPrivateCommandReceiptRow(row: PrivateCommandReceiptRow): StoredPrivateCommandReceipt {
return {
appSlug: row.app_slug,
idempotencyKey: row.idempotency_key,
requestFingerprint: row.request_fingerprint,
status: row.status,
contentSchema: row.content_schema,
envelopeBase64: row.envelope_base64,
createdAt: normalizeTimestamp(row.created_at),
expiresAt: normalizeTimestamp(row.expires_at),
};
}
function mapAuthAttemptRow(row: AuthAttemptRow): StoredAuthAttempt {
const attempt: StoredAuthAttempt = {
handle: row.handle,
appSlug: row.app_slug,
provider: row.provider,
mode: row.mode,
returnTo: row.return_to,
status: row.status,
createdAt: normalizeTimestamp(row.created_at),
expiresAt: normalizeTimestamp(row.expires_at),
};
if (row.completed_at) attempt.completedAt = normalizeTimestamp(row.completed_at);
if (row.consumed_at) attempt.consumedAt = normalizeTimestamp(row.consumed_at);
if (row.denial_code) attempt.denialCode = row.denial_code;
return attempt;
}
export class PostgresStore implements HeimdallStore {
constructor(private readonly pool: Pick<Pool, "query" | "end">) {}
@ -501,6 +560,88 @@ export class PostgresStore implements HeimdallStore {
return mapSessionRow(expectRow(result.rows[0], "createSession"));
}
async createAuthAttempt(input: CreateAuthAttemptInput): Promise<StoredAuthAttempt> {
const handle = input.handle ?? randomUUID();
const result = await this.pool.query<AuthAttemptRow>(
`INSERT INTO auth_attempts (
handle, app_slug, provider, mode, return_to, status, created_at, expires_at
) VALUES ($1, $2, $3, $4, $5, 'pending', $6, $7) RETURNING *`,
[handle, input.appSlug, input.provider, input.mode, input.returnTo, input.createdAt, input.expiresAt],
);
return mapAuthAttemptRow(expectRow(result.rows[0], "createAuthAttempt"));
}
async findAuthAttempt(appSlug: AppSlug, handle: string): Promise<StoredAuthAttempt | null> {
const result = await this.pool.query<AuthAttemptRow>(
"SELECT * FROM auth_attempts WHERE app_slug = $1 AND handle = $2",
[appSlug, handle],
);
return result.rowCount ? mapAuthAttemptRow(expectRow(result.rows[0], "findAuthAttempt")) : null;
}
async updateAuthAttempt(
appSlug: AppSlug,
handle: string,
update: { status: HeimdallAuthAttemptStatus; at: string; denialCode?: string },
): Promise<StoredAuthAttempt | null> {
const result = await this.pool.query<AuthAttemptRow>(
`UPDATE auth_attempts SET
status = $3,
completed_at = CASE WHEN $3 = 'completed' THEN $4 ELSE completed_at END,
consumed_at = CASE WHEN $3 = 'consumed' THEN $4 ELSE consumed_at END,
denial_code = COALESCE($5, denial_code)
WHERE app_slug = $1 AND handle = $2
AND (
status = $3 OR
(status = 'pending' AND $3 IN ('completed', 'denied', 'expired')) OR
(status = 'completed' AND $3 = 'consumed')
)
RETURNING *`,
[appSlug, handle, update.status, update.at, nullable(update.denialCode)],
);
if (result.rowCount) return mapAuthAttemptRow(expectRow(result.rows[0], "updateAuthAttempt"));
return await this.findAuthAttempt(appSlug, handle);
}
async createPrivateCommandReceipt(input: CreatePrivateCommandReceiptInput): Promise<StoredPrivateCommandReceipt> {
const result = await this.pool.query<PrivateCommandReceiptRow>(
`INSERT INTO private_command_receipts (
app_slug, idempotency_key, request_fingerprint, status,
content_schema, envelope_base64, created_at, expires_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (app_slug, idempotency_key) DO NOTHING
RETURNING *`,
[
input.appSlug,
input.idempotencyKey,
input.requestFingerprint,
input.status,
input.contentSchema,
input.envelopeBase64,
input.createdAt,
input.expiresAt,
],
);
const existing = result.rowCount
? mapPrivateCommandReceiptRow(expectRow(result.rows[0], "createPrivateCommandReceipt"))
: await this.findPrivateCommandReceipt(input.appSlug, input.idempotencyKey);
if (!existing) throw new Error("Private command receipt conflict returned no record.");
if (existing.requestFingerprint !== input.requestFingerprint) {
throw new Error("Idempotency key was reused with different command content.");
}
return existing;
}
async findPrivateCommandReceipt(appSlug: AppSlug, idempotencyKey: string): Promise<StoredPrivateCommandReceipt | null> {
const result = await this.pool.query<PrivateCommandReceiptRow>(
"SELECT * FROM private_command_receipts WHERE app_slug = $1 AND idempotency_key = $2",
[appSlug, idempotencyKey],
);
return result.rowCount
? mapPrivateCommandReceiptRow(expectRow(result.rows[0], "findPrivateCommandReceipt"))
: null;
}
async createAuthCompletion(input: CreateAuthCompletionInput): Promise<StoredAuthCompletion> {
const code = input.code ?? randomUUID();
const result = await this.pool.query<AuthCompletionRow>(

View file

@ -42,6 +42,38 @@ CREATE TABLE IF NOT EXISTS sessions (
CREATE INDEX IF NOT EXISTS sessions_account_id_idx
ON sessions(account_id);
CREATE TABLE IF NOT EXISTS auth_attempts (
handle TEXT PRIMARY KEY,
app_slug TEXT NOT NULL,
provider TEXT NOT NULL,
mode TEXT NOT NULL,
return_to TEXT NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ,
consumed_at TIMESTAMPTZ,
denial_code TEXT
);
CREATE INDEX IF NOT EXISTS auth_attempts_lookup_idx
ON auth_attempts(app_slug, expires_at, status);
CREATE TABLE IF NOT EXISTS private_command_receipts (
app_slug TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
request_fingerprint TEXT NOT NULL,
status TEXT NOT NULL,
content_schema TEXT NOT NULL,
envelope_base64 TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (app_slug, idempotency_key)
);
CREATE INDEX IF NOT EXISTS private_command_receipts_expiry_idx
ON private_command_receipts(expires_at);
CREATE TABLE IF NOT EXISTS auth_completions (
code TEXT PRIMARY KEY,
app_slug TEXT NOT NULL,

View file

@ -1,4 +1,10 @@
import { type AppSlug, type LinkedIdentityInput, type OAuthMode, type Provider } from "../contracts.js";
import {
type AppSlug,
type HeimdallAuthAttemptStatus,
type LinkedIdentityInput,
type OAuthMode,
type Provider,
} from "../contracts.js";
export interface StoredAccount {
id: string;
@ -148,6 +154,43 @@ export interface CreateAuthCompletionInput {
payloadJson: Record<string, unknown>;
}
export interface StoredAuthAttempt {
handle: string;
appSlug: AppSlug;
provider: Provider;
mode: OAuthMode;
returnTo: string;
status: HeimdallAuthAttemptStatus;
createdAt: string;
expiresAt: string;
completedAt?: string;
consumedAt?: string;
denialCode?: string;
}
export interface CreateAuthAttemptInput {
handle?: string;
appSlug: AppSlug;
provider: Provider;
mode: OAuthMode;
returnTo: string;
createdAt: string;
expiresAt: string;
}
export interface StoredPrivateCommandReceipt {
appSlug: AppSlug;
idempotencyKey: string;
requestFingerprint: string;
status: string;
contentSchema: string;
envelopeBase64: string;
createdAt: string;
expiresAt: string;
}
export type CreatePrivateCommandReceiptInput = StoredPrivateCommandReceipt;
export interface HeimdallStore {
ensureSchema(): Promise<void>;
close(): Promise<void>;
@ -163,6 +206,15 @@ export interface HeimdallStore {
createCapabilityGrant(input: CreateCapabilityGrantInput): Promise<StoredCapabilityGrant>;
listActiveGrants(accountId: string, appSlug: AppSlug, at: string): Promise<StoredCapabilityGrant[]>;
createSession(input: CreateSessionInput): Promise<StoredSession>;
createAuthAttempt(input: CreateAuthAttemptInput): Promise<StoredAuthAttempt>;
findAuthAttempt(appSlug: AppSlug, handle: string): Promise<StoredAuthAttempt | null>;
updateAuthAttempt(
appSlug: AppSlug,
handle: string,
update: { status: HeimdallAuthAttemptStatus; at: string; denialCode?: string },
): Promise<StoredAuthAttempt | null>;
createPrivateCommandReceipt(input: CreatePrivateCommandReceiptInput): Promise<StoredPrivateCommandReceipt>;
findPrivateCommandReceipt(appSlug: AppSlug, idempotencyKey: string): Promise<StoredPrivateCommandReceipt | null>;
createAuthCompletion(input: CreateAuthCompletionInput): Promise<StoredAuthCompletion>;
consumeAuthCompletion(appSlug: AppSlug, code: string, at: string): Promise<StoredAuthCompletion | null>;
upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise<void>;

View file

@ -2,6 +2,7 @@ import { mkdir, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { encode } from "@msgpack/msgpack";
import { appSlugs, providers } from "./contracts.js";
import { buildHeimdallAccessPluginAdvertisement } from "./access-plugin.js";
import type { HeimdallConfig } from "./config.js";
import { buildHeimdallProviderAdvertisement } from "./verse-witness.js";
@ -55,6 +56,14 @@ export async function publishHeimdallVerseState(config: HeimdallConfig, pulse: H
payload: buildCommandBoundary(config, pulse),
storedAt: pulse.updatedAt,
},
{
key: "gamecult.heimdall.access",
schemaId: "gamecult.eve.plugin_advertisement.v1",
schemaName: "gamecult.eve.plugin_advertisement",
schemaVersion: "gamecult.eve.plugin_advertisement.v1",
payload: buildHeimdallAccessPluginAdvertisement(),
storedAt: pulse.updatedAt,
},
{
key: "heimdall",
schemaId: "heimdall.transport_profile.v1",
@ -98,7 +107,7 @@ function buildRuntimeProviderAdvertisement(config: HeimdallConfig, pulse: Heimda
...advertisement.controlSurface,
controls: {
...advertisement.controlSurface.controls,
reason: "This runtime publication advertises provider shape and lifecycle boundaries. Auth mutation remains behind explicit Heimdall HTTP APIs.",
reason: "This runtime publication advertises provider, plugin, and lifecycle boundaries. Auth mutation remains behind Heimdall's authenticated private command plane or retained HTTP compatibility APIs.",
},
},
runtime: {
@ -108,6 +117,7 @@ function buildRuntimeProviderAdvertisement(config: HeimdallConfig, pulse: Heimda
cultCachePath: witnessPath,
idunnHealthContract: config.idunnHealthContract,
idunnRudpHealth: config.idunnRudpHealth ?? null,
privateCommandRoute: `rudp://${config.privateCommandHost ?? "127.0.0.1"}:${config.privateCommandPort ?? 4101}`,
},
};
}
@ -127,7 +137,24 @@ function buildCommandBoundary(config: HeimdallConfig, pulse: HeimdallRuntimePuls
publicationSource: config.idunnRudpHealth ? "daemon-published" : "daemon-published-cultcache",
stateOwner: "Heimdall auth runtime",
},
commands: [],
commands: [
{
operation: "heimdall.auth.begin",
requestSchema: "heimdall.private_command_envelope.v1",
responseSchema: "heimdall.auth_begin_receipt.v1",
},
{
operation: "heimdall.auth.complete",
requestSchema: "heimdall.private_command_envelope.v1",
responseSchema: "heimdall.auth_completion_receipt.v1",
},
],
privateRoute: {
endpoint: `rudp://${config.privateCommandHost ?? "127.0.0.1"}:${config.privateCommandPort ?? 4101}`,
exposure: "loopback-only",
authentication: "app-bound HMAC + AES-256-GCM envelope",
secretBearing: false,
},
forbiddenWriters: [
"Odin and Idunn may observe Heimdall boundary state but do not mutate auth/control-plane truth.",
"HTTP /healthz, JWKS, discovery, systemd, and nginx routing are compatibility witnesses, not daemon truth.",

View file

@ -8,6 +8,7 @@ export const heimdallWitnessSchemaIds = [
"heimdall.capability_grant.v0",
"heimdall.session.v0",
"heimdall.entitlement_snapshot.v0",
"heimdall.auth_attempt.v1",
"heimdall.auth_completion.v0",
"heimdall.audit_event.v0",
"heimdall.app_profile.v0",
@ -66,6 +67,14 @@ export const heimdallWitnessDocuments: readonly HeimdallWitnessDocumentDescripto
ownedTruth: "Cached provider entitlement evaluation result.",
forbiddenFields: ["rawProviderResponse", "providerAccessToken", "providerRefreshToken"],
},
{
schemaId: "heimdall.auth_attempt.v1",
documentType: "heimdall.auth_attempt.v1",
cultCachePath: "cultcache/heimdall/auth-attempts/{attemptHandleHash}.cc",
redaction: "Attempt handles, return URLs, nonces, signatures, and policy contents stay private; export only app, provider, status, and expiry.",
ownedTruth: "Persistent Heimdall-owned OAuth attempt lifecycle.",
forbiddenFields: ["handle", "returnTo", "nonce", "signature", "entitlementPolicy"],
},
{
schemaId: "heimdall.auth_completion.v0",
documentType: "heimdall.auth_completion.v0",
@ -160,6 +169,7 @@ export interface EveProviderAdvertisementDocument {
providers: HeimdallAdvertisedProvider[];
appProfiles: HeimdallAdvertisedAppProfile[];
documents: HeimdallWitnessDocumentDescriptor[];
plugins: Array<Record<string, unknown>>;
}
export function buildHeimdallProviderAdvertisement(options: { updatedAt: string }): EveProviderAdvertisementDocument {
@ -172,7 +182,7 @@ export function buildHeimdallProviderAdvertisement(options: { updatedAt: string
locatedService: "asgard.yggdrasil.heimdall",
cultMeshAddress: "asgard.yggdrasil.heimdall/eve/tui",
title: "Heimdall",
description: "Shared GameCult auth/control-plane authority with redacted CultCache witness projections.",
description: "Shared GameCult auth/control-plane authority with a reusable Eve access plugin and redacted CultCache witness projections.",
version: "read-only-witness-v0",
status: "read_only_witness_planned",
updatedAt: options.updatedAt,
@ -188,6 +198,8 @@ export function buildHeimdallProviderAdvertisement(options: { updatedAt: string
"session-claims",
"managed-credential-custody",
"redacted-cultcache-witness",
"eve-access-plugin",
"private-cultnet-auth-commands",
],
usesCultMesh: true,
transport: "CultMesh provider advertisement; first cut is a read-only fixture/export.",
@ -244,5 +256,17 @@ export function buildHeimdallProviderAdvertisement(options: { updatedAt: string
};
}),
documents: [...heimdallWitnessDocuments],
plugins: [
{
schema: "gamecult.eve.plugin_advertisement.v1",
pluginId: "gamecult.heimdall.access",
ownerService: "asgard.heimdall",
version: "0.1.0",
manifestAddress: "cultmesh://gamecult/heimdall/plugins/access/manifest",
capabilities: ["auth.gate", "auth.begin", "auth.complete", "auth.logout"],
componentKinds: ["heimdall.access_gate", "heimdall.identity", "heimdall.access_status"],
commands: ["heimdall.auth.begin", "heimdall.auth.complete", "app.auth.logout"],
},
],
};
}

View file

@ -30,3 +30,4 @@
{"ts": "2026-06-09T19:21:17+00:00", "type": "implementation", "status": "accepted", "note": "Bifrost patron support sync reuses Heimdall's existing linked Patreon identity and membership reader. Heimdall exposes app-authenticated /v1/apps/bifrost/patron-support/sync, refreshes Patreon custody when needed, maps active paid tier membership into a signed RecurringSupportSnapshot, and posts it to Bifrost's /heimdall/patron-support/events intake; Bifrost owns points, tiers, voting weight, and ledger meaning."}
{"ts": "2026-06-16T16:59:54+00:00", "type": "implementation", "status": "accepted", "note": "Heimdall now writes a daemon-owned CultCache boundary store with provider advertisement, command boundary, transport profile, and daemon-health summary state, and can publish heimdall.cultnet-rudp-provider-health to Idunn over cultnet.transport.rudp.v0 when configured."}
{"ts":"2026-08-20T18:32:47+00:00","type":"deployment","status":"ok","note":"Yggdrasil runs Heimdall commit 2a905354b9dc85101463030dea9045c1561a4638 with a persistent machine-bound Ed25519 provider-health identity and canonical idunn.signed_daemon_health.v1 publication. Idunn at Odin commit 5586910fa704b8a0d330b90a902a89b99566db77 has the exact root trust binding, retries interrupted UDP receives, respects whole-second ingress timestamp precision, admitted seven consecutive Heimdall pulses as authority=authenticated at the intended cadence, evaluated the daemon healthy, and maintained the redacted public provider-health .cc projection. Both services were active with zero restarts and the deployment brake engaged."}
{"ts": "2026-08-21T22:37:58+00:00", "type": "implementation", "status": "accepted", "note": "Ghostlight auth now enters Heimdall through gamecult.heimdall.access plus a loopback CultNet operation plane. Heimdall persists attempts and completions; app-secret/source-runtime/expiry/nonce/idempotency binding and AES-256-GCM keep completion claims off public CultMesh and browser state. The plugin is projection-only and cannot grant access."}

View file

@ -12,9 +12,9 @@ objective:
Create a shared auth authority service that Repixelizer, StreamPixels, and
future GameCult-hosted experiments can consume.
current: >
Migrate StreamPixels viewer and creator OAuth onto Heimdall while keeping
StreamPixels-owned viewer profiles, creator memberships, connector
bindings, runtime state, and audience data local.
Publish the Heimdall Eve access plugin and private CultNet authentication
plane, then migrate Ghostlight onto app-local sessions without moving
campaign or membership authority into Heimdall.
constraints:
- Heimdall owns auth/control-plane authority; app-domain data stays app-owned by default.
@ -34,6 +34,8 @@ invariants:
- Spotiverse consumes Spotify as a Heimdall-managed credential; Spotiverse does not own Spotify refresh-token custody.
- Heimdall's first Odin/Eve daemon boundary publication remains secret-safe: it writes only provider advertisement, command boundary, transport profile, and daemon-health summary state, not per-account auth truth.
- Heimdall owns its persistent provider-health signing identity and signed observations; Idunn owns root-bound admission and the public provider-health projection. Unsigned health is never lifecycle authority.
- Heimdall owns OAuth attempts and completion redemption; the Eve access plugin and browser can project or carry one opaque handle but cannot grant access.
- Private auth commands are app-secret, source-runtime, expiry, nonce, and idempotency bound; sensitive completion claims remain AES-GCM wrapped until the app backend receives them.
- Persistent state cruft is the same failure class as architecture cruft.
data_flow:
@ -56,6 +58,7 @@ data_flow:
- reject per-request auth round-trip chatter
active_subgoals:
- Migrate Ghostlight to the gamecult.heimdall.access plugin and private command plane.
- Keep the shared architecture explicit and separate from app profiles.
- Preserve the default boundary that app-domain data remains app-owned.
- Align the plan to Heimdall as a Yggdrasil-hosted authority service.
@ -64,6 +67,8 @@ active_subgoals:
- Wire StreamPixels through Heimdall for Twitch/YouTube viewer identity and managed creator-provider credentials without moving StreamPixels domain state into Heimdall.
accepted_design:
- `gamecult.heimdall.access` owns access-gate representation and browser navigation semantics; it has no access-grant or provider-state authority.
- Heimdall's loopback CultNet operation service owns authenticated auth begin/complete commands, while retained HTTP integrations remain supported for existing consumers.
- `docs/architecture.md` is the canonical shared architecture note.
- `docs/service-contract.md` records the concrete HTTP/JWKS contract of the landed service slice.
- `docs/app-profiles/*.md` are thin app bindings, not duplicate brains.
@ -86,6 +91,7 @@ accepted_design:
- Heimdall's first runtime Verse cut publishes a service-owned CultCache boundary store and optional Idunn RUDP health pulse; full redacted auth-document witness export remains a later pass.
landed_baseline:
- The Heimdall access plugin, persistent OAuth attempts, encrypted private CultNet commands, conformance fixtures, and browser reference adapter are implemented and locally verified.
- Shared architecture extracted into its own repo and renamed to Heimdall.
- Repixelizer and StreamPixels app-profile notes created.
- Repo-local state, handoff, and compaction scaffolding created.
@ -114,5 +120,5 @@ rejected_paths:
reason: reusing auth mechanics does not justify centralizing app-domain audience or product data
current_status:
summary: Heimdall is deployed on Yggdrasil at commit `2a905354b9dc85101463030dea9045c1561a4638`. Its daemon-owned boundary witness remains secret-safe, and its lifecycle health now crosses CultNet/RUDP as canonical signed state from a persistent machine-bound identity. Idunn has the exact root trust binding, admits every pulse as authenticated, evaluates Heimdall healthy, and publishes the redacted provider projection. Odin commit `5586910fa704b8a0d330b90a902a89b99566db77` keeps Idunn's UDP listeners alive across interrupted system calls and correctly compares millisecond publisher observations against its coarse whole-second ingress clock. The deployment brake is engaged; Heimdall and Idunn are active with zero restarts.
next_action: Return to Bifrost patron-support sync and the remaining browser verification passes. Preserve the signed health owner split: Heimdall signs observations; Idunn admits and projects them.
summary: The repository now contains the gamecult.heimdall.access Eve plugin, persistent auth attempts, and an authenticated encrypted loopback CultNet command plane for Ghostlight. The prior Yggdrasil release remains live until this commit is admitted through Idunn; signed health ownership remains unchanged.
next_action: Commit and push this Heimdall cut, then update Idunn/Odin deployment truth and migrate Ghostlight to the plugin/private-command contract.

231
tests/access-plugin.test.ts Normal file
View file

@ -0,0 +1,231 @@
import { decode, encode } from "@msgpack/msgpack";
import { invokeCultNetOperation } from "cultnet-ts";
import { afterEach, describe, expect, it } from "vitest";
import { buildApp } from "../src/app.js";
import { executeHeimdallAccessPlugin } from "../src/access-plugin.js";
import { type HeimdallConfig } from "../src/config.js";
import { entitlementFacts } from "../src/facts.js";
import { startHeimdallPrivateCommandPlane } from "../src/private-command-plane.js";
import { openPrivateEnvelope, sealPrivateEnvelope, type HeimdallPrivateEnvelope } from "../src/private-command-security.js";
import { type OAuthProviderRuntime } from "../src/oauth.js";
const secret = "ghostlight-private-command-secret";
const resources: Array<{ close(): Promise<unknown> }> = [];
afterEach(async () => {
await Promise.all(resources.splice(0).reverse().map(resource => resource.close()));
});
describe("Heimdall Eve access plugin", () => {
it("describes, validates, and projects access state without granting access", () => {
const description = executeHeimdallAccessPlugin({
schema: "gamecult.eve.plugin_abi.request.v1",
pluginId: "gamecult.heimdall.access",
operation: "describe",
requestId: "describe-1",
input: {},
});
expect(description.status).toBe("ok");
expect(description.output.authority).toContain("no-access-grant");
const projection = executeHeimdallAccessPlugin({
schema: "gamecult.eve.plugin_abi.request.v1",
pluginId: "gamecult.heimdall.access",
operation: "project",
requestId: "project-1",
input: {
state: {
schema: "heimdall.access_gate_state.v1",
state: "anonymous",
title: "Sign in",
detail: "Discord access is required.",
canBegin: true,
canComplete: false,
},
},
});
expect(projection.status).toBe("ok");
expect(projection.output.component).toEqual(expect.objectContaining({ kind: "heimdall.access_gate" }));
});
it("persists and redeems a private Ghostlight attempt exactly once behind encrypted CultNet envelopes", async () => {
const config = testConfig();
const app = await buildApp({
config,
oauthRuntimes: { discord: mockDiscordRuntime() },
});
resources.push(app);
const plane = await startHeimdallPrivateCommandPlane(app, config);
resources.push(plane);
const pluginDescription = await invokeCultNetOperation(plane.endpoint, {
schemaVersion: "cultnet.operation_request.v0",
messageId: "plugin-describe-transport",
serviceId: "gamecult.heimdall.access",
operation: "describe",
payloadSchema: "gamecult.eve.plugin_abi.request.v1",
payloadEncoding: "messagepack-base64",
payload: Buffer.from(encode({
schema: "gamecult.eve.plugin_abi.request.v1",
pluginId: "gamecult.heimdall.access",
operation: "describe",
requestId: "plugin-describe",
input: {},
})).toString("base64"),
sourceRuntimeId: "odin-test",
}, { runtimeId: "odin-test" });
expect(pluginDescription.status).toBe("ok");
expect(decode(Buffer.from(pluginDescription.payload, "base64"))).toMatchObject({
pluginId: "gamecult.heimdall.access",
operation: "describe",
status: "ok",
});
const beginEnvelope = sealPrivateEnvelope({
appSlug: "ghostlight",
operation: "heimdall.auth.begin",
contentSchema: "heimdall.auth_begin_command.v1",
idempotencyKey: "begin-1",
secret,
payload: {
provider: "discord",
mode: "sign_in",
returnTo: "https://yggdrasil.gamecult.org/ghostlight/",
entitlementPolicy: {
kind: "discord_role_access",
guildId: "gamecult-guild",
allowedRoleIds: ["role-ktlst"],
},
},
});
const beginResponse = await invoke(plane.endpoint, "heimdall.auth.begin", "transport-begin-1", beginEnvelope);
expect(beginResponse.status).toBe("accepted");
expect(beginResponse.payloadSchema).toBe("heimdall.private_command_envelope.v1");
expect(beginResponse.payload).not.toContain("discord.com");
const beginWire = decodeEnvelope(beginResponse.payload);
expect(beginWire.contentSchema).toBe("heimdall.auth_begin_receipt.v1");
const begin = openPrivateEnvelope(beginWire, secret);
expect(begin.schema).toBe("heimdall.auth_begin_receipt.v1");
const handle = String(begin.handle);
const navigation = begin.navigation as { url: string };
const state = new URL(navigation.url).searchParams.get("state");
expect(state).toBeTruthy();
const callback = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(state ?? "")}`,
});
expect(callback.statusCode, callback.body).toBe(201);
expect(callback.json().completion.code).toBe(handle);
const completeEnvelope = sealPrivateEnvelope({
appSlug: "ghostlight",
operation: "heimdall.auth.complete",
contentSchema: "heimdall.auth_complete_command.v1",
idempotencyKey: "complete-1",
secret,
payload: { handle },
});
const completionResponse = await invoke(plane.endpoint, "heimdall.auth.complete", "transport-complete-1", completeEnvelope);
expect(completionResponse.status).toBe("accepted");
expect(completionResponse.payloadSchema).toBe("heimdall.private_command_envelope.v1");
expect(completionResponse.payload).not.toContain("heimdall_access");
const completionWire = decodeEnvelope(completionResponse.payload);
expect(completionWire.contentSchema).toBe("heimdall.auth_completion_receipt.v1");
const completion = openPrivateEnvelope(completionWire, secret);
expect(completion.status).toBe("authenticated");
expect(completion.accessToken).toEqual(expect.any(String));
const retry = await invoke(plane.endpoint, "heimdall.auth.complete", "transport-complete-retry", completeEnvelope);
expect(openPrivateEnvelope(decodeEnvelope(retry.payload), secret)).toEqual(completion);
const wrongRuntime = await invokeCultNetOperation(plane.endpoint, request(
"heimdall.auth.complete",
"transport-wrong-runtime",
completeEnvelope,
"not-ghostlight",
), { runtimeId: "not-ghostlight" });
expect(wrongRuntime.status).toBe("denied");
}, 20_000);
});
async function invoke(endpoint: string, operation: string, messageId: string, envelope: HeimdallPrivateEnvelope) {
return await invokeCultNetOperation(endpoint, request(operation, messageId, envelope, "yggdrasil-ghostlight"), {
runtimeId: "yggdrasil-ghostlight",
});
}
function request(operation: string, messageId: string, envelope: HeimdallPrivateEnvelope, sourceRuntimeId: string) {
return {
schemaVersion: "cultnet.operation_request.v0" as const,
messageId,
serviceId: "heimdall.private.commands",
operation,
payloadSchema: "heimdall.private_command_envelope.v1",
payloadEncoding: "messagepack-base64" as const,
payload: Buffer.from(encode(envelope)).toString("base64"),
sourceRuntimeId,
};
}
function decodeEnvelope(payload: string): HeimdallPrivateEnvelope {
return decode(Buffer.from(payload, "base64")) as HeimdallPrivateEnvelope;
}
function testConfig(): HeimdallConfig {
return {
serviceName: "heimdall",
host: "127.0.0.1",
port: 4100,
privateCommandHost: "127.0.0.1",
privateCommandPort: 0,
workspaceRoot: "F:/Projects/Heimdall",
dataRoot: "F:/Projects/Heimdall/.heimdall-data",
cultCachePath: "F:/Projects/Heimdall/.heimdall-data/cultcache/test.cc",
publicBaseUrl: "https://heimdall.gamecult.org",
issuer: "https://heimdall.gamecult.org",
daemonId: "yggdrasil-heimdall",
idunnRudpHealth: undefined,
idunnHealthContract: "heimdall.cultnet-rudp-provider-health",
providerHealthIdentityPath: "F:/Projects/Heimdall/.heimdall-data/provider-health-test.cc",
sessionTtlSeconds: 3600,
refreshTtlSeconds: 3600,
stateTtlSeconds: 600,
completionTtlSeconds: 300,
bootstrapSigningPrivateKeyOnMissing: false,
tokenEncryptionKeyBase64: Buffer.alloc(32, 7).toString("base64"),
appSharedSecrets: { ghostlight: secret },
appRuntimeIds: { ghostlight: ["yggdrasil-ghostlight"] },
appBackendCallbacks: {},
storage: { backend: "memory", applySchemaOnStartup: true },
providers: {
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
patreon: {}, github: {}, twitch: {}, youtube: {}, spotify: {},
},
};
}
function mockDiscordRuntime(): OAuthProviderRuntime {
return {
async exchangeAuthorizationCode() {
return { accessToken: "provider-access", tokenType: "Bearer", scope: ["identify", "guilds.members.read"], raw: {} };
},
async resolveIdentity() {
return { provider: "discord", providerUserId: "discord-1", displayName: "Tester", profile: {} };
},
async evaluateEntitlements({ callback }) {
return {
facts: [entitlementFacts.appAccess],
snapshots: [{
accountId: callback.accountId,
provider: "discord",
scope: "ghostlight:discord_role_access:gamecult-guild",
evaluatedAt: new Date().toISOString(),
isAllowed: true,
reasonCode: "matched_role",
rawSummaryJson: { matchedRoles: ["role-ktlst"] },
}],
};
},
};
}

View file

@ -145,6 +145,41 @@ describe("PostgresStore", () => {
const consumedAgain = await store.consumeAuthCompletion("repixelizer", completion.code, "2026-04-26T12:02:00.000Z");
expect(consumedAgain).toBeNull();
const attempt = await store.createAuthAttempt({
handle: "attempt-123",
appSlug: "ghostlight",
provider: "discord",
mode: "sign_in",
returnTo: "https://yggdrasil.gamecult.org/ghostlight/",
createdAt: now,
expiresAt: "2026-04-26T12:05:00.000Z",
});
expect(attempt.status).toBe("pending");
expect((await store.updateAuthAttempt("ghostlight", attempt.handle, {
status: "completed",
at: "2026-04-26T12:01:00.000Z",
}))?.status).toBe("completed");
expect((await store.updateAuthAttempt("ghostlight", attempt.handle, {
status: "pending",
at: "2026-04-26T12:02:00.000Z",
}))?.status).toBe("completed");
const privateReceipt = await store.createPrivateCommandReceipt({
appSlug: "ghostlight",
idempotencyKey: "idem-123",
requestFingerprint: "fingerprint-123",
status: "accepted",
contentSchema: "heimdall.auth_begin_receipt.v1",
envelopeBase64: "encrypted-messagepack",
createdAt: now,
expiresAt: "2026-04-26T12:01:00.000Z",
});
expect(await store.findPrivateCommandReceipt("ghostlight", "idem-123")).toEqual(privateReceipt);
await expect(store.createPrivateCommandReceipt({
...privateReceipt,
requestFingerprint: "different-command",
})).rejects.toThrow("Idempotency key was reused");
await store.close();
});
});

View file

@ -47,6 +47,19 @@ describe("Heimdall verse runtime publication", () => {
contract: "heimdall.cultnet-rudp-provider-health",
transport: "cultnet.transport.rudp.v0",
},
commands: [
{ operation: "heimdall.auth.begin" },
{ operation: "heimdall.auth.complete" },
],
privateRoute: {
endpoint: "rudp://127.0.0.1:4101",
exposure: "loopback-only",
},
});
expect(bySchema.get("gamecult.eve.plugin_advertisement.v1")).toMatchObject({
pluginId: "gamecult.heimdall.access",
ownerService: "asgard.heimdall",
commands: ["heimdall.auth.begin", "heimdall.auth.complete", "app.auth.logout"],
});
expect(bySchema.get("heimdall.transport_profile.v1")).toMatchObject({
daemonId: "yggdrasil-heimdall",