Add Eve access plugin and private auth plane
This commit is contained in:
parent
3b6229652b
commit
19e5109cf4
47 changed files with 1781 additions and 35 deletions
|
|
@ -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
|
||||
|
|
|
|||
11
README.md
11
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
32
docs/eve-access-plugin.md
Normal 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.
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
29
plugins/gamecult.heimdall.access/advertisement.json
Normal file
29
plugins/gamecult.heimdall.access/advertisement.json
Normal 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"]
|
||||
}
|
||||
80
plugins/gamecult.heimdall.access/browser-adapter.ts
Normal file
80
plugins/gamecult.heimdall.access/browser-adapter.ts
Normal 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> : {};
|
||||
}
|
||||
1
plugins/gamecult.heimdall.access/fixtures/anonymous.json
Normal file
1
plugins/gamecult.heimdall.access/fixtures/anonymous.json
Normal 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}
|
||||
|
|
@ -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"}
|
||||
1
plugins/gamecult.heimdall.access/fixtures/denied.json
Normal file
1
plugins/gamecult.heimdall.access/fixtures/denied.json
Normal 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}
|
||||
1
plugins/gamecult.heimdall.access/fixtures/expired.json
Normal file
1
plugins/gamecult.heimdall.access/fixtures/expired.json
Normal 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}
|
||||
1
plugins/gamecult.heimdall.access/fixtures/pending.json
Normal file
1
plugins/gamecult.heimdall.access/fixtures/pending.json
Normal 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}
|
||||
|
|
@ -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}
|
||||
49
plugins/gamecult.heimdall.access/manifest.json
Normal file
49
plugins/gamecult.heimdall.access/manifest.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
16
schemas/heimdall.access_gate_state.v1.schema.json
Normal file
16
schemas/heimdall.access_gate_state.v1.schema.json
Normal 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
|
||||
}
|
||||
21
schemas/heimdall.auth_attempt.v1.schema.json
Normal file
21
schemas/heimdall.auth_attempt.v1.schema.json
Normal 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
|
||||
}
|
||||
35
schemas/heimdall.auth_begin_command.v1.schema.json
Normal file
35
schemas/heimdall.auth_begin_command.v1.schema.json
Normal 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
|
||||
}
|
||||
22
schemas/heimdall.auth_begin_receipt.v1.schema.json
Normal file
22
schemas/heimdall.auth_begin_receipt.v1.schema.json
Normal 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
|
||||
}
|
||||
8
schemas/heimdall.auth_complete_command.v1.schema.json
Normal file
8
schemas/heimdall.auth_complete_command.v1.schema.json
Normal 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
|
||||
}
|
||||
23
schemas/heimdall.auth_completion_receipt.v1.schema.json
Normal file
23
schemas/heimdall.auth_completion_receipt.v1.schema.json
Normal 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
|
||||
}
|
||||
12
schemas/heimdall.auth_completion_status.v1.schema.json
Normal file
12
schemas/heimdall.auth_completion_status.v1.schema.json
Normal 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
|
||||
}
|
||||
7
schemas/heimdall.auth_logout_command.v1.schema.json
Normal file
7
schemas/heimdall.auth_logout_command.v1.schema.json
Normal 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
|
||||
}
|
||||
20
schemas/heimdall.auth_navigation_receipt.v1.schema.json
Normal file
20
schemas/heimdall.auth_navigation_receipt.v1.schema.json
Normal 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
|
||||
}
|
||||
21
schemas/heimdall.private_command_envelope.v1.schema.json
Normal file
21
schemas/heimdall.private_command_envelope.v1.schema.json
Normal 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
127
src/access-plugin.ts
Normal 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> : {};
|
||||
}
|
||||
|
|
@ -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"] : [];
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
49
src/app.ts
49
src/app.ts
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
292
src/private-command-plane.ts
Normal file
292
src/private-command-plane.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
129
src/private-command-security.ts
Normal file
129
src/private-command-security.ts
Normal 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>;
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
|
|
|
|||
|
|
@ -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
231
tests/access-plugin.test.ts
Normal 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"] },
|
||||
}],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue