Publish Heimdall daemon boundary state for Idunn

This commit is contained in:
Meta Vens 2026-06-16 18:01:07 +01:00
commit 758892db87
17 changed files with 638 additions and 40 deletions

View file

@ -39,12 +39,17 @@ Landed right now:
Heimdall JWTs without phoning home on every guarded request
- a read-only Odin/Eve provider advertisement export that names redacted
CultCache witness document shapes and paths without touching live secrets
- a daemon-owned CultCache boundary witness at `GC_ACCESS_CULTCACHE_PATH`
containing the Heimdall provider advertisement, command boundary, transport
profile, and Idunn daemon-health summary
- optional Idunn daemon-health publication over `cultnet.transport.rudp.v0`
when `GC_ACCESS_IDUNN_RUDP_HEALTH` is configured
Still not landed:
- end-to-end app integrations in consumer repos
- refresh/revocation flows and admin surfaces
- runtime CultCache `.cc` witness writing or CultMesh publication
- full redacted per-document Heimdall auth witness export or CultMesh publication
Canonical docs:

View file

@ -57,6 +57,9 @@ What exists right now is:
only public provider, required access, and queue protection
- explicit state/doctrine scaffolding so the plan does not immediately turn into
soup
- 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
## Primary objective

View file

@ -40,8 +40,8 @@ Heimdall currently has durable Postgres storage for deployed auth/control-plane
state. That is acceptable for live auth operations, but it is not enough for the
GameCult Verse service contract by itself.
The next state cut is a CultCache `.cc` witness/export path for Heimdall-owned
auth/control-plane documents:
The next full state cut is a redacted CultCache `.cc` witness/export path for
Heimdall-owned auth/control-plane documents:
- `heimdall.account.v0`
- `heimdall.linked_identity.v0`
@ -53,8 +53,12 @@ auth/control-plane documents:
- `heimdall.app_profile.v0`
- `heimdall.managed_credential_projection.v0`
The `.cc` store does not need to replace Postgres in the first pass. The first
pass should export a typed, redacted, operator-safe witness of Heimdall state so
The `.cc` store does not need to replace Postgres in the first pass. The live
first pass is narrower: it publishes a daemon-owned boundary store for Odin and
Idunn. That store carries the Heimdall provider advertisement, command
boundary, transport profile, and daemon-health summary without exporting
per-account auth truth. The next pass should extend that runtime-owned boundary
store into a typed, redacted, operator-safe witness of Heimdall auth state so
CultMesh/Odin/Eve can inspect service truth without receiving provider secrets.
Sensitive fields must be handled explicitly:
@ -89,41 +93,48 @@ refreshing an entitlement snapshot. Heimdall accepts or denies those actions.
1. Define Heimdall CultCache document shapes for redacted auth/control-plane
witness state.
2. Add a read-only export command from the current store into a `.cc` witness.
3. Publish the witness through CultMesh with secret-safe projections only.
4. Add an Eve DSL provider over the witness and existing health/discovery data.
5. Register Heimdall's provider surface with Odin.
6. Only after the witness path is stable, decide whether any live state should
2. Publish a daemon-owned boundary `.cc` store plus Idunn daemon-health
summary from the live runtime.
3. Extend the boundary store into redacted auth/document witness exports.
4. Publish the witness through CultMesh with secret-safe projections only.
5. Add an Eve DSL provider over the witness and existing health/discovery data.
6. Register Heimdall's provider surface with Odin.
7. Only after the witness path is stable, decide whether any live state should
move from Postgres into a CultCache-backed primary store.
The invariant: Heimdall owns shared auth truth, not app-domain truth. CultCache
and CultMesh make Heimdall inspectable; they do not leak secrets or move product
state into the auth authority.
## Read-Only Witness First Cut
## Runtime Boundary First Cut
The current repo cut defines the read-only witness/ad surface without wiring a
runtime writer or migrating live state.
The current repo cut now publishes a daemon-owned boundary witness without
migrating live auth truth out of Postgres or the HTTP auth surface.
Authority map:
- Owner: Heimdall remains the only owner of auth/control-plane mutation.
- Inputs: static provider catalog, static app profiles, and the redacted witness
descriptor table in `src/verse-witness.ts`.
- Outputs: `gamecult.eve.provider_advertisement.v1` JSON printed by
`pnpm export:provider-advertisement`.
- Derived state: advertised CultCache paths and schema IDs are witness/export
targets. They do not prove that a live `.cc` writer exists yet.
- Forbidden writers: Odin probes, Eve renderers, dashboards, and this export
command cannot mutate accounts, linked identities, grants, sessions, tokens,
completions, entitlements, audit events, or app profiles.
- Shared paths: future CultCache export, CultMesh publication, and Eve lowering
should reuse the same document IDs and redaction doctrine.
- Inputs: static provider catalog, static app profiles, deployment/runtime
config, and the redacted witness descriptor table in `src/verse-witness.ts`.
- Outputs: the existing read-only `pnpm export:provider-advertisement` JSON plus
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.
- 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.
- Forbidden writers: Odin probes, Eve renderers, dashboards, Idunn, and the
boundary store cannot mutate accounts, linked identities, grants, sessions,
tokens, completions, entitlements, audit events, or app profiles.
- Shared paths: the runtime boundary store, future redacted auth witness
exports, CultMesh publication, and Eve lowering should reuse the same
document IDs and redaction doctrine.
- Deletion line: if a future writer needs app-domain fields or provider tokens
to make the surface useful, the design is wrong; keep those fields out or move
the concern back to the owning app/service.
to make the surface useful, the design is wrong; keep those fields out or
move the concern back to the owning app/service.
The advertised witness descriptors currently name these redacted `.cc` targets:
The boundary store currently names the daemon-owned publication seam. The next
redacted auth witness cut should add these `.cc` targets:
- `heimdall.account.v0` at `cultcache/heimdall/accounts/{accountId}.cc`
- `heimdall.linked_identity.v0` at

