diff --git a/content/README.md b/content/README.md index 7c7c9ec..95320b1 100644 --- a/content/README.md +++ b/content/README.md @@ -2,4 +2,4 @@ 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//packages//…` + 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. diff --git a/package-lock.json b/package-lock.json index 9dca904..39c95fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,6 @@ "typescript": "^5.8.2", "typescript-eslint": "^8.26.1", "vite": "^6.4.3", - "vite-node": "^3.0.0", "vitest": "^3.1.1" }, "engines": { diff --git a/package.json b/package.json index bceab54..8b006a0 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,6 @@ "test:watch": "vitest", "build": "vite build", "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" }, "devDependencies": { @@ -35,7 +33,6 @@ "typescript": "^5.8.2", "typescript-eslint": "^8.26.1", "vite": "^6.4.3", - "vite-node": "^3.0.0", "vitest": "^3.1.1" }, "dependencies": { diff --git a/pipeline/run.ts b/pipeline/run.ts deleted file mode 100644 index 4b3e3f9..0000000 --- a/pipeline/run.ts +++ /dev/null @@ -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//packages//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 { - const p = join("content", section, "source.json"); - if (!existsSync(p)) die(`missing content sheet ${p}`); - return JSON.parse(readFileSync(p, "utf8")) as Record; -} - -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>).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(); -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; - 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");