@mpgd/cli 0.28.1 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1087 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, realpathSync, statSync, } from 'node:fs';
3
+ import { dirname, join, posix, relative, resolve } from 'node:path';
4
+ import { decodeHTMLAttribute } from 'entities';
5
+ import { cloudflarePagesPathMatches, evaluateCloudflarePagesHeader, normalizeHeaderDirectiveValue, parseCloudflarePagesHeaders, parseCloudflarePagesRedirects, } from './cloudflare-pages-static.js';
6
+ import { createMicrosoftStorePwaRevisionFromFiles, createMicrosoftStorePwaServiceWorker, listPrecacheUrls, readMicrosoftStorePwaReleaseEvidence, } from './microsoft-store-pwa-release.js';
7
+ /**
8
+ * Read-only verification that a hosted Cloudflare Pages deployment directory
9
+ * serves exactly the files of an already-verified PWA artifact, with routing
10
+ * and cache headers that keep the PWA entry points reachable and fresh.
11
+ *
12
+ * The verifier knows nothing about individual games or workspace layouts: it
13
+ * compares an explicit source artifact directory against an explicit
14
+ * deployment directory under one of the reviewed Pages host profiles.
15
+ */
16
+ export const hostedPwaDeploymentHosts = ['cloudflare-pages'];
17
+ export const cloudflarePagesDeploymentProfiles = ['api-only', 'api-canonical-index'];
18
+ /** Host files that Pages adds around the game artifact. */
19
+ const cloudflarePagesHostFileAllowlist = new Set([
20
+ '_worker.js',
21
+ '_headers',
22
+ '_redirects',
23
+ '_routes.json',
24
+ 'wrangler.jsonc',
25
+ 'legal-site.json',
26
+ ]);
27
+ /** SVG presentation attributes whose url() values load external resources. */
28
+ const svgUrlAttributeNames = new Set([
29
+ 'fill',
30
+ 'stroke',
31
+ 'filter',
32
+ 'clip-path',
33
+ 'mask',
34
+ 'marker-start',
35
+ 'marker-mid',
36
+ 'marker-end',
37
+ ]);
38
+ /** Control artifacts Pages consumes instead of serving to browsers. */
39
+ const pagesControlArtifactNames = new Set([
40
+ '_worker.js',
41
+ '_headers',
42
+ '_redirects',
43
+ '_routes.json',
44
+ 'wrangler.jsonc',
45
+ ]);
46
+ /** Request paths a redirect may never cover, matched as patterns. */
47
+ const protectedPwaMetadataRequestPaths = [
48
+ '/',
49
+ '/index.html',
50
+ '/manifest.webmanifest',
51
+ '/service-worker.js',
52
+ '/pwa-release.json',
53
+ ];
54
+ const freshCacheControl = 'public, max-age=0, must-revalidate';
55
+ const noStoreCacheControl = 'no-store, must-revalidate';
56
+ const immutableCacheControl = 'public, max-age=31536000, immutable';
57
+ const freshCacheControlFiles = new Set([
58
+ '/manifest.webmanifest',
59
+ '/pwa-release.json',
60
+ '/mpgd-effective-target.json',
61
+ '/mpgd-icon-manifest.json',
62
+ '/mpgd-icon-precache.json',
63
+ '/legal-site.json',
64
+ '/game-services-routing.json',
65
+ ]);
66
+ const noStoreCacheControlFiles = new Set(['/service-worker.js']);
67
+ const immutableCacheControlDirectories = new Set(['assets']);
68
+ const noStoreCacheControlDirectories = new Set(['icons']);
69
+ /** Precache URLs the reviewed PWA contract always includes. */
70
+ const requiredPrecacheUrls = new Set([
71
+ './index.html',
72
+ './manifest.webmanifest',
73
+ './pwa-release.json',
74
+ ]);
75
+ export function isSupportedHostedPwaProfile(host, profile) {
76
+ if (!hostedPwaDeploymentHosts.includes(host)) {
77
+ return false;
78
+ }
79
+ return cloudflarePagesDeploymentProfiles.includes(profile);
80
+ }
81
+ export function verifyHostedPwaDeployment(input) {
82
+ if (!hostedPwaDeploymentHosts.includes(input.host)) {
83
+ throw new Error(`Unsupported hosted PWA deployment host: ${input.host} `
84
+ + `(supported: ${hostedPwaDeploymentHosts.join(', ')}).`);
85
+ }
86
+ if (!cloudflarePagesDeploymentProfiles.includes(input.profile)) {
87
+ throw new Error(`Unsupported Cloudflare Pages host profile: ${input.profile} `
88
+ + `(supported: ${cloudflarePagesDeploymentProfiles.join(', ')}).`);
89
+ }
90
+ const sourceRoot = resolveArtifactDirectory(input.sourceArtifactRoot, 'source PWA artifact');
91
+ const deploymentRoot = resolveArtifactDirectory(input.deploymentRoot, 'deployment');
92
+ if (sourceRoot === deploymentRoot) {
93
+ throw new Error('The source artifact and the deployment directory must be different directories; '
94
+ + 'a self-comparison cannot validate the deployment merge.');
95
+ }
96
+ const sourceFiles = listArtifactFilesStrict(sourceRoot, 'source PWA artifact');
97
+ const deploymentFiles = listArtifactFilesStrict(deploymentRoot, 'deployment');
98
+ const evidence = verifySourceArtifactSelfConsistency(sourceRoot, sourceFiles);
99
+ verifyDeploymentGameFiles(sourceRoot, sourceFiles, deploymentRoot, deploymentFiles);
100
+ const hostFiles = verifyDeploymentFileClassification(deploymentFiles, sourceFiles, deploymentRoot, input.profile);
101
+ const referencedDirectoryUrls = verifyIndexReferences(deploymentRoot, deploymentFiles);
102
+ const workerRoutes = verifyCloudflarePagesRoutes(deploymentRoot, input.profile);
103
+ assertNoSourceRoutesShadowed(workerRoutes, sourceFiles, input.profile);
104
+ assertNoLegalRoutesShadowed(workerRoutes, [...readLegalSitePages(deploymentRoot)], input.profile);
105
+ const legalPages = [...readLegalSitePages(deploymentRoot)];
106
+ const declaredLegalDirectories = new Set(legalPages.map((page) => page.slice(0, -'index.html'.length)));
107
+ verifyCloudflarePagesHeaders(deploymentRoot, sourceFiles, deploymentFiles, declaredLegalDirectories, workerRoutes);
108
+ const deploymentLegalPaths = [
109
+ ...[...readLegalSitePages(deploymentRoot)].flatMap((page) => {
110
+ const directoryUrl = `/${page.slice(0, -'index.html'.length)}`;
111
+ return [directoryUrl, `/${page}`];
112
+ }),
113
+ ...(existsSync(join(deploymentRoot, 'legal-site.json'))
114
+ ? ['/legal-site.json']
115
+ : []),
116
+ ];
117
+ verifyCloudflarePagesRedirects(deploymentRoot, sourceFiles, deploymentLegalPaths, referencedDirectoryUrls);
118
+ return {
119
+ host: input.host,
120
+ profile: input.profile,
121
+ sourceArtifactRoot: sourceRoot,
122
+ deploymentRoot,
123
+ appVersion: evidence.appVersion,
124
+ buildId: evidence.buildId,
125
+ revision: evidence.revision,
126
+ verifiedGameFileCount: sourceFiles.length,
127
+ hostFileCount: hostFiles.length,
128
+ workerRoutes,
129
+ };
130
+ }
131
+ function resolveArtifactDirectory(path, label) {
132
+ const resolved = resolve(path);
133
+ if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
134
+ throw new Error(`The ${label} directory does not exist: ${resolved}`);
135
+ }
136
+ return realpathSync(resolved);
137
+ }
138
+ /**
139
+ * Enumerate every file under a root with strict safety: symbolic links,
140
+ * non-regular files, and non-portable names are rejected so the deployment
141
+ * comparison cannot be redirected outside the inspected directory.
142
+ */
143
+ function listArtifactFilesStrict(root, label) {
144
+ const files = [];
145
+ const pendingDirectories = [root];
146
+ while (pendingDirectories.length > 0) {
147
+ const directory = pendingDirectories.pop();
148
+ if (directory === undefined) {
149
+ throw new Error(`${label} traversal lost its directory.`);
150
+ }
151
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
152
+ const absolute = join(directory, entry.name);
153
+ if (entry.isSymbolicLink()) {
154
+ throw new Error(`The ${label} must not contain symbolic links: ${absolute}`);
155
+ }
156
+ if (/[\\\t\n\r]/u.test(entry.name)) {
157
+ throw new Error(`The ${label} contains a non-portable file name with a backslash or `
158
+ + `URL-stripped control character: ${JSON.stringify(entry.name)}`);
159
+ }
160
+ if (entry.isDirectory()) {
161
+ pendingDirectories.push(absolute);
162
+ }
163
+ else if (entry.isFile()) {
164
+ const relativePath = posix.normalize(relativePortablePath(root, absolute));
165
+ if (relativePath.startsWith('../')
166
+ || relativePath === '..'
167
+ || relativePath.includes('/../')
168
+ || posix.isAbsolute(relativePath)) {
169
+ throw new Error(`The ${label} file escapes its root: ${absolute}`);
170
+ }
171
+ // Digests stream from disk; bytes are retained only for the files
172
+ // whose content is parsed later, so large game assets are not held
173
+ // in memory during verification.
174
+ const digest = createHash('sha256');
175
+ const handle = openSync(absolute, 'r');
176
+ const chunk = Buffer.allocUnsafe(1024 * 1024);
177
+ try {
178
+ let read = readSync(handle, chunk, 0, chunk.length, null);
179
+ while (read > 0) {
180
+ digest.update(chunk.subarray(0, read));
181
+ read = readSync(handle, chunk, 0, chunk.length, null);
182
+ }
183
+ }
184
+ finally {
185
+ closeSync(handle);
186
+ }
187
+ files.push({
188
+ path: relativePath,
189
+ sha256: digest.digest('hex'),
190
+ });
191
+ }
192
+ else {
193
+ throw new Error(`The ${label} contains an entry that is neither a directory nor a regular `
194
+ + `file: ${absolute}`);
195
+ }
196
+ }
197
+ }
198
+ return files.sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
199
+ }
200
+ function relativePortablePath(root, absolute) {
201
+ const rootReal = realpathSync(root);
202
+ const parentReal = realpathSync(dirname(absolute));
203
+ return relative(rootReal, join(parentReal, absolute.slice(dirname(absolute).length)))
204
+ .split('\\')
205
+ .join('/');
206
+ }
207
+ function verifySourceArtifactSelfConsistency(sourceRoot, sourceFiles) {
208
+ const evidencePath = `${sourceRoot}/pwa-release.json`;
209
+ if (!existsSync(evidencePath)) {
210
+ throw new Error('The source PWA artifact is missing pwa-release.json.');
211
+ }
212
+ const evidence = readMicrosoftStorePwaReleaseEvidence(evidencePath);
213
+ for (const reserved of cloudflarePagesHostFileAllowlist) {
214
+ if (sourceFiles.some((file) => file.path === reserved)) {
215
+ throw new Error(`The source PWA artifact contains the Pages control file ${reserved}; `
216
+ + 'control files belong to the host deployment, not the game artifact.');
217
+ }
218
+ }
219
+ for (const required of ['index.html', 'manifest.webmanifest', 'service-worker.js']) {
220
+ if (!sourceFiles.some((file) => file.path === required)) {
221
+ throw new Error(`The source PWA artifact is missing ${required}.`);
222
+ }
223
+ }
224
+ const manifest = JSON.parse(readFileSync(`${sourceRoot}/manifest.webmanifest`, 'utf8'));
225
+ if (manifest.id !== evidence.pwaId) {
226
+ throw new Error('The source PWA artifact manifest application id does not match the '
227
+ + 'release evidence; the manifest and pwa-release.json must identify '
228
+ + 'the same application.');
229
+ }
230
+ const enumeratedUrls = [
231
+ ...listPrecacheUrls(sourceRoot),
232
+ './pwa-release.json',
233
+ ].sort();
234
+ const recordedUrls = [...evidence.precacheUrls].sort();
235
+ if (JSON.stringify(enumeratedUrls) !== JSON.stringify(recordedUrls)) {
236
+ throw new Error('The source PWA artifact precache contract is inconsistent: the recorded '
237
+ + 'pwa-release.json URLs do not match the files actually present in the '
238
+ + 'artifact, so the service worker precache list cannot be trusted.');
239
+ }
240
+ for (const required of requiredPrecacheUrls) {
241
+ if (!evidence.precacheUrls.includes(required)) {
242
+ throw new Error(`The source PWA artifact precache contract omits the required URL ${required}.`);
243
+ }
244
+ }
245
+ const sourceWorker = sourceFiles.find((file) => file.path === 'service-worker.js');
246
+ if (sourceWorker !== undefined
247
+ && readFileSync(join(sourceRoot, 'service-worker.js'), 'utf8')
248
+ !== createMicrosoftStorePwaServiceWorker(evidence)) {
249
+ throw new Error('The source PWA artifact service worker does not match the release '
250
+ + 'evidence; it must be the deterministic worker generated for the '
251
+ + 'recorded precache contract so its cache identity and precache list '
252
+ + 'cannot drift from pwa-release.json.');
253
+ }
254
+ const recomputedRevision = createMicrosoftStorePwaRevisionFromFiles({
255
+ appVersion: evidence.appVersion,
256
+ buildId: evidence.buildId,
257
+ sourceGitSha: evidence.sourceGitSha,
258
+ kitGitSha: evidence.kitGitSha,
259
+ precacheUrls: evidence.precacheUrls,
260
+ artifactRoot: sourceRoot,
261
+ });
262
+ if (recomputedRevision !== evidence.revision) {
263
+ throw new Error('The source PWA artifact is not self-consistent: the recomputed release revision '
264
+ + `${recomputedRevision} does not match the recorded ${evidence.revision}.`);
265
+ }
266
+ return evidence;
267
+ }
268
+ function verifyDeploymentGameFiles(sourceRoot, sourceFiles, deploymentRoot, deploymentFiles) {
269
+ const deploymentByPath = new Map(deploymentFiles.map((file) => [file.path, file]));
270
+ for (const sourceFile of sourceFiles) {
271
+ const deployed = deploymentByPath.get(sourceFile.path);
272
+ if (deployed === undefined) {
273
+ throw new Error(`The deployment is missing game file ${sourceFile.path} `
274
+ + `from the source artifact ${sourceRoot}.`);
275
+ }
276
+ if (deployed.sha256 !== sourceFile.sha256) {
277
+ throw new Error(`The deployment game file ${sourceFile.path} does not match the source artifact; `
278
+ + `expected sha256 ${sourceFile.sha256} but found ${deployed.sha256}.`);
279
+ }
280
+ }
281
+ }
282
+ function verifyDeploymentFileClassification(deploymentFiles, sourceFiles, deploymentRoot, profile) {
283
+ const sourcePaths = new Set(sourceFiles.map((file) => file.path));
284
+ const legalPages = readLegalSitePages(deploymentRoot);
285
+ const hostFiles = [];
286
+ const unexpected = [];
287
+ for (const file of deploymentFiles) {
288
+ if (sourcePaths.has(file.path)) {
289
+ continue;
290
+ }
291
+ if (cloudflarePagesHostFileAllowlist.has(file.path) || legalPages.has(file.path)) {
292
+ hostFiles.push(file.path);
293
+ continue;
294
+ }
295
+ unexpected.push(file.path);
296
+ }
297
+ if (unexpected.length > 0) {
298
+ throw new Error('The deployment contains files that belong neither to the source artifact nor to '
299
+ + `the recognized Pages host files: ${unexpected.join(', ')}. `
300
+ + 'This usually means files from a different build were mixed into the deployment.');
301
+ }
302
+ // Both reviewed profiles route /api/* through the Pages worker, so a
303
+ // deployment without the worker would verify statically while shipping
304
+ // broken API routes.
305
+ if (!hostFiles.includes('_worker.js')) {
306
+ throw new Error(`The deployment is missing _worker.js; the reviewed ${profile} profile `
307
+ + 'routes /api/* through the Pages worker.');
308
+ }
309
+ return hostFiles;
310
+ }
311
+ function readLegalSitePages(deploymentRoot) {
312
+ const manifestPath = `${deploymentRoot}/legal-site.json`;
313
+ if (!existsSync(manifestPath)) {
314
+ return new Set();
315
+ }
316
+ const parsed = JSON.parse(readFileSync(manifestPath, 'utf8'));
317
+ const pages = new Set();
318
+ if (typeof parsed !== 'object'
319
+ || parsed === null
320
+ || !Array.isArray(parsed.pages)) {
321
+ throw new Error('The deployment legal-site.json must declare a pages array.');
322
+ }
323
+ for (const page of parsed.pages) {
324
+ if (typeof page !== 'object' || page === null) {
325
+ throw new Error('The deployment legal-site.json page entries must be objects.');
326
+ }
327
+ const path = page.path;
328
+ const canonicalPath = typeof path === 'string'
329
+ && path.startsWith('/')
330
+ && path.endsWith('/')
331
+ && path !== '/'
332
+ && path
333
+ .slice(1, -1)
334
+ .split('/')
335
+ .every((segment) => /^[A-Za-z0-9_-]+$/u.test(segment));
336
+ if (!canonicalPath) {
337
+ throw new Error('The deployment legal-site.json page paths must be canonical slash-delimited '
338
+ + `URL pathnames without queries, fragments, dot segments, or escapes: ${String(path)}`);
339
+ }
340
+ const relativePage = `${path.slice(1)}index.html`;
341
+ const distance = relative(deploymentRoot, resolve(deploymentRoot, relativePage));
342
+ if (distance === '' || distance.startsWith('..') || posix.isAbsolute(distance)) {
343
+ throw new Error(`The deployment legal-site.json page path escapes the deployment root: ${path}`);
344
+ }
345
+ pages.add(relativePage);
346
+ }
347
+ for (const page of pages) {
348
+ const pageFile = join(deploymentRoot, page);
349
+ if (!existsSync(pageFile) || !statSync(pageFile).isFile()) {
350
+ throw new Error('The deployment legal-site.json declares a page that is not present as a '
351
+ + `regular file: ${page}`);
352
+ }
353
+ }
354
+ return pages;
355
+ }
356
+ function verifyIndexReferences(deploymentRoot, deploymentFiles) {
357
+ const index = deploymentFiles.find((file) => file.path === 'index.html');
358
+ if (index === undefined) {
359
+ throw new Error('The deployment is missing index.html.');
360
+ }
361
+ const rawHtml = readFileSync(join(deploymentRoot, 'index.html'), 'utf8');
362
+ const cssReferences = [];
363
+ const cssSourceHtml = stripScriptsAndComments(rawHtml);
364
+ for (const styleBody of cssSourceHtml.matchAll(/<style\b(?:(?:"[^"]*"|'[^']*'|[^>"])*)>([\s\S]*?)<\/style\s*>/giu)) {
365
+ collectInlineCssReferences(styleBody[1] ?? '', cssReferences);
366
+ }
367
+ const html = stripNonMarkupRanges(rawHtml);
368
+ if (/<base\b/iu.test(html)) {
369
+ throw new Error('The deployment index.html declares a base element; reference resolution '
370
+ + 'against a non-root document base is unsupported by this verifier.');
371
+ }
372
+ const deploymentPaths = new Set(deploymentFiles.map((file) => file.path));
373
+ const referenced = new Set();
374
+ const referencedDirectoryUrls = new Set();
375
+ const references = [...cssReferences];
376
+ const metaRefreshContents = [];
377
+ const embeddedDocuments = [];
378
+ // Attributes only exist on start tags; scanning raw text would treat prose
379
+ // like `Set href="..."` as a reference. Extract within element start tags,
380
+ // with object data URLs and srcset candidates handled per tag.
381
+ const startTagPattern = /<([A-Za-z][^\s/>]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/gu;
382
+ for (const tag of html.matchAll(startTagPattern)) {
383
+ const tagName = (tag[1] ?? '').toLowerCase();
384
+ const attributes = tag[2] ?? '';
385
+ const resourceNames = tagName === 'object'
386
+ ? ['href', 'src', 'poster', 'data']
387
+ : ['href', 'src', 'poster', 'xlink:href'];
388
+ // Tokenize real attribute name/value pairs first so quoted values of
389
+ // unrelated attributes (title='See href="..."') never leak references.
390
+ let isMetaRefresh = false;
391
+ let metaContent;
392
+ let styleValue;
393
+ // Detect srcdoc before iterating resource attributes so an iframe src
394
+ // appearing before its srcdoc is already known to be overridden.
395
+ const attributesHasSrcdoc = tagName === 'iframe'
396
+ && /(?:^|\s)srcdoc\s*=\s*(?:"[^"]+"|'[^']+'|[^\s>]+)/iu.test(attributes);
397
+ for (const attribute of attributes.matchAll(/([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gu)) {
398
+ const name = (attribute[1] ?? '').toLowerCase();
399
+ const value = attribute[2] ?? attribute[3] ?? attribute[4] ?? '';
400
+ if (tagName === 'meta' && name === 'http-equiv' && value.trim().toLowerCase() === 'refresh') {
401
+ isMetaRefresh = true;
402
+ }
403
+ if (tagName === 'meta' && name === 'content') {
404
+ metaContent = value;
405
+ }
406
+ if (name === 'style') {
407
+ styleValue = value;
408
+ }
409
+ if (!resourceNames.includes(name) && name !== 'srcset' && name !== 'srcdoc') {
410
+ continue;
411
+ }
412
+ if (name === 'srcdoc' && value.length > 0) {
413
+ // srcdoc embeds a document resolved relative to this one; scan its
414
+ // content for the same resource attributes.
415
+ embeddedDocuments.push(decodeHtmlReferences(value));
416
+ continue;
417
+ }
418
+ if (name !== 'srcset' && value.length > 0) {
419
+ if (tagName === 'iframe' && name === 'src' && attributesHasSrcdoc) {
420
+ continue;
421
+ }
422
+ references.push(value);
423
+ }
424
+ }
425
+ if (isMetaRefresh && metaContent !== undefined) {
426
+ metaRefreshContents.push(metaContent);
427
+ }
428
+ if (styleValue !== undefined) {
429
+ collectInlineCssReferences(decodeHtmlReferences(styleValue), references);
430
+ }
431
+ if (!/(?:^|\s)srcset\s*=/iu.test(attributes)) {
432
+ continue;
433
+ }
434
+ const srcset = attributes.match(/(?:^|\s)srcset\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/iu);
435
+ const rawCandidates = srcset?.[1] ?? srcset?.[2] ?? srcset?.[3] ?? '';
436
+ const candidates = decodeHtmlReferences(rawCandidates);
437
+ // WHATWG srcset tokenizing: whitespace-split tokens alternate between a
438
+ // candidate URL and its descriptors; a numeric single-token value like
439
+ // srcset="404" stays a URL, while densities after a URL are skipped.
440
+ let holdingUrl = false;
441
+ for (const token of candidates.split(/\s+/u)) {
442
+ for (const piece of token.split(/(?<=\d[xw]),/u)) {
443
+ const trimmed = piece.replace(/^[,]+|[,]+$/gu, '');
444
+ if (trimmed.length === 0) {
445
+ continue;
446
+ }
447
+ const isDescriptor = /^[\d.]+[wx]?$/u.test(trimmed);
448
+ if (holdingUrl && isDescriptor) {
449
+ continue;
450
+ }
451
+ references.push(trimmed);
452
+ // A trailing comma on a URL token ends the candidate, so the next
453
+ // token starts fresh rather than being read as a descriptor.
454
+ const endedCandidate = /,$/u.test(piece);
455
+ holdingUrl = !isDescriptor && !endedCandidate;
456
+ }
457
+ if (/^[\d.]+[wx],?$/u.test(token)) {
458
+ holdingUrl = false;
459
+ }
460
+ }
461
+ }
462
+ // Embedded srcdoc documents resolve their resources against this
463
+ // deployment; scan them with the full tokenizer (resource attributes,
464
+ // srcset candidates, object data, and inline CSS alike).
465
+ for (const document of embeddedDocuments) {
466
+ collectEmbeddedDocumentReferences(document, references);
467
+ }
468
+ // Meta-refresh targets navigate automatically; treat the URL part of the
469
+ // content directive as a browser-loaded local reference.
470
+ for (const rawContent of metaRefreshContents) {
471
+ const content = decodeHtmlReferences(rawContent);
472
+ const target = /^[^;,]*[,;]\s*url\s*=\s*(?:"([^"]+)"|'([^']+)'|([^;,\s]+))/iu.exec(content);
473
+ if (target !== null) {
474
+ references.push(target[1] ?? target[2] ?? target[3] ?? '');
475
+ }
476
+ }
477
+ // Referenced local stylesheets carry their own url()/@import targets;
478
+ // resolve them relative to each stylesheet's directory.
479
+ const stylesheetReferences = [];
480
+ for (const reference of [...referenced]) {
481
+ if (!reference.endsWith('.css') || !deploymentPaths.has(reference)) {
482
+ continue;
483
+ }
484
+ const cssContent = readFileSync(join(deploymentRoot, reference), 'utf8');
485
+ const cssDir = reference.includes('/')
486
+ ? `./${reference.slice(0, reference.lastIndexOf('/'))}`
487
+ : '.';
488
+ for (const cssUrl of cssContent.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/giu)) {
489
+ const raw = (cssUrl[2] ?? '').trim();
490
+ if (raw.length === 0 || raw.startsWith('data:') || raw.startsWith('#')) {
491
+ continue;
492
+ }
493
+ const scheme = /^([a-z][a-z0-9+.-]*):/iu.exec(raw);
494
+ if (scheme !== null && scheme[1] !== undefined && scheme[1].toLowerCase() !== 'https' && scheme[1].toLowerCase() !== 'http') {
495
+ continue;
496
+ }
497
+ const schemeText = scheme?.[1] ?? '';
498
+ let cssPath = scheme !== null ? raw.slice(schemeText.length + 1) : raw;
499
+ if (cssPath.startsWith('//')) {
500
+ continue;
501
+ }
502
+ cssPath = cssPath.split('?')[0]?.split('#')[0] ?? cssPath;
503
+ const cssTarget = cssPath.startsWith('/') ? cssPath.slice(1) : `${cssDir}/${cssPath}`;
504
+ const resolved = posix.normalize(cssTarget);
505
+ if (!resolved.startsWith('../')) {
506
+ stylesheetReferences.push(resolved);
507
+ }
508
+ }
509
+ for (const importMatch of cssContent.matchAll(/@import\s+(['"])([^'"]+)\1/giu)) {
510
+ const raw = (importMatch[2] ?? '').trim();
511
+ if (raw.length > 0 && !raw.startsWith('/') && !raw.startsWith('data:')) {
512
+ stylesheetReferences.push(posix.normalize(`${cssDir}/${raw.split('?')[0] ?? raw}`));
513
+ }
514
+ }
515
+ }
516
+ for (const stylesheetReference of stylesheetReferences) {
517
+ referenced.add(stylesheetReference);
518
+ }
519
+ for (const rawReference of references) {
520
+ const reference = decodeHtmlReferences(rawReference).trim();
521
+ const schemeSeparated = /^([a-z][a-z0-9+.-]*):/iu.exec(reference);
522
+ if (reference.length === 0 || reference.startsWith('#') || reference.startsWith('data:')) {
523
+ continue;
524
+ }
525
+ // Scheme-relative (//host/path) and cross-origin URLs stay external;
526
+ // same-scheme references like "https:./file.png" resolve against the
527
+ // deployment like the browser does on an HTTPS page.
528
+ if (reference.startsWith('//')) {
529
+ continue;
530
+ }
531
+ let effectiveReference = reference;
532
+ if (schemeSeparated !== null && schemeSeparated[1] !== undefined) {
533
+ const scheme = schemeSeparated[1].toLowerCase();
534
+ if (scheme === 'https' || scheme === 'http') {
535
+ const afterScheme = reference.slice(schemeSeparated[1].length + 1);
536
+ if (afterScheme.startsWith('//')) {
537
+ continue;
538
+ }
539
+ effectiveReference = afterScheme;
540
+ }
541
+ else {
542
+ continue;
543
+ }
544
+ }
545
+ if (process.env.MPGD_DEBUG_SRCDOC === '1') {
546
+ console.error('DEBUG-REF', JSON.stringify(reference.slice(0, 60)), '->', JSON.stringify(effectiveReference.slice(0, 60)));
547
+ }
548
+ const withoutQuery = effectiveReference.split(/[?#]/u)[0] ?? effectiveReference;
549
+ if (withoutQuery.length === 0) {
550
+ continue;
551
+ }
552
+ let normalized = posix.normalize(withoutQuery.startsWith('/') ? withoutQuery.slice(1) : `./${withoutQuery}`);
553
+ normalized = normalized.split('\\').join('/');
554
+ try {
555
+ normalized = normalized
556
+ .split('/')
557
+ .map((segment) => decodeURIComponent(segment))
558
+ .join('/');
559
+ }
560
+ catch {
561
+ throw new Error(`The deployment index.html references a malformed percent-encoded URL: ${reference}`);
562
+ }
563
+ if (normalized.startsWith('../') || normalized === '..' || posix.isAbsolute(normalized)) {
564
+ throw new Error(`The deployment index.html references a path outside the artifact root: ${reference}`);
565
+ }
566
+ // Track the concrete directory request URL before resolving it to the
567
+ // index document, so redirect protection sees the URL browsers request.
568
+ if (normalized.endsWith('/')) {
569
+ referencedDirectoryUrls.add(`/${normalized.slice(0, -1)}/`);
570
+ }
571
+ else if (normalized === '.') {
572
+ referencedDirectoryUrls.add('/');
573
+ }
574
+ // Directory URLs resolve to their index document, matching how Pages
575
+ // serves '/' and '/legal/' from index.html files.
576
+ if (normalized === '.' || normalized.endsWith('/')) {
577
+ normalized = `${normalized === '.' ? '' : normalized}index.html`;
578
+ }
579
+ referenced.add(normalized);
580
+ }
581
+ for (const reference of [...referenced].sort()) {
582
+ if (pagesControlArtifactNames.has(reference)) {
583
+ throw new Error(`The deployment index.html references the Pages control file ${reference}; `
584
+ + 'control artifacts are consumed by the host, not served to browsers.');
585
+ }
586
+ if (!deploymentPaths.has(reference)) {
587
+ throw new Error(`The deployment index.html references a missing file: ${reference} `
588
+ + `(resolved under ${deploymentRoot}).`);
589
+ }
590
+ }
591
+ // Declared legal pages are deployment-promised documents; their local
592
+ // references resolve against the page's own directory.
593
+ for (const page of declaredLegalPages(deploymentRoot)) {
594
+ const rawPageHtml = readFileSync(join(deploymentRoot, page), 'utf8');
595
+ const pageStyleReferences = [];
596
+ const pageCssSource = stripScriptsAndComments(rawPageHtml);
597
+ for (const styleBody of pageCssSource.matchAll(/<style\b(?:(?:"[^"]*"|'[^']*'|[^>"])*)>([\s\S]*?)<\/style\s*>/giu)) {
598
+ collectInlineCssReferences(styleBody[1] ?? '', pageStyleReferences);
599
+ }
600
+ for (const cssReference of pageStyleReferences) {
601
+ assertLocalReferenceResolves(cssReference, page.slice(0, -'index.html'.length), deploymentPaths, page);
602
+ }
603
+ const strippedPageHtml = stripNonMarkupRanges(rawPageHtml);
604
+ if (/<base\b/iu.test(strippedPageHtml)) {
605
+ throw new Error(`The legal page ${page} declares a base element; reference resolution `
606
+ + 'against a legal-page base is unsupported by this verifier.');
607
+ }
608
+ const metaTagPattern = /<meta\b(?:(?:"[^"]*"|'[^']*'|[^>"]))*>/giu;
609
+ for (const meta of rawPageHtml.matchAll(metaTagPattern)) {
610
+ const metaTag = meta[0] ?? '';
611
+ if (!/http-equiv\s*=\s*["']?refresh/iu.test(metaTag)) {
612
+ continue;
613
+ }
614
+ const content = metaTag.match(/content\s*=\s*(?:"([^"]*)"|'([^']*)')/iu);
615
+ if (content === null) {
616
+ continue;
617
+ }
618
+ const decodedContent = decodeHtmlReferences(content[1] ?? '');
619
+ const target = /^[^;,]*[,;]\s*url\s*=\s*(?:"([^"]+)"|'([^']+)'|([^;,\s]+))/iu.exec(decodedContent);
620
+ if (target !== null) {
621
+ assertLocalReferenceResolves(target[1] ?? target[2] ?? target[3] ?? '', page.slice(0, -'index.html'.length), deploymentPaths, page);
622
+ }
623
+ }
624
+ const pageHtml = strippedPageHtml;
625
+ for (const tag of pageHtml.matchAll(/<([A-Za-z][^\s/>]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/gu)) {
626
+ const tagName = (tag[1] ?? '').toLowerCase();
627
+ const attributes = tag[2] ?? '';
628
+ const resourceNames = tagName === 'object'
629
+ ? ['href', 'src', 'poster', 'data']
630
+ : ['href', 'src', 'poster', 'xlink:href'];
631
+ for (const attribute of attributes.matchAll(/([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gu)) {
632
+ const name = (attribute[1] ?? '').toLowerCase();
633
+ const value = attribute[2] ?? attribute[3] ?? attribute[4] ?? '';
634
+ if (name === 'style') {
635
+ const pageCssReferences = [];
636
+ collectInlineCssReferences(decodeHtmlReferences(value), pageCssReferences);
637
+ for (const cssReference of pageCssReferences) {
638
+ assertLocalReferenceResolves(cssReference, page.slice(0, -'index.html'.length), deploymentPaths, page);
639
+ }
640
+ continue;
641
+ }
642
+ if (!resourceNames.includes(name) && name !== 'srcset' && name !== 'srcdoc') {
643
+ continue;
644
+ }
645
+ if (name === 'srcdoc' && value.length > 0) {
646
+ const embeddedReferences = [];
647
+ collectEmbeddedDocumentReferences(decodeHtmlReferences(value), embeddedReferences);
648
+ for (const embeddedReference of embeddedReferences) {
649
+ assertLocalReferenceResolves(embeddedReference, page.slice(0, -'index.html'.length), deploymentPaths, page);
650
+ }
651
+ continue;
652
+ }
653
+ if (name === 'srcset') {
654
+ const candidates = [];
655
+ pushSrcsetCandidates(decodeHtmlReferences(value), candidates);
656
+ for (const candidate of candidates) {
657
+ assertLocalReferenceResolves(candidate, page.slice(0, -'index.html'.length), deploymentPaths, page);
658
+ }
659
+ continue;
660
+ }
661
+ if (value.length === 0) {
662
+ continue;
663
+ }
664
+ assertLocalReferenceResolves(value, page.slice(0, -'index.html'.length), deploymentPaths, page);
665
+ }
666
+ }
667
+ }
668
+ return [...referencedDirectoryUrls];
669
+ }
670
+ /**
671
+ * Decode attribute values with standards-compliant HTML semantics using the
672
+ * package's existing entities dependency (named references, numeric
673
+ * references, and U+FFFD replacement for invalid code points).
674
+ */
675
+ function decodeHtmlReferences(text) {
676
+ return decodeHTMLAttribute(text);
677
+ }
678
+ /** List the deployment's declared legal page index files. */
679
+ function declaredLegalPages(deploymentRoot) {
680
+ const manifestPath = join(deploymentRoot, 'legal-site.json');
681
+ if (!existsSync(manifestPath)) {
682
+ return [];
683
+ }
684
+ const parsed = JSON.parse(readFileSync(manifestPath, 'utf8'));
685
+ if (typeof parsed !== 'object'
686
+ || parsed === null
687
+ || !Array.isArray(parsed.pages)) {
688
+ return [];
689
+ }
690
+ return parsed.pages
691
+ .filter((page) => typeof page.path === 'string')
692
+ .map((page) => `${page.path.slice(1)}index.html`);
693
+ }
694
+ /** Resolve a legal-page reference against its directory and require the file. */
695
+ function assertLocalReferenceResolves(reference, pageDirectory, deploymentPaths, page) {
696
+ const decoded = decodeHtmlReferences(reference).trim();
697
+ const schemeSeparated = /^([a-z][a-z0-9+.-]*):/iu.exec(decoded);
698
+ if (decoded.length === 0 || decoded.startsWith('#') || decoded.startsWith('data:')) {
699
+ return;
700
+ }
701
+ if (decoded.startsWith('//')) {
702
+ return;
703
+ }
704
+ let effective = decoded;
705
+ if (schemeSeparated !== null && schemeSeparated[1] !== undefined) {
706
+ const scheme = schemeSeparated[1].toLowerCase();
707
+ if (scheme === 'https' || scheme === 'http') {
708
+ const afterScheme = decoded.slice(schemeSeparated[1].length + 1);
709
+ if (afterScheme.startsWith('//')) {
710
+ return;
711
+ }
712
+ effective = afterScheme;
713
+ }
714
+ else {
715
+ return;
716
+ }
717
+ }
718
+ let withoutQuery = effective.split(/[?#]/u)[0] ?? effective;
719
+ if (withoutQuery.length === 0) {
720
+ return;
721
+ }
722
+ withoutQuery = withoutQuery.split('\\').join('/');
723
+ try {
724
+ withoutQuery = withoutQuery
725
+ .split('/')
726
+ .map((segment) => decodeURIComponent(segment))
727
+ .join('/');
728
+ }
729
+ catch {
730
+ throw new Error(`The legal page ${page} references a malformed percent-encoded URL: ${reference}`);
731
+ }
732
+ const fromDirectory = pageDirectory.length > 0 ? `./${pageDirectory}` : '.';
733
+ let normalized = posix.normalize(withoutQuery.startsWith('/') ? withoutQuery.slice(1) : `${fromDirectory}/${withoutQuery}`);
734
+ if (normalized.endsWith('/') || normalized === '.') {
735
+ normalized = normalized === '.' ? 'index.html' : `${normalized}index.html`;
736
+ }
737
+ if (normalized.startsWith('../') || normalized === '..' || posix.isAbsolute(normalized)) {
738
+ throw new Error(`The legal page ${page} references a path outside the deployment root: ${reference}`);
739
+ }
740
+ if (pagesControlArtifactNames.has(normalized)) {
741
+ throw new Error(`The legal page ${page} references the Pages control file ${normalized}; `
742
+ + 'control artifacts are consumed by the host, not served to browsers.');
743
+ }
744
+ if (!deploymentPaths.has(normalized)) {
745
+ throw new Error(`The legal page ${page} references a missing file: ${reference} `
746
+ + `(resolved to ${normalized}).`);
747
+ }
748
+ }
749
+ /** Apply the full resource scanner to an embedded srcdoc document. */
750
+ function collectEmbeddedDocumentReferences(document, references) {
751
+ // The caller decoded this document's own attribute once, matching the
752
+ // browser's per-attribute decoding; style blocks are collected before
753
+ // stripping so their CSS references survive.
754
+ for (const styleBody of document.matchAll(/<style\b(?:(?:"[^"]*"|'[^']*'|[^>"])*)>([\s\S]*?)<\/style\s*>/giu)) {
755
+ collectInlineCssReferences(styleBody[1] ?? '', references);
756
+ }
757
+ const stripped = stripNonMarkupRanges(document);
758
+ for (const tag of stripped.matchAll(/<([A-Za-z][^\s/>]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/gu)) {
759
+ const tagName = (tag[1] ?? '').toLowerCase();
760
+ const attributes = tag[2] ?? '';
761
+ const resourceNames = tagName === 'object'
762
+ ? ['href', 'src', 'poster', 'data']
763
+ : ['href', 'src', 'poster', 'xlink:href'];
764
+ let embeddedMetaRefresh = false;
765
+ let embeddedMetaContent;
766
+ for (const attribute of attributes.matchAll(/([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gu)) {
767
+ const name = (attribute[1] ?? '').toLowerCase();
768
+ const value = attribute[2] ?? attribute[3] ?? attribute[4] ?? '';
769
+ if (tagName === 'meta' && name === 'http-equiv') {
770
+ embeddedMetaRefresh = value.trim().toLowerCase() === 'refresh';
771
+ }
772
+ if (tagName === 'meta' && name === 'content') {
773
+ embeddedMetaContent = value;
774
+ }
775
+ if (name === 'style') {
776
+ collectInlineCssReferences(decodeHtmlReferences(value), references);
777
+ continue;
778
+ }
779
+ if (svgUrlAttributeNames.has(name) && value.includes('url(')) {
780
+ collectInlineCssReferences(decodeHtmlReferences(value), references);
781
+ continue;
782
+ }
783
+ if (!resourceNames.includes(name) && name !== 'srcset' && name !== 'srcdoc') {
784
+ continue;
785
+ }
786
+ if (name === 'srcdoc' && value.length > 0) {
787
+ collectEmbeddedDocumentReferences(decodeHtmlReferences(value), references);
788
+ continue;
789
+ }
790
+ if (name !== 'srcset' && value.length > 0) {
791
+ references.push(value);
792
+ }
793
+ }
794
+ if (embeddedMetaRefresh && embeddedMetaContent !== undefined) {
795
+ const decodedContent = decodeHtmlReferences(embeddedMetaContent);
796
+ const target = /^[^;,]*[,;]\s*url\s*=\s*(?:"([^"]+)"|'([^']+)'|([^;,\s]+))/iu.exec(decodedContent);
797
+ if (target !== null) {
798
+ references.push(target[1] ?? target[2] ?? target[3] ?? '');
799
+ }
800
+ }
801
+ const srcset = attributes.match(/(?:^|\s)srcset\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/iu);
802
+ if (srcset !== null) {
803
+ const rawCandidates = srcset[1] ?? srcset[2] ?? srcset[3] ?? '';
804
+ pushSrcsetCandidates(decodeHtmlReferences(rawCandidates), references);
805
+ }
806
+ }
807
+ }
808
+ /** Tokenize srcset candidates into the reference list. */
809
+ function pushSrcsetCandidates(candidates, references) {
810
+ let holdingUrl = false;
811
+ for (const token of candidates.split(/\s+/u)) {
812
+ for (const piece of token.split(/(?<=\d[xw]),/u)) {
813
+ const trimmed = piece.replace(/^[,]+|[,]+$/gu, '');
814
+ if (trimmed.length === 0) {
815
+ continue;
816
+ }
817
+ const isDescriptor = /^[\d.]+[wx]?$/u.test(trimmed);
818
+ if (holdingUrl && isDescriptor) {
819
+ continue;
820
+ }
821
+ references.push(trimmed);
822
+ const endedCandidate = /,$/u.test(piece);
823
+ holdingUrl = !isDescriptor && !endedCandidate;
824
+ }
825
+ if (/^[\d.]+[wx],?$/u.test(token)) {
826
+ holdingUrl = false;
827
+ }
828
+ }
829
+ }
830
+ /** Collect local url() and @import targets from inline CSS. */
831
+ function collectInlineCssReferences(html, references) {
832
+ for (const match of html.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/giu)) {
833
+ references.push(match[2] ?? '');
834
+ }
835
+ for (const match of html.matchAll(/@import\s+(['"])([^'"]+)\1/giu)) {
836
+ references.push(match[2] ?? '');
837
+ }
838
+ }
839
+ /** Drop comments, script bodies, and style bodies from HTML before scanning. */
840
+ /** Strip scripts and HTML comments while keeping style blocks intact. */
841
+ function stripScriptsAndComments(html) {
842
+ return html
843
+ .replace(/<!--[\s\S]*?-->/gu, '')
844
+ .replace(/<script\b((?:"[^"]*"|'[^']*'|[^>"'])*)>[\s\S]*?<\/script\s*>/giu, '')
845
+ .replace(/<template\b(?:(?:"[^"]*"|'[^']*'|[^>"]))*>[\s\S]*?<\/template\s*>/giu, '');
846
+ }
847
+ function stripNonMarkupRanges(html) {
848
+ return html
849
+ .replace(/<!--[\s\S]*?-->/gu, '')
850
+ .replace(/<script\b((?:"[^"]*"|'[^']*'|[^>"'])*)>[\s\S]*?<\/script\s*>/giu, '<script$1></script>')
851
+ .replace(/<textarea\b[^>]*>[\s\S]*?<\/textarea\s*>/giu, '<textarea></textarea>')
852
+ .replace(/<template\b(?:(?:"[^"]*"|'[^']*'|[^>"]))*>[\s\S]*?<\/template\s*>/giu, '<template></template>')
853
+ .replace(/<title\b[^>]*>[\s\S]*?<\/title\s*>/giu, '<title></title>')
854
+ .replace(/<style\b((?:"[^"]*"|'[^']*'|[^>"'])*)>[\s\S]*?<\/style\s*>/giu, '<style$1></style>');
855
+ }
856
+ function verifyCloudflarePagesRoutes(deploymentRoot, profile) {
857
+ const routesPath = `${deploymentRoot}/_routes.json`;
858
+ if (!existsSync(routesPath)) {
859
+ throw new Error('The deployment is missing _routes.json.');
860
+ }
861
+ const routes = JSON.parse(readFileSync(routesPath, 'utf8'));
862
+ if (typeof routes !== 'object' || routes === null) {
863
+ throw new Error('The deployment _routes.json must be an object.');
864
+ }
865
+ const record = routes;
866
+ const include = record.include;
867
+ const exclude = record.exclude;
868
+ if (record.version !== 1 || !Array.isArray(include) || !Array.isArray(exclude)) {
869
+ throw new Error('The deployment _routes.json must declare version 1 with include and exclude arrays.');
870
+ }
871
+ const expectedInclude = profile === 'api-canonical-index'
872
+ ? ['/api/*', '/index.html']
873
+ : ['/api/*'];
874
+ const actual = {
875
+ version: 1,
876
+ include: [...include],
877
+ exclude: [...exclude],
878
+ };
879
+ const expected = {
880
+ version: 1,
881
+ include: expectedInclude,
882
+ exclude: [],
883
+ };
884
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
885
+ throw new Error(`The deployment _routes.json does not match the reviewed ${profile} profile. `
886
+ + `Expected ${JSON.stringify(expected)} but found ${JSON.stringify(actual)}. `
887
+ + 'Broader worker routes are rejected because they silently bypass the '
888
+ + 'static PWA serving this verification guarantees.');
889
+ }
890
+ return {
891
+ include: actual.include.map(String),
892
+ exclude: actual.exclude.map(String),
893
+ };
894
+ }
895
+ function assertNoSourceRoutesShadowed(routes, sourceFiles, profile) {
896
+ // The api-canonical-index profile deliberately routes /index.html through
897
+ // the worker, whose reviewed handler serves the canonical index bytes from
898
+ // the deployment assets; every other route collision is a real shadowing.
899
+ const shadowingRoutes = routes.include.filter((route) => !(profile === 'api-canonical-index' && route === '/index.html'));
900
+ for (const file of sourceFiles) {
901
+ const requestPath = `/${file.path}`;
902
+ for (const route of shadowingRoutes) {
903
+ if (cloudflarePagesPathMatches(route, requestPath)) {
904
+ throw new Error(`The source artifact file ${file.path} is served at ${requestPath}, which the `
905
+ + `Pages worker route ${route} intercepts; the reviewed profiles route /api/* `
906
+ + 'through the worker, so precached files must not live under those paths.');
907
+ }
908
+ }
909
+ }
910
+ }
911
+ /** Declared legal pages must be served statically, not intercepted by the worker. */
912
+ function assertNoLegalRoutesShadowed(routes, legalPages, profile) {
913
+ for (const page of legalPages) {
914
+ const requestPath = `/${page.slice(0, -'index.html'.length)}`;
915
+ for (const route of routes.include) {
916
+ if (cloudflarePagesPathMatches(route, requestPath)
917
+ && !(profile === 'api-canonical-index' && route === '/index.html')) {
918
+ throw new Error(`The declared legal page ${page} is served at ${requestPath}, which the `
919
+ + `Pages worker route ${route} intercepts instead of serving the file.`);
920
+ }
921
+ }
922
+ }
923
+ }
924
+ function verifyCloudflarePagesHeaders(deploymentRoot, sourceFiles, deploymentFiles, declaredLegalDirectories, workerRoutes) {
925
+ const headersPath = `${deploymentRoot}/_headers`;
926
+ if (!existsSync(headersPath)) {
927
+ throw new Error('The deployment is missing _headers.');
928
+ }
929
+ const blocks = parseCloudflarePagesHeaders(readFileSync(headersPath, 'utf8'));
930
+ const sourcePaths = new Set(sourceFiles.map((file) => file.path));
931
+ const deploymentPaths = new Set(deploymentFiles.map((file) => file.path));
932
+ const requirements = [];
933
+ // Host-owned legal content must stay fresh: the deployment declares the
934
+ // pages and ships legal-site.json, and stale privacy or terms pages after
935
+ // an update are a compliance problem, so they carry revalidation policy.
936
+ for (const file of deploymentFiles) {
937
+ if (file.path === 'legal-site.json') {
938
+ requirements.push({
939
+ requestPath: '/legal-site.json',
940
+ expected: freshCacheControl,
941
+ label: 'legal-site.json',
942
+ });
943
+ }
944
+ const declaredLegalPage = file.path.endsWith('/index.html')
945
+ && file.path.includes('/')
946
+ && declaredLegalDirectories.has(`${file.path.slice(0, -'index.html'.length - 1)}/`);
947
+ if ((declaredLegalPage || (!sourcePaths.has(file.path) && file.path.includes('/')))
948
+ && file.path.endsWith('/index.html')) {
949
+ const directoryUrl = `/${file.path.slice(0, -'index.html'.length)}`;
950
+ requirements.push({
951
+ requestPath: directoryUrl,
952
+ expected: freshCacheControl,
953
+ label: `${file.path} directory URL`,
954
+ });
955
+ requirements.push({
956
+ requestPath: `/${file.path}`,
957
+ expected: freshCacheControl,
958
+ label: `${file.path} index URL`,
959
+ });
960
+ }
961
+ }
962
+ if (sourcePaths.has('index.html')) {
963
+ // Under api-canonical-index the worker serves /index.html and Pages
964
+ // _headers rules do not apply to function responses, so the index.html
965
+ // policy is only certifiable for the statically served root path.
966
+ requirements.push({ requestPath: '/', expected: freshCacheControl, label: 'the PWA root' });
967
+ if (workerRoutes.include.includes('/index.html')) {
968
+ requirements.push({
969
+ requestPath: '/index.html',
970
+ expected: freshCacheControl,
971
+ label: 'index.html (worker-served; static _headers policy not certified)',
972
+ skip: true,
973
+ });
974
+ }
975
+ else {
976
+ requirements.push({
977
+ requestPath: '/index.html',
978
+ expected: freshCacheControl,
979
+ label: 'index.html',
980
+ });
981
+ }
982
+ }
983
+ for (const file of [...freshCacheControlFiles, ...noStoreCacheControlFiles]) {
984
+ if (sourcePaths.has(file.slice(1))) {
985
+ requirements.push({
986
+ requestPath: file,
987
+ expected: noStoreCacheControlFiles.has(file)
988
+ ? noStoreCacheControl
989
+ : freshCacheControl,
990
+ label: file.slice(1),
991
+ });
992
+ }
993
+ }
994
+ // Directory-scoped policies are evaluated against every actual file path so
995
+ // placeholder-shaped blocks that do not cover the real URLs cannot satisfy
996
+ // the requirement by coincidence. Only filenames that carry a content
997
+ // hash may be cached immutably; stable names require revalidation so a
998
+ // later release at the same URL is served fresh.
999
+ for (const file of sourceFiles) {
1000
+ const directory = file.path.includes('/') ? (file.path.split('/')[0] ?? '') : '';
1001
+ if (directory.length === 0) {
1002
+ continue;
1003
+ }
1004
+ if (immutableCacheControlDirectories.has(directory)) {
1005
+ // Filename-based hash detection cannot distinguish hashes from words
1006
+ // after six review rounds of counterexamples, so the contract is now
1007
+ // explicit-policy: every asset URL must carry a deliberate caching
1008
+ // strategy — immutable for hashed pipelines, revalidation for stable
1009
+ // names — and only a missing or conflicting policy fails.
1010
+ requirements.push({
1011
+ requestPath: `/${file.path}`,
1012
+ expected: immutableCacheControl,
1013
+ alsoAccepts: freshCacheControl,
1014
+ label: `asset ${file.path}`,
1015
+ });
1016
+ }
1017
+ else if (noStoreCacheControlDirectories.has(directory)) {
1018
+ requirements.push({
1019
+ requestPath: `/${file.path}`,
1020
+ expected: noStoreCacheControl,
1021
+ label: `stable-name ${file.path}`,
1022
+ });
1023
+ }
1024
+ }
1025
+ for (const requirement of requirements) {
1026
+ if (requirement.skip === true) {
1027
+ continue;
1028
+ }
1029
+ const effective = evaluateCloudflarePagesHeader(blocks, requirement.requestPath, 'Cache-Control');
1030
+ if (effective.additions.length > 1) {
1031
+ throw new Error(`Conflicting Cloudflare Pages cache policy for ${requirement.label}: `
1032
+ + `${requirement.requestPath} receives comma-joined Cache-Control values `
1033
+ + `(${effective.additions.join(', ')}) because more than one matching block `
1034
+ + 'adds the header. Cloudflare joins duplicate headers with commas instead '
1035
+ + 'of overriding them.');
1036
+ }
1037
+ if (effective.value === undefined) {
1038
+ throw new Error(`Cloudflare Pages cache policy for ${requirement.label} is missing: no matching `
1039
+ + `block sets Cache-Control to ${requirement.expected} for `
1040
+ + `${requirement.requestPath}`
1041
+ + (effective.removed
1042
+ ? ' (a matching block removed the header with a removal directive)'
1043
+ : '')
1044
+ + '.');
1045
+ }
1046
+ const acceptedValues = [requirement.expected, requirement.alsoAccepts]
1047
+ .filter((value) => value !== undefined)
1048
+ .map(normalizeHeaderDirectiveValue);
1049
+ if (!acceptedValues.includes(normalizeHeaderDirectiveValue(effective.value))) {
1050
+ throw new Error(`Cloudflare Pages cache policy for ${requirement.label} is wrong: expected `
1051
+ + `${requirement.expected}${requirement.alsoAccepts === undefined ? '' : ` or ${requirement.alsoAccepts}`} `
1052
+ + `for ${requirement.requestPath} but the effective value is ${effective.value}.`);
1053
+ }
1054
+ }
1055
+ }
1056
+ /** Whether a filename carries a Vite-style content hash segment. */
1057
+ function carriesContentHash(portablePath) {
1058
+ const name = portablePath.split('/').pop() ?? '';
1059
+ // Only unambiguous hexadecimal segments earn the immutable policy. Vite's
1060
+ // URL-safe base64 segments cannot be told apart from mixed-case words like
1061
+ // "Player2D" lexically, so they take the fail-safe revalidation policy:
1062
+ // a misclassified hash merely revalidates, while a misclassified stable
1063
+ // name would serve stale release bytes for a year.
1064
+ return /(^|[/.-])[0-9a-f]{8,}\.[a-z0-9]+$/iu.test(name);
1065
+ }
1066
+ function verifyCloudflarePagesRedirects(deploymentRoot, sourceFiles, deploymentLegalPaths, referencedDirectoryUrls) {
1067
+ const redirectsPath = `${deploymentRoot}/_redirects`;
1068
+ if (!existsSync(redirectsPath)) {
1069
+ throw new Error('The deployment is missing _redirects.');
1070
+ }
1071
+ const rules = parseCloudflarePagesRedirects(readFileSync(redirectsPath, 'utf8'));
1072
+ const protectedPaths = [
1073
+ ...protectedPwaMetadataRequestPaths,
1074
+ ...sourceFiles.map((file) => `/${file.path}`),
1075
+ ...deploymentLegalPaths,
1076
+ ...referencedDirectoryUrls,
1077
+ ];
1078
+ for (const rule of rules) {
1079
+ for (const protectedPath of protectedPaths) {
1080
+ if (cloudflarePagesPathMatches(rule.source, protectedPath)) {
1081
+ throw new Error(`The deployment _redirects rule ${rule.source} covers the protected PWA path `
1082
+ + `${protectedPath} and moves it to ${rule.destination}; the game root and `
1083
+ + 'PWA-critical files must be served directly.');
1084
+ }
1085
+ }
1086
+ }
1087
+ }