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
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Cron fan-out. EmDash's scheduled handler publishes due scheduled content and
3
+ * runs plugin cron for ONE site: whichever is ambient, which in a cron trigger
4
+ * (no request, no hostname) is the primary. Wrap it so every ready site gets
5
+ * its turn:
6
+ *
7
+ * import handler, { createScheduledHandler, PluginBridge } from "@emdash-cms/cloudflare/worker";
8
+ * import { perSiteScheduled } from "emdash-multisite/scheduled";
9
+ * export default { ...handler, scheduled: perSiteScheduled(createScheduledHandler()) };
10
+ *
11
+ * ponytail: sites run one after another inside a single trigger, so a tick takes
12
+ * roughly (sites x per-site sweep). Fine for hundreds of mostly idle sites; the
13
+ * upgrade path is a Queue with one message per site, fed by this same loop.
14
+ */
15
+ import { runAsSite } from "./dialect.ts";
16
+ import { sitesConfig } from "./config.ts";
17
+ import { reconcile } from "./orgs.ts";
18
+ import { backupSite, readLastBackups } from "./backup.ts";
19
+ import { mediaBucket } from "./media-bucket.ts";
20
+ import { syncRecentPasswords } from "./passwords.ts";
21
+ import { purgeSite } from "./purge.ts";
22
+ import { readAllStates, readAllStatuses, warm } from "./registry.ts";
23
+ import { DEFAULT_DELETE_GRACE_DAYS, backupDue, isPurgeDue, mayStartAnother } from "./rules.ts";
24
+ import { scopeState } from "./scope.ts";
25
+
26
+ type Scheduled = (controller: unknown, env: unknown, ctx: { waitUntil(p: Promise<unknown>): void }) => unknown;
27
+
28
+ /**
29
+ * Sites that should run: the primary, then every site whose provisioning finished
30
+ * and that is neither suspended nor deleted. Skipping the latter two matters: waking
31
+ * a site's runtime runs its migrations, which would re-create the tables of a site
32
+ * that is being purged.
33
+ */
34
+ export async function sitesToSweep(): Promise<string[]> {
35
+ await warm();
36
+ const inactive = await readAllStates();
37
+ const ready = [...(await readAllStatuses())].filter(([slug, s]) => s.status === "ready" && !inactive.has(slug)).map(([slug]) => slug);
38
+ return [scopeState().primary, ...ready.sort()];
39
+ }
40
+
41
+ export function perSiteScheduled<T extends Scheduled>(base: T): T {
42
+ const wrapped: Scheduled = (controller, env, ctx) => {
43
+ ctx.waitUntil(
44
+ (async () => {
45
+ // A cron run can start in a fresh isolate that has served no request, so the database wrapper does not exist yet.
46
+ // Without this every task below failed with "database not initialised yet" (purge, backup, password sync, reconcile).
47
+ try {
48
+ await warm();
49
+ } catch (error) {
50
+ console.error("[multisite] could not open the database for the cron run:", error);
51
+ }
52
+ // Destroy at most one site whose delete grace period is over (a purge is many DROPs).
53
+ try {
54
+ const grace = sitesConfig()?.deleteGraceDays ?? DEFAULT_DELETE_GRACE_DAYS;
55
+ const due = [...(await readAllStates())].find(([, st]) => isPurgeDue(st, grace, Date.now()));
56
+ if (due) await runAsSite(scopeState().primary, () => purgeSite(due[0]));
57
+ } catch (error) {
58
+ console.error("[multisite] purge failed:", error);
59
+ }
60
+ // Back up at most one ready site that has no backup from the last day. Skipped without R2.
61
+ try {
62
+ const last = await readLastBackups();
63
+ const inactive = await readAllStates();
64
+ const due = [...(await readAllStatuses())]
65
+ .find(([slug, st]) => st.status === "ready" && !inactive.has(slug) && backupDue(last.get(slug), Date.now()));
66
+ if (due && (await mediaBucket())) await runAsSite(scopeState().primary, () => backupSite(due[0]));
67
+ } catch (error) {
68
+ console.error("[multisite] backup failed:", error);
69
+ }
70
+ // Safety net for password changes whose immediate sync was cut off.
71
+ try {
72
+ await runAsSite(scopeState().primary, () => syncRecentPasswords(10 * 60_000));
73
+ } catch (error) {
74
+ console.error("[multisite] password sync failed:", error);
75
+ }
76
+ // Finish one site whose provisioning died and that nobody is watching.
77
+ try {
78
+ const cfg = sitesConfig();
79
+ if (cfg) {
80
+ // Several stuck sites per tick (template clones take seconds), not one: a bad deploy can strand dozens.
81
+ const t0 = Date.now();
82
+ for (let done = 0; mayStartAnother(done, Date.now() - t0); done++) {
83
+ if (!(await runAsSite(scopeState().primary, () => reconcile(cfg))).length) break;
84
+ }
85
+ }
86
+ } catch (error) {
87
+ console.error("[multisite] reconcile failed:", error);
88
+ }
89
+ for (const site of await sitesToSweep()) {
90
+ // The base handler hands its work to ctx.waitUntil; collect it so each
91
+ // site finishes before the next starts, and one failing site does not
92
+ // stop the rest.
93
+ const pending: Promise<unknown>[] = [];
94
+ try {
95
+ await runAsSite(site, async () => {
96
+ await base(controller, env, { ...ctx, waitUntil: (p) => void pending.push(p) });
97
+ });
98
+ } catch (error) {
99
+ console.error(`[multisite] scheduled sweep for "${site}" failed:`, error);
100
+ }
101
+ await Promise.allSettled(pending);
102
+ }
103
+ })(),
104
+ );
105
+ };
106
+ return wrapped as T;
107
+ }
package/src/scope.ts ADDED
@@ -0,0 +1,178 @@
1
+ import { currentSite } from "./dialect.ts";
2
+
3
+ /**
4
+ * Per-site holders for core's process-wide caches.
5
+ *
6
+ * Core keeps many caches as ONE object captured at module load. The build-time
7
+ * patch (see vite.ts) replaces each such object with `siteScoped(key, init)`:
8
+ * a Proxy that forwards every operation to a separate real object per site.
9
+ */
10
+
11
+ const STATE = Symbol.for("emdash-multisite:state");
12
+ const REQUEST_CONTEXT = Symbol.for("emdash:request-context");
13
+ const SLOT_MEMO = Symbol.for("emdash-multisite:slot-proxies");
14
+
15
+ interface ScopeState {
16
+ primary: string;
17
+ /** the one shared inner dialect (set when the database descriptor initialises) */
18
+ inner?: import("kysely").Dialect;
19
+ /** resolves the site of the work happening right now */
20
+ activeSite: () => string;
21
+ /** per-site Kysely handle -> site id (core stores that handle in its request context) */
22
+ siteOfDb: WeakMap<object, string>;
23
+ holders: Map<string, object>;
24
+ /** site -> (globalThis symbol slot -> value), for caches read from globalThis on every use */
25
+ slots: Map<string, Map<symbol, unknown>>;
26
+ }
27
+
28
+ // On globalThis so duplicated bundle chunks share one registry.
29
+ export function scopeState(): ScopeState {
30
+ const g = globalThis as Record<symbol, unknown>;
31
+ return (g[STATE] ??= {
32
+ primary: "main",
33
+ activeSite: () => activeSite(),
34
+ siteOfDb: new WeakMap(),
35
+ holders: new Map(),
36
+ slots: new Map(),
37
+ }) as ScopeState;
38
+ }
39
+
40
+ /** Site the current work belongs to: request's db handle, else runAsSite, else primary. */
41
+ export function activeSite(): string {
42
+ const st = scopeState();
43
+ const als = (globalThis as Record<symbol, unknown>)[REQUEST_CONTEXT] as
44
+ | { getStore(): { db?: object } | undefined }
45
+ | undefined;
46
+ const db = als?.getStore()?.db;
47
+ return (db && st.siteOfDb.get(db)) ?? currentSite() ?? st.primary;
48
+ }
49
+
50
+ /** The nested single-flight cache (its own fields; 1.2.x this WAS the whole memo). */
51
+ interface SingleFlight {
52
+ version: number;
53
+ hasValue: boolean;
54
+ value: unknown;
55
+ valueVersion: number;
56
+ lock: { ownerStartedAt: unknown };
57
+ }
58
+ /**
59
+ * emdash 1.3.x wraps the single-flight cache in a stale-while-revalidate holder:
60
+ * `{ snapshot, expiresAt, generation, revalidatingUntil, refresh: SingleFlight }`.
61
+ * Fields optional so this also covers the 1.2.x bare-SingleFlight shape.
62
+ */
63
+ interface SettingsMemo extends Partial<SingleFlight> {
64
+ snapshot?: unknown;
65
+ expiresAt?: number;
66
+ generation?: number;
67
+ revalidatingUntil?: number;
68
+ refresh?: SingleFlight;
69
+ }
70
+ const lastForget = new Map<string, number>();
71
+ /**
72
+ * Drop the active site's memo of its site settings (title, tagline, logo, SEO defaults). Core keeps that memo for the whole
73
+ * isolate lifetime with no TTL, and only the isolate that handled a settings write drops its copy. A render that fills the edge
74
+ * cache right after a purge would otherwise store the OLD title for the whole cache TTL. Same steps as core's
75
+ * `invalidateSingleFlightCache`. At most once per 2 s per site, so a burst of fills after a purge shares one re-read (the
76
+ * trailing purge covers an edit that lands inside that window).
77
+ */
78
+ export function forgetSiteSettings(now = Date.now()): void {
79
+ const site = activeSite();
80
+ if (now - (lastForget.get(site) ?? 0) < 2000) return;
81
+ if (lastForget.size > 2000) lastForget.clear();
82
+ lastForget.set(site, now);
83
+ const memo = scopeState().holders.get("emdash:site-settings:swr") as SettingsMemo | undefined;
84
+ if (!memo) return;
85
+ // 1.3.x stale-while-revalidate wrapper: drop the stored snapshot + force a refresh.
86
+ if ("snapshot" in memo || "generation" in memo) {
87
+ memo.snapshot = null;
88
+ memo.expiresAt = 0;
89
+ memo.revalidatingUntil = 0;
90
+ if (typeof memo.generation === "number") memo.generation++;
91
+ }
92
+ // The inner single-flight cache: nested under `.refresh` in 1.3.x, the memo
93
+ // itself in 1.2.x. Clear whichever carries the single-flight fields.
94
+ const sf: Partial<SingleFlight> = memo.refresh ?? memo;
95
+ if (typeof sf.version === "number") sf.version++;
96
+ sf.hasValue = false;
97
+ sf.value = null;
98
+ sf.valueVersion = -1;
99
+ if (sf.lock) sf.lock.ownerStartedAt = null;
100
+ }
101
+
102
+ export function siteScoped<T extends object>(key: string, init: () => T): T {
103
+ const st = scopeState();
104
+ const existing = st.holders.get(key);
105
+ if (existing) return existing as T;
106
+
107
+ const perSite = new Map<string, T>();
108
+ const real = (): T => {
109
+ const site = activeSite();
110
+ let v = perSite.get(site);
111
+ if (!v) {
112
+ v = init();
113
+ perSite.set(site, v);
114
+ }
115
+ return v;
116
+ };
117
+ // ponytail: methods are re-bound on every read, so function identity is not
118
+ // stable across reads. Core only calls them; revisit if something compares them.
119
+ const proxy = new Proxy({} as T, {
120
+ get(_t, p) {
121
+ const r = real();
122
+ const v = Reflect.get(r, p, r);
123
+ return typeof v === "function" ? v.bind(r) : v;
124
+ },
125
+ set: (_t, p, v) => Reflect.set(real(), p, v),
126
+ has: (_t, p) => Reflect.has(real(), p),
127
+ deleteProperty: (_t, p) => Reflect.deleteProperty(real(), p),
128
+ ownKeys: () => Reflect.ownKeys(real()),
129
+ getOwnPropertyDescriptor(_t, p) {
130
+ const d = Reflect.getOwnPropertyDescriptor(real(), p);
131
+ if (d) d.configurable = true;
132
+ return d;
133
+ },
134
+ defineProperty: (_t, p, d) => Reflect.defineProperty(real(), p, d),
135
+ getPrototypeOf: () => Reflect.getPrototypeOf(real()),
136
+ });
137
+ st.holders.set(key, proxy);
138
+ return proxy;
139
+ }
140
+
141
+ /**
142
+ * Stand-in for `globalThis` in code that reads a cache from a `Symbol.for` slot
143
+ * on every use (`store[KEY] ?? create`). Reads and writes of the listed keys go
144
+ * to the active site's own slot; everything else passes through to the real
145
+ * globalThis.
146
+ */
147
+ export function siteGlobal(keys: readonly string[]): Record<symbol, unknown> {
148
+ const memo = (globalThis as Record<symbol, unknown>)[SLOT_MEMO] as Map<string, Record<symbol, unknown>> | undefined;
149
+ const id = keys.join("|");
150
+ const hit = memo?.get(id);
151
+ if (hit) return hit;
152
+ const scoped = new Set<symbol>(keys.map((k) => Symbol.for(k)));
153
+ const slot = (): Map<symbol, unknown> => {
154
+ const st = scopeState();
155
+ const site = activeSite();
156
+ let m = st.slots.get(site);
157
+ if (!m) st.slots.set(site, (m = new Map()));
158
+ return m;
159
+ };
160
+ const real = globalThis as unknown as Record<symbol, unknown>;
161
+ const proxy = new Proxy(real, {
162
+ get: (t, p) => (scoped.has(p as symbol) ? slot().get(p as symbol) : Reflect.get(t, p, t)),
163
+ set(t, p, v) {
164
+ if (scoped.has(p as symbol)) slot().set(p as symbol, v);
165
+ else Reflect.set(t, p, v, t);
166
+ return true;
167
+ },
168
+ has: (t, p) => (scoped.has(p as symbol) ? slot().has(p as symbol) : Reflect.has(t, p)),
169
+ deleteProperty: (t, p) => (scoped.has(p as symbol) ? slot().delete(p as symbol) : Reflect.deleteProperty(t, p)),
170
+ });
171
+ // Some declarations sit inside functions, so hand back one proxy per key list.
172
+ const table = ((real as Record<symbol, unknown>)[SLOT_MEMO] ??= new Map<string, Record<symbol, unknown>>()) as Map<
173
+ string,
174
+ Record<symbol, unknown>
175
+ >;
176
+ table.set(id, proxy);
177
+ return proxy;
178
+ }
package/src/sites.ts ADDED
@@ -0,0 +1,97 @@
1
+ import { isSiteId } from "./rewrite.ts";
2
+ import { THEME_ID, isLocalPath, type Limits, type Themes } from "./rules.ts";
3
+
4
+ /** Serializable (lives in the build-time database descriptor). */
5
+ export interface SitesConfig {
6
+ /** Site that keeps the original unprefixed tables. */
7
+ primary: string;
8
+ /** Exact hostname -> site id (custom domains). */
9
+ hosts?: Record<string, string>;
10
+ /** Base domain: `acme.example.com` -> site `acme`. */
11
+ wildcard?: string;
12
+ /** Caps on site creation. Defaults to 3 sites per user, no platform cap. */
13
+ limits?: Limits;
14
+ /**
15
+ * Make the site's sign-up state available to themes as `Astro.locals.multisite` (`{ signup, account }`), for the
16
+ * host's `AuthLinks` component that draws Sign in / Sign up / My account / Admin inside the theme header
17
+ * (see `authLinksFor`). Customer sites offer Sign in / Sign up only while their public sign-up is on. Default off.
18
+ */
19
+ authLinks?: boolean;
20
+ /** Extra email domains refused at sign-up, on top of the built-in disposable-mail list. */
21
+ blockedEmailDomains?: string[];
22
+ /**
23
+ * Themes a new site can pick, each with its seed. Omit for a single theme
24
+ * (the host's bundled seed).
25
+ * ponytail: seeds are embedded in the build-time config, which bloats it by
26
+ * the size of the seeds (tens of KB each). Upgrade path: a virtual module
27
+ * generated from a themes/ directory.
28
+ */
29
+ themes?: Themes;
30
+ /**
31
+ * The host shows "My sites" as a tab in Better Auth UI's account page
32
+ * (`/account/sites`, see `emdash-multisite/auth-ui`). `/sites` then redirects
33
+ * there and users land on it after signing in.
34
+ */
35
+ accountTab?: boolean;
36
+ /** Public address of the app (account, my sites), e.g. `https://app.example.com`. Used for links back from a site's admin. */
37
+ appUrl?: string;
38
+ /** Days a deleted site stays restorable before its data is purged. Default 14. */
39
+ deleteGraceDays?: number;
40
+ /** Where users land after signing in or up on the control plane. Defaults to `/sites`. */
41
+ postLoginPath?: string;
42
+ /** Theme preselected in the wizard and used when none is requested. Defaults to the first theme. */
43
+ defaultTheme?: string;
44
+ }
45
+
46
+ export function assertSitesConfig(c: SitesConfig): void {
47
+ if (!isSiteId(c.primary)) throw new Error(`[multisite] invalid primary site id: ${c.primary}`);
48
+ if (c.postLoginPath !== undefined && !isLocalPath(c.postLoginPath)) {
49
+ throw new Error(`[multisite] postLoginPath must be a path on this site: ${c.postLoginPath}`);
50
+ }
51
+ for (const id of Object.keys(c.themes ?? {})) {
52
+ if (!THEME_ID.test(id)) throw new Error(`[multisite] invalid theme id: ${id}`);
53
+ }
54
+ if (c.defaultTheme && !Object.hasOwn(c.themes ?? {}, c.defaultTheme)) {
55
+ throw new Error(`[multisite] defaultTheme "${c.defaultTheme}" is not in themes`);
56
+ }
57
+ if (c.appUrl !== undefined && !/^https?:\/\/[^/\s]+$/.test(c.appUrl)) throw new Error("[multisite] appUrl must be an origin like https://app.example.com (no path)");
58
+ if (c.deleteGraceDays !== undefined && (!Number.isInteger(c.deleteGraceDays) || c.deleteGraceDays < 0)) {
59
+ throw new Error("[multisite] deleteGraceDays must be a non-negative integer");
60
+ }
61
+ if (c.limits?.concurrentProvisions !== undefined && c.limits.concurrentProvisions < 1) throw new Error("[multisite] limits.concurrentProvisions must be at least 1");
62
+ for (const [k, v] of Object.entries(c.limits ?? {})) {
63
+ if (v !== undefined && (!Number.isInteger(v) || v < 0)) throw new Error(`[multisite] limits.${k} must be a non-negative integer`);
64
+ }
65
+ if (c.blockedEmailDomains && !c.blockedEmailDomains.every((d) => typeof d === "string" && /^[a-z0-9.-]+\.[a-z]{2,}$/i.test(d))) {
66
+ throw new Error("[multisite] blockedEmailDomains must be domain names like example.com");
67
+ }
68
+ for (const [host, id] of Object.entries(c.hosts ?? {})) {
69
+ if (!isSiteId(id)) throw new Error(`[multisite] invalid site id for ${host}: ${id}`);
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Hostname -> site id. Unknown hosts (localhost, workers.dev previews) fall
75
+ * back to the primary site, which is what a single-site install does anyway.
76
+ */
77
+ export function resolveSite(hostname: string, c: SitesConfig): string {
78
+ const host = hostname.toLowerCase().replace(/:\d+$/, "");
79
+ const exact = c.hosts?.[host];
80
+ if (exact) return exact;
81
+ if (c.wildcard && host.endsWith(`.${c.wildcard}`)) {
82
+ const label = host.slice(0, -(c.wildcard.length + 1));
83
+ if (isSiteId(label)) return label;
84
+ }
85
+ return c.primary;
86
+ }
87
+
88
+ /**
89
+ * Public address of a site, or `null` when no hostname is configured for it.
90
+ * An exact `hosts` entry wins (it is what the Worker actually routes), then the
91
+ * wildcard base domain.
92
+ */
93
+ export function siteUrl(slug: string, c: SitesConfig): string | null {
94
+ const host = Object.entries(c.hosts ?? {}).find(([, id]) => id === slug)?.[0];
95
+ if (host) return `https://${host}`;
96
+ return c.wildcard ? `https://${slug}.${c.wildcard}` : null;
97
+ }
package/src/slots.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * A platform-wide cap on how many sites are provisioned at the same time. One D1 database does one write at a time and a site
3
+ * clone writes ~370 objects, so a burst of sign-ups made every other site slow (measured: page reads p95 0.6 s to 3.2 s while 12
4
+ * sites were created at once). A slot is a control-plane `options` row holding its expiry; one INSERT ... SELECT takes a slot only
5
+ * while fewer than `max` unexpired rows exist, and D1 runs statements one at a time, so the cap is exact. A crashed holder frees
6
+ * its slot when the row expires.
7
+ * ponytail: expired rows of crashed holders stay until their site is provisioned again (they are ignored, not counted).
8
+ */
9
+ import { sql } from "kysely";
10
+ import { runAsSite } from "./dialect.ts";
11
+ import { controlDb } from "./registry.ts";
12
+ import { SLOT_PREFIX, SLOT_TTL_MS } from "./rules.ts";
13
+ import { scopeState } from "./scope.ts";
14
+
15
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
16
+
17
+ /** Take a slot for `slug`, waiting up to `waitMs`. False when none came free in time. */
18
+ export async function acquireSlot(slug: string, max: number, waitMs: number): Promise<boolean> {
19
+ const db = controlDb();
20
+ const deadline = Date.now() + waitMs;
21
+ for (;;) {
22
+ const now = Date.now();
23
+ const r = await runAsSite(scopeState().primary, () =>
24
+ sql`INSERT INTO options (name, value)
25
+ SELECT ${SLOT_PREFIX + slug}, ${String(now + SLOT_TTL_MS)}
26
+ WHERE (SELECT COUNT(*) FROM options WHERE name LIKE ${`${SLOT_PREFIX}%`} AND CAST(value AS INTEGER) > ${now}) < ${max}
27
+ ON CONFLICT(name) DO UPDATE SET value = excluded.value`.execute(db),
28
+ );
29
+ if (Number(r.numAffectedRows ?? 0) > 0) return true;
30
+ if (Date.now() >= deadline) return false;
31
+ await sleep(500);
32
+ }
33
+ }
34
+
35
+ export async function releaseSlot(slug: string): Promise<void> {
36
+ const db = controlDb();
37
+ await runAsSite(scopeState().primary, () => sql`DELETE FROM options WHERE name = ${SLOT_PREFIX + slug}`.execute(db));
38
+ }
package/src/storage.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Runtime storage entrypoint: one shared R2 bucket, one key prefix per site.
3
+ *
4
+ * Core caches ONE storage instance for the whole Worker, so the site is read on
5
+ * every call (from `runAsSite`, set by the middleware), not when the instance is
6
+ * built. Keys stored in a site's `media` table stay unprefixed; the prefix is
7
+ * added here and removed from listings, so a site can only ever name its own
8
+ * objects, whatever key a request carries.
9
+ *
10
+ * Host config:
11
+ * storage: { ...r2({ binding: "MEDIA" }), entrypoint: "emdash-multisite/storage" }
12
+ */
13
+ import { sql } from "kysely";
14
+ import { createStorage as createR2 } from "@emdash-cms/cloudflare/storage/r2";
15
+ import { EmDashStorageError, type Storage } from "emdash";
16
+ import { currentSite } from "./dialect.ts";
17
+ import { sitesConfig } from "./config.ts";
18
+ import { controlDb } from "./registry.ts";
19
+ import { mediaKey, unmediaKey } from "./rules.ts";
20
+ import { scopeState } from "./scope.ts";
21
+
22
+ const site = (): string => currentSite() ?? scopeState().primary;
23
+
24
+ /** Throws when the active site is at or over `limits.mediaBytesPerSite`. */
25
+ async function assertQuota(): Promise<void> {
26
+ const cap = sitesConfig()?.limits?.mediaBytesPerSite;
27
+ if (cap === undefined) return;
28
+ const r = await sql<{ used: number | null }>`SELECT SUM(size) AS used FROM media`.execute(controlDb());
29
+ // ponytail: checked before the upload, so one file can overshoot the cap. Upgrade path: pass the size in.
30
+ if ((r.rows[0]?.used ?? 0) >= cap) throw new EmDashStorageError("Media storage limit reached for this site", "QUOTA_EXCEEDED");
31
+ }
32
+
33
+ export function createStorage(config: Record<string, unknown>): Storage {
34
+ const inner = createR2(config);
35
+ const publicUrl = typeof config.publicUrl === "string";
36
+ const getPublicUrl = (key: string) => inner.getPublicUrl(publicUrl ? mediaKey(site(), key) : key);
37
+ return {
38
+ async upload(o) {
39
+ await assertQuota();
40
+ const r = await inner.upload({ ...o, key: mediaKey(site(), o.key) });
41
+ return { ...r, key: o.key, url: getPublicUrl(o.key) };
42
+ },
43
+ // ponytail: `o` (byte range) exists in current core only; `any` keeps older typings compiling.
44
+ download: (key: string, o?: any) => (inner.download as any)(mediaKey(site(), key), o),
45
+ delete: (key) => inner.delete(mediaKey(site(), key)),
46
+ exists: (key) => inner.exists(mediaKey(site(), key)),
47
+ async list(o: { prefix?: string; limit?: number; cursor?: string } = {}) {
48
+ const s = site();
49
+ const r = await inner.list({ ...o, prefix: mediaKey(s, o.prefix ?? "") });
50
+ return { ...r, files: r.files.flatMap((f) => { const k = unmediaKey(s, f.key); return k === null ? [] : [{ ...f, key: k }]; }) };
51
+ },
52
+ getSignedUploadUrl: (o) => inner.getSignedUploadUrl(o),
53
+ // Served through the host (`/_emdash/api/media/file/<key>`), which the site's own request already scopes.
54
+ // A configured CDN `publicUrl` has no such scoping, so its URLs carry the prefix.
55
+ getPublicUrl,
56
+ };
57
+ }