emdash-multisite 0.0.0-stage → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +681 -2
  3. package/SETUP.md +205 -0
  4. package/bin/setup.mjs +83 -0
  5. package/package.json +91 -4
  6. package/src/abuse.ts +65 -0
  7. package/src/auth-links.ts +21 -0
  8. package/src/auth-ui.ts +390 -0
  9. package/src/backup.ts +137 -0
  10. package/src/bootstrap.ts +51 -0
  11. package/src/clone-run.ts +57 -0
  12. package/src/clone.ts +77 -0
  13. package/src/config.ts +17 -0
  14. package/src/console-page.ts +318 -0
  15. package/src/console.ts +141 -0
  16. package/src/db/d1.ts +9 -0
  17. package/src/db/sqlite.ts +6 -0
  18. package/src/demo.ts +23 -0
  19. package/src/dialect.ts +109 -0
  20. package/src/edge-cache.ts +249 -0
  21. package/src/fleet-actions.ts +44 -0
  22. package/src/fleet-blocks.ts +115 -0
  23. package/src/fleet.ts +151 -0
  24. package/src/handoff.ts +58 -0
  25. package/src/index.ts +35 -0
  26. package/src/log.ts +6 -0
  27. package/src/media-bucket.ts +44 -0
  28. package/src/middleware.ts +607 -0
  29. package/src/orgs.ts +343 -0
  30. package/src/passwords.ts +60 -0
  31. package/src/platform-env.ts +10 -0
  32. package/src/plugin-entry.ts +145 -0
  33. package/src/plugin.ts +26 -0
  34. package/src/provision.ts +17 -0
  35. package/src/purge.ts +105 -0
  36. package/src/registry.ts +421 -0
  37. package/src/rewrite.ts +199 -0
  38. package/src/rules.ts +521 -0
  39. package/src/runtime.ts +69 -0
  40. package/src/scheduled.ts +107 -0
  41. package/src/scope.ts +178 -0
  42. package/src/sites.ts +97 -0
  43. package/src/slots.ts +38 -0
  44. package/src/storage.ts +57 -0
  45. package/src/vite.ts +256 -0
  46. package/templates/astro.config.snippet.mjs +42 -0
  47. package/templates/worker.ts +61 -0
  48. package/templates/wrangler.snippet.jsonc +26 -0
