The product was materialized from the template at `4dc033c` and has stayed on it through 43 template commits, so it was missing all three rounds of adapter remediation — including files it never had, such as the shared `abortable-operation` primitive and the `exact-snapshot` decoder that later fixes are written against. Taking only the newest round was not possible for that reason: the delta is coherent only as a whole. The product had not touched `src/adapters` at all since materialization, so the 140-file delta applied with a three-way merge and no conflicts. `package.json` was the single overlap and merged cleanly: the product owns `name`, the template contributed `check:adapter-inventory`, `check:remediation-ledger` and the image-resolve-signal type fixture. All 24 product-owned files — README, index.html, CI workflow, i18n catalog, home page, generated schemas, evidence scripts, component and visual snapshots — are byte-identical to `main`. `template.lock.json` now pins the synced revision and tree. Verified in this repository, not inherited from the template: six type projects, lint, nine gates (adapter inventory, remediation ledger, registries, diagnostics, realtime boundaries, architecture, browser file/storage boundaries, optional recipes, documentation), the production build, and 2,054 of 2,073 tests. The 19 failures are all in `tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template records; four suites that failed once under parallel load pass in isolation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
244 lines
8.1 KiB
TypeScript
244 lines
8.1 KiB
TypeScript
import { SERVICE_WORKER_BOUNDS } from "./service-worker.ts";
|
|
|
|
/**
|
|
* SW-05. Runtime-neutral static manifest codec.
|
|
*
|
|
* The generator, the Node build gate and the Service Worker all need the same
|
|
* answer to "is this manifest exactly the one that was generated?". This module
|
|
* owns the exact row keys, the content-type and extension allowlist, the
|
|
* root-relative URL rule and the length-prefixed canonical byte serialization.
|
|
*
|
|
* It deliberately contains no digest implementation: the generator and build
|
|
* gate hash these bytes with Node SHA-256 while the worker hashes the very same
|
|
* bytes with injected WebCrypto, so `node:crypto` never reaches worker code and
|
|
* the algorithm is never written twice.
|
|
*/
|
|
|
|
export type StaticAssetRow = Readonly<{
|
|
url: string;
|
|
sha256: string;
|
|
bytes: number;
|
|
contentType: string;
|
|
}>;
|
|
|
|
export type StaticAssetManifest = Readonly<{
|
|
schemaVersion: 1;
|
|
buildId: string;
|
|
releaseId: string;
|
|
setDigest: string;
|
|
assets: readonly StaticAssetRow[];
|
|
}>;
|
|
|
|
export const STATIC_ASSET_SET_DOMAIN = "CA_STATIC_ASSET_SET_V1";
|
|
|
|
/**
|
|
* SW-RR-03. The single authoritative extension → content type table.
|
|
*
|
|
* The build generator and this decoder must agree exactly: an extension the
|
|
* generator emits but the decoder refuses turns a correct build into a runtime
|
|
* contract failure, and the reverse admits an asset kind no build produces.
|
|
* `.json` is deliberately absent — every JSON file in a build output is a
|
|
* control document (runtime config, release manifest, schema), not a cacheable
|
|
* static asset, and the generator excludes them by name.
|
|
*/
|
|
export const CACHEABLE_ASSET_CONTENT_TYPES: Readonly<
|
|
Record<string, string>
|
|
> = Object.freeze({
|
|
".css": "text/css",
|
|
".js": "text/javascript",
|
|
".mjs": "text/javascript",
|
|
".png": "image/png",
|
|
".svg": "image/svg+xml",
|
|
".webp": "image/webp",
|
|
".woff2": "font/woff2",
|
|
});
|
|
|
|
const MANIFEST_KEYS = Object.freeze([
|
|
"assets",
|
|
"buildId",
|
|
"releaseId",
|
|
"schemaVersion",
|
|
"setDigest",
|
|
] as const);
|
|
const ASSET_ROW_KEYS = Object.freeze([
|
|
"bytes",
|
|
"contentType",
|
|
"sha256",
|
|
"url",
|
|
] as const);
|
|
const DIGEST = /^sha256:[0-9a-f]{64}$/u;
|
|
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
|
/** Root-relative, hashed, no dot segments, no query and no fragment. */
|
|
const ASSET_URL = /^\/(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+$/u;
|
|
|
|
/**
|
|
* SW-02. The one canonical asset-path predicate, shared by the build generator
|
|
* and this decoder. Sharing only the extension table left the two with
|
|
* different path grammars: the generator emitted a URL for a directory
|
|
* containing a space, an `@` or a percent-escape, and the decoder then refused
|
|
* the manifest it had just produced, failing the release build.
|
|
*/
|
|
export function isCanonicalStaticAssetUrl(url: string): boolean {
|
|
return (
|
|
typeof url === "string" &&
|
|
ASSET_URL.test(url) &&
|
|
!url.includes("/../") &&
|
|
!url.includes("/./")
|
|
);
|
|
}
|
|
|
|
export type StaticManifestDecodeFailure = Readonly<{
|
|
reason: string;
|
|
}>;
|
|
|
|
export type StaticManifestDecodeResult =
|
|
| Readonly<{ ok: true; manifest: StaticAssetManifest }>
|
|
| Readonly<{ ok: false; error: StaticManifestDecodeFailure }>;
|
|
|
|
function exactKeys(
|
|
value: unknown,
|
|
allowed: readonly string[],
|
|
): Record<string, unknown> | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
const record = value as Record<string, unknown>;
|
|
if (Object.getOwnPropertySymbols(record).length > 0) return null;
|
|
const keys = Object.keys(record).sort();
|
|
return keys.length === allowed.length &&
|
|
keys.every((key, index) => key === allowed[index])
|
|
? record
|
|
: null;
|
|
}
|
|
|
|
function extensionOf(url: string): string {
|
|
const lastSlash = url.lastIndexOf("/");
|
|
const base = url.slice(lastSlash + 1);
|
|
const dot = base.lastIndexOf(".");
|
|
return dot < 0 ? "" : base.slice(dot).toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* Decodes a generated manifest with every row rule applied. It does not verify
|
|
* `setDigest`; callers pair it with their own digest implementation over
|
|
* `canonicalStaticManifestBytes`.
|
|
*/
|
|
export function decodeStaticAssetManifest(
|
|
value: unknown,
|
|
): StaticManifestDecodeResult {
|
|
const record = exactKeys(value, MANIFEST_KEYS);
|
|
if (!record) return failure("manifest keys are not exact");
|
|
if (record.schemaVersion !== 1) return failure("schemaVersion must be 1");
|
|
if (
|
|
typeof record.buildId !== "string" ||
|
|
!IDENTITY.test(record.buildId) ||
|
|
typeof record.releaseId !== "string" ||
|
|
!IDENTITY.test(record.releaseId)
|
|
) {
|
|
return failure("buildId or releaseId is invalid");
|
|
}
|
|
if (typeof record.setDigest !== "string" || !DIGEST.test(record.setDigest)) {
|
|
return failure("setDigest is not a lower-hex sha256");
|
|
}
|
|
if (!Array.isArray(record.assets)) return failure("assets must be an array");
|
|
if (record.assets.length > SERVICE_WORKER_BOUNDS.assets) {
|
|
return failure("asset count exceeds its bound");
|
|
}
|
|
|
|
const rows: StaticAssetRow[] = [];
|
|
const seen = new Set<string>();
|
|
let totalBytes = 0;
|
|
let previousUrl: string | null = null;
|
|
for (const candidate of record.assets) {
|
|
const row = exactKeys(candidate, ASSET_ROW_KEYS);
|
|
if (!row) return failure("asset row keys are not exact");
|
|
const { url, sha256, bytes, contentType } = row;
|
|
if (typeof url !== "string" || !isCanonicalStaticAssetUrl(url)) {
|
|
return failure("asset url must be root-relative without dot segments");
|
|
}
|
|
if (seen.has(url)) return failure("asset urls must be unique");
|
|
// A sorted set makes the canonical bytes independent of directory order.
|
|
if (previousUrl !== null && url <= previousUrl) {
|
|
return failure("asset urls must be sorted");
|
|
}
|
|
if (typeof sha256 !== "string" || !DIGEST.test(sha256)) {
|
|
return failure("asset sha256 is not a lower-hex sha256");
|
|
}
|
|
if (
|
|
typeof bytes !== "number" ||
|
|
!Number.isSafeInteger(bytes) ||
|
|
bytes < 0 ||
|
|
bytes > SERVICE_WORKER_BOUNDS.singleAssetBytes
|
|
) {
|
|
return failure("asset byte length is invalid");
|
|
}
|
|
if (typeof contentType !== "string") {
|
|
return failure("asset content type is invalid");
|
|
}
|
|
const expectedContentType =
|
|
CACHEABLE_ASSET_CONTENT_TYPES[extensionOf(url)];
|
|
if (!expectedContentType || expectedContentType !== contentType) {
|
|
return failure("asset extension and content type do not match");
|
|
}
|
|
totalBytes += bytes;
|
|
if (totalBytes > SERVICE_WORKER_BOUNDS.assetSetBytes) {
|
|
return failure("asset set exceeds its byte bound");
|
|
}
|
|
seen.add(url);
|
|
previousUrl = url;
|
|
rows.push(Object.freeze({ url, sha256, bytes, contentType }));
|
|
}
|
|
|
|
return Object.freeze({
|
|
ok: true as const,
|
|
manifest: Object.freeze({
|
|
schemaVersion: 1 as const,
|
|
buildId: record.buildId,
|
|
releaseId: record.releaseId,
|
|
setDigest: record.setDigest,
|
|
assets: Object.freeze(rows),
|
|
}),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The exact bytes both the Node generator and the worker hash. A reordered
|
|
* directory listing, a renamed field or a changed byte length all change these
|
|
* bytes; nothing else does.
|
|
*/
|
|
export function canonicalStaticManifestBytes(
|
|
assets: readonly StaticAssetRow[],
|
|
): Uint8Array {
|
|
const encoder = new TextEncoder();
|
|
const parts: Uint8Array[] = [encoder.encode(`${STATIC_ASSET_SET_DOMAIN}\0`)];
|
|
for (const asset of assets) {
|
|
parts.push(lengthPrefixed(encoder, asset.url));
|
|
parts.push(lengthPrefixed(encoder, asset.sha256));
|
|
parts.push(lengthPrefixed(encoder, String(asset.bytes)));
|
|
parts.push(lengthPrefixed(encoder, asset.contentType));
|
|
}
|
|
let total = 0;
|
|
for (const part of parts) total += part.byteLength;
|
|
const bytes = new Uint8Array(total);
|
|
let offset = 0;
|
|
for (const part of parts) {
|
|
bytes.set(part, offset);
|
|
offset += part.byteLength;
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
function lengthPrefixed(encoder: TextEncoder, value: string): Uint8Array {
|
|
const encoded = encoder.encode(value);
|
|
const prefix = encoder.encode(`${encoded.byteLength}:`);
|
|
const combined = new Uint8Array(prefix.byteLength + encoded.byteLength);
|
|
combined.set(prefix, 0);
|
|
combined.set(encoded, prefix.byteLength);
|
|
return combined;
|
|
}
|
|
|
|
function failure(reason: string): StaticManifestDecodeResult {
|
|
return Object.freeze({
|
|
ok: false as const,
|
|
error: Object.freeze({ reason }),
|
|
});
|
|
}
|