View file

@ -113,6 +113,13 @@ Do not trust this file for the exact live HEAD. Always check git.
for the StreamPixels migration slice
- the intended first deployment shape is a Heimdall service on Yggdrasil behind
nginx, not an embedded cross-runtime shared library fantasy
- Heimdall now writes a daemon-owned boundary witness store at
`GC_ACCESS_CULTCACHE_PATH` containing provider advertisement, command
boundary, transport profile, and daemon-health summary state
- Heimdall can now publish `heimdall.cultnet-rudp-provider-health` to Idunn
over `cultnet.transport.rudp.v0` when `GC_ACCESS_IDUNN_RUDP_HEALTH` is
configured; HTTP `/healthz`, JWKS, discovery, systemd, and nginx routing
remain compatibility witnesses
## Critical Doctrine
@ -128,6 +135,18 @@ Do not trust this file for the exact live HEAD. Always check git.
Do not continue implementation automatically from a rehydrate-only request.
If the user asks to continue on the Idunn/swarm transport cut, the current next
move is deployment and live verification:
- ship the `CultLib/packages/cultnet-ts` snapshot through the Yggdrasil
Heimdall deploy lane
- configure persistent `GC_ACCESS_CULTCACHE_PATH` under `/srv/heimdall`
- configure `GC_ACCESS_IDUNN_RUDP_HEALTH=10.77.0.2:17870`
- redeploy Heimdall on Yggdrasil
- update `check-heimdall.sh` to verify the witness store and the new `/healthz`
fields
- confirm live Idunn accepts `yggdrasil-heimdall` from `10.77.0.1`
If the user asks to continue on Spotiverse, the current next move is the real
browser `/auth/login` flow through public Heimdall. The Yggdrasil SSH tunnel
must include reverse forward

View file

@ -16,6 +16,8 @@
},
"dependencies": {
"@fastify/cors": "^11.2.0",
"@msgpack/msgpack": "^3.1.3",
"cultnet-ts": "file:../CultLib/packages/cultnet-ts",
"fastify": "^5.8.5",
"pg": "^8.20.0"
},

34
pnpm-lock.yaml generated
View file

@ -11,6 +11,12 @@ importers:
'@fastify/cors':
specifier: ^11.2.0
version: 11.2.0
'@msgpack/msgpack':
specifier: ^3.1.3
version: 3.1.3
cultnet-ts:
specifier: file:../CultLib/packages/cultnet-ts
version: file:../CultLib/packages/cultnet-ts
fastify:
specifier: ^5.8.5
version: 5.8.5
@ -228,6 +234,10 @@ packages:
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@msgpack/msgpack@3.1.3':
resolution: {integrity: sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==}
engines: {node: '>= 18'}
'@napi-rs/wasm-runtime@1.1.4':
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
peerDependencies:
@ -439,6 +449,12 @@ packages:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
cultcache-ts@file:../CultLib/packages/cultcache-ts:
resolution: {directory: ../CultLib/packages/cultcache-ts, type: directory}
cultnet-ts@file:../CultLib/packages/cultnet-ts:
resolution: {directory: ../CultLib/packages/cultnet-ts, type: directory}
define-data-property@1.1.4:
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
engines: {node: '>= 0.4'}
@ -1037,6 +1053,9 @@ packages:
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
snapshots:
'@emnapi/core@1.10.0':
@ -1163,6 +1182,8 @@ snapshots:
'@jridgewell/sourcemap-codec@1.5.5': {}
'@msgpack/msgpack@3.1.3': {}
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0
@ -1339,6 +1360,17 @@ snapshots:
cookie@1.1.1: {}
cultcache-ts@file:../CultLib/packages/cultcache-ts:
dependencies:
'@msgpack/msgpack': 3.1.3
zod: 3.25.76
cultnet-ts@file:../CultLib/packages/cultnet-ts:
dependencies:
'@msgpack/msgpack': 3.1.3
ajv: 8.20.0
cultcache-ts: file:../CultLib/packages/cultcache-ts
define-data-property@1.1.4:
dependencies:
es-define-property: 1.0.1
@ -1858,3 +1890,5 @@ snapshots:
xtend@4.0.2: {}
yallist@4.0.0: {}
zod@3.25.76: {}

View file

@ -525,6 +525,10 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
service: config.serviceName,
issuer: config.issuer,
storageBackend: config.storage.backend,
cultCachePath: config.cultCachePath,
idunnDaemon: config.daemonId,
idunnHealthContract: config.idunnHealthContract,
idunnRudpHealth: config.idunnRudpHealth ?? null,
signingKeySource: keys.source,
tokenCustodySource: tokenCustody.source,
now: new Date().toISOString(),

View file

