emdash-multisite 0.0.0-stage → 0.8.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/rules.ts ADDED
@@ -0,0 +1,521 @@
1
+ /**
2
+ * Pure rules for creating and recovering sites. No database, no virtual
3
+ * modules, so scripts/*.selfcheck.ts can import it under plain Node.
4
+ */
5
+ import { isSiteId } from "./rewrite.ts";
6
+
7
+ export const ORG_CREATE_PATH = "/api/auth/organization/create";
8
+
9
+ export const SIGNUP_PATH = "/api/auth/sign-up/email";
10
+ /**
11
+ * Subdomain labels that must never become customer sites: infrastructure and mail names,
12
+ * words that make a phishing address look official (`login`, `secure`, `billing`), and
13
+ * big brands. ponytail: a short list, not a moderation system. Upgrade path: a
14
+ * `reservedSiteIds` config option, or review at creation for anything borderline.
15
+ */
16
+ export const RESERVED_SITE_IDS: ReadonlySet<string> = new Set([
17
+ "www", "app", "api", "admin", "auth", "login", "mail", "smtp", "ftp", "cdn", "static",
18
+ "assets", "dashboard", "console", "status", "docs", "help", "support", "billing", "internal",
19
+ "root", "administrator", "webmaster", "postmaster", "hostmaster", "abuse", "security", "noc", "noreply", "no-reply",
20
+ "ns", "ns1", "ns2", "dns", "mx", "imap", "pop", "pop3", "webmail", "email", "autodiscover", "autoconfig",
21
+ "account", "accounts", "signin", "sign-in", "signup", "sign-up", "register", "verify", "oauth", "sso", "secure",
22
+ "pay", "payment", "payments", "checkout", "wallet", "bank", "invoice",
23
+ "staging", "stage", "dev", "preview", "sandbox", "beta", "alpha", "prod", "production", "origin", "edge",
24
+ "media", "files", "upload", "uploads", "download", "downloads", "img", "images", "storage",
25
+ "legal", "privacy", "terms", "sales", "staff",
26
+ "cloudflare", "emdash", "astro", "google", "microsoft", "apple", "amazon", "facebook", "meta", "paypal", "stripe", "github",
27
+ ]);
28
+ /**
29
+ * Throwaway-mail domains. Sign-up with one of these is refused, so a bot cannot mint
30
+ * accounts (each able to create sites) from an inbox it never reads.
31
+ * ponytail: a seed list that goes stale. Upgrade path: a maintained list, or Turnstile.
32
+ */
33
+ export const DISPOSABLE_EMAIL_DOMAINS: ReadonlySet<string> = new Set([
34
+ "mailinator.com", "guerrillamail.com", "guerrillamail.net", "guerrillamail.org", "sharklasers.com", "grr.la",
35
+ "10minutemail.com", "10minutemail.net", "tempmail.com", "temp-mail.org", "temp-mail.io", "tempmailo.com",
36
+ "throwawaymail.com", "yopmail.com", "yopmail.net", "trashmail.com", "trashmail.net", "getnada.com", "nada.email",
37
+ "maildrop.cc", "dispostable.com", "fakeinbox.com", "mailnesia.com", "mintemail.com", "mohmal.com", "emailondeck.com",
38
+ "spamgourmet.com", "mytemp.email", "tempinbox.com", "burnermail.io", "moakt.com", "discard.email", "tmpmail.org",
39
+ ]);
40
+ /** True when the address's domain (or a parent of it) is on the built-in list or in `extra`. */
41
+ export function emailDomainBlocked(email: unknown, extra: readonly string[] = []): boolean {
42
+ if (typeof email !== "string") return false;
43
+ const at = email.lastIndexOf("@");
44
+ if (at < 0) return false;
45
+ const labels = email.slice(at + 1).trim().toLowerCase().split(".");
46
+ const blocked = new Set([...DISPOSABLE_EMAIL_DOMAINS, ...extra.map((d) => d.toLowerCase())]);
47
+ // "a.b.mailinator.com" is blocked by "mailinator.com".
48
+ return labels.some((_, i) => blocked.has(labels.slice(i).join(".")));
49
+ }
50
+ /** Template sites (a pre-built copy per theme that new sites are cloned from) live under this prefix. */
51
+ export const TEMPLATE_PREFIX = "tpl-";
52
+ export const templateId = (theme: string): string => `${TEMPLATE_PREFIX}${theme}`;
53
+ export const isTemplateId = (id: string): boolean => id.startsWith(TEMPLATE_PREFIX);
54
+
55
+ /** `null` when the slug can be a site id, otherwise a human-readable reason. */
56
+ export function slugProblem(slug: unknown, primary: string): string | null {
57
+ if (typeof slug !== "string" || slug.length === 0) return "Choose a name for your site address.";
58
+ if (!isSiteId(slug)) {
59
+ return "Use 1-40 lowercase letters, digits or hyphens, starting and ending with a letter or digit.";
60
+ }
61
+ if (slug === primary || RESERVED_SITE_IDS.has(slug) || isTemplateId(slug)) return `"${slug}" is reserved. Choose another address.`;
62
+ return null;
63
+ }
64
+
65
+ export type SiteStatus =
66
+ | { status: "provisioning"; startedAt: string; step?: ProvisionStep }
67
+ | { status: "ready"; startedAt: string; readyAt: string; timings?: Partial<Record<ProvisionStep, number>> }
68
+ | { status: "failed"; startedAt: string; error: string };
69
+
70
+ interface CreatedOrg {
71
+ id?: string;
72
+ slug?: string;
73
+ name?: string;
74
+ metadata?: unknown;
75
+ members?: Array<{ userId?: string; role?: string }>;
76
+ }
77
+
78
+ /** Provisioning phases, in order. Shown in the wizard and timed in the `ready` status. */
79
+ export type ProvisionStep = "queued" | "tables" | "content" | "identity";
80
+
81
+ /** Pull the fields provisioning needs out of Better Auth's create-org response. */
82
+ export function parseCreatedOrg(
83
+ body: unknown,
84
+ ): { slug: string; name: string; ownerUserId: string; theme: string | null } | null {
85
+ const o = body as CreatedOrg | null;
86
+ const owner = o?.members?.find((m) => m.role === "owner") ?? o?.members?.[0];
87
+ if (!o?.slug || !o.name || !owner?.userId) return null;
88
+ return { slug: o.slug, name: o.name, ownerUserId: owner.userId, theme: themeFromMetadata(o.metadata) };
89
+ }
90
+
91
+ // ---- limits ----------------------------------------------------------------
92
+
93
+ /** Serializable (lives in the build-time config). `undefined` = no cap. */
94
+ export interface Limits {
95
+ /** Sites one user may own. */
96
+ sitesPerUser?: number;
97
+ /** Sites on the whole platform (each site is ~90 D1 tables). */
98
+ totalSites?: number;
99
+ /** Media bytes one site may hold (sum of `media.size`). Checked before each upload. */
100
+ mediaBytesPerSite?: number;
101
+ /** Sites provisioned at the same time, platform-wide (default 1: measured, see OP-43). The rest wait their turn. */
102
+ concurrentProvisions?: number;
103
+ }
104
+
105
+ /** Object key a site's media lives under in the shared bucket. */
106
+ export const mediaKey = (site: string, key: string): string => `${site}/${key}`;
107
+ /** Inverse of `mediaKey`; `null` when the key belongs to another site. */
108
+ export const unmediaKey = (site: string, key: string): string | null =>
109
+ key.startsWith(`${site}/`) ? key.slice(site.length + 1) : null;
110
+
111
+ /** A sane default so an unconfigured install is not an unlimited-site free-for-all. */
112
+ export const DEFAULT_LIMITS: Limits = { sitesPerUser: 3 };
113
+ /** The configured limits on top of the defaults: setting one limit (say `mediaBytesPerSite`) must not silently drop the 3-site cap. */
114
+ export const effectiveLimits = (configured: Limits | undefined): Limits => ({ ...DEFAULT_LIMITS, ...configured });
115
+
116
+ /** The subset of `Limits` a billing plan grants (sites + storage). */
117
+ export type PlanLimits = Pick<Limits, "sitesPerUser" | "mediaBytesPerSite">;
118
+
119
+ /** GiB in bytes, for readable storage caps. */
120
+ const GiB = 1024 ** 3;
121
+
122
+ /**
123
+ * Plan name (the Better Auth / Stripe subscription `plan` field, lowercased) →
124
+ * resource limits. Kept HERE, not imported from `emdash-better-auth`, so this
125
+ * private package stays decoupled from the open-source plugin (the plugin owns
126
+ * the Stripe price catalog + pricing display; this map owns what each tier is
127
+ * ALLOWED to provision). The two must agree on the tier NAMES — that is the only
128
+ * contract between them. A plan not in this map falls back to the free tier.
129
+ *
130
+ * Mirrors the plugin's ladder: Pro (individual) → Business (team) → Agency
131
+ * (many client sites) → Enterprise (custom, high ceiling).
132
+ */
133
+ export const PLAN_LIMITS: Readonly<Record<string, PlanLimits>> = {
134
+ pro: { sitesPerUser: 3, mediaBytesPerSite: 10 * GiB },
135
+ // Enterprise's built-in caps are a high ceiling only; the real per-customer
136
+ // caps come from the metadata on their manual Stripe subscription (folded
137
+ // into a per-user override by the billing webhook). See
138
+ // emdash-plan/product/better-auth-stripe-features.md.
139
+ enterprise: { sitesPerUser: 1000, mediaBytesPerSite: 1000 * GiB },
140
+ };
141
+
142
+ /** Limits for a user with NO active subscription (the free tier baseline). */
143
+ export const FREE_PLAN_LIMITS: PlanLimits = { sitesPerUser: 1, mediaBytesPerSite: 1 * GiB };
144
+
145
+ /** The limits a plan name grants; unknown/empty (no subscription) → free tier. */
146
+ export const planLimits = (plan: string | null | undefined): PlanLimits =>
147
+ plan ? (PLAN_LIMITS[plan.toLowerCase()] ?? FREE_PLAN_LIMITS) : FREE_PLAN_LIMITS;
148
+
149
+ /**
150
+ * Merge a subscriber's PLAN limits on top of the host's configured limits (which
151
+ * themselves sit on {@link DEFAULT_LIMITS}). A plan value wins when set; a plan
152
+ * that omits a key leaves the configured/default value in place, so turning on
153
+ * billing never drops an unrelated cap. Pure + unit-testable; the caller does
154
+ * the subscription read and passes the resolved plan name through
155
+ * {@link planLimits}.
156
+ */
157
+ export const limitsForPlan = (configured: Limits | undefined, plan: PlanLimits): Limits => {
158
+ const base = effectiveLimits(configured);
159
+ return {
160
+ ...base,
161
+ ...(plan.sitesPerUser !== undefined ? { sitesPerUser: plan.sitesPerUser } : {}),
162
+ ...(plan.mediaBytesPerSite !== undefined ? { mediaBytesPerSite: plan.mediaBytesPerSite } : {}),
163
+ };
164
+ };
165
+
166
+ export interface CreateContext {
167
+ /** Signed-in user, or null. */
168
+ userId: string | null;
169
+ emailVerified: boolean;
170
+ /** Sites this user already owns (includes failed ones, so retry beats re-create). */
171
+ owned: number;
172
+ /** All sites on the platform. */
173
+ total: number;
174
+ }
175
+
176
+ export interface Problem {
177
+ status: number;
178
+ code: string;
179
+ message: string;
180
+ }
181
+
182
+ /**
183
+ * Why this user may not create another site, or `null` when they may.
184
+ * An anonymous caller returns `null`: Better Auth answers 401 itself.
185
+ */
186
+ export function createProblem(c: CreateContext, limits: Limits = DEFAULT_LIMITS): Problem | null {
187
+ if (!c.userId) return null;
188
+ if (!c.emailVerified) {
189
+ return { status: 403, code: "EMAIL_NOT_VERIFIED", message: "Verify your email address before creating a site." };
190
+ }
191
+ if (limits.sitesPerUser !== undefined && c.owned >= limits.sitesPerUser) {
192
+ return {
193
+ status: 403,
194
+ code: "SITE_LIMIT_REACHED",
195
+ message: `You can own up to ${limits.sitesPerUser} site${limits.sitesPerUser === 1 ? "" : "s"}.`,
196
+ };
197
+ }
198
+ if (limits.totalSites !== undefined && c.total >= limits.totalSites) {
199
+ return { status: 503, code: "PLATFORM_FULL", message: "New sites are paused right now. Please try again later." };
200
+ }
201
+ return null;
202
+ }
203
+
204
+ /**
205
+ * Did this freshly created site lose a race against its owner's other creations?
206
+ * `createProblem` runs BEFORE Better Auth creates the org, so parallel requests all see
207
+ * "0 owned" and all pass. Run again AFTER creation: sites are ranked by creation time
208
+ * (slug breaks ties, so every parallel request ranks them the same way), and a site
209
+ * ranked past the limit loses. Deleted sites do not count.
210
+ */
211
+ export function lostLimitRace(
212
+ slug: string,
213
+ orgs: ReadonlyArray<{ slug: string; ownerUserId: string | null; createdAt: string | null }>,
214
+ deleted: ReadonlySet<string>,
215
+ limits: Limits = DEFAULT_LIMITS,
216
+ ): boolean {
217
+ const order = (a: { slug: string; createdAt: string | null }, b: { slug: string; createdAt: string | null }) =>
218
+ (a.createdAt ?? "").localeCompare(b.createdAt ?? "") || a.slug.localeCompare(b.slug);
219
+ const live = orgs.filter((o) => !deleted.has(o.slug)).sort(order);
220
+ const me = live.find((o) => o.slug === slug);
221
+ if (!me) return false;
222
+ const mine = live.filter((o) => o.ownerUserId === me.ownerUserId);
223
+ if (limits.sitesPerUser !== undefined && mine.indexOf(me) >= limits.sitesPerUser) return true;
224
+ return limits.totalSites !== undefined && live.indexOf(me) >= limits.totalSites;
225
+ }
226
+
227
+ // ---- public theme demos ------------------------------------------------------
228
+
229
+ /** Public paths a demo may serve besides its pages: search results and media files. */
230
+ const DEMO_OPEN_API = ["/_emdash/api/search", "/_emdash/api/media/file/"];
231
+ /**
232
+ * A template site doubles as a public, read-only demo of its theme. Returns the HTTP status to refuse a
233
+ * request with, or `null` to let it through: only GET/HEAD, and no admin, auth, plugin or write APIs.
234
+ */
235
+ export function demoGuard(method: string, pathname: string): number | null {
236
+ if (method !== "GET" && method !== "HEAD") return 405;
237
+ if (pathname.startsWith("/api/") || pathname.startsWith("/_multisite/")) return 404;
238
+ if (pathname.startsWith("/_emdash") && !DEMO_OPEN_API.some((p) => (p.endsWith("/") ? pathname.startsWith(p) : pathname === p))) return 404;
239
+ return null;
240
+ }
241
+ const escapeHtml = (v: string) => v.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
242
+ /** The strip shown at the bottom of a demo page. `url` is where "Use this theme" goes. */
243
+ export function demoBannerHtml(themeName: string, url: string): string {
244
+ // The strip is fixed to the bottom; padding keeps it from covering the footer (theme switcher, links).
245
+ return `<style>body{padding-bottom:64px}</style><div role="region" aria-label="Theme demo" style="position:fixed;inset:auto 0 0 0;z-index:2147483647;display:flex;gap:12px;align-items:center;justify-content:center;flex-wrap:wrap;padding:10px 16px;background:#111827;color:#fff;font:500 14px/1.4 system-ui,sans-serif">`
246
+ + `<span>This is a demo of the ${escapeHtml(themeName)} theme.</span>`
247
+ + `<a href="${escapeHtml(url)}" style="background:#fff;color:#111827;padding:6px 12px;border-radius:8px;text-decoration:none;font-weight:600">Use this theme</a></div>`;
248
+ }
249
+
250
+ // ---- backups ---------------------------------------------------------------
251
+
252
+ /** Backups kept per site; older ones are deleted when a new one is saved. */
253
+ export const BACKUP_KEEP = 7;
254
+ export const BACKUP_EVERY_MS = 24 * 3_600_000;
255
+ /** Folder of a site's backups in the media bucket. Site ids start with a letter or digit, so `_backups` never collides with a site's media prefix. */
256
+ export const backupPrefix = (slug: string): string => `_backups/${slug}/`;
257
+ export const backupKey = (slug: string, iso: string, tag?: string): string => `${backupPrefix(slug)}${iso}${tag ? `-${tag}` : ""}.json`;
258
+ /** A backup is due when there is none yet or the last one is a day old. */
259
+ export const backupDue = (lastIso: string | undefined, now: number): boolean => !lastIso || now - Date.parse(lastIso) >= BACKUP_EVERY_MS;
260
+
261
+ // ---- recovery --------------------------------------------------------------
262
+
263
+ /**
264
+ * A `provisioning` row older than this is a run that died. Work after a
265
+ * response (`waitUntil`) gets ~30 s, so a live run is never older than ~35 s;
266
+ * the blog theme's first run (tables ~25 s, then ~40 s of content) is killed
267
+ * and finished by the next one.
268
+ */
269
+ export const STALE_PROVISIONING_MS = 40_000;
270
+
271
+ /**
272
+ * The cron finishes stuck sites in a loop. A site made from a template takes a few seconds, one without takes up to ~90 s, and
273
+ * ticks are 5 minutes apart (two ticks must not both pick up the same half-finished site), so a tick starts another site only
274
+ * while fewer than 150 s have passed and at most 10 per tick.
275
+ */
276
+ export const RECONCILE_MAX_PER_TICK = 10;
277
+ export const RECONCILE_START_BUDGET_MS = 150_000;
278
+ export const mayStartAnother = (done: number, elapsedMs: number): boolean => done < RECONCILE_MAX_PER_TICK && elapsedMs < RECONCILE_START_BUDGET_MS;
279
+
280
+ /** Provisioning slots (see slots.ts): the default cap, how long a slot lives if its holder dies, how long one attempt waits, and how soon a queued site is retried. */
281
+ export const DEFAULT_CONCURRENT_PROVISIONS = 1;
282
+ export const SLOT_PREFIX = "multisite:slot:";
283
+ export const SLOT_TTL_MS = 150_000;
284
+ export const SLOT_WAIT_MS = 6_000;
285
+ export const QUEUED_RETRY_MS = 8_000;
286
+
287
+ /** Should provisioning run (again) for a site with this status? */
288
+ export function needsReconcile(status: SiteStatus | null, now: number, staleMs = STALE_PROVISIONING_MS): boolean {
289
+ if (!status) return true;
290
+ if (status.status === "failed") return true;
291
+ // A site waiting for a slot is retried soon (every few seconds, so two attempts never overlap); a dead run is detected by age.
292
+ if (status.status === "provisioning") return now - Date.parse(status.startedAt) > (status.step === "queued" ? QUEUED_RETRY_MS : staleMs);
293
+ return false;
294
+ }
295
+
296
+ // ---- themes ----------------------------------------------------------------
297
+
298
+ /** What a theme is to the platform. Serializable: it lives in the build-time config. */
299
+ export interface ThemeDef {
300
+ /** Shown in the picker. */
301
+ name: string;
302
+ description?: string;
303
+ /** EmDash seed (collections, settings, starter content) applied when a site picks this theme. */
304
+ seed?: unknown;
305
+ /** Plain-words label for the picker ("A blog") and a one-line pitch ("Write posts and share ideas"). */
306
+ pitch?: string;
307
+ /** A screenshot of the theme (a path on the app host), shown on the picker cards. */
308
+ preview?: string;
309
+ /** "What next" links shown when the site is ready: a label and an admin path (`/_emdash/admin/...`) on the new site. */
310
+ checklist?: Array<{ label: string; path: string }>;
311
+ }
312
+ export type Themes = Record<string, ThemeDef>;
313
+
314
+ /** Theme ids end up in an option value and a URL query, so keep them boring. */
315
+ export const THEME_ID = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
316
+
317
+ /** Id used when no themes are configured: the host's single bundled seed. */
318
+ export const BUNDLED_THEME = "default";
319
+
320
+ /**
321
+ * The theme id carried in a Better Auth organization's `metadata`. Depending on
322
+ * who wrote the row it is an object or a JSON string.
323
+ */
324
+ export function themeFromMetadata(metadata: unknown): string | null {
325
+ let m = metadata;
326
+ if (typeof m === "string") {
327
+ try {
328
+ m = JSON.parse(m);
329
+ } catch {
330
+ return null;
331
+ }
332
+ }
333
+ const t = (m as { theme?: unknown } | null)?.theme;
334
+ return typeof t === "string" && t ? t : null;
335
+ }
336
+
337
+ /** Why `requested` cannot be used, or `null` (absent is fine: the default applies). */
338
+ export function themeProblem(requested: unknown, themes: Themes | undefined): string | null {
339
+ if (requested === undefined || requested === null) return null;
340
+ if (typeof requested !== "string" || !THEME_ID.test(requested)) return "Choose one of the available themes.";
341
+ if (!themes || !Object.hasOwn(themes, requested)) return "Choose one of the available themes.";
342
+ return null;
343
+ }
344
+
345
+ /**
346
+ * The theme a site gets: the requested one when it is configured, else the
347
+ * default (`defaultTheme`, else the first configured, else the bundled seed).
348
+ * A stored theme that has since been removed from the config falls back too.
349
+ */
350
+ export function effectiveTheme(requested: string | null, themes: Themes | undefined, defaultTheme?: string): string {
351
+ const ids = Object.keys(themes ?? {});
352
+ if (ids.length === 0) return BUNDLED_THEME;
353
+ if (requested && ids.includes(requested)) return requested;
354
+ return defaultTheme && ids.includes(defaultTheme) ? defaultTheme : ids[0]!;
355
+ }
356
+
357
+ // ---- theme routing ---------------------------------------------------------
358
+
359
+ /**
360
+ * Each theme's pages live under this prefix in the host (`src/pages/theme-routes/<id>/`).
361
+ * The middleware rewrites a public request to its site's theme subtree, so two
362
+ * themes can both own `/` or `/contact` without colliding, and each theme's CSS
363
+ * only loads on its own pages.
364
+ */
365
+ export const THEME_ROUTE_PREFIX = "/theme-routes";
366
+
367
+ /** Paths owned by EmDash, Better Auth, the console or the platform: never themed. */
368
+ const NOT_THEMED =
369
+ /^\/(?:_emdash|_astro|_image|api|auth|login|signup|account|organization|admin|audit-log|sites|\.well-known|theme-routes)(?:\/|$)|^\/(?:sitemap(?:-[^/]+)?\.xml|robots\.txt)$/;
370
+
371
+ /** Is this a public page path that the site's theme renders? */
372
+ export function isThemed(pathname: string): boolean {
373
+ return !NOT_THEMED.test(pathname);
374
+ }
375
+
376
+ /** Does this request address the theme subtree directly (which would bypass site selection)? */
377
+ export function isThemeRouteRequest(pathname: string): boolean {
378
+ return pathname === THEME_ROUTE_PREFIX || pathname.startsWith(`${THEME_ROUTE_PREFIX}/`);
379
+ }
380
+
381
+ /** `/posts/hello` + `blog` -> `/theme-routes/blog/posts/hello`. */
382
+ export function themeRoute(theme: string, pathname: string): string {
383
+ return `${THEME_ROUTE_PREFIX}/${theme}${pathname === "/" ? "" : pathname.replace(/\/$/, "")}`;
384
+ }
385
+
386
+ // ---- CSRF ------------------------------------------------------------------
387
+
388
+ /** Header every state-changing multisite API call must carry, as core requires for `/_emdash` APIs. */
389
+ export const CSRF_HEADER = "x-emdash-request";
390
+
391
+ /**
392
+ * A browser cannot send a custom header cross-site without a CORS preflight we
393
+ * never grant, so requiring it stops a forged form post or `fetch` from another
394
+ * origin from riding the user's session cookie. Also accept a same-origin
395
+ * `Origin` for clients that cannot add headers.
396
+ */
397
+ export function csrfOk(headers: { get(name: string): string | null }, requestOrigin: string): boolean {
398
+ if (headers.get(CSRF_HEADER) === "1") return true;
399
+ return headers.get("origin") === requestOrigin;
400
+ }
401
+
402
+ // ---- theme switching -------------------------------------------------------
403
+
404
+ /** Collection slugs a theme's seed defines. */
405
+ export function seedCollections(seed: unknown): string[] {
406
+ const cols = (seed as { collections?: Array<{ slug?: unknown }> } | null)?.collections;
407
+ return Array.isArray(cols) ? cols.flatMap((c) => (typeof c?.slug === "string" ? [c.slug] : [])) : [];
408
+ }
409
+
410
+ /**
411
+ * What switching `from` -> `to` does to the data model. Switching never deletes:
412
+ * `adds` are collections the target needs (created empty, or with sample content
413
+ * if asked), `hides` are collections the target does not display (content stays
414
+ * and comes back if the owner switches back).
415
+ */
416
+ export function switchSummary(themes: Themes, from: string, to: string): { adds: string[]; hides: string[] } {
417
+ const a = seedCollections(themes[from]?.seed);
418
+ const b = seedCollections(themes[to]?.seed);
419
+ return { adds: b.filter((c) => !a.includes(c)), hides: a.filter((c) => !b.includes(c)) };
420
+ }
421
+
422
+ // ---- post-login landing ----------------------------------------------------
423
+
424
+ /** Where the control plane sends users after sign-in or sign-up when nothing else was asked. */
425
+ export const DEFAULT_POST_LOGIN_PATH = "/sites";
426
+
427
+ const AUTH_ENTRY = new Set(["/login", "/signup", "/auth/sign-in", "/auth/sign-up"]);
428
+
429
+ /** A path-only redirect target: starts with one `/`, not `//` or `/\` (which browsers read as another host). */
430
+ export const isLocalPath = (p: string): boolean => /^\/(?![/\\])/.test(p);
431
+
432
+ /**
433
+ * The URL to redirect a sign-in / sign-up page request to, or null to serve it.
434
+ * A signed-in visitor goes straight to the landing page; an anonymous one gets
435
+ * the landing page added as `redirect` so Better Auth UI sends them there after
436
+ * authenticating. An explicit `redirect` is always respected.
437
+ */
438
+ export function loginEntryRedirect(url: URL, signedIn: boolean, landing: string): string | null {
439
+ if (!AUTH_ENTRY.has(url.pathname)) return null;
440
+ if (signedIn) return landing;
441
+ if (url.searchParams.has("redirect")) return null;
442
+ const next = new URL(url);
443
+ next.searchParams.set("redirect", landing);
444
+ return next.pathname + next.search;
445
+ }
446
+
447
+ // ---- site state (suspend / delete) -----------------------------------------
448
+
449
+ /**
450
+ * Operational state of a site, kept on the control plane so a tenant can never
451
+ * change it. `suspended` is reversible by staff; `deleted` is the owner's soft
452
+ * delete: the site is hidden and unreachable, and staff can restore it until it
453
+ * is purged.
454
+ */
455
+ export type SiteState = { state: "suspended" | "deleted"; at: string; reason?: string };
456
+
457
+ export const STATE_KEY_PREFIX = "multisite:state:";
458
+
459
+ export function parseSiteState(raw: string | null | undefined): SiteState | null {
460
+ if (!raw) return null;
461
+ try {
462
+ const v = JSON.parse(raw) as Partial<SiteState> | null;
463
+ return v && (v.state === "suspended" || v.state === "deleted") && typeof v.at === "string"
464
+ ? { state: v.state, at: v.at, ...(typeof v.reason === "string" ? { reason: v.reason } : {}) }
465
+ : null;
466
+ } catch {
467
+ return null;
468
+ }
469
+ }
470
+
471
+ export type StateAction = "suspend" | "resume" | "delete" | "restore";
472
+
473
+ /**
474
+ * What an action does from the current state, or why it cannot be done.
475
+ * `next: null` means "back to active". Suspend/resume/restore are staff only;
476
+ * the owner may delete.
477
+ */
478
+ export function transition(
479
+ current: SiteState | null,
480
+ action: StateAction,
481
+ actor: { staff: boolean; owner: boolean },
482
+ ): { ok: true; next: SiteState["state"] | null } | { ok: false; status: number; code: string } {
483
+ const forbidden = { ok: false, status: 403, code: "FORBIDDEN" } as const;
484
+ const conflict = { ok: false, status: 409, code: "INVALID_STATE" } as const;
485
+ switch (action) {
486
+ case "suspend":
487
+ if (!actor.staff) return forbidden;
488
+ return current ? conflict : { ok: true, next: "suspended" };
489
+ case "resume":
490
+ if (!actor.staff) return forbidden;
491
+ return current?.state === "suspended" ? { ok: true, next: null } : conflict;
492
+ case "delete":
493
+ if (!actor.staff && !actor.owner) return forbidden;
494
+ return current?.state === "deleted" ? conflict : { ok: true, next: "deleted" };
495
+ case "restore":
496
+ if (!actor.staff) return forbidden;
497
+ return current?.state === "deleted" ? { ok: true, next: null } : conflict;
498
+ }
499
+ }
500
+
501
+ // ---- purge -----------------------------------------------------------------
502
+
503
+ /** Days a deleted site is kept (restorable) before its data is destroyed. */
504
+ export const DEFAULT_DELETE_GRACE_DAYS = 14;
505
+
506
+ export function isPurgeDue(state: SiteState | undefined | null, graceDays: number, now: number): boolean {
507
+ return state?.state === "deleted" && now - Date.parse(state.at) >= graceDays * 86_400_000;
508
+ }
509
+
510
+ /**
511
+ * Does the physical table `name` belong to site `slug`? Tables are `<t>$<slug>`; a
512
+ * full-text table also owns shadow tables `<t>$<slug>_data|_idx|_content|_docsize|_config`.
513
+ * Matching is exact on the slug, so `$a` never claims `$ab`.
514
+ */
515
+ export function isTableOfSite(name: string, slug: string): boolean {
516
+ const i = name.lastIndexOf(`$${slug}`);
517
+ if (i < 1) return false;
518
+ const rest = name.slice(i + 1 + slug.length);
519
+ return rest === "" || /^_(?:data|idx|content|docsize|config)$/.test(rest);
520
+ }
521
+
package/src/runtime.ts ADDED
@@ -0,0 +1,69 @@
1
+ import { Kysely, type Dialect } from "kysely";
2
+ import { currentSite, siteDialect } from "./dialect.ts";
3
+ import { activeSite, scopeState } from "./scope.ts";
4
+ import { resolveSite, type SitesConfig } from "./sites.ts";
5
+
6
+ /** Config the wrapped descriptor hands to its runtime entrypoint. */
7
+ export interface TenancyConfig<InnerConfig = unknown> {
8
+ inner: InnerConfig;
9
+ sites: SitesConfig;
10
+ }
11
+
12
+ /** Subset of core's RequestScopedDbOpts this wrapper reads. */
13
+ interface ScopedOpts {
14
+ config: unknown;
15
+ url: URL;
16
+ }
17
+
18
+ /**
19
+ * Build the `createDialect` / `createRequestScopedDb` pair core expects from a
20
+ * descriptor entrypoint, around an inner adapter's `createDialect`.
21
+ *
22
+ * ponytail: one database config per isolate (a Worker has exactly one DB
23
+ * descriptor), so the inner dialect and per-site Kysely instances are cached
24
+ * at module scope without keying on config.
25
+ */
26
+ export function makeRuntime<InnerConfig>(createInner: (config: InnerConfig) => Dialect) {
27
+ let inner: Dialect | undefined;
28
+ const getInner = (c: TenancyConfig<InnerConfig>) => {
29
+ inner ??= createInner(c.inner);
30
+ scopeState().inner = inner;
31
+ return inner;
32
+ };
33
+ const perSite = new Map<string, Kysely<unknown>>();
34
+
35
+ return {
36
+ /** Singleton path: boot, migrations, cron. Site comes from `runAsSite`, else primary. */
37
+ createDialect(config: TenancyConfig<InnerConfig>): Dialect {
38
+ scopeState().primary = config.sites.primary;
39
+ // The shared singleton also serves per-site runtimes, so its site is resolved per query
40
+ // (request's site handle, else runAsSite, else primary), never fixed at creation.
41
+ return siteDialect(getInner(config), activeSite, config.sites.primary);
42
+ },
43
+
44
+ /**
45
+ * Per-request path: the site is resolved from the hostname and baked into
46
+ * a cached per-site Kysely, so nothing downstream can run unscoped.
47
+ * Always returns a handle (never null), or core would fall back to the
48
+ * primary-site singleton for every request.
49
+ */
50
+ createRequestScopedDb(opts: ScopedOpts): { db: Kysely<unknown>; commit: () => void } {
51
+ const config = opts.config as TenancyConfig<InnerConfig>;
52
+ // Core also asks for a scoped db with a fixed internal URL (`withEmDashRuntime`, cron
53
+ // events). That URL says nothing about the site, so use the site the outer middleware
54
+ // already set for this request; only a real request URL is resolved by hostname.
55
+ const site =
56
+ opts.url.hostname.endsWith(".emdash.internal") && currentSite()
57
+ ? currentSite()!
58
+ : resolveSite(opts.url.hostname, config.sites);
59
+ let db = perSite.get(site);
60
+ if (!db) {
61
+ db = new Kysely<unknown>({ dialect: siteDialect(getInner(config), () => site, config.sites.primary) });
62
+ perSite.set(site, db);
63
+ scopeState().siteOfDb.set(db, site);
64
+ }
65
+ scopeState().primary = config.sites.primary;
66
+ return { db, commit: () => {} };
67
+ },
68
+ };
69
+ }