Bind Ghostlight logout to exact session custody

This commit is contained in:
Meta Vens 2026-08-22 01:53:21 +02:00
commit 9065dfa43b
16 changed files with 221 additions and 14 deletions

View file

@ -3,7 +3,7 @@
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
access claims, completion redemption, refresh, and exact-session revocation. Ghostlight verifies claims
locally and owns app sessions, campaigns, campaign membership, current campaign
selection, and every world mutation.
@ -26,3 +26,7 @@ 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.
Short-lived local access continues without a Heimdall round trip. Scheduled
refresh re-evaluates the Ghostlight-owned KLTST role policy; explicit logout
revokes both Ghostlight's local session and the matching Heimdall refresh
custody record.

View file

@ -219,9 +219,13 @@ Landed in the first pass:
Next in this phase:
- revocation flows
- connection lifecycle and token-rotation work
Landed for the Ghostlight private-command consumer:
- exact persisted-session validation for refresh
- revision-bound logout revocation with stale-refresh rejection
### Phase 3: Repixelizer binding
Landed in Repixelizer:

View file

@ -595,7 +595,6 @@ Examples:
The following are not landed yet:
- revocation flows
- admin/grant management surfaces
- GitHub callback runtime
- redacted per-account auth witness export and Odin/CultMesh consumption beyond
@ -608,10 +607,13 @@ The live daemon also binds a loopback-only CultNet/RUDP operation service at
persists its payloads.
- service: `heimdall.private.commands`
- operations: `heimdall.auth.begin`, `heimdall.auth.complete`
- operations: `heimdall.auth.begin`, `heimdall.auth.complete`,
`heimdall.auth.refresh`, `heimdall.auth.logout`
- request envelope: `heimdall.private_command_envelope.v1`
- begin result: `heimdall.auth_begin_receipt.v1`
- completion result: `heimdall.auth_completion_receipt.v1`
- refresh result: `heimdall.auth_refresh_receipt.v1`
- logout result: `heimdall.auth_logout_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
@ -621,6 +623,12 @@ same result only for the exact same authenticated request; content changes are
denied. Completion consumption remains single-use in Heimdall's canonical
store.
Refresh claims are admitted only while their exact persisted session,
app/account custody, access revision, and refresh expiry still match. Logout
uses the refresh claim as custody proof and atomically advances the persisted
access revision while expiring the session. A stale or concurrently superseded
refresh cannot recreate the revoked session.
`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.

View file

@ -31,7 +31,8 @@ Do not trust this file for the exact live HEAD. Always check git.
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
`heimdall.auth.begin` / `heimdall.auth.complete` / `heimdall.auth.refresh` /
`heimdall.auth.logout` 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
@ -39,6 +40,9 @@ Do not trust this file for the exact live HEAD. Always check git.
- 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
- refresh verifies the exact persisted session revision before reissuing a
claim; logout expires that session and advances its revision, so a stale or
racing refresh cannot resurrect revoked custody
- this repo now has a first stateful Heimdall service slice
- the canonical shared architecture lives in `docs/architecture.md`
@ -165,10 +169,10 @@ 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.
For the active Ghostlight cut, deploy the reviewed Heimdall and Ghostlight
releases 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

View file

@ -2,6 +2,10 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "heimdall.auth_logout_command.v1",
"type": "object",
"properties": {},
"required": ["schema", "refreshToken"],
"properties": {
"schema": { "const": "heimdall.auth_logout_command.v1" },
"refreshToken": { "type": "string", "minLength": 1 }
},
"additionalProperties": false
}

View file

@ -0,0 +1,14 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "heimdall.auth_logout_receipt.v1",
"type": "object",
"required": ["schema", "status", "sessionId", "accessRevision", "revokedAt"],
"properties": {
"schema": { "const": "heimdall.auth_logout_receipt.v1" },
"status": { "const": "revoked" },
"sessionId": { "type": "string", "minLength": 1 },
"accessRevision": { "type": "integer", "minimum": 1 },
"revokedAt": { "type": "string", "format": "date-time" }
},
"additionalProperties": false
}

View file

