commit 43d4590c2757cfa5e85c824b2c0f02b27de4f1ae Author: Meta Date: Sun Sep 6 19:44:53 2026 +0200 Extract Hermodr from Odin Hermodr is the Eve browser lowering. It was an organ of Odin: Node inside a Rust repository, sharing a common module directory with Odin's coordinator, and until recently running its own provider-session ingress and accepting publications into a private store. The ingress was removed first, deliberately. An extraction that carried it would have moved a second acceptance authority into a repository of its own and called that progress. What moved here is a bridge: it resolves providers through Odin's catalog, reads those providers directly, and relays operator intent to provider-advertised command routes. Contents: the daemon, the provider subscription source and its lifecycle tests, the state stream registry, the browser catalog tests, the four lifecycle scripts, and the persona avatar. Three dependencies on Odin were resolved rather than carried. The swarm document catalog now comes from CultLib's cultcache-ts alongside defineDocumentType. Idunn's health contract is declared locally in idunn-health.cjs, over the generic transport that also moved to CultLib, with sourceRuntimeId "hermodr" rather than Odin's. parseArgs is seventeen lines in args.cjs, because Hermodr used one of that utility module's six functions and carrying the other five would have been carrying a library nobody here reads. MIT, matching CultLib, Odin, Idunn, Eve and Erycina, so the deployment chain is one a creator may lawfully run end to end. Verified: 17 tests pass, every module loads with CultLib resolving from the new location, and no import references Odin. Not verified: behaviour against a live provider over RUDP, which needs a running Verse. Co-Authored-By: Claude Opus 5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..74037b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*.log +.DS_Store +scratch/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b3fa4fd --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 GameCult + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..75854a7 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# Hermodr + +Hermodr is the Eve browser lowering. It resolves providers through Odin's +catalog, reads those providers for their state, renders their surfaces to +browsers, and relays operator intent back as typed CultMesh command documents. + +It owns the browser edge. It does not own truth. + +## Authority + +- **Providers** own their state and serve it. Hermodr reads; it never accepts. +- **Odin** owns discovery: what exists, what schema speaks it, where to reach + it. Odin is not in the data path, and nothing here reads state *from* Odin. +- **Hermodr** owns session, transport, lowering, and the relay of intent. + Everything it holds about provider state is a projection carrying a sequence + number. Its staleness marks are display facts, not withdrawal decisions. + +A lowering runtime cannot make something true by rendering it. + +## The round trip + +Intent leaves by `POST /hermodr/commands/eve`, which resolves a `cultmesh://` +target from the request or the provider catalog, wraps the body as +`gamecult.eve.command.v1`, stamps `publishedBy: "hermodr-browser-lowering"`, and +publishes to the provider's own advertised route. The provider decides whether +to act. Eve's client refuses any response that is not a correlated +`gamecult.eve.command_receipt.v1` in state `reconciled`. + +Consequence returns as newly published provider state, by the same subscription +as every other change. That is the whole point of the split: intent and +consequence travel the same road in opposite directions, and neither passes +through a store Hermodr owns. + +## Running it + +```bash +npm start -- --host 127.0.0.1 --port 8798 +``` + +Needs a reachable Odin catalog and `CULTLIB_ROOT` pointing at a CultLib +checkout. Provider read cadence is `--provider-poll-interval-ms`, default 2000. + +```bash +npm test # unit tests, no live Verse required +npm run check +``` + +## Known shape + +Provider state is read by polling, wrapped in lifecycle semantics that emit +snapshot, update, and withdrawn events with sequence numbers. Polling is a first +cut: CultMesh ships `cultMeshPollingDocumentWatcher` for exactly this shape, and +a push subscription belongs here eventually. It does not change the authority, +which is why the swap was made before the move. + +One invariant is easy to get wrong and quiet when you do: **a failed read is not +a withdrawal.** A provider restart or a dropped packet must not render as the +provider retracting state it still holds. Read errors are reported and the +previous value stands; only genuine absence withdraws. Both paths are tested. + +## History + +Hermodr was an organ of Odin, and until recently it ran its own provider-session +ingress and accepted publications into a private store. A surface reached a +browser only if its provider bound to Hermodr specifically, so a provider had to +publish twice: once where the Verse expected it, and once into Hermodr so an +operator could watch their own command land. + +That ingress is gone. The extraction happened after it was removed, so what +moved here was a bridge rather than a second acceptance authority wearing one's +name. + +`GameCult/Odin` holds the coordinator and the Rust organs. `GameCult/CultLib` +holds the swarm document catalog and the CultNet transports this depends on. diff --git a/assets/hermodr-avatar.png b/assets/hermodr-avatar.png new file mode 100644 index 0000000..2576914 Binary files /dev/null and b/assets/hermodr-avatar.png differ diff --git a/package.json b/package.json new file mode 100644 index 0000000..ddcaf55 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "@gamecult/hermodr", + "version": "0.1.0", + "private": true, + "description": "Eve browser lowering: resolves providers through Odin, reads their state, renders surfaces, and relays operator intent as typed CultMesh command documents.", + "type": "commonjs", + "scripts": { + "start": "node src/hermodr-daemon.cjs", + "test": "node --test test/*.test.cjs", + "check": "node --check src/hermodr-daemon.cjs && node --check src/hermodr-provider-subscription.cjs && node --check src/hermodr-state-stream.cjs && node --check src/idunn-health.cjs && node --check src/args.cjs" + }, + "license": "MIT" +} diff --git a/scripts/health-hermodr.ps1 b/scripts/health-hermodr.ps1 new file mode 100644 index 0000000..1025954 --- /dev/null +++ b/scripts/health-hermodr.ps1 @@ -0,0 +1,14 @@ +param( + [string] $BaseUrl = $(if ($env:HERMODR_BASE_URL) { $env:HERMODR_BASE_URL } else { "http://127.0.0.1:8798" }) +) + +$ErrorActionPreference = "Stop" + +$healthUrl = $BaseUrl.TrimEnd("/") + "/health" +$response = Invoke-RestMethod -Uri $healthUrl -Method Get -TimeoutSec 3 + +if ($response.authority -ne "lowering-only") { + throw "Hermodr responded, but did not report lowering-only authority." +} + +Write-Host "Hermodr browser lowering responds at $BaseUrl; authority=$($response.authority). This is not daemon health or Verse discovery authority." diff --git a/scripts/restart-hermodr.ps1 b/scripts/restart-hermodr.ps1 new file mode 100644 index 0000000..85c84d4 --- /dev/null +++ b/scripts/restart-hermodr.ps1 @@ -0,0 +1,23 @@ +param( + [string] $HostAddress = $(if ($env:HERMODR_BIND_HOST) { $env:HERMODR_BIND_HOST } else { "127.0.0.1" }), + [int] $Port = $(if ($env:HERMODR_PORT) { [int]$env:HERMODR_PORT } else { 8798 }), + [string] $OdinCultMeshUri = $(if ($env:HERMODR_ODIN_CULTMESH_URI) { $env:HERMODR_ODIN_CULTMESH_URI } elseif ($env:ODIN_CULTMESH_URI) { $env:ODIN_CULTMESH_URI } else { "cultmesh://odin/rendezvous/provider-catalog" }), + [string] $IdunnRudpHealth = $(if ($env:HERMODR_IDUNN_RUDP_HEALTH) { $env:HERMODR_IDUNN_RUDP_HEALTH } else { $env:IDUNN_RUDP_HEALTH }), + [string] $IdunnDaemon = "hermodr", + [string] $IdunnHealthContract = "hermodr.cultnet-rudp-browser-lowering-health" +) + +$ErrorActionPreference = "Stop" + +if ($env:IDUNN_ACTUATOR -ne "1" -or $env:IDUNN_COMMAND_AUTHORITY -ne "idunn-daemon") { + throw "restart-hermodr.ps1 is an Idunn actuator body. Redeploy by poking Idunn; direct service restart is not an owned path." +} + +$stop = Join-Path $PSScriptRoot "stop-hermodr.ps1" +$start = Join-Path $PSScriptRoot "start-hermodr.ps1" + +if (Test-Path -LiteralPath $stop) { + & $stop +} + +& $start -HostAddress $HostAddress -Port $Port -OdinCultMeshUri $OdinCultMeshUri -IdunnRudpHealth $IdunnRudpHealth -IdunnDaemon $IdunnDaemon -IdunnHealthContract $IdunnHealthContract -NoWindow diff --git a/scripts/start-hermodr.ps1 b/scripts/start-hermodr.ps1 new file mode 100644 index 0000000..fcff881 --- /dev/null +++ b/scripts/start-hermodr.ps1 @@ -0,0 +1,57 @@ +param( + [string] $HostAddress = $(if ($env:HERMODR_BIND_HOST) { $env:HERMODR_BIND_HOST } else { "127.0.0.1" }), + [int] $Port = $(if ($env:HERMODR_PORT) { [int]$env:HERMODR_PORT } else { 8798 }), + [string] $OdinCultMeshUri = $(if ($env:HERMODR_ODIN_CULTMESH_URI) { $env:HERMODR_ODIN_CULTMESH_URI } elseif ($env:ODIN_CULTMESH_URI) { $env:ODIN_CULTMESH_URI } else { "cultmesh://odin/rendezvous/provider-catalog" }), + [string] $IdunnRudpHealth = $(if ($env:HERMODR_IDUNN_RUDP_HEALTH) { $env:HERMODR_IDUNN_RUDP_HEALTH } else { $env:IDUNN_RUDP_HEALTH }), + [string] $IdunnDaemon = "hermodr", + [string] $IdunnHealthContract = "hermodr.cultnet-rudp-browser-lowering-health", + [string] $StateDir = $(Join-Path $PSScriptRoot "..\scratch\Hermodr"), + [switch] $NoWindow +) + +$ErrorActionPreference = "Stop" + +foreach ($name in @( + "HERMODR_ODIN_HTTP_BASE", + "HERMODR_ODIN_WS_BASE", + "HERMODR_ODIN_STATE_PATH", + "HERMODR_SLEIPNIR_COMMAND_RUDP", + "HERMODR_SLEIPNIR_COMMAND_ENDPOINT", + "HERMODR_MUNINN_RUDP", + "HERMODR_AETHERIA_COMMAND_RUDP", + "HERMODR_ODIN_CULTMESH_STORE", + "ODIN_CULTMESH_STORE" +)) { + if (-not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { + throw "$name was removed. Hermodr lowers Odin/CultMesh state for browsers and may only publish typed CultMesh command documents." + } +} +if (-not [string]::IsNullOrWhiteSpace($env:HERMODR_COMMAND_STORE)) { + throw "HERMODR_COMMAND_STORE was removed. Hermodr publishes typed commands through provider-advertised CultMesh routes and records visible witnesses in Odin's CultMesh cache." +} + +$node = "node" +$Hermodr = Join-Path $PSScriptRoot "..\src\hermodr-daemon.cjs" +$arguments = @( + $Hermodr, + "--host", $HostAddress, + "--port", $Port, + "--odin-cultmesh-uri", $OdinCultMeshUri +) +if (-not [string]::IsNullOrWhiteSpace($IdunnRudpHealth)) { + $arguments += @( + "--idunn-rudp-health", $IdunnRudpHealth, + "--idunn-daemon", $IdunnDaemon, + "--idunn-health-contract", $IdunnHealthContract + ) +} + +if ($NoWindow) { + New-Item -ItemType Directory -Force -Path $StateDir | Out-Null + $proc = Start-Process -FilePath $node -ArgumentList $arguments -WindowStyle Hidden -PassThru + $proc.Id | Set-Content -Encoding ASCII -LiteralPath (Join-Path $StateDir "hermodr.pid") + Write-Host "Started Hermodr browser lowering at http://${HostAddress}:${Port}/" + return +} + +& $node @arguments diff --git a/scripts/stop-hermodr.ps1 b/scripts/stop-hermodr.ps1 new file mode 100644 index 0000000..849e71e --- /dev/null +++ b/scripts/stop-hermodr.ps1 @@ -0,0 +1,22 @@ +param( + [string] $StateDir = (Join-Path (Split-Path -Parent $PSScriptRoot) "scratch\Hermodr") +) + +$ErrorActionPreference = "Stop" + +$pidPath = Join-Path $StateDir "hermodr.pid" +if (-not (Test-Path $pidPath)) { + Write-Host "No Hermodr PID file found at $pidPath." + exit 0 +} + +$HermodrPid = (Get-Content -Raw $pidPath).Trim() +if ($HermodrPid -match "^\d+$") { + $process = Get-Process -Id ([int] $HermodrPid) -ErrorAction SilentlyContinue + if ($process) { + Stop-Process -Id $process.Id + Write-Host "Stopped Hermodr PID $HermodrPid." + } +} + +Remove-Item -LiteralPath $pidPath -Force diff --git a/src/args.cjs b/src/args.cjs new file mode 100644 index 0000000..3d57943 --- /dev/null +++ b/src/args.cjs @@ -0,0 +1,31 @@ +"use strict"; + +// Command-line parsing, kept deliberately dumb. +// +// Keys are stored exactly as typed, so --provider-poll-interval-ms and +// --providerPollIntervalMs are different keys. Option readers accept both, and +// rejectRemovedOptions in the daemon rejects both, because a removed option that +// is silently ignored under one spelling is worse than no rejection at all. +// +// This came from Odin's src/odin/utils.cjs, of which Hermodr used this one +// function out of six. Carrying the other five would have been carrying a +// library nobody reads. + +function parseArgs(argv) { + const parsed = {}; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (!arg.startsWith("--")) continue; + const key = arg.slice(2); + const next = argv[index + 1]; + if (!next || next.startsWith("--")) { + parsed[key] = true; + } else { + parsed[key] = next; + index += 1; + } + } + return parsed; +} + +module.exports = { parseArgs }; diff --git a/src/hermodr-daemon.cjs b/src/hermodr-daemon.cjs new file mode 100644 index 0000000..83acf93 --- /dev/null +++ b/src/hermodr-daemon.cjs @@ -0,0 +1,1535 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("fs"); +const http = require("http"); +const { createRequire } = require("module"); +const path = require("path"); + +const { createIdunnRudpHealthPublisher, publishIdunnRudpHealth } = require("./idunn-health.cjs"); +const { parseArgs } = require("./args.cjs"); +const { ProviderSubscriptionSource } = require("./hermodr-provider-subscription.cjs"); +const { HermodrStateStreamRegistry } = require("./hermodr-state-stream.cjs"); + +const repoRoot = path.resolve(__dirname, ".."); +const projectsRoot = path.resolve(repoRoot, ".."); +const defaultEveRepoRoot = path.resolve(projectsRoot, "Eve"); +const defaultEveWebRoot = path.resolve(defaultEveRepoRoot, "web"); +const defaultOdinCultMeshUri = "cultmesh://odin/rendezvous/provider-catalog"; +const defaultOdinRudpEndpoint = "rudp://127.0.0.1:17871"; +const odinRudpConnectionId = 0x0d1d0002; +const providerRudpConnectionId = 0x43554c54; +const providerRudpPeers = new Map(); +const defaultProviderCatalogKeys = []; + +const cultCacheRequire = createRequire(resolveCultCachePackagePath()); +const cultMeshRequire = createRequire(resolveCultMeshPackagePath()); +// The swarm document catalog and the runtime that builds it come from the same +// CultLib package; Odin used to hold the catalog and no longer does. +const { defineDocumentType, defineSwarmDocuments } = cultCacheRequire(resolveCultCacheRuntimePath()); +const cultMeshRuntime = cultMeshRequire(resolveCultMeshRuntimePath()); +const { CultMesh } = cultMeshRuntime; +const { decode: decodeMessagePack } = cultMeshRequire("@msgpack/msgpack"); + +const documents = defineSwarmDocuments(defineDocumentType); +const odinDocumentDefinitions = Object.values(documents).filter(Boolean); +const HermodrCommandDefinition = defineDocumentType({ + type: "gamecult.eve.command", + schemaName: "gamecult.eve.command", + schemaId: "gamecult.eve.command.v1", + schemaVersion: "gamecult.eve.command.v1", + global: false, + name: (value) => value?.commandId || value?.command_id || "hermodr-command", + schema: requireObject("Hermodr command document"), +}); +const EveCommandReceiptDefinition = defineDocumentType({ + type: "gamecult.eve.command_receipt", + schemaName: "gamecult.eve.command_receipt", + schemaId: "gamecult.eve.command_receipt.v1", + schemaVersion: "gamecult.eve.command_receipt.v1", + global: false, + name: (value) => value?.commandId || value?.receiptId || "command-receipt", + schema: requireObject("Eve command receipt document"), +}); + +async function main() { + const options = parseOptions(process.argv.slice(2)); + rejectRemovedOptions(options); + if (options.help) { + printUsage(); + return; + } + + // Providers own their state. Odin says where it lives. Hermodr resolves the + // provider through the catalog and reads that provider directly, the same way + // the command path already routes intent to a provider-advertised endpoint. + const livePublicationSource = new ProviderSubscriptionSource( + (providerId, schemaId, recordKey) => readProviderStateDocument(options, providerId, schemaId, recordKey), + { + pollIntervalMs: options.providerPollIntervalMs, + onError: (error, selection) => console.error( + `Hermodr provider read failed for ${selection.providerId} ${selection.schemaId}:${selection.recordKey}: ${error.message}`, + ), + }, + ); + const bridge = createHermodrBridge(options, livePublicationSource); + const server = http.createServer((request, response) => { + bridge.handle(request, response).catch((error) => { + writeJson(response, error?.statusCode || 500, { + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + }); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(options.port, options.host, () => { + server.off("error", reject); + resolve(); + }); + }); + + const address = server.address(); + const port = typeof address === "object" && address ? address.port : options.port; + console.log(`Hermodr browser lowering listening on http://${options.host}:${port}`); + console.log(`Hermodr Odin CultMesh URI: ${options.odinCultMeshUri}`); + + const idunnHealthPublisher = createIdunnRudpHealthPublisher(options.idunnRudpHealth); + const publishHealth = () => { + if (!idunnHealthPublisher) return; + publishIdunnRudpHealth(idunnHealthPublisher, { + state: "healthy", + detail: `Hermodr browser lowering accepts requests on http://${options.host}:${port}.`, + observedAt: new Date().toISOString(), + }).catch((error) => console.error(`Hermodr Idunn health publication failed: ${error.message}`)); + }; + publishHealth(); + const healthTimer = idunnHealthPublisher + ? setInterval(publishHealth, options.idunnHealthIntervalMs) + : null; + + const shutdown = () => { + if (healthTimer) clearInterval(healthTimer); + server.close(() => {}); + closeProviderRudpPeers(); + }; + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); +} + +function parseOptions(argv) { + const parsed = parseArgs(argv); + return { + help: Boolean(parsed.help), + host: stringOption(parsed.host, process.env.HERMODR_BIND_HOST || "127.0.0.1"), + port: numberOption(parsed.port, process.env.HERMODR_PORT || 8798), + odinCultMeshUri: stringOption( + parsed.odinCultMeshUri || parsed.odinUri || parsed["odin-cultmesh-uri"], + process.env.HERMODR_ODIN_CULTMESH_URI || process.env.ODIN_CULTMESH_URI || defaultOdinCultMeshUri, + ), + odinRudpEndpoint: stringOption( + parsed.odinRudpEndpoint || parsed["odin-rudp-endpoint"], + process.env.HERMODR_ODIN_RUDP || process.env.CULTMESH_URI_ODIN_RUDP || defaultOdinRudpEndpoint, + ), + odinSurfaceKey: stringOption(parsed.odinSurfaceKey, process.env.HERMODR_ODIN_SURFACE_KEY || "surface:gamecult.network.status"), + eveWebRoot: path.resolve(stringOption(parsed.eveWebRoot, process.env.HERMODR_EVE_WEB_ROOT || defaultEveWebRoot)), + eveRepoRoot: path.resolve(stringOption(parsed.eveRepoRoot, process.env.HERMODR_EVE_REPO_ROOT || defaultEveRepoRoot)), + providerCatalogKeys: stringListOption( + parsed.providerCatalogKeys || parsed["provider-catalog-keys"], + process.env.HERMODR_PROVIDER_CATALOG_KEYS, + defaultProviderCatalogKeys, + ), + providerPollIntervalMs: Math.max(250, numberOption( + parsed.providerPollIntervalMs || parsed["provider-poll-interval-ms"], + process.env.HERMODR_PROVIDER_POLL_INTERVAL_MS || 2_000, + )), + sleipnirProviderId: stringOption(parsed.sleipnirProviderId, process.env.HERMODR_SLEIPNIR_PROVIDER_ID || "sleipnir.input-mirror.starfire"), + idunnRudpHealth: idunnRudpHealthOptions(parsed), + idunnHealthIntervalMs: Math.max(1_000, numberOption( + parsed.idunnHealthIntervalMs || parsed["idunn-health-interval-ms"], + process.env.HERMODR_IDUNN_HEALTH_INTERVAL_MS || 15_000, + )), + raw: parsed, + }; +} + +function idunnRudpHealthOptions(parsed) { + const endpoint = stringOption( + parsed.idunnRudpHealth || parsed["idunn-rudp-health"], + process.env.HERMODR_IDUNN_RUDP_HEALTH || process.env.IDUNN_RUDP_HEALTH || "", + ); + if (!endpoint) return null; + return { + endpoint, + daemonId: stringOption(parsed.idunnDaemon || parsed["idunn-daemon"], process.env.HERMODR_IDUNN_DAEMON || "hermodr"), + healthContract: stringOption( + parsed.idunnHealthContract || parsed["idunn-health-contract"], + process.env.HERMODR_IDUNN_HEALTH_CONTRACT || "hermodr.cultnet-rudp-browser-lowering-health", + ), + }; +} + +function rejectRemovedOptions(options) { + const removed = [ + "odinHttpBase", + "odinWsBase", + "odinStatePath", + "sleipnirMappingPath", + "sleipnirStorePath", + "sleipnirCommandEndpoint", + "sleipnirCommandRudp", + "muninnRudp", + "aetheriaCommandRudp", + "hermodrCommandStorePath", + "odinCachePath", + // parseArgs keys options exactly as typed, so both spellings are reachable + // and both must fail. Hermodr no longer accepts provider publications; it + // reads providers through the routes Odin advertises. + "providerSessionBind", + "provider-session-bind", + "providerSessionToken", + "provider-session-token", + ]; + for (const option of removed) { + if (Object.hasOwn(options.raw, option)) { + throw new Error(`--${option} was removed. Hermodr is a browser lowering over Odin/CultMesh state, not a fallback discovery or raw command transport.`); + } + } + + const removedEnv = [ + "HERMODR_ODIN_HTTP_BASE", + "HERMODR_ODIN_WS_BASE", + "HERMODR_ODIN_STATE_PATH", + "HERMODR_SLEIPNIR_MAPPING_PATH", + "HERMODR_SLEIPNIR_STORE_PATH", + "HERMODR_SLEIPNIR_COMMAND_ENDPOINT", + "HERMODR_SLEIPNIR_COMMAND_RUDP", + "HERMODR_MUNINN_RUDP", + "HERMODR_AETHERIA_COMMAND_RUDP", + "HERMODR_ODIN_CULTMESH_STORE", + "ODIN_CULTMESH_STORE", + "HERMODR_PROVIDER_SESSION_BIND", + "HERMODR_PROVIDER_SESSION_TOKEN", + ]; + for (const name of removedEnv) { + if (process.env[name]) { + throw new Error(`${name} was removed. Hermodr must lower Odin/CultMesh state and publish typed CultMesh command documents.`); + } + } +} + +function createHermodrBridge(options, livePublicationSource) { + const stateStreams = livePublicationSource ? new HermodrStateStreamRegistry(livePublicationSource) : null; + return { + async handle(request, response) { + const url = new URL(request.url || "/", `http://${request.headers.host || "localhost"}`); + if (request.method === "OPTIONS") { + writeCors(response, 204); + response.end(); + return; + } + + if (request.method === "GET" && url.pathname === "/health") { + writeJson(response, 200, { + ok: true, + service: "hermodr-browser-lowering", + authority: "lowering-only", + visibleAuthority: "Odin sees provider-owned CultMesh documents; Odin does not own provider daemons.", + odinCultMeshUri: options.odinCultMeshUri, + eveWebRoot: options.eveWebRoot, + assetBytes: "provider-cultmesh-cdn", + }); + return; + } + + if (request.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) { + await serveStaticFile(response, path.join(options.eveWebRoot, "index.html"), "text/html; charset=utf-8"); + return; + } + + if (request.method === "GET" && url.pathname === "/cultmesh-cdn/catalog") { + writeJson(response, 200, await readAssetCatalog(options)); + return; + } + + if ((request.method === "GET" || request.method === "HEAD") && url.pathname.startsWith("/cultmesh-cdn/")) { + await serveCultMeshAsset( + options, + response, + decodeURIComponent(url.pathname.slice("/cultmesh-cdn/".length)), + request.method === "HEAD", + ); + return; + } + + if (request.method === "GET" && url.pathname === "/hermodr/verse/catalog") { + writeJson(response, 200, await readCatalog(options)); + return; + } + + if (request.method === "GET" && url.pathname.startsWith("/hermodr/surface/")) { + const providerId = decodeURIComponent(url.pathname.slice("/hermodr/surface/".length)); + const surfaceId = stringOption(url.searchParams.get("surfaceId"), ""); + const catalog = await readCatalog(options); + const surface = await readProviderSurface(options, catalog, providerId, surfaceId); + if (!surface) { + writeJson(response, 404, { ok: false, error: `No surface found for provider '${providerId}'.` }); + return; + } + writeJson(response, 200, surface); + return; + } + + if (request.method === "GET" && url.pathname.startsWith("/hermodr/document/")) { + const providerId = decodeURIComponent(url.pathname.slice("/hermodr/document/".length)); + const documentId = stringOption(url.searchParams.get("documentId"), ""); + const schemaId = stringOption(url.searchParams.get("schemaId"), ""); + const resolved = await readVisibleDocument(options, { providerId, documentId, schemaId }); + if (!resolved) { + writeJson(response, 404, { ok: false, error: `No visible document '${documentId}' (${schemaId || "any schema"}) for provider '${providerId}'.` }); + return; + } + writeJson(response, 200, resolved); + return; + } + + if (request.method === "GET" && url.pathname.startsWith("/hermodr/state/")) { + const selection = normalizeStateBindingSource({ + providerId: decodeURIComponent(url.pathname.slice("/hermodr/state/".length)), + sourceId: stringOption(url.searchParams.get("sourceId"), ""), + schemaId: stringOption(url.searchParams.get("schemaId"), ""), + }); + if (!selection) { + writeJson(response, 400, { ok: false, error: "providerId, sourceId, and schemaId are required for a typed state stream." }); + return; + } + if (!stateStreams) { + writeJson(response, 503, { ok: false, error: "Provider-session live publication ingress is unavailable." }); + return; + } + openStateEventStream(request, response, stateStreams, selection); + return; + } + + if (request.method === "POST" && url.pathname === "/hermodr/commands/sleipnir/input-mapping") { + const body = await readJsonBody(request); + writeJson(response, 202, await publishSleipnirMapping(options, body)); + return; + } + + if (request.method === "POST" && url.pathname === "/hermodr/commands/eve") { + const body = await readJsonBody(request); + writeJson(response, 200, await publishEveCommand(options, body)); + return; + } + + if (request.method === "GET" && url.pathname.startsWith("/packages/")) { + await serveStaticFile(response, safeJoin(path.join(options.eveRepoRoot, "packages"), url.pathname.slice("/packages/".length))); + return; + } + + if (request.method === "GET") { + await serveStaticFile(response, safeJoin(options.eveWebRoot, url.pathname.slice(1))); + return; + } + + writeJson(response, 404, { ok: false, error: "not found" }); + }, + }; +} + +function normalizeStateBindingSource(value) { + const providerId = String(value?.providerId || "").trim(); + const sourceId = String(value?.sourceId || "").trim(); + const schemaId = String(value?.schemaId || "").trim(); + if (!providerId || !sourceId || !schemaId) return null; + const prefix = `${schemaId.replace(/\.v\d+$/, "")}:`; + const recordKey = sourceId.startsWith(prefix) ? sourceId.slice(prefix.length) : sourceId; + return recordKey ? { providerId, sourceId, schemaId, recordKey } : null; +} + +function openStateEventStream(request, response, registry, selection) { + writeCors(response, 200); + response.setHeader("Content-Type", "text/event-stream; charset=utf-8"); + response.setHeader("Cache-Control", "no-cache, no-transform"); + response.setHeader("Connection", "keep-alive"); + response.flushHeaders?.(); + const release = registry.acquire(selection, event => { + response.write(`id: ${event.sequence}\n`); + response.write(`event: ${event.type}\n`); + response.write(`data: ${JSON.stringify(event)}\n\n`); + }); + request.once("close", release); + response.once("close", release); +} + +async function readCatalog(options) { + const providerSnapshots = []; + for (let attempt = 0; attempt < 3; attempt += 1) { + providerSnapshots.push(...await readOdinProviderAdvertisements(options)); + } + const providerAdvertisements = mergeProviderAdvertisements([ + ...await readOdinSurfaceProviderCatalog(options), + ...providerSnapshots, + ]); + return createBrowserCatalog(providerAdvertisements, options); +} + +function createBrowserCatalog(providerAdvertisements, options = {}) { + const surfaces = providerAdvertisements.flatMap((provider) => { + const advertised = provider.surfaces?.length ? provider.surfaces : [{}]; + return advertised.map((surface) => ({ + providerId: provider.id, + surfaceId: surface.surfaceId || provider.id, + surfaceKind: surface.surfaceKind || "", + title: surface.title || provider.title || surface.surfaceId || provider.id, + version: 0, + updatedAt: provider.updatedAt || null, + surface: { advertised: true }, + })); + }); + + return { + schema: "gamecult.hermodr.browser_lowering_catalog.v1", + service: "hermodr-browser-lowering", + generatedAt: new Date().toISOString(), + authority: { + owner: "Odin/CultMesh", + ownerNote: "Odin sees provider-owned daemon publications; it does not own provider daemon state.", + hermodrRole: "browser lowering adapter", + forbidden: ["provider catalog synthesis", "provider state ownership", "renderer-owned discovery"], + }, + source: { + odinCultMeshUri: options.odinCultMeshUri || "", + odinSurfaceKey: options.odinSurfaceKey || "", + }, + surface: null, + surfaces, + providers: providerAdvertisements, + }; +} + +async function readOdinSurfaceProviderCatalog(options) { + const peer = await CultMesh.createRudpPeer( + "hermodr-browser-lowering-odin-surface", + odinRudpConnectionId, + resolveHermodrRudpEndpoint(options.odinCultMeshUri, options), + hermodrRudpPeerOptions({ + resolveCultMeshRudpEndpoint: (uri) => resolveHermodrRudpEndpoint(uri, options), + }), + ); + try { + const keys = [ + options.odinSurfaceKey, + String(options.odinSurfaceKey || "").startsWith("surface:") + ? String(options.odinSurfaceKey || "").slice("surface:".length) + : `surface:${options.odinSurfaceKey}`, + ]; + const document = await requestCultNetRawSnapshotFirstDocument( + peer, + "gamecult.eve.surface_state.v1", + keys, + { timeoutMs: 4_000, messageIdPrefix: "hermodr-odin-provider-catalog" }, + ); + const decoded = decodeMessagePack(bufferFromPayload(document.payload)); + const catalog = Array.isArray(decoded?.providerCatalog) ? decoded.providerCatalog : []; + return catalog.map(normalizeCatalogProviderAdvertisement).filter(Boolean); + } finally { + if (typeof peer.close === "function") { + peer.close(); + } + } +} + +async function readOdinProviderAdvertisements(options) { + const peer = await CultMesh.createRudpPeer( + "hermodr-browser-lowering-odin", + odinRudpConnectionId, + resolveHermodrRudpEndpoint(options.odinCultMeshUri, options), + hermodrRudpPeerOptions({ + resolveCultMeshRudpEndpoint: (uri) => resolveHermodrRudpEndpoint(uri, options), + }), + ); + try { + const response = await requestCultNetRawSnapshot( + peer, + options.providerCatalogKeys, + { + schemaIds: ["gamecult.eve.provider_advertisement.v1"], + timeoutMs: 4_000, + messageIdPrefix: "hermodr-odin-providers", + }, + ); + return response.documents + .filter((document) => document.schemaId === "gamecult.eve.provider_advertisement.v1") + .map((document) => normalizeProviderAdvertisement( + decodeMessagePack(bufferFromPayload(document.payload)), + document.recordKey, + )) + .filter(Boolean); + } finally { + if (typeof peer.close === "function") { + peer.close(); + } + } +} + +function normalizeCatalogProviderAdvertisement(value) { + const providerId = value?.id || value?.providerId || value?.provider?.id; + if (!providerId) return null; + return { + id: String(providerId), + title: value.title || String(providerId), + description: value.description || "", + endpoint: value.endpoint || value.cultMeshAddress || value.provider?.endpoint || null, + cultMeshAddress: value.cultMeshAddress || value.endpoint || value.provider?.endpoint || null, + endpoints: arrayOfObjects(value.endpoints), + routes: arrayOfObjects(value.routes), + commandSurface: value.commandSurface || null, + surfaces: normalizeAdvertisedSurfaces(value.surfaces), + status: value.status || "unknown", + updatedAt: value.updatedAt || null, + }; +} + +function mergeProviderAdvertisements(providers) { + const merged = new Map(); + for (const provider of providers) { + if (!provider?.id) continue; + const existing = merged.get(provider.id) || {}; + merged.set(provider.id, { + ...existing, + ...provider, + endpoints: mergeAdvertisedRoutes(existing.endpoints, provider.endpoints), + routes: mergeAdvertisedRoutes(existing.routes, provider.routes), + surfaces: provider.surfaces?.length ? provider.surfaces : existing.surfaces || [], + cultMeshAddress: provider.cultMeshAddress || existing.cultMeshAddress || null, + endpoint: provider.endpoint || existing.endpoint || null, + }); + } + return [...merged.values()].sort((left, right) => String(left.id).localeCompare(String(right.id))); +} + +function mergeAdvertisedRoutes(left, right) { + const merged = new Map(); + for (const route of [...arrayOfObjects(left), ...arrayOfObjects(right)]) { + const key = [route.id, route.role, route.uri, route.endpoint, route.address, route.transport] + .map((value) => String(value || "")) + .join("\u001f"); + merged.set(key, route); + } + return [...merged.values()]; +} + +function normalizeAdvertisedSurfaces(surfaces) { + return arrayOfObjects(surfaces) + .map((surface) => ({ + ...surface, + surfaceId: String(surface.surfaceId || surface.id || "").trim(), + surfaceKind: String(surface.surfaceKind || surface.kind || "").trim(), + recordRef: String(surface.recordRef || surface.key || "").trim(), + })) + .filter((surface) => surface.surfaceId); +} + +function surfaceRecordKeys(catalog, providerId, surfaceId) { + const provider = (catalog.providers || []).find((candidate) => candidate.id === providerId); + const surface = (provider?.surfaces || []).find((candidate) => candidate.surfaceId === surfaceId); + return [...new Set([ + surface?.recordRef, + surfaceId, + `surface:${surfaceId}`, + `eve:surface:${surfaceId}`, + providerId, + `surface:${providerId}`, + ].filter(Boolean))]; +} + +function resolveHermodrRudpEndpoint(uri, options) { + const text = String(uri || "").trim(); + if (text === defaultOdinCultMeshUri || text.startsWith("cultmesh://odin/")) { + return normalizeRudpEndpoint(options.odinRudpEndpoint); + } + return undefined; +} + +function normalizeRudpEndpoint(endpoint) { + const text = String(endpoint || "").trim(); + if (!text) return text; + return text.startsWith("rudp://") ? text : `rudp://${text}`; +} + +function hermodrRudpPeerOptions(overrides = {}) { + return { + bindHost: "0.0.0.0", + connectTimeoutMs: 2_000, + maxFragmentBytes: 1200, + maxPendingReliablePackets: 512, + ...overrides, + }; +} + +async function readProviderSurface(options, catalog, providerId, surfaceId = "") { + const selectedSurfaceId = surfaceId || providerId; + const recordKeys = surfaceRecordKeys(catalog, providerId, selectedSurfaceId); + const odinSurface = await readOdinProviderSurface(options, providerId, selectedSurfaceId, recordKeys); + if (odinSurface) { + return odinSurface; + } + + const route = findProviderSnapshotRoute(catalog, providerId); + if (!route) return null; + try { + const document = await requestProviderSnapshotFirstDocumentWithReconnect( + route.endpoint, + "gamecult.eve.surface_state.v1", + recordKeys, + { timeoutMs: 4_000, messageIdPrefix: "hermodr-surface-state" }, + ); + const state = normalizeSurfaceState(decodeMessagePack(bufferFromPayload(document.payload)), document.recordKey); + if (state?.surface?.root) { + return { + providerId, + documentProviderId: state.providerId, + providerKind: "provider-cultmesh-rudp", + title: state.title || providerId, + version: state.version ?? 0, + updatedAt: state.updatedAt || null, + surface: normalizeEveSurfaceTree(state.surface), + commands: [], + }; + } + } catch { + // Older providers may still expose gamecult.eve.surface.v1 directly. + } + const document = await requestProviderSnapshotFirstDocumentWithReconnect( + route.endpoint, + "gamecult.eve.surface.v1", + recordKeys, + { timeoutMs: 4_000, messageIdPrefix: "hermodr-surface" }, + ); + return normalizeEveSurfaceDocument(decodeMessagePack(bufferFromPayload(document.payload)), providerId); +} + +async function readOdinProviderSurface(options, providerId, surfaceId = providerId, recordKeys = []) { + const peer = await CultMesh.createRudpPeer( + "hermodr-browser-lowering-odin-provider-surface", + odinRudpConnectionId, + resolveHermodrRudpEndpoint(options.odinCultMeshUri, options), + hermodrRudpPeerOptions({ + resolveCultMeshRudpEndpoint: (uri) => resolveHermodrRudpEndpoint(uri, options), + }), + ); + try { + const candidates = recordKeys.length ? recordKeys : [surfaceId, `surface:${surfaceId}`, providerId, `surface:${providerId}`]; + for (const recordKey of candidates) { + try { + const document = await requestCultNetRawSnapshotFirstDocument( + peer, + "gamecult.eve.surface_state.v1", + [recordKey], + { timeoutMs: 4_000, messageIdPrefix: "hermodr-odin-provider-surface" }, + ); + const state = normalizeSurfaceState(decodeMessagePack(bufferFromPayload(document.payload)), document.recordKey); + if (!state?.surface?.root) continue; + return { + providerId, + documentProviderId: state.providerId, + providerKind: "odin-cultmesh-surface-state", + title: state.title || providerId, + version: state.version ?? 0, + updatedAt: state.updatedAt || null, + surface: normalizeEveSurfaceTree(state.surface), + commands: [], + }; + } catch { + // Try the next provider-advertised alias. + } + } + return null; + } catch { + return null; + } finally { + if (typeof peer.close === "function") { + peer.close(); + } + } +} + +function normalizeEveSurfaceDocument(value, fallbackProviderId) { + if (Array.isArray(value)) { + if (Array.isArray(value[5])) { + return { + providerId: String(fallbackProviderId || value[0] || ""), + documentProviderId: String(value[0] || ""), + providerKind: String(value[1] || ""), + title: String(value[2] || value[0] || fallbackProviderId || ""), + version: value[3] ?? 0, + updatedAt: value[4] || null, + surface: normalizeEveSurfaceTree(value[5]), + commands: Array.isArray(value[6]) ? normalizeEveCommands(value[6]) : [], + }; + } + return { + providerId: String(value[2] || fallbackProviderId || ""), + providerKind: String(value[3] || ""), + title: String(value[4] || value[2] || fallbackProviderId || ""), + version: value[5] ?? 0, + updatedAt: value[6] || null, + surface: normalizeEveSurfaceTree(value[7]), + commands: Array.isArray(value[8]) ? normalizeEveCommands(value[8]) : [], + }; + } + return { + providerId: String(value?.providerId || fallbackProviderId || ""), + providerKind: String(value?.providerKind || ""), + title: String(value?.title || value?.surface?.title || value?.providerId || fallbackProviderId || ""), + version: value?.version ?? 0, + updatedAt: value?.updatedAtUtc || value?.updatedAt || null, + surface: normalizeEveSurfaceTree(value?.surface), + commands: Array.isArray(value?.commands) ? normalizeEveCommands(value.commands) : [], + }; +} + +function normalizeEveSurfaceTree(value) { + if (Array.isArray(value)) { + return { + id: String(value[0] || ""), + root: normalizeEveSurfaceComponent(value[1]), + styles: Array.isArray(value[2]) ? value[2] : [], + }; + } + return { + id: String(value?.id || ""), + title: value?.title, + root: normalizeEveSurfaceComponent(value?.root), + styles: Array.isArray(value?.styles) ? value.styles : [], + }; +} + +function normalizeEveSurfaceComponent(value) { + if (Array.isArray(value)) { + const component = { + id: String(value[0] || ""), + kind: String(value[1] || ""), + props: value[2] && typeof value[2] === "object" && !Array.isArray(value[2]) ? value[2] : {}, + children: Array.isArray(value[3]) ? value[3].map(normalizeEveSurfaceComponent) : [], + }; + if (Array.isArray(value[5])) component.embeddedDocuments = normalizeEmbeddedDocuments(value[5]); + if (value[6] && typeof value[6] === "object" && !Array.isArray(value[6])) component.layout = value[6]; + if (value[7] && typeof value[7] === "object" && !Array.isArray(value[7])) component.style = value[7]; + return component; + } + if (!value || typeof value !== "object") { + return { id: "", kind: "surface", props: {}, children: [] }; + } + return { + ...value, + props: value.props && typeof value.props === "object" && !Array.isArray(value.props) ? value.props : {}, + children: Array.isArray(value.children) ? value.children.map(normalizeEveSurfaceComponent) : [], + }; +} + +function normalizeEveCommands(commands) { + return commands.map((command) => { + if (!Array.isArray(command)) return command; + return { + commandId: String(command[0] || ""), + label: String(command[1] || ""), + description: String(command[2] || ""), + authority: String(command[3] || ""), + transport: String(command[4] || ""), + }; + }); +} + +function normalizeEmbeddedDocuments(documents) { + return documents.map((document) => { + if (!Array.isArray(document)) return document; + return { + slotId: String(document[0] || ""), + documentId: String(document[1] || ""), + schemaId: String(document[2] || ""), + presentationKind: String(document[3] || ""), + bindingMode: String(document[4] || ""), + query: String(document[5] || ""), + }; + }); +} + +async function publishSleipnirMapping(options, body) { + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw new Error("Sleipnir input mapping command body must be an object."); + } + + const catalog = await readCatalog(options); + const route = findSleipnirCommandRoute(catalog, options.sleipnirProviderId); + if (!route) { + const error = new Error(`Odin does not advertise a cultmesh:// Sleipnir input mapping command route for ${options.sleipnirProviderId}.`); + error.statusCode = 409; + throw error; + } + + const commandId = body.commandId || body.command_id || `hermodr:sleipnir:${Date.now()}:${Math.random().toString(16).slice(2)}`; + const mapping = { + ...body, + schema: "sleipnir.input_mapping.v1", + providerId: options.sleipnirProviderId, + commandId, + commandRoute: route.uri, + commandRouteResolver: "odin-cultmesh", + publishedBy: "hermodr-browser-lowering", + publishedAt: new Date().toISOString(), + }; + delete mapping.muninnRudp; + delete mapping.muninn_rudp; + const publication = await publishCommandDocument(options, documents.SleipnirInputMappingDefinition, options.sleipnirProviderId, mapping, route.uri); + if (publication.routeError) { + const error = new Error(`CultMesh route publish failed for ${route.uri}: ${publication.routeError}`); + error.statusCode = 502; + throw error; + } + return { + ok: true, + schema: "sleipnir.input_mapping.v1", + commandId, + providerId: options.sleipnirProviderId, + commandRoute: route.uri, + route: route.uri, + routeError: publication.routeError || undefined, + }; +} + +async function publishEveCommand(options, body) { + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw new Error("Eve command body must be an object."); + } + + const target = typeof body.target === "string" ? body.target.trim() : ""; + const catalog = await readCatalog(options); + const route = target.startsWith("cultmesh://") + ? { uri: target, publishEndpoint: target } + : findProviderCommandRoute(catalog, body.providerId || body.provider_id || ""); + if (!route) { + const error = new Error("No provider-advertised cultmesh:// command route was visible."); + error.statusCode = 409; + throw error; + } + + const commandId = body.commandId || body.command_id || `hermodr:eve:${Date.now()}:${Math.random().toString(16).slice(2)}`; + const command = { + ...body, + schema: "gamecult.eve.command.v1", + commandId, + target: route.uri, + publishedBy: "hermodr-browser-lowering", + publishedAt: new Date().toISOString(), + }; + const publication = await publishCommandDocument( + options, + HermodrCommandDefinition, + commandId, + command, + route.publishEndpoint || route.uri, + route.connectionId, + EveCommandReceiptDefinition, + ); + if (publication.routeError) { + const error = new Error(`CultMesh route publish failed for ${route.uri}: ${publication.routeError}`); + error.statusCode = 502; + throw error; + } + return { ok: true, ...publication.receipt, target: route.uri }; +} + +async function publishCommandDocument(options, definition, key, value, route, connectionId = 0xe1e00001, receiptDefinition = null) { + let routeError = null; + let receipt = null; + if (route && (route.startsWith("cultmesh://") || route.startsWith("rudp://"))) { + try { + const publishOptions = { + bindHost: "0.0.0.0", + sourceRuntimeId: "hermodr-browser-lowering", + sourceRole: "browser-lowering", + tags: ["hermodr", "browser", "command"], + receiptTimeoutMs: 5_000, + }; + receipt = receiptDefinition + ? await CultMesh.publishRudpDocumentAndWaitForReceipt( + "hermodr-browser-lowering", Number(connectionId), route, { definition }, key, value, + { definition: receiptDefinition }, publishOptions, + ) + : await CultMesh.publishRudpDocumentOnce( + "hermodr-browser-lowering", Number(connectionId), route, { definition }, key, value, publishOptions, + ); + } catch (error) { + routeError = error instanceof Error ? error.message : String(error); + } + } + return { routeError, receipt }; +} + +async function readVisibleDocument(options, request) { + const catalog = await readCatalog(options); + const resolved = await fetchProviderDocument(catalog, request); + if (!resolved) return null; + return { + ok: true, + schema: "gamecult.hermodr.resolved_document.v1", + providerId: request.providerId, + documentId: request.documentId, + schemaId: resolved.schemaId, + recordKey: resolved.recordKey, + document: resolved.document, + source: { + visibility: "provider-cultmesh-rudp-snapshot", + endpoint: resolved.endpoint, + odinCultMeshUri: options.odinCultMeshUri, + }, + }; +} + +function findSleipnirCommandRoute(catalog, providerId) { + const providers = catalog.providers.filter((provider) => provider.id === providerId || String(provider.id).includes("sleipnir")); + for (const provider of providers) { + const endpoints = [...(provider.endpoints || []), ...(provider.routes || [])]; + const route = endpoints.find((endpoint) => { + const uri = endpoint.uri || endpoint.endpoint || endpoint.address || ""; + const role = [endpoint.id, endpoint.role, ...(endpoint.tags || [])].join(" ").toLowerCase(); + return uri.startsWith("cultmesh://") && role.includes("command") && role.includes("sleipnir"); + }) || endpoints.find((endpoint) => { + const uri = endpoint.uri || endpoint.endpoint || endpoint.address || ""; + return uri.startsWith("cultmesh://") && uri.includes("sleipnir") && uri.includes("input_mapping"); + }); + if (route) { + return { + ...route, + uri: route.uri || route.endpoint || route.address, + }; + } + } + return null; +} + +function findProviderCommandRoute(catalog, providerId) { + const normalized = String(providerId || "").trim(); + const providers = catalog.providers.filter((provider) => { + if (!normalized) return true; + return provider.id === normalized || provider.id.includes(normalized) || normalized.includes(provider.id); + }); + for (const provider of providers) { + const endpoints = [...(provider.endpoints || []), ...(provider.routes || [])]; + const route = endpoints.find((endpoint) => { + const uri = endpoint.uri || endpoint.endpoint || endpoint.address || ""; + const role = [endpoint.id, endpoint.role, ...(endpoint.tags || [])].join(" ").toLowerCase(); + return uri.startsWith("cultmesh://") && role.includes("command"); + }); + if (route) { + const uri = route.uri || route.endpoint || route.address; + const address = String(route.address || "").trim(); + return { + ...route, + uri, + publishEndpoint: address ? `rudp://${address}` : uri, + }; + } + } + return ""; +} + +// Provider state routes change far more slowly than state does, and resolving +// one costs a round of Odin catalog reads. Cache the route, not the state. +const providerStateRoutes = new Map(); +const PROVIDER_ROUTE_TTL_MS = 30_000; + +async function resolveProviderStateEndpoint(options, providerId) { + const cached = providerStateRoutes.get(providerId); + if (cached && cached.expiresAt > Date.now()) return cached.endpoint; + + const catalog = await readCatalog(options); + const endpoint = findProviderStateEndpoint(catalog, providerId); + if (!endpoint) { + throw new Error(`Odin advertises no reachable state route for provider ${providerId}.`); + } + providerStateRoutes.set(providerId, { endpoint, expiresAt: Date.now() + PROVIDER_ROUTE_TTL_MS }); + return endpoint; +} + +function findProviderStateEndpoint(catalog, providerId) { + const normalized = String(providerId || "").trim(); + const provider = (catalog.providers || []).find((candidate) => candidate.id === normalized) + || (catalog.providers || []).find((candidate) => candidate.id.includes(normalized) || normalized.includes(candidate.id)); + if (!provider) return ""; + + const endpoints = [...(provider.endpoints || []), ...(provider.routes || [])]; + const stateEndpoint = endpoints.find((endpoint) => { + const role = [endpoint.id, endpoint.role, ...(endpoint.tags || [])].join(" ").toLowerCase(); + return Boolean(endpoint.address) && /state|document|surface/.test(role); + }); + const anyEndpoint = endpoints.find((endpoint) => Boolean(endpoint.address)); + const address = stateEndpoint?.address || anyEndpoint?.address || provider.cultMeshAddress; + if (!address) return ""; + return String(address).startsWith("rudp://") ? String(address) : `rudp://${address}`; +} + +// The read behind the provider subscription. Odin says where; the provider +// answers with what. Hermodr does not cache the answer as truth: the +// subscription source owns lifecycle, and this returns null for absence rather +// than throwing, so a missing document reads as withdrawal while a transport +// failure propagates as an error and is not mistaken for one. +async function readProviderStateDocument(options, providerId, schemaId, recordKey) { + const endpoint = await resolveProviderStateEndpoint(options, providerId); + const peer = await getProviderRudpPeer(endpoint); + try { + const document = await requestCultNetRawSnapshotDocument(peer, schemaId, recordKey, { + timeoutMs: 4_000, + messageIdPrefix: "hermodr-provider-state", + }); + if (!document) return null; + return decodeMessagePack(bufferFromPayload(document.payload)); + } catch (error) { + if (/did not return/.test(String(error?.message || ""))) { + // The provider answered and does not hold this document. That is absence, + // not failure. + return null; + } + // The peer may be wedged. Drop it so the next poll reconnects, and let the + // subscription source treat this as a failed read rather than a withdrawal. + await dropProviderRudpPeer(endpoint); + providerStateRoutes.delete(providerId); + throw error; + } +} + +function normalizeProviderAdvertisement(value, recordKey) { + const providerId = value?.providerId || value?.provider?.id || value?.id || recordKey; + if (!providerId) { + return null; + } + const endpoints = arrayOfObjects(value?.endpoints); + const routes = arrayOfObjects(value?.routes); + return { + id: String(providerId), + title: value.title || value.provider?.title || String(providerId), + description: value.description || "", + cultMeshAddress: value.cultMeshAddress || value.provider?.endpoint || null, + endpoints, + routes, + commandSurface: value.commandSurface || null, + surfaces: normalizeAdvertisedSurfaces(value.surfaces), + status: value.status || "unknown", + updatedAt: value.updatedAt || value.generatedAt || null, + }; +} + +function normalizeSurfaceState(value, recordKey) { + if (Array.isArray(value)) { + value = { + providerId: value[0], + title: value[1], + version: value[2], + updatedAt: value[3], + surface: value[4], + }; + } + const providerId = value?.providerId || value?.provider_id || recordKey?.replace(/^surface:/, ""); + if (!providerId) { + return null; + } + return { + providerId: String(providerId), + title: value.title || String(providerId), + version: value.version || 0, + updatedAt: value.updatedAt || value.updated_at || null, + surface: value.surface ? normalizeEveSurfaceTree(value.surface) : null, + }; +} + +function decodeCultMeshDocumentId(documentId) { + const text = String(documentId || ""); + if (!text.startsWith("cultmesh://")) return text; + const parsed = new URL(text); + return decodeURIComponent(parsed.pathname.replace(/^\/+/, "")); +} + +function arrayOfObjects(value) { + return Array.isArray(value) ? value.filter((item) => item && typeof item === "object" && !Array.isArray(item)) : []; +} + +function requireObject(label) { + return { + parse(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object.`); + } + return value; + }, + }; +} + +function readJsonBody(request) { + return new Promise((resolve, reject) => { + const chunks = []; + request.on("data", (chunk) => chunks.push(chunk)); + request.on("error", reject); + request.on("end", () => { + try { + const text = Buffer.concat(chunks).toString("utf8").trim(); + resolve(text ? JSON.parse(text) : {}); + } catch (error) { + reject(new Error(`Invalid JSON body: ${error.message}`)); + } + }); + }); +} + +function writeJson(response, statusCode, body) { + writeCors(response, statusCode); + response.setHeader("Content-Type", "application/json; charset=utf-8"); + response.end(`${JSON.stringify(body, null, 2)}\n`); +} + +function writeCors(response, statusCode) { + response.statusCode = statusCode; + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS"); + response.setHeader("Access-Control-Allow-Headers", "content-type"); +} + +async function serveStaticFile(response, filePath, contentType, headOnly = false) { + if (!filePath || !fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) { + writeJson(response, 404, { ok: false, error: "not found" }); + return; + } + writeCors(response, 200); + response.setHeader("Content-Type", contentType || contentTypeForPath(filePath)); + response.setHeader("Content-Length", fs.statSync(filePath).size); + if (headOnly) { + response.end(); + return; + } + response.end(await fs.promises.readFile(filePath)); +} + +function safeJoin(root, relativePath) { + const resolved = path.resolve(root, relativePath || ""); + const normalizedRoot = path.resolve(root); + if (resolved !== normalizedRoot && !resolved.startsWith(`${normalizedRoot}${path.sep}`)) { + return ""; + } + return resolved; +} + +async function readAssetCatalog(options) { + const catalog = await readCatalog(options); + return { + ok: true, + schema: "gamecult.hermodr.asset_catalog.v1", + source: "odin-cultmesh-provider-routes", + providers: catalog.providers, + manifests: [], + }; +} + +async function serveCultMeshAsset(options, response, uri, headOnly = false) { + const assetUri = normalizeCultMeshAssetUri(uri); + try { + const asset = await fetchCultMeshCdnAsset(options, assetUri); + writeCors(response, 200); + response.setHeader("Content-Type", asset.contentType || contentTypeForPath(assetUri)); + response.setHeader("Content-Length", asset.bytes.length); + response.setHeader("X-CultMesh-CDN-Provider", asset.providerId); + response.setHeader("X-CultMesh-CDN-Endpoint", asset.endpoint); + if (headOnly) { + response.end(); + return; + } + response.end(asset.bytes); + return; + } catch (error) { + writeJson(response, 502, { + ok: false, + error: `Provider-owned CultMesh CDN fetch failed for ${assetUri}: ${error instanceof Error ? error.message : String(error)}`, + note: "Hermodr resolves provider-advertised CultMesh CDN routes and never reads product filesystems.", + }); + return; + } +} + +async function fetchCultMeshCdnAsset(options, assetUri) { + const catalog = await readCatalog(options); + const route = findProviderCdnRoute(catalog, assetUri); + if (!route) { + throw new Error(`No provider advertised a CultMesh CDN route for '${assetUri}'.`); + } + + const peer = await getProviderRudpPeer(route.endpoint); + const document = await requestCultNetRawSnapshotDocument( + peer, + "gamecult.cultmesh.cdn.asset_blob.v1", + assetUri, + { timeoutMs: 4_000, messageIdPrefix: "hermodr-cdn" }, + ); + return { + providerId: route.providerId, + endpoint: route.endpoint, + contentType: contentTypeFromTags(document.tags) || contentTypeForPath(assetUri), + bytes: bufferFromMessagePackedBytes(document.payload), + }; +} + +async function fetchProviderDocument(catalog, request) { + const route = findProviderSnapshotRoute(catalog, request.providerId); + if (!route) return null; + const recordKey = decodeCultMeshDocumentId(request.documentId); + const document = await requestProviderSnapshotDocumentWithReconnect( + route.endpoint, + request.schemaId, + recordKey, + { timeoutMs: 4_000, messageIdPrefix: "hermodr-document" }, + ); + return { + endpoint: route.endpoint, + schemaId: document.schemaId, + recordKey: document.recordKey, + document: decodeMessagePack(bufferFromPayload(document.payload)), + }; +} + +async function requestProviderSnapshotDocumentWithReconnect(endpoint, schemaId, recordKey, options = {}) { + try { + const peer = await getProviderRudpPeer(endpoint); + return await requestCultNetRawSnapshotDocument(peer, schemaId, recordKey, options); + } catch (firstError) { + await dropProviderRudpPeer(endpoint); + const peer = await getProviderRudpPeer(endpoint); + try { + return await requestCultNetRawSnapshotDocument(peer, schemaId, recordKey, options); + } catch (secondError) { + secondError.message = `${secondError.message} Provider route ${endpoint} was retried after dropping a stale Hermodr RUDP peer. First error: ${ + firstError instanceof Error ? firstError.message : String(firstError) + }`; + throw secondError; + } + } +} + +async function requestProviderSnapshotFirstDocumentWithReconnect(endpoint, schemaId, recordKeys, options = {}) { + try { + const peer = await getProviderRudpPeer(endpoint); + return await requestCultNetRawSnapshotFirstDocument(peer, schemaId, recordKeys, options); + } catch (firstError) { + await dropProviderRudpPeer(endpoint); + const peer = await getProviderRudpPeer(endpoint); + try { + return await requestCultNetRawSnapshotFirstDocument(peer, schemaId, recordKeys, options); + } catch (secondError) { + secondError.message = `${secondError.message} Provider route ${endpoint} was retried after dropping a stale Hermodr RUDP peer. First error: ${ + firstError instanceof Error ? firstError.message : String(firstError) + }`; + throw secondError; + } + } +} + +async function getProviderRudpPeer(endpoint) { + const key = String(endpoint || "").trim(); + if (!key) throw new Error("Provider RUDP endpoint is empty."); + const existing = providerRudpPeers.get(key); + if (existing) return existing; + + const pending = CultMesh.createRudpPeer( + "hermodr-browser-lowering-provider", + providerRudpConnectionId, + key, + hermodrRudpPeerOptions(), + ).catch((error) => { + providerRudpPeers.delete(key); + throw error; + }); + providerRudpPeers.set(key, pending); + return pending; +} + +async function dropProviderRudpPeer(endpoint) { + const key = String(endpoint || "").trim(); + const peerOrPromise = providerRudpPeers.get(key); + providerRudpPeers.delete(key); + if (!peerOrPromise) return; + try { + const peer = await peerOrPromise; + if (typeof peer?.close === "function") peer.close(); + } catch { + // The peer is already unusable; removing it is the repair. + } +} + +function closeProviderRudpPeers() { + for (const peerOrPromise of providerRudpPeers.values()) { + Promise.resolve(peerOrPromise) + .then((peer) => { + if (typeof peer?.close === "function") peer.close(); + }) + .catch(() => {}); + } + providerRudpPeers.clear(); +} + +function findProviderSnapshotRoute(catalog, providerId) { + const normalized = String(providerId || "").trim(); + const providers = (catalog.providers || []).filter((provider) => { + if (!normalized) return true; + return provider.id === normalized || + provider.id.includes(normalized) || + normalized.includes(provider.id); + }); + const candidates = normalized ? providers : (catalog.providers || []); + for (const provider of candidates) { + const endpoints = [...(provider.endpoints || []), ...(provider.routes || [])]; + const route = endpoints.find((endpoint) => { + const address = String(endpoint.uri || endpoint.endpoint || endpoint.address || ""); + const role = [endpoint.id, endpoint.role, endpoint.resolver, ...(endpoint.tags || [])].join(" ").toLowerCase(); + return address.startsWith("rudp://") && (role.includes("snapshot") || role.includes("provider-cultmesh-rudp")); + }) || endpoints.find((endpoint) => { + const address = String(endpoint.uri || endpoint.endpoint || endpoint.address || ""); + return address.startsWith("rudp://"); + }); + if (route) { + return { + providerId: provider.id, + endpoint: route.uri || route.endpoint || route.address, + }; + } + } + return null; +} + +function findProviderCdnRoute(catalog, assetUri = "") { + const providerHint = providerIdFromCultMeshUri(assetUri); + const providers = (catalog.providers || []).filter((provider) => + !providerHint || provider.id === providerHint || provider.id.startsWith(`${providerHint}.`) || providerHint.startsWith(`${provider.id}.`)); + for (const provider of providers) { + const endpoints = [...(provider.endpoints || []), ...(provider.routes || [])]; + const route = endpoints.find((endpoint) => { + const address = String(endpoint.uri || endpoint.endpoint || endpoint.address || ""); + const role = [endpoint.id, endpoint.role, endpoint.resolver, endpoint.schemaId, ...(endpoint.tags || [])].join(" ").toLowerCase(); + return (address.startsWith("cultmesh://") || address.startsWith("rudp://")) && + role.includes("cultmesh-cdn") && + (role.includes("asset_blob") || role.includes("cdn")); + }) || endpoints.find((endpoint) => { + const address = String(endpoint.uri || endpoint.endpoint || endpoint.address || ""); + const role = [endpoint.id, endpoint.role, endpoint.resolver, ...(endpoint.tags || [])].join(" ").toLowerCase(); + return (address.startsWith("cultmesh://") || address.startsWith("rudp://")) && role.includes("cdn"); + }); + if (route) { + return { + providerId: provider.id, + endpoint: route.uri || route.endpoint || route.address, + }; + } + } + return null; +} + +async function requestCultNetRawSnapshotDocument(peer, schemaId, recordKey, options = {}) { + const messageId = `${options.messageIdPrefix || "hermodr-snapshot"}:${Date.now()}:${Math.random().toString(16).slice(2)}`; + const response = await requestCultNetRawSnapshot(peer, [recordKey], { + schemaIds: [schemaId], + messageId, + timeoutMs: options.timeoutMs, + }); + const candidates = response.documents.filter((candidate) => candidate.recordKey === recordKey); + const document = candidates.find((candidate) => candidate.schemaId === schemaId) || candidates[0]; + if (!document) { + throw new Error(`CultMesh peer snapshot did not return ${schemaId} at ${recordKey}.`); + } + return document; +} + +async function requestCultNetRawSnapshotFirstDocument(peer, schemaId, recordKeys, options = {}) { + const keys = [...new Set(recordKeys.map((key) => String(key || "").trim()).filter(Boolean))]; + const messageId = `${options.messageIdPrefix || "hermodr-snapshot"}:${Date.now()}:${Math.random().toString(16).slice(2)}`; + const response = await requestCultNetRawSnapshot(peer, keys, { + schemaIds: [schemaId], + messageId, + timeoutMs: options.timeoutMs, + }); + const document = response.documents.find((candidate) => candidate.schemaId === schemaId) || response.documents[0]; + if (!document) { + throw new Error(`CultMesh peer snapshot did not return ${schemaId} at ${keys.join(", ")}.`); + } + return document; +} + +async function requestCultNetRawSnapshot(peer, recordKeys, options) { + const messageId = options.messageId || `${options.messageIdPrefix || "hermodr-snapshot"}:${Date.now()}:${Math.random().toString(16).slice(2)}`; + return new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timer); + peer.off("message", onMessage); + peer.off("invalidMessage", onInvalidMessage); + peer.off("error", onError); + peer.off("close", onClose); + }; + const rejectWith = (error) => { + cleanup(); + reject(error); + }; + const onMessage = (message) => { + if (message?.schemaVersion === "cultnet.error.v0") { + rejectWith(new Error(message.error || "CultNet peer returned an error.")); + return; + } + if (message?.schemaVersion !== "cultnet.snapshot_response_raw.v0" || + message.messageId !== messageId) { + return; + } + cleanup(); + resolve(message); + }; + const onInvalidMessage = (error) => rejectWith(error); + const onError = (error) => rejectWith(error); + const onClose = () => rejectWith(new Error("CultNet peer closed before snapshot response.")); + const timer = setTimeout( + () => rejectWith(new Error(`Timed out waiting for CultNet snapshot ${messageId}.`)), + options.timeoutMs || 4_000, + ); + + peer.on("message", onMessage); + peer.on("invalidMessage", onInvalidMessage); + peer.on("error", onError); + peer.on("close", onClose); + peer.sendSnapshotRequest({ + schemaVersion: "cultnet.snapshot_request.v0", + messageId, + ...(recordKeys.length > 0 ? { recordKeys: [...recordKeys] } : {}), + schemaIds: Array.isArray(options.schemaIds) ? [...options.schemaIds] : undefined, + }); + }); +} + +function bufferFromMessagePackedBytes(payload) { + const decoded = decodeMessagePack(bufferFromPayload(payload)); + return bufferFromPayload(decoded); +} + +function bufferFromPayload(payload) { + if (Buffer.isBuffer(payload)) return payload; + if (payload instanceof Uint8Array) return Buffer.from(payload); + if (Array.isArray(payload)) return Buffer.from(payload); + if (payload && typeof payload === "object") { + const values = Object.keys(payload) + .sort((left, right) => Number(left) - Number(right)) + .map((key) => payload[key]); + if (values.every((value) => Number.isInteger(value) && value >= 0 && value <= 255)) { + return Buffer.from(values); + } + } + throw new Error("CultMesh CDN asset payload was not binary."); +} + +function contentTypeFromTags(tags) { + const tag = (Array.isArray(tags) ? tags : []).find((candidate) => String(candidate).startsWith("mime:")); + return tag ? String(tag).slice("mime:".length) : ""; +} + +function normalizeCultMeshAssetUri(uri) { + const text = String(uri || "").trim(); + if (!text) return ""; + if (text.startsWith("cultmesh://") || text.startsWith("resources://")) return text; + throw new Error(`Asset URI '${text}' is not a provider-owned CultMesh URI.`); +} + +function providerIdFromCultMeshUri(uri) { + const match = /^cultmesh:\/\/([^/]+)/i.exec(String(uri || "").trim()); + return match ? decodeURIComponent(match[1]) : ""; +} + +function contentTypeForPath(filePath) { + const ext = path.extname(filePath).toLowerCase(); + return { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".svg": "image/svg+xml", + }[ext] || "application/octet-stream"; +} + +function stringOption(value, fallback) { + return typeof value === "string" && value.trim() ? value.trim() : fallback; +} + +function numberOption(value, fallback) { + const parsed = Number.parseInt(String(value ?? ""), 10); + return Number.isInteger(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback; +} + +function stringListOption(value, fallback, defaultValue = []) { + const text = String(value ?? fallback ?? "").trim(); + if (!text) return [...defaultValue]; + return text.split(",").map((entry) => entry.trim()).filter(Boolean); +} + +function resolveCultCachePackagePath() { + const candidate = path.resolve(projectsRoot, "CultLib", "packages", "cultcache-ts", "package.json"); + if (!fs.existsSync(candidate)) { + throw new Error(`CultCache TypeScript runtime is unavailable at ${candidate}`); + } + return candidate; +} + +function resolveCultMeshPackagePath() { + const candidate = path.resolve(process.env.CULTLIB_ROOT || path.resolve(projectsRoot, "CultLib"), "packages", "cultmesh-ts", "package.json"); + if (!fs.existsSync(candidate)) { + throw new Error(`CultMesh TypeScript runtime is unavailable at ${candidate}`); + } + return candidate; +} + +function resolveCultCacheRuntimePath() { + return path.resolve(projectsRoot, "CultLib", "packages", "cultcache-ts", "dist", "index.js"); +} + +function resolveCultMeshRuntimePath() { + return path.resolve(process.env.CULTLIB_ROOT || path.resolve(projectsRoot, "CultLib"), "packages", "cultmesh-ts", "dist", "index.js"); +} + +function printUsage() { + console.log(`Usage: + node src/hermodr-daemon.cjs [--host 127.0.0.1] [--port 8798] [--odin-cultmesh-uri cultmesh://odin/rendezvous/provider-catalog] + +Hermodr is a browser lowering adapter. It owns no provider discovery and no +daemon state. Odin advertises where a provider's state lives; Hermodr resolves +that route and reads the provider directly, then lowers the surface to browsers +and relays operator intent back as typed CultMesh command documents. + +Live Eve state needs a reachable Odin catalog and a CULTLIB_ROOT pointing at the +CultLib reliability branch. Poll cadence is --provider-poll-interval-ms +(default 2000). +`); +} + +if (require.main === module) { + main().catch((error) => { + const statusCode = error?.statusCode || 1; + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = statusCode === 409 ? 1 : statusCode; + }); +} + +module.exports = { + createBrowserCatalog, + createHermodrBridge, + findProviderCdnRoute, + hermodrRudpPeerOptions, + normalizeStateBindingSource, + normalizeProviderAdvertisement, + normalizeSurfaceState, + surfaceRecordKeys, +}; diff --git a/src/hermodr-provider-subscription.cjs b/src/hermodr-provider-subscription.cjs new file mode 100644 index 0000000..9fe43f7 --- /dev/null +++ b/src/hermodr-provider-subscription.cjs @@ -0,0 +1,154 @@ +"use strict"; + +// Provider state subscription for the browser lowering. +// +// Hermodr previously ran its own provider-session ingress and accepted +// publications into a private store, which made it a required ingress rather +// than a bridge: a surface reached a browser only if its provider bound to +// Hermodr specifically. Providers own their state. Odin publishes where to find +// it. This source resolves a provider through the catalog and reads that +// provider directly, exactly as the command path already routes intent. +// +// It presents the same narrow port the previous store did - +// forProvider(id) -> { latest, watch, watchLifecycle } - so +// HermodrStateStreamRegistry, its sequence numbers, and its stale/reconnected +// transitions are untouched. +// +// readDocument is injected. That keeps the polling and lifecycle semantics +// testable without a live Verse, and keeps CultMesh transport out of this file. + +const DEFAULT_POLL_INTERVAL_MS = 2_000; + +class ProviderSubscriptionSource { + constructor(readDocument, options = {}) { + if (typeof readDocument !== "function") { + throw new Error("Provider subscription source requires a readDocument(providerId, schemaId, recordKey) function."); + } + this.readDocument = readDocument; + this.pollIntervalMs = Number.isFinite(options.pollIntervalMs) && options.pollIntervalMs > 0 + ? options.pollIntervalMs + : DEFAULT_POLL_INTERVAL_MS; + this.setInterval = options.setInterval || setInterval; + this.clearInterval = options.clearInterval || clearInterval; + this.onError = options.onError || (() => {}); + this.entries = new Map(); + this.sequence = 0; + } + + forProvider(providerId) { + return { + latest: (schemaId, recordKey) => this.latest(providerId, schemaId, recordKey), + watch: (schemaId, recordKey, callback) => this.watch(providerId, schemaId, recordKey, callback), + watchLifecycle: (schemaId, recordKey, callback) => this.watchLifecycle(providerId, schemaId, recordKey, callback), + }; + } + + async latest(providerId, schemaId, recordKey) { + const value = await this.readDocument(providerId, schemaId, recordKey); + if (value === undefined || value === null) { + throw new Error(`Provider ${providerId} published no ${schemaId}:${recordKey}.`); + } + return value; + } + + watch(providerId, schemaId, recordKey, callback) { + return this.watchLifecycle(providerId, schemaId, recordKey, (event) => { + if (event.kind !== "withdrawn") callback(event.value); + }); + } + + watchLifecycle(providerId, schemaId, recordKey, callback) { + const key = keyOf(providerId, schemaId, recordKey); + let entry = this.entries.get(key); + if (!entry) { + entry = { + providerId, + schemaId, + recordKey, + listeners: new Set(), + value: undefined, + present: false, + timer: null, + polling: false, + }; + this.entries.set(key, entry); + entry.timer = this.setInterval(() => { this.poll(key); }, this.pollIntervalMs); + if (typeof entry.timer?.unref === "function") entry.timer.unref(); + } + + entry.listeners.add(callback); + if (entry.present) { + callback({ kind: "snapshot", sequence: ++this.sequence, value: entry.value }); + } + // First listener on a cold entry still needs a value without waiting a full + // interval. Subsequent listeners piggyback on the shared poll. + this.poll(key); + + let released = false; + return () => { + if (released) return; + released = true; + entry.listeners.delete(callback); + if (entry.listeners.size === 0) { + this.clearInterval(entry.timer); + this.entries.delete(key); + } + }; + } + + async poll(key) { + const entry = this.entries.get(key); + if (!entry || entry.polling) return; + entry.polling = true; + try { + let value; + try { + value = await this.readDocument(entry.providerId, entry.schemaId, entry.recordKey); + } catch (error) { + // A read failure is not a withdrawal. The provider may be restarting, + // and claiming withdrawal would let a transport hiccup look like the + // provider retracting state it still holds. + this.onError(error, { providerId: entry.providerId, schemaId: entry.schemaId, recordKey: entry.recordKey }); + return; + } + + if (!this.entries.has(key)) return; + + const missing = value === undefined || value === null; + if (missing) { + if (entry.present) { + entry.present = false; + entry.value = undefined; + this.emit(entry, { kind: "withdrawn", sequence: ++this.sequence, reason: "withdrawn" }); + } + return; + } + + if (entry.present && equivalent(entry.value, value)) return; + + const kind = entry.present ? "update" : "snapshot"; + entry.present = true; + entry.value = value; + this.emit(entry, { kind, sequence: ++this.sequence, value }); + } finally { + entry.polling = false; + } + } + + emit(entry, event) { + for (const listener of entry.listeners) listener(event); + } +} + +function keyOf(providerId, schemaId, recordKey) { + if (!String(providerId || "").trim() || !String(schemaId || "").trim() || !String(recordKey || "").trim()) { + throw new Error("Provider subscription requires providerId, schemaId, and recordKey."); + } + return `${providerId}\u001f${schemaId}\u001f${recordKey}`; +} + +function equivalent(left, right) { + return JSON.stringify(left) === JSON.stringify(right); +} + +module.exports = { ProviderSubscriptionSource, DEFAULT_POLL_INTERVAL_MS }; diff --git a/src/hermodr-state-stream.cjs b/src/hermodr-state-stream.cjs new file mode 100644 index 0000000..b5db48b --- /dev/null +++ b/src/hermodr-state-stream.cjs @@ -0,0 +1,32 @@ +"use strict"; + +class HermodrStateStreamRegistry { + constructor(source) { this.source = source; this.entries = new Map(); } + acquire(selection, listener) { + const key = `${selection.schemaId}\u001f${selection.recordKey}`; + let entry = this.entries.get(key); + if (!entry) { + entry = { listeners: new Set([listener]), sequence: 0, stale: false, unsubscribe: null }; + entry.unsubscribe = this.source.forProvider(selection.providerId).watchLifecycle(selection.schemaId, selection.recordKey, event => { + const type = event.kind === "withdrawn" ? "stale" : entry.stale ? "reconnected" : event.kind; + entry.stale = type === "stale"; + const lowered = { type, sequence: ++entry.sequence, source: selection, + ...(event.value === undefined ? {} : { value: event.value }), + ...(event.reason ? { reason: event.reason } : {}) }; + for (const subscriber of entry.listeners) subscriber(lowered); + }); + this.entries.set(key, entry); + } else { + entry.listeners.add(listener); + } + let released = false; + return () => { + if (released) return; + released = true; + entry.listeners.delete(listener); + if (entry.listeners.size === 0) { entry.unsubscribe(); this.entries.delete(key); } + }; + } +} + +module.exports = { HermodrStateStreamRegistry }; diff --git a/src/idunn-health.cjs b/src/idunn-health.cjs new file mode 100644 index 0000000..6fa676c --- /dev/null +++ b/src/idunn-health.cjs @@ -0,0 +1,65 @@ +"use strict"; + +// Idunn health publication, as Odin consumes it. +// +// The transport used to live here: 300-odd lines of socket handling, RUDP +// handshaking, Ed25519 signing, and packet receipt, of which almost nothing was +// about Idunn. That is generic CultNet client work and now lives in CultLib as +// cultnet-ts's signed-daemon-health module. +// +// What remains is what Odin actually needs to know: Idunn's wire contract, and +// the shape of the calls its consumers already make. The constants are defined +// by Idunn in docs/signed-daemon-health-authority.md. They are declared here, +// not decided here; if they change, that document changes first. + +const path = require("path"); +const { createRequire } = require("module"); + +const cultLibRoot = process.env.CULTLIB_ROOT + ? path.resolve(process.env.CULTLIB_ROOT) + : path.resolve(__dirname, "..", "..", "CultLib"); + +const requireCultNet = createRequire(path.resolve(cultLibRoot, "packages", "cultnet-ts", "package.json")); + +const { + CULTNET_RUDP_PROTOCOL_ID, + createSignedDaemonHealthPublisher, + publishSignedDaemonHealth, + signedDaemonHealthPayload, + signedDaemonHealthSigningMessage, +} = requireCultNet("./dist/index.js"); + +// Idunn's contract. Owner: GameCult/Idunn, docs/signed-daemon-health-authority.md. +const IDUNN_HEALTH_CONTRACT = Object.freeze({ + connectionId: 0x1d0d0001, + signedSchemaId: "idunn.signed_daemon_health.v1", + unsignedSchemaId: "idunn.daemon_health", + messageIdPrefix: "odin-health", +}); + +function createIdunnRudpHealthPublisher(options) { + if (!options) return null; + return createSignedDaemonHealthPublisher({ + sourceRuntimeId: "hermodr", + ...options, + contract: IDUNN_HEALTH_CONTRACT, + }); +} + +const publishIdunnRudpHealth = publishSignedDaemonHealth; +const signedHealthPayload = signedDaemonHealthPayload; + +// The signing purpose is Idunn's signed schema id, so a statement signed for +// Idunn cannot verify as another service's health. +function signingMessage(payload) { + return signedDaemonHealthSigningMessage(IDUNN_HEALTH_CONTRACT.signedSchemaId, payload); +} + +module.exports = { + CULTNET_RUDP_PROTOCOL_ID, + IDUNN_HEALTH_CONTRACT, + createIdunnRudpHealthPublisher, + publishIdunnRudpHealth, + signedHealthPayload, + signingMessage, +}; diff --git a/test/hermodr-catalog.test.cjs b/test/hermodr-catalog.test.cjs new file mode 100644 index 0000000..7a4a2c2 --- /dev/null +++ b/test/hermodr-catalog.test.cjs @@ -0,0 +1,70 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { + createBrowserCatalog, + findProviderCdnRoute, + hermodrRudpPeerOptions, + normalizeProviderAdvertisement, + normalizeSurfaceState, + surfaceRecordKeys, +} = require("../src/hermodr-daemon.cjs"); + +test("Hermodr outbound RUDP peers bind beyond loopback", () => { + const options = hermodrRudpPeerOptions(); + assert.equal(options.bindHost, "0.0.0.0"); + assert.equal(options.connectTimeoutMs, 2_000); +}); + +const provider = normalizeProviderAdvertisement({ + providerId: "fixture.game", + title: "Fixture game", + surfaces: [ + { surfaceId: "fixture.menu", surfaceKind: "menu", recordRef: "cultmesh://fixture/surfaces/menu" }, + { surfaceId: "fixture.world", surfaceKind: "interactive-world", recordRef: "cultmesh://fixture/surfaces/world" }, + ], + routes: [{ + id: "fixture-cdn", + uri: "rudp://127.0.0.1:3000", + tags: ["cultmesh-cdn", "asset_blob"], + }], +}); + +test("Hermodr preserves every advertised surface and its semantic kind", () => { + const catalog = createBrowserCatalog([provider], { odinCultMeshUri: "cultmesh://odin/providers" }); + assert.deepEqual(catalog.surfaces.map(surface => surface.surfaceId), ["fixture.menu", "fixture.world"]); + assert.equal(catalog.surfaces[1].surfaceKind, "interactive-world"); + assert.equal(catalog.providers[0].surfaces[1].recordRef, "cultmesh://fixture/surfaces/world"); +}); + +test("surface reads prefer the provider-advertised record reference", () => { + const catalog = createBrowserCatalog([provider]); + assert.equal(surfaceRecordKeys(catalog, "fixture.game", "fixture.world")[0], "cultmesh://fixture/surfaces/world"); +}); + +test("surface-state normalization lowers MessagePack array records", () => { + const state = normalizeSurfaceState([ + "gjallar.overview", + "Gjallar", + 7, + "2026-08-25T00:00:00Z", + [ + "gjallar.overview.surface", + ["root", "dashboard", { title: "Gjallar" }, [], [], [], { mode: "weighted-bisect" }, {}], + [], + ], + ], "gjallar.overview"); + + assert.equal(state.surface.id, "gjallar.overview.surface"); + assert.equal(state.surface.root.kind, "dashboard"); + assert.equal(state.surface.root.layout.mode, "weighted-bisect"); +}); + +test("CDN routing follows the asset URI provider instead of a product name", () => { + const catalog = createBrowserCatalog([provider]); + const route = findProviderCdnRoute(catalog, "cultmesh://fixture.game/assets/player"); + assert.equal(route.providerId, "fixture.game"); + assert.equal(route.endpoint, "rudp://127.0.0.1:3000"); +}); diff --git a/test/hermodr-provider-subscription.test.cjs b/test/hermodr-provider-subscription.test.cjs new file mode 100644 index 0000000..99e4c27 --- /dev/null +++ b/test/hermodr-provider-subscription.test.cjs @@ -0,0 +1,188 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { ProviderSubscriptionSource } = require("../src/hermodr-provider-subscription.cjs"); + +function harness(reads) { + const state = { value: reads.shift(), calls: 0, cleared: 0 }; + let tick = null; + const source = new ProviderSubscriptionSource( + async () => { + state.calls += 1; + if (state.value instanceof Error) throw state.value; + return state.value; + }, + { + setInterval: (fn) => { tick = fn; return { unref() {} }; }, + clearInterval: () => { state.cleared += 1; tick = null; }, + onError: () => {}, + }, + ); + return { + source, + state, + set: (value) => { state.value = value; }, + poll: async () => { if (tick) tick(); await flush(); }, + hasTimer: () => tick !== null, + }; +} + +async function flush() { + for (let i = 0; i < 4; i += 1) await new Promise((resolve) => setImmediate(resolve)); +} + +function collect(source, providerId = "erycina", schemaId = "gamecult.eve.surface_state.v1", recordKey = "erycina") { + const events = []; + const release = source.forProvider(providerId).watchLifecycle(schemaId, recordKey, (event) => events.push(event)); + return { events, release }; +} + +test("first watch emits a snapshot of the provider's document", async () => { + const h = harness([{ title: "Erycina" }]); + const { events } = collect(h.source); + await flush(); + + assert.equal(events.length, 1); + assert.equal(events[0].kind, "snapshot"); + assert.deepEqual(events[0].value, { title: "Erycina" }); +}); + +test("an unchanged document emits nothing further", async () => { + const h = harness([{ title: "Erycina" }]); + const { events } = collect(h.source); + await flush(); + await h.poll(); + await h.poll(); + + assert.equal(events.length, 1); +}); + +test("a changed document emits an update", async () => { + const h = harness([{ version: 1 }]); + const { events } = collect(h.source); + await flush(); + h.set({ version: 2 }); + await h.poll(); + + assert.equal(events.length, 2); + assert.equal(events[1].kind, "update"); + assert.deepEqual(events[1].value, { version: 2 }); + assert.ok(events[1].sequence > events[0].sequence); +}); + +test("a document that disappears is withdrawn", async () => { + const h = harness([{ version: 1 }]); + const { events } = collect(h.source); + await flush(); + h.set(null); + await h.poll(); + + assert.equal(events.at(-1).kind, "withdrawn"); + assert.equal(events.at(-1).reason, "withdrawn"); +}); + +test("a document that returns after withdrawal emits a fresh snapshot", async () => { + const h = harness([{ version: 1 }]); + const { events } = collect(h.source); + await flush(); + h.set(null); + await h.poll(); + h.set({ version: 3 }); + await h.poll(); + + assert.equal(events.at(-1).kind, "snapshot"); + assert.deepEqual(events.at(-1).value, { version: 3 }); +}); + +test("a read failure is not a withdrawal", async () => { + // A provider restart or a dropped packet must not look like the provider + // retracting state it still holds. This is the invariant that stops a + // transport hiccup from being rendered as authoritative absence. + const h = harness([{ version: 1 }]); + const { events } = collect(h.source); + await flush(); + const before = events.length; + + h.set(new Error("rudp timeout")); + await h.poll(); + await h.poll(); + + assert.equal(events.length, before); + assert.equal(events.at(-1).kind, "snapshot"); +}); + +test("state survives the failure and updates once reads recover", async () => { + const h = harness([{ version: 1 }]); + const { events } = collect(h.source); + await flush(); + h.set(new Error("rudp timeout")); + await h.poll(); + h.set({ version: 9 }); + await h.poll(); + + assert.equal(events.at(-1).kind, "update"); + assert.deepEqual(events.at(-1).value, { version: 9 }); +}); + +test("releasing the last listener stops polling", async () => { + const h = harness([{ version: 1 }]); + const { release } = collect(h.source); + await flush(); + assert.ok(h.hasTimer()); + + release(); + + assert.equal(h.state.cleared, 1); + assert.equal(h.hasTimer(), false); +}); + +test("a second listener shares one poll and receives the current snapshot", async () => { + const h = harness([{ version: 1 }]); + const first = collect(h.source); + await flush(); + const callsAfterFirst = h.state.calls; + + const second = collect(h.source); + await flush(); + + assert.equal(second.events[0].kind, "snapshot"); + assert.deepEqual(second.events[0].value, { version: 1 }); + // The shared entry polls once per interval regardless of listener count. + await h.poll(); + assert.ok(h.state.calls <= callsAfterFirst + 2); + + first.release(); + assert.equal(h.state.cleared, 0, "the entry survives while one listener remains"); + second.release(); + assert.equal(h.state.cleared, 1); +}); + +test("watch skips withdrawal events and yields values only", async () => { + const h = harness([{ version: 1 }]); + const values = []; + h.source.forProvider("erycina").watch("s", "k", (value) => values.push(value)); + await flush(); + h.set(null); + await h.poll(); + h.set({ version: 2 }); + await h.poll(); + + assert.deepEqual(values, [{ version: 1 }, { version: 2 }]); +}); + +test("latest refuses to invent a document the provider does not publish", async () => { + const h = harness([null]); + await assert.rejects( + () => h.source.forProvider("erycina").latest("gamecult.eve.surface_state.v1", "erycina"), + /published no/, + ); +}); + +test("a subscription requires provider, schema, and record key", () => { + const h = harness([{}]); + assert.throws(() => h.source.forProvider("").watchLifecycle("s", "k", () => {}), /requires providerId/); + assert.throws(() => h.source.forProvider("p").watchLifecycle("", "k", () => {}), /requires providerId/); + assert.throws(() => h.source.forProvider("p").watchLifecycle("s", "", () => {}), /requires providerId/); +});