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
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
import { after } from "emdash";
|
|
2
|
+
import { runAsSite } from "./dialect.ts";
|
|
3
|
+
import { EDGE_REQUEST_HEADER, classifyEdit, purgeSiteCache, tagSite, touchesPublicPage } from "./edge-cache.ts";
|
|
4
|
+
import { appearanceState, canManage, checkAddress, consoleState } from "./console.ts";
|
|
5
|
+
import { consolePageHtml } from "./console-page.ts";
|
|
6
|
+
import { isStaff, listSites } from "./fleet.ts";
|
|
7
|
+
import {
|
|
8
|
+
ORG_CREATE_PATH,
|
|
9
|
+
buildTemplate,
|
|
10
|
+
createSiteForOrg,
|
|
11
|
+
parseCreatedOrg,
|
|
12
|
+
pendingReconcile,
|
|
13
|
+
planFor,
|
|
14
|
+
readStatus,
|
|
15
|
+
reconcile,
|
|
16
|
+
signUpOpenCached,
|
|
17
|
+
slugProblem,
|
|
18
|
+
switchTheme,
|
|
19
|
+
} from "./orgs.ts";
|
|
20
|
+
import {
|
|
21
|
+
consumeHandoffNonce,
|
|
22
|
+
handoffSecret,
|
|
23
|
+
isEmailVerified,
|
|
24
|
+
readAllStates,
|
|
25
|
+
readOrgs,
|
|
26
|
+
readSiteState,
|
|
27
|
+
readSiteTheme,
|
|
28
|
+
resolveLimits,
|
|
29
|
+
siteExists,
|
|
30
|
+
siteHasTables,
|
|
31
|
+
siteHasUser,
|
|
32
|
+
warm,
|
|
33
|
+
writeSiteState,
|
|
34
|
+
} from "./registry.ts";
|
|
35
|
+
import { overLimit } from "./abuse.ts";
|
|
36
|
+
import { demoUrls } from "./demo.ts";
|
|
37
|
+
import { log } from "./log.ts";
|
|
38
|
+
import { PASSWORD_PATHS, syncRecentPasswords } from "./passwords.ts";
|
|
39
|
+
import { backupSite, listBackups, restoreSite } from "./backup.ts";
|
|
40
|
+
import { sitesConfig } from "./config.ts";
|
|
41
|
+
import { safeNext, signHandoff, verifyHandoff } from "./handoff.ts";
|
|
42
|
+
import { purgeSite } from "./purge.ts";
|
|
43
|
+
import { siteUrl } from "./sites.ts";
|
|
44
|
+
import { sql } from "kysely";
|
|
45
|
+
import { getDb } from "emdash/runtime";
|
|
46
|
+
import { effectiveLimits, TEMPLATE_PREFIX, demoBannerHtml, demoGuard, isTemplateId, lostLimitRace, SIGNUP_PATH, emailDomainBlocked, DEFAULT_DELETE_GRACE_DAYS, DEFAULT_LIMITS, DEFAULT_POST_LOGIN_PATH, csrfOk, isPurgeDue, loginEntryRedirect, transition, type SiteState, type StateAction, createProblem, needsReconcile, effectiveTheme, isThemeRouteRequest, isThemed, themeFromMetadata, themeProblem, themeRoute } from "./rules.ts";
|
|
47
|
+
import { isSiteId } from "./rewrite.ts";
|
|
48
|
+
import { forgetSiteSettings, scopeState } from "./scope.ts";
|
|
49
|
+
import { resolveSite, type SitesConfig } from "./sites.ts";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Outer middleware: runs BEFORE EmDash's own, and wraps the whole request in
|
|
53
|
+
* the site its hostname maps to.
|
|
54
|
+
*
|
|
55
|
+
* Why it exists: EmDash reads its runtime and caches at the very top of a
|
|
56
|
+
* request, before the per-site database handle exists. Setting the site here
|
|
57
|
+
* means every per-site cache and the runtime resolve to the right site from the
|
|
58
|
+
* first line of the request.
|
|
59
|
+
*
|
|
60
|
+
* On the control-plane (primary) site it also turns "create an organization"
|
|
61
|
+
* into "create a site" (see orgs.ts) and serves
|
|
62
|
+
* GET /api/multisite/sites/<slug> so a page can poll provisioning.
|
|
63
|
+
*
|
|
64
|
+
* Wire it through EmDash's supported hook:
|
|
65
|
+
* emdash({ middleware: { outer: "emdash-multisite/middleware" } })
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/** Astro's `next`; a path argument rewrites the request. */
|
|
69
|
+
type Next = (rewrite?: string) => Promise<Response>;
|
|
70
|
+
interface Session {
|
|
71
|
+
get(key: string): unknown | Promise<unknown>;
|
|
72
|
+
set(key: string, value: unknown): void;
|
|
73
|
+
}
|
|
74
|
+
interface Ctx {
|
|
75
|
+
url: URL;
|
|
76
|
+
request: Request;
|
|
77
|
+
/** Astro's per-request locals; themes read what we set here. */
|
|
78
|
+
locals?: Record<string, unknown>;
|
|
79
|
+
session?: Session;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const STATUS_PREFIX = "/api/multisite/sites/";
|
|
83
|
+
const RECONCILE_API = "/api/multisite/reconcile";
|
|
84
|
+
const TEMPLATES_API = "/api/multisite/templates";
|
|
85
|
+
const FLEET_API = "/api/multisite/fleet";
|
|
86
|
+
const FLEET_PAGE = "/admin/fleet";
|
|
87
|
+
const FLEET_NATIVE_PAGE = "/_emdash/admin/plugins/multisite/site";
|
|
88
|
+
const forbidden = () => new Response(JSON.stringify({ code: "CSRF", message: "Missing or cross-site request." }), {
|
|
89
|
+
status: 403,
|
|
90
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
91
|
+
});
|
|
92
|
+
const html = (body: string, status: number) =>
|
|
93
|
+
new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
|
|
94
|
+
|
|
95
|
+
/** The signed-in EmDash user id, or null. Set by emdash-better-auth as session "user" = { id }. */
|
|
96
|
+
async function sessionUserId(ctx: Ctx): Promise<string | null> {
|
|
97
|
+
try {
|
|
98
|
+
const u = (await ctx.session?.get("user")) as { id?: string } | undefined;
|
|
99
|
+
return u?.id ?? null;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const json = (body: unknown, status: number) =>
|
|
105
|
+
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", "cache-control": "no-store" } });
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `POST /api/auth/organization/create`: refuse before the org exists.
|
|
109
|
+
* Bad address, unverified email, or over a limit. Anonymous callers fall
|
|
110
|
+
* through so Better Auth answers 401 itself.
|
|
111
|
+
*/
|
|
112
|
+
/** Provision a just-created site, unless it lost a creation race (see `lostLimitRace`): then it is deleted unprovisioned. */
|
|
113
|
+
async function createOrRejectSite(sites: SitesConfig, org: NonNullable<ReturnType<typeof parseCreatedOrg>>): Promise<void> {
|
|
114
|
+
const states = await readAllStates();
|
|
115
|
+
// Plan-aware limit: a subscriber's cap, not the static config cap, so an
|
|
116
|
+
// upgraded user is not stripped of a legitimately-allowed site by the race
|
|
117
|
+
// check. Falls back to the free tier when they have no subscription.
|
|
118
|
+
const raceLimits = org.ownerUserId
|
|
119
|
+
? await resolveLimits(org.ownerUserId, sites.limits)
|
|
120
|
+
: effectiveLimits(sites.limits);
|
|
121
|
+
if (lostLimitRace(org.slug, await readOrgs(), new Set([...states].filter(([, v]) => v.state === "deleted").map(([k]) => k)), raceLimits)) {
|
|
122
|
+
log("create.race_lost", { site: org.slug, user: org.ownerUserId });
|
|
123
|
+
await writeSiteState(org.slug, { state: "deleted", at: new Date().toISOString(), reason: "over the site limit (parallel requests)" });
|
|
124
|
+
await purgeSiteCache(org.slug);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
await createSiteForOrg(org, planFor(sites, { theme: org.theme }));
|
|
128
|
+
}
|
|
129
|
+
const tooMany = () =>
|
|
130
|
+
new Response(JSON.stringify({ code: "RATE_LIMITED", message: "Too many requests. Try again in a minute." }), {
|
|
131
|
+
status: 429,
|
|
132
|
+
headers: { "content-type": "application/json", "cache-control": "no-store", "retry-after": "60" },
|
|
133
|
+
});
|
|
134
|
+
/** Sign-up: refuse throwaway-mail domains and brake bursts per client IP. Null = let it through.
|
|
135
|
+
*
|
|
136
|
+
* Turnstile is NOT verified here. Better Auth's own `captcha` plugin (emdash-better-auth auth.ts)
|
|
137
|
+
* verifies the `x-captcha-response` header against TURNSTILE_SECRET on the sign-up request. A
|
|
138
|
+
* Turnstile token is SINGLE-USE, so a second verifier here consumed the token first and made the
|
|
139
|
+
* plugin's verify fail with VERIFICATION_FAILED (and before that, reading the body — where the
|
|
140
|
+
* token never is — failed with TURNSTILE_MISSING). One verifier only: Better Auth's captcha plugin. */
|
|
141
|
+
async function guardSignup(ctx: Ctx, sites: SitesConfig): Promise<Response | null> {
|
|
142
|
+
const body = (await ctx.request.clone().json().catch(() => null)) as { email?: unknown } | null;
|
|
143
|
+
if (emailDomainBlocked(body?.email, sites.blockedEmailDomains)) {
|
|
144
|
+
log("signup.blocked_domain");
|
|
145
|
+
return json({ code: "EMAIL_DOMAIN_BLOCKED", message: "Please sign up with a permanent email address." }, 400);
|
|
146
|
+
}
|
|
147
|
+
if (await overLimit("SIGNUP_LIMITER", ctx.request.headers.get("cf-connecting-ip") ?? "unknown")) {
|
|
148
|
+
log("signup.rate_limited");
|
|
149
|
+
return tooMany();
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
async function guardOrgCreate(ctx: Ctx, sites: SitesConfig): Promise<Response | null> {
|
|
154
|
+
const { primary } = sites;
|
|
155
|
+
let body: { slug?: unknown; metadata?: unknown } | null = null;
|
|
156
|
+
try {
|
|
157
|
+
body = (await ctx.request.clone().json()) as { slug?: unknown; metadata?: unknown };
|
|
158
|
+
} catch {
|
|
159
|
+
return null; // not JSON: let Better Auth produce its own error
|
|
160
|
+
}
|
|
161
|
+
const addressProblem = slugProblem(body?.slug, primary);
|
|
162
|
+
if (addressProblem) return json({ code: "INVALID_SITE_ADDRESS", message: addressProblem }, 400);
|
|
163
|
+
|
|
164
|
+
const themeIssue = themeProblem(themeFromMetadata(body?.metadata) ?? undefined, sites.themes);
|
|
165
|
+
if (themeIssue && sites.themes) return json({ code: "INVALID_THEME", message: themeIssue }, 400);
|
|
166
|
+
|
|
167
|
+
const userId = await sessionUserId(ctx);
|
|
168
|
+
if (!userId) return null;
|
|
169
|
+
if (await overLimit("CREATE_LIMITER", userId)) {
|
|
170
|
+
log("create.rate_limited", { user: userId });
|
|
171
|
+
return tooMany();
|
|
172
|
+
}
|
|
173
|
+
// OP-13: Per-IP site creation cap (prevents one IP from creating many sites via different accounts)
|
|
174
|
+
const clientIp = ctx.request.headers.get("cf-connecting-ip") ?? "unknown";
|
|
175
|
+
if (await overLimit("CREATE_IP_LIMITER", clientIp)) {
|
|
176
|
+
log("create.ip_rate_limited", { ip: clientIp });
|
|
177
|
+
return tooMany();
|
|
178
|
+
}
|
|
179
|
+
const orgs = await readOrgs();
|
|
180
|
+
const states = await readAllStates();
|
|
181
|
+
// Plan-aware limit (OP-11): a subscriber gets their plan's cap, everyone else
|
|
182
|
+
// the free tier. Replaces the former static `effectiveLimits(sites.limits)`.
|
|
183
|
+
const limits = await resolveLimits(userId, sites.limits);
|
|
184
|
+
const problem = createProblem(
|
|
185
|
+
{
|
|
186
|
+
userId,
|
|
187
|
+
emailVerified: await isEmailVerified(userId),
|
|
188
|
+
owned: orgs.filter((o) => o.ownerUserId === userId && states.get(o.slug)?.state !== "deleted").length,
|
|
189
|
+
total: orgs.filter((o) => states.get(o.slug)?.state !== "deleted").length,
|
|
190
|
+
},
|
|
191
|
+
limits,
|
|
192
|
+
);
|
|
193
|
+
return problem ? json({ code: problem.code, message: problem.message }, problem.status) : null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Hand the request to EmDash, rewritten to the site's theme subtree when the
|
|
198
|
+
* path is a public page. With no themes configured nothing is rewritten and
|
|
199
|
+
* the host's own pages serve every site.
|
|
200
|
+
*
|
|
201
|
+
* Direct requests to the subtree (`/theme-routes/...`) are refused: they would
|
|
202
|
+
* skip site selection and show one theme's pages on another site's host.
|
|
203
|
+
*/
|
|
204
|
+
async function themedNext(ctx: Ctx, sites: SitesConfig, site: string, next: Next, opts: { authLinks?: boolean } = {}): Promise<Response> {
|
|
205
|
+
if (!sites.themes) return next();
|
|
206
|
+
const { pathname } = ctx.url;
|
|
207
|
+
if (isThemeRouteRequest(pathname)) return new Response("Not found", { status: 404 });
|
|
208
|
+
if ((ctx.request.method !== "GET" && ctx.request.method !== "HEAD") || !isThemed(pathname)) return next();
|
|
209
|
+
await warm();
|
|
210
|
+
const theme = effectiveTheme(await readSiteTheme(site, ctx.request.headers.has(EDGE_REQUEST_HEADER)), sites.themes, sites.defaultTheme);
|
|
211
|
+
if (sites.authLinks && opts.authLinks !== false) {
|
|
212
|
+
// The control plane is always open for sign-up; a customer site follows its own setting.
|
|
213
|
+
const primary = site === sites.primary;
|
|
214
|
+
(ctx.locals ??= {}).multisite = { signup: primary || (await signUpOpenCached(site)), account: primary ? "/account/sites" : "/account" };
|
|
215
|
+
}
|
|
216
|
+
return next(themeRoute(theme, pathname) + ctx.url.search);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Workers global; absent on Node, where the demo simply has no strip.
|
|
220
|
+
declare const HTMLRewriter: (new () => { on(sel: string, h: object): { transform(r: Response): Response } }) | undefined;
|
|
221
|
+
|
|
222
|
+
/** A template site as a public demo: read-only, with a strip offering to use the theme. */
|
|
223
|
+
async function serveDemo(ctx: Ctx, sites: SitesConfig, site: string, next: Next): Promise<Response> {
|
|
224
|
+
const refused = demoGuard(ctx.request.method, ctx.url.pathname);
|
|
225
|
+
if (refused) return new Response(refused === 405 ? "Method not allowed" : "Not found", { status: refused, headers: { allow: "GET, HEAD" } });
|
|
226
|
+
const response = await themedNext(ctx, sites, site, next, { authLinks: false });
|
|
227
|
+
const theme = site.slice(TEMPLATE_PREFIX.length);
|
|
228
|
+
if (!sites.appUrl || !response.headers.get("content-type")?.includes("text/html") || typeof HTMLRewriter === "undefined") return response;
|
|
229
|
+
const banner = demoBannerHtml(sites.themes?.[theme]?.name ?? theme, `${sites.appUrl}/account/sites?new=1&theme=${encodeURIComponent(theme)}`);
|
|
230
|
+
return new HTMLRewriter().on("body", { element: (el: { append(c: string, o: { html: boolean }): void }) => el.append(banner, { html: true }) }).transform(response);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** What visitors of a suspended or deleted site see. A deleted site is indistinguishable from one that never existed. */
|
|
234
|
+
function unavailable(state: SiteState): Response {
|
|
235
|
+
return state.state === "deleted"
|
|
236
|
+
? html("<!doctype html><meta charset=utf-8><title>Not found</title><h1>Not found</h1>", 404)
|
|
237
|
+
: html("<!doctype html><meta charset=utf-8><title>Site unavailable</title><h1>This site is unavailable</h1><p>It has been suspended. If this is your site, contact support.</p>", 503);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const HANDOFF_PATH = "/_multisite/handoff";
|
|
241
|
+
const noStore = { "cache-control": "no-store", "referrer-policy": "no-referrer" };
|
|
242
|
+
const redirectTo = (location: string) => new Response(null, { status: 302, headers: { location, ...noStore } });
|
|
243
|
+
const handoffFailed = () =>
|
|
244
|
+
html("<!doctype html><meta charset=utf-8><title>Link expired</title><p>This sign-in link has expired or was already used. Go back and open the admin again.</p>", 403);
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Tenant side of "Open admin": check the signed link the control plane made,
|
|
248
|
+
* accept it once, start the user's session on this site and go to the admin.
|
|
249
|
+
*/
|
|
250
|
+
async function acceptHandoff(ctx: Ctx, site: string): Promise<Response> {
|
|
251
|
+
const token = ctx.url.searchParams.get("t") ?? "";
|
|
252
|
+
await warm();
|
|
253
|
+
const claims = await verifyHandoff(await handoffSecret(), token, site);
|
|
254
|
+
if (!claims) return handoffFailed();
|
|
255
|
+
if (!(await siteHasUser(site, claims.uid))) return handoffFailed();
|
|
256
|
+
if (!(await consumeHandoffNonce(site, claims.nonce, claims.exp))) return handoffFailed();
|
|
257
|
+
ctx.session?.set("user", { id: claims.uid });
|
|
258
|
+
return redirectTo(safeNext(ctx.url.searchParams.get("next")));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Entry point. Labels successful GETs with their site when the cached entrypoint asked for it (see edge-cache.ts), and purges
|
|
263
|
+
* the site's cached pages after an admin write that changes a public page but has no plugin hook (menus, settings, ...).
|
|
264
|
+
*/
|
|
265
|
+
export async function onRequest(context: Ctx, next: Next): Promise<Response> {
|
|
266
|
+
const { method } = context.request;
|
|
267
|
+
const edit = classifyEdit(method, context.url.pathname);
|
|
268
|
+
// The body can be read only once: peek at a clone before the route consumes it. Only content saves need it.
|
|
269
|
+
const body = edit && edit !== "always" ? await context.request.clone().json().catch(() => null) : null;
|
|
270
|
+
const response = await handle(context, next);
|
|
271
|
+
if (edit && response.status >= 200 && response.status < 300 && touchesPublicPage(edit, body)) {
|
|
272
|
+
const sites = sitesConfig();
|
|
273
|
+
if (sites) {
|
|
274
|
+
const site = resolveSite(context.url.hostname, sites);
|
|
275
|
+
after(async () => void (await purgeSiteCache(site)));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (!context.request.headers.has(EDGE_REQUEST_HEADER)) return response;
|
|
279
|
+
const sites = sitesConfig();
|
|
280
|
+
return sites ? tagSite(response, method, resolveSite(context.url.hostname, sites)) : response;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function handle(context: Ctx, next: Next): Promise<Response> {
|
|
284
|
+
// Renders that fill the edge cache must not trust per-isolate memos of site existence / state / theme (see registry.ts).
|
|
285
|
+
const fresh = context.request.headers.has(EDGE_REQUEST_HEADER);
|
|
286
|
+
const sites = sitesConfig();
|
|
287
|
+
if (!sites) return next();
|
|
288
|
+
const state = scopeState();
|
|
289
|
+
state.primary = sites.primary;
|
|
290
|
+
const site = resolveSite(context.url.hostname, sites);
|
|
291
|
+
|
|
292
|
+
return runAsSite(site, async () => {
|
|
293
|
+
if (fresh) forgetSiteSettings();
|
|
294
|
+
if (site !== sites.primary) {
|
|
295
|
+
await warm();
|
|
296
|
+
// Never start the runtime of a site that does not exist: it would create the site's tables.
|
|
297
|
+
if (!(await siteExists(site, fresh))) return html("<!doctype html><meta charset=utf-8><title>Not found</title><h1>Not found</h1>", 404);
|
|
298
|
+
const blocked = await readSiteState(site, fresh);
|
|
299
|
+
if (blocked) return unavailable(blocked);
|
|
300
|
+
if (isTemplateId(site)) return serveDemo(context, sites, site, next);
|
|
301
|
+
if (context.request.method === "GET" && context.url.pathname === HANDOFF_PATH) return acceptHandoff(context, site);
|
|
302
|
+
return themedNext(context, sites, site, next);
|
|
303
|
+
}
|
|
304
|
+
const { pathname } = context.url;
|
|
305
|
+
const { method } = context.request;
|
|
306
|
+
if (pathname.startsWith("/api/multisite/") || pathname === FLEET_PAGE || pathname === ORG_CREATE_PATH || pathname.startsWith("/_multisite/") || /^\/sites\/[^/]+\/open-admin$/.test(pathname)) await warm();
|
|
307
|
+
|
|
308
|
+
// Organization deletes would orphan the site's tables; deleting goes through the state route.
|
|
309
|
+
if (method === "POST" && pathname === "/api/auth/organization/delete") {
|
|
310
|
+
return json({ code: "USE_DELETE_SITE", message: "Delete the site from My sites instead." }, 403);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Suspend / resume (staff) and delete / restore: the site's operational state.
|
|
314
|
+
const stateApi = /^\/api\/multisite\/sites\/([a-z0-9-]+)\/state$/.exec(pathname);
|
|
315
|
+
if (method === "POST" && stateApi) {
|
|
316
|
+
if (!csrfOk(context.request.headers, context.url.origin)) return forbidden();
|
|
317
|
+
const slug = stateApi[1]!;
|
|
318
|
+
const userId = await sessionUserId(context);
|
|
319
|
+
if (!userId) return json({ code: "UNAUTHORIZED" }, 401);
|
|
320
|
+
let body: { action?: unknown } | null = null;
|
|
321
|
+
try {
|
|
322
|
+
body = (await context.request.json()) as { action?: unknown };
|
|
323
|
+
} catch {
|
|
324
|
+
return json({ code: "BAD_REQUEST" }, 400);
|
|
325
|
+
}
|
|
326
|
+
const action = body?.action as StateAction;
|
|
327
|
+
if (!["suspend", "resume", "delete", "restore"].includes(action)) return json({ code: "BAD_REQUEST" }, 400);
|
|
328
|
+
const org = (await readOrgs()).find((o) => o.slug === slug);
|
|
329
|
+
if (!org || slug === sites.primary) return json({ code: "NOT_FOUND" }, 404);
|
|
330
|
+
const staff = await isStaff(userId);
|
|
331
|
+
const owner = org.ownerUserId === userId;
|
|
332
|
+
if (!staff && !owner) return json({ code: "NOT_FOUND" }, 404);
|
|
333
|
+
const result = transition(await readSiteState(slug), action, { staff, owner });
|
|
334
|
+
if (!result.ok) return json({ code: result.code }, result.status);
|
|
335
|
+
await writeSiteState(slug, result.next ? { state: result.next, at: new Date().toISOString(), reason: `${action} by ${userId}` } : null);
|
|
336
|
+
// the site's cached pages must go offline / change NOW, not at the TTL
|
|
337
|
+
await purgeSiteCache(slug);
|
|
338
|
+
return json({ state: result.next ?? "active" }, 200);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Purge a deleted site for good (staff). Normally the cron does this after the grace period.
|
|
342
|
+
const purgeApi = /^\/api\/multisite\/sites\/([a-z0-9-]+)\/purge$/.exec(pathname);
|
|
343
|
+
if (method === "POST" && purgeApi) {
|
|
344
|
+
if (!csrfOk(context.request.headers, context.url.origin)) return forbidden();
|
|
345
|
+
const slug = purgeApi[1]!;
|
|
346
|
+
if (!(await isStaff(await sessionUserId(context)))) return json({ code: "FORBIDDEN" }, 403);
|
|
347
|
+
const current = await readSiteState(slug);
|
|
348
|
+
if (current?.state !== "deleted" || slug === sites.primary) return json({ code: "INVALID_STATE" }, 409);
|
|
349
|
+
const body = (await context.request.json().catch(() => null)) as { force?: unknown } | null;
|
|
350
|
+
const graceDays = sites.deleteGraceDays ?? DEFAULT_DELETE_GRACE_DAYS;
|
|
351
|
+
if (body?.force !== true && !isPurgeDue(current, graceDays, Date.now())) {
|
|
352
|
+
return json({ code: "NOT_DUE", message: `Deleted sites are kept ${graceDays} days.` }, 409);
|
|
353
|
+
}
|
|
354
|
+
after(async () => {
|
|
355
|
+
try {
|
|
356
|
+
await runAsSite(site, () => purgeSite(slug));
|
|
357
|
+
} catch (error) {
|
|
358
|
+
console.error(`[multisite] purging "${slug}" failed:`, error);
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
return json({ purging: true }, 202);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Appearance: the owner's theme picker for one site.
|
|
365
|
+
const appearancePage = /^\/sites\/([a-z0-9-]+)\/appearance$/.exec(pathname);
|
|
366
|
+
if (method === "GET" && appearancePage) {
|
|
367
|
+
if (!(await sessionUserId(context))) {
|
|
368
|
+
return Response.redirect(new URL(`/login?redirect=${encodeURIComponent(pathname)}`, context.url), 302);
|
|
369
|
+
}
|
|
370
|
+
// With the account tab, theme switching lives there (one home for it); the standalone page is for hosts without the tab.
|
|
371
|
+
if (sites.accountTab) return redirectTo(`/account/sites?appearance=${appearancePage[1]}`);
|
|
372
|
+
return html(consolePageHtml("appearance", appearancePage[1]), 200);
|
|
373
|
+
}
|
|
374
|
+
const appearanceApi = /^\/api\/multisite\/sites\/([a-z0-9-]+)\/(appearance|theme)$/.exec(pathname);
|
|
375
|
+
if (appearanceApi) {
|
|
376
|
+
const [, slug, kind] = appearanceApi as unknown as [string, string, string];
|
|
377
|
+
const userId = await sessionUserId(context);
|
|
378
|
+
if (!userId) return json({ code: "UNAUTHORIZED" }, 401);
|
|
379
|
+
if (!(await canManage(userId, slug, sites.primary))) return json({ code: "FORBIDDEN" }, 403);
|
|
380
|
+
if (method === "GET" && kind === "appearance") {
|
|
381
|
+
const state = await appearanceState(slug, sites);
|
|
382
|
+
return state ? json(state, 200) : json({ code: "NOT_FOUND" }, 404);
|
|
383
|
+
}
|
|
384
|
+
if (method === "POST" && kind === "theme") {
|
|
385
|
+
if (!csrfOk(context.request.headers, context.url.origin)) return forbidden();
|
|
386
|
+
let body: { theme?: unknown; includeContent?: unknown } | null = null;
|
|
387
|
+
try {
|
|
388
|
+
body = (await context.request.json()) as { theme?: unknown; includeContent?: unknown };
|
|
389
|
+
} catch {
|
|
390
|
+
return json({ code: "BAD_REQUEST" }, 400);
|
|
391
|
+
}
|
|
392
|
+
const issue = themeProblem(body?.theme, sites.themes);
|
|
393
|
+
if (issue || typeof body?.theme !== "string") return json({ code: "INVALID_THEME", message: issue ?? "Choose a theme." }, 400);
|
|
394
|
+
const state = await appearanceState(slug, sites);
|
|
395
|
+
if (!state) return json({ code: "NOT_FOUND" }, 404);
|
|
396
|
+
// Re-applying the current theme is allowed: it is additive and repairs a site
|
|
397
|
+
// that is missing the theme's collections (for example after a seed change).
|
|
398
|
+
if (slug !== sites.primary && (await readStatus(slug))?.status !== "ready") return json({ code: "NOT_READY" }, 409);
|
|
399
|
+
if (await readSiteState(slug)) return json({ code: "NOT_ACTIVE" }, 409);
|
|
400
|
+
const theme = body.theme;
|
|
401
|
+
const includeContent = body.includeContent === true;
|
|
402
|
+
after(async () => {
|
|
403
|
+
try {
|
|
404
|
+
await runAsSite(site, () => switchTheme(sites, slug, theme, { includeContent }));
|
|
405
|
+
await purgeSiteCache(slug);
|
|
406
|
+
} catch (error) {
|
|
407
|
+
console.error(`[multisite] switching "${slug}" to "${theme}" failed:`, error);
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
return json({ switching: true, to: theme }, 202);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (method === "GET" && pathname.startsWith(STATUS_PREFIX) && !pathname.slice(STATUS_PREFIX.length).includes("/")) {
|
|
415
|
+
const slug = pathname.slice(STATUS_PREFIX.length);
|
|
416
|
+
if (!isSiteId(slug)) return json({ code: "NOT_FOUND" }, 404);
|
|
417
|
+
const status = await readStatus(slug);
|
|
418
|
+
// Self-heal: whoever is watching a site whose run died restarts it, so a
|
|
419
|
+
// killed run costs about a minute instead of a stuck site. Signed-in callers
|
|
420
|
+
// only: the status URL is otherwise public.
|
|
421
|
+
if (status?.status === "provisioning" && needsReconcile(status, Date.now()) && (await sessionUserId(context))) {
|
|
422
|
+
after(async () => void (await runAsSite(site, () => reconcile(sites, slug))));
|
|
423
|
+
}
|
|
424
|
+
return status ? json(status, 200) : json({ code: "NOT_FOUND" }, 404);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Sign-in / sign-up land on the console, not the marketing home.
|
|
428
|
+
if (method === "GET") {
|
|
429
|
+
const entry = loginEntryRedirect(
|
|
430
|
+
context.url,
|
|
431
|
+
(await sessionUserId(context)) !== null,
|
|
432
|
+
sites.postLoginPath ?? (sites.accountTab ? "/account/sites" : DEFAULT_POST_LOGIN_PATH),
|
|
433
|
+
);
|
|
434
|
+
if (entry) return Response.redirect(new URL(entry, context.url), 302);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// OP-27: Legal pages on the control plane (no tracking, no scripts, plain HTML)
|
|
438
|
+
const legalPages: Record<string, string> = {
|
|
439
|
+
"/terms": "Terms of Service",
|
|
440
|
+
"/privacy": "Privacy Policy",
|
|
441
|
+
"/dpa": "Data Processing Agreement",
|
|
442
|
+
"/support": "Support",
|
|
443
|
+
};
|
|
444
|
+
if (method === "GET" && pathname in legalPages) {
|
|
445
|
+
const title = legalPages[pathname];
|
|
446
|
+
// TODO: Replace with real legal copy before launch
|
|
447
|
+
const content = `<!doctype html>
|
|
448
|
+
<meta charset=utf-8>
|
|
449
|
+
<title>${title}</title>
|
|
450
|
+
<style>
|
|
451
|
+
body{font-family:system-ui,sans-serif;max-width:720px;margin:0 auto;padding:2rem;line-height:1.6}
|
|
452
|
+
h1{margin-bottom:1rem}
|
|
453
|
+
.notice{background:#fef3cd;padding:1rem;border-radius:4px;margin-bottom:2rem}
|
|
454
|
+
</style>
|
|
455
|
+
<div class="notice"><strong>TODO:</strong> Replace this placeholder with real legal text before launch.</div>
|
|
456
|
+
<h1>${title}</h1>
|
|
457
|
+
<p>This is a placeholder for the ${title}. Replace with real legal copy.</p>
|
|
458
|
+
<p><a href="/">← Back to home</a></p>`;
|
|
459
|
+
return html(content, 200);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// "Open admin": sign a one-time link for the user and send them to the tenant.
|
|
463
|
+
const openAdmin = /^\/sites\/([a-z0-9-]+)\/open-admin$/.exec(pathname);
|
|
464
|
+
if (method === "GET" && openAdmin) {
|
|
465
|
+
const slug = openAdmin[1]!;
|
|
466
|
+
const userId = await sessionUserId(context);
|
|
467
|
+
if (!userId) return redirectTo(`/login?redirect=${encodeURIComponent(pathname)}`);
|
|
468
|
+
const base = siteUrl(slug, sites);
|
|
469
|
+
if (!base || !(await canManage(userId, slug, sites.primary)) || slug === sites.primary || (await readSiteState(slug))) {
|
|
470
|
+
return html("Not found", 404);
|
|
471
|
+
}
|
|
472
|
+
const token = await signHandoff(await handoffSecret(), { uid: userId, site: slug });
|
|
473
|
+
return redirectTo(`${base}${HANDOFF_PATH}?t=${encodeURIComponent(token)}&next=${encodeURIComponent(safeNext(context.url.searchParams.get("next")))}`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Customer console. Pages need a session (shell only, no data); the JSON
|
|
477
|
+
// is separately gated by the same session.
|
|
478
|
+
if (method === "GET" && sites.accountTab && (pathname === "/sites" || pathname === "/sites/new")) {
|
|
479
|
+
return redirectTo(pathname === "/sites" ? "/account/sites" : "/account/sites?new=1");
|
|
480
|
+
}
|
|
481
|
+
if (method === "GET" && (pathname === "/sites" || pathname === "/sites/new")) {
|
|
482
|
+
if (!(await sessionUserId(context))) {
|
|
483
|
+
return Response.redirect(new URL(`/login?redirect=${encodeURIComponent(pathname)}`, context.url), 302);
|
|
484
|
+
}
|
|
485
|
+
return html(consolePageHtml(pathname === "/sites" ? "list" : "new"), 200);
|
|
486
|
+
}
|
|
487
|
+
if (method === "GET" && pathname === "/api/multisite/me") {
|
|
488
|
+
const userId = await sessionUserId(context);
|
|
489
|
+
if (!userId) return json({ code: "UNAUTHORIZED" }, 401);
|
|
490
|
+
const state = await consoleState(userId, sites);
|
|
491
|
+
const stalled = state.sites.find((x) => x.status === "provisioning" && x.canRetry);
|
|
492
|
+
if (stalled) after(async () => void (await runAsSite(site, () => reconcile(sites, stalled.slug))));
|
|
493
|
+
return json(state, 200);
|
|
494
|
+
}
|
|
495
|
+
// Themes a new site can pick (no seeds: those stay on the server).
|
|
496
|
+
if (method === "GET" && pathname === "/api/multisite/themes") {
|
|
497
|
+
if (!(await sessionUserId(context))) return json({ code: "UNAUTHORIZED" }, 401);
|
|
498
|
+
const entries = Object.entries(sites.themes ?? {});
|
|
499
|
+
const fallback = effectiveTheme(null, sites.themes, sites.defaultTheme);
|
|
500
|
+
const demos = await demoUrls(sites);
|
|
501
|
+
return json(
|
|
502
|
+
{ default: fallback, themes: entries.map(([id, t]) => ({ id, name: t.name, description: t.description ?? "", pitch: t.pitch, preview: t.preview, checklist: t.checklist ?? [], ...(demos[id] ? { demoUrl: demos[id] } : {}) })) },
|
|
503
|
+
200,
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
if (method === "GET" && pathname === "/api/multisite/slug-check") {
|
|
507
|
+
if (!(await sessionUserId(context))) return json({ code: "UNAUTHORIZED" }, 401);
|
|
508
|
+
const checked = await checkAddress(context.url.searchParams.get("slug"), sites.primary);
|
|
509
|
+
return json(checked.available ? { ...checked, url: siteUrl(String(context.url.searchParams.get("slug")).toLowerCase(), sites) } : checked, 200);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Retry one site's provisioning: its owner or staff. Idempotent, 202 = started.
|
|
513
|
+
if (method === "POST" && pathname.startsWith(STATUS_PREFIX) && pathname.endsWith("/retry")) {
|
|
514
|
+
if (!csrfOk(context.request.headers, context.url.origin)) return forbidden();
|
|
515
|
+
const slug = pathname.slice(STATUS_PREFIX.length, -"/retry".length);
|
|
516
|
+
if (!isSiteId(slug)) return json({ code: "NOT_FOUND" }, 404);
|
|
517
|
+
const userId = await sessionUserId(context);
|
|
518
|
+
if (!userId) return json({ code: "UNAUTHORIZED" }, 401);
|
|
519
|
+
const org = (await readOrgs()).find((o) => o.slug === slug);
|
|
520
|
+
if (!org) return json({ code: "NOT_FOUND" }, 404);
|
|
521
|
+
if (org.ownerUserId !== userId && !(await isStaff(userId))) return json({ code: "FORBIDDEN" }, 403);
|
|
522
|
+
if ((await pendingReconcile(slug)).length === 0) return json({ code: "NOT_NEEDED" }, 409);
|
|
523
|
+
after(async () => void (await runAsSite(site, () => reconcile(sites, slug))));
|
|
524
|
+
return json({ retrying: true }, 202);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Staff: re-run the next stuck site (failed, or provisioning that died). One per call; call again for the next.
|
|
528
|
+
if (method === "POST" && pathname === RECONCILE_API) {
|
|
529
|
+
if (!csrfOk(context.request.headers, context.url.origin)) return forbidden();
|
|
530
|
+
if (!(await isStaff(await sessionUserId(context)))) return json({ code: "FORBIDDEN" }, 403);
|
|
531
|
+
const slugs = await pendingReconcile();
|
|
532
|
+
if (slugs.length) after(async () => void (await runAsSite(site, () => reconcile(sites))));
|
|
533
|
+
return json({ pending: slugs, started: slugs[0] ?? null }, 202);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Staff: back up, list backups of, and restore one site (see backup.ts). Run inside the request.
|
|
537
|
+
const backupApi = /^\/api\/multisite\/sites\/([a-z0-9-]+)\/(backup|backups|restore)$/.exec(pathname);
|
|
538
|
+
if (backupApi && ((method === "GET" && backupApi[2] === "backups") || (method === "POST" && backupApi[2] !== "backups"))) {
|
|
539
|
+
if (method === "POST" && !csrfOk(context.request.headers, context.url.origin)) return forbidden();
|
|
540
|
+
if (!(await isStaff(await sessionUserId(context)))) return json({ code: "FORBIDDEN" }, 403);
|
|
541
|
+
const slug = backupApi[1]!;
|
|
542
|
+
if (slug === sites.primary || !(await siteHasTables(slug))) return json({ code: "NOT_FOUND" }, 404);
|
|
543
|
+
return runAsSite(site, async () => {
|
|
544
|
+
if (backupApi[2] === "backups") return json({ backups: await listBackups(slug) }, 200);
|
|
545
|
+
if (backupApi[2] === "backup") return json(await backupSite(slug), 201);
|
|
546
|
+
const body = (await context.request.json().catch(() => null)) as { key?: unknown; replace?: unknown } | null;
|
|
547
|
+
if (typeof body?.key !== "string") return json({ code: "BAD_REQUEST" }, 400);
|
|
548
|
+
const result = await restoreSite(slug, body.key, { replace: body.replace === true });
|
|
549
|
+
const status = { restored: 200, "not-found": 404, "site-has-tables": 409, "bad-backup": 422 }[result];
|
|
550
|
+
return json({ result }, status);
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
// Staff: build the template site of ONE theme (`{ theme }`; `rebuild: true` replaces an existing one). Runs inside the request, not in the
|
|
554
|
+
// background: it takes ~70 s, longer than the ~30 s a background task gets. New sites of that theme
|
|
555
|
+
// are then cloned from it (seconds) instead of migrated and seeded.
|
|
556
|
+
if (method === "POST" && pathname === TEMPLATES_API) {
|
|
557
|
+
if (!csrfOk(context.request.headers, context.url.origin)) return forbidden();
|
|
558
|
+
if (!(await isStaff(await sessionUserId(context)))) return json({ code: "FORBIDDEN" }, 403);
|
|
559
|
+
const body = (await context.request.json().catch(() => null)) as { theme?: unknown; rebuild?: unknown } | null;
|
|
560
|
+
const theme = typeof body?.theme === "string" ? body.theme : "";
|
|
561
|
+
const started = Date.now();
|
|
562
|
+
const result = await runAsSite(site, () => buildTemplate(sites, theme, { rebuild: body?.rebuild === true }));
|
|
563
|
+
return json({ theme, result, ms: Date.now() - started }, result === "unknown-theme" ? 400 : 200);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Fleet view — staff only (role 50 on the control plane). The keys to the
|
|
567
|
+
// whole fleet, so the gate is the point: never reachable by a tenant admin
|
|
568
|
+
// or an anonymous visitor.
|
|
569
|
+
if (method === "GET" && pathname === FLEET_API) {
|
|
570
|
+
if (!(await isStaff(await sessionUserId(context)))) return json({ code: "FORBIDDEN" }, 403);
|
|
571
|
+
return json({ sites: await listSites() }, 200);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// The old fleet page is now the control plane's native admin page (plugin "Site"). Keep the
|
|
575
|
+
// address working: staff go there, everyone else signs in first.
|
|
576
|
+
if (method === "GET" && pathname === FLEET_PAGE) {
|
|
577
|
+
if (!(await isStaff(await sessionUserId(context)))) {
|
|
578
|
+
return Response.redirect(new URL(`/login?redirect=${FLEET_PAGE}`, context.url), 302);
|
|
579
|
+
}
|
|
580
|
+
return redirectTo(FLEET_NATIVE_PAGE);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// A password change or reset on the control plane reaches the owner's sites too (see passwords.ts).
|
|
584
|
+
if (method === "POST" && PASSWORD_PATHS.has(pathname)) {
|
|
585
|
+
const response = await next();
|
|
586
|
+
if (response.status === 200) after(() => runAsSite(site, () => syncRecentPasswords(120_000)).then(() => undefined));
|
|
587
|
+
return response;
|
|
588
|
+
}
|
|
589
|
+
if (method === "POST" && pathname === SIGNUP_PATH) {
|
|
590
|
+
const rejected = await guardSignup(context, sites);
|
|
591
|
+
if (rejected) return rejected;
|
|
592
|
+
}
|
|
593
|
+
if (method === "POST" && pathname === ORG_CREATE_PATH) {
|
|
594
|
+
const rejected = await guardOrgCreate(context, sites);
|
|
595
|
+
if (rejected) return rejected;
|
|
596
|
+
const response = await next();
|
|
597
|
+
if (response.status === 200) {
|
|
598
|
+
const org = parseCreatedOrg(await response.clone().json().catch(() => null));
|
|
599
|
+
// Provisioning outlives the response: `after` uses the Worker's waitUntil.
|
|
600
|
+
if (org) after(() => runAsSite(site, () => createOrRejectSite(sites, org)));
|
|
601
|
+
}
|
|
602
|
+
return response;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return themedNext(context, sites, site, next);
|
|
606
|
+
});
|
|
607
|
+
}
|