@augurworks/augur 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/CANVAS.md +858 -0
  2. package/INSTALL.md +603 -0
  3. package/LICENSE +27 -0
  4. package/README.md +150 -0
  5. package/TRADEMARKS.md +72 -0
  6. package/agents/README.md +65 -0
  7. package/agents/canon-extract.md +117 -0
  8. package/agents/canon.md +151 -0
  9. package/agents/canvas.md +88 -0
  10. package/agents/currency.md +74 -0
  11. package/agents/drafts.md +92 -0
  12. package/agents/identity.md +148 -0
  13. package/agents/prototype-contract.md +69 -0
  14. package/agents/publishing.md +324 -0
  15. package/agents/review-feedback.md +76 -0
  16. package/agents/space-json.md +132 -0
  17. package/agents/ui-skill.md +101 -0
  18. package/agents/working-marks.md +86 -0
  19. package/brand/augur-eye.svg +3 -0
  20. package/brand/augur-icon.svg +41 -0
  21. package/brand/augur-mark.svg +5 -0
  22. package/brand/lentianova-bulky.otf +0 -0
  23. package/brand/lentianova-median.otf +0 -0
  24. package/brand/lentianova.woff2 +0 -0
  25. package/build.js +8409 -0
  26. package/changelog.md +133 -0
  27. package/fonts/inter-latin-wght-normal.woff2 +0 -0
  28. package/package.json +57 -0
  29. package/pitis/PETS.md +121 -0
  30. package/pitis/README.md +117 -0
  31. package/pitis/index.html +266 -0
  32. package/pitis/piti.build.js +81 -0
  33. package/pitis/piti.js +929 -0
  34. package/pitis/roast-agent.md +185 -0
  35. package/realtime/src/index.js +119 -0
  36. package/realtime/wrangler.example.toml +44 -0
  37. package/scripts/adopt.mjs +106 -0
  38. package/scripts/board-snapshot.mjs +197 -0
  39. package/scripts/build-seed-pack.mjs +33 -0
  40. package/scripts/bundle-rekey.mjs +112 -0
  41. package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
  42. package/scripts/canon-extract.mjs +578 -0
  43. package/scripts/canon.mjs +599 -0
  44. package/scripts/canvas-screen.mjs +333 -0
  45. package/scripts/check-local.mjs +136 -0
  46. package/scripts/clawd-art.mjs +75 -0
  47. package/scripts/clawd-canvas.mjs +741 -0
  48. package/scripts/cli.mjs +100 -0
  49. package/scripts/clone.mjs +422 -0
  50. package/scripts/close.mjs +30 -0
  51. package/scripts/connect.mjs +103 -0
  52. package/scripts/deploy.mjs +157 -0
  53. package/scripts/dev.mjs +54 -0
  54. package/scripts/doc-lint.mjs +143 -0
  55. package/scripts/export.mjs +300 -0
  56. package/scripts/fork.mjs +64 -0
  57. package/scripts/freeze.mjs +74 -0
  58. package/scripts/frontdoor-parity.mjs +180 -0
  59. package/scripts/hook.mjs +67 -0
  60. package/scripts/identity-rekey.mjs +112 -0
  61. package/scripts/init.mjs +122 -0
  62. package/scripts/land.mjs +40 -0
  63. package/scripts/lib/adapters.mjs +156 -0
  64. package/scripts/lib/board-snapshot.mjs +511 -0
  65. package/scripts/lib/draft.mjs +385 -0
  66. package/scripts/lib/graduate.mjs +276 -0
  67. package/scripts/lib/instance.mjs +74 -0
  68. package/scripts/lib/marks.mjs +107 -0
  69. package/scripts/lib/materialize.mjs +138 -0
  70. package/scripts/lib/merge3.mjs +124 -0
  71. package/scripts/lib/offline-posture.mjs +120 -0
  72. package/scripts/lib/offline-respawn.mjs +18 -0
  73. package/scripts/lib/offline-wrangler.mjs +78 -0
  74. package/scripts/lib/publish-compose.mjs +12 -0
  75. package/scripts/lib/publish-conflict.mjs +78 -0
  76. package/scripts/lib/publish-evidence.mjs +144 -0
  77. package/scripts/lib/refine-compare.mjs +140 -0
  78. package/scripts/lib/refine-ledger.mjs +150 -0
  79. package/scripts/lib/refine-png.mjs +149 -0
  80. package/scripts/lib/seed-pack-build.mjs +141 -0
  81. package/scripts/lib/state-compare.mjs +68 -0
  82. package/scripts/lib/store.mjs +122 -0
  83. package/scripts/login.mjs +84 -0
  84. package/scripts/mark.mjs +112 -0
  85. package/scripts/migrate-board-keys.mjs +164 -0
  86. package/scripts/migrate.mjs +265 -0
  87. package/scripts/no-foreign-vocabulary.mjs +216 -0
  88. package/scripts/no-product-names.mjs +157 -0
  89. package/scripts/no-tenant-globals.mjs +1134 -0
  90. package/scripts/offline.mjs +247 -0
  91. package/scripts/og.mjs +170 -0
  92. package/scripts/one-tenant-resolver.mjs +146 -0
  93. package/scripts/open.mjs +63 -0
  94. package/scripts/publish.mjs +1041 -0
  95. package/scripts/read.mjs +31 -0
  96. package/scripts/refine.mjs +455 -0
  97. package/scripts/release-drift.mjs +138 -0
  98. package/scripts/restore.mjs +322 -0
  99. package/scripts/review.mjs +125 -0
  100. package/scripts/save.mjs +21 -0
  101. package/scripts/shell-lint.mjs +189 -0
  102. package/scripts/ship.mjs +460 -0
  103. package/scripts/shoot.mjs +264 -0
  104. package/scripts/smoke-invite.mjs +113 -0
  105. package/scripts/state-inventory.mjs +223 -0
  106. package/scripts/status.mjs +177 -0
  107. package/scripts/sync.mjs +27 -0
  108. package/scripts/tenant-do-rehearsal.mjs +1314 -0
  109. package/scripts/ui-copy-lint.mjs +202 -0
  110. package/scripts/watch.mjs +32 -0
  111. package/scripts/wrangler-preflight.mjs +246 -0
  112. package/seed/CANON.md +43 -0
  113. package/seed/README.md +119 -0
  114. package/seed/prototype-status.json +9 -0
  115. package/seed/registry.json +88 -0
  116. package/seed/skills/starter-ui/SKILL.md +52 -0
  117. package/seed/skills/starter-ui/skill.json +11 -0
  118. package/seed/skills/starter-ui/starter-tokens.css +73 -0
  119. package/seed/skills/starter-ui/starter-ui.css +318 -0
  120. package/seed/skills/starter-ui/starter-ui.js +50 -0
  121. package/seed/space.json +5 -0
  122. package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
  123. package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
  124. package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
  125. package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
  126. package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
  127. package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
  128. package/seed/threads.json +52 -0
  129. package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
  130. package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
  131. package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
  132. package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
  133. package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
  134. package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
  135. package/src/_worker.js +12668 -0
  136. package/src/board-key.mjs +57 -0
  137. package/src/board-room.mjs +881 -0
  138. package/src/bundle-keys.mjs +138 -0
  139. package/src/canon/collect-in-browser.js +215 -0
  140. package/src/canon/emit.mjs +232 -0
  141. package/src/canon/observe.mjs +260 -0
  142. package/src/canon/schema.mjs +451 -0
  143. package/src/canvas/DSEG-LICENSE.txt +95 -0
  144. package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
  145. package/src/canvas/canvas.css +885 -0
  146. package/src/canvas/canvas.js +5780 -0
  147. package/src/canvas/capture.js +485 -0
  148. package/src/chrome/appchrome.mjs +568 -0
  149. package/src/currency.mjs +203 -0
  150. package/src/delete-confirmation.mjs +254 -0
  151. package/src/drafts/drafts.js +326 -0
  152. package/src/entry.js +58 -0
  153. package/src/galleries.mjs +400 -0
  154. package/src/health-cron.mjs +173 -0
  155. package/src/identity.json +1 -0
  156. package/src/kv-codec.mjs +135 -0
  157. package/src/kv-identity.mjs +259 -0
  158. package/src/mail.mjs +599 -0
  159. package/src/provenance.mjs +74 -0
  160. package/src/publish-compose.mjs +363 -0
  161. package/src/publish-fork.mjs +221 -0
  162. package/src/publish-units.mjs +44 -0
  163. package/src/purge.mjs +81 -0
  164. package/src/review/COMMENTING-UX.md +306 -0
  165. package/src/review/cat.png +0 -0
  166. package/src/review/comment-cursor.svg +7 -0
  167. package/src/review/comments.js +1738 -0
  168. package/src/room-ticket.mjs +96 -0
  169. package/src/roster-chip.mjs +32 -0
  170. package/src/seed-pack.mjs +261 -0
  171. package/src/state-inventory.mjs +249 -0
  172. package/src/sw-logic.mjs +48 -0
  173. package/src/tenant-cache.mjs +141 -0
  174. package/src/tenant-context.mjs +386 -0
  175. package/src/tenant-do.js +2940 -0
  176. package/src/tenant-host.mjs +144 -0
  177. package/src/tenant-quotas.mjs +140 -0
  178. package/src/unit-core.mjs +103 -0
  179. package/src/unit-object.mjs +338 -0
  180. package/templates/README.md +67 -0
  181. package/templates/shell/deploy.yml +145 -0
  182. package/templates/shell/engine-bump.yml +142 -0
  183. package/templates/shell/health.yml +233 -0
  184. package/templates/shell/kv-backup.yml +201 -0
  185. package/templates/shell/roster-update.yml +110 -0
  186. package/templates/shell/space-preflight.yml +35 -0
  187. package/templates/shell/space-rebake.yml +79 -0
  188. package/templates/shell/store-backup.yml +152 -0
  189. package/templates/shell/wrangler.example.toml +266 -0
  190. package/templates/space/publish.yml +91 -0
