@bygghammar/cms-platform 0.91.1 → 0.92.1

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,832 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Review what an upgrade to contract v3 would do to a site's public pages, and
4
+ * turn the answers into something the migration will accept.
5
+ *
6
+ * 0.90.0-m7.12 and 0.91.1 read publication rows differently. The old reader
7
+ * dropped closed rows before picking the newest; the new one picks the newest
8
+ * row that has started and only then asks whether it is still open. Where a
9
+ * page has an expired row newer than its open one, the new reader serves
10
+ * nothing and the page goes dark. Most pages are unaffected, some the migration
11
+ * can settle from the data alone, and a few cannot be settled without somebody
12
+ * saying what the page is supposed to be doing. This is where that somebody
13
+ * says it.
14
+ *
15
+ * cms-review scan what is here, and what it would become
16
+ * cms-review show the same, read back from the review file
17
+ * cms-review resolve <key> <what> record an answer for one page
18
+ * cms-review approve seal the answers against this database
19
+ * cms-review options the startup options the migration reads
20
+ * cms-review record the same approval as an installable record
21
+ *
22
+ * An approval reaches a database by one of two roads, and both carry the same
23
+ * fields in the same spellings because both are printed from one description of
24
+ * the approval. `options` is the direct road: a connection this machine makes
25
+ * itself. `record` is the road onto a server nobody connects to from here --
26
+ * the file it prints is carried over, installed by an administrator with
27
+ * install-lovable-migration-approval, and replayed into the connection that
28
+ * applies the migration.
29
+ *
30
+ * The database to read comes from CMS_REVIEW_DSN, or DATABASE_URL. It is never
31
+ * passed on a command line: a connection string in argv is a password in every
32
+ * `ps` on the machine. A read-only role is enough for scan; nothing here writes.
33
+ *
34
+ * Typical run:
35
+ *
36
+ * export CMS_REVIEW_DSN=postgres://...
37
+ * cms-review scan
38
+ * cms-review resolve marketing-offer leave-hidden --by you@example.com
39
+ * cms-review approve --site-key voyara --environment production \
40
+ * --release-sha "$GITHUB_SHA" --by you@example.com
41
+ * PGOPTIONS="$(cms-review options)" psql -f 20260905120000_....sql
42
+ *
43
+ * or, for a server this machine cannot reach:
44
+ *
45
+ * cms-review record > 20260905120000_....sql.approval
46
+ * # carry it over, then, on the host:
47
+ * sudo install-lovable-migration-approval <site> <environment> <file>
48
+ *
49
+ * The approval binds itself to this database, to this migration file, to the
50
+ * exact rows the scan saw and to the moment it was evaluated. The migration
51
+ * checks all of that again on the other side, because the helper and the
52
+ * database can disagree and the database is the one that knows.
53
+ */
54
+ import { spawnSync } from "node:child_process";
55
+ import { createHash, randomUUID } from "node:crypto";
56
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
57
+ import { dirname, join, resolve } from "node:path";
58
+ import { fileURLToPath } from "node:url";
59
+
60
+ const here = dirname(fileURLToPath(import.meta.url));
61
+ const root = resolve(here, "..");
62
+
63
+ /* ------------------------------------------------------------------ saying */
64
+
65
+ const bail = (...lines) => {
66
+ for (const line of lines) console.error(line);
67
+ process.exit(1);
68
+ };
69
+
70
+ /* ---------------------------------------------------- the shared classifier */
71
+
72
+ // Imported from the TypeScript that ships rather than written again here. The
73
+ // migration is already a second implementation of this classification, held to
74
+ // the first by tests/fixtures/publication-review-corpus.json; a third one
75
+ // living in the CLI would be a third opinion about what a page is showing.
76
+ // Node strips the types on the way in, which it has done by default since
77
+ // 22.18 -- older ones get told so rather than a stack trace about `export type`.
78
+ let classifyPage, canonicalInputs, REVIEW_DECISIONS, canonicalJson, digestJson, MIGRATION_MANIFEST;
79
+ try {
80
+ ({ classifyPage, canonicalInputs, REVIEW_DECISIONS } = await import(
81
+ join(root, "src/platform/publication-review.ts")
82
+ ));
83
+ ({ canonicalJson, digestJson } = await import(join(root, "src/platform/digest.ts")));
84
+ ({ MIGRATION_MANIFEST } = await import(join(root, "src/platform/migration-manifest.ts")));
85
+ } catch (error) {
86
+ bail(
87
+ `cms-review reads this package's TypeScript sources directly, and node ${process.versions.node} could not.`,
88
+ "Node 22.18 or newer strips types on its own; on an older one, run it under a newer node.",
89
+ "",
90
+ String(error?.message ?? error),
91
+ );
92
+ }
93
+
94
+ /* -------------------------------------------------------------- the arguments */
95
+
96
+ // Every --flag takes a value, so the token after one is never a subcommand and
97
+ // never a page key. Reading argv in order rather than searching it is what
98
+ // keeps `cms-review --file r.json scan` from trying to run a subcommand called
99
+ // r.json.
100
+ const flags = new Map();
101
+ const positional = [];
102
+ {
103
+ const argv = process.argv.slice(2);
104
+ for (let i = 0; i < argv.length; i += 1) {
105
+ if (!argv[i].startsWith("--")) {
106
+ positional.push(argv[i]);
107
+ continue;
108
+ }
109
+ const name = argv[i].slice(2);
110
+ const value = argv[i + 1];
111
+ if (value === undefined || value.startsWith("--")) bail(`--${name} needs a value.`);
112
+ flags.set(name, value);
113
+ i += 1;
114
+ }
115
+ }
116
+
117
+ const command = positional[0] ?? "help";
118
+ const flag = (name, fallback) => flags.get(name) ?? fallback;
119
+
120
+ /*
121
+ * A process list is world-readable, and it is read: `ps` on a shared VPS, a
122
+ * shell history file, a CI log that echoes the command it ran. So this CLI has
123
+ * no option that takes a credential, and rather than silently ignoring one it
124
+ * says where the credential belongs. The value check catches the same mistake
125
+ * spelled differently -- a service-role JWT handed to --by, a whole connection
126
+ * URI handed to --site-key.
127
+ */
128
+ const CREDENTIAL_FLAGS = new Set([
129
+ "password", "pass", "pw", "key", "api-key", "anon-key", "secret", "token",
130
+ "service-role", "service-role-key", "dsn", "database-url", "connection-string",
131
+ ]);
132
+ const JWT = /^[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}$/;
133
+ const URI_WITH_PASSWORD = /^[a-z][a-z0-9+.-]*:\/\/[^/@\s]*:[^/@\s]+@/i;
134
+ const ENV_FILE_ADVICE = [
135
+ "Credentials do not travel on a command line, where every process listing shows them.",
136
+ "Put the connection string in a root-owned 0600 file and export it from there:",
137
+ "",
138
+ " CMS_REVIEW_DSN=postgres://reviewer:...@host:5432/app",
139
+ "",
140
+ "A role with SELECT on pages, page_versions and page_publications is enough to scan.",
141
+ ];
142
+ for (const name of flags.keys()) {
143
+ if (CREDENTIAL_FLAGS.has(name)) bail(`cms-review has no --${name}.`, "", ...ENV_FILE_ADVICE);
144
+ }
145
+ for (const [name, value] of [...flags, ...positional.map((v) => ["an argument", v])]) {
146
+ if (JWT.test(value) || URI_WITH_PASSWORD.test(value)) {
147
+ const where = name === "an argument" ? name : `--${name}`;
148
+ bail(`${where} looks like a credential.`, "", ...ENV_FILE_ADVICE);
149
+ }
150
+ }
151
+
152
+ const filePath = resolve(flag("file", ".cms-review.json"));
153
+
154
+ /* ------------------------------------------------------------- the database */
155
+
156
+ let pgEnv;
157
+ function connection() {
158
+ if (pgEnv) return pgEnv;
159
+ const dsn = process.env.CMS_REVIEW_DSN ?? process.env.DATABASE_URL ?? "";
160
+ if (!dsn) {
161
+ bail(
162
+ "No database to read.",
163
+ "Set CMS_REVIEW_DSN (or DATABASE_URL) to the site you are reviewing.",
164
+ "A role with SELECT on pages, page_versions and page_publications is enough.",
165
+ );
166
+ }
167
+ let url;
168
+ try {
169
+ url = new URL(dsn);
170
+ } catch {
171
+ bail("CMS_REVIEW_DSN is not a connection URL.");
172
+ }
173
+ if (!/^postgres(ql)?:$/.test(url.protocol)) {
174
+ bail(`CMS_REVIEW_DSN must be a postgres:// URL, not ${url.protocol}//`);
175
+ }
176
+ // Taken apart into PG* variables rather than handed to psql as an argument,
177
+ // so the password stays out of argv and out of every process listing.
178
+ pgEnv = { ...process.env };
179
+ delete pgEnv.PGSERVICE;
180
+ delete pgEnv.PGOPTIONS;
181
+ if (url.hostname) pgEnv.PGHOST = decodeURIComponent(url.hostname);
182
+ if (url.port) pgEnv.PGPORT = url.port;
183
+ if (url.username) pgEnv.PGUSER = decodeURIComponent(url.username);
184
+ if (url.password) pgEnv.PGPASSWORD = decodeURIComponent(url.password);
185
+ const database = url.pathname.replace(/^\//, "");
186
+ if (database) pgEnv.PGDATABASE = decodeURIComponent(database);
187
+ const sslmode = url.searchParams.get("sslmode");
188
+ if (sslmode) pgEnv.PGSSLMODE = sslmode;
189
+ return pgEnv;
190
+ }
191
+
192
+ /** Runs one of the shipped .sql files and returns its single answer. */
193
+ function ask(name) {
194
+ const result = spawnSync(
195
+ "psql",
196
+ ["-X", "-q", "-t", "-A", "-v", "ON_ERROR_STOP=1", "-f", "-"],
197
+ { input: readFileSync(join(here, name), "utf8"), encoding: "utf8", env: connection() },
198
+ );
199
+ if (result.error?.code === "ENOENT") {
200
+ bail("cms-review needs psql on PATH to read the database.");
201
+ }
202
+ if (result.status !== 0) {
203
+ bail(`Could not read the database (${name}):`, (result.stderr || "").trim());
204
+ }
205
+ return result.stdout.trim();
206
+ }
207
+
208
+ /* ------------------------------------------------------------- the review file */
209
+
210
+ const FORM = "cms-platform-publication-review";
211
+ const MIGRATION = "20260905120000_da2db0b2-6048-4aa8-8e4f-13ee33e9cc24";
212
+
213
+ function load() {
214
+ if (!existsSync(filePath)) {
215
+ bail(`No review at ${filePath}.`, "Run `cms-review scan` first.");
216
+ }
217
+ let review;
218
+ try {
219
+ review = JSON.parse(readFileSync(filePath, "utf8"));
220
+ } catch (error) {
221
+ bail(`${filePath} is not readable json: ${error.message}`);
222
+ }
223
+ if (review?.form !== FORM) bail(`${filePath} is not a cms-review file.`);
224
+ return review;
225
+ }
226
+
227
+ const save = (review) => writeFileSync(filePath, `${JSON.stringify(review, null, 2)}\n`);
228
+
229
+ /**
230
+ * PGOPTIONS is split on whitespace by the server, so a value containing any
231
+ * would arrive as a separate, unparseable argument and the connection would
232
+ * fail with something that says nothing about notes or e-mail addresses. Caught
233
+ * where the text is typed instead.
234
+ */
235
+ function spaceFree(what, value) {
236
+ if (/\s/.test(value)) bail(`${what} cannot contain spaces or newlines: ${JSON.stringify(value)}`);
237
+ return value;
238
+ }
239
+
240
+ /**
241
+ * A moment, to the second, in UTC.
242
+ *
243
+ * Every timestamp an approval carries is written this way, and the reason is
244
+ * that one of the two roads out of here compares them as text. The server-side
245
+ * record is read by a shell script that has no date parser and does not want
246
+ * one: fixed-width, zero-padded and UTC-only is exactly the shape that sorts
247
+ * correctly under a string comparison, so `expires` and `now` can be compared
248
+ * without either side agreeing on a timezone database.
249
+ *
250
+ * Truncating rather than rounding is deliberate in both directions. An
251
+ * evaluation moved a fraction of a second earlier widens the window the
252
+ * migration checks for publication rows that moved underneath the review, and
253
+ * an expiry moved a fraction earlier expires marginally sooner. Both errors
254
+ * land on the side of refusing.
255
+ */
256
+ const atSecond = (when) => `${new Date(when).toISOString().slice(0, 19)}Z`;
257
+
258
+ /* --------------------------------------------------------------- the commands */
259
+
260
+ async function scan() {
261
+ const seen = JSON.parse(ask("review-scan.sql"));
262
+ // Truncated here, not on the way out, so the moment the pages were classified
263
+ // against and the moment the approval reports are the same moment. Rounding
264
+ // one of them later would leave the review describing a classification that
265
+ // was made a fraction of a second after the time it claims.
266
+ const evaluatedAt = new Date(Math.floor(Date.now() / 1000) * 1000);
267
+ const owners = new Map(Object.entries(seen.versionOwners));
268
+ const items = [];
269
+ for (const page of seen.pages) {
270
+ const item = classifyPage(page, evaluatedAt, owners);
271
+ items.push({
272
+ ...item,
273
+ inputsDigest: await digestJson(canonicalInputs(item)),
274
+ // Whether `publish-legacy-version` is even an honest answer for this page.
275
+ // The migration refuses to write another page's version into a fresh
276
+ // publication row under a note claiming preservation; better to know that
277
+ // while the reviewer is still reading than at three in the morning.
278
+ legacyVersionOwned:
279
+ item.legacyVersionId !== null && owners.get(item.legacyVersionId) === item.pageId,
280
+ });
281
+ }
282
+
283
+ const review = {
284
+ form: FORM,
285
+ migration: MIGRATION,
286
+ databaseIdentity: seen.databaseIdentity,
287
+ clusterIdentity: seen.clusterIdentity,
288
+ evaluatedAt: atSecond(evaluatedAt),
289
+ inputFingerprint: await digestJson(JSON.parse(ask("review-fingerprint.sql"))),
290
+ items,
291
+ decisions: [],
292
+ approval: null,
293
+ };
294
+ save(review);
295
+ report(review);
296
+ console.log(`\nWritten to ${filePath}.`);
297
+ }
298
+
299
+ function report(review) {
300
+ const tally = new Map();
301
+ for (const item of review.items) {
302
+ tally.set(item.classification, (tally.get(item.classification) ?? 0) + 1);
303
+ }
304
+ console.log(`${review.items.length} live page(s), read at ${review.evaluatedAt}:`);
305
+ for (const [name, count] of [...tally].sort()) console.log(` ${String(count).padStart(4)} ${name}`);
306
+
307
+ const answered = new Map(review.decisions.map((d) => [d.pageKey, d]));
308
+ const open = review.items.filter((i) => i.requiresDecision && !answered.has(i.pageKey));
309
+ const preserved = review.items.filter((i) => i.classification === "preserved");
310
+
311
+ if (preserved.length > 0) {
312
+ console.log(`\n${preserved.length} page(s) the upgrade keeps alive on its own:`);
313
+ for (const item of preserved) console.log(` ${item.pageKey} ${item.reason ?? ""}`);
314
+ }
315
+ if (open.length === 0) {
316
+ console.log("\nNothing here needs a decision.");
317
+ return;
318
+ }
319
+ console.log(`\n${open.length} page(s) need somebody to say what should happen:`);
320
+ for (const item of open) {
321
+ console.log(` ${item.pageKey} [${item.classification}]`);
322
+ console.log(` ${item.reason ?? ""}`);
323
+ console.log(
324
+ ` answers: ${item.legacyVersionOwned ? "publish-legacy-version, " : ""}leave-hidden, no-change`,
325
+ );
326
+ }
327
+ console.log("\n cms-review resolve <page-key> <answer> --by you@example.com");
328
+ }
329
+
330
+ function resolve_() {
331
+ const review = load();
332
+ const [, pageKey, decision] = positional;
333
+ if (!pageKey || !decision) bail("Usage: cms-review resolve <page-key> <decision> --by <e-mail>");
334
+
335
+ const item = review.items.find((i) => i.pageKey === pageKey);
336
+ if (!item) bail(`No page called ${pageKey} in this review.`);
337
+ if (!REVIEW_DECISIONS.includes(decision)) {
338
+ bail(`${decision} is not an answer. One of: ${REVIEW_DECISIONS.join(", ")}`);
339
+ }
340
+ if (!item.requiresDecision) {
341
+ bail(
342
+ `${pageKey} classified as ${item.classification}, which the upgrade settles on its own.`,
343
+ "Recording an answer for it would claim a question was asked that was not.",
344
+ );
345
+ }
346
+ if (decision === "publish-legacy-version" && !item.legacyVersionOwned) {
347
+ bail(
348
+ `${pageKey} cannot be republished: the version recorded for it belongs to another page.`,
349
+ "leave-hidden records the outage honestly; repairing the version pointer is a",
350
+ "separate, deliberate change and not something an upgrade should do quietly.",
351
+ );
352
+ }
353
+
354
+ const by = spaceFree("--by", flag("by", ""));
355
+ if (!by) bail("--by <e-mail> so the record says who decided.");
356
+ const note = spaceFree("--note", flag("note", ""));
357
+
358
+ review.decisions = [
359
+ ...review.decisions.filter((d) => d.pageKey !== pageKey),
360
+ {
361
+ pageId: item.pageId,
362
+ pageKey,
363
+ decision,
364
+ expectedInputsDigest: item.inputsDigest,
365
+ resolvedByEmail: by,
366
+ note: note || null,
367
+ },
368
+ ].sort((a, b) => (a.pageId < b.pageId ? -1 : 1));
369
+
370
+ // An approval describes a set of answers. Changing one of them makes it a
371
+ // description of something else.
372
+ if (review.approval) {
373
+ review.approval = null;
374
+ console.log("The earlier approval no longer describes these answers, so it was dropped.");
375
+ }
376
+ save(review);
377
+
378
+ const left = review.items.filter(
379
+ (i) => i.requiresDecision && !review.decisions.some((d) => d.pageKey === i.pageKey),
380
+ );
381
+ console.log(`${pageKey}: ${decision}. ${left.length} page(s) still unanswered.`);
382
+ }
383
+
384
+ /**
385
+ * What an approval is about, said out loud before it is sealed.
386
+ *
387
+ * Sealing is where a review stops being a document and starts being something
388
+ * that lets a migration change a database, so this is the last point at which
389
+ * somebody can notice they are pointed at the wrong one. --confirm-environment
390
+ * has to be typed to match: where CMS_ENVIRONMENT is set the deployment's own
391
+ * answer is what must be matched, and where it is not, the operator says the
392
+ * name twice. A staging manifest cannot then be built against production by
393
+ * recalling the wrong line from a shell history.
394
+ */
395
+ function confirmEnvironment(review, { siteKey, environment, releaseSha, packageVersion }) {
396
+ const declared = process.env.CMS_ENVIRONMENT ?? "";
397
+ if (declared && declared !== environment) {
398
+ bail(
399
+ `This connection says it is ${declared}, and --environment says ${environment}.`,
400
+ "One of them is wrong, and neither this CLI nor the migration can tell which.",
401
+ );
402
+ }
403
+ const target = connection();
404
+
405
+ console.log("Sealing an approval that will let a migration change a live database.\n");
406
+ console.log(` site ${siteKey}`);
407
+ console.log(` environment ${environment}${declared ? "" : " (declared here; CMS_ENVIRONMENT is not set)"}`);
408
+ console.log(` database ${review.databaseIdentity}`);
409
+ console.log(` host ${target.PGHOST ?? "(unset)"}:${target.PGPORT ?? "5432"}/${target.PGDATABASE ?? "(unset)"}`);
410
+ console.log(` release ${releaseSha}`);
411
+ console.log(` package ${packageVersion}`);
412
+ console.log(` migration ${MIGRATION}`);
413
+ console.log(` pages answered ${review.decisions.length} of ${review.items.length} scanned`);
414
+ console.log(` scanned at ${review.evaluatedAt}\n`);
415
+
416
+ if (flag("confirm-environment", "") !== environment) {
417
+ bail(
418
+ `Nothing has been sealed. Read the block above; if it is the database you mean,`,
419
+ `run the same command again with --confirm-environment ${environment}.`,
420
+ );
421
+ }
422
+ }
423
+
424
+ /** Everything an approval speaks for, in the order digestJson will read it. */
425
+ const reviewBody = (review) => ({
426
+ databaseIdentity: review.databaseIdentity,
427
+ decisions: payloadOf(review),
428
+ evaluatedAt: review.evaluatedAt,
429
+ inputFingerprint: review.inputFingerprint,
430
+ items: review.items.map((item) => canonicalInputs(item)),
431
+ migration: review.migration,
432
+ });
433
+
434
+ /** The decision list as the migration reads it: SQL column names, sorted. */
435
+ const payloadOf = (review) =>
436
+ review.decisions.map((d) => ({
437
+ page_id: d.pageId,
438
+ decision: d.decision,
439
+ expected_inputs_digest: d.expectedInputsDigest,
440
+ resolved_by_email: d.resolvedByEmail,
441
+ note: d.note,
442
+ }));
443
+
444
+ async function approve() {
445
+ const review = load();
446
+ const open = review.items.filter(
447
+ (i) => i.requiresDecision && !review.decisions.some((d) => d.pageKey === i.pageKey),
448
+ );
449
+ if (open.length > 0) {
450
+ bail(
451
+ `${open.length} page(s) have not been answered for:`,
452
+ ...open.map((i) => ` ${i.pageKey} [${i.classification}]`),
453
+ "",
454
+ "The migration refuses an approval with an unanswered page, so this does too,",
455
+ "here where you can still do something about it.",
456
+ );
457
+ }
458
+
459
+ const by = spaceFree("--by", flag("by", ""));
460
+ if (!by) bail("--by <e-mail> so the record says who approved.");
461
+ const siteKey = spaceFree("--site-key", flag("site-key", ""));
462
+ const environment = spaceFree("--environment", flag("environment", ""));
463
+ const releaseSha = spaceFree("--release-sha", flag("release-sha", process.env.GITHUB_SHA ?? ""));
464
+ if (!siteKey || !environment || !releaseSha) {
465
+ bail("--site-key, --environment and --release-sha say which deployment this approves.");
466
+ }
467
+
468
+ const seconds = Number(flag("expires-in", "3600"));
469
+ if (!Number.isInteger(seconds) || seconds < 60 || seconds > 86_400) {
470
+ bail("--expires-in is a whole number of seconds between 60 and 86400.");
471
+ }
472
+
473
+ const file = join(root, "migrations", `${MIGRATION}.sql`);
474
+ if (!existsSync(file)) bail(`This package does not carry ${MIGRATION}.sql.`);
475
+
476
+ const packageVersion = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version;
477
+ confirmEnvironment(review, { siteKey, environment, releaseSha, packageVersion });
478
+
479
+ const payload = payloadOf(review);
480
+ review.approval = {
481
+ // Hyphens dropped: an approval is carried to a server as a text record
482
+ // whose reader holds every field to one spelling, and this one it holds to
483
+ // thirty-two hex digits. PostgreSQL reads that form as a uuid, so nothing
484
+ // downstream has to know which spelling was chosen -- but the two sides
485
+ // have to have chosen the same one, and this is where it is chosen.
486
+ approvalNonce: randomUUID().replace(/-/g, ""),
487
+ runId: randomUUID(),
488
+ siteKey,
489
+ environment,
490
+ releaseSha,
491
+ packageVersion,
492
+ // Bytes, not canonical json: this one is a digest of a file, and what it
493
+ // pins is that the .sql applied is the .sql reviewed against.
494
+ migrationDigest: createHash("sha256").update(readFileSync(file)).digest("hex"),
495
+ manifestDigest: flag("manifest-digest", await manifestDigest()),
496
+ approvalExpiresAt: atSecond(Date.now() + seconds * 1000),
497
+ approvedByEmail: by,
498
+ decisionsDigest: await digestJson(payload),
499
+ // The manifest's own content. install-lovable-migration-approval recomputes
500
+ // it and refuses a file that was edited after it was approved -- which is
501
+ // the same promise decisionsDigest makes to the migration, one step earlier
502
+ // and about the whole file rather than the answers alone.
503
+ reviewDigest: await digestJson(reviewBody(review)),
504
+ };
505
+ save(review);
506
+
507
+ console.log(`Approved by ${by}, for ${siteKey}/${environment}.`);
508
+ console.log(`Valid until ${review.approval.approvalExpiresAt} -- the migration refuses it after that.`);
509
+ console.log(`\n PGOPTIONS="$(cms-review options --file ${filePath})" psql -f ${file}`);
510
+ }
511
+
512
+ /**
513
+ * What this package ships, as one value: every canonical migration and its bytes.
514
+ *
515
+ * Read off the directory rather than out of `src/platform/migration-manifest.ts`,
516
+ * even though that file holds the same names and the same digests. The manifest
517
+ * is what the package *claims*; an approval has to be bound to what the package
518
+ * *is*. Derive this from the claim and an edited `.sql` whose manifest row was
519
+ * never regenerated would produce a digest that matched -- exactly the
520
+ * substitution the binding exists to notice.
521
+ *
522
+ * The manifest is still worth reading, for the one question the directory
523
+ * cannot answer by itself: whether this package's two records of itself agree.
524
+ */
525
+ async function manifestDigest() {
526
+ const dir = join(root, "migrations");
527
+ const files = readdirSync(dir).filter((f) => f.endsWith(".sql")).sort();
528
+ const entries = files.map((name) => ({
529
+ name,
530
+ digest: createHash("sha256").update(readFileSync(join(dir, name))).digest("hex"),
531
+ }));
532
+
533
+ const line = (name, digest) => `${name} ${digest}`;
534
+ const claimed = MIGRATION_MANIFEST.map((m) => line(m.name, m.sha256)).join("\n");
535
+ const actual = entries.map((e) => line(e.name, e.digest)).join("\n");
536
+ if (claimed !== actual) {
537
+ bail(
538
+ "This package's migration manifest does not describe its own migrations/ directory.",
539
+ "",
540
+ "In a checkout, that means a migration was edited without regenerating the catalog:",
541
+ " npm run manifest",
542
+ "",
543
+ "In an installed package it means something rather worse, because publishing checks",
544
+ "this. Re-install from the registry before approving anything against this copy.",
545
+ );
546
+ }
547
+
548
+ return digestJson(entries);
549
+ }
550
+
551
+ /**
552
+ * The fields an approval travels as, in the one order both roads use.
553
+ *
554
+ * The order is not cosmetic. The record reader on a server walks this list and
555
+ * refuses a file whose lines arrive in any other sequence, because two
556
+ * spellings of one approval are two approvals and only one of them was
557
+ * reviewed. It is mirrored in APPROVAL_RECORD_KEYS in the deployment
558
+ * template's lovable-authority-records.sh, and the round-trip gate
559
+ * (ops/approval-record-roundtrip.mjs) is what keeps the two copies honest.
560
+ */
561
+ const APPROVAL_RECORD_KEYS = [
562
+ "approval_nonce",
563
+ "site_key",
564
+ "environment",
565
+ "database_identity",
566
+ "review_database_identity",
567
+ "release_sha",
568
+ "package_version",
569
+ "migration_name",
570
+ "migration_digest",
571
+ "run_id",
572
+ "input_fingerprint",
573
+ "manifest_digest",
574
+ "decision_count",
575
+ "decisions",
576
+ "decisions_digest",
577
+ "evaluated_at",
578
+ "approval_expires_at",
579
+ "classified_by_email",
580
+ "approved_by_email",
581
+ ];
582
+
583
+ /**
584
+ * One description of a sealed approval, printed two ways.
585
+ *
586
+ * Both roads out of here -- the connection this machine makes, and the file an
587
+ * administrator installs on a server -- carry the same fields with the same
588
+ * spellings, because both are rendered from this. They were once built
589
+ * separately, and separately is how they came to disagree about how a nonce is
590
+ * spelled, how many digits a timestamp has, and whether the decisions travel at
591
+ * all. A single builder cannot drift from itself.
592
+ *
593
+ * Two identities are carried, and they are not alternatives:
594
+ *
595
+ * database_identity the cluster, as initdb named it. What the
596
+ * deployment tooling compares against the running
597
+ * server, so an approval cannot be spent on a
598
+ * cluster that was thrown away and recreated.
599
+ * review_database_identity the database inside it, by name and oid. What
600
+ * the migration compares, because two databases in
601
+ * one cluster share the first identity and are not
602
+ * the same database to review.
603
+ *
604
+ * Each side checks the one it means; neither interprets the other's.
605
+ */
606
+ function approvalSettings(review) {
607
+ const a = review.approval;
608
+ const payload = payloadOf(review);
609
+ return {
610
+ approval_nonce: a.approvalNonce,
611
+ site_key: a.siteKey,
612
+ environment: a.environment,
613
+ database_identity: review.clusterIdentity,
614
+ review_database_identity: review.databaseIdentity,
615
+ release_sha: a.releaseSha,
616
+ package_version: a.packageVersion,
617
+ // The file name, not the internal name. It is what the deployment tooling
618
+ // has in its hand -- the migration it is about to apply, by its basename --
619
+ // and the migration strips the suffix back off before comparing.
620
+ migration_name: `${review.migration}.sql`,
621
+ migration_digest: a.migrationDigest,
622
+ run_id: a.runId,
623
+ input_fingerprint: review.inputFingerprint,
624
+ manifest_digest: a.manifestDigest,
625
+ decision_count: String(review.decisions.length),
626
+ // Canonical text, and the digest beside it is the digest of exactly these
627
+ // bytes. The server-side reader has no json parser: it digests the field as
628
+ // it found it and compares. `JSON.stringify` would preserve insertion order
629
+ // and produce text whose digest is not the one that was sealed.
630
+ //
631
+ // Always present, including as the empty list. An approval that answered
632
+ // nothing still says so, rather than saying nothing -- a missing field and
633
+ // a field saying "no decisions" are different claims, and the reader is
634
+ // right to refuse a record that makes neither.
635
+ decisions: canonicalJson(payload),
636
+ decisions_digest: a.decisionsDigest,
637
+ evaluated_at: review.evaluatedAt,
638
+ approval_expires_at: a.approvalExpiresAt,
639
+ // Who is answerable for this, carried rather than left behind. Without
640
+ // these the migration has no address to write into its audit columns and
641
+ // falls back to a sentinel that is nobody, so an upgrade a person approved
642
+ // would be recorded as one that nobody approved.
643
+ classified_by_email: a.approvedByEmail,
644
+ approved_by_email: a.approvedByEmail,
645
+ };
646
+ }
647
+
648
+ /** The sealed approval, or a refusal. Shared by both roads so they agree. */
649
+ function sealed() {
650
+ const review = load();
651
+ if (!review.approval) bail("This review has not been approved. Run `cms-review approve`.");
652
+ if (Date.parse(review.approval.approvalExpiresAt) <= Date.now()) {
653
+ bail(
654
+ `This approval expired at ${review.approval.approvalExpiresAt}.`,
655
+ "Scan again: the database may have moved on since it was read.",
656
+ );
657
+ }
658
+ if (!review.clusterIdentity) {
659
+ bail(
660
+ `${filePath} was scanned before an approval carried the cluster's identity.`,
661
+ "Run `cms-review scan` again: the deployment tooling compares that identity",
662
+ "against the server it is about to change, and cannot be told it afterwards.",
663
+ );
664
+ }
665
+ return review;
666
+ }
667
+
668
+ function options() {
669
+ const review = sealed();
670
+ const text = Object.entries(approvalSettings(review))
671
+ .map(([name, value]) => `-c lovable.${name}=${spaceFree(name, String(value))}`)
672
+ .join(" ");
673
+
674
+ // PostgreSQL's startup packet is a little under 10000 bytes and the options
675
+ // travel inside it. A site with more answered pages than that fits cannot be
676
+ // approved this way, and saying so here is better than a connection that
677
+ // fails on the far side without naming a cause.
678
+ if (Buffer.byteLength(text, "utf8") > 9000) {
679
+ bail(
680
+ `These ${review.decisions.length} answers do not fit in a connection's startup options`,
681
+ `(${Buffer.byteLength(text, "utf8")} bytes, and the server's limit is near 10000).`,
682
+ "Answer the pages in smaller groups, or resolve some of them in the site before upgrading.",
683
+ );
684
+ }
685
+ process.stdout.write(`${text}\n`);
686
+ }
687
+
688
+ /*
689
+ * The shapes the record reader holds each field to.
690
+ *
691
+ * A second copy of somebody else's rules, which is worth being uncomfortable
692
+ * about. They live in approval_field_is_wellformed in the deployment template
693
+ * and that is where they are enforced; these exist so that a record this
694
+ * command prints is refused *here*, by name, while the person who ran it is
695
+ * still watching -- rather than on a server, hours later, by a script that can
696
+ * only say the file is malformed. The round-trip gate runs a real record
697
+ * through the real reader, so the copy cannot quietly fall out of step.
698
+ */
699
+ /*
700
+ * An address, bounded where the reader bounds it.
701
+ *
702
+ * The lookahead counts the whole field. An earlier spelling put the bound
703
+ * inside the local part instead, which let this command print a 262-byte
704
+ * address that read_approval_record then refused on the server -- the exact
705
+ * failure this table exists to move back to the keyboard of whoever ran the
706
+ * command. The reader counts ${#value} across the whole value, so this counts
707
+ * the whole value too: the two agree by construction rather than by
708
+ * resemblance.
709
+ *
710
+ * What an address may look like is deliberately not decided here. One printable
711
+ * token, one @, a dotted domain: enough that it cannot carry a space into
712
+ * PGOPTIONS or a newline into the record, and no opinion beyond that.
713
+ */
714
+ const RECORD_EMAIL =
715
+ /^(?=.{3,254}$)[!-?A-~]+@[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$/;
716
+
717
+ const RECORD_SHAPES = {
718
+ approval_nonce: [/^[0-9a-f]{32}$/, "thirty-two lowercase hex digits"],
719
+ site_key: [/^[a-z][a-z0-9-]{1,30}$/, "a lowercase letter, then 1-30 letters, digits or hyphens"],
720
+ environment: [/^(staging|production)$/, "staging or production"],
721
+ database_identity: [/^[0-9]{1,20}$/, "the cluster's identifier, all digits"],
722
+ review_database_identity: [/^[!-~]{1,120}$/, "printable, no spaces, at most 120 characters"],
723
+ release_sha: [/^[0-9a-f]{40}$/, "a full forty-character commit sha"],
724
+ package_version: [/^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$/, "a semantic version"],
725
+ migration_name: [/^[A-Za-z0-9_.-]+\.sql$/, "a migration file name ending in .sql"],
726
+ migration_digest: [/^[0-9a-f]{64}$/, "a sha256, sixty-four lowercase hex digits"],
727
+ run_id: [/^[A-Za-z0-9_.:/-]{1,200}$/, "1-200 characters from A-Z a-z 0-9 _ . : / -"],
728
+ input_fingerprint: [/^[0-9a-f]{64}$/, "a sha256, sixty-four lowercase hex digits"],
729
+ manifest_digest: [/^[0-9a-f]{64}$/, "a sha256, sixty-four lowercase hex digits"],
730
+ decision_count: [/^[0-9]{1,6}$/, "a count, at most six digits"],
731
+ decisions: [/^[ -~]{1,8192}$/, "printable ascii, 1 to 8192 characters"],
732
+ decisions_digest: [/^[0-9a-f]{64}$/, "a sha256, sixty-four lowercase hex digits"],
733
+ evaluated_at: [/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/, "a UTC timestamp to the second, ending in Z"],
734
+ approval_expires_at: [/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/, "a UTC timestamp to the second, ending in Z"],
735
+ classified_by_email: [RECORD_EMAIL, "an e-mail address, 254 characters at the most"],
736
+ approved_by_email: [RECORD_EMAIL, "an e-mail address, 254 characters at the most"],
737
+ };
738
+
739
+ /**
740
+ * The approval as a file, for a database this machine cannot reach.
741
+ *
742
+ * Printed rather than written, so where it lands and who owns it is the
743
+ * caller's decision and not a surprise. The administrator's install command is
744
+ * what gives it a home, an owner and a mode.
745
+ *
746
+ * Everything is checked against the reader's own shapes before a byte is
747
+ * printed. A record is refused on the far side rather than repaired, so half a
748
+ * record is worse than none: the operator who carried it would find out on a
749
+ * server, and the only thing they could do about it is come back here.
750
+ */
751
+ function record() {
752
+ const review = sealed();
753
+ const settings = approvalSettings(review);
754
+
755
+ const wrong = [];
756
+ for (const key of APPROVAL_RECORD_KEYS) {
757
+ const value = String(settings[key] ?? "");
758
+ const [shape, described] = RECORD_SHAPES[key];
759
+ if (!shape.test(value)) wrong.push([key, value, described]);
760
+ }
761
+ if (wrong.length > 0) {
762
+ bail(
763
+ "This approval cannot be written as a record a server will accept:",
764
+ "",
765
+ ...wrong.flatMap(([key, value, described]) => [
766
+ ` ${key}`,
767
+ ` is ${value === "" ? "(empty)" : JSON.stringify(value)}`,
768
+ ` wants ${described}`,
769
+ ]),
770
+ "",
771
+ "The record is judged whole, on the host, by a reader that refuses rather",
772
+ "than repairs -- so it is judged here too, where it can still be reissued.",
773
+ "`cms-review options` is unaffected: a direct connection carries these",
774
+ "fields under looser rules than a file left on a server does.",
775
+ );
776
+ }
777
+
778
+ // The reader's budget, checked before the file exists rather than at install
779
+ // time on the host. The composed form is what has to fit, so the composed
780
+ // form is what is measured -- the record itself is a little longer, and
781
+ // measuring that instead would refuse approvals that would have travelled.
782
+ const composed = APPROVAL_RECORD_KEYS
783
+ .map((key) => `-c lovable.${key}=${String(settings[key]).replace(/\\/g, "\\\\").replace(/ /g, "\\ ")}`)
784
+ .join(" ");
785
+ if (composed.length > 8192) {
786
+ bail(
787
+ `These ${review.decisions.length} answers do not fit in a connection's startup options`,
788
+ `(${composed.length} bytes composed, and the reader's budget is 8192).`,
789
+ "Answer the pages in smaller groups, or split the change across migrations.",
790
+ );
791
+ }
792
+
793
+ process.stdout.write(APPROVAL_RECORD_KEYS.map((key) => `${key}=${settings[key]}\n`).join(""));
794
+ }
795
+
796
+ /* -------------------------------------------------------------------- go */
797
+
798
+ switch (command) {
799
+ case "scan":
800
+ await scan();
801
+ break;
802
+ case "show":
803
+ report(load());
804
+ break;
805
+ case "resolve":
806
+ resolve_();
807
+ break;
808
+ case "approve":
809
+ await approve();
810
+ break;
811
+ case "options":
812
+ options();
813
+ break;
814
+ case "record":
815
+ record();
816
+ break;
817
+ default:
818
+ // The comment at the top of this file is the manual. Keeping one text means
819
+ // a reader of the source and a reader of `cms-review help` cannot be told
820
+ // two different things.
821
+ console.log(
822
+ readFileSync(fileURLToPath(import.meta.url), "utf8")
823
+ .split("*/")[0]
824
+ .split("/**")[1]
825
+ .split("\n")
826
+ .map((line) => line.replace(/^\s*\* ?/, ""))
827
+ .join("\n")
828
+ .trim(),
829
+ );
830
+ if (command !== "help") console.error(`\nNot a command: ${command}`);
831
+ process.exit(command === "help" ? 0 : 1);
832
+ }