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/README.md CHANGED
@@ -1,3 +1,682 @@
1
- # Temporary Holding Version
1
+ # emdash-multisite
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Run many EmDash sites from one codebase, one Cloudflare Worker, and one D1 database —
4
+ no Workers for Platforms, no fork of EmDash core. Each site gets its own prefixed tables
5
+ (`ec_posts$acme`); EmDash's in-memory caches are made per-site by a build-time patch; creating a
6
+ Better Auth **organization** provisions a site and its **slug** becomes the subdomain.
7
+
8
+ [![npm](https://img.shields.io/npm/v/emdash-multisite.svg)](https://www.npmjs.com/package/emdash-multisite)
9
+ [![license](https://img.shields.io/npm/l/emdash-multisite.svg)](./LICENSE)
10
+
11
+ MIT licensed. One command to wire it into an existing EmDash + Cloudflare app:
12
+
13
+ ```bash
14
+ pnpm add emdash-multisite
15
+ npx emdash-multisite setup # scaffolds the worker + config snippets
16
+ ```
17
+
18
+ Then merge the two printed snippets and deploy. The step-by-step is in
19
+ [SETUP.md](./SETUP.md); the reference and the land mines are below.
20
+
21
+ ---
22
+
23
+ ## Table of contents
24
+ - [How it works (30 seconds)](#how-it-works-30-seconds)
25
+ - [Requirements](#requirements)
26
+ - [Installing](#installing)
27
+ - [Set up a site (the 3 wires)](#set-up-a-site-the-3-wires)
28
+ - [Config reference](#config-reference)
29
+ - [The control-plane site vs tenant sites](#the-control-plane-site-vs-tenant-sites)
30
+ - [Routing / subdomains](#routing--subdomains)
31
+ - [Provisioning a site](#provisioning-a-site)
32
+ - [The customer console](#the-customer-console)
33
+ - [Themes](#themes)
34
+ - [Creating sites, limits and recovery](#creating-sites-limits-and-recovery)
35
+ - [Local development](#local-development)
36
+ - [Deploy to Cloudflare](#deploy-to-cloudflare)
37
+ - [Tests / self-checks](#tests--self-checks)
38
+ - [Upgrading EmDash (important)](#upgrading-emdash-important)
39
+ - [Operating a fleet](#operating-a-fleet)
40
+ - [Limits & gotchas](#limits--gotchas)
41
+ - [FAQ](#faq)
42
+ - [Package exports](#package-exports)
43
+
44
+ ---
45
+
46
+ ## How it works (30 seconds)
47
+
48
+ Three mechanisms, three wires in `astro.config.mjs`:
49
+
50
+ 1. **Table prefix** — a wrapped Kysely dialect rewrites SQL so site `acme` reads/writes
51
+ `ec_posts$acme`, `users$acme`, … The **primary** site keeps unprefixed names, so an existing
52
+ single-site install is unchanged. `users` is per-site, so roles are per-site with no RBAC change.
53
+ 2. **Per-site caches** — a Vite plugin rewrites EmDash's process-wide caches (settings, collections,
54
+ taxonomies, runtime, secrets, …) so each site gets its own copy in the Worker isolate. Without
55
+ this, one site's cached settings leak into another. The build **fails loudly** if EmDash core
56
+ changes a cache it doesn't recognize.
57
+ 3. **Outer middleware** — resolves the site from the request hostname before EmDash reads anything,
58
+ and (on the control-plane site) turns "create an organization" into "create a site".
59
+
60
+ It is **not an EmDash plugin**: a plugin can't replace the database or patch the build. It plugs
61
+ into slots EmDash already exposes (`database:`, `vite.plugins`, `middleware.outer`).
62
+
63
+ ## Requirements
64
+
65
+ - **EmDash ≥ 0.30** on **Cloudflare Workers** (D1 + R2). SQLite/Node works for local dev only.
66
+ - **Node ≥ 22.18** for tooling (the self-checks use native TS execution). On Cloudflare the Worker
67
+ runs the compiled bundle; Node version there is irrelevant.
68
+ - Peer deps (provided by the consuming site): `emdash`, `@emdash-cms/cloudflare`, `kysely`.
69
+ - For the org→site flow: `emdash-better-auth` with the **organization** feature enabled.
70
+ - `pnpm` (the EmDash templates use it).
71
+
72
+ ## Installing
73
+
74
+ ```bash
75
+ pnpm add emdash-multisite # or: npm i / yarn add
76
+ ```
77
+
78
+ Then scaffold the wiring into your app:
79
+
80
+ ```bash
81
+ npx emdash-multisite setup # writes src/worker.ts + two merge-snippets, prints next steps
82
+ ```
83
+
84
+ Prefer to wire it by hand? See [SETUP.md](./SETUP.md).
85
+
86
+ Other ways to depend on it (monorepo / pinned source):
87
+
88
+ - **pnpm workspace** (your sites + this package in one repo): add both to `pnpm-workspace.yaml`,
89
+ then `"emdash-multisite": "workspace:*"`. Best for a monorepo fleet; local edits are instant,
90
+ no publish needed for your own use.
91
+ - **Git dependency** (pin to a tag): `"emdash-multisite": "github:theweekendprojects/emdash-multisite#<tag>"`.
92
+ - **Local `file:`** (dev, folders side by side): `"emdash-multisite": "file:../emdash-multisite"`.
93
+
94
+ This package ships **TypeScript source** (like `emdash-better-auth`); the consuming site's build
95
+ compiles it. There is no `dist` — that is intentional (the cache patch and the wrapper need to run
96
+ through your app's Vite/Astro build).
97
+
98
+ ## Set up a site (the 3 wires)
99
+
100
+ ```js
101
+ // astro.config.mjs
102
+ import cloudflare from "@astrojs/cloudflare";
103
+ import { d1, r2 } from "@emdash-cms/cloudflare";
104
+ import { withSites } from "emdash-multisite"; // wire 1
105
+ import { multisiteCaches } from "emdash-multisite/vite"; // wire 3
106
+ import emdash from "emdash/astro";
107
+ import { defineConfig } from "astro/config";
108
+
109
+ export default defineConfig({
110
+ output: "server",
111
+ adapter: cloudflare(),
112
+ vite: {
113
+ plugins: [multisiteCaches()], // wire 3: per-site cache patch (build-time)
114
+ },
115
+ integrations: [
116
+ emdash({
117
+ database: withSites( // wire 1: per-site tables
118
+ d1({ binding: "DB" }),
119
+ { primary: "main", wildcard: "sites.example.com" },
120
+ ),
121
+ storage: { ...r2({ binding: "MEDIA" }), entrypoint: "emdash-multisite/storage" }, // one bucket, key prefix per site
122
+ middleware: { outer: "emdash-multisite/middleware" }, // wire 2: hostname -> site + org glue
123
+ // ...authProviders / plugins (e.g. emdash-better-auth) ...
124
+ }),
125
+ ],
126
+ });
127
+ ```
128
+
129
+ That's the whole integration: install + three wires. No plugin entry, no EmDash core change.
130
+
131
+ > A single `emdashMultisite()` integration that collapses the three wires into one array entry is
132
+ > on the roadmap (see `emdash-plan`), but the three wires above are the stable surface today.
133
+
134
+ ## Config reference
135
+
136
+ `withSites(innerDatabaseDescriptor, sites)` — `sites` is a `SitesConfig`:
137
+
138
+ | Field | Type | Meaning |
139
+ |---|---|---|
140
+ | `primary` | `string` (required) | The site that keeps **unprefixed** tables. Use for the control plane / your existing single site. |
141
+ | `wildcard` | `string` | Base domain. `acme.<wildcard>` → site `acme`. |
142
+ | `hosts` | `Record<hostname, siteId>` | Exact hostname → site id, for custom domains and for hosts with no wildcard (e.g. `*.workers.dev`). |
143
+ | `accountTab` | `boolean` | Show "My sites" as the **Sites** tab of Better Auth UI's account page (`/account/sites`); `/sites` redirects there and sign-in lands there. Needs the `auth-ui` plugin above. |
144
+ | `deleteGraceDays` | `number` | Days a deleted site stays restorable before it is purged. Default 14. |
145
+ | `postLoginPath` | `string` | Where users land after signing in or up on the control plane. Default `/sites`. The middleware adds `?redirect=` to `/login`, `/signup`, `/auth/sign-in`, `/auth/sign-up` when none is given, and sends already-signed-in visitors of those pages there. No change to `emdash-better-auth` needed. |
146
+ | `limits` | `{ sitesPerUser?, totalSites? }` | Caps on site creation. Default `{ sitesPerUser: 3 }`; pass `{}` for no caps. See [Creating sites](#creating-sites-limits-and-recovery). |
147
+
148
+ | `themes` | `Record<id, { name, description?, seed? }>` | Themes a new site can pick. Each `seed` is an EmDash seed JSON applied when the site is created. Omit for a single theme (the host's bundled seed). |
149
+ | `defaultTheme` | `string` | Preselected in the wizard and used when none is requested. Defaults to the first theme. |
150
+
151
+ Supported inner adapters: `d1({...})` (Cloudflare) and `sqlite({...})` (local/Node dev). Site ids
152
+ are DNS labels: `^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$`. Unknown hostnames resolve to `primary`.
153
+
154
+ ## The control-plane site vs tenant sites
155
+
156
+ - **Control-plane site = the `primary` site.** This is where people sign up and create
157
+ organizations. It runs the org→site glue (slug guard, provisioning, status endpoint). Lock its
158
+ EmDash admin to your staff.
159
+ - **Tenant sites** are every other site. Each has its own `users`, content, settings, and its owner
160
+ is a real EmDash **admin (role 50)** of that site only — not of any other site.
161
+ - One Better Auth identity on the control plane does **not** grant CMS access elsewhere; each tenant
162
+ site has its own `users` row for that person (created at provision time).
163
+
164
+ ## Routing / subdomains
165
+
166
+ The middleware maps the request `Host` to a site: exact `hosts` first, then `wildcard`, else
167
+ `primary`.
168
+
169
+ - **Wildcard subdomains** (`acme.sites.example.com`): set up once — a `*.sites.example.com` DNS
170
+ record + a Worker route. After that every new tenant subdomain needs **no** DNS/route/redeploy.
171
+ - **`*.workers.dev` has no wildcard.** For preview deploys, map each hostname explicitly in `hosts`
172
+ (and `wrangler versions upload --preview-alias <name>`).
173
+ - **Custom domains** (`blog.acme.com`): add to `hosts`, then Cloudflare for SaaS Custom Hostnames
174
+ (not built here; a later step).
175
+
176
+ ## Provisioning a site
177
+
178
+ Two ways:
179
+
180
+ - **Automatic (org→site):** on the control-plane site, a successful
181
+ `POST /api/auth/organization/create` provisions a site named after the org **slug** in the
182
+ background. Poll `GET /api/multisite/sites/<slug>` → `{ status: "provisioning" | "ready" |
183
+ "failed" }`. The org owner becomes admin of the new site and can sign in there with the same
184
+ credentials.
185
+ - **Manual (script/CLI):** `provisionSite(db, siteId)` runs EmDash's migrations as that site. `db`
186
+ must be a Kysely built on the wrapped dialect (see `scripts/provision.ts` in the smoke-test site).
187
+
188
+ Provisioning is **idempotent** — re-running finishes a half-built site. It creates ~227 schema
189
+ objects per site (one full EmDash schema).
190
+
191
+ ## First admin & the setup wizard
192
+
193
+ EmDash ships a first-run **setup wizard** (`/_emdash/admin/setup`) that creates the first admin via
194
+ passkey. In a multi-site fleet, sites are born **without** the wizard, so you must make sure it
195
+ never runs — otherwise it appears and then refuses with "admin user already exists" (because a user
196
+ already exists but `emdash:setup_complete` was never set).
197
+
198
+ - **Tenant sites: automatic.** Provisioning creates the owner as admin and sets
199
+ `emdash:setup_complete = true`. The wizard never shows. Nothing to do.
200
+ - **Control-plane (`primary`) site: one-time bootstrap.** Its first human signs up through Better
201
+ Auth as a *subscriber* (role 10), not through the wizard. Promote your staff account to admin and
202
+ mark setup complete, once:
203
+
204
+ ```ts
205
+ // scripts/bootstrap.ts — run once after you sign up on the control-plane site
206
+ import { Kysely } from "kysely";
207
+ import { createDialect } from "emdash-multisite/db/d1"; // or /db/sqlite in local dev
208
+ import { bootstrapControlPlane } from "emdash-multisite/bootstrap";
209
+
210
+ const db = new Kysely<never>({
211
+ dialect: createDialect({ inner: { binding: "DB" }, sites: { primary: "main", wildcard: "sites.example.com" } }),
212
+ });
213
+ await bootstrapControlPlane(db, "you@example.com"); // promotes to role 50 + sets setup_complete
214
+ await db.destroy();
215
+ ```
216
+
217
+ (On a deployed Worker you can equivalently run two `wrangler d1 execute` statements: `UPDATE users
218
+ SET role=50, email_verified=1 WHERE email=…` and set `emdash:setup_complete` to `true`.)
219
+
220
+ Order on the control plane: deploy → sign up your staff account → run bootstrap → sign in at
221
+ `/_emdash/admin`.
222
+
223
+ ## Local development
224
+
225
+ `workerd` needs macOS 13.5+. If you can run it, use the Cloudflare adapter and `wrangler dev`.
226
+ If you can't (older macOS), develop against the **Node adapter + SQLite**:
227
+
228
+ ```js
229
+ database: withSites(sqlite({ url: "file:./data.db" }), { primary: "main", wildcard: "localhost" }),
230
+ ```
231
+
232
+ Chrome/Firefox resolve `*.localhost` to your machine, so `acme.localhost:4321` hits site `acme`.
233
+ Node-adapter dev needs a few shims that are **not** part of this package and are **not** needed on
234
+ Cloudflare — a `cloudflare:workers` alias, `ssr.noExternal` for the HeroUI/Better-Auth-UI packages,
235
+ and an `optimizeDeps` entry. See the smoke-test site for a working example. Run tooling with Node
236
+ ≥ 22.18.
237
+
238
+ ## Deploy to Cloudflare
239
+
240
+ 1. `wrangler d1 create <db>`, put its `database_id` in `wrangler.jsonc` (`binding: "DB"`), add an
241
+ `r2_buckets` entry (`binding: "MEDIA"`).
242
+ 2. Set secrets your auth needs (e.g. `wrangler secret put BETTER_AUTH_SECRET`).
243
+ 3. `astro build && wrangler deploy`.
244
+ 4. The **first request** to a fresh site runs its migrations (cold ~a few seconds). The control
245
+ plane (`primary`) is just the unprefixed site.
246
+
247
+ Nothing in this package creates Cloudflare resources for you — provisioning only writes tables into
248
+ the existing D1. Adding a brand-new Worker/DB per fleet is your deploy, not per-tenant.
249
+
250
+ ## Tests / self-checks
251
+
252
+ Run with Node ≥ 22.18 (e.g. `~/.nvm/versions/node/v22.23.2/bin/node --no-warnings <script>`):
253
+
254
+ - `scripts/selfcheck.ts` — runs EmDash's **real migrations** and repositories through the wrapper
255
+ for several sites; asserts each site's schema equals the primary's modulo the `$site` suffix, FKs
256
+ stay in-site, triggers fire on their own tables, and content is isolated. Needs `emdash` installed
257
+ (run it from a consuming site, or point it at one).
258
+ - `scripts/caches.selfcheck.ts <path-to-node_modules>` — the **cache tripwire**: scans `emdash` +
259
+ all `@emdash-cms/*` and fails if any EmDash cache key is unclassified or a patched declaration
260
+ changed shape. **Run this on every EmDash upgrade.**
261
+ - `scripts/orgs.selfcheck.ts` — slug validation, org-response parsing, creation limits, reconcile
262
+ rules, theme resolution and theme routing paths (no deps; pure rules in `src/rules.ts`).
263
+ - `scripts/console.selfcheck.ts` — parses the console pages' embedded client script (a syntax slip
264
+ only shows in the browser otherwise) and checks site URL rules.
265
+ - `scripts/fleet.selfcheck.ts` — fleet view rules.
266
+
267
+ ## Upgrading EmDash (important)
268
+
269
+ The cache patch rewrites **EmDash's own code** at build time, so an EmDash upgrade can move it.
270
+ Safe routine:
271
+
272
+ 1. Bump `emdash` (+ `@emdash-cms/*`) in a **staging** build.
273
+ 2. Run `scripts/caches.selfcheck.ts` against the new `node_modules`, and `scripts/selfcheck.ts`.
274
+ 3. If the build or tripwire fails, it tells you which cache changed — update `HOLDER_KEYS` /
275
+ `SLOT_KEYS` / `SITE_INDEPENDENT_KEYS` in `src/vite.ts`, re-run.
276
+ 4. Only then deploy. **Never auto-upgrade EmDash in production without a build.**
277
+
278
+ The tripwire failing is the feature: it refuses to ship a cache that would leak across sites.
279
+
280
+ ## The customer console
281
+ Two pages on the control-plane host, plain HTML served by the middleware (no host build step):
282
+
283
+ | Page | What it does |
284
+ |---|---|
285
+ | `/sites` | The signed-in user's sites (any org they belong to) with status, Open site, Open admin, Retry. Shows the cap ("9 of 3 sites") and an unverified-email notice. Polls while a site is being set up. |
286
+ | `/sites/new` | Wizard: name, address (auto-filled from the name, live availability check), Create, then a progress screen until the site is ready. |
287
+
288
+ Anonymous visitors are sent to `/login?redirect=<page>`. Both pages use `GET /api/multisite/me`
289
+ (sites, role, status, URL, cap, staff flag) and `GET /api/multisite/slug-check?slug=`, and create
290
+ through Better Auth's `organization/create`, so the checks below apply to them too. A site's
291
+ public URL comes from `hosts` (exact entry) or `wildcard`; with neither configured the card says
292
+ no public address is set up.
293
+
294
+ ## Themes
295
+ A theme is a seed (collections, settings, starter content) plus the page layouts that render it.
296
+ The seeds are configured here; the layouts ship in the host codebase (one Worker, one build).
297
+
298
+ ```js
299
+ const seed = (id) => JSON.parse(readFileSync(new URL(`./themes/${id}.seed.json`, import.meta.url), "utf8"));
300
+ withSites(d1({ binding: "DB" }), {
301
+ primary: "main",
302
+ wildcard: "example.com",
303
+ themes: {
304
+ marketing: { name: "Marketing", description: "Landing page, pricing, contact.", seed: seed("marketing") },
305
+ blog: { name: "Blog", description: "Posts, categories, tags.", seed: seed("blog") },
306
+ },
307
+ defaultTheme: "marketing",
308
+ });
309
+ ```
310
+
311
+ The theme id travels in the organization `metadata` (`{ "theme": "blog" }`) on
312
+ `organization/create`; an unknown id is rejected with `400 INVALID_THEME`, a missing one gets the
313
+ default. The wizard shows a picker when there is more than one theme. Provisioning applies that
314
+ theme's seed and records it in the site's `multisite:theme` option, which the host reads to choose
315
+ layouts. Retries re-read the theme from the org, so a recovered site keeps its theme.
316
+
317
+ ### How a site gets its theme's pages
318
+ A theme's pages are a route subtree in the host: `src/pages/theme-routes/<id>/` (its layouts,
319
+ components and styles live in `src/themes/<id>/`). The middleware rewrites every public `GET`/`HEAD`
320
+ request to the subtree of the site's theme, so `/` on a blog site renders
321
+ `theme-routes/blog/index.astro` while the same `/` on a portfolio site renders
322
+ `theme-routes/portfolio/index.astro`. Consequences:
323
+
324
+ - Themes never collide on a URL, and a theme's CSS loads only on its own pages (the stock templates
325
+ all define the same `:root` design tokens, so bundling them into one page would clash).
326
+ - A URL the theme does not have (`/pricing` on a blog) falls to the theme's own 404 page.
327
+ - Platform paths are never themed: `/_emdash`, `/_astro`, `/_image`, `/api`, `/auth`, `/login`,
328
+ `/signup`, `/account`, `/organization`, `/admin`, `/audit-log`, `/sites`, `/.well-known`,
329
+ `/sitemap*.xml`, `/robots.txt`, `/llms.txt`.
330
+ - Requests to `/theme-routes/...` directly get a 404, so one theme's pages cannot be shown on
331
+ another site's host.
332
+ - The theme id is read from the site's `multisite:theme` option and memoised for a minute per
333
+ isolate (it runs on every public request and only changes at provisioning).
334
+ - Astro's `Astro.url` reflects the rewritten path inside a theme page. Use `Astro.originPathname`
335
+ for the public path (canonical links, "current page" in nav). Stock templates need three small
336
+ patches for this; see the host's `scripts/import-themes.mjs`.
337
+
338
+ ### Switching a live site's theme
339
+ Owners (and staff) switch from `/sites` -> **Appearance** (`/sites/<slug>/appearance`), or via
340
+ `POST /api/multisite/sites/<slug>/theme` with `{ "theme": "blog", "includeContent": true }` and the
341
+ `X-EmDash-Request: 1` header. Switching is additive and never deletes: the target theme's seed is
342
+ applied with skip-on-conflict (missing collections, menus and settings are created; existing ones,
343
+ content and the site title are untouched), then the site's `multisite:theme` option flips. Anything
344
+ the new theme does not show stays in the database and returns if the owner switches back. The page
345
+ shows per theme what it adds and hides. Re-applying the current theme is allowed and repairs a site
346
+ that lacks the theme's collections. The change shows within about a minute (the theme is memoised
347
+ per isolate). Installing a brand-new theme still needs a build and deploy: Astro compiles pages at
348
+ build time.
349
+
350
+ Themes should not share a collection slug with a different shape. The stock marketing theme uses
351
+ `landing_pages` (its `content` is a blocks field) while blog, portfolio and starter share
352
+ `pages` (portable text).
353
+
354
+ With no `themes` configured nothing is rewritten and the host's own pages serve every site.
355
+
356
+ Seeds are applied with `skipMediaDownload` (downloading images would also eat the provisioning time
357
+ budget), so seed images are skipped.
358
+
359
+ ## Logs
360
+ Notable events are written as one JSON line each (`src/log.ts`): `site.ready` (site, theme, whether it was cloned, total
361
+ ms, per-step timings), `site.failed`, `template.built`, `backup.saved`, `backup.restored`, `site.purged`,
362
+ `signup.blocked_domain`, `signup.rate_limited`, `create.rate_limited`, `create.race_lost`, `password.synced`. Turn on
363
+ Workers Logs with `"observability": { "enabled": true }` in `wrangler.jsonc` to search and count them in the dashboard
364
+ (`wrangler tail` shows them live). No secrets, passwords or full email addresses are logged.
365
+
366
+ ## Free tier (recommended settings)
367
+ Anyone can register on the control plane and create a site; the limits decide what free means. For one free site per person:
368
+ ```ts
369
+ withSites(d1({ binding: "DB" }), {
370
+ primary: "main", wildcard: "example.com", appUrl: "https://app.example.com", authBar: true,
371
+ limits: { sitesPerUser: 1, totalSites: 5000, mediaBytesPerSite: 200 * 1024 * 1024 },
372
+ })
373
+ ```
374
+ The account page shows "N of M sites used". Needed alongside: a working email sender with `requireEmailVerification` on (otherwise
375
+ nobody can create a site), the rate limit bindings, and Turnstile on sign-up (see Abuse controls). Paid plans raise these numbers
376
+ per user (not built: plans, OP-11). Not built either: reclaiming idle free sites.
377
+
378
+ ## Sign in / Sign up links in the theme header
379
+ `authLinks: true` sets `Astro.locals.multisite = { signup, account }` for themed pages. The host's `AuthLinks.astro` component
380
+ (`multisite-smoke/src/multisite/`, patched into each theme's header by `import-themes.mjs`) prints plain links with the theme's
381
+ own font and colour, decided by `authLinksFor`: a visitor sees **Sign in** and **Sign up** while the site's public sign-up is on
382
+ (the control plane is always on), nothing while it is off; a signed-in person sees **My account** and, from the author role
383
+ (30) up, **Admin** (themes that draw their own Admin link pass `admin={false}`). It is rendered on the server: **no script,
384
+ no extra request, +0.2 KB of HTML** for a visitor. Pages differ only between "has a session" and "has none", which the
385
+ themes already did for their Admin link. Public theme demos get none. Measured against no links and against an earlier
386
+ script-driven bar in `emdash-plan/product/multisite-performance.md`. On a narrow screen the links wrap onto their own row
387
+ in the header (+32 to +48 px); folding them into a theme's mobile menu is a later polish.
388
+
389
+ ## Unknown sites
390
+ Before a site's runtime starts, the middleware checks the site has tables (`siteExists`). A host for a site that was never
391
+ created, or was purged, answers 404 and creates nothing. Without this, wildcard DNS would let a request for any subdomain
392
+ create ~90 tables. The answer is remembered per isolate (15 s for a known site, 10 s for an unknown one). Check:
393
+ `multisite-smoke/scripts/unknown-site-check.mjs`.
394
+
395
+ ## Public sign-up per site
396
+ Whether visitors can register is a setting of each site: "Allow new sign-ups" (`emdash-better-auth` 0.11.0, in the site's
397
+ admin under Settings, Better Auth). A new customer site is created with it **off**: only people the owner adds can sign in,
398
+ and any other attempt to create an account answers `403 SIGN_UP_DISABLED`. Owners who want subscribers turn it on. The
399
+ control plane keeps it on (anyone can register and create a site). The setting is not inherited from the control plane, and
400
+ a re-run of provisioning never changes it back. The Site page in each admin shows the current value. Sites created before
401
+ the setting existed have none stored, which means open; close them with the runbook's backfill. The switch covers every
402
+ way an account is created (email, social, magic link, one-time code), because it is a single user-creation hook.
403
+ Check: `multisite-smoke/scripts/signup-switch-check.mjs`.
404
+
405
+ ## Provisioning slots (bursts of new sites)
406
+ One D1 database does one write at a time, and a site clone writes ~370 objects, so many sites created at once slowed every other
407
+ site (12 at once: page reads p95 0.6 s to 3.2 s, max 6 s). `limits.concurrentProvisions` (default **1**) caps how many sites are
408
+ provisioned at the same time across the whole platform. A slot is a control-plane row taken with one atomic statement and freed
409
+ after the run or when it expires (150 s) if the holder died. A site that cannot get a slot within 6 s shows "Waiting for a free
410
+ slot" and is retried every 8 s by the wizard poll (or by the cron). Measured with `scripts/load-check.mjs`, 12 sites at once:
411
+
412
+ | Cap | Reads during the burst (p95 / max) | Last site ready |
413
+ |---|---|---|
414
+ | none | 3.2 s / 6.0 s | 18 s |
415
+ | 3 | 3.1 s / 4.0 s | 27 s |
416
+ | **1** | **1.2 s / 1.9 s** (as quiet) | 42 s |
417
+
418
+ Raise it only after measuring. A `limits` object you write is merged over the defaults (`effectiveLimits`), so setting
419
+ `concurrentProvisions` or `mediaBytesPerSite` alone no longer drops the 3-site-per-user cap.
420
+
421
+ ## Fleet page (staff)
422
+ On the control plane the plugin's **Site** page in the EmDash admin (`/_emdash/admin/plugins/multisite/site`, also reached
423
+ from `/admin/fleet`) is the fleet: counts (sites, needing attention, suspended, deleted but not purged, users), and one row
424
+ per site with its owner, setup status, state, users, age and an **Actions** menu: suspend, resume, delete, restore, retry
425
+ setup, back up now. Purging a deleted site is a separate form that needs a confirmation toggle, because it cannot be
426
+ undone. The same rules as the HTTP routes apply (`transition`, purge only for a deleted site); slow work (purge, retry)
427
+ continues after the response. Only reachable with `plugins:manage`, which on the control plane means staff. The page is
428
+ built by `fleet-blocks.ts` (pure, self-checked) and the actions run in `fleet-actions.ts`. JSON for scripts:
429
+ `GET /api/multisite/fleet` (staff).
430
+
431
+ ## Theme switching lives in the account page
432
+ Choosing another theme for a site is a view of the **Sites** tab (Better Auth UI): the site card's **Appearance** button
433
+ (or `/account/sites?appearance=<slug>`) shows every theme with what it adds and hides, a Preview link when a demo exists,
434
+ and a **Switch theme** button. The old standalone `/sites/<slug>/appearance` address redirects there (hosts without the account
435
+ tab keep the old page). The site's own admin Site page shows the current theme and a **Change theme** link to the same view;
436
+ it no longer carries a second picker.
437
+
438
+ ## Public theme demos
439
+ A template site (`tpl-<theme>`) is a seeded copy of its theme, so it doubles as a public demo with no extra content:
440
+ give it an address like any site (a `hosts` entry, or `tpl-<theme>.<wildcard>` automatically) and visitors can browse it.
441
+ Demos are read-only: only GET/HEAD, the admin, auth, plugin and write APIs answer 404/405 (`demoGuard`); pages, search and
442
+ media files work. Each page gets a strip at the bottom, "This is a demo of the X theme. Use this theme", which opens the
443
+ create-site wizard with the theme preselected (`/account/sites?new=1&theme=blog`). The theme pickers show a **Preview**
444
+ link for every theme whose template is complete and has an address (`demoUrl` in `/api/multisite/themes`, memoised 60 s).
445
+ The strip is fixed to the bottom and the page gets bottom padding so it never covers the footer. It needs `HTMLRewriter` (Workers) and `appUrl`. A template needs `emdash:setup_complete` for its search API;
446
+ `buildTemplate` sets it. Check: `multisite-smoke/scripts/demo-check.mjs`.
447
+
448
+ ## Using the package
449
+
450
+ The package is on npm: `pnpm add emdash-multisite`. A host pins a version range like any
451
+ dependency (`"emdash-multisite": "^0.7.0"`); npm fetches the sources, which the host's Vite
452
+ compiles (there is no `dist`). Releasing is `npm version <patch|minor|major> && npm publish`
453
+ after CI is green. `examples/host-deploy.yml` is a ready-to-copy Cloudflare deploy workflow
454
+ (install, build, `wrangler deploy`). During development you can also use a pnpm `workspace:*`
455
+ link or `"emdash-multisite": "file:../emdash-multisite"` for instant local edits.
456
+
457
+ ## Backups and restore (per site)
458
+ D1 Time Travel restores the whole database, so one damaged site needs its own copy. A backup is one JSON file in the
459
+ media bucket at `_backups/<site>/<time>.json`: the site's `CREATE` statements plus its rows as multi-row `INSERT`s (values
460
+ written by SQLite's `quote()`, so blobs and NULLs survive). The cron backs up at most one ready site per tick that has
461
+ no backup from the last day, and keeps the newest 7 per site. Staff routes (CSRF header, run inside the request):
462
+ - `POST /api/multisite/sites/<slug>/backup` take one now.
463
+ - `GET /api/multisite/sites/<slug>/backups` list keys, oldest first.
464
+ - `POST /api/multisite/sites/<slug>/restore {"key": "...", "replace": true}` rebuild the site from a backup. Without
465
+ `replace` the site must have no tables (409). With it the current state is backed up first (`-pre-restore`), then the
466
+ tables are dropped and rebuilt in one atomic D1 batch, so a bad restore can itself be undone.
467
+ Not included: the organization, state, theme and status records (they stay as they are), and the control plane (primary
468
+ site; use Time Travel). Warm isolates may serve cached site settings for a short while after a restore. Purging a site
469
+ deletes its backups too, so a deleted customer's data does not outlive the grace period. Needs the D1 and R2 bindings.
470
+
471
+ ## Abuse controls
472
+ Sign-up is open, and every account can create sites (~90 D1 tables each), so there are four brakes:
473
+ - **Throwaway mail**: sign-up with a disposable-mail domain is refused (`EMAIL_DOMAIN_BLOCKED`, 400). A built-in
474
+ list plus `blockedEmailDomains: ["bad.example"]` in the config. Subdomains match (`x.mailinator.com`).
475
+ - **Reserved names**: infrastructure, mail, "looks official" (`secure`, `webmail`, `billing`) and big-brand names
476
+ cannot become sites (`RESERVED_SITE_IDS` in `rules.ts`; the `tpl-` prefix is reserved for templates).
477
+ - **Rate limits** (optional): declare Cloudflare Rate Limiting bindings `SIGNUP_LIMITER` (per client IP) and
478
+ `CREATE_LIMITER` (per user) in `wrangler.jsonc`; exceeding them returns 429. Without the bindings nothing is limited.
479
+ Counters are per Cloudflare location and periods are 10 or 60 s, so they stop bursts, not slow trickles.
480
+ - **Site limits hold under parallel requests**: the limit check runs before Better Auth creates the organization, so
481
+ parallel requests all passed it. It now runs again after creation; a site ranked past `sitesPerUser` or `totalSites`
482
+ (by creation time) is deleted unprovisioned (`lostLimitRace`).
483
+ Turnstile (needs a site key from the production domain) and alerting on `totalSites` are not built yet.
484
+
485
+ ## Templates (fast site creation)
486
+ Migrating and seeding a new site takes ~70 s. With a **template** it takes ~4 s: staff build one template
487
+ site per theme once (`POST /api/multisite/templates {"theme":"blog"}`, ~30 s, runs inside the request), stored as
488
+ site `tpl-<theme>` (the `tpl-` prefix is reserved and hidden from the fleet). A new site then copies it in
489
+ one D1 batch: the template's `CREATE` statements with the suffix swapped, then `INSERT ... SELECT` per table, then
490
+ triggers; the owner, title and options are patched afterwards. The batch is atomic, so a failure leaves nothing.
491
+ Without a complete template (or on the Node/SQLite host) creation falls back to migrate-and-seed. After an
492
+ EmDash upgrade the template is stale until rebuilt (`{"theme":"blog","rebuild":true}` drops it and builds again); sites created from it still run any newer
493
+ migrations like any older site. D1 only. Script: `multisite-smoke/scripts/build-templates.mjs`.
494
+
495
+ ## Media (uploads)
496
+ `emdash-multisite/storage` wraps the R2 storage: every key gets a `<site>/` prefix on write and read,
497
+ and listings only return the active site's keys. Keys in a site's `media` table stay unprefixed. The
498
+ wrapper reads the site on every call because core caches one storage instance per Worker. Purging a
499
+ site deletes its prefix. `limits.mediaBytesPerSite` caps a site (sum of `media.size`, checked before
500
+ each upload, so one file can overshoot). With a CDN `publicUrl` the URL carries the prefix.
501
+ Check it live: `multisite-smoke/scripts/media-check.mjs`.
502
+
503
+ ## Opening a tenant's admin (handoff)
504
+ Cookies are per host, so a user signed in on the control plane is signed out on `acme.example.com`.
505
+ "Open admin" on `/sites` therefore goes to `/sites/<slug>/open-admin` on the control plane, which
506
+ checks the user may manage the site, signs a link (HMAC-SHA256, 60 s, bound to user and site, one
507
+ nonce) and redirects to `https://<site>/_multisite/handoff?t=...`. The tenant verifies the link,
508
+ accepts its nonce once, checks the user exists on that site, starts the user's session there and
509
+ redirects to `/_emdash/admin`. Replayed, tampered, expired or wrong-site links get `403`. The
510
+ signing key is created on first use and stored on the control plane (`multisite:handoff-secret`),
511
+ so no environment secret is shared. Anyone who can read the database can read that key and sign in as any site's
512
+ owner, so in production set a Worker secret instead: `wrangler secret put MULTISITE_SECRET` (32+ characters; it wins over
513
+ the stored key). Set it before the first handoff or users with a link in flight get a "Link expired" page. This signs the **owner** in; it does not sync passwords (a
514
+ password changed on the control plane reaches the owner's sites, see "Passwords").
515
+
516
+ ## Passwords
517
+ The owner's credential account is copied into each site at creation. A password change or reset on the control plane
518
+ (`change-password`, `reset-password`, `set-password` returning 200) copies the changed account to every non-suspended site
519
+ that user owns, right after the response; the cron repeats it for the last 10 minutes as a safety net. Editors who signed up
520
+ on a site have their own account there and are not touched.
521
+
522
+ ## Scheduled work (cron) for every site
523
+ EmDash's scheduled handler publishes due scheduled content and runs plugin cron for the one ambient
524
+ site, which in a cron trigger (no request, no hostname) is the primary. Wrap it so each ready site
525
+ gets a turn:
526
+
527
+ ```ts
528
+ // src/worker.ts
529
+ import handler, { createScheduledHandler, PluginBridge } from "@emdash-cms/cloudflare/worker";
530
+ import { perSiteScheduled } from "emdash-multisite/scheduled";
531
+ export { PluginBridge };
532
+ export default { ...handler, scheduled: perSiteScheduled(createScheduledHandler()) } satisfies ExportedHandler;
533
+ ```
534
+
535
+ and add a trigger to `wrangler.jsonc` (`"triggers": { "crons": ["*/5 * * * *"] }`; apply with
536
+ `wrangler triggers deploy`, since `versions deploy` does not change triggers). A tick sweeps the
537
+ primary and then every site whose status is `ready`, one after another, and a failing site does not
538
+ stop the rest. Verified live: scheduled posts on two tenant sites were published by one tick.
539
+ Ceiling: a tick takes about (sites x per-site sweep); upgrade path is a Queue with one message per
540
+ site, fed by the same loop.
541
+
542
+ ## "Sites" as a tab in the account page (Better Auth UI)
543
+ With `emdash-better-auth` >= 0.10.0 the console list can live inside Better Auth UI's account page, next to
544
+ Account and Security: `/account/sites`.
545
+
546
+ ```ts
547
+ // astro.config.mjs
548
+ import { betterAuthUi } from "emdash-better-auth/vite";
549
+ export default defineConfig({
550
+ vite: { plugins: [betterAuthUi({ plugins: "emdash-multisite/auth-ui" })] },
551
+ integrations: [emdash({ database: withSites(d1({ binding: "DB" }), { primary: "main", accountTab: true, /* ... */ }) })],
552
+ });
553
+ ```
554
+
555
+ The host also needs `@better-auth-ui/core` (same version as `emdash-better-auth` uses) and `react` as dependencies.
556
+ With `accountTab: true`, `/sites` redirects to `/account/sites` and users land there after signing in. The tab lists
557
+ the user's sites with Open site, Open admin and Appearance, and links to `/sites/new` for the create wizard and to
558
+ `/sites` for Retry and Delete (the old `/sites` page still serves those until they are ported into the tab).
559
+ The tab uses plain React elements and this package's JSON endpoints only, so it has no Better Auth UI hooks.
560
+
561
+ ## Creating sites, limits and recovery
562
+ Creating a site = creating a Better Auth organization (`POST /api/auth/organization/create`); the
563
+ slug is the site address. The middleware checks the request **before** Better Auth sees it:
564
+
565
+ | Check | Result |
566
+ |---|---|
567
+ | slug is not a valid, unreserved DNS label | `400 INVALID_SITE_ADDRESS` |
568
+ | signed-in user has an unverified email | `403 EMAIL_NOT_VERIFIED` |
569
+ | user already owns `limits.sitesPerUser` sites (failed ones count) | `403 SITE_LIMIT_REACHED` |
570
+ | platform already has `limits.totalSites` sites | `503 PLATFORM_FULL` |
571
+
572
+ Anonymous callers fall through, so Better Auth answers 401 itself. Because every route to
573
+ "create an organization" goes through this one path, the Better Auth organization UI is covered too.
574
+
575
+ Provisioning runs in the background after the 200 and can die (Worker evicted). Recovery:
576
+ - `POST /api/multisite/sites/<slug>/retry` — the site's owner or staff. `202` started, `409` if the
577
+ site does not need it.
578
+ - `POST /api/multisite/reconcile` — staff only. Re-provisions the **next** stuck site (failed, or
579
+ `provisioning` for over 5 minutes) and reports the rest as `pending`. One site per call, because
580
+ the background budget after a response (~30 s) is shorter than two provisioning runs. Call again
581
+ for the next.
582
+
583
+ State-changing routes (`retry`, `reconcile`, `theme`) require the `X-EmDash-Request: 1` header (or a
584
+ same-origin `Origin`), like core's `/_emdash` APIs; without it they answer `403 CSRF`.
585
+
586
+ A killed run leaves core's migration lock held; retries release a lock older than 5 minutes
587
+ automatically. A site that has tables but no status row (created before status tracking) is
588
+ adopted as `ready` instead of being provisioned a second time on top of live content.
589
+
590
+ ## Suspending and deleting sites
591
+ A site's operational state lives on the control plane (`multisite:state:<slug>` in `options`), so a
592
+ tenant cannot change it. `POST /api/multisite/sites/<slug>/state` with `{ "action": ... }` and the
593
+ `X-EmDash-Request: 1` header:
594
+
595
+ | action | who | effect |
596
+ |---|---|---|
597
+ | `suspend` | staff | every request to the site answers `503 This site is unavailable`; the owner sees it listed as suspended |
598
+ | `resume` | staff | back to active |
599
+ | `delete` | owner or staff | soft delete: the site answers `404` (as if it never existed) and disappears from the owner's list; frees one of the owner's site slots; the slug stays taken |
600
+ | `restore` | staff | undo a delete |
601
+
602
+ The fleet page (below) has the matching actions. Better Auth's `organization/delete` is refused on the
603
+ control plane (`403 USE_DELETE_SITE`) because it would orphan the site's tables. The state is read on
604
+ every tenant request and memoised 30 s per isolate (a change made in an isolate applies to it
605
+ immediately).
606
+
607
+ **Purge.** A deleted site is kept for `deleteGraceDays` (default 14) and then destroyed for good: its
608
+ tables are dropped, its organization and members are removed (which frees the slug) and its control-plane
609
+ records are cleared. The cron fan-out purges at most one due site per tick; staff can purge earlier with
610
+ `POST /api/multisite/sites/<slug>/purge` (`{ "force": true }` skips the grace period). Purging is
611
+ idempotent and resumes after a partial run. Not supported: renaming a site (the slug is the table suffix).
612
+
613
+ ## Operating a fleet
614
+
615
+ - **One shared D1 is single-threaded.** One busy site can slow all. Shard across multiple D1s (by
616
+ site) when a DB gets hot.
617
+ - **Time Travel restores the whole DB** — it rewinds every site. Use the per-site backup and restore
618
+ (see "Backups and restore") when only one site is hurt.
619
+ - Every EmDash upgrade migrates **every** site's tables (fleet migration, inside one DB).
620
+ - Billing is per account (rows read/written), so N sites in one DB cost the same as N DBs. Bots on
621
+ many sites multiply cost — gate signup.
622
+
623
+ ## Limits & gotchas
624
+
625
+ - **Object cache is not per-site.** Do **not** enable the object cache (`objectCache:`) on a
626
+ multi-site deploy, or cached content can cross sites. R2 media is per-site only through the
627
+ `emdash-multisite/storage` entrypoint; a plain `r2()` descriptor shares one namespace.
628
+ - **Sandboxed plugins bypass the wrapper** (their bridge opens its own DB). Use **native** plugins
629
+ only in a multi-site deploy.
630
+ - **Cron / scheduled publishing runs for the `primary` site only** (a cron tick has no request, so
631
+ no site). A per-tenant sweep is on the roadmap.
632
+ - **SQL rewrite is positional.** A comma-joined second table (`FROM a, b`) or an alias that reuses a
633
+ core table name is not rewritten → fails loudly ("no such table"), never leaks silently.
634
+ - **Open signup + unlimited orgs** = unbounded sites = cost risk. Add a per-user cap and
635
+ verified-email gating before public exposure.
636
+ - **A stuck `provisioning` status** has no auto-retry yet (provisioning itself is repeatable).
637
+
638
+ ## FAQ
639
+
640
+ **Why not a plugin?** A plugin can't replace the `database` descriptor and can't patch the build.
641
+ Those are exactly the two seams this needs.
642
+
643
+ **Does it change EmDash core files on disk?** No. It wraps the DB descriptor at runtime and
644
+ transforms EmDash's modules **in the build output** via a Vite plugin. The installed `emdash`
645
+ package is untouched.
646
+
647
+ **Can I add it to an existing single site later?** Yes. Make that site the `primary` (unprefixed),
648
+ add the three wires, deploy. Its data is untouched; new tenant sites get `$slug` tables.
649
+
650
+ **Is `emdash-better-auth` required?** Only for the org→site flow and auth. The table-prefix + cache
651
+ mechanisms work without it; you'd then provision sites manually via `provisionSite`.
652
+
653
+ ## Package exports
654
+
655
+ | Import | What |
656
+ |---|---|
657
+ | `emdash-multisite` | `withSites(inner, sites)`, `resolveSite`, `runAsSite`, `SitesConfig` |
658
+ | `emdash-multisite/vite` | `multisiteCaches()` Vite plugin (per-site cache patch + classification) |
659
+ | `emdash-multisite/middleware` | `onRequest` outer middleware (hostname→site + org→site glue + status route) |
660
+ | `emdash-multisite/db/d1` | runtime entry for the wrapped Cloudflare D1 adapter |
661
+ | `emdash-multisite/db/sqlite` | runtime entry for the wrapped SQLite adapter (local dev) |
662
+ | `emdash-multisite/provision` | `provisionSite(db, siteId)` / `provisionWith` |
663
+ | `emdash-multisite/bootstrap` | `bootstrapControlPlane(db, email)` — one-time control-plane admin + setup flag |
664
+
665
+ Internal (not a stable API): `src/orgs.ts`, `src/scope.ts`, `src/dialect.ts`, `src/rewrite.ts`,
666
+ `src/runtime.ts`.
667
+
668
+ ## Contributing
669
+
670
+ Issues and PRs welcome. The maintainer-facing architecture notes and land mines live in
671
+ [AGENTS.md](./AGENTS.md) — read that before changing the cache patch or the SQL rewriter.
672
+ Run the self-checks before a PR (Node ≥ 22.18):
673
+
674
+ ```bash
675
+ node --no-warnings scripts/orgs.selfcheck.ts # slug / org parsing
676
+ node --no-warnings scripts/caches.selfcheck.ts <site>/node_modules # cache tripwire
677
+ node --no-warnings scripts/selfcheck.ts # migrations + repo oracle
678
+ ```
679
+
680
+ ## License
681
+
682
+ [MIT](./LICENSE) © The Weekend Projects