@@ -0,0 +1,781 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * bundle-tenancy-rehearsal — the workspace segment on the bundle store, driven on REAL
4
+ * workerd against a REAL R2 bucket, with TWO workspaces, over real HTTP.
5
+ *
6
+ * WHY THIS EXISTS, and why the suite is not it. `test/bundle-tenancy.test.mjs` drives the
7
+ * real worker over the real routes, but the bucket underneath it is a `Map` behind a
8
+ * hand-written stub. That proves the SEAM — which key is formed, which family is exempt,
9
+ * what a revert answers — and it cannot prove the RUNTIME. A stub's `list` is a filter over
10
+ * a Map: it cannot get `delimitedPrefixes` wrong, it cannot truncate, and it will happily
11
+ * agree with whatever the code believes about prefixes because it was written from the same
12
+ * belief. The whole of this item is a claim about key shapes in a real object store, and a
13
+ * fixture that models the store is a fixture that can agree with a bug for months.
14
+ *
15
+ * So this stands up the ACTUAL deploy entry (`src/entry.js`'s module graph) under
16
+ * `wrangler dev --local`, with a REAL local R2 bucket (workerd's own implementation), a
17
+ * `TENANTS` Durable Object namespace and a local KV, and publishes into it over HTTP with
18
+ * publish tokens — the same front door `augur publish` uses. Nothing is imported and no
19
+ * worker function is called from here except through a request.
20
+ *
21
+ * ⚠️ LOCAL BY CONSTRUCTION AND MUST STAY THAT WAY. No account, no token, no route, no
22
+ * remote binding: `--local` plus a `--persist-to` directory under `.wrangler/`, which is
23
+ * gitignored. The fixture is invented hostnames on a reserved `.test` domain and a handful
24
+ * of made-up bytes. Nothing here may ever be pointed at a deployed instance — the phases
25
+ * below deliberately publish, delete and garbage-collect.
26
+ *
27
+ * THE THREE DEPLOYMENTS, over ONE persisted bucket, in this order, because the sequence is
28
+ * the argument:
29
+ *
30
+ * single No `TENANT_HOST_SUFFIX` and no `TENANTS` — every instance running today.
31
+ * Publishes a space and its chrome. The bucket must come out holding the keys
32
+ * it has always held and NOT ONE key under `t/`. That is the additive claim,
33
+ * and it is measured rather than assumed.
34
+ *
35
+ * hosted The suffix and the binding — the shared-worker shape. TWO workspaces answer
36
+ * on two labels. The first is the `single` phase's content, which it cannot
37
+ * see until it is MOVED (`/__publish/_state/rekey`, the same route
38
+ * `augur bundle-rekey` drives) — that move is the live migration, rehearsed.
39
+ * The second publishes a space with THE SAME ID and different bytes.
40
+ *
41
+ * reverted `hosted` again, but `main` resolves a COPY of src/ with one word flipped:
42
+ * `BUNDLE_TENANCY.spaces = false`. The per-family revert, RUN rather than read.
43
+ *
44
+ * Usage:
45
+ * node scripts/bundle-tenancy-rehearsal.mjs [--port 8812] [--keep]
46
+ *
47
+ * `--keep` leaves the generated tree for inspection. `WRANGLER_BIN` overrides how wrangler
48
+ * is spawned (default `npx wrangler`).
49
+ *
50
+ * Exit 0 when every clause passed, 1 otherwise. Each clause prints what it asked and what
51
+ * it saw, because the point of a rehearsal is the transcript.
52
+ */
53
+ import fs from "node:fs";
54
+ import path from "node:path";
55
+ import crypto from "node:crypto";
56
+ import http from "node:http";
57
+ import { spawn } from "node:child_process";
58
+ import { fileURLToPath } from "node:url";
59
+ import { __testables as WORKER_TESTABLES } from "../src/_worker.js";
60
+ const __IDENTITY_KEY = (k, ws) => WORKER_TESTABLES.identityKey(k, ws);
61
+
62
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
63
+ const WORK = path.join(ROOT, ".wrangler", "bundle-tenancy-rehearsal");
64
+
65
+ const argv = process.argv.slice(2);
66
+ const PORT = Number((argv.find((a) => a.startsWith("--port=")) || "").split("=")[1]
67
+ || (argv.includes("--port") ? argv[argv.indexOf("--port") + 1] : 0)) || 8812;
68
+ const KEEP = argv.includes("--keep");
69
+
70
+ // The fixture. Invented labels on a reserved-for-documentation domain; `.test` resolves
71
+ // nowhere, so a stray lookup cannot leave this machine. Neither name is in
72
+ // `RESERVED_LABELS` (`src/tenant-host.mjs` refuses those with a bare 404 that looks exactly
73
+ // like a broken fixture) — both are birds.
74
+ const SUFFIX = ".rehearsal.test";
75
+ const A = "wren"; // the workspace the `single` phase's content is moved to
76
+ const B = "finch"; // the neighbour, publishing a space of the SAME id
77
+ const INVENTED = ["zzprobe-one", "zzprobe-two"]; // two independent labels nobody provisioned
78
+ const SPACE = "site"; // ONE space id, published by BOTH workspaces
79
+ const TOKEN = "rehearsal-publish-token-not-a-credential";
80
+ const SESSION_SECRET = "rehearsal-session-secret-not-a-credential";
81
+ const WORKER_NAME = "augur-bundle-tenancy-rehearsal";
82
+ const KV_ID = "bundle-tenancy-rehearsal-kv";
83
+ const BUCKET = "bundle-tenancy-rehearsal-bundles";
84
+
85
+ // ---- the transcript ---------------------------------------------------------
86
+
87
+ const results = [];
88
+ let currentClause = "setup";
89
+ function clause(name) { currentClause = name; console.log(`\n── ${name} ${"─".repeat(Math.max(0, 74 - name.length))}`); }
90
+ function check(what, ok, detail = "") {
91
+ results.push({ clause: currentClause, what, ok });
92
+ console.log(` ${ok ? "PASS" : "FAIL"} ${what}${detail ? `\n ${String(detail).replace(/\n/g, "\n ")}` : ""}`);
93
+ }
94
+ function note(text) { console.log(` ${String(text).replace(/\n/g, "\n ")}`); }
95
+
96
+ const sha256 = (buf) => crypto.createHash("sha256").update(buf).digest("hex");
97
+ const sha40 = (buf) => sha256(buf).slice(0, 40);
98
+
99
+ // ---- the generated tree -----------------------------------------------------
100
+
101
+ /**
102
+ * The rehearsal entry: `src/entry.js` plus a `/__rehearsal/*` prefix in FRONT of the real
103
+ * front door, for SETUP and EVIDENCE only.
104
+ *
105
+ * Two things live behind it and neither is ever asserted on in place of a real response.
106
+ * `/__rehearsal/r2` reads and writes the bucket directly — that is how the transcript can
107
+ * say which PHYSICAL keys exist, which is the one fact no HTTP response can show and the
108
+ * whole subject of this item. `/__rehearsal/call` reaches two engine functions that have no
109
+ * route at all: `assetGc`, which has no driver anywhere (that is a finding, not an
110
+ * omission), and the tenancy table, so the revert can be shown to have landed.
111
+ */
112
+ function entrySource(srcDir) {
113
+ const q = (p) => JSON.stringify(path.join(srcDir, p));
114
+ return `// GENERATED by scripts/bundle-tenancy-rehearsal.mjs — not a deploy entry, never committed.
115
+ import worker, { __testables } from ${q("_worker.js")};
116
+ export { TenantStore } from ${q("tenant-do.js")};
117
+ export { BoardRoom } from ${q("board-room.mjs")};
118
+
119
+ async function probe(request, env, url) {
120
+ if (url.pathname === "/__rehearsal/r2") {
121
+ const b = await request.json();
122
+ const r2 = env.BUNDLES;
123
+ if (b.op === "list") {
124
+ const out = []; const prefixes = [];
125
+ let cursor;
126
+ do {
127
+ const page = await r2.list({ prefix: b.prefix || "", delimiter: b.delimiter, cursor, limit: 1000 });
128
+ for (const o of page.objects || []) out.push({ key: o.key, size: o.size });
129
+ for (const p of page.delimitedPrefixes || []) prefixes.push(p);
130
+ cursor = page.truncated ? page.cursor : null;
131
+ } while (cursor);
132
+ return Response.json({ objects: out, delimitedPrefixes: prefixes });
133
+ }
134
+ if (b.op === "get") {
135
+ const o = await r2.get(b.key);
136
+ if (!o) return Response.json({ present: false });
137
+ const buf = new Uint8Array(await o.arrayBuffer());
138
+ let hex = ""; for (const x of buf) hex += x.toString(16).padStart(2, "0");
139
+ return Response.json({ present: true, size: buf.byteLength, sha256: await sha(buf), text: new TextDecoder().decode(buf).slice(0, 400) });
140
+ }
141
+ if (b.op === "put") { await r2.put(b.key, b.body); return Response.json({ ok: true }); }
142
+ if (b.op === "delete") { await r2.delete(b.key); return Response.json({ ok: true }); }
143
+ return Response.json({ error: "bad-op" }, { status: 400 });
144
+ }
145
+ if (url.pathname === "/__rehearsal/kv") {
146
+ const b = await request.json();
147
+ const kv = env.COMMENTS;
148
+ if (b.op === "get") return Response.json({ value: await kv.get(b.key) });
149
+ if (b.op === "put") { await kv.put(b.key, b.value); return Response.json({ ok: true }); }
150
+ if (b.op === "list") {
151
+ const out = []; let cursor;
152
+ do {
153
+ const page = await kv.list({ prefix: b.prefix || "", cursor });
154
+ for (const k of page.keys || []) out.push(k.name);
155
+ cursor = page.list_complete ? null : page.cursor;
156
+ } while (cursor);
157
+ return Response.json({ keys: out });
158
+ }
159
+ return Response.json({ error: "bad-op" }, { status: 400 });
160
+ }
161
+ if (url.pathname === "/__rehearsal/call") {
162
+ const b = await request.json();
163
+ if (b.fn === "tenancy") {
164
+ return Response.json({
165
+ BUNDLE_TENANCY: __testables.BUNDLE_TENANCY,
166
+ segment: __testables.bundleWorkspaceSegment(env, b.workspace),
167
+ exampleKeys: {
168
+ manifest: __testables.bundleKey("spaces/" + (b.space || "x") + "/manifest.json", __testables.bundleWorkspaceSegment(env, b.workspace).workspace),
169
+ engine: __testables.bundleKey("spaces/_engine/manifest.json", __testables.bundleWorkspaceSegment(env, b.workspace).workspace),
170
+ blob: __testables.bundleKey("blobs/deadbeef", __testables.bundleWorkspaceSegment(env, b.workspace).workspace),
171
+ config: __testables.bundleKey("config/instance.json", __testables.bundleWorkspaceSegment(env, b.workspace).workspace),
172
+ asset: __testables.bundleKey("assets/deadbeef", __testables.bundleWorkspaceSegment(env, b.workspace).workspace),
173
+ },
174
+ });
175
+ }
176
+ if (b.fn === "assetGc") {
177
+ // ⚠️ THE REAL COLLECTOR, WITH THE REAL BINDINGS, and it is reached this way because
178
+ // it is reachable NO other way: nothing in the engine drives it — no route, no cron,
179
+ // no verb. That is the state of the code, not a gap in this harness.
180
+ const tctx = { tenantId: b.workspace };
181
+ return Response.json(await __testables.assetGc(env, tctx, { now: Date.now() + 40 * 24 * 3600_000 }));
182
+ }
183
+ if (b.fn === "assetRow") {
184
+ const tctx = { tenantId: b.workspace };
185
+ const store = __testables.overlayFor(env, tctx);
186
+ if (b.set) await store.set("assets", "", b.hash, { ct: "image/png", bytes: b.bytes, at: new Date(Date.now() - 40 * 24 * 3600_000).toISOString() });
187
+ return Response.json({ rows: Object.keys(await store.read("assets")) });
188
+ }
189
+ return Response.json({ error: "bad-fn" }, { status: 400 });
190
+ }
191
+ return Response.json({ error: "not-found" }, { status: 404 });
192
+ }
193
+
194
+ async function sha(buf) {
195
+ const d = await crypto.subtle.digest("SHA-256", buf);
196
+ return [...new Uint8Array(d)].map((x) => x.toString(16).padStart(2, "0")).join("");
197
+ }
198
+
199
+ export default {
200
+ async fetch(request, env, ctx) {
201
+ const url = new URL(request.url);
202
+ if (url.pathname.startsWith("/__rehearsal/")) return probe(request, env, url);
203
+ return worker.fetch(request, env, ctx);
204
+ },
205
+ };
206
+ `;
207
+ }
208
+
209
+ /** The wrangler config. `hosted:false` removes the suffix AND the binding, nothing else. */
210
+ function wranglerConfig({ entry, assets, hosted }) {
211
+ return `# GENERATED by scripts/bundle-tenancy-rehearsal.mjs on every run — edit that, not this.
212
+ name = ${JSON.stringify(WORKER_NAME)}
213
+ main = ${JSON.stringify(entry)}
214
+ compatibility_date = "2025-01-01"
215
+
216
+ [assets]
217
+ directory = ${JSON.stringify(assets)}
218
+ binding = "ASSETS"
219
+ run_worker_first = true
220
+ not_found_handling = "none"
221
+ html_handling = "auto-trailing-slash"
222
+
223
+ [vars]
224
+ # The engine's own name for "this is a real deployment": content serves from the store.
225
+ GV_ASSET_SOURCE = "r2"
226
+
227
+ [[kv_namespaces]]
228
+ binding = "COMMENTS"
229
+ id = ${JSON.stringify(KV_ID)}
230
+
231
+ [[r2_buckets]]
232
+ binding = "BUNDLES"
233
+ bucket_name = ${JSON.stringify(BUCKET)}
234
+ ${hosted ? `
235
+ [vars.hosted]
236
+
237
+ [[durable_objects.bindings]]
238
+ name = "TENANTS"
239
+ class_name = "TenantStore"
240
+
241
+ [[migrations]]
242
+ tag = "v1"
243
+ new_sqlite_classes = ["TenantStore"]
244
+ ` : `
245
+ # NO TENANT_HOST_SUFFIX AND NO TENANTS BINDING. Every instance running today, and the whole
246
+ # of the "with neither, the store answers exactly as it always has" claim.
247
+ `}`;
248
+ }
249
+
250
+ async function generate() {
251
+ fs.rmSync(WORK, { recursive: true, force: true });
252
+ fs.mkdirSync(path.join(WORK, "assets", "__config"), { recursive: true });
253
+ // A raw asset host with no config: a deployed instance in bundle mode reads its config
254
+ // from the STORE, and shipping one here would let a phase pass on the file instead.
255
+ fs.writeFileSync(path.join(WORK, "assets", "robots.txt"), "User-agent: *\nDisallow:\n");
256
+
257
+ const src = path.join(ROOT, "src");
258
+ fs.writeFileSync(path.join(WORK, "entry.js"), entrySource(src));
259
+
260
+ // THE REVERT, as a copy of src/ with one word changed. Copied rather than patched in
261
+ // place for the obvious reason — the tree under test must not be edited to test it — and
262
+ // the edit is ASSERTED to have landed, because a revert that silently did not apply is a
263
+ // clause that passes for the wrong reason.
264
+ const revertedSrc = path.join(WORK, "reverted-src");
265
+ fs.cpSync(src, revertedSrc, { recursive: true });
266
+ // The constant lives in src/bundle-keys.mjs since the workspace object started writing the
267
+ // store too (the seed pack at provisioning); the worker imports it from there.
268
+ const wf = path.join(revertedSrc, "bundle-keys.mjs");
269
+ const before = fs.readFileSync(wf, "utf8");
270
+ const after = before.replace(/(export const BUNDLE_TENANCY = Object\.freeze\(\{[\s\S]{0,3000}?\n)(\s*)spaces: true,/,
271
+ (_m, head, indent) => `${head}${indent}spaces: false,`);
272
+ if (after === before) throw new Error("the revert edit did not apply — has BUNDLE_TENANCY moved or been renamed?");
273
+ fs.writeFileSync(wf, after);
274
+ fs.writeFileSync(path.join(WORK, "reverted-entry.js"), entrySource(revertedSrc));
275
+
276
+ const assets = path.join(WORK, "assets");
277
+ fs.writeFileSync(path.join(WORK, "single.toml"),
278
+ wranglerConfig({ entry: path.join(WORK, "entry.js"), assets, hosted: false }));
279
+ fs.writeFileSync(path.join(WORK, "hosted.toml"),
280
+ wranglerConfig({ entry: path.join(WORK, "entry.js"), assets, hosted: true }));
281
+ fs.writeFileSync(path.join(WORK, "reverted.toml"),
282
+ wranglerConfig({ entry: path.join(WORK, "reverted-entry.js"), assets, hosted: true }));
283
+ }
284
+
285
+ // ---- one deployment ---------------------------------------------------------
286
+
287
+ /**
288
+ * Spawn `wrangler dev` on one config and wait for it to answer.
289
+ *
290
+ * One at a time, on one port, sharing one persist directory: the deployments are a
291
+ * SEQUENCE, not a set. "the single-workspace store is not read by a host-resolved one",
292
+ * "the move puts it back" and "the revert restores the unprefixed answer" are all claims
293
+ * about ONE bucket seen through two deployments, and a fresh bucket each time would make
294
+ * every one of them vacuous.
295
+ */
296
+ async function boot(config, label, hostSuffix) {
297
+ const bin = process.env.WRANGLER_BIN || "npx";
298
+ const args = process.env.WRANGLER_BIN ? [] : ["--yes", "wrangler"];
299
+ const child = spawn(bin, [...args,
300
+ "dev", "-c", path.join(WORK, `${config}.toml`),
301
+ "--local", "--ip", "127.0.0.1", "--port", String(PORT),
302
+ "--persist-to", path.join(WORK, "state"),
303
+ "--var", `SESSION_SECRET:${SESSION_SECRET}`,
304
+ ...(hostSuffix ? ["--var", `TENANT_HOST_SUFFIX:${hostSuffix}`] : []),
305
+ "--log-level", "warn",
306
+ ], {
307
+ cwd: WORK,
308
+ stdio: ["ignore", "pipe", "pipe"],
309
+ env: { ...process.env, WRANGLER_SEND_METRICS: "false", CLOUDFLARE_API_TOKEN: "", CLOUDFLARE_ACCOUNT_ID: "" },
310
+ });
311
+ const log = [];
312
+ child.stdout.on("data", (d) => log.push(String(d)));
313
+ child.stderr.on("data", (d) => log.push(String(d)));
314
+ let exited = null;
315
+ child.on("exit", (code) => { exited = code; });
316
+
317
+ const deadline = Date.now() + 90_000;
318
+ for (;;) {
319
+ if (exited !== null) throw new Error(`wrangler exited (${exited}) booting "${label}":\n${log.join("")}`);
320
+ if (Date.now() > deadline) { child.kill("SIGKILL"); throw new Error(`"${label}" never answered:\n${log.join("")}`); }
321
+ try {
322
+ const r = await req("/__rehearsal/r2", "127.0.0.1", {
323
+ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ op: "list", prefix: "zzz" }),
324
+ });
325
+ if (r.status === 200) { console.log(`\n▸ deployment "${label}" up on ${PORT} (${config}.toml)`); return { child, log }; }
326
+ } catch (e) { /* not listening yet */ }
327
+ await new Promise((r) => setTimeout(r, 400));
328
+ }
329
+ }
330
+
331
+ async function shutdown(dep) {
332
+ if (!dep || !dep.child) return;
333
+ await new Promise((resolve) => {
334
+ dep.child.on("exit", resolve);
335
+ dep.child.kill("SIGINT");
336
+ setTimeout(() => { try { dep.child.kill("SIGKILL"); } catch (e) {} resolve(); }, 8000);
337
+ });
338
+ await new Promise((r) => setTimeout(r, 500));
339
+ }
340
+
341
+ // ---- talking to it ----------------------------------------------------------
342
+
343
+ /**
344
+ * ⚠️ `node:http`, NOT `fetch`. undici treats `Host` as a forbidden header and drops it
345
+ * silently, so every request would go out naming `127.0.0.1`, the resolver would answer
346
+ * null, and the front door would serve the bare 404 a hostname naming no workspace gets —
347
+ * which is correct behaviour and looks exactly like a broken fixture.
348
+ */
349
+ function req(pathname, host, { method = "GET", headers = {}, body } = {}) {
350
+ const h = { Host: host, ...headers };
351
+ if (body != null) h["content-length"] = String(Buffer.byteLength(body));
352
+ return new Promise((resolve, reject) => {
353
+ const r = http.request({ host: "127.0.0.1", port: PORT, path: pathname, method, headers: h }, (res) => {
354
+ const chunks = [];
355
+ res.on("data", (c) => chunks.push(c));
356
+ res.on("end", () => {
357
+ const buf = Buffer.concat(chunks);
358
+ resolve({ status: res.statusCode, headers: res.headers, body: buf, text: buf.toString("utf8"), sha256: sha256(buf) });
359
+ });
360
+ });
361
+ r.on("error", reject);
362
+ if (body != null) r.write(body);
363
+ r.end();
364
+ });
365
+ }
366
+ const json = (r) => { try { return JSON.parse(r.text); } catch (e) { return { __unparsed: r.text.slice(0, 200), __status: r.status }; } };
367
+ const post = (pathname, host, obj, extra = {}) => req(pathname, host, {
368
+ method: "POST", headers: { "content-type": "application/json", ...(extra.headers || {}) }, body: JSON.stringify(obj),
369
+ });
370
+ const authed = { Authorization: `Bearer ${TOKEN}` };
371
+
372
+ // Setup/evidence only — see the entry's header.
373
+ const r2 = (body) => post("/__rehearsal/r2", "127.0.0.1", body).then(json);
374
+ const kv = (body) => post("/__rehearsal/kv", "127.0.0.1", body).then(json);
375
+ const call = (body) => post("/__rehearsal/call", "127.0.0.1", body).then(json);
376
+
377
+ // ---- the fixture content ----------------------------------------------------
378
+
379
+ const bytesFor = (who, what) => Buffer.from(`${who}:${what}\n${"x".repeat(40)}`);
380
+
381
+ const manifestFor = (who, spaceId, files) => ({
382
+ id: spaceId, format: 1,
383
+ source: { sha: `sha-${who}`, dirty: false },
384
+ builtWith: { engine: "rehearsal", version: "0.0.0" },
385
+ files,
386
+ routing: {
387
+ publicPrefixes: [`/${spaceId}/`],
388
+ versionMap: {},
389
+ unitSources: { [`/${spaceId}/`]: { sha: `sha-${who}`, dirty: false } },
390
+ },
391
+ space: { id: spaceId, default: true },
392
+ });
393
+
394
+ /** Upload the bytes, then commit the manifest — the two calls `augur publish` makes. */
395
+ async function publish(host, spaceId, files) {
396
+ for (const f of Object.values(files)) {
397
+ if (f.__skipUpload) continue;
398
+ const put = await req(`/__publish/${spaceId}/blob/${f.h}`, host, {
399
+ method: "PUT", headers: { ...authed }, body: f.__bytes,
400
+ });
401
+ if (put.status !== 204) throw new Error(`blob PUT for ${f.h} answered ${put.status}: ${put.text.slice(0, 200)}`);
402
+ }
403
+ const m = manifestFor(host.split(".")[0], spaceId,
404
+ Object.fromEntries(Object.entries(files).map(([p, f]) => [p, { h: f.h, ct: f.ct, s: f.s }])));
405
+ const res = await post(`/__publish/${spaceId}/commit`, host, m, { headers: authed });
406
+ return { status: res.status, body: json(res) };
407
+ }
408
+
409
+ const fileOf = (buf, ct) => ({ h: sha256(buf), ct, s: buf.byteLength, __bytes: buf });
410
+
411
+ // ---- phase: single ----------------------------------------------------------
412
+
413
+ const state = {};
414
+
415
+ async function singlePhase() {
416
+ clause("the deployment every instance runs today — no suffix, no TENANTS");
417
+
418
+ // The publish token. ⚠️ THE KEY COMES FROM THE PRODUCER: this phase's deployment sets no
419
+ // host suffix, so the document is at the unsegmented key it has always been at; the
420
+ // hosted phase below seeds it PER WORKSPACE, because `identityKey` segments it there
421
+ // (`B-identity-kv-write-segmentation`). One flat key used to be why a single token
422
+ // reached both hostnames — that is closed, and the two workspaces below now hold their
423
+ // own credential rather than sharing one, which is also the only way an isolation claim
424
+ // about them means anything.
425
+ const h = crypto.createHash("sha256").update("gv:pub:" + TOKEN, "utf8").digest("hex");
426
+ await kv({
427
+ op: "put",
428
+ key: __IDENTITY_KEY("publish:tokens", ""),
429
+ value: JSON.stringify({ [h]: { space: "*", label: "ci" } }),
430
+ });
431
+
432
+ const cfg = await post("/__publish/_instance/config", "127.0.0.1", {
433
+ tenantId: A, users: [], engineVersion: "0.0.0", sentinels: [],
434
+ }, { headers: authed });
435
+ check("the instance config pushes", cfg.status === 200, `${cfg.status} ${cfg.text.slice(0, 120)}`);
436
+
437
+ // The engine chrome, as its own pseudo-space. One build serves every workspace, which is
438
+ // why its key is the one `spaces/` key that never takes a segment.
439
+ const sw = bytesFor("engine", "sw.js");
440
+ const eng = await publish("127.0.0.1", "_engine", { "/sw.js": fileOf(sw, "application/javascript") });
441
+ check("the engine chrome publishes", eng.status === 200, JSON.stringify(eng.body).slice(0, 160));
442
+
443
+ // The space. `/space-icon.png` is deliberately one of the files: it is one of the three
444
+ // surfaces the disclosure finding was measured on.
445
+ state.pageA = bytesFor(A, "index.html");
446
+ state.iconA = bytesFor(A, "space-icon.png");
447
+ state.blobA = sha256(state.pageA);
448
+ const pub = await publish("127.0.0.1", SPACE, {
449
+ [`/${SPACE}/index.html`]: fileOf(state.pageA, "text/html"),
450
+ "/space-icon.png": fileOf(state.iconA, "image/png"),
451
+ });
452
+ check("the space publishes", pub.status === 200, JSON.stringify(pub.body).slice(0, 200));
453
+ state.versionA = pub.body.version;
454
+
455
+ const page = await req(`/${SPACE}/index.html`, "127.0.0.1");
456
+ check("and serves its own bytes", page.status === 200 && page.sha256 === sha256(state.pageA),
457
+ `${page.status} sha ${page.sha256.slice(0, 16)}`);
458
+ state.buildJsonSingle = json(await req("/_build.json", "127.0.0.1"));
459
+
460
+ clause("⚠️ THE ADDITIVE CLAIM: with no suffix and no binding, not one key carries a segment");
461
+ const all = await r2({ op: "list", prefix: "" });
462
+ const prefixed = all.objects.filter((o) => o.key.startsWith("t/"));
463
+ check("the bucket holds no key under `t/` at all", prefixed.length === 0,
464
+ prefixed.length ? prefixed.map((o) => o.key).join("\n") : `${all.objects.length} objects, none prefixed`);
465
+ const shapes = all.objects.map((o) => o.key).sort();
466
+ check("and every key it does hold is the shape it has always been",
467
+ shapes.every((k) => /^(config\/|spaces\/|blobs\/|assets\/)/.test(k)), shapes.join("\n"));
468
+ // ⚠️ THE SAME CLAIM FOR THE IDENTITY DOCUMENTS, AND IT IS THE ONE A LIVE INSTANCE PAYS
469
+ // FOR. `B-identity-kv-write-segmentation` gave the roster, the tokens and the rest the
470
+ // same segment — and a deployment that resolves no workspace from the Host must write
471
+ // not one key under `t/`. Counted rather than argued, on real workerd, after a real
472
+ // publish and a real config push have both run.
473
+ const kvAll = await kv({ op: "list", prefix: "" });
474
+ const kvPrefixed = (kvAll.keys || []).filter((k) => k.startsWith("t/"));
475
+ check("the namespace holds no key under `t/` at all", kvPrefixed.length === 0,
476
+ kvPrefixed.length ? kvPrefixed.join("\n") : `${(kvAll.keys || []).length} keys, none prefixed`);
477
+ const t = await call({ fn: "tenancy", workspace: A, space: SPACE });
478
+ check("the segment this deployment computes is EMPTY", t.segment.workspace === "",
479
+ JSON.stringify(t.segment));
480
+ check("so the key-former is the identity here",
481
+ t.exampleKeys.manifest === `spaces/${SPACE}/manifest.json`, JSON.stringify(t.exampleKeys));
482
+
483
+ clause("realistic history, so the move below is a move and not a gesture");
484
+ // 260 retained version manifests, written straight into the bucket — a mature workspace's
485
+ // footprint is overwhelmingly rollback history (versions are never pruned), and the move
486
+ // has to page through more of them than one call will carry.
487
+ const vdoc = JSON.stringify({ id: SPACE, version: 1, files: {}, routing: {} });
488
+ for (let n = 2; n <= 260; n++) await r2({ op: "put", key: `spaces/${SPACE}/versions/${n}.json`, body: vdoc });
489
+ const hist = await r2({ op: "list", prefix: `spaces/${SPACE}/versions/` });
490
+ check("the store holds a realistic version history", hist.objects.length >= 260, `${hist.objects.length} version objects`);
491
+ state.objectsBefore = (await r2({ op: "list", prefix: "" })).objects.length;
492
+ note(`${state.objectsBefore} objects in the bucket`);
493
+ }
494
+
495
+ // ---- phase: hosted ----------------------------------------------------------
496
+
497
+ const hostA = A + SUFFIX;
498
+ const hostB = B + SUFFIX;
499
+
500
+ async function hostedPhase() {
501
+ clause("the same bucket, seen by a deployment that resolves the workspace from the Host");
502
+
503
+ // ⚠️ EACH WORKSPACE GETS ITS OWN COPY OF THE CREDENTIAL, at its own segmented key. The
504
+ // token VALUE is the same string only so this script has one thing to send; the point is
505
+ // that the document is per workspace, so nothing below can pass because a shared
506
+ // document authenticated everywhere.
507
+ const th = crypto.createHash("sha256").update("gv:pub:" + TOKEN, "utf8").digest("hex");
508
+ for (const ws of [A, B]) {
509
+ await kv({
510
+ op: "put",
511
+ key: __IDENTITY_KEY("publish:tokens", ws),
512
+ value: JSON.stringify({ [th]: { space: "*", label: "ci" } }),
513
+ });
514
+ }
515
+
516
+ const t = await call({ fn: "tenancy", workspace: A, space: SPACE });
517
+ check("the segment is now this workspace", t.segment.workspace === A, JSON.stringify(t.segment));
518
+ check("⚠️ and an unprefixed key is NOT read as ours — there is no fallback to fall back to",
519
+ t.segment.legacyIsOurs === false, JSON.stringify(t.segment));
520
+ check("`spaces/` and `config/` take the segment",
521
+ t.exampleKeys.manifest === `t/${A}/spaces/${SPACE}/manifest.json` && t.exampleKeys.config === `t/${A}/config/instance.json`,
522
+ JSON.stringify(t.exampleKeys, null, 1));
523
+ check("⚠️ `spaces/_engine/` and `blobs/` DO NOT, deliberately",
524
+ t.exampleKeys.engine === "spaces/_engine/manifest.json" && t.exampleKeys.blob === "blobs/deadbeef",
525
+ JSON.stringify(t.exampleKeys, null, 1));
526
+
527
+ const before = json(await req("/_build.json", hostA));
528
+ check("so before the move this workspace serves NOTHING of the old content",
529
+ !Object.keys(before.spaces || {}).includes(SPACE), JSON.stringify(before.spaces || {}));
530
+ const pageBefore = await req(`/${SPACE}/index.html`, hostA);
531
+ check("and its published page is not served either", pageBefore.sha256 !== sha256(state.pageA),
532
+ `${pageBefore.status}`);
533
+
534
+ clause("THE MIGRATION, RUN: /__publish/_state/rekey — the route `augur bundle-rekey` drives");
535
+ const dry = json(await post("/__publish/_state/rekey", hostA, {}, { headers: authed }));
536
+ check("a dry run says what would move and moves nothing", dry.ok && dry.dryRun && dry.copied > 0,
537
+ `considered ${dry.considered}, would copy ${dry.copied}+${dry.pending}, shared ${dry.shared}`);
538
+ check("⚠️ it counts `spaces/_engine/` and anything else global as SHARED, not as moved",
539
+ dry.shared >= 1, `shared ${dry.shared}`);
540
+ const stillNothing = await r2({ op: "list", prefix: "t/" });
541
+ check("and the dry run really wrote nothing", stillNothing.objects.length === 0, `${stillNothing.objects.length} keys under t/`);
542
+
543
+ const wrong = json(await post("/__publish/_state/rekey", hostA, { confirm: "somebody-else" }, { headers: authed }));
544
+ check("a mistyped confirmation is refused", !wrong.ok && wrong.reason === "confirm-mismatch", JSON.stringify(wrong));
545
+
546
+ let pages = 0, copied = 0;
547
+ for (;;) {
548
+ const out = json(await post("/__publish/_state/rekey", hostA, { confirm: A }, { headers: authed }));
549
+ if (!out.ok) { check("the move ran", false, JSON.stringify(out)); break; }
550
+ pages++; copied += out.copied;
551
+ if (out.done) break;
552
+ if (pages > 20) { check("the move terminated", false, `still not done after ${pages} pages`); break; }
553
+ }
554
+ check("the move ran, in pages, to completion", copied >= 260, `${copied} objects over ${pages} call(s)`);
555
+
556
+ const again = json(await post("/__publish/_state/rekey", hostA, { confirm: A }, { headers: authed }));
557
+ check("⚠️ and it is IDEMPOTENT — a second run copies nothing and says it is done",
558
+ again.ok && again.copied === 0 && again.done, JSON.stringify({ copied: again.copied, skipped: again.skipped, done: again.done }));
559
+
560
+ const originals = await r2({ op: "list", prefix: `spaces/${SPACE}/` });
561
+ check("⚠️ the originals are UNTOUCHED — it is a copy and never a cut",
562
+ originals.objects.length >= 261, `${originals.objects.length} objects still at the old keys`);
563
+
564
+ clause("the workspace survives its own migration");
565
+ const after = json(await req("/_build.json", hostA));
566
+ check("`/_build.json` names the space again", !!(after.spaces || {})[SPACE], JSON.stringify(after.spaces || {}));
567
+ check("at the version it was published at", (after.spaces || {})[SPACE].version === state.versionA,
568
+ `before ${state.versionA} · after ${(after.spaces || {})[SPACE].version}`);
569
+ const pageA = await req(`/${SPACE}/index.html`, hostA);
570
+ check("and the page serves BYTE-IDENTICAL content, hash for hash",
571
+ pageA.status === 200 && pageA.sha256 === sha256(state.pageA),
572
+ `${pageA.status} · expected ${sha256(state.pageA).slice(0, 16)} · got ${pageA.sha256.slice(0, 16)}`);
573
+ const vers = json(await req(`/__publish/${SPACE}/versions`, hostA, { headers: authed }));
574
+ check("its publish history is still reachable", (vers.versions || []).length >= 260,
575
+ `${(vers.versions || []).length} versions`);
576
+ const roll = json(await req(`/__publish/${SPACE}/version/7`, hostA, { headers: authed }));
577
+ check("and an individual old version reads back", !roll.error, JSON.stringify(roll).slice(0, 120));
578
+
579
+ clause("A SECOND WORKSPACE, publishing a space with THE SAME ID");
580
+ const cfgB = await post("/__publish/_instance/config", hostB, {
581
+ tenantId: B, users: [], engineVersion: "0.0.0", sentinels: [],
582
+ }, { headers: authed });
583
+ check("it pushes its own instance config", cfgB.status === 200, `${cfgB.status}`);
584
+
585
+ state.pageB = bytesFor(B, "index.html");
586
+ state.iconB = bytesFor(B, "space-icon.png");
587
+ const pubB = await publish(hostB, SPACE, {
588
+ [`/${SPACE}/index.html`]: fileOf(state.pageB, "text/html"),
589
+ "/space-icon.png": fileOf(state.iconB, "image/png"),
590
+ // ⚠️ THE SHARED BLOB, REFERENCED AND NOT RE-UPLOADED. `__skipUpload` means these bytes
591
+ // never cross the wire on this publish: the hash is the one workspace A uploaded, and
592
+ // the commit's own spot-check has to find it under the global `blobs/` key or refuse.
593
+ [`/${SPACE}/borrowed.html`]: { ...fileOf(state.pageA, "text/html"), __skipUpload: true },
594
+ });
595
+ check("it publishes, referencing A's blob WITHOUT re-uploading it", pubB.status === 200,
596
+ JSON.stringify(pubB.body).slice(0, 200));
597
+
598
+ const a2 = await req(`/${SPACE}/index.html`, hostA);
599
+ const b2 = await req(`/${SPACE}/index.html`, hostB);
600
+ check("A still serves A's bytes after B published the same space id",
601
+ a2.sha256 === sha256(state.pageA), a2.sha256.slice(0, 16));
602
+ check("B serves B's, hash for hash", b2.sha256 === sha256(state.pageB), b2.sha256.slice(0, 16));
603
+ check("⚠️ and they are DIFFERENT — the collision is gone", a2.sha256 !== b2.sha256,
604
+ `${a2.sha256.slice(0, 16)} vs ${b2.sha256.slice(0, 16)}`);
605
+ const iconA = await req("/space-icon.png", hostA);
606
+ const iconB = await req("/space-icon.png", hostB);
607
+ check("their icons differ too", iconA.sha256 === sha256(state.iconA) && iconB.sha256 === sha256(state.iconB),
608
+ `${iconA.sha256.slice(0, 12)} / ${iconB.sha256.slice(0, 12)}`);
609
+
610
+ clause("neither can READ, LIST, OVERWRITE or DELETE the other's — over real HTTP");
611
+ const manB = json(await req(`/__publish/${SPACE}/manifest`, hostB, { headers: authed }));
612
+ check("the manifest read at B is B's, not A's",
613
+ manB.source && manB.source.sha === `sha-${B}`, JSON.stringify(manB.source));
614
+ const versB = json(await req(`/__publish/${SPACE}/versions`, hostB, { headers: authed }));
615
+ check("⚠️ the version LISTING at B does not show A's 260 versions",
616
+ (versB.versions || []).length <= 2, `${(versB.versions || []).length} versions`);
617
+ const oldAtB = await req(`/__publish/${SPACE}/version/7`, hostB, { headers: authed });
618
+ check("a version number only A has is unknown at B", oldAtB.status === 404, `${oldAtB.status}`);
619
+ const rollAtB = json(await post(`/__publish/${SPACE}/rollback`, hostB, { version: 7 }, { headers: authed }));
620
+ check("and rolling B back to it is refused", rollAtB.error === "unknown-version", JSON.stringify(rollAtB));
621
+
622
+ // The overwrite. A star token is admin-equivalent and reaches every space; what it may no
623
+ // longer do is reach every WORKSPACE's copy of one.
624
+ const evil = bytesFor("attacker", "index.html");
625
+ const over = await publish(hostB, SPACE, {
626
+ [`/${SPACE}/index.html`]: fileOf(evil, "text/html"),
627
+ "/space-icon.png": fileOf(state.iconB, "image/png"),
628
+ [`/${SPACE}/borrowed.html`]: { ...fileOf(state.pageA, "text/html"), __skipUpload: true },
629
+ });
630
+ check("B may overwrite B's own space", over.status === 200, JSON.stringify(over.body).slice(0, 160));
631
+ const a3 = await req(`/${SPACE}/index.html`, hostA);
632
+ check("⚠️ and A's page is untouched by it", a3.sha256 === sha256(state.pageA),
633
+ `${a3.sha256.slice(0, 16)} (expected ${sha256(state.pageA).slice(0, 16)})`);
634
+ const b3 = await req(`/${SPACE}/index.html`, hostB);
635
+ check("while B's moved", b3.sha256 === sha256(evil), b3.sha256.slice(0, 16));
636
+
637
+ // The delete verb, as far as it goes: `_state/delete` refuses without the workspace
638
+ // object's own agreement, so the dry run is what is reachable — and what it reports is
639
+ // exactly the set of keys it would be allowed to name.
640
+ const delB = json(await post("/__publish/_state/delete", hostB, {}, { headers: authed }));
641
+ check("a dry-run erasure at B names only keys under B's own prefix",
642
+ delB.ok === true && delB.workspace === B, JSON.stringify(delB).slice(0, 160));
643
+ const aStillThere = await req(`/${SPACE}/index.html`, hostA);
644
+ check("and A is still serving after it", aStillThere.sha256 === sha256(state.pageA), `${aStillThere.status}`);
645
+
646
+ clause("THE DISCLOSURE FINDING, RE-RUN — it must now FAIL, on two independent fresh labels");
647
+ for (const label of INVENTED) {
648
+ const host = label + SUFFIX;
649
+ const bj = await req("/_build.json", host);
650
+ const b = json(bj);
651
+ check(`${label}: /_build.json serves no real workspace's spaces`,
652
+ !((b.spaces || {})[SPACE]), `${bj.status} ${JSON.stringify(b.spaces || b).slice(0, 140)}`);
653
+ check(`${label}: and it is NOT byte-identical to a real workspace's`,
654
+ bj.sha256 !== sha256(Buffer.from(JSON.stringify(after))), `${bj.sha256.slice(0, 16)}`);
655
+ const ic = await req("/space-icon.png", host);
656
+ check(`${label}: /space-icon.png is neither workspace's icon`,
657
+ ic.sha256 !== sha256(state.iconA) && ic.sha256 !== sha256(state.iconB), `${ic.status} ${ic.sha256.slice(0, 16)}`);
658
+ const as = await req(`/__asset/${state.assetHash || "0".repeat(40)}`, host);
659
+ check(`${label}: /__asset/<hash> answers nothing`, as.status === 404, `${as.status}`);
660
+ }
661
+
662
+ clause("`blobs/` and `spaces/_engine/` are PROVEN still shared");
663
+ const borrowed = await req(`/${SPACE}/borrowed.html`, hostB);
664
+ check("⚠️ B serves the bytes A uploaded, from the ONE global blob — no re-upload happened",
665
+ borrowed.status === 200 && borrowed.sha256 === sha256(state.pageA),
666
+ `${borrowed.status} ${borrowed.sha256.slice(0, 16)}`);
667
+ const blobKeys = await r2({ op: "list", prefix: "blobs/" });
668
+ check("and there is exactly one copy of it in the bucket",
669
+ blobKeys.objects.filter((o) => o.key === `blobs/${state.blobA}`).length === 1,
670
+ blobKeys.objects.map((o) => o.key).join("\n"));
671
+ const engKeys = await r2({ op: "list", prefix: "", delimiter: undefined });
672
+ const engineCopies = engKeys.objects.filter((o) => /(^|\/)spaces\/_engine\//.test(o.key)).map((o) => o.key);
673
+ check("⚠️ the engine chrome exists ONCE — every one of its keys is outside every workspace prefix",
674
+ engineCopies.length > 0 && engineCopies.every((k) => k.startsWith("spaces/_engine/"))
675
+ && engineCopies.filter((k) => k === "spaces/_engine/manifest.json").length === 1,
676
+ engineCopies.join("\n"));
677
+ const swA = await req("/sw.js", hostA);
678
+ const swB = await req("/sw.js", hostB);
679
+ check("and both workspaces' chrome resolves to the same object",
680
+ swA.status === 200 && swA.sha256 === swB.sha256, `${swA.status}/${swB.status} ${swA.sha256.slice(0, 16)}`);
681
+
682
+ clause("assetGc run as A deletes no object belonging to B");
683
+ const img = bytesFor("shared", "pasted.png");
684
+ state.assetHash = sha40(img);
685
+ // The SAME bytes pasted in both workspaces, which is the whole failure: one hash, one
686
+ // unprefixed key, and A's collector deleting what B is displaying.
687
+ for (const host of [hostA, hostB]) {
688
+ const put = await req(`/__publish/_state/asset/${state.assetHash}`, host, {
689
+ method: "PUT", headers: { ...authed, "content-type": "image/png" }, body: img,
690
+ });
691
+ if (put.status !== 200) throw new Error(`asset PUT at ${host} answered ${put.status}: ${put.text.slice(0, 200)}`);
692
+ }
693
+ // The rows the collector reads. Written through the engine's own overlay writer, aged
694
+ // past the grace window so the pass has something to collect. A is given a row; B is
695
+ // given one too, because B's asset must survive on its own terms rather than because
696
+ // nothing knew about it.
697
+ await call({ fn: "assetRow", workspace: A, hash: state.assetHash, bytes: img.length, set: true });
698
+ await call({ fn: "assetRow", workspace: B, hash: state.assetHash, bytes: img.length, set: true });
699
+
700
+ const bBefore = await req(`/__asset/${state.assetHash}`, hostB);
701
+ check("B serves its pasted image", bBefore.status === 200 && bBefore.sha256 === sha256(img), `${bBefore.status}`);
702
+ const aBefore = await req(`/__asset/${state.assetHash}`, hostA);
703
+ check("so does A — same bytes, same hash, two objects", aBefore.status === 200, `${aBefore.status}`);
704
+
705
+ const gc = await call({ fn: "assetGc", workspace: A });
706
+ check("the collector runs as A and deletes A's copy", gc.ok && gc.deleted === 1, JSON.stringify(gc));
707
+ const aAfter = await req(`/__asset/${state.assetHash}`, hostA);
708
+ check("A's image is gone", aAfter.status === 404, `${aAfter.status}`);
709
+ const bAfter = await req(`/__asset/${state.assetHash}`, hostB);
710
+ check("⚠️ AND B's IS NOT — counted before and after, over HTTP",
711
+ bAfter.status === 200 && bAfter.sha256 === sha256(img),
712
+ `before 200/${bBefore.sha256.slice(0, 12)} · after ${bAfter.status}/${bAfter.sha256.slice(0, 12)}`);
713
+
714
+ clause("the disclosure finding again, now that there IS an asset to disclose");
715
+ for (const label of INVENTED) {
716
+ const as = await req(`/__asset/${state.assetHash}`, label + SUFFIX);
717
+ check(`${label}: /__asset/<hash> does not serve a real workspace's image`, as.status === 404, `${as.status}`);
718
+ }
719
+
720
+ state.buildJsonA = json(await req("/_build.json", hostA));
721
+ }
722
+
723
+ // ---- phase: reverted --------------------------------------------------------
724
+
725
+ async function revertedPhase() {
726
+ clause("PER-FAMILY REVERT, RUN — `BUNDLE_TENANCY.spaces = false`, one word, nothing else");
727
+ const t = await call({ fn: "tenancy", workspace: A, space: SPACE });
728
+ check("the edit landed and reached exactly one family",
729
+ t.BUNDLE_TENANCY.spaces === false && t.BUNDLE_TENANCY.config === true && t.BUNDLE_TENANCY.assets === true,
730
+ JSON.stringify(t.BUNDLE_TENANCY));
731
+ check("so `spaces/` keys are unprefixed again",
732
+ t.exampleKeys.manifest === `spaces/${SPACE}/manifest.json`, JSON.stringify(t.exampleKeys, null, 1));
733
+ check("and `config/` and `assets/` still carry the segment — nothing else was touched",
734
+ t.exampleKeys.config === `t/${A}/config/instance.json` && t.exampleKeys.asset === `t/${A}/assets/deadbeef`,
735
+ JSON.stringify(t.exampleKeys, null, 1));
736
+
737
+ // What the unprefixed key holds is what PREDATES the segment and nothing later: the
738
+ // `single` phase's publish, which the move copied and never deleted. A segmented write
739
+ // reaches no unprefixed key (one workspace's document must not sit where every workspace
740
+ // shares), so flipping the flag on a shared bucket is a ROLLBACK to the day of the cut —
741
+ // both workspaces answer with A's original page, B's publish is nowhere on that path —
742
+ // and saying so out loud is the point of running it.
743
+ const a = await req(`/${SPACE}/index.html`, hostA);
744
+ const b = await req(`/${SPACE}/index.html`, hostB);
745
+ check("the unprefixed answer is back, and it is the same one for both workspaces",
746
+ a.status === 200 && a.sha256 === b.sha256, `A ${a.status}/${a.sha256.slice(0, 12)} · B ${b.status}/${b.sha256.slice(0, 12)}`);
747
+ check("⚠️ and it is the page from BEFORE the segment — a rollback to the day of the cut, not a revert",
748
+ a.sha256 === sha256(state.pageA) && b.sha256 !== sha256(state.pageB),
749
+ `A expected ${sha256(state.pageA).slice(0, 12)}, B must NOT be ${sha256(state.pageB).slice(0, 12)}`);
750
+
751
+ const asB = await req(`/__asset/${state.assetHash}`, hostB);
752
+ check("`assets` did NOT revert: B's image is still B's and still there", asB.status === 200, `${asB.status}`);
753
+ const asA = await req(`/__asset/${state.assetHash}`, hostA);
754
+ check("and A's is still collected — the revert reached `spaces` and stopped", asA.status === 404, `${asA.status}`);
755
+ }
756
+
757
+ // ---- run --------------------------------------------------------------------
758
+
759
+ async function main() {
760
+ console.log(`bundle-tenancy-rehearsal — real workerd, real R2, two workspaces, port ${PORT}`);
761
+ console.log(` generated tree: ${WORK}`);
762
+ await generate();
763
+
764
+ let dep = null;
765
+ try {
766
+ dep = await boot("single", "single (no suffix, no TENANTS)", null); await singlePhase(); await shutdown(dep); dep = null;
767
+ dep = await boot("hosted", `hosted (TENANT_HOST_SUFFIX=${SUFFIX})`, SUFFIX); await hostedPhase(); await shutdown(dep); dep = null;
768
+ dep = await boot("reverted", "reverted (BUNDLE_TENANCY.spaces = false)", SUFFIX); await revertedPhase();
769
+ } finally {
770
+ await shutdown(dep);
771
+ if (!KEEP) fs.rmSync(WORK, { recursive: true, force: true });
772
+ }
773
+
774
+ const failed = results.filter((r) => !r.ok);
775
+ console.log(`\n${"═".repeat(78)}`);
776
+ console.log(`${results.length - failed.length}/${results.length} checks passed`);
777
+ for (const f of failed) console.log(` FAILED [${f.clause}] ${f.what}`);
778
+ process.exit(failed.length ? 1 : 0);
779
+ }
780
+
781
+ main().catch((e) => { console.error(`\nrehearsal aborted: ${(e && e.stack) || e}`); process.exit(1); });