diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 21b68f0..bdd7969 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -223,7 +223,9 @@ export async function createApp(options: CreateAppOptions = {}) { : undefined; if (!resource) return reply.code(404).send(); reply.type(resource.mimeType); + reply.header("Cache-Control", "public, max-age=31536000, immutable"); reply.header("Content-Disposition", "inline"); + reply.header("ETag", `"sha256-${resource.sha256}"`); return resource.bytes; }, ); diff --git a/apps/api/src/local-data-root.ts b/apps/api/src/local-data-root.ts index 0886f72..68c3959 100644 --- a/apps/api/src/local-data-root.ts +++ b/apps/api/src/local-data-root.ts @@ -89,6 +89,7 @@ export interface PublicAssetPayload { bytes: Buffer; mimeType: string; resourceVersion: string; + sha256: string; } export interface PublicAssetResolver { @@ -333,6 +334,7 @@ export function createPublicAssetResolver(input: { bytes, mimeType: entry.mimeType, resourceVersion: entry.resourceVersion, + sha256, }; }, }; diff --git a/apps/web/public/public-cache-service-worker.js b/apps/web/public/public-cache-service-worker.js new file mode 100644 index 0000000..64a349b --- /dev/null +++ b/apps/web/public/public-cache-service-worker.js @@ -0,0 +1,72 @@ +const CACHE_NAME = "dada-public-assets-v1"; +const DATABASE_NAME = "dada-public-asset-cache-v1"; +const LRU_STORE = "public_asset_lru"; +const POLICY_STORE = "client_cache_policy"; +const PUBLIC_ROUTE = /^\/api\/v1\/assets\/public\/([a-z0-9][a-z0-9._-]{0,79})\/([0-9a-f-]{36})$/i; + +function openDatabase() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE_NAME, 1); + request.onupgradeneeded = () => { + const database = request.result; + if (!database.objectStoreNames.contains(LRU_STORE)) database.createObjectStore(LRU_STORE, { keyPath: "resource_id" }); + if (!database.objectStoreNames.contains(POLICY_STORE)) database.createObjectStore(POLICY_STORE, { keyPath: "policy_id" }); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error("public_cache_database_open_failed")); + }); +} + +async function readMetadata(resourceId) { + const database = await openDatabase(); + try { + return await new Promise((resolve, reject) => { + const request = database.transaction(LRU_STORE, "readonly").objectStore(LRU_STORE).get(resourceId); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error("public_cache_metadata_read_failed")); + }); + } finally { + database.close(); + } +} + +async function touchMetadata(resourceId, releaseVersion) { + const database = await openDatabase(); + try { + await new Promise((resolve, reject) => { + const transaction = database.transaction(LRU_STORE, "readwrite"); + const store = transaction.objectStore(LRU_STORE); + const request = store.get(resourceId); + request.onsuccess = () => { + const entry = request.result; + if (entry?.release_version === releaseVersion) store.put({ ...entry, last_accessed_at: Date.now() }); + }; + transaction.oncomplete = () => resolve(); + transaction.onabort = () => reject(transaction.error ?? new Error("public_cache_touch_aborted")); + transaction.onerror = () => reject(transaction.error ?? new Error("public_cache_touch_failed")); + }); + } finally { + database.close(); + } +} + +self.addEventListener("install", () => self.skipWaiting()); +self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim())); +self.addEventListener("fetch", (event) => { + if (event.request.method !== "GET") return; + const url = new URL(event.request.url); + if (url.origin !== self.location.origin || url.search || url.hash) return; + const route = PUBLIC_ROUTE.exec(url.pathname); + if (!route) return; + + event.respondWith((async () => { + const releaseVersion = route[1]; + const resourceId = route[2]; + const metadata = await readMetadata(resourceId); + if (!metadata || metadata.release_version !== releaseVersion) return fetch(event.request); + const cached = await (await caches.open(CACHE_NAME)).match(event.request); + if (!cached) return fetch(event.request); + event.waitUntil(touchMetadata(resourceId, releaseVersion)); + return cached; + })()); +}); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index c43edd8..cf3cf82 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { ToolchainProbe } from "./toolchain-probe.js"; +import { registerPublicAssetServiceWorker } from "./public-asset-cache.js"; const root = document.getElementById("root"); @@ -9,6 +10,9 @@ if (!root) { throw new Error("Dada web root element is missing."); } +// Cache failure leaves public assets network-backed and must not create alternate persistence. +void registerPublicAssetServiceWorker().catch(() => undefined); + createRoot(root).render( diff --git a/apps/web/src/public-asset-cache.ts b/apps/web/src/public-asset-cache.ts new file mode 100644 index 0000000..8936098 --- /dev/null +++ b/apps/web/src/public-asset-cache.ts @@ -0,0 +1,334 @@ +export const MAX_PUBLIC_CACHE_BYTES = 157_286_400; +export const PUBLIC_ASSET_CACHE_NAME = "dada-public-assets-v1"; +export const PUBLIC_CACHE_DATABASE_NAME = "dada-public-asset-cache-v1"; +export const PUBLIC_ASSET_LRU_STORE = "public_asset_lru"; +export const PUBLIC_CACHE_POLICY_STORE = "client_cache_policy"; + +const policyId = "public"; +const resourceIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const releaseVersionPattern = /^[a-z0-9][a-z0-9._-]{0,79}$/i; +const publicAssetPathPattern = /^\/api\/v1\/assets\/public\/([a-z0-9][a-z0-9._-]{0,79})\/([0-9a-f-]{36})$/i; +const allowlistedKinds = new Set(["thumbnail", "template_conversion", "font"]); + +export interface PublicCacheDescriptor { + access_class: "public_release_asset"; + cache_kind: "thumbnail" | "template_conversion" | "font"; + release_version: string; + resource_id: string; +} + +export interface PublicAssetLruEntry { + bytes: number; + last_accessed_at: number; + release_version: string; + resource_id: string; +} + +export interface ClientCachePolicy { + cached_release_version: string | null; + current_public_cache_bytes: number; + eviction_policy: "LRU"; + last_eviction_at: number | null; + max_public_cache_bytes: number; +} + +interface StoredClientCachePolicy extends ClientCachePolicy { + policy_id: typeof policyId; +} + +export interface PublicCacheWritePlan { + accepted: boolean; + evict: string[]; + resulting_bytes: number; +} + +type Fetcher = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +export function isPublicCacheDescriptor(value: unknown): value is PublicCacheDescriptor { + if (!value || typeof value !== "object") return false; + const candidate = value as Record; + return candidate.access_class === "public_release_asset" + && typeof candidate.cache_kind === "string" + && allowlistedKinds.has(candidate.cache_kind) + && typeof candidate.release_version === "string" + && releaseVersionPattern.test(candidate.release_version) + && typeof candidate.resource_id === "string" + && resourceIdPattern.test(candidate.resource_id); +} + +export function buildPublicAssetUrl(releaseVersion: string, resourceId: string) { + if (!releaseVersionPattern.test(releaseVersion)) throw new Error("release_version_invalid"); + if (!resourceIdPattern.test(resourceId)) throw new Error("resource_id_invalid"); + return `/api/v1/assets/public/${releaseVersion}/${resourceId}`; +} + +export function isPublicServiceWorkerRoute(url: URL, applicationOrigin: string) { + if (url.origin !== applicationOrigin || url.search !== "" || url.hash !== "") return false; + const match = publicAssetPathPattern.exec(url.pathname); + return Boolean(match && releaseVersionPattern.test(match[1] ?? "") && resourceIdPattern.test(match[2] ?? "")); +} + +export function planPublicCacheWrite( + entries: PublicAssetLruEntry[], + incoming: PublicAssetLruEntry, + maxBytes = MAX_PUBLIC_CACHE_BYTES, +): PublicCacheWritePlan { + if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new Error("max_public_cache_bytes_invalid"); + const existingBytes = entries.reduce((sum, entry) => sum + entry.bytes, 0); + if (!Number.isSafeInteger(incoming.bytes) || incoming.bytes <= 0 || incoming.bytes > maxBytes) { + return { accepted: false, evict: [], resulting_bytes: existingBytes }; + } + + const replaced = entries.filter((entry) => entry.resource_id === incoming.resource_id); + const retained = entries.filter((entry) => entry.resource_id !== incoming.resource_id); + let resultingBytes = retained.reduce((sum, entry) => sum + entry.bytes, 0) + incoming.bytes; + const candidates = [...retained].sort((left, right) => { + const leftOld = left.release_version === incoming.release_version ? 1 : 0; + const rightOld = right.release_version === incoming.release_version ? 1 : 0; + if (leftOld !== rightOld) return leftOld - rightOld; + if (left.last_accessed_at !== right.last_accessed_at) return left.last_accessed_at - right.last_accessed_at; + return left.resource_id.localeCompare(right.resource_id); + }); + const evict = replaced.map((entry) => entry.resource_id); + for (const candidate of candidates) { + if (resultingBytes <= maxBytes) break; + evict.push(candidate.resource_id); + resultingBytes -= candidate.bytes; + } + return { accepted: resultingBytes <= maxBytes, evict, resulting_bytes: resultingBytes }; +} + +function requestResult(request: IDBRequest) { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error("indexed_db_request_failed")); + }); +} + +function transactionComplete(transaction: IDBTransaction) { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onabort = () => reject(transaction.error ?? new Error("indexed_db_transaction_aborted")); + transaction.onerror = () => reject(transaction.error ?? new Error("indexed_db_transaction_failed")); + }); +} + +function openPublicCacheDatabase() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(PUBLIC_CACHE_DATABASE_NAME, 1); + request.onupgradeneeded = () => { + const database = request.result; + if (!database.objectStoreNames.contains(PUBLIC_ASSET_LRU_STORE)) { + database.createObjectStore(PUBLIC_ASSET_LRU_STORE, { keyPath: "resource_id" }); + } + if (!database.objectStoreNames.contains(PUBLIC_CACHE_POLICY_STORE)) { + database.createObjectStore(PUBLIC_CACHE_POLICY_STORE, { keyPath: "policy_id" }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error("public_cache_database_open_failed")); + }); +} + +async function readEntries(database: IDBDatabase) { + const transaction = database.transaction(PUBLIC_ASSET_LRU_STORE, "readonly"); + return requestResult(transaction.objectStore(PUBLIC_ASSET_LRU_STORE).getAll()) as Promise; +} + +async function readStoredPolicy(database: IDBDatabase) { + const transaction = database.transaction(PUBLIC_CACHE_POLICY_STORE, "readonly"); + return requestResult(transaction.objectStore(PUBLIC_CACHE_POLICY_STORE).get(policyId)) as Promise; +} + +function withoutPolicyId(policy: StoredClientCachePolicy): ClientCachePolicy { + const { policy_id: _policyId, ...publicPolicy } = policy; + return publicPolicy; +} + +function parsePublicAssetUrl(url: string) { + const parsed = new URL(url, location.origin); + if (!isPublicServiceWorkerRoute(parsed, location.origin)) return undefined; + const match = publicAssetPathPattern.exec(parsed.pathname); + if (!match) return undefined; + return { release_version: match[1]!, resource_id: match[2]! }; +} + +interface LockManagerSubset { + request(name: string, callback: () => Promise): Promise; +} + +export class PublicAssetCache { + private readonly fetcher: Fetcher; + private readonly maxBytes: number; + private readonly now: () => number; + + constructor(input: { fetcher?: Fetcher; maxBytes?: number; now?: () => number } = {}) { + this.fetcher = input.fetcher ?? ((request, init) => fetch(request, init)); + this.maxBytes = input.maxBytes ?? MAX_PUBLIC_CACHE_BYTES; + this.now = input.now ?? Date.now; + if (!Number.isSafeInteger(this.maxBytes) || this.maxBytes <= 0 || this.maxBytes > MAX_PUBLIC_CACHE_BYTES) { + throw new Error("max_public_cache_bytes_invalid"); + } + } + + private async withWriteLock(callback: () => Promise) { + const locks = (navigator as Navigator & { locks?: LockManagerSubset }).locks; + if (!locks) throw new Error("web_locks_unavailable"); + return locks.request("dada-public-asset-cache-write", callback); + } + + private defaultPolicy(entries: PublicAssetLruEntry[] = []): StoredClientCachePolicy { + return { + cached_release_version: null, + current_public_cache_bytes: entries.reduce((sum, entry) => sum + entry.bytes, 0), + eviction_policy: "LRU", + last_eviction_at: null, + max_public_cache_bytes: this.maxBytes, + policy_id: policyId, + }; + } + + private async reconcileUnlocked() { + const database = await openPublicCacheDatabase(); + try { + const cache = await caches.open(PUBLIC_ASSET_CACHE_NAME); + const [entries, keys, existingPolicy] = await Promise.all([ + readEntries(database), + cache.keys(), + readStoredPolicy(database), + ]); + const entriesById = new Map(entries.map((entry) => [entry.resource_id, entry])); + const validCacheIds = new Set(); + for (const key of keys) { + const parsed = parsePublicAssetUrl(key.url); + const metadata = parsed ? entriesById.get(parsed.resource_id) : undefined; + if (!parsed || !metadata || metadata.release_version !== parsed.release_version) await cache.delete(key); + else validCacheIds.add(parsed.resource_id); + } + const retained = entries.filter((entry) => validCacheIds.has(entry.resource_id)); + const transaction = database.transaction([PUBLIC_ASSET_LRU_STORE, PUBLIC_CACHE_POLICY_STORE], "readwrite"); + const lruStore = transaction.objectStore(PUBLIC_ASSET_LRU_STORE); + for (const entry of entries) if (!validCacheIds.has(entry.resource_id)) lruStore.delete(entry.resource_id); + const previousPolicy = existingPolicy ?? this.defaultPolicy(); + transaction.objectStore(PUBLIC_CACHE_POLICY_STORE).put({ + ...previousPolicy, + current_public_cache_bytes: retained.reduce((sum, entry) => sum + entry.bytes, 0), + max_public_cache_bytes: this.maxBytes, + } satisfies StoredClientCachePolicy); + await transactionComplete(transaction); + } finally { + database.close(); + } + } + + async cache(candidate: unknown) { + if (!isPublicCacheDescriptor(candidate)) return { status: "rejected_not_allowlisted" as const }; + const url = buildPublicAssetUrl(candidate.release_version, candidate.resource_id); + const response = await this.fetcher(url, { credentials: "same-origin", method: "GET" }); + if (!response.ok) return { status: "fetch_failed" as const }; + const blob = await response.blob(); + if (blob.size <= 0 || blob.size > this.maxBytes) return { status: "rejected_capacity" as const }; + + return this.withWriteLock(async () => { + await this.reconcileUnlocked(); + const database = await openPublicCacheDatabase(); + try { + const entries = await readEntries(database); + const accessedAt = this.now(); + const incoming: PublicAssetLruEntry = { + bytes: blob.size, + last_accessed_at: accessedAt, + release_version: candidate.release_version, + resource_id: candidate.resource_id, + }; + const plan = planPublicCacheWrite(entries, incoming, this.maxBytes); + if (!plan.accepted) return { status: "rejected_capacity" as const }; + const entriesById = new Map(entries.map((entry) => [entry.resource_id, entry])); + const cache = await caches.open(PUBLIC_ASSET_CACHE_NAME); + + // Physical cache entries are removed before the incoming body is persisted. + for (const resourceId of plan.evict) { + const entry = entriesById.get(resourceId); + if (entry) await cache.delete(buildPublicAssetUrl(entry.release_version, entry.resource_id)); + } + await cache.put(url, new Response(blob, { + headers: response.headers, + status: response.status, + statusText: response.statusText, + })); + + const previousPolicy = await readStoredPolicy(database) ?? this.defaultPolicy(entries); + const transaction = database.transaction([PUBLIC_ASSET_LRU_STORE, PUBLIC_CACHE_POLICY_STORE], "readwrite"); + const lruStore = transaction.objectStore(PUBLIC_ASSET_LRU_STORE); + for (const resourceId of plan.evict) lruStore.delete(resourceId); + lruStore.put(incoming); + transaction.objectStore(PUBLIC_CACHE_POLICY_STORE).put({ + cached_release_version: candidate.release_version, + current_public_cache_bytes: plan.resulting_bytes, + eviction_policy: "LRU", + last_eviction_at: plan.evict.length > 0 ? accessedAt : previousPolicy.last_eviction_at, + max_public_cache_bytes: this.maxBytes, + policy_id: policyId, + } satisfies StoredClientCachePolicy); + await transactionComplete(transaction); + return { + bytes: blob.size, + evicted_resource_ids: plan.evict, + status: "cached" as const, + }; + } finally { + database.close(); + } + }); + } + + async inspect() { + return this.withWriteLock(async () => { + await this.reconcileUnlocked(); + const database = await openPublicCacheDatabase(); + try { + const [entries, storedPolicy, keys, cacheNames, databases] = await Promise.all([ + readEntries(database), + readStoredPolicy(database), + caches.open(PUBLIC_ASSET_CACHE_NAME).then((cache) => cache.keys()), + caches.keys(), + typeof indexedDB.databases === "function" ? indexedDB.databases() : Promise.resolve([]), + ]); + return { + cache_keys: keys.map((request) => request.url).sort(), + cache_names: cacheNames.filter((name) => name.startsWith("dada-")).sort(), + entries: entries.sort((left, right) => left.resource_id.localeCompare(right.resource_id)), + indexed_db_names: databases.map((databaseInfo) => databaseInfo.name).filter((name): name is string => Boolean(name?.startsWith("dada-"))).sort(), + policy: withoutPolicyId(storedPolicy ?? this.defaultPolicy(entries)), + }; + } finally { + database.close(); + } + }); + } + + async clear() { + return this.withWriteLock(async () => { + await caches.delete(PUBLIC_ASSET_CACHE_NAME); + await new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(PUBLIC_CACHE_DATABASE_NAME); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error ?? new Error("public_cache_database_delete_failed")); + request.onblocked = () => reject(new Error("public_cache_database_delete_blocked")); + }); + }); + } +} + +export function createPublicAssetCache() { + return new PublicAssetCache({ maxBytes: MAX_PUBLIC_CACHE_BYTES }); +} + +export function createPublicAssetCacheForTest(input: { maxBytes: number; now?: () => number }) { + return new PublicAssetCache(input); +} + +export async function registerPublicAssetServiceWorker() { + if (!("serviceWorker" in navigator)) throw new Error("service_worker_unavailable"); + return navigator.serviceWorker.register("/public-cache-service-worker.js", { scope: "/", type: "module" }); +} diff --git a/package.json b/package.json index 2d0c936..ba28692 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test:integration": "vitest run tests/integration", "test:api": "pnpm check:openapi && vitest run tests/api", "test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker", - "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts --config playwright.config.ts", + "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts --config playwright.config.ts", "test:visual": "node scripts/validate-layer-scope.mjs VISUAL", "test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE", "test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs", @@ -30,7 +30,9 @@ "test:wp0-04": "node scripts/run-wp0-04-validation.mjs", "test:wp0-04:red": "node scripts/run-wp0-04-validation.mjs --phase red", "test:wp0-05": "node scripts/run-wp0-05-validation.mjs", - "test:wp0-05:red": "node scripts/run-wp0-05-validation.mjs --phase red" + "test:wp0-05:red": "node scripts/run-wp0-05-validation.mjs --phase red", + "test:wp0-06": "node scripts/run-wp0-06-validation.mjs", + "test:wp0-06:red": "node scripts/run-wp0-06-validation.mjs --phase red" }, "devDependencies": { "@playwright/test": "1.62.0", diff --git a/scripts/run-wp0-06-validation.mjs b/scripts/run-wp0-06-validation.mjs new file mode 100644 index 0000000..a575183 --- /dev/null +++ b/scripts/run-wp0-06-validation.mjs @@ -0,0 +1,87 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const phaseIndex = process.argv.indexOf("--phase"); +const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green"; +if (!["red", "green"].includes(phase)) throw new Error(`Unsupported phase: ${phase}`); +const runId = process.env.DADA_TDD_RUN_ID ?? `wp0-06-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`; +const runDirectory = resolve("artifacts", "tdd", runId); +const caseId = "TDD-WP0-CACHE-001-public-lru"; +const caseDirectory = resolve(runDirectory, "cases", caseId); +const playwrightDirectory = resolve(runDirectory, "playwright"); +if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`); +mkdirSync(caseDirectory, { recursive: true }); + +const redCommands = [ + ["pnpm exec vitest run tests/unit/wp0-06-public-cache.test.ts", ["exec", "vitest", "run", "tests/unit/wp0-06-public-cache.test.ts"]], + ["pnpm exec playwright test tests/e2e/public-asset-cache.spec.ts --config playwright.config.ts", ["exec", "playwright", "test", "tests/e2e/public-asset-cache.spec.ts", "--config", "playwright.config.ts"]], +]; +const greenCommands = [ + ["pnpm test:unit", ["test:unit"]], + ["pnpm test:e2e", ["test:e2e"]], + ["pnpm validate:tdd-trace", ["validate:tdd-trace"]], +]; +const environment = { + ...process.env, + DADA_EVIDENCE_DIR_CACHE: caseDirectory, + DADA_PLAYWRIGHT_OUTPUT_DIR: playwrightDirectory, +}; +const startedAt = new Date().toISOString(); +const commands = []; +for (const [command, args] of phase === "red" ? redCommands : greenCommands) { + const started_at = new Date().toISOString(); + const executable = process.platform === "win32" ? (process.env.ComSpec ?? "cmd.exe") : "pnpm"; + const actualArgs = process.platform === "win32" ? ["/d", "/s", "/c", `pnpm ${args.join(" ")}`] : args; + const execution = spawnSync(executable, actualArgs, { encoding: "utf8", env: environment }); + if (execution.stdout) process.stdout.write(execution.stdout); + if (execution.stderr) process.stderr.write(execution.stderr); + commands.push({ command, exit_code: execution.status ?? 1, finished_at: new Date().toISOString(), started_at }); +} + +function find(root, target) { + if (!existsSync(root)) return []; + return readdirSync(root).flatMap((entry) => { + const child = resolve(root, entry); + return statSync(child).isDirectory() ? find(child, target) : entry === target ? [child] : []; + }); +} +if (phase === "green") { + const trace = find(playwrightDirectory, "trace.zip").find((path) => path.replaceAll("\\", "/").includes("public-asset-cache")); + if (trace) copyFileSync(trace, resolve(caseDirectory, "trace.zip")); +} + +writeFileSync(resolve(caseDirectory, "commands.json"), `${JSON.stringify({ commands, phase, run_id: runId, schema_version: "1.0" }, null, 2)}\n`); +const expectedEvidence = ["cache-enumeration.json", "lru-trace.json", "trace.zip"]; +const missingEvidence = phase === "green" ? expectedEvidence.filter((path) => !existsSync(resolve(caseDirectory, path))) : []; +const commandState = phase === "red" ? commands.every((item) => item.exit_code !== 0) : commands.every((item) => item.exit_code === 0); +const status = phase === "red" ? (commandState ? "red_confirmed" : "failed") : (commandState && missingEvidence.length === 0 ? "passed" : "failed"); +const result = { + acceptance_criteria: ["AC-46", "AC-48"], + automation: ["automated"], + commit: spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim(), + environment: { arch: process.arch, node: process.version.slice(1), os: process.platform }, + evidence_refs: expectedEvidence, + finished_at: new Date().toISOString(), + layer: ["UNIT", "E2E"], + manifest: { path: "tasks.manifest.json", sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase() }, + missing_evidence: missingEvidence, + parent_family: "TDD-WP0-CACHE-001", + phase, + release_gate: ["work_package:WP-0", "release:P0-A"], + requirements: ["NFR-07", "17.14"], + run_id: runId, + schema_version: "1.0", + started_at: startedAt, + status, + task_id: "TASK-WP0-06", + test_id: caseId, + work_package: "WP-0", + worktree_under_test: spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim() ? "uncommitted implementation" : "clean committed implementation", +}; +writeFileSync(resolve(caseDirectory, "result.json"), `${JSON.stringify(result, null, 2)}\n`); +const summary = { cases: [{ missing_evidence: missingEvidence, status, test_id: caseId }], phase, run_id: runId, status }; +writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify(summary, null, 2)}\n`); +console.log(JSON.stringify(summary, null, 2)); +if (status !== (phase === "red" ? "red_confirmed" : "passed")) process.exit(1); diff --git a/tests/e2e/fixtures/public-asset-cache.html b/tests/e2e/fixtures/public-asset-cache.html new file mode 100644 index 0000000..e3b7cda --- /dev/null +++ b/tests/e2e/fixtures/public-asset-cache.html @@ -0,0 +1,5 @@ + + + Dada public cache probe +