@ -1,3 +1,5 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { appSlugs, providers, type AppSlug, type Provider } from "./contracts.js";
export interface ProviderClientConfig {
@ -15,8 +17,14 @@ export interface HeimdallConfig {
serviceName: string;
host: string;
port: number;
workspaceRoot: string;
dataRoot: string;
cultCachePath: string;
publicBaseUrl: string;
issuer: string;
daemonId: string;
idunnRudpHealth: string | undefined;
idunnHealthContract: string;
sessionTtlSeconds: number;
refreshTtlSeconds: number;
stateTtlSeconds: number;
@ -85,11 +93,19 @@ function readList(envValue: string | undefined): string[] {
return envValue?.split(",").map((entry) => entry.trim()).filter(Boolean) ?? [];
}
function readOptionalString(envValue: string | undefined): string | undefined {
const value = envValue?.trim();
return value ? value : undefined;
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): HeimdallConfig {
const sourceRoot = fileURLToPath(new URL("../", import.meta.url));
const workspaceRoot = path.resolve(sourceRoot, "..");
const host = env.HOST ?? "127.0.0.1";
const port = readInt(env.PORT, 4100);
const publicBaseUrl = trimTrailingSlash(env.GC_ACCESS_BASE_URL ?? `http://${host}:${port}`);
const issuer = trimTrailingSlash(env.GC_ACCESS_ISSUER ?? publicBaseUrl);
const dataRoot = env.GC_ACCESS_DATA_ROOT ?? path.join(workspaceRoot, ".heimdall-data");
const storageBackend =
env.GC_ACCESS_STORAGE_BACKEND === "postgres" || env.GC_ACCESS_DATABASE_URL ? "postgres" : "memory";
const providersConfig = Object.fromEntries(
@ -116,8 +132,14 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): HeimdallConfig
serviceName: "heimdall",
host,
port,
workspaceRoot,
dataRoot,
cultCachePath: env.GC_ACCESS_CULTCACHE_PATH ?? path.join(dataRoot, "cultcache", "heimdall.service.cc"),
publicBaseUrl,
issuer,
daemonId: env.GC_ACCESS_IDUNN_DAEMON ?? "yggdrasil-heimdall",
idunnRudpHealth: readOptionalString(env.GC_ACCESS_IDUNN_RUDP_HEALTH),
idunnHealthContract: env.GC_ACCESS_IDUNN_HEALTH_CONTRACT ?? "heimdall.cultnet-rudp-provider-health",
sessionTtlSeconds: readInt(env.GC_ACCESS_SESSION_TTL_SECONDS, 3600),
refreshTtlSeconds: readInt(env.GC_ACCESS_REFRESH_TTL_SECONDS, 60 * 60 * 24 * 30),
stateTtlSeconds: readInt(env.GC_ACCESS_STATE_TTL_SECONDS, 600),

190
src/idunn-rudp-health.ts Normal file
View file

@ -0,0 +1,190 @@
import dgram from "node:dgram";
import { encode } from "@msgpack/msgpack";
import {
encodeCultNetMessageForWire,
encodeRudpPacket,
type CultNetDocumentPutRawMessage,
type CultNetRudpPacket,
} from "cultnet-ts";
import type { HeimdallConfig } from "./config.js";
const CULTNET_RUDP_PROTOCOL_ID = "cultnet.transport.rudp.v0";
const IDUNN_HEALTH_RUDP_CONNECTION_ID = 0x1d0d0001;
const RUDP_HEALTH_CONNECT_ATTEMPTS = 3;
const RUDP_PULSE_POST_SEND_GRACE_MS = 1000;
const RUDP_CONNECT_TO_DATA_GRACE_MS = 300;
type Endpoint = {
host: string;
port: number;
};
type IdunnHealthInput = {
daemonId: string;
state: string;
detail: string;
observedAt: string;
};
export async function publishIdunnRudpHealth(config: HeimdallConfig, health: IdunnHealthInput): Promise<void> {
if (!config.idunnRudpHealth) {
return;
}
const endpoint = parseEndpoint(config.idunnRudpHealth);
let lastError: unknown;
for (let attempt = 1; attempt <= RUDP_HEALTH_CONNECT_ATTEMPTS; attempt += 1) {
try {
await publishIdunnRudpHealthOnce(endpoint, config, health);
return;
} catch (error) {
lastError = error;
if ((error as Error & { code?: string }).code !== "ETIMEDOUT") {
throw error;
}
}
}
throw lastError;
}
async function publishIdunnRudpHealthOnce(
endpoint: Endpoint,
config: HeimdallConfig,
health: IdunnHealthInput,
): Promise<void> {
const socket = dgram.createSocket(endpointFamily(endpoint.host));
try {
await bindSocket(socket, endpoint);
const connect = buildConnectPacket();
await sendPacket(socket, endpoint, connect);
await sleep(RUDP_CONNECT_TO_DATA_GRACE_MS);
const payload = buildDocumentPutPayload(config, health);
await sendPacket(socket, endpoint, buildSchemaDataPacket(payload));
await sleep(RUDP_PULSE_POST_SEND_GRACE_MS);
} finally {
socket.close();
}
}
function buildDocumentPutPayload(config: HeimdallConfig, health: IdunnHealthInput): Uint8Array {
const recordPayload = encode([
health.daemonId,
health.state,
health.detail,
health.observedAt,
config.idunnHealthContract,
"daemon-published",
CULTNET_RUDP_PROTOCOL_ID,
]);
const message: CultNetDocumentPutRawMessage = {
schemaVersion: "cultnet.document_put_raw.v0",
messageId: `heimdall-health:${health.daemonId}:${health.observedAt.replace(/[:.]/g, "-")}`,
document: {
schemaId: "idunn.daemon_health",
recordKey: health.daemonId,
storedAt: health.observedAt,
payloadEncoding: "messagepack",
payload: recordPayload,
sourceRuntimeId: "heimdall-service",
sourceRole: "daemon-health-publisher",
tags: [CULTNET_RUDP_PROTOCOL_ID],
},
};
return encode(encodeCultNetMessageForWire(message, "cultnet.schema.v0"));
}
function buildConnectPacket(): CultNetRudpPacket {
return {
packetType: "connect",
connectionId: IDUNN_HEALTH_RUDP_CONNECTION_ID,
sequence: 1,
ack: 0,
ackMask: 0,
channelId: "control",
reliable: true,
ordered: true,
sequenced: false,
payload: new Uint8Array(),
};
}
function buildSchemaDataPacket(payload: Uint8Array): CultNetRudpPacket {
return {
packetType: "data",
connectionId: IDUNN_HEALTH_RUDP_CONNECTION_ID,
sequence: 2,
ack: 0,
ackMask: 0,
channelId: "schema",
reliable: true,
ordered: true,
sequenced: false,
payload,
};
}
async function bindSocket(socket: dgram.Socket, endpoint: Endpoint): Promise<void> {
await new Promise<void>((resolve, reject) => {
socket.once("error", reject);
socket.bind(0, localBindHost(endpoint.host), () => {
socket.off("error", reject);
resolve();
});
});
}
function localBindHost(remoteHost: string): string {
if (remoteHost === "localhost" || remoteHost.startsWith("127.")) {
return "127.0.0.1";
}
if (remoteHost === "::1") {
return "::1";
}
return remoteHost.includes(":") ? "::" : "0.0.0.0";
}
async function sendPacket(socket: dgram.Socket, endpoint: Endpoint, input: CultNetRudpPacket): Promise<void> {
const wire = encodeRudpPacket(input);
await new Promise<void>((resolve, reject) => {
socket.send(wire, endpoint.port, endpoint.host, (error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function endpointFamily(host: string): "udp4" | "udp6" {
return host.includes(":") ? "udp6" : "udp4";
}
function parseEndpoint(value: string): Endpoint {
const text = value.trim();
const ipv6 = text.match(/^\[([^\]]+)\]:(\d+)$/);
if (ipv6) {
return { host: ipv6[1]!, port: parsePort(ipv6[2]!) };
}
const index = text.lastIndexOf(":");
if (index <= 0) {
throw new Error(`Idunn RUDP endpoint must be host:port, got "${value}".`);
}
return {
host: text.slice(0, index),
port: parsePort(text.slice(index + 1)),
};
}
function parsePort(value: string): number {
const port = Number(value);
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
throw new Error(`Idunn RUDP endpoint port is invalid: ${value}`);
}
return port;
}

View file

@ -1,13 +1,36 @@
import { buildApp } from "./app.js";
import { loadConfig } from "./config.js";
import { publishIdunnRudpHealth } from "./idunn-rudp-health.js";
import { createHeimdallRuntimePulse, buildHeimdallHealthDetail, publishHeimdallVerseState } from "./verse-state.js";
const config = loadConfig();
const app = await buildApp({ config });
const versePulseIntervalMs = 60_000;
try {
await app.listen({ host: config.host, port: config.port });
app.log.info(`Heimdall listening on ${config.host}:${config.port}`);
await publishVerseState().catch((error) => {
console.error("Heimdall verse publication failed on startup:", error);
});
const timer = setInterval(() => {
void publishVerseState().catch((error) => {
console.error("Heimdall verse publication failed on interval:", error);
});
}, versePulseIntervalMs);
timer.unref?.();
} catch (error) {
app.log.error(error);
process.exitCode = 1;
}
async function publishVerseState(): Promise<void> {
const pulse = createHeimdallRuntimePulse(config);
await publishHeimdallVerseState(config, pulse);
await publishIdunnRudpHealth(config, {
daemonId: config.daemonId,
state: "active",
detail: buildHeimdallHealthDetail(config, pulse),
observedAt: pulse.updatedAt,
});
}

203
src/verse-state.ts Normal file
View file

@ -0,0 +1,203 @@
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 type { HeimdallConfig } from "./config.js";
import { buildHeimdallProviderAdvertisement } from "./verse-witness.js";
const STORE_FORMAT_VERSION = "cultcache.store.v1";
type CultCacheRecord = {
key: string;
schemaId: string;
schemaName: string;
schemaVersion: string;
payload: unknown;
storedAt: string;
};
export type HeimdallRuntimePulse = {
updatedAt: string;
configuredProviders: string[];
storageBackend: string;
appProfileCount: number;
cultCachePath: string;
};
export function createHeimdallRuntimePulse(config: HeimdallConfig, updatedAt = new Date().toISOString()): HeimdallRuntimePulse {
return {
updatedAt,
configuredProviders: providers.filter((provider) => {
const entry = config.providers[provider];
return Boolean(entry?.clientId && entry.clientSecret);
}),
storageBackend: config.storage.backend,
appProfileCount: appSlugs.length,
cultCachePath: config.cultCachePath,
};
}
export async function publishHeimdallVerseState(config: HeimdallConfig, pulse: HeimdallRuntimePulse): Promise<void> {
const records: CultCacheRecord[] = [
{
key: "heimdall",
schemaId: "gamecult.eve.provider_advertisement.v1",
schemaName: "gamecult.eve.provider_advertisement",
schemaVersion: "gamecult.eve.provider_advertisement.v1",
payload: buildRuntimeProviderAdvertisement(config, pulse),
storedAt: pulse.updatedAt,
},
{
key: "heimdall",
schemaId: "heimdall.command_boundary.v1",
schemaName: "heimdall.command_boundary",
schemaVersion: "heimdall.command_boundary.v1",
payload: buildCommandBoundary(config, pulse),
storedAt: pulse.updatedAt,
},
{
key: "heimdall",
schemaId: "heimdall.transport_profile.v1",
schemaName: "heimdall.transport_profile",
schemaVersion: "heimdall.transport_profile.v1",
payload: buildTransportProfile(config, pulse),
storedAt: pulse.updatedAt,
},
{
key: config.daemonId,
schemaId: "idunn.daemon_health",
schemaName: "idunn.daemon_health",
schemaVersion: "idunn.daemon_health.v1",
payload: buildDaemonHealthRecord(config, pulse),
storedAt: pulse.updatedAt,
},
];
await writeCultCacheSnapshot(config.cultCachePath, records);
}
export function buildHeimdallHealthDetail(config: HeimdallConfig, pulse: HeimdallRuntimePulse): string {
const healthTransport = config.idunnRudpHealth ? "CultNet/RUDP" : "CultCache witness only";
return `Heimdall auth runtime active; storage=${pulse.storageBackend}; providers=${pulse.configuredProviders.length}/${providers.length}; apps=${pulse.appProfileCount}; healthTransport=${healthTransport}`;
}
function buildRuntimeProviderAdvertisement(config: HeimdallConfig, pulse: HeimdallRuntimePulse) {
const advertisement = buildHeimdallProviderAdvertisement({ updatedAt: pulse.updatedAt });
const witnessPath = path.relative(config.workspaceRoot, config.cultCachePath).replace(/\\/g, "/");
return {
...advertisement,
status: "daemon_live" as const,
version: "daemon-live-v1",
provider: {
...advertisement.provider,
transport: config.idunnRudpHealth
? "CultCache witness store + daemon-published Idunn health over CultNet/RUDP."
: "CultCache witness store; Idunn health still falls back to local witness publication.",
},
controlSurface: {
...advertisement.controlSurface,
controls: {
...advertisement.controlSurface.controls,
reason: "This runtime publication advertises provider shape and lifecycle boundaries. Auth mutation remains behind explicit Heimdall HTTP APIs.",
},
},
runtime: {
storageBackend: pulse.storageBackend,
configuredProviders: pulse.configuredProviders,
appProfileCount: pulse.appProfileCount,
cultCachePath: witnessPath,
idunnHealthContract: config.idunnHealthContract,
idunnRudpHealth: config.idunnRudpHealth ?? null,
},
};
}
function buildCommandBoundary(config: HeimdallConfig, pulse: HeimdallRuntimePulse) {
return {
schema: "heimdall.command_boundary.v1",
boundaryId: "heimdall",
daemonId: config.daemonId,
providerId: "heimdall",
updatedAt: pulse.updatedAt,
owner: "Heimdall auth runtime",
lifecycleAuthority: "idunn.yggdrasil-source-app.deploy",
healthPublication: {
contract: config.idunnHealthContract,
transport: config.idunnRudpHealth ? "cultnet.transport.rudp.v0" : "cultcache-store",
publicationSource: config.idunnRudpHealth ? "daemon-published" : "daemon-published-cultcache",
stateOwner: "Heimdall auth runtime",
},
commands: [],
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.",
],
compatibility: {
httpHealth: "/healthz",
jwks: "/.well-known/jwks.json",
discovery: "/.well-known/heimdall-configuration",
yggdrasilDeployLane: "E:/Projects/Odin/scripts/deploy-yggdrasil-heimdall.cmd",
status: "fallback-witness-only",
},
};
}
function buildTransportProfile(config: HeimdallConfig, pulse: HeimdallRuntimePulse) {
return {
schema: "heimdall.transport_profile.v1",
profileId: "heimdall",
daemonId: config.daemonId,
providerId: "heimdall",
updatedAt: pulse.updatedAt,
targetTransport: "cultnet.transport.rudp.v0",
currentTransport: config.idunnRudpHealth
? "cultcache-redacted-witness + daemon-published-rudp-health + compatibility-http"
: "cultcache-redacted-witness + compatibility.ssh-systemd-http",
healthTransport: config.idunnRudpHealth ? "cultnet.transport.rudp.v0" : "cultcache-store",
stateTransport: "cultcache-store",
rendererTransport: "browser-http-jwks-discovery-lowering",
storageBackend: pulse.storageBackend,
cutLine: config.idunnRudpHealth
? "Heimdall now publishes daemon-owned witness state and Idunn health; systemd, /healthz, JWKS, discovery, and nginx routing can be demoted to deployment/debug witnesses once Odin consumes the typed store."
: "Heimdall now publishes a daemon-owned witness store; the remaining transport debt is Idunn health publication over CultNet/RUDP and Odin ingestion of the typed witness surface.",
};
}
function buildDaemonHealthRecord(config: HeimdallConfig, pulse: HeimdallRuntimePulse) {
return [
config.daemonId,
"active",
buildHeimdallHealthDetail(config, pulse),
pulse.updatedAt,
config.idunnHealthContract,
config.idunnRudpHealth ? "daemon-published" : "daemon-published-cultcache",
config.idunnRudpHealth ? "cultnet.transport.rudp.v0" : "cultcache-store",
];
}
async function writeCultCacheSnapshot(filePath: string, records: CultCacheRecord[]): Promise<void> {
await mkdir(path.dirname(filePath), { recursive: true });
const catalog = records.map((record) => [
record.schemaId,
record.schemaName,
record.schemaVersion,
record.schemaId,
JSON.stringify({
schemaName: record.schemaName,
schemaVersion: record.schemaVersion,
members: [],
}),
[record.schemaId],
[],
]);
const entries = records.map((record) => [record.key, record.schemaId, record.storedAt, encode(record.payload)]);
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
try {
await writeFile(tempPath, encode([STORE_FORMAT_VERSION, catalog, entries]));
await rename(tempPath, filePath);
} catch (error) {
await rm(tempPath, { force: true }).catch(() => undefined);
throw error;
}
}

View file

@ -128,12 +128,12 @@ export interface EveProviderAdvertisementDocument {
verseId: "gamecult";
rootVerse: "asgard";
canonicalService: "asgard.heimdall";
locatedService: "asgard.starfire.heimdall";
cultMeshAddress: "asgard.starfire.heimdall/eve/tui";
locatedService: "asgard.yggdrasil.heimdall";
cultMeshAddress: "asgard.yggdrasil.heimdall/eve/tui";
title: "Heimdall";
description: string;
version: string;
status: "read_only_witness_planned";
status: "read_only_witness_planned" | "daemon_live";
updatedAt: string;
provider: {
id: "heimdall";
@ -169,8 +169,8 @@ export function buildHeimdallProviderAdvertisement(options: { updatedAt: string
verseId: "gamecult",
rootVerse: "asgard",
canonicalService: "asgard.heimdall",
locatedService: "asgard.starfire.heimdall",
cultMeshAddress: "asgard.starfire.heimdall/eve/tui",
locatedService: "asgard.yggdrasil.heimdall",
cultMeshAddress: "asgard.yggdrasil.heimdall/eve/tui",
title: "Heimdall",
description: "Shared GameCult auth/control-plane authority with redacted CultCache witness projections.",
version: "read-only-witness-v0",
@ -193,7 +193,7 @@ export function buildHeimdallProviderAdvertisement(options: { updatedAt: string
transport: "CultMesh provider advertisement; first cut is a read-only fixture/export.",
},
controlSurface: {
primary: "asgard.starfire.heimdall/eve/tui",
primary: "asgard.yggdrasil.heimdall/eve/tui",
controls: {
mode: "read-only",
writes: false,
@ -201,9 +201,9 @@ export function buildHeimdallProviderAdvertisement(options: { updatedAt: string
},
},
endpoints: [
"asgard.starfire.heimdall/eve/tui",
"asgard.starfire.heimdall/eve/gui",
"asgard.starfire.heimdall/witness",
"asgard.yggdrasil.heimdall/eve/tui",
"asgard.yggdrasil.heimdall/eve/gui",
"asgard.yggdrasil.heimdall/witness",
],
routes: [
{

View file

@ -28,3 +28,4 @@
{"ts": "2026-06-02T18:36:46+00:00", "type": "deployment", "status": "ok", "note": "Public Yggdrasil Heimdall was deployed with the Spotify/Spotiverse provider code, Spotify credentials from secrets/spotify.txt, a generated Spotiverse app shared secret, and GC_ACCESS_APP_SPOTIVERSE_BACKEND_CALLBACK_URLS set to the Yggdrasil loopback reverse-forward callback. Local Spotiverse now points at public Heimdall, and the Yggdrasil SSH tunnel exposes 127.0.0.1:18796 back to local Spotiverse :8796."}
{"ts": "2026-06-08T20:02:27+00:00", "type": "implementation", "status": "accepted", "note": "Added Bifrost as a Heimdall app profile for Discord/Patreon member access. Bifrost owns KTLST role and Patreon tier policies through backend-callback OAuth starts; Heimdall owns provider OAuth/token custody and generic configured backend callback allowlisting."}
{"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."}

View file

@ -32,7 +32,7 @@ invariants:
- Host apps verify signed claims locally and keep resource ownership checks local.
- StreamPixels can reuse shared auth authority without centralizing streamer-audience data.
- Spotiverse consumes Spotify as a Heimdall-managed credential; Spotiverse does not own Spotify refresh-token custody.
- Heimdall's first Odin/Eve provider advertisement is read-only: it names redacted CultCache witness document shapes and paths, but does not write `.cc` state or mutate auth truth.
- 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.
- Persistent state cruft is the same failure class as architecture cruft.
data_flow:
@ -58,6 +58,7 @@ active_subgoals:
- 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.
- Publish Heimdall daemon health and boundary state for Idunn/Odin without moving auth truth out of Heimdall.
- Verify the Discord and Patreon Repixelizer auth paths with real provider credentials, stable signing, persistent storage, and live browser callback verification.
- Wire StreamPixels through Heimdall for Twitch/YouTube viewer identity and managed creator-provider credentials without moving StreamPixels domain state into Heimdall.
@ -81,6 +82,7 @@ accepted_design:
- StreamPixels viewer and creator provider OAuth should start through Heimdall; StreamPixels redeems one-time completion codes, owns local profile/connector binding, and resolves current provider access tokens from Heimdall by app-authenticated managed-credential calls.
- Bifrost is a Heimdall app profile for Discord/Patreon member access. Bifrost owns the KTLST Discord role policy and Patreon tier policy; Heimdall owns provider OAuth, token custody, entitlement evaluation, and configured backend callback trust.
- Bifrost patron support sync reuses Heimdall's existing linked Patreon identity and membership reader. Heimdall turns active paid Patreon tier evidence into signed support facts; Bifrost owns point/tier/vote derivation.
- 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:
- Shared architecture extracted into its own repo and renamed to Heimdall.
@ -99,6 +101,7 @@ landed_baseline:
- Bifrost now has a Heimdall app profile for Discord/Patreon member access, and backend callback trust is checked through configured app callback allowlists before legacy app-specific defaults.
- Heimdall exposes `POST /v1/apps/bifrost/patron-support/sync` as an app-authenticated bridge from linked Patreon membership evidence to Bifrost's signed patron support intake endpoint.
- A read-only `gamecult.eve.provider_advertisement.v1` export now advertises Heimdall provider/app surfaces plus redacted CultCache witness document paths for Odin/Eve discovery.
- Heimdall now writes a daemon-owned `GC_ACCESS_CULTCACHE_PATH` boundary store containing provider advertisement, command boundary, transport profile, and daemon-health summary state, and it can publish `heimdall.cultnet-rudp-provider-health` to Idunn over `cultnet.transport.rudp.v0` when configured.
rejected_paths:
- name: per_app_auth_copy_paste
@ -109,5 +112,5 @@ rejected_paths:
reason: reusing auth mechanics does not justify centralizing app-domain audience or product data
current_status:
summary: Heimdall and StreamPixels are deployed on Yggdrasil for the StreamPixels auth migration slice. Heimdall has Twitch/YouTube provider credentials, the shared StreamPixels app secret, generic Twitch/YouTube identity and managed-credential custody runtimes, and a live fix for Twitch token responses that return scopes as arrays. Live StreamPixels Twitch sign-in through Heimdall has succeeded and redeemed a completion code. StreamPixels delegates Twitch/YouTube OAuth to Heimdall, redeems one-time completion codes server-side, writes its own httpOnly viewer session, stores local profile/creator binding state, and asks Heimdall for current provider access tokens when runtime code needs them. Spotiverse is added as a Heimdall app profile with Spotify as a managed credential provider. Bifrost is added as a Heimdall app profile for Discord/Patreon member access through backend-callback handoff, with Bifrost owning KTLST role and Patreon tier policies while Heimdall owns provider OAuth/token custody and callback allowlist enforcement. Bifrost patron support sync now reuses Heimdall's linked Patreon identity and membership reader to emit signed recurring-support facts to Bifrost; Bifrost still owns ledgers, patron points, voting weight, and audit meaning. The key boundary still holds: Heimdall owns provider OAuth and credential custody; host apps own local sessions, route/resource checks, runtime behavior, and app-domain data.
next_action: Deploy Heimdall with `GC_ACCESS_APP_BIFROST_SHARED_SECRET`, `GC_ACCESS_BIFROST_PATRON_SUPPORT_ENDPOINT`, and `GC_ACCESS_BIFROST_PATRON_SUPPORT_SECRET`, then trigger `/v1/apps/bifrost/patron-support/sync` for a linked Patreon account and verify Bifrost records the recurring support snapshot. After that, return to Spotiverse and StreamPixels browser verification.
summary: Heimdall and StreamPixels are deployed on Yggdrasil for the StreamPixels auth migration slice. Heimdall has Twitch/YouTube provider credentials, the shared StreamPixels app secret, generic Twitch/YouTube identity and managed-credential custody runtimes, and a live fix for Twitch token responses that return scopes as arrays. Live StreamPixels Twitch sign-in through Heimdall has succeeded and redeemed a completion code. StreamPixels delegates Twitch/YouTube OAuth to Heimdall, redeems one-time completion codes server-side, writes its own httpOnly viewer session, stores local profile/creator binding state, and asks Heimdall for current provider access tokens when runtime code needs them. Spotiverse is added as a Heimdall app profile with Spotify as a managed credential provider. Bifrost is added as a Heimdall app profile for Discord/Patreon member access through backend-callback handoff, with Bifrost owning KTLST role and Patreon tier policies while Heimdall owns provider OAuth/token custody and callback allowlist enforcement. Bifrost patron support sync now reuses Heimdall's linked Patreon identity and membership reader to emit signed recurring-support facts to Bifrost; Bifrost still owns ledgers, patron points, voting weight, and audit meaning. Heimdall now also has a daemon-owned boundary witness store and optional Idunn RUDP health pulse for the swarm transport cut. The key boundary still holds: Heimdall owns provider OAuth and credential custody; host apps own local sessions, route/resource checks, runtime behavior, and app-domain data.
next_action: Ship the CultLib snapshot through the Yggdrasil Heimdall deploy lane, deploy with a persistent `GC_ACCESS_CULTCACHE_PATH` plus `GC_ACCESS_IDUNN_RUDP_HEALTH=10.77.0.2:17870`, then update `check-heimdall.sh` to verify the witness store and confirm live Idunn accepts `yggdrasil-heimdall` from `10.77.0.1`. After that, return to Bifrost patron-support sync and the remaining browser verification passes.

View file

@ -13,8 +13,14 @@ function createTestConfig(): HeimdallConfig {
serviceName: "heimdall",
host: "127.0.0.1",
port: 4100,
workspaceRoot: "E:/Projects/Heimdall",
dataRoot: "E:/Projects/Heimdall/.heimdall-data",
cultCachePath: "E:/Projects/Heimdall/.heimdall-data/cultcache/heimdall.service.cc",
publicBaseUrl: "https://heimdall.gamecult.org",
issuer: "https://heimdall.gamecult.org",
daemonId: "yggdrasil-heimdall",
idunnRudpHealth: undefined,
idunnHealthContract: "heimdall.cultnet-rudp-provider-health",
sessionTtlSeconds: 3600,
refreshTtlSeconds: 60 * 60 * 24 * 30,
stateTtlSeconds: 600,
@ -175,7 +181,7 @@ describe("Heimdall service", () => {
],
})
);
});
}, 15_000);
it("builds signed OAuth start state for supported providers", async () => {
const app = await buildApp({ config: createTestConfig() });

View file

@ -11,8 +11,14 @@ function createTestConfig(overrides: Partial<HeimdallConfig> = {}): HeimdallConf
serviceName: "heimdall",
host: "127.0.0.1",
port: 4100,
workspaceRoot: "E:/Projects/Heimdall",
dataRoot: "E:/Projects/Heimdall/.heimdall-data",
cultCachePath: "E:/Projects/Heimdall/.heimdall-data/cultcache/heimdall.service.cc",
publicBaseUrl: "https://heimdall.gamecult.org",
issuer: "https://heimdall.gamecult.org",
daemonId: "yggdrasil-heimdall",
idunnRudpHealth: undefined,
idunnHealthContract: "heimdall.cultnet-rudp-provider-health",
sessionTtlSeconds: 3600,
refreshTtlSeconds: 60 * 60 * 24 * 30,
stateTtlSeconds: 600,

View file

@ -0,0 +1,66 @@
import { mkdtemp, readFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { decode } from "@msgpack/msgpack";
import { describe, expect, it } from "vitest";
import { loadConfig } from "../src/config.js";
import { createHeimdallRuntimePulse, publishHeimdallVerseState } from "../src/verse-state.js";
describe("Heimdall verse runtime publication", () => {
it("writes provider advertisement, command boundary, transport profile, and daemon health to the witness store", async () => {
const tmp = await mkdtemp(path.join(os.tmpdir(), "heimdall-verse-runtime-"));
const config = loadConfig({
GC_ACCESS_BASE_URL: "https://heimdall.gamecult.org",
GC_ACCESS_CULTCACHE_PATH: path.join(tmp, "cultcache", "heimdall.service.cc"),
GC_ACCESS_DATABASE_URL: "postgres://heimdall:test@localhost/heimdall",
GC_ACCESS_IDUNN_RUDP_HEALTH: "10.77.0.2:17870",
GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID: "discord-client",
GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET: "discord-secret",
GC_ACCESS_PROVIDER_TWITCH_CLIENT_ID: "twitch-client",
GC_ACCESS_PROVIDER_TWITCH_CLIENT_SECRET: "twitch-secret",
});
const pulse = createHeimdallRuntimePulse(config, "2026-06-16T17:10:00.000Z");
await publishHeimdallVerseState(config, pulse);
const contents = await readFile(config.cultCachePath);
const decoded = decode(contents) as [string, unknown[], Array<[string, string, string, Uint8Array]>];
expect(decoded[0]).toBe("cultcache.store.v1");
const entries = decoded[2];
const bySchema = new Map(entries.map((entry) => [entry[1], decode(entry[3])]));
expect(bySchema.get("gamecult.eve.provider_advertisement.v1")).toMatchObject({
providerId: "heimdall",
status: "daemon_live",
locatedService: "asgard.yggdrasil.heimdall",
runtime: {
storageBackend: "postgres",
configuredProviders: ["discord", "twitch"],
appProfileCount: 4,
},
});
expect(bySchema.get("heimdall.command_boundary.v1")).toMatchObject({
boundaryId: "heimdall",
daemonId: "yggdrasil-heimdall",
healthPublication: {
contract: "heimdall.cultnet-rudp-provider-health",
transport: "cultnet.transport.rudp.v0",
},
});
expect(bySchema.get("heimdall.transport_profile.v1")).toMatchObject({
daemonId: "yggdrasil-heimdall",
targetTransport: "cultnet.transport.rudp.v0",
healthTransport: "cultnet.transport.rudp.v0",
});
expect(bySchema.get("idunn.daemon_health")).toEqual([
"yggdrasil-heimdall",
"active",
"Heimdall auth runtime active; storage=postgres; providers=2/6; apps=4; healthTransport=CultNet/RUDP",
"2026-06-16T17:10:00.000Z",
"heimdall.cultnet-rudp-provider-health",
"daemon-published",
"cultnet.transport.rudp.v0",
]);
});
});