@ -213,7 +213,7 @@ function parseRefreshTokenPayload(payload: Record<string, unknown>): RefreshToke
return payload as unknown as RefreshTokenPayload;
}
function verifyRefreshToken(
export function verifyRefreshToken(
token: string,
appSlug: AppSlug,
config: HeimdallConfig,
@ -1432,6 +1432,14 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
}
const nowIso = new Date().toISOString();
const persistedSession = await store.findSession(request.params.appSlug, refreshClaim.sid);
if (!persistedSession
|| persistedSession.accountId !== refreshClaim.account_id
|| persistedSession.accessRevision !== refreshClaim.access_revision
|| Date.parse(persistedSession.expiresAt) <= Date.parse(nowIso)) {
reply.code(401);
return { error: "revoked_or_stale_refresh_token" };
}
const account = await store.findAccountById(refreshClaim.account_id);
const linkedIdentities = await store.listLinkedIdentitiesForAccount(refreshClaim.account_id);
const grants = await store.listActiveGrants(refreshClaim.account_id, request.params.appSlug, nowIso);

View file

@ -7,7 +7,7 @@ import {
type CultNetOperationServer,
} from "cultnet-ts";
import { type FastifyInstance } from "fastify";
import { getHeimdallRuntimeContext } from "./app.js";
import { getHeimdallRuntimeContext, verifyRefreshToken } 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";
@ -72,6 +72,8 @@ export async function startHeimdallPrivateCommandPlane(
? "heimdall.auth_complete_command.v1"
: request.operation === "heimdall.auth.refresh"
? "heimdall.auth_refresh_command.v1"
: request.operation === "heimdall.auth.logout"
? "heimdall.auth_logout_command.v1"
: undefined;
if (!expectedContentSchema || envelope.contentSchema !== expectedContentSchema) {
throw new Error("Private command content schema does not match its operation.");
@ -161,9 +163,53 @@ async function executePrivateCommand(
if (operation === "heimdall.auth.begin") return await beginAuth(app, config, appSlug, payload);
if (operation === "heimdall.auth.complete") return await completeAuth(app, appSlug, payload);
if (operation === "heimdall.auth.refresh") return await refreshAuth(app, config, appSlug, payload);
if (operation === "heimdall.auth.logout") return await logoutAuth(app, config, appSlug, payload);
throw new Error(`Unsupported Heimdall private operation '${operation}'.`);
}
async function logoutAuth(
app: FastifyInstance,
config: HeimdallConfig,
appSlug: AppSlug,
payload: Record<string, unknown>,
): Promise<{ status: string; payloadSchema: string; payload: Record<string, unknown> }> {
const refreshToken = String(payload.refreshToken ?? "");
if (!refreshToken) throw new Error("Auth logout requires the app's encrypted refresh claim.");
const context = getHeimdallRuntimeContext(app);
const claim = verifyRefreshToken(refreshToken, appSlug, config, context.keys);
if (!claim) throw new Error("Auth logout received an invalid or expired refresh claim.");
const revokedAt = new Date().toISOString();
const session = await context.store.revokeSession(
appSlug,
claim.sid,
claim.account_id,
claim.access_revision,
revokedAt,
);
if (!session || session.accessRevision !== claim.access_revision + 1) {
throw new Error("Auth logout could not revoke the exact session custody claim.");
}
await context.store.createAuditEvent({
accountId: claim.account_id,
sessionId: claim.sid,
appSlug,
eventType: "session_revoked",
eventPayloadJson: { previousAccessRevision: claim.access_revision, accessRevision: session.accessRevision },
createdAt: revokedAt,
});
return {
status: "accepted",
payloadSchema: "heimdall.auth_logout_receipt.v1",
payload: {
schema: "heimdall.auth_logout_receipt.v1",
status: "revoked",
sessionId: claim.sid,
accessRevision: session.accessRevision,
revokedAt,
},
};
}
async function refreshAuth(
app: FastifyInstance,
config: HeimdallConfig,

View file

@ -229,11 +229,45 @@ export class InMemoryStore implements HeimdallStore {
}
async createSession(input: CreateSessionInput): Promise<StoredSession> {
const existing = this.sessions.get(input.id);
if (existing && (existing.appSlug !== input.appSlug || existing.accountId !== input.accountId)) {
throw new Error("Session custody cannot move between accounts or apps.");
}
if (existing && existing.accessRevision > input.accessRevision) {
throw new Error("Session access revision cannot move backward.");
}
const session: StoredSession = clone(input);
this.sessions.set(session.id, session);
return clone(session);
}
async findSession(appSlug: AppSlug, sessionId: string): Promise<StoredSession | null> {
const session = this.sessions.get(sessionId);
return session?.appSlug === appSlug ? structuredClone(session) : null;
}
async revokeSession(
appSlug: AppSlug,
sessionId: string,
accountId: string,
expectedAccessRevision: number,
at: string,
): Promise<StoredSession | null> {
const session = this.sessions.get(sessionId);
if (!session
|| session.appSlug !== appSlug
|| session.accountId !== accountId
|| session.accessRevision !== expectedAccessRevision) return null;
const revoked = {
...session,
lastSeenAt: at,
expiresAt: at,
accessRevision: expectedAccessRevision + 1,
};
this.sessions.set(sessionId, revoked);
return structuredClone(revoked);
}
async createAuthAttempt(input: CreateAuthAttemptInput): Promise<StoredAuthAttempt> {
const attempt: StoredAuthAttempt = {
handle: input.handle ?? randomUUID(),

View file

@ -543,6 +543,9 @@ export class PostgresStore implements HeimdallStore {
expires_at = EXCLUDED.expires_at,
claims_json = EXCLUDED.claims_json,
access_revision = EXCLUDED.access_revision
WHERE sessions.account_id = EXCLUDED.account_id
AND sessions.app_slug = EXCLUDED.app_slug
AND sessions.access_revision <= EXCLUDED.access_revision
RETURNING *
`,
[
@ -560,6 +563,31 @@ export class PostgresStore implements HeimdallStore {
return mapSessionRow(expectRow(result.rows[0], "createSession"));
}
async findSession(appSlug: AppSlug, sessionId: string): Promise<StoredSession | null> {
const result = await this.pool.query<SessionRow>(
"SELECT * FROM sessions WHERE app_slug = $1 AND id = $2",
[appSlug, sessionId],
);
return result.rowCount ? mapSessionRow(expectRow(result.rows[0], "findSession")) : null;
}
async revokeSession(
appSlug: AppSlug,
sessionId: string,
accountId: string,
expectedAccessRevision: number,
at: string,
): Promise<StoredSession | null> {
const result = await this.pool.query<SessionRow>(
`UPDATE sessions
SET last_seen_at = $5, expires_at = $5, access_revision = access_revision + 1
WHERE app_slug = $1 AND id = $2 AND account_id = $3 AND access_revision = $4
RETURNING *`,
[appSlug, sessionId, accountId, expectedAccessRevision, at],
);
return result.rowCount ? mapSessionRow(expectRow(result.rows[0], "revokeSession")) : null;
}
async createAuthAttempt(input: CreateAuthAttemptInput): Promise<StoredAuthAttempt> {
const handle = input.handle ?? randomUUID();
const result = await this.pool.query<AuthAttemptRow>(

View file

@ -206,6 +206,14 @@ export interface HeimdallStore {
createCapabilityGrant(input: CreateCapabilityGrantInput): Promise<StoredCapabilityGrant>;
listActiveGrants(accountId: string, appSlug: AppSlug, at: string): Promise<StoredCapabilityGrant[]>;
createSession(input: CreateSessionInput): Promise<StoredSession>;
findSession(appSlug: AppSlug, sessionId: string): Promise<StoredSession | null>;
revokeSession(
appSlug: AppSlug,
sessionId: string,
accountId: string,
expectedAccessRevision: number,
at: string,
): Promise<StoredSession | null>;
createAuthAttempt(input: CreateAuthAttemptInput): Promise<StoredAuthAttempt>;
findAuthAttempt(appSlug: AppSlug, handle: string): Promise<StoredAuthAttempt | null>;
updateAuthAttempt(

View file

@ -153,6 +153,11 @@ function buildCommandBoundary(config: HeimdallConfig, pulse: HeimdallRuntimePuls
requestSchema: "heimdall.private_command_envelope.v1",
responseSchema: "heimdall.auth_refresh_receipt.v1",
},
{
operation: "heimdall.auth.logout",
requestSchema: "heimdall.private_command_envelope.v1",
responseSchema: "heimdall.auth_logout_receipt.v1",
},
],
privateRoute: {
endpoint: `rudp://${config.privateCommandHost ?? "127.0.0.1"}:${config.privateCommandPort ?? 4101}`,

View file

@ -31,3 +31,4 @@
{"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."}
{"ts": "2026-08-21T23:53:15+00:00", "type": "implementation", "status": "ok", "note": "Ghostlight private auth now validates refresh against exact persisted session custody and revision; logout atomically expires that session and advances its revision, preventing stale or racing refresh from resurrecting revoked access."}

View file

@ -36,6 +36,7 @@ invariants:
- 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.
- Refresh and logout are exact persisted-session transitions. App/account custody and access revision must match; logout advances the revision and stale refresh cannot recreate the session.
- Persistent state cruft is the same failure class as architecture cruft.
data_flow:
@ -69,6 +70,7 @@ active_subgoals:
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.
- Ghostlight refresh and logout use the same private CultNet boundary; refresh re-evaluates the caller-owned KLTST policy and logout revokes exact Heimdall session custody.
- `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.
@ -92,6 +94,7 @@ accepted_design:
landed_baseline:
- The Heimdall access plugin, persistent OAuth attempts, encrypted private CultNet commands, conformance fixtures, and browser reference adapter are implemented and locally verified.
- Exact-session refresh validation and logout revocation are implemented; stale or racing refresh cannot resurrect a revoked session.
- 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.
@ -120,5 +123,5 @@ rejected_paths:
reason: reusing auth mechanics does not justify centralizing app-domain audience or product data
current_status:
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.
summary: The repository contains the gamecult.heimdall.access Eve plugin, persistent auth attempts, and authenticated encrypted loopback CultNet begin, complete, refresh, and logout commands for Ghostlight. Refresh and logout are revision-bound to exact persisted session custody. The prior Yggdrasil release remains live until this commit is admitted through Idunn; signed health ownership remains unchanged.
next_action: Deploy this Heimdall release with the Eve-native Ghostlight release through Idunn, then verify Odin discovery, loopback-only port 4101, real KLTST authentication, refresh, logout, and secret absence from public surfaces.

View file

@ -158,6 +158,41 @@ describe("Heimdall Eve access plugin", () => {
expect(refresh.accessToken).toEqual(expect.any(String));
expect(refresh.refreshToken).toEqual(expect.any(String));
const logoutEnvelope = sealPrivateEnvelope({
appSlug: "ghostlight",
operation: "heimdall.auth.logout",
contentSchema: "heimdall.auth_logout_command.v1",
idempotencyKey: "logout-1",
secret,
payload: {
schema: "heimdall.auth_logout_command.v1",
refreshToken: refresh.refreshToken,
},
});
const logoutResponse = await invoke(plane.endpoint, "heimdall.auth.logout", "transport-logout-1", logoutEnvelope);
expect(logoutResponse.status).toBe("accepted");
const logout = openPrivateEnvelope(decodeEnvelope(logoutResponse.payload), secret);
expect(logout).toMatchObject({ schema: "heimdall.auth_logout_receipt.v1", status: "revoked" });
const staleRefreshEnvelope = sealPrivateEnvelope({
appSlug: "ghostlight",
operation: "heimdall.auth.refresh",
contentSchema: "heimdall.auth_refresh_command.v1",
idempotencyKey: "refresh-after-logout",
secret,
payload: {
refreshToken: refresh.refreshToken,
entitlementPolicy: {
kind: "discord_role_access",
guildId: "gamecult-guild",
allowedRoleIds: ["role-ktlst"],
},
},
});
const staleRefreshResponse = await invoke(plane.endpoint, "heimdall.auth.refresh", "transport-refresh-after-logout", staleRefreshEnvelope);
const staleRefresh = openPrivateEnvelope(decodeEnvelope(staleRefreshResponse.payload), secret);
expect(staleRefresh).toMatchObject({ schema: "heimdall.auth_refresh_receipt.v1", status: "denied" });
const retry = await invoke(plane.endpoint, "heimdall.auth.complete", "transport-complete-retry", completeEnvelope);
expect(openPrivateEnvelope(decodeEnvelope(retry.payload), secret)).toEqual(completion);

View file

@ -51,6 +51,7 @@ describe("Heimdall verse runtime publication", () => {
{ operation: "heimdall.auth.begin" },
{ operation: "heimdall.auth.complete" },
{ operation: "heimdall.auth.refresh" },
{ operation: "heimdall.auth.logout" },
],
privateRoute: {
endpoint: "rudp://127.0.0.1:4101",