Take the Idunn candidate socket, and keep it out of the advertised URL

Heimdall bound config.port unconditionally, so it could not warm alongside
itself: Idunn runs candidate and incumbent at once under distinct UIDs and
namespaces, and two generations on 4100 is EADDRINUSE. src/config.ts now
prefers GAMECULT_IDUNN_CANDIDATE_BIND, requiring loopback and a usable
port, and falls back to HOST/PORT so developer runs and the current plain
unit are unaffected.

The part worth the care: publicBaseUrl defaulted to `http://${host}:${port}`,
and it builds the OAuth callback URL registered with each provider. Left
alone, a candidate would have advertised its ephemeral port as that
callback -- a URL that stops resolving the moment the candidate is
promoted or discarded, breaking logins in a way that would look like a
provider problem. Listening address and advertised address are separate
authorities, so GC_ACCESS_BASE_URL is now mandatory whenever a candidate
bind is present, and startup fails loudly rather than advertising a
disappearing port.

Six tests cover it, including that regression by name and rejection of
non-loopback, zero-port and malformed values. 41 pass across 10 files.

Recipe corrected while here: required_environment named
HEIMDALL_DATABASE_URL, which does not exist. The real name is
GC_ACCESS_DATABASE_URL, confirmed in src/config.ts and in
/srv/heimdall/env/service.env; that would have failed at first deploy.
HOST and PORT are dropped, since the candidate bind supersedes them under
Idunn, and GC_ACCESS_BASE_URL added as required.

The recipe's status banner now lists what is done and what is pending
rather than saying the contract is unimplemented. The write lease is the
remaining blocker: the state slot is process-bound-single-writer and the
service does not read the lease, so two generations could both open
heimdall.service.cc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-06 02:16:41 +02:00
commit 817d4970b9
3 changed files with 134 additions and 16 deletions

View file

@ -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]

View file

@ -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");

View file

@ -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);
});
});