@runeditrun/rer 0.1.0 → 0.2.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.
Files changed (109) hide show
  1. package/README.md +115 -2
  2. package/cli/README.md +28 -3
  3. package/cli/contract.mjs +4 -4
  4. package/cli/dev-identity.mjs +2 -1
  5. package/cli/door-mode.mjs +134 -0
  6. package/cli/door.mjs +263 -0
  7. package/cli/hosted-output.mjs +15 -5
  8. package/cli/hydration/browser.mjs +91 -0
  9. package/cli/hydration/cases.mjs +106 -0
  10. package/cli/hydration/check.mjs +105 -0
  11. package/cli/hydration/probe.mjs +164 -0
  12. package/cli/hydration/scripts.mjs +39 -0
  13. package/cli/hydration/verdict.mjs +85 -0
  14. package/cli/inspect.mjs +56 -9
  15. package/cli/lifecycle.mjs +36 -12
  16. package/cli/local.mjs +9 -3
  17. package/cli/main.mjs +2 -0
  18. package/cli/managed-server.mjs +52 -18
  19. package/cli/package-calls.mjs +81 -0
  20. package/cli/package-mode.mjs +46 -0
  21. package/cli/parse.mjs +14 -4
  22. package/cli/pnpm-runtime.mjs +96 -0
  23. package/cli/portability-worker.mjs +2 -2
  24. package/cli/runtime-front-door.mjs +4 -2
  25. package/cli/server-proxy.mjs +69 -5
  26. package/cli/server-registry.mjs +19 -11
  27. package/cli/settings.mjs +14 -0
  28. package/cli/site-check.mjs +96 -0
  29. package/cli/site-preview.mjs +53 -0
  30. package/cli/site-render.mjs +77 -0
  31. package/cli/site.mjs +109 -0
  32. package/global/CHANGELOG.md +63 -0
  33. package/global/README.md +115 -2
  34. package/global/checkout.mjs +24 -10
  35. package/global/cli/install.mjs +6 -2
  36. package/global/delegate.mjs +9 -7
  37. package/global/door/command.mjs +254 -0
  38. package/global/door/control.mjs +65 -0
  39. package/global/door/cookies.mjs +41 -0
  40. package/global/door/pages.mjs +49 -0
  41. package/global/door/policy.mjs +49 -0
  42. package/global/door/routes.mjs +45 -0
  43. package/global/door/server.mjs +255 -0
  44. package/global/door/sites.mjs +192 -0
  45. package/global/launch/dev.mjs +25 -23
  46. package/global/launch/door.mjs +36 -0
  47. package/global/launch/facts.mjs +3 -2
  48. package/global/launch/install-pinned.mjs +22 -0
  49. package/global/launch/prepare.mjs +41 -6
  50. package/global/launch/render.mjs +6 -1
  51. package/global/launch/state.mjs +10 -1
  52. package/global/launch/steps.mjs +24 -10
  53. package/global/launch/stop.mjs +89 -0
  54. package/global/launch/worker.mjs +30 -11
  55. package/global/list.mjs +6 -3
  56. package/global/main.mjs +37 -3
  57. package/global/version.mjs +8 -8
  58. package/modules/dependencies/protocol.mjs +2 -1
  59. package/modules/deploy-kit/README.md +34 -1
  60. package/modules/deploy-kit/artifact.mjs +10 -0
  61. package/modules/deploy-kit/owner.mjs +5 -4
  62. package/modules/deploy-kit/prepare.mjs +3 -2
  63. package/modules/deploy-kit/provider.mjs +6 -3
  64. package/modules/deploy-kit/site.mjs +135 -0
  65. package/modules/erasure/CONTRACT.md +6 -3
  66. package/modules/erasure/README.md +8 -1
  67. package/modules/erasure/audit.mjs +2 -2
  68. package/modules/erasure/cascade.mjs +52 -11
  69. package/modules/erasure/order.mjs +29 -22
  70. package/modules/erasure/plan.mjs +4 -2
  71. package/modules/erasure/queue.mjs +41 -0
  72. package/modules/erasure/scope.mjs +3 -3
  73. package/modules/erasure/verification.mjs +4 -2
  74. package/modules/mail/README.md +1 -1
  75. package/modules/mail/index.mjs +1 -1
  76. package/modules/mail/src/core/errors.mjs +9 -0
  77. package/modules/mail/src/development/index.d.mts +6 -0
  78. package/modules/mail/src/development/index.mjs +6 -3
  79. package/modules/mail/src/development/viewer-html.mjs +3 -3
  80. package/modules/mail/src/development/viewer.mjs +8 -1
  81. package/modules/operator-auth/dev/index.mjs +15 -5
  82. package/modules/operator-auth/http.mjs +2 -3
  83. package/modules/operator-auth/loopback.mjs +17 -0
  84. package/modules/owner-claim/verify.mjs +2 -1
  85. package/modules/portability/README.md +8 -0
  86. package/modules/portability/hash.mjs +6 -2
  87. package/modules/portability/import.mjs +1 -1
  88. package/modules/portability/legacy-files.mjs +4 -4
  89. package/modules/portability/legacy-safety.mjs +51 -10
  90. package/modules/portability/migration-history.mjs +15 -0
  91. package/modules/release/README.md +5 -3
  92. package/modules/release/produce.mjs +5 -2
  93. package/modules/release/source.mjs +15 -4
  94. package/onboarding/adapter-auth.mjs +4 -3
  95. package/onboarding/adapter-settings.mjs +21 -5
  96. package/onboarding/adapter.mjs +2 -1
  97. package/onboarding/app-fetch.mjs +79 -0
  98. package/onboarding/archive-output.d.mts +4 -2
  99. package/onboarding/archive-output.mjs +22 -10
  100. package/onboarding/dependency-approval.mjs +4 -3
  101. package/onboarding/local/environment.mjs +7 -2
  102. package/onboarding/operator-install.mjs +8 -2
  103. package/onboarding/operator-prerequisites.mjs +5 -2
  104. package/onboarding/queued-entry.mjs +9 -5
  105. package/operations/operations.mjs +11 -2
  106. package/package.json +75 -1
  107. package/scripts/publish-check/github_refs.py +28 -2
  108. package/scripts/publish-check/public_hygiene.py +35 -4
  109. package/scripts/publish-check/publication-config.json +14 -1
@@ -1,6 +1,24 @@
1
1
  import { all, canonicalJson, insist, quote } from './sql.mjs';
2
2
  import { matching } from './plan.mjs';
3
3
 
