@runeditrun/rer 0.1.1 → 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 (106) hide show
  1. package/README.md +111 -1
  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 +10 -7
  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 +111 -1
  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 +21 -7
  46. package/global/launch/door.mjs +36 -0
  47. package/global/launch/install-pinned.mjs +22 -0
  48. package/global/launch/prepare.mjs +38 -5
  49. package/global/launch/render.mjs +6 -1
  50. package/global/launch/steps.mjs +12 -7
  51. package/global/launch/worker.mjs +3 -3
  52. package/global/list.mjs +6 -3
  53. package/global/main.mjs +37 -3
  54. package/global/version.mjs +8 -8
  55. package/modules/dependencies/protocol.mjs +2 -1
  56. package/modules/deploy-kit/README.md +34 -1
  57. package/modules/deploy-kit/artifact.mjs +10 -0
  58. package/modules/deploy-kit/owner.mjs +5 -4
  59. package/modules/deploy-kit/prepare.mjs +3 -2
  60. package/modules/deploy-kit/provider.mjs +6 -3
  61. package/modules/deploy-kit/site.mjs +135 -0
  62. package/modules/erasure/CONTRACT.md +6 -3
  63. package/modules/erasure/README.md +8 -1
  64. package/modules/erasure/audit.mjs +2 -2
  65. package/modules/erasure/cascade.mjs +52 -11
  66. package/modules/erasure/order.mjs +29 -22
  67. package/modules/erasure/plan.mjs +4 -2
  68. package/modules/erasure/queue.mjs +41 -0
  69. package/modules/erasure/scope.mjs +3 -3
  70. package/modules/erasure/verification.mjs +4 -2
  71. package/modules/mail/README.md +1 -1
  72. package/modules/mail/index.mjs +1 -1
  73. package/modules/mail/src/core/errors.mjs +9 -0
  74. package/modules/mail/src/development/index.d.mts +6 -0
  75. package/modules/mail/src/development/index.mjs +6 -3
  76. package/modules/mail/src/development/viewer-html.mjs +3 -3
  77. package/modules/mail/src/development/viewer.mjs +8 -1
  78. package/modules/operator-auth/dev/index.mjs +15 -5
  79. package/modules/operator-auth/http.mjs +2 -3
  80. package/modules/operator-auth/loopback.mjs +17 -0
  81. package/modules/owner-claim/verify.mjs +2 -1
  82. package/modules/portability/README.md +8 -0
  83. package/modules/portability/hash.mjs +6 -2
  84. package/modules/portability/import.mjs +1 -1
  85. package/modules/portability/legacy-files.mjs +4 -4
  86. package/modules/portability/legacy-safety.mjs +51 -10
  87. package/modules/portability/migration-history.mjs +15 -0
  88. package/modules/release/README.md +5 -3
  89. package/modules/release/produce.mjs +5 -2
  90. package/modules/release/source.mjs +15 -4
  91. package/onboarding/adapter-auth.mjs +4 -3
  92. package/onboarding/adapter-settings.mjs +21 -5
  93. package/onboarding/adapter.mjs +2 -1
  94. package/onboarding/app-fetch.mjs +79 -0
  95. package/onboarding/archive-output.d.mts +4 -2
  96. package/onboarding/archive-output.mjs +22 -10
  97. package/onboarding/dependency-approval.mjs +4 -3
  98. package/onboarding/local/environment.mjs +7 -2
  99. package/onboarding/operator-install.mjs +8 -2
  100. package/onboarding/operator-prerequisites.mjs +5 -2
  101. package/onboarding/queued-entry.mjs +9 -5
  102. package/operations/operations.mjs +11 -2
  103. package/package.json +75 -1
  104. package/scripts/publish-check/github_refs.py +28 -2
  105. package/scripts/publish-check/public_hygiene.py +35 -4
  106. package/scripts/publish-check/publication-config.json +14 -1
