@augurworks/augur 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/CANVAS.md +858 -0
  2. package/INSTALL.md +603 -0
  3. package/LICENSE +27 -0
  4. package/README.md +150 -0
  5. package/TRADEMARKS.md +72 -0
  6. package/agents/README.md +65 -0
  7. package/agents/canon-extract.md +117 -0
  8. package/agents/canon.md +151 -0
  9. package/agents/canvas.md +88 -0
  10. package/agents/currency.md +74 -0
  11. package/agents/drafts.md +92 -0
  12. package/agents/identity.md +148 -0
  13. package/agents/prototype-contract.md +69 -0
  14. package/agents/publishing.md +324 -0
  15. package/agents/review-feedback.md +76 -0
  16. package/agents/space-json.md +132 -0
  17. package/agents/ui-skill.md +101 -0
  18. package/agents/working-marks.md +86 -0
  19. package/brand/augur-eye.svg +3 -0
  20. package/brand/augur-icon.svg +41 -0
  21. package/brand/augur-mark.svg +5 -0
  22. package/brand/lentianova-bulky.otf +0 -0
  23. package/brand/lentianova-median.otf +0 -0
  24. package/brand/lentianova.woff2 +0 -0
  25. package/build.js +8409 -0
  26. package/changelog.md +133 -0
  27. package/fonts/inter-latin-wght-normal.woff2 +0 -0
  28. package/package.json +57 -0
  29. package/pitis/PETS.md +121 -0
  30. package/pitis/README.md +117 -0
  31. package/pitis/index.html +266 -0
  32. package/pitis/piti.build.js +81 -0
  33. package/pitis/piti.js +929 -0
  34. package/pitis/roast-agent.md +185 -0
  35. package/realtime/src/index.js +119 -0
  36. package/realtime/wrangler.example.toml +44 -0
  37. package/scripts/adopt.mjs +106 -0
  38. package/scripts/board-snapshot.mjs +197 -0
  39. package/scripts/build-seed-pack.mjs +33 -0
  40. package/scripts/bundle-rekey.mjs +112 -0
  41. package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
  42. package/scripts/canon-extract.mjs +578 -0
  43. package/scripts/canon.mjs +599 -0
  44. package/scripts/canvas-screen.mjs +333 -0
  45. package/scripts/check-local.mjs +136 -0
  46. package/scripts/clawd-art.mjs +75 -0
  47. package/scripts/clawd-canvas.mjs +741 -0
  48. package/scripts/cli.mjs +100 -0
  49. package/scripts/clone.mjs +422 -0
  50. package/scripts/close.mjs +30 -0
  51. package/scripts/connect.mjs +103 -0
  52. package/scripts/deploy.mjs +157 -0
  53. package/scripts/dev.mjs +54 -0
  54. package/scripts/doc-lint.mjs +143 -0
  55. package/scripts/export.mjs +300 -0
  56. package/scripts/fork.mjs +64 -0
  57. package/scripts/freeze.mjs +74 -0
  58. package/scripts/frontdoor-parity.mjs +180 -0
  59. package/scripts/hook.mjs +67 -0
  60. package/scripts/identity-rekey.mjs +112 -0
  61. package/scripts/init.mjs +122 -0
  62. package/scripts/land.mjs +40 -0
  63. package/scripts/lib/adapters.mjs +156 -0
  64. package/scripts/lib/board-snapshot.mjs +511 -0
  65. package/scripts/lib/draft.mjs +385 -0
  66. package/scripts/lib/graduate.mjs +276 -0
  67. package/scripts/lib/instance.mjs +74 -0
  68. package/scripts/lib/marks.mjs +107 -0
  69. package/scripts/lib/materialize.mjs +138 -0
  70. package/scripts/lib/merge3.mjs +124 -0
  71. package/scripts/lib/offline-posture.mjs +120 -0
  72. package/scripts/lib/offline-respawn.mjs +18 -0
  73. package/scripts/lib/offline-wrangler.mjs +78 -0
  74. package/scripts/lib/publish-compose.mjs +12 -0
  75. package/scripts/lib/publish-conflict.mjs +78 -0
  76. package/scripts/lib/publish-evidence.mjs +144 -0
  77. package/scripts/lib/refine-compare.mjs +140 -0
  78. package/scripts/lib/refine-ledger.mjs +150 -0
  79. package/scripts/lib/refine-png.mjs +149 -0
  80. package/scripts/lib/seed-pack-build.mjs +141 -0
  81. package/scripts/lib/state-compare.mjs +68 -0
  82. package/scripts/lib/store.mjs +122 -0
  83. package/scripts/login.mjs +84 -0
  84. package/scripts/mark.mjs +112 -0
  85. package/scripts/migrate-board-keys.mjs +164 -0
  86. package/scripts/migrate.mjs +265 -0
  87. package/scripts/no-foreign-vocabulary.mjs +216 -0
  88. package/scripts/no-product-names.mjs +157 -0
  89. package/scripts/no-tenant-globals.mjs +1134 -0
  90. package/scripts/offline.mjs +247 -0
  91. package/scripts/og.mjs +170 -0
  92. package/scripts/one-tenant-resolver.mjs +146 -0
  93. package/scripts/open.mjs +63 -0
  94. package/scripts/publish.mjs +1041 -0
  95. package/scripts/read.mjs +31 -0
  96. package/scripts/refine.mjs +455 -0
  97. package/scripts/release-drift.mjs +138 -0
  98. package/scripts/restore.mjs +322 -0
  99. package/scripts/review.mjs +125 -0
  100. package/scripts/save.mjs +21 -0
  101. package/scripts/shell-lint.mjs +189 -0
  102. package/scripts/ship.mjs +460 -0
  103. package/scripts/shoot.mjs +264 -0
  104. package/scripts/smoke-invite.mjs +113 -0
  105. package/scripts/state-inventory.mjs +223 -0
  106. package/scripts/status.mjs +177 -0
  107. package/scripts/sync.mjs +27 -0
  108. package/scripts/tenant-do-rehearsal.mjs +1314 -0
  109. package/scripts/ui-copy-lint.mjs +202 -0
  110. package/scripts/watch.mjs +32 -0
  111. package/scripts/wrangler-preflight.mjs +246 -0
  112. package/seed/CANON.md +43 -0
  113. package/seed/README.md +119 -0
  114. package/seed/prototype-status.json +9 -0
  115. package/seed/registry.json +88 -0
  116. package/seed/skills/starter-ui/SKILL.md +52 -0
  117. package/seed/skills/starter-ui/skill.json +11 -0
  118. package/seed/skills/starter-ui/starter-tokens.css +73 -0
  119. package/seed/skills/starter-ui/starter-ui.css +318 -0
  120. package/seed/skills/starter-ui/starter-ui.js +50 -0
  121. package/seed/space.json +5 -0
  122. package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
  123. package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
  124. package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
  125. package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
  126. package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
  127. package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
  128. package/seed/threads.json +52 -0
  129. package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
  130. package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
  131. package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
  132. package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
  133. package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
  134. package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
  135. package/src/_worker.js +12668 -0
  136. package/src/board-key.mjs +57 -0
  137. package/src/board-room.mjs +881 -0
  138. package/src/bundle-keys.mjs +138 -0
  139. package/src/canon/collect-in-browser.js +215 -0
  140. package/src/canon/emit.mjs +232 -0
  141. package/src/canon/observe.mjs +260 -0
  142. package/src/canon/schema.mjs +451 -0
  143. package/src/canvas/DSEG-LICENSE.txt +95 -0
  144. package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
  145. package/src/canvas/canvas.css +885 -0
  146. package/src/canvas/canvas.js +5780 -0
  147. package/src/canvas/capture.js +485 -0
  148. package/src/chrome/appchrome.mjs +568 -0
  149. package/src/currency.mjs +203 -0
  150. package/src/delete-confirmation.mjs +254 -0
  151. package/src/drafts/drafts.js +326 -0
  152. package/src/entry.js +58 -0
  153. package/src/galleries.mjs +400 -0
  154. package/src/health-cron.mjs +173 -0
  155. package/src/identity.json +1 -0
  156. package/src/kv-codec.mjs +135 -0
  157. package/src/kv-identity.mjs +259 -0
  158. package/src/mail.mjs +599 -0
  159. package/src/provenance.mjs +74 -0
  160. package/src/publish-compose.mjs +363 -0
  161. package/src/publish-fork.mjs +221 -0
  162. package/src/publish-units.mjs +44 -0
  163. package/src/purge.mjs +81 -0
  164. package/src/review/COMMENTING-UX.md +306 -0
  165. package/src/review/cat.png +0 -0
  166. package/src/review/comment-cursor.svg +7 -0
  167. package/src/review/comments.js +1738 -0
  168. package/src/room-ticket.mjs +96 -0
  169. package/src/roster-chip.mjs +32 -0
  170. package/src/seed-pack.mjs +261 -0
  171. package/src/state-inventory.mjs +249 -0
  172. package/src/sw-logic.mjs +48 -0
  173. package/src/tenant-cache.mjs +141 -0
  174. package/src/tenant-context.mjs +386 -0
  175. package/src/tenant-do.js +2940 -0
  176. package/src/tenant-host.mjs +144 -0
  177. package/src/tenant-quotas.mjs +140 -0
  178. package/src/unit-core.mjs +103 -0
  179. package/src/unit-object.mjs +338 -0
  180. package/templates/README.md +67 -0
  181. package/templates/shell/deploy.yml +145 -0
  182. package/templates/shell/engine-bump.yml +142 -0
  183. package/templates/shell/health.yml +233 -0
  184. package/templates/shell/kv-backup.yml +201 -0
  185. package/templates/shell/roster-update.yml +110 -0
  186. package/templates/shell/space-preflight.yml +35 -0
  187. package/templates/shell/space-rebake.yml +79 -0
  188. package/templates/shell/store-backup.yml +152 -0
  189. package/templates/shell/wrangler.example.toml +266 -0
  190. package/templates/space/publish.yml +91 -0
