Files
tech-log-frontend/src/contracts/service-worker-static-manifest.ts
T
DongHyeonkaandClaude Opus 5 4bff9ca151 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>
2026-08-15 12:04:58 +09:00

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 }),
});
}