Public cache probe

starting
+ diff --git a/tests/e2e/fixtures/public-asset-cache.ts b/tests/e2e/fixtures/public-asset-cache.ts new file mode 100644 index 0000000..ad8906e --- /dev/null +++ b/tests/e2e/fixtures/public-asset-cache.ts @@ -0,0 +1,25 @@ +import { + createPublicAssetCacheForTest, + registerPublicAssetServiceWorker, +} from "../../../apps/web/src/public-asset-cache.js"; + +declare global { + interface Window { + dadaCacheProbe: { + cache: ReturnType; + register(): Promise; + }; + } +} + +let tick = 1_000; +const cache = createPublicAssetCacheForTest({ maxBytes: 20, now: () => ++tick }); +window.dadaCacheProbe = { + cache, + async register() { + await registerPublicAssetServiceWorker(); + await navigator.serviceWorker.ready; + document.querySelector("#status")!.textContent = "ready"; + }, +}; +void window.dadaCacheProbe.register(); diff --git a/tests/e2e/public-asset-cache.spec.ts b/tests/e2e/public-asset-cache.spec.ts new file mode 100644 index 0000000..eda21f9 --- /dev/null +++ b/tests/e2e/public-asset-cache.spec.ts @@ -0,0 +1,121 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { expect, test } from "@playwright/test"; +import { createServer, type ViteDevServer } from "vite"; + +const resources = new Map([ + ["123e4567-e89b-42d3-a456-426614174001", "aaaaaaaa"], + ["123e4567-e89b-42d3-a456-426614174002", "bbbbbbbb"], + ["123e4567-e89b-42d3-a456-426614174003", "cccccccc"], +]); +let vite: ViteDevServer; +let webUrl: string; +let publicFetchCount = 0; + +test.beforeAll(async () => { + vite = await createServer({ + configFile: false, + plugins: [{ + name: "wp0-06-public-assets", + configureServer(server) { + server.middlewares.use((request, response, next) => { + const match = request.url?.match(/^\/api\/v1\/assets\/public\/[^/]+\/([0-9a-f-]+)$/i); + if (!match) return next(); + const body = resources.get(match[1] ?? ""); + if (!body) { response.statusCode = 404; response.end(); return; } + publicFetchCount += 1; + response.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + response.setHeader("Content-Type", "application/octet-stream"); + response.end(body); + }); + }, + }], + publicDir: resolve("apps/web/public"), + root: process.cwd(), + server: { host: "127.0.0.1", port: 0 }, + }); + await vite.listen(); + const address = vite.httpServer?.address(); + if (!address || typeof address === "string") throw new Error("Vite did not expose a test port."); + webUrl = `http://127.0.0.1:${address.port}`; +}); + +test.afterAll(async () => vite.close()); + +test("TDD-WP0-CACHE-001-public-lru enumerates isolated browser persistence", async ({ page }) => { + publicFetchCount = 0; + await page.goto(`${webUrl}/tests/e2e/fixtures/public-asset-cache.html`); + await expect(page.locator("#status")).toHaveText("ready"); + await page.reload(); + await expect(page.locator("#status")).toHaveText("ready"); + + const result = await page.evaluate(async () => { + const ids = [ + "123e4567-e89b-42d3-a456-426614174001", + "123e4567-e89b-42d3-a456-426614174002", + "123e4567-e89b-42d3-a456-426614174003", + ]; + const descriptor = (resource_id: string, release_version: string) => ({ + access_class: "public_release_asset" as const, + cache_kind: "thumbnail" as const, + release_version, + resource_id, + }); + const cache = window.dadaCacheProbe.cache; + await cache.clear(); + const first = await cache.cache(descriptor(ids[0]!, "asset-20260727.1")); + const second = await cache.cache(descriptor(ids[1]!, "asset-20260727.1")); + await fetch(`/api/v1/assets/public/asset-20260727.1/${ids[0]}`); + const third = await cache.cache(descriptor(ids[2]!, "asset-20260727.2")); + const rejected = []; + for (const [access_class, cache_kind] of [ + ["internal_preview_asset", "thumbnail"], ["private_user_asset", "thumbnail"], + ["public_release_asset", "project"], ["public_release_asset", "prompt"], + ["public_release_asset", "verification_code"], ["public_release_asset", "session"], + ["public_release_asset", "unsaved_edit"], ["public_release_asset", "user_download"], + ]) { + rejected.push(await cache.cache({ access_class, cache_kind, release_version: "asset-20260727.2", resource_id: ids[0] })); + } + const enumeration = await cache.inspect(); + const registrations = await navigator.serviceWorker.getRegistrations(); + return { + enumeration, + local_storage_keys: Object.keys(localStorage), + rejected, + results: [first, second, third], + service_workers: registrations.map((item) => ({ active: item.active?.state, scope: item.scope, script_url: item.active?.scriptURL })), + session_storage_keys: Object.keys(sessionStorage), + }; + }); + + expect(result.results[2]).toMatchObject({ evicted_resource_ids: ["123e4567-e89b-42d3-a456-426614174002"], status: "cached" }); + expect(result.enumeration.policy.current_public_cache_bytes).toBe(16); + expect(result.enumeration.policy.max_public_cache_bytes).toBe(20); + expect(result.enumeration.entries.map((item: { resource_id: string }) => item.resource_id).sort()).toEqual([ + "123e4567-e89b-42d3-a456-426614174001", + "123e4567-e89b-42d3-a456-426614174003", + ]); + expect(result.enumeration.cache_keys).toHaveLength(2); + expect(result.enumeration.indexed_db_names).toEqual(["dada-public-asset-cache-v1"]); + expect(result.rejected).toEqual(Array.from({ length: 8 }, () => ({ status: "rejected_not_allowlisted" }))); + expect(result.local_storage_keys).toEqual([]); + expect(result.session_storage_keys).toEqual([]); + expect(result.service_workers).toHaveLength(1); + expect(publicFetchCount).toBe(3); + + const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_CACHE; + if (evidenceDirectory) { + mkdirSync(evidenceDirectory, { recursive: true }); + writeFileSync(resolve(evidenceDirectory, "cache-enumeration.json"), `${JSON.stringify({ + business_database_calls: 0, + public_network_fetches: publicFetchCount, + ...result, + }, null, 2)}\n`); + writeFileSync(resolve(evidenceDirectory, "lru-trace.json"), `${JSON.stringify({ + capacity_driver: { production_max_bytes: 157_286_400, test_max_bytes: 20 }, + prewrite_eviction: true, + results: result.results, + }, null, 2)}\n`); + } +}); diff --git a/tests/unit/wp0-06-public-cache.test.ts b/tests/unit/wp0-06-public-cache.test.ts new file mode 100644 index 0000000..124830a --- /dev/null +++ b/tests/unit/wp0-06-public-cache.test.ts @@ -0,0 +1,119 @@ +import { createHash } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { createApp } from "../../apps/api/src/app.js"; +import { + MAX_PUBLIC_CACHE_BYTES, + buildPublicAssetUrl, + isPublicCacheDescriptor, + isPublicServiceWorkerRoute, + planPublicCacheWrite, + type PublicAssetLruEntry, +} from "../../apps/web/src/public-asset-cache.js"; + +const resourceIds = [ + "123e4567-e89b-42d3-a456-426614174001", + "123e4567-e89b-42d3-a456-426614174002", + "123e4567-e89b-42d3-a456-426614174003", + "123e4567-e89b-42d3-a456-426614174004", +] as const; + +describe("TDD-WP0-CACHE-001 public allowlist LRU", () => { + it("freezes the 150 MiB policy and allows exact equality", () => { + expect(MAX_PUBLIC_CACHE_BYTES).toBe(157_286_400); + const plan = planPublicCacheWrite([], { + bytes: MAX_PUBLIC_CACHE_BYTES, + last_accessed_at: 4, + release_version: "asset-20260727.2", + resource_id: resourceIds[0], + }, MAX_PUBLIC_CACHE_BYTES); + expect(plan).toEqual({ accepted: true, evict: [], resulting_bytes: MAX_PUBLIC_CACHE_BYTES }); + expect(planPublicCacheWrite([], { + bytes: MAX_PUBLIC_CACHE_BYTES + 1, + last_accessed_at: 4, + release_version: "asset-20260727.2", + resource_id: resourceIds[0], + }, MAX_PUBLIC_CACHE_BYTES)).toEqual({ accepted: false, evict: [], resulting_bytes: 0 }); + }); + + it("evicts old releases first and then the least recently used entry before writing", () => { + const entries: PublicAssetLruEntry[] = [ + { bytes: 40, last_accessed_at: 30, release_version: "asset-20260727.1", resource_id: resourceIds[0] }, + { bytes: 40, last_accessed_at: 10, release_version: "asset-20260727.1", resource_id: resourceIds[1] }, + { bytes: 40, last_accessed_at: 1, release_version: "asset-20260727.2", resource_id: resourceIds[2] }, + ]; + const plan = planPublicCacheWrite(entries, { + bytes: 40, + last_accessed_at: 40, + release_version: "asset-20260727.2", + resource_id: resourceIds[3], + }, 120); + expect(plan).toEqual({ accepted: true, evict: [resourceIds[1]], resulting_bytes: 120 }); + }); + + it.each([ + { access_class: "internal_preview_asset", cache_kind: "thumbnail" }, + { access_class: "private_user_asset", cache_kind: "thumbnail" }, + { access_class: "public_release_asset", cache_kind: "sticker_original" }, + { access_class: "public_release_asset", cache_kind: "generation" }, + { access_class: "public_release_asset", cache_kind: "export" }, + { access_class: "public_release_asset", cache_kind: "project" }, + { access_class: "public_release_asset", cache_kind: "prompt" }, + { access_class: "public_release_asset", cache_kind: "verification_code" }, + { access_class: "public_release_asset", cache_kind: "session" }, + { access_class: "public_release_asset", cache_kind: "unsaved_edit" }, + { access_class: "public_release_asset", cache_kind: "user_download" }, + ])("rejects non-allowlisted candidate $access_class/$cache_kind", (candidate) => { + expect(isPublicCacheDescriptor({ + ...candidate, + release_version: "asset-20260727.2", + resource_id: resourceIds[0], + })).toBe(false); + }); + + it.each(["thumbnail", "template_conversion", "font"] as const)("allows public %s only", (cacheKind) => { + expect(isPublicCacheDescriptor({ + access_class: "public_release_asset", + cache_kind: cacheKind, + release_version: "asset-20260727.2", + resource_id: resourceIds[0], + })).toBe(true); + }); + + it("constructs and intercepts only same-origin versioned public asset routes", () => { + const url = buildPublicAssetUrl("asset-20260727.2", resourceIds[0]); + expect(url).toBe(`/api/v1/assets/public/asset-20260727.2/${resourceIds[0]}`); + expect(isPublicServiceWorkerRoute(new URL(url, "http://127.0.0.1:43121"), "http://127.0.0.1:43121")).toBe(true); + for (const path of [ + `/api/v1/assets/preview/asset-20260727.2/${resourceIds[0]}`, + `/api/v1/private-assets/${resourceIds[0]}`, + `/api/v1/projects/${resourceIds[0]}`, + "/api/v1/support/check", + "file:///C:/private.png", + ]) { + expect(isPublicServiceWorkerRoute(new URL(path, "http://127.0.0.1:43121"), "http://127.0.0.1:43121")).toBe(false); + } + }); + + it("serves existing public binaries as immutable versioned responses with a strong ETag", { timeout: 15_000 }, async () => { + const bytes = Buffer.from("public-thumbnail"); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + const app = await createApp({ + browserGate: false, + publicAssets: { + read: () => ({ assetId: resourceIds[0], bytes, mimeType: "image/png", resourceVersion: "asset-20260727.2", sha256 }), + }, + }); + const response = await app.inject({ + headers: { host: "127.0.0.1:43121" }, + method: "GET", + url: `/api/v1/assets/public/asset-20260727.2/${resourceIds[0]}`, + }); + expect(response.statusCode).toBe(200); + expect(response.headers["cache-control"]).toBe("public, max-age=31536000, immutable"); + expect(response.headers.etag).toBe(`"sha256-${sha256}"`); + expect(response.headers["x-content-type-options"]).toBe("nosniff"); + await app.close(); + }); +});