Compare commits

..

No commits in common. "c6479d6811b82b16db3eabf35b5842689332bdf0" and "0f912547cd461644405b996e541b7986ab965ad4" have entirely different histories.

4 changed files with 1 additions and 244 deletions

View file

@ -2,4 +2,4 @@
Authoritative source for pipeline (ADR-014) — not shipped. emergency/schedule/map/info/assets. Authoritative source for pipeline (ADR-014) — not shipped. emergency/schedule/map/info/assets.
Stage 6: implemented — canonical source sheets `emergency/source.json`, `schedule/source.json`, `map/source.json`, `info/source.json`. The current sheets model SolarPunk Summit 2026, sourced from the public site on 2026-08-31. Stable IDs and festival-zone `dayKey` values are used; the site does not provide verified emergency phone numbers or machine-readable POI coordinates, so those fields remain conservative rather than fabricated. Same emergency source generates both `emergency.json` and emergency floor `≤16KB` via pipeline/emergency.ts (no drift per ARCH 10.3). Runbook CLI: `npm run package:staging` builds+signs the `lumen-2026` staging edition from these sheets and writes the origin layout (`/editions/<ed>/packages/<v>/…` + mutable `latest.json`) under `instance/origin/`, then smoke re-fetches and re-verifies every file hash, the manifest hash, and the Ed25519 signature (test key only; `npm run package:smoke` re-verifies without rebuilding). See IMPLEMENTATION-CONTRACT.md §4 responsibilities and §6 B-1…B-7. Stage 6: implemented — canonical source sheets `emergency/source.json`, `schedule/source.json`, `map/source.json`, `info/source.json`. The current sheets model SolarPunk Summit 2026, sourced from the public site on 2026-08-31. Stable IDs and festival-zone `dayKey` values are used; the site does not provide verified emergency phone numbers or machine-readable POI coordinates, so those fields remain conservative rather than fabricated. Same emergency source generates both `emergency.json` and emergency floor `≤16KB` via pipeline/emergency.ts (no drift per ARCH 10.3). See IMPLEMENTATION-CONTRACT.md §4 responsibilities and §6 B-1…B-7.

1
package-lock.json generated
View file

