diff --git a/package.json b/package.json index 991cc54..77ab72b 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,11 @@ "@fastify/cors": "^11.2.0", "@msgpack/msgpack": "^3.1.3", "cultcache-ts": "file:../CultLib/packages/cultcache-ts", + "cultmesh-ts": "file:../CultLib/packages/cultmesh-ts", "cultnet-ts": "file:../CultLib/packages/cultnet-ts", "fastify": "^5.8.5", - "pg": "^8.20.0" + "pg": "^8.20.0", + "zod": "^3.25.76" }, "devDependencies": { "@types/node": "^25.6.0", @@ -30,5 +32,11 @@ "tsx": "^4.21.0", "typescript": "^6.0.3", "vitest": "^4.1.5" + }, + "pnpm": { + "overrides": { + "cultcache-ts": "file:../CultLib/packages/cultcache-ts", + "cultnet-ts": "file:../CultLib/packages/cultnet-ts" + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b756803..e1dac83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,10 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + cultcache-ts: file:../CultLib/packages/cultcache-ts + cultnet-ts: file:../CultLib/packages/cultnet-ts + importers: .: @@ -17,6 +21,9 @@ importers: cultcache-ts: specifier: file:../CultLib/packages/cultcache-ts version: file:../CultLib/packages/cultcache-ts + cultmesh-ts: + specifier: file:../CultLib/packages/cultmesh-ts + version: file:../CultLib/packages/cultmesh-ts cultnet-ts: specifier: file:../CultLib/packages/cultnet-ts version: file:../CultLib/packages/cultnet-ts @@ -26,6 +33,9 @@ importers: pg: specifier: ^8.20.0 version: 8.20.0 + zod: + specifier: ^3.25.76 + version: 3.25.76 devDependencies: '@types/node': specifier: ^25.6.0 @@ -454,9 +464,15 @@ packages: cultcache-ts@file:../CultLib/packages/cultcache-ts: resolution: {directory: ../CultLib/packages/cultcache-ts, type: directory} + engines: {node: '>=18'} + + cultmesh-ts@file:../CultLib/packages/cultmesh-ts: + resolution: {directory: ../CultLib/packages/cultmesh-ts, type: directory} + engines: {node: '>=18'} cultnet-ts@file:../CultLib/packages/cultnet-ts: resolution: {directory: ../CultLib/packages/cultnet-ts, type: directory} + engines: {node: '>=18'} define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} @@ -1368,6 +1384,12 @@ snapshots: '@msgpack/msgpack': 3.1.3 zod: 3.25.76 + cultmesh-ts@file:../CultLib/packages/cultmesh-ts: + dependencies: + '@msgpack/msgpack': 3.1.3 + cultcache-ts: file:../CultLib/packages/cultcache-ts + cultnet-ts: file:../CultLib/packages/cultnet-ts + cultnet-ts@file:../CultLib/packages/cultnet-ts: dependencies: '@msgpack/msgpack': 3.1.3 diff --git a/src/config.ts b/src/config.ts index 24472a5..f2be4cb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -27,6 +27,7 @@ export interface HeimdallConfig { daemonId: string; idunnRudpHealth: string | undefined; idunnHealthContract: string; + odinCultMeshUri?: string; providerHealthIdentityPath: string; sessionTtlSeconds: number; refreshTtlSeconds: number; @@ -171,6 +172,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): HeimdallConfig providers: providersConfig, }; + const odinCultMeshUri = readOptionalString(env.GC_ACCESS_ODIN_CULTMESH_URI); + if (odinCultMeshUri) { + config.odinCultMeshUri = odinCultMeshUri; + } + const signingPrivateKeyPem = env.GC_ACCESS_SIGNING_PRIVATE_KEY_PEM?.replace(/\\n/g, "\n"); if (signingPrivateKeyPem) { config.signingPrivateKeyPem = signingPrivateKeyPem; diff --git a/src/index.ts b/src/index.ts index e1030ad..7c8bd5e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { buildApp } from "./app.js"; import { loadConfig } from "./config.js"; import { publishIdunnRudpHealth } from "./idunn-rudp-health.js"; +import { publishHeimdallOdinState } from "./odin-publication.js"; import { startHeimdallPrivateCommandPlane } from "./private-command-plane.js"; import type { CultNetOperationServer } from "cultnet-ts"; import { createHeimdallRuntimePulse, buildHeimdallHealthDetail, publishHeimdallVerseState } from "./verse-state.js"; @@ -39,6 +40,7 @@ try { async function publishVerseState(): Promise { const pulse = createHeimdallRuntimePulse(config); await publishHeimdallVerseState(config, pulse); + await publishHeimdallOdinState(config, pulse); } async function publishHealthState(): Promise { diff --git a/src/odin-publication.ts b/src/odin-publication.ts new file mode 100644 index 0000000..7878545 --- /dev/null +++ b/src/odin-publication.ts @@ -0,0 +1,67 @@ +import { defineDocumentType } from "cultcache-ts"; +import { defineCultNetDocumentBinding } from "cultnet-ts"; +import { CultMesh, type CultMeshRudpEndpoint } from "cultmesh-ts"; +import { z } from "zod"; + +import type { HeimdallConfig } from "./config.js"; +import { + buildHeimdallVerseRecords, + type CultCacheRecord, + type HeimdallRuntimePulse, +} from "./verse-state.js"; + +const ODIN_DOCUMENT_CATALOG_CONNECTION_ID = 0x0d1d0002; +const HEIMDALL_ODIN_RUNTIME_ID = "yggdrasil-heimdall"; + +export type HeimdallOdinDocumentPublisher = ( + record: CultCacheRecord, + endpoint: CultMeshRudpEndpoint, +) => Promise; + +export async function publishHeimdallOdinState( + config: HeimdallConfig, + pulse: HeimdallRuntimePulse, + options: { + environment?: Record; + publish?: HeimdallOdinDocumentPublisher; + } = {}, +): Promise { + if (!config.odinCultMeshUri) { + return; + } + + const endpoint = CultMesh.resolveRudpEndpoint( + config.odinCultMeshUri, + options.environment ?? process.env, + ); + const publish = options.publish ?? publishRecord; + await Promise.all( + buildHeimdallVerseRecords(config, pulse).map((record) => publish(record, endpoint)), + ); +} + +async function publishRecord( + record: CultCacheRecord, + endpoint: CultMeshRudpEndpoint, +): Promise { + const definition = defineDocumentType({ + type: record.schemaName, + schemaId: record.schemaId, + schemaName: record.schemaName, + schemaVersion: record.schemaVersion, + schema: z.record(z.unknown()), + }); + const payload = definition.schema.parse(record.payload); + await CultMesh.publishRudpDocumentOnce( + HEIMDALL_ODIN_RUNTIME_ID, + ODIN_DOCUMENT_CATALOG_CONNECTION_ID, + endpoint, + defineCultNetDocumentBinding({ definition }), + record.key, + payload, + { + sourceRole: "heimdall-auth-authority", + tags: ["heimdall", "redacted", "discovery"], + }, + ); +} diff --git a/src/verse-state.ts b/src/verse-state.ts index 08c4ab1..7bae2cf 100644 --- a/src/verse-state.ts +++ b/src/verse-state.ts @@ -8,7 +8,7 @@ import { buildHeimdallProviderAdvertisement } from "./verse-witness.js"; const STORE_FORMAT_VERSION = "cultcache.store.v1"; -type CultCacheRecord = { +export type CultCacheRecord = { key: string; schemaId: string; schemaName: string; @@ -40,6 +40,22 @@ export function createHeimdallRuntimePulse(config: HeimdallConfig, updatedAt = n export async function publishHeimdallVerseState(config: HeimdallConfig, pulse: HeimdallRuntimePulse): Promise { const records: CultCacheRecord[] = [ + ...buildHeimdallVerseRecords(config, pulse), + { + 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 buildHeimdallVerseRecords(config: HeimdallConfig, pulse: HeimdallRuntimePulse): CultCacheRecord[] { + return [ { key: "heimdall", schemaId: "gamecult.eve.provider_advertisement.v1", @@ -72,17 +88,7 @@ export async function publishHeimdallVerseState(config: HeimdallConfig, pulse: H 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 { diff --git a/tests/odin-publication.test.ts b/tests/odin-publication.test.ts new file mode 100644 index 0000000..4544f97 --- /dev/null +++ b/tests/odin-publication.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { CultMesh } from "cultmesh-ts"; +import { CultNetDocumentRegistry } from "cultnet-ts"; + +import { loadConfig } from "../src/config.js"; +import { publishHeimdallOdinState } from "../src/odin-publication.js"; +import { createHeimdallRuntimePulse, type CultCacheRecord } from "../src/verse-state.js"; + +describe("Heimdall Odin publication", () => { + it("publishes the exact redacted provider, command, plugin, and transport records", async () => { + const config = loadConfig({ + GC_ACCESS_ODIN_CULTMESH_URI: "cultmesh://odin/rendezvous/provider-catalog", + GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID: "public-client-id", + GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET: "must-never-publish", + GC_ACCESS_APP_GHOSTLIGHT_SHARED_SECRET: "must-never-publish-either", + }); + const pulse = createHeimdallRuntimePulse(config, "2026-08-22T12:00:00.000Z"); + const published: CultCacheRecord[] = []; + + await publishHeimdallOdinState(config, pulse, { + environment: { CULTMESH_URI_ODIN_RUDP: "10.77.0.1:17871" }, + publish: async (record, endpoint) => { + expect(endpoint).toEqual({ + host: "10.77.0.1", + port: 17871, + uri: "rudp://10.77.0.1:17871", + }); + published.push(record); + }, + }); + + expect(published.map((record) => [record.schemaId, record.key])).toEqual([ + ["gamecult.eve.provider_advertisement.v1", "heimdall"], + ["heimdall.command_boundary.v1", "heimdall"], + ["gamecult.eve.plugin_advertisement.v1", "gamecult.heimdall.access"], + ["heimdall.transport_profile.v1", "heimdall"], + ]); + const serialized = JSON.stringify(published); + expect(serialized).not.toContain("must-never-publish"); + expect(serialized).not.toContain("must-never-publish-either"); + expect(serialized).not.toContain("access_token"); + expect(serialized).not.toContain("refresh_token"); + }); + + it("does not invent an Odin route when publication is not configured", async () => { + const config = loadConfig({}); + let calls = 0; + await publishHeimdallOdinState(config, createHeimdallRuntimePulse(config), { + publish: async () => { + calls += 1; + }, + }); + expect(calls).toBe(0); + }); + + it("publishes all four documents over Odin's RUDP catalog contract", async () => { + const received = new Set(); + const server = CultMesh.createRudpDocumentServer( + "odin-heimdall-test-catalog", + 0x0d1d0002, + { + documents: new CultNetDocumentRegistry(), + bindHost: "127.0.0.1", + bindPort: 0, + resendPollMs: 5, + onDocumentPutRaw: (document) => { + received.add(`${document.schemaId}:${document.recordKey}`); + }, + }, + ); + + try { + await server.start(); + const config = loadConfig({ + GC_ACCESS_ODIN_CULTMESH_URI: "cultmesh://odin/rendezvous/provider-catalog", + }); + await publishHeimdallOdinState(config, createHeimdallRuntimePulse(config), { + environment: { CULTMESH_URI_ODIN_RUDP: `127.0.0.1:${server.bind.port}` }, + }); + expect([...received].sort()).toEqual([ + "gamecult.eve.plugin_advertisement.v1:gamecult.heimdall.access", + "gamecult.eve.provider_advertisement.v1:heimdall", + "heimdall.command_boundary.v1:heimdall", + "heimdall.transport_profile.v1:heimdall", + ]); + } finally { + server.close(); + } + }); +});