anthodev/lapin-futepublic Fork
⑂ 9dc8049
Commits
⬇ Clone ▾
git clone https://git.rickub.com/anthodev/lapin-fute.git
git clone ssh://git@rickub.com/anthodev/lapin-fute.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat: verify published configuration siteUnverified

anthodev committed 2026-09-13T12:24:38+02:00 Browse files
9dc8049 parent: e1d539d
modified package.json +1 -0
@@ -19,6 +19,7 @@
1919 "measure:radio": "node scripts/measure-radio.mjs",
2020 "measure:catalog": "node scripts/measure-catalog.mjs",
2121 "measure:config-page": "node scripts/measure-config-page.mjs",
22+ "verify:config-site": "node scripts/verify-config-site.mjs",
2223 "test": "node scripts/test.mjs",
2324 "test:contracts": "node --test packages/contracts/test/contracts.test.ts",
2425 "test:watch": "node scripts/generate-display-copy.mjs && node --test packages/watch/test/*.test.js",
@@ -19,6 +19,7 @@
19 "measure:radio": "node scripts/measure-radio.mjs",19 "measure:radio": "node scripts/measure-radio.mjs",
20 "measure:catalog": "node scripts/measure-catalog.mjs",20 "measure:catalog": "node scripts/measure-catalog.mjs",
21 "measure:config-page": "node scripts/measure-config-page.mjs",21 "measure:config-page": "node scripts/measure-config-page.mjs",
22+ "verify:config-site": "node scripts/verify-config-site.mjs",
22 "test": "node scripts/test.mjs",23 "test": "node scripts/test.mjs",
23 "test:contracts": "node --test packages/contracts/test/contracts.test.ts",24 "test:contracts": "node --test packages/contracts/test/contracts.test.ts",
24 "test:watch": "node scripts/generate-display-copy.mjs && node --test packages/watch/test/*.test.js",25 "test:watch": "node scripts/generate-display-copy.mjs && node --test packages/watch/test/*.test.js",
modified packages/config-page/src/catalog-client.js +2 -2
@@ -36,7 +36,7 @@ function exactFields(value, fields) {
3636 && fields.every((field) => Object.hasOwn(value, field));
3737 }
3838
39-function isManifest(value) {
39+export function isCatalogManifest(value) {
4040 return exactFields(value, MANIFEST_FIELDS)
4141 && value.schemaVersion === SCHEMA_VERSION
4242 && typeof value.revision === "string" && REVISION.test(value.revision)
@@ -133,7 +133,7 @@ export function createCatalogClient({
133133 if (signal?.aborted) throw abortError();
134134 if (manifest !== null) return manifest;
135135 const body = await json("manifest.json", signal, "BACKEND_UNAVAILABLE", true);
136- if (!isManifest(body)) throw new CatalogClientError("BACKEND_UNAVAILABLE");
136+ if (!isCatalogManifest(body)) throw new CatalogClientError("BACKEND_UNAVAILABLE");
137137 if (signal?.aborted) throw abortError();
138138 // Concurrent first operations use the first successfully loaded revision.
139139 manifest ??= body;
@@ -36,7 +36,7 @@ function exactFields(value, fields) {
36 && fields.every((field) => Object.hasOwn(value, field));36 && fields.every((field) => Object.hasOwn(value, field));
37 }37 }
38 38
39-function isManifest(value) {39+export function isCatalogManifest(value) {
40 return exactFields(value, MANIFEST_FIELDS)40 return exactFields(value, MANIFEST_FIELDS)
41 && value.schemaVersion === SCHEMA_VERSION41 && value.schemaVersion === SCHEMA_VERSION
42 && typeof value.revision === "string" && REVISION.test(value.revision)42 && typeof value.revision === "string" && REVISION.test(value.revision)
@@ -133,7 +133,7 @@ export function createCatalogClient({
133 if (signal?.aborted) throw abortError();133 if (signal?.aborted) throw abortError();
134 if (manifest !== null) return manifest;134 if (manifest !== null) return manifest;
135 const body = await json("manifest.json", signal, "BACKEND_UNAVAILABLE", true);135 const body = await json("manifest.json", signal, "BACKEND_UNAVAILABLE", true);
136- if (!isManifest(body)) throw new CatalogClientError("BACKEND_UNAVAILABLE");136+ if (!isCatalogManifest(body)) throw new CatalogClientError("BACKEND_UNAVAILABLE");
137 if (signal?.aborted) throw abortError();137 if (signal?.aborted) throw abortError();
138 // Concurrent first operations use the first successfully loaded revision.138 // Concurrent first operations use the first successfully loaded revision.
139 manifest ??= body;139 manifest ??= body;
modified packages/config-page/test/config-site-build.test.ts +73 -0
@@ -4,6 +4,7 @@ import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync
44 import { tmpdir } from "node:os";
55 import { join } from "node:path";
66 import { buildConfigSite } from "../../../scripts/build-config-site.mjs";
7+import { verifyConfigSite } from "../../../scripts/verify-config-site.mjs";
78
89 function fixture() {
910 const root = mkdtempSync(join(tmpdir(), "lapin-fute-config-site-"));
@@ -53,6 +54,78 @@ test("full configuration build still requires and copies a published catalog", (
5354 }
5455 });
5556
57+test("configuration-site verification checks every local and served file on the current catalog revision", async () => {
58+ const paths = fixture();
59+ try {
60+ const revision = "a".repeat(64);
61+ const manifest = {
62+ schemaVersion: 1,
63+ revision,
64+ sourceRevision: "idfm-v1-fixture",
65+ createdAt: "2026-09-13T00:00:00.000Z",
66+ attribution: [{
67+ dataset: "fixture",
68+ url: "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/fixture",
69+ retrievedAt: "2026-09-13T00:00:00.000Z",
70+ license: "Licence fixture",
71+ }],
72+ };
73+ const files = new Map([
74+ ["index.html", "<h1>Lapin Futé</h1>"],
75+ ["catalog/manifest.json", JSON.stringify(manifest)],
76+ [`catalog/${revision}/search/a/0.json`, JSON.stringify({
77+ schemaVersion: 1, revision, page: 0, nextPage: null, places: [],
78+ })],
79+ [`catalog/${revision}/places/plc_${"a".repeat(43)}/0.json`, JSON.stringify({
80+ schemaVersion: 1, revision, placeId: `plc_${"a".repeat(43)}`, page: 0, nextPage: null, services: [],
81+ })],
82+ [`catalog/${revision}/services/svc_${"b".repeat(43)}.json`, JSON.stringify({
83+ schemaVersion: 1, revision, service: {},
84+ })],
85+ ]);
86+ for (const [relativePath, body] of files) {
87+ const path = join(paths.outputPath, relativePath);
88+ mkdirSync(join(path, ".."), { recursive: true });
89+ writeFileSync(path, body);
90+ }
91+
92+ const requested = [];
93+ const result = await verifyConfigSite({
94+ sitePath: paths.outputPath,
95+ origin: "https://config.example.test/lapin-fute/",
96+ fetcher: async (url) => {
97+ requested.push(url.href);
98+ const relativePath = decodeURIComponent(url.pathname.slice("/lapin-fute/".length));
99+ const body = files.get(relativePath);
100+ return body === undefined
101+ ? new Response(null, { status: 404 })
102+ : new Response(body, { status: 200 });
103+ },
104+ });
105+
106+ assert.equal(result.revision, revision);
107+ assert.equal(result.fileCount, files.size);
108+ assert.equal(result.catalogJsonCount, 3);
109+ assert.deepEqual(requested.sort(), [...files.keys()]
110+ .map((path) => `https://config.example.test/lapin-fute/${path}`)
111+ .sort());
112+
113+ files.set(`catalog/${revision}/services/svc_${"b".repeat(43)}.json`, "{}");
114+ await assert.rejects(
115+ verifyConfigSite({
116+ sitePath: paths.outputPath,
117+ origin: "https://config.example.test/lapin-fute/",
118+ fetcher: async (url) => new Response(files.get(
119+ decodeURIComponent(url.pathname.slice("/lapin-fute/".length)),
120+ ), { status: 200 }),
121+ }),
122+ /served bytes differ/u,
123+ );
124+ } finally {
125+ rmSync(paths.root, { recursive: true, force: true });
126+ }
127+});
128+
56129 test("Bunny releases deploy production tags while preview tags stop after quality checks", () => {
57130 const workflow = readFileSync(
58131 new URL("../../../.github/workflows/deploy-bunny.yml", import.meta.url),
@@ -4,6 +4,7 @@ import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync
4 import { tmpdir } from "node:os";4 import { tmpdir } from "node:os";
5 import { join } from "node:path";5 import { join } from "node:path";
6 import { buildConfigSite } from "../../../scripts/build-config-site.mjs";6 import { buildConfigSite } from "../../../scripts/build-config-site.mjs";
7+import { verifyConfigSite } from "../../../scripts/verify-config-site.mjs";
7 8
8 function fixture() {9 function fixture() {
9 const root = mkdtempSync(join(tmpdir(), "lapin-fute-config-site-"));10 const root = mkdtempSync(join(tmpdir(), "lapin-fute-config-site-"));
@@ -53,6 +54,78 @@ test("full configuration build still requires and copies a published catalog", (
53 }54 }
54 });55 });
55 56
57+test("configuration-site verification checks every local and served file on the current catalog revision", async () => {
58+ const paths = fixture();
59+ try {
60+ const revision = "a".repeat(64);
61+ const manifest = {
62+ schemaVersion: 1,
63+ revision,
64+ sourceRevision: "idfm-v1-fixture",
65+ createdAt: "2026-09-13T00:00:00.000Z",
66+ attribution: [{
67+ dataset: "fixture",
68+ url: "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/fixture",
69+ retrievedAt: "2026-09-13T00:00:00.000Z",
70+ license: "Licence fixture",
71+ }],
72+ };
73+ const files = new Map([
74+ ["index.html", "<h1>Lapin Futé</h1>"],
75+ ["catalog/manifest.json", JSON.stringify(manifest)],
76+ [`catalog/${revision}/search/a/0.json`, JSON.stringify({
77+ schemaVersion: 1, revision, page: 0, nextPage: null, places: [],
78+ })],
79+ [`catalog/${revision}/places/plc_${"a".repeat(43)}/0.json`, JSON.stringify({
80+ schemaVersion: 1, revision, placeId: `plc_${"a".repeat(43)}`, page: 0, nextPage: null, services: [],
81+ })],
82+ [`catalog/${revision}/services/svc_${"b".repeat(43)}.json`, JSON.stringify({
83+ schemaVersion: 1, revision, service: {},
84+ })],
85+ ]);
86+ for (const [relativePath, body] of files) {
87+ const path = join(paths.outputPath, relativePath);
88+ mkdirSync(join(path, ".."), { recursive: true });
89+ writeFileSync(path, body);
90+ }
91+
92+ const requested = [];
93+ const result = await verifyConfigSite({
94+ sitePath: paths.outputPath,
95+ origin: "https://config.example.test/lapin-fute/",
96+ fetcher: async (url) => {
97+ requested.push(url.href);
98+ const relativePath = decodeURIComponent(url.pathname.slice("/lapin-fute/".length));
99+ const body = files.get(relativePath);
100+ return body === undefined
101+ ? new Response(null, { status: 404 })
102+ : new Response(body, { status: 200 });
103+ },
104+ });
105+
106+ assert.equal(result.revision, revision);
107+ assert.equal(result.fileCount, files.size);
108+ assert.equal(result.catalogJsonCount, 3);
109+ assert.deepEqual(requested.sort(), [...files.keys()]
110+ .map((path) => `https://config.example.test/lapin-fute/${path}`)
111+ .sort());
112+
113+ files.set(`catalog/${revision}/services/svc_${"b".repeat(43)}.json`, "{}");
114+ await assert.rejects(
115+ verifyConfigSite({
116+ sitePath: paths.outputPath,
117+ origin: "https://config.example.test/lapin-fute/",
118+ fetcher: async (url) => new Response(files.get(
119+ decodeURIComponent(url.pathname.slice("/lapin-fute/".length)),
120+ ), { status: 200 }),
121+ }),
122+ /served bytes differ/u,
123+ );
124+ } finally {
125+ rmSync(paths.root, { recursive: true, force: true });
126+ }
127+});
128+
56 test("Bunny releases deploy production tags while preview tags stop after quality checks", () => {129 test("Bunny releases deploy production tags while preview tags stop after quality checks", () => {
57 const workflow = readFileSync(130 const workflow = readFileSync(
58 new URL("../../../.github/workflows/deploy-bunny.yml", import.meta.url),131 new URL("../../../.github/workflows/deploy-bunny.yml", import.meta.url),
added scripts/verify-config-site.mjs +161 -0
new file mode 100644
@@ -0,0 +1,161 @@
1+import { readdirSync, readFileSync, statSync } from "node:fs";
2+import { dirname, join, relative, resolve, sep } from "node:path";
3+import { fileURLToPath } from "node:url";
4+import { isCatalogManifest } from "../packages/config-page/src/catalog-client.js";
5+
6+const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
7+const DEFAULT_SITE_PATH = join(PROJECT_ROOT, "var/config-site");
8+const REQUIRED_CATALOG_GROUPS = ["search", "places", "services"];
9+const REMOTE_CONCURRENCY = 16;
10+
11+function fail(message) {
12+ throw new Error(`verify:config-site failed: ${message}`);
13+}
14+
15+function siteOrigin(value) {
16+ let parsed;
17+ try {
18+ parsed = new URL(value);
19+ } catch {
20+ fail("origin must be an absolute HTTPS URL");
21+ }
22+ if (parsed.protocol !== "https:"
23+ || parsed.username !== ""
24+ || parsed.password !== ""
25+ || parsed.search !== ""
26+ || parsed.hash !== "") {
27+ fail("origin must be an absolute HTTPS URL without credentials, query, or fragment");
28+ }
29+ if (!parsed.pathname.endsWith("/")) parsed.pathname += "/";
30+ return parsed;
31+}
32+
33+function filesUnder(root) {
34+ const files = [];
35+ function visit(directory) {
36+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
37+ const path = join(directory, entry.name);
38+ if (entry.isDirectory()) visit(path);
39+ else if (entry.isFile()) files.push(path);
40+ else fail(`unsupported site entry: ${relative(root, path)}`);
41+ }
42+ }
43+ visit(root);
44+ return files.sort();
45+}
46+
47+function parseJson(path, label) {
48+ try {
49+ return JSON.parse(readFileSync(path, "utf8"));
50+ } catch {
51+ fail(`${label} is not valid JSON`);
52+ }
53+}
54+
55+function verifyLocalSite(sitePath) {
56+ const root = resolve(sitePath);
57+ if (!statSync(root, { throwIfNoEntry: false })?.isDirectory()) {
58+ fail(`site directory does not exist: ${root}`);
59+ }
60+ const indexPath = join(root, "index.html");
61+ const manifestPath = join(root, "catalog", "manifest.json");
62+ if (!statSync(indexPath, { throwIfNoEntry: false })?.isFile()) fail("index.html is missing");
63+ if (!statSync(manifestPath, { throwIfNoEntry: false })?.isFile()) fail("catalog/manifest.json is missing");
64+
65+ const manifest = parseJson(manifestPath, "catalog manifest");
66+ if (!isCatalogManifest(manifest)) fail("catalog manifest does not satisfy the static catalog contract");
67+ const revisionRoot = join(root, "catalog", manifest.revision);
68+ if (!statSync(revisionRoot, { throwIfNoEntry: false })?.isDirectory()) {
69+ fail(`catalog revision directory is missing: ${manifest.revision}`);
70+ }
71+
72+ let catalogJsonCount = 0;
73+ for (const group of REQUIRED_CATALOG_GROUPS) {
74+ const groupRoot = join(revisionRoot, group);
75+ if (!statSync(groupRoot, { throwIfNoEntry: false })?.isDirectory()) {
76+ fail(`current catalog has no ${group} directory`);
77+ }
78+ const groupFiles = filesUnder(groupRoot).filter((path) => path.endsWith(".json"));
79+ if (groupFiles.length === 0) fail(`current catalog has no ${group} JSON`);
80+ for (const path of groupFiles) {
81+ const document = parseJson(path, relative(root, path));
82+ if (document?.schemaVersion !== manifest.schemaVersion || document?.revision !== manifest.revision) {
83+ fail(`${relative(root, path)} does not match the current schema and revision`);
84+ }
85+ catalogJsonCount += 1;
86+ }
87+ }
88+
89+ return {
90+ root,
91+ manifest,
92+ files: filesUnder(root),
93+ catalogJsonCount,
94+ };
95+}
96+
97+function remoteUrl(origin, root, path) {
98+ const parts = relative(root, path).split(sep);
99+ if (parts.some((part) => part === "" || part === "." || part === "..")) {
100+ fail(`unsafe site path: ${relative(root, path)}`);
101+ }
102+ return new URL(parts.map(encodeURIComponent).join("/"), origin);
103+}
104+
105+async function verifyServedFiles(local, origin, fetcher) {
106+ let next = 0;
107+ async function worker() {
108+ while (next < local.files.length) {
109+ const path = local.files[next];
110+ next += 1;
111+ const url = remoteUrl(origin, local.root, path);
112+ let response;
113+ try {
114+ response = await fetcher(url, {
115+ headers: { Accept: "*/*", "Cache-Control": "no-cache" },
116+ redirect: "error",
117+ });
118+ } catch {
119+ fail(`could not fetch ${url.href}`);
120+ }
121+ if (!response.ok) fail(`${url.href} returned HTTP ${response.status}`);
122+ const expected = readFileSync(path);
123+ const actual = Buffer.from(await response.arrayBuffer());
124+ if (!actual.equals(expected)) fail(`served bytes differ for ${url.href}`);
125+ }
126+ }
127+ await Promise.all(Array.from(
128+ { length: Math.min(REMOTE_CONCURRENCY, local.files.length) },
129+ () => worker(),
130+ ));
131+}
132+
133+export async function verifyConfigSite({
134+ sitePath = DEFAULT_SITE_PATH,
135+ origin,
136+ fetcher = globalThis.fetch,
137+} = {}) {
138+ const local = verifyLocalSite(sitePath);
139+ const parsedOrigin = siteOrigin(origin);
140+ await verifyServedFiles(local, parsedOrigin, fetcher);
141+ return {
142+ origin: parsedOrigin.href,
143+ revision: local.manifest.revision,
144+ sourceRevision: local.manifest.sourceRevision,
145+ fileCount: local.files.length,
146+ catalogJsonCount: local.catalogJsonCount,
147+ };
148+}
149+
150+if (process.argv[1] === fileURLToPath(import.meta.url)) {
151+ try {
152+ const result = await verifyConfigSite({
153+ sitePath: process.argv[2] ?? DEFAULT_SITE_PATH,
154+ origin: process.argv[3] ?? process.env.CONFIG_SITE_ORIGIN,
155+ });
156+ console.log(JSON.stringify(result, null, 2));
157+ } catch (error) {
158+ console.error(error instanceof Error ? error.message : error);
159+ process.exitCode = 1;
160+ }
161+}
new file mode 100644
@@ -0,0 +1,161 @@
1+import { readdirSync, readFileSync, statSync } from "node:fs";
2+import { dirname, join, relative, resolve, sep } from "node:path";
3+import { fileURLToPath } from "node:url";
4+import { isCatalogManifest } from "../packages/config-page/src/catalog-client.js";
5+
6+const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
7+const DEFAULT_SITE_PATH = join(PROJECT_ROOT, "var/config-site");
8+const REQUIRED_CATALOG_GROUPS = ["search", "places", "services"];
9+const REMOTE_CONCURRENCY = 16;
10+
11+function fail(message) {
12+ throw new Error(`verify:config-site failed: ${message}`);
13+}
14+
15+function siteOrigin(value) {
16+ let parsed;
17+ try {
18+ parsed = new URL(value);
19+ } catch {
20+ fail("origin must be an absolute HTTPS URL");
21+ }
22+ if (parsed.protocol !== "https:"
23+ || parsed.username !== ""
24+ || parsed.password !== ""
25+ || parsed.search !== ""
26+ || parsed.hash !== "") {
27+ fail("origin must be an absolute HTTPS URL without credentials, query, or fragment");
28+ }
29+ if (!parsed.pathname.endsWith("/")) parsed.pathname += "/";
30+ return parsed;
31+}
32+
33+function filesUnder(root) {
34+ const files = [];
35+ function visit(directory) {
36+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
37+ const path = join(directory, entry.name);
38+ if (entry.isDirectory()) visit(path);
39+ else if (entry.isFile()) files.push(path);
40+ else fail(`unsupported site entry: ${relative(root, path)}`);
41+ }
42+ }
43+ visit(root);
44+ return files.sort();
45+}
46+
47+function parseJson(path, label) {
48+ try {
49+ return JSON.parse(readFileSync(path, "utf8"));
50+ } catch {
51+ fail(`${label} is not valid JSON`);
52+ }
53+}
54+
55+function verifyLocalSite(sitePath) {
56+ const root = resolve(sitePath);
57+ if (!statSync(root, { throwIfNoEntry: false })?.isDirectory()) {
58+ fail(`site directory does not exist: ${root}`);
59+ }
60+ const indexPath = join(root, "index.html");
61+ const manifestPath = join(root, "catalog", "manifest.json");
62+ if (!statSync(indexPath, { throwIfNoEntry: false })?.isFile()) fail("index.html is missing");
63+ if (!statSync(manifestPath, { throwIfNoEntry: false })?.isFile()) fail("catalog/manifest.json is missing");
64+
65+ const manifest = parseJson(manifestPath, "catalog manifest");
66+ if (!isCatalogManifest(manifest)) fail("catalog manifest does not satisfy the static catalog contract");
67+ const revisionRoot = join(root, "catalog", manifest.revision);
68+ if (!statSync(revisionRoot, { throwIfNoEntry: false })?.isDirectory()) {
69+ fail(`catalog revision directory is missing: ${manifest.revision}`);
70+ }
71+
72+ let catalogJsonCount = 0;
73+ for (const group of REQUIRED_CATALOG_GROUPS) {
74+ const groupRoot = join(revisionRoot, group);
75+ if (!statSync(groupRoot, { throwIfNoEntry: false })?.isDirectory()) {
76+ fail(`current catalog has no ${group} directory`);
77+ }
78+ const groupFiles = filesUnder(groupRoot).filter((path) => path.endsWith(".json"));
79+ if (groupFiles.length === 0) fail(`current catalog has no ${group} JSON`);
80+ for (const path of groupFiles) {
81+ const document = parseJson(path, relative(root, path));
82+ if (document?.schemaVersion !== manifest.schemaVersion || document?.revision !== manifest.revision) {
83+ fail(`${relative(root, path)} does not match the current schema and revision`);
84+ }
85+ catalogJsonCount += 1;
86+ }
87+ }
88+
89+ return {
90+ root,
91+ manifest,
92+ files: filesUnder(root),
93+ catalogJsonCount,
94+ };
95+}
96+
97+function remoteUrl(origin, root, path) {
98+ const parts = relative(root, path).split(sep);
99+ if (parts.some((part) => part === "" || part === "." || part === "..")) {
100+ fail(`unsafe site path: ${relative(root, path)}`);
101+ }
102+ return new URL(parts.map(encodeURIComponent).join("/"), origin);
103+}
104+
105+async function verifyServedFiles(local, origin, fetcher) {
106+ let next = 0;
107+ async function worker() {
108+ while (next < local.files.length) {
109+ const path = local.files[next];
110+ next += 1;
111+ const url = remoteUrl(origin, local.root, path);
112+ let response;
113+ try {
114+ response = await fetcher(url, {
115+ headers: { Accept: "*/*", "Cache-Control": "no-cache" },
116+ redirect: "error",
117+ });
118+ } catch {
119+ fail(`could not fetch ${url.href}`);
120+ }
121+ if (!response.ok) fail(`${url.href} returned HTTP ${response.status}`);
122+ const expected = readFileSync(path);
123+ const actual = Buffer.from(await response.arrayBuffer());
124+ if (!actual.equals(expected)) fail(`served bytes differ for ${url.href}`);
125+ }
126+ }
127+ await Promise.all(Array.from(
128+ { length: Math.min(REMOTE_CONCURRENCY, local.files.length) },
129+ () => worker(),
130+ ));
131+}
132+
133+export async function verifyConfigSite({
134+ sitePath = DEFAULT_SITE_PATH,
135+ origin,
136+ fetcher = globalThis.fetch,
137+} = {}) {
138+ const local = verifyLocalSite(sitePath);
139+ const parsedOrigin = siteOrigin(origin);
140+ await verifyServedFiles(local, parsedOrigin, fetcher);
141+ return {
142+ origin: parsedOrigin.href,
143+ revision: local.manifest.revision,
144+ sourceRevision: local.manifest.sourceRevision,
145+ fileCount: local.files.length,
146+ catalogJsonCount: local.catalogJsonCount,
147+ };
148+}
149+
150+if (process.argv[1] === fileURLToPath(import.meta.url)) {
151+ try {
152+ const result = await verifyConfigSite({
153+ sitePath: process.argv[2] ?? DEFAULT_SITE_PATH,
154+ origin: process.argv[3] ?? process.env.CONFIG_SITE_ORIGIN,
155+ });
156+ console.log(JSON.stringify(result, null, 2));
157+ } catch (error) {
158+ console.error(error instanceof Error ? error.message : error);
159+ process.exitCode = 1;
160+ }
161+}