Checkpoint: current Lumen state
Some checks failed
ci / check (push) Has been cancelled

This commit is contained in:
Lumen Stage1 2026-09-23 18:58:21 -05:00
commit fcc18ddcc8
96 changed files with 8074 additions and 214 deletions

199
pipeline/package.ts Normal file
View file

@ -0,0 +1,199 @@
/**
* Package builder — validate → build → hash → manifest → sign → latest → floor.
* Orchestrates gates 1-5; fails closed on any violation.
* Trace: IMPLEMENTATION-CONTRACT.md Stage 6, SPIKE-04 §3, ARCH 10.3
*/
import { canonicalJson } from "./canonical-json.js";
import { sha256HexOfString, sha256Hex } from "./hash.js";
import { BUDGETS, checkBudgets } from "./budgets.js";
import type { PipelineInput, BuiltPackage, SectionFile, AssetFile } from "./types.js";
import type { FestivalManifest } from "../src/data/festival-package/types.js";
import { buildManifest } from "./manifest.js";
import { deriveEmergencyFloor } from "./emergency.js";
import {
gate1Schema,
gate2StableIds,
gate3TimeSanity,
gate4Budgets,
gate5HashPresent,
validateManifestGates,
} from "./gates.js";
import { signManifest, type KeyPair } from "./sign.js";
export type BuildResult =
| { readonly ok: true; readonly pkg: BuiltPackage }
| { readonly ok: false; readonly reason: string };
function sectionToBytes(obj: unknown): { bytes: Uint8Array; str: string } {
const str = canonicalJson(obj);
return { bytes: new TextEncoder().encode(str), str };
}
export function buildPackage(
input: PipelineInput,
opts?: { signWith?: KeyPair | null },
): BuildResult {
// Basic monotonic check — informational; caller may check latest pointer
if (!Number.isInteger(input.packageVersion) || input.packageVersion < 1)
return { ok: false, reason: "packageVersion must be integer >=1" };
if (input.previousPackageVersion !== undefined && input.previousPackageVersion !== null) {
if (input.packageVersion <= input.previousPackageVersion)
return {
ok: false,
reason: `packageVersion ${input.packageVersion} must be > previous ${input.previousPackageVersion}`,
};
}
// Early gates that don't need hashes
const g1 = gate1Schema(input.content);
if (!g1.ok) return g1;
const g2 = gate2StableIds(input);
if (!g2.ok) return g2;
const g3 = gate3TimeSanity(input);
if (!g3.ok) return g3;
// Serialize sections deterministically and hash
const files = new Map<string, SectionFile>();
const assetMap = input.content.assets.blobs;
// Build section JSONs
const emergencyBytes = sectionToBytes(input.content.emergency);
const scheduleBytes = sectionToBytes(input.content.schedule);
const mapBytes = sectionToBytes(input.content.map);
const infoBytes = sectionToBytes(input.content.info);
// assets.json carries the hashes and byte lengths of the actual asset blobs.
const assetsInventory = {
assets: input.content.assets.assets.map((asset) => {
const blob = input.content.assets.blobs.get(asset.id);
if (!blob) throw new Error(`asset ${asset.id} missing blob`);
return { ...asset, bytes: blob.length, sha256: sha256Hex(blob) };
}),
};
const assetsJsonBytes = sectionToBytes(assetsInventory);
// Asset files — map asset id -> blob bytes
const assetFiles: AssetFile[] = [];
for (const a of input.content.assets.assets) {
const blob = assetMap.get(a.id);
if (!blob) return { ok: false, reason: `asset ${a.id} missing blob` };
if (blob.length > BUDGETS.MAX_FILE_BYTES)
return { ok: false, reason: `asset ${a.id} exceeds 6MB` };
const sha = sha256Hex(blob);
assetFiles.push({
id: a.id,
file: a.file,
bytes: blob.length,
sha256: sha,
kind: a.kind,
role: a.role,
bytesContent: blob,
});
}
// Create section files entries
const sections: Array<[string, Uint8Array]> = [
["emergency.json", emergencyBytes.bytes],
["schedule.json", scheduleBytes.bytes],
["map.json", mapBytes.bytes],
["info.json", infoBytes.bytes],
["assets.json", assetsJsonBytes.bytes],
];
for (const [file, bytes] of sections) {
if (bytes.length > BUDGETS.MAX_FILE_BYTES)
return { ok: false, reason: `section ${file} exceeds 6MB` };
const sha = sha256Hex(bytes);
files.set(file, {
file,
bytes: bytes.length,
sha256: sha,
json: JSON.parse(new TextDecoder().decode(bytes)),
canonicalBytes: bytes,
});
}
// Also include assets as files for budget check (assets themselves)
const budgetMap = new Map<string, { bytes: number; kind?: string; sha256: string }>();
for (const [k, v] of files) budgetMap.set(k, { bytes: v.bytes, sha256: v.sha256 });
for (const af of assetFiles)
budgetMap.set(af.file, { bytes: af.bytes, kind: af.kind, sha256: af.sha256 });
const budgetCheck = checkBudgets(budgetMap);
if (!budgetCheck.ok) return { ok: false, reason: budgetCheck.reason! };
const g4 = gate4Budgets(budgetMap, input.content.map);
if (!g4.ok) return g4;
const g5 = gate5HashPresent(budgetMap);
if (!g5.ok) return g5;
// Build manifest
const totalBytes = [...budgetMap.values()].reduce((sum, v) => sum + v.bytes, 0);
let manifest: FestivalManifest;
try {
manifest = buildManifest(input, files, totalBytes);
} catch (e) {
return { ok: false, reason: String((e as Error).message) };
}
const manifestGates = validateManifestGates(manifest);
if (!manifestGates.ok) return manifestGates;
// Derive floor from same source (must succeed and ≤16KB)
let floorDerived: ReturnType<typeof deriveEmergencyFloor>;
try {
const floorVersion = `${input.appCompatibility.minAppVersion}+${input.packageVersion}`;
floorDerived = deriveEmergencyFloor(input.content.emergency, {
floorVersion,
generatedAt: input.generatedAt,
});
} catch (e) {
return { ok: false, reason: `floor: ${String((e as Error).message)}` };
}
// Sign if requested (test seam) — otherwise signature is absent (pipeline still produces package)
let signature: BuiltPackage["signature"] = null;
let latest: BuiltPackage["latest"];
const manifestBytes = new TextEncoder().encode(canonicalJson(manifest));
const manifestSha = sha256Hex(manifestBytes);
if (opts?.signWith) {
const kp = opts.signWith;
try {
signature = signManifest(manifestBytes, kp.privateKeyPem, kp.fingerprint);
// sanity: manifestSha matches signature.manifestSha256
if (signature.manifestSha256 !== manifestSha)
return { ok: false, reason: "manifestSha mismatch after sign" };
} catch (e) {
return { ok: false, reason: `sign: ${String((e as Error).message)}` };
}
} else {
// unsigned — still include latest pointer but no signature
signature = null;
}
latest = {
edition: input.edition,
packageVersion: input.packageVersion,
manifestUrl: `/editions/${input.edition}/packages/${input.packageVersion}/manifest.json`,
generatedAt: input.generatedAt,
};
return {
ok: true,
pkg: {
manifest,
signature,
latest,
files,
assets: assetFiles,
emergencyFloor: floorDerived.floor,
floorBytes: floorDerived.bytes,
floorSha256: floorDerived.sha256,
},
};
}
/**
* Totally pure deterministic check — build twice with same input yields same manifest bytes/sha.
*/
export function isDeterministic(input: PipelineInput, kp?: KeyPair | null): boolean {
const a = buildPackage(input, kp ? { signWith: kp } : undefined);
const b = buildPackage(input, kp ? { signWith: kp } : undefined);
if (!a.ok || !b.ok) return false;
const aBytes = canonicalJson(a.pkg.manifest);
const bBytes = canonicalJson(b.pkg.manifest);
return aBytes === bBytes && sha256HexOfString(aBytes) === sha256HexOfString(bBytes);
}