@augurworks/augur 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CANVAS.md +858 -0
- package/INSTALL.md +603 -0
- package/LICENSE +27 -0
- package/README.md +150 -0
- package/TRADEMARKS.md +72 -0
- package/agents/README.md +65 -0
- package/agents/canon-extract.md +117 -0
- package/agents/canon.md +151 -0
- package/agents/canvas.md +88 -0
- package/agents/currency.md +74 -0
- package/agents/drafts.md +92 -0
- package/agents/identity.md +148 -0
- package/agents/prototype-contract.md +69 -0
- package/agents/publishing.md +324 -0
- package/agents/review-feedback.md +76 -0
- package/agents/space-json.md +132 -0
- package/agents/ui-skill.md +101 -0
- package/agents/working-marks.md +86 -0
- package/brand/augur-eye.svg +3 -0
- package/brand/augur-icon.svg +41 -0
- package/brand/augur-mark.svg +5 -0
- package/brand/lentianova-bulky.otf +0 -0
- package/brand/lentianova-median.otf +0 -0
- package/brand/lentianova.woff2 +0 -0
- package/build.js +8409 -0
- package/changelog.md +133 -0
- package/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/package.json +57 -0
- package/pitis/PETS.md +121 -0
- package/pitis/README.md +117 -0
- package/pitis/index.html +266 -0
- package/pitis/piti.build.js +81 -0
- package/pitis/piti.js +929 -0
- package/pitis/roast-agent.md +185 -0
- package/realtime/src/index.js +119 -0
- package/realtime/wrangler.example.toml +44 -0
- package/scripts/adopt.mjs +106 -0
- package/scripts/board-snapshot.mjs +197 -0
- package/scripts/build-seed-pack.mjs +33 -0
- package/scripts/bundle-rekey.mjs +112 -0
- package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
- package/scripts/canon-extract.mjs +578 -0
- package/scripts/canon.mjs +599 -0
- package/scripts/canvas-screen.mjs +333 -0
- package/scripts/check-local.mjs +136 -0
- package/scripts/clawd-art.mjs +75 -0
- package/scripts/clawd-canvas.mjs +741 -0
- package/scripts/cli.mjs +100 -0
- package/scripts/clone.mjs +422 -0
- package/scripts/close.mjs +30 -0
- package/scripts/connect.mjs +103 -0
- package/scripts/deploy.mjs +157 -0
- package/scripts/dev.mjs +54 -0
- package/scripts/doc-lint.mjs +143 -0
- package/scripts/export.mjs +300 -0
- package/scripts/fork.mjs +64 -0
- package/scripts/freeze.mjs +74 -0
- package/scripts/frontdoor-parity.mjs +180 -0
- package/scripts/hook.mjs +67 -0
- package/scripts/identity-rekey.mjs +112 -0
- package/scripts/init.mjs +122 -0
- package/scripts/land.mjs +40 -0
- package/scripts/lib/adapters.mjs +156 -0
- package/scripts/lib/board-snapshot.mjs +511 -0
- package/scripts/lib/draft.mjs +385 -0
- package/scripts/lib/graduate.mjs +276 -0
- package/scripts/lib/instance.mjs +74 -0
- package/scripts/lib/marks.mjs +107 -0
- package/scripts/lib/materialize.mjs +138 -0
- package/scripts/lib/merge3.mjs +124 -0
- package/scripts/lib/offline-posture.mjs +120 -0
- package/scripts/lib/offline-respawn.mjs +18 -0
- package/scripts/lib/offline-wrangler.mjs +78 -0
- package/scripts/lib/publish-compose.mjs +12 -0
- package/scripts/lib/publish-conflict.mjs +78 -0
- package/scripts/lib/publish-evidence.mjs +144 -0
- package/scripts/lib/refine-compare.mjs +140 -0
- package/scripts/lib/refine-ledger.mjs +150 -0
- package/scripts/lib/refine-png.mjs +149 -0
- package/scripts/lib/seed-pack-build.mjs +141 -0
- package/scripts/lib/state-compare.mjs +68 -0
- package/scripts/lib/store.mjs +122 -0
- package/scripts/login.mjs +84 -0
- package/scripts/mark.mjs +112 -0
- package/scripts/migrate-board-keys.mjs +164 -0
- package/scripts/migrate.mjs +265 -0
- package/scripts/no-foreign-vocabulary.mjs +216 -0
- package/scripts/no-product-names.mjs +157 -0
- package/scripts/no-tenant-globals.mjs +1134 -0
- package/scripts/offline.mjs +247 -0
- package/scripts/og.mjs +170 -0
- package/scripts/one-tenant-resolver.mjs +146 -0
- package/scripts/open.mjs +63 -0
- package/scripts/publish.mjs +1041 -0
- package/scripts/read.mjs +31 -0
- package/scripts/refine.mjs +455 -0
- package/scripts/release-drift.mjs +138 -0
- package/scripts/restore.mjs +322 -0
- package/scripts/review.mjs +125 -0
- package/scripts/save.mjs +21 -0
- package/scripts/shell-lint.mjs +189 -0
- package/scripts/ship.mjs +460 -0
- package/scripts/shoot.mjs +264 -0
- package/scripts/smoke-invite.mjs +113 -0
- package/scripts/state-inventory.mjs +223 -0
- package/scripts/status.mjs +177 -0
- package/scripts/sync.mjs +27 -0
- package/scripts/tenant-do-rehearsal.mjs +1314 -0
- package/scripts/ui-copy-lint.mjs +202 -0
- package/scripts/watch.mjs +32 -0
- package/scripts/wrangler-preflight.mjs +246 -0
- package/seed/CANON.md +43 -0
- package/seed/README.md +119 -0
- package/seed/prototype-status.json +9 -0
- package/seed/registry.json +88 -0
- package/seed/skills/starter-ui/SKILL.md +52 -0
- package/seed/skills/starter-ui/skill.json +11 -0
- package/seed/skills/starter-ui/starter-tokens.css +73 -0
- package/seed/skills/starter-ui/starter-ui.css +318 -0
- package/seed/skills/starter-ui/starter-ui.js +50 -0
- package/seed/space.json +5 -0
- package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
- package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
- package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
- package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
- package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
- package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
- package/seed/threads.json +52 -0
- package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
- package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
- package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
- package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
- package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
- package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
- package/src/_worker.js +12668 -0
- package/src/board-key.mjs +57 -0
- package/src/board-room.mjs +881 -0
- package/src/bundle-keys.mjs +138 -0
- package/src/canon/collect-in-browser.js +215 -0
- package/src/canon/emit.mjs +232 -0
- package/src/canon/observe.mjs +260 -0
- package/src/canon/schema.mjs +451 -0
- package/src/canvas/DSEG-LICENSE.txt +95 -0
- package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
- package/src/canvas/canvas.css +885 -0
- package/src/canvas/canvas.js +5780 -0
- package/src/canvas/capture.js +485 -0
- package/src/chrome/appchrome.mjs +568 -0
- package/src/currency.mjs +203 -0
- package/src/delete-confirmation.mjs +254 -0
- package/src/drafts/drafts.js +326 -0
- package/src/entry.js +58 -0
- package/src/galleries.mjs +400 -0
- package/src/health-cron.mjs +173 -0
- package/src/identity.json +1 -0
- package/src/kv-codec.mjs +135 -0
- package/src/kv-identity.mjs +259 -0
- package/src/mail.mjs +599 -0
- package/src/provenance.mjs +74 -0
- package/src/publish-compose.mjs +363 -0
- package/src/publish-fork.mjs +221 -0
- package/src/publish-units.mjs +44 -0
- package/src/purge.mjs +81 -0
- package/src/review/COMMENTING-UX.md +306 -0
- package/src/review/cat.png +0 -0
- package/src/review/comment-cursor.svg +7 -0
- package/src/review/comments.js +1738 -0
- package/src/room-ticket.mjs +96 -0
- package/src/roster-chip.mjs +32 -0
- package/src/seed-pack.mjs +261 -0
- package/src/state-inventory.mjs +249 -0
- package/src/sw-logic.mjs +48 -0
- package/src/tenant-cache.mjs +141 -0
- package/src/tenant-context.mjs +386 -0
- package/src/tenant-do.js +2940 -0
- package/src/tenant-host.mjs +144 -0
- package/src/tenant-quotas.mjs +140 -0
- package/src/unit-core.mjs +103 -0
- package/src/unit-object.mjs +338 -0
- package/templates/README.md +67 -0
- package/templates/shell/deploy.yml +145 -0
- package/templates/shell/engine-bump.yml +142 -0
- package/templates/shell/health.yml +233 -0
- package/templates/shell/kv-backup.yml +201 -0
- package/templates/shell/roster-update.yml +110 -0
- package/templates/shell/space-preflight.yml +35 -0
- package/templates/shell/space-rebake.yml +79 -0
- package/templates/shell/store-backup.yml +152 -0
- package/templates/shell/wrangler.example.toml +266 -0
- package/templates/space/publish.yml +91 -0
package/src/tenant-do.js
ADDED
|
@@ -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
|
+
}
|