@@ -0,0 +1,135 @@
1
+ import { mkdir, rename, writeFile } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ import { assert, hash, resourceName } from './validation.mjs';
4
+ import { ownerWorkerName, workerSettings } from './owner.mjs';
5
+ import { uploadAssets, workerOrigin } from './worker.mjs';
6
+
7
+ /* A seed's run site (run-<seed>.com): the folder `rer build --site` writes,
8
+ * served on the owner's own account by a Base-generated module Worker (fixed
9
+ * content, no seed code) whose only binding is its assets. The Worker runs
10
+ * first on every request: www.<domain> gets a 301 to the apex with path and
11
+ * query kept, and everything else is answered from the assets. It has no
12
+ * secrets and no schedules, workers.dev stays off, and its name can never be
13
+ * the installed app's Worker name, so a site deploy never touches the app or
14
+ * its resources. The apex and www.<domain> are Workers Custom Domains; no zone
15
+ * ruleset is read or written.
16
+ *
17
+ * Ownership is proven from the provider alone, so a deploy from a fresh
18
+ * checkout (a CI runner) recognises the site it deployed before: the Worker is
19
+ * the kit's when its latest deployment serves, at 100%, the Worker's newest
20
+ * version and that version carries the kit's `rer-site:<fingerprint>`
21
+ * annotation; a Custom Domain is the kit's when its service is the kit's
22
+ * Worker. Anything of the same name that is not the kit's is refused, never
23
+ * adopted. A local receipt is written for the person and never read. */
24
+
25
+ const RECEIPT_KIND = 'rer-deploy-site-receipt';
26
+ const APEX = /^(?!www\.)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,62}$/;
27
+ const ANNOTATION = /^rer-site:[0-9a-f]{64}$/;
28
+ export const SITE_ASSETS_CONFIG = Object.freeze({ html_handling: 'auto-trailing-slash', not_found_handling: '404-page' });
29
+ export const SITE_MODULE = 'site-worker.mjs';
30
+ /** The site Worker. It names no domain: only the site's two Custom Domains route to it. */
31
+ export const SITE_WORKER = `// Run site Worker, generated by Base's deploy kit (rer deploy --site). Do not edit.
32
+ export default {
33
+ async fetch(request, env) {
34
+ const url = new URL(request.url);
35
+ if (url.hostname.startsWith('www.')) return new Response(null, { status: 301, headers: { location: \`https://\${url.hostname.slice(4)}\${url.pathname}\${url.search}\` } });
36
+ return env.ASSETS.fetch(request);
37
+ },
38
+ };
39
+ `;
40
+
41
+ function refuse(code, kind, name) { const error = new Error(code); error.resource = `${kind}:${name}`; return error; }
42
+
43
+ /** deploy-site.json: `{ "version": 1, "domain": "run-cradle.com" }`, the apex hostname only. */
44
+ export function siteDeclaration(value) {
45
+ assert(value && typeof value === 'object' && !Array.isArray(value) && value.version === 1 && Object.keys(value).sort().join() === 'domain,version', 'invalid-site-declaration');
46
+ assert(typeof value.domain === 'string' && value.domain.length <= 253 && APEX.test(value.domain), 'invalid-site-domain');
47
+ return value;
48
+ }
49
+
50
+ /** The app is `<project>-<environment>`; owner names never hold `--`, so the site Worker cannot be it. */
51
+ export function siteWorkerName(project, environment) {
52
+ const worker = `${ownerWorkerName(project, environment)}--site`;
53
+ assert(resourceName(worker), 'owner-name-too-long');
54
+ return worker;
55
+ }
56
+
57
+ /** Everything a site deploy will do, with no provider request. */
58
+ export function sitePlan({ project, environment, compatibilityDate, declaration, assets }) {
59
+ const { domain } = siteDeclaration(declaration);
60
+ assert(/^\d{4}-\d{2}-\d{2}$/.test(compatibilityDate ?? ''), 'invalid-plan');
61
+ assert(typeof assets === 'string' && assets.startsWith('/'), 'invalid-site-assets');
62
+ return { version: 1, worker: siteWorkerName(project, environment), appWorker: ownerWorkerName(project, environment), module: SITE_MODULE, assets, compatibilityDate,
63
+ bindings: ['ASSETS'], secrets: [], crons: [], workersDev: false, runWorkerFirst: true, assetsConfig: { ...SITE_ASSETS_CONFIG },
64
+ domain, hostnames: [domain, `www.${domain}`],
65
+ redirects: [{ from: `https://www.${domain}/*`, to: `https://${domain}/`, status: 301, keeps: ['path', 'query'] }] };
66
+ }
67
+
68
+ /** The version the Worker's latest deployment serves, as the kit reads ownership: null when there is no Worker. */
69
+ async function servedVersion(api, worker) {
70
+ if (!await workerSettings(api, worker)) return null;
71
+ const { deployments } = await api.request(`/workers/scripts/${worker}/deployments`);
72
+ assert(Array.isArray(deployments) && deployments.length > 0, 'invalid-deployments');
73
+ const [latest] = deployments, single = latest.versions?.length === 1 && latest.versions[0].percentage === 100 ? latest.versions[0].version_id : null;
74
+ const newest = (await api.request(`/workers/scripts/${worker}/versions`))?.items?.[0]?.id;
75
+ const version = single ? await api.request(`/workers/scripts/${worker}/versions/${single}`) : null;
76
+ const annotation = version?.annotations?.['workers/message'];
77
+ return { kit: typeof annotation === 'string' && ANNOTATION.test(annotation), annotation, current: Boolean(single) && single === newest };
78
+ }
79
+ /** The Worker is absent (false) or the kit's (true); anything else is refused. */
80
+ async function ownWorker(api, worker) {
81
+ const served = await servedVersion(api, worker);
82
+ if (!served) return false;
83
+ // Someone else's Worker of this name, or one whose live version the kit did not upload.
84
+ if (!served.kit) throw refuse('resource-name-collision', 'worker', worker);
85
+ // A kit version that is no longer the newest one served alone: a dashboard rollback or a split deployment.
86
+ if (!served.current) throw refuse('owned-resource-replaced', 'worker', worker);
87
+ return true;
88
+ }
89
+
90
+ async function writeReceipt(path, receipt) {
91
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
92
+ const temporary = `${path}.${process.pid}.tmp`;
93
+ await writeFile(temporary, JSON.stringify({ version: 1, kind: RECEIPT_KIND, ...receipt }, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
94
+ await rename(temporary, path);
95
+ }
96
+
97
+ /** Read-only preflight, then the site Worker upload and both Custom Domains, then readback. */
98
+ export async function siteDeploy({ api, plan, files, receiptPath }) {
99
+ assert(typeof receiptPath === 'string' && receiptPath.length, 'missing-receipt-path');
100
+ assert(plan?.module === SITE_MODULE && Array.isArray(files) && files.some(file => file.path === 'index.html'), 'invalid-site-assets');
101
+ const zones = await api.zoneRequest(`/zones?name=${plan.domain}&account.id=${api.accountId}`);
102
+ const zone = Array.isArray(zones) ? zones.find(row => row?.name === plan.domain && row.account?.id === api.accountId) : null;
103
+ if (!zone) throw refuse('site-zone-missing', 'zone', plan.domain);
104
+ // Preflight: the Worker and each hostname are the kit's own or absent before any write.
105
+ const existed = await ownWorker(api, plan.worker);
106
+ const attached = await api.request(`/workers/domains?zone_id=${zone.id}`);
107
+ assert(Array.isArray(attached), 'invalid-domain-list');
108
+ for (const hostname of plan.hostnames) {
109
+ const row = attached.find(item => item?.hostname === hostname);
110
+ if (row && (!existed || row.service !== plan.worker)) throw refuse('resource-name-collision', 'domain', hostname);
111
+ }
112
+
113
+ const fingerprint = hash({ files: files.map(({ path, sha256 }) => ({ path, sha256 })), config: plan.assetsConfig, module: hash(SITE_WORKER), compatibilityDate: plan.compatibilityDate });
114
+ const jwt = await uploadAssets(api, plan.worker, files);
115
+ const body = new FormData();
116
+ body.set('metadata', new Blob([JSON.stringify({ main_module: SITE_MODULE, compatibility_date: plan.compatibilityDate, bindings: [{ type: 'assets', name: 'ASSETS' }],
117
+ assets: { jwt, config: { ...plan.assetsConfig, run_worker_first: true } }, annotations: { 'workers/message': `rer-site:${fingerprint}` } })], { type: 'application/json' }));
118
+ body.set(SITE_MODULE, new Blob([SITE_WORKER], { type: 'application/javascript+module' }), SITE_MODULE);
119
+ await api.request(`/workers/scripts/${plan.worker}`, { method: 'PUT', body });
120
+ const uploaded = await servedVersion(api, plan.worker);
121
+ assert(uploaded?.current && uploaded.annotation === `rer-site:${fingerprint}`, 'site-upload-not-live');
122
+ await workerOrigin(api, plan.worker, { enable: true, workersDev: false });
123
+ for (const hostname of plan.hostnames) {
124
+ if (attached.some(row => row?.hostname === hostname)) continue;
125
+ const row = await api.request('/workers/domains', { method: 'PUT', body: { environment: 'production', hostname, service: plan.worker, zone_id: zone.id } });
126
+ assert(row?.hostname === hostname && row.service === plan.worker && typeof row.id === 'string', 'invalid-domain-attach');
127
+ }
128
+ // Readback: the kit's Worker live and both domains on it.
129
+ assert(await ownWorker(api, plan.worker), 'site-worker-missing');
130
+ const domains = await api.request(`/workers/domains?zone_id=${zone.id}`);
131
+ for (const hostname of plan.hostnames) assert(domains.some(row => row.hostname === hostname && row.service === plan.worker), 'domain-readback-mismatch');
132
+ const receipt = { worker: plan.worker, domain: plan.domain, zoneId: zone.id, fingerprint, files: files.length, hostnames: plan.hostnames, redirect: plan.redirects[0] };
133
+ await writeReceipt(receiptPath, receipt);
134
+ return { origin: `https://${plan.domain}`, receipt };
135
+ }
@@ -31,8 +31,11 @@ outside D1 and R2.
31
31
  leaves-first, the subject row last) is deleted in a D1 batch that also holds
32
32
  portability's `erasureStatements(table, key)` for that row and
33
33
  `invalidateTransfersStatements()`. A batch holds at most `rows` dependents, so
34
- deletion work is bounded. Scope/audit and completeness checks also read the
35
- declared closure; their cost grows with that closure. A guard on the operation's step
34
+ deletion work is bounded. The first step reads and orders the declared closure
35
+ and queues what it leaves; every later step reads, verifies and deletes only its
36
+ own slice of that queue, and the last step reads the closure once more from the
37
+ subject and every remembered parent, so the work of an erasure is linear in its
38
+ rows. A guard on the operation's step
36
39
  counter makes a concurrent runner fail with `erasure_busy` (409) instead of
37
40
  committing twice.
38
41
  - **ERASE-005 — Blobs after the commit.** For a declared `blob` edge, each row's
@@ -51,7 +54,7 @@ outside D1 and R2.
51
54
  replaced with one label per erasure, `erased:<128 random bits>`. The label is
52
55
  not derived from the subject, so nobody who holds the database or an older
53
56
  archive can recompute it or test a guess against it. Declared `clear` columns
54
- are overwritten. Audit rows for selected dependents are relabelled in every batch. Temporary
57
+ are overwritten. Audit rows for a batch's dependents are relabelled in that batch, and every remembered dependent's in the final one. Temporary
55
58
  scope preserves earlier keys until the final pass, catching audit rows written
56
59
  after those dependents disappeared; that scope is then removed atomically.
57
60
  - **ERASE-008 — Value-free receipt.** The receipt row holds the entity name,
@@ -62,7 +62,14 @@ if (receipt.phase !== 'complete') await env.JOBS.send({ erasure: opened.id }); /
62
62
  `commit(id,{rows,until})` runs only the D1 phase: bounded atomic batches of at
63
63
  most `rows` dependents (default 500), leaves first, with the subject row last.
64
64
  Each deleted row's `erasureStatements` and `invalidateTransfersStatements()` go
65
- in the same batch. `deleteBlobs(id,{until})` runs only the blob phase, after the
65
+ in the same batch.
66
+ The first step collects and orders the whole plan and queues what it does not delete
67
+ in the private scope table; each later step reads and verifies only its own slice, so the
68
+ work of an erasure is linear in its rows, however many steps it takes. The queue is
69
+ not trusted for completeness: once it is empty, one more step collects the graph again
70
+ from the subject and every remembered parent, and anything written meanwhile is
71
+ erased before the receipt says `complete`. Index the child columns an edge
72
+ matches on (`on`), as foreign keys usually are, or each step scans that table. `deleteBlobs(id,{until})` runs only the blob phase, after the
66
73
  commit. `run` does both within the time budget. Every phase is safe to repeat,
67
74
  and a concurrent runner gets `erasure_busy` (409). The receipt says `complete`
68
75
  only after the completion transaction re-reads every declared dependent and audit path
@@ -4,7 +4,7 @@ import { statement, quote, chunks, randomHex } from './sql.mjs';
4
4
  * nobody holding the database (or an old archive) can recompute or test it. */
5
5
  export const newLabel = () => 'erased:' + randomHex(16);
6
6
 
7
- export function auditStatements(db, d, plan, label, { only } = {}) {
7
+ export function auditStatements(db, d, plan, label, { only, relabel = true } = {}) {
8
8
  const out = [];
9
9
  for (const item of d.audit) {
10
10
  if (only && item.of !== only) continue;
@@ -13,7 +13,7 @@ export function auditStatements(db, d, plan, label, { only } = {}) {
13
13
  const set = [`${quote(item.column)}=?`, ...clear.map(([c]) => `${quote(c)}=?`)].join(',');
14
14
  // A previously relabelled row is still this operation's audit: a writer
15
15
  // may have changed its detail after the first cleanup.
16
- out.push(statement(db, `UPDATE ${quote(item.table)} SET ${set} WHERE ${quote(item.column)}=?${item.where ? ` AND (${item.where})` : ''}`, label, ...clear.map(([, v]) => v), label));
16
+ if (relabel) out.push(statement(db, `UPDATE ${quote(item.table)} SET ${set} WHERE ${quote(item.column)}=?${item.where ? ` AND (${item.where})` : ''}`, label, ...clear.map(([, v]) => v), label));
17
17
  for (const part of chunks(keys, 200)) {
18
18
  out.push(statement(db, `UPDATE ${quote(item.table)} SET ${set} WHERE ${quote(item.column)} IN (SELECT value FROM json_each(?))${item.where ? ` AND (${item.where})` : ''}`,
19
19
  label, ...clear.map(([, v]) => v), JSON.stringify(part)));
@@ -5,6 +5,7 @@ import { verificationStatements, childAbsenceStatements } from './verification.m
5
5
  import { collect, matchSql, checkDeclared } from './plan.mjs';
6
6
  import { auditStatements } from './audit.mjs';
7
7
  import { progressStatement, committedStatement } from './receipt.mjs';
8
+ import { queueStatements, readQueue, consumeStatement, clearQueue, slicePlan, rootScopeExists } from './queue.mjs';
8
9
 
9
10
  const where = (keys, table) => keys.get(table).pk.map(c => `${c === 'rowid' ? 'rowid' : quote(c)}=?`).join(' AND ');
10
11
  const values = e => Object.values(e.key);
@@ -37,7 +38,8 @@ export async function deletionStatements(ctx, d, plan, entries, id, deleted) {
37
38
  export async function firstStatements(ctx, d, plan, label) {
38
39
  return d.beforeStatements ? await d.beforeStatements({ db: ctx.db, subject: plan.subject, label }) : [];
39
40
  }
40
- export const cleanupStatements = (ctx, d, plan, label) => [...redactStatements(ctx.db, d, plan), ...auditStatements(ctx.db, d, plan, label)];
41
+ // `relabel: false` (a slice of the plan) leaves the table-wide relabel of earlier audit rows to the final batch.
42
+ export const cleanupStatements = (ctx, d, plan, label, options) => [...redactStatements(ctx.db, d, plan), ...auditStatements(ctx.db, d, plan, label, options)];
41
43
  /** Commits with the subject row: a second redaction/audit pass catches rows written
42
44
  * during a long erasure. Verify before removing the root (so triggers cannot hide
43
45
  * new children through CASCADE), then after final policy effects. */
@@ -53,8 +55,43 @@ export async function finalStatements(ctx, d, plan, id, label, now, deleted) {
53
55
  return out;
54
56
  }
55
57
 
58
+ const lost = error => {
59
+ if (/erasure_retained_missing/.test(String(error?.message ?? error))) throw new ErasureError('retained_row_missing', 409);
60
+ if (/CHECK constraint failed/.test(String(error?.message ?? error))) throw new ErasureError('erasure_busy', 409);
61
+ throw error;
62
+ };
63
+
64
+ /** A non-final step: deletes `entries` (a slice of the plan in deletion order)
65
+ * and leaves `rest` queued. Everything the batch verifies is bounded by the
66
+ * slice. Completeness is the final step's job, which collects the whole graph
67
+ * again from the subject and every remembered parent. */
68
+ async function sliceStep(ctx, d, op, entries, { first = null, rest = null } = {}) {
69
+ const { db, fence } = ctx, id = op.id, deleted = JSON.parse(op.deleted);
70
+ const plan = await slicePlan(db, d, JSON.parse(op.subject), entries);
71
+ const batch = [db.prepare('PRAGMA defer_foreign_keys=ON'),
72
+ guard(db, "EXISTS(SELECT 1 FROM rer_erasure_operations WHERE id=? AND step=? AND phase='deleting')", id, op.step),
73
+ ...await fence.transfers()];
74
+ batch.push(...await scopeStatements(db, d, first ?? plan, id, first ? [...entries, first.root] : entries, { whole: Boolean(first) }));
75
+ if (first) batch.push(...await firstStatements(ctx, d, plan, op.label));
76
+ batch.push(...await verificationStatements(db, d, plan, id, op.label));
77
+ batch.push(...cleanupStatements(ctx, d, plan, op.label, { relabel: false }));
78
+ batch.push(...await deletionStatements(ctx, d, plan, entries, id, deleted));
79
+ batch.push(...await verificationStatements(db, d, plan, id, op.label, { intake: false }));
80
+ if (rest) batch.push(...queueStatements(db, id, rest));
81
+ else batch.push(consumeStatement(db, id, entries.at(-1).seq));
82
+ batch.push(progressStatement(db, id, op.step, deleted), db.prepare('DELETE FROM rer_erasure_guard'));
83
+ try { await db.batch(batch); }
84
+ catch (error) {
85
+ if (/erasure_scope_changed/.test(String(error?.message ?? error))) { await clearQueue(db, id).run(); return 'changed'; }
86
+ return lost(error);
87
+ }
88
+ return true;
89
+ }
90
+
56
91
  /** One bounded, atomic D1 commit of up to `rows` dependents, leaves first. The
57
- * subject row commits with the last step. A concurrent runner loses the guard. */
92
+ * subject row commits with the last step. A concurrent runner loses the guard.
93
+ * The first step collects and orders the whole plan and queues what it leaves;
94
+ * later steps take their slice from the queue until it is empty. */
58
95
  export async function step(ctx, d, id, { rows = 500 } = {}) {
59
96
  const { db, fence } = ctx;
60
97
  const op = await statement(db, 'SELECT * FROM rer_erasure_operations WHERE id=?', id).first();
@@ -63,29 +100,33 @@ export async function step(ctx, d, id, { rows = 500 } = {}) {
63
100
  const now = new Date().toISOString(), deleted = JSON.parse(op.deleted);
64
101
  await checkDeclared(db, d);
65
102
  insist(Number.isInteger(rows) && rows > 0, 'batch_rows');
103
+ if (op.step > 0) {
104
+ const slice = await readQueue(db, id, rows);
105
+ if (slice.length) {
106
+ insist(await rootScopeExists(db, id, d), 'erasure_scope_missing', 409);
107
+ return sliceStep(ctx, d, op, slice);
108
+ }
109
+ }
66
110
  const remembered = await readScope(db, id);
67
111
  insist(op.step === 0 || remembered.some(e => e.table === d.root.table), 'erasure_scope_missing', 409);
68
112
  const plan = await collect(db, d, JSON.parse(op.subject), remembered);
69
113
  insist(plan && plan.root, 'subject_removed_outside_erasure', 409);
114
+ const take = plan.order.slice(0, rows), final = take.length === plan.order.length;
115
+ if (!final) return sliceStep(ctx, d, op, take, { first: op.step === 0 ? plan : null, rest: plan.order.slice(rows) });
70
116
  const batch = [db.prepare('PRAGMA defer_foreign_keys=ON'),
71
117
  guard(db, "EXISTS(SELECT 1 FROM rer_erasure_operations WHERE id=? AND step=? AND phase='deleting')", id, op.step),
72
118
  ...await fence.transfers()];
73
- const take = plan.order.slice(0, rows), final = take.length === plan.order.length;
74
119
  batch.push(...await scopeStatements(db, d, plan, id, [...take, plan.root]));
75
120
  if (op.step === 0) batch.push(...await firstStatements(ctx, d, plan, op.label));
76
121
  batch.push(...await verificationStatements(db, d, plan, id, op.label));
77
122
  batch.push(...cleanupStatements(ctx, d, plan, op.label));
78
123
  batch.push(...await deletionStatements(ctx, d, plan, take, id, deleted));
79
- if (final) batch.push(...await finalStatements(ctx, d, plan, id, op.label, now, deleted));
80
- else batch.push(...await verificationStatements(db, d, plan, id, op.label, { intake: false }));
81
-
82
- batch.push(progressStatement(db, id, op.step, deleted), db.prepare('DELETE FROM rer_erasure_guard'));
124
+ batch.push(...await finalStatements(ctx, d, plan, id, op.label, now, deleted));
125
+ batch.push(clearQueue(db, id), progressStatement(db, id, op.step, deleted), db.prepare('DELETE FROM rer_erasure_guard'));
83
126
  try { await db.batch(batch); }
84
127
  catch (error) {
85
128
  if (/erasure_scope_changed/.test(String(error?.message ?? error))) return 'changed';
86
- if (/erasure_retained_missing/.test(String(error?.message ?? error))) throw new ErasureError('retained_row_missing', 409);
87
- if (/CHECK constraint failed/.test(String(error?.message ?? error))) throw new ErasureError('erasure_busy', 409);
88
- throw error;
129
+ return lost(error);
89
130
  }
90
- return !final;
131
+ return false;
91
132
  }
@@ -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; }