@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.
- package/CANVAS.md +858 -0
- package/INSTALL.md +603 -0
- package/LICENSE +27 -0
- package/README.md +150 -0
- package/TRADEMARKS.md +72 -0
- package/agents/README.md +65 -0
- package/agents/canon-extract.md +117 -0
- package/agents/canon.md +151 -0
- package/agents/canvas.md +88 -0
- package/agents/currency.md +74 -0
- package/agents/drafts.md +92 -0
- package/agents/identity.md +148 -0
- package/agents/prototype-contract.md +69 -0
- package/agents/publishing.md +324 -0
- package/agents/review-feedback.md +76 -0
- package/agents/space-json.md +132 -0
- package/agents/ui-skill.md +101 -0
- package/agents/working-marks.md +86 -0
- package/brand/augur-eye.svg +3 -0
- package/brand/augur-icon.svg +41 -0
- package/brand/augur-mark.svg +5 -0
- package/brand/lentianova-bulky.otf +0 -0
- package/brand/lentianova-median.otf +0 -0
- package/brand/lentianova.woff2 +0 -0
- package/build.js +8409 -0
- package/changelog.md +133 -0
- package/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/package.json +57 -0
- package/pitis/PETS.md +121 -0
- package/pitis/README.md +117 -0
- package/pitis/index.html +266 -0
- package/pitis/piti.build.js +81 -0
- package/pitis/piti.js +929 -0
- package/pitis/roast-agent.md +185 -0
- package/realtime/src/index.js +119 -0
- package/realtime/wrangler.example.toml +44 -0
- package/scripts/adopt.mjs +106 -0
- package/scripts/board-snapshot.mjs +197 -0
- package/scripts/build-seed-pack.mjs +33 -0
- package/scripts/bundle-rekey.mjs +112 -0
- package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
- package/scripts/canon-extract.mjs +578 -0
- package/scripts/canon.mjs +599 -0
- package/scripts/canvas-screen.mjs +333 -0
- package/scripts/check-local.mjs +136 -0
- package/scripts/clawd-art.mjs +75 -0
- package/scripts/clawd-canvas.mjs +741 -0
- package/scripts/cli.mjs +100 -0
- package/scripts/clone.mjs +422 -0
- package/scripts/close.mjs +30 -0
- package/scripts/connect.mjs +103 -0
- package/scripts/deploy.mjs +157 -0
- package/scripts/dev.mjs +54 -0
- package/scripts/doc-lint.mjs +143 -0
- package/scripts/export.mjs +300 -0
- package/scripts/fork.mjs +64 -0
- package/scripts/freeze.mjs +74 -0
- package/scripts/frontdoor-parity.mjs +180 -0
- package/scripts/hook.mjs +67 -0
- package/scripts/identity-rekey.mjs +112 -0
- package/scripts/init.mjs +122 -0
- package/scripts/land.mjs +40 -0
- package/scripts/lib/adapters.mjs +156 -0
- package/scripts/lib/board-snapshot.mjs +511 -0
- package/scripts/lib/draft.mjs +385 -0
- package/scripts/lib/graduate.mjs +276 -0
- package/scripts/lib/instance.mjs +74 -0
- package/scripts/lib/marks.mjs +107 -0
- package/scripts/lib/materialize.mjs +138 -0
- package/scripts/lib/merge3.mjs +124 -0
- package/scripts/lib/offline-posture.mjs +120 -0
- package/scripts/lib/offline-respawn.mjs +18 -0
- package/scripts/lib/offline-wrangler.mjs +78 -0
- package/scripts/lib/publish-compose.mjs +12 -0
- package/scripts/lib/publish-conflict.mjs +78 -0
- package/scripts/lib/publish-evidence.mjs +144 -0
- package/scripts/lib/refine-compare.mjs +140 -0
- package/scripts/lib/refine-ledger.mjs +150 -0
- package/scripts/lib/refine-png.mjs +149 -0
- package/scripts/lib/seed-pack-build.mjs +141 -0
- package/scripts/lib/state-compare.mjs +68 -0
- package/scripts/lib/store.mjs +122 -0
- package/scripts/login.mjs +84 -0
- package/scripts/mark.mjs +112 -0
- package/scripts/migrate-board-keys.mjs +164 -0
- package/scripts/migrate.mjs +265 -0
- package/scripts/no-foreign-vocabulary.mjs +216 -0
- package/scripts/no-product-names.mjs +157 -0
- package/scripts/no-tenant-globals.mjs +1134 -0
- package/scripts/offline.mjs +247 -0
- package/scripts/og.mjs +170 -0
- package/scripts/one-tenant-resolver.mjs +146 -0
- package/scripts/open.mjs +63 -0
- package/scripts/publish.mjs +1041 -0
- package/scripts/read.mjs +31 -0
- package/scripts/refine.mjs +455 -0
- package/scripts/release-drift.mjs +138 -0
- package/scripts/restore.mjs +322 -0
- package/scripts/review.mjs +125 -0
- package/scripts/save.mjs +21 -0
- package/scripts/shell-lint.mjs +189 -0
- package/scripts/ship.mjs +460 -0
- package/scripts/shoot.mjs +264 -0
- package/scripts/smoke-invite.mjs +113 -0
- package/scripts/state-inventory.mjs +223 -0
- package/scripts/status.mjs +177 -0
- package/scripts/sync.mjs +27 -0
- package/scripts/tenant-do-rehearsal.mjs +1314 -0
- package/scripts/ui-copy-lint.mjs +202 -0
- package/scripts/watch.mjs +32 -0
- package/scripts/wrangler-preflight.mjs +246 -0
- package/seed/CANON.md +43 -0
- package/seed/README.md +119 -0
- package/seed/prototype-status.json +9 -0
- package/seed/registry.json +88 -0
- package/seed/skills/starter-ui/SKILL.md +52 -0
- package/seed/skills/starter-ui/skill.json +11 -0
- package/seed/skills/starter-ui/starter-tokens.css +73 -0
- package/seed/skills/starter-ui/starter-ui.css +318 -0
- package/seed/skills/starter-ui/starter-ui.js +50 -0
- package/seed/space.json +5 -0
- package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
- package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
- package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
- package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
- package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
- package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
- package/seed/threads.json +52 -0
- package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
- package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
- package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
- package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
- package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
- package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
- package/src/_worker.js +12668 -0
- package/src/board-key.mjs +57 -0
- package/src/board-room.mjs +881 -0
- package/src/bundle-keys.mjs +138 -0
- package/src/canon/collect-in-browser.js +215 -0
- package/src/canon/emit.mjs +232 -0
- package/src/canon/observe.mjs +260 -0
- package/src/canon/schema.mjs +451 -0
- package/src/canvas/DSEG-LICENSE.txt +95 -0
- package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
- package/src/canvas/canvas.css +885 -0
- package/src/canvas/canvas.js +5780 -0
- package/src/canvas/capture.js +485 -0
- package/src/chrome/appchrome.mjs +568 -0
- package/src/currency.mjs +203 -0
- package/src/delete-confirmation.mjs +254 -0
- package/src/drafts/drafts.js +326 -0
- package/src/entry.js +58 -0
- package/src/galleries.mjs +400 -0
- package/src/health-cron.mjs +173 -0
- package/src/identity.json +1 -0
- package/src/kv-codec.mjs +135 -0
- package/src/kv-identity.mjs +259 -0
- package/src/mail.mjs +599 -0
- package/src/provenance.mjs +74 -0
- package/src/publish-compose.mjs +363 -0
- package/src/publish-fork.mjs +221 -0
- package/src/publish-units.mjs +44 -0
- package/src/purge.mjs +81 -0
- package/src/review/COMMENTING-UX.md +306 -0
- package/src/review/cat.png +0 -0
- package/src/review/comment-cursor.svg +7 -0
- package/src/review/comments.js +1738 -0
- package/src/room-ticket.mjs +96 -0
- package/src/roster-chip.mjs +32 -0
- package/src/seed-pack.mjs +261 -0
- package/src/state-inventory.mjs +249 -0
- package/src/sw-logic.mjs +48 -0
- package/src/tenant-cache.mjs +141 -0
- package/src/tenant-context.mjs +386 -0
- package/src/tenant-do.js +2940 -0
- package/src/tenant-host.mjs +144 -0
- package/src/tenant-quotas.mjs +140 -0
- package/src/unit-core.mjs +103 -0
- package/src/unit-object.mjs +338 -0
- package/templates/README.md +67 -0
- package/templates/shell/deploy.yml +145 -0
- package/templates/shell/engine-bump.yml +142 -0
- package/templates/shell/health.yml +233 -0
- package/templates/shell/kv-backup.yml +201 -0
- package/templates/shell/roster-update.yml +110 -0
- package/templates/shell/space-preflight.yml +35 -0
- package/templates/shell/space-rebake.yml +79 -0
- package/templates/shell/store-backup.yml +152 -0
- package/templates/shell/wrangler.example.toml +266 -0
- package/templates/space/publish.yml +91 -0
|
@@ -0,0 +1,1314 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* tenant-do-rehearsal — the identity families, driven on REAL workerd against a REAL
|
|
4
|
+
* Durable Object.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS. `test/kv-read-cutover.test.mjs` drives the real worker over the real
|
|
7
|
+
* routes, but the store underneath it is `node:sqlite` behind a hand-written storage stub
|
|
8
|
+
* and the namespace is an object literal with an `idFromName` that returns its argument.
|
|
9
|
+
* That proves the SEAM — which store answers, what happens when it cannot, that the two
|
|
10
|
+
* answers agree — and it cannot prove the RUNTIME. workerd's SQLite is not node's: column
|
|
11
|
+
* affinity, the JS type a TEXT column comes back as, what a thrown error inside a Durable
|
|
12
|
+
* Object looks like from the other side of `stub.fetch`, and whether a schema written by
|
|
13
|
+
* one process is still there for the next one are all questions a stub answers by
|
|
14
|
+
* construction rather than by being right. Every one of them is load-bearing here: the cut
|
|
15
|
+
* families are the login gate's, and the first bug this cut-over found was an expiry
|
|
16
|
+
* crossing as epoch milliseconds into an ISO TEXT column, which a strict read answers `NaN`
|
|
17
|
+
* for and a tolerant one has to carry.
|
|
18
|
+
*
|
|
19
|
+
* So this stands up the ACTUAL deploy entry (`src/entry.js`'s module graph) under
|
|
20
|
+
* `wrangler dev`, with a `TENANTS` binding to a `new_sqlite_classes` Durable Object and a
|
|
21
|
+
* local KV namespace, and drives it over HTTP with nothing imported and no function called.
|
|
22
|
+
*
|
|
23
|
+
* ⚠️ IT IS LOCAL BY CONSTRUCTION AND MUST STAY THAT WAY. No account, no token, no route,
|
|
24
|
+
* no remote binding: `--local` plus a `--persist-to` directory under `.wrangler/`, which is
|
|
25
|
+
* gitignored. The fixture roster it generates is five invented addresses and a password it
|
|
26
|
+
* prints in its own source. Nothing here may ever be pointed at a deployed instance — the
|
|
27
|
+
* cases below deliberately break a workspace's store and delete rows.
|
|
28
|
+
*
|
|
29
|
+
* WHAT IS GENERATED AND WHY NONE OF IT IS COMMITTED. Three wrangler configs, an entry for
|
|
30
|
+
* each, a two-file asset directory and a copy of `src/` with one word changed. They live in
|
|
31
|
+
* `.wrangler/tenant-do-rehearsal/` for `scripts/lib/offline-wrangler.mjs`'s reason: a
|
|
32
|
+
* config in the tree is a config a stray `wrangler` picks up, and an entry file carrying a
|
|
33
|
+
* probe surface is a file that must never be deployable. The probe is the one liberty this
|
|
34
|
+
* takes — a `/__rehearsal/*` prefix in front of the real front door, and a subclass of
|
|
35
|
+
* `TenantStore` that adds ONE verb which runs a statement — and both exist only in the
|
|
36
|
+
* generated tree. The engine's own request path, its worker and its store class are
|
|
37
|
+
* untouched: every assertion below is made against a response the real front door produced.
|
|
38
|
+
*
|
|
39
|
+
* THE FIVE DEPLOYMENTS, which differ in exactly what the clauses need them to:
|
|
40
|
+
* bound `TENANTS` bound. The hosted shape.
|
|
41
|
+
* unbound the same config with the binding and the migration removed, and nothing else.
|
|
42
|
+
* reverted-* bound, but `main` resolves a COPY of src/ with ONE word flipped — one tree
|
|
43
|
+
* per family, because the property being run is that reverting one family
|
|
44
|
+
* restores ITS KV answer and touches no other family's.
|
|
45
|
+
* All of them share one `--persist-to` directory and one worker name, so the KV documents and
|
|
46
|
+
* the object's SQLite survive from one to the next. That sharing is not a convenience: "the
|
|
47
|
+
* two answer the same" and "the revert restores the KV answer" are both claims about one
|
|
48
|
+
* workspace's state seen through two deployments, and a fresh store each time would make
|
|
49
|
+
* either of them vacuous.
|
|
50
|
+
*
|
|
51
|
+
* Usage:
|
|
52
|
+
* node scripts/tenant-do-rehearsal.mjs [--port 8811] [--keep]
|
|
53
|
+
*
|
|
54
|
+
* `--keep` leaves the generated tree for inspection. `WRANGLER_BIN` overrides how wrangler
|
|
55
|
+
* is spawned (default `npx wrangler`), for a machine that has one installed already.
|
|
56
|
+
*
|
|
57
|
+
* Exit 0 when every clause passed, 1 otherwise. Each clause prints the request it made and
|
|
58
|
+
* the response it saw, because the point of a rehearsal is the transcript.
|
|
59
|
+
*/
|
|
60
|
+
import fs from "node:fs";
|
|
61
|
+
import path from "node:path";
|
|
62
|
+
import crypto from "node:crypto";
|
|
63
|
+
import http from "node:http";
|
|
64
|
+
import { spawn } from "node:child_process";
|
|
65
|
+
import { fileURLToPath } from "node:url";
|
|
66
|
+
import { __testables as WORKER_TESTABLES } from "../src/_worker.js";
|
|
67
|
+
|
|
68
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
69
|
+
const WORK = path.join(ROOT, ".wrangler", "tenant-do-rehearsal");
|
|
70
|
+
|
|
71
|
+
const argv = process.argv.slice(2);
|
|
72
|
+
const PORT = Number((argv.find((a) => a.startsWith("--port=")) || "").split("=")[1]
|
|
73
|
+
|| (argv.includes("--port") ? argv[argv.indexOf("--port") + 1] : 0)) || 8811;
|
|
74
|
+
const KEEP = argv.includes("--keep");
|
|
75
|
+
|
|
76
|
+
// The fixture. Five invented addresses on a reserved-for-documentation domain, one
|
|
77
|
+
// password, one session secret — none of it is a credential and none of it leaves this
|
|
78
|
+
// machine. `.test` and `example.test` are reserved names by RFC, so a stray lookup resolves
|
|
79
|
+
// nowhere.
|
|
80
|
+
const SUFFIX = ".rehearsal.test";
|
|
81
|
+
// Not `alpha`, not `demo`, not `test`: `RESERVED_LABELS` in src/tenant-host.mjs refuses
|
|
82
|
+
// every name an operator reaches for in a hurry, and the resolver's refusal is a bare 404
|
|
83
|
+
// that looks exactly like a broken fixture. This one is a bird.
|
|
84
|
+
const WORKSPACE = "wren";
|
|
85
|
+
const HOST = WORKSPACE + SUFFIX;
|
|
86
|
+
const ADMIN = "ada@example.test";
|
|
87
|
+
const ONE = "grace@example.test";
|
|
88
|
+
const TWO = "hedy@example.test";
|
|
89
|
+
const THREE = "ida@example.test";
|
|
90
|
+
const FOUR = "jean@example.test";
|
|
91
|
+
// Two more, invited from the overlay rather than seeded in the file — the promotion clause.
|
|
92
|
+
const FIVE = "leah@example.test";
|
|
93
|
+
const SIX = "mae@example.test";
|
|
94
|
+
const PASSWORD = "a properly long password";
|
|
95
|
+
const SESSION_SECRET = "rehearsal-session-secret-not-a-credential";
|
|
96
|
+
const WORKER_NAME = "augur-tenant-do-rehearsal";
|
|
97
|
+
const KV_ID = "tenant-do-rehearsal-kv";
|
|
98
|
+
|
|
99
|
+
// The families that get their OWN reverted deployment. Each is one word in `KV_CUTOVER`.
|
|
100
|
+
const REVERTED_FAMILIES = ["invites", "publishTokens", "roster"];
|
|
101
|
+
|
|
102
|
+
const USER_INVITES_KEY = "users:invites";
|
|
103
|
+
const USER_SECRETS_KEY = "users:secrets";
|
|
104
|
+
const LASTSEEN_PREFIX = "users:lastseen:";
|
|
105
|
+
const PUBLISH_TOKENS_KEY = "publish:tokens";
|
|
106
|
+
const ROSTER_KEYS = ["users:roster", "users:roles", "users:names", "users:avatars"];
|
|
107
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
108
|
+
|
|
109
|
+
// The durable roster exactly as the generated `instance.json` carries it — filled by
|
|
110
|
+
// `generate()` and read by the promotion clause, which has to push a config that is this
|
|
111
|
+
// file PLUS one person. Two copies of a roster is how a fixture starts lying.
|
|
112
|
+
let FILE_ROSTER = null;
|
|
113
|
+
|
|
114
|
+
// ---- the transcript ---------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
const results = [];
|
|
117
|
+
let currentClause = "setup";
|
|
118
|
+
function clause(name) { currentClause = name; console.log(`\n── ${name} ${"─".repeat(Math.max(0, 74 - name.length))}`); }
|
|
119
|
+
function check(what, ok, detail = "") {
|
|
120
|
+
results.push({ clause: currentClause, what, ok });
|
|
121
|
+
console.log(` ${ok ? "PASS" : "FAIL"} ${what}${detail ? `\n ${String(detail).replace(/\n/g, "\n ")}` : ""}`);
|
|
122
|
+
}
|
|
123
|
+
function note(text) { console.log(` ${String(text).replace(/\n/g, "\n ")}`); }
|
|
124
|
+
|
|
125
|
+
// ---- the generated tree -----------------------------------------------------
|
|
126
|
+
|
|
127
|
+
/** sha256 hex of a string — the digest `tokenFor` computes, recomputed here without it. */
|
|
128
|
+
const sha256hex = (s) => crypto.createHash("sha256").update(s, "utf8").digest("hex");
|
|
129
|
+
/** How an invite token is keyed in the object: `tokenFor("inv:" + token)`. One contract. */
|
|
130
|
+
const inviteHash = (token) => sha256hex("gv:inv:" + token);
|
|
131
|
+
/** How a PUBLISH token is keyed in both stores: `tokenFor("pub:" + token)`. */
|
|
132
|
+
const publishHash = (token) => sha256hex("gv:pub:" + token);
|
|
133
|
+
/** A token shaped like the ones `mintInvite` issues, for the rows written directly. */
|
|
134
|
+
const freshToken = () => crypto.randomBytes(32).toString("base64")
|
|
135
|
+
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The rehearsal entry.
|
|
139
|
+
*
|
|
140
|
+
* Two additions to `src/entry.js`, both confined to a prefix the engine does not use:
|
|
141
|
+
*
|
|
142
|
+
* `/__rehearsal/*` in FRONT of the worker — read and write the KV binding, call the
|
|
143
|
+
* workspace object directly, and report what the runtime says it is. Setup and
|
|
144
|
+
* inspection only: no clause is asserted from anything this returns except the two that
|
|
145
|
+
* ask what the runtime IS, and every clause's evidence is a response the real front door
|
|
146
|
+
* produced.
|
|
147
|
+
*
|
|
148
|
+
* ONE verb on the store class, `/__rehearsal/sql`, which runs a statement. That is how a
|
|
149
|
+
* row is written in the shape an older copy wrote it, and how the store is BROKEN at the
|
|
150
|
+
* runtime level: renaming the `invites` table away makes the real `inviteRead`'s real
|
|
151
|
+
* SELECT throw inside the real object, which is an outage the way an outage happens
|
|
152
|
+
* rather than a stub returning a 500. `init()` short-circuits on a warm object, so the
|
|
153
|
+
* table does not spring back before the case can see it.
|
|
154
|
+
*/
|
|
155
|
+
function entrySource(srcDir) {
|
|
156
|
+
const q = (p) => JSON.stringify(path.join(srcDir, p));
|
|
157
|
+
return `// GENERATED by scripts/tenant-do-rehearsal.mjs — not a deploy entry, never committed.
|
|
158
|
+
import worker, { __testables } from ${q("_worker.js")};
|
|
159
|
+
import { TenantStore as RealTenantStore } from ${q("tenant-do.js")};
|
|
160
|
+
export { BoardRoom } from ${q("board-room.mjs")};
|
|
161
|
+
|
|
162
|
+
export class TenantStore extends RealTenantStore {
|
|
163
|
+
async fetch(request) {
|
|
164
|
+
const url = new URL(request.url);
|
|
165
|
+
if (url.pathname === "/__rehearsal/sql") {
|
|
166
|
+
const { stmt, params = [] } = await request.json();
|
|
167
|
+
try {
|
|
168
|
+
const rows = [...this.ctx.storage.sql.exec(stmt, ...params)];
|
|
169
|
+
return Response.json({ ok: true, rows, types: rows.map((r) => Object.fromEntries(
|
|
170
|
+
Object.entries(r).map(([k, v]) => [k, v === null ? "null" : typeof v]))) });
|
|
171
|
+
} catch (e) {
|
|
172
|
+
return Response.json({ ok: false, error: String((e && e.message) || e) });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return super.fetch(request);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function probe(request, env, url) {
|
|
180
|
+
if (url.pathname === "/__rehearsal/runtime") {
|
|
181
|
+
let sqlite = null;
|
|
182
|
+
let sqliteVersion = null;
|
|
183
|
+
if (env.TENANTS) {
|
|
184
|
+
const stub = env.TENANTS.get(env.TENANTS.idFromName(${JSON.stringify(WORKSPACE)}));
|
|
185
|
+
const ask = async (stmt) => {
|
|
186
|
+
const r = await stub.fetch("https://workspace/__rehearsal/sql", {
|
|
187
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
188
|
+
body: JSON.stringify({ stmt }),
|
|
189
|
+
});
|
|
190
|
+
return r.json();
|
|
191
|
+
};
|
|
192
|
+
// The tables, which is the proof; and sqlite_version(), which workerd REFUSES and
|
|
193
|
+
// node:sqlite answers — the cheapest evidence that this is not the test harness.
|
|
194
|
+
sqlite = await ask("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name");
|
|
195
|
+
sqliteVersion = await ask("SELECT sqlite_version() AS v");
|
|
196
|
+
}
|
|
197
|
+
return Response.json({
|
|
198
|
+
userAgent: (globalThis.navigator && globalThis.navigator.userAgent) || null,
|
|
199
|
+
compatibilityDate: null,
|
|
200
|
+
tenantsBound: !!env.TENANTS,
|
|
201
|
+
namespaceCtor: env.TENANTS ? String(env.TENANTS.constructor && env.TENANTS.constructor.name) : null,
|
|
202
|
+
idFromName: env.TENANTS ? String(env.TENANTS.idFromName(${JSON.stringify(WORKSPACE)})) : null,
|
|
203
|
+
kvCutover: __testables.KV_CUTOVER,
|
|
204
|
+
hostSuffix: env.TENANT_HOST_SUFFIX || null,
|
|
205
|
+
sqlite, sqliteVersion,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
if (url.pathname === "/__rehearsal/kv") {
|
|
209
|
+
const b = await request.json();
|
|
210
|
+
const kv = env.COMMENTS;
|
|
211
|
+
if (b.op === "get") return Response.json({ value: await kv.get(b.key) });
|
|
212
|
+
if (b.op === "put") { await kv.put(b.key, b.value); return Response.json({ ok: true }); }
|
|
213
|
+
if (b.op === "delete") { await kv.delete(b.key); return Response.json({ ok: true }); }
|
|
214
|
+
if (b.op === "list") return Response.json(await kv.list({ prefix: b.prefix || "" }));
|
|
215
|
+
return Response.json({ error: "bad-op" }, { status: 400 });
|
|
216
|
+
}
|
|
217
|
+
if (url.pathname === "/__rehearsal/do") {
|
|
218
|
+
if (!env.TENANTS) return Response.json({ error: "no-binding" }, { status: 400 });
|
|
219
|
+
const b = await request.json();
|
|
220
|
+
const stub = env.TENANTS.get(env.TENANTS.idFromName(b.workspace));
|
|
221
|
+
try {
|
|
222
|
+
const res = await stub.fetch("https://workspace" + b.path, {
|
|
223
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
224
|
+
body: JSON.stringify(b.body || {}),
|
|
225
|
+
});
|
|
226
|
+
return Response.json({ status: res.status, body: await res.text() });
|
|
227
|
+
} catch (e) {
|
|
228
|
+
return Response.json({ threw: String((e && e.message) || e) });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return Response.json({ error: "not-found" }, { status: 404 });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export default {
|
|
235
|
+
async fetch(request, env, ctx) {
|
|
236
|
+
const url = new URL(request.url);
|
|
237
|
+
if (url.pathname.startsWith("/__rehearsal/")) return probe(request, env, url);
|
|
238
|
+
return worker.fetch(request, env, ctx);
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The wrangler config. `tenants:false` removes the binding AND the migration, nothing else. */
|
|
245
|
+
function wranglerConfig({ entry, assets, tenants }) {
|
|
246
|
+
return `# GENERATED by scripts/tenant-do-rehearsal.mjs on every run — edit that, not this.
|
|
247
|
+
name = ${JSON.stringify(WORKER_NAME)}
|
|
248
|
+
main = ${JSON.stringify(entry)}
|
|
249
|
+
compatibility_date = "2025-01-01"
|
|
250
|
+
|
|
251
|
+
[assets]
|
|
252
|
+
directory = ${JSON.stringify(assets)}
|
|
253
|
+
binding = "ASSETS"
|
|
254
|
+
# The gate. Same line, same reason, as scripts/lib/offline-wrangler.mjs.
|
|
255
|
+
run_worker_first = true
|
|
256
|
+
not_found_handling = "none"
|
|
257
|
+
html_handling = "auto-trailing-slash"
|
|
258
|
+
|
|
259
|
+
[vars]
|
|
260
|
+
# Set means the workspace is the first Host label — the dynamic resolver, which is the only
|
|
261
|
+
# shape a TENANTS binding is legal in (scripts/wrangler-preflight.mjs refuses the halves).
|
|
262
|
+
TENANT_HOST_SUFFIX = ${JSON.stringify(SUFFIX)}
|
|
263
|
+
|
|
264
|
+
[[kv_namespaces]]
|
|
265
|
+
binding = "COMMENTS"
|
|
266
|
+
id = ${JSON.stringify(KV_ID)}
|
|
267
|
+
|
|
268
|
+
# The bundle store. Bound so the publish routes reach their AUTH check — without it every
|
|
269
|
+
# one of them answers 501 before publishAuthDetailed runs, which would make the clauses
|
|
270
|
+
# below compare two identical refusals and prove nothing. GV_ASSET_SOURCE is deliberately
|
|
271
|
+
# NOT set, so serving stays in assets mode and the front door is the one every other clause
|
|
272
|
+
# drives. Local simulator under --persist-to; no account, no bucket, no network.
|
|
273
|
+
[[r2_buckets]]
|
|
274
|
+
binding = "BUNDLES"
|
|
275
|
+
bucket_name = ${JSON.stringify(WORKER_NAME + "-bundles")}
|
|
276
|
+
${tenants ? `
|
|
277
|
+
[[durable_objects.bindings]]
|
|
278
|
+
name = "TENANTS"
|
|
279
|
+
class_name = "TenantStore"
|
|
280
|
+
|
|
281
|
+
[[migrations]]
|
|
282
|
+
tag = "v1"
|
|
283
|
+
new_sqlite_classes = ["TenantStore"]
|
|
284
|
+
` : `
|
|
285
|
+
# NO TENANTS BINDING. The single difference from the bound config, and the whole of the
|
|
286
|
+
# "with it unbound the instance answers from KV" clause.
|
|
287
|
+
`}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function generate() {
|
|
291
|
+
fs.rmSync(WORK, { recursive: true, force: true });
|
|
292
|
+
fs.mkdirSync(path.join(WORK, "assets", "__config"), { recursive: true });
|
|
293
|
+
|
|
294
|
+
// The roster. `passHash` is computed by the engine's own hasher — the fixture has to be a
|
|
295
|
+
// hash this worker will verify, and reimplementing PBKDF2 here would be a second opinion
|
|
296
|
+
// about the credential format. It is the only thing this file imports from src/, it runs
|
|
297
|
+
// before any deployment exists, and nothing below calls a worker function.
|
|
298
|
+
const { __testables: W } = await import(path.join(ROOT, "src", "_worker.js"));
|
|
299
|
+
const passHash = await W.hashPassword(PASSWORD);
|
|
300
|
+
const roster = [
|
|
301
|
+
{ email: ADMIN, name: "Ada", initials: "A", role: "admin", passHash },
|
|
302
|
+
{ email: ONE, name: "Grace", initials: "G", role: "editor" },
|
|
303
|
+
{ email: TWO, name: "Hedy", initials: "H", role: "editor" },
|
|
304
|
+
{ email: THREE, name: "Ida", initials: "I", role: "editor" },
|
|
305
|
+
{ email: FOUR, name: "Jean", initials: "J", role: "editor" },
|
|
306
|
+
];
|
|
307
|
+
FILE_ROSTER = roster;
|
|
308
|
+
fs.writeFileSync(path.join(WORK, "assets", "__config", "instance.json"), JSON.stringify({
|
|
309
|
+
users: roster, engineVersion: "0.0.0-rehearsal", updateFeed: "",
|
|
310
|
+
mcpHostSuffixes: [], mcpHostAllowlistUrl: "", vanityRedirects: {}, rtOrigin: "", sentinels: [],
|
|
311
|
+
}));
|
|
312
|
+
fs.writeFileSync(path.join(WORK, "assets", "__config", "routing.json"), JSON.stringify({
|
|
313
|
+
buildId: "rehearsal", versionMap: {}, publicPrefixes: [], publicSkillPrefixes: [],
|
|
314
|
+
restrictedBases: [], canvasLoaderExtras: "", canvasCatalog: [], canvasTracks: [], mcpAllowlist: [],
|
|
315
|
+
spaces: [{ id: "one", name: "One", badge: "O", default: true, base: "", adminOnly: false }],
|
|
316
|
+
defaultSpace: "one",
|
|
317
|
+
}));
|
|
318
|
+
|
|
319
|
+
const src = path.join(ROOT, "src");
|
|
320
|
+
fs.writeFileSync(path.join(WORK, "entry.js"), entrySource(src));
|
|
321
|
+
|
|
322
|
+
// THE REVERT, as a copy of src/ with one word changed — ONE TREE PER FAMILY, because the
|
|
323
|
+
// property being run is that reverting ONE family restores its KV answer and touches
|
|
324
|
+
// nothing else. Copied rather than patched in place for the obvious reason (the tree under
|
|
325
|
+
// test must not be edited to test it), and each edit is asserted to have landed, because a
|
|
326
|
+
// revert that silently did not apply is a clause that passes for the wrong reason.
|
|
327
|
+
const assets = path.join(WORK, "assets");
|
|
328
|
+
for (const family of REVERTED_FAMILIES) {
|
|
329
|
+
const revertedSrc = path.join(WORK, `reverted-src-${family}`);
|
|
330
|
+
fs.cpSync(src, revertedSrc, { recursive: true });
|
|
331
|
+
const wf = path.join(revertedSrc, "_worker.js");
|
|
332
|
+
const before = fs.readFileSync(wf, "utf8");
|
|
333
|
+
// ⚠️ THE EDIT IS ANCHORED INSIDE `KV_CUTOVER`, AND IT HAS TO BE. Three constants now
|
|
334
|
+
// carry per-family flags under the SAME family names — `KV_CUTOVER`, `BUNDLE_TENANCY`
|
|
335
|
+
// and `IDENTITY_TENANCY` — and a bare `\n invites: true,` matches whichever is
|
|
336
|
+
// declared first. It found that out by reverting the wrong table and reporting every
|
|
337
|
+
// clause in this section as a failure of a change nobody had made.
|
|
338
|
+
const start = before.indexOf("const KV_CUTOVER = Object.freeze({");
|
|
339
|
+
const end = before.indexOf("\n});", start);
|
|
340
|
+
if (start < 0 || end < 0) throw new Error("KV_CUTOVER has moved or been renamed");
|
|
341
|
+
const block = before.slice(start, end);
|
|
342
|
+
const edited = block.replace(`\n ${family}: true,`, `\n ${family}: false,`);
|
|
343
|
+
if (edited === block) throw new Error(`the revert edit did not apply for ${family} — has KV_CUTOVER.${family} moved or been renamed?`);
|
|
344
|
+
const after = before.slice(0, start) + edited + before.slice(start + block.length);
|
|
345
|
+
fs.writeFileSync(wf, after);
|
|
346
|
+
fs.writeFileSync(path.join(WORK, `reverted-entry-${family}.js`), entrySource(revertedSrc));
|
|
347
|
+
fs.writeFileSync(path.join(WORK, `reverted-${family}.toml`),
|
|
348
|
+
wranglerConfig({ entry: path.join(WORK, `reverted-entry-${family}.js`), assets, tenants: true }));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
fs.writeFileSync(path.join(WORK, "bound.toml"),
|
|
352
|
+
wranglerConfig({ entry: path.join(WORK, "entry.js"), assets, tenants: true }));
|
|
353
|
+
fs.writeFileSync(path.join(WORK, "unbound.toml"),
|
|
354
|
+
wranglerConfig({ entry: path.join(WORK, "entry.js"), assets, tenants: false }));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---- one deployment ---------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
const BASE = () => `http://127.0.0.1:${PORT}`;
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Spawn `wrangler dev` on one config and wait for it to answer.
|
|
363
|
+
*
|
|
364
|
+
* One at a time, on one port, sharing one persist directory: the deployments are a
|
|
365
|
+
* SEQUENCE, not a set. Two of them at once would be two workerd processes with the same
|
|
366
|
+
* SQLite files open.
|
|
367
|
+
*/
|
|
368
|
+
async function boot(config, label) {
|
|
369
|
+
const bin = process.env.WRANGLER_BIN || "npx";
|
|
370
|
+
const args = process.env.WRANGLER_BIN ? [] : ["--yes", "wrangler"];
|
|
371
|
+
const child = spawn(bin, [...args,
|
|
372
|
+
"dev", "-c", path.join(WORK, `${config}.toml`),
|
|
373
|
+
"--local", "--ip", "127.0.0.1", "--port", String(PORT),
|
|
374
|
+
"--persist-to", path.join(WORK, "state"),
|
|
375
|
+
"--var", `SESSION_SECRET:${SESSION_SECRET}`,
|
|
376
|
+
"--log-level", "warn",
|
|
377
|
+
], {
|
|
378
|
+
cwd: WORK,
|
|
379
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
380
|
+
env: { ...process.env, WRANGLER_SEND_METRICS: "false", CLOUDFLARE_API_TOKEN: "", CLOUDFLARE_ACCOUNT_ID: "" },
|
|
381
|
+
});
|
|
382
|
+
const log = [];
|
|
383
|
+
child.stdout.on("data", (d) => log.push(String(d)));
|
|
384
|
+
child.stderr.on("data", (d) => log.push(String(d)));
|
|
385
|
+
let exited = null;
|
|
386
|
+
child.on("exit", (code) => { exited = code; });
|
|
387
|
+
|
|
388
|
+
const deadline = Date.now() + 90_000;
|
|
389
|
+
for (;;) {
|
|
390
|
+
if (exited !== null) throw new Error(`wrangler exited (${exited}) booting "${label}":\n${log.join("")}`);
|
|
391
|
+
if (Date.now() > deadline) { child.kill("SIGKILL"); throw new Error(`"${label}" never answered:\n${log.join("")}`); }
|
|
392
|
+
try {
|
|
393
|
+
const r = await req("/__rehearsal/runtime", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
|
|
394
|
+
if (r.status === 200) { console.log(`\n▸ deployment "${label}" up on ${PORT} (${config}.toml)`); return { child, log }; }
|
|
395
|
+
} catch (e) { /* not listening yet */ }
|
|
396
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function shutdown(dep) {
|
|
401
|
+
if (!dep || !dep.child) return;
|
|
402
|
+
await new Promise((resolve) => {
|
|
403
|
+
dep.child.on("exit", resolve);
|
|
404
|
+
dep.child.kill("SIGINT");
|
|
405
|
+
setTimeout(() => { try { dep.child.kill("SIGKILL"); } catch (e) {} resolve(); }, 8000);
|
|
406
|
+
});
|
|
407
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ---- talking to it ----------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Every request carries the workspace's Host — that is the whole of the resolver's input.
|
|
414
|
+
*
|
|
415
|
+
* ⚠️ `node:http`, NOT `fetch`. undici treats `Host` as a forbidden header and drops it
|
|
416
|
+
* silently, so every request goes out naming `127.0.0.1`, `tenantLabelFromHost` answers
|
|
417
|
+
* null and the front door serves the bare 404 a hostname naming no workspace gets. Which
|
|
418
|
+
* is correct behaviour, and looks exactly like a broken fixture.
|
|
419
|
+
*/
|
|
420
|
+
function req(pathname, { method = "GET", headers = {}, body, cookie } = {}) {
|
|
421
|
+
const h = { Host: HOST, ...headers };
|
|
422
|
+
if (cookie) h.Cookie = cookie;
|
|
423
|
+
if (body != null) h["content-length"] = String(Buffer.byteLength(body));
|
|
424
|
+
return new Promise((resolve, reject) => {
|
|
425
|
+
const r = http.request({ host: "127.0.0.1", port: PORT, path: pathname, method, headers: h }, (res) => {
|
|
426
|
+
const chunks = [];
|
|
427
|
+
res.on("data", (c) => chunks.push(c));
|
|
428
|
+
res.on("end", () => resolve({
|
|
429
|
+
status: res.statusCode,
|
|
430
|
+
headers: res.headers,
|
|
431
|
+
setCookie: (res.headers["set-cookie"] || [])[0] || null,
|
|
432
|
+
text: Buffer.concat(chunks).toString("utf8"),
|
|
433
|
+
}));
|
|
434
|
+
});
|
|
435
|
+
r.on("error", reject);
|
|
436
|
+
if (body != null) r.write(body);
|
|
437
|
+
r.end();
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
const form = (obj) => ({
|
|
441
|
+
method: "POST",
|
|
442
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
443
|
+
body: new URLSearchParams(obj).toString(),
|
|
444
|
+
});
|
|
445
|
+
async function rehearse(pathname, body) {
|
|
446
|
+
const res = await req(pathname, {
|
|
447
|
+
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
|
|
448
|
+
});
|
|
449
|
+
return JSON.parse(res.text);
|
|
450
|
+
}
|
|
451
|
+
// ⚠️ THE KEY COMES FROM THE PRODUCER, NOT FROM THIS FILE. These deployments set
|
|
452
|
+
// `TENANT_HOST_SUFFIX`, so the identity documents carry a workspace segment
|
|
453
|
+
// (`identityKey`). A poke that spelled `users:invites` by hand would be writing and
|
|
454
|
+
// deleting a key the worker does not read — and the revert clauses would then report a
|
|
455
|
+
// failure of a change nobody had made, which is exactly how this was found.
|
|
456
|
+
// `users:secrets` maps to itself here, because a credential is account-level.
|
|
457
|
+
const IK = (key) => WORKER_TESTABLES.identityKey(key, WORKSPACE);
|
|
458
|
+
const kvGet = (key) => rehearse("/__rehearsal/kv", { op: "get", key: IK(key) });
|
|
459
|
+
const kvPut = (key, value) => rehearse("/__rehearsal/kv", { op: "put", key: IK(key), value });
|
|
460
|
+
const kvDel = (key) => rehearse("/__rehearsal/kv", { op: "delete", key: IK(key) });
|
|
461
|
+
const sql = (stmt, params = []) => rehearse("/__rehearsal/do", {
|
|
462
|
+
workspace: WORKSPACE, path: "/__rehearsal/sql", body: { stmt, params },
|
|
463
|
+
}).then((r) => (r.body ? JSON.parse(r.body) : r));
|
|
464
|
+
const identityCall = (op, body) => rehearse("/__rehearsal/do", {
|
|
465
|
+
workspace: WORKSPACE, path: `/identity/${op}`, body: { ...body, workspaceId: WORKSPACE },
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
/** Sign in over the real login form. Returns the session cookie pair. */
|
|
469
|
+
async function signIn(email) {
|
|
470
|
+
const r = await req("/__auth", form({ email, password: PASSWORD, redirect: "/" }));
|
|
471
|
+
if (r.status !== 303 || !r.setCookie) throw new Error(`sign-in failed (${r.status})`);
|
|
472
|
+
return r.setCookie.split(";")[0];
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Mint an invite the only way a person ever gets one: the admin route. */
|
|
476
|
+
async function mintVia(cookie, email) {
|
|
477
|
+
const r = await req("/__admin/users", {
|
|
478
|
+
method: "POST", headers: { "content-type": "application/json" }, cookie,
|
|
479
|
+
body: JSON.stringify({ op: "reset", email }),
|
|
480
|
+
});
|
|
481
|
+
if (r.status !== 200) throw new Error(`mint failed (${r.status}): ${r.text.slice(0, 300)}`);
|
|
482
|
+
return new URL(JSON.parse(r.text).url).searchParams.get("t");
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Mint a publish token the only way a person does: the admin panel. */
|
|
486
|
+
async function mintToken(cookie, space, label) {
|
|
487
|
+
const r = await req("/__admin/tokens", {
|
|
488
|
+
method: "POST", headers: { "content-type": "application/json" }, cookie,
|
|
489
|
+
body: JSON.stringify({ space, label }),
|
|
490
|
+
});
|
|
491
|
+
if (r.status !== 200) throw new Error(`token mint failed (${r.status}): ${r.text.slice(0, 300)}`);
|
|
492
|
+
return JSON.parse(r.text).token;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Present a publish token at the REAL front door and report what the gate said.
|
|
497
|
+
*
|
|
498
|
+
* `403` is the refusal `publishAuthDetailed` produces and anything else means the token got
|
|
499
|
+
* PAST it — which is the whole of what is being measured here. Nothing is read from either
|
|
500
|
+
* store to decide it.
|
|
501
|
+
*/
|
|
502
|
+
async function publishAs(token, space = "one") {
|
|
503
|
+
const r = await req(`/__publish/${space}/check`, {
|
|
504
|
+
method: "POST",
|
|
505
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
506
|
+
body: JSON.stringify({ files: {} }),
|
|
507
|
+
});
|
|
508
|
+
return { status: r.status, authorized: r.status !== 403, body: r.text.slice(0, 160) };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Force this isolate to re-read the roster overlay on the next request.
|
|
513
|
+
*
|
|
514
|
+
* `rosterFields` memoises the overlay for sixty seconds per workspace, and every write
|
|
515
|
+
* handler busts that entry for the workspace it wrote in. Setting the display name to what
|
|
516
|
+
* it already is changes no answer and is the cheapest real write that does it.
|
|
517
|
+
*/
|
|
518
|
+
async function bustRosterMemo(cookie, name = "Ada Renamed") {
|
|
519
|
+
await req("/__me/name", {
|
|
520
|
+
method: "POST", headers: { "content-type": "application/json" }, cookie,
|
|
521
|
+
body: JSON.stringify({ name }),
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** The admin list, as `address/role/name` lines — one HTTP answer, never a store read. */
|
|
526
|
+
async function adminList(cookie) {
|
|
527
|
+
const r = await req("/__admin/users", { cookie });
|
|
528
|
+
if (r.status !== 200) throw new Error(`admin list failed (${r.status}): ${r.text.slice(0, 200)}`);
|
|
529
|
+
return JSON.parse(r.text).users.map((u) => `${u.email}/${u.role}/${u.name || ""}`).sort();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const invitesDoc = async () => JSON.parse((await kvGet(USER_INVITES_KEY)).value || "{}");
|
|
533
|
+
const secretsDoc = async () => JSON.parse((await kvGet(USER_SECRETS_KEY)).value || "{}");
|
|
534
|
+
const inviteRow = async (token) => {
|
|
535
|
+
const r = await sql("SELECT token_hash, email, expires_at FROM invites WHERE token_hash = ?", [inviteHash(token)]);
|
|
536
|
+
return r.ok && r.rows.length ? { row: r.rows[0], type: r.types[0].expires_at } : null;
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
// ---- the clauses ------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
const state = {};
|
|
542
|
+
|
|
543
|
+
async function boundPhase() {
|
|
544
|
+
clause("0 · the runtime is workerd and the store is a real Durable Object");
|
|
545
|
+
const rt = await rehearse("/__rehearsal/runtime", {});
|
|
546
|
+
check("the isolate reports a workerd user agent", /workerd|Cloudflare-Workers/i.test(rt.userAgent || ""), rt.userAgent);
|
|
547
|
+
check("TENANTS is a Durable Object namespace: idFromName is a 64-hex object id, not its argument",
|
|
548
|
+
rt.tenantsBound && /^[0-9a-f]{64}$/.test(String(rt.idFromName)),
|
|
549
|
+
`idFromName("${WORKSPACE}") = ${rt.idFromName}`);
|
|
550
|
+
check("this deployment reads all four cut families from the object",
|
|
551
|
+
rt.kvCutover.invites === true && rt.kvCutover.lastseen === true
|
|
552
|
+
&& rt.kvCutover.publishTokens === true && rt.kvCutover.roster === true,
|
|
553
|
+
JSON.stringify(rt.kvCutover));
|
|
554
|
+
// One identity call, which is what applies the schema — the object is created lazily and
|
|
555
|
+
// `init()` runs on the first request the request path makes to it.
|
|
556
|
+
await identityCall("lastseen/read", {});
|
|
557
|
+
const tables = await sql("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name");
|
|
558
|
+
const names = tables.ok ? tables.rows.map((r) => r.name) : [];
|
|
559
|
+
check("the object's storage is SQLite and carries the engine's schema",
|
|
560
|
+
["invites", "lastseen", "members", "meta", "publish_tokens"].every((t) => names.includes(t)),
|
|
561
|
+
names.join(", "));
|
|
562
|
+
check("and it is workerd's SQLite, not node's: sqlite_version() is REFUSED here",
|
|
563
|
+
rt.sqliteVersion && rt.sqliteVersion.ok === false && /not authorized/i.test(rt.sqliteVersion.error || ""),
|
|
564
|
+
JSON.stringify(rt.sqliteVersion));
|
|
565
|
+
|
|
566
|
+
// The front door serves nothing for a workspace that is not provisioned — a touched object
|
|
567
|
+
// is not a workspace (`F-seed-pack-at-provision`) — so the sign-in every later clause
|
|
568
|
+
// rides has to follow a real `provision`. The insert is `ON CONFLICT … DO NOTHING`, so the
|
|
569
|
+
// second call in the chrome clause below touches no row this one wrote.
|
|
570
|
+
const provisionedFirst = await rehearse("/__rehearsal/do", {
|
|
571
|
+
workspace: WORKSPACE, path: "/__control/provision", body: { workspaceId: WORKSPACE, adminEmail: ADMIN },
|
|
572
|
+
});
|
|
573
|
+
check("the workspace is provisioned before anybody signs in, because an unprovisioned one serves nothing",
|
|
574
|
+
provisionedFirst.status === 200, (provisionedFirst.body || "").slice(0, 200));
|
|
575
|
+
|
|
576
|
+
clause("1 · the cut families answer from the object, and the read is not vacuous");
|
|
577
|
+
const cookie = await signIn(ADMIN);
|
|
578
|
+
check("the login gate admits the admin over the real form", !!cookie, cookie.slice(0, 34) + "…");
|
|
579
|
+
const t1 = await mintVia(cookie, ONE);
|
|
580
|
+
const row1 = await inviteRow(t1);
|
|
581
|
+
check("minting wrote a row to the object's invites table", !!row1 && row1.row.email === ONE,
|
|
582
|
+
row1 ? JSON.stringify(row1.row) : "no row");
|
|
583
|
+
check("and wrote the same invite to KV, which is what makes the flag a revert",
|
|
584
|
+
!!(await invitesDoc())[t1], `users:invites keys: ${Object.keys(await invitesDoc()).length}`);
|
|
585
|
+
|
|
586
|
+
// The expiry, in the column, as real SQLite holds it. This is the shape the bug was.
|
|
587
|
+
const isoish = row1 && /^\d{4}-\d{2}-\d{2}T/.test(String(row1.row.expires_at));
|
|
588
|
+
const ms = row1 ? Date.parse(row1.row.expires_at) : NaN;
|
|
589
|
+
check("the expiry round-tripped as a parseable ISO stamp, not epoch milliseconds",
|
|
590
|
+
isoish && Number.isFinite(ms) && Math.abs(ms - (Date.now() + 7 * DAY_MS)) < 5 * 60_000,
|
|
591
|
+
`expires_at = ${row1 && row1.row.expires_at} (${row1 && row1.type}) → Date.parse = ${ms}`);
|
|
592
|
+
|
|
593
|
+
// NON-VACUOUS: with the KV document gone, only the object can answer.
|
|
594
|
+
await kvDel(USER_INVITES_KEY);
|
|
595
|
+
check("KV's invite document is deleted, so nothing but the object can answer",
|
|
596
|
+
Object.keys(await invitesDoc()).length === 0);
|
|
597
|
+
const g1 = await req(`/__invite?t=${encodeURIComponent(t1)}`);
|
|
598
|
+
check("GET /__invite resolves the invitee from the object with KV deleted",
|
|
599
|
+
g1.status === 200 && g1.text.includes(ONE), `${g1.status}, names ${ONE}: ${g1.text.includes(ONE)}`);
|
|
600
|
+
const p1 = await req("/__invite", form({ token: t1, password: PASSWORD }));
|
|
601
|
+
check("POST /__invite is ADMITTED on the object path with KV deleted",
|
|
602
|
+
p1.status === 303 && !!p1.setCookie, `${p1.status}, Set-Cookie: ${p1.setCookie ? "yes" : "no"}`);
|
|
603
|
+
check("redeeming wrote the credential", !!(await secretsDoc())[ONE]);
|
|
604
|
+
check("and burned the object's row", (await inviteRow(t1)) === null);
|
|
605
|
+
|
|
606
|
+
clause("5 · the second use of the same link is refused, on the object path");
|
|
607
|
+
const p2 = await req("/__invite", form({ token: t1, password: PASSWORD }));
|
|
608
|
+
check("POST /__invite a second time is refused with no session",
|
|
609
|
+
p2.status === 400 && !p2.setCookie && /no longer valid/i.test(p2.text),
|
|
610
|
+
`${p2.status}, Set-Cookie: ${p2.setCookie ? "yes" : "no"}`);
|
|
611
|
+
|
|
612
|
+
// ⚠️ THE CLAIM A STUB CANNOT TEST. `consumeInvite`'s comment says a Durable Object is
|
|
613
|
+
// single-threaded, so the read and the delete cannot interleave and the second of two
|
|
614
|
+
// concurrent redemptions gets null. The harness's `blockConcurrencyWhile` is `(f) => f()`
|
|
615
|
+
// and its storage is one synchronous database, so it agrees with that claim for reasons
|
|
616
|
+
// that have nothing to do with the runtime. Six requests in flight at once, on workerd.
|
|
617
|
+
const race = await mintVia(cookie, ONE);
|
|
618
|
+
const flight = await Promise.all(Array.from({ length: 6 },
|
|
619
|
+
() => req("/__invite", form({ token: race, password: PASSWORD }))));
|
|
620
|
+
const admitted = flight.filter((r) => r.status === 303 && r.setCookie);
|
|
621
|
+
check("six simultaneous redemptions of one link admit EXACTLY ONE",
|
|
622
|
+
admitted.length === 1, `statuses: ${flight.map((r) => r.status).join(" ")}`);
|
|
623
|
+
|
|
624
|
+
clause("4 · the expiry round-trips through real SQLite, both spellings");
|
|
625
|
+
// (b) A row in the OLD numeric shape — what a copy written before the fix left behind.
|
|
626
|
+
const tOld = freshToken();
|
|
627
|
+
const oldMs = String(Date.now() + 3 * DAY_MS);
|
|
628
|
+
const ins = await sql(
|
|
629
|
+
`INSERT INTO invites (token_hash, email, created_at, expires_at, created_by) VALUES (?,?,?,?,NULL)`,
|
|
630
|
+
[inviteHash(tOld), TWO, new Date().toISOString(), oldMs]);
|
|
631
|
+
check("a pre-fix row (epoch milliseconds in the ISO TEXT column) inserts", ins.ok, JSON.stringify(ins).slice(0, 200));
|
|
632
|
+
const rOld = await inviteRow(tOld);
|
|
633
|
+
note(`workerd returns that column as a JS ${rOld && rOld.type}: ${JSON.stringify(rOld && rOld.row.expires_at)}`);
|
|
634
|
+
check("Date.parse still cannot read it — the bug is real on this runtime",
|
|
635
|
+
!Number.isFinite(Date.parse(String(rOld && rOld.row.expires_at))),
|
|
636
|
+
`Date.parse(${JSON.stringify(rOld && rOld.row.expires_at)}) = ${Date.parse(String(rOld && rOld.row.expires_at))}`);
|
|
637
|
+
const gOld = await req(`/__invite?t=${encodeURIComponent(tOld)}`);
|
|
638
|
+
check("and the pre-fix row is STILL REDEEMABLE — stampMs carries it",
|
|
639
|
+
gOld.status === 200 && gOld.text.includes(TWO), `${gOld.status}, names ${TWO}: ${gOld.text.includes(TWO)}`);
|
|
640
|
+
const pOld = await req("/__invite", form({ token: tOld, password: PASSWORD }));
|
|
641
|
+
check("redeeming the pre-fix row issues a session", pOld.status === 303 && !!pOld.setCookie, `${pOld.status}`);
|
|
642
|
+
|
|
643
|
+
// The same row, written the way the COPY actually wrote it — through the object's own
|
|
644
|
+
// `/state/import`, with `expiresAt` a JS NUMBER. `writeIdentity` binds it verbatim, so
|
|
645
|
+
// what the column ends up holding is workerd's affinity decision and not this file's.
|
|
646
|
+
const tImport = freshToken();
|
|
647
|
+
const imported = await rehearse("/__rehearsal/do", {
|
|
648
|
+
workspace: WORKSPACE, path: "/state/import",
|
|
649
|
+
body: {
|
|
650
|
+
workspaceId: WORKSPACE, overlay: {},
|
|
651
|
+
identity: { invites: [{
|
|
652
|
+
tokenHash: inviteHash(tImport), email: TWO,
|
|
653
|
+
createdAt: new Date().toISOString(), expiresAt: Date.now() + 3 * DAY_MS, createdBy: null,
|
|
654
|
+
}] },
|
|
655
|
+
},
|
|
656
|
+
});
|
|
657
|
+
check("the copy's own import verb accepts a numeric expiry without throwing",
|
|
658
|
+
imported.status === 200, JSON.stringify(imported).slice(0, 220));
|
|
659
|
+
const rImp = await inviteRow(tImport);
|
|
660
|
+
note(`bound as a JS number, workerd's TEXT column returns a ${rImp && rImp.type}: ${JSON.stringify(rImp && rImp.row.expires_at)}`);
|
|
661
|
+
const gImp = await req(`/__invite?t=${encodeURIComponent(tImport)}`);
|
|
662
|
+
check("and a row the copy wrote that way redeems over the real route",
|
|
663
|
+
gImp.status === 200 && gImp.text.includes(TWO), `${gImp.status}`);
|
|
664
|
+
|
|
665
|
+
// (a) Expiry is enforced, in both spellings.
|
|
666
|
+
const tExpIso = freshToken(), tExpNum = freshToken();
|
|
667
|
+
await sql(`INSERT INTO invites (token_hash, email, created_at, expires_at, created_by) VALUES (?,?,?,?,NULL)`,
|
|
668
|
+
[inviteHash(tExpIso), TWO, new Date(Date.now() - DAY_MS).toISOString(), new Date(Date.now() - 60_000).toISOString()]);
|
|
669
|
+
await sql(`INSERT INTO invites (token_hash, email, created_at, expires_at, created_by) VALUES (?,?,?,?,NULL)`,
|
|
670
|
+
[inviteHash(tExpNum), TWO, new Date(Date.now() - DAY_MS).toISOString(), String(Date.now() - 60_000)]);
|
|
671
|
+
const eIso = await req(`/__invite?t=${encodeURIComponent(tExpIso)}`);
|
|
672
|
+
const eNum = await req(`/__invite?t=${encodeURIComponent(tExpNum)}`);
|
|
673
|
+
check("an expired invite (ISO) is refused", eIso.status === 400 && /no longer valid/i.test(eIso.text), `${eIso.status}`);
|
|
674
|
+
check("an expired invite (epoch milliseconds) is refused too — the tolerant read is not a blanket yes",
|
|
675
|
+
eNum.status === 400 && /no longer valid/i.test(eNum.text), `${eNum.status}`);
|
|
676
|
+
|
|
677
|
+
clause("3 · THE LOGIN GATE FAILS CLOSED ON A REAL OBJECT");
|
|
678
|
+
const t5 = await mintVia(cookie, THREE);
|
|
679
|
+
check("a fresh invite exists in BOTH stores, so a fall-through to KV would succeed",
|
|
680
|
+
!!(await inviteRow(t5)) && !!(await invitesDoc())[t5]);
|
|
681
|
+
check("the invitee has no credential yet", !(await secretsDoc())[THREE]);
|
|
682
|
+
|
|
683
|
+
// THE BREAK, at the runtime level: the table the real SELECT reads is renamed away, so
|
|
684
|
+
// the real method throws inside the real object. init() is a no-op on a warm object, so
|
|
685
|
+
// nothing puts it back before the case can look.
|
|
686
|
+
const broke = await sql(`ALTER TABLE invites RENAME TO invites_broken`);
|
|
687
|
+
check("the object's invites table is renamed away", broke.ok, JSON.stringify(broke).slice(0, 160));
|
|
688
|
+
const direct = await identityCall("invite/read", { tokenHash: inviteHash(t5), now: Date.now() });
|
|
689
|
+
check("the object now ANSWERS AN ERROR to an identity read (a real throw, not a stub)",
|
|
690
|
+
direct.status >= 500 || !!direct.threw, JSON.stringify(direct).slice(0, 220));
|
|
691
|
+
|
|
692
|
+
const gBroke = await req(`/__invite?t=${encodeURIComponent(t5)}`);
|
|
693
|
+
check("GET /__invite REFUSES rather than falling through to the live KV row",
|
|
694
|
+
gBroke.status === 400 && /no longer valid/i.test(gBroke.text), `${gBroke.status}`);
|
|
695
|
+
const pBroke = await req("/__invite", form({ token: t5, password: PASSWORD }));
|
|
696
|
+
check("POST /__invite is a REFUSAL, NOT AN ADMISSION", pBroke.status === 400, `${pBroke.status}`);
|
|
697
|
+
check("no Set-Cookie was issued", !pBroke.setCookie, `Set-Cookie: ${pBroke.setCookie || "none"}`);
|
|
698
|
+
check("no credential was written", !(await secretsDoc())[THREE]);
|
|
699
|
+
const stillIn = await signIn(ADMIN).then(() => true, () => false);
|
|
700
|
+
check("a family that was NOT broken still answers — the outage is scoped, not global", stillIn);
|
|
701
|
+
const meBroke = await req("/__me", { cookie });
|
|
702
|
+
check("an ALREADY-SIGNED-IN session still resolves while the object's read is broken",
|
|
703
|
+
meBroke.status === 200 && JSON.parse(meBroke.text).user
|
|
704
|
+
&& JSON.parse(meBroke.text).user.email === ADMIN, meBroke.text.slice(0, 120));
|
|
705
|
+
|
|
706
|
+
const fixed = await sql(`ALTER TABLE invites_broken RENAME TO invites`);
|
|
707
|
+
check("the object's table is restored", fixed.ok);
|
|
708
|
+
const pOk = await req("/__invite", form({ token: t5, password: PASSWORD }));
|
|
709
|
+
check("THE SAME LINK IS NOW ADMITTED", pOk.status === 303 && !!pOk.setCookie, `${pOk.status}`);
|
|
710
|
+
check("and the credential is written", !!(await secretsDoc())[THREE]);
|
|
711
|
+
|
|
712
|
+
clause("7 · revocation on the object path — DELETE … RETURNING, on workerd's SQLite");
|
|
713
|
+
// `inviteRevoke` and `lastseenForget` are the only statements in the cut families that
|
|
714
|
+
// use RETURNING, and they are the two whose failure is a way back in for somebody who was
|
|
715
|
+
// just removed: redeeming calls `setUserSecret`, which REPLACES the tombstone removal
|
|
716
|
+
// wrote. If this dialect were unsupported the call would throw, and `revokeInvitesFor`
|
|
717
|
+
// does not swallow it — so this is the case that says which of the two happened.
|
|
718
|
+
const tGone = await mintVia(cookie, FOUR);
|
|
719
|
+
check("the person has an outstanding link in the object", !!(await inviteRow(tGone)));
|
|
720
|
+
const rm = await req("/__admin/users", {
|
|
721
|
+
method: "POST", headers: { "content-type": "application/json" }, cookie,
|
|
722
|
+
body: JSON.stringify({ op: "remove", email: FOUR }),
|
|
723
|
+
});
|
|
724
|
+
check("removing them succeeds — the RETURNING statements ran", rm.status === 200, `${rm.status} ${rm.text.slice(0, 160)}`);
|
|
725
|
+
check("their invite row is gone from the object", (await inviteRow(tGone)) === null);
|
|
726
|
+
check("and gone from KV too", !(await invitesDoc())[tGone]);
|
|
727
|
+
const gGone = await req(`/__invite?t=${encodeURIComponent(tGone)}`);
|
|
728
|
+
check("their link is dead", gGone.status === 400 && !gGone.setCookie, `${gGone.status}`);
|
|
729
|
+
|
|
730
|
+
clause("8 · PUBLISH TOKENS answer from the object, WITH their scope, on real SQLite");
|
|
731
|
+
const tScoped = await mintToken(cookie, "one", "backup");
|
|
732
|
+
const tStar = await mintToken(cookie, "*", "ci");
|
|
733
|
+
const tokRows = await sql(`SELECT token_hash, label, scope FROM publish_tokens ORDER BY label`);
|
|
734
|
+
check("the object's publish_tokens table has a `scope` column and the mint filled it",
|
|
735
|
+
tokRows.ok && tokRows.rows.length === 2
|
|
736
|
+
&& tokRows.rows.find((r) => r.label === "ci").scope === "*"
|
|
737
|
+
&& tokRows.rows.find((r) => r.label === "backup").scope === "one",
|
|
738
|
+
JSON.stringify(tokRows.rows));
|
|
739
|
+
// ⚠️ THE SHAPE THE LAST SLICE'S BUG HAD. A value bound into a column is the RUNTIME's
|
|
740
|
+
// affinity decision, not this file's — so what the scope comes back AS is asserted, not
|
|
741
|
+
// assumed. A number here would mean a space id spelled `"1.0"` somewhere.
|
|
742
|
+
check("and workerd returns it as a JS string, not as a number through TEXT affinity",
|
|
743
|
+
tokRows.ok && tokRows.types.every((t) => t.scope === "string"),
|
|
744
|
+
JSON.stringify(tokRows.types));
|
|
745
|
+
|
|
746
|
+
const kvTokens = (await kvGet(PUBLISH_TOKENS_KEY)).value;
|
|
747
|
+
check("the mint ALSO wrote KV, which is what makes the flag a revert",
|
|
748
|
+
!!kvTokens && Object.keys(JSON.parse(kvTokens)).length === 2, `keys: ${Object.keys(JSON.parse(kvTokens || "{}")).length}`);
|
|
749
|
+
|
|
750
|
+
// NON-VACUOUS: with the KV document gone, only the object can answer.
|
|
751
|
+
await kvDel(PUBLISH_TOKENS_KEY);
|
|
752
|
+
const pScoped = await publishAs(tScoped, "one");
|
|
753
|
+
check("a SPACE-SCOPED token publishes at its own space with KV deleted",
|
|
754
|
+
pScoped.authorized, `${pScoped.status} ${pScoped.body}`);
|
|
755
|
+
const pWrong = await publishAs(tScoped, "two");
|
|
756
|
+
check("…and is REFUSED elsewhere — the scope is an authorization, not a label",
|
|
757
|
+
!pWrong.authorized, `${pWrong.status} ${pWrong.body}`);
|
|
758
|
+
const pStar = await publishAs(tStar, "one");
|
|
759
|
+
check("a STAR token still publishes — it was not narrowed to the default space",
|
|
760
|
+
pStar.authorized, `${pStar.status} ${pStar.body}`);
|
|
761
|
+
const pJunk = await publishAs("not-a-token-anybody-minted", "one");
|
|
762
|
+
check("and a token nobody minted is refused, so the three above are not a blanket yes",
|
|
763
|
+
!pJunk.authorized, `${pJunk.status} ${pJunk.body}`);
|
|
764
|
+
await kvPut(PUBLISH_TOKENS_KEY, kvTokens); // the mirror back, for the revert clause
|
|
765
|
+
|
|
766
|
+
clause("9 · THE ROSTER answers from the object — four KV documents, one round trip");
|
|
767
|
+
const invited = await req("/__admin/users", {
|
|
768
|
+
method: "POST", headers: { "content-type": "application/json" }, cookie,
|
|
769
|
+
body: JSON.stringify({ op: "invite", email: "kay@example.test", name: "Kay", role: "viewer" }),
|
|
770
|
+
});
|
|
771
|
+
check("an invite through the admin route succeeds", invited.status === 200, `${invited.status} ${invited.text.slice(0, 200)}`);
|
|
772
|
+
const memberRow = await sql(
|
|
773
|
+
`SELECT email, role, name, initials, colour, source, added_by, name_overlay, role_overlay
|
|
774
|
+
FROM members WHERE email = ?`, ["kay@example.test"]);
|
|
775
|
+
check("the object's members table holds the invitee, with its provenance and its chip fields",
|
|
776
|
+
memberRow.ok && memberRow.rows.length === 1 && memberRow.rows[0].source === "overlay"
|
|
777
|
+
&& memberRow.rows[0].role === "viewer" && !!memberRow.rows[0].initials && !!memberRow.rows[0].colour,
|
|
778
|
+
JSON.stringify(memberRow.rows[0] || null));
|
|
779
|
+
|
|
780
|
+
const named = await req("/__me/name", {
|
|
781
|
+
method: "POST", headers: { "content-type": "application/json" }, cookie,
|
|
782
|
+
body: JSON.stringify({ name: "Ada Renamed" }),
|
|
783
|
+
});
|
|
784
|
+
check("a self-set display name succeeds", named.status === 200, `${named.status} ${named.text.slice(0, 160)}`);
|
|
785
|
+
const adaRow = await sql(`SELECT name, name_overlay, source FROM members WHERE email = ?`, [ADMIN]);
|
|
786
|
+
check("the OVERLAY name is its own column and the file's name is still in the durable one",
|
|
787
|
+
adaRow.ok && adaRow.rows[0].name === "Ada"
|
|
788
|
+
&& JSON.parse(adaRow.rows[0].name_overlay || "null") && JSON.parse(adaRow.rows[0].name_overlay).name === "Ada Renamed",
|
|
789
|
+
JSON.stringify(adaRow.rows[0] || null));
|
|
790
|
+
|
|
791
|
+
// NON-VACUOUS: the four KV documents are deleted, so only the object can answer.
|
|
792
|
+
const rosterKv = {};
|
|
793
|
+
for (const k of ROSTER_KEYS) { rosterKv[k] = (await kvGet(k)).value; await kvDel(k); }
|
|
794
|
+
const listNoKv = await adminList(cookie);
|
|
795
|
+
check("the admin list still names the invitee with all four KV documents deleted",
|
|
796
|
+
listNoKv.some((l) => l.startsWith("kay@example.test/viewer/")), listNoKv.join(" · "));
|
|
797
|
+
check("…and still shows the self-set name, which lives in the overlay half",
|
|
798
|
+
listNoKv.some((l) => l.includes("/Ada Renamed")), listNoKv.join(" · "));
|
|
799
|
+
for (const k of ROSTER_KEYS) if (rosterKv[k] != null) await kvPut(k, rosterKv[k]);
|
|
800
|
+
|
|
801
|
+
clause("10 · THE LOGIN GATE ON THE ROSTER: a broken object REFUSES an overlay grant");
|
|
802
|
+
// ⚠️ `rosterFields` fails OPEN to the CONFIG roster on purpose — the tombstone and not
|
|
803
|
+
// this overlay is the security boundary. What must NOT happen is a fall-through onto KV,
|
|
804
|
+
// so the KV role overlay is left LIVE and a promotion recorded only in the overlay has to
|
|
805
|
+
// stop taking effect the moment the object cannot be read.
|
|
806
|
+
const promoted = await req("/__admin/users", {
|
|
807
|
+
method: "POST", headers: { "content-type": "application/json" }, cookie,
|
|
808
|
+
body: JSON.stringify({ op: "role", email: ONE, role: "admin" }),
|
|
809
|
+
});
|
|
810
|
+
check("the invitee-turned-editor is promoted to admin in the overlay", promoted.status === 200,
|
|
811
|
+
`${promoted.status} ${promoted.text.slice(0, 200)}`);
|
|
812
|
+
const oneCookie = await signIn(ONE);
|
|
813
|
+
const adminBefore = await req("/__admin/users", { cookie: oneCookie });
|
|
814
|
+
check("and that promotion admits them to the admin API", adminBefore.status === 200, `${adminBefore.status}`);
|
|
815
|
+
const rolesLive = (await kvGet("users:roles")).value;
|
|
816
|
+
check("KV STILL HOLDS the role overlay, so a fall-through would succeed",
|
|
817
|
+
!!rolesLive && JSON.parse(rolesLive)[ONE] === "admin", String(rolesLive));
|
|
818
|
+
|
|
819
|
+
const brokeRoster = await sql(`ALTER TABLE members RENAME TO members_broken`);
|
|
820
|
+
check("the object's members table is renamed away", brokeRoster.ok, JSON.stringify(brokeRoster).slice(0, 160));
|
|
821
|
+
// ⚠️ THE OVERLAY IS MEMOISED FOR SIXTY SECONDS, so a break is invisible until the memo
|
|
822
|
+
// is asked to refresh — which is a real property of this layer and not a test artefact.
|
|
823
|
+
// Any roster write busts it for this workspace, so one is made: the same display name
|
|
824
|
+
// again, which changes no answer and forces the next request to re-read the store.
|
|
825
|
+
await bustRosterMemo(cookie);
|
|
826
|
+
const adminDuring = await req("/__admin/users", { cookie: oneCookie });
|
|
827
|
+
check("the admin API REFUSES rather than falling through to the live KV overlay",
|
|
828
|
+
adminDuring.status === 403, `${adminDuring.status} ${adminDuring.text.slice(0, 160)}`);
|
|
829
|
+
const adaDuring = await req("/__admin/users", { cookie });
|
|
830
|
+
check("a CONFIG admin is still admitted — the fail-open is to the config roster, as designed",
|
|
831
|
+
adaDuring.status === 200, `${adaDuring.status}`);
|
|
832
|
+
const fixedRoster = await sql(`ALTER TABLE members_broken RENAME TO members`);
|
|
833
|
+
check("the object's table is restored", fixedRoster.ok);
|
|
834
|
+
await bustRosterMemo(cookie);
|
|
835
|
+
const adminAfter = await req("/__admin/users", { cookie: oneCookie });
|
|
836
|
+
check("THE SAME SESSION IS ADMITTED AGAIN — the outage was a refusal, not a wedge",
|
|
837
|
+
adminAfter.status === 200, `${adminAfter.status}`);
|
|
838
|
+
|
|
839
|
+
clause("11 · PROMOTING an overlay member into the config file does not delete them");
|
|
840
|
+
// ⚠️ TWO SQL PASSES IN ONE REAL DURABLE OBJECT TRANSACTION, AND THE ORDER IS THE BUG.
|
|
841
|
+
// `rosterWrite` writes the `configUsers` list first and then tombstones every row still
|
|
842
|
+
// marked `'overlay'` that the incoming `add` no longer carries. Handed the config the
|
|
843
|
+
// request was LOADED with — the one the push replaces — that first pass does not name the
|
|
844
|
+
// person being promoted, and the second buries them. It is a live defect on this shape of
|
|
845
|
+
// deployment, found by running a real migration and not by reading the code.
|
|
846
|
+
//
|
|
847
|
+
// WHAT THIS CLAUSE ADDS OVER THE SUITE is the runtime: real workerd SQLite, real bound
|
|
848
|
+
// parameters, one real `transactionSync`. What it deliberately does NOT assert is the
|
|
849
|
+
// SERVED roster — this fixture runs in assets mode on purpose (see wranglerConfig), so a
|
|
850
|
+
// pushed config is written to the store and never becomes the config the front door reads.
|
|
851
|
+
// `roster/read`'s `remove` list is asserted instead, because that list is the only thing
|
|
852
|
+
// that can drop a config-named person from the merge, and `test/roster-promotion.test.mjs`
|
|
853
|
+
// drives the served end in bundle mode.
|
|
854
|
+
const invitedFive = await req("/__admin/users", {
|
|
855
|
+
method: "POST", headers: { "content-type": "application/json" }, cookie,
|
|
856
|
+
body: JSON.stringify({ op: "invite", email: FIVE, name: "Leah", role: "editor" }),
|
|
857
|
+
});
|
|
858
|
+
check("an overlay invite lands", invitedFive.status === 200, `${invitedFive.status} ${invitedFive.text.slice(0, 160)}`);
|
|
859
|
+
const beforeRow = await sql(`SELECT source, removed_at FROM members WHERE email = ?`, [FIVE]);
|
|
860
|
+
check("their row is an overlay entry with no tombstone",
|
|
861
|
+
beforeRow.ok && beforeRow.rows[0].source === "overlay" && beforeRow.rows[0].removed_at === null,
|
|
862
|
+
JSON.stringify(beforeRow.rows[0] || null));
|
|
863
|
+
|
|
864
|
+
// THE PUSH. `roster-update` has committed them to the identity file and the deploy sends
|
|
865
|
+
// that file back over the real route, with the star token minted in clause 8.
|
|
866
|
+
const pushed = await req("/__publish/_instance/config", {
|
|
867
|
+
method: "POST",
|
|
868
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${tStar}` },
|
|
869
|
+
body: JSON.stringify({
|
|
870
|
+
users: [...FILE_ROSTER, { email: FIVE, name: "Leah", role: "editor" }],
|
|
871
|
+
engineVersion: "0.0.0-rehearsal", updateFeed: "",
|
|
872
|
+
mcpHostSuffixes: [], mcpHostAllowlistUrl: "", vanityRedirects: {}, rtOrigin: "", sentinels: [],
|
|
873
|
+
}),
|
|
874
|
+
});
|
|
875
|
+
check("the deploy's config push is accepted", pushed.status === 200, `${pushed.status} ${pushed.text.slice(0, 160)}`);
|
|
876
|
+
const afterRow = await sql(`SELECT source, removed_at FROM members WHERE email = ?`, [FIVE]);
|
|
877
|
+
check("THE PROMOTED PERSON IS NOT TOMBSTONED",
|
|
878
|
+
afterRow.ok && afterRow.rows[0] && afterRow.rows[0].removed_at === null,
|
|
879
|
+
JSON.stringify(afterRow.rows[0] || null));
|
|
880
|
+
check("…and their row now says the durable record names them",
|
|
881
|
+
afterRow.ok && afterRow.rows[0] && afterRow.rows[0].source === "config",
|
|
882
|
+
JSON.stringify(afterRow.rows[0] || null));
|
|
883
|
+
const docs = await identityCall("roster/read", {});
|
|
884
|
+
const readBack = docs.body ? JSON.parse(docs.body) : {};
|
|
885
|
+
check("the object's `remove` list — the only thing that can hide a config user — does not name them",
|
|
886
|
+
Array.isArray(readBack.roster && readBack.roster.remove) && !readBack.roster.remove.includes(FIVE),
|
|
887
|
+
JSON.stringify((readBack.roster || {}).remove));
|
|
888
|
+
check("and the overlay half really did retire, so this is a promotion and not a second record",
|
|
889
|
+
readBack.roster && readBack.roster.add && !readBack.roster.add[FIVE],
|
|
890
|
+
Object.keys((readBack.roster || {}).add || {}).join(", "));
|
|
891
|
+
const kvRoster = JSON.parse((await kvGet("users:roster")).value || "{}");
|
|
892
|
+
check("KV drained with it — the dual write is what keeps the flag a revert",
|
|
893
|
+
kvRoster.add && !kvRoster.add[FIVE], Object.keys(kvRoster.add || {}).join(", "));
|
|
894
|
+
|
|
895
|
+
clause("12 · …and the clause that buries a REAL orphan still buries one");
|
|
896
|
+
// The tombstone the orphan clause exists to write. Reviving every removed row would have
|
|
897
|
+
// "fixed" clause 11 by undoing this, and a removed person coming back is the failure that
|
|
898
|
+
// matters more. An invited person's removal leaves no entry in EITHER half of the KV
|
|
899
|
+
// document, so their row is the only record that they must not come back.
|
|
900
|
+
const invitedSix = await req("/__admin/users", {
|
|
901
|
+
method: "POST", headers: { "content-type": "application/json" }, cookie,
|
|
902
|
+
body: JSON.stringify({ op: "invite", email: SIX, name: "Mae", role: "editor" }),
|
|
903
|
+
});
|
|
904
|
+
check("a second overlay invite lands", invitedSix.status === 200, `${invitedSix.status} ${invitedSix.text.slice(0, 160)}`);
|
|
905
|
+
const removedSix = await req("/__admin/users", {
|
|
906
|
+
method: "POST", headers: { "content-type": "application/json" }, cookie,
|
|
907
|
+
body: JSON.stringify({ op: "remove", email: SIX }),
|
|
908
|
+
});
|
|
909
|
+
check("removing them succeeds", removedSix.status === 200, `${removedSix.status} ${removedSix.text.slice(0, 160)}`);
|
|
910
|
+
const sixRow = await sql(`SELECT source, removed_at FROM members WHERE email = ?`, [SIX]);
|
|
911
|
+
check("their row IS tombstoned on real workerd",
|
|
912
|
+
sixRow.ok && sixRow.rows[0] && sixRow.rows[0].removed_at !== null,
|
|
913
|
+
JSON.stringify(sixRow.rows[0] || null));
|
|
914
|
+
const listAfter = await adminList(cookie);
|
|
915
|
+
check("and they are gone from the admin list, which is the served answer",
|
|
916
|
+
!listAfter.some((l) => l.startsWith(SIX + "/")), listAfter.join(" · "));
|
|
917
|
+
const fiveStill = await sql(`SELECT removed_at FROM members WHERE email = ?`, [FIVE]);
|
|
918
|
+
check("the promoted person survived somebody else's removal",
|
|
919
|
+
fiveStill.ok && fiveStill.rows[0] && fiveStill.rows[0].removed_at === null,
|
|
920
|
+
JSON.stringify(fiveStill.rows[0] || null));
|
|
921
|
+
|
|
922
|
+
clause("13 · the chrome key on real workerd");
|
|
923
|
+
// Seed a star-scope token capped to `chrome` — minted the ONLY way it may be: the control
|
|
924
|
+
// plane's `chrome` operator verb, reachable only by code holding the namespace binding.
|
|
925
|
+
// `/__rehearsal/do` stands in for that binding here, exactly as clause 3's identityCall
|
|
926
|
+
// does for `/identity/*`: no HTTP route on the front door reaches `/__control/*` at all.
|
|
927
|
+
// The chrome one completes check → blob PUT → commit against the SHARED `_engine` bundle,
|
|
928
|
+
// twice — once from nothing (this store has never held one) and once as a genuine refresh
|
|
929
|
+
// riding the base-version CAS `check.liveVersion` feeds `commit`, the same shape
|
|
930
|
+
// `scripts/publish.mjs --engine` drives. The plain one (`tStar`, from clause 8) is refused
|
|
931
|
+
// at the same door. If a read the chrome token itself needs — `manifest`, `versions`,
|
|
932
|
+
// `version` — were missing from `CAP_ROUTES.chrome`, capabilityRefusal would 403 THIS
|
|
933
|
+
// RESTRICTED CREDENTIAL on its own reads before sharedChromeRefusal is ever reached, which
|
|
934
|
+
// is how this clause PINS the op list rather than merely exercising the happy path.
|
|
935
|
+
// `chrome` refuses `not-provisioned` like every other control verb (only `provision` may
|
|
936
|
+
// create anything), and nothing earlier in this rehearsal calls it — every prior clause
|
|
937
|
+
// reached the object through lazy `init()`, which builds the schema but never writes
|
|
938
|
+
// `meta.provisioned_at`. The insert is `ON CONFLICT … DO NOTHING` throughout, so
|
|
939
|
+
// provisioning now, with the admin this fixture already signed in as, touches no row any
|
|
940
|
+
// earlier clause wrote.
|
|
941
|
+
const provisioned = await rehearse("/__rehearsal/do", {
|
|
942
|
+
workspace: WORKSPACE, path: "/__control/provision", body: { workspaceId: WORKSPACE, adminEmail: ADMIN },
|
|
943
|
+
});
|
|
944
|
+
check("the workspace is provisioned, which the chrome verb requires",
|
|
945
|
+
provisioned.status === 200, (provisioned.body || "").slice(0, 200));
|
|
946
|
+
|
|
947
|
+
const chromeMint = await rehearse("/__rehearsal/do", { workspace: WORKSPACE, path: "/__control/chrome", body: {} });
|
|
948
|
+
check("the operator verb mints a chrome-capability token", chromeMint.status === 200, (chromeMint.body || "").slice(0, 200));
|
|
949
|
+
const chromeToken = JSON.parse(chromeMint.body).token;
|
|
950
|
+
|
|
951
|
+
const sha256 = (buf) => crypto.createHash("sha256").update(buf).digest("hex");
|
|
952
|
+
const swBytes = Buffer.from("// rehearsal service worker\n");
|
|
953
|
+
const cssBytes = Buffer.from("/* rehearsal chrome css */\n");
|
|
954
|
+
const jsBytes = Buffer.from("// rehearsal chrome js\n");
|
|
955
|
+
const v1Files = {
|
|
956
|
+
"/sw.js": { h: sha256(swBytes), ct: "text/javascript", s: swBytes.byteLength },
|
|
957
|
+
"/_chrome.1.0.rehearsal.css": { h: sha256(cssBytes), ct: "text/css", s: cssBytes.byteLength },
|
|
958
|
+
"/_chrome.1.0.rehearsal.js": { h: sha256(jsBytes), ct: "text/javascript", s: jsBytes.byteLength },
|
|
959
|
+
};
|
|
960
|
+
const v1Routing = {
|
|
961
|
+
chrome: { css: "_chrome.1.0.rehearsal.css", js: "_chrome.1.0.rehearsal.js", ui: "1.0" },
|
|
962
|
+
runtimeChrome: true, publicPrefixes: [], versionMap: {},
|
|
963
|
+
};
|
|
964
|
+
const v1BuiltWith = { engine: "rehearsal", version: "0.0.0-rehearsal" };
|
|
965
|
+
const engineBearer = (token) => ({ "content-type": "application/json", authorization: `Bearer ${token}` });
|
|
966
|
+
|
|
967
|
+
const check1 = await req("/__publish/_engine/check", {
|
|
968
|
+
method: "POST", headers: engineBearer(chromeToken), body: JSON.stringify({ files: v1Files }),
|
|
969
|
+
});
|
|
970
|
+
check("a chrome token clears the _engine preflight on the real runtime — a FIRST publish, nothing live yet",
|
|
971
|
+
check1.status === 200, `${check1.status} ${check1.text.slice(0, 200)}`);
|
|
972
|
+
const missing1 = (JSON.parse(check1.text).missing || []).sort();
|
|
973
|
+
const want1 = Object.values(v1Files).map((f) => f.h).sort();
|
|
974
|
+
check("every file in the fresh manifest is reported missing — this store has never held _engine",
|
|
975
|
+
JSON.stringify(missing1) === JSON.stringify(want1), JSON.stringify({ missing1, want1 }));
|
|
976
|
+
|
|
977
|
+
for (const [path, bytes] of [["/sw.js", swBytes], ["/_chrome.1.0.rehearsal.css", cssBytes], ["/_chrome.1.0.rehearsal.js", jsBytes]]) {
|
|
978
|
+
const h = v1Files[path].h;
|
|
979
|
+
const put = await req(`/__publish/_engine/blob/${h}`, { method: "PUT", headers: { authorization: `Bearer ${chromeToken}` }, body: bytes });
|
|
980
|
+
check(`the chrome token PUTs the blob for ${path}`, put.status === 204, `${put.status} ${put.text.slice(0, 160)}`);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const commit1 = await req("/__publish/_engine/commit", {
|
|
984
|
+
method: "POST", headers: engineBearer(chromeToken),
|
|
985
|
+
body: JSON.stringify({ id: "_engine", files: v1Files, routing: v1Routing, builtWith: v1BuiltWith }),
|
|
986
|
+
});
|
|
987
|
+
check("and its commit STICKS — a new _engine version is live", commit1.status === 200, `${commit1.status} ${commit1.text.slice(0, 300)}`);
|
|
988
|
+
const v1 = JSON.parse(commit1.text).version;
|
|
989
|
+
check("the first-ever _engine publish is version 1", v1 === 1, String(v1));
|
|
990
|
+
|
|
991
|
+
// The chrome token reading its OWN CAS inputs — proof `CAP_ROUTES.chrome`'s three read
|
|
992
|
+
// entries are pulling weight, since these are refused to a RESTRICTED credential by
|
|
993
|
+
// capabilityRefusal before sharedChromeRefusal (which exempts every read regardless of
|
|
994
|
+
// capability) is ever reached.
|
|
995
|
+
const mAfter1 = await req("/__publish/_engine/manifest", { headers: { authorization: `Bearer ${chromeToken}` } });
|
|
996
|
+
check("the chrome token reads the manifest it just committed", mAfter1.status === 200, `${mAfter1.status}`);
|
|
997
|
+
const vsAfter1 = await req("/__publish/_engine/versions", { headers: { authorization: `Bearer ${chromeToken}` } });
|
|
998
|
+
check("the chrome token lists versions", vsAfter1.status === 200 && (JSON.parse(vsAfter1.text).versions || []).includes(1),
|
|
999
|
+
`${vsAfter1.status} ${vsAfter1.text.slice(0, 120)}`);
|
|
1000
|
+
const vAfter1 = await req(`/__publish/_engine/version/${v1}`, { headers: { authorization: `Bearer ${chromeToken}` } });
|
|
1001
|
+
check("the chrome token reads that version by number", vAfter1.status === 200, `${vAfter1.status}`);
|
|
1002
|
+
|
|
1003
|
+
// A genuine REFRESH: one more file, riding the base-version CAS `scripts/publish.mjs
|
|
1004
|
+
// --engine` drives (`baseVersion: check.liveVersion`), not a from-nothing publish.
|
|
1005
|
+
const extraBytes = Buffer.from(`rehearsal chrome refresh ${WORKSPACE}`);
|
|
1006
|
+
const v2Files = { ...v1Files, "/_chrome-extra.txt": { h: sha256(extraBytes), ct: "text/plain", s: extraBytes.byteLength } };
|
|
1007
|
+
const check2 = await req("/__publish/_engine/check", {
|
|
1008
|
+
method: "POST", headers: engineBearer(chromeToken), body: JSON.stringify({ files: v2Files }),
|
|
1009
|
+
});
|
|
1010
|
+
const check2Body = JSON.parse(check2.text);
|
|
1011
|
+
check("the second check sees v1 live and only the new file missing",
|
|
1012
|
+
check2.status === 200 && check2Body.liveVersion === v1 && check2Body.missing.length === 1
|
|
1013
|
+
&& check2Body.missing[0] === v2Files["/_chrome-extra.txt"].h,
|
|
1014
|
+
`${check2.status} ${check2.text.slice(0, 200)}`);
|
|
1015
|
+
const putExtra = await req(`/__publish/_engine/blob/${v2Files["/_chrome-extra.txt"].h}`, {
|
|
1016
|
+
method: "PUT", headers: { authorization: `Bearer ${chromeToken}` }, body: extraBytes,
|
|
1017
|
+
});
|
|
1018
|
+
check("the chrome token PUTs the refresh's one new blob", putExtra.status === 204, `${putExtra.status}`);
|
|
1019
|
+
const commit2 = await req("/__publish/_engine/commit", {
|
|
1020
|
+
method: "POST", headers: engineBearer(chromeToken),
|
|
1021
|
+
body: JSON.stringify({
|
|
1022
|
+
id: "_engine", files: v2Files, routing: v1Routing, builtWith: v1BuiltWith, baseVersion: check2Body.liveVersion,
|
|
1023
|
+
}),
|
|
1024
|
+
});
|
|
1025
|
+
check("the refresh commit STICKS, on the base-version CAS", commit2.status === 200, `${commit2.status} ${commit2.text.slice(0, 300)}`);
|
|
1026
|
+
const v2 = JSON.parse(commit2.text).version;
|
|
1027
|
+
check("the live version advanced past the first publish", v2 > v1, `${v1} → ${v2}`);
|
|
1028
|
+
const mAfter2 = await req("/__publish/_engine/manifest", { headers: { authorization: `Bearer ${chromeToken}` } });
|
|
1029
|
+
check("the live manifest carries the refreshed file", !!JSON.parse(mAfter2.text).files["/_chrome-extra.txt"],
|
|
1030
|
+
Object.keys(JSON.parse(mAfter2.text).files).join(","));
|
|
1031
|
+
|
|
1032
|
+
// And beside it: the credential that could NOT have done any of this.
|
|
1033
|
+
const refusedCheck = await req("/__publish/_engine/check", {
|
|
1034
|
+
method: "POST", headers: engineBearer(tStar), body: JSON.stringify({ files: {} }),
|
|
1035
|
+
});
|
|
1036
|
+
const refusedBody = JSON.parse(refusedCheck.text);
|
|
1037
|
+
check("a plain star token is STILL refused chrome-not-writable-here — even after a real publish exists to protect",
|
|
1038
|
+
refusedCheck.status === 403 && refusedBody.reason === "chrome-not-writable-here",
|
|
1039
|
+
`${refusedCheck.status} ${refusedCheck.text.slice(0, 160)}`);
|
|
1040
|
+
|
|
1041
|
+
clause("2 · setup — an answer to compare, and a state only one store holds");
|
|
1042
|
+
await req("/__me", { cookie }); // stamps lastseen on both stores
|
|
1043
|
+
const seenRow = await sql(`SELECT email, at FROM lastseen WHERE email = ?`, [ADMIN]);
|
|
1044
|
+
check("the object holds the admin's lastseen stamp", seenRow.ok && seenRow.rows.length === 1,
|
|
1045
|
+
JSON.stringify(seenRow.rows));
|
|
1046
|
+
const seenKv = (await kvGet(LASTSEEN_PREFIX + ADMIN)).value;
|
|
1047
|
+
check("and KV holds the mirror of it — the dual write", !!seenKv, String(seenKv));
|
|
1048
|
+
state.lastSeenAt = seenRow.rows[0].at;
|
|
1049
|
+
// The throttle is the object's now, and the mirror is written from ITS verdict — so a
|
|
1050
|
+
// second page view inside the window must move neither stamp.
|
|
1051
|
+
await kvDel(LASTSEEN_PREFIX + ADMIN);
|
|
1052
|
+
await req("/__me", { cookie });
|
|
1053
|
+
const seenAgain = await sql(`SELECT at FROM lastseen WHERE email = ?`, [ADMIN]);
|
|
1054
|
+
check("a second page view inside the window rewrites nothing — the throttle is the object's",
|
|
1055
|
+
seenAgain.rows[0].at === state.lastSeenAt && !(await kvGet(LASTSEEN_PREFIX + ADMIN)).value,
|
|
1056
|
+
`object ${seenAgain.rows[0].at}, KV mirror rewritten: ${!!(await kvGet(LASTSEEN_PREFIX + ADMIN)).value}`);
|
|
1057
|
+
await kvPut(LASTSEEN_PREFIX + ADMIN, state.lastSeenAt); // put the mirror back for clause 2
|
|
1058
|
+
|
|
1059
|
+
// The token both deployments will be asked about: minted through the admin route, so it
|
|
1060
|
+
// is in both stores, which is the only way "the two answers agree" means anything.
|
|
1061
|
+
state.shared = await mintVia(cookie, ONE);
|
|
1062
|
+
const gShared = await req(`/__invite?t=${encodeURIComponent(state.shared)}`);
|
|
1063
|
+
state.boundInvite = { status: gShared.status, names: gShared.text.includes(ONE) };
|
|
1064
|
+
const admin = await req("/__admin/users", { cookie });
|
|
1065
|
+
state.boundAdmin = JSON.parse(admin.text).users.find((u) => u.email === ADMIN);
|
|
1066
|
+
note(`bound: GET /__invite → ${gShared.status}; admin list lastSeen = ${state.boundAdmin.lastSeen}`);
|
|
1067
|
+
|
|
1068
|
+
// The object-only token for the revert clause, and the admin cookie the next two
|
|
1069
|
+
// deployments reuse. Minted now, stripped from KV after the unbound comparison.
|
|
1070
|
+
state.objectOnly = await mintVia(cookie, TWO);
|
|
1071
|
+
state.cookie = cookie;
|
|
1072
|
+
|
|
1073
|
+
// The three answers the unbound deployment has to reproduce, over the same endpoints.
|
|
1074
|
+
state.tokens = { scoped: tScoped, star: tStar };
|
|
1075
|
+
state.boundPublish = {
|
|
1076
|
+
scoped: await publishAs(tScoped, "one"),
|
|
1077
|
+
wrong: await publishAs(tScoped, "two"),
|
|
1078
|
+
star: await publishAs(tStar, "one"),
|
|
1079
|
+
};
|
|
1080
|
+
state.boundRoster = await adminList(cookie);
|
|
1081
|
+
note(`bound: publish one=${state.boundPublish.scoped.status} two=${state.boundPublish.wrong.status} star=${state.boundPublish.star.status}`);
|
|
1082
|
+
note(`bound roster: ${state.boundRoster.join(" · ")}`);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
async function unboundPhase() {
|
|
1086
|
+
clause("2 · bound and unbound return the same answer for the same workspace");
|
|
1087
|
+
const rt = await rehearse("/__rehearsal/runtime", {});
|
|
1088
|
+
check("this deployment binds no workspace object", rt.tenantsBound === false, JSON.stringify({ tenantsBound: rt.tenantsBound }));
|
|
1089
|
+
const g = await req(`/__invite?t=${encodeURIComponent(state.shared)}`);
|
|
1090
|
+
check("GET /__invite: same status and same invitee as the bound deployment",
|
|
1091
|
+
g.status === state.boundInvite.status && g.text.includes(ONE) === state.boundInvite.names,
|
|
1092
|
+
`bound ${state.boundInvite.status}/${state.boundInvite.names} · unbound ${g.status}/${g.text.includes(ONE)}`);
|
|
1093
|
+
const admin = await req("/__admin/users", { cookie: state.cookie });
|
|
1094
|
+
const me = JSON.parse(admin.text).users.find((u) => u.email === ADMIN);
|
|
1095
|
+
check("GET /__admin/users: the same lastSeen stamp for the same person",
|
|
1096
|
+
me.lastSeen === state.boundAdmin.lastSeen, `bound ${state.boundAdmin.lastSeen} · unbound ${me.lastSeen}`);
|
|
1097
|
+
|
|
1098
|
+
const pubOne = await publishAs(state.tokens.scoped, "one");
|
|
1099
|
+
const pubTwo = await publishAs(state.tokens.scoped, "two");
|
|
1100
|
+
const pubStar = await publishAs(state.tokens.star, "one");
|
|
1101
|
+
check("POST /__publish/one/check: the same verdict for the same space-scoped token",
|
|
1102
|
+
pubOne.status === state.boundPublish.scoped.status,
|
|
1103
|
+
`bound ${state.boundPublish.scoped.status} · unbound ${pubOne.status}`);
|
|
1104
|
+
check("…and the same REFUSAL at the space it is not scoped to",
|
|
1105
|
+
pubTwo.status === state.boundPublish.wrong.status && pubTwo.status === 403,
|
|
1106
|
+
`bound ${state.boundPublish.wrong.status} · unbound ${pubTwo.status}`);
|
|
1107
|
+
check("…and the same verdict for the STAR token, which was neither widened nor narrowed",
|
|
1108
|
+
pubStar.status === state.boundPublish.star.status,
|
|
1109
|
+
`bound ${state.boundPublish.star.status} · unbound ${pubStar.status}`);
|
|
1110
|
+
|
|
1111
|
+
const roster = await adminList(state.cookie);
|
|
1112
|
+
check("GET /__admin/users: the same roster, person for person, role for role, name for name",
|
|
1113
|
+
JSON.stringify(roster) === JSON.stringify(state.boundRoster),
|
|
1114
|
+
`bound ${state.boundRoster.join(" · ")}\nunbound ${roster.join(" · ")}`);
|
|
1115
|
+
|
|
1116
|
+
// Set up the revert clauses from here, where there is no object to touch by accident:
|
|
1117
|
+
// one invite left only in the object, a lastseen stamp left only in the object, one
|
|
1118
|
+
// publish token left only in the object, and one roster change left only in the object.
|
|
1119
|
+
const doc = await invitesDoc();
|
|
1120
|
+
delete doc[state.objectOnly];
|
|
1121
|
+
await kvPut(USER_INVITES_KEY, JSON.stringify(doc));
|
|
1122
|
+
await kvDel(LASTSEEN_PREFIX + ADMIN);
|
|
1123
|
+
check("one invite is now object-only, and the admin's lastseen stamp is object-only",
|
|
1124
|
+
!(await invitesDoc())[state.objectOnly] && !(await kvGet(LASTSEEN_PREFIX + ADMIN)).value);
|
|
1125
|
+
|
|
1126
|
+
const tokenMap = JSON.parse((await kvGet(PUBLISH_TOKENS_KEY)).value || "{}");
|
|
1127
|
+
delete tokenMap[publishHash(state.tokens.star)];
|
|
1128
|
+
await kvPut(PUBLISH_TOKENS_KEY, JSON.stringify(tokenMap));
|
|
1129
|
+
check("the STAR token is now object-only, and the space-scoped one is still in KV",
|
|
1130
|
+
!JSON.parse((await kvGet(PUBLISH_TOKENS_KEY)).value)[publishHash(state.tokens.star)]
|
|
1131
|
+
&& !!JSON.parse((await kvGet(PUBLISH_TOKENS_KEY)).value)[publishHash(state.tokens.scoped)],
|
|
1132
|
+
`KV holds ${Object.keys(JSON.parse((await kvGet(PUBLISH_TOKENS_KEY)).value)).length} token(s)`);
|
|
1133
|
+
|
|
1134
|
+
const rosterDoc = JSON.parse((await kvGet("users:roster")).value || '{"add":{},"remove":[]}');
|
|
1135
|
+
delete rosterDoc.add["kay@example.test"];
|
|
1136
|
+
await kvPut("users:roster", JSON.stringify(rosterDoc));
|
|
1137
|
+
check("the invitee is now object-only, while the ROLE overlay stays in both stores",
|
|
1138
|
+
!JSON.parse((await kvGet("users:roster")).value).add["kay@example.test"]
|
|
1139
|
+
&& JSON.parse((await kvGet("users:roles")).value)[ONE] === "admin");
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
async function revertedPhase() {
|
|
1143
|
+
clause("6 · the per-family revert, RUN on the real runtime");
|
|
1144
|
+
const rt = await rehearse("/__rehearsal/runtime", {});
|
|
1145
|
+
check("this deployment is bound, and reads invites from KV while lastseen stays on the object",
|
|
1146
|
+
rt.tenantsBound === true && rt.kvCutover.invites === false && rt.kvCutover.lastseen === true,
|
|
1147
|
+
JSON.stringify(rt.kvCutover));
|
|
1148
|
+
|
|
1149
|
+
// ── THE COLUMN MIGRATION, ON A COLD OBJECT, ON REAL WORKERD ───────────────────────────
|
|
1150
|
+
//
|
|
1151
|
+
// ⚠️ `CREATE TABLE IF NOT EXISTS` IS NOT A MIGRATION, and no clause above proves the part
|
|
1152
|
+
// that is: every object here was BUILT in today's shape, so `applySchemaAdditions` has
|
|
1153
|
+
// only ever been a no-op. So a column is dropped from a table that is warm in no isolate
|
|
1154
|
+
// — this deployment has only ever spoken to the object through the rehearsal's own SQL
|
|
1155
|
+
// verb, which does not `init()` — and then a REAL request is made, which does.
|
|
1156
|
+
const dropped = await sql(`ALTER TABLE members DROP COLUMN initials`);
|
|
1157
|
+
check("a column is dropped, putting the object back in an earlier version's shape", dropped.ok,
|
|
1158
|
+
JSON.stringify(dropped).slice(0, 160));
|
|
1159
|
+
const gone = await sql(`SELECT initials FROM members LIMIT 1`);
|
|
1160
|
+
check("and the object really cannot answer for it", gone.ok === false, JSON.stringify(gone).slice(0, 160));
|
|
1161
|
+
|
|
1162
|
+
// ── AND THE SAME DROP ON THE COLUMN THAT CARRIES AN AUTHORIZATION ─────────────────────
|
|
1163
|
+
//
|
|
1164
|
+
// `publish_tokens.caps` is what keeps a capability-restricted bearer restricted:
|
|
1165
|
+
// `capabilityRefusal` in src/_worker.js is deny-by-default over it, and it is what lets
|
|
1166
|
+
// the control plane hold a purge-only credential instead of a star token that could
|
|
1167
|
+
// publish over every workspace's content. The object had no such column, so a COPY of a
|
|
1168
|
+
// KV record landed here as an ordinary row and the narrow credential came back out of the
|
|
1169
|
+
// read as a FULL star token — a fail-open with no attacker in it.
|
|
1170
|
+
//
|
|
1171
|
+
// The star token here is OBJECT-ONLY (clause 5 deleted it from KV), so there is nothing to
|
|
1172
|
+
// fall through to and the object's own answer is the whole answer. Dropped, the read
|
|
1173
|
+
// cannot state the capability, and the ONLY safe verdict is a refusal.
|
|
1174
|
+
const capsGone = await sql(`ALTER TABLE publish_tokens DROP COLUMN caps`);
|
|
1175
|
+
check("the caps column is dropped too, in the shape a pre-column copy left behind",
|
|
1176
|
+
capsGone.ok, JSON.stringify(capsGone).slice(0, 160));
|
|
1177
|
+
const pBlind = await publishAs(state.tokens.star, "one");
|
|
1178
|
+
check("A TOKEN THE OBJECT CANNOT STATE A CAPABILITY FOR IS REFUSED, never widened",
|
|
1179
|
+
!pBlind.authorized, `${pBlind.status} ${pBlind.body}`);
|
|
1180
|
+
|
|
1181
|
+
await req("/__me", { cookie: state.cookie }); // the first request that reaches init()
|
|
1182
|
+
const back = await sql(`SELECT email, initials FROM members ORDER BY email`);
|
|
1183
|
+
check("a real request re-applied the schema and the column is BACK, rows intact",
|
|
1184
|
+
back.ok && back.rows.length > 0 && "initials" in back.rows[0],
|
|
1185
|
+
JSON.stringify(back.rows).slice(0, 220));
|
|
1186
|
+
const capsBack = await sql(`SELECT label, caps FROM publish_tokens ORDER BY label`);
|
|
1187
|
+
check("…and so is caps — one list, one pass, both columns",
|
|
1188
|
+
capsBack.ok && capsBack.rows.length > 0 && "caps" in capsBack.rows[0]
|
|
1189
|
+
&& capsBack.rows.every((r) => r.caps === null),
|
|
1190
|
+
JSON.stringify(capsBack.rows).slice(0, 220));
|
|
1191
|
+
const pStated = await publishAs(state.tokens.star, "one");
|
|
1192
|
+
check("the column is back and still UNSTATED, so the token is still refused — a column is not an answer",
|
|
1193
|
+
!pStated.authorized, `${pStated.status} ${pStated.body}`);
|
|
1194
|
+
// Back to where the mint left it. A real deployment heals on the next mint or from KV;
|
|
1195
|
+
// this run has neither for this token, and the clauses after this one are about other
|
|
1196
|
+
// families and must not inherit this one's damage.
|
|
1197
|
+
await sql(`UPDATE publish_tokens SET caps = 'null'`);
|
|
1198
|
+
const pHealed = await publishAs(state.tokens.star, "one");
|
|
1199
|
+
check("with the capability stated again the object answers exactly as it did before",
|
|
1200
|
+
pHealed.authorized, `${pHealed.status} ${pHealed.body}`);
|
|
1201
|
+
|
|
1202
|
+
const rowStillThere = await inviteRow(state.objectOnly);
|
|
1203
|
+
check("the object still HOLDS the object-only invite — the revert loses no data",
|
|
1204
|
+
!!rowStillThere, rowStillThere ? JSON.stringify(rowStillThere.row) : "gone");
|
|
1205
|
+
const g = await req(`/__invite?t=${encodeURIComponent(state.objectOnly)}`);
|
|
1206
|
+
check("and it is no longer answered — the object is not read for this family any more",
|
|
1207
|
+
g.status === 400, `${g.status}`);
|
|
1208
|
+
const gKv = await req(`/__invite?t=${encodeURIComponent(state.shared)}`);
|
|
1209
|
+
check("the invite KV still holds IS answered — KV answers again",
|
|
1210
|
+
gKv.status === 200 && gKv.text.includes(ONE), `${gKv.status}`);
|
|
1211
|
+
|
|
1212
|
+
const t8 = await mintVia(state.cookie, THREE);
|
|
1213
|
+
check("a new invite goes to KV", !!(await invitesDoc())[t8]);
|
|
1214
|
+
check("and NOT to the object", (await inviteRow(t8)) === null);
|
|
1215
|
+
const p8 = await req("/__invite", form({ token: t8, password: PASSWORD }));
|
|
1216
|
+
check("and redeems over the KV path", p8.status === 303 && !!p8.setCookie, `${p8.status}`);
|
|
1217
|
+
|
|
1218
|
+
check("KV still holds no lastseen stamp for the admin", !(await kvGet(LASTSEEN_PREFIX + ADMIN)).value);
|
|
1219
|
+
const admin = await req("/__admin/users", { cookie: state.cookie });
|
|
1220
|
+
const me = JSON.parse(admin.text).users.find((u) => u.email === ADMIN);
|
|
1221
|
+
check("yet the admin list still shows one — lastseen did NOT come back with invites",
|
|
1222
|
+
me.lastSeen === state.lastSeenAt, `object ${state.lastSeenAt} · served ${me.lastSeen}`);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
async function revertedTokensPhase() {
|
|
1226
|
+
clause("6b · reverting `publishTokens` ALONE, RUN on the real runtime");
|
|
1227
|
+
const rt = await rehearse("/__rehearsal/runtime", {});
|
|
1228
|
+
check("this deployment is bound, and reads publish tokens from KV while the rest stay on the object",
|
|
1229
|
+
rt.tenantsBound === true && rt.kvCutover.publishTokens === false
|
|
1230
|
+
&& rt.kvCutover.invites === true && rt.kvCutover.lastseen === true && rt.kvCutover.roster === true,
|
|
1231
|
+
JSON.stringify(rt.kvCutover));
|
|
1232
|
+
|
|
1233
|
+
const row = await sql(`SELECT scope FROM publish_tokens WHERE token_hash = ?`, [publishHash(state.tokens.star)]);
|
|
1234
|
+
check("the object still HOLDS the object-only token — the revert loses no data",
|
|
1235
|
+
row.ok && row.rows.length === 1 && row.rows[0].scope === "*", JSON.stringify(row.rows));
|
|
1236
|
+
const star = await publishAs(state.tokens.star, "one");
|
|
1237
|
+
check("and it is no longer answered — the object is not read for this family any more",
|
|
1238
|
+
!star.authorized, `${star.status} ${star.body}`);
|
|
1239
|
+
|
|
1240
|
+
const scoped = await publishAs(state.tokens.scoped, "one");
|
|
1241
|
+
check("the token KV still holds IS answered — KV answers again, at its own scope",
|
|
1242
|
+
scoped.authorized, `${scoped.status} ${scoped.body}`);
|
|
1243
|
+
const wrong = await publishAs(state.tokens.scoped, "two");
|
|
1244
|
+
check("…and still refused elsewhere: the scope came back from KV unchanged",
|
|
1245
|
+
!wrong.authorized, `${wrong.status} ${wrong.body}`);
|
|
1246
|
+
|
|
1247
|
+
const fresh = await mintToken(state.cookie, "one", "after-the-revert");
|
|
1248
|
+
const freshRow = await sql(`SELECT token_hash FROM publish_tokens WHERE token_hash = ?`, [publishHash(fresh)]);
|
|
1249
|
+
check("a new token goes to KV and NOT to the object",
|
|
1250
|
+
(await publishAs(fresh, "one")).authorized && freshRow.ok && freshRow.rows.length === 0,
|
|
1251
|
+
`object rows: ${freshRow.rows && freshRow.rows.length}`);
|
|
1252
|
+
|
|
1253
|
+
const roster = await adminList(state.cookie);
|
|
1254
|
+
check("the ROSTER did not come back with it — the invitee KV no longer names is still served",
|
|
1255
|
+
roster.some((l) => l.startsWith("kay@example.test/")), roster.join(" · "));
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
async function revertedRosterPhase() {
|
|
1259
|
+
clause("6c · reverting `roster` ALONE, RUN on the real runtime");
|
|
1260
|
+
const rt = await rehearse("/__rehearsal/runtime", {});
|
|
1261
|
+
check("this deployment is bound, and reads the roster from KV while the rest stay on the object",
|
|
1262
|
+
rt.tenantsBound === true && rt.kvCutover.roster === false
|
|
1263
|
+
&& rt.kvCutover.invites === true && rt.kvCutover.lastseen === true && rt.kvCutover.publishTokens === true,
|
|
1264
|
+
JSON.stringify(rt.kvCutover));
|
|
1265
|
+
|
|
1266
|
+
const row = await sql(`SELECT email, source FROM members WHERE email = ?`, ["kay@example.test"]);
|
|
1267
|
+
check("the object still HOLDS the object-only member — the revert loses no data",
|
|
1268
|
+
row.ok && row.rows.length === 1 && row.rows[0].source === "overlay", JSON.stringify(row.rows));
|
|
1269
|
+
|
|
1270
|
+
const roster = await adminList(state.cookie);
|
|
1271
|
+
check("and they are no longer served — the object is not read for this family any more",
|
|
1272
|
+
!roster.some((l) => l.startsWith("kay@example.test/")), roster.join(" · "));
|
|
1273
|
+
check("the roster KV still holds IS answered — the role overlay still promotes them",
|
|
1274
|
+
roster.some((l) => l.startsWith(`${ONE}/admin/`)), roster.join(" · "));
|
|
1275
|
+
|
|
1276
|
+
const token = await mintVia(state.cookie, THREE);
|
|
1277
|
+
const stillObject = await inviteRow(token);
|
|
1278
|
+
check("INVITES did not come back with it: a new one still goes to the object",
|
|
1279
|
+
!!stillObject, stillObject ? JSON.stringify(stillObject.row) : "no row");
|
|
1280
|
+
const tokenAuth = await publishAs(state.tokens.star, "one");
|
|
1281
|
+
check("and PUBLISH TOKENS did not either: the object-only star token is answered again",
|
|
1282
|
+
tokenAuth.authorized, `${tokenAuth.status} ${tokenAuth.body}`);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// ---- run --------------------------------------------------------------------
|
|
1286
|
+
|
|
1287
|
+
async function main() {
|
|
1288
|
+
console.log(`tenant-do-rehearsal — real workerd, real Durable Object, port ${PORT}`);
|
|
1289
|
+
console.log(` generated tree: ${WORK}`);
|
|
1290
|
+
await generate();
|
|
1291
|
+
|
|
1292
|
+
let dep = null;
|
|
1293
|
+
try {
|
|
1294
|
+
dep = await boot("bound", "bound"); await boundPhase(); await shutdown(dep); dep = null;
|
|
1295
|
+
dep = await boot("unbound", "unbound"); await unboundPhase(); await shutdown(dep); dep = null;
|
|
1296
|
+
dep = await boot("reverted-invites", "reverted (KV_CUTOVER.invites = false)");
|
|
1297
|
+
await revertedPhase(); await shutdown(dep); dep = null;
|
|
1298
|
+
dep = await boot("reverted-publishTokens", "reverted (KV_CUTOVER.publishTokens = false)");
|
|
1299
|
+
await revertedTokensPhase(); await shutdown(dep); dep = null;
|
|
1300
|
+
dep = await boot("reverted-roster", "reverted (KV_CUTOVER.roster = false)");
|
|
1301
|
+
await revertedRosterPhase();
|
|
1302
|
+
} finally {
|
|
1303
|
+
await shutdown(dep);
|
|
1304
|
+
if (!KEEP) fs.rmSync(WORK, { recursive: true, force: true });
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
const failed = results.filter((r) => !r.ok);
|
|
1308
|
+
console.log(`\n${"═".repeat(78)}`);
|
|
1309
|
+
console.log(`${results.length - failed.length}/${results.length} checks passed`);
|
|
1310
|
+
for (const f of failed) console.log(` FAILED [${f.clause}] ${f.what}`);
|
|
1311
|
+
process.exit(failed.length ? 1 : 0);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
main().catch((e) => { console.error(`\nrehearsal aborted: ${(e && e.stack) || e}`); process.exit(1); });
|