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.
- 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/src/orgs.ts
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Creating an organization creates a site."
|
|
3
|
+
*
|
|
4
|
+
* Watches ONE Better Auth call on the control-plane site, POST
|
|
5
|
+
* /api/auth/organization/create, without changing the (open-source) plugin:
|
|
6
|
+
* - before: rejects a slug that cannot be a site name;
|
|
7
|
+
* - after a 200: provisions the site in the background and tracks status.
|
|
8
|
+
*
|
|
9
|
+
* The org slug is the site id and therefore the subdomain: `acme` -> acme.<domain>.
|
|
10
|
+
*/
|
|
11
|
+
import { sql } from "kysely";
|
|
12
|
+
import { applySeed } from "emdash";
|
|
13
|
+
// @ts-ignore - virtual module provided by EmDash's integration (the bundled seed)
|
|
14
|
+
import { userSeed } from "virtual:emdash/seed";
|
|
15
|
+
import { runAsSite } from "./dialect.ts";
|
|
16
|
+
import { isSiteId } from "./rewrite.ts";
|
|
17
|
+
import { BUNDLED_THEME, DEFAULT_CONCURRENT_PROVISIONS, SLOT_WAIT_MS, STALE_PROVISIONING_MS, templateId, effectiveTheme, needsReconcile, themeFromMetadata, type ProvisionStep, type SiteStatus } from "./rules.ts";
|
|
18
|
+
import { siteUrl, type SitesConfig } from "./sites.ts";
|
|
19
|
+
import { controlDb, forgetSiteTheme, readAllStates, readOrgs, readSiteTheme, siteHasTables } from "./registry.ts";
|
|
20
|
+
import { scopeState } from "./scope.ts";
|
|
21
|
+
import { provisionWith } from "./provision.ts";
|
|
22
|
+
import { log } from "./log.ts";
|
|
23
|
+
import { cloneTemplate, hasTemplate } from "./clone-run.ts";
|
|
24
|
+
import { sitesConfig } from "./config.ts";
|
|
25
|
+
import { acquireSlot, releaseSlot } from "./slots.ts";
|
|
26
|
+
import { dropSiteTables } from "./purge.ts";
|
|
27
|
+
|
|
28
|
+
export { ORG_CREATE_PATH, RESERVED_SITE_IDS, parseCreatedOrg, slugProblem, type SiteStatus } from "./rules.ts";
|
|
29
|
+
|
|
30
|
+
const statusKey = (slug: string) => `multisite:site:${slug}`;
|
|
31
|
+
|
|
32
|
+
export async function readStatus(slug: string): Promise<SiteStatus | null> {
|
|
33
|
+
const db = controlDb();
|
|
34
|
+
const row = await runAsSite(scopeState().primary, () =>
|
|
35
|
+
sql<{ value: string }>`SELECT value FROM options WHERE name = ${statusKey(slug)}`.execute(db),
|
|
36
|
+
);
|
|
37
|
+
const v = row.rows[0]?.value;
|
|
38
|
+
return v ? (JSON.parse(v) as SiteStatus) : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function writeStatus(slug: string, s: SiteStatus): Promise<void> {
|
|
42
|
+
const db = controlDb();
|
|
43
|
+
await runAsSite(scopeState().primary, () =>
|
|
44
|
+
sql`INSERT INTO options (name, value) VALUES (${statusKey(slug)}, ${JSON.stringify(s)})
|
|
45
|
+
ON CONFLICT(name) DO UPDATE SET value = excluded.value`.execute(db),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const BA_SETTINGS = "plugin:better-auth-settings:settings:";
|
|
50
|
+
// SMTP provider config + state (settings:global, settings:provider:*, state:selectedProviderId). Inherited so every tenant
|
|
51
|
+
// sends mail through the platform's email provider out of the box.
|
|
52
|
+
const SMTP_SETTINGS = "plugin:emdash-smtp:";
|
|
53
|
+
/** Settings not inherited by customer sites: they decide for themselves. */
|
|
54
|
+
const NOT_INHERITED = new Set([`${BA_SETTINGS}orgEnabled`, `${BA_SETTINGS}teamsEnabled`, `${BA_SETTINGS}signUpEnabled`]);
|
|
55
|
+
|
|
56
|
+
/** Can visitors register on this site? Unset means open (Better Auth's default; sites made before the setting existed). */
|
|
57
|
+
export async function signUpOpen(slug: string): Promise<boolean> {
|
|
58
|
+
const db = controlDb();
|
|
59
|
+
const r = await runAsSite(slug, () => sql<{ value: string }>`SELECT value FROM options WHERE name = ${`${BA_SETTINGS}signUpEnabled`}`.execute(db));
|
|
60
|
+
return r.rows[0]?.value !== "false";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const SIGNUP_MEMO = Symbol.for("emdash-multisite:signup-memo");
|
|
64
|
+
/** `signUpOpen`, remembered for 30 s per isolate: it is asked for on every public page view. */
|
|
65
|
+
export async function signUpOpenCached(slug: string): Promise<boolean> {
|
|
66
|
+
const g = globalThis as Record<symbol, Map<string, { v: boolean; t: number }> | undefined>;
|
|
67
|
+
const memo = (g[SIGNUP_MEMO] ??= new Map());
|
|
68
|
+
const hit = memo.get(slug);
|
|
69
|
+
if (hit && Date.now() - hit.t < 30_000) return hit.v;
|
|
70
|
+
const v = await signUpOpen(slug);
|
|
71
|
+
if (memo.size > 2000) memo.clear();
|
|
72
|
+
memo.set(slug, { v, t: Date.now() });
|
|
73
|
+
return v;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Free core's migration lock if it has been held longer than a provisioning run can last. */
|
|
77
|
+
async function releaseStaleLock(db: ReturnType<typeof controlDb>, slug: string): Promise<void> {
|
|
78
|
+
const cutoff = Date.now() - STALE_PROVISIONING_MS;
|
|
79
|
+
await runAsSite(slug, () =>
|
|
80
|
+
sql`UPDATE _emdash_migrations_lock SET is_locked = 0 WHERE is_locked > 0 AND is_locked < ${cutoff}`
|
|
81
|
+
.execute(db)
|
|
82
|
+
.catch(() => {}), // first run: the table does not exist yet
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A site's tables (core migrations, run as that site) and its starter content.
|
|
88
|
+
* Migrations alone leave an empty schema with no collections, so without the
|
|
89
|
+
* seed a new tenant has nothing to edit. core's applySeed upserts, so a retry is
|
|
90
|
+
* safe. Media is not downloaded: see "Media (uploads)" in the README.
|
|
91
|
+
*/
|
|
92
|
+
async function buildSchemaAndSeed(
|
|
93
|
+
db: ReturnType<typeof controlDb>,
|
|
94
|
+
slug: string,
|
|
95
|
+
seed: unknown,
|
|
96
|
+
done?: (ended: ProvisionStep, next?: ProvisionStep) => Promise<void>,
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
await provisionWith(db, slug);
|
|
99
|
+
await done?.("tables", "content");
|
|
100
|
+
if (seed) {
|
|
101
|
+
// eslint-disable-next-line typescript/no-explicit-any -- core types its own Database/Seed
|
|
102
|
+
await runAsSite(slug, () => applySeed(db as any, seed as any, { includeContent: true, skipMediaDownload: true }));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Build the template site for a theme (`tpl-<theme>`): the pre-built copy that new
|
|
108
|
+
* sites are cloned from. Slow (the full migrate-and-seed), so run it once, not per
|
|
109
|
+
* customer. It has no owner and no organization. Refuses to overwrite unless `rebuild` is set, which
|
|
110
|
+
* drops the template first (after an EmDash upgrade or a theme seed change). New sites made while it is
|
|
111
|
+
* being rebuilt take the slow path.
|
|
112
|
+
*/
|
|
113
|
+
export async function buildTemplate(sites: SitesConfig, theme: string, opts: { rebuild?: boolean } = {}): Promise<"built" | "exists" | "unknown-theme"> {
|
|
114
|
+
const seed = sites.themes?.[theme]?.seed;
|
|
115
|
+
if (!seed) return "unknown-theme";
|
|
116
|
+
const slug = templateId(theme);
|
|
117
|
+
if (await hasTemplate(theme)) {
|
|
118
|
+
if (!opts.rebuild) return "exists";
|
|
119
|
+
const dropped = await runAsSite(scopeState().primary, () => dropSiteTables(slug));
|
|
120
|
+
if (dropped.remaining) throw new Error(`[multisite] could not clear ${dropped.remaining} tables of ${slug}`);
|
|
121
|
+
}
|
|
122
|
+
await buildSchemaAndSeed(controlDb(), slug, seed);
|
|
123
|
+
await runAsSite(slug, async () => {
|
|
124
|
+
// Without this flag EmDash's APIs answer NOT_CONFIGURED (the template doubles as a public demo, whose
|
|
125
|
+
// search needs them). Clones get it again from the identity step either way.
|
|
126
|
+
await sql`INSERT INTO options (name, value) VALUES ('emdash:setup_complete', 'true')
|
|
127
|
+
ON CONFLICT(name) DO UPDATE SET value = 'true'`.execute(controlDb());
|
|
128
|
+
// Written last: it marks the template complete.
|
|
129
|
+
await sql`INSERT INTO options (name, value) VALUES ('multisite:theme', ${JSON.stringify(theme)})
|
|
130
|
+
ON CONFLICT(name) DO UPDATE SET value = excluded.value`.execute(controlDb());
|
|
131
|
+
});
|
|
132
|
+
log("template.built", { theme });
|
|
133
|
+
return "built";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Create the site `slug` and hand it to `ownerUserId`. Idempotent: running it
|
|
138
|
+
* again on a half-provisioned site completes it.
|
|
139
|
+
*/
|
|
140
|
+
export async function createSiteForOrg(
|
|
141
|
+
org: { slug: string; name: string; ownerUserId: string },
|
|
142
|
+
plan: { theme: string; seed?: unknown } = { theme: BUNDLED_THEME },
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
const st = scopeState();
|
|
145
|
+
const startedAt = new Date().toISOString();
|
|
146
|
+
await writeStatus(org.slug, { status: "provisioning", startedAt, step: "tables" });
|
|
147
|
+
// Wait for one of the platform's provisioning slots; if none frees up soon, say "queued" and let the next poll or cron retry.
|
|
148
|
+
const max = sitesConfig()?.limits?.concurrentProvisions ?? DEFAULT_CONCURRENT_PROVISIONS;
|
|
149
|
+
if (!(await acquireSlot(org.slug, max, SLOT_WAIT_MS))) {
|
|
150
|
+
await writeStatus(org.slug, { status: "provisioning", startedAt: new Date().toISOString(), step: "queued" });
|
|
151
|
+
log("site.queued", { site: org.slug });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const timings: Partial<Record<ProvisionStep, number>> = {};
|
|
155
|
+
let lap = Date.now();
|
|
156
|
+
/** Record how long the step that just ended took, and announce the next one. */
|
|
157
|
+
const done = async (ended: ProvisionStep, next?: ProvisionStep) => {
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
timings[ended] = now - lap;
|
|
160
|
+
lap = now;
|
|
161
|
+
if (next) await writeStatus(org.slug, { status: "provisioning", startedAt, step: next });
|
|
162
|
+
};
|
|
163
|
+
try {
|
|
164
|
+
const db = controlDb();
|
|
165
|
+
|
|
166
|
+
// 0. a run that was killed mid-migration (Worker evicted) leaves core's
|
|
167
|
+
// migration lock held, and every retry then refuses to start. Release a
|
|
168
|
+
// lock old enough that its owner is certainly gone.
|
|
169
|
+
await releaseStaleLock(db, org.slug);
|
|
170
|
+
|
|
171
|
+
// 1. the site's tables and starter content: copied from the theme's template in one
|
|
172
|
+
// D1 batch when there is one, else core migrations + the theme's seed (slow: ~70 s).
|
|
173
|
+
// A site that already has tables is a retry of a partial run; it takes the slow path.
|
|
174
|
+
const cloned = !(await siteHasTables(org.slug)) && (await cloneTemplate(plan.theme, org.slug));
|
|
175
|
+
if (cloned) {
|
|
176
|
+
await done("tables", "content");
|
|
177
|
+
} else {
|
|
178
|
+
await buildSchemaAndSeed(db, org.slug, plan.seed ?? userSeed, done);
|
|
179
|
+
}
|
|
180
|
+
await done("content", "identity");
|
|
181
|
+
// 2. read the owner's identity and platform auth policy from the control plane
|
|
182
|
+
const { owner, storage, inherited } = await runAsSite(st.primary, async () => {
|
|
183
|
+
const u = await sql<Record<string, unknown>>`SELECT * FROM users WHERE id = ${org.ownerUserId}`.execute(db);
|
|
184
|
+
const s = await sql<Record<string, unknown>>`
|
|
185
|
+
SELECT * FROM _plugin_storage
|
|
186
|
+
WHERE collection IN ('accounts', 'usernames') AND data LIKE ${`%"userId":"${org.ownerUserId}"%`}`.execute(db);
|
|
187
|
+
// Better Auth policy + the SMTP provider config (stored in `options` under plugin:emdash-smtp:). Copying the SMTP
|
|
188
|
+
// settings/state gives every tenant the platform's own email provider (Resend) so plugins and auth can send mail
|
|
189
|
+
// without the owner configuring anything. SECURITY: the Resend API key is a secret and becomes readable in the
|
|
190
|
+
// tenant's own SMTP admin — acceptable here because every tenant shares one platform key (rotate platform-wide).
|
|
191
|
+
const o = await sql<{ name: string; value: string }>`
|
|
192
|
+
SELECT name, value FROM options WHERE name LIKE ${`${BA_SETTINGS}%`} OR name LIKE ${`${SMTP_SETTINGS}%`}`.execute(db);
|
|
193
|
+
return { owner: u.rows[0], storage: s.rows, inherited: o.rows.filter((r) => !NOT_INHERITED.has(r.name)) };
|
|
194
|
+
});
|
|
195
|
+
if (!owner) throw new Error(`owner ${org.ownerUserId} not found on the control plane`);
|
|
196
|
+
|
|
197
|
+
// 3. write them into the new site: owner becomes admin (EmDash role 50 and Better Auth role "admin", or the
|
|
198
|
+
// Users page and Make premium would be refused for the person who owns the site), verified
|
|
199
|
+
await runAsSite(org.slug, async () => {
|
|
200
|
+
await sql`INSERT INTO users (id, email, name, avatar_url, role, email_verified, data, created_at, updated_at, disabled)
|
|
201
|
+
VALUES (${owner.id}, ${owner.email}, ${owner.name}, ${owner.avatar_url}, 50, 1, json_set(COALESCE(${owner.data}, '{}'), '$.role', 'admin'),
|
|
202
|
+
${owner.created_at}, ${owner.updated_at}, 0)
|
|
203
|
+
ON CONFLICT(id) DO UPDATE SET role = 50, email_verified = 1, data = json_set(COALESCE(users.data, '{}'), '$.role', 'admin')`.execute(db);
|
|
204
|
+
for (const r of storage) {
|
|
205
|
+
await sql`INSERT INTO _plugin_storage (plugin_id, collection, id, data, created_at, updated_at, revision)
|
|
206
|
+
VALUES (${r.plugin_id}, ${r.collection}, ${r.id}, ${r.data}, ${r.created_at}, ${r.updated_at}, ${r.revision})
|
|
207
|
+
ON CONFLICT(plugin_id, collection, id) DO NOTHING`.execute(db);
|
|
208
|
+
}
|
|
209
|
+
for (const r of inherited) {
|
|
210
|
+
await sql`INSERT INTO options (name, value) VALUES (${r.name}, ${r.value})
|
|
211
|
+
ON CONFLICT(name) DO NOTHING`.execute(db);
|
|
212
|
+
}
|
|
213
|
+
// A new customer site is closed to public registration; its owner turns it on in Settings
|
|
214
|
+
// ("Allow new sign-ups", emdash-better-auth 0.11) if they want subscribers. DO NOTHING so a
|
|
215
|
+
// retry of provisioning never undoes the owner's choice.
|
|
216
|
+
await sql`INSERT INTO options (name, value) VALUES (${`${BA_SETTINGS}signUpEnabled`}, 'false')
|
|
217
|
+
ON CONFLICT(name) DO NOTHING`.execute(db);
|
|
218
|
+
await sql`INSERT INTO options (name, value) VALUES ('multisite:theme', ${JSON.stringify(plan.theme)})
|
|
219
|
+
ON CONFLICT(name) DO UPDATE SET value = excluded.value`.execute(db);
|
|
220
|
+
await sql`INSERT INTO options (name, value) VALUES ('site:title', ${JSON.stringify(org.name)})
|
|
221
|
+
ON CONFLICT(name) DO UPDATE SET value = excluded.value`.execute(db);
|
|
222
|
+
// EmDash's runtime (and so every plugin's `ctx.site.name`) reads `emdash:site_title`, which only first-run setup writes;
|
|
223
|
+
// `site:title` is the user-facing alias. Keep both so plugins see the site's name.
|
|
224
|
+
await sql`INSERT INTO options (name, value) VALUES ('emdash:site_title', ${JSON.stringify(org.name)})
|
|
225
|
+
ON CONFLICT(name) DO UPDATE SET value = excluded.value`.execute(db);
|
|
226
|
+
// The site's own public origin. EmDash plugins build absolute URLs from it (the SEO plugin's llms.txt, sitemaps, canonical
|
|
227
|
+
// links); a cloned tenant never ran first-run setup, so without this they have no origin and emit nothing.
|
|
228
|
+
const origin = siteUrl(org.slug, sitesConfig() ?? ({} as SitesConfig));
|
|
229
|
+
if (origin) {
|
|
230
|
+
await sql`INSERT INTO options (name, value) VALUES ('site:url', ${JSON.stringify(origin)})
|
|
231
|
+
ON CONFLICT(name) DO UPDATE SET value = excluded.value`.execute(db);
|
|
232
|
+
}
|
|
233
|
+
// The site is born with an admin (the owner, above), so EmDash's
|
|
234
|
+
// first-run setup wizard must never run on it. Without this flag the
|
|
235
|
+
// wizard appears and then refuses ("admin user already exists").
|
|
236
|
+
await sql`INSERT INTO options (name, value) VALUES ('emdash:setup_complete', 'true')
|
|
237
|
+
ON CONFLICT(name) DO UPDATE SET value = 'true'`.execute(db);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
await done("identity");
|
|
241
|
+
await writeStatus(org.slug, { status: "ready", startedAt, readyAt: new Date().toISOString(), timings });
|
|
242
|
+
log("site.ready", { site: org.slug, theme: plan.theme, cloned, ms: Date.now() - Date.parse(startedAt), timings });
|
|
243
|
+
// Pre-warm the new site's cold public routes so the FIRST real visitor (or a PageSpeed/agent
|
|
244
|
+
// crawler) hits a warm edge cache instead of the ~450ms cold render. The SEO plugin generates
|
|
245
|
+
// robots.txt / llms.txt / sitemap live on a cold isolate; that cold render exceeded Lighthouse's
|
|
246
|
+
// tight fetch budget on a brand-new site, flagging robots.txt/llms.txt as "failed to fetch"
|
|
247
|
+
// (SEO 92, Agentic 2/3). One request per route fills the host-keyed edge cache. Best-effort and
|
|
248
|
+
// non-blocking: the site is already "ready"; failures are ignored.
|
|
249
|
+
{
|
|
250
|
+
const warmOrigin = siteUrl(org.slug, sitesConfig() ?? ({} as SitesConfig));
|
|
251
|
+
if (warmOrigin) {
|
|
252
|
+
await Promise.allSettled(
|
|
253
|
+
["/", "/robots.txt", "/llms.txt", "/sitemap.xml"].map((p) =>
|
|
254
|
+
fetch(new URL(p, warmOrigin), { headers: { "user-agent": "hostmyemdash-prewarm" } }),
|
|
255
|
+
),
|
|
256
|
+
).catch(() => {});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
} catch (error) {
|
|
260
|
+
console.error(`[multisite] provisioning "${org.slug}" failed:`, error);
|
|
261
|
+
log("site.failed", { site: org.slug, error: error instanceof Error ? error.message : String(error) });
|
|
262
|
+
await writeStatus(org.slug, {
|
|
263
|
+
status: "failed",
|
|
264
|
+
startedAt,
|
|
265
|
+
error: error instanceof Error ? error.message : String(error),
|
|
266
|
+
}).catch(() => {});
|
|
267
|
+
} finally {
|
|
268
|
+
await releaseSlot(org.slug).catch(() => {});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Theme id and seed for a site, from what was requested at creation (org metadata). */
|
|
273
|
+
export function planFor(sites: SitesConfig, metadata: unknown): { theme: string; seed?: unknown } {
|
|
274
|
+
const theme = effectiveTheme(themeFromMetadata(metadata), sites.themes, sites.defaultTheme);
|
|
275
|
+
return { theme, seed: sites.themes?.[theme]?.seed };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Sites that need provisioning (again): `failed`, a `provisioning` row old
|
|
280
|
+
* enough to be a dead run, or no status row at all. A site with no status row
|
|
281
|
+
* but with tables predates status tracking; it is adopted as `ready` instead of
|
|
282
|
+
* being provisioned a second time on top of live content.
|
|
283
|
+
*/
|
|
284
|
+
export async function pendingReconcile(only?: string): Promise<string[]> {
|
|
285
|
+
const out: string[] = [];
|
|
286
|
+
// Suspended and deleted sites are left alone: provisioning would wake their runtime
|
|
287
|
+
// and re-create what a purge removed.
|
|
288
|
+
const inactive = await readAllStates();
|
|
289
|
+
for (const org of await readOrgs()) {
|
|
290
|
+
if (only && org.slug !== only) continue;
|
|
291
|
+
if (!org.ownerUserId || !isSiteId(org.slug) || inactive.has(org.slug)) continue;
|
|
292
|
+
const status = await readStatus(org.slug);
|
|
293
|
+
if (!needsReconcile(status, Date.now())) continue;
|
|
294
|
+
if (!status && (await siteHasTables(org.slug))) {
|
|
295
|
+
const now = new Date().toISOString();
|
|
296
|
+
await writeStatus(org.slug, { status: "ready", startedAt: now, readyAt: now });
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
out.push(org.slug);
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Re-provision ONE stuck site (the first pending, or `only`). One per call on
|
|
306
|
+
* purpose: a run is ~20 s of D1 work and the background budget after a response
|
|
307
|
+
* (`waitUntil`) is ~30 s, so a loop would be killed half way and leave a site
|
|
308
|
+
* stuck in `provisioning`. Call again for the next one.
|
|
309
|
+
* ponytail: upgrade path is a Queue or Workflow that runs one site per message.
|
|
310
|
+
*/
|
|
311
|
+
export async function reconcile(sites: SitesConfig, only?: string): Promise<string[]> {
|
|
312
|
+
const [slug] = await pendingReconcile(only);
|
|
313
|
+
if (!slug) return [];
|
|
314
|
+
const org = (await readOrgs()).find((o) => o.slug === slug)!;
|
|
315
|
+
// A switched site keeps its current theme; the org metadata only records the first choice.
|
|
316
|
+
const current = await readSiteTheme(slug);
|
|
317
|
+
await createSiteForOrg({ slug, name: org.name, ownerUserId: org.ownerUserId! }, planFor(sites, current ? { theme: current } : org.metadata));
|
|
318
|
+
return [slug];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Switch a live site to another theme. Additive and non-destructive: the target
|
|
323
|
+
* theme's seed is applied with skip-on-conflict (core creates only what is
|
|
324
|
+
* missing, never overwrites settings, menus or content), then the site's theme
|
|
325
|
+
* option is flipped. Content of the old theme stays in the database, hidden.
|
|
326
|
+
*/
|
|
327
|
+
export async function switchTheme(
|
|
328
|
+
sites: SitesConfig,
|
|
329
|
+
slug: string,
|
|
330
|
+
theme: string,
|
|
331
|
+
opts: { includeContent: boolean },
|
|
332
|
+
): Promise<void> {
|
|
333
|
+
const seed = sites.themes?.[theme]?.seed;
|
|
334
|
+
if (!seed) throw new Error(`unknown theme: ${theme}`);
|
|
335
|
+
const db = controlDb();
|
|
336
|
+
await runAsSite(slug, async () => {
|
|
337
|
+
// eslint-disable-next-line typescript/no-explicit-any -- core types its own Database/Seed
|
|
338
|
+
await applySeed(db as any, seed as any, { includeContent: opts.includeContent, skipMediaDownload: true, onConflict: "skip" });
|
|
339
|
+
await sql`INSERT INTO options (name, value) VALUES ('multisite:theme', ${JSON.stringify(theme)})
|
|
340
|
+
ON CONFLICT(name) DO UPDATE SET value = excluded.value`.execute(db);
|
|
341
|
+
});
|
|
342
|
+
forgetSiteTheme(slug);
|
|
343
|
+
}
|
package/src/passwords.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep an owner's password the same on the control plane and on their sites.
|
|
3
|
+
*
|
|
4
|
+
* Provisioning copies the owner's Better Auth credential account into each site. A later password change or
|
|
5
|
+
* reset on the control plane would leave those copies on the old password. After such a request (and on every
|
|
6
|
+
* cron tick as a safety net) this copies recently changed credential accounts to the sites their user owns.
|
|
7
|
+
*
|
|
8
|
+
* Only owners: an editor who signed up on a site has a separate account there and keeps its own password.
|
|
9
|
+
* ponytail: matches by "changed in the last N minutes", so a missed window (Worker down for longer than the cron
|
|
10
|
+
* window) is not caught. Upgrade path: a per-account version stamp compared on every handoff.
|
|
11
|
+
*/
|
|
12
|
+
import { sql } from "kysely";
|
|
13
|
+
import { runAsSite } from "./dialect.ts";
|
|
14
|
+
import { log } from "./log.ts";
|
|
15
|
+
import { controlDb, readAllStates, readOrgs } from "./registry.ts";
|
|
16
|
+
import { scopeState } from "./scope.ts";
|
|
17
|
+
|
|
18
|
+
export const PASSWORD_PATHS: ReadonlySet<string> = new Set(["/api/auth/change-password", "/api/auth/reset-password", "/api/auth/set-password"]);
|
|
19
|
+
|
|
20
|
+
interface AccountRow {
|
|
21
|
+
plugin_id: string;
|
|
22
|
+
collection: string;
|
|
23
|
+
id: string;
|
|
24
|
+
data: string;
|
|
25
|
+
created_at: string;
|
|
26
|
+
updated_at: string;
|
|
27
|
+
revision: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Copy credential accounts changed within `withinMs` to the sites their user owns. Returns how many site copies were updated. */
|
|
31
|
+
export async function syncRecentPasswords(withinMs: number): Promise<number> {
|
|
32
|
+
const db = controlDb();
|
|
33
|
+
const cutoff = new Date(Date.now() - withinMs).toISOString();
|
|
34
|
+
const rows = await runAsSite(scopeState().primary, async () =>
|
|
35
|
+
(await sql<AccountRow>`SELECT * FROM _plugin_storage WHERE collection = 'accounts' AND updated_at >= ${cutoff}`.execute(db)).rows,
|
|
36
|
+
);
|
|
37
|
+
const changed = rows.flatMap((r) => {
|
|
38
|
+
try {
|
|
39
|
+
const d = JSON.parse(r.data) as { providerId?: string; userId?: string };
|
|
40
|
+
return d.providerId === "credential" && d.userId ? [{ row: r, userId: d.userId }] : [];
|
|
41
|
+
} catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
if (!changed.length) return 0;
|
|
46
|
+
const inactive = await readAllStates();
|
|
47
|
+
const orgs = (await readOrgs()).filter((o) => o.ownerUserId && !inactive.has(o.slug) && o.slug !== scopeState().primary);
|
|
48
|
+
let n = 0;
|
|
49
|
+
for (const { row, userId } of changed) {
|
|
50
|
+
for (const org of orgs.filter((o) => o.ownerUserId === userId)) {
|
|
51
|
+
await runAsSite(org.slug, () =>
|
|
52
|
+
sql`INSERT INTO _plugin_storage (plugin_id, collection, id, data, created_at, updated_at, revision)
|
|
53
|
+
VALUES (${row.plugin_id}, ${row.collection}, ${row.id}, ${row.data}, ${row.created_at}, ${row.updated_at}, ${row.revision})
|
|
54
|
+
ON CONFLICT(plugin_id, collection, id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at, revision = excluded.revision`.execute(db),
|
|
55
|
+
).then(() => n++, (error) => console.error(`[multisite] password sync to "${org.slug}" failed:`, error));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (n) log("password.synced", { copies: n });
|
|
59
|
+
return n;
|
|
60
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Workers bindings (`env`), or undefined off Workers (Node/SQLite lab).
|
|
3
|
+
* Imported at run time, through a variable, so bundlers for other platforms do not
|
|
4
|
+
* try to resolve `cloudflare:workers` and fail the build.
|
|
5
|
+
*/
|
|
6
|
+
let cached: Promise<Record<string, unknown> | undefined> | undefined;
|
|
7
|
+
export function platformEnv(): Promise<Record<string, unknown> | undefined> {
|
|
8
|
+
const specifier = "cloudflare:workers";
|
|
9
|
+
return (cached ??= import(/* @vite-ignore */ specifier).then((m) => m.env as Record<string, unknown>).catch(() => undefined));
|
|
10
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime of the multisite admin plugin: one Block Kit page, "Site", in each
|
|
3
|
+
* site's own EmDash admin.
|
|
4
|
+
*
|
|
5
|
+
* - **Appearance**: pick another theme for THIS site (additive, nothing is deleted;
|
|
6
|
+
* see `switchTheme`). The site is the one the request is for, so the page works
|
|
7
|
+
* unchanged on every tenant and on the control plane.
|
|
8
|
+
* - **Account**: links back to the app (account, my sites).
|
|
9
|
+
*
|
|
10
|
+
* The route is admin-only (`plugins:manage`), which on a tenant means its owner
|
|
11
|
+
* and any admins they appoint, and nobody else.
|
|
12
|
+
*/
|
|
13
|
+
import { definePlugin } from "emdash";
|
|
14
|
+
import { purgeSiteCache } from "./edge-cache.ts";
|
|
15
|
+
import { appearanceState } from "./console.ts";
|
|
16
|
+
import { sitesConfig } from "./config.ts";
|
|
17
|
+
import { PLUGIN_ID, PLUGIN_PAGE_PATH, PLUGIN_VERSION } from "./plugin.ts";
|
|
18
|
+
import { signUpOpen } from "./orgs.ts";
|
|
19
|
+
import { activeSite, scopeState } from "./scope.ts";
|
|
20
|
+
import { listSites } from "./fleet.ts";
|
|
21
|
+
import { runFleetAction } from "./fleet-actions.ts";
|
|
22
|
+
import { PURGE_FORM_ACTION, fleetBlocks, parseFleetAction } from "./fleet-blocks.ts";
|
|
23
|
+
import { isSiteId } from "./rewrite.ts";
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
interface Interaction {
|
|
27
|
+
type?: string;
|
|
28
|
+
action_id?: string;
|
|
29
|
+
value?: unknown;
|
|
30
|
+
values?: Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
interface Page {
|
|
33
|
+
blocks: unknown[];
|
|
34
|
+
toast?: { message: string; type: "info" | "success" | "error" };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The control plane's Site page is the fleet: every site, with staff actions. */
|
|
38
|
+
async function fleetPage(toast?: Page["toast"]): Promise<Page> {
|
|
39
|
+
return { blocks: fleetBlocks(await listSites(), scopeState().primary), ...(toast ? { toast } : {}) };
|
|
40
|
+
}
|
|
41
|
+
async function buildPage(toast?: Page["toast"]): Promise<Page> {
|
|
42
|
+
const sites = sitesConfig();
|
|
43
|
+
const slug = activeSite();
|
|
44
|
+
if (slug === scopeState().primary) return fleetPage(toast);
|
|
45
|
+
const state = sites ? await appearanceState(slug, sites) : null;
|
|
46
|
+
const blocks: unknown[] = [{ type: "header", text: "Site" }];
|
|
47
|
+
|
|
48
|
+
if (!sites || !state) {
|
|
49
|
+
blocks.push({
|
|
50
|
+
type: "context",
|
|
51
|
+
text: `This site is not managed by the multi-site platform (site "${slug}", ${sites ? (sites.themes ? "no matching organization" : "no themes configured") : "no multisite config"}).`,
|
|
52
|
+
});
|
|
53
|
+
return { blocks, ...(toast ? { toast } : {}) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
blocks.push({ type: "context", text: `${state.name} (${state.slug})` });
|
|
57
|
+
blocks.push({
|
|
58
|
+
type: "context",
|
|
59
|
+
text: `Public sign-up is ${(await signUpOpen(slug)) ? "ON: visitors can create an account" : "OFF: only people you add can sign in"}. Change it in Settings, Better Auth, "Allow new sign-ups".`,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (state.themes.length > 1 && sites.appUrl) {
|
|
63
|
+
// One home for theme switching: the Sites tab of the account page (Better Auth UI).
|
|
64
|
+
const current = state.themes.find((t) => t.current);
|
|
65
|
+
blocks.push({ type: "divider" }, { type: "header", text: "Appearance" });
|
|
66
|
+
blocks.push({ type: "context", text: `Theme: ${current?.name ?? state.current}. Switching keeps all of your content; anything the new theme does not show is hidden and comes back if you switch back.` });
|
|
67
|
+
blocks.push({
|
|
68
|
+
type: "actions",
|
|
69
|
+
elements: [
|
|
70
|
+
{
|
|
71
|
+
type: "link" as const,
|
|
72
|
+
label: "Change theme",
|
|
73
|
+
target: { kind: "external" as const, url: `${sites.appUrl}/account/sites?appearance=${state.slug}` },
|
|
74
|
+
appearance: "secondary" as const,
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (sites.appUrl) {
|
|
80
|
+
const link = (path: string, label: string) => ({
|
|
81
|
+
type: "link" as const,
|
|
82
|
+
label,
|
|
83
|
+
target: { kind: "external" as const, url: `${sites.appUrl}${path}` },
|
|
84
|
+
appearance: "secondary" as const,
|
|
85
|
+
});
|
|
86
|
+
blocks.push({ type: "divider" }, { type: "header", text: "Account" });
|
|
87
|
+
blocks.push({ type: "actions", elements: [link("/account/sites", "My sites"), link("/account", "Account")] });
|
|
88
|
+
}
|
|
89
|
+
return { blocks, ...(toast ? { toast } : {}) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function createPlugin() {
|
|
93
|
+
return definePlugin({
|
|
94
|
+
id: PLUGIN_ID,
|
|
95
|
+
version: PLUGIN_VERSION,
|
|
96
|
+
capabilities: ["read:content"],
|
|
97
|
+
// Anything that changes a public page drops this site's cached pages (purge by `site:<slug>`, see edge-cache.ts).
|
|
98
|
+
// Draft autosaves of a published entry only stage a draft revision (`draftRevisionId` set), so they must NOT purge: the
|
|
99
|
+
// purge budget is tiny and shared by every tenant. Edits to menus, widgets, settings, taxonomies and so on have no hook;
|
|
100
|
+
// the middleware purges those after the successful admin write (see watchEdits in middleware.ts).
|
|
101
|
+
hooks: {
|
|
102
|
+
"content:afterPublish": { handler: async () => void (await purgeSiteCache(activeSite())) },
|
|
103
|
+
"content:afterUnpublish": { handler: async () => void (await purgeSiteCache(activeSite())) },
|
|
104
|
+
"content:afterDelete": { handler: async () => void (await purgeSiteCache(activeSite())) },
|
|
105
|
+
"content:afterSave": {
|
|
106
|
+
handler: async (event: { content?: Record<string, unknown> }) => {
|
|
107
|
+
// Collections without revisions write the live row directly.
|
|
108
|
+
if (event.content?.status === "published" && !event.content.draftRevisionId) await purgeSiteCache(activeSite());
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
"byline:afterSave": { handler: async () => void (await purgeSiteCache(activeSite())) },
|
|
112
|
+
"byline:afterDelete": { handler: async () => void (await purgeSiteCache(activeSite())) },
|
|
113
|
+
},
|
|
114
|
+
admin: { pages: [{ path: PLUGIN_PAGE_PATH, label: "Site", icon: "globe" }] },
|
|
115
|
+
routes: {
|
|
116
|
+
admin: {
|
|
117
|
+
// Admin-only: changing the theme changes the whole public site.
|
|
118
|
+
permission: "plugins:manage",
|
|
119
|
+
handler: async (routeCtx: { input?: unknown }): Promise<Page> => {
|
|
120
|
+
const interaction = (routeCtx.input ?? { type: "page_load" }) as Interaction;
|
|
121
|
+
const sitesConfigured = sitesConfig();
|
|
122
|
+
if (sitesConfigured && activeSite() === scopeState().primary) {
|
|
123
|
+
// Control plane: row menu actions and the purge form. (Only reachable with `plugins:manage`, i.e. staff.)
|
|
124
|
+
const row = interaction.type === "block_action" ? parseFleetAction(interaction.action_id, interaction.value) : null;
|
|
125
|
+
if (row) {
|
|
126
|
+
const r = await runFleetAction(sitesConfigured, row.slug, row.action);
|
|
127
|
+
return fleetPage({ message: r.message, type: r.ok ? "success" : "error" });
|
|
128
|
+
}
|
|
129
|
+
if (interaction.type === "form_submit" && interaction.action_id === PURGE_FORM_ACTION) {
|
|
130
|
+
const slug = interaction.values?.slug;
|
|
131
|
+
if (typeof slug !== "string" || !isSiteId(slug) || interaction.values?.confirm !== true) {
|
|
132
|
+
return fleetPage({ message: "Pick a site and tick the box to purge.", type: "error" });
|
|
133
|
+
}
|
|
134
|
+
const r = await runFleetAction(sitesConfigured, slug, "purge");
|
|
135
|
+
return fleetPage({ message: r.message, type: r.ok ? "success" : "error" });
|
|
136
|
+
}
|
|
137
|
+
return fleetPage();
|
|
138
|
+
}
|
|
139
|
+
return buildPage();
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
export default createPlugin;
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { PluginDescriptor } from "emdash";
|
|
2
|
+
|
|
3
|
+
export const PLUGIN_ID = "multisite";
|
|
4
|
+
export const PLUGIN_VERSION = "0.1.0";
|
|
5
|
+
/** Sidebar page path inside the site's admin: `/_emdash/admin/plugins/multisite/site`. */
|
|
6
|
+
export const PLUGIN_PAGE_PATH = "/site";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Native EmDash plugin that adds a **Site** page to every site's own admin: change
|
|
10
|
+
* the theme (Appearance) and jump back to the account area. Register it next to
|
|
11
|
+
* the database wrapper:
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* emdash({ database: withSites(...), plugins: [multisitePlugin()] })
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* Runtime lives in `plugin-entry.ts` (loaded only on the server).
|
|
18
|
+
*/
|
|
19
|
+
export function multisitePlugin(): PluginDescriptor {
|
|
20
|
+
return {
|
|
21
|
+
id: PLUGIN_ID,
|
|
22
|
+
version: PLUGIN_VERSION,
|
|
23
|
+
format: "native",
|
|
24
|
+
entrypoint: "emdash-multisite/plugin-entry",
|
|
25
|
+
};
|
|
26
|
+
}
|
package/src/provision.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Kysely } from "kysely";
|
|
2
|
+
import { runMigrations } from "emdash/db";
|
|
3
|
+
import { runAsSite } from "./dialect.ts";
|
|
4
|
+
import { isSiteId } from "./rewrite.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create (or upgrade) one site's tables by running core's migrations as that
|
|
8
|
+
* site. `db` must be a Kysely built on the wrapped dialect. Idempotent.
|
|
9
|
+
*/
|
|
10
|
+
export async function provisionWith(db: Kysely<never>, site: string): Promise<{ applied: string[] }> {
|
|
11
|
+
if (!isSiteId(site)) throw new Error(`[multisite] invalid site id: ${site}`);
|
|
12
|
+
// eslint-disable-next-line typescript/no-explicit-any -- core types its own Database
|
|
13
|
+
return runAsSite(site, () => runMigrations(db as any));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Public name used by scripts. */
|
|
17
|
+
export const provisionSite = provisionWith;
|