@ -22,7 +22,6 @@
"typescript": "^5.8.2", "typescript": "^5.8.2",
"typescript-eslint": "^8.26.1", "typescript-eslint": "^8.26.1",
"vite": "^6.4.3", "vite": "^6.4.3",
"vite-node": "^3.0.0",
"vitest": "^3.1.1" "vitest": "^3.1.1"
}, },
"engines": { "engines": {

View file

@ -19,8 +19,6 @@
"test:watch": "vitest", "test:watch": "vitest",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"package:staging": "vite-node pipeline/run.ts",
"package:smoke": "vite-node pipeline/run.ts --smoke-only",
"ci": "npm run typecheck && npm run lint && npm run format && npm run test && npm run build" "ci": "npm run typecheck && npm run lint && npm run format && npm run test && npm run build"
}, },
"devDependencies": { "devDependencies": {
@ -35,7 +33,6 @@
"typescript": "^5.8.2", "typescript": "^5.8.2",
"typescript-eslint": "^8.26.1", "typescript-eslint": "^8.26.1",
"vite": "^6.4.3", "vite": "^6.4.3",
"vite-node": "^3.0.0",
"vitest": "^3.1.1" "vitest": "^3.1.1"
}, },
"dependencies": { "dependencies": {

View file

@ -1,239 +0,0 @@
/* eslint-disable no-console -- this is the publisher CLI; stdout is its interface */
/**
* Stage 6 runbook CLI — validate → build → hash → sign → upload → smoke.
* `node_modules/.bin/vite-node pipeline/run.ts [--edition lumen-2026] [--version 1] [--out instance/origin]`
*
* Upload target is a LOCAL directory laid out exactly like the hosting origin
* (contract §37): /editions/<ed>/packages/<v>/manifest.json|signature.json|
* sections|assets, mutable /latest.json, compiled emergency floor. Smoke step
* re-reads the uploaded bytes from disk and re-verifies every file hash,
* manifest hash, and the Ed25519 signature — a genuine round-trip, test key only.
* Trace: IMPLEMENTATION-CONTRACT.md Stage 6, SPIKE-04 §3 gate 5, ARCH 18.7.
*/
import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { buildPackage } from "./package.js";
import { generateTestKeyPair, fingerprintFromPublicPem } from "./sign.js";
import { sha256Hex } from "./hash.js";
import { verify } from "node:crypto";
import { canonicalJson } from "./canonical-json.js";
import type { PipelineInput, EmergencySource } from "./types.js";
import { dayKeyFor } from "../src/domain/clock/logic.js";
// ——— CLI args ———
const argv = process.argv.slice(2);
function arg(name: string, dflt: string): string {
const i = argv.indexOf(`--${name}`);
const v = i >= 0 ? argv[i + 1] : undefined;
return v !== undefined && v.length > 0 ? v : dflt;
}
const edition = arg("edition", "lumen-2026");
const version = Number(arg("version", "1"));
const outDir = arg("out", "instance/origin");
const smokeOnly = argv.includes("--smoke-only");
function log(step: string, msg: string): void {
console.log(`[${step}] ${msg}`);
}
function die(msg: string): never {
console.error(`FAIL: ${msg}`);
process.exit(1);
}
// ——— Load provisional content sheets (Stage 6: placeholder, not real organizer data) ———
function loadSheet(section: string): Record<string, unknown> {
const p = join("content", section, "source.json");
if (!existsSync(p)) die(`missing content sheet ${p}`);
return JSON.parse(readFileSync(p, "utf8")) as Record<string, unknown>;
}
const emergency = loadSheet("emergency") as unknown as EmergencySource;
const schedule = loadSheet("schedule");
const mapSheet = loadSheet("map");
const info = loadSheet("info");
// Festival window: SolarPunk Summit 2026 (provisional, from public site Aug 31).
// Derived from the sheet's own event span so gate3 window checks are honest.
const FEST_TZ = "America/Chicago";
const allEvents = schedule.events as Array<{ startUtc: number; endUtc: number }>;
const startUtc = Math.min(...allEvents.map((e) => e.startUtc));
const endUtc = Math.max(...allEvents.map((e) => e.endUtc));
// Patch dayKey so gate3 can pass against the sheets' own startUtc values —
// source sheets stay hand-editable; the pipeline stamps derived fields.
const events = (schedule.events as Array<Record<string, unknown>>).map((ev) => ({
...ev,
dayKey: dayKeyFor(ev.startUtc as number, FEST_TZ),
}));
// Provisional placeholder assets: tiny deterministic blobs standing in for art
// (contract: "Real organizer content — provisional placeholder" only).
function placeholderPng(id: string): Uint8Array {
// 1x1 PNG-ish deterministic payload; hash/size budgets are what matter here.
const marker = new TextEncoder().encode(`lumen-placeholder:${id}`);
const pngHeader = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, ...marker]);
return pngHeader;
}
const mapLevelIds = (
(mapSheet.base as { levels: Array<{ id: string; assetId: string }> }).levels ?? []
).map((l) => l.assetId);
const assetIds = mapLevelIds.length > 0 ? mapLevelIds : ["map-base-overview"];
const blobs = new Map<string, Uint8Array>();
for (const id of assetIds) blobs.set(id, placeholderPng(id));
const assetsInventory = {
assets: assetIds.map((id) => ({
id,
file: `assets/${id}.png`,
kind: "map-base" as const,
role: id.includes("detail") ? "detail" : "overview",
sha256: "",
bytes: 0,
})),
blobs,
};
const input: PipelineInput = {
edition,
packageVersion: version,
schemaVersion: 1,
generatedAt: new Date().toISOString(),
festival: { name: "SolarPunk Summit 2026", timezone: FEST_TZ, startUtc, endUtc },
appCompatibility: { minAppVersion: "1.0.0", maxAppVersion: null },
content: {
emergency,
schedule: { ...schedule, events } as unknown as PipelineInput["content"]["schedule"],
map: mapSheet as unknown as PipelineInput["content"]["map"],
info: info as unknown as PipelineInput["content"]["info"],
assets: assetsInventory,
},
previous: null,
previousPackageVersion: null,
};
// ——— Smoke: re-read the "origin" tree and re-verify everything ———
function smokeCheck(originRoot: string, publicKeyPem: string | null): void {
log("smoke", "re-fetching from origin and re-verifying");
const latestRaw = JSON.parse(readFileSync(join(originRoot, "latest.json"), "utf8")) as {
edition: string;
packageVersion: number;
manifestUrl: string;
};
const smokePkgDir = join(
originRoot,
"editions",
latestRaw.edition,
"packages",
String(latestRaw.packageVersion),
);
const manifestBytes = new TextEncoder().encode(
readFileSync(join(smokePkgDir, "manifest.json"), "utf8"),
);
const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as {
sections: Record<string, { file: string; sha256: string; bytes: number }>;
limits: { totalBytes: number };
};
const sig = JSON.parse(readFileSync(join(smokePkgDir, "signature.json"), "utf8")) as {
manifestSha256: string;
signature: string;
publicKeyFingerprint: string;
};
// hash of the manifest bytes must match signature
if (sha256Hex(manifestBytes) !== sig.manifestSha256) die("smoke: manifest hash mismatch");
// Ed25519 verify over sha256(manifest bytes). With no key given, resolve the
// public key from the published publicKey.pem and check its fingerprint.
let verifyPem = publicKeyPem;
if (!verifyPem) {
const pubPemPath = join(smokePkgDir, "publicKey.pem");
if (!existsSync(pubPemPath)) die("smoke: no key to verify with (publicKey.pem missing)");
const pem = readFileSync(pubPemPath, "utf8");
if (fingerprintFromPublicPem(pem) !== sig.publicKeyFingerprint)
die("smoke: published publicKey.pem fingerprint mismatch");
verifyPem = pem;
}
const okSig = verify(
null,
new Uint8Array(Buffer.from(sha256Hex(manifestBytes), "hex")),
{ key: verifyPem, format: "pem", type: "spki" },
Buffer.from(sig.signature, "base64"),
);
if (!okSig) die("smoke: signature verification FAILED");
if (fingerprintFromPublicPem(verifyPem) !== sig.publicKeyFingerprint)
die("smoke: fingerprint mismatch");
// every section + asset file: re-read from disk, recompute sha256, compare
let checked = 0;
for (const [name, meta] of Object.entries(manifest.sections)) {
const p = join(smokePkgDir, meta.file);
if (!existsSync(p)) die(`smoke: section ${name} not reachable at ${meta.file}`);
const bytes = readFileSync(p);
if (bytes.length !== meta.bytes) die(`smoke: ${meta.file} size mismatch`);
if (sha256Hex(new Uint8Array(bytes)) !== meta.sha256) die(`smoke: ${meta.file} sha mismatch`);
checked++;
}
// assets inventory carries per-asset hashes
const assetsJson = JSON.parse(readFileSync(join(smokePkgDir, "assets.json"), "utf8")) as {
assets: Array<{ id: string; file: string; sha256: string; bytes: number }>;
};
for (const a of assetsJson.assets) {
const p = join(smokePkgDir, a.file);
if (!existsSync(p)) die(`smoke: asset ${a.id} missing`);
const bytes = readFileSync(p);
if (sha256Hex(new Uint8Array(bytes)) !== a.sha256) die(`smoke: asset ${a.file} sha mismatch`);
checked++;
}
log(
"smoke",
`PASS — ${String(checked)} files re-fetched + hash-verified, signature + fingerprint verified, latest.json consistent`,
);
}
// ——— --smoke-only: skip build/sign/upload, verify what is already on disk ———
if (smokeOnly) {
smokeCheck(outDir, null);
console.log("STAGE6 RUNBOOK OK");
process.exit(0);
}
// ——— 1-2-3-4: validate + build + hash + sign (buildPackage runs gates 1-5) ———
log("validate/build/hash", `building ${edition} v${version}`);
const kp = generateTestKeyPair();
const built = buildPackage(input, { signWith: kp });
if (!built.ok) die(`build rejected: ${built.reason}`);
const pkg = built.pkg;
const totalBytes =
[...pkg.files.values()].reduce((s, f) => s + f.bytes, 0) +
pkg.assets.reduce((s, a) => s + a.bytes, 0);
log(
"validate/build/hash",
`ok — ${pkg.files.size} sections + ${pkg.assets.length} assets, ${String(totalBytes)} bytes (budget ≤40MB: ${totalBytes <= 40 * 1024 * 1024 ? "PASS" : "FAIL"})`,
);
if (pkg.floorBytes > 16 * 1024) die(`emergency floor ${String(pkg.floorBytes)} > 16KB`);
log("floor", `emergency floor compiled: ${String(pkg.floorBytes)} bytes (≤16KB)`);
if (!pkg.signature) die("expected signature with test key");
log("sign", `signed with test key ${kp.fingerprint.slice(0, 18)}…`);
// ——— 5: upload immutable files + flip latest.json (local dir = origin layout §37) ———
const pkgDir = join(outDir, "editions", edition, "packages", String(version));
mkdirSync(join(pkgDir, "assets"), { recursive: true });
for (const [, f] of pkg.files) {
writeFileSync(join(pkgDir, f.file), f.canonicalBytes);
}
for (const a of pkg.assets) {
writeFileSync(join(pkgDir, a.file), a.bytesContent);
}
writeFileSync(join(pkgDir, "manifest.json"), canonicalJson(pkg.manifest));
writeFileSync(join(pkgDir, "signature.json"), JSON.stringify(pkg.signature, null, 2));
// TEST-ONLY: publish the test public key next to the package so `--smoke-only`
// can re-verify offline. Production trust pins the key inside the app shell
// (Stage 7 verifier) — never rely on a key published alongside the data.
writeFileSync(join(pkgDir, "publicKey.pem"), kp.publicKeyPem);
writeFileSync(join(pkgDir, "emergency-floor.json"), JSON.stringify(pkg.emergencyFloor, null, 2));
// Mutable pointer — last write, so immutable files always exist before flip.
writeFileSync(join(outDir, "latest.json"), JSON.stringify(pkg.latest, null, 2));
log("upload", `wrote immutable tree under ${pkgDir}/ + latest.json flip`);
// ——— 6: smoke — verify what we just uploaded (key known from this run) ———
smokeCheck(outDir, kp.publicKeyPem);
console.log("STAGE6 RUNBOOK OK");