cinatra 0.1.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,679 @@
1
+ // Production required-extension acquisition.
2
+ //
3
+ // The prod base image needs the full "bootable set" of extension packages on
4
+ // disk at build time: every package declared in `cinatra.extensions`
5
+ // (root package.json). That set is the union of the genuine system packages
6
+ // and every extension package the host source still value-imports — see the
7
+ // coverage gate (scripts/audit/required-extensions-cover-host-imports.mjs),
8
+ // which keeps the declaration honest against the real import graph.
9
+ //
10
+ // Dev acquires those packages as tracking git clones (cinatra-dev-extensions);
11
+ // PROD must not depend on a git/gh binary or movable refs. This module
12
+ // downloads each package as a GitHub codeload tarball pinned to an immutable
13
+ // commit SHA — which removes the git-binary dependency, NOT the dependency on
14
+ // reaching github.com: a genuinely air-gapped build still needs a vendored
15
+ // bundle or a private mirror. Each tarball is verified against a committed
16
+ // lockfile,
17
+ // `cinatra-required-extensions.lock.json` (regenerated by
18
+ // scripts/extensions/update-required-extension-lock.mjs). Prod consumes ONLY
19
+ // the lock — never `main`/`latest`, never the version ranges.
20
+ //
21
+ // Integrity model (deliberately NOT `dist.cinatraSignature` — that signature
22
+ // covers the npm registry tarball used by the runtime marketplace install
23
+ // pipeline, a different artifact and a separate path that stays separate):
24
+ // 1. the download URL embeds the pinned commit SHA (an immutable ref);
25
+ // 2. the whole archive is inspected IN MEMORY before anything touches disk:
26
+ // entry types, paths, and sizes are validated (see below) and a
27
+ // deterministic content hash of the delivered file tree is computed and
28
+ // compared against the lock's `treeSha256`;
29
+ // 3. the extracted `package.json` must carry the locked `name` + `version`;
30
+ // 4. only then is the verified archive extracted (to a temp dir, renamed
31
+ // into place atomically, and stamped with a marker that later runs
32
+ // RE-VERIFY rather than trust).
33
+ //
34
+ // Archive hardening (fail-closed, never silently dropped): an entry that is
35
+ // not a plain file or directory (symlink, hardlink, FIFO, device), an
36
+ // absolute path, a `..` traversal segment, an entry outside the single
37
+ // archive root directory, or a reserved marker filename is a hard failure.
38
+ // Bounded resources: compressed size, decompressed size, per-file size, and
39
+ // entry count are all capped before extraction, so a hostile response cannot
40
+ // fill the disk.
41
+ //
42
+ // This module is data-driven end to end: package identities come from the
43
+ // lockfile, never from code (no host->extension coupling is added here).
44
+ // `tar` (a root dependency) is imported lazily AFTER the entry guards so
45
+ // `cinatra setup prod` inside the standalone runtime image — detected
46
+ // POSITIVELY by `server.js` + `.next/` at the root (Next's file tracing
47
+ // copies pnpm-workspace.yaml into the standalone output, so "no workspace
48
+ // file" alone is NOT a reliable standalone signal), where the `tar`
49
+ // dependency may not exist — skips cleanly.
50
+
51
+ import { createHash } from "node:crypto";
52
+ import {
53
+ chmodSync,
54
+ existsSync,
55
+ mkdirSync,
56
+ readFileSync,
57
+ readdirSync,
58
+ renameSync,
59
+ rmSync,
60
+ statSync,
61
+ writeFileSync,
62
+ } from "node:fs";
63
+ import path from "node:path";
64
+ import { createGunzip } from "node:zlib";
65
+
66
+ import { destDirForExtension } from "./cinatra-dev-extensions.mjs";
67
+
68
+ export const LOCK_FILENAME = "cinatra-required-extensions.lock.json";
69
+ export const ACQUISITION_MARKER_FILENAME = ".cinatra-acquired.json";
70
+
71
+ // Resource bounds. Extension repos are small (single-digit MiB); these caps
72
+ // are generous headroom, not tuning knobs — they exist so a compromised or
73
+ // malfunctioning endpoint cannot exhaust memory/disk before the integrity
74
+ // check fails the run.
75
+ export const MAX_COMPRESSED_BYTES = 64 * 1024 * 1024;
76
+ export const MAX_DECOMPRESSED_BYTES = 256 * 1024 * 1024;
77
+ export const MAX_FILE_BYTES = 64 * 1024 * 1024;
78
+ export const MAX_ENTRY_COUNT = 20_000;
79
+ const DOWNLOAD_TIMEOUT_MS = 120_000;
80
+
81
+ const SCOPED_PKG_RE = /^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/;
82
+ const REPO_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
83
+ const COMMIT_SHA_RE = /^[0-9a-f]{40}$/;
84
+ const SHA256_RE = /^[0-9a-f]{64}$/;
85
+ const CONCRETE_VERSION_RE = /^\d+\.\d+\.\d+$/;
86
+
87
+ /**
88
+ * Read + strictly validate the committed lock. Every entry must carry a
89
+ * well-formed scoped package name, an `owner/repo` slug, a 40-hex commit SHA,
90
+ * a concrete x.y.z version, and a 64-hex tree hash; duplicates are rejected.
91
+ * Throws (listing every defect) rather than skipping — prod consumes ONLY
92
+ * this file, so a malformed lock must never half-acquire.
93
+ */
94
+ export function readRequiredExtensionsLock(lockPath) {
95
+ if (!existsSync(lockPath)) {
96
+ throw new Error(
97
+ `[prod-extension-acquisition] lockfile not found: ${lockPath}. The committed ` +
98
+ `${LOCK_FILENAME} is the ONLY source prod acquires from — regenerate it with ` +
99
+ `\`node scripts/extensions/update-required-extension-lock.mjs\` and commit it.`,
100
+ );
101
+ }
102
+ let doc;
103
+ try {
104
+ doc = JSON.parse(readFileSync(lockPath, "utf8"));
105
+ } catch (err) {
106
+ throw new Error(`[prod-extension-acquisition] lockfile ${lockPath} is not valid JSON: ${err.message}`);
107
+ }
108
+ const packages = Array.isArray(doc?.packages) ? doc.packages : null;
109
+ if (!packages || packages.length === 0) {
110
+ throw new Error(
111
+ `[prod-extension-acquisition] lockfile ${lockPath} has no "packages" entries — refusing to continue.`,
112
+ );
113
+ }
114
+ const defects = [];
115
+ const seen = new Set();
116
+ for (const [i, p] of packages.entries()) {
117
+ const tag = `packages[${i}]${p && typeof p.packageName === "string" ? ` (${p.packageName})` : ""}`;
118
+ if (!p || typeof p !== "object") {
119
+ defects.push(`${tag}: not an object`);
120
+ continue;
121
+ }
122
+ if (typeof p.packageName !== "string" || !SCOPED_PKG_RE.test(p.packageName)) {
123
+ defects.push(`${tag}: packageName must be a lowercase @scope/name`);
124
+ } else if (seen.has(p.packageName)) {
125
+ defects.push(`${tag}: duplicate packageName`);
126
+ } else {
127
+ seen.add(p.packageName);
128
+ }
129
+ if (typeof p.repo !== "string" || !REPO_SLUG_RE.test(p.repo) || p.repo.includes("..")) {
130
+ defects.push(`${tag}: repo must be an "owner/name" GitHub slug`);
131
+ }
132
+ if (typeof p.resolvedSha !== "string" || !COMMIT_SHA_RE.test(p.resolvedSha)) {
133
+ defects.push(`${tag}: resolvedSha must be a 40-hex lowercase commit SHA`);
134
+ }
135
+ if (typeof p.packageVersion !== "string" || !CONCRETE_VERSION_RE.test(p.packageVersion)) {
136
+ defects.push(`${tag}: packageVersion must be a concrete x.y.z version`);
137
+ }
138
+ if (typeof p.treeSha256 !== "string" || !SHA256_RE.test(p.treeSha256)) {
139
+ defects.push(`${tag}: treeSha256 must be a 64-hex lowercase sha256`);
140
+ }
141
+ }
142
+ if (defects.length > 0) {
143
+ throw new Error(
144
+ `[prod-extension-acquisition] lockfile ${lockPath} failed validation:\n - ${defects.join("\n - ")}`,
145
+ );
146
+ }
147
+ return { schemaVersion: doc.schemaVersion ?? null, packages };
148
+ }
149
+
150
+ /**
151
+ * The declared `cinatra.extensions` package NAMES (ranges stripped —
152
+ * the same last-`@` split as the canonical host parser in
153
+ * packages/extensions/src/required-in-prod.ts). Returns an empty set when the
154
+ * manifest or block is absent/unreadable (the caller treats that as
155
+ * "nothing to cross-check", never as an acquisition failure).
156
+ */
157
+ export function readDeclaredRequiredExtensionNames(packageJsonPath) {
158
+ try {
159
+ const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8"));
160
+ const raw = Array.isArray(pkg?.cinatra?.extensions) ? pkg.cinatra.extensions : [];
161
+ const names = new Set();
162
+ for (const entry of raw) {
163
+ if (typeof entry !== "string" || entry.trim().length === 0) continue;
164
+ const trimmed = entry.trim();
165
+ const at = trimmed.lastIndexOf("@");
166
+ names.add(at <= 0 ? trimmed : trimmed.slice(0, at));
167
+ }
168
+ return names;
169
+ } catch {
170
+ return new Set();
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Canonical tree-hash fold shared by every producer (archive inspection,
176
+ * disk re-verification, the lock regenerator). Input records are
177
+ * `{ relPath, executable, sha256 }` for REGULAR FILES ONLY; the fold sorts by
178
+ * relPath and hashes `<relPath>\n<gitMode>\n<contentSha256>\n` per file, so
179
+ * the result is independent of archive entry order and filesystem walk order.
180
+ * Modes are normalized to git's two file modes (100755 when the owner-exec
181
+ * bit is set, else 100644) so umask differences cannot shift the hash.
182
+ * Directories (incl. empty ones) are not hashed — git archives do not
183
+ * meaningfully carry them.
184
+ */
185
+ export function foldTreeHash(records) {
186
+ const sorted = [...records].sort((a, b) => (a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0));
187
+ const hash = createHash("sha256");
188
+ for (const r of sorted) {
189
+ hash.update(`${r.relPath}\n${r.executable ? "100755" : "100644"}\n${r.sha256}\n`);
190
+ }
191
+ return hash.digest("hex");
192
+ }
193
+
194
+ /**
195
+ * Compute the canonical tree hash of an on-disk package directory (the
196
+ * marker-hit RE-VERIFICATION path). The acquisition marker at the package
197
+ * root is excluded (it is written by us after verification, never part of
198
+ * the upstream tree). Any directory entry that is not a regular file or a
199
+ * directory (e.g. a symlink introduced after acquisition) is a hard error —
200
+ * a verified tree never contains one.
201
+ */
202
+ export function computeTreeSha256FromDir(rootDir) {
203
+ const records = [];
204
+ const walk = (dir) => {
205
+ for (const dirent of readdirSync(dir, { withFileTypes: true })) {
206
+ const full = path.join(dir, dirent.name);
207
+ const rel = path.relative(rootDir, full).split(path.sep).join("/");
208
+ if (rel === ACQUISITION_MARKER_FILENAME) continue;
209
+ if (dirent.isDirectory()) {
210
+ walk(full);
211
+ } else if (dirent.isFile()) {
212
+ const st = statSync(full);
213
+ if (st.size > MAX_FILE_BYTES) {
214
+ throw new Error(`[prod-extension-acquisition] ${rel} exceeds the per-file size bound`);
215
+ }
216
+ records.push({
217
+ relPath: rel,
218
+ executable: (st.mode & 0o100) !== 0,
219
+ sha256: createHash("sha256").update(readFileSync(full)).digest("hex"),
220
+ });
221
+ } else {
222
+ throw new Error(
223
+ `[prod-extension-acquisition] unexpected non-regular entry in acquired tree: ${rel} ` +
224
+ `(symlinks/devices are never part of a verified acquisition)`,
225
+ );
226
+ }
227
+ }
228
+ };
229
+ walk(rootDir);
230
+ return foldTreeHash(records);
231
+ }
232
+
233
+ /** Decompress a gzip buffer with a hard output bound (zip-bomb guard). */
234
+ export function gunzipBounded(gzBuffer, maxBytes = MAX_DECOMPRESSED_BYTES) {
235
+ return new Promise((resolvePromise, reject) => {
236
+ const gunzip = createGunzip();
237
+ const chunks = [];
238
+ let total = 0;
239
+ gunzip.on("data", (chunk) => {
240
+ total += chunk.length;
241
+ if (total > maxBytes) {
242
+ gunzip.destroy();
243
+ reject(
244
+ new Error(`[prod-extension-acquisition] decompressed archive exceeds the ${maxBytes}-byte bound`),
245
+ );
246
+ return;
247
+ }
248
+ chunks.push(chunk);
249
+ });
250
+ gunzip.on("error", (err) => reject(new Error(`[prod-extension-acquisition] gunzip failed: ${err.message}`)));
251
+ gunzip.on("end", () => resolvePromise(Buffer.concat(chunks)));
252
+ gunzip.end(gzBuffer);
253
+ });
254
+ }
255
+
256
+ /** Download a URL into a buffer with a size bound and timeout; fail loud. */
257
+ export async function downloadBounded(url, { fetchImpl = globalThis.fetch, maxBytes = MAX_COMPRESSED_BYTES } = {}) {
258
+ let res;
259
+ try {
260
+ res = await fetchImpl(url, { redirect: "follow", signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
261
+ } catch (err) {
262
+ throw new Error(`[prod-extension-acquisition] fetch ${url} failed: ${err.message}`);
263
+ }
264
+ if (!res.ok || !res.body) {
265
+ throw new Error(
266
+ `[prod-extension-acquisition] fetch ${url} failed: HTTP ${res.status} ${res.statusText ?? ""}`.trim(),
267
+ );
268
+ }
269
+ const declared = Number(res.headers?.get?.("content-length") ?? 0);
270
+ if (declared > maxBytes) {
271
+ throw new Error(`[prod-extension-acquisition] ${url}: declared size ${declared} exceeds the ${maxBytes}-byte bound`);
272
+ }
273
+ const chunks = [];
274
+ let total = 0;
275
+ for await (const chunk of res.body) {
276
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
277
+ total += buf.length;
278
+ if (total > maxBytes) {
279
+ throw new Error(`[prod-extension-acquisition] ${url}: download exceeds the ${maxBytes}-byte bound`);
280
+ }
281
+ chunks.push(buf);
282
+ }
283
+ return Buffer.concat(chunks);
284
+ }
285
+
286
+ /**
287
+ * Validate one raw tar entry path against the hardening rules. Returns
288
+ * `{ stripped }` (the path with the single archive-root directory removed,
289
+ * "" for the root directory itself) or records a violation. Exported for
290
+ * direct unit testing of the path rules.
291
+ */
292
+ export function classifyEntryPath(rawPath) {
293
+ const normalized = String(rawPath).replace(/^\.\//, "");
294
+ if (normalized.startsWith("/") || /^[A-Za-z]:[\\/]/.test(normalized) || normalized.startsWith("\\")) {
295
+ return { violation: "absolute path" };
296
+ }
297
+ const segments = normalized.split("/").filter((s) => s.length > 0);
298
+ if (segments.some((s) => s === "..")) {
299
+ return { violation: "path traversal (`..` segment)" };
300
+ }
301
+ if (segments.length === 0) {
302
+ return { violation: "empty path" };
303
+ }
304
+ return { stripped: segments.slice(1).join("/") };
305
+ }
306
+
307
+ /**
308
+ * PASS 1 — inspect the (already size-bounded, decompressed) tar buffer
309
+ * entirely in memory: enforce the entry hardening rules, compute the
310
+ * canonical tree hash, and capture the root `package.json` bytes. Nothing is
311
+ * written to disk; a violation list (never a silent drop) comes back to the
312
+ * caller, which must treat ANY violation as fatal.
313
+ */
314
+ export async function inspectTarball(tarBuffer, { tar }) {
315
+ const violations = [];
316
+ const records = [];
317
+ let entryCount = 0;
318
+ let packageJsonRaw = null;
319
+
320
+ const parser = tar.t({
321
+ onReadEntry: (entry) => {
322
+ entryCount += 1;
323
+ if (entryCount > MAX_ENTRY_COUNT) {
324
+ violations.push(`entry count exceeds ${MAX_ENTRY_COUNT}`);
325
+ return;
326
+ }
327
+ const { stripped, violation } = classifyEntryPath(entry.path);
328
+ if (violation) {
329
+ violations.push(`${entry.path}: ${violation}`);
330
+ return;
331
+ }
332
+ // Mode hardening. Git stores only the owner-exec distinction; the
333
+ // archive writer's umask decides the rest (GitHub codeload emits
334
+ // 664/775). setuid/setgid/sticky bits, however, can NEVER come from a
335
+ // git tree — an archive carrying one is hostile and is rejected here;
336
+ // everything else is NORMALIZED to the canonical 0644/0755 at
337
+ // extraction (see extractVerifiedTarball), so the applied metadata is
338
+ // exactly what the tree hash describes.
339
+ const perms = (entry.mode ?? 0) & 0o7777;
340
+ if ((perms & 0o7000) !== 0) {
341
+ violations.push(
342
+ `${entry.path}: forbidden special mode bits in ${perms.toString(8)} ` +
343
+ `(setuid/setgid/sticky can never originate from a git tree)`,
344
+ );
345
+ return;
346
+ }
347
+ if (entry.type === "Directory") {
348
+ return; // directories (incl. the single archive root) carry no content
349
+ }
350
+ if (stripped === "") {
351
+ violations.push(`${entry.path}: non-directory entry at the archive root`);
352
+ return;
353
+ }
354
+ if (entry.type !== "File") {
355
+ violations.push(`${entry.path}: forbidden entry type "${entry.type}" (symlink/hardlink/device/FIFO)`);
356
+ return;
357
+ }
358
+ if (stripped === ACQUISITION_MARKER_FILENAME) {
359
+ violations.push(`${entry.path}: reserved acquisition-marker filename in archive`);
360
+ return;
361
+ }
362
+ if (typeof entry.size === "number" && entry.size > MAX_FILE_BYTES) {
363
+ violations.push(`${entry.path}: file exceeds the per-file size bound`);
364
+ return;
365
+ }
366
+ const contentHash = createHash("sha256");
367
+ const wantPackageJson = stripped === "package.json";
368
+ const packageJsonChunks = wantPackageJson ? [] : null;
369
+ entry.on("data", (chunk) => {
370
+ contentHash.update(chunk);
371
+ if (packageJsonChunks) packageJsonChunks.push(Buffer.from(chunk));
372
+ });
373
+ entry.on("end", () => {
374
+ records.push({
375
+ relPath: stripped,
376
+ executable: ((entry.mode ?? 0) & 0o100) !== 0,
377
+ sha256: contentHash.digest("hex"),
378
+ });
379
+ if (packageJsonChunks) packageJsonRaw = Buffer.concat(packageJsonChunks).toString("utf8");
380
+ });
381
+ },
382
+ });
383
+
384
+ await new Promise((resolvePromise, reject) => {
385
+ parser.on("error", (err) => reject(new Error(`[prod-extension-acquisition] tar parse failed: ${err.message}`)));
386
+ // node-tar list/parse streams emit "end"; "finish" is not reliably emitted.
387
+ parser.on("end", resolvePromise);
388
+ parser.end(tarBuffer);
389
+ });
390
+
391
+ return { records, entryCount, packageJsonRaw, violations };
392
+ }
393
+
394
+ /**
395
+ * PASS 2 — extract the ALREADY-VERIFIED tar buffer into `destDir`. The same
396
+ * entry rules are enforced again as defense in depth (filter excludes; pass 1
397
+ * is the authority that FAILS the run).
398
+ */
399
+ export async function extractVerifiedTarball(tarBuffer, destDir, { tar }) {
400
+ mkdirSync(destDir, { recursive: true });
401
+ await new Promise((resolvePromise, reject) => {
402
+ const extractor = tar.x({
403
+ cwd: destDir,
404
+ strip: 1,
405
+ // Never apply archive uid/gid: node-tar PRESERVES ownership when the
406
+ // process runs as root (the Docker build does) unless told otherwise.
407
+ preserveOwner: false,
408
+ // Normalize every applied mode to the canonical git pair (0755 dirs,
409
+ // 0644/0755 files keyed on the owner-exec bit) — the archive writer's
410
+ // umask (codeload emits 664/775) and any other permission noise never
411
+ // reach disk, so the applied metadata is exactly what the tree hash
412
+ // describes. Special bits were already rejected during inspection.
413
+ onReadEntry: (entry) => {
414
+ entry.mode = entry.type === "Directory" ? 0o755 : ((entry.mode ?? 0) & 0o100) !== 0 ? 0o755 : 0o644;
415
+ },
416
+ filter: (entryPath, entry) => {
417
+ const { stripped, violation } = classifyEntryPath(entryPath);
418
+ if (violation) return false;
419
+ if (((entry.mode ?? 0) & 0o7000) !== 0) return false;
420
+ if (entry.type === "Directory") return true;
421
+ return entry.type === "File" && stripped !== "" && stripped !== ACQUISITION_MARKER_FILENAME;
422
+ },
423
+ });
424
+ extractor.on("error", (err) => reject(new Error(`[prod-extension-acquisition] extraction failed: ${err.message}`)));
425
+ extractor.on("finish", resolvePromise);
426
+ extractor.end(tarBuffer);
427
+ });
428
+ // node-tar applies the process umask at file creation, so the normalized
429
+ // entry modes can land narrower on disk (e.g. 0600 under umask 077) —
430
+ // harmless but not canonical. Walk the extracted tree once and chmod every
431
+ // node to the exact git pair keyed on the (umask-surviving) owner-exec
432
+ // bit, so the applied modes are deterministic for any builder umask.
433
+ // (A umask hostile enough to strip owner bits changes the exec-bit hash
434
+ // input and fails the re-hash that follows extraction — fail closed.)
435
+ const normalizeModes = (dir) => {
436
+ for (const dirent of readdirSync(dir, { withFileTypes: true })) {
437
+ const full = path.join(dir, dirent.name);
438
+ if (dirent.isDirectory()) {
439
+ chmodSync(full, 0o755);
440
+ normalizeModes(full);
441
+ } else if (dirent.isFile()) {
442
+ chmodSync(full, (statSync(full).mode & 0o100) !== 0 ? 0o755 : 0o644);
443
+ }
444
+ }
445
+ };
446
+ normalizeModes(destDir);
447
+ }
448
+
449
+ function readMarker(destDir) {
450
+ const markerPath = path.join(destDir, ACQUISITION_MARKER_FILENAME);
451
+ if (!existsSync(markerPath)) return null;
452
+ try {
453
+ const m = JSON.parse(readFileSync(markerPath, "utf8"));
454
+ return m && typeof m === "object" ? m : null;
455
+ } catch {
456
+ return null;
457
+ }
458
+ }
459
+
460
+ function verifyPackageManifest(rawPackageJson, lockEntry, label) {
461
+ if (typeof rawPackageJson !== "string" || rawPackageJson.length === 0) {
462
+ throw new Error(`[prod-extension-acquisition] ${label}: archive carries no root package.json`);
463
+ }
464
+ let manifest;
465
+ try {
466
+ manifest = JSON.parse(rawPackageJson);
467
+ } catch (err) {
468
+ throw new Error(`[prod-extension-acquisition] ${label}: root package.json is not valid JSON: ${err.message}`);
469
+ }
470
+ if (manifest.name !== lockEntry.packageName) {
471
+ throw new Error(
472
+ `[prod-extension-acquisition] ${label}: package.json name "${manifest.name}" does not match the locked ` +
473
+ `packageName "${lockEntry.packageName}"`,
474
+ );
475
+ }
476
+ if (manifest.version !== lockEntry.packageVersion) {
477
+ throw new Error(
478
+ `[prod-extension-acquisition] ${label}: package.json version "${manifest.version}" does not match the ` +
479
+ `locked packageVersion "${lockEntry.packageVersion}"`,
480
+ );
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Acquire every package in the committed lock into `extensions/<scope>/<name>`.
486
+ *
487
+ * - In a non-workspace root (the Next standalone runtime image, where the
488
+ * extension source was baked in at image build), returns
489
+ * `{ skipped: true, reason: "not-a-workspace" }` without importing `tar`.
490
+ * - Idempotent: a directory stamped by a marker matching the lock is
491
+ * RE-VERIFIED (tree hash + manifest) and skipped; a marker that matches a
492
+ * STALE lock entry triggers a clean re-acquisition; a directory WITHOUT a
493
+ * marker (e.g. a dev git clone) is a hard error — this routine never
494
+ * clobbers a tree it does not own.
495
+ * - Fail-fast and loud on network errors, HTTP failures, unsafe archives,
496
+ * hash/manifest mismatches. A failed run leaves previously verified
497
+ * packages in place (markers make a re-run resume cheaply).
498
+ *
499
+ * Returns `{ results: [{ pkgName, action, changed, dest }] }`, the shape
500
+ * `installAfterExtensionSync` consumes ("downloaded" counts as materially
501
+ * changed; "verified-existing" does not).
502
+ */
503
+ export async function acquireProdRequiredExtensions({
504
+ repoRoot,
505
+ lockPath,
506
+ fetchImpl = globalThis.fetch,
507
+ log = console.log,
508
+ } = {}) {
509
+ if (!repoRoot) throw new Error("[prod-extension-acquisition] repoRoot is required");
510
+ // Standalone runtime image — POSITIVE detection, checked FIRST. Next's
511
+ // output-file tracing mirrors the project root into .next/standalone
512
+ // INCLUDING pnpm-workspace.yaml (so "workspace file absent" is NOT a
513
+ // reliable standalone detector) and INCLUDING the host-imported extension
514
+ // sources WITHOUT their acquisition markers (tracing copies only the
515
+ // imported files) — running the acquisition there would refuse on
516
+ // "exists but is not acquisition-managed" and brick `setup prod` on a
517
+ // perfectly good image (caught by scripts/ci/prod-boot-e2e.sh). The
518
+ // standalone root is identified by the traced server entry `server.js`
519
+ // sitting NEXT TO `.next/` — true only for the standalone output (a real
520
+ // repo root has no root-level server.js; the build stage's standalone
521
+ // output lives nested under .next/standalone/, not at the build root).
522
+ if (
523
+ existsSync(path.join(repoRoot, "server.js")) &&
524
+ existsSync(path.join(repoRoot, ".next"))
525
+ ) {
526
+ log(
527
+ "- Required-extension acquisition: skipped (standalone runtime image — the extension " +
528
+ "source was baked and verified at image build time).",
529
+ );
530
+ return { skipped: true, reason: "standalone-runtime-image" };
531
+ }
532
+ if (!existsSync(path.join(repoRoot, "pnpm-workspace.yaml"))) {
533
+ log(
534
+ "- Required-extension acquisition: skipped (no pnpm workspace at this root — the standalone " +
535
+ "runtime image bakes the extension source at image build time).",
536
+ );
537
+ return { skipped: true, reason: "not-a-workspace" };
538
+ }
539
+
540
+ const lock = readRequiredExtensionsLock(lockPath ?? path.join(repoRoot, LOCK_FILENAME));
541
+
542
+ // The lock must match the declaration it was generated from. Enforcing the
543
+ // bijection HERE (not only in the CI coverage gate) means the image build
544
+ // itself fails on a drifted lock — the publish path cannot outrun a
545
+ // forgotten `update-required-extension-lock` regeneration. Only a root with
546
+ // NO package.json at all skips the cross-check (unit-test scratch roots);
547
+ // a real workspace manifest that declares nothing while the lock pins
548
+ // packages is itself drift and fails loud.
549
+ const rootManifestPath = path.join(repoRoot, "package.json");
550
+ if (existsSync(rootManifestPath)) {
551
+ const declared = readDeclaredRequiredExtensionNames(rootManifestPath);
552
+ const lockedNames = new Set(lock.packages.map((p) => p.packageName));
553
+ const missingFromLock = [...declared].filter((n) => !lockedNames.has(n)).sort();
554
+ const staleInLock = [...lockedNames].filter((n) => !declared.has(n)).sort();
555
+ if (missingFromLock.length > 0 || staleInLock.length > 0) {
556
+ throw new Error(
557
+ `[prod-extension-acquisition] the acquisition lock does not match cinatra.extensions:` +
558
+ (missingFromLock.length ? `\n declared but not locked: ${missingFromLock.join(", ")}` : "") +
559
+ (staleInLock.length ? `\n locked but not declared: ${staleInLock.join(", ")}` : "") +
560
+ `\nRegenerate with \`node scripts/extensions/update-required-extension-lock.mjs\` and commit the lock.`,
561
+ );
562
+ }
563
+ }
564
+
565
+ // Lazy: `tar` is a root workspace dependency, intentionally not resolvable
566
+ // in the standalone runtime image (the guard above returns first there).
567
+ const tar = await import("tar");
568
+
569
+ const results = [];
570
+ let downloaded = 0;
571
+ let verified = 0;
572
+ log(`- Required-extension acquisition: ${lock.packages.length} locked package(s)…`);
573
+
574
+ for (const entry of lock.packages) {
575
+ const dest = destDirForExtension(entry.packageName, {}, repoRoot);
576
+ const label = `${entry.packageName}@${entry.packageVersion} (${entry.repo}#${entry.resolvedSha.slice(0, 12)})`;
577
+
578
+ if (existsSync(dest)) {
579
+ const marker = readMarker(dest);
580
+ if (!marker) {
581
+ throw new Error(
582
+ `[prod-extension-acquisition] ${dest} exists but is not acquisition-managed (no ` +
583
+ `${ACQUISITION_MARKER_FILENAME}). Refusing to overwrite — if this is a dev checkout, use the dev ` +
584
+ `setup flow; otherwise remove the directory and re-run.`,
585
+ );
586
+ }
587
+ if (marker.resolvedSha === entry.resolvedSha && marker.treeSha256 === entry.treeSha256) {
588
+ // Marker hit is a CLAIM, not proof — re-verify content before trusting.
589
+ const actualTreeSha = computeTreeSha256FromDir(dest);
590
+ if (actualTreeSha !== entry.treeSha256) {
591
+ throw new Error(
592
+ `[prod-extension-acquisition] ${label}: on-disk tree hash ${actualTreeSha} does not match the ` +
593
+ `locked treeSha256 ${entry.treeSha256} (content changed after acquisition). Remove ${dest} and re-run.`,
594
+ );
595
+ }
596
+ const manifestPath = path.join(dest, "package.json");
597
+ verifyPackageManifest(existsSync(manifestPath) ? readFileSync(manifestPath, "utf8") : "", entry, label);
598
+ results.push({ pkgName: entry.packageName, action: "verified-existing", changed: false, dest });
599
+ verified += 1;
600
+ continue;
601
+ }
602
+ // Acquisition-managed but pinned elsewhere: the lock moved — re-acquire.
603
+ // The stale tree is NOT removed yet: it stays in place until the
604
+ // replacement has been fully downloaded and verified, so a transient
605
+ // network/integrity failure cannot leave the slot empty.
606
+ }
607
+
608
+ const url = `https://codeload.github.com/${entry.repo}/tar.gz/${entry.resolvedSha}`;
609
+ log(` - ${label}: downloading…`);
610
+ const gzBuffer = await downloadBounded(url, { fetchImpl });
611
+ const tarBuffer = await gunzipBounded(gzBuffer);
612
+ const { records, packageJsonRaw, violations } = await inspectTarball(tarBuffer, { tar });
613
+ if (violations.length > 0) {
614
+ throw new Error(
615
+ `[prod-extension-acquisition] ${label}: unsafe archive from ${url}:\n - ${violations.join("\n - ")}`,
616
+ );
617
+ }
618
+ const treeSha = foldTreeHash(records);
619
+ if (treeSha !== entry.treeSha256) {
620
+ throw new Error(
621
+ `[prod-extension-acquisition] ${label}: tree hash mismatch for ${url}\n expected ${entry.treeSha256}\n` +
622
+ ` actual ${treeSha}\nThe pinned content changed or the response was tampered with — refusing to install.`,
623
+ );
624
+ }
625
+ verifyPackageManifest(packageJsonRaw, entry, label);
626
+
627
+ // Verified in memory — now (and only now) touch disk: extract to a temp
628
+ // sibling, RE-HASH what actually landed (closes any divergence between
629
+ // inspection and extraction), stamp the marker, then swap into place.
630
+ const tmpDir = path.join(repoRoot, "extensions", `.acquire-tmp-${process.pid}-${downloaded}`);
631
+ rmSync(tmpDir, { recursive: true, force: true });
632
+ try {
633
+ await extractVerifiedTarball(tarBuffer, tmpDir, { tar });
634
+ const extractedTreeSha = computeTreeSha256FromDir(tmpDir);
635
+ if (extractedTreeSha !== entry.treeSha256) {
636
+ throw new Error(
637
+ `[prod-extension-acquisition] ${label}: extracted tree hash ${extractedTreeSha} does not match the ` +
638
+ `locked treeSha256 ${entry.treeSha256} — refusing to install.`,
639
+ );
640
+ }
641
+ writeFileSync(
642
+ path.join(tmpDir, ACQUISITION_MARKER_FILENAME),
643
+ JSON.stringify(
644
+ { resolvedSha: entry.resolvedSha, treeSha256: entry.treeSha256, acquiredAt: new Date().toISOString() },
645
+ null,
646
+ 2,
647
+ ) + "\n",
648
+ );
649
+ mkdirSync(path.dirname(dest), { recursive: true });
650
+ // Swap. The previously verified (now stale-pinned) tree is renamed
651
+ // ASIDE — not deleted — so a failed rename into place restores it: a
652
+ // failed replacement can never leave the slot empty. The aside dir is
653
+ // dot-prefixed (like the tmp dir) so workspace globs never see it as a
654
+ // package; it is removed once the new tree is in place.
655
+ const asideDir = path.join(repoRoot, "extensions", `.acquire-old-${process.pid}-${downloaded}`);
656
+ rmSync(asideDir, { recursive: true, force: true });
657
+ let movedOldAside = false;
658
+ if (existsSync(dest)) {
659
+ renameSync(dest, asideDir);
660
+ movedOldAside = true;
661
+ }
662
+ try {
663
+ renameSync(tmpDir, dest);
664
+ } catch (renameErr) {
665
+ if (movedOldAside) renameSync(asideDir, dest); // restore the old verified tree
666
+ throw renameErr;
667
+ }
668
+ if (movedOldAside) rmSync(asideDir, { recursive: true, force: true });
669
+ } catch (err) {
670
+ rmSync(tmpDir, { recursive: true, force: true });
671
+ throw err;
672
+ }
673
+ results.push({ pkgName: entry.packageName, action: "downloaded", changed: true, dest });
674
+ downloaded += 1;
675
+ }
676
+
677
+ log(`- Required-extension acquisition: OK (${downloaded} downloaded, ${verified} verified in place).`);
678
+ return { results };
679
+ }