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.
- package/LICENSE +21 -0
- package/README.md +681 -2
- package/SETUP.md +205 -0
- package/bin/setup.mjs +83 -0
- package/package.json +91 -4
- package/src/abuse.ts +65 -0
- package/src/auth-links.ts +21 -0
- package/src/auth-ui.ts +390 -0
- package/src/backup.ts +137 -0
- package/src/bootstrap.ts +51 -0
- package/src/clone-run.ts +57 -0
- package/src/clone.ts +77 -0
- package/src/config.ts +17 -0
- package/src/console-page.ts +318 -0
- package/src/console.ts +141 -0
- package/src/db/d1.ts +9 -0
- package/src/db/sqlite.ts +6 -0
- package/src/demo.ts +23 -0
- package/src/dialect.ts +109 -0
- package/src/edge-cache.ts +249 -0
- package/src/fleet-actions.ts +44 -0
- package/src/fleet-blocks.ts +115 -0
- package/src/fleet.ts +151 -0
- package/src/handoff.ts +58 -0
- package/src/index.ts +35 -0
- package/src/log.ts +6 -0
- package/src/media-bucket.ts +44 -0
- package/src/middleware.ts +607 -0
- package/src/orgs.ts +343 -0
- package/src/passwords.ts +60 -0
- package/src/platform-env.ts +10 -0
- package/src/plugin-entry.ts +145 -0
- package/src/plugin.ts +26 -0
- package/src/provision.ts +17 -0
- package/src/purge.ts +105 -0
- package/src/registry.ts +421 -0
- package/src/rewrite.ts +199 -0
- package/src/rules.ts +521 -0
- package/src/runtime.ts +69 -0
- package/src/scheduled.ts +107 -0
- package/src/scope.ts +178 -0
- package/src/sites.ts +97 -0
- package/src/slots.ts +38 -0
- package/src/storage.ts +57 -0
- package/src/vite.ts +256 -0
- package/templates/astro.config.snippet.mjs +42 -0
- package/templates/worker.ts +61 -0
- package/templates/wrangler.snippet.jsonc +26 -0
package/README.md
CHANGED
|
@@ -1,3 +1,682 @@
|
|
|
1
|
-
#
|
|
1
|
+
# emdash-multisite
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
[](https://www.npmjs.com/package/emdash-multisite)
|
|
9
|
+
[](./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
|