4
+ /** Every foreign key between two deleted tables, grouped per constraint. Schema
5
+ * only: it never reads a row, so a later step can use it without a plan. */
6
+ export async function foreignKeyLinks(db, d) {
7
+ const links = [];
8
+ for (const table of d.deleted) {
9
+ const groups = new Map();
10
+ for (const fk of await all(db, `PRAGMA foreign_key_list(${quote(table)})`)) {
11
+ if (!groups.has(fk.id)) groups.set(fk.id, []);
12
+ groups.get(fk.id).push(fk);
13
+ }
14
+ for (const group of groups.values()) {
15
+ const parentTable = d.deleted.find(name => name.toLowerCase() === group[0].table.toLowerCase());
16
+ if (parentTable) links.push({ table, parentTable, group });
17
+ }
18
+ }
19
+ return links;
20
+ }
21
+
4
22
  /** Topological row order across every path, not the depth of the first visit.
5
23
  * Actual FK links also constrain order, even if a row was reached by another edge. */
6
24
  export async function deletionOrder(db, d, plan) {
@@ -18,28 +36,17 @@ export async function deletionOrder(db, d, plan) {
18
36
  if (child) link(parent, child);
19
37
  }
20
38
  }
21
- plan.foreignKeys = [];
22
- for (const table of d.deleted) {
23
- const groups = new Map();
24
- for (const fk of await all(db, `PRAGMA foreign_key_list(${quote(table)})`)) {
25
- if (!groups.has(fk.id)) groups.set(fk.id, []);
26
- groups.get(fk.id).push(fk);
27
- }
28
- for (const group of groups.values()) {
29
- const parentTable = d.deleted.find(name => name.toLowerCase() === group[0].table.toLowerCase());
30
- const parents = parentTable && plan.rows.get(parentTable);
31
- if (parentTable) plan.foreignKeys.push({ table, parentTable, group });
32
- if (!parents) continue;
33
- for (const parent of parents.values()) {
34
- const parentKeys = plan.keys.get(parent.table).pk;
35
- // Read the actual join: FK columns need not be part of a declared edge's projection.
36
- const cols = plan.keys.get(table).pk;
37
- const found = await all(db, `SELECT ${cols.map(c => c === 'rowid' ? 'rowid AS rowid' : quote(c)).join(',')} FROM ${quote(table)} WHERE ${group.map(f => `${quote(f.from)}=(SELECT ${quote(f.to || parentKeys[f.seq])} FROM ${quote(parent.table)} WHERE ${parentKeys.map(c => `${quote(c)}=?`).join(' AND ')})`).join(' AND ')}`, ...group.flatMap(() => parentKeys.map(c => parent.key[c])));
38
- for (const row of found) {
39
- const child = entries.get(table + '\0' + canonicalJson(Object.fromEntries(cols.map(c => [c, row[c]]))));
40
- insist(child, `unreached_dependent:${table}:${parent.table}`, 409);
41
- link(parent, child);
42
- }
39
+ plan.foreignKeys = await foreignKeyLinks(db, d);
40
+ for (const { table, parentTable, group } of plan.foreignKeys) {
41
+ for (const parent of plan.rows.get(parentTable).values()) {
42
+ const parentKeys = plan.keys.get(parent.table).pk;
43
+ // Read the actual join: FK columns need not be part of a declared edge's projection.
44
+ const cols = plan.keys.get(table).pk;
45
+ const found = await all(db, `SELECT ${cols.map(c => c === 'rowid' ? 'rowid AS rowid' : quote(c)).join(',')} FROM ${quote(table)} WHERE ${group.map(f => `${quote(f.from)}=(SELECT ${quote(f.to || parentKeys[f.seq])} FROM ${quote(parent.table)} WHERE ${parentKeys.map(c => `${quote(c)}=?`).join(' AND ')})`).join(' AND ')}`, ...group.flatMap(() => parentKeys.map(c => parent.key[c])));
46
+ for (const row of found) {
47
+ const child = entries.get(table + '\0' + canonicalJson(Object.fromEntries(cols.map(c => [c, row[c]]))));
48
+ insist(child, `unreached_dependent:${table}:${parent.table}`, 409);
49
+ link(parent, child);
43
50
  }
44
51
  }
45
52
  }
@@ -40,7 +40,9 @@ export function matchSql(item, parents) {
40
40
  if (!item.on) return parents.map(p => ({ where: `(${item.where})`, values: (item.params ?? []).map(c => p[c]) }));
41
41
  const pairs = Object.entries(item.on);
42
42
  const tuples = parents.map(p => pairs.map(([, parent]) => p[parent])).filter(t => t.every(v => v !== null && v !== undefined));
43
- const where = `EXISTS(SELECT 1 FROM json_each(?) j WHERE ${pairs.map(([child], i) => `${quote(item.table)}.${quote(child)}=json_extract(j.value,'$[${i}]')`).join(' AND ')})`;
43
+ // A set lookup, so an index on the child columns serves it; json_each's own
44
+ // columns (id, key, value...) are never in scope beside the child's.
45
+ const where = `(${pairs.map(([child]) => `${quote(item.table)}.${quote(child)}`).join(',')}) IN (SELECT ${pairs.map((_, i) => `json_extract(j.value,'$[${i}]')`).join(',')} FROM json_each(?) j)`;
44
46
  return chunks(tuples, 200).map(part => ({ where, values: [JSON.stringify(part)] }));
45
47
  }
46
48
 
@@ -71,7 +73,7 @@ export async function collect(db, d, subject, remembered = []) {
71
73
  for (const [table, old] of known) {
72
74
  const pk = keys.get(table).pk;
73
75
  for (const part of chunks([...old.values()], 200)) {
74
- const found = await all(db, `SELECT ${projection(columns.get(table))} FROM ${quote(table)} WHERE EXISTS(SELECT 1 FROM json_each(?) j WHERE ${pk.map((c, i) => `${quote(c)} IS json_extract(j.value,'$[${i}]')`).join(' AND ')})`, JSON.stringify(part.map(row => pk.map(c => row[c]))));
76
+ const found = await all(db, `SELECT ${projection(columns.get(table))} FROM ${quote(table)} WHERE EXISTS(SELECT 1 FROM json_each(?) j WHERE ${pk.map((c, i) => `${quote(table)}.${c === 'rowid' ? 'rowid' : quote(c)} IS json_extract(j.value,'$[${i}]')`).join(' AND ')})`, JSON.stringify(part.map(row => pk.map(c => row[c]))));
75
77
  for (const row of found) { remember(table, row); const key = keyOf(table, row); rows.get(table).set(canonicalJson(key), { table, key, row, depth: 1 }); }
76
78
  }
77
79
  }
@@ -0,0 +1,41 @@
1
+ import { all, statement, canonicalJson } from './sql.mjs';
2
+ import { tableKeys } from './plan.mjs';
3
+ import { foreignKeyLinks } from './order.mjs';
4
+
5
+ /** What is left of a plan after its first step, in deletion order, held in the
6
+ * private scope table (no schema of its own). Each later step reads only its
7
+ * slice, so no step re-reads or re-checks the rows still waiting: the cost of
8
+ * the whole erasure stays linear in its rows. The slice is verified inside the
9
+ * step's batch; anything the queue does not know about (rows written after it
10
+ * was built) is found by the full collection that runs once the queue is empty. */
11
+ const seq = n => String(n).padStart(10, '0');
12
+
13
+ export const clearQueue = (db, id) => statement(db, "DELETE FROM rer_erasure_scope WHERE operation=? AND kind='queue'", id);
14
+
15
+ /** Replaces the operation's queue with `entries`; chunks stay small enough for one bound value. */
16
+ export function queueStatements(db, id, entries) {
17
+ const out = [clearQueue(db, id)], texts = entries.map((e, i) => canonicalJson({ i, t: e.table, k: e.key, r: e.row }));
18
+ for (let from = 0, to = 0; from < texts.length; from = to) {
19
+ for (let size = 0; to < texts.length && size + texts[to].length < 60000 && to - from < 200; to++) size += texts[to].length;
20
+ if (to === from) to++;
21
+ out.push(statement(db, "INSERT INTO rer_erasure_scope SELECT ?,'queue',printf('%010d',json_extract(j.value,'$.i')),'{}',j.value FROM json_each(?) j", id, `[${texts.slice(from, to).join(',')}]`));
22
+ }
23
+ return out;
24
+ }
25
+
26
+ export async function readQueue(db, id, limit) {
27
+ const rows = await all(db, "SELECT table_name AS seq,row_json FROM rer_erasure_scope WHERE operation=? AND kind='queue' ORDER BY table_name LIMIT ?", id, limit);
28
+ return rows.map(r => { const e = JSON.parse(r.row_json); return { seq: r.seq, table: e.t, key: e.k, row: e.r, depth: 1 }; });
29
+ }
30
+ export const consumeStatement = (db, id, last) => statement(db, "DELETE FROM rer_erasure_scope WHERE operation=? AND kind='queue' AND table_name<=?", id, last);
31
+
32
+ /** The part of a plan one step needs: only the rows it deletes are tracked, so
33
+ * every check in its batch is bounded by the slice, not by the rows waiting. */
34
+ export async function slicePlan(db, d, subject, entries) {
35
+ const keys = new Map();
36
+ for (const t of new Set([...d.tracked, ...d.redact.map(r => r.table)])) keys.set(t, await tableKeys(db, t));
37
+ const rows = new Map(d.tracked.map(t => [t, new Map()])), known = new Map(d.tracked.map(t => [t, new Map()]));
38
+ for (const e of entries) { rows.get(e.table).set(canonicalJson(e.key), e); known.get(e.table).set(canonicalJson(e.row), e.row); }
39
+ return { subject, root: null, keys, rows, entries, known, order: entries, foreignKeys: await foreignKeyLinks(db, d) };
40
+ }
41
+ export const rootScopeExists = async (db, id, d) => (await all(db, "SELECT 1 FROM rer_erasure_scope WHERE operation=? AND kind='graph' AND table_name=? LIMIT 1", id, d.root.table)).length > 0;
@@ -6,17 +6,17 @@ import { tableKeys } from './plan.mjs';
6
6
  export async function readScope(db, id) {
7
7
  return (await all(db, "SELECT table_name,key_json,row_json FROM rer_erasure_scope WHERE operation=? AND kind='graph'", id)).map(r => ({ table: r.table_name, key: JSON.parse(r.key_json), row: JSON.parse(r.row_json) }));
8
8
  }
9
- export async function scopeStatements(db, d, plan, id, entries = plan.entries) {
9
+ export async function scopeStatements(db, d, plan, id, entries = plan.entries, { whole = true } = {}) {
10
10
  const out = [];
11
11
  for (const { table, row, key } of entries) {
12
12
  out.push(statement(db, "INSERT OR IGNORE INTO rer_erasure_scope VALUES(?,'graph',?,?,?)", id, table, canonicalJson(key), canonicalJson(row)));
13
13
  }
14
- for (const { table } of d.retain) {
14
+ for (const { table } of whole ? d.retain : []) {
15
15
  const { pk } = await tableKeys(db, table);
16
16
  const key = `json_object(${pk.flatMap(c => ["'" + c.replaceAll("'", "''") + "'", quote(c)]).join(',')})`;
17
17
  out.push(statement(db, `INSERT OR IGNORE INTO rer_erasure_scope SELECT ?,'retain',?,${key},${key} FROM ${quote(table)}`, id, table));
18
18
  }
19
- if (d.root.redact && plan.root) {
19
+ if (whole && d.root.redact && plan.root) {
20
20
  const identity = Object.fromEntries((d.root.redact.identityColumns ?? []).map(c => [c, plan.root.row[c]]));
21
21
  out.push(statement(db, "INSERT INTO rer_erasure_scope SELECT ?,'identity',?,?,? WHERE NOT EXISTS(SELECT 1 FROM rer_erasure_scope WHERE operation=? AND kind='identity')", id, d.root.table, canonicalJson(plan.subject), canonicalJson(identity), id));
22
22
  }
@@ -19,9 +19,11 @@ export async function verificationStatements(db, d, plan, id, label, { final = f
19
19
  const pk = plan.keys.get(table).pk, parentPk = plan.keys.get(parentTable).pk;
20
20
  const parents = [...plan.rows.get(parentTable).values()].map(e => parentPk.map(c => e.key[c]));
21
21
  const children = [...plan.rows.get(table).values()].map(e => pk.map(c => e.key[c]));
22
- const keyMatch = (alias, columns) => columns.map((c, i) => `${alias}.${quote(c)} IS json_extract(j.value,'$[${i}]')`).join(' AND ');
22
+ if (!parents.length) continue;
23
+ const keyMatch = (alias, columns, json = 'j') => columns.map((c, i) => `${alias}.${quote(c)} IS json_extract(${json}.value,'$[${i}]')`).join(' AND ');
23
24
  const joined = group.map(f => `c.${quote(f.from)}=p.${quote(f.to || parentPk[f.seq])}`).join(' AND ');
24
- out.push(verify(db, 'scope', `NOT EXISTS(SELECT 1 FROM ${quote(table)} c JOIN ${quote(parentTable)} p ON ${joined} WHERE EXISTS(SELECT 1 FROM json_each(?) j WHERE ${keyMatch('p', parentPk)}) AND NOT EXISTS(SELECT 1 FROM json_each(?) j WHERE ${keyMatch('c', pk)}))`, JSON.stringify(parents), JSON.stringify(children)));
25
+ // Parents first, then their children through the key: cost follows the parents, not the child table.
26
+ out.push(verify(db, 'scope', `NOT EXISTS(SELECT 1 FROM json_each(?) j CROSS JOIN ${quote(parentTable)} p CROSS JOIN ${quote(table)} c WHERE ${keyMatch('p', parentPk)} AND ${joined} AND NOT EXISTS(SELECT 1 FROM json_each(?) k WHERE ${keyMatch('c', pk, 'k')}))`, JSON.stringify(parents), JSON.stringify(children)));
25
27
  }
26
28
  for (const edge of d.edges) for (const m of matchSql(edge, [...plan.known.get(edge.from).values()])) {
27
29
  const pk = plan.keys.get(edge.table).pk, keys = [...plan.rows.get(edge.table).values()].map(e => pk.map(c => e.key[c]));
@@ -149,7 +149,7 @@ Network loss, redirects, HTTP 408 and 5xx are conservatively `uncertain`; 429 is
149
149
 
150
150
  ## Development and unsubscribe
151
151
 
152
- Development requires all three: `provider:'development'`, `mode:'development'`, and explicit `dependencies.outbox.put`. It captures the prepared message with its raw attachment bytes and returns `development`, never remote acceptance. Configure storage only in your development host; no route is mounted automatically and no network replay is provided. Treat captured messages and sign-in links as sensitive local data.
152
+ Development requires all three: `provider:'development'`, `mode:'development'`, and explicit `dependencies.outbox.put`. It captures the prepared message with its raw attachment bytes and returns `development`, never remote acceptance. A storage failure throws `DevelopmentOutboxError` (`code: 'development_outbox_failed'`, `retryable: true`): the message is valid, so a queue consumer retries it. Invalid input throws `MailValidationError`, which is permanent and is dropped. Configure storage only in your development host; no route is mounted automatically and no network replay is provided. Treat captured messages and sign-in links as sensitive local data.
153
153
 
154
154
  ### Adopting the local outbox
155
155
 
@@ -1,6 +1,6 @@
1
1
  export { createMailSender } from './src/sender.mjs';
2
2
 
3
- export { MailValidationError } from './src/core/errors.mjs';
3
+ export { MailValidationError, DevelopmentOutboxError } from './src/core/errors.mjs';
4
4
  export { serializeSendGridMessage } from './src/adapters/sendgrid/index.mjs';
5
5
  export { verifyWebhook, MAX_WEBHOOK_BYTES, createDeliveryEventIntake, createD1EventStore } from './src/events/index.mjs';
6
6
  export { normalizeInbound, createResendAttachmentFetcher } from './src/inbound/index.mjs';
@@ -7,3 +7,12 @@ export class MailValidationError extends Error {
7
7
  }
8
8
  }
9
9
  export function fail(code) { throw new MailValidationError(code); }
10
+ /** The development outbox could not store the message. The message is fine: send it again. */
11
+ export class DevelopmentOutboxError extends Error {
12
+ constructor() {
13
+ super('Development outbox storage failed; the same message may succeed when sent again.');
14
+ this.name = 'DevelopmentOutboxError';
15
+ this.code = 'development_outbox_failed';
16
+ this.retryable = true;
17
+ }
18
+ }
@@ -27,6 +27,12 @@ export interface DevelopmentD1Database {
27
27
  prepare(sql: string): DevelopmentD1Statement;
28
28
  batch(statements: DevelopmentD1Statement[]): Promise<Array<{ success: boolean; results: unknown[] }>>;
29
29
  }
30
+ /** Storage failure: the message is valid, so a queue consumer retries instead of dropping it. Invalid input throws `MailValidationError`, which is permanent. */
31
+ export class DevelopmentOutboxError extends Error {
32
+ readonly name: 'DevelopmentOutboxError';
33
+ readonly code: 'development_outbox_failed';
34
+ readonly retryable: true;
35
+ }
30
36
  export function createD1DevelopmentOutbox(options: { db: DevelopmentD1Database; maxMessages?: number; maxAgeMs?: number; clock?: () => number }): DevelopmentOutbox;
31
37
  export function createDevelopmentOutboxHandler(options: {
32
38
  local: boolean; origin: string; outbox: Pick<DevelopmentOutbox, 'list' | 'get'>; path?: string;
@@ -1,9 +1,10 @@
1
- import { MailValidationError, fail } from '../core/errors.mjs';
1
+ import { MailValidationError, DevelopmentOutboxError, fail } from '../core/errors.mjs';
2
2
  import { validateConfig, validateMessage } from '../core/validation.mjs';
3
3
  import { sendResult } from '../core/transport.mjs';
4
4
 
5
5
  export { createD1DevelopmentOutbox } from './outbox.mjs';
6
6
  export { createDevelopmentOutboxHandler } from './viewer.mjs';
7
+ export { DevelopmentOutboxError } from '../core/errors.mjs';
7
8
 
8
9
  function validate(operation, errorCode) {
9
10
  try {
@@ -27,8 +28,10 @@ export async function sendDevelopment(config, message, dependencies = {}) {
27
28
 
28
29
  try {
29
30
  await dependencies.outbox.put(snapshot);
30
- } catch {
31
- fail('development_outbox_failed');
31
+ } catch (error) {
32
+ // Input the outbox itself refuses stays permanent; a storage failure is worth a retry.
33
+ if (error instanceof MailValidationError) throw error;
34
+ throw new DevelopmentOutboxError();
32
35
  }
33
36
 
34
37
  return sendResult('development', 'development', null);
@@ -1,4 +1,4 @@
1
- export const style = `*{box-sizing:border-box}body{margin:0;background:#f5f7fa;color:#17212e;font:16px/1.6 system-ui,sans-serif}main{max-width:52rem;margin:0 auto;padding:24px 16px}h1{font-size:1.75rem;line-height:1.2}article{margin:20px 0;padding:20px;background:white;border:1px solid #cad2dc;border-radius:12px}h2{font-size:1.2rem}h1,h2,p,a,pre{overflow-wrap:anywhere;word-break:normal}pre{white-space:pre-wrap;font:inherit}a{color:#174fb5}a.open{display:block;padding:16px;margin:20px 0;border-radius:8px;background:#174fb5;color:white;font-weight:700;text-align:center;text-decoration:none;min-height:56px}small{color:#465569}`;
1
+ export const style = `*{box-sizing:border-box}body{margin:0;background:#f5f7fa;color:#17212e;font:16px/1.6 system-ui,sans-serif}main{max-width:52rem;margin:0 auto;padding:24px 16px}h1{font-size:1.75rem;line-height:1.2}article{margin:20px 0;padding:20px;background:white;border:1px solid #cad2dc;border-radius:12px}h2{font-size:1.2rem}h1,h2,p,a,pre{overflow-wrap:anywhere;word-break:normal}pre{white-space:pre-wrap;font:inherit}a{color:#174fb5}a.open{display:block;padding:16px;margin:20px 0;border-radius:8px;background:#174fb5;color:white;font-weight:700;text-align:center;text-decoration:none;min-height:56px}a.nav{display:inline-block;padding:4px 0}small{color:#465569}`;
2
2
 
3
3
  // Added only when the seed declares a theme: its linked rer-theme.css supplies the
4
4
  // --rer-* tokens. Each falls back to the plain sheet's value, and the focus ring is
@@ -33,10 +33,10 @@ export function document(body, theme) {
33
33
  }
34
34
 
35
35
  export function listing(rows, path) {
36
- return rows.length ? rows.map(row => `<article>${metadata(row)}<p><a href="${escape(`${path}?message=${encodeURIComponent(row.id)}`)}">Read message</a></p></article>`).join('') : '<p>No messages yet.</p>';
36
+ return rows.length ? rows.map(row => `<article>${metadata(row)}<p><a href="${escape(`${path}?message=${encodeURIComponent(row.id)}`)}" class="nav">Read message</a></p></article>`).join('') : '<p>No messages yet.</p>';
37
37
  }
38
38
 
39
39
  export function detail(row, path) {
40
40
  const link = firstLink(row.message);
41
- return `<p><a href="${escape(path)}">All messages</a></p><article>${metadata(row)}${link ? `<a class="open" href="${escape(link)}" rel="noreferrer noopener">Open this link</a>` : ''}<h2>Message text</h2><pre>${escape(row.message.text || row.message.html || '')}</pre>${row.message.attachments?.length ? `<p>${row.message.attachments.length} attachment(s) stored with this message.</p>` : ''}</article>`;
41
+ return `<p><a href="${escape(path)}" class="nav">All messages</a></p><article>${metadata(row)}${link ? `<a class="open" href="${escape(link)}" rel="noreferrer noopener">Open this link</a>` : ''}<h2>Message text</h2><pre>${escape(row.message.text || row.message.html || '')}</pre>${row.message.attachments?.length ? `<p>${row.message.attachments.length} attachment(s) stored with this message.</p>` : ''}</article>`;
42
42
  }
@@ -1,11 +1,18 @@
1
1
  import { detail, document, listing, style, themedStyle } from './viewer-html.mjs';
2
2
 
3
+ // The mail module runs as its own module root (in workerd too), so it cannot
4
+ // import operator-auth's shared predicate (modules/operator-auth/loopback.mjs);
5
+ // this is the same rule: the loopback hosts, 127/8, or a name under .localhost
6
+ // (the front door's http://NAME.localhost:PORT).
7
+ const localhostName = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+localhost$/;
8
+ const isLoopbackHost = hostname => hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || (hostname.length <= 253 && localhostName.test(hostname));
9
+
3
10
  function localOrigin(origin) {
4
11
  if (typeof origin !== 'string') return null;
5
12
  try {
6
13
  const url = new URL(origin);
7
14
  const ipv4 = url.hostname.split('.');
8
- const loopback = url.hostname === 'localhost' || url.hostname === '[::1]' ||
15
+ const loopback = isLoopbackHost(url.hostname) ||
9
16
  (ipv4.length === 4 && ipv4[0] === '127' && ipv4.every(part => /^(0|[1-9]\d{0,2})$/.test(part) && Number(part) <= 255));
10
17
  return loopback && ['http:', 'https:'].includes(url.protocol) && url.origin === origin ? url : null;
11
18
  } catch { return null; }
@@ -50,7 +50,7 @@ export function withDevIdentity(worker, { bindings = env => env, config: authCon
50
50
  if (!gate || env.RER_DEV_IDENTITY === 'off' || !await gate.admits(request)) return devNotFound();
51
51
  const context = identity(env, authConfig, declared);
52
52
  try {
53
- return path === '/__dev/personas' ? Response.json(context.list(), { headers }) : await enter(request, gate, context);
53
+ return path === '/__dev/personas' ? Response.json(await context.list(), { headers }) : await enter(request, gate, context);
54
54
  } catch (error) {
55
55
  if (error instanceof AuthError) return Response.json({ error: error.code }, { status: error.status, headers });
56
56
  throw error;
@@ -68,7 +68,16 @@ export function withDevIdentity(worker, { bindings = env => env, config: authCon
68
68
  function identity(env, authConfig, declared) {
69
69
  const config = configure(authConfig(env)), store = new AuthStore(env.DB);
70
70
  const hash = token => hashToken(config.SESSION_SECRET, 'session', token);
71
- const everyone = () => [{ as: 'owner', email: config.OWNER_EMAIL }, ...declared];
71
+ // The `owner` persona is the owner rer setup claimed while that owner is
72
+ // active; after that owner has left, the earliest-created active owner; with
73
+ // no active owner left, nobody (email null), so rer names who it signs in as.
74
+ const owner = async () => {
75
+ const setup = await store.byEmail(config.OWNER_EMAIL);
76
+ if (setup?.role === 'owner' && isActiveOperator(setup, config.allowList)) return setup.email;
77
+ const rows = (await store.statement(`SELECT email FROM oa_operators WHERE role='owner' AND status='active' ORDER BY created_at, id`).all()).results;
78
+ return rows.find(row => isActiveOperator({ ...row, status: 'active' }, config.allowList))?.email ?? (setup ? null : config.OWNER_EMAIL);
79
+ };
80
+ const everyone = async () => [{ as: 'owner', email: await owner() }, ...declared];
72
81
  async function current(request) {
73
82
  const token = readCookie(request, config);
74
83
  if (!token) return null;
@@ -85,8 +94,8 @@ function identity(env, authConfig, declared) {
85
94
  if (!defaultRolePolicy({ actor: actor.operator, action: 'operators:invite', role: 'owner' })) throw new AuthError('forbidden', 403);
86
95
  return { ...actor, hash: await hash(readCookie(request, config)) };
87
96
  },
88
- list: () => [...everyone().map(({ as, email, role }) => ({ as, email, ...(role ? { role } : {}) })), { as: 'signed-out' }],
89
- async badge(request) { return { session: await current(request), choices: everyone() }; },
97
+ list: async () => [...(await everyone()).map(({ as, email, role }) => ({ as, email, ...(role ? { role } : {}) })), { as: 'signed-out' }],
98
+ async badge(request) { return { session: await current(request), choices: (await everyone()).filter(persona => persona.email) }; },
90
99
  };
91
100
  }
92
101
 
@@ -104,8 +113,9 @@ async function enter(request, gate, { config, store, hash, current, everyone, ow
104
113
  reply.set('location', '/');
105
114
  return new Response(null, { status: 303, headers: reply });
106
115
  }
107
- const persona = everyone().find(p => p.as === as);
116
+ const persona = (await everyone()).find(p => p.as === as);
108
117
  if (!persona) return devNotFound();
118
+ if (!persona.email) return text(409, 'No active owner remains in this app. Sign in another way, or run rer reset --yes and rer setup for a new owner.');
109
119
  let operator = await store.byEmail(persona.email);
110
120
  // Personas are real operators, created only here, in local dev.
111
121
  if (!operator && persona.role) {
@@ -1,8 +1,7 @@
1
1
  import { deny } from './errors.mjs';
2
2
  import { validToken } from './crypto.mjs';
3
- export function isLoopbackHost(hostname) {
4
- return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
5
- }
3
+ import { isLoopbackHost } from './loopback.mjs';
4
+ export { isLoopbackHost };
6
5
  // Browsers keep cookies per host, not per port, so checkouts side by side on
7
6
  // one loopback host would overwrite each other's cookies. A local cookie name
8
7
  // therefore carries its origin's port; hosted names never change. Seeds on
@@ -0,0 +1,17 @@
1
+ // The one loopback predicate every local-only check shares. A local origin's
2
+ // host is one of the three loopback hosts or a name under `.localhost`, which
3
+ // RFC 6761 reserves for loopback and browsers resolve to it themselves: the
4
+ // front door serves each checkout at `http://<name>.localhost:<port>`. Hosted
5
+ // origins never pass through this predicate; they must be https.
6
+ const label = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
7
+ const localhostName = new RegExp(`^(?:${label}\\.)+localhost$`);
8
+
9
+ /** A name under `.localhost` (lowercase, no trailing dot), such as `cradle.localhost`. */
10
+ export function isLocalhostName(hostname) {
11
+ return typeof hostname === 'string' && hostname.length <= 253 && localhostName.test(hostname);
12
+ }
13
+
14
+ /** `localhost`, `127.0.0.1`, `[::1]` (as URL.hostname spells it) or a `*.localhost` name. */
15
+ export function isLoopbackHost(hostname) {
16
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || isLocalhostName(hostname);
17
+ }
@@ -1,4 +1,5 @@
1
1
  // Portable Web Crypto verifier. This file has no application or database imports.
2
+ import { isLoopbackHost } from '../operator-auth/loopback.mjs';
2
3
  export class OwnerClaimError extends Error {
3
4
  constructor(code = 'invalid_claim', status = 403) { super(code); this.name = 'OwnerClaimError'; this.code = code; this.status = status; }
4
5
  }
@@ -10,7 +11,7 @@ function coordinate(value) {
10
11
  return btoa(atob(value.replaceAll('-', '+').replaceAll('_', '/'))).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '') === value;
11
12
  }
12
13
  function origin(value) {
13
- try { const u = new URL(value); return u.origin === value && !u.username && !u.password && (u.protocol === 'https:' || (u.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname))); } catch { return false; }
14
+ try { const u = new URL(value); return u.origin === value && !u.username && !u.password && (u.protocol === 'https:' || (u.protocol === 'http:' && isLoopbackHost(u.hostname))); } catch { return false; }
14
15
  }
15
16
  export function configureClaim(input) {
16
17
  if (!input || !origin(input.issuer) || !origin(input.appOrigin)
@@ -217,6 +217,14 @@ and refuses rows matching its tombstones or any destination erasure fence.
217
217
  Product validation, foreign keys and normal restore safeguards still apply.
218
218
  Any mismatch refuses recovery; this mode does not repair damaged archives.
219
219
 
220
+ Recovery needs a new installation. SQLite, migration and transfer bookkeeping, the
221
+ storage tables of FTS5 indexes (`<index>_data|idx|content|docsize|config`; the index
222
+ itself is still checked through its own SELECT) and the rows the destination's own
223
+ migrations seed may exist; the seed rows are compared with a scratch replay of the
224
+ applied migrations (Node runners) and must be unchanged. Any other row refuses with
225
+ `legacy_target_not_empty`, before staging and again inside the restore batch. A table
226
+ the policy lists in `replaceableTables` is replaced by the restore as usual.
227
+
220
228
  V1 cannot authenticate the complete manifest: source identity, export time,
221
229
  erasure epoch/journal, rowids and other metadata lack the v2 archive binding.
222
230
  Checksums are consistency checks, not proof of authorship. A missing or altered
@@ -17,9 +17,13 @@ export function base64(bytes) {
17
17
  for (let i = 0; i < bytes.length; i += 8192) result += String.fromCharCode(...bytes.subarray(i, i + 8192));
18
18
  return btoa(result);
19
19
  }
20
+ // Canonical base64 only: a plain character-class scan (no group repetition, so no regex stack) and
21
+ // the padding rule by arithmetic, in time linear in the input however large an original is.
20
22
  export function unbase64(value) {
21
- if (typeof value !== 'string' || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) throw new Error('invalid_base64');
22
- return Uint8Array.from(atob(value), c => c.charCodeAt(0));
23
+ if (typeof value !== 'string' || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) throw new Error('invalid_base64');
24
+ const text = atob(value), bytes = new Uint8Array(text.length);
25
+ for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i);
26
+ return bytes;
23
27
  }
24
28
 
25
29
  // Incremental SHA-256 for semantic JSON and objects. SubtleCrypto has no streaming
@@ -134,7 +134,7 @@ export async function finalizeImport(context,id,{contentHash,archiveHash,laterJo
134
134
  const digest=verifier.digest();if(contentHash!==undefined)insist(contentHash===digest,'content_checksum');
135
135
  insist(archiveHash===archive.digest(),'archive_checksum',409);
136
136
  const checked=await fence(db,manifest,row.metadata.copies,laterJournal);
137
- if(row.metadata.legacyRecovery)await requireLegacyEmpty(context,row.metadata.legacyRecovery.emptyTables);
137
+ if(row.metadata.legacyRecovery)await requireLegacyEmpty(context,row.metadata.legacyRecovery);
138
138
  for(const table of row.metadata.targets) if(!config.replaceableTables.includes(table.name))insist(!(await statement(db,`SELECT 1 FROM ${quote(table.name)} LIMIT 1`).first()),'target_not_empty:'+table.name,409);
139
139
  const dependentGuards=await excludedDependentGuards(db,row.metadata.targets,config.replaceableTables);
140
140
  await bucketsEmpty(context,row);
@@ -4,7 +4,7 @@ import { createPortability } from './index.mjs';
4
4
  import { createImport, uploadPart, finalizeImport, abortImport } from './import.mjs';
5
5
  import { readLegacyArchive, ArchiveVerifier } from './format.mjs';
6
6
  import { fileRecords, exportArchive } from './files.mjs';
7
- import { ensureLegacyAudit, legacyEmptyTables, requireLegacyEmpty } from './legacy-safety.mjs';
7
+ import { ensureLegacyAudit, legacyTargetState, requireLegacyEmpty } from './legacy-safety.mjs';
8
8
  import { insist, PortabilityError } from './storage.mjs';
9
9
 
10
10
  export async function recoverLegacyArchive({db,buckets,policy:input,migrations},path,output,transient,acknowledged) {
@@ -13,8 +13,8 @@ export async function recoverLegacyArchive({db,buckets,policy:input,migrations},
13
13
  insist(!(await lstat(output).catch(error=>{if(error.code==='ENOENT')return null;throw error;})),'legacy_output_exists',409);
14
14
  // Whole-file verification precedes even staging/audit schema writes.
15
15
  for await(const record of fileRecords(path,readLegacyArchive)){}
16
- const context={db,buckets,config:policy(input),migrations},emptyTables=await legacyEmptyTables(db);
17
- await requireLegacyEmpty(context,emptyTables);
16
+ const context={db,buckets,config:policy(input),migrations},target=await legacyTargetState(db,migrations);
17
+ await requireLegacyEmpty(context,target);
18
18
  let id,manifest,closing,integrity,status,complete=false;
19
19
  try {
20
20
  for await(const record of fileRecords(path,readLegacyArchive)) {
@@ -25,7 +25,7 @@ export async function recoverLegacyArchive({db,buckets,policy:input,migrations},
25
25
  id=await createImport(context,manifest,'legacy-v1',{
26
26
  event:'legacy_v1_unverified_restore',acknowledgedUnverifiedMetadata:true,
27
27
  sourceIdentity:record.sourceIdentity,exportedAt:record.exportedAt,
28
- recoveryOutput:output,emptyTables,
28
+ recoveryOutput:output,...target,
29
29
  });
30
30
  // A refused schema writes nothing, not even the audit table.
31
31
  await ensureLegacyAudit(db);
@@ -2,18 +2,59 @@ import { all, statement, guard, quote, insist } from './storage.mjs';
2
2
  import { canonicalJson } from './hash.mjs';
3
3
 
4
4
  // Recovery requires a new installation, including excluded identity tables and
5
- // normally replaceable product rows. Only SQLite/migration/transfer bookkeeping
6
- // may already exist. Do not use the product's export exclusions for this check.
7
- export async function legacyEmptyTables(db) {
8
- const tables=await all(db,"SELECT name FROM sqlite_master WHERE type='table' AND sql IS NOT NULL ORDER BY name");
9
- return tables.map(t=>t.name).filter(name=>!(/^(sqlite_|_cf_|rer_portability_)/i.test(name) || ['d1_migrations','_rer_deploy_migrations','_rer_portability_migrations'].includes(name.toLowerCase())));
5
+ // normally replaceable product rows. Only SQLite/migration/transfer bookkeeping,
6
+ // the storage of FTS5 indexes and rows the destination's own migrations seed may
7
+ // already exist. Do not use the product's export exclusions for this check.
8
+ const bookkeeping=name=>/^(sqlite_|_cf_|rer_portability_)/i.test(name) || ['d1_migrations','_rer_deploy_migrations','_rer_portability_migrations'].includes(name.toLowerCase());
9
+ const fts5=/^\s*CREATE\s+VIRTUAL\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:"((?:[^"]|"")+)"|`([^`]+)`|\[([^\]]+)\]|'((?:[^']|'')+)'|([A-Za-z0-9_$]+))\s+USING\s+fts5\b/i;
10
+ const unquoted=m=>m[1]!==undefined?m[1].replaceAll('""','"'):m[4]!==undefined?m[4].replaceAll("''","'"):m[2]??m[3]??m[5];
11
+ // An FTS5 index keeps its data in <name>_data|idx|content|docsize|config. The index itself stays
12
+ // listed and is checked through its own SELECT, so a real indexed row still refuses.
13
+ function shadowTables(tables) {
14
+ const shadows=new Set();
15
+ for(const table of tables) {
16
+ const match=fts5.exec(table.sql);
17
+ if(match)for(const suffix of ['data','idx','content','docsize','config'])shadows.add(unquoted(match).toLowerCase()+'_'+suffix);
18
+ }
19
+ return shadows;
10
20
  }
11
- export async function requireLegacyEmpty({db,buckets},tables) {
12
- for(const table of tables)insist(!(await statement(db,`SELECT 1 FROM ${quote(table)} LIMIT 1`).first()),'legacy_target_not_empty:'+table,409);
21
+ const imageSql=(table,columns)=>`SELECT json_array(${columns.map(c=>`quote(${quote(c)})`).join(',')}) AS image FROM ${quote(table)}`;
22
+ const MAX_SEED_ROWS=10000;
23
+
24
+ /** What the destination must still hold when the restore commits: `emptyTables` stay empty and
25
+ * each of `seededTables` keeps exactly the rows its migrations put there. A table with rows the
26
+ * replay of the destination's applied migrations (migrations(), a node runner's scratch database)
27
+ * does not produce is listed as to-be-empty, so the preflight refuses it. */
28
+ export async function legacyTargetState(db,migrations) {
29
+ const tables=await all(db,"SELECT name,sql FROM sqlite_master WHERE type='table' AND sql IS NOT NULL ORDER BY name"),shadows=shadowTables(tables);
30
+ const names=tables.map(t=>t.name).filter(name=>!bookkeeping(name) && !shadows.has(name.toLowerCase()));
31
+ const filled=[];
32
+ for(const name of names)if(await statement(db,`SELECT 1 FROM ${quote(name)} LIMIT 1`).first())filled.push(name);
33
+ const seeded=[];
34
+ if(filled.length && migrations) {
35
+ const requests=[];
36
+ for(const name of filled)requests.push({name,columns:(await all(db,`SELECT name FROM pragma_table_info(?)`,name)).map(c=>c.name)});
37
+ // Without a usable replay no seed is vouched for, so those rows count as the installation's own.
38
+ const expected=await migrations().then(replay=>replay.rowImages(requests,MAX_SEED_ROWS),()=>new Map());
39
+ for(const {name,columns} of requests) {
40
+ const want=expected.get(name);
41
+ if(!want || (await statement(db,`SELECT count(*) AS n FROM ${quote(name)}`).first()).n!==want.length)continue;
42
+ const found=(await all(db,imageSql(name,columns))).map(r=>r.image).sort();
43
+ if(canonicalJson(found)===canonicalJson(want))seeded.push({name,columns,images:found});
44
+ }
45
+ }
46
+ return {emptyTables:names.filter(name=>!seeded.some(s=>s.name===name)),seededTables:seeded};
47
+ }
48
+ const seededCondition=(db,table)=>[`(SELECT count(*) FROM ${quote(table.name)})=? AND NOT EXISTS(SELECT 1 FROM (${imageSql(table.name,table.columns)}) WHERE image NOT IN (SELECT value FROM json_each(?)))`,table.images.length,JSON.stringify(table.images)];
49
+ export async function requireLegacyEmpty({db,buckets},{emptyTables,seededTables=[]}) {
50
+ for(const table of emptyTables)insist(!(await statement(db,`SELECT 1 FROM ${quote(table)} LIMIT 1`).first()),'legacy_target_not_empty:'+table,409);
51
+ for(const table of seededTables){const [condition,...values]=seededCondition(db,table);insist((await statement(db,`SELECT CASE WHEN (${condition}) THEN 1 ELSE 0 END AS ok`,...values).first()).ok===1,'legacy_target_not_empty:'+table.name,409);}
13
52
  for(const [name,bucket] of Object.entries(buckets))insist(!(await bucket.list({limit:1})).objects.length,'legacy_bucket_not_empty:'+name,409);
14
53
  }
15
54
  export function legacyEmptyGuards(db,row) {
16
- return (row.metadata.legacyRecovery?.emptyTables??[]).map(table=>guard(db,`NOT EXISTS(SELECT 1 FROM ${quote(table)})`));
55
+ const recovery=row.metadata.legacyRecovery;
56
+ return [...(recovery?.emptyTables??[]).map(table=>guard(db,`NOT EXISTS(SELECT 1 FROM ${quote(table)})`)),
57
+ ...(recovery?.seededTables??[]).map(table=>{const [condition,...values]=seededCondition(db,table);return guard(db,condition,...values);})];
17
58
  }
18
59
  export async function ensureLegacyAudit(db) {
19
60
  // Additive sidecar, installed only by recovery; existing product migrations
@@ -22,6 +63,6 @@ export async function ensureLegacyAudit(db) {
22
63
  }
23
64
  export function legacyAuditStatements(db,row) {
24
65
  if(!row.metadata.legacyRecovery)return [];
25
- const {emptyTables,...details}=row.metadata.legacyRecovery;
26
- return [statement(db,'INSERT INTO rer_portability_recovery_audit VALUES(?,?,?)',row.id,new Date().toISOString(),canonicalJson({...details,contentHash:row.metadata.contentHash,...(row.metadata.bridge?{bridged:row.metadata.bridge}:{})}))];
66
+ const {emptyTables,seededTables=[],...details}=row.metadata.legacyRecovery;
67
+ return [statement(db,'INSERT INTO rer_portability_recovery_audit VALUES(?,?,?)',row.id,new Date().toISOString(),canonicalJson({...details,...(seededTables.length?{seededTables:seededTables.map(t=>t.name)}:{}),contentHash:row.metadata.contentHash,...(row.metadata.bridge?{bridged:row.metadata.bridge}:{})}))];
27
68
  }
@@ -14,5 +14,20 @@ export function migrationReplay(plan,input) {
14
14
  for(const migration of plan.filter(m=>names.includes(m.name)))for(const sql of migration.statements)scratch.exec(sql);
15
15
  return await schemaIdentity(db,config);
16
16
  }finally{scratch.close();}
17
+ },
18
+ // The rows the whole replay leaves in each requested table, as sorted images (legacy-safety.mjs).
19
+ // A table the replay lacks, or one holding more than `limit` rows, has no entry.
20
+ async rowImages(requests,limit) {
21
+ const scratch=new DatabaseSync(':memory:'),found=new Map();
22
+ try {
23
+ for(const migration of plan)for(const sql of migration.statements)scratch.exec(sql);
24
+ for(const {name,columns} of requests) {
25
+ try {
26
+ const rows=scratch.prepare(`SELECT json_array(${columns.map(c=>`quote("${c.replaceAll('"','""')}")`).join(',')}) AS image FROM "${name.replaceAll('"','""')}" LIMIT ?`).all(limit+1).map(r=>r.image);
27
+ if(rows.length<=limit)found.set(name,rows.sort());
28
+ }catch{}
29
+ }
30
+ return found;
31
+ }finally{scratch.close();}
17
32
  }};
18
33
  }
@@ -55,9 +55,11 @@ default verification succeeds. Keep capacity, maximum-size memory and load cases
55
55
  in that release-only script. Both dry runs and releases require it to pass;
56
56
  `gates.verify.release` records its result. Routine CI and default verification
57
57
  do not invoke it.
58
- Child logs are suppressed to avoid credential disclosure; the receipt retains
59
- exit status and numeric final summaries from Node's spec/TAP reporters and
60
- Playwright's text reporters. Counts add across suites: `tests`, `passed`,
58
+ A passing step's receipt retains exit status and numeric final summaries from
59
+ Node's spec/TAP reporters and Playwright's text reporters. A failing install,
60
+ `verify` or `verify:release` step also keeps `output`: the last 40 lines (at most
61
+ 8 KB) of that child's output, after the command store's credential redaction.
62
+ The output stays in the receipt and is left out of the site preview. Counts add across suites: `tests`, `passed`,
61
63
  `failed`, `skipped` and `flaky`; nonzero `cancelled`, `todo`, `interrupted` and
62
64
  `notRun` counts are retained when reported. Playwright's total includes each
63
65
  outcome once, with flaky tests counted separately from passed tests. Node's
@@ -8,6 +8,9 @@ import { publicationScan, validatePublicationDestination } from './publication-s
8
8
  import { publicDestination, requirePublicDestination, remotePublicHead, publicChild, assertPublicTree } from './public-child.mjs';
9
9
  import { writePublishHandoff } from './publish-handoff.mjs';
10
10
 
11
+ // Failure output belongs to the private receipt, not the site preview.
12
+ const withoutOutput = gates => JSON.parse(JSON.stringify(gates, (key, value) => key === 'output' ? undefined : value));
13
+
11
14
  export async function release({ repo, ref = 'HEAD', output, project, repository, dryRun = false, tag = false, cleanHistory: clean = false, envFile, onto }) {
12
15
  if (!/^[\w.-]+\/[\w.-]+$/.test(project ?? '')) throw Error('--project must be publisher/name');
13
16
  if (!/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+(?:\.git)?$/i.test(repository ?? '')) throw Error('--repository must be a credential-free GitHub repository URL');
@@ -37,7 +40,7 @@ export async function release({ repo, ref = 'HEAD', output, project, repository,
37
40
  // Honour the seed's committed install policy. Base's native-build settings
38
41
  // are not interchangeable with pnpm 8's locked onlyBuiltDependencies.
39
42
  const install = await command('pnpm', ['install', '--frozen-lockfile'], checkout);
40
- const verify = install.status === 'PASS' ? await command('pnpm', ['run', 'verify'], checkout) : { status: 'FAIL', reason: 'frozen-install-failed', tests: 0, passed: 0, failed: 0 };
43
+ const verify = install.status === 'PASS' ? await command('pnpm', ['run', 'verify'], checkout) : { status: 'FAIL', reason: 'frozen-install-failed', tests: 0, passed: 0, failed: 0, output: install.output };
41
44
  // Release-only scale and capacity cases stay outside routine CI. The
42
45
  // committed seed script is an additional gate in previews and releases.
43
46
  if (verify.status === 'PASS' && JSON.parse(source.bytes('package.json')).scripts['verify:release']) {
@@ -101,7 +104,7 @@ export async function release({ repo, ref = 'HEAD', output, project, repository,
101
104
  // A blocked preview may prepare the site metadata/docs for local review.
102
105
  // Its provenance explicitly records blocked gates; it is never a release.
103
106
  const publicSource = exported ? { ...source, commit: exported.commit, publicTagStaged: Boolean(destination && tag) } : source;
104
- if (passed || preview) receipt.siteRelease = writeCatalogue(publicSource, output, catalogue(publicSource, project, repository, gates, preview));
107
+ if (passed || preview) receipt.siteRelease = writeCatalogue(publicSource, output, catalogue(publicSource, project, repository, withoutOutput(gates), preview));
105
108
  if (passed) {
106
109
  if (tag) {
107
110
  receipt.publicExport = publicExport(source, temporary, output);