Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5146c722af | ||
|
|
1a36175f3a | ||
|
|
c044155855 | ||
|
|
c0d30e8081 | ||
|
|
e8548614c8 | ||
|
|
55732a901d | ||
|
|
25d43d1121 | ||
|
|
899ea0b8fe | ||
|
|
c15fb97706 | ||
|
|
c1dfe15dd5 | ||
|
|
c7cf78bbbc | ||
|
|
5952cce8a0 | ||
|
|
30a1ace6fd | ||
|
|
df05570ef6 | ||
|
|
d51fa6b999 |
26 changed files with 1107 additions and 241 deletions
13
README.md
13
README.md
|
|
@ -12,6 +12,19 @@ The point is painfully simple:
|
|||
become "everything phones home for every request"
|
||||
- keep app-domain data separate so "shared auth" does not become "shared swamp"
|
||||
|
||||
## Front Door
|
||||
|
||||
- **Value:** one shared auth authority: provider OAuth (Discord, Patreon, GitHub, Twitch, YouTube, Spotify), linked identities, grants, entitlements and session issuance, with Ed25519-signed claims that app backends verify locally.
|
||||
- **Problem:** every GameCult app was rebuilding Discord/Patreon login glue. Heimdall centralizes identity authority and keeps app-domain data in the apps.
|
||||
- **Entry points:**
|
||||
- `src/index.ts` and `src/app.ts`: the Fastify service. `src/signing.ts` and `src/claims.ts` issue JWTs and serve `/.well-known/jwks.json`; `src/oauth.ts` and `src/providers.ts` own provider flows.
|
||||
- `src/verifier.ts`: the local verifier helper an app backend imports to validate Heimdall JWTs without phoning home.
|
||||
- `src/odin-publication.ts` and `plugins/gamecult.heimdall.access`: the redacted Odin/Eve provider advertisement and the `gamecult.heimdall.access` plugin (`docs/eve-access-plugin.md`).
|
||||
- `docs/architecture.md`, `docs/service-contract.md`, `docs/verse-service-contract.md`, `docs/app-profiles/`: the contracts.
|
||||
- `pnpm install`, `pnpm dev`, `pnpm test` (see Dev Commands).
|
||||
- **Live:** a stateful first slice: signing and JWKS, OAuth state for six providers, in-memory and Postgres stores, a real Discord callback path for the Repixelizer flow, sealed provider tokens, the loopback CultNet operation boundary, Idunn health publication, and the deployed Ghostlight Eve host consuming the discovered boundary.
|
||||
- **Planned:** the two-account and eight-account Ghostlight human canaries, refresh and revocation flows, admin surfaces, and broader inspection projections beyond the redacted access boundary.
|
||||
|
||||
## Current Shape
|
||||
|
||||
This repo now has a stateful first slice instead of a decorative skeleton.
|
||||
|
|
|
|||
|
|
@ -26,18 +26,23 @@ Do not trust this file for the exact live HEAD. Always check git.
|
|||
|
||||
## Active Work
|
||||
|
||||
Joystick H1 is the active, M1-critical cut (operator ruling 2026-10-18):
|
||||
campaign `joystick:campaign:self`, target `joystick:target:r1`, branch
|
||||
`eureka/joystick-h1`. Read the campaign and target in the typed mind; do not
|
||||
re-derive them here. The Ghostlight KLTST canaries below stay open, behind H1.
|
||||
Joystick H1 is merged and live (PR GameCult/Heimdall#1; campaign
|
||||
`joystick:campaign:self`). Next: `joystick:follow_up:h1-joystick-consent-round-trip`, then
|
||||
StreamPixels S0/S1 (other sessions). Open Heimdall follow-ups, by id:
|
||||
`joystick:follow_up:cross-process-failed-refresh-marker`,
|
||||
`joystick:follow_up:connect-start-needs-app-auth`,
|
||||
`joystick:follow_up:heimdall-idunn-onboarding`,
|
||||
`joystick:follow_up:joystick-viewer-signin-public-bot`. The Ghostlight KLTST
|
||||
canaries below stay open, behind them.
|
||||
|
||||
## Current Orientation
|
||||
|
||||
Deploy facts below are last-recorded, from 2026-08-22 (`1086aee`); HEAD has
|
||||
moved ~100 commits past it. Check Yggdrasil, not this file, for what runs.
|
||||
Yggdrasil runs Heimdall `d51fa6b`, hand-deployed 2026-10-19 15:51Z;
|
||||
gamecult-ops `210a1af` records backup, rollback and provenance. Facts below
|
||||
about Odin, Ghostlight and CultLib pins were verified at `1086aee` (2026-08-22)
|
||||
and are not re-verified at `d51fa6b`.
|
||||
|
||||
- Yggdrasil runs Heimdall
|
||||
`1086aee01169bf60e8a492b2740db1c6f3e8cabf` with CultLib
|
||||
- At `1086aee01169bf60e8a492b2740db1c6f3e8cabf` Heimdall ran with CultLib
|
||||
`5cefa0db0079a8e3ee22f29d7b9e6e5aa60912a9`
|
||||
- Heimdall publishes four redacted access records to Odin under unique keys:
|
||||
`eve:provider:heimdall`, `heimdall:command-boundary`,
|
||||
|
|
@ -157,8 +162,7 @@ moved ~100 commits past it. Check Yggdrasil, not this file, for what runs.
|
|||
over `cultnet.transport.rudp.v0` when `GC_ACCESS_IDUNN_RUDP_HEALTH` is
|
||||
configured; HTTP `/healthz`, JWKS, discovery, systemd, and nginx routing
|
||||
remain compatibility witnesses
|
||||
- Yggdrasil now runs Heimdall commit
|
||||
`1086aee01169bf60e8a492b2740db1c6f3e8cabf`. Heimdall persists its private
|
||||
- Since `1086aee` Heimdall persists its private
|
||||
provider-health identity at `/srv/heimdall/keys/provider-health-identity.cc`
|
||||
and publishes canonical signed health independently of the slower Verse
|
||||
snapshot cadence. Idunn owns the exact root trust binding, authenticated
|
||||
|
|
@ -180,7 +184,7 @@ moved ~100 commits past it. Check Yggdrasil, not this file, for what runs.
|
|||
|
||||
## Next Real Move
|
||||
|
||||
Joystick H1 first (see Active Work). Everything below is the standing queue.
|
||||
The Active Work follow-ups first. Everything below is the standing queue.
|
||||
|
||||
Do not continue implementation automatically from a rehydrate-only request.
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
"@gamecult/cultcache-ts": "file:vendor/CultLib/packages/cultcache-ts",
|
||||
"cultmesh-ts": "file:vendor/CultLib/packages/cultmesh-ts",
|
||||
"cultnet-ts": "file:vendor/CultLib/packages/cultnet-ts",
|
||||
"fastify": "^5.8.5",
|
||||
"fastify": "^5.12.6",
|
||||
"pg": "^8.20.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
|
|
|
|||
47
pnpm-lock.yaml
generated
47
pnpm-lock.yaml
generated
|
|
@ -28,8 +28,8 @@ importers:
|
|||
specifier: file:vendor/CultLib/packages/cultnet-ts
|
||||
version: file:vendor/CultLib/packages/cultnet-ts
|
||||
fastify:
|
||||
specifier: ^5.8.5
|
||||
version: 5.8.5
|
||||
specifier: ^5.12.6
|
||||
version: 5.12.7
|
||||
pg:
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
|
|
@ -532,17 +532,23 @@ packages:
|
|||
fast-json-stringify@6.3.0:
|
||||
resolution: {integrity: sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==}
|
||||
|
||||
fast-json-stringify@7.1.3:
|
||||
resolution: {integrity: sha512-5TDM1yOF/BDFOclKz2G6rs1RsIl5HXMCrg/npmdNIPNQCezshR5kIHjhwBNuMY67jhkTUAm9DguL8xkL/x0Wtw==}
|
||||
|
||||
fast-querystring@1.1.2:
|
||||
resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
|
||||
|
||||
fast-uri@3.1.0:
|
||||
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
||||
|
||||
fast-uri@4.2.3:
|
||||
resolution: {integrity: sha512-I1PXwP5eAhlNlcdpjt9CgB59op2ZvZfxw+BkqGFVIUOgNpCVTcTqp3HhTX7brxj5vsip+nSY4nTvff+eIA76hg==}
|
||||
|
||||
fastify-plugin@5.1.0:
|
||||
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
|
||||
|
||||
fastify@5.8.5:
|
||||
resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==}
|
||||
fastify@5.12.7:
|
||||
resolution: {integrity: sha512-v4pEPsTRPuqlDIYiQFB+BHjVN4SdHX9u0ABk0ZDUKbbdY1vsNryUd1j4Z+jQEZajvYIVk047Kimva9/AJ79GYw==}
|
||||
|
||||
fastq@1.20.1:
|
||||
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
||||
|
|
@ -556,8 +562,8 @@ packages:
|
|||
picomatch:
|
||||
optional: true
|
||||
|
||||
find-my-way@9.5.0:
|
||||
resolution: {integrity: sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==}
|
||||
find-my-way@9.10.0:
|
||||
resolution: {integrity: sha512-eru+uCPQ2g+nLBkf84MHP/KyNNMar0BDy0j+i8Fb72GF9/YHBZ58q7FODUtfVV7XU9nfRUHXSmaM8NmZcIbZ4Q==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
fsevents@2.3.3:
|
||||
|
|
@ -853,8 +859,8 @@ packages:
|
|||
process-warning@4.0.1:
|
||||
resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==}
|
||||
|
||||
process-warning@5.0.0:
|
||||
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
|
||||
process-warning@5.1.1:
|
||||
resolution: {integrity: sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==}
|
||||
|
||||
quick-format-unescaped@4.0.4:
|
||||
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
|
||||
|
|
@ -1475,15 +1481,26 @@ snapshots:
|
|||
json-schema-ref-resolver: 3.0.0
|
||||
rfdc: 1.4.1
|
||||
|
||||
fast-json-stringify@7.1.3:
|
||||
dependencies:
|
||||
'@fastify/merge-json-schemas': 0.2.1
|
||||
ajv: 8.20.0
|
||||
ajv-formats: 3.0.1(ajv@8.20.0)
|
||||
fast-uri: 4.2.3
|
||||
json-schema-ref-resolver: 3.0.0
|
||||
rfdc: 1.4.1
|
||||
|
||||
fast-querystring@1.1.2:
|
||||
dependencies:
|
||||
fast-decode-uri-component: 1.0.1
|
||||
|
||||
fast-uri@3.1.0: {}
|
||||
|
||||
fast-uri@4.2.3: {}
|
||||
|
||||
fastify-plugin@5.1.0: {}
|
||||
|
||||
fastify@5.8.5:
|
||||
fastify@5.12.7:
|
||||
dependencies:
|
||||
'@fastify/ajv-compiler': 4.0.5
|
||||
'@fastify/error': 4.2.0
|
||||
|
|
@ -1491,11 +1508,11 @@ snapshots:
|
|||
'@fastify/proxy-addr': 5.1.0
|
||||
abstract-logging: 2.0.1
|
||||
avvio: 9.2.0
|
||||
fast-json-stringify: 6.3.0
|
||||
find-my-way: 9.5.0
|
||||
fast-json-stringify: 7.1.3
|
||||
find-my-way: 9.10.0
|
||||
light-my-request: 6.6.0
|
||||
pino: 10.3.1
|
||||
process-warning: 5.0.0
|
||||
process-warning: 5.1.1
|
||||
rfdc: 1.4.1
|
||||
secure-json-parse: 4.1.0
|
||||
semver: 7.7.4
|
||||
|
|
@ -1509,7 +1526,7 @@ snapshots:
|
|||
optionalDependencies:
|
||||
picomatch: 4.0.4
|
||||
|
||||
find-my-way@9.5.0:
|
||||
find-my-way@9.10.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-querystring: 1.1.2
|
||||
|
|
@ -1733,7 +1750,7 @@ snapshots:
|
|||
on-exit-leak-free: 2.1.2
|
||||
pino-abstract-transport: 3.0.0
|
||||
pino-std-serializers: 7.1.0
|
||||
process-warning: 5.0.0
|
||||
process-warning: 5.1.1
|
||||
quick-format-unescaped: 4.0.4
|
||||
real-require: 0.2.0
|
||||
safe-stable-stringify: 2.5.0
|
||||
|
|
@ -1758,7 +1775,7 @@ snapshots:
|
|||
|
||||
process-warning@4.0.1: {}
|
||||
|
||||
process-warning@5.0.0: {}
|
||||
process-warning@5.1.1: {}
|
||||
|
||||
quick-format-unescaped@4.0.4: {}
|
||||
|
||||
|
|
|
|||
45
src/index.ts
45
src/index.ts
|
|
@ -1,4 +1,3 @@
|
|||
import path from "node:path";
|
||||
import { resolveWriteLease, WriteLeaseNotHeldError } from "./process-write-lease.js";
|
||||
import { buildApp } from "./app.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
|
|
@ -7,14 +6,16 @@ import { publishHeimdallOdinState } from "./odin-publication.js";
|
|||
import { startHeimdallPrivateCommandPlane } from "./private-command-plane.js";
|
||||
import type { CultNetOperationServer } from "cultnet-ts";
|
||||
import { createHeimdallRuntimePulse, buildHeimdallHealthDetail, publishHeimdallVerseState } from "./verse-state.js";
|
||||
import {
|
||||
buildPresenceStatement,
|
||||
nextPublisherSequence,
|
||||
readRuntimeBundleFacts,
|
||||
type RuntimeBundleFacts,
|
||||
} from "./runtime-presence.js";
|
||||
import { openPresenceAuthority } from "./runtime-presence.js";
|
||||
|
||||
const config = loadConfig();
|
||||
// Under Idunn the launch's presence keys, bundle facts and publisher sequence
|
||||
// open here, before anything listens. A launch-contract fault throws, uncaught,
|
||||
// and the process exits non-zero with no listener: Idunn fails Warming in
|
||||
// seconds rather than waiting on a candidate that can never sign.
|
||||
const presenceAuthority = config.idunnRuntimeBundlePath
|
||||
? await openPresenceAuthority(config)
|
||||
: undefined;
|
||||
const app = await buildApp({ config });
|
||||
const versePulseIntervalMs = 60_000;
|
||||
const healthPulseIntervalMs = 10_000;
|
||||
|
|
@ -70,13 +71,6 @@ async function publishVerseState(): Promise<void> {
|
|||
await publishHeimdallOdinState(config, pulse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read once and keep: the bundle is Idunn's immutable record of this launch and
|
||||
* cannot change while the process lives, so re-reading it every pulse would be
|
||||
* work that can only ever return the same answer or fail.
|
||||
*/
|
||||
let runtimeBundleFacts: RuntimeBundleFacts | undefined;
|
||||
|
||||
async function publishHealthState(): Promise<void> {
|
||||
const pulse = createHeimdallRuntimePulse(config);
|
||||
|
||||
|
|
@ -94,32 +88,17 @@ async function publishHealthState(): Promise<void> {
|
|||
? buildHeimdallHealthDetail(config, pulse)
|
||||
: `Heimdall candidate warming; ${lease.reason}`;
|
||||
|
||||
if (!config.idunnRuntimeBundlePath) {
|
||||
if (!presenceAuthority) {
|
||||
// Outside Idunn there is no launch to attest and nothing that admits the
|
||||
// statement; publishing is simply not part of that world.
|
||||
return;
|
||||
}
|
||||
runtimeBundleFacts ??= await readRuntimeBundleFacts(config.idunnRuntimeBundlePath);
|
||||
|
||||
const observedAtUnixMillis = Date.parse(pulse.updatedAt);
|
||||
const presence = await buildPresenceStatement({
|
||||
bundle: runtimeBundleFacts,
|
||||
state: lease.mayWrite ? "active" : "warming",
|
||||
detail,
|
||||
boundEndpoint: `http://${config.host}:${config.port}`,
|
||||
// A warming candidate holds no lease and must not claim one; the contract
|
||||
// refuses the pair, and Idunn needs the warming statement to promote it.
|
||||
writeLeaseSha256: null,
|
||||
observedAtUnixMillis,
|
||||
publisherSequence: await nextPublisherSequence(
|
||||
path.join(config.dataRoot, "runtime-presence-sequence"),
|
||||
),
|
||||
providerHealthIdentityPath: config.providerHealthIdentityPath,
|
||||
});
|
||||
const state = lease.mayWrite ? "active" : "warming";
|
||||
const presence = presenceAuthority.sign(state, detail, Date.parse(pulse.updatedAt));
|
||||
|
||||
await publishIdunnRudpHealth(config, {
|
||||
daemonId: config.daemonId,
|
||||
state: lease.mayWrite ? "active" : "warming",
|
||||
state,
|
||||
detail,
|
||||
observedAt: pulse.updatedAt,
|
||||
presence,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
sign,
|
||||
type KeyObject,
|
||||
} from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { closeSync, fstatSync, readSync } from "node:fs";
|
||||
import { chmod, readFile } from "node:fs/promises";
|
||||
import { SingleFileMessagePackBackingStore, type CultCacheEnvelope } from "@gamecult/cultcache-ts";
|
||||
import { readPrivateStoreRecord } from "./idunn-store.js";
|
||||
|
|
@ -71,7 +71,7 @@ export async function openOrEnrollProviderHealthIdentity(path: string): Promise<
|
|||
if (existing.length !== 1) {
|
||||
throw new Error("Provider-health identity store must contain exactly one record.");
|
||||
}
|
||||
return decodePrivateIdentity(existing[0]!.payload, await readMachineId());
|
||||
return decodePrivateIdentity(existing[0]!.payload, await readMachineId(), "bin");
|
||||
}
|
||||
|
||||
const PRESENCE_IDENTITY_FD_NAME = "gamecult-runtime-presence-identity";
|
||||
|
|
@ -124,19 +124,50 @@ export function passedDescriptor(
|
|||
*/
|
||||
export async function openProviderHealthIdentity(
|
||||
path: string,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
machineId: () => Promise<string> = readMachineId
|
||||
): Promise<PrivateIdentity> {
|
||||
const descriptor = passedDescriptor(PRESENCE_IDENTITY_FD_NAME, env);
|
||||
if (descriptor === undefined) {
|
||||
if (env.GAMECULT_IDUNN_RUNTIME_BUNDLE) {
|
||||
// Inside an Idunn generation a self-enrolled key is one Idunn does not
|
||||
// trust, and its private store would land in a state root the next
|
||||
// generation must share. The descriptor is the only identity there.
|
||||
throw new Error(
|
||||
`Idunn runtime bundle is set but the ${PRESENCE_IDENTITY_FD_NAME} descriptor was not passed; refusing to self-enroll`
|
||||
);
|
||||
}
|
||||
return openOrEnrollProviderHealthIdentity(path);
|
||||
}
|
||||
|
||||
const payload = readPrivateStoreRecord(
|
||||
readFileSync(descriptor),
|
||||
readPassedDescriptor(descriptor),
|
||||
"Idunn runtime presence identity"
|
||||
);
|
||||
|
||||
return decodePrivateIdentity(payload, await readMachineId());
|
||||
// The descriptor's writer is the Rust owner, whose key fields are integer arrays.
|
||||
return decodePrivateIdentity(payload, await machineId(), "int-array");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a passed descriptor whole, from offset 0, and close it. A descriptor is
|
||||
* a one-shot input: reading it again starts at the offset the last read left,
|
||||
* which is the end, so the owner reads once at open and keeps what it decoded.
|
||||
*/
|
||||
export function readPassedDescriptor(descriptor: number): Buffer {
|
||||
try {
|
||||
const size = fstatSync(descriptor).size;
|
||||
const bytes = Buffer.alloc(size);
|
||||
let read = 0;
|
||||
while (read < size) {
|
||||
const count = readSync(descriptor, bytes, read, size - read, read);
|
||||
if (count === 0) break;
|
||||
read += count;
|
||||
}
|
||||
return bytes.subarray(0, read);
|
||||
} finally {
|
||||
closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
export function signProviderHealthPayload(identity: PrivateIdentity, payload: Uint8Array): Uint8Array {
|
||||
|
|
@ -168,23 +199,58 @@ export function providerHealthPublicIdentity(identity: PrivateIdentity): {
|
|||
return { identityId: identity.identityId, publicKeyHex: Buffer.from(identity.publicKey).toString("hex") };
|
||||
}
|
||||
|
||||
function decodePrivateIdentity(payload: Uint8Array, machineId: string): PrivateIdentity {
|
||||
/**
|
||||
* Which writer produced the record. The self-enrolling store this process
|
||||
* writes keeps key fields as MessagePack bin; the Rust owner (cultnet-rs
|
||||
* service_identity.rs) serialises a Vec<u8> without serde_bytes, so its key
|
||||
* fields are arrays of integers. Each source is read in its own shape and the
|
||||
* other is refused.
|
||||
*/
|
||||
type KeyShape = "bin" | "int-array";
|
||||
|
||||
function keyBytes(value: unknown, shape: KeyShape, field: string): Uint8Array {
|
||||
const bytes =
|
||||
shape === "bin"
|
||||
? value instanceof Uint8Array
|
||||
? value
|
||||
: undefined
|
||||
: Array.isArray(value) &&
|
||||
value.every((byte) => typeof byte === "number" && Number.isInteger(byte) && byte >= 0 && byte <= 255)
|
||||
? Uint8Array.from(value as number[])
|
||||
: undefined;
|
||||
if (bytes === undefined || bytes.length !== 32) {
|
||||
throw new Error(
|
||||
`Provider-health identity key shape check failed: ${field} is not a 32-byte ${shape === "bin" ? "binary" : "integer-array"} key field.`
|
||||
);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function decodePrivateIdentity(payload: Uint8Array, machineId: string, shape: KeyShape): PrivateIdentity {
|
||||
const value = decode(payload);
|
||||
if (!Array.isArray(value) || value.length !== 10) {
|
||||
throw new Error("Provider-health identity payload is malformed.");
|
||||
throw new Error("Provider-health identity schema check failed: payload is not a 10-field record.");
|
||||
}
|
||||
const [schema, identityId, publicKey, protectedSeed, protector, binding, version] = value;
|
||||
if (
|
||||
schema !== PRIVATE_SCHEMA ||
|
||||
typeof identityId !== "string" ||
|
||||
!(publicKey instanceof Uint8Array) || publicKey.length !== 32 ||
|
||||
!(protectedSeed instanceof Uint8Array) || protectedSeed.length !== 32 ||
|
||||
protector !== "linux_file_mode_machine_id_binding" ||
|
||||
binding !== protectorBinding(machineId) ||
|
||||
version !== "v1" ||
|
||||
identityId !== deriveIdentityId(publicKey)
|
||||
) {
|
||||
throw new Error("Provider-health identity does not belong to this host or profile.");
|
||||
const [schema, identityId, publicKeyField, protectedSeedField, protector, binding, version] = value;
|
||||
if (schema !== PRIVATE_SCHEMA) {
|
||||
throw new Error("Provider-health identity schema check failed: unexpected schema id.");
|
||||
}
|
||||
if (typeof identityId !== "string") {
|
||||
throw new Error("Provider-health identity id check failed: id is not a string.");
|
||||
}
|
||||
const publicKey = keyBytes(publicKeyField, shape, "public key");
|
||||
const protectedSeed = keyBytes(protectedSeedField, shape, "protected seed");
|
||||
if (protector !== "linux_file_mode_machine_id_binding") {
|
||||
throw new Error("Provider-health identity protector check failed: unexpected protector.");
|
||||
}
|
||||
if (binding !== protectorBinding(machineId)) {
|
||||
throw new Error("Provider-health identity binding check failed: not bound to this host's machine id.");
|
||||
}
|
||||
if (version !== "v1") {
|
||||
throw new Error("Provider-health identity version check failed: unexpected version.");
|
||||
}
|
||||
if (identityId !== deriveIdentityId(publicKey)) {
|
||||
throw new Error("Provider-health identity id check failed: id does not derive from the public key.");
|
||||
}
|
||||
const seed = xorSeed(protectedSeed, binding);
|
||||
const privateKey = privateKeyFromSeed(seed);
|
||||
|
|
|
|||
|
|
@ -19,8 +19,6 @@
|
|||
|
||||
import path from "node:path";
|
||||
import { createHash, createPrivateKey, type KeyObject } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readFile, writeFile, rename } from "node:fs/promises";
|
||||
|
||||
import { decode } from "@msgpack/msgpack";
|
||||
import {
|
||||
|
|
@ -34,11 +32,14 @@ import {
|
|||
type RuntimePresenceState,
|
||||
} from "cultnet-ts";
|
||||
|
||||
import type { HeimdallConfig } from "./config.js";
|
||||
import { readIdunnStoreRecord } from "./idunn-store.js";
|
||||
import {
|
||||
openProviderHealthIdentity,
|
||||
passedDescriptor,
|
||||
readPassedDescriptor,
|
||||
signWithIdentity,
|
||||
type PrivateIdentity,
|
||||
} from "./provider-health-identity.js";
|
||||
|
||||
const PKCS8_ED25519_SEED_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
|
||||
|
|
@ -178,70 +179,37 @@ export async function readRuntimeBundleFacts(bundlePath: string): Promise<Runtim
|
|||
}
|
||||
|
||||
/**
|
||||
* Open the activation signing key Idunn passed as a descriptor.
|
||||
* The publisher sequence a process stamps on its presence statements.
|
||||
*
|
||||
* The credential is exactly 32 bytes: the raw Ed25519 seed. It is passed as a
|
||||
* parent-only descriptor and not as a path, so only the process Idunn launched
|
||||
* can produce this half of the proof.
|
||||
* Odin compares `publisher_sequence` only against stored presence of the same
|
||||
* activation, and Idunn mints a fresh activation for every start, continuity
|
||||
* restarts included. So the sequence need only strictly increase within this
|
||||
* process; nothing has to survive a restart, and nothing is written to the
|
||||
* state root. Muninn derives its sequence the same way: the observation clock,
|
||||
* never below the previous value plus one.
|
||||
*/
|
||||
export function openActivationSigner(env: NodeJS.ProcessEnv = process.env): KeyObject | undefined {
|
||||
const descriptor = passedDescriptor(IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME, env);
|
||||
if (descriptor === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const seed = readFileSync(descriptor);
|
||||
if (seed.length !== 32) {
|
||||
throw new Error("Idunn runtime activation credential is not a 32-byte seed");
|
||||
}
|
||||
return createPrivateKey({
|
||||
key: Buffer.concat([PKCS8_ED25519_SEED_PREFIX, seed]),
|
||||
format: "der",
|
||||
type: "pkcs8",
|
||||
});
|
||||
}
|
||||
export class PublisherSequence {
|
||||
private last = 0;
|
||||
|
||||
/**
|
||||
* The publisher sequence must strictly increase for the life of a signer
|
||||
* identity, because Idunn treats a repeat or a regression as a replay and
|
||||
* refuses the observation.
|
||||
*
|
||||
* An in-process counter is therefore not enough: a restart would reset it and
|
||||
* every statement afterwards would be refused until the count climbed past the
|
||||
* old high-water mark. It lives beside the target's state instead, and is
|
||||
* advanced before use.
|
||||
*/
|
||||
export async function nextPublisherSequence(sequencePath: string): Promise<number> {
|
||||
let current = 0;
|
||||
try {
|
||||
const raw = (await readFile(sequencePath, "utf8")).trim();
|
||||
const parsed = Number(raw);
|
||||
if (Number.isSafeInteger(parsed) && parsed > 0) {
|
||||
current = parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
next(observedAtUnixMillis: number): number {
|
||||
if (!Number.isSafeInteger(observedAtUnixMillis)) {
|
||||
throw new Error("observedAtUnixMillis is not a safe integer");
|
||||
}
|
||||
this.last = Math.max(observedAtUnixMillis, this.last + 1);
|
||||
return this.last;
|
||||
}
|
||||
const next = current + 1;
|
||||
// Written through a temporary file: a torn write here would look like a
|
||||
// regression and cost the target every future observation.
|
||||
const temporary = `${sequencePath}.next`;
|
||||
await writeFile(temporary, `${next}\n`, { mode: 0o600 });
|
||||
await rename(temporary, sequencePath);
|
||||
return next;
|
||||
}
|
||||
|
||||
export interface PresenceStatementInputs {
|
||||
bundle: RuntimeBundleFacts;
|
||||
identity: PrivateIdentity;
|
||||
activationKey: KeyObject;
|
||||
state: RuntimePresenceState;
|
||||
detail: string;
|
||||
boundEndpoint: string | null;
|
||||
writeLeaseSha256: string | null;
|
||||
observedAtUnixMillis: number;
|
||||
publisherSequence: number;
|
||||
providerHealthIdentityPath: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
export interface PresenceStatement {
|
||||
|
|
@ -250,24 +218,14 @@ export interface PresenceStatement {
|
|||
}
|
||||
|
||||
/**
|
||||
* Build and dual-sign one presence statement.
|
||||
* Build and dual-sign one presence statement from keys and facts already open.
|
||||
*
|
||||
* Returns the target alongside the bytes because the document key must equal
|
||||
* the signed target; Odin refuses the pair when they differ, and that is the
|
||||
* one part of the envelope the transport cannot infer.
|
||||
*/
|
||||
export async function buildPresenceStatement(
|
||||
inputs: PresenceStatementInputs,
|
||||
): Promise<PresenceStatement> {
|
||||
const env = inputs.env ?? process.env;
|
||||
const activationKey = openActivationSigner(env);
|
||||
if (activationKey === undefined) {
|
||||
throw new Error(
|
||||
"Idunn passed no runtime activation credential, so this process cannot prove it is the " +
|
||||
"incarnation Idunn started",
|
||||
);
|
||||
}
|
||||
const identity = await openProviderHealthIdentity(inputs.providerHealthIdentityPath, env);
|
||||
export function buildPresenceStatement(inputs: PresenceStatementInputs): PresenceStatement {
|
||||
const { identity } = inputs;
|
||||
|
||||
const presence: RuntimePresenceHealth = {
|
||||
target: inputs.bundle.target,
|
||||
|
|
@ -295,7 +253,7 @@ export async function buildPresenceStatement(
|
|||
const proof = runtimePresenceProofPayload(presence);
|
||||
const signature = signWithIdentity(identity, runtimePresenceProviderSigningMessage(proof));
|
||||
const activationSignature = signWithIdentity(
|
||||
{ ...identity, privateKey: activationKey },
|
||||
{ ...identity, privateKey: inputs.activationKey },
|
||||
runtimePresenceActivationSigningMessage(proof),
|
||||
);
|
||||
|
||||
|
|
@ -304,3 +262,72 @@ export async function buildPresenceStatement(
|
|||
payload: encodeRuntimePresenceHealth(presence, signature, activationSignature),
|
||||
};
|
||||
}
|
||||
|
||||
export interface PresenceAuthority {
|
||||
sign(
|
||||
state: RuntimePresenceState,
|
||||
detail: string,
|
||||
observedAtUnixMillis: number,
|
||||
): PresenceStatement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open everything this launch's presence statements stand on, once, before the
|
||||
* process listens: Idunn's bundle facts, the activation seed and the presence
|
||||
* identity (each descriptor is read whole and closed), and the one publisher
|
||||
* sequence. It owns them for the life of the process; a pulse supplies only its
|
||||
* state, detail and observation time.
|
||||
*
|
||||
* A launch-contract fault (a missing or malformed bundle, descriptor or
|
||||
* identity) throws here, so the process exits before it accepts a request and
|
||||
* Idunn fails Warming at once, instead of a listener that can never sign.
|
||||
*/
|
||||
export async function openPresenceAuthority(
|
||||
config: Pick<HeimdallConfig, "idunnRuntimeBundlePath" | "host" | "port" | "providerHealthIdentityPath">,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
machineId?: () => Promise<string>,
|
||||
): Promise<PresenceAuthority> {
|
||||
if (!config.idunnRuntimeBundlePath) {
|
||||
throw new Error("no Idunn runtime bundle path: presence has no launch to attest");
|
||||
}
|
||||
const bundle = await readRuntimeBundleFacts(config.idunnRuntimeBundlePath);
|
||||
|
||||
const activationDescriptor = passedDescriptor(IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME, env);
|
||||
if (activationDescriptor === undefined) {
|
||||
throw new Error(
|
||||
"Idunn passed no runtime activation credential, so this process cannot prove it is the " +
|
||||
"incarnation Idunn started",
|
||||
);
|
||||
}
|
||||
const seed = readPassedDescriptor(activationDescriptor);
|
||||
if (seed.length !== 32) {
|
||||
throw new Error("Idunn runtime activation credential is not a 32-byte seed");
|
||||
}
|
||||
const activationKey = createPrivateKey({
|
||||
key: Buffer.concat([PKCS8_ED25519_SEED_PREFIX, seed]),
|
||||
format: "der",
|
||||
type: "pkcs8",
|
||||
});
|
||||
|
||||
const identity = await openProviderHealthIdentity(config.providerHealthIdentityPath, env, machineId);
|
||||
const boundEndpoint = `http://${config.host}:${config.port}`;
|
||||
const sequence = new PublisherSequence();
|
||||
|
||||
return {
|
||||
sign: (state, detail, observedAtUnixMillis) =>
|
||||
buildPresenceStatement({
|
||||
bundle,
|
||||
identity,
|
||||
activationKey,
|
||||
state,
|
||||
detail,
|
||||
boundEndpoint,
|
||||
// A warming candidate holds no lease and must not claim one; the
|
||||
// contract refuses the pair, and Idunn needs the warming statement to
|
||||
// promote it.
|
||||
writeLeaseSha256: null,
|
||||
observedAtUnixMillis,
|
||||
publisherSequence: sequence.next(observedAtUnixMillis),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,11 +16,11 @@
|
|||
{
|
||||
"id": "eureka/joystick-h1",
|
||||
"hypothesis": "One locked refresh primitive plus a Joystick OAuth provider in Heimdall lets StreamPixels ingest Joystick without ever presenting a provider refresh token.",
|
||||
"status": "active",
|
||||
"status": "accepted",
|
||||
"artifacts": [
|
||||
"state/map.yaml"
|
||||
],
|
||||
"notes": "Campaign joystick:campaign:self, target joystick:target:r1. Hands owns code in a separate worktree."
|
||||
"notes": "Merged as PR GameCult/Heimdall#1 (d51fa6b), deployed to Yggdrasil 2026-10-19 15:51Z; gamecult-ops 210a1af. Campaign joystick:campaign:self."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,3 +35,5 @@
|
|||
{"ts":"2026-08-22T02:01:00+00:00","type":"deployment","status":"accepted","note":"Yggdrasil runs Heimdall 1086aee01169bf60e8a492b2740db1c6f3e8cabf with CultLib 5cefa0db0079a8e3ee22f29d7b9e6e5aa60912a9. Odin b4f9a2e95f0b41cebdeddc49223781d1d3c7b42a returns Heimdall's provider, command boundary, Eve plugin, and transport profile under unique catalog keys. Ghostlight b515ca90c25573005a616244143803b37f2d06ec accepted canonical auth.begin through the Odin-resolved boundary; only advertised Discord and Heimdall navigation origins reached the browser."}
|
||||
{"ts": "2026-09-06T22:12:03+00:00", "type": "design", "status": "accepted", "note": "App slugs are no longer compile-time. AppSlug is a string; membership is a registry lookup, or isAppSlug shape validation where no store is reachable (the offline verifier). Capability rules are data (anyOf over Heimdall's own fact vocabulary), replacing a per-profile evaluateSharedCapabilities function that could not be accepted from a stranger. POST /v1/apps registers in RFC 7591 shape, gated on GC_ACCESS_APP_REGISTRATION_SECRET and closed until it is set. Built-in slugs are reserved so a registration cannot shadow a shipped app's audience. Motivation: a creator self-hosting Erycina alongside Heimdall would otherwise maintain a patched auth server to add one slug."}
|
||||
{"ts": "2026-09-08T12:20:12+00:00", "type": "security", "status": "accepted", "note": "epiphany/heimdall-caller-identity, second Hands pass: closed the two Soul-proven holes. (1) auth-completions redeem code is now always store-minted (never handoff.attemptId); attemptId survives only as a separate correlation field on StoredAuthCompletion/CreateAuthCompletionInput, and a new store.consumeAuthCompletionByAttempt(appSlug, attemptId, at) lets the private command plane's completeAuth redeem by the handle it already owns instead of forging an HTTP self-call with completionCode=handle. (2) startOAuthFlow now refuses any returnTo whose origin is not in AppProfile.allowedReturnOrigins (repixelizer/streampixels/bifrost -> <slug>.gamecult.org, ghostlight -> yggdrasil.gamecult.org per its docs/app-profiles doc and existing test fixtures; a registered app's allowlist is derived from its stored redirectUris origins). AppCaller is now branded with an unexported Symbol (app-caller.ts); private-command-plane.ts's two { appSlug } literals are gone, replaced by app-caller.ts's callerFromOpenedEnvelope. Deleted: validateAppRegistration, AppRegistrationRequest, RegistrationProblem, AppRegistrationError, HeimdallStore.registerApp (writer). Kept: findRegisteredApp/listRegisteredApps/resolveAppProfile fallback -- AppSlug is a plain string (contracts.ts), registered_apps is a real extension point for a slug Heimdall was never rebuilt for, and isBuiltInApp's only production call site (listAppProfiles) depends on that fallback existing; a row is now provisioned by direct SQL (or InMemoryStore.seedRegisteredApp in tests), not by any app-facing writer. Deleted the two appSlug===\"ghostlight\" branches in private-command-plane.ts; the requirement is now AppProfile.requiredEntitlementPolicyKind, checked generically through resolveAppProfile. tsc --noEmit clean; vitest 94 passed/1 pre-existing-skip, only pre-existing failure is tests/odin-publication.test.ts (Cannot find module 'cultcache-ts', present on main too).", "branch": "epiphany/heimdall-caller-identity"}
|
||||
{"ts": "2026-10-19T15:56:24+00:00", "type": "design", "status": "accepted", "note": "Refresh is now one locked primitive for every provider (replaces unserialized refreshLinkedIdentityCredentialIfNeeded); Joystick is managed-only (Heimdall-held credential, no viewer sign-in until joystick:follow_up:joystick-viewer-signin-public-bot). Merged in PR #1, live at d51fa6b; campaign joystick:campaign:self.", "branch": "eureka/joystick-h1"}
|
||||
{"ts": "2026-10-19T16:03:16+00:00", "type": "verification", "status": "accepted", "note": "Live d51fa6b (H1) verified by a real human sign-in: the operator's StreamPixels Twitch sign-in through Heimdall start/callback, 2026-10-19 ('Twitch auth worked fine'); a streampixels session was created 16:02:33Z, after the 15:51Z switch. Joystick consent round trip still open: joystick:follow_up:h1-joystick-consent-round-trip."}
|
||||
|
|
|
|||
|
|
@ -59,8 +59,8 @@ data_flow:
|
|||
- reject per-request auth round-trip chatter
|
||||
|
||||
active_subgoals:
|
||||
- ACTIVE, M1-critical (operator ruling 2026-10-18): Joystick H1, campaign joystick:campaign:self (branch eureka/joystick-h1). Joystick OAuth provider, joystick as a streampixels viewer provider, one locked refresh primitive for every provider replacing refreshLinkedIdentityCredentialIfNeeded.
|
||||
- OPEN, behind H1: pressure the deployed Ghostlight gamecult.heimdall.access integration with two- and eight-account human canaries.
|
||||
- Joystick H1 is merged and live (PR GameCult/Heimdall#1, d51fa6b; campaign joystick:campaign:self). Open follow-ups by id: joystick:follow_up:h1-joystick-consent-round-trip (next), cross-process-failed-refresh-marker, connect-start-needs-app-auth, heimdall-idunn-onboarding, joystick-viewer-signin-public-bot. StreamPixels S0/S1 run in other sessions.
|
||||
- OPEN, behind the H1 live round-trips: pressure the deployed Ghostlight gamecult.heimdall.access integration with two- and eight-account human canaries.
|
||||
- Keep the shared architecture explicit and separate from app profiles.
|
||||
- Preserve the default boundary that app-domain data remains app-owned.
|
||||
- Align the plan to Heimdall as a Yggdrasil-hosted authority service.
|
||||
|
|
@ -95,7 +95,7 @@ accepted_design:
|
|||
|
||||
landed_baseline:
|
||||
- Heimdall publishes its redacted provider, command boundary, Eve plugin, and transport profile through Odin under globally unique record keys; Ghostlight resolves that boundary instead of owning a direct endpoint.
|
||||
- Yggdrasil runs Heimdall 1086aee01169bf60e8a492b2740db1c6f3e8cabf with CultLib 5cefa0db0079a8e3ee22f29d7b9e6e5aa60912a9; live Ghostlight auth.begin through Odin discovery is accepted.
|
||||
- Yggdrasil runs Heimdall d51fa6b (hand-deployed 2026-10-19 15:51Z; gamecult-ops 210a1af holds backup, rollback and provenance; prior 1086aee) with CultLib 5cefa0db0079a8e3ee22f29d7b9e6e5aa60912a9; live Ghostlight auth.begin through Odin discovery is accepted.
|
||||
- The Heimdall access plugin, persistent OAuth attempts, encrypted private CultNet commands, conformance fixtures, and browser reference adapter are implemented and locally verified.
|
||||
- Exact-session refresh validation and logout revocation are implemented; stale or racing refresh cannot resurrect a revoked session.
|
||||
- Shared architecture extracted into its own repo and renamed to Heimdall.
|
||||
|
|
@ -126,5 +126,5 @@ rejected_paths:
|
|||
reason: reusing auth mechanics does not justify centralizing app-domain audience or product data
|
||||
|
||||
current_status:
|
||||
summary: Active work is Joystick H1 (campaign joystick:campaign:self). Last recorded Yggdrasil deploy (not verifiable from this repo; HEAD is far past it, see git): Yggdrasil runs the gamecult.heimdall.access plugin and encrypted loopback begin, complete, refresh, and logout plane at 1086aee01169bf60e8a492b2740db1c6f3e8cabf. Odin returns all four redacted discovery records, Ghostlight resolves the boundary through Odin, and live anonymous auth.begin is accepted without exposing claims or secrets.
|
||||
next_action: Land Joystick H1 per campaign joystick:campaign:self and target joystick:target:r1 (worktree and branch eureka/joystick-h1). Then, still open, run separate-account KLTST completion, refresh, logout, privacy, and stale-attempt canaries through the deployed Ghostlight Eve host; keep provider credential rotation on the operator list because prior tool output exposed OAuth secrets.
|
||||
summary: Yggdrasil runs Heimdall release d51fa6b (hand-deployed 2026-10-19 15:51Z, gamecult-ops 210a1af), which includes Joystick H1 (PR #1). The plugin and encrypted loopback begin, complete, refresh and logout plane, the Odin discovery records and Ghostlight resolution were verified at 1086aee (2026-08-22) and are not re-verified at d51fa6b.
|
||||
next_action: Run joystick:follow_up:h1-joystick-consent-round-trip, then StreamPixels S0/S1 (other sessions). Still open behind that: separate-account KLTST completion, refresh, logout, privacy, and stale-attempt canaries through the deployed Ghostlight Eve host; keep provider credential rotation on the operator list because prior tool output exposed OAuth secrets.
|
||||
|
|
|
|||
47
tests/fastify-malformed-url.test.ts
Normal file
47
tests/fastify-malformed-url.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
// GHSA-p68q-wchp-6fh7 (fastify < 5.12.2): a malformed request target under one
|
||||
// plugin prefix, with a method that has no route in the main router, was
|
||||
// dispatched to the not-found handler of a sibling plugin, skipping that
|
||||
// handler's preHandler. This pins the framework fix; it is not about a Heimdall
|
||||
// route.
|
||||
describe("fastify malformed URL under sibling not-found handlers", () => {
|
||||
const apps: Array<ReturnType<typeof Fastify>> = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(apps.splice(0).map((app) => app.close()));
|
||||
});
|
||||
|
||||
async function build() {
|
||||
const app = Fastify();
|
||||
apps.push(app);
|
||||
await app.register(async (pub) => {
|
||||
pub.get("/ping", async () => "pong");
|
||||
}, { prefix: "/public" });
|
||||
await app.register(async (priv) => {
|
||||
priv.setNotFoundHandler(
|
||||
{
|
||||
preHandler: async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
if (!request.headers.authorization) return reply.code(401).send({ error: "auth" });
|
||||
},
|
||||
},
|
||||
(_request: FastifyRequest, reply: FastifyReply) => reply.code(404).send({ secret: "PRIVATE-TENANT-DATA" }),
|
||||
);
|
||||
priv.get("/x", async () => "x");
|
||||
}, { prefix: "/private" });
|
||||
return app;
|
||||
}
|
||||
|
||||
it("fails closed with 400 and never reaches another prefix's not-found handler", async () => {
|
||||
const app = await build();
|
||||
const response = await app.inject({ method: "DELETE", url: "/public/%zz" });
|
||||
expect(response.body).not.toContain("PRIVATE-TENANT-DATA");
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("still runs the authenticated not-found preHandler for a well-formed URL", async () => {
|
||||
const app = await build();
|
||||
const response = await app.inject({ method: "DELETE", url: "/private/nothing" });
|
||||
expect(response.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
64
tests/fixtures/fake-idunn-rudp.ts
vendored
Normal file
64
tests/fixtures/fake-idunn-rudp.ts
vendored
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* A stand-in for Idunn's health endpoint: it accepts each RUDP connect, acks
|
||||
* the frame that follows and keeps every delivered frame. Heimdall opens a
|
||||
* fresh socket and session per publication, so sessions are kept per remote.
|
||||
*/
|
||||
import dgram from "node:dgram";
|
||||
import {
|
||||
CultNetRudpSession,
|
||||
decodeRudpPacket,
|
||||
encodeRudpPacket,
|
||||
type CultNetTransportFrame,
|
||||
} from "cultnet-ts";
|
||||
|
||||
export interface FakeIdunnRudp {
|
||||
port: number;
|
||||
frames: CultNetTransportFrame[];
|
||||
/** Resolves with the nth delivered frame (0-based) once it has arrived. */
|
||||
frame(index: number): Promise<CultNetTransportFrame>;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export async function startFakeIdunnRudp(): Promise<FakeIdunnRudp> {
|
||||
const server = dgram.createSocket("udp4");
|
||||
const sessions = new Map<string, CultNetRudpSession>();
|
||||
const frames: CultNetTransportFrame[] = [];
|
||||
const waiting: Array<{ index: number; resolve: (frame: CultNetTransportFrame) => void }> = [];
|
||||
|
||||
server.on("message", (wire, remote) => {
|
||||
const packet = decodeRudpPacket(wire);
|
||||
const key = `${remote.address}:${remote.port}`;
|
||||
if (packet.packetType === "connect") {
|
||||
const session = new CultNetRudpSession({
|
||||
connectionId: 0x1d0d0001,
|
||||
initialSequence: 1,
|
||||
resendDelayMs: 100,
|
||||
});
|
||||
sessions.set(key, session);
|
||||
server.send(encodeRudpPacket(session.acceptConnect(packet, Date.now())), remote.port, remote.address);
|
||||
return;
|
||||
}
|
||||
const session = sessions.get(key);
|
||||
if (!session) return;
|
||||
const result = session.receive(packet, Date.now());
|
||||
if (result.reply) server.send(encodeRudpPacket(result.reply), remote.port, remote.address);
|
||||
for (const frame of result.delivered) {
|
||||
frames.push(frame);
|
||||
for (const waiter of waiting.filter((entry) => entry.index === frames.length - 1)) waiter.resolve(frame);
|
||||
}
|
||||
if (result.delivered.length > 0) {
|
||||
server.send(encodeRudpPacket(session.createAck()), remote.port, remote.address);
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve) => server.bind(0, "127.0.0.1", resolve));
|
||||
|
||||
return {
|
||||
port: server.address().port,
|
||||
frames,
|
||||
frame: (index) =>
|
||||
frames[index]
|
||||
? Promise.resolve(frames[index]!)
|
||||
: new Promise((resolve) => waiting.push({ index, resolve })),
|
||||
close: () => server.close(),
|
||||
};
|
||||
}
|
||||
1
tests/fixtures/idunn-runtime/.gitattributes
vendored
Normal file
1
tests/fixtures/idunn-runtime/.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
* -text
|
||||
11
tests/fixtures/idunn-runtime/README.md
vendored
Normal file
11
tests/fixtures/idunn-runtime/README.md
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Idunn runtime fixtures
|
||||
|
||||
Written by the Rust owner, not by Heimdall's tests. Copied byte for byte from CultLib
|
||||
`217a9480`, `packages/cultnet-ts/test/fixtures/idunn-runtime/web` (`expected.cc`,
|
||||
`activation.cc`, `activation-key.seed`, `provider-identity.credential`) and that directory's
|
||||
`machine-id`. CultLib regenerates them with `packages/cultnet-rs/examples/idunn_runtime_fixture.rs`
|
||||
(its own README has the Yggdrasil command); the provider identity is bound to `machine-id`, so a
|
||||
test that decodes it supplies that machine id instead of `/etc/machine-id`.
|
||||
|
||||
Every key here is a test key. `provider-identity.credential` is the shape Idunn passes on the
|
||||
presence-identity descriptor: key fields are 32-integer arrays, which only the Rust owner writes.
|
||||
1
tests/fixtures/idunn-runtime/activation-key.seed
vendored
Normal file
1
tests/fixtures/idunn-runtime/activation-key.seed
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
¯ÂOd×f¯l*oµ‘ÀFªv‰n<E280B0>hLûÑpšS´p
|
||||
BIN
tests/fixtures/idunn-runtime/activation.cc
vendored
Normal file
BIN
tests/fixtures/idunn-runtime/activation.cc
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/idunn-runtime/expected.cc
vendored
Normal file
BIN
tests/fixtures/idunn-runtime/expected.cc
vendored
Normal file
Binary file not shown.
1
tests/fixtures/idunn-runtime/machine-id
vendored
Normal file
1
tests/fixtures/idunn-runtime/machine-id
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1
|
||||
BIN
tests/fixtures/idunn-runtime/provider-identity.credential
vendored
Normal file
BIN
tests/fixtures/idunn-runtime/provider-identity.credential
vendored
Normal file
Binary file not shown.
61
tests/fixtures/presence-descriptors.ts
vendored
Normal file
61
tests/fixtures/presence-descriptors.ts
vendored
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* What Idunn hands a generation for runtime presence: the bundle directory, a
|
||||
* parent-only identity store and an activation seed, the last two passed as
|
||||
* descriptors named in LISTEN_FDNAMES. The bytes are the Rust owner's, copied
|
||||
* from CultLib (see idunn-runtime/README.md); every key in them is a test key.
|
||||
*/
|
||||
import { closeSync, openSync } from "node:fs";
|
||||
import { chmod, copyFile, mkdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME } from "cultnet-ts";
|
||||
|
||||
export const idunnRuntimeFixtures = path.join(import.meta.dirname, "idunn-runtime");
|
||||
|
||||
const FIXTURE_FILES = [
|
||||
"expected.cc",
|
||||
"activation.cc",
|
||||
"activation-key.seed",
|
||||
"provider-identity.credential",
|
||||
"machine-id",
|
||||
] as const;
|
||||
|
||||
/** The machine id the fixture identity is bound to, in place of /etc/machine-id. */
|
||||
export const fixtureMachineId = async (dir: string = idunnRuntimeFixtures): Promise<string> =>
|
||||
(await readFile(path.join(dir, "machine-id"), "utf8")).trim();
|
||||
|
||||
/** Copy the fixtures into `dir`, readable by any uid (the children run as others). */
|
||||
export async function writePresenceFixtures(dir: string): Promise<void> {
|
||||
await mkdir(dir, { recursive: true });
|
||||
await chmod(dir, 0o755);
|
||||
for (const name of FIXTURE_FILES) {
|
||||
const target = path.join(dir, name);
|
||||
await copyFile(path.join(idunnRuntimeFixtures, name), target);
|
||||
await chmod(target, 0o644);
|
||||
}
|
||||
}
|
||||
|
||||
/** Open both descriptors and name them the way systemd's LISTEN_FDNAMES does. */
|
||||
export function openPresenceDescriptors(dir: string): { env: NodeJS.ProcessEnv; close: () => void } {
|
||||
const fds = [
|
||||
[openSync(path.join(dir, "provider-identity.credential"), "r"), "gamecult-runtime-presence-identity"],
|
||||
[openSync(path.join(dir, "activation-key.seed"), "r"), IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME],
|
||||
] as const;
|
||||
const names: string[] = [];
|
||||
for (const [fd, name] of fds) {
|
||||
while (names.length < fd - 3) names.push(`pad-${names.length}`);
|
||||
names[fd - 3] = name;
|
||||
}
|
||||
return {
|
||||
env: { LISTEN_FDNAMES: names.join(":") },
|
||||
// The authority closes each descriptor it reads; this releases only what it did not.
|
||||
close: () =>
|
||||
fds.forEach(([fd]) => {
|
||||
try {
|
||||
closeSync(fd);
|
||||
} catch {
|
||||
// already closed by the reader
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
57
tests/fixtures/state-root-generation-child.ts
vendored
Normal file
57
tests/fixtures/state-root-generation-child.ts
vendored
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* One Idunn generation, as a process: it runs as the uid and gid its parent
|
||||
* gave it, under UMask=0007, and does what Heimdall does to a state root on a
|
||||
* pulse. The parent test spawns two of these under different uids that share
|
||||
* a group.
|
||||
*
|
||||
* argv: <role: first|next> <stateRoot> <fixtureDir>
|
||||
*/
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { loadConfig } from "../../src/config.js";
|
||||
import { openPresenceAuthority } from "../../src/runtime-presence.js";
|
||||
import { fixtureMachineId, openPresenceDescriptors } from "./presence-descriptors.js";
|
||||
import { createHeimdallRuntimePulse, publishHeimdallVerseState } from "../../src/verse-state.js";
|
||||
|
||||
const [role, stateRoot, fixtureDir] = process.argv.slice(2) as [string, string, string];
|
||||
process.umask(0o007);
|
||||
|
||||
const config = loadConfig({
|
||||
GC_ACCESS_BASE_URL: "https://heimdall.example.test",
|
||||
GC_ACCESS_DATA_ROOT: stateRoot,
|
||||
});
|
||||
|
||||
async function publishPresence(): Promise<void> {
|
||||
const descriptors = openPresenceDescriptors(fixtureDir);
|
||||
try {
|
||||
const authority = await openPresenceAuthority(
|
||||
{
|
||||
idunnRuntimeBundlePath: fixtureDir,
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
providerHealthIdentityPath: path.join(stateRoot, "provider-health-identity.cc"),
|
||||
},
|
||||
descriptors.env,
|
||||
() => fixtureMachineId(fixtureDir),
|
||||
);
|
||||
authority.sign("warming", "generation probe", Date.now());
|
||||
} finally {
|
||||
descriptors.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (role === "next") {
|
||||
// Generation N+1 reads what N left before it replaces it.
|
||||
const previous = await readFile(config.cultCachePath);
|
||||
if (previous.length === 0) throw new Error("previous generation's snapshot is empty");
|
||||
}
|
||||
|
||||
await publishHeimdallVerseState(config, createHeimdallRuntimePulse(config));
|
||||
await publishPresence();
|
||||
|
||||
if (role === "first") {
|
||||
// The 0600 counter the 2026-09-07 generation left behind. Nothing reads it
|
||||
// now; it stays as the stale fixture the next generation must live beside.
|
||||
await writeFile(path.join(stateRoot, "runtime-presence-sequence"), "41\n", { mode: 0o600 });
|
||||
}
|
||||
320
tests/idunn-boot.test.ts
Normal file
320
tests/idunn-boot.test.ts
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
import { execFile, spawn, type ChildProcess } from "node:child_process";
|
||||
import { createPublicKey, createPrivateKey, verify } from "node:crypto";
|
||||
import dgram from "node:dgram";
|
||||
import { chmod, copyFile, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { closeSync, openSync } from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { decode, encode } from "@msgpack/msgpack";
|
||||
import {
|
||||
RUNTIME_PRESENCE_SLOT,
|
||||
parseCultNetMessage,
|
||||
runtimePresenceActivationSigningMessage,
|
||||
runtimePresenceProviderSigningMessage,
|
||||
} from "cultnet-ts";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { startFakeIdunnRudp, type FakeIdunnRudp } from "./fixtures/fake-idunn-rudp.js";
|
||||
import { idunnRuntimeFixtures } from "./fixtures/presence-descriptors.js";
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
const GENERATION_UID = 61184;
|
||||
const STATE_GID = 4242;
|
||||
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
||||
const PKCS8_ED25519_SEED_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
|
||||
|
||||
// The launch needs root (to pick the uid and bind a machine-id) and a mount
|
||||
// namespace (so the fixture machine-id does not replace the host's). The
|
||||
// stopgap's node container has both; anywhere else the test steps aside.
|
||||
const canLaunch =
|
||||
process.getuid?.() === 0 &&
|
||||
(await run("unshare", ["--mount", "--", "true"]).then(
|
||||
() => true,
|
||||
() => false
|
||||
));
|
||||
|
||||
/**
|
||||
* The artifact under test. By default the esbuild bundle `pnpm bundle` makes,
|
||||
* run by this process's node; a runbook proves a sealed release by naming its
|
||||
* bundle and node here instead.
|
||||
*/
|
||||
const bundleOverride = process.env.HEIMDALL_BOOT_BUNDLE;
|
||||
const nodeOverride = process.env.HEIMDALL_BOOT_NODE;
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const probe = net.createServer();
|
||||
probe.once("error", reject);
|
||||
probe.listen(0, "127.0.0.1", () => {
|
||||
const { port } = probe.address() as net.AddressInfo;
|
||||
probe.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
interface Launch {
|
||||
child: ChildProcess;
|
||||
stderr: () => string;
|
||||
exited: Promise<number | null>;
|
||||
candidatePort: number;
|
||||
}
|
||||
|
||||
describe.skipIf(!canLaunch)("the bundle booted as Idunn launches it", () => {
|
||||
let base: string;
|
||||
let bundle: string;
|
||||
let node: string;
|
||||
let idunn: FakeIdunnRudp;
|
||||
let odin: dgram.Socket;
|
||||
let odinPort: number;
|
||||
const launched: ChildProcess[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
base = await mkdtemp(path.join(os.tmpdir(), "heimdall-boot-"));
|
||||
await chmod(base, 0o755);
|
||||
if (bundleOverride) {
|
||||
bundle = bundleOverride;
|
||||
} else {
|
||||
await run("corepack", ["pnpm", "bundle"], { cwd: path.join(import.meta.dirname, "..") });
|
||||
bundle = path.join(base, "heimdall.mjs");
|
||||
await copyFile(path.join(import.meta.dirname, "..", "dist", "heimdall"), bundle);
|
||||
await chmod(bundle, 0o644);
|
||||
}
|
||||
node = nodeOverride ?? process.execPath;
|
||||
idunn = await startFakeIdunnRudp();
|
||||
odin = dgram.createSocket("udp4");
|
||||
await new Promise<void>((resolve) => odin.bind(0, "127.0.0.1", resolve));
|
||||
odinPort = odin.address().port;
|
||||
}, 120_000);
|
||||
|
||||
afterAll(() => {
|
||||
for (const child of launched) child.kill("SIGKILL");
|
||||
idunn?.close();
|
||||
odin?.close();
|
||||
});
|
||||
|
||||
/** Credential files as Idunn holds them: readable only by the parent. */
|
||||
async function credentials(dir: string, identity: Buffer | undefined) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
const seed = path.join(dir, "activation-key.seed");
|
||||
await copyFile(path.join(idunnRuntimeFixtures, "activation-key.seed"), seed);
|
||||
await chmod(seed, 0o400);
|
||||
let identityFile: string | undefined;
|
||||
if (identity) {
|
||||
identityFile = path.join(dir, "provider-identity.credential");
|
||||
await writeFile(identityFile, identity, { mode: 0o400 });
|
||||
}
|
||||
return { seed, identityFile };
|
||||
}
|
||||
|
||||
/** Start the bundle as the generation unit does, with the fixture machine-id bound over the host's. */
|
||||
async function launch(label: string, identity: Buffer | undefined): Promise<Launch> {
|
||||
const dir = path.join(base, label);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await chmod(dir, 0o755);
|
||||
|
||||
// Idunn's runtime bundle: the two records, read-only.
|
||||
const bundleDir = path.join(dir, "bundle");
|
||||
await mkdir(bundleDir);
|
||||
for (const name of ["expected.cc", "activation.cc"]) {
|
||||
await copyFile(path.join(idunnRuntimeFixtures, name), path.join(bundleDir, name));
|
||||
await chmod(path.join(bundleDir, name), 0o444);
|
||||
}
|
||||
await chmod(bundleDir, 0o555);
|
||||
|
||||
const machineId = path.join(dir, "machine-id");
|
||||
await copyFile(path.join(idunnRuntimeFixtures, "machine-id"), machineId);
|
||||
await chmod(machineId, 0o444);
|
||||
|
||||
const stateRoot = path.join(dir, "state");
|
||||
await mkdir(stateRoot);
|
||||
await run("chown", [`0:${STATE_GID}`, stateRoot]);
|
||||
await chmod(stateRoot, 0o2770);
|
||||
|
||||
const heimdall = path.join(dir, "heimdall.mjs");
|
||||
await copyFile(bundle, heimdall);
|
||||
await chmod(heimdall, 0o444);
|
||||
|
||||
const files = await credentials(path.join(dir, "credentials"), identity);
|
||||
const candidatePort = await freePort();
|
||||
|
||||
const fds = [openSync(files.seed, "r")];
|
||||
const names = ["gamecult-idunn-runtime-activation-key"];
|
||||
if (files.identityFile) {
|
||||
fds.push(openSync(files.identityFile, "r"));
|
||||
names.push("gamecult-runtime-presence-identity");
|
||||
}
|
||||
|
||||
const env = {
|
||||
PATH: process.env.PATH ?? "",
|
||||
LISTEN_FDS: String(fds.length),
|
||||
LISTEN_FDNAMES: names.join(":"),
|
||||
GAMECULT_IDUNN_CANDIDATE_BIND: `127.0.0.1:${candidatePort}`,
|
||||
GAMECULT_IDUNN_RUNTIME_BUNDLE: bundleDir,
|
||||
GAMECULT_IDUNN_PROCESS_WRITE_LEASE: path.join(dir, "process-write-lease.cc"),
|
||||
GC_ACCESS_BASE_URL: "https://heimdall.test",
|
||||
GC_ACCESS_ISSUER: "https://heimdall.test",
|
||||
GC_ACCESS_STORAGE_BACKEND: "memory",
|
||||
GC_ACCESS_IDUNN_RUDP_HEALTH: `127.0.0.1:${idunn.port}`,
|
||||
GC_ACCESS_IDUNN_HEALTH_CONTRACT: "heimdall.cultnet-rudp-provider-health",
|
||||
GC_ACCESS_ODIN_CULTMESH_URI: `rudp://127.0.0.1:${odinPort}`,
|
||||
CULTMESH_URI_ODIN_RUDP: `rudp://127.0.0.1:${odinPort}`,
|
||||
GC_ACCESS_SIGNING_PRIVATE_KEY_BOOTSTRAP: "1",
|
||||
GC_ACCESS_SIGNING_PRIVATE_KEY_PATH: path.join(stateRoot, "signing.pem"),
|
||||
GC_ACCESS_SIGNING_KEY_ID: "boot-test",
|
||||
};
|
||||
|
||||
// uid, gid and umask as Idunn's DynamicUser unit gives them. The fixture
|
||||
// machine-id is bound only inside this mount namespace.
|
||||
const script =
|
||||
`mount --bind ${machineId} /etc/machine-id && ` +
|
||||
`exec setpriv --reuid ${GENERATION_UID} --regid ${STATE_GID} --clear-groups ` +
|
||||
`sh -c 'umask 0007; cd ${dir}; exec ${node} heimdall.mjs --state-root ${stateRoot}'`;
|
||||
const child = spawn("unshare", ["--mount", "--", "sh", "-c", script], {
|
||||
env,
|
||||
stdio: ["ignore", "ignore", "pipe", ...fds],
|
||||
});
|
||||
launched.push(child);
|
||||
fds.forEach((fd) => closeSync(fd));
|
||||
|
||||
let stderr = "";
|
||||
child.stderr!.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
const exited = new Promise<number | null>((resolve) => child.once("exit", (code) => resolve(code)));
|
||||
return { child, stderr: () => stderr, exited, candidatePort };
|
||||
}
|
||||
|
||||
async function answers(port: number): Promise<number | undefined> {
|
||||
return fetch(`http://127.0.0.1:${port}/.well-known/jwks.json`).then(
|
||||
(response) => response.status,
|
||||
() => undefined
|
||||
);
|
||||
}
|
||||
|
||||
it("signs warming presence on every pulse and answers HTTP", async () => {
|
||||
const credential = await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential"));
|
||||
const generation = await launch("signs", credential);
|
||||
|
||||
// Startup publication, then one per 10 s pulse. A publication fault is
|
||||
// reported as it is logged rather than after the frames never arrive.
|
||||
const publicationFault = new Promise<never>((_resolve, reject) => {
|
||||
const watch = setInterval(() => {
|
||||
if (generation.stderr().includes(" failed on ")) {
|
||||
clearInterval(watch);
|
||||
reject(new Error(`publication failed; stderr: ${generation.stderr()}`));
|
||||
}
|
||||
}, 200);
|
||||
void idunn.frame(2).then(() => clearInterval(watch));
|
||||
});
|
||||
const exitedEarly = generation.exited.then((code) => {
|
||||
throw new Error(`bundle exited ${code}; stderr: ${generation.stderr()}`);
|
||||
});
|
||||
// The first statement is the startup publication, which follows the listen
|
||||
// directly; the 10 s pulse loop has not yet fired.
|
||||
await Promise.race([
|
||||
idunn.frame(0),
|
||||
publicationFault,
|
||||
exitedEarly,
|
||||
new Promise<never>((_resolve, reject) =>
|
||||
setTimeout(() => reject(new Error(`no presence at startup; stderr: ${generation.stderr()}`)), 8_000)
|
||||
),
|
||||
]);
|
||||
await Promise.race([idunn.frame(2), publicationFault, exitedEarly]);
|
||||
|
||||
expect(await answers(generation.candidatePort)).toBe(200);
|
||||
|
||||
// Public keys of the two proofs, from the fixtures' test keys.
|
||||
const store = decode(credential) as unknown[][];
|
||||
const record = decode(store[0]![2] as Uint8Array) as unknown[];
|
||||
const providerKey = createPublicKey({
|
||||
key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(record[2] as number[])]),
|
||||
format: "der",
|
||||
type: "spki",
|
||||
});
|
||||
const activationKey = createPublicKey(
|
||||
createPrivateKey({
|
||||
key: Buffer.concat([
|
||||
PKCS8_ED25519_SEED_PREFIX,
|
||||
await readFile(path.join(idunnRuntimeFixtures, "activation-key.seed")),
|
||||
]),
|
||||
format: "der",
|
||||
type: "pkcs8",
|
||||
})
|
||||
);
|
||||
|
||||
let previousSequence = 0;
|
||||
for (const frame of idunn.frames.slice(0, 3)) {
|
||||
const message = parseCultNetMessage(decode(frame.payload), "cultnet.schema.v0");
|
||||
if (message.schemaVersion !== "cultnet.document_put_raw.v0") throw new Error("not a raw document put");
|
||||
const fields = decode(message.document.payload) as unknown[];
|
||||
expect(fields[RUNTIME_PRESENCE_SLOT.state]).toBe("warming");
|
||||
|
||||
const unsigned = [...fields];
|
||||
unsigned[RUNTIME_PRESENCE_SLOT.signature] = new Uint8Array();
|
||||
unsigned[RUNTIME_PRESENCE_SLOT.activationSignature] = new Uint8Array();
|
||||
const proof = encode(unsigned);
|
||||
expect(
|
||||
verify(
|
||||
null,
|
||||
runtimePresenceProviderSigningMessage(proof),
|
||||
providerKey,
|
||||
fields[RUNTIME_PRESENCE_SLOT.signature] as Uint8Array
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
verify(
|
||||
null,
|
||||
runtimePresenceActivationSigningMessage(proof),
|
||||
activationKey,
|
||||
fields[RUNTIME_PRESENCE_SLOT.activationSignature] as Uint8Array
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
const sequence = fields[RUNTIME_PRESENCE_SLOT.publisherSequence] as number;
|
||||
expect(sequence).toBeGreaterThan(previousSequence);
|
||||
previousSequence = sequence;
|
||||
}
|
||||
|
||||
expect(generation.stderr()).not.toContain("failed on startup");
|
||||
expect(generation.stderr()).not.toContain("failed on interval");
|
||||
generation.child.kill("SIGKILL");
|
||||
}, 90_000);
|
||||
|
||||
it.each([
|
||||
["the identity descriptor is not passed", undefined, "gamecult-runtime-presence-identity"],
|
||||
["the identity is damaged", "damaged", "identity id check"],
|
||||
])("exits before listening when %s", async (_name, damage, expected) => {
|
||||
const credential = await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential"));
|
||||
const store = decode(credential) as unknown[][];
|
||||
const record = decode(store[0]![2] as Uint8Array) as unknown[];
|
||||
const publicKey = [...(record[2] as number[])];
|
||||
const original = { publicKeyHex: Buffer.from(publicKey).toString("hex"), id: record[1] as string, binding: record[5] as string };
|
||||
let identity: Buffer | undefined;
|
||||
if (damage) {
|
||||
publicKey[0] = publicKey[0]! ^ 1;
|
||||
record[2] = publicKey;
|
||||
identity = Buffer.from(encode([[store[0]![0], store[0]![1], encode(record), store[0]![3], store[0]![4]]]));
|
||||
}
|
||||
const started = Date.now();
|
||||
const generation = await launch(damage ? "damaged" : "no-identity", identity);
|
||||
|
||||
const code = await Promise.race([
|
||||
generation.exited,
|
||||
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5_000)),
|
||||
]);
|
||||
|
||||
expect(code, generation.stderr()).not.toBe("timeout");
|
||||
expect(code).not.toBe(0);
|
||||
expect(Date.now() - started).toBeLessThan(6_000);
|
||||
expect(await answers(generation.candidatePort)).toBeUndefined();
|
||||
expect(generation.stderr()).toContain(expected);
|
||||
// No byte of the credential: not the key bytes, not the id, not the binding.
|
||||
expect(generation.stderr()).not.toContain(original.publicKeyHex);
|
||||
expect(generation.stderr()).not.toContain(original.id.slice(0, 16));
|
||||
expect(generation.stderr()).not.toContain(original.binding.slice(-16));
|
||||
}, 30_000);
|
||||
});
|
||||
|
|
@ -1,18 +1,12 @@
|
|||
import dgram from "node:dgram";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { decode } from "@msgpack/msgpack";
|
||||
import {
|
||||
CultNetRudpSession,
|
||||
decodeRudpPacket,
|
||||
encodeRudpPacket,
|
||||
parseCultNetMessage,
|
||||
type CultNetTransportFrame,
|
||||
} from "cultnet-ts";
|
||||
import { parseCultNetMessage } from "cultnet-ts";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { publishIdunnRudpHealth } from "../src/idunn-rudp-health.js";
|
||||
import { startFakeIdunnRudp } from "./fixtures/fake-idunn-rudp.js";
|
||||
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("node:fs/promises")>();
|
||||
|
|
@ -27,37 +21,12 @@ vi.mock("node:fs/promises", async (importOriginal) => {
|
|||
|
||||
describe("Heimdall Idunn health publication", () => {
|
||||
it("completes the canonical RUDP handshake before publishing its signed frame", async () => {
|
||||
const server = dgram.createSocket("udp4");
|
||||
const session = new CultNetRudpSession({
|
||||
connectionId: 0x1d0d0001,
|
||||
initialSequence: 1,
|
||||
resendDelayMs: 100,
|
||||
});
|
||||
let resolveFrame!: (frame: CultNetTransportFrame) => void;
|
||||
const frameReceived = new Promise<CultNetTransportFrame>((resolve) => {
|
||||
resolveFrame = resolve;
|
||||
});
|
||||
|
||||
server.on("message", (wire, remote) => {
|
||||
const packet = decodeRudpPacket(wire);
|
||||
if (packet.packetType === "connect") {
|
||||
server.send(encodeRudpPacket(session.acceptConnect(packet, Date.now())), remote.port, remote.address);
|
||||
return;
|
||||
}
|
||||
const result = session.receive(packet, Date.now());
|
||||
if (result.reply) server.send(encodeRudpPacket(result.reply), remote.port, remote.address);
|
||||
for (const frame of result.delivered) resolveFrame(frame);
|
||||
if (result.delivered.length > 0) {
|
||||
server.send(encodeRudpPacket(session.createAck()), remote.port, remote.address);
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolve) => server.bind(0, "127.0.0.1", resolve));
|
||||
const server = await startFakeIdunnRudp();
|
||||
|
||||
try {
|
||||
const tmp = await mkdtemp(path.join(os.tmpdir(), "heimdall-idunn-rudp-"));
|
||||
const address = server.address();
|
||||
const config = loadConfig({
|
||||
GC_ACCESS_IDUNN_RUDP_HEALTH: `127.0.0.1:${address.port}`,
|
||||
GC_ACCESS_IDUNN_RUDP_HEALTH: `127.0.0.1:${server.port}`,
|
||||
GC_ACCESS_PROVIDER_HEALTH_IDENTITY_PATH: path.join(tmp, "provider-health-identity.cc"),
|
||||
});
|
||||
const observedAt = "2026-08-22T00:45:00.000Z";
|
||||
|
|
@ -78,7 +47,7 @@ describe("Heimdall Idunn health publication", () => {
|
|||
observedAt,
|
||||
presence,
|
||||
});
|
||||
const frame = await frameReceived;
|
||||
const frame = await server.frame(0);
|
||||
const message = parseCultNetMessage(decode(frame.payload), "cultnet.schema.v0");
|
||||
|
||||
expect(frame.channelId).toBe("schema");
|
||||
|
|
|
|||
|
|
@ -1,18 +1,17 @@
|
|||
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { openSync, closeSync } from "node:fs";
|
||||
import { access, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import { openSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { encode } from "@msgpack/msgpack";
|
||||
import { SingleFileMessagePackBackingStore } from "@gamecult/cultcache-ts";
|
||||
import { decode, encode } from "@msgpack/msgpack";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
openOrEnrollProviderHealthIdentity,
|
||||
openProviderHealthIdentity,
|
||||
passedDescriptor,
|
||||
providerHealthPublicIdentity,
|
||||
} from "../src/provider-health-identity.js";
|
||||
import { fixtureMachineId, idunnRuntimeFixtures } from "./fixtures/presence-descriptors.js";
|
||||
|
||||
const machineIdAvailable = await readFile("/etc/machine-id", "utf8").then(
|
||||
() => true,
|
||||
|
|
@ -23,59 +22,79 @@ const machineIdAvailable = await readFile("/etc/machine-id", "utf8").then(
|
|||
* The identity is machine-id bound, so enrolment only works where a machine-id
|
||||
* exists. These run on Linux and are skipped elsewhere.
|
||||
*/
|
||||
describe.skipIf(!machineIdAvailable)("presence identity from a passed descriptor", () => {
|
||||
it("reads an Idunn-written store, not a cultcache-ts one", async () => {
|
||||
/** Name the presence descriptor so it lands on whatever fd open() returned. */
|
||||
function presenceEnv(fd: number): NodeJS.ProcessEnv {
|
||||
// The lookup maps a name index onto 3 + index, so the name list is padded
|
||||
// until the presence entry lands on the descriptor open() actually returned.
|
||||
// Assuming it is 3 is how this passes on one machine and reads the wrong
|
||||
// descriptor on another.
|
||||
const names = [
|
||||
...Array.from({ length: fd - 3 }, (_, i) => `pad-${i}`),
|
||||
"gamecult-runtime-presence-identity",
|
||||
].join(":");
|
||||
return { LISTEN_FDNAMES: names, LISTEN_PID: String(process.pid) };
|
||||
}
|
||||
|
||||
describe("presence identity from a passed descriptor", () => {
|
||||
it("decodes the Rust owner's record, whose key fields are integer arrays", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-presence-"));
|
||||
const fd = openSync(path.join(idunnRuntimeFixtures, "provider-identity.credential"), "r");
|
||||
|
||||
// Enrol with the TypeScript client to get a genuine machine-bound payload,
|
||||
// then re-wrap that payload the way cultcache-rs writes a store: a bare
|
||||
// array of positional [key, type, payload, stored_at, schema_id] envelopes.
|
||||
// Idunn writes with the Rust client, and cultcache-ts cannot read that
|
||||
// layout, so wrapping it here is what makes this test resemble reality.
|
||||
const tsStore = path.join(dir, "ts-enrolled.cc");
|
||||
const enrolled = await openOrEnrollProviderHealthIdentity(tsStore);
|
||||
const records = await new SingleFileMessagePackBackingStore(tsStore).pullAll();
|
||||
expect(records).toHaveLength(1);
|
||||
|
||||
const idunnStore = path.join(dir, "runtime-presence-identity.cc");
|
||||
await writeFile(
|
||||
idunnStore,
|
||||
encode([
|
||||
[
|
||||
"gamecult-provider-health-identity",
|
||||
"gamecult.provider_health_identity.private.v1",
|
||||
records[0]!.payload,
|
||||
"2026-09-06T00:00:00.000Z",
|
||||
"gamecult.provider_health_identity.private.v1",
|
||||
],
|
||||
])
|
||||
const opened = await openProviderHealthIdentity(
|
||||
path.join(dir, "would-be-self-enrolled.cc"),
|
||||
presenceEnv(fd),
|
||||
fixtureMachineId
|
||||
);
|
||||
|
||||
const fd = openSync(idunnStore, "r");
|
||||
try {
|
||||
// The lookup maps a name index onto 3 + index, so the name list is padded
|
||||
// until the presence entry lands on whatever descriptor open() actually
|
||||
// returned. Assuming it is 3 is how this passes on one machine and reads
|
||||
// the wrong descriptor on another.
|
||||
const names = [
|
||||
...Array.from({ length: fd - 3 }, (_, i) => `pad-${i}`),
|
||||
"gamecult-runtime-presence-identity",
|
||||
].join(":");
|
||||
// The fixture's own public key means it came from the descriptor. A fresh
|
||||
// self-enrolment would produce a different key, which is exactly the failure
|
||||
// that makes Idunn refuse this daemon's health and stall promotion.
|
||||
const store = decode(await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential"))) as unknown[][];
|
||||
const record = decode(store[0]![2] as Uint8Array) as unknown[];
|
||||
expect(providerHealthPublicIdentity(opened).publicKeyHex).toBe(
|
||||
Buffer.from(record[2] as number[]).toString("hex")
|
||||
);
|
||||
await expect(access(path.join(dir, "would-be-self-enrolled.cc"))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
const opened = await openProviderHealthIdentity(
|
||||
path.join(dir, "would-be-self-enrolled.cc"),
|
||||
{ LISTEN_FDNAMES: names, LISTEN_PID: String(process.pid) }
|
||||
);
|
||||
it("refuses a bin-shaped record on the descriptor path, naming the key-shape check and no value", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-presence-bin-"));
|
||||
const store = decode(await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential"))) as unknown[][];
|
||||
const record = decode(store[0]![2] as Uint8Array) as unknown[];
|
||||
// What this process's own enroller writes: the same record with bin key fields.
|
||||
const binRecord = record.map((field, index) =>
|
||||
index === 2 || index === 3 ? Uint8Array.from(field as number[]) : field
|
||||
);
|
||||
const binStore = path.join(dir, "bin-shaped.cc");
|
||||
await writeFile(binStore, encode([[store[0]![0], store[0]![1], encode(binRecord), store[0]![3], store[0]![4]]]));
|
||||
const fd = openSync(binStore, "r");
|
||||
|
||||
// Same key means it came from the descriptor. A fresh self-enrolment
|
||||
// would produce a different key -- which is exactly the failure that
|
||||
// makes Idunn refuse this daemon's health and stall promotion.
|
||||
expect(providerHealthPublicIdentity(opened).publicKeyHex).toBe(
|
||||
providerHealthPublicIdentity(enrolled).publicKeyHex
|
||||
);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
const failure = await openProviderHealthIdentity(path.join(dir, "x.cc"), presenceEnv(fd), fixtureMachineId).then(
|
||||
() => undefined,
|
||||
(error: Error) => error
|
||||
);
|
||||
|
||||
expect(failure?.message).toContain("key shape check");
|
||||
expect(failure?.message).not.toContain((record[1] as string).slice(0, 16));
|
||||
});
|
||||
|
||||
it.each([
|
||||
["schema", 0, "bogus", "schema check"],
|
||||
["protector", 4, "bogus", "protector check"],
|
||||
["binding", 5, "bogus", "binding check"],
|
||||
["version", 6, "v9", "version check"],
|
||||
])("a record failing only the %s check is refused by that check", async (_name, index, replacement, check) => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-presence-check-"));
|
||||
const store = decode(await readFile(path.join(idunnRuntimeFixtures, "provider-identity.credential"))) as unknown[][];
|
||||
const record = decode(store[0]![2] as Uint8Array) as unknown[];
|
||||
record[index] = replacement;
|
||||
const damaged = path.join(dir, "damaged.cc");
|
||||
await writeFile(damaged, encode([[store[0]![0], store[0]![1], encode(record), store[0]![3], store[0]![4]]]));
|
||||
const fd = openSync(damaged, "r");
|
||||
|
||||
await expect(
|
||||
openProviderHealthIdentity(path.join(dir, "x.cc"), presenceEnv(fd), fixtureMachineId)
|
||||
).rejects.toThrow(check);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -112,3 +131,29 @@ describe("presence identity descriptor selection", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("an Idunn generation without the presence identity descriptor", () => {
|
||||
it("refuses, naming the descriptor, and enrolls nothing", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-nodescriptor-"));
|
||||
const identityPath = path.join(dir, "provider-health-identity.cc");
|
||||
|
||||
await expect(
|
||||
openProviderHealthIdentity(identityPath, {
|
||||
GAMECULT_IDUNN_RUNTIME_BUNDLE: "/run/idunn/bundle",
|
||||
LISTEN_FDNAMES: "some-other-descriptor",
|
||||
})
|
||||
).rejects.toThrow("gamecult-runtime-presence-identity");
|
||||
|
||||
await expect(access(identityPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("still self-enrolls outside Idunn", async () => {
|
||||
if (!machineIdAvailable) return;
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-outside-idunn-"));
|
||||
const identityPath = path.join(dir, "provider-health-identity.cc");
|
||||
|
||||
await openProviderHealthIdentity(identityPath, {});
|
||||
|
||||
await expect(access(identityPath)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
83
tests/publisher-sequence.test.ts
Normal file
83
tests/publisher-sequence.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { mkdtemp, readdir, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { PublisherSequence, openPresenceAuthority } from "../src/runtime-presence.js";
|
||||
import { fixtureMachineId, openPresenceDescriptors, writePresenceFixtures } from "./fixtures/presence-descriptors.js";
|
||||
|
||||
describe("PublisherSequence", () => {
|
||||
it("starts at the observed millis", () => {
|
||||
expect(new PublisherSequence().next(1_700_000_000_000)).toBe(1_700_000_000_000);
|
||||
});
|
||||
|
||||
it("strictly increases for equal, earlier and later observations", () => {
|
||||
const sequence = new PublisherSequence();
|
||||
const first = sequence.next(1_700_000_000_000);
|
||||
const equal = sequence.next(1_700_000_000_000);
|
||||
const earlier = sequence.next(1_699_999_990_000);
|
||||
const later = sequence.next(1_700_000_050_000);
|
||||
expect([first, equal, earlier, later]).toEqual([
|
||||
1_700_000_000_000,
|
||||
1_700_000_000_001,
|
||||
1_700_000_000_002,
|
||||
1_700_000_050_000,
|
||||
]);
|
||||
});
|
||||
|
||||
it("never falls below the observed millis once the clock moves ahead", () => {
|
||||
const sequence = new PublisherSequence();
|
||||
sequence.next(1_700_000_000_000);
|
||||
sequence.next(1_700_000_000_000);
|
||||
sequence.next(1_700_000_000_000);
|
||||
expect(sequence.next(1_700_000_000_002)).toBe(1_700_000_000_003);
|
||||
expect(sequence.next(1_700_000_000_010)).toBe(1_700_000_000_010);
|
||||
});
|
||||
|
||||
it("refuses a value that is not a safe integer and keeps its place", () => {
|
||||
const sequence = new PublisherSequence();
|
||||
expect(sequence.next(10)).toBe(10);
|
||||
for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, 1.5, 2 ** 60]) {
|
||||
expect(() => sequence.next(bad)).toThrow("observedAtUnixMillis");
|
||||
}
|
||||
expect(sequence.next(5)).toBe(11);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the presence authority", () => {
|
||||
it("opens once, touches no path under dataRoot, and signs every pulse after its descriptors are gone", async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "heimdall-nodataroot-"));
|
||||
const fixtures = path.join(dir, "fixtures");
|
||||
await writePresenceFixtures(fixtures);
|
||||
// A regular file as dataRoot: opening anything beneath it fails with
|
||||
// ENOTDIR for every uid, root included.
|
||||
const dataRoot = path.join(dir, "not-a-directory");
|
||||
await writeFile(dataRoot, "");
|
||||
const config = loadConfig({
|
||||
GC_ACCESS_BASE_URL: "https://heimdall.example.test",
|
||||
GC_ACCESS_DATA_ROOT: dataRoot,
|
||||
});
|
||||
|
||||
const descriptors = openPresenceDescriptors(fixtures);
|
||||
try {
|
||||
const authority = await openPresenceAuthority(
|
||||
{ ...config, idunnRuntimeBundlePath: fixtures },
|
||||
descriptors.env,
|
||||
fixtureMachineId,
|
||||
);
|
||||
// The descriptors were read whole and closed: a second read would find
|
||||
// nothing, so signing again proves the authority holds what it decoded.
|
||||
const observedAtUnixMillis = Date.parse("2026-10-23T12:00:00.000Z");
|
||||
const first = authority.sign("warming", "probe", observedAtUnixMillis);
|
||||
const second = authority.sign("warming", "probe", observedAtUnixMillis);
|
||||
expect(first.target).toBe("streampixels-web");
|
||||
expect(second.target).toBe("streampixels-web");
|
||||
expect(Buffer.from(second.payload).equals(Buffer.from(first.payload))).toBe(false);
|
||||
} finally {
|
||||
descriptors.close();
|
||||
}
|
||||
expect((await readdir(dir)).sort()).toEqual(["fixtures", "not-a-directory"]);
|
||||
});
|
||||
});
|
||||
98
tests/state-root-generations.test.ts
Normal file
98
tests/state-root-generations.test.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { chmod, chown, mkdir, mkdtemp, readdir, stat } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { build } from "esbuild";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { writePresenceFixtures } from "./fixtures/presence-descriptors.js";
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
const STATE_GID = 4242;
|
||||
const GENERATION_N = 61184;
|
||||
const GENERATION_N_PLUS_1 = 61185;
|
||||
|
||||
// Creating uids and groups needs root; the stopgap's node container runs as root.
|
||||
const isRoot = process.getuid?.() === 0;
|
||||
|
||||
async function walk(dir: string): Promise<string[]> {
|
||||
const found: string[] = [];
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
found.push(full);
|
||||
if (entry.isDirectory()) found.push(...(await walk(full)));
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idunn gives every generation a fresh DynamicUser uid and one shared group,
|
||||
* UMask=0007, and a 2770 setgid state root. Generation N+1 runs beside or after
|
||||
* N and must be able to read and replace everything N left there.
|
||||
*/
|
||||
describe.skipIf(!isRoot)("Idunn state root across generations", () => {
|
||||
it("generation N+1, a different uid in the same group, reads and replaces generation N's state", async () => {
|
||||
const base = await mkdtemp(path.join(os.tmpdir(), "heimdall-generations-"));
|
||||
await chmod(base, 0o755);
|
||||
|
||||
// The fixtures stand in for what Idunn passes as descriptors.
|
||||
const fixtures = path.join(base, "fixtures");
|
||||
await writePresenceFixtures(fixtures);
|
||||
|
||||
const stateRoot = path.join(base, "state");
|
||||
await mkdir(stateRoot);
|
||||
await chown(stateRoot, 0, STATE_GID);
|
||||
await chmod(stateRoot, 0o2770);
|
||||
|
||||
// The generations run as uids that cannot read the checkout, so each runs
|
||||
// one self-contained bundle built into the world-readable scratch root.
|
||||
const child = path.join(base, "generation.mjs");
|
||||
await build({
|
||||
entryPoints: [path.join(import.meta.dirname, "fixtures", "state-root-generation-child.ts")],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
target: "node24",
|
||||
outfile: child,
|
||||
external: ["pg-native"],
|
||||
banner: {
|
||||
js: "import{createRequire}from'node:module';const require=createRequire(import.meta.url);",
|
||||
},
|
||||
logLevel: "silent",
|
||||
});
|
||||
await chmod(child, 0o644);
|
||||
const generation = (role: "first" | "next", uid: number) =>
|
||||
run(process.execPath, [child, role, stateRoot, fixtures], {
|
||||
uid,
|
||||
gid: STATE_GID,
|
||||
cwd: base,
|
||||
env: { PATH: process.env.PATH ?? "", HOME: fixtures },
|
||||
});
|
||||
|
||||
await generation("first", GENERATION_N);
|
||||
|
||||
const left = await walk(stateRoot);
|
||||
expect(left.map((entry) => path.relative(stateRoot, entry)).sort()).toEqual([
|
||||
"cultcache",
|
||||
path.join("cultcache", "heimdall.service.cc"),
|
||||
"runtime-presence-sequence",
|
||||
]);
|
||||
|
||||
await generation("next", GENERATION_N_PLUS_1);
|
||||
|
||||
// Generation N+1 replaced the snapshot, and no owner-only file is left
|
||||
// behind for generation N+2: everything but the stale 0600 fixture is
|
||||
// readable and writable by the state group.
|
||||
const snapshot = await stat(path.join(stateRoot, "cultcache", "heimdall.service.cc"));
|
||||
expect(snapshot.uid).toBe(GENERATION_N_PLUS_1);
|
||||
expect(snapshot.gid).toBe(STATE_GID);
|
||||
for (const entry of await walk(stateRoot)) {
|
||||
if (path.basename(entry) === "runtime-presence-sequence") continue;
|
||||
expect((await stat(entry)).mode & 0o060, path.relative(stateRoot, entry)).toBe(0o060);
|
||||
}
|
||||
expect((await readdir(stateRoot)).sort()).toEqual(["cultcache", "runtime-presence-sequence"]);
|
||||
}, 60_000);
|
||||
});
|
||||
Loading…
Reference in a new issue