chore: sync the frontend template from 4dc033c to 8157ad4
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
002ba3624e
commit
4bff9ca151
@@ -0,0 +1,243 @@
|
||||
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 }),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user