@@ -0,0 +1,2940 @@
1
+ // The workspace Durable Object: everything mutable a workspace owns, in its own storage.
2
+ //
3
+ // `B-do-schema-core`. Today these live as flat KV documents in one namespace shared by
4
+ // every workspace an isolate serves — `users:roster`, `users:roles`, `users:names`,
5
+ // `users:avatars`, `users:invites`, `publish:tokens`, `users:lastseen:<email>`,
6
+ // `spaces:icons`. That is the second axis of the isolation problem, and the one keying the
7
+ // per-request caches did NOT close: an isolate serving two workspaces through one binding
8
+ // answers a neighbour's roster even with every memo cold.
9
+ //
10
+ // ONE DURABLE OBJECT PER WORKSPACE MAKES THAT STRUCTURAL. A DO's storage belongs to that
11
+ // object; there is no key you can construct in workspace A that reads workspace B, because
12
+ // there is no shared namespace to construct it in. The isolation stops being a rule about
13
+ // key naming that reviewers have to hold, and becomes a property of where the bytes live.
14
+ //
15
+ // ⛔ THE CREDENTIAL DOES NOT COME HERE, and that is the load-bearing omission.
16
+ // `users:secrets` holds password hashes. Under the settled architecture a credential is
17
+ // ACCOUNT-level — one address, one password, several workspaces — so it belongs to the
18
+ // control plane's account store. This object is the authority on MEMBERSHIP and ROLE and on
19
+ // nothing else about identity.
20
+ //
21
+ // The reason is not tidiness. A workspace that could reset a credential could reach every
22
+ // other workspace that address opens: a workspace admin would silently become an admin of
23
+ // their colleague's unrelated workspace, by resetting a password they share. The schema
24
+ // below therefore contains no password and no hash of one —
25
+ // `test/tenant-do.test.mjs` asserts that by reading the tables SQLite actually built, so
26
+ // the claim is checked rather than promised.
27
+ //
28
+ // THE ONE SECRET THAT IS HERE, NAMED RATHER THAN SMUGGLED: `signing_keys`. A workspace's
29
+ // session cookies are HMACs, and the key they sign with has to live SOMEWHERE. Today it is
30
+ // `env.SESSION_SECRET`, one value for the whole Worker — which is fine while a Worker
31
+ // serves one workspace and forgeable across every workspace the moment it serves several:
32
+ // anyone holding it could mint a valid cookie for a neighbour. Per-workspace is the fix,
33
+ // and a per-workspace key can only live in the per-workspace store.
34
+ //
35
+ // The rule this does not break: NOTHING HERE AUTHENTICATES A PERSON. A password is a
36
+ // person's, reused across workspaces, and reachable by whoever administers any one of
37
+ // them — that is what must never be here. A signing key is the workspace's own, used
38
+ // nowhere else, and the blast radius of reading it is exactly the workspace whose storage
39
+ // it was read from. `test/tenant-provisioning.test.mjs` pins that distinction rather than
40
+ // leaving it to a reader's judgement.
41
+ //
42
+ // WHAT IS HERE AND WHY IT LOOKS SIMPLER THAN THE KV IT REPLACES. `users:spaces` mapped an
43
+ // address to a role PER SPACE, because one deployment used to mount several. A workspace is
44
+ // now the only tier, so a member has one role in this workspace and the table has one
45
+ // column for it. Anyone reinstating a per-space role should put it in the membership model
46
+ // rather than resurrect the map.
47
+
48
+ import { PLANS, DEFAULT_PLAN, QUOTA_FIELDS, quotasForPlan } from "./tenant-quotas.mjs";
49
+ import { purgeThreads, personIdFor, idCollisions } from "./purge.mjs";
50
+ import { deleteConfirmation, backupRetentionFromEnv } from "./delete-confirmation.mjs";
51
+ import { tenantLabelFromHost, normalizeHost, parseReservedLabels } from "./tenant-host.mjs";
52
+ import { nameFromEmail, initialsFor, colorFor } from "./roster-chip.mjs";
53
+ // The seed pack a fresh workspace is furnished with at provisioning, and the store key shape
54
+ // it lands under — the worker's own module, so the object writes exactly where the front
55
+ // door reads. See src/seed-pack.mjs for the order and the failure it is shaped around.
56
+ import { bundleStore } from "./bundle-keys.mjs";
57
+ import { loadSeedPack, publishSeedPack, seedOverlayFrom, workspaceOrigin } from "./seed-pack.mjs";
58
+
59
+ // 1 → 2: `B-kv-read-cutover`'s second slice. `publish_tokens` gained `scope`, and `members`
60
+ // gained the columns that let the roster documents be READ back rather than inferred — see
61
+ // `TENANT_SCHEMA_ADDITIONS` for why each one exists and what was wrong without it.
62
+ //
63
+ // 2 → 3: `publish_tokens` gained `caps`, the second half of the same record. Without it a
64
+ // COPY of a capability-restricted token — the control plane's purge-only bearer — landed
65
+ // here as an ordinary row, and since this object is what the request path reads FIRST, the
66
+ // narrow credential came back out of it as a full star token. A missing column read as "no
67
+ // restriction", which is the one reading a deny-by-default capability may never be given.
68
+ //
69
+ // 3 → 4: `members` gained `first_publish_at` — the per-person half of the onboarding
70
+ // completion signal (`C-first-publish-signal`). The workspace's own half is a `meta` row,
71
+ // which needs no column. Nullable and additive like every other addition: a row that
72
+ // predates it reads as "never published from here", which is the honest answer for a
73
+ // member nobody has watched publish.
74
+ export const TENANT_SCHEMA_VERSION = 4;
75
+
76
+ /**
77
+ * The schema, as a list of statements so a migration can apply them one at a time and a
78
+ * test can execute them against a real SQLite engine rather than eyeball them.
79
+ *
80
+ * Every timestamp is an ISO-8601 string rather than an epoch integer: these rows are read
81
+ * by people during incidents, and a number nobody can read at 3am is a number that gets
82
+ * misread.
83
+ */
84
+ export const TENANT_SCHEMA = Object.freeze([
85
+ // Who belongs to this workspace, what they may do, and how they appear.
86
+ // Merges users:roster, users:roles, users:names and users:avatars, which were four
87
+ // documents describing one thing and drifting independently.
88
+ //
89
+ // ⚠️ THE DURABLE HALF AND THE OVERLAY HALF ARE DIFFERENT COLUMNS, and that is not
90
+ // tidiness. `identity.json` names a person and the KV overlay changes what they are
91
+ // called; the serving path needs to know WHICH said what, because `applyNames` DROPS a
92
+ // config-set `initials` when there is a name override and keeps it when there is not.
93
+ // One merged column cannot answer both, so a cut that merged them would serve one person
94
+ // as two — see `KV_CUTOVER` in src/_worker.js.
95
+ //
96
+ // name / role / initials / colour / added_by — the DURABLE record: the config file's
97
+ // values, or the invitation's for somebody the file does not name yet.
98
+ // name_overlay / role_overlay / avatar_* — the OVERLAY: `users:names`,
99
+ // `users:roles`, `users:avatars`, each of which a person or an admin set after the
100
+ // build, and each of which reverts to the column above it when cleared.
101
+ // source — 'config' or 'overlay': where the
102
+ // MEMBERSHIP came from, which is what tells a `users:roster` `add` entry from a row
103
+ // the file already named. Reconstructing it by inference is exactly the guess this
104
+ // column exists to refuse. ⚠️ ONLY 'overlay' ROWS ARE SERVED FROM HERE: `rosterRead`
105
+ // emits them into `add`, and a 'config' row is the mirror of a file the serving
106
+ // path reads for itself. A membership that came from no file — the first admin
107
+ // `applyProvisioning` writes — is therefore 'overlay', or it is invisible.
108
+ `CREATE TABLE IF NOT EXISTS members (
109
+ email TEXT PRIMARY KEY,
110
+ role TEXT NOT NULL CHECK (role IN ('admin','editor','viewer')),
111
+ name TEXT,
112
+ avatar_key TEXT,
113
+ avatar_mime TEXT,
114
+ avatar_at TEXT,
115
+ added_at TEXT NOT NULL,
116
+ removed_at TEXT,
117
+ initials TEXT,
118
+ colour TEXT,
119
+ source TEXT,
120
+ added_by TEXT,
121
+ name_overlay TEXT,
122
+ role_overlay TEXT,
123
+ first_publish_at TEXT
124
+ )`,
125
+ // A REMOVED member is a tombstone, never a deleted row. The KV design learned this the
126
+ // hard way on the credential side: a removal that merely deletes is undone by any
127
+ // fallback, and re-inviting an address must not inherit the last person's role.
128
+ `CREATE INDEX IF NOT EXISTS members_active ON members (removed_at)`,
129
+
130
+ // Outstanding invitations. Only the HASH of the token is stored, so a read of this
131
+ // storage — a backup, an export, an operator looking — cannot redeem anybody's invite.
132
+ `CREATE TABLE IF NOT EXISTS invites (
133
+ token_hash TEXT PRIMARY KEY,
134
+ email TEXT NOT NULL,
135
+ created_at TEXT NOT NULL,
136
+ expires_at TEXT NOT NULL,
137
+ created_by TEXT
138
+ )`,
139
+ `CREATE INDEX IF NOT EXISTS invites_email ON invites (email)`,
140
+
141
+ // Publish tokens for THIS workspace. Hash only, same reasoning as invites.
142
+ // `expires_at` is nullable because tokens minted before expiry existed have none, and
143
+ // the check that reads it is additive on purpose.
144
+ //
145
+ // ⚠️ `scope` IS THE AUTHORIZATION AND NOT A LABEL. It carries KV's `space` VERBATIM:
146
+ // a space id means that space and `*` means every one of them, which is
147
+ // admin-equivalent because a star token pushes instance config — the user list itself.
148
+ // `publishAuthDetailed` in src/_worker.js refuses `wrong-space` on this value, so a
149
+ // copy that dropped it would either widen every space-scoped token to star or refuse
150
+ // every token, and neither is visible until somebody publishes.
151
+ //
152
+ // NULLABLE, and the null is load-bearing: it means "a copy wrote this row before the
153
+ // column existed and does not know". The read treats such a row as no answer at all and
154
+ // falls through to KV, which still holds the scope — a token is never widened by a
155
+ // missing value and never refused for one.
156
+ //
157
+ // ⚠️ `caps` IS THE OTHER HALF OF THE AUTHORIZATION AND IS NOT A LABEL EITHER. KV records
158
+ // an optional `caps` array on a token, and `capabilityRefusal` in src/_worker.js reads it
159
+ // deny-by-default: absent means unrestricted, a list means ONLY what those names grant,
160
+ // and an unknown name grants nothing. It is what lets the control plane hold a purge-only
161
+ // bearer instead of a star token that could publish over every workspace's content.
162
+ //
163
+ // Stored as the JSON of the value KV holds, so the two stores spell the same record: the
164
+ // text `null` is a token that carries NO `caps` field (unrestricted, and this object knows
165
+ // it), and `["purge"]` is a restricted one. SQL NULL is neither — see below.
166
+ //
167
+ // NULLABLE, and the null is load-bearing for the same reason `scope`'s is, with a sharper
168
+ // consequence: it means "a copy wrote this row before the column existed and does not
169
+ // know". The read treats such a row as no answer at all and falls through to KV, which
170
+ // still holds the field. Reading it as "no caps field, therefore unrestricted" is exactly
171
+ // how a narrow credential became a full one, and it is the reading a fresh mint avoids by
172
+ // writing `null` as text rather than leaving the column empty.
173
+ `CREATE TABLE IF NOT EXISTS publish_tokens (
174
+ token_hash TEXT PRIMARY KEY,
175
+ label TEXT,
176
+ created_at TEXT NOT NULL,
177
+ expires_at TEXT,
178
+ scope TEXT,
179
+ caps TEXT
180
+ )`,
181
+
182
+ // Last connection, shown in the admin list. Its own table rather than a column on
183
+ // members: it is written on a completely different cadence from everything else about a
184
+ // person, and a write here must not touch a row that carries their role.
185
+ `CREATE TABLE IF NOT EXISTS lastseen (
186
+ email TEXT PRIMARY KEY,
187
+ at TEXT NOT NULL
188
+ )`,
189
+
190
+ // Small content-addressed blobs: profile photos and the workspace icon, which are
191
+ // base64 data URLs today. Canvas images are NOT here — they are large and binary and go
192
+ // to shared R2 (B-migrate-canvas-assets-to-r2); putting megabytes in DO storage would
193
+ // make every cold start pay for them.
194
+ `CREATE TABLE IF NOT EXISTS blobs (
195
+ key TEXT PRIMARY KEY,
196
+ mime TEXT,
197
+ body TEXT NOT NULL,
198
+ at TEXT NOT NULL
199
+ )`,
200
+
201
+ // The workspace's own settings that are not derivable from a build: today just the icon
202
+ // pointer. A key/value table rather than a wide row, because the alternative is a
203
+ // migration every time a workspace grows a preference.
204
+ `CREATE TABLE IF NOT EXISTS settings (
205
+ k TEXT PRIMARY KEY,
206
+ v TEXT
207
+ )`,
208
+
209
+ // What this workspace is allowed: seeded at provisioning from src/tenant-quotas.mjs and
210
+ // read by every enforcement point, so raising a limit for one customer is a row and not
211
+ // a deploy. Separate from `settings` because these are not preferences — nobody in the
212
+ // workspace may change them — and a table an admin UI writes to must not be the table a
213
+ // ceiling is read from.
214
+ //
215
+ // A key/value shape for the same reason as settings, and one more: a quota added later
216
+ // is an INSERT, while a column added later is a migration on every workspace that
217
+ // exists. `n` is a number, never null — see QUOTA_FIELDS for why unlimited is a large
218
+ // number rather than an absent one.
219
+ `CREATE TABLE IF NOT EXISTS quotas (
220
+ k TEXT PRIMARY KEY,
221
+ n REAL NOT NULL
222
+ )`,
223
+
224
+ // The content overlay: everything the site remembers ABOUT published content rather than
225
+ // in it. Four families today — `statuses` (a prototype's dev status), `names` (a card's
226
+ // display-name override), `canvases` (boards created from a folder index) and `pins` (a
227
+ // person's sidebar) — each of which was a single KV document holding the whole map.
228
+ //
229
+ // ONE ROW PER KEY IS THE POINT. A whole-map document is read, mutated and written back,
230
+ // so two edits to DIFFERENT keys landing together lose one of them: the second write is
231
+ // computed from a map that predates the first. Nobody sees an error; a status simply
232
+ // does not stick, and the person clicks it again. Per-key rows make concurrent edits to
233
+ // different keys independent by construction.
234
+ //
235
+ // `scope` is for the one family that is per person: pins. Empty for the rest. It is a
236
+ // column rather than four tables because a fifth family should be an INSERT and not a
237
+ // migration on every workspace that exists.
238
+ //
239
+ // `v` is TEXT holding JSON, not a typed column: `statuses` stores a word and `canvases`
240
+ // stores an object, and a schema that tried to be both would be a schema that is neither.
241
+ //
242
+ // `rev` is what makes a read-modify-write safe for the families whose value is a
243
+ // DOCUMENT rather than a scalar — a page's comment threads, a board's nodes. Those are
244
+ // read, changed and written back by the worker, so per-key rows alone do not help: two
245
+ // edits to ONE key still lose each other. The writer sends back the rev it read, the
246
+ // update matches on it, and a mismatch is a retry rather than a silent overwrite.
247
+ //
248
+ // `owner` and `acl` are the DATA BASIS for per-resource permissions and nothing else —
249
+ // nothing reads them to decide anything yet, deliberately. `owner` is stamped from the
250
+ // authenticated caller AT ROW CREATION and never from a request body, and never moved by
251
+ // a later writer: a board is owned by whoever made it, not by whoever last saved it.
252
+ // `acl` is a JSON email→role map; absent means "no per-resource restriction, the
253
+ // workspace role decides", which is the narrowing-only default the rest of the
254
+ // permission model already uses.
255
+ //
256
+ // They are here rather than in a table of their own because the alternative is a join on
257
+ // every read of every board, comment thread and status row for a check that does not
258
+ // exist yet. Two nullable columns cost nothing until something reads them.
259
+ `CREATE TABLE IF NOT EXISTS overlay (
260
+ family TEXT NOT NULL,
261
+ scope TEXT NOT NULL DEFAULT '',
262
+ k TEXT NOT NULL,
263
+ v TEXT NOT NULL,
264
+ rev INTEGER NOT NULL DEFAULT 0,
265
+ at TEXT NOT NULL,
266
+ owner TEXT,
267
+ acl TEXT,
268
+ PRIMARY KEY (family, scope, k)
269
+ )`,
270
+
271
+ // Rate and volume counters, one row per thing being counted, reset by window rather than
272
+ // by a sweep. `window` is the bucket the count belongs to — an ISO minute for a rate, an
273
+ // ISO day for a volume — so a new window is a comparison rather than a job that has to
274
+ // run, and a counter for a window nobody is in costs one row until the next bump.
275
+ //
276
+ // A REFUSED REQUEST STILL COUNTS. The increment and the verdict are one statement, so a
277
+ // caller cannot be told "no" without having been counted — which means hammering a
278
+ // ceiling does not get you more than pacing yourself does, and the window still ends when
279
+ // it ends.
280
+ `CREATE TABLE IF NOT EXISTS counters (
281
+ k TEXT PRIMARY KEY,
282
+ window TEXT NOT NULL,
283
+ n REAL NOT NULL
284
+ )`,
285
+
286
+ // The publish counter, per space. R2 keeps the payloads — the blobs, `versions/<n>.json`,
287
+ // the manifest — and this table is the sole ISSUER of the next number.
288
+ //
289
+ // It exists because R2 has no compare-and-swap and the store's own counter is read,
290
+ // incremented and written back by the client of it: two commits landing together both
291
+ // compute the same next number, and the second PUT overwrites the first's
292
+ // `versions/<n>.json` — destroying a point in the history that recovery depends on,
293
+ // silently, while both publishes report success. A Durable Object is single-threaded, so
294
+ // the increment here cannot interleave.
295
+ //
296
+ // `version` is what was last ISSUED, not what is live. The two differ for as long as a
297
+ // commit is in flight, and after a failed one they differ forever — a number is burned
298
+ // rather than reused, which is the same trade the rollback path already makes for the
299
+ // same reason: reusing one means overwriting a version that exists.
300
+ `CREATE TABLE IF NOT EXISTS publish_versions (
301
+ space TEXT PRIMARY KEY,
302
+ version INTEGER NOT NULL
303
+ )`,
304
+
305
+ // The workspace's own signing keys — today exactly one, `session`, the HMAC key its
306
+ // session cookies are signed with. See the header for why this is here and why it is not
307
+ // the credential the rest of the file refuses to hold.
308
+ //
309
+ // `rotated_at` rather than an UPDATE in place: rotating a session key signs everybody
310
+ // out, so it is an event somebody should be able to see having happened.
311
+ `CREATE TABLE IF NOT EXISTS signing_keys (
312
+ purpose TEXT PRIMARY KEY CHECK (purpose IN ('session')),
313
+ key TEXT NOT NULL,
314
+ created_at TEXT NOT NULL,
315
+ rotated_at TEXT
316
+ )`,
317
+
318
+ // Schema version and the workspace's own id, so a stored object can say what it is
319
+ // without being told. `meta` is deliberately separate from `settings`: one is about the
320
+ // database, the other about the workspace, and a migration reads the first before it is
321
+ // safe to trust the second.
322
+ `CREATE TABLE IF NOT EXISTS meta (
323
+ k TEXT PRIMARY KEY,
324
+ v TEXT NOT NULL
325
+ )`,
326
+ ]);
327
+
328
+ /**
329
+ * Column names that must never appear in this schema.
330
+ *
331
+ * Not a lint for its own sake: the failure it prevents is a workspace admin being able to
332
+ * reset a credential that opens their colleague's other workspaces. `test/tenant-do.test.mjs`
333
+ * runs this over the executed schema, so it reads the tables SQLite actually built rather
334
+ * than the text somebody wrote.
335
+ */
336
+ export const FORBIDDEN_COLUMNS = Object.freeze([
337
+ "password", "passhash", "pass_hash", "pass", "secret", "credential", "pbkdf2", "salt",
338
+ ]);
339
+
340
+ /**
341
+ * Columns added to a table that already exists, for objects built at an earlier version.
342
+ *
343
+ * ⚠️ `CREATE TABLE IF NOT EXISTS` IS NOT A MIGRATION, and that is the trap this closes. An
344
+ * object provisioned at version 1 has a `members` table, so every statement above is a
345
+ * no-op on it — including the one that now names six more columns. Without this list the
346
+ * new columns exist on a workspace created today and on no workspace created before today,
347
+ * and the difference shows up as a roster read answering `undefined` on exactly the
348
+ * instances that have been running longest.
349
+ *
350
+ * Every entry is additive and nullable by construction: SQLite can add a column to a
351
+ * populated table only if it needs no default, which is the same constraint that makes
352
+ * this safe to run against a live workspace. Nothing is renamed, nothing is dropped, and
353
+ * a downgrade to an engine that does not know these columns still reads every row it wrote.
354
+ *
355
+ * `test/tenant-do.test.mjs` executes the schema and asserts this list and the CREATE
356
+ * statements name the same columns, so the two cannot drift.
357
+ */
358
+ export const TENANT_SCHEMA_ADDITIONS = Object.freeze([
359
+ { table: "members", column: "initials", type: "TEXT" },
360
+ { table: "members", column: "colour", type: "TEXT" },
361
+ { table: "members", column: "source", type: "TEXT" },
362
+ { table: "members", column: "added_by", type: "TEXT" },
363
+ { table: "members", column: "name_overlay", type: "TEXT" },
364
+ { table: "members", column: "role_overlay", type: "TEXT" },
365
+ { table: "members", column: "first_publish_at", type: "TEXT" },
366
+ { table: "publish_tokens", column: "scope", type: "TEXT" },
367
+ { table: "publish_tokens", column: "caps", type: "TEXT" },
368
+ ]);
369
+
370
+ /**
371
+ * The text a `caps` column holds, from the value a KV record carries.
372
+ *
373
+ * `undefined` — the caller does not know, so the column stays SQL NULL and the read declines
374
+ * to answer for the row. Anything else, including `null`, is a statement about the token and
375
+ * is written as JSON: `null` is "no caps field, therefore unrestricted", which is a fact the
376
+ * mint knows and a copy from a pre-`caps` source does not.
377
+ */
378
+ function capsColumn(caps) {
379
+ return caps === undefined ? null : JSON.stringify(caps);
380
+ }
381
+
382
+ /**
383
+ * The value a `caps` column states, or `undefined` for a column that cannot answer.
384
+ *
385
+ * A row a pre-`caps` copy wrote and a row this build cannot parse are the same thing: no
386
+ * answer. Neither may be guessed at, because the only guess available — "no restriction" —
387
+ * is the one that turns a narrow credential into a full one.
388
+ */
389
+ function capsValue(text) {
390
+ if (text == null) return undefined;
391
+ try { return JSON.parse(String(text)); } catch (e) { return undefined; }
392
+ }
393
+
394
+ /**
395
+ * Add any column in `TENANT_SCHEMA_ADDITIONS` that this object's tables are missing.
396
+ *
397
+ * ⚠️ IT ASKS BY TRYING, NOT BY INTROSPECTING. The obvious shape is `PRAGMA table_info` and
398
+ * a set difference, and it is wrong in a way that passes every test: a PRAGMA is neither a
399
+ * SELECT nor a plain statement, so a harness that routes by keyword answers it with no rows
400
+ * — and "no rows" reads as "this table has no columns", which skips every addition. The
401
+ * migration would then exist only in the source. `ALTER TABLE … ADD COLUMN` is the same
402
+ * question asked of the engine itself, and a column that is already there is the one error
403
+ * it can raise that means success.
404
+ *
405
+ * Nothing else is swallowed. A failure that is not "the column is already there" is a
406
+ * schema this build cannot read, and carrying on would serve wrong answers rather than
407
+ * refuse.
408
+ */
409
+ export function applySchemaAdditions(sql) {
410
+ const added = [];
411
+ for (const { table, column, type } of TENANT_SCHEMA_ADDITIONS) {
412
+ try {
413
+ sql.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
414
+ added.push(`${table}.${column}`);
415
+ } catch (e) {
416
+ const msg = String((e && e.message) || e);
417
+ if (!/duplicate column/i.test(msg)) throw e;
418
+ }
419
+ }
420
+ return added;
421
+ }
422
+
423
+ /** Apply the schema to a SQLite-backed store. Idempotent — every statement is IF NOT EXISTS. */
424
+ export function applyTenantSchema(sql, workspaceId) {
425
+ for (const stmt of TENANT_SCHEMA) sql.exec(stmt);
426
+ // AFTER the creates, so a table that does not exist yet is created in today's shape and
427
+ // this pass is the no-op it should be on it.
428
+ applySchemaAdditions(sql);
429
+ sql.exec(
430
+ `INSERT INTO meta (k, v) VALUES ('schema_version', ?)
431
+ ON CONFLICT(k) DO UPDATE SET v = excluded.v`,
432
+ String(TENANT_SCHEMA_VERSION),
433
+ );
434
+ if (workspaceId) {
435
+ // The id is written ONCE and never updated: an object that could be told it is a
436
+ // different workspace is an object that can be pointed at somebody else's data.
437
+ sql.exec(
438
+ `INSERT INTO meta (k, v) VALUES ('workspace', ?) ON CONFLICT(k) DO NOTHING`,
439
+ String(workspaceId),
440
+ );
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Seed this workspace's quotas from its plan. Provisioning calls it once.
446
+ *
447
+ * INSERT ... DO NOTHING, so it is safe to re-run and so an operator's raise for one
448
+ * customer is not undone by anything that re-seeds. Changing a plan is a deliberate write
449
+ * (`setWorkspacePlan` below), never a side effect of the schema being applied again.
450
+ *
451
+ * The `plans` parameter exists so the defaults have exactly one home: a test proves that
452
+ * changing a value in that table is the whole change, with nothing else to keep in step.
453
+ */
454
+ export function seedQuotas(sql, plan = DEFAULT_PLAN, plans = PLANS) {
455
+ const q = quotasForPlan(plan, plans);
456
+ sql.exec(`INSERT INTO meta (k, v) VALUES ('plan', ?) ON CONFLICT(k) DO NOTHING`, q.plan);
457
+ for (const field of QUOTA_FIELDS) {
458
+ sql.exec(`INSERT INTO quotas (k, n) VALUES (?, ?) ON CONFLICT(k) DO NOTHING`, field, q[field]);
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Move this workspace onto another plan: the plan name AND every quota it implies, in one
464
+ * statement each. A plan change that moved the label and left the ceilings behind would be
465
+ * a workspace that has paid and is still refused.
466
+ *
467
+ * It overwrites, which means it also DISCARDS a per-customer raise. That is the honest
468
+ * behaviour for "put this workspace on the paid plan" and the reason a raise should be
469
+ * re-applied after a plan change rather than assumed to survive one.
470
+ */
471
+ export function setWorkspacePlan(sql, plan, plans = PLANS) {
472
+ const q = quotasForPlan(plan, plans);
473
+ sql.exec(`INSERT INTO meta (k, v) VALUES ('plan', ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v`, q.plan);
474
+ for (const field of QUOTA_FIELDS) {
475
+ sql.exec(
476
+ `INSERT INTO quotas (k, n) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET n = excluded.n`,
477
+ field, q[field],
478
+ );
479
+ }
480
+ return q.plan;
481
+ }
482
+
483
+ /**
484
+ * A fresh session-signing key: 32 bytes of CSPRNG, hex.
485
+ *
486
+ * NEVER an env var, and that is the whole point of generating it here. `env.SESSION_SECRET`
487
+ * is ONE value for the whole Worker: fine while a Worker serves one workspace, and
488
+ * forgeable across every workspace the moment it serves several — anyone holding it could
489
+ * mint a valid session cookie for a neighbour. A key that is generated per workspace, into
490
+ * that workspace's own storage, cannot be that.
491
+ */
492
+ /**
493
+ * How long a deleted workspace's data survives the delete.
494
+ *
495
+ * ⚠️ THIS NUMBER IS PUBLISHED, so it is not a tuning knob. The hosted lifecycle page tells
496
+ * customers "gone from the service in 30 days, gone from the backups within 70", the
497
+ * delete-confirmation screen says the same thing (`F-tenant-delete-ux`), and the backup
498
+ * rotation is what makes the second number true (`D-2-nightly-backup-worm`, 30 kept plus
499
+ * 40). Change this and all three change the same day, or the platform is promising
500
+ * something it does not do.
501
+ *
502
+ * The screen is the one of the three that CANNOT fall behind: `src/delete-confirmation.mjs`
503
+ * derives every number it shows from this constant, and `GET /__control/delete` serves that
504
+ * to whatever renders the confirmation, so no surface holds a second copy of "30". The page
505
+ * and the rotation are still hand-kept, and still have to move on the same day.
506
+ */
507
+ export const DELETE_GRACE_MS = 30 * 24 * 60 * 60 * 1000;
508
+
509
+ /**
510
+ * Every verb the outside world may ask a workspace to perform on itself.
511
+ *
512
+ * ⚠️ IT MUST MATCH `TENANT_RPC` in the control plane's src/provisioning.js EXACTLY. The two
513
+ * are separate repos and neither can import the other, so the list is written twice on
514
+ * purpose and the seam between them was open for a while: the control plane POSTed
515
+ * `/__control/<verb>` and this object routed `/status`, `/activity`, `/destroy` and nothing
516
+ * else, so every control-plane call was a 404 that nothing was watching for.
517
+ */
518
+ export const CONTROL_VERBS = Object.freeze([
519
+ "provision", "status", "suspend", "resume", "rotate", "delete", "purge", "rename", "claim", "chrome",
520
+ "account-key",
521
+ ]);
522
+
523
+ /**
524
+ * The KV document the front door's alias lookup reads, for one hostname. ONE key shape for
525
+ * every kind of alias — a platform label the resolver refuses (`demo.<suffix>`) and, later,
526
+ * a customer's own hostname that carries no suffix at all — so the resolver ever makes ONE
527
+ * lookup, keyed on the full normalized hostname, rather than growing a second table when
528
+ * custom hostnames arrive.
529
+ */
530
+ export const HOST_ALIAS_KEY_PREFIX = "host:alias:";
531
+ export const hostAliasKey = (hostname) => `${HOST_ALIAS_KEY_PREFIX}${hostname}`;
532
+
533
+ /**
534
+ * A hostname a claim may carry: a normalized, dotted DNS name — at least two labels, each
535
+ * of legal shape, 253 characters or fewer. Deliberately NARROW: no port, no trailing dot,
536
+ * no case (normalizeHost has already folded those away for a live request, and a stored
537
+ * key must hold exactly the form the lookup asks for).
538
+ */
539
+ export function normalizeClaimHostname(hostname) {
540
+ const h = normalizeHost(hostname);
541
+ if (!h || h.length > 253 || h.startsWith("[")) return null;
542
+ const labels = h.split(".");
543
+ if (labels.length < 2) return null;
544
+ const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
545
+ for (const l of labels) if (!LABEL.test(l)) return null;
546
+ return h;
547
+ }
548
+
549
+ /**
550
+ * The suspension reasons a member can lift by signing in — an ALLOWLIST, and the WHOLE of
551
+ * `E-dormancy-resume` is this list rather than the resume that stands beside it.
552
+ *
553
+ * The hosted lifecycle page promises that a workspace suspended for dormancy "reactivates
554
+ * on the first successful sign-in by an admin". Every OTHER suspension has to survive that
555
+ * same sign-in: an acceptable-use takedown is lifted by whoever imposed it, a tombstone is
556
+ * lifted by a restore, and neither is something the suspended workspace's own admin may do
557
+ * by proving they are the admin. A resume that fired on the wrong reason would un-take-down
558
+ * a phishing page on the strength of its own owner signing in, which is the one failure
559
+ * here that reaches people who are not customers.
560
+ *
561
+ * ⚠️ AN ALLOWLIST, NEVER A DENYLIST, and that is a decision rather than a style. A denylist
562
+ * — "resume unless the reason is `deleted`, or starts with the acceptable-use word" —
563
+ * resumes on every suspension kind invented after it, the day that kind ships, silently and
564
+ * with nobody having decided it. This list makes a new kind INERT instead: an unrecognised
565
+ * reason never resumes, and somebody has to come here on purpose and add it.
566
+ * `test/dormancy-resume.test.mjs` pins the contents, so growing the list is a visible act.
567
+ *
568
+ * ⚠️ MATCHED EXACTLY, byte for byte. Not a prefix, not case-folded, not trimmed. An
569
+ * operator's free-text reason that merely CONTAINS the word is not a dormancy suspension,
570
+ * and every near miss therefore fails to "it stays paused", which is the safe side of this
571
+ * particular wrong answer.
572
+ *
573
+ * ⚠️ NOTHING WRITES THIS WORD YET. The 90-day sweep the lifecycle page describes is not
574
+ * built; when it is, it must call `suspend()` with exactly this string, and this constant is
575
+ * the definition it has to match. Same shape as CONTROL_VERBS and the control plane's
576
+ * `TENANT_RPC` — the two repos cannot import each other, so the value is written twice and
577
+ * each side's suite asserts the other's copy. Until the sweep exists, no live workspace can
578
+ * carry a reason on this list, so the resume below is reachable only by an operator who
579
+ * suspends with that exact word.
580
+ */
581
+ export const DORMANCY_SUSPENSION_REASONS = Object.freeze(["dormant"]);
582
+
583
+ export function newSigningKey(random = (b) => crypto.getRandomValues(b)) {
584
+ const bytes = new Uint8Array(32);
585
+ random(bytes);
586
+ return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
587
+ }
588
+
589
+ /**
590
+ * How long a chrome-refresh token lives. Long enough to build the engine and publish
591
+ * `--engine`, short enough that a forgotten one is dead soon. The credential is minted by the
592
+ * operator `chrome` verb and used once from a laptop; it is not a machine token.
593
+ */
594
+ export const CHROME_TOKEN_TTL_MS = 60 * 60 * 1000;
595
+
596
+ async function sha256Hex(s) {
597
+ const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(String(s)));
598
+ return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
599
+ }
600
+
601
+ /**
602
+ * Everything provisioning writes, as ONE synchronous body a transaction can wrap.
603
+ *
604
+ * ORDER IS PART OF THE CONTRACT. `provisioned_at` is written LAST, and it is the only row
605
+ * anything else reads to decide whether this workspace exists. So even without a
606
+ * transaction there is no observable half-state: a crash anywhere above leaves the flag
607
+ * unset and the workspace unresolvable. The transaction is the belt on top of that brace,
608
+ * and `TenantStore.provision` supplies it.
609
+ *
610
+ * WHAT IT DOES NOT DO, both deliberate:
611
+ * · It does not mint a password. The credential is account-level, so this BINDS an
612
+ * already-verified account as the first admin. Nothing here takes a secret from a
613
+ * caller, which is why the signature has nowhere to put one.
614
+ * · It does not seed a default space. The workspace IS the space; there is no inner
615
+ * tier to create, and no `no-default-space` failure left to design around.
616
+ *
617
+ * Re-running it on a provisioned workspace is a NO-OP that returns the existing state.
618
+ * That is not politeness — two concurrent provisions of one slug reach the same object,
619
+ * and the second must not mint a second signing key (which would sign the first one's
620
+ * sessions out) or overwrite the first admin.
621
+ */
622
+ /**
623
+ * The families a seed pack may write, as a frozen list.
624
+ *
625
+ * ⚠️ AN ALLOWLIST, NOT THE OVERLAY'S OWN FAMILY LIST. A seed arrives from outside this object
626
+ * — the control plane hands it over at provisioning — and the families it may touch are the
627
+ * ones a NEW workspace can meaningfully have content in. `assets` is not here (canvas image
628
+ * metadata whose bytes live in R2, so a seeded row would point at nothing) and neither is
629
+ * `piti`. Adding one is a deliberate edit, which is the point.
630
+ */
631
+ export const SEEDABLE_FAMILIES = Object.freeze(["comments", "boards", "statuses", "names", "canvases", "pins"]);
632
+
633
+ /**
634
+ * The identity families a copy may carry, and the roles a member row may hold.
635
+ *
636
+ * `B-kv-to-do-migration-tool`. These have had tables since `B-do-schema-core` and no write
637
+ * path, so a copy of a workspace's state landed the content and left the roster, the
638
+ * invites and the publish tokens in KV. This is that write path — and it is a COPY:
639
+ * nothing reads what it writes until `B-kv-read-cutover` moves the reads over.
640
+ *
641
+ * ⚠️ THERE IS NO CREDENTIAL FAMILY HERE AND THERE MUST NEVER BE ONE. A password is
642
+ * account-level — one address, one credential, several workspaces — so a workspace that
643
+ * held a hash could reach every other workspace that address opens. The object writes only
644
+ * what this list names, so a caller that sends a `secrets` key is ignored rather than
645
+ * trusted to have meant something else.
646
+ */
647
+ export const IDENTITY_FAMILIES = Object.freeze(["members", "invites", "publishTokens", "lastseen", "blobs"]);
648
+ const MEMBER_ROLES = Object.freeze(["admin", "editor", "viewer"]);
649
+
650
+ /** The address as an identity: lowercased and trimmed, the normalisation the gate uses. */
651
+ const lcAddr = (s) => String(s == null ? "" : s).trim().toLowerCase();
652
+
653
+ /**
654
+ * A stored timestamp as epoch milliseconds, or null if it is not one.
655
+ *
656
+ * ⚠️ IT ACCEPTS TWO SPELLINGS ON PURPOSE, and only one of them is written here. Every
657
+ * timestamp column in this schema is an ISO-8601 string, because these rows are read by
658
+ * people during incidents. But KV stored an invite's expiry as epoch milliseconds, and a
659
+ * copy carries what the source held — so a table filled by one holds rows whose
660
+ * `expires_at` is a number in a text column, which `Date.parse` answers `NaN` for. Reading
661
+ * strictly would have declared every invite carried across invalid, without a word, and the
662
+ * first sign of it would be somebody clicking a link that has not expired.
663
+ *
664
+ * The numeric branch is therefore a READ accommodation and never a writing style: nothing
665
+ * in this file produces one, and `src/kv-identity.mjs` no longer does either.
666
+ *
667
+ * ⚠️ AND IT HAS TO ACCEPT THE SPELLING SQLITE PRODUCES, WHICH IS NOT THE ONE JAVASCRIPT
668
+ * DOES. The pre-fix copy did not stringify the number; it BOUND it, and a JS number bound
669
+ * into a TEXT-affinity column is converted by SQLite from a double — which renders as
670
+ * `"1788484474092.0"`, with a trailing `.0` no `String(n)` ever writes. So `/^\d+$/` matches
671
+ * a fixture that spelled the row by hand and misses every row the copy actually wrote, and
672
+ * an accommodation that only covers the hand-spelled form covers nothing. Measured on
673
+ * workerd and on node:sqlite, which agree: `scripts/tenant-do-rehearsal.mjs` drives
674
+ * `/state/import` with the real numeric field and was the run that found it.
675
+ */
676
+ function stampMs(v) {
677
+ if (typeof v === "number" && Number.isFinite(v)) return v;
678
+ const s = String(v == null ? "" : v).trim();
679
+ if (!s) return null;
680
+ // Digits, optionally with the fractional part SQLite's double rendering adds. Deliberately
681
+ // NOT a general numeric grammar: exponent notation and a leading sign are not shapes any
682
+ // producer of these rows emits, and widening this to "anything Number() likes" would start
683
+ // reading strings that are not timestamps as timestamps.
684
+ if (/^\d+(?:\.\d+)?$/.test(s)) return Number(s);
685
+ const ms = Date.parse(s);
686
+ return Number.isFinite(ms) ? ms : null;
687
+ }
688
+
689
+ /**
690
+ * Whether this object has ever been given ONE identity family's contents — by a copy, by
691
+ * provisioning, or by a write since the reads moved.
692
+ *
693
+ * ⚠️ "EMPTY" AND "NEVER FILLED" ARE DIFFERENT ANSWERS AND THE CUT TURNS ON TELLING THEM
694
+ * APART. A workspace whose roster overlay is genuinely empty and one whose copy has not run
695
+ * yet both read as no rows, and answering the first from the object is correct while
696
+ * answering the second from it silently un-removes everybody KV's `remove` list names. So
697
+ * the object says which it is, and the worker falls back to KV for the second — the same
698
+ * "the object first, KV as the fallback" rule an outstanding invite link already relies on.
699
+ *
700
+ * A stamp rather than a flag, because the first question anybody asks of a straddle is when
701
+ * it started.
702
+ */
703
+ const SEEDED_KEY = (family) => `identity_seeded:${family}`;
704
+
705
+ function markSeeded(sql, family, at) {
706
+ sql.exec(
707
+ `INSERT INTO meta (k, v) VALUES (?1, ?2) ON CONFLICT(k) DO NOTHING`,
708
+ SEEDED_KEY(family), at || new Date().toISOString(),
709
+ );
710
+ }
711
+
712
+ function isSeeded(sql, family) {
713
+ return [...sql.exec(`SELECT v FROM meta WHERE k = ?`, SEEDED_KEY(family))].length > 0;
714
+ }
715
+
716
+ /** How many rows a seed pack would write. Used to report, and to tell "none" from "empty". */
717
+ function seedCount(seed) {
718
+ if (!seed || typeof seed !== "object") return 0;
719
+ let n = 0;
720
+ for (const family of SEEDABLE_FAMILIES) {
721
+ const scopes = seed[family];
722
+ if (!scopes || typeof scopes !== "object") continue;
723
+ for (const scope of Object.keys(scopes)) {
724
+ const map = scopes[scope];
725
+ if (map && typeof map === "object") n += Object.keys(map).length;
726
+ }
727
+ }
728
+ return n;
729
+ }
730
+
731
+ /**
732
+ * Write the identity families. Rows arrive translated and hashed; see `IDENTITY_FAMILIES`.
733
+ *
734
+ * Every insert is an UPSERT rather than a plain insert, because the copy has to be safe to
735
+ * re-run: a run killed halfway is fixed by running it again, and that is only true if the
736
+ * second run lands on top of the first instead of colliding with it.
737
+ *
738
+ * A REMOVED MEMBER IS A TOMBSTONE, never an absent row. KV records a removal in
739
+ * `users:roster`'s `remove` list, and dropping the person here instead would let a
740
+ * re-invite inherit the role the last holder of that address had.
741
+ */
742
+ function writeIdentity(sql, identity, at, written = [], refused = []) {
743
+ if (!identity || typeof identity !== "object") return { written, refused };
744
+ const list = (k) => (Array.isArray(identity[k]) ? identity[k] : []);
745
+ const touched = (family, n) => { if (n) written.push(family); };
746
+
747
+ let n = 0;
748
+ for (const m of list("members")) {
749
+ if (!m || !m.email) continue;
750
+ // A role the schema does not allow is the source's shape, not the caller's bug.
751
+ if (!MEMBER_ROLES.includes(m.role)) {
752
+ refused.push({ family: "members", key: String(m.email), why: `role '${m.role}' is not admin, editor or viewer` });
753
+ continue;
754
+ }
755
+ sql.exec(
756
+ `INSERT INTO members (email, role, name, avatar_key, avatar_mime, avatar_at, added_at,
757
+ removed_at, initials, colour, source, added_by, name_overlay, role_overlay)
758
+ VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)
759
+ ON CONFLICT(email) DO UPDATE SET
760
+ role = ?2, name = ?3, avatar_key = ?4, avatar_mime = ?5,
761
+ avatar_at = ?6, added_at = ?7, removed_at = ?8, initials = ?9, colour = ?10,
762
+ source = ?11, added_by = ?12, name_overlay = ?13, role_overlay = ?14`,
763
+ String(m.email), m.role, m.name ?? null, m.avatarKey ?? null, m.avatarMime ?? null,
764
+ m.avatarAt ?? null, m.addedAt || at, m.removedAt ?? null,
765
+ m.initials ?? null, m.colour ?? null, m.source === "overlay" ? "overlay" : "config",
766
+ m.addedBy ?? null,
767
+ // The overlay name travels as the JSON the KV document held, not as a string. See
768
+ // `rosterRead` — `users:names` has TWO live shapes and `applyNames` honours one of
769
+ // them, so normalising here would start applying a display name the KV path ignores.
770
+ m.nameOverlay === undefined || m.nameOverlay === null ? null : JSON.stringify(m.nameOverlay),
771
+ m.roleOverlay ?? null,
772
+ );
773
+ n++;
774
+ }
775
+ touched("members", n);
776
+ if (n) markSeeded(sql, "roster", at);
777
+
778
+ n = 0;
779
+ for (const i of list("invites")) {
780
+ if (!i || !i.tokenHash) continue;
781
+ sql.exec(
782
+ `INSERT INTO invites (token_hash, email, created_at, expires_at, created_by)
783
+ VALUES (?1,?2,?3,?4,?5)
784
+ ON CONFLICT(token_hash) DO UPDATE SET email = ?2, created_at = ?3, expires_at = ?4, created_by = ?5`,
785
+ String(i.tokenHash), String(i.email || ""), i.createdAt || at, i.expiresAt || at, i.createdBy ?? null,
786
+ );
787
+ n++;
788
+ }
789
+ touched("invites", n);
790
+
791
+ n = 0;
792
+ for (const t of list("publishTokens")) {
793
+ if (!t || !t.tokenHash) continue;
794
+ sql.exec(
795
+ `INSERT INTO publish_tokens (token_hash, label, created_at, expires_at, scope, caps)
796
+ VALUES (?1,?2,?3,?4,?5,?6)
797
+ ON CONFLICT(token_hash) DO UPDATE SET label = ?2, created_at = ?3, expires_at = ?4,
798
+ -- COALESCE, so re-running a copy from a source that has no scope cannot blank a
799
+ -- scope a later one carried. A null here means "not known", never "star".
800
+ scope = COALESCE(?5, publish_tokens.scope),
801
+ -- The same, and for a sharper reason: a copy that predates the column carries no
802
+ -- caps key at all, and letting it blank the column would turn the control
803
+ -- plane's purge-only bearer back into a credential that can publish anything.
804
+ caps = COALESCE(?6, publish_tokens.caps)`,
805
+ String(t.tokenHash), t.label ?? null, t.createdAt || at, t.expiresAt ?? null,
806
+ t.scope == null ? null : String(t.scope),
807
+ // ⚠️ ABSENT AND `null` ARE DIFFERENT HERE. No `caps` key is a copy that does not know,
808
+ // and leaves the column alone; a `caps` of null is the translation saying KV's record
809
+ // carries no capability, which is a statement this object may hold.
810
+ Object.prototype.hasOwnProperty.call(t, "caps") ? capsColumn(t.caps) : null,
811
+ );
812
+ n++;
813
+ }
814
+ touched("publishTokens", n);
815
+ if (n) markSeeded(sql, "publishTokens", at);
816
+
817
+ n = 0;
818
+ for (const s of list("lastseen")) {
819
+ if (!s || !s.email) continue;
820
+ sql.exec(
821
+ `INSERT INTO lastseen (email, at) VALUES (?1,?2)
822
+ ON CONFLICT(email) DO UPDATE SET at = ?2`,
823
+ String(s.email), s.at || at,
824
+ );
825
+ n++;
826
+ }
827
+ touched("lastseen", n);
828
+
829
+ n = 0;
830
+ for (const b of list("blobs")) {
831
+ if (!b || !b.key) continue;
832
+ sql.exec(
833
+ `INSERT INTO blobs (key, mime, body, at) VALUES (?1,?2,?3,?4)
834
+ ON CONFLICT(key) DO UPDATE SET mime = ?2, body = ?3, at = ?4`,
835
+ String(b.key), b.mime ?? null, b.body, b.at || at,
836
+ );
837
+ n++;
838
+ }
839
+ touched("blobs", n);
840
+
841
+ return { written, refused };
842
+ }
843
+
844
+ /**
845
+ * Write a seed pack into the overlay. Shape: `{ <family>: { <scope>: { <key>: <value> } } }`.
846
+ *
847
+ * ⚠️ EVERY INSERT IS `DO NOTHING`. Re-provisioning an existing workspace is a no-op that keeps
848
+ * the first admin, and it must be a no-op here too: a second provision must not overwrite a
849
+ * board somebody has been editing for a month with the sample it started as.
850
+ *
851
+ * A family that is not on the allowlist is SKIPPED, not an error. The seed comes from the
852
+ * control plane rather than from a stranger, and refusing the whole provision because a pack
853
+ * carried one unknown key would turn a cosmetic mismatch into a failed signup.
854
+ */
855
+ function seedOverlay(sql, seed, at) {
856
+ if (!seed || typeof seed !== "object") return 0;
857
+ let written = 0;
858
+ for (const family of SEEDABLE_FAMILIES) {
859
+ const scopes = seed[family];
860
+ if (!scopes || typeof scopes !== "object" || Array.isArray(scopes)) continue;
861
+ for (const [scope, map] of Object.entries(scopes)) {
862
+ if (!map || typeof map !== "object" || Array.isArray(map)) continue;
863
+ for (const [k, v] of Object.entries(map)) {
864
+ if (v === null || v === undefined) continue;
865
+ sql.exec(
866
+ `INSERT INTO overlay (family, scope, k, v, at) VALUES (?1,?2,?3,?4,?5)
867
+ ON CONFLICT(family, scope, k) DO NOTHING`,
868
+ family, String(scope), String(k), JSON.stringify(v), at,
869
+ );
870
+ written++;
871
+ }
872
+ }
873
+ }
874
+ return written;
875
+ }
876
+
877
+ export function applyProvisioning(sql, {
878
+ workspaceId, adminEmail, adminName = "", plan = DEFAULT_PLAN, now, sessionKey, seed = null,
879
+ seedVersions = [],
880
+ } = {}) {
881
+ if (!workspaceId) throw new Error("provisioning needs a workspace id");
882
+ if (!adminEmail) throw new Error("provisioning needs an admin address");
883
+ const at = now || new Date().toISOString();
884
+
885
+ const existing = [...sql.exec(`SELECT v FROM meta WHERE k = 'provisioned_at'`)];
886
+ if (existing.length) return { provisionedAt: existing[0].v, created: false };
887
+
888
+ sql.exec(`INSERT INTO meta (k, v) VALUES ('workspace', ?) ON CONFLICT(k) DO NOTHING`, String(workspaceId));
889
+ sql.exec(
890
+ `INSERT INTO signing_keys (purpose, key, created_at) VALUES ('session', ?, ?)
891
+ ON CONFLICT(purpose) DO NOTHING`,
892
+ sessionKey || newSigningKey(), at,
893
+ );
894
+ // ⚠️ THE FIRST ADMIN IS AN OVERLAY ROW, AND THAT IS THE WHOLE OF HOW THEY ARE SERVED.
895
+ // `source` says where a MEMBERSHIP came from, and this one came from provisioning — the
896
+ // first invitation a workspace ever issues — not from a config file, which a workspace born
897
+ // here has never had. The serving read (`rosterRead`) emits `'overlay'` rows into the `add`
898
+ // document and nothing else, because a `'config'` row is the MIRROR of a file and the file
899
+ // is what serves it. Written `'config'`, this row existed in the table and nowhere the
900
+ // serving path looks: `/__people` did not resolve the admin, the people list was empty,
901
+ // and their own invite link answered "no longer valid". Measured on a live signup.
902
+ //
903
+ // It is spelled exactly the way the admin invite spells an entry — a name, initials and a
904
+ // colour, stamped by the same three functions — so the first person a workspace has is a
905
+ // chip like everybody else's and not a blank. The row promotes to `'config'` the day a
906
+ // config push names them and drains like any invite; nothing about it is special after
907
+ // this INSERT, and nothing needs to be.
908
+ const email = String(adminEmail).trim().toLowerCase();
909
+ const name = String(adminName || "").trim() || nameFromEmail(email);
910
+ sql.exec(
911
+ `INSERT INTO members (email, role, name, added_at, initials, colour, source)
912
+ VALUES (?1, 'admin', ?2, ?3, ?4, ?5, 'overlay')
913
+ ON CONFLICT(email) DO NOTHING`,
914
+ email, name, at, initialsFor(name), colorFor(email),
915
+ );
916
+ // A workspace PROVISIONED here has no KV era behind it, so this object is the record for
917
+ // both identity families from its first moment and there is nothing to fall back to. A
918
+ // workspace that ARRIVES from KV is marked by the copy instead — see `markSeeded`.
919
+ markSeeded(sql, "roster", at);
920
+ markSeeded(sql, "publishTokens", at);
921
+ seedQuotas(sql, plan);
922
+ // ── the seed, INSIDE the same body ────────────────────────────────────────────────
923
+ //
924
+ // `F-atomic-tenant-seed-write`. A workspace whose admin exists and whose first thread does
925
+ // not is a workspace whose owner arrives at an empty room the product promised would not
926
+ // be empty — and the repair is a second write that can fail on its own, at a moment nobody
927
+ // is watching. So the seed goes here, above `provisioned_at`, and inherits the ordering the
928
+ // admin and the signing key already have: a crash anywhere leaves the workspace
929
+ // unresolvable rather than half-furnished.
930
+ //
931
+ // ⚠️ WHAT IT CAN SEED IS THE OVERLAY, AND THAT IS THE HONEST BOUNDARY. The sample comment
932
+ // thread, a status, a card name — everything this object owns. PUBLISHED CONTENT IS NOT
933
+ // HERE and cannot be: prototypes are blobs in R2, a different store with no transaction in
934
+ // common with this one, and a seed pack that claimed otherwise would be claiming an
935
+ // atomicity nothing can provide. The order that makes that safe is the same one used
936
+ // everywhere else here — publish the content FIRST, then provision; content nobody can
937
+ // reach yet is invisible, an admin with no content is a promise broken on the first screen.
938
+ seedOverlay(sql, seed, at);
939
+ // THE VERSION ROW FOR THE SEED PUBLISH, in the same transaction. The pack's manifest and
940
+ // version file are already in the bundle store by the time this runs (`F-seed-pack-at-
941
+ // provision` — content first, then this commit); what makes that content THIS workspace's
942
+ // is a `publish_versions` row, the only record of which spaces a workspace owns
943
+ // (`publishedSpaces`), which a delete walks and the next real publish counts up from. A
944
+ // MAX rather than an insert: an object re-provisioned after a crash keeps whatever it
945
+ // had, and a counter can never move backwards to a version file that already exists.
946
+ for (const v of Array.isArray(seedVersions) ? seedVersions : []) {
947
+ if (!v || !v.space || !Number.isInteger(v.version) || v.version < 1) continue;
948
+ sql.exec(
949
+ `INSERT INTO publish_versions (space, version) VALUES (?1, ?2)
950
+ ON CONFLICT(space) DO UPDATE SET version = MAX(publish_versions.version, excluded.version)`,
951
+ String(v.space), v.version,
952
+ );
953
+ }
954
+ sql.exec(`INSERT INTO meta (k, v) VALUES ('created_at', ?) ON CONFLICT(k) DO NOTHING`, at);
955
+ // LAST. Everything above is invisible until this row exists.
956
+ sql.exec(`INSERT INTO meta (k, v) VALUES ('provisioned_at', ?) ON CONFLICT(k) DO NOTHING`, at);
957
+ return { provisionedAt: at, created: true, seeded: seedCount(seed) };
958
+ }
959
+
960
+ /** A provisioning that asked for the seed pack on a deployment that cannot supply one. */
961
+ class SeedPackUnavailable extends Error {
962
+ constructor(detail) { super(`seed-pack-unavailable: ${detail}`); this.code = "seed-pack-unavailable"; }
963
+ }
964
+
965
+ /**
966
+ * A caller's overlay seed and the pack's, as one. Per family, per scope, the caller's keys
967
+ * win — a control plane that hands over a thread of its own is not overridden by the pack's.
968
+ */
969
+ function mergeSeed(mine, packs) {
970
+ if (!packs || typeof packs !== "object") return mine || null;
971
+ if (!mine || typeof mine !== "object") return packs;
972
+ const out = { ...packs };
973
+ for (const [family, scopes] of Object.entries(mine)) {
974
+ if (!scopes || typeof scopes !== "object" || Array.isArray(scopes)) continue;
975
+ out[family] = { ...(out[family] || {}) };
976
+ for (const [scope, map] of Object.entries(scopes)) {
977
+ out[family][scope] = { ...((out[family] || {})[scope] || {}), ...(map || {}) };
978
+ }
979
+ }
980
+ return out;
981
+ }
982
+
983
+ /**
984
+ * One workspace's mutable state.
985
+ *
986
+ * Deliberately thin at this stage: the schema and its migration are what `B-do-schema-core`
987
+ * is for, and the read/write verbs arrive with the families that move onto them
988
+ * (`B-do-schema-content-overlay`, `B-do-schema-comments-boards`). Adding speculative
989
+ * methods now would mean guessing at call sites that do not exist.
990
+ */
991
+ export class TenantStore {
992
+ constructor(ctx, env) {
993
+ this.ctx = ctx;
994
+ this.env = env;
995
+ this.ready = false;
996
+ }
997
+
998
+ /** SQLite handle. Named so a stub in a test is obviously standing in for one thing. */
999
+ get sql() {
1000
+ return this.ctx.storage.sql;
1001
+ }
1002
+
1003
+ /**
1004
+ * Apply the schema once per object lifetime. Wrapped in blockConcurrencyWhile so two
1005
+ * concurrent requests to a cold object cannot both run the migration — the statements are
1006
+ * idempotent, but a half-applied schema read by the other request is not.
1007
+ */
1008
+ async init(workspaceId, { plan = DEFAULT_PLAN } = {}) {
1009
+ if (this.ready) return;
1010
+ const run = () => {
1011
+ applyTenantSchema(this.sql, workspaceId);
1012
+ // Seeded here rather than at provisioning ONLY: a workspace that somehow reaches an
1013
+ // enforcement point with no quota row would be a workspace with no ceilings, and
1014
+ // "no ceilings" is the wrong way for that to fail. Seeding is DO NOTHING, so a
1015
+ // provisioned plan and any per-customer raise both survive it.
1016
+ seedQuotas(this.sql, plan);
1017
+ this.ready = true;
1018
+ };
1019
+ if (this.ctx.blockConcurrencyWhile) await this.ctx.blockConcurrencyWhile(async () => run());
1020
+ else run();
1021
+ }
1022
+
1023
+ /** What this object believes it is. Reads `meta`, never the request. */
1024
+ workspaceId() {
1025
+ const rows = [...this.sql.exec(`SELECT v FROM meta WHERE k = 'workspace'`)];
1026
+ return rows.length ? rows[0].v : null;
1027
+ }
1028
+
1029
+ schemaVersion() {
1030
+ const rows = [...this.sql.exec(`SELECT v FROM meta WHERE k = 'schema_version'`)];
1031
+ return rows.length ? Number(rows[0].v) : 0;
1032
+ }
1033
+
1034
+ /**
1035
+ * Provision this workspace: first admin, signing key, quotas, in ONE transaction.
1036
+ *
1037
+ * `transactionSync` is what makes the crash case all-or-nothing rather than
1038
+ * nearly-always-fine; `blockConcurrencyWhile` is what stops a second request arriving
1039
+ * mid-provision and reading a workspace that is halfway there. A Durable Object is
1040
+ * single-threaded, so the two together are the whole concurrency story.
1041
+ *
1042
+ * A runtime without `transactionSync` still gets the right ANSWER, because
1043
+ * `provisioned_at` is written last — it just gets it by ordering rather than by
1044
+ * rollback.
1045
+ */
1046
+ async provision(opts = {}) {
1047
+ await this.init(opts.workspaceId, { plan: opts.plan });
1048
+ // ONE instant for everything this provisioning writes — the content's stamps in the
1049
+ // store, the threads' timestamps, the rows below — so the seed is born all at once and
1050
+ // Start Here sorts first (seed/README.md).
1051
+ const at = opts.now || new Date().toISOString();
1052
+ const go = async () => {
1053
+ // ── the seed pack, BEFORE the transaction ────────────────────────────────────────
1054
+ //
1055
+ // `F-seed-pack-at-provision`. Published content lives in the bundle store, not in this
1056
+ // object, and no transaction spans the two; so the content goes FIRST and the commit
1057
+ // that makes this workspace exist goes second. Content nobody can reach is invisible:
1058
+ // an object left unprovisioned by a crash here is refused at the front door, and a
1059
+ // later provisioning of the same object rewrites these keys with its own pack. Never
1060
+ // on a workspace that already exists — re-provisioning keeps the first admin, and it
1061
+ // keeps the first content for the same reason.
1062
+ let furnished = null;
1063
+ if (opts.seedPack === true && !this.isProvisioned()) furnished = await this.furnish(opts.workspaceId, at);
1064
+ const body = () => applyProvisioning(this.sql, {
1065
+ ...opts, now: at,
1066
+ seed: furnished ? mergeSeed(opts.seed, furnished.overlay) : opts.seed,
1067
+ seedVersions: furnished ? [{ space: furnished.space, version: furnished.version }] : [],
1068
+ });
1069
+ const out = this.ctx.storage.transactionSync ? this.ctx.storage.transactionSync(body) : body();
1070
+ if (furnished && out.created) {
1071
+ const { overlay: _o, ...written } = furnished;
1072
+ out.seedPack = written;
1073
+ }
1074
+ return out;
1075
+ };
1076
+ if (this.ctx.blockConcurrencyWhile) return this.ctx.blockConcurrencyWhile(go);
1077
+ return go();
1078
+ }
1079
+
1080
+ /**
1081
+ * Write the deployed engine's seed pack into this workspace's segment of the bundle store.
1082
+ *
1083
+ * Refuses rather than provisioning an empty room: a deployment that was asked for the
1084
+ * pack and has none (`seed-pack-unavailable`) is misconfigured, and a signup that quietly
1085
+ * produced a bare workspace would be the failure Phase F was written to design out. The
1086
+ * store's own refusals (`seed-over-real-content`, `seed-pack-corrupt`) surface as they are.
1087
+ */
1088
+ async furnish(workspaceId, at) {
1089
+ // The id off the call: the `workspace` meta row is written by the commit this precedes.
1090
+ const store = bundleStore(this.env, workspaceId);
1091
+ const pack = await loadSeedPack(this.env);
1092
+ if (!pack) throw new SeedPackUnavailable("no pack in this deployment's asset bundle");
1093
+ if (!store) throw new SeedPackUnavailable("no bundle store binding");
1094
+ const written = await publishSeedPack({
1095
+ store, pack, workspaceId, at, origin: workspaceOrigin(this.env, workspaceId),
1096
+ });
1097
+ return { ...written, overlay: seedOverlayFrom(pack, at) };
1098
+ }
1099
+
1100
+ /**
1101
+ * Note when somebody used this workspace — a sign-in OR a publish, coarsely.
1102
+ *
1103
+ * `lastActivityAt` exists nowhere else, and the four signals that do each cover a slice
1104
+ * with a hole. `publishedAt` is publishes only. `users:lastseen:<address>` is browser
1105
+ * sessions and is BLIND TO PUBLISHING, because `augur publish` carries a bearer token and
1106
+ * never touches `/__me` — a team shipping daily from CI reads as months idle. Comment
1107
+ * recency would mean reading every page's threads. The canvas registry's stamp is
1108
+ * creation, not editing, and a board document carries no wall clock at all.
1109
+ *
1110
+ * So it is one column, bumped at both, and THROTTLED: skipping the write while the stored
1111
+ * value is fresh is what stops a per-request write on a busy workspace, the same reason
1112
+ * the browser-session stamp throttles.
1113
+ *
1114
+ * The dormancy clock the lifecycle policy promises is keyed on this. Anything narrower
1115
+ * suspends workspaces that are being used, which is why "publish counts" is not an extra.
1116
+ */
1117
+ touchActivity(now = Date.now(), throttleMs = 15 * 60 * 1000) {
1118
+ const rows = [...this.sql.exec(`SELECT v FROM meta WHERE k = 'last_activity_at'`)];
1119
+ const prev = rows.length ? Date.parse(rows[0].v) || 0 : 0;
1120
+ if (now - prev < throttleMs) return false;
1121
+ this.sql.exec(
1122
+ `INSERT INTO meta (k, v) VALUES ('last_activity_at', ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v`,
1123
+ new Date(now).toISOString(),
1124
+ );
1125
+ return true;
1126
+ }
1127
+
1128
+ /**
1129
+ * The facts this workspace can state about itself. No customer content, ever — counts and
1130
+ * scalars only, because this is read by an operator-facing isolate and a comment body has
1131
+ * no business being anywhere near one.
1132
+ *
1133
+ * ⚠️ IT MUST NOT WRITE, AND MUST NOT init(). `ns.get(ns.idFromName(name))` always hands
1134
+ * back a live stub, and a Durable Object comes into existence on its first WRITE — so a
1135
+ * status call on a typo or a released slug that applied the schema would spring an empty
1136
+ * workspace into being and then report it as real. Everything below reads, and a workspace
1137
+ * that has never been written to answers `hasStoredData: false`.
1138
+ */
1139
+ status() {
1140
+ // OUR tables, not any table. A Durable Object's storage carries bookkeeping of its
1141
+ // own — `_cf_*` in production, and `__miniflare_do_name` under a local run — so
1142
+ // "does this object have any table at all" answers `true` for an object nobody has
1143
+ // ever written to, which is the exact question this must not get wrong.
1144
+ const ours = new Set(TENANT_SCHEMA
1145
+ .map((stmt) => (/CREATE TABLE IF NOT EXISTS (\w+)/.exec(stmt) || [])[1])
1146
+ .filter(Boolean));
1147
+ const tables = new Set([...this.sql.exec(
1148
+ `SELECT name FROM sqlite_master WHERE type = 'table'`,
1149
+ )].map((r) => String(r.name)).filter((n) => ours.has(n)));
1150
+ if (!tables.has("meta")) return { provisioned: false, hasStoredData: tables.size > 0 };
1151
+
1152
+ const meta = {};
1153
+ for (const row of this.sql.exec(`SELECT k, v FROM meta`)) meta[row.k] = row.v;
1154
+ const count = (sql, ...p) => {
1155
+ const rows = [...this.sql.exec(sql, ...p)];
1156
+ return rows.length ? Number(rows[0].n) : 0;
1157
+ };
1158
+ // `dbstat` is a compile-time SQLite option, so it is asked for rather than assumed: a
1159
+ // null here means "this runtime cannot tell me", which is a different answer from zero.
1160
+ let doStoredBytes = null;
1161
+ try { doStoredBytes = count(`SELECT SUM(pgsize) AS n FROM dbstat`); } catch (e) { doStoredBytes = null; }
1162
+
1163
+ return {
1164
+ provisioned: !!meta.provisioned_at,
1165
+ hasStoredData: true,
1166
+ // Reported as fields rather than omitted, so a caller never has to tell "not
1167
+ // suspended" from "this build is too old to know".
1168
+ suspended: meta.suspended === "1",
1169
+ suspendedReason: meta.suspended_reason || null,
1170
+ suspendedAt: meta.suspended_at || null,
1171
+ // A tombstone. The data is all still here until `purgeAfter`, which is why the two are
1172
+ // reported together — "deleted" without the date reads as "gone", and for thirty days
1173
+ // it is not.
1174
+ deleted: !!meta.deleted_at,
1175
+ deletedAt: meta.deleted_at || null,
1176
+ purgeAfter: meta.purge_after || null,
1177
+ // This address stopped being the workspace's address, and when. Reported here and not
1178
+ // where the workspace went: see renameAway for why that pointer is not kept.
1179
+ moved: !!meta.moved_at,
1180
+ movedAt: meta.moved_at || null,
1181
+ createdAt: meta.created_at || null,
1182
+ lastActivityAt: meta.last_activity_at || null,
1183
+ // The last time a sign-in brought this workspace back, what it had been paused for,
1184
+ // and who by. A dormancy resume CLEARS the suspension row, so without these three
1185
+ // nothing afterwards can say it ever happened. `resumedBy` is the one-way person id,
1186
+ // never an address — see resumeOnSignIn.
1187
+ resumedAt: meta.resumed_at || null,
1188
+ resumedFrom: meta.resumed_from || null,
1189
+ resumedBy: meta.resumed_by || null,
1190
+ // The chosen hostname this workspace also answers at, and when it was claimed.
1191
+ // Public by design — it is in every redirect the generated address serves.
1192
+ canonicalHost: meta.canonical_host || null,
1193
+ canonicalHostAt: meta.canonical_host_at || null,
1194
+ // The onboarding completion signal's workspace half — the first REAL publish, never a
1195
+ // seed write. Reported here so an operator can count connected workspaces without
1196
+ // asking each one a second question; the member half is `onboardingStatus()`.
1197
+ firstPublishAt: meta.first_publish_at || null,
1198
+ plan: meta.plan || DEFAULT_PLAN,
1199
+ quotas: this.quotas(),
1200
+ members: tables.has("members") ? count(`SELECT COUNT(*) AS n FROM members WHERE removed_at IS NULL`) : 0,
1201
+ invites: tables.has("invites") ? count(`SELECT COUNT(*) AS n FROM invites`) : 0,
1202
+ threads: tables.has("overlay") ? count(`SELECT COUNT(*) AS n FROM overlay WHERE family = 'comments'`) : 0,
1203
+ boards: tables.has("overlay") ? count(`SELECT COUNT(*) AS n FROM overlay WHERE family = 'boards'`) : 0,
1204
+ images: tables.has("overlay") ? count(`SELECT COUNT(*) AS n FROM overlay WHERE family = 'assets'`) : 0,
1205
+ doStoredBytes,
1206
+ };
1207
+ }
1208
+
1209
+ /** Whether this workspace exists as far as anything else is concerned. */
1210
+ isProvisioned() {
1211
+ return [...this.sql.exec(`SELECT v FROM meta WHERE k = 'provisioned_at'`)].length > 0;
1212
+ }
1213
+
1214
+ // ── The operator verbs ────────────────────────────────────────────────────────────
1215
+ //
1216
+ // `B-control-plane-verbs`. Four things somebody can do TO a workspace from outside it,
1217
+ // and one property they all share: THEY NEVER CREATE ONE. Each takes its name from a URL
1218
+ // path an operator typed, and a typo that provisioned a workspace would be a workspace
1219
+ // nobody knows exists, holding a signing key and a quota row, invisible to every list.
1220
+ // So each refuses `not-provisioned` by reading `meta` the way `status()` does, before
1221
+ // `init()` is anywhere near the call.
1222
+
1223
+ /**
1224
+ * Stop this workspace serving, with a reason a person wrote.
1225
+ *
1226
+ * ⚠️ SETTING THE FLAG IS NOT THE ENFORCEMENT. Nothing consults it on the request path
1227
+ * yet — that is `B-suspend-check-in-resolver`, which reads it on every resolve and
1228
+ * short-circuits before any content, login gate or publish endpoint runs. Until then a
1229
+ * suspension is a fact recorded here and reported by `status()`, and an operator reading
1230
+ * "suspended" must not conclude the site is dark.
1231
+ *
1232
+ * Re-suspending an already-suspended workspace does NOT restart the clock or replace the
1233
+ * reason, for the same reason re-freezing does not: a script that retries its first step
1234
+ * must not lose why the suspension started or when.
1235
+ */
1236
+ suspend(reason = "", at = new Date().toISOString()) {
1237
+ if (!this.hasMeta()) return { ok: false, error: "not-provisioned" };
1238
+ if (!this.isProvisioned()) return { ok: false, error: "not-provisioned" };
1239
+ const already = this.readMeta("suspended") === "1";
1240
+ if (already) {
1241
+ return { ok: true, changed: false, since: this.readMeta("suspended_at"), reason: this.readMeta("suspended_reason") };
1242
+ }
1243
+ this.writeMeta("suspended", "1");
1244
+ this.writeMeta("suspended_at", at);
1245
+ this.writeMeta("suspended_reason", String(reason || ""));
1246
+ return { ok: true, changed: true, since: at, reason: String(reason || "") };
1247
+ }
1248
+
1249
+ /**
1250
+ * Let it serve again, and say how long it did not.
1251
+ *
1252
+ * The duration is returned because somebody planned around it — a customer was told
1253
+ * "back within the hour", and "about an hour" from memory is not a number. Same reason
1254
+ * `augur thaw` prints one.
1255
+ *
1256
+ * ⚠️ IT REFUSES TO RESUME A DELETED WORKSPACE. A delete suspends as part of tombstoning
1257
+ * (see `deleteWorkspace`), so resume would otherwise be an undelete that puts a
1258
+ * tombstoned workspace back on the air while its purge date still stands — a workspace
1259
+ * serving traffic with a scheduled erasure behind it. Undeleting is `restore`, which is
1260
+ * `E-gdpr-delete-tenant`'s to build, and it is a different act with a different audit line.
1261
+ */
1262
+ resume(at = new Date().toISOString()) {
1263
+ if (!this.hasMeta()) return { ok: false, error: "not-provisioned" };
1264
+ if (!this.isProvisioned()) return { ok: false, error: "not-provisioned" };
1265
+ if (this.readMeta("deleted_at")) return { ok: false, error: "deleted" };
1266
+ const since = this.readMeta("suspended_at");
1267
+ if (this.readMeta("suspended") !== "1") return { ok: true, changed: false, suspendedMs: 0 };
1268
+ for (const k of ["suspended", "suspended_at", "suspended_reason"]) this.clearMeta(k);
1269
+ const ms = since ? Math.max(0, Date.parse(at) - Date.parse(since)) : 0;
1270
+ return { ok: true, changed: true, suspendedMs: Number.isFinite(ms) ? ms : 0, since, until: at };
1271
+ }
1272
+
1273
+ /**
1274
+ * Bring this workspace back because an admin just signed in — if, and ONLY if, the
1275
+ * suspension is the one the lifecycle page promises a sign-in lifts.
1276
+ *
1277
+ * `E-dormancy-resume`. The page says "Sign in. That is the whole procedure", and until
1278
+ * this existed that sentence was prose: the flag was set by an operator verb and cleared
1279
+ * by an operator verb, and no sign-in touched it.
1280
+ *
1281
+ * ── ⚠️ THIS OBJECT DECIDES THE REASON; THE WORKER DECIDES THE PERSON ────────────────
1282
+ *
1283
+ * Each side checks the half it alone can know, and neither is asked to take the other's
1284
+ * word for its own half:
1285
+ *
1286
+ * · THE WORKER authenticated the sign-in and holds the roster, so it says who this is
1287
+ * and what role they have. This object cannot re-derive that — a live instance still
1288
+ * authenticates against the config roster, not against `members` — so `role` arrives
1289
+ * as a parameter. That is not a hole: a Durable Object stub is not routable, so the
1290
+ * only caller that can reach this is the worker that did the authenticating.
1291
+ * · THIS OBJECT holds the live suspension row. The worker's copy comes out of a cache
1292
+ * with a TTL, so it can be seconds stale — and a workspace suspended for dormancy at
1293
+ * 10:00:00 and re-suspended for the acceptable-use policy at 10:00:03 still reads as
1294
+ * dormancy on the worker's copy. Reading the reason HERE, inside the single-threaded
1295
+ * object that owns the row, is what makes that race unreachable rather than unlikely.
1296
+ *
1297
+ * ── A REFUSAL IS NOT AN ERROR ───────────────────────────────────────────────────────
1298
+ *
1299
+ * This rides a sign-in that has already succeeded, and a person must never be refused
1300
+ * entry because their workspace was not eligible to come back. So every "no" is a 200
1301
+ * carrying `{resumed: false, why}` and the caller drops it. That is the opposite of
1302
+ * `controlResult`'s rule, and deliberately: an operator verb's verdict is read off the
1303
+ * status line into an audit log, and this is not an operator verb.
1304
+ */
1305
+ resumeOnSignIn({ role = "", by = "" } = {}, at = new Date().toISOString()) {
1306
+ if (!this.hasMeta()) return { resumed: false, why: "not-provisioned" };
1307
+ if (!this.isProvisioned()) return { resumed: false, why: "not-provisioned" };
1308
+ // ⚠️ ONLY AN ADMIN. The promise is "the first successful sign-in by an admin"; an
1309
+ // editor or a viewer signing in is activity, which `touchActivity` already records, and
1310
+ // is not a decision to put the public site back on the air.
1311
+ if (role !== "admin") return { resumed: false, why: "not-an-admin" };
1312
+ // A tombstone is not a pause. `resume()` refuses one as well — this is the earlier and
1313
+ // more legible refusal, and unlike that one it does not depend on the reason column
1314
+ // still saying `deleted`.
1315
+ if (this.readMeta("deleted_at")) return { resumed: false, why: "deleted" };
1316
+ if (this.readMeta("suspended") !== "1") return { resumed: false, why: "not-suspended" };
1317
+ const reason = this.readMeta("suspended_reason") || "";
1318
+ // THE DISCRIMINATOR. See DORMANCY_SUSPENSION_REASONS for why it is an allowlist and why
1319
+ // the match is exact — a reason nobody has added is inert here, not open.
1320
+ if (!DORMANCY_SUSPENSION_REASONS.includes(reason)) {
1321
+ return { resumed: false, why: "reason-not-in-allowlist", reason };
1322
+ }
1323
+ const body = () => {
1324
+ const out = this.resume(at);
1325
+ if (!out.ok || !out.changed) return { resumed: false, why: "not-suspended" };
1326
+ // ⚠️ THE RECORD, and it has to be written here because `resume()` CLEARS the reason
1327
+ // and the date. Without these three, nothing afterwards can say the workspace was
1328
+ // ever paused or who lifted it — and "an admin signed in and the site came back" is
1329
+ // exactly the event an incident asks about a week later. A SNAPSHOT, not a log: a
1330
+ // second dormancy round trip overwrites it, because the last one is what anybody
1331
+ // needs, and a growing audit table on a request path is a different decision.
1332
+ // `by` is the one-way person id the rest of the engine stamps provenance with, never
1333
+ // an address: `status()` reads out to an operator console.
1334
+ this.writeMeta("resumed_at", at);
1335
+ this.writeMeta("resumed_from", reason);
1336
+ this.writeMeta("resumed_by", String(by || ""));
1337
+ return {
1338
+ resumed: true, why: reason, reason,
1339
+ suspendedMs: out.suspendedMs, since: out.since, until: at,
1340
+ };
1341
+ };
1342
+ // One transaction, so a half-resume — flag cleared, record missing — cannot exist.
1343
+ return this.ctx.storage.transactionSync ? this.ctx.storage.transactionSync(body) : body();
1344
+ }
1345
+
1346
+ /**
1347
+ * Cut off everything this workspace has handed out, in one transaction.
1348
+ *
1349
+ * The verb for "a credential of this workspace's is in somebody else's hands and we do
1350
+ * not know which one". So it takes away BOTH kinds at once rather than offering a choice:
1351
+ * an operator who has to pick which half to rotate under that pressure will pick wrong,
1352
+ * and the cost of taking both is that people sign in again and CI re-logs-in once.
1353
+ *
1354
+ * ⚠️ ONE HALF OF THIS IS REAL TODAY AND ONE IS NOT, and the difference matters more than
1355
+ * the code:
1356
+ *
1357
+ * · PUBLISH TOKENS — really gone. The rows are deleted, and a bearer is only ever a row.
1358
+ * · SESSIONS — NOT YET. A session cookie HMACs on the Worker-wide `env.SESSION_SECRET`
1359
+ * (`userToken`, src/_worker.js), not on this key, so rotating the key here invalidates
1360
+ * nothing a browser is holding. The key has existed per workspace since provisioning
1361
+ * precisely so that swap is a read change rather than a migration, and it belongs with
1362
+ * putting this object on the request path (`B-cross-workspace-signin`). Until it
1363
+ * happens, ROTATE IS NOT A SESSION KILL, and an operator responding to a compromise
1364
+ * must reset the affected people's credentials as well — which does end their sessions,
1365
+ * because the token binds to each person's own effective secret.
1366
+ *
1367
+ * `test/tenant-verbs.test.mjs` pins that gap rather than describing it, so the day the read
1368
+ * swaps over, the failing test is the one that tells you rotate became a session kill.
1369
+ */
1370
+ rotate(at = new Date().toISOString()) {
1371
+ if (!this.hasMeta()) return { ok: false, error: "not-provisioned" };
1372
+ if (!this.isProvisioned()) return { ok: false, error: "not-provisioned" };
1373
+ const body = () => {
1374
+ const before = [...this.sql.exec(`SELECT COUNT(*) AS n FROM publish_tokens`)][0];
1375
+ const tokens = before ? Number(before.n) : 0;
1376
+ this.sql.exec(`DELETE FROM publish_tokens`);
1377
+ const key = newSigningKey();
1378
+ this.sql.exec(
1379
+ `INSERT INTO signing_keys (purpose, key, created_at, rotated_at) VALUES ('session', ?, ?, ?)
1380
+ ON CONFLICT(purpose) DO UPDATE SET key = excluded.key, rotated_at = excluded.rotated_at`,
1381
+ key, at, at,
1382
+ );
1383
+ return { ok: true, publishTokensRevoked: tokens, rotatedAt: at, sessionsEnded: false };
1384
+ };
1385
+ return this.ctx.storage.transactionSync ? this.ctx.storage.transactionSync(body) : body();
1386
+ }
1387
+
1388
+ /**
1389
+ * Tombstone this workspace and set the date its data is erased.
1390
+ *
1391
+ * ⚠️ IT DELETES NOTHING. That is the point of a tombstone: for the grace window the data
1392
+ * is all still here, so a delete somebody regrets is a support mail rather than a
1393
+ * catastrophe. Actually erasing it — the spaces this workspace owns, this object's
1394
+ * storage, dedup-safely — is `E-gdpr-delete-tenant`, and `destroy()` is the primitive it
1395
+ * uses. Which spaces those are comes from `publishedSpaces()` below, because the store's
1396
+ * keys name a space and no key in it names a workspace.
1397
+ *
1398
+ * ⚠️ A DELETE SUSPENDS, and it is the same flag rather than a second one. Otherwise
1399
+ * everything that has to refuse a dead workspace — the resolver, the publish endpoints,
1400
+ * the realtime join — would need two checks, and the second check is the one somebody
1401
+ * forgets. `reason` says which it was, so an operator reading `status()` can still tell a
1402
+ * suspension from a tombstone.
1403
+ */
1404
+ deleteWorkspace(at = new Date().toISOString(), graceMs = DELETE_GRACE_MS) {
1405
+ if (!this.hasMeta()) return { ok: false, error: "not-provisioned" };
1406
+ if (!this.isProvisioned()) return { ok: false, error: "not-provisioned" };
1407
+ const existing = this.readMeta("deleted_at");
1408
+ if (existing) {
1409
+ return { ok: true, changed: false, deletedAt: existing, purgeAfter: this.readMeta("purge_after") };
1410
+ }
1411
+ const purgeAfter = new Date(Date.parse(at) + graceMs).toISOString();
1412
+ const body = () => {
1413
+ this.writeMeta("deleted_at", at);
1414
+ this.writeMeta("purge_after", purgeAfter);
1415
+ this.writeMeta("suspended", "1");
1416
+ // ⚠️ THE REASON IS REPLACED AND THE DATE IS NOT, and the asymmetry is the point.
1417
+ // Somebody reading this workspace has to know it is a tombstone rather than a pause,
1418
+ // so "deleted" wins over whatever it was suspended for. But how long it has been dark
1419
+ // is a different fact from when it was deleted, `deleted_at` already records the
1420
+ // second, and overwriting the first would lose it: a workspace suspended for the AUP
1421
+ // at 10:00 and deleted at 12:00 has been dark since 10:00, and that is the number
1422
+ // somebody asks for. (Real workerd caught this — the first version wrote both.)
1423
+ if (!this.readMeta("suspended_at")) this.writeMeta("suspended_at", at);
1424
+ this.writeMeta("suspended_reason", "deleted");
1425
+ this.sql.exec(`DELETE FROM publish_tokens`);
1426
+ return { ok: true, changed: true, deletedAt: at, purgeAfter };
1427
+ };
1428
+ return this.ctx.storage.transactionSync ? this.ctx.storage.transactionSync(body) : body();
1429
+ }
1430
+
1431
+ /**
1432
+ * This address is not this workspace's address any more.
1433
+ *
1434
+ * A workspace's hostname is the FIRST LABEL of the Host header and the resolver turns that
1435
+ * label straight into this object's name (`idFromName`), with no lookup in between and no
1436
+ * round trip in front of every request. So a workspace cannot be given a second address:
1437
+ * a different label is a different object. What "rename" means here is therefore the
1438
+ * CUT-OVER and not the move — this object stops answering at this address, permanently —
1439
+ * and moving a workspace's state to the object behind its new address is what the
1440
+ * migration machinery already does (`augur migrate`, docs/migration-freeze.md). A second,
1441
+ * silent copy of that inside a verb would be the worse of the two.
1442
+ *
1443
+ * ⚠️ IT DOES NOT RECORD WHERE THE WORKSPACE WENT, and that is the whole point rather than
1444
+ * an omission. The most common honest reason to change an address that nobody chose and
1445
+ * nobody can guess is that the current one reached somebody it should not have. A
1446
+ * forwarding pointer stored here is one JSON field away from being served, and the day it
1447
+ * is served the change has undone itself for exactly the person it was made to get away
1448
+ * from. Which address replaced which is a fact the operator's registry keeps, where the
1449
+ * people who can act on it are the only ones who can read it.
1450
+ *
1451
+ * ⚠️ IT REVOKES NOTHING. This object still holds the only copy of the workspace's roster,
1452
+ * threads and boards until something moves them, so taking away the credential an export
1453
+ * runs on at the moment the address goes dark is the trap `SUSPENDED_ALLOWED` exists to
1454
+ * avoid, one step worse. The store is reachable by anything holding the namespace binding;
1455
+ * only the public address is gone.
1456
+ *
1457
+ * ⚠️ A TOMBSTONE CANNOT BE RENAMED AWAY. A deleted workspace is already promising its
1458
+ * members a page with an erasure date on it for thirty days, and a bare 404 in its place
1459
+ * takes that page away from the people the grace window exists for.
1460
+ *
1461
+ * Idempotent: the same call twice reports the first move's timestamp and changes nothing,
1462
+ * so a control plane retrying its own step cannot restate when the address went dark.
1463
+ */
1464
+ renameAway(at = new Date().toISOString()) {
1465
+ if (!this.hasMeta()) return { ok: false, error: "not-provisioned" };
1466
+ if (!this.isProvisioned()) return { ok: false, error: "not-provisioned" };
1467
+ if (this.readMeta("deleted_at")) return { ok: false, error: "deleted" };
1468
+ const already = this.readMeta("moved_at");
1469
+ if (already) return { ok: true, changed: false, movedAt: already };
1470
+ this.writeMeta("moved_at", at);
1471
+ return { ok: true, changed: true, movedAt: at };
1472
+ }
1473
+
1474
+ /**
1475
+ * Give this workspace a SECOND, chosen hostname — the canonical one — while its own
1476
+ * generated address keeps working as a redirect.
1477
+ *
1478
+ * `B-claim-platform-subdomain`. The rules, each of which is a decision and not a default:
1479
+ *
1480
+ * · A CLAIM MAY ONLY TAKE A HOSTNAME THE LITERAL RESOLVER DOES NOT RESOLVE. On a
1481
+ * deployment with a host suffix, `tenantLabelFromHost` is asked first and a non-null
1482
+ * answer refuses the claim — so the alias table and the literal resolver are DISJOINT
1483
+ * by construction. A reserved label (`demo.<suffix>`) is claimable, which is the whole
1484
+ * point of the verb; a generated-shape label is not, because the literal resolver
1485
+ * already resolves it, which keeps the generator's namespace clean of aliases and
1486
+ * means an alias can never shadow or race a workspace that exists or could exist.
1487
+ * WHO may aim this verb at a reserved name is the caller's question — the operator
1488
+ * gate in the control plane — not this object's; the resolver's own reserved refusal
1489
+ * for every self-service path is untouched.
1490
+ * · THE GENERATED ADDRESS IS NOT FREED. Nothing here touches the registry or the
1491
+ * resolver's view of this workspace's own label; the front door redirects it (see
1492
+ * the canonicalHost read in src/_worker.js). A freed label is a label somebody else
1493
+ * can be handed, and every link ever published to it would then resolve to a
1494
+ * stranger's workspace — the same reasoning as RELEASE_COOLDOWN_MS.
1495
+ * · AN EXISTING ALIAS FOR ANOTHER WORKSPACE REFUSES, never re-points. The durable
1496
+ * compare-and-swap lives in the caller's registry (the alias row's primary key);
1497
+ * this check is the belt on the store the resolver actually reads.
1498
+ * · ONE CANONICAL HOSTNAME PER WORKSPACE. A second, different claim refuses rather
1499
+ * than silently moving the front door; un-claiming is a separate decision nobody
1500
+ * has made yet.
1501
+ *
1502
+ * Idempotent: re-claiming the SAME hostname re-writes the KV row (so a crashed claim
1503
+ * converges on retry) and reports changed: false.
1504
+ */
1505
+ async claimHostname(hostname, at = new Date().toISOString()) {
1506
+ if (!this.hasMeta()) return { ok: false, error: "not-provisioned" };
1507
+ if (!this.isProvisioned()) return { ok: false, error: "not-provisioned" };
1508
+ if (this.readMeta("deleted_at")) return { ok: false, error: "deleted" };
1509
+ const host = normalizeClaimHostname(hostname);
1510
+ if (!host) return { ok: false, error: "bad-hostname" };
1511
+ const suffix = this.env && typeof this.env.TENANT_HOST_SUFFIX === "string"
1512
+ ? this.env.TENANT_HOST_SUFFIX.trim() : "";
1513
+ // Only a deployment that resolves workspaces by hostname has an alias table to write.
1514
+ if (!suffix) return { ok: false, error: "no-host-routing" };
1515
+ // The disjointness rule. `demo.<suffix>` answers null here (reserved) and is claimable;
1516
+ // `misty-fox-123.<suffix>` answers a label and is not, whoever holds it.
1517
+ const extra = parseReservedLabels(this.env && typeof this.env.RESERVED_LABELS_EXTRA === "string"
1518
+ ? this.env.RESERVED_LABELS_EXTRA : "");
1519
+ if (tenantLabelFromHost(host, suffix, extra) !== null) {
1520
+ return { ok: false, error: "hostname-resolves-literally" };
1521
+ }
1522
+ const ws = this.workspaceId();
1523
+ if (!ws) return { ok: false, error: "not-provisioned" };
1524
+ const current = this.readMeta("canonical_host");
1525
+ if (current && current !== host) {
1526
+ return { ok: false, error: "already-claimed", canonicalHost: current };
1527
+ }
1528
+ // The store the resolver reads. Same binding the worker's kvForRaw answers with; a
1529
+ // deployment that resolves by Host always has one (the login gate depends on it).
1530
+ const kv = this.env && this.env.COMMENTS;
1531
+ if (!kv) return { ok: false, error: "no-alias-store" };
1532
+ let row = null;
1533
+ try {
1534
+ row = JSON.parse((await kv.get(hostAliasKey(host))) || "null");
1535
+ } catch (e) {
1536
+ // An unreadable store is not evidence the hostname is free. Refuse, don't guess.
1537
+ return { ok: false, error: "alias-store-unreadable" };
1538
+ }
1539
+ if (row && row.workspace && row.workspace !== ws) {
1540
+ return { ok: false, error: "alias-taken" };
1541
+ }
1542
+ await kv.put(hostAliasKey(host), JSON.stringify({ workspace: ws, at }));
1543
+ if (current === host) {
1544
+ return { ok: true, changed: false, canonicalHost: host, claimedAt: this.readMeta("canonical_host_at") };
1545
+ }
1546
+ this.writeMeta("canonical_host", host);
1547
+ this.writeMeta("canonical_host_at", at);
1548
+ return { ok: true, changed: true, canonicalHost: host, claimedAt: at };
1549
+ }
1550
+
1551
+ /**
1552
+ * A verb's answer, as HTTP.
1553
+ *
1554
+ * ⚠️ A REFUSAL IS A 4xx, NOT AN `ok: false` INSIDE A 200. The control plane logs a verb's
1555
+ * verdict from the status line (`operator-route.js`), so a refusal wearing a 200 would be
1556
+ * written into the audit log as a suspension that happened. 409 rather than 404 because
1557
+ * the name was fine and the state was not — a 404 reads as "wrong URL" to whoever is
1558
+ * looking at it three months later.
1559
+ */
1560
+ controlResult(out) {
1561
+ if (out && out.ok === false) {
1562
+ return Response.json(out, { status: out.error === "not-provisioned" ? 404 : 409 });
1563
+ }
1564
+ return Response.json(out);
1565
+ }
1566
+
1567
+ /**
1568
+ * Erase one person from this workspace's record of itself.
1569
+ *
1570
+ * `E-gdpr-purge-user`. The same sweep the worker's admin route runs, reachable as a
1571
+ * workspace verb — because under Decision 2 an erasure has to happen in EVERY workspace
1572
+ * the account belongs to, and only the control plane knows which those are. An erasure
1573
+ * that could only be run by somebody who happens to administer each workspace is an
1574
+ * erasure that does not happen.
1575
+ *
1576
+ * ⚠️ IT REFUSES ON AN ID COLLISION RATHER THAN OVER-REDACTING. Messages carry a 32-bit
1577
+ * one-way hash of the address, so two addresses can share one, and a sweep keyed on it
1578
+ * would redact an innocent third party. A machine cannot choose between them; this names
1579
+ * the count and stops, which turns a silent over-redaction into a question for a person.
1580
+ * The roster it checks is THIS workspace's members, including removed ones — a person
1581
+ * removed last year still has messages, and their id still collides.
1582
+ *
1583
+ * ⚠️ IT DOES NOT TOUCH MEMBERSHIP. Erasure and removal are different acts and conflating
1584
+ * them is wrong in both directions: `remove` revokes access and leaves the record, this
1585
+ * de-identifies the record and says nothing about access. A caller wanting both does both.
1586
+ */
1587
+ purgeAuthor(email, at = new Date().toISOString()) {
1588
+ if (!this.hasMeta()) return { ok: false, reason: "not-provisioned" };
1589
+ const addr = String(email || "").trim().toLowerCase();
1590
+ if (!addr) return { ok: false, reason: "bad-address" };
1591
+ const id = personIdFor(addr);
1592
+
1593
+ // Every member ever, not just the active ones: a person removed last year still has
1594
+ // messages in these threads, and their id still collides.
1595
+ const everyone = [...this.sql.exec(`SELECT email FROM members`)];
1596
+ const clashes = idCollisions(everyone, addr);
1597
+ if (clashes.length) return { ok: false, reason: "id-collision", id, collidesWith: clashes.length };
1598
+
1599
+ // ONE TRANSACTION over the whole sweep. A half-finished erasure is the worst outcome
1600
+ // here: some pages redacted, some not, and a caller that reports success either way. The
1601
+ // handler is single-threaded so nothing interleaves, and the transaction is what makes a
1602
+ // throw mid-sweep leave the record as it was rather than partly rewritten.
1603
+ const body = () => {
1604
+ let redacted = 0, scanned = 0;
1605
+ const pathsTouched = [];
1606
+ const rows = [...this.sql.exec(
1607
+ `SELECT scope, k, v, rev FROM overlay WHERE family = 'comments'`,
1608
+ )];
1609
+ for (const row of rows) {
1610
+ scanned++;
1611
+ let threads = null;
1612
+ try { threads = JSON.parse(row.v); } catch (e) { continue; }
1613
+ const res = purgeThreads(threads, id);
1614
+ if (!res.redacted) continue;
1615
+ // `rev` is bumped so anything holding an older revision of this page loses its
1616
+ // compare-and-swap and re-reads, rather than writing the un-redacted copy back.
1617
+ this.sql.exec(
1618
+ `UPDATE overlay SET v = ?3, at = ?4, rev = rev + 1
1619
+ WHERE family = 'comments' AND scope = ?1 AND k = ?2`,
1620
+ row.scope, row.k, JSON.stringify(res.threads), at,
1621
+ );
1622
+ redacted += res.redacted;
1623
+ pathsTouched.push(row.k);
1624
+ }
1625
+ // The lastseen stamp is an address in a KEY, so it is erased rather than redacted.
1626
+ this.sql.exec(`DELETE FROM lastseen WHERE email = ?`, addr);
1627
+ return { ok: true, id, redacted, scanned, pathsTouched };
1628
+ };
1629
+ return this.ctx.storage.transactionSync ? this.ctx.storage.transactionSync(body) : body();
1630
+ }
1631
+
1632
+ /**
1633
+ * Is this workspace paused, and why — three rows and nothing else.
1634
+ *
1635
+ * ⚠️ SEPARATE FROM `status()` ON PURPOSE. This one is on the REQUEST PATH: the front door
1636
+ * asks it before serving anything (`B-suspend-check-in-resolver`), so it must stay three
1637
+ * indexed reads. `status()` counts members, invites, threads, boards and images and asks
1638
+ * `dbstat` for a page total — fine for an operator console, absurd behind every page view.
1639
+ * If a field is ever wanted here, weigh it against being run on every request of every
1640
+ * workspace, not against how useful it would be on a dashboard.
1641
+ *
1642
+ * A workspace that does not exist answers `suspended: false`, the same as a live one.
1643
+ * Whether the name means anything is the front door's other question and this is not it —
1644
+ * and it must not create the workspace to find out, so nothing here calls `init()`.
1645
+ */
1646
+ suspension() {
1647
+ if (!this.hasMeta()) {
1648
+ return { provisioned: false, suspended: false, reason: null, at: null, deleted: false, moved: false, canonicalHost: null };
1649
+ }
1650
+ return {
1651
+ // ⚠️ THE ONE EXISTENCE ANSWER THE FRONT DOOR READS. `F-seed-pack-at-provision` writes a
1652
+ // workspace's content to the store BEFORE the commit that creates the workspace, and
1653
+ // that order is only safe if an uncommitted workspace serves nothing: the front door
1654
+ // turns `provisioned: false` into the answer a hostname naming nobody gets. It rides
1655
+ // this document because the front door already pays this read on every request.
1656
+ provisioned: !!this.readMeta("provisioned_at"),
1657
+ suspended: this.readMeta("suspended") === "1",
1658
+ reason: this.readMeta("suspended_reason"),
1659
+ at: this.readMeta("suspended_at"),
1660
+ deleted: !!this.readMeta("deleted_at"),
1661
+ // ⚠️ A BOOLEAN, NOT THE NEW ADDRESS. This answer travels to the front door on every
1662
+ // request; the new address must not, or the old hostname becomes the way to find it.
1663
+ // See renameAway.
1664
+ moved: !!this.readMeta("moved_at"),
1665
+ // ⚠️ AND THIS ONE IS THE ADDRESS, DELIBERATELY — the opposite decision from `moved`,
1666
+ // for the opposite reason. A rename hides where the workspace went; a claim exists to
1667
+ // ADVERTISE it: the generated address keeps working precisely so every old link can be
1668
+ // walked to the canonical one, and the front door needs the destination to do it.
1669
+ // Riding this answer keeps the redirect free — the front door already pays this read.
1670
+ canonicalHost: this.readMeta("canonical_host") || null,
1671
+ };
1672
+ }
1673
+
1674
+ /** Is the `meta` table there at all? The question `status()` asks, for the same reason. */
1675
+ hasMeta() {
1676
+ return [...this.sql.exec(
1677
+ `SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'meta'`,
1678
+ )].length > 0;
1679
+ }
1680
+
1681
+ readMeta(k) {
1682
+ const rows = [...this.sql.exec(`SELECT v FROM meta WHERE k = ?`, k)];
1683
+ return rows.length ? rows[0].v : null;
1684
+ }
1685
+
1686
+ writeMeta(k, v) {
1687
+ this.sql.exec(
1688
+ `INSERT INTO meta (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v`, k, String(v),
1689
+ );
1690
+ }
1691
+
1692
+ clearMeta(k) {
1693
+ this.sql.exec(`DELETE FROM meta WHERE k = ?`, k);
1694
+ }
1695
+
1696
+ /**
1697
+ * This workspace's session-signing key, or null before it is provisioned.
1698
+ *
1699
+ * Nothing in the request path reads this yet — `userToken()` still uses the Worker-wide
1700
+ * `env.SESSION_SECRET`, and moving it over belongs with putting the object in the
1701
+ * request path at all. What provisioning buys today is that the key EXISTS per
1702
+ * workspace from the first moment, so that move is a read swap and not a migration of
1703
+ * every live session.
1704
+ */
1705
+ sessionKey() {
1706
+ const rows = [...this.sql.exec(`SELECT key FROM signing_keys WHERE purpose = 'session'`)];
1707
+ return rows.length ? rows[0].key : null;
1708
+ }
1709
+
1710
+ /**
1711
+ * This workspace's account-store bearer, or null before the control plane has
1712
+ * delivered one via the `account-key` verb. What `/__enter` (Task 6) reads to
1713
+ * authenticate to the account store — internal only, never exposed on any
1714
+ * external/public route.
1715
+ *
1716
+ * `hasMeta()`-guarded like `suspension()`: an object nobody has ever provisioned has no
1717
+ * `meta` table at all, and reading straight off it would throw instead of answering
1718
+ * "no key yet" — the same distinction `suspension()` draws for the same reason.
1719
+ */
1720
+ accountKey() {
1721
+ return this.hasMeta() ? this.readMeta("account_key") : null;
1722
+ }
1723
+
1724
+ /** The admins and editors and viewers who have not been removed. */
1725
+ members() {
1726
+ return [...this.sql.exec(
1727
+ `SELECT email, role, name FROM members WHERE removed_at IS NULL ORDER BY added_at`,
1728
+ )];
1729
+ }
1730
+
1731
+ // ── the identity families the request path now READS from here ─────────────
1732
+ //
1733
+ // `B-kv-read-cutover`. `B-kv-to-do-migration-tool` gave these tables a write path and
1734
+ // nothing that read it. These are the reads, and each is the shape ONE accessor in the
1735
+ // worker wants — a verb no call site asks for is a verb whose semantics nobody has
1736
+ // thought about, which is the rule the overlay verbs above already follow.
1737
+ //
1738
+ // ⚠️ AN INVITE'S EXPIRY IS READ TOLERANTLY AND WRITTEN STRICTLY. The column is TEXT
1739
+ // holding an ISO stamp, and that is what everything here writes. But the COPY that
1740
+ // filled this table from KV wrote what KV held, and KV holds epoch milliseconds — a
1741
+ // number, stringified, which `Date.parse` answers `NaN` for. A strict read would
1742
+ // therefore have called every invite carried across by a copy invalid, silently, and
1743
+ // the first anyone would know is somebody clicking a link that has not expired. So
1744
+ // `stampMs` accepts both, and `src/kv-identity.mjs` no longer produces the second.
1745
+
1746
+ /**
1747
+ * An invite by its token HASH, or null if there is no live one.
1748
+ *
1749
+ * The raw token never reaches this object — see `inviteHash` in src/_worker.js. That is
1750
+ * the same contract the copy hashes on, so a link minted before the reads moved resolves
1751
+ * after them.
1752
+ */
1753
+ inviteRead(tokenHash, nowMs = Date.now()) {
1754
+ if (!tokenHash) return null;
1755
+ const rows = [...this.sql.exec(
1756
+ `SELECT email, expires_at FROM invites WHERE token_hash = ?`, String(tokenHash),
1757
+ )];
1758
+ if (!rows.length) return null;
1759
+ const exp = stampMs(rows[0].expires_at);
1760
+ if (exp === null || exp <= nowMs) return null;
1761
+ return rows[0].email;
1762
+ }
1763
+
1764
+ /**
1765
+ * Resolve and burn an invite in ONE act.
1766
+ *
1767
+ * KV could only narrow this race — it has no compare-and-swap, so two redemptions could
1768
+ * both read before either wrote. A Durable Object is single-threaded, so the read and the
1769
+ * delete here cannot interleave and the second caller gets null. The comment on
1770
+ * `consumeInvite` in the worker describes the window this closes.
1771
+ */
1772
+ inviteConsume(tokenHash, nowMs = Date.now()) {
1773
+ const email = this.inviteRead(tokenHash, nowMs);
1774
+ if (email === null) return null;
1775
+ this.sql.exec(`DELETE FROM invites WHERE token_hash = ?`, String(tokenHash));
1776
+ return email;
1777
+ }
1778
+
1779
+ /**
1780
+ * Record an invitation, dropping every outstanding one for the same address first.
1781
+ *
1782
+ * Issuing invalidates a person's other links, so there is never more than one — the rule
1783
+ * the KV path already holds, and for the same reason: two live links for one person is
1784
+ * two ways in when somebody was handed one.
1785
+ */
1786
+ inviteMint({ tokenHash, email, createdAt, expiresAt, createdBy = null }, nowMs = Date.now()) {
1787
+ if (!tokenHash || !email) return { ok: false };
1788
+ const at = new Date(nowMs).toISOString();
1789
+ this.inviteRevoke(email);
1790
+ this.sql.exec(
1791
+ `INSERT INTO invites (token_hash, email, created_at, expires_at, created_by)
1792
+ VALUES (?1,?2,?3,?4,?5)
1793
+ ON CONFLICT(token_hash) DO UPDATE SET email = ?2, created_at = ?3, expires_at = ?4, created_by = ?5`,
1794
+ String(tokenHash), lcAddr(email), createdAt || at, expiresAt || at, createdBy ?? null,
1795
+ );
1796
+ return { ok: true };
1797
+ }
1798
+
1799
+ /** Drop every outstanding invite for one address. Removal needs this without minting. */
1800
+ inviteRevoke(email) {
1801
+ if (!email) return { dropped: 0 };
1802
+ const rows = [...this.sql.exec(
1803
+ `DELETE FROM invites WHERE email = ? RETURNING token_hash`, lcAddr(email),
1804
+ )];
1805
+ return { dropped: rows.length };
1806
+ }
1807
+
1808
+ /** Every last-connection stamp, as the `{address: iso}` map the admin list reads. */
1809
+ lastseenRead() {
1810
+ const out = {};
1811
+ for (const row of this.sql.exec(`SELECT email, at FROM lastseen`)) out[row.email] = row.at;
1812
+ return out;
1813
+ }
1814
+
1815
+ /**
1816
+ * Stamp a person as seen, unless the stored stamp is still fresh.
1817
+ *
1818
+ * The throttle lives HERE rather than at the call site, and that is the difference the
1819
+ * move buys: KV had to read the stamp and then write it, two round trips and a race
1820
+ * between them. One call now answers whether it wrote.
1821
+ */
1822
+ lastseenTouch(email, throttleMs = 15 * 60 * 1000, nowMs = Date.now()) {
1823
+ if (!email) return { wrote: false };
1824
+ const addr = lcAddr(email);
1825
+ const rows = [...this.sql.exec(`SELECT at FROM lastseen WHERE email = ?`, addr)];
1826
+ if (rows.length) {
1827
+ const prev = stampMs(rows[0].at);
1828
+ if (prev !== null && nowMs - prev < throttleMs) return { wrote: false };
1829
+ }
1830
+ const at = new Date(nowMs).toISOString();
1831
+ this.sql.exec(
1832
+ `INSERT INTO lastseen (email, at) VALUES (?1,?2) ON CONFLICT(email) DO UPDATE SET at = ?2`,
1833
+ addr, at,
1834
+ );
1835
+ return { wrote: true, at };
1836
+ }
1837
+
1838
+ // ── the roster overlay: four KV documents, one table, one round trip ───────
1839
+ //
1840
+ // ⚠️ WHAT THESE ANSWER WITH IS THE FOUR KV DOCUMENTS, NOT A ROSTER. `users:roster`,
1841
+ // `users:roles`, `users:names` and `users:avatars`, spelled exactly as KV spells them,
1842
+ // so `mergeRoster`/`applyRoles`/`applyNames`/`applyAvatars` in src/_worker.js run on
1843
+ // identical input either way. The serving pipeline is then not two pipelines that have
1844
+ // to be kept in agreement — it is one, fed from whichever store holds the documents,
1845
+ // which is what makes "the two answers are identical" a property rather than a hope.
1846
+ //
1847
+ // Six KV gets per workspace per sixty-second tick become this one call. That read volume
1848
+ // — the site's dominant KV consumer, enough to exhaust a day's `get()` budget and take
1849
+ // every KV-touching route down with it — is why this item was urgent rather than tidy.
1850
+
1851
+ /** The four roster documents, as KV holds them, plus whether this object may be believed. */
1852
+ rosterRead() {
1853
+ const seeded = isSeeded(this.sql, "roster");
1854
+ const add = {};
1855
+ const remove = [];
1856
+ const roles = {};
1857
+ const names = {};
1858
+ const avatars = {};
1859
+ for (const row of this.sql.exec(
1860
+ `SELECT email, role, name, initials, colour, added_at, added_by, removed_at,
1861
+ source, name_overlay, role_overlay, avatar_key, avatar_mime, avatar_at
1862
+ FROM members ORDER BY added_at, email`,
1863
+ )) {
1864
+ const e = String(row.email);
1865
+ // A TOMBSTONE IS THE `remove` LIST. Every removed row lands there, including one for
1866
+ // somebody the config file never named — `mergeRoster` filters the config list by it,
1867
+ // so naming an address the file does not carry costs nothing and dropping the row
1868
+ // instead would let a re-invite inherit the last holder's role.
1869
+ if (row.removed_at != null) remove.push(e);
1870
+ else if (row.source === "overlay") {
1871
+ // An `add` entry, spelled the way `adminUsersApi`'s invite writes one. `color` and
1872
+ // not `colour`: the column is the schema's spelling and this is the document's, and
1873
+ // the translation belongs at exactly one edge.
1874
+ const rec = { email: e, addedAt: row.added_at };
1875
+ if (row.name != null) rec.name = row.name;
1876
+ if (row.role != null) rec.role = row.role;
1877
+ if (row.initials != null) rec.initials = row.initials;
1878
+ if (row.colour != null) rec.color = row.colour;
1879
+ if (row.added_by != null) rec.addedBy = row.added_by;
1880
+ add[e] = rec;
1881
+ }
1882
+ if (row.role_overlay != null) roles[e] = row.role_overlay;
1883
+ if (row.name_overlay != null) {
1884
+ // ⚠️ TWO SHAPES, BOTH LIVE, AND ONLY ONE OF THEM IS HONOURED. `users:names` holds
1885
+ // `{name, at}` today and a bare string on instances that have not written a name
1886
+ // since the shape changed — and `applyNames` reads `rec.name`, so it applies the
1887
+ // first and IGNORES the second. Normalising a bare string into an object here would
1888
+ // therefore start showing a display name the KV path does not, on exactly the oldest
1889
+ // instances. The column holds the document's own JSON so the shape survives the
1890
+ // round trip and neither path invents an answer the other would not give.
1891
+ try { names[e] = JSON.parse(row.name_overlay); } catch (err) { /* a corrupt row is not a corrupt map */ }
1892
+ }
1893
+ if (row.avatar_key != null) {
1894
+ avatars[e] = { k: row.avatar_key, mime: row.avatar_mime ?? null, at: row.avatar_at ?? null };
1895
+ }
1896
+ }
1897
+ return { seeded, roster: { add, remove }, roles, names, avatars };
1898
+ }
1899
+
1900
+ /**
1901
+ * Write whichever of the four documents the caller is holding.
1902
+ *
1903
+ * ⚠️ IT TAKES THE WHOLE DOCUMENT, because that is what the KV path computes and what the
1904
+ * straddle has to mirror. Per-key rows are what the content overlay moved to and what
1905
+ * this table will move to when KV is gone; while both stores are live the document is the
1906
+ * unit, and a mirror written key-by-key from a document the other store wrote whole would
1907
+ * diverge the moment a key was deleted rather than changed.
1908
+ *
1909
+ * `configUsers` is the durable half and is not optional in practice: an overlay entry can
1910
+ * name somebody this object has no row for — a display name set by a config user on a
1911
+ * workspace whose copy has not run — and `members.role` is NOT NULL, so there is no
1912
+ * honest row to invent for them. Passing the config roster means the row is seeded from
1913
+ * the record rather than from a guess.
1914
+ */
1915
+ rosterWrite({ configUsers = null, roster = null, roles = null, names = null, avatars = null } = {}, nowMs = Date.now()) {
1916
+ const at = new Date(nowMs).toISOString();
1917
+ const wrote = [];
1918
+ const body = () => {
1919
+ if (Array.isArray(configUsers)) {
1920
+ for (const u of configUsers) {
1921
+ const e = lcAddr(u && u.email);
1922
+ if (!e) continue;
1923
+ const role = MEMBER_ROLES.includes(u.role) ? u.role : "editor";
1924
+ this.sql.exec(
1925
+ `INSERT INTO members (email, role, name, added_at, initials, colour, source)
1926
+ VALUES (?1,?2,?3,?4,?5,?6,'config')
1927
+ ON CONFLICT(email) DO UPDATE SET
1928
+ role = ?2, name = ?3, initials = ?5, colour = ?6, source = 'config'`,
1929
+ e, role, u.name ?? null, u.addedAt || at, u.initials ?? null, u.color ?? null,
1930
+ );
1931
+ }
1932
+ wrote.push("configUsers");
1933
+ }
1934
+
1935
+ if (roster && typeof roster === "object") {
1936
+ const add = roster.add && typeof roster.add === "object" ? roster.add : {};
1937
+ const removed = new Set((Array.isArray(roster.remove) ? roster.remove : []).map(lcAddr).filter(Boolean));
1938
+ const added = new Set();
1939
+ for (const rec of Object.values(add)) {
1940
+ const e = lcAddr(rec && rec.email);
1941
+ if (!e) continue;
1942
+ added.add(e);
1943
+ const role = MEMBER_ROLES.includes(rec.role) ? rec.role : "editor";
1944
+ // `source` is set on INSERT only. An address the config file also names keeps
1945
+ // 'config', which is the precedence `mergeRoster` applies: the file wins, and an
1946
+ // `add` entry for somebody it names has already stopped taking effect.
1947
+ this.sql.exec(
1948
+ `INSERT INTO members (email, role, name, added_at, initials, colour, added_by, source, removed_at)
1949
+ VALUES (?1,?2,?3,?4,?5,?6,?7,'overlay',NULL)
1950
+ ON CONFLICT(email) DO UPDATE SET
1951
+ role = ?2, name = ?3, added_at = ?4, initials = ?5, colour = ?6, added_by = ?7,
1952
+ removed_at = NULL`,
1953
+ e, role, rec.name ?? null, rec.addedAt || at, rec.initials ?? null,
1954
+ rec.color ?? null, rec.addedBy ?? null,
1955
+ );
1956
+ }
1957
+ for (const e of removed) {
1958
+ this.sql.exec(
1959
+ `INSERT INTO members (email, role, added_at, removed_at, source)
1960
+ VALUES (?1,'viewer',?2,?2,'config')
1961
+ ON CONFLICT(email) DO UPDATE SET removed_at = COALESCE(members.removed_at, ?2)`,
1962
+ e, at,
1963
+ );
1964
+ }
1965
+ // An INVITED person's removal deletes their `add` entry and writes NO tombstone —
1966
+ // the KV list is only for addresses the config file names, and an unbounded one
1967
+ // would grow forever. Here they become a tombstone anyway: `mergeRoster` filters
1968
+ // the CONFIG list by `remove`, so naming somebody the file does not carry changes
1969
+ // no answer, and the row is what stops a re-invite inheriting their role.
1970
+ const orphans = [...this.sql.exec(
1971
+ `SELECT email FROM members WHERE source = 'overlay' AND removed_at IS NULL`,
1972
+ )].map((r) => String(r.email)).filter((e) => !added.has(e));
1973
+ for (const e of orphans) {
1974
+ this.sql.exec(`UPDATE members SET removed_at = ? WHERE email = ?`, at, e);
1975
+ }
1976
+ // …and the other direction: a config user the file has caught up with drops out of
1977
+ // `remove`, and the tombstone has to go with it or they stay hidden forever. Only
1978
+ // 'config' rows, so the orphan tombstones above are not undone by their own absence.
1979
+ for (const row of this.sql.exec(
1980
+ `SELECT email FROM members WHERE source = 'config' AND removed_at IS NOT NULL`,
1981
+ )) {
1982
+ if (!removed.has(String(row.email))) {
1983
+ this.sql.exec(`UPDATE members SET removed_at = NULL WHERE email = ?`, String(row.email));
1984
+ }
1985
+ }
1986
+ wrote.push("roster");
1987
+ }
1988
+
1989
+ // The three flat overlays. Each is REPLACED, not merged: the KV document is written
1990
+ // whole, so a key that has gone from it has been cleared and a merge would resurrect it.
1991
+ const flat = (doc, column, encode) => {
1992
+ this.sql.exec(`UPDATE members SET ${column} = NULL WHERE ${column} IS NOT NULL`);
1993
+ for (const [key, value] of Object.entries(doc)) {
1994
+ const e = lcAddr(key);
1995
+ if (!e || value === null || value === undefined) continue;
1996
+ const v = encode(value);
1997
+ if (v === null) continue;
1998
+ const hit = [...this.sql.exec(
1999
+ `UPDATE members SET ${column} = ?2 WHERE email = ?1 RETURNING email`, e, v,
2000
+ )];
2001
+ // An overlay entry for somebody with no row is not dropped: `applyRoles` and
2002
+ // `applyNames` are asked about the MERGED roster, which includes config users this
2003
+ // object may not have been told about yet. The row is a carrier, and `source` says
2004
+ // so — 'config' means the durable half of it is elsewhere.
2005
+ if (!hit.length) {
2006
+ this.sql.exec(
2007
+ `INSERT INTO members (email, role, added_at, source, ${column})
2008
+ VALUES (?1,'viewer',?2,'config',?3) ON CONFLICT(email) DO NOTHING`,
2009
+ e, at, v,
2010
+ );
2011
+ }
2012
+ }
2013
+ };
2014
+ if (roles && typeof roles === "object") {
2015
+ flat(roles, "role_overlay", (v) => (typeof v === "string" && v ? v : null));
2016
+ wrote.push("roles");
2017
+ }
2018
+ if (names && typeof names === "object") {
2019
+ flat(names, "name_overlay", (v) => JSON.stringify(v));
2020
+ wrote.push("names");
2021
+ }
2022
+ if (avatars && typeof avatars === "object") {
2023
+ this.sql.exec(`UPDATE members SET avatar_key = NULL, avatar_mime = NULL, avatar_at = NULL
2024
+ WHERE avatar_key IS NOT NULL`);
2025
+ for (const [key, rec] of Object.entries(avatars)) {
2026
+ const e = lcAddr(key);
2027
+ if (!e || !rec || typeof rec !== "object" || typeof rec.k !== "string") continue;
2028
+ const hit = [...this.sql.exec(
2029
+ `UPDATE members SET avatar_key = ?2, avatar_mime = ?3, avatar_at = ?4
2030
+ WHERE email = ?1 RETURNING email`,
2031
+ e, rec.k, rec.mime ?? null, rec.at ?? null,
2032
+ )];
2033
+ if (!hit.length) {
2034
+ this.sql.exec(
2035
+ `INSERT INTO members (email, role, added_at, source, avatar_key, avatar_mime, avatar_at)
2036
+ VALUES (?1,'viewer',?2,'config',?3,?4,?5) ON CONFLICT(email) DO NOTHING`,
2037
+ e, at, rec.k, rec.mime ?? null, rec.at ?? null,
2038
+ );
2039
+ }
2040
+ }
2041
+ wrote.push("avatars");
2042
+ }
2043
+ markSeeded(this.sql, "roster", at);
2044
+ };
2045
+ // One transaction: four documents describing one roster, half-written, is the state
2046
+ // `importAll` refuses to leave behind and this must refuse it for the same reason.
2047
+ if (typeof this.ctx.storage.transactionSync === "function") this.ctx.storage.transactionSync(body);
2048
+ else body();
2049
+ return { wrote };
2050
+ }
2051
+
2052
+ // ── publish tokens ─────────────────────────────────────────────────────────
2053
+
2054
+ /**
2055
+ * One token by its hash, or null when this object cannot answer for it.
2056
+ *
2057
+ * NULL MEANS TWO THINGS AND BOTH ARE "ASK KV": no such row, or a row that cannot state one
2058
+ * of the two authorization fields — a pre-`scope` copy, or a pre-`caps` one. Such a row
2059
+ * knows the token exists and not what it may do. Answering from here would have to invent
2060
+ * the missing half, and every available invention is wrong: `*` widens a space-scoped token
2061
+ * to admin-equivalent, a space id refuses a star one, and "no caps" turns the control
2062
+ * plane's purge-only bearer into a credential that can publish over every workspace.
2063
+ */
2064
+ publishTokenRead(tokenHash) {
2065
+ if (!tokenHash) return null;
2066
+ const rows = [...this.sql.exec(
2067
+ `SELECT token_hash, label, created_at, expires_at, scope, caps FROM publish_tokens
2068
+ WHERE token_hash = ?`, String(tokenHash),
2069
+ )];
2070
+ if (!rows.length || rows[0].scope == null) return null;
2071
+ const caps = capsValue(rows[0].caps);
2072
+ if (caps === undefined) return null;
2073
+ const entry = {
2074
+ space: String(rows[0].scope), label: rows[0].label ?? null,
2075
+ createdAt: rows[0].created_at, expiresAt: rows[0].expires_at ?? null,
2076
+ };
2077
+ // Spelled the way KV spells it: an unrestricted token has NO `caps` key at all, which is
2078
+ // the shape `capabilityRefusal` reads as unrestricted. A `null` column value states that
2079
+ // absence rather than being carried through as a field holding null.
2080
+ if (caps !== null) entry.caps = caps;
2081
+ return entry;
2082
+ }
2083
+
2084
+ /** Every token as the `{hash: {space,label,createdAt,expiresAt,caps?}}` map the panel lists. */
2085
+ publishTokenList() {
2086
+ const out = {};
2087
+ for (const row of this.sql.exec(
2088
+ `SELECT token_hash, label, created_at, expires_at, scope, caps FROM publish_tokens`,
2089
+ )) {
2090
+ if (row.scope == null) continue;
2091
+ const rec = { space: String(row.scope), label: row.label ?? null, createdAt: row.created_at };
2092
+ if (row.expires_at != null) rec.expiresAt = row.expires_at;
2093
+ // The panel shows the union of both stores with these rows winning, so a list that
2094
+ // dropped the field would show an operator a narrow credential as a full one.
2095
+ const caps = capsValue(row.caps);
2096
+ if (caps !== undefined && caps !== null) rec.caps = caps;
2097
+ out[String(row.token_hash)] = rec;
2098
+ }
2099
+ return { seeded: isSeeded(this.sql, "publishTokens"), tokens: out };
2100
+ }
2101
+
2102
+ /**
2103
+ * Record a minted token. The raw token never reaches this object — only its hash.
2104
+ *
2105
+ * `caps` DEFAULTS TO `null` AND NOT TO UNKNOWN, deliberately. Every mint the engine has is
2106
+ * a mint of an unrestricted token, and that is a fact this object may state — leaving the
2107
+ * column empty instead would send every read on a cut deployment back to KV, which is the
2108
+ * read volume this family moved to get away from. A caller minting a restricted token
2109
+ * passes the list; there is no such caller yet, and a capability nothing can mint is a
2110
+ * capability that does not exist.
2111
+ */
2112
+ publishTokenMint({ tokenHash, space, label = null, createdAt = null, expiresAt = null, caps = null }, nowMs = Date.now()) {
2113
+ if (!tokenHash || space == null) return { ok: false };
2114
+ const at = new Date(nowMs).toISOString();
2115
+ this.sql.exec(
2116
+ `INSERT INTO publish_tokens (token_hash, label, created_at, expires_at, scope, caps)
2117
+ VALUES (?1,?2,?3,?4,?5,?6)
2118
+ ON CONFLICT(token_hash) DO UPDATE SET label = ?2, created_at = ?3, expires_at = ?4,
2119
+ scope = ?5, caps = ?6`,
2120
+ String(tokenHash), label, createdAt || at, expiresAt ?? null, String(space), capsColumn(caps),
2121
+ );
2122
+ markSeeded(this.sql, "publishTokens", at);
2123
+ return { ok: true };
2124
+ }
2125
+
2126
+ /**
2127
+ * Revoke by hash, or every token one person holds.
2128
+ *
2129
+ * By LABEL is not a convenience: `augur login` labels a token with the holder's address,
2130
+ * and removing or demoting somebody has to drop the tokens that outlived their role. A
2131
+ * revocation that missed this store would leave the credential live on the other one.
2132
+ */
2133
+ publishTokenRevoke({ tokenHash = null, label = null } = {}) {
2134
+ if (tokenHash) {
2135
+ const rows = [...this.sql.exec(
2136
+ `DELETE FROM publish_tokens WHERE token_hash = ? RETURNING token_hash`, String(tokenHash),
2137
+ )];
2138
+ return { dropped: rows.length };
2139
+ }
2140
+ if (label) {
2141
+ const rows = [...this.sql.exec(
2142
+ `DELETE FROM publish_tokens WHERE LOWER(label) = ? RETURNING token_hash`, lcAddr(label),
2143
+ )];
2144
+ return { dropped: rows.length };
2145
+ }
2146
+ return { dropped: 0 };
2147
+ }
2148
+
2149
+ /** Forget one person's last connection — what removal and purge do to their row. */
2150
+ lastseenForget(email) {
2151
+ if (!email) return { dropped: 0 };
2152
+ const rows = [...this.sql.exec(
2153
+ `DELETE FROM lastseen WHERE email = ? RETURNING email`, lcAddr(email),
2154
+ )];
2155
+ return { dropped: rows.length };
2156
+ }
2157
+
2158
+ /** Whether this workspace has anybody in it — the gate's "is the roster on" question. */
2159
+ usersActive() {
2160
+ return this.members().length > 0;
2161
+ }
2162
+
2163
+ /**
2164
+ * Issue the next publish version for a space. Atomic: one statement, one object, one
2165
+ * thread.
2166
+ *
2167
+ * `floor` is the version the STORE currently holds, and it is what makes adopting this
2168
+ * safe on a workspace that has been publishing for months. The counter starts empty, so
2169
+ * without a floor the first issue would be 1 and would overwrite `versions/1.json` — the
2170
+ * exact history destruction this exists to prevent. With it, the first issue is
2171
+ * `live + 1` and every later one comes from the counter alone.
2172
+ *
2173
+ * It is a MAX rather than a trust: once the counter is ahead (a number issued for a
2174
+ * commit that then failed), a stale floor from a slow R2 read cannot drag it back.
2175
+ */
2176
+ nextPublishVersion(space, floor = 0) {
2177
+ const f = Number.isFinite(floor) && floor > 0 ? Math.floor(floor) : 0;
2178
+ const rows = [...this.sql.exec(
2179
+ `INSERT INTO publish_versions (space, version) VALUES (?1, ?2 + 1)
2180
+ ON CONFLICT(space) DO UPDATE SET version = MAX(publish_versions.version, ?2) + 1
2181
+ RETURNING version`,
2182
+ String(space), f,
2183
+ )];
2184
+ return rows.length ? Number(rows[0].version) : null;
2185
+ }
2186
+
2187
+ /**
2188
+ * WHICH SPACES ARE THIS WORKSPACE'S, and the only record of it that exists.
2189
+ *
2190
+ * `spaces/<spaceId>/…` in the bundle store names a SPACE. Nothing in that key names a
2191
+ * workspace, and two workspaces may publish a space under the same id, so no listing of
2192
+ * that bucket can say whose anything is. This table can: a row lands here only when a
2193
+ * publish addressed to THIS object asked it for a version number, and a Durable Object's
2194
+ * storage belongs to its id — there is no key constructible in one workspace that writes
2195
+ * a row in another. That is the same reason the counter is here in the first place.
2196
+ *
2197
+ * ⚠️ IT IS AUTHORITATIVE, NOT PROVABLY COMPLETE. Content published before this
2198
+ * deployment bound the workspace objects went through `nextPublishVersion`'s no-object
2199
+ * branch and left no row, so an empty answer means "this workspace has issued no publish
2200
+ * version", never "this workspace owns nothing". The caller has to treat those two
2201
+ * differently, and `deleteWorkspace` does.
2202
+ *
2203
+ * NO init(), for `status()`'s reason: asking what a workspace holds must not bring one
2204
+ * into being. A workspace with no schema answers with an empty list and says it is not
2205
+ * provisioned, which is exactly what it can honestly say.
2206
+ */
2207
+ publishedSpaces() {
2208
+ if (!this.hasMeta()) return { provisioned: false, spaces: [] };
2209
+ const rows = [...this.sql.exec(`SELECT space FROM publish_versions ORDER BY space`)];
2210
+ return { provisioned: this.isProvisioned(), spaces: rows.map((r) => String(r.space)) };
2211
+ }
2212
+
2213
+ // ── the onboarding completion signal ──────────────────────────────────────
2214
+ //
2215
+ // `C-first-publish-signal`. Onboarding ends when the workspace has published something
2216
+ // REAL — not the seed pack the platform wrote on the person's behalf, and not a CLI
2217
+ // process's exit code or an agent's own report of success. The worker decides "real"
2218
+ // through `isSeedSource()`, the one predicate, and calls here only for a publish that
2219
+ // passed it; this object records the answer and never re-derives it.
2220
+ //
2221
+ // Two stamps, each written ONCE and never moved: the workspace's (`meta.first_publish_at`
2222
+ // — "is this workspace connected") and the member's (`members.first_publish_at` — "did
2223
+ // THIS person convert", because the conversion event is somebody's first publish and a
2224
+ // second person's first publish is a second conversion). Plus one counter,
2225
+ // `meta.viewers_became_editors`, bumped where the role-change verb writes. Three numbers
2226
+ // a launch retro can read, kept where the counter every commit already goes through is.
2227
+
2228
+ /**
2229
+ * A real publish landed, credited to `email` (the publish token's resolved actor; null or
2230
+ * a non-member label — a CI token — stamps the workspace and nobody).
2231
+ *
2232
+ * `DO NOTHING` on both writes: the first stamp wins, and a later publish — a second
2233
+ * person's, a re-bake, a restore — leaves it exactly where it was. A removed member is
2234
+ * not stamped: the row is a tombstone, and a re-invite must not inherit the last holder's
2235
+ * conversion any more than their role.
2236
+ */
2237
+ noteFirstPublish(email, at = new Date().toISOString()) {
2238
+ const when = typeof at === "string" && Number.isFinite(Date.parse(at)) ? at : new Date().toISOString();
2239
+ const ws = [...this.sql.exec(
2240
+ `INSERT INTO meta (k, v) VALUES ('first_publish_at', ?) ON CONFLICT(k) DO NOTHING RETURNING v`, when,
2241
+ )];
2242
+ const addr = lcAddr(email);
2243
+ let member = null;
2244
+ if (addr) {
2245
+ const rows = [...this.sql.exec(
2246
+ `UPDATE members SET first_publish_at = ?
2247
+ WHERE email = ? AND removed_at IS NULL AND first_publish_at IS NULL
2248
+ RETURNING email`,
2249
+ when, addr,
2250
+ )];
2251
+ member = { email: addr, wrote: rows.length > 0 };
2252
+ }
2253
+ return { firstPublishAt: this.readMeta("first_publish_at"), wrote: ws.length > 0, member };
2254
+ }
2255
+
2256
+ /**
2257
+ * A role change took effect. Counted only when a VIEWER became something more — the
2258
+ * transition onboarding is watching for, because a viewer is the role whose password
2259
+ * may be public knowledge and an editor is somebody who can publish. Every other change
2260
+ * is a role change, not a conversion, and is not counted here.
2261
+ */
2262
+ noteRoleTransition(from, to) {
2263
+ if (from !== "viewer" || !["editor", "admin"].includes(to)) return { counted: false };
2264
+ const n = Number(this.readMeta("viewers_became_editors") || 0) + 1;
2265
+ this.writeMeta("viewers_became_editors", n);
2266
+ return { counted: true, viewersBecameEditors: n };
2267
+ }
2268
+
2269
+ /**
2270
+ * The signal, as `GET /__onboarding/status` answers it. `email` is the caller's own, for
2271
+ * the `me` half; the workspace half is the same for every member.
2272
+ *
2273
+ * NO init(), for `status()`'s reason: a read must not bring a workspace into being. An
2274
+ * object with no schema answers "not connected", which is what it can honestly say.
2275
+ */
2276
+ onboardingStatus(email) {
2277
+ const none = { connected: false, firstPublishAt: null, members: { converted: 0, active: 0 }, viewersBecameEditors: 0, me: null };
2278
+ if (!this.hasMeta()) return { provisioned: false, ...none };
2279
+ const count = (sql) => {
2280
+ const rows = [...this.sql.exec(sql)];
2281
+ return rows.length ? Number(rows[0].n) : 0;
2282
+ };
2283
+ const firstPublishAt = this.readMeta("first_publish_at");
2284
+ const addr = lcAddr(email);
2285
+ let me = null;
2286
+ if (addr) {
2287
+ const rows = [...this.sql.exec(
2288
+ `SELECT first_publish_at FROM members WHERE email = ? AND removed_at IS NULL`, addr,
2289
+ )];
2290
+ me = { firstPublishAt: rows.length && rows[0].first_publish_at != null ? String(rows[0].first_publish_at) : null };
2291
+ }
2292
+ return {
2293
+ provisioned: this.isProvisioned(),
2294
+ connected: !!firstPublishAt,
2295
+ firstPublishAt,
2296
+ members: {
2297
+ converted: count(`SELECT COUNT(*) AS n FROM members WHERE removed_at IS NULL AND first_publish_at IS NOT NULL`),
2298
+ active: count(`SELECT COUNT(*) AS n FROM members WHERE removed_at IS NULL`),
2299
+ },
2300
+ viewersBecameEditors: Number(this.readMeta("viewers_became_editors") || 0),
2301
+ me,
2302
+ };
2303
+ }
2304
+
2305
+ // ── the content overlay ────────────────────────────────────────────────────
2306
+ // Four verbs, each the shape one of the four families actually needs. Reading them
2307
+ // together: `read` is what a page load does, `set` is a single edit, `insert` is a
2308
+ // create that must not clobber, and `replace` is a family whose client owns the whole
2309
+ // map. Nothing here is generic for its own sake — a verb that no call site wants is a
2310
+ // verb whose semantics nobody has thought about.
2311
+
2312
+ /** The whole family as a plain `{key: value}` map, JSON decoded. */
2313
+ overlayRead(family, scope = "") {
2314
+ const out = {};
2315
+ for (const row of this.sql.exec(
2316
+ `SELECT k, v FROM overlay WHERE family = ? AND scope = ?`, String(family), String(scope),
2317
+ )) {
2318
+ try { out[row.k] = JSON.parse(row.v); } catch (e) { /* a corrupt row is not a corrupt map */ }
2319
+ }
2320
+ return out;
2321
+ }
2322
+
2323
+ /**
2324
+ * Every scope a family holds rows under, including the empty one.
2325
+ *
2326
+ * THE ONE READ AN EXPORT NEEDS AND NO PAGE DOES. A scoped family — `pins`, whose scope is
2327
+ * the address a sidebar belongs to — is a set of maps, and every other verb here answers
2328
+ * about ONE scope because every other caller already knows which one it wants: a page load
2329
+ * is one person's. A COPY does not know, and there is no other way to find out from
2330
+ * outside: on the KV backing the scopes are visible as a key prefix, and here they are
2331
+ * rows in a column nothing could list. Without this a backup of a workspace on this
2332
+ * backing omits every person's sidebar and reports itself complete.
2333
+ */
2334
+ overlayScopes(family) {
2335
+ return [...this.sql.exec(
2336
+ `SELECT DISTINCT scope FROM overlay WHERE family = ? ORDER BY scope`, String(family),
2337
+ )].map((row) => String(row.scope));
2338
+ }
2339
+
2340
+ /**
2341
+ * Set or clear ONE key. `null` clears — the same signal the KV path uses, where an empty
2342
+ * name means "revert to the build default".
2343
+ *
2344
+ * This is the verb the whole item is about: two edits to different keys are two rows and
2345
+ * cannot lose each other, where the KV document they replace lost one every time they
2346
+ * landed together.
2347
+ */
2348
+ overlaySet(family, scope, k, v, at, owner) {
2349
+ if (v === null || v === undefined) {
2350
+ this.sql.exec(`DELETE FROM overlay WHERE family = ? AND scope = ? AND k = ?`,
2351
+ String(family), String(scope), String(k));
2352
+ return null;
2353
+ }
2354
+ // The owner is set on INSERT and left alone on UPDATE. Whoever made a board owns it;
2355
+ // whoever saved it last does not, and `COALESCE` is what makes that true without the
2356
+ // caller having to know whether the row already existed.
2357
+ this.sql.exec(
2358
+ `INSERT INTO overlay (family, scope, k, v, at, owner) VALUES (?1,?2,?3,?4,?5,?6)
2359
+ ON CONFLICT(family, scope, k) DO UPDATE SET
2360
+ v = excluded.v, at = excluded.at, owner = COALESCE(overlay.owner, ?6)`,
2361
+ String(family), String(scope), String(k), JSON.stringify(v),
2362
+ at || new Date().toISOString(), owner ? String(owner) : null,
2363
+ );
2364
+ return v;
2365
+ }
2366
+
2367
+ /** Who owns a row, and any per-resource ACL on it. Read by nothing yet, on purpose. */
2368
+ overlayOwner(family, scope, k) {
2369
+ const rows = [...this.sql.exec(
2370
+ `SELECT owner, acl FROM overlay WHERE family = ? AND scope = ? AND k = ?`,
2371
+ String(family), String(scope), String(k),
2372
+ )];
2373
+ if (!rows.length) return null;
2374
+ let acl = null;
2375
+ try { acl = rows[0].acl ? JSON.parse(rows[0].acl) : null; } catch (e) { acl = null; }
2376
+ return { owner: rows[0].owner || null, acl };
2377
+ }
2378
+
2379
+ /** One key's value AND the revision it is at, so a caller can write it back safely. */
2380
+ overlayReadRev(family, scope, k) {
2381
+ const rows = [...this.sql.exec(
2382
+ `SELECT v, rev FROM overlay WHERE family = ? AND scope = ? AND k = ?`,
2383
+ String(family), String(scope), String(k),
2384
+ )];
2385
+ if (!rows.length) return { v: null, rev: 0 };
2386
+ try { return { v: JSON.parse(rows[0].v), rev: Number(rows[0].rev) }; }
2387
+ catch (e) { return { v: null, rev: Number(rows[0].rev) }; }
2388
+ }
2389
+
2390
+ /**
2391
+ * Write a key only if it is still at the revision the caller read. Returns the new rev,
2392
+ * or null when somebody else got there first.
2393
+ *
2394
+ * This is the verb for a value that is a DOCUMENT — a page's comment threads, a board's
2395
+ * nodes — where the worker reads it, changes part of it and writes the whole thing back.
2396
+ * Per-key rows do not help there: two edits to ONE key still lose each other. Matching on
2397
+ * the revision turns "one of these two ops vanished" into "one of them retried".
2398
+ *
2399
+ * `rev 0` means "I expect this key to be absent", so a create races correctly too.
2400
+ */
2401
+ overlayCas(family, scope, k, v, expectedRev, at) {
2402
+ const stamp = at || new Date().toISOString();
2403
+ const rows = [...this.sql.exec(
2404
+ `INSERT INTO overlay (family, scope, k, v, rev, at) VALUES (?1, ?2, ?3, ?4, 1, ?6)
2405
+ ON CONFLICT(family, scope, k) DO UPDATE SET v = ?4, rev = overlay.rev + 1, at = ?6
2406
+ WHERE overlay.rev = ?5
2407
+ RETURNING rev`,
2408
+ String(family), String(scope), String(k), JSON.stringify(v), Number(expectedRev) || 0, stamp,
2409
+ )];
2410
+ return rows.length ? Number(rows[0].rev) : null;
2411
+ }
2412
+
2413
+ /**
2414
+ * Create a key only if it is absent, and say which happened.
2415
+ *
2416
+ * Creating a board reads the map, checks the slug is free, then writes — so two creates
2417
+ * of one name both pass the check and the second silently takes the first's board. One
2418
+ * statement in one object cannot.
2419
+ */
2420
+ overlayInsert(family, scope, k, v, at, owner) {
2421
+ const rows = [...this.sql.exec(
2422
+ `INSERT INTO overlay (family, scope, k, v, at, owner) VALUES (?,?,?,?,?,?)
2423
+ ON CONFLICT(family, scope, k) DO NOTHING
2424
+ RETURNING k`,
2425
+ String(family), String(scope), String(k), JSON.stringify(v),
2426
+ at || new Date().toISOString(), owner ? String(owner) : null,
2427
+ )];
2428
+ return rows.length > 0;
2429
+ }
2430
+
2431
+ /**
2432
+ * Replace a whole family for one scope, atomically.
2433
+ *
2434
+ * For the family whose client owns the complete map — pins, where adding, removing and
2435
+ * reordering all produce a new full map. Delete-then-insert inside a transaction, so a
2436
+ * reader never sees the gap between the two.
2437
+ */
2438
+ overlayReplace(family, scope, map, at) {
2439
+ const body = () => {
2440
+ this.sql.exec(`DELETE FROM overlay WHERE family = ? AND scope = ?`, String(family), String(scope));
2441
+ // Note the asymmetry with the KV backing, which cannot delete what a document does
2442
+ // not mention without a listing: here the family IS the rows, so a replace really
2443
+ // replaces. Both are right for their store, and a restore that means to remove
2444
+ // something has to say so rather than relying on either.
2445
+ const stamp = at || new Date().toISOString();
2446
+ for (const [k, v] of Object.entries(map || {})) {
2447
+ this.sql.exec(`INSERT INTO overlay (family, scope, k, v, at) VALUES (?,?,?,?,?)`,
2448
+ String(family), String(scope), String(k), JSON.stringify(v), stamp);
2449
+ }
2450
+ };
2451
+ if (this.ctx.storage.transactionSync) this.ctx.storage.transactionSync(body);
2452
+ else body();
2453
+ return map || {};
2454
+ }
2455
+
2456
+ // ── quota counters ─────────────────────────────────────────────────────────
2457
+
2458
+ /**
2459
+ * Add to a counter and say whether it is still under its ceiling — in one statement, so
2460
+ * two requests arriving together cannot both read the same number and both be let past.
2461
+ *
2462
+ * `window` is what makes this cheap: the row carries the bucket its count belongs to, so
2463
+ * a new minute or a new day resets it on the next bump rather than needing anything to
2464
+ * sweep. A ceiling of 0 or less means unlimited, matching the quota table's own rule that
2465
+ * unlimited is a number rather than an absence.
2466
+ */
2467
+ bumpCounter(k, window, by, ceiling) {
2468
+ const rows = [...this.sql.exec(
2469
+ `INSERT INTO counters (k, window, n) VALUES (?1, ?2, ?3)
2470
+ ON CONFLICT(k) DO UPDATE SET
2471
+ n = CASE WHEN counters.window = ?2 THEN counters.n + ?3 ELSE ?3 END,
2472
+ window = ?2
2473
+ RETURNING n`,
2474
+ String(k), String(window), Number(by) || 0,
2475
+ )];
2476
+ const n = rows.length ? Number(rows[0].n) : 0;
2477
+ const limit = Number(ceiling);
2478
+ return { n, limit, allowed: !Number.isFinite(limit) || limit <= 0 || n <= limit };
2479
+ }
2480
+
2481
+ /** What a counter stands at, without touching it. */
2482
+ readCounter(k) {
2483
+ const rows = [...this.sql.exec(`SELECT window, n FROM counters WHERE k = ?`, String(k))];
2484
+ return rows.length ? { window: rows[0].window, n: Number(rows[0].n) } : { window: null, n: 0 };
2485
+ }
2486
+
2487
+ /**
2488
+ * Write a whole snapshot of the overlay in ONE transaction.
2489
+ *
2490
+ * `MIG-do-import-endpoint`. Every other write path in this codebase is a
2491
+ * read-modify-write: read a document, change part of it, put it back. That is survivable
2492
+ * for one edit and wrong for a restore, where a failure halfway leaves a workspace with
2493
+ * some families from the copy and some from whatever was there before — a state that
2494
+ * matches no backup and no moment in time, and that nobody can tell apart from a
2495
+ * successful restore by looking.
2496
+ *
2497
+ * So it is all of it or none of it. `transactionSync` gives that for free inside a
2498
+ * Durable Object; a runtime without one gets the same ORDER but not the same guarantee,
2499
+ * and says so to its caller rather than pretending.
2500
+ *
2501
+ * The bundle is `{family: {scope: {key: value}}}` — DO families, not KV document names.
2502
+ * Translating one to the other is the worker's job: this object has never needed to know
2503
+ * what anything was called in KV, and a restore is a poor moment to teach it.
2504
+ */
2505
+ importOverlay(bundle, at, prune = false) {
2506
+ return this.importAll({ overlay: bundle, at, prune });
2507
+ }
2508
+
2509
+ /**
2510
+ * The overlay AND the identity families, in ONE transaction.
2511
+ *
2512
+ * `B-kv-to-do-migration-tool`. Splitting these into two calls would put the exact seam
2513
+ * back that `importOverlay` exists to remove: a workspace holding its content from the
2514
+ * copy and its roster from before it is a state matching no moment in time, and the
2515
+ * roster is the half that decides who can get in.
2516
+ *
2517
+ * ⚠️ TWO KINDS OF BAD ROW, TREATED DIFFERENTLY ON PURPOSE. A role KV does not recognise
2518
+ * is a value the SOURCE can legitimately hold — `users:roles` is a free-text map and
2519
+ * `members.role` has a CHECK constraint — so it is refused by name and the copy carries
2520
+ * on, because one odd role must not abort a copy of somebody's whole workspace. A row
2521
+ * that violates the schema any other way is a defect in the CALLER, and it throws: the
2522
+ * transaction rolls back and nobody is left with a half-copy that looks finished.
2523
+ *
2524
+ * Everything here arrives already translated and already hashed. The object never learns
2525
+ * how a token is spelled — hashing is `crypto.subtle`, which is async, and this body runs
2526
+ * inside `transactionSync`, which is not.
2527
+ */
2528
+ importAll({ overlay, identity, at, prune = false } = {}) {
2529
+ const stamp = at || new Date().toISOString();
2530
+ const written = [];
2531
+ const refused = [];
2532
+ const bundle = overlay;
2533
+ const body = () => {
2534
+ for (const [family, scopes] of Object.entries(bundle || {})) {
2535
+ for (const [scope, map] of Object.entries(scopes || {})) {
2536
+ // PRUNE IS ASKED FOR, never assumed. Deleting the family first would make this
2537
+ // backing destructive where the KV one is not, and a restore of a copy that
2538
+ // turned out to be short a family would empty the live one. "This family is
2539
+ // exactly this" is a reset's sentence; a restore's is "at least this".
2540
+ if (prune) {
2541
+ this.sql.exec(`DELETE FROM overlay WHERE family = ? AND scope = ?`, String(family), String(scope));
2542
+ }
2543
+ for (const [k, v] of Object.entries(map || {})) {
2544
+ this.sql.exec(
2545
+ `INSERT INTO overlay (family, scope, k, v, rev, at) VALUES (?1,?2,?3,?4,1,?5)
2546
+ ON CONFLICT(family, scope, k) DO UPDATE SET v = ?4, rev = overlay.rev + 1, at = ?5`,
2547
+ String(family), String(scope), String(k), JSON.stringify(v), stamp,
2548
+ );
2549
+ }
2550
+ written.push(scope ? `${family}/${scope}` : family);
2551
+ }
2552
+ }
2553
+ writeIdentity(this.sql, identity, stamp, written, refused);
2554
+ };
2555
+ const atomic = typeof this.ctx.storage.transactionSync === "function";
2556
+ if (atomic) this.ctx.storage.transactionSync(body); else body();
2557
+ return { written, refused, atomic };
2558
+ }
2559
+
2560
+ /**
2561
+ * Destroy everything this workspace holds.
2562
+ *
2563
+ * `deleteAll` is the Durable Object's own verb and it is the only honest one: dropping
2564
+ * the tables would leave a database with a schema in it, which reads as an empty
2565
+ * workspace rather than as no workspace, and the difference matters to the status verb
2566
+ * and to anyone auditing what was actually erased.
2567
+ *
2568
+ * A runtime without `deleteAll` falls back to dropping every table this schema created —
2569
+ * named from the schema itself, so a table added later is dropped without anybody
2570
+ * remembering to add it here too.
2571
+ */
2572
+ async destroy() {
2573
+ this.ready = false;
2574
+ if (typeof this.ctx.storage.deleteAll === "function") {
2575
+ await this.ctx.storage.deleteAll();
2576
+ return { method: "deleteAll" };
2577
+ }
2578
+ const tables = TENANT_SCHEMA
2579
+ .map((stmt) => (/CREATE TABLE IF NOT EXISTS (\w+)/.exec(stmt) || [])[1])
2580
+ .filter(Boolean);
2581
+ for (const t of tables) this.sql.exec(`DROP TABLE IF EXISTS ${t}`);
2582
+ return { method: "drop-tables", tables };
2583
+ }
2584
+
2585
+ /**
2586
+ * The worker's way in. A Durable Object stub is not publicly routable — only code
2587
+ * holding the binding can reach it — so this is an internal API, not a surface.
2588
+ *
2589
+ * Deliberately narrow: one verb, the one thing that cannot be done correctly anywhere
2590
+ * else. Reads that a worker can serve from KV or R2 do not belong here yet, because
2591
+ * every one added is a round trip on a request path that does not need it.
2592
+ */
2593
+ async fetch(request) {
2594
+ const url = new URL(request.url);
2595
+
2596
+ // ── The control plane's door ───────────────────────────────────────────────────
2597
+ //
2598
+ // One prefix, one verb list, and the verb list is the whole of what the outside world
2599
+ // can ask. Reachable only by code holding the namespace binding — a stub is not
2600
+ // routable — so the boundary is the binding, not a token this object would have to
2601
+ // keep. What this prefix adds is that the boundary is now READABLE: "what can the
2602
+ // control plane do to a workspace" is answered by CONTROL_VERBS rather than by
2603
+ // reading the whole handler.
2604
+ //
2605
+ // ⚠️ ONLY `provision` MAY CREATE ANYTHING. Every other verb refuses `not-provisioned`
2606
+ // without calling init(), because each takes its workspace name from a URL an operator
2607
+ // typed and a typo that provisioned would leave a workspace nobody knows exists.
2608
+ if (url.pathname.startsWith("/__control/")) {
2609
+ const verb = url.pathname.slice("/__control/".length);
2610
+ if (!CONTROL_VERBS.includes(verb)) {
2611
+ return Response.json({ error: "tenant-verb-not-allowed", verb }, { status: 404 });
2612
+ }
2613
+ if (verb === "status") return Response.json(this.status());
2614
+ // ── The confirmation, as a READ on the verb that performs it ──────────────────
2615
+ //
2616
+ // `F-tenant-delete-ux`. Delete is the one verb no rollback reaches, so what a person
2617
+ // is shown before they confirm is part of the verb rather than a screen beside it.
2618
+ // Serving it from HERE is what stops the copy drifting: the two surfaces that show a
2619
+ // confirmation — a workspace's own settings and an operator console in the control
2620
+ // plane — are in different repos and cannot import this module, so a rendered-in-both
2621
+ // design means the retention window is typed twice and corrected once. This is the
2622
+ // same seam `CONTROL_VERBS` and the control plane's `TENANT_RPC` sit on, and the same
2623
+ // answer: one side owns it and the other reads it.
2624
+ //
2625
+ // ⚠️ THE DATE ON THE SCREEN AND THE DATE THE DELETE WRITES ARE ONE ARITHMETIC on one
2626
+ // instant, which is why `at` rides the query string exactly as it rides the POST body.
2627
+ // A screen that computed its own clock would agree with the delete every day except
2628
+ // the one where the reading straddled midnight.
2629
+ //
2630
+ // GET, and no init() — for `status()`'s reason. Somebody typing a workspace name to
2631
+ // see what deleting it would cost must not bring one into being by asking.
2632
+ if (verb === "delete" && request.method === "GET") {
2633
+ const s = this.status();
2634
+ if (!s.provisioned) {
2635
+ return Response.json({ ok: false, error: "not-provisioned" }, { status: 404 });
2636
+ }
2637
+ const at = Date.parse(url.searchParams.get("at") || "");
2638
+ return Response.json(deleteConfirmation({
2639
+ workspaceId: this.workspaceId() || "",
2640
+ graceMs: DELETE_GRACE_MS,
2641
+ at: Number.isFinite(at) ? at : Date.now(),
2642
+ backupRetentionMs: backupRetentionFromEnv(this.env),
2643
+ status: s,
2644
+ }));
2645
+ }
2646
+ if (request.method !== "POST") {
2647
+ return Response.json({ error: "method-not-allowed" }, { status: 405 });
2648
+ }
2649
+ let body = null;
2650
+ try { body = await request.json(); } catch (e) { /* an empty body is fine for most */ }
2651
+ const at = (body && body.at) || new Date().toISOString();
2652
+ switch (verb) {
2653
+ case "provision": {
2654
+ if (!body || !body.workspaceId || !body.adminEmail) {
2655
+ return Response.json({ error: "bad-input" }, { status: 400 });
2656
+ }
2657
+ // `seed` rides in the SAME call, so it lands in the same transaction — see
2658
+ // applyProvisioning. A second request to seed would be the exact gap this closes.
2659
+ // `seedPack: true` asks for the deployed engine's own pack as well — written to the
2660
+ // store first, then committed with the rest — and a pack that cannot be written is
2661
+ // a REFUSAL, 4xx/5xx and no workspace, never an empty workspace wearing a 200.
2662
+ let out;
2663
+ try {
2664
+ out = await this.provision(body);
2665
+ } catch (e) {
2666
+ const code = (e && e.code) || "";
2667
+ if (code === "seed-over-real-content") return Response.json({ ok: false, error: code }, { status: 409 });
2668
+ if (code === "seed-pack-unavailable" || code === "seed-pack-invalid" || code === "seed-pack-corrupt") {
2669
+ return Response.json({ ok: false, error: code }, { status: 503 });
2670
+ }
2671
+ throw e;
2672
+ }
2673
+ return Response.json({ ok: true, ...out });
2674
+ }
2675
+ case "suspend": return this.controlResult(this.suspend(body && body.reason, at));
2676
+ case "resume": return this.controlResult(this.resume(at));
2677
+ case "rotate": return this.controlResult(this.rotate(at));
2678
+ case "delete": return this.controlResult(this.deleteWorkspace(at));
2679
+ // No `to` is read off the body, and none may ever be: see renameAway. The caller
2680
+ // knows the new address; this object must not.
2681
+ case "rename": return this.controlResult(this.renameAway(at));
2682
+ case "claim": {
2683
+ if (!body || !body.hostname) return Response.json({ error: "bad-input" }, { status: 400 });
2684
+ return this.controlResult(await this.claimHostname(body.hostname, at));
2685
+ }
2686
+ case "purge": {
2687
+ if (!body || !body.email) return Response.json({ error: "bad-input" }, { status: 400 });
2688
+ const out = this.purgeAuthor(body.email, at);
2689
+ // A refusal here is not "the workspace is in the wrong state" — it is "this cannot
2690
+ // be done safely", which is what an id collision is. 409, and the reason travels.
2691
+ return out.ok ? Response.json(out) : Response.json(out, {
2692
+ status: out.reason === "not-provisioned" ? 404 : 409,
2693
+ });
2694
+ }
2695
+ // Mint the ONE credential that may write the shared page chrome: a star-scope token
2696
+ // (for reach) capped to `chrome` (for restraint), short-lived, returned exactly once.
2697
+ // Reachable only here — i.e. only by the control plane holding the namespace binding —
2698
+ // which is what keeps it out of every workspace's own Settings panel. See
2699
+ // `sharedChromeRefusal` and `CAP_ROUTES.chrome` in src/_worker.js.
2700
+ case "chrome": {
2701
+ const s = this.status();
2702
+ if (!s.provisioned) return Response.json({ ok: false, error: "not-provisioned" }, { status: 404 });
2703
+ const bearer = newSigningKey();
2704
+ // The worker's read path hashes as tokenFor("pub:"+bearer), and tokenFor(secret) is
2705
+ // SHA-256("gv:"+secret) — so the stored hash MUST be SHA-256("gv:pub:"+bearer), or
2706
+ // this token authenticates against nothing. See `publishAuthDetailed` in _worker.js.
2707
+ const tokenHash = await sha256Hex("gv:pub:" + bearer);
2708
+ const expiresAt = Date.now() + CHROME_TOKEN_TTL_MS;
2709
+ // STORE an ISO string, like every other timestamp in this schema — `stampMs`'s
2710
+ // header is explicit that nothing in this file produces a numeric one, and a
2711
+ // numeric `expiresAt` here binds into the TEXT column as SQLite's double rendering
2712
+ // ("…092.0"), which `Date.parse` in the worker's auth path answers NaN for, so the
2713
+ // TTL this token exists to bound is silently never enforced. The RESPONSE keeps the
2714
+ // numeric epoch-ms: the operator/CLI reading this wants a number, not a re-parse.
2715
+ this.publishTokenMint(
2716
+ { tokenHash, space: "*", caps: ["chrome"], label: "chrome-refresh", expiresAt: new Date(expiresAt).toISOString() },
2717
+ Date.now(),
2718
+ );
2719
+ return Response.json({ ok: true, token: bearer, expiresAt });
2720
+ }
2721
+ // The control plane delivers this workspace's account-store bearer here (the
2722
+ // cross-workspace switcher's hand-off), so it can be redeemed and membership
2723
+ // reported back without a person's session doing the asking. A per-workspace
2724
+ // secret, durable — `meta`, not KV a reset clears — and never exposed on any
2725
+ // external/public route: `accountKey()` below is read internally only.
2726
+ case "account-key": {
2727
+ if (!this.hasMeta() || !this.isProvisioned()) {
2728
+ return Response.json({ ok: false, error: "not-provisioned" }, { status: 404 });
2729
+ }
2730
+ if (!body || typeof body.accountKey !== "string" || !body.accountKey) {
2731
+ return Response.json({ error: "bad-input" }, { status: 400 });
2732
+ }
2733
+ this.writeMeta("account_key", body.accountKey);
2734
+ return this.controlResult({ ok: true });
2735
+ }
2736
+ }
2737
+ }
2738
+
2739
+ // The onboarding completion signal — see noteFirstPublish. The two writes init() like
2740
+ // `/activity` and `/publish-version` do: a publish or a role change that reached a
2741
+ // workspace reached one that exists. The read does NOT, for `/status`'s reason.
2742
+ if (url.pathname === "/onboarding/note-publish" && request.method === "POST") {
2743
+ let body = null;
2744
+ try { body = await request.json(); } catch (e) { /* handled below */ }
2745
+ if (!body) return Response.json({ error: "bad-input" }, { status: 400 });
2746
+ await this.init(body.workspaceId);
2747
+ return Response.json(this.noteFirstPublish(body.email, body.at));
2748
+ }
2749
+ if (url.pathname === "/onboarding/note-role" && request.method === "POST") {
2750
+ let body = null;
2751
+ try { body = await request.json(); } catch (e) { /* handled below */ }
2752
+ if (!body) return Response.json({ error: "bad-input" }, { status: 400 });
2753
+ await this.init(body.workspaceId);
2754
+ return Response.json(this.noteRoleTransition(body.from, body.to));
2755
+ }
2756
+ if (url.pathname === "/onboarding/status" && request.method === "POST") {
2757
+ let body = null;
2758
+ try { body = await request.json(); } catch (e) { /* an empty body asks about nobody */ }
2759
+ return Response.json(this.onboardingStatus(body && body.email));
2760
+ }
2761
+ if (url.pathname === "/publish-version" && request.method === "POST") {
2762
+ let body = null;
2763
+ try { body = await request.json(); } catch (e) { /* handled below */ }
2764
+ const space = body && body.space;
2765
+ if (!space) return Response.json({ error: "no-space" }, { status: 400 });
2766
+ await this.init(body.workspaceId);
2767
+ const version = this.nextPublishVersion(space, body.floor);
2768
+ return Response.json({ version });
2769
+ }
2770
+ // The workspace's own account of which spaces are its own. NOT a control verb: like
2771
+ // `/status` and `/suspension` this is the request path asking the workspace about
2772
+ // itself, and `CONTROL_VERBS` is what the OUTSIDE may do TO one. No init() either —
2773
+ // asking what a workspace holds must not bring one into being.
2774
+ if (url.pathname === "/publish-spaces" && request.method === "GET") {
2775
+ return Response.json(this.publishedSpaces());
2776
+ }
2777
+ // NO init() ON THIS ONE. See status() — a call on a typo must not create a workspace.
2778
+ if (url.pathname === "/status" && request.method === "GET") {
2779
+ return Response.json(this.status());
2780
+ }
2781
+ // The request path's question, and the reason it is not /status. No init() either.
2782
+ if (url.pathname === "/suspension" && request.method === "GET") {
2783
+ return Response.json(this.suspension());
2784
+ }
2785
+ // The workspace's own account-store bearer, for `/__enter` (Task 6, src/_worker.js)
2786
+ // to read via a stub fetch — a Durable Object stub only speaks HTTP, so `accountKey()`
2787
+ // needs a route the same way `status()` and `suspension()` do. NOT a control verb:
2788
+ // asking a workspace what it holds is the request path asking about itself, not
2789
+ // something the outside does TO it — and CONTROL_VERBS is that second list. No
2790
+ // init() either, for the same reason as the two routes above: asking must not create
2791
+ // a workspace nobody provisioned.
2792
+ if (url.pathname === "/account-key" && request.method === "GET") {
2793
+ return Response.json({ accountKey: this.accountKey() });
2794
+ }
2795
+ // A sign-in that already succeeded, offered to the workspace as a chance to come back.
2796
+ //
2797
+ // NOT a control verb, on purpose: `CONTROL_VERBS` is what the OUTSIDE may do to a
2798
+ // workspace, and this is the request path asking the workspace about itself — the same
2799
+ // side of the line `/activity` and `/suspension` are on. Adding it there would also put
2800
+ // it in the control plane's `TENANT_RPC`, which is a list of things an operator can be
2801
+ // granted, and nobody should be granted this.
2802
+ //
2803
+ // NO init(), for `status()`'s reason: the workspace id comes from a hostname, and a
2804
+ // sign-in attempt against a name nobody provisioned must not spring one into being.
2805
+ if (url.pathname === "/resume-on-sign-in" && request.method === "POST") {
2806
+ let body = null;
2807
+ try { body = await request.json(); } catch (e) { /* an empty body refuses inside */ }
2808
+ return Response.json(this.resumeOnSignIn(body || {}));
2809
+ }
2810
+ if (url.pathname === "/activity" && request.method === "POST") {
2811
+ let body = null;
2812
+ try { body = await request.json(); } catch (e) { /* an empty body is fine */ }
2813
+ await this.init(body && body.workspaceId);
2814
+ return Response.json({ wrote: this.touchActivity() });
2815
+ }
2816
+ if (url.pathname === "/destroy" && request.method === "POST") {
2817
+ // No init(): destroying a workspace that does not exist must not create one first.
2818
+ return Response.json(await this.destroy());
2819
+ }
2820
+ if (url.pathname === "/state/import" && request.method === "POST") {
2821
+ let body = null;
2822
+ try { body = await request.json(); } catch (e) { /* handled below */ }
2823
+ if (!body || !body.overlay || typeof body.overlay !== "object" || Array.isArray(body.overlay)) {
2824
+ return Response.json({ error: "bad-input" }, { status: 400 });
2825
+ }
2826
+ await this.init(body.workspaceId);
2827
+ // `identity` is optional and rides in the SAME call, so the roster and the content
2828
+ // land in one transaction. A second request for it would put back the seam this
2829
+ // route exists to remove — and the roster is the half that decides who gets in.
2830
+ return Response.json(this.importAll({
2831
+ overlay: body.overlay, identity: body.identity, at: null, prune: !!body.prune,
2832
+ }));
2833
+ }
2834
+ if (url.pathname === "/quota/bump" && request.method === "POST") {
2835
+ let body = null;
2836
+ try { body = await request.json(); } catch (e) { /* handled below */ }
2837
+ if (!body || !body.k || !body.field) return Response.json({ error: "bad-input" }, { status: 400 });
2838
+ await this.init(body.workspaceId);
2839
+ // The ceiling is read HERE rather than sent by the caller: a limit that travels in a
2840
+ // request body is a limit a caller can choose, and this object is the only thing that
2841
+ // can answer both questions in one round trip anyway.
2842
+ const ceiling = this.quotas()[body.field];
2843
+ const out = this.bumpCounter(body.k, body.window || "", body.by, ceiling);
2844
+ return Response.json(out);
2845
+ }
2846
+ // ── the identity families, read and written where they live ────────────────
2847
+ //
2848
+ // `B-kv-read-cutover`. Same shape as `/overlay/*` below, and deliberately so: that
2849
+ // straddle is the one every other family in this phase came across on, so a reader who
2850
+ // has understood one has understood both. `init()` for the same reason too — a
2851
+ // workspace the request path has reached is a workspace, and refusing to build its
2852
+ // tables here would make the first invite after a provision the one that fails.
2853
+ if (url.pathname.startsWith("/identity/") && request.method === "POST") {
2854
+ let body = null;
2855
+ try { body = await request.json(); } catch (e) { /* handled per verb below */ }
2856
+ if (!body) return Response.json({ error: "bad-input" }, { status: 400 });
2857
+ await this.init(body.workspaceId);
2858
+ const now = Number.isFinite(body.now) ? body.now : Date.now();
2859
+ switch (url.pathname) {
2860
+ // The raw token never crosses this wire — only its hash. See inviteRead.
2861
+ case "/identity/invite/read":
2862
+ return Response.json({ email: this.inviteRead(body.tokenHash, now) });
2863
+ case "/identity/invite/consume":
2864
+ return Response.json({ email: this.inviteConsume(body.tokenHash, now) });
2865
+ case "/identity/invite/mint":
2866
+ return Response.json(this.inviteMint(body, now));
2867
+ case "/identity/invite/revoke":
2868
+ return Response.json(this.inviteRevoke(body.email));
2869
+ case "/identity/lastseen/read":
2870
+ return Response.json({ map: this.lastseenRead() });
2871
+ case "/identity/lastseen/touch":
2872
+ return Response.json(this.lastseenTouch(body.email, body.throttleMs, now));
2873
+ case "/identity/lastseen/forget":
2874
+ return Response.json(this.lastseenForget(body.email));
2875
+ case "/identity/roster/read":
2876
+ return Response.json(this.rosterRead());
2877
+ case "/identity/roster/write":
2878
+ return Response.json(this.rosterWrite(body, now));
2879
+ case "/identity/token/read":
2880
+ return Response.json({ entry: this.publishTokenRead(body.tokenHash) });
2881
+ case "/identity/token/list":
2882
+ return Response.json(this.publishTokenList());
2883
+ case "/identity/token/mint":
2884
+ return Response.json(this.publishTokenMint(body, now));
2885
+ case "/identity/token/revoke":
2886
+ return Response.json(this.publishTokenRevoke(body));
2887
+ default:
2888
+ return Response.json({ error: "not-found" }, { status: 404 });
2889
+ }
2890
+ }
2891
+
2892
+ if (url.pathname.startsWith("/overlay/") && request.method === "POST") {
2893
+ let body = null;
2894
+ try { body = await request.json(); } catch (e) { /* handled below */ }
2895
+ if (!body || !body.family) return Response.json({ error: "no-family" }, { status: 400 });
2896
+ await this.init(body.workspaceId);
2897
+ const scope = body.scope || "";
2898
+ switch (url.pathname) {
2899
+ case "/overlay/read":
2900
+ return Response.json({ map: this.overlayRead(body.family, scope) });
2901
+ case "/overlay/scopes":
2902
+ // No key and no scope: the question is which scopes exist at all. Only a copy asks.
2903
+ return Response.json({ scopes: this.overlayScopes(body.family) });
2904
+ case "/overlay/set":
2905
+ if (!body.k) return Response.json({ error: "no-key" }, { status: 400 });
2906
+ this.overlaySet(body.family, scope, body.k, body.v === undefined ? null : body.v, null, body.owner);
2907
+ return Response.json({ map: this.overlayRead(body.family, scope) });
2908
+ case "/overlay/insert": {
2909
+ if (!body.k) return Response.json({ error: "no-key" }, { status: 400 });
2910
+ const inserted = this.overlayInsert(body.family, scope, body.k, body.v, null, body.owner);
2911
+ return Response.json({ inserted, map: this.overlayRead(body.family, scope) });
2912
+ }
2913
+ case "/overlay/owner":
2914
+ if (!body.k) return Response.json({ error: "no-key" }, { status: 400 });
2915
+ return Response.json(this.overlayOwner(body.family, scope, body.k) || { owner: null, acl: null });
2916
+ case "/overlay/read-rev":
2917
+ if (!body.k) return Response.json({ error: "no-key" }, { status: 400 });
2918
+ return Response.json(this.overlayReadRev(body.family, scope, body.k));
2919
+ case "/overlay/cas": {
2920
+ if (!body.k) return Response.json({ error: "no-key" }, { status: 400 });
2921
+ const rev = this.overlayCas(body.family, scope, body.k, body.v, body.rev);
2922
+ return Response.json({ ok: rev !== null, rev });
2923
+ }
2924
+ case "/overlay/replace":
2925
+ return Response.json({ map: this.overlayReplace(body.family, scope, body.map) });
2926
+ default:
2927
+ return Response.json({ error: "not-found" }, { status: 404 });
2928
+ }
2929
+ }
2930
+ return Response.json({ error: "not-found" }, { status: 404 });
2931
+ }
2932
+
2933
+ /** The plan this workspace is on, and every ceiling it carries, as one flat object. */
2934
+ quotas() {
2935
+ const plan = [...this.sql.exec(`SELECT v FROM meta WHERE k = 'plan'`)];
2936
+ const out = { plan: plan.length ? plan[0].v : DEFAULT_PLAN };
2937
+ for (const row of this.sql.exec(`SELECT k, n FROM quotas`)) out[row.k] = row.n;
2938
+ return out;
2939
+ }
2940
+ }