package/src/purge.ts ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Destroy a deleted site: drop its tables, remove its organization (which frees
3
+ * the slug) and clear its control-plane records. Irreversible, so it only runs
4
+ * for a site that is in the `deleted` state.
5
+ */
6
+ import { sql } from "kysely";
7
+ import { log } from "./log.ts";
8
+ import { runAsSite } from "./dialect.ts";
9
+ import { controlDb, forgetKnownSite, forgetSiteState, readSiteState } from "./registry.ts";
10
+ import { isTableOfSite, STATE_KEY_PREFIX } from "./rules.ts";
11
+ import { scopeState } from "./scope.ts";
12
+ import { purgeSiteMedia } from "./media-bucket.ts";
13
+
14
+ const quote = (name: string) => `"${name.replaceAll('"', '""')}"`;
15
+ const ORG_COLLECTIONS = ["members", "invitations", "teams", "teamMembers", "organizationRoles"];
16
+
17
+ /**
18
+ * SQLite refuses to drop a table whose foreign key points at a table that is
19
+ * already gone ("no such table: main.users$acme"). Children are dropped before
20
+ * parents when the order allows, but the order is not known up front, so when
21
+ * that error names a table of this site, put a one-column stand-in in its place,
22
+ * drop, and remove the stand-in again.
23
+ */
24
+ async function dropTable(db: ReturnType<typeof controlDb>, slug: string, name: string): Promise<void> {
25
+ try {
26
+ await sql.raw(`DROP TABLE ${quote(name)}`).execute(db);
27
+ } catch (error) {
28
+ const missing = /no such table: main\.([^:]+):/.exec(String((error as Error)?.message ?? error))?.[1];
29
+ if (!missing || !isTableOfSite(missing, slug)) throw error;
30
+ await sql.raw(`CREATE TABLE IF NOT EXISTS ${quote(missing)} (id TEXT PRIMARY KEY)`).execute(db);
31
+ try {
32
+ await sql.raw(`DROP TABLE ${quote(name)}`).execute(db);
33
+ } finally {
34
+ await sql.raw(`DROP TABLE IF EXISTS ${quote(missing)}`).execute(db);
35
+ }
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Drop every table of a site. Must run as the primary (physical table names, no rewriting).
41
+ * Also used before a restore replaces a site.
42
+ */
43
+ export async function dropSiteTables(slug: string): Promise<{ dropped: number; remaining: number }> {
44
+ const db = controlDb();
45
+ // Tables of this site that exist right now. Listed again on every pass: dropping a table
46
+ // can remove others (full-text shadows) and the stand-in below can create one.
47
+ const list = async () =>
48
+ (await sql<{ name: string }>`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '%$%'`.execute(db)).rows
49
+ .map((r) => r.name)
50
+ .filter((n) => isTableOfSite(n, slug));
51
+ let dropped = 0;
52
+ let todo = await list();
53
+ // Foreign keys point child -> parent, so a parent cannot be dropped while children
54
+ // (with rows, in a cloned site) exist. Keep making passes until nothing more drops.
55
+ for (let pass = 0; pass < 6 && todo.length; pass++) {
56
+ let progress = false;
57
+ for (const name of todo) {
58
+ // A full-text drop removes its shadow tables too, so one may already be gone.
59
+ const stillThere = await sql<{ n: number }>`SELECT COUNT(*) AS n FROM sqlite_master WHERE name = ${name}`.execute(db);
60
+ if (!Number(stillThere.rows[0]?.n)) continue;
61
+ try {
62
+ await dropTable(db, slug, name);
63
+ dropped++;
64
+ progress = true;
65
+ } catch {
66
+ // left for the next pass
67
+ }
68
+ }
69
+ todo = await list();
70
+ if (!progress) break;
71
+ }
72
+ return { dropped, remaining: todo.length };
73
+ }
74
+ export async function purgeSite(slug: string): Promise<{ dropped: number; remaining: number }> {
75
+ if ((await readSiteState(slug))?.state !== "deleted") throw new Error(`[multisite] refusing to purge "${slug}": not deleted`);
76
+ const db = controlDb();
77
+ const primary = scopeState().primary;
78
+
79
+ return runAsSite(primary, async () => {
80
+ const { dropped, remaining } = await dropSiteTables(slug);
81
+ if (remaining) return { dropped, remaining };
82
+ // Uploaded files and backups (shared bucket, one key prefix per site).
83
+ await purgeSiteMedia(slug);
84
+ // Organization and its members/invitations/teams on the control plane.
85
+ const orgs = await sql<{ id: string; data: string }>`SELECT id, data FROM _plugin_storage WHERE collection = 'organizations'`.execute(db);
86
+ const org = orgs.rows.find((r) => {
87
+ try {
88
+ return (JSON.parse(r.data) as { slug?: string }).slug === slug;
89
+ } catch {
90
+ return false;
91
+ }
92
+ });
93
+ if (org) {
94
+ for (const c of ORG_COLLECTIONS) {
95
+ await sql`DELETE FROM _plugin_storage WHERE collection = ${c} AND data LIKE ${`%"organizationId":"${org.id}"%`}`.execute(db);
96
+ }
97
+ await sql`DELETE FROM _plugin_storage WHERE collection = 'organizations' AND id = ${org.id}`.execute(db);
98
+ }
99
+ await sql`DELETE FROM options WHERE name IN (${`multisite:site:${slug}`}, ${STATE_KEY_PREFIX + slug})`.execute(db);
100
+ forgetSiteState(slug);
101
+ forgetKnownSite(slug);
102
+ log("site.purged", { site: slug, tables: dropped });
103
+ return { dropped, remaining: 0 };
104
+ });
105
+ }
@@ -0,0 +1,421 @@
1
+ /**
2
+ * Read-side of the control plane: which organizations (= sites) exist, who owns
3
+ * them, and whether a user may create another. Better Auth keeps orgs and
4
+ * members in plugin storage on the primary site; we read them directly so no
5
+ * HTTP round trip (and no change to the open-source plugin) is needed.
6
+ */
7
+ import { Kysely, sql } from "kysely";
8
+ import { getDb } from "emdash/runtime";
9
+ import { currentSite, siteDialect, runAsSite } from "./dialect.ts";
10
+ import { platformEnv } from "./platform-env.ts";
11
+ import { scopeState } from "./scope.ts";
12
+ import {
13
+ STATE_KEY_PREFIX,
14
+ parseSiteState,
15
+ planLimits,
16
+ limitsForPlan,
17
+ type Limits,
18
+ type SiteState,
19
+ type SiteStatus,
20
+ } from "./rules.ts";
21
+
22
+ /**
23
+ * Make sure the shared inner dialect exists. On a cold isolate our outer
24
+ * middleware runs BEFORE EmDash initialises its database, so the first request
25
+ * to one of our routes would otherwise find nothing to connect with
26
+ * ("database not initialised yet", HTTP 500). `getDb()` only builds the dialect.
27
+ */
28
+ export async function warm(): Promise<void> {
29
+ if (!scopeState().inner) await getDb();
30
+ }
31
+
32
+ /**
33
+ * Control-plane connection: the shared inner driver, site chosen per query by
34
+ * `runAsSite` only. It must NOT use the request's own site (`activeSite`): code
35
+ * that runs inside a tenant's request (a plugin route in the site's admin) still
36
+ * has to read the control plane, and the request's site would win over
37
+ * `runAsSite(primary)` and make it read the tenant's tables instead.
38
+ */
39
+ export function controlDb(): Kysely<never> {
40
+ const st = scopeState();
41
+ if (!st.inner) throw new Error("[multisite] database not initialised yet");
42
+ return new Kysely<never>({ dialect: siteDialect(st.inner, () => currentSite() ?? st.primary, st.primary) });
43
+ }
44
+
45
+ export interface OrgRecord {
46
+ id: string;
47
+ slug: string;
48
+ name: string;
49
+ ownerUserId: string | null;
50
+ /** Everyone in the org with their Better Auth role (owner, admin, member). */
51
+ members: Array<{ userId: string; role: string }>;
52
+ createdAt: string | null;
53
+ /** Better Auth `metadata` (a JSON string or object, depending on the writer). */
54
+ metadata: unknown;
55
+ }
56
+
57
+ const parse = <T>(s: string): T | null => {
58
+ try {
59
+ return JSON.parse(s) as T;
60
+ } catch {
61
+ return null;
62
+ }
63
+ };
64
+
65
+ /** Every organization with its owner. Two plain reads of the primary's plugin storage. */
66
+ export async function readOrgs(): Promise<OrgRecord[]> {
67
+ const db = controlDb();
68
+ return runAsSite(scopeState().primary, async () => {
69
+ const orgs = await sql<{ data: string }>`SELECT data FROM _plugin_storage WHERE collection = 'organizations'`.execute(db);
70
+ const members = await sql<{ data: string }>`SELECT data FROM _plugin_storage WHERE collection = 'members'`.execute(db);
71
+ const ownerByOrg = new Map<string, string>();
72
+ const membersByOrg = new Map<string, Array<{ userId: string; role: string }>>();
73
+ for (const r of members.rows) {
74
+ const m = parse<{ organizationId?: string; userId?: string; role?: string }>(r.data);
75
+ if (!m?.organizationId || !m.userId) continue;
76
+ if (m.role === "owner") ownerByOrg.set(m.organizationId, m.userId);
77
+ const list = membersByOrg.get(m.organizationId) ?? [];
78
+ list.push({ userId: m.userId, role: m.role ?? "member" });
79
+ membersByOrg.set(m.organizationId, list);
80
+ }
81
+ const out: OrgRecord[] = [];
82
+ for (const r of orgs.rows) {
83
+ const o = parse<{ id?: string; slug?: string; name?: string; createdAt?: string; metadata?: unknown }>(r.data);
84
+ if (!o?.slug || !o.id) continue;
85
+ out.push({
86
+ id: o.id,
87
+ slug: o.slug,
88
+ name: o.name ?? o.slug,
89
+ ownerUserId: ownerByOrg.get(o.id) ?? null,
90
+ members: membersByOrg.get(o.id) ?? [],
91
+ createdAt: o.createdAt ?? null,
92
+ metadata: o.metadata ?? null,
93
+ });
94
+ }
95
+ return out;
96
+ });
97
+ }
98
+
99
+ /** Has this user verified their email on the control plane? */
100
+ export async function isEmailVerified(userId: string): Promise<boolean> {
101
+ const db = controlDb();
102
+ const r = await runAsSite(scopeState().primary, () =>
103
+ sql<{ v: number }>`SELECT email_verified AS v FROM users WHERE id = ${userId}`.execute(db),
104
+ );
105
+ return Number(r.rows[0]?.v ?? 0) === 1;
106
+ }
107
+
108
+ /**
109
+ * The plan name of a user's active (or trialing) Better Auth / Stripe
110
+ * subscription, or `null` when they have none (the free tier). Reads the
111
+ * control-plane `subscriptions` plugin-storage collection, where
112
+ * `@better-auth/stripe` stores one row per subscription keyed by `referenceId`
113
+ * (the user id, our default). Only an "active" or "trialing" status counts; a
114
+ * canceled/incomplete sub is treated as no plan. Never throws — a read failure
115
+ * degrades to `null` (free tier), the safe direction.
116
+ * ponytail: O(subscriptions-for-this-user) JSON scan in app code because the
117
+ * value is a JSON blob, not columns. Fine (a user has one, maybe two rows).
118
+ * Upgrade path: index `referenceId` + `status` if this ever gets hot.
119
+ */
120
+ export async function readActivePlan(userId: string): Promise<string | null> {
121
+ try {
122
+ const db = controlDb();
123
+ const r = await runAsSite(scopeState().primary, () =>
124
+ sql<{ data: string }>`SELECT data FROM _plugin_storage WHERE collection = 'subscriptions'`.execute(db),
125
+ );
126
+ for (const row of r.rows) {
127
+ let sub: { referenceId?: unknown; plan?: unknown; status?: unknown };
128
+ try {
129
+ sub = JSON.parse(row.data);
130
+ } catch {
131
+ continue;
132
+ }
133
+ if (sub.referenceId !== userId) continue;
134
+ if (sub.status === "active" || sub.status === "trialing") {
135
+ return typeof sub.plan === "string" ? sub.plan : null;
136
+ }
137
+ }
138
+ } catch {
139
+ // Control plane unreadable — fall back to the free tier (safe).
140
+ }
141
+ return null;
142
+ }
143
+
144
+ /**
145
+ * The entitlements the billing webhook stashed on a user from their Stripe plan
146
+ * + subscription metadata (`users.data.planEntitlements`), or `null` when none.
147
+ * This is the metadata-driven path: `emdash-better-auth`'s stripe webhook parses
148
+ * the Stripe metadata once and writes the resolved caps here, so we read them
149
+ * with no Stripe call. Only the keys we enforce are picked out. Never throws.
150
+ */
151
+ export async function readUserEntitlements(userId: string): Promise<Limits | null> {
152
+ try {
153
+ const db = controlDb();
154
+ const r = await runAsSite(scopeState().primary, () =>
155
+ sql<{ data: string | null }>`SELECT data FROM users WHERE id = ${userId}`.execute(db),
156
+ );
157
+ const dataJson = r.rows[0]?.data;
158
+ if (!dataJson) return null;
159
+ const ent = (JSON.parse(dataJson) as { planEntitlements?: unknown }).planEntitlements;
160
+ if (!ent || typeof ent !== "object") return null;
161
+ const e = ent as Record<string, unknown>;
162
+ const out: Limits = {};
163
+ if (typeof e.sitesPerUser === "number") out.sitesPerUser = e.sitesPerUser;
164
+ if (typeof e.mediaBytesPerSite === "number") out.mediaBytesPerSite = e.mediaBytesPerSite;
165
+ return Object.keys(out).length > 0 ? out : null;
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Is Stripe billing turned on for the platform? Reads the control-plane
173
+ * `emdash-better-auth` setting. Only when this is true does the Better Auth UI
174
+ * expose `/account/billing`, so the console gates its "Upgrade" link on it (the
175
+ * route 404s otherwise). Never throws — defaults to off (no Upgrade link).
176
+ */
177
+ export async function billingEnabled(): Promise<boolean> {
178
+ try {
179
+ const db = controlDb();
180
+ const r = await runAsSite(scopeState().primary, () =>
181
+ sql<{ value: string }>`SELECT value FROM options WHERE name = 'plugin:better-auth-settings:settings:billingEnabled'`.execute(db),
182
+ );
183
+ const v = r.rows[0]?.value;
184
+ return v === "true" || v === '"true"' || v === "1";
185
+ } catch {
186
+ return false;
187
+ }
188
+ }
189
+
190
+ /**
191
+ * The effective limits for a user: their active plan's limits merged over the
192
+ * host's configured limits. A user with no active subscription gets the free
193
+ * tier. This is the resolver OP-11 called for — the create guard feeds its
194
+ * result to `createProblem`/`lostLimitRace` in place of the static
195
+ * `effectiveLimits(sites.limits)`.
196
+ */
197
+ export async function resolveLimits(userId: string, configured: Limits | undefined): Promise<Limits> {
198
+ // Precedence: metadata-driven entitlements (from the Stripe webhook, cover
199
+ // per-plan AND per-customer overrides) > built-in plan map (fallback when no
200
+ // metadata is set) > configured/default > free. The webhook has already
201
+ // merged price + subscription metadata, so a non-null result here is the
202
+ // authoritative per-user cap.
203
+ const [plan, entitlements] = await Promise.all([
204
+ readActivePlan(userId),
205
+ readUserEntitlements(userId),
206
+ ]);
207
+ const planPart = entitlements ?? planLimits(plan);
208
+ return limitsForPlan(configured, planPart);
209
+ }
210
+
211
+ /** Does this site already have its tables? (A site that predates status rows has them but no status.) */
212
+ export async function siteHasTables(slug: string): Promise<boolean> {
213
+ const db = controlDb();
214
+ const r = await runAsSite(scopeState().primary, () =>
215
+ sql<{ n: number }>`SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = ${`users$${slug}`}`.execute(db),
216
+ );
217
+ return Number(r.rows[0]?.n ?? 0) > 0;
218
+ }
219
+
220
+ const KNOWN = Symbol.for("emdash-multisite:known-sites");
221
+ const KNOWN_TTL_MS = 15_000;
222
+ const UNKNOWN_TTL_MS = 10_000;
223
+ /**
224
+ * Does this site exist (have tables)? Asked on every tenant request, BEFORE the site's runtime starts:
225
+ * starting it runs EmDash's migrations, so with wildcard DNS a request for ANY subdomain would otherwise
226
+ * create ~90 tables for a site nobody made (and a purged site would come back to life). Memoised per
227
+ * isolate: a known site for 15 s (shorter than the 30 s state memo, so right after a purge a stale "exists" ends first), an unknown one for 10 s (so a flood of random names costs one read
228
+ * per name per 10 s, not one per request).
229
+ * ponytail: the memo holds at most 2000 names, then starts over. Upgrade path: a Cloudflare rate limit on unknown hosts.
230
+ */
231
+ /**
232
+ * `fresh` (the three lookups below take it) skips the per-isolate memo. The edge cache passes it for renders that FILL the cache:
233
+ * after a delete/suspend/theme switch the cache is purged, and a stale memo in another isolate would otherwise re-render the old
234
+ * site and store it again right after the purge (seen live 2026-10-18). Cache hits never reach this code, so it only costs a few
235
+ * small reads per cache miss.
236
+ */
237
+ /** Forget what we know about a site (this isolate only), e.g. right after its purge. */
238
+ export function forgetKnownSite(slug: string): void {
239
+ (globalThis as Record<symbol, Map<string, unknown> | undefined>)[KNOWN]?.delete(slug);
240
+ }
241
+ export async function siteExists(slug: string, fresh = false): Promise<boolean> {
242
+ const g = globalThis as Record<symbol, Map<string, { ok: boolean; expires: number }> | undefined>;
243
+ const memo = (g[KNOWN] ??= new Map());
244
+ const hit = memo.get(slug);
245
+ if (!fresh && hit && hit.expires > Date.now()) return hit.ok;
246
+ const ok = await siteHasTables(slug);
247
+ if (memo.size > 2000) memo.clear();
248
+ memo.set(slug, { ok, expires: Date.now() + (ok ? KNOWN_TTL_MS : UNKNOWN_TTL_MS) });
249
+ return ok;
250
+ }
251
+
252
+ /** Provisioning status of every site, keyed by slug. */
253
+ export async function readAllStatuses(): Promise<Map<string, SiteStatus>> {
254
+ const db = controlDb();
255
+ const r = await runAsSite(scopeState().primary, () =>
256
+ sql<{ name: string; value: string }>`SELECT name, value FROM options WHERE name LIKE 'multisite:site:%'`.execute(db),
257
+ );
258
+ const out = new Map<string, SiteStatus>();
259
+ for (const row of r.rows) {
260
+ const v = parse<SiteStatus>(row.value);
261
+ if (v) out.set(row.name.slice("multisite:site:".length), v);
262
+ }
263
+ return out;
264
+ }
265
+
266
+ // Per-site theme, memoised briefly. A plain module variable would be duplicated
267
+ // across bundle chunks, so it lives on globalThis like the other multisite state.
268
+ const THEME_MEMO = Symbol.for("emdash-multisite:theme-memo");
269
+ const THEME_TTL_MS = 60_000;
270
+ type Memo = Map<string, { theme: string | null; expires: number }>;
271
+ const themeMemo = (): Memo => ((globalThis as Record<symbol, unknown>)[THEME_MEMO] ??= new Map()) as Memo;
272
+
273
+ /**
274
+ * The theme id recorded for `site` (`multisite:theme`), or null when none is
275
+ * set (the control plane, or a site that predates themes). Cached for a minute
276
+ * per isolate: it runs on every public request, and the value only changes
277
+ * when a site is provisioned. Failures (tables not created yet) are not cached.
278
+ */
279
+ export function forgetSiteTheme(site: string): void {
280
+ themeMemo().delete(site);
281
+ }
282
+
283
+ export async function readSiteTheme(site: string, fresh = false): Promise<string | null> {
284
+ const memo = themeMemo();
285
+ const hit = memo.get(site);
286
+ if (!fresh && hit && hit.expires > Date.now()) return hit.theme;
287
+ try {
288
+ const db = controlDb();
289
+ const r = await runAsSite(site, () =>
290
+ sql<{ value: string }>`SELECT value FROM options WHERE name = 'multisite:theme'`.execute(db),
291
+ );
292
+ const raw = r.rows[0]?.value;
293
+ const parsed = raw ? (JSON.parse(raw) as unknown) : null;
294
+ const theme = typeof parsed === "string" ? parsed : null;
295
+ memo.set(site, { theme, expires: Date.now() + THEME_TTL_MS });
296
+ return theme;
297
+ } catch {
298
+ return null;
299
+ }
300
+ }
301
+
302
+ // ---- control plane -> tenant handoff ---------------------------------------
303
+
304
+ const SECRET_KEY = Symbol.for("emdash-multisite:handoff-secret");
305
+
306
+ /**
307
+ * The key that signs handoff links. Generated once, stored on the control plane
308
+ * (the only place that can mint a link), and read by tenants from there, so no
309
+ * environment secret has to be shared between sites. Stored in D1 it is readable by anyone
310
+ * who can read the database, so a Worker secret `MULTISITE_SECRET` (32+ characters) takes
311
+ * precedence when the host sets one: `wrangler secret put MULTISITE_SECRET`.
312
+ */
313
+ export async function handoffSecret(): Promise<string> {
314
+ const g = globalThis as Record<symbol, string | undefined>;
315
+ if (g[SECRET_KEY]) return g[SECRET_KEY]!;
316
+ const fromEnv = (await platformEnv())?.MULTISITE_SECRET;
317
+ if (typeof fromEnv === "string" && fromEnv.length >= 32) return (g[SECRET_KEY] = fromEnv);
318
+ const db = controlDb();
319
+ const read = () =>
320
+ runAsSite(scopeState().primary, () =>
321
+ sql<{ value: string }>`SELECT value FROM options WHERE name = 'multisite:handoff-secret'`.execute(db),
322
+ );
323
+ let value = (await read()).rows[0]?.value;
324
+ if (!value) {
325
+ const fresh = JSON.stringify(btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32)))));
326
+ await runAsSite(scopeState().primary, () =>
327
+ sql`INSERT INTO options (name, value) VALUES ('multisite:handoff-secret', ${fresh}) ON CONFLICT(name) DO NOTHING`.execute(db),
328
+ );
329
+ value = (await read()).rows[0]?.value;
330
+ }
331
+ const secret = JSON.parse(value ?? "null") as string | null;
332
+ if (!secret) throw new Error("[multisite] could not create the handoff secret");
333
+ g[SECRET_KEY] = secret;
334
+ return secret;
335
+ }
336
+
337
+ /**
338
+ * Accept a handoff nonce once, on the tenant. Returns false if it was used before.
339
+ * Expired nonces are swept in the same call so the table does not grow.
340
+ */
341
+ export async function consumeHandoffNonce(site: string, nonce: string, exp: number): Promise<boolean> {
342
+ const db = controlDb();
343
+ return runAsSite(site, async () => {
344
+ await sql`DELETE FROM options WHERE name LIKE 'multisite:handoff:%' AND CAST(value AS INTEGER) < ${Date.now()}`.execute(db);
345
+ const r = await sql<{ name: string }>`
346
+ INSERT INTO options (name, value) VALUES (${`multisite:handoff:${nonce}`}, ${String(exp)})
347
+ ON CONFLICT(name) DO NOTHING RETURNING name`.execute(db);
348
+ return r.rows.length === 1;
349
+ });
350
+ }
351
+
352
+ /** Is `userId` an enabled user of `site`? */
353
+ export async function siteHasUser(site: string, userId: string): Promise<boolean> {
354
+ const db = controlDb();
355
+ const r = await runAsSite(site, () =>
356
+ sql<{ id: string }>`SELECT id FROM users WHERE id = ${userId} AND disabled = 0`.execute(db),
357
+ );
358
+ return r.rows.length === 1;
359
+ }
360
+
361
+ // ---- suspend / delete state --------------------------------------------------
362
+
363
+ const STATE_MEMO = Symbol.for("emdash-multisite:state-memo");
364
+ const STATE_TTL_MS = 30_000;
365
+ type StateMemo = Map<string, { value: SiteState | null; expires: number }>;
366
+ const stateMemo = (): StateMemo => ((globalThis as Record<symbol, unknown>)[STATE_MEMO] ??= new Map()) as StateMemo;
367
+
368
+ /**
369
+ * The site's suspend/delete state, read from the CONTROL PLANE (a tenant cannot
370
+ * touch it). Runs on every tenant request, so it is memoised for 30 s per
371
+ * isolate; a change made in this isolate takes effect immediately. On a read
372
+ * failure it returns null (serve the site) and does not cache the miss.
373
+ */
374
+ export async function readSiteState(site: string, fresh = false): Promise<SiteState | null> {
375
+ const memo = stateMemo();
376
+ const hit = memo.get(site);
377
+ if (!fresh && hit && hit.expires > Date.now()) return hit.value;
378
+ try {
379
+ const db = controlDb();
380
+ const r = await runAsSite(scopeState().primary, () =>
381
+ sql<{ value: string }>`SELECT value FROM options WHERE name = ${STATE_KEY_PREFIX + site}`.execute(db),
382
+ );
383
+ const value = parseSiteState(r.rows[0]?.value);
384
+ memo.set(site, { value, expires: Date.now() + STATE_TTL_MS });
385
+ return value;
386
+ } catch {
387
+ return null;
388
+ }
389
+ }
390
+
391
+ export function forgetSiteState(site: string): void {
392
+ stateMemo().delete(site);
393
+ }
394
+
395
+ /** Every site that is not active, keyed by slug. */
396
+ export async function readAllStates(): Promise<Map<string, SiteState>> {
397
+ const db = controlDb();
398
+ const r = await runAsSite(scopeState().primary, () =>
399
+ sql<{ name: string; value: string }>`SELECT name, value FROM options WHERE name LIKE ${`${STATE_KEY_PREFIX}%`}`.execute(db),
400
+ );
401
+ const out = new Map<string, SiteState>();
402
+ for (const row of r.rows) {
403
+ const v = parseSiteState(row.value);
404
+ if (v) out.set(row.name.slice(STATE_KEY_PREFIX.length), v);
405
+ }
406
+ return out;
407
+ }
408
+
409
+ /** Set (or clear, with null) a site's state. */
410
+ export async function writeSiteState(site: string, state: SiteState | null): Promise<void> {
411
+ const db = controlDb();
412
+ await runAsSite(scopeState().primary, async () => {
413
+ if (state) {
414
+ await sql`INSERT INTO options (name, value) VALUES (${STATE_KEY_PREFIX + site}, ${JSON.stringify(state)})
415
+ ON CONFLICT(name) DO UPDATE SET value = excluded.value`.execute(db);
416
+ } else {
417
+ await sql`DELETE FROM options WHERE name = ${STATE_KEY_PREFIX + site}`.execute(db);
418
+ }
419
+ });
420
+ stateMemo().set(site, { value: state, expires: Date.now() + STATE_TTL_MS });
421
+ }