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/dialect.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import type {
|
|
3
|
+
CompiledQuery,
|
|
4
|
+
DatabaseConnection,
|
|
5
|
+
Dialect,
|
|
6
|
+
Driver,
|
|
7
|
+
QueryResult,
|
|
8
|
+
TransactionSettings,
|
|
9
|
+
} from "kysely";
|
|
10
|
+
import { rewriteParams, rewriteSql, suffixFor, translateRows } from "./rewrite.ts";
|
|
11
|
+
|
|
12
|
+
// On globalThis so duplicated bundle chunks share one store.
|
|
13
|
+
const SITE_STORE = Symbol.for("emdash-multisite:site-store");
|
|
14
|
+
const siteStore = ((globalThis as Record<symbol, unknown>)[SITE_STORE] ??= new AsyncLocalStorage<string>()) as AsyncLocalStorage<string>;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Run `fn` as `site`. Used by provisioning, cron and anything else that runs
|
|
18
|
+
* outside a request (requests get their site baked into a per-site Kysely).
|
|
19
|
+
*/
|
|
20
|
+
export const runAsSite = <T>(site: string, fn: () => T): T => siteStore.run(site, fn);
|
|
21
|
+
export const currentSite = (): string | undefined => siteStore.getStore();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Wrap `inner` so every query is rewritten for the site `getSite()` returns.
|
|
25
|
+
*
|
|
26
|
+
* All wrapped dialects built from the same `inner` share ONE inner driver:
|
|
27
|
+
* SQLite has a single connection, and its mutex lives in the driver, so
|
|
28
|
+
* per-site drivers would let one site's transaction swallow another's queries.
|
|
29
|
+
*/
|
|
30
|
+
export function siteDialect(inner: Dialect, getSite: () => string, primary: string): Dialect {
|
|
31
|
+
const shared = sharedDriver(inner);
|
|
32
|
+
return {
|
|
33
|
+
createAdapter: () => scopeBatch(inner.createAdapter(), getSite, primary),
|
|
34
|
+
createQueryCompiler: () => inner.createQueryCompiler(),
|
|
35
|
+
createIntrospector: (db) => inner.createIntrospector(db),
|
|
36
|
+
createDriver: (): Driver => ({
|
|
37
|
+
init: () => shared.init(),
|
|
38
|
+
// ponytail: no-op; the shared inner driver outlives any one site's Kysely.
|
|
39
|
+
destroy: async () => {},
|
|
40
|
+
acquireConnection: async () => scoped(await shared.driver.acquireConnection(), getSite, primary),
|
|
41
|
+
beginTransaction: (c: DatabaseConnection, s: TransactionSettings) =>
|
|
42
|
+
shared.driver.beginTransaction(unscoped(c), s),
|
|
43
|
+
commitTransaction: (c) => shared.driver.commitTransaction(unscoped(c)),
|
|
44
|
+
rollbackTransaction: (c) => shared.driver.rollbackTransaction(unscoped(c)),
|
|
45
|
+
releaseConnection: (c) => shared.driver.releaseConnection(unscoped(c)),
|
|
46
|
+
}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* D1 has no transactions, so EmDash calls `adapter.executeAtomicBatch(queries)`
|
|
52
|
+
* (content writes, block types, ...). That path never touches a connection, so
|
|
53
|
+
* the per-connection rewrite above is skipped and the writes land in the
|
|
54
|
+
* PRIMARY site's tables. Wrap it with the same rewrite.
|
|
55
|
+
*/
|
|
56
|
+
function scopeBatch<A extends object>(adapter: A, getSite: () => string, primary: string): A {
|
|
57
|
+
const batch = (adapter as { executeAtomicBatch?: (qs: CompiledQuery[]) => Promise<QueryResult<unknown>[]> })
|
|
58
|
+
.executeAtomicBatch;
|
|
59
|
+
if (typeof batch !== "function") return adapter;
|
|
60
|
+
(adapter as { executeAtomicBatch: typeof batch }).executeAtomicBatch = async (qs) => {
|
|
61
|
+
const suffix = suffixFor(getSite(), primary);
|
|
62
|
+
if (!suffix) return batch.call(adapter, qs);
|
|
63
|
+
const results = await batch.call(
|
|
64
|
+
adapter,
|
|
65
|
+
qs.map((q) => ({ ...q, sql: rewriteSql(q.sql, suffix), parameters: rewriteParams(q.sql, q.parameters, suffix) })),
|
|
66
|
+
);
|
|
67
|
+
return results.map((r, i) => ({ ...r, rows: translateRows(qs[i]!.sql, r.rows as never, suffix) }));
|
|
68
|
+
};
|
|
69
|
+
return adapter;
|
|
70
|
+
}
|
|
71
|
+
const sharedByInner = new WeakMap<Dialect, { driver: Driver; init: () => Promise<void> }>();
|
|
72
|
+
function sharedDriver(inner: Dialect) {
|
|
73
|
+
let s = sharedByInner.get(inner);
|
|
74
|
+
if (!s) {
|
|
75
|
+
const driver = inner.createDriver();
|
|
76
|
+
let started: Promise<void> | undefined;
|
|
77
|
+
s = { driver, init: () => (started ??= driver.init()) };
|
|
78
|
+
sharedByInner.set(inner, s);
|
|
79
|
+
}
|
|
80
|
+
return s;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const REAL = Symbol("realConnection");
|
|
84
|
+
type Scoped = DatabaseConnection & { [REAL]: DatabaseConnection };
|
|
85
|
+
const unscoped = (c: DatabaseConnection): DatabaseConnection => (c as Scoped)[REAL] ?? c;
|
|
86
|
+
|
|
87
|
+
function scoped(real: DatabaseConnection, getSite: () => string, primary: string): DatabaseConnection {
|
|
88
|
+
const conn: Scoped = {
|
|
89
|
+
[REAL]: real,
|
|
90
|
+
async executeQuery<R>(q: CompiledQuery): Promise<QueryResult<R>> {
|
|
91
|
+
const suffix = suffixFor(getSite(), primary);
|
|
92
|
+
if (!suffix) return real.executeQuery<R>(q);
|
|
93
|
+
const result = await real.executeQuery<R>({
|
|
94
|
+
...q,
|
|
95
|
+
sql: rewriteSql(q.sql, suffix),
|
|
96
|
+
parameters: rewriteParams(q.sql, q.parameters, suffix),
|
|
97
|
+
});
|
|
98
|
+
return { ...result, rows: translateRows(q.sql, result.rows, suffix) };
|
|
99
|
+
},
|
|
100
|
+
streamQuery<R>(q: CompiledQuery, chunkSize: number) {
|
|
101
|
+
const suffix = suffixFor(getSite(), primary);
|
|
102
|
+
return real.streamQuery<R>(
|
|
103
|
+
suffix ? { ...q, sql: rewriteSql(q.sql, suffix), parameters: rewriteParams(q.sql, q.parameters, suffix) } : q,
|
|
104
|
+
chunkSize,
|
|
105
|
+
);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
return conn;
|
|
109
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-aware edge cache helpers. Design, evidence and the incident behind it:
|
|
3
|
+
* emdash-plan/product/hostmyemdash-edge-cache-design.md
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: Cloudflare Workers Cache keys entries by path + query (+ Worker version), NOT by hostname. One Worker serves
|
|
6
|
+
* every site, so the host must be put into the key by a gateway (ctx.props of a cached inner entrypoint). The host Worker
|
|
7
|
+
* (src/worker.ts) owns the two entrypoints; this module owns the rules, so they are testable under plain Node.
|
|
8
|
+
*
|
|
9
|
+
* Principles: default-deny (anything not provably public + anonymous + 200 is `no-store`), fail closed (a response that cannot
|
|
10
|
+
* be tagged to a site is never stored, because it could not be purged), and purge is best-effort but never throws.
|
|
11
|
+
*/
|
|
12
|
+
import { isThemed } from "./rules.ts";
|
|
13
|
+
|
|
14
|
+
/** Internal response header: the middleware tells the cache layer which site rendered the response. Stripped before storing. */
|
|
15
|
+
export const SITE_HEADER = "x-ms-site";
|
|
16
|
+
/** Internal REQUEST header set by the cached entrypoint: only then does the middleware label the response with its site. */
|
|
17
|
+
export const EDGE_REQUEST_HEADER = "x-ms-edge";
|
|
18
|
+
/** Name of the cached entrypoint exported by the host Worker. */
|
|
19
|
+
export const CACHE_ENTRYPOINT = "PublicCache";
|
|
20
|
+
export const siteTag = (site: string) => `site:${site}`;
|
|
21
|
+
|
|
22
|
+
// Core serves these per site (they are not themed) but they are public and safe to cache per host.
|
|
23
|
+
const PUBLIC_FILES = /^\/(?:llms\.txt|rss\.xml|robots\.txt|sitemap(?:-[^/]+)?\.xml)$/;
|
|
24
|
+
const CACHEABLE_TYPE = /^(?:text\/(?:html|plain|xml)|application\/(?:xml|rss\+xml))/;
|
|
25
|
+
|
|
26
|
+
/** Lowercase, drop the port and a trailing dot, so `Acme.com.:443` and `acme.com` share one entry. */
|
|
27
|
+
export function normalizeHost(h: string): string {
|
|
28
|
+
return h.toLowerCase().replace(/:\d+$/, "").replace(/\.$/, "");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Anonymous = no Authorization, and no cookies except the privacy plugin's consent cookie (`c15t*`). Anything else counts as signed in. */
|
|
32
|
+
export function isAnonymous(headers: Headers): boolean {
|
|
33
|
+
if (headers.has("authorization")) return false;
|
|
34
|
+
const cookie = headers.get("cookie");
|
|
35
|
+
if (!cookie) return true;
|
|
36
|
+
return cookie.split(";").every((c) => {
|
|
37
|
+
const name = c.trim().split("=")[0] ?? "";
|
|
38
|
+
return name === "" || name.startsWith("c15t");
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Request side (gateway): may this request be served from / stored in the shared cache? */
|
|
43
|
+
export function cacheableRequest(method: string, url: URL, headers: Headers): boolean {
|
|
44
|
+
if (method !== "GET" && method !== "HEAD") return false;
|
|
45
|
+
if (url.search !== "") return false;
|
|
46
|
+
if (!isAnonymous(headers)) return false;
|
|
47
|
+
return isThemed(url.pathname) || PUBLIC_FILES.test(url.pathname);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Response side (inner entrypoint): the single choke point. Store only a clean anonymous 200 of an allowed type that the
|
|
52
|
+
* middleware tagged with its site. Everything else gets an explicit `no-store` (Workers Cache would otherwise cache a bare
|
|
53
|
+
* 404 for ~3 min and a bare 200 for ~2 h; verified in the spike). The internal site header is always removed.
|
|
54
|
+
*/
|
|
55
|
+
export function guardResponse(res: Response): Response {
|
|
56
|
+
const site = res.headers.get(SITE_HEADER);
|
|
57
|
+
const out = new Response(res.body, res);
|
|
58
|
+
out.headers.delete(SITE_HEADER);
|
|
59
|
+
const ok = res.status === 200 && !!site && !res.headers.has("set-cookie") && CACHEABLE_TYPE.test(res.headers.get("content-type") ?? "");
|
|
60
|
+
if (ok) {
|
|
61
|
+
// 1 h + 1 day stale-while-revalidate. Freshness comes from purging the site tag on every edit that changes a public page
|
|
62
|
+
// (hooks in plugin-entry.ts, mutation watcher in middleware.ts); the TTL is only the backstop if a purge is lost.
|
|
63
|
+
out.headers.set("cache-control", "public, max-age=3600, stale-while-revalidate=86400");
|
|
64
|
+
out.headers.set("cache-tag", siteTag(site));
|
|
65
|
+
} else {
|
|
66
|
+
out.headers.set("cache-control", "no-store");
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Middleware side: label a successful GET/HEAD response with its site so the cache layer can tag it. */
|
|
72
|
+
export function tagSite(res: Response, method: string, site: string): Response {
|
|
73
|
+
if (res.status !== 200 || (method !== "GET" && method !== "HEAD")) return res;
|
|
74
|
+
const out = new Response(res.body, res);
|
|
75
|
+
out.headers.set(SITE_HEADER, site);
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---- Which admin writes change a public page -----------------------------------------------------------------------------
|
|
80
|
+
// EmDash has plugin hooks for content and bylines only. Menus, widgets, settings, taxonomies, redirects, sections, media
|
|
81
|
+
// edits and imports have none, so the middleware watches the admin API instead: a 2xx answer to one of these writes means an
|
|
82
|
+
// authorized user changed something. Public routes (form submits, consent saves, ...) are deliberately not listed, otherwise
|
|
83
|
+
// any visitor could burn the shared purge budget.
|
|
84
|
+
const EDIT_API = "/_emdash/api/";
|
|
85
|
+
const ALWAYS_EDIT: RegExp[] = [
|
|
86
|
+
/^(?:menus|widget-areas|settings|sections|taxonomies|redirects|schema|import)(?:\/|$)/,
|
|
87
|
+
/^admin\/(?:transfer\/imports\/[^/]+\/execute|plugins\/[^/]+\/(?:settings|enable|disable|uninstall))$/,
|
|
88
|
+
/^content\/[^/]+\/[^/]+\/terms\//, // term assignment to an entry
|
|
89
|
+
/^revisions\/[^/]+\/restore$/, // collections without revisions write the live row
|
|
90
|
+
];
|
|
91
|
+
const MEDIA_EDIT = /^media\/[^/]+(?:\/replace)?$/; // PUT/PATCH/DELETE only: new uploads change nothing public until used
|
|
92
|
+
|
|
93
|
+
export type EditKind = "always" | "content-put" | "content-create";
|
|
94
|
+
/** Does this request look like an admin write that can change a public page? `content-*` kinds need the body (see touchesPublicPage). */
|
|
95
|
+
export function classifyEdit(method: string, pathname: string): EditKind | null {
|
|
96
|
+
if (method === "GET" || method === "HEAD" || method === "OPTIONS" || !pathname.startsWith(EDIT_API)) return null;
|
|
97
|
+
const rest = pathname.slice(EDIT_API.length).replace(/\/$/, "");
|
|
98
|
+
if (ALWAYS_EDIT.some((r) => r.test(rest))) return "always";
|
|
99
|
+
if (method !== "POST" && MEDIA_EDIT.test(rest)) return "always";
|
|
100
|
+
if ((method === "PUT" || method === "PATCH") && /^content\/[^/]+\/[^/]+$/.test(rest)) return "content-put";
|
|
101
|
+
if (method === "POST" && /^content\/[^/]+$/.test(rest)) return "content-create";
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Mirrors DRAFT_ONLY_UPDATE_KEYS in emdash core (emdash-runtime.ts): a save that sends only these stages a draft revision and
|
|
106
|
+
// leaves the live page unchanged (autosave). Any other key (status, bylines, seo, publishedAt, ...) writes live columns.
|
|
107
|
+
// A key core adds later makes us purge more often, never less.
|
|
108
|
+
const DRAFT_ONLY_KEYS = new Set(["data", "slug", "locale", "skipRevision", "references", "actor", "migrateBlocks", "replaceBlocks", "_rev"]);
|
|
109
|
+
export function touchesPublicPage(kind: EditKind, body: unknown): boolean {
|
|
110
|
+
if (kind === "always") return true;
|
|
111
|
+
if (typeof body !== "object" || body === null) return false;
|
|
112
|
+
const b = body as Record<string, unknown>;
|
|
113
|
+
if (kind === "content-create") return b.status === "published";
|
|
114
|
+
return Object.entries(b).some(([k, v]) => v !== undefined && !DRAFT_ONLY_KEYS.has(k));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Purge budget. Workers Cache purges always use Cloudflare's FREE-tier limits, whatever the plan: 5 requests per minute
|
|
119
|
+
* (bucket of 25), 100 tags per request, counted per ACCOUNT, so every tenant shares it. A rate-limited purge resolves
|
|
120
|
+
* `{ success: false }` instead of throwing, which is why every send is checked and retried. Ceiling: about two edit bursts
|
|
121
|
+
* per minute across the whole platform once the bucket is empty. Past that, switch to a per-site version in the cache key
|
|
122
|
+
* (no purge calls at all) or batch purges through one Durable Object. Do not call this per keystroke.
|
|
123
|
+
*/
|
|
124
|
+
const TRAIL_MS = 4000; // a render that began before the edit can finish after the first purge and re-store the old page
|
|
125
|
+
const RETRY_MS = 13000; // one token refills every 12 s
|
|
126
|
+
const MAX_TRIES = 3;
|
|
127
|
+
const MAX_TAGS = 100;
|
|
128
|
+
|
|
129
|
+
export interface PurgeEnv {
|
|
130
|
+
/** Purge these tags. Resolves false when the purge was not accepted (rate limit, error). Never throws. */
|
|
131
|
+
send(tags: string[]): Promise<boolean>;
|
|
132
|
+
sleep(ms: number): Promise<void>;
|
|
133
|
+
now(): number;
|
|
134
|
+
/** Keep background work alive after the response (waitUntil). */
|
|
135
|
+
defer(work: Promise<unknown>): void;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Per-isolate purge scheduler. A purge call resolves after the first send (callers never wait for the follow-ups). Then:
|
|
140
|
+
* - same-tick calls for different sites share one send (one budget token);
|
|
141
|
+
* - an edit that lands while a send is in flight is sent again right after it, never dropped;
|
|
142
|
+
* - every purged site is purged once more after TRAIL_MS (an edit during that wait pushes it back);
|
|
143
|
+
* - a rejected send is retried every RETRY_MS, MAX_TRIES attempts in total.
|
|
144
|
+
*/
|
|
145
|
+
export function createPurger(env: PurgeEnv, log: (e: Record<string, unknown>) => void = () => {}) {
|
|
146
|
+
const pending = new Set<string>();
|
|
147
|
+
const due = new Map<string, { at: number; tries: number }>();
|
|
148
|
+
let draining: Promise<boolean> | null = null;
|
|
149
|
+
let ticking = false;
|
|
150
|
+
|
|
151
|
+
const sendAll = async (sites: string[]) => {
|
|
152
|
+
let ok = true;
|
|
153
|
+
for (let i = 0; i < sites.length; i += MAX_TAGS) ok = (await env.send(sites.slice(i, i + MAX_TAGS).map(siteTag))) && ok;
|
|
154
|
+
return ok;
|
|
155
|
+
};
|
|
156
|
+
const schedule = (sites: string[], ms: number, tries: number) => {
|
|
157
|
+
for (const s of sites) due.set(s, { at: env.now() + ms, tries });
|
|
158
|
+
if (!ticking) {
|
|
159
|
+
ticking = true;
|
|
160
|
+
env.defer(tick());
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
const tick = async () => {
|
|
164
|
+
try {
|
|
165
|
+
while (due.size) {
|
|
166
|
+
const wait = Math.min(...[...due.values()].map((d) => d.at)) - env.now();
|
|
167
|
+
if (wait > 0) await env.sleep(wait);
|
|
168
|
+
const now = env.now();
|
|
169
|
+
const batch = [...due].filter(([, d]) => d.at <= now);
|
|
170
|
+
if (!batch.length) continue;
|
|
171
|
+
for (const [s] of batch) due.delete(s);
|
|
172
|
+
if (await sendAll(batch.map(([s]) => s))) continue;
|
|
173
|
+
for (const [s, d] of batch) {
|
|
174
|
+
if (d.tries + 1 >= MAX_TRIES) log({ event: "cache.purge_gave_up", site: s });
|
|
175
|
+
else if (!due.has(s)) due.set(s, { at: env.now() + RETRY_MS, tries: d.tries + 1 });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
} finally {
|
|
179
|
+
ticking = false;
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
const drain = async () => {
|
|
183
|
+
let ok = true;
|
|
184
|
+
try {
|
|
185
|
+
await 0; // let same-tick callers join this batch
|
|
186
|
+
while (pending.size) {
|
|
187
|
+
const batch = [...pending];
|
|
188
|
+
pending.clear();
|
|
189
|
+
schedule(batch, TRAIL_MS, 0); // before the send: it supersedes any follow-up still waiting for these sites
|
|
190
|
+
const sent = await sendAll(batch);
|
|
191
|
+
ok = sent && ok;
|
|
192
|
+
if (!sent) schedule(batch, RETRY_MS, 1);
|
|
193
|
+
}
|
|
194
|
+
} finally {
|
|
195
|
+
draining = null;
|
|
196
|
+
}
|
|
197
|
+
return ok;
|
|
198
|
+
};
|
|
199
|
+
return (site: string): Promise<boolean> => {
|
|
200
|
+
pending.add(site);
|
|
201
|
+
return (draining ??= drain());
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
type CacheEntry = (o: unknown) => { purgeTag(t: string[]): Promise<{ success?: boolean; errors?: unknown } | undefined> };
|
|
206
|
+
async function cacheEntry(): Promise<CacheEntry | null> {
|
|
207
|
+
try {
|
|
208
|
+
const specifier = "cloudflare:workers";
|
|
209
|
+
const { exports } = (await import(/* @vite-ignore */ specifier)) as { exports?: Record<string, CacheEntry | undefined> };
|
|
210
|
+
return exports?.[CACHE_ENTRYPOINT] ?? null;
|
|
211
|
+
} catch {
|
|
212
|
+
return null; // off Workers (Node/SQLite lab)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const log = (e: Record<string, unknown>) => console.log(JSON.stringify(e));
|
|
216
|
+
const purger = createPurger(
|
|
217
|
+
{
|
|
218
|
+
async send(tags) {
|
|
219
|
+
try {
|
|
220
|
+
const entry = await cacheEntry();
|
|
221
|
+
if (!entry) return true; // nothing cached here, nothing to purge
|
|
222
|
+
const res = await entry({ props: { host: "purge", gpc: false } }).purgeTag(tags);
|
|
223
|
+
const ok = res?.success !== false;
|
|
224
|
+
log(ok ? { event: "cache.purged", tags } : { event: "cache.purge_failed", tags, errors: JSON.stringify(res?.errors ?? null).slice(0, 300) });
|
|
225
|
+
return ok;
|
|
226
|
+
} catch (e) {
|
|
227
|
+
log({ event: "cache.purge_failed", tags, error: String(e) });
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
232
|
+
now: Date.now,
|
|
233
|
+
defer(work) {
|
|
234
|
+
const specifier = "cloudflare:workers";
|
|
235
|
+
import(/* @vite-ignore */ specifier).then((m: { waitUntil?: (p: Promise<unknown>) => void }) => m.waitUntil?.(work), () => {});
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
log,
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Drop every cached page of a site (all of its hostnames carry the same `site:<slug>` tag). Must run INSIDE the cached
|
|
243
|
+
* entrypoint (a purge from anywhere else reports success and does nothing; verified in the spike), so it calls an RPC method
|
|
244
|
+
* on it. Never throws. Returns whether the first purge was accepted; follow-ups (trailing purge, retries) run in the background.
|
|
245
|
+
*/
|
|
246
|
+
export async function purgeSiteCache(site: string): Promise<boolean> {
|
|
247
|
+
if (!(await cacheEntry())) return false;
|
|
248
|
+
return purger(site);
|
|
249
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run a fleet action picked on the control plane's Site page (see fleet-blocks.ts). Same rules as the
|
|
3
|
+
* HTTP routes in middleware.ts: state changes go through `transition`, purge needs a deleted site.
|
|
4
|
+
* Slow work (a purge drops ~90 tables, a retry may migrate and seed) continues after the response.
|
|
5
|
+
*/
|
|
6
|
+
import { after } from "emdash";
|
|
7
|
+
import { backupSite } from "./backup.ts";
|
|
8
|
+
import { runAsSite } from "./dialect.ts";
|
|
9
|
+
import type { FleetAction } from "./fleet-blocks.ts";
|
|
10
|
+
import { reconcile } from "./orgs.ts";
|
|
11
|
+
import { purgeSite } from "./purge.ts";
|
|
12
|
+
import { purgeSiteCache } from "./edge-cache.ts";
|
|
13
|
+
import { readSiteState, writeSiteState } from "./registry.ts";
|
|
14
|
+
import { transition, type StateAction } from "./rules.ts";
|
|
15
|
+
import { scopeState } from "./scope.ts";
|
|
16
|
+
import type { SitesConfig } from "./sites.ts";
|
|
17
|
+
|
|
18
|
+
const STATE_ACTIONS = new Set<FleetAction>(["suspend", "resume", "delete", "restore"]);
|
|
19
|
+
|
|
20
|
+
export async function runFleetAction(sites: SitesConfig, slug: string, action: FleetAction): Promise<{ ok: boolean; message: string }> {
|
|
21
|
+
const primary = scopeState().primary;
|
|
22
|
+
if (slug === primary) return { ok: false, message: "The control plane is not managed here." };
|
|
23
|
+
if (STATE_ACTIONS.has(action)) {
|
|
24
|
+
const result = transition(await readSiteState(slug), action as StateAction, { staff: true, owner: false });
|
|
25
|
+
if (!result.ok) return { ok: false, message: `Cannot ${action} "${slug}" in its current state.` };
|
|
26
|
+
await writeSiteState(slug, result.next ? { state: result.next, at: new Date().toISOString(), reason: `${action} from the fleet page` } : null);
|
|
27
|
+
await purgeSiteCache(slug);
|
|
28
|
+
return { ok: true, message: `${slug}: ${action} done.` };
|
|
29
|
+
}
|
|
30
|
+
if (action === "backup") {
|
|
31
|
+
const { bytes } = await runAsSite(primary, () => backupSite(slug));
|
|
32
|
+
return { ok: true, message: `${slug}: backed up (${Math.round(bytes / 1024)} KB).` };
|
|
33
|
+
}
|
|
34
|
+
if (action === "retry") {
|
|
35
|
+
after(() => runAsSite(primary, () => reconcile(sites, slug)).then(() => undefined));
|
|
36
|
+
return { ok: true, message: `${slug}: setup restarted. Reload in a minute.` };
|
|
37
|
+
}
|
|
38
|
+
if (action === "purge") {
|
|
39
|
+
if ((await readSiteState(slug))?.state !== "deleted") return { ok: false, message: `"${slug}" is not deleted.` };
|
|
40
|
+
after(() => runAsSite(primary, () => purgeSite(slug)).then(() => undefined));
|
|
41
|
+
return { ok: true, message: `${slug}: purge started. It takes about a minute.` };
|
|
42
|
+
}
|
|
43
|
+
return { ok: false, message: "Unknown action." };
|
|
44
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fleet as an EmDash admin page (Block Kit), for staff on the control plane.
|
|
3
|
+
* Pure: takes the site list, returns blocks; no database, so a plain-Node self-check covers it
|
|
4
|
+
* (scripts/fleet.selfcheck.ts). The actions themselves run in fleet-actions.ts.
|
|
5
|
+
*/
|
|
6
|
+
import { isSiteId } from "./rewrite.ts";
|
|
7
|
+
|
|
8
|
+
/** What the page needs to know about a site (a subset of `FleetSite`). */
|
|
9
|
+
export interface FleetRow {
|
|
10
|
+
slug: string;
|
|
11
|
+
name: string;
|
|
12
|
+
ownerEmail: string | null;
|
|
13
|
+
status: "provisioning" | "ready" | "failed" | "unknown";
|
|
14
|
+
state: "active" | "suspended" | "deleted";
|
|
15
|
+
createdAt: string | null;
|
|
16
|
+
userCount: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type FleetAction = "suspend" | "resume" | "delete" | "restore" | "retry" | "backup" | "purge";
|
|
20
|
+
const ACTIONS: ReadonlySet<string> = new Set<FleetAction>(["suspend", "resume", "delete", "restore", "retry", "backup", "purge"]);
|
|
21
|
+
export const ROW_ACTION_PREFIX = "fleet:";
|
|
22
|
+
export const PURGE_FORM_ACTION = "fleet_purge";
|
|
23
|
+
|
|
24
|
+
const LABELS: Record<FleetAction, string> = {
|
|
25
|
+
suspend: "Suspend",
|
|
26
|
+
resume: "Resume",
|
|
27
|
+
delete: "Delete (kept 14 days)",
|
|
28
|
+
restore: "Restore",
|
|
29
|
+
retry: "Retry setup",
|
|
30
|
+
backup: "Back up now",
|
|
31
|
+
purge: "Purge",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Actions that make sense for a site in its current state. Purge is not here: it has its own confirmed form. */
|
|
35
|
+
export function actionsFor(site: FleetRow): FleetAction[] {
|
|
36
|
+
if (site.state === "deleted") return ["restore"];
|
|
37
|
+
if (site.state === "suspended") return ["resume", "backup", "delete"];
|
|
38
|
+
return site.status === "ready" ? ["suspend", "backup", "delete"] : ["retry", "suspend", "delete"];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Read a menu click: `fleet:<slug>` + the chosen value. `null` for anything else. */
|
|
42
|
+
export function parseFleetAction(actionId: unknown, value: unknown): { slug: string; action: FleetAction } | null {
|
|
43
|
+
if (typeof actionId !== "string" || !actionId.startsWith(ROW_ACTION_PREFIX) || typeof value !== "string") return null;
|
|
44
|
+
const slug = actionId.slice(ROW_ACTION_PREFIX.length);
|
|
45
|
+
if (!isSiteId(slug) || !ACTIONS.has(value) || value === "purge") return null;
|
|
46
|
+
return { slug, action: value as FleetAction };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function fleetBlocks(all: FleetRow[], primary: string): unknown[] {
|
|
50
|
+
const sites = all.filter((s) => s.slug !== primary);
|
|
51
|
+
const count = (f: (s: FleetRow) => boolean) => sites.filter(f).length;
|
|
52
|
+
const deleted = sites.filter((s) => s.state === "deleted");
|
|
53
|
+
const attention = count((s) => s.state === "active" && s.status !== "ready");
|
|
54
|
+
const blocks: unknown[] = [
|
|
55
|
+
{ type: "header", text: "Fleet" },
|
|
56
|
+
{
|
|
57
|
+
type: "stats",
|
|
58
|
+
items: [
|
|
59
|
+
{ label: "Sites", value: sites.length - deleted.length },
|
|
60
|
+
{ label: "Needs attention", value: attention, trend: attention ? "down" : "neutral" },
|
|
61
|
+
{ label: "Suspended", value: count((s) => s.state === "suspended") },
|
|
62
|
+
{ label: "Deleted, not purged", value: deleted.length },
|
|
63
|
+
{ label: "Users on sites", value: sites.reduce((n, s) => n + s.userCount, 0) },
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
blocks.push({
|
|
68
|
+
type: "table",
|
|
69
|
+
page_action_id: "fleet_page",
|
|
70
|
+
empty_text: "No sites yet.",
|
|
71
|
+
columns: [
|
|
72
|
+
{ key: "site", label: "Site", format: "code" },
|
|
73
|
+
{ key: "owner", label: "Owner" },
|
|
74
|
+
{ key: "status", label: "Setup", format: "badge" },
|
|
75
|
+
{ key: "state", label: "State", format: "badge" },
|
|
76
|
+
{ key: "users", label: "Users", format: "number" },
|
|
77
|
+
{ key: "created", label: "Created", format: "relative_time" },
|
|
78
|
+
{ key: "actions", label: "", format: "element" },
|
|
79
|
+
],
|
|
80
|
+
rows: sites.map((s) => ({
|
|
81
|
+
site: s.slug,
|
|
82
|
+
owner: s.ownerEmail ?? "-",
|
|
83
|
+
status: s.status,
|
|
84
|
+
state: s.state,
|
|
85
|
+
users: s.userCount,
|
|
86
|
+
created: s.createdAt ?? "",
|
|
87
|
+
actions: {
|
|
88
|
+
type: "menu",
|
|
89
|
+
action_id: `${ROW_ACTION_PREFIX}${s.slug}`,
|
|
90
|
+
label: "Actions",
|
|
91
|
+
items: actionsFor(s).map((a) => ({ label: LABELS[a], value: a })),
|
|
92
|
+
},
|
|
93
|
+
})),
|
|
94
|
+
});
|
|
95
|
+
if (deleted.length) {
|
|
96
|
+
blocks.push(
|
|
97
|
+
{ type: "divider" },
|
|
98
|
+
{ type: "header", text: "Purge" },
|
|
99
|
+
{
|
|
100
|
+
type: "context",
|
|
101
|
+
text: "Deleted sites are destroyed automatically after the grace period. Purging now removes the tables, files, backups and organization for good.",
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
type: "form",
|
|
105
|
+
block_id: "purge",
|
|
106
|
+
fields: [
|
|
107
|
+
{ type: "select", action_id: "slug", label: "Deleted site", options: deleted.map((s) => ({ label: s.slug, value: s.slug })), initial_value: deleted[0]!.slug },
|
|
108
|
+
{ type: "toggle", action_id: "confirm", label: "I understand this cannot be undone", initial_value: false },
|
|
109
|
+
],
|
|
110
|
+
submit: { label: "Purge now", action_id: PURGE_FORM_ACTION },
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return blocks;
|
|
115
|
+
}
|
package/src/fleet.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet view for the hosting provider (super-admin).
|
|
3
|
+
*
|
|
4
|
+
* Read-only to start: list every site with its status, owner and user count.
|
|
5
|
+
* The data lives in EmDash, not in Better Auth — the registry is control-plane
|
|
6
|
+
* `options` rows (`multisite:site:<slug>`) + the Better Auth org/member records
|
|
7
|
+
* in plugin storage + each site's own `users` table — so this is ours to serve,
|
|
8
|
+
* gated to staff on the control-plane host.
|
|
9
|
+
*
|
|
10
|
+
* Staff = a user with EmDash role 50 (admin) on the CONTROL-PLANE site. That is
|
|
11
|
+
* your account, not a tenant admin: tenant admins are role 50 only on their own
|
|
12
|
+
* site's `users` table, which this never consults for the gate.
|
|
13
|
+
*/
|
|
14
|
+
import { Kysely, sql } from "kysely";
|
|
15
|
+
import { siteDialect, runAsSite } from "./dialect.ts";
|
|
16
|
+
import { isSiteId, isSiteTable } from "./rewrite.ts";
|
|
17
|
+
import { isTemplateId } from "./rules.ts";
|
|
18
|
+
import { scopeState } from "./scope.ts";
|
|
19
|
+
import type { SiteStatus } from "./orgs.ts";
|
|
20
|
+
import { readAllStates } from "./registry.ts";
|
|
21
|
+
|
|
22
|
+
function controlDb(): Kysely<never> {
|
|
23
|
+
const st = scopeState();
|
|
24
|
+
if (!st.inner) throw new Error("[multisite] database not initialised yet");
|
|
25
|
+
return new Kysely<never>({ dialect: siteDialect(st.inner, st.activeSite, st.primary) });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Is `userId` a staff member? True only when they are role 50 on the
|
|
30
|
+
* control-plane `users` table. `userId` comes from the EmDash session.
|
|
31
|
+
*/
|
|
32
|
+
export async function isStaff(userId: string | null | undefined): Promise<boolean> {
|
|
33
|
+
if (!userId) return false;
|
|
34
|
+
const db = controlDb();
|
|
35
|
+
const row = await runAsSite(scopeState().primary, () =>
|
|
36
|
+
sql<{ role: number }>`SELECT role FROM users WHERE id = ${userId}`.execute(db),
|
|
37
|
+
);
|
|
38
|
+
return (row.rows[0]?.role ?? 0) >= 50;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface FleetSite {
|
|
42
|
+
slug: string;
|
|
43
|
+
name: string;
|
|
44
|
+
ownerEmail: string | null;
|
|
45
|
+
status: SiteStatus["status"] | "unknown";
|
|
46
|
+
/** active, or suspended / deleted by staff or the owner. */
|
|
47
|
+
state: "active" | "suspended" | "deleted";
|
|
48
|
+
createdAt: string | null;
|
|
49
|
+
userCount: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Every provisioned tenant site + the control plane itself. One pass over the
|
|
54
|
+
* control-plane registry, then a per-site COUNT. Reads only; no tenant content.
|
|
55
|
+
*/
|
|
56
|
+
export async function listSites(): Promise<FleetSite[]> {
|
|
57
|
+
const db = controlDb();
|
|
58
|
+
const primary = scopeState().primary;
|
|
59
|
+
|
|
60
|
+
return runAsSite(primary, async () => {
|
|
61
|
+
// Which per-site tables physically exist -> the set of provisioned sites.
|
|
62
|
+
const tables = await sql<{ name: string }>`
|
|
63
|
+
SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'users$%' ESCAPE '\\'`.execute(db);
|
|
64
|
+
// NOTE: translateRows strips the suffix from sqlite_master names for the
|
|
65
|
+
// PRIMARY site; here we are the primary, so suffix stripping is OFF and we
|
|
66
|
+
// see the real physical names. Guard anyway.
|
|
67
|
+
const slugs = tables.rows
|
|
68
|
+
.map((r) => r.name)
|
|
69
|
+
.filter((n) => n.startsWith("users$"))
|
|
70
|
+
.map((n) => n.slice("users$".length))
|
|
71
|
+
.filter((id) => isSiteId(id) && !isTemplateId(id));
|
|
72
|
+
|
|
73
|
+
// Org name/slug/createdAt + owner, from Better Auth storage on the primary.
|
|
74
|
+
const orgs = await sql<{ data: string }>`
|
|
75
|
+
SELECT data FROM _plugin_storage WHERE collection = 'organizations'`.execute(db);
|
|
76
|
+
const members = await sql<{ data: string }>`
|
|
77
|
+
SELECT data FROM _plugin_storage WHERE collection = 'members'`.execute(db);
|
|
78
|
+
const states = await readAllStates();
|
|
79
|
+
const statusRows = await sql<{ name: string; value: string }>`
|
|
80
|
+
SELECT name, value FROM options WHERE name LIKE 'multisite:site:%'`.execute(db);
|
|
81
|
+
|
|
82
|
+
const orgBySlug = new Map<string, { name: string; id: string; createdAt: string }>();
|
|
83
|
+
for (const r of orgs.rows) {
|
|
84
|
+
try {
|
|
85
|
+
const o = JSON.parse(r.data) as { slug?: string; name?: string; id?: string; createdAt?: string };
|
|
86
|
+
if (o.slug) orgBySlug.set(o.slug, { name: o.name ?? o.slug, id: o.id ?? "", createdAt: o.createdAt ?? "" });
|
|
87
|
+
} catch {}
|
|
88
|
+
}
|
|
89
|
+
const ownerIdByOrgId = new Map<string, string>();
|
|
90
|
+
for (const r of members.rows) {
|
|
91
|
+
try {
|
|
92
|
+
const m = JSON.parse(r.data) as { organizationId?: string; userId?: string; role?: string };
|
|
93
|
+
if (m.organizationId && m.userId && m.role === "owner") ownerIdByOrgId.set(m.organizationId, m.userId);
|
|
94
|
+
} catch {}
|
|
95
|
+
}
|
|
96
|
+
const statusBySlug = new Map<string, SiteStatus["status"]>();
|
|
97
|
+
for (const r of statusRows.rows) {
|
|
98
|
+
const slug = r.name.slice("multisite:site:".length);
|
|
99
|
+
try {
|
|
100
|
+
statusBySlug.set(slug, (JSON.parse(r.value) as SiteStatus).status);
|
|
101
|
+
} catch {}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const out: FleetSite[] = [];
|
|
105
|
+
// The control plane itself (unprefixed `users`), shown first.
|
|
106
|
+
out.push({
|
|
107
|
+
slug: primary,
|
|
108
|
+
name: "(control plane)",
|
|
109
|
+
ownerEmail: null,
|
|
110
|
+
status: "ready",
|
|
111
|
+
state: "active",
|
|
112
|
+
createdAt: null,
|
|
113
|
+
userCount: await countUsers(db, primary, primary),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
for (const slug of slugs.sort()) {
|
|
117
|
+
const org = orgBySlug.get(slug);
|
|
118
|
+
const ownerId = org ? ownerIdByOrgId.get(org.id) : undefined;
|
|
119
|
+
const ownerEmail = ownerId ? await emailOf(db, ownerId) : null;
|
|
120
|
+
out.push({
|
|
121
|
+
slug,
|
|
122
|
+
name: org?.name ?? slug,
|
|
123
|
+
ownerEmail,
|
|
124
|
+
status: statusBySlug.get(slug) ?? "unknown",
|
|
125
|
+
state: states.get(slug)?.state ?? "active",
|
|
126
|
+
createdAt: org?.createdAt || null,
|
|
127
|
+
userCount: await countUsers(db, slug, primary),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function emailOf(db: Kysely<never>, userId: string): Promise<string | null> {
|
|
135
|
+
const r = await sql<{ email: string }>`SELECT email FROM users WHERE id = ${userId}`.execute(db);
|
|
136
|
+
return r.rows[0]?.email ?? null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** COUNT a site's own users table. `site` is validated; table name is built, never interpolated from input beyond that. */
|
|
140
|
+
async function countUsers(db: Kysely<never>, site: string, primary: string): Promise<number> {
|
|
141
|
+
if (!isSiteId(site) || !isSiteTable("users")) return 0;
|
|
142
|
+
// Run AS the site so the dialect rewrites `users` -> `users$site` for us.
|
|
143
|
+
return runAsSite(site, async () => {
|
|
144
|
+
try {
|
|
145
|
+
const r = await sql<{ c: number }>`SELECT COUNT(*) AS c FROM users`.execute(db);
|
|
146
|
+
return Number(r.rows[0]?.c ?? 0);
|
|
147
|
+
} catch {
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|