diff --git a/deployment/idunn/recipe.toml b/deployment/idunn/recipe.toml index 8fe2e0e..5b260d7 100644 --- a/deployment/idunn/recipe.toml +++ b/deployment/idunn/recipe.toml @@ -1,16 +1,17 @@ -# NOT YET DEPLOYABLE. This recipe is correct and `idunn validate` accepts it, -# but the service does not yet implement the runtime contract it declares. +# NOT YET DEPLOYABLE, but closer. Status 2026-09-06: # -# src/index.ts binds `config.port` (4100, fixed). Idunn's warm-then-cutover -# requires a candidate to bind where it is told, via GAMECULT_IDUNN_CANDIDATE_BIND, -# because the candidate and the incumbent must run at once under distinct UIDs -# and namespaces. A service hardcoded to one port cannot warm alongside itself. -# GAMECULT_IDUNN_PROCESS_WRITE_LEASE and GAMECULT_IDUNN_RUNTIME_BUNDLE are -# likewise declared and unread; `grep -r GAMECULT_IDUNN src/` returns nothing. +# done GAMECULT_IDUNN_CANDIDATE_BIND -- src/config.ts takes the candidate +# socket over HOST/PORT, requires loopback and a usable port, and +# refuses to start unless GC_ACCESS_BASE_URL is set explicitly, so +# an ephemeral candidate port can never become the provider- +# registered OAuth callback. +# pending GAMECULT_IDUNN_PROCESS_WRITE_LEASE -- declared here because the +# state slot below is process-bound-single-writer, and unread by the +# service. Two generations could both open heimdall.service.cc. +# pending GAMECULT_IDUNN_RUNTIME_BUNDLE -- declared and unread. # -# Odin, Ghostlight and CodexConnector each read these. Heimdall publishes Idunn -# health already (src/idunn-rudp-health.ts) but stops short of the rest. -# Implement the contract before the first `idunn up heimdall`. +# Do not `idunn up heimdall` until the write lease is honoured. Health +# publication already exists in src/idunn-rudp-health.ts. schema = "gamecult.idunn.target_declaration.v1" target = "heimdall" @@ -94,9 +95,8 @@ required_environment = [ "GAMECULT_IDUNN_RUNTIME_BUNDLE", "GAMECULT_IDUNN_CANDIDATE_BIND", "GAMECULT_IDUNN_PROCESS_WRITE_LEASE", - "HEIMDALL_DATABASE_URL", - "HOST", - "PORT", + "GC_ACCESS_BASE_URL", + "GC_ACCESS_DATABASE_URL", ] [service.health] diff --git a/src/config.ts b/src/config.ts index f2be4cb..1153996 100644 --- a/src/config.ts +++ b/src/config.ts @@ -77,6 +77,41 @@ function readBoolean(envValue: string | undefined, fallback: boolean): boolean { return fallback; } +/** + * Parse `GAMECULT_IDUNN_CANDIDATE_BIND`, the loopback socket Idunn assigns to + * a candidate incarnation. Returns undefined when unset, so a developer run + * and a plain systemd unit keep using HOST/PORT. + * + * Loopback is required: the candidate is reached through Idunn's route, never + * directly. A non-loopback or zero-port value means the launch inputs are + * wrong, and starting anyway would expose an unrouted generation. + */ +function readIdunnCandidateBind( + value: string | undefined +): { host: string; port: number } | undefined { + if (!value) { + return undefined; + } + + const separator = value.lastIndexOf(":"); + if (separator <= 0) { + throw new Error(`GAMECULT_IDUNN_CANDIDATE_BIND is not host:port: ${value}`); + } + + const host = value.slice(0, separator).replace(/^\[|\]$/g, ""); + const port = Number(value.slice(separator + 1)); + + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`GAMECULT_IDUNN_CANDIDATE_BIND has no usable port: ${value}`); + } + + if (host !== "127.0.0.1" && host !== "::1" && host !== "localhost") { + throw new Error(`GAMECULT_IDUNN_CANDIDATE_BIND must be loopback: ${value}`); + } + + return { host, port }; +} + function readProviderConfig(env: NodeJS.ProcessEnv, provider: Provider): ProviderClientConfig { const prefix = `GC_ACCESS_PROVIDER_${provider.toUpperCase()}`; const config: ProviderClientConfig = {}; @@ -106,8 +141,24 @@ function readOptionalString(envValue: string | undefined): string | undefined { export function loadConfig(env: NodeJS.ProcessEnv = process.env): HeimdallConfig { const sourceRoot = fileURLToPath(new URL("../", import.meta.url)); const workspaceRoot = path.resolve(sourceRoot, ".."); - const host = env.HOST ?? "127.0.0.1"; - const port = readInt(env.PORT, 4100); + // Where Heimdall listens and what Heimdall advertises are two different + // authorities, and Idunn is the reason they must not be conflated. Under + // Idunn a candidate and the incumbent run at once, so the candidate is told + // which loopback socket to take; that address is ephemeral by design. The + // advertised base URL builds the OAuth callback registered with each + // provider, and cannot move. + const candidateBind = readIdunnCandidateBind(env.GAMECULT_IDUNN_CANDIDATE_BIND); + const host = candidateBind?.host ?? env.HOST ?? "127.0.0.1"; + const port = candidateBind?.port ?? readInt(env.PORT, 4100); + + if (candidateBind && !env.GC_ACCESS_BASE_URL) { + throw new Error( + "GC_ACCESS_BASE_URL is required when GAMECULT_IDUNN_CANDIDATE_BIND is set: " + + "deriving the public base URL from an Idunn candidate socket would advertise " + + "an ephemeral port as the provider-registered OAuth callback." + ); + } + const publicBaseUrl = trimTrailingSlash(env.GC_ACCESS_BASE_URL ?? `http://${host}:${port}`); const issuer = trimTrailingSlash(env.GC_ACCESS_ISSUER ?? publicBaseUrl); const dataRoot = env.GC_ACCESS_DATA_ROOT ?? path.join(workspaceRoot, ".heimdall-data"); diff --git a/tests/idunn-candidate-bind.test.ts b/tests/idunn-candidate-bind.test.ts new file mode 100644 index 0000000..5467f54 --- /dev/null +++ b/tests/idunn-candidate-bind.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import { loadConfig } from "../src/config.js"; + +describe("Idunn candidate bind", () => { + it("falls back to HOST/PORT when Idunn is not the launcher", () => { + const config = loadConfig({ HOST: "127.0.0.1", PORT: "4100" }); + + expect(config.host).toBe("127.0.0.1"); + expect(config.port).toBe(4100); + }); + + it("takes the candidate socket over HOST/PORT", () => { + // Idunn owns the listen address for a candidate incarnation, because the + // incumbent still holds the ordinary one. HOST/PORT are not consulted. + const config = loadConfig({ + HOST: "127.0.0.1", + PORT: "4100", + GAMECULT_IDUNN_CANDIDATE_BIND: "127.0.0.1:18833", + GC_ACCESS_BASE_URL: "https://heimdall.gamecult.org", + }); + + expect(config.port).toBe(18833); + expect(config.host).toBe("127.0.0.1"); + }); + + it("never advertises the candidate socket as the public base URL", () => { + // The regression this guards: publicBaseUrl defaults to host:port, and it + // builds the OAuth callback registered with each provider. Deriving it + // from an ephemeral candidate port would hand providers a URL that stops + // resolving the moment the candidate is promoted or discarded. + const config = loadConfig({ + GAMECULT_IDUNN_CANDIDATE_BIND: "127.0.0.1:18833", + GC_ACCESS_BASE_URL: "https://heimdall.gamecult.org", + }); + + expect(config.publicBaseUrl).toBe("https://heimdall.gamecult.org"); + expect(config.publicBaseUrl).not.toContain("18833"); + }); + + it("refuses to start under Idunn without an explicit public base URL", () => { + expect(() => + loadConfig({ GAMECULT_IDUNN_CANDIDATE_BIND: "127.0.0.1:18833" }) + ).toThrow(/GC_ACCESS_BASE_URL is required/); + }); + + it("rejects a non-loopback or unusable candidate socket", () => { + for (const value of ["0.0.0.0:18833", "10.77.0.1:18833", "127.0.0.1:0", "127.0.0.1", "127.0.0.1:not-a-port"]) { + expect(() => + loadConfig({ + GAMECULT_IDUNN_CANDIDATE_BIND: value, + GC_ACCESS_BASE_URL: "https://heimdall.gamecult.org", + }) + ).toThrow(/GAMECULT_IDUNN_CANDIDATE_BIND/); + } + }); + + it("accepts a bracketed IPv6 loopback", () => { + const config = loadConfig({ + GAMECULT_IDUNN_CANDIDATE_BIND: "[::1]:18833", + GC_ACCESS_BASE_URL: "https://heimdall.gamecult.org", + }); + + expect(config.host).toBe("::1"); + expect(config.port).toBe(18833); + }); +});