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/clone.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure: turn the schema of one site into the statements that rebuild it, with
|
|
3
|
+
* its data, under another site id. No database access, so a plain-Node
|
|
4
|
+
* self-check covers it (scripts/clone.selfcheck.ts).
|
|
5
|
+
*
|
|
6
|
+
* Input is a site's `sqlite_master` rows. Table names carry the site as a
|
|
7
|
+
* `$<site>` suffix, so cloning is a text swap of that suffix.
|
|
8
|
+
*/
|
|
9
|
+
export interface MasterRow {
|
|
10
|
+
type: string;
|
|
11
|
+
name: string;
|
|
12
|
+
sql: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ClonePlan {
|
|
16
|
+
/** Tables, virtual tables and indexes. Run first. */
|
|
17
|
+
schema: string[];
|
|
18
|
+
/** One `INSERT ... SELECT` per table; the rows never leave D1. */
|
|
19
|
+
copy: string[];
|
|
20
|
+
/** The tables (and virtual tables) that hold data, by their current names. Used to export rows. */
|
|
21
|
+
tables: string[];
|
|
22
|
+
/** Triggers. Created after the copy so they do not fire on the copied rows. */
|
|
23
|
+
triggers: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const quote = (name: string) => `"${name.replaceAll('"', '""')}"`;
|
|
27
|
+
|
|
28
|
+
export function clonePlan(rows: MasterRow[], from: string, to: string): ClonePlan {
|
|
29
|
+
const swap = (s: string) => s.replaceAll(`$${from}`, `$${to}`);
|
|
30
|
+
const own = rows.filter((r) => r.sql && !r.name.startsWith("sqlite_") && r.name.endsWith(`$${from}`));
|
|
31
|
+
const virtual = own.filter((r) => r.type === "table" && /^\s*CREATE\s+VIRTUAL\s+TABLE/i.test(r.sql!));
|
|
32
|
+
// An FTS table's shadow tables (`<name>_data`, `_idx`, ...) do not end in `$<site>`, so
|
|
33
|
+
// `own` already leaves them out: CREATE VIRTUAL TABLE makes them itself.
|
|
34
|
+
const plain = own.filter((r) => r.type === "table" && !virtual.includes(r));
|
|
35
|
+
const copyOf = (r: MasterRow) => `INSERT INTO ${quote(swap(r.name))} SELECT * FROM ${quote(r.name)}`;
|
|
36
|
+
return {
|
|
37
|
+
schema: [...plain, ...virtual, ...own.filter((r) => r.type === "index")].map((r) => swap(r.sql!)),
|
|
38
|
+
copy: [...plain, ...virtual].map(copyOf),
|
|
39
|
+
tables: [...plain, ...virtual].map((r) => r.name),
|
|
40
|
+
triggers: own.filter((r) => r.type === "trigger").map((r) => swap(r.sql!)),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Multi-row INSERTs for an export. `tuples` are already SQL literals, e.g. `('a',1,NULL)`
|
|
46
|
+
* (made by SQLite's `quote()`, so text, numbers, NULL and blobs all round-trip).
|
|
47
|
+
* Grouped so a big table is a few statements, each under `maxBytes`.
|
|
48
|
+
*/
|
|
49
|
+
export function insertStatements(table: string, columns: string[], tuples: string[], maxRows = 100, maxBytes = 60_000): string[] {
|
|
50
|
+
const head = `INSERT INTO ${quote(table)} (${columns.map(quote).join(", ")}) VALUES `;
|
|
51
|
+
const out: string[] = [];
|
|
52
|
+
let batch: string[] = [];
|
|
53
|
+
let size = head.length;
|
|
54
|
+
const flush = () => {
|
|
55
|
+
if (batch.length) out.push(head + batch.join(", "));
|
|
56
|
+
batch = [];
|
|
57
|
+
size = head.length;
|
|
58
|
+
};
|
|
59
|
+
for (const t of tuples) {
|
|
60
|
+
if (batch.length >= maxRows || (batch.length && size + t.length > maxBytes)) flush();
|
|
61
|
+
batch.push(t);
|
|
62
|
+
size += t.length + 2;
|
|
63
|
+
}
|
|
64
|
+
flush();
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Restore a statement under another site id. Schema statements swap the `$<site>` suffix
|
|
70
|
+
* everywhere; an INSERT swaps it only in the table name, never in the row values.
|
|
71
|
+
*/
|
|
72
|
+
export function retarget(statement: string, from: string, to: string): string {
|
|
73
|
+
if (from === to) return statement;
|
|
74
|
+
const insert = /^(INSERT INTO ")([^"]+)(")/.exec(statement);
|
|
75
|
+
if (!insert) return statement.replaceAll(`$${from}`, `$${to}`);
|
|
76
|
+
return insert[1] + insert[2]!.replaceAll(`$${from}`, `$${to}`) + insert[3] + statement.slice(insert[0].length);
|
|
77
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SitesConfig } from "./sites.ts";
|
|
2
|
+
// Set at build time by withSites(), carried in the config baked into the Worker.
|
|
3
|
+
// @ts-ignore - virtual module provided by EmDash's integration
|
|
4
|
+
import emdashConfig from "virtual:emdash/config";
|
|
5
|
+
|
|
6
|
+
interface WithSites {
|
|
7
|
+
database?: { config?: { sites?: SitesConfig; inner?: { binding?: string } } };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The multisite config of this build, or undefined when the database is not wrapped. */
|
|
11
|
+
export function sitesConfig(): SitesConfig | undefined {
|
|
12
|
+
return (emdashConfig as WithSites).database?.config?.sites;
|
|
13
|
+
}
|
|
14
|
+
/** Name of the D1 binding under the wrapper, or undefined (e.g. the Node/SQLite adapter). */
|
|
15
|
+
export function d1Binding(): string | undefined {
|
|
16
|
+
return (emdashConfig as WithSites).database?.config?.inner?.binding;
|
|
17
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The customer console HTML: "My sites" (/sites) and the create wizard
|
|
3
|
+
* (/sites/new). Served by the middleware on the control-plane host to signed-in
|
|
4
|
+
* users only; all data comes from /api/multisite/me and Better Auth.
|
|
5
|
+
*
|
|
6
|
+
* ponytail: plain HTML + vanilla JS, same as the fleet page, so the host build
|
|
7
|
+
* needs no extra React island. Upgrade path: an injected .astro page + island
|
|
8
|
+
* over the same JSON endpoints if the console grows past two screens.
|
|
9
|
+
*
|
|
10
|
+
* The client script avoids backticks and `${` on purpose: it lives inside a
|
|
11
|
+
* template literal.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const CLIENT_JS = String.raw`
|
|
15
|
+
(() => {
|
|
16
|
+
const mode = document.body.dataset.mode;
|
|
17
|
+
const root = document.getElementById("root");
|
|
18
|
+
const nav = document.getElementById("nav");
|
|
19
|
+
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"']/g, (c) => ({ "&":"&","<":"<",">":">",'"':""","'":"'" }[c]));
|
|
20
|
+
const status = (s) => '<span class="pill ' + esc(s) + '">' + esc(s === "provisioning" ? "setting up" : s) + '</span>';
|
|
21
|
+
const getJson = async (url, init) => {
|
|
22
|
+
const res = await fetch(url, Object.assign({ headers: { accept: "application/json" }, credentials: "same-origin" }, init));
|
|
23
|
+
let body = null;
|
|
24
|
+
try { body = await res.json(); } catch (e) {}
|
|
25
|
+
return { ok: res.ok, status: res.status, body };
|
|
26
|
+
};
|
|
27
|
+
const toLogin = () => { location.href = "/login?redirect=" + encodeURIComponent(location.pathname); };
|
|
28
|
+
|
|
29
|
+
const renderNav = (me) => {
|
|
30
|
+
nav.innerHTML =
|
|
31
|
+
'<a href="/sites"' + (mode === "list" ? ' aria-current="page"' : '') + '>My sites</a>' +
|
|
32
|
+
'<a href="/organization">Teams</a>' +
|
|
33
|
+
'<a href="/account">Account</a>' +
|
|
34
|
+
(me && me.staff ? '<a href="/admin/fleet">Fleet</a>' : '');
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// ---------- My sites ----------
|
|
38
|
+
const siteCard = (s, staff, themed) => {
|
|
39
|
+
const suspended = s.state === "suspended";
|
|
40
|
+
const open = s.url && s.status === "ready" && !suspended
|
|
41
|
+
? '<a class="btn" href="' + esc(s.url) + '" target="_blank" rel="noopener">Open site</a>' +
|
|
42
|
+
'<a class="btn primary" href="' + esc(s.adminUrl) + '" target="_blank" rel="noopener">Open admin</a>'
|
|
43
|
+
: "";
|
|
44
|
+
const noUrl = s.status === "ready" && !s.url ? '<p class="muted">No public address is configured for this site yet.</p>' : "";
|
|
45
|
+
const err = s.error ? '<p class="errtext">' + esc(s.error) + '</p>' : "";
|
|
46
|
+
const appearance = s.status === "ready" && !suspended && (s.role === "owner" || staff) && themed
|
|
47
|
+
? '<a class="btn" href="/sites/' + esc(s.slug) + '/appearance">Appearance</a>' : "";
|
|
48
|
+
const retry = s.canRetry ? '<button class="btn" data-retry="' + esc(s.slug) + '">Retry setup</button>' : "";
|
|
49
|
+
const del = s.role === "owner" ? '<button class="btn danger" data-delete="' + esc(s.slug) + '" data-name="' + esc(s.name) + '">Delete</button>' : "";
|
|
50
|
+
const note = suspended ? '<p class="errtext">This site is suspended. Contact support to restore it.</p>' : "";
|
|
51
|
+
return '<article class="card"><div class="row"><h2>' + esc(s.name) + '</h2>' + (suspended ? '<span class="pill failed">suspended</span>' : status(s.status)) + '</div>' + note +
|
|
52
|
+
'<p class="muted"><code>' + esc(s.slug) + '</code> \u00b7 ' + esc(s.role) + (s.theme ? ' \u00b7 ' + esc(s.theme) : '') + '</p>' + err + noUrl +
|
|
53
|
+
'<div class="actions">' + open + appearance + retry + del + '</div></article>';
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
let timer = null;
|
|
57
|
+
const loadList = async () => {
|
|
58
|
+
const r = await getJson("/api/multisite/me");
|
|
59
|
+
if (r.status === 401) return toLogin();
|
|
60
|
+
if (!r.ok) { root.innerHTML = '<div class="err">Could not load your sites.</div>'; return; }
|
|
61
|
+
const me = r.body;
|
|
62
|
+
renderNav(me);
|
|
63
|
+
const atLimit = me.max != null && me.owned >= me.max;
|
|
64
|
+
const banner = !me.emailVerified
|
|
65
|
+
? '<div class="note">Verify your email address to create sites. Check your inbox for the verification link.</div>' : "";
|
|
66
|
+
const create = atLimit
|
|
67
|
+
? '<p class="muted">You own ' + me.owned + ' of ' + me.max + ' sites. That is the limit.</p>'
|
|
68
|
+
: '<a class="btn primary" href="/sites/new"' + (me.emailVerified ? "" : ' aria-disabled="true" tabindex="-1"') + '>Create site</a>';
|
|
69
|
+
root.innerHTML = banner +
|
|
70
|
+
'<div class="row head"><h1>My sites</h1><div>' + create + '</div></div>' +
|
|
71
|
+
(me.sites.length
|
|
72
|
+
? '<div class="grid">' + me.sites.map((s) => siteCard(s, me.staff, me.themed)).join("") + '</div>'
|
|
73
|
+
: '<div class="empty"><p>You have no sites yet.</p></div>');
|
|
74
|
+
root.querySelectorAll("[data-retry]").forEach((b) => b.addEventListener("click", async () => {
|
|
75
|
+
b.disabled = true; b.textContent = "Starting\u2026";
|
|
76
|
+
await getJson("/api/multisite/sites/" + b.dataset.retry + "/retry", { method: "POST", headers: { "x-emdash-request": "1" } });
|
|
77
|
+
loadList();
|
|
78
|
+
}));
|
|
79
|
+
root.querySelectorAll("[data-delete]").forEach((b) => b.addEventListener("click", async () => {
|
|
80
|
+
if (!confirm('Delete "' + b.dataset.name + '"? The site goes offline now. Support can restore it for a limited time.')) return;
|
|
81
|
+
b.disabled = true;
|
|
82
|
+
const res = await getJson("/api/multisite/sites/" + b.dataset.delete + "/state", {
|
|
83
|
+
method: "POST", headers: { "content-type": "application/json", "x-emdash-request": "1" }, body: JSON.stringify({ action: "delete" }),
|
|
84
|
+
});
|
|
85
|
+
if (!res.ok) { alert((res.body && (res.body.message || res.body.code)) || "Could not delete the site."); b.disabled = false; return; }
|
|
86
|
+
loadList();
|
|
87
|
+
}));
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
if (me.sites.some((s) => s.status === "provisioning")) timer = setTimeout(loadList, 4000);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// ---------- Create wizard ----------
|
|
93
|
+
const slugify = (v) => v.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
|
|
94
|
+
const loadWizard = async () => {
|
|
95
|
+
const r = await getJson("/api/multisite/me");
|
|
96
|
+
if (r.status === 401) return toLogin();
|
|
97
|
+
const me = r.body || {};
|
|
98
|
+
renderNav(me);
|
|
99
|
+
if (!me.emailVerified) {
|
|
100
|
+
root.innerHTML = '<div class="note">Verify your email address before creating a site.</div><p><a href="/sites">Back to my sites</a></p>';
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (me.max != null && me.owned >= me.max) {
|
|
104
|
+
root.innerHTML = '<div class="note">You own ' + me.owned + ' of ' + me.max + ' sites. That is the limit.</div><p><a href="/sites">Back to my sites</a></p>';
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const tr = await getJson("/api/multisite/themes");
|
|
108
|
+
const themes = (tr.body && tr.body.themes) || [];
|
|
109
|
+
const picker = themes.length > 1
|
|
110
|
+
? '<fieldset class="themes"><legend>Theme</legend>' + themes.map((t) =>
|
|
111
|
+
'<label class="theme"><input type="radio" name="theme" value="' + esc(t.id) + '"' + (t.id === tr.body.default ? " checked" : "") + '>' +
|
|
112
|
+
'<span><strong>' + esc(t.name) + '</strong><small>' + esc(t.description) + '</small>' +
|
|
113
|
+
(/^https:\/\//.test(t.demoUrl || "") ? '<small><a href="' + esc(t.demoUrl) + '" target="_blank" rel="noopener">Preview \u2197</a></small>' : '') + '</span></label>').join("") + '</fieldset>'
|
|
114
|
+
: "";
|
|
115
|
+
root.innerHTML =
|
|
116
|
+
'<h1>Create a site</h1>' +
|
|
117
|
+
'<form id="f" novalidate>' +
|
|
118
|
+
'<label for="name">Site name</label>' +
|
|
119
|
+
'<input id="name" name="name" autocomplete="off" maxlength="80" required>' +
|
|
120
|
+
'<label for="slug">Address</label>' +
|
|
121
|
+
'<div class="addr"><input id="slug" name="slug" autocomplete="off" maxlength="40" required aria-describedby="slugmsg">' +
|
|
122
|
+
'<span class="muted" id="suffix"></span></div>' +
|
|
123
|
+
'<p id="slugmsg" class="muted" aria-live="polite"></p>' + picker +
|
|
124
|
+
'<p id="formerr" class="errtext" role="alert"></p>' +
|
|
125
|
+
'<button class="btn primary" id="go" type="submit" disabled>Create site</button> ' +
|
|
126
|
+
'<a class="btn" href="/sites">Cancel</a>' +
|
|
127
|
+
'</form>';
|
|
128
|
+
const name = document.getElementById("name"), slug = document.getElementById("slug");
|
|
129
|
+
const msg = document.getElementById("slugmsg"), go = document.getElementById("go"), ferr = document.getElementById("formerr");
|
|
130
|
+
let touched = false, ok = false, seq = 0, t = null;
|
|
131
|
+
const check = () => {
|
|
132
|
+
ok = false; go.disabled = true;
|
|
133
|
+
clearTimeout(t);
|
|
134
|
+
if (!slug.value) { msg.textContent = ""; return; }
|
|
135
|
+
msg.textContent = "Checking\u2026";
|
|
136
|
+
t = setTimeout(async () => {
|
|
137
|
+
const mine = ++seq;
|
|
138
|
+
const c = await getJson("/api/multisite/slug-check?slug=" + encodeURIComponent(slug.value));
|
|
139
|
+
if (mine !== seq) return;
|
|
140
|
+
ok = !!(c.body && c.body.available);
|
|
141
|
+
msg.className = ok ? "okmsg" : "errtext";
|
|
142
|
+
msg.textContent = ok ? "Available." : ((c.body && c.body.message) || "Not available.");
|
|
143
|
+
go.disabled = !(ok && name.value.trim());
|
|
144
|
+
}, 300);
|
|
145
|
+
};
|
|
146
|
+
name.addEventListener("input", () => { if (!touched) slug.value = slugify(name.value); check(); go.disabled = true; });
|
|
147
|
+
slug.addEventListener("input", () => { touched = true; check(); });
|
|
148
|
+
document.getElementById("f").addEventListener("submit", async (e) => {
|
|
149
|
+
e.preventDefault();
|
|
150
|
+
if (!ok) return;
|
|
151
|
+
go.disabled = true; ferr.textContent = "";
|
|
152
|
+
const res = await getJson("/api/auth/organization/create", {
|
|
153
|
+
method: "POST", headers: { "content-type": "application/json", accept: "application/json" },
|
|
154
|
+
body: JSON.stringify(Object.assign({ name: name.value.trim(), slug: slug.value },
|
|
155
|
+
themes.length > 1 ? { metadata: { theme: (document.querySelector('input[name="theme"]:checked') || {}).value } } : {})),
|
|
156
|
+
});
|
|
157
|
+
if (!res.ok) {
|
|
158
|
+
ferr.textContent = (res.body && (res.body.message || res.body.code)) || ("Could not create the site (HTTP " + res.status + ").");
|
|
159
|
+
go.disabled = false; return;
|
|
160
|
+
}
|
|
161
|
+
progress(slug.value, name.value.trim());
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const progress = (slug, name) => {
|
|
166
|
+
root.innerHTML = '<h1>Setting up ' + esc(name) + '\u2026</h1>' +
|
|
167
|
+
'<p class="muted" role="status" aria-live="polite" id="ps">This takes about half a minute.</p>';
|
|
168
|
+
const ps = document.getElementById("ps");
|
|
169
|
+
let tries = 0;
|
|
170
|
+
const tick = async () => {
|
|
171
|
+
tries++;
|
|
172
|
+
const r = await getJson("/api/multisite/sites/" + slug);
|
|
173
|
+
const st = r.body && r.body.status;
|
|
174
|
+
if (st === "ready") {
|
|
175
|
+
const me = (await getJson("/api/multisite/me")).body || { sites: [] };
|
|
176
|
+
const s = me.sites.find((x) => x.slug === slug);
|
|
177
|
+
root.innerHTML = '<h1>' + esc(name) + ' is ready</h1>' +
|
|
178
|
+
'<div class="actions">' +
|
|
179
|
+
(s && s.adminUrl ? '<a class="btn primary" href="' + esc(s.adminUrl) + '">Open admin</a>' : '') +
|
|
180
|
+
(s && s.url ? '<a class="btn" href="' + esc(s.url) + '">Open site</a>' : '') +
|
|
181
|
+
'<a class="btn" href="/sites">My sites</a></div>';
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (st === "failed") {
|
|
185
|
+
root.innerHTML = '<h1>Setup did not finish</h1><p class="errtext">' + esc(r.body.error || "") + '</p>' +
|
|
186
|
+
'<p><a class="btn primary" href="/sites">Go to my sites to retry</a></p>';
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const labels = { queued: "Waiting for a free slot\u2026", tables: "Creating your site\u2026", content: "Adding starter content\u2026", identity: "Setting up your account\u2026" };
|
|
190
|
+
if (st === "provisioning" && r.body.step && labels[r.body.step]) ps.textContent = labels[r.body.step];
|
|
191
|
+
if (tries > 90) { ps.textContent = "Still working. You can leave this page and check My sites."; return; }
|
|
192
|
+
setTimeout(tick, 2500);
|
|
193
|
+
};
|
|
194
|
+
tick();
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// ---------- Appearance (switch theme) ----------
|
|
198
|
+
const loadAppearance = async () => {
|
|
199
|
+
const slug = document.body.dataset.slug;
|
|
200
|
+
const r = await getJson("/api/multisite/sites/" + slug + "/appearance");
|
|
201
|
+
if (r.status === 401) return toLogin();
|
|
202
|
+
const me = (await getJson("/api/multisite/me")).body || {};
|
|
203
|
+
renderNav(me);
|
|
204
|
+
if (!r.ok) { root.innerHTML = '<div class="err">You cannot change this site\u2019s appearance.</div><p><a href="/sites">Back to my sites</a></p>'; return; }
|
|
205
|
+
const a = r.body;
|
|
206
|
+
const list = (xs) => xs.map((x) => '<code>' + esc(x) + '</code>').join(", ");
|
|
207
|
+
root.innerHTML =
|
|
208
|
+
'<h1>Appearance \u00b7 ' + esc(a.name) + '</h1>' +
|
|
209
|
+
'<p class="muted">Switching keeps all of your content. Anything the new theme does not show is hidden, and comes back if you switch back.</p>' +
|
|
210
|
+
'<form id="f"><fieldset class="themes"><legend>Theme</legend>' + a.themes.map((t) =>
|
|
211
|
+
'<label class="theme"><input type="radio" name="theme" value="' + esc(t.id) + '"' + (t.current ? " checked" : "") + '>' +
|
|
212
|
+
'<span><strong>' + esc(t.name) + (t.current ? ' (current)' : '') + '</strong><small>' + esc(t.description) + '</small>' +
|
|
213
|
+
(!t.current && t.adds.length ? '<small>Adds: ' + list(t.adds) + '</small>' : '') +
|
|
214
|
+
(!t.current && t.hides.length ? '<small>Hides: ' + list(t.hides) + '</small>' : '') + '</span></label>').join("") + '</fieldset>' +
|
|
215
|
+
'<label class="check"><input type="checkbox" id="sample"> Add sample content for any new sections</label>' +
|
|
216
|
+
'<p id="msg" class="muted" role="status" aria-live="polite"></p>' +
|
|
217
|
+
'<button class="btn primary" id="go" type="submit" disabled>Switch theme</button> <a class="btn" href="/sites">Back</a></form>';
|
|
218
|
+
const go = document.getElementById("go"), msg = document.getElementById("msg");
|
|
219
|
+
const chosen = () => (document.querySelector('input[name="theme"]:checked') || {}).value;
|
|
220
|
+
root.querySelectorAll('input[name="theme"]').forEach((i) => i.addEventListener("change", () => { go.disabled = chosen() === a.current; }));
|
|
221
|
+
document.getElementById("f").addEventListener("submit", async (e) => {
|
|
222
|
+
e.preventDefault();
|
|
223
|
+
const to = chosen();
|
|
224
|
+
if (!to || to === a.current) return;
|
|
225
|
+
go.disabled = true; msg.className = "muted"; msg.textContent = "Switching\u2026";
|
|
226
|
+
const res = await getJson("/api/multisite/sites/" + slug + "/theme", {
|
|
227
|
+
method: "POST", headers: { "content-type": "application/json", "x-emdash-request": "1" },
|
|
228
|
+
body: JSON.stringify({ theme: to, includeContent: document.getElementById("sample").checked }),
|
|
229
|
+
});
|
|
230
|
+
if (!res.ok) { msg.className = "errtext"; msg.textContent = (res.body && (res.body.message || res.body.code)) || "Could not switch."; go.disabled = false; return; }
|
|
231
|
+
let tries = 0;
|
|
232
|
+
const poll = async () => {
|
|
233
|
+
tries++;
|
|
234
|
+
const p = await getJson("/api/multisite/sites/" + slug + "/appearance");
|
|
235
|
+
if (p.ok && p.body.current === to) { loadAppearance(); return; }
|
|
236
|
+
if (tries > 40) { msg.className = "errtext"; msg.textContent = "This is taking longer than expected. Check back in a minute."; return; }
|
|
237
|
+
setTimeout(poll, 2000);
|
|
238
|
+
};
|
|
239
|
+
poll();
|
|
240
|
+
});
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
(mode === "new" ? loadWizard : mode === "appearance" ? loadAppearance : loadList)();
|
|
244
|
+
})();
|
|
245
|
+
`;
|
|
246
|
+
|
|
247
|
+
const STYLE = String.raw`
|
|
248
|
+
:root { color-scheme: light dark; }
|
|
249
|
+
* { box-sizing: border-box; }
|
|
250
|
+
body { margin: 0; font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
251
|
+
background: light-dark(#f6f7f9, #0b0c0e); color: light-dark(#111, #e8e8e8); }
|
|
252
|
+
a { color: inherit; }
|
|
253
|
+
header { padding: 14px 24px; border-bottom: 1px solid light-dark(#e5e7eb, #1f2124);
|
|
254
|
+
display: flex; align-items: center; gap: 24px; flex-wrap: wrap; }
|
|
255
|
+
header strong { font-size: 16px; }
|
|
256
|
+
nav { display: flex; gap: 16px; flex-wrap: wrap; }
|
|
257
|
+
nav a { text-decoration: none; color: light-dark(#4b5563, #9aa0a6); padding: 4px 0; border-bottom: 2px solid transparent; }
|
|
258
|
+
nav a:hover, nav a[aria-current="page"] { color: inherit; border-bottom-color: currentColor; }
|
|
259
|
+
main { padding: 24px; max-width: 920px; margin: 0 auto; }
|
|
260
|
+
h1 { font-size: 22px; margin: 0 0 16px; }
|
|
261
|
+
h2 { font-size: 16px; margin: 0; }
|
|
262
|
+
.head { margin-bottom: 16px; } .head h1 { margin: 0; }
|
|
263
|
+
.row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
|
264
|
+
.muted { color: light-dark(#6b7280, #9aa0a6); font-size: 13px; margin: 4px 0; }
|
|
265
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
|
|
266
|
+
.card { background: light-dark(#fff, #141618); border: 1px solid light-dark(#e5e7eb, #1f2124); border-radius: 12px; padding: 16px; }
|
|
267
|
+
.actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
|
|
268
|
+
.btn { display: inline-block; font: inherit; font-weight: 600; font-size: 14px; cursor: pointer; text-decoration: none;
|
|
269
|
+
padding: 8px 14px; border-radius: 8px; border: 1px solid light-dark(#d1d5db, #2b2f33);
|
|
270
|
+
background: light-dark(#fff, #1a1c1f); color: inherit; }
|
|
271
|
+
.btn.primary { background: #2563eb; border-color: #2563eb; color: #fff; }
|
|
272
|
+
.btn.danger { color: #dc2626; border-color: #dc262666; }
|
|
273
|
+
.btn:focus-visible, input:focus-visible, a:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
|
|
274
|
+
.btn[disabled], .btn[aria-disabled="true"] { opacity: .5; cursor: not-allowed; pointer-events: none; }
|
|
275
|
+
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
|
|
276
|
+
.pill { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; }
|
|
277
|
+
.ready { background: #10b98122; color: #059669; }
|
|
278
|
+
.provisioning { background: #f59e0b22; color: #b45309; }
|
|
279
|
+
.failed { background: #ef444422; color: #dc2626; }
|
|
280
|
+
.unknown { background: #6b728022; color: #6b7280; }
|
|
281
|
+
.note { padding: 12px 16px; border: 1px solid #f59e0b; border-radius: 10px; background: #f59e0b14; margin-bottom: 16px; }
|
|
282
|
+
.err { padding: 16px; border: 1px solid #ef4444; border-radius: 10px; color: #dc2626; background: #ef444411; }
|
|
283
|
+
.errtext { color: #dc2626; font-size: 13px; margin: 4px 0; }
|
|
284
|
+
.okmsg { color: #059669; font-size: 13px; margin: 4px 0; }
|
|
285
|
+
.empty { padding: 32px; text-align: center; border: 1px dashed light-dark(#d1d5db, #2b2f33); border-radius: 12px; }
|
|
286
|
+
form { max-width: 460px; }
|
|
287
|
+
label { display: block; font-weight: 600; margin: 16px 0 6px; }
|
|
288
|
+
input { width: 100%; font: inherit; padding: 9px 12px; border-radius: 8px; border: 1px solid light-dark(#d1d5db, #2b2f33);
|
|
289
|
+
background: light-dark(#fff, #141618); color: inherit; }
|
|
290
|
+
.addr { display: flex; align-items: center; gap: 8px; } .addr input { flex: 1; }
|
|
291
|
+
form .btn { margin-top: 16px; }
|
|
292
|
+
label.check { display: flex; align-items: center; gap: 8px; font-weight: 400; margin: 16px 0 0; }
|
|
293
|
+
label.check input { width: auto; }
|
|
294
|
+
fieldset.themes { border: 0; padding: 0; margin: 16px 0 0; display: grid; gap: 8px; }
|
|
295
|
+
fieldset.themes legend { font-weight: 600; margin-bottom: 6px; padding: 0; }
|
|
296
|
+
label.theme { display: flex; align-items: flex-start; gap: 10px; margin: 0; font-weight: 400; padding: 10px 12px;
|
|
297
|
+
border: 1px solid light-dark(#d1d5db, #2b2f33); border-radius: 10px; background: light-dark(#fff, #141618); cursor: pointer; }
|
|
298
|
+
label.theme:has(input:checked) { border-color: #2563eb; box-shadow: 0 0 0 1px #2563eb; }
|
|
299
|
+
label.theme input { width: auto; margin-top: 4px; }
|
|
300
|
+
label.theme small { display: block; color: light-dark(#6b7280, #9aa0a6); }
|
|
301
|
+
`;
|
|
302
|
+
|
|
303
|
+
export function consolePageHtml(mode: "list" | "new" | "appearance", slug = ""): string {
|
|
304
|
+
return `<!doctype html>
|
|
305
|
+
<html lang="en">
|
|
306
|
+
<head>
|
|
307
|
+
<meta charset="utf-8">
|
|
308
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
309
|
+
<title>${mode === "new" ? "Create a site" : mode === "appearance" ? "Appearance" : "My sites"}</title>
|
|
310
|
+
<style>${STYLE}</style>
|
|
311
|
+
</head>
|
|
312
|
+
<body data-mode="${mode}" data-slug="${slug.replace(/[^a-z0-9-]/g, "")}">
|
|
313
|
+
<header><strong>Sites</strong><nav id="nav" aria-label="Main"></nav></header>
|
|
314
|
+
<main><div id="root"><p class="muted">Loading\u2026</p></div></main>
|
|
315
|
+
<script>${CLIENT_JS}</script>
|
|
316
|
+
</body>
|
|
317
|
+
</html>`;
|
|
318
|
+
}
|
package/src/console.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data behind the customer console (/sites, /sites/new): the signed-in user's
|
|
3
|
+
* sites and whether they may create another. Read-only; creation itself goes
|
|
4
|
+
* through Better Auth's organization/create so every rule in one place applies.
|
|
5
|
+
*/
|
|
6
|
+
import { isStaff } from "./fleet.ts";
|
|
7
|
+
import { billingEnabled, isEmailVerified, readActivePlan, readAllStates, readAllStatuses, readOrgs, readSiteTheme, resolveLimits } from "./registry.ts";
|
|
8
|
+
import { effectiveTheme, needsReconcile, slugProblem, switchSummary, themeFromMetadata, type SiteStatus } from "./rules.ts";
|
|
9
|
+
import { siteUrl, type SitesConfig } from "./sites.ts";
|
|
10
|
+
|
|
11
|
+
export interface MySite {
|
|
12
|
+
slug: string;
|
|
13
|
+
name: string;
|
|
14
|
+
/** The user's Better Auth role in this org: owner, admin, member. */
|
|
15
|
+
role: string;
|
|
16
|
+
/** Theme display name, or null when the platform has a single bundled theme. */
|
|
17
|
+
theme: string | null;
|
|
18
|
+
/** Theme id (key in `themes`), for matching previews. */
|
|
19
|
+
themeId: string | null;
|
|
20
|
+
status: SiteStatus["status"] | "unknown";
|
|
21
|
+
/** `suspended` sites show but cannot be opened or changed; deleted ones are not listed at all. */
|
|
22
|
+
state: "active" | "suspended";
|
|
23
|
+
error?: string;
|
|
24
|
+
/** Public address, or null when no hostname is configured for the site. */
|
|
25
|
+
url: string | null;
|
|
26
|
+
/** The site's own EmDash admin. */
|
|
27
|
+
adminUrl: string | null;
|
|
28
|
+
/** Provisioning needs a push (failed, or a run that died) and the user may trigger it. */
|
|
29
|
+
canRetry: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ConsoleState {
|
|
33
|
+
sites: MySite[];
|
|
34
|
+
emailVerified: boolean;
|
|
35
|
+
staff: boolean;
|
|
36
|
+
/** Does the platform offer more than one theme? (Shows the Appearance action.) */
|
|
37
|
+
themed: boolean;
|
|
38
|
+
/** Sites owned vs. the cap (`max` null = no cap). The cap is the user's PLAN cap. */
|
|
39
|
+
owned: number;
|
|
40
|
+
max: number | null;
|
|
41
|
+
/** The user's active plan name (lowercased), or null for the free tier. Drives the "N of M used" + Upgrade copy. */
|
|
42
|
+
plan: string | null;
|
|
43
|
+
/** Billing is configured, so `/account/billing` exists and the Upgrade link should show. */
|
|
44
|
+
canUpgrade: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function consoleState(userId: string, config: SitesConfig): Promise<ConsoleState> {
|
|
48
|
+
const [orgs, statuses, states, emailVerified, staff, plan, limits, canUpgrade] = await Promise.all([
|
|
49
|
+
readOrgs(),
|
|
50
|
+
readAllStatuses(),
|
|
51
|
+
readAllStates(),
|
|
52
|
+
isEmailVerified(userId),
|
|
53
|
+
isStaff(userId),
|
|
54
|
+
readActivePlan(userId),
|
|
55
|
+
resolveLimits(userId, config.limits),
|
|
56
|
+
billingEnabled(),
|
|
57
|
+
]);
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
const sites: MySite[] = [];
|
|
60
|
+
for (const org of orgs) {
|
|
61
|
+
const me = org.members.find((m) => m.userId === userId);
|
|
62
|
+
if (!me) continue;
|
|
63
|
+
const siteState = states.get(org.slug);
|
|
64
|
+
if (siteState?.state === "deleted") continue;
|
|
65
|
+
// The site's own option wins over the org metadata: it changes when the owner switches themes.
|
|
66
|
+
const live = config.themes ? await readSiteTheme(org.slug) : null;
|
|
67
|
+
const themeId = config.themes ? effectiveTheme(live ?? themeFromMetadata(org.metadata), config.themes, config.defaultTheme) : "";
|
|
68
|
+
const st = statuses.get(org.slug);
|
|
69
|
+
const url = siteUrl(org.slug, config);
|
|
70
|
+
sites.push({
|
|
71
|
+
slug: org.slug,
|
|
72
|
+
name: org.name,
|
|
73
|
+
role: me.role,
|
|
74
|
+
theme: config.themes ? (config.themes[themeId]?.name ?? null) : null,
|
|
75
|
+
themeId: config.themes ? themeId : null,
|
|
76
|
+
status: st?.status ?? "unknown",
|
|
77
|
+
state: siteState?.state === "suspended" ? "suspended" : "active",
|
|
78
|
+
...(st?.status === "failed" ? { error: st.error } : {}),
|
|
79
|
+
url,
|
|
80
|
+
adminUrl: url && !siteState ? `/sites/${org.slug}/open-admin` : null,
|
|
81
|
+
canRetry: !siteState && (me.role === "owner" || staff) && needsReconcile(st ?? null, now),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
sites.sort((a, b) => a.name.localeCompare(b.name));
|
|
85
|
+
return {
|
|
86
|
+
sites,
|
|
87
|
+
emailVerified,
|
|
88
|
+
staff,
|
|
89
|
+
themed: Object.keys(config.themes ?? {}).length > 1,
|
|
90
|
+
owned: sites.filter((s) => s.role === "owner").length,
|
|
91
|
+
// The user's PLAN cap, not the static config cap, so "N of M used" is true.
|
|
92
|
+
max: limits.sitesPerUser ?? null,
|
|
93
|
+
plan,
|
|
94
|
+
canUpgrade,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Is this address free and allowed? Used for the live check in the wizard. */
|
|
99
|
+
export async function checkAddress(slug: unknown, primary: string): Promise<{ available: boolean; message: string | null }> {
|
|
100
|
+
const problem = slugProblem(slug, primary);
|
|
101
|
+
if (problem) return { available: false, message: problem };
|
|
102
|
+
const taken = (await readOrgs()).some((o) => o.slug === slug);
|
|
103
|
+
return taken ? { available: false, message: "That address is taken." } : { available: true, message: null };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** May this user change the site's settings (theme)? The owner, or staff; the control plane is staff only. */
|
|
107
|
+
export async function canManage(userId: string, slug: string, primary: string): Promise<boolean> {
|
|
108
|
+
if (await isStaff(userId)) return true;
|
|
109
|
+
if (slug === primary) return false;
|
|
110
|
+
const org = (await readOrgs()).find((o) => o.slug === slug);
|
|
111
|
+
return org?.ownerUserId === userId;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface AppearanceState {
|
|
115
|
+
slug: string;
|
|
116
|
+
name: string;
|
|
117
|
+
current: string;
|
|
118
|
+
themes: Array<{ id: string; name: string; description: string; current: boolean; adds: string[]; hides: string[] }>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The theme picker data for one site, with what each switch would add and hide. */
|
|
122
|
+
export async function appearanceState(slug: string, config: SitesConfig): Promise<AppearanceState | null> {
|
|
123
|
+
if (!config.themes) return null;
|
|
124
|
+
const org = (await readOrgs()).find((o) => o.slug === slug);
|
|
125
|
+
if (!org && slug !== config.primary) return null;
|
|
126
|
+
const current = effectiveTheme((await readSiteTheme(slug)) ?? themeFromMetadata(org?.metadata), config.themes, config.defaultTheme);
|
|
127
|
+
return {
|
|
128
|
+
slug,
|
|
129
|
+
name: org?.name ?? slug,
|
|
130
|
+
current,
|
|
131
|
+
themes: Object.entries(config.themes).map(([id, t]) => ({
|
|
132
|
+
id,
|
|
133
|
+
name: t.name,
|
|
134
|
+
description: t.description ?? "",
|
|
135
|
+
pitch: t.pitch,
|
|
136
|
+
preview: t.preview,
|
|
137
|
+
current: id === current,
|
|
138
|
+
...switchSummary(config.themes!, current, id),
|
|
139
|
+
})),
|
|
140
|
+
};
|
|
141
|
+
}
|
package/src/db/d1.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createDialect as createInner } from "@emdash-cms/cloudflare/db/d1";
|
|
2
|
+
import { makeRuntime } from "../runtime.ts";
|
|
3
|
+
|
|
4
|
+
type InnerConfig = Parameters<typeof createInner>[0];
|
|
5
|
+
|
|
6
|
+
// ponytail: D1 Sessions (read replicas, bookmark cookies) are not used; every
|
|
7
|
+
// request gets the plain binding. Upgrade path: build the per-site Kysely over
|
|
8
|
+
// the inner adapter's session-backed dialect.
|
|
9
|
+
export const { createDialect, createRequestScopedDb } = makeRuntime<InnerConfig>(createInner);
|
package/src/db/sqlite.ts
ADDED
package/src/demo.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where each theme's public demo lives. The template site `tpl-<theme>` is served like any other site
|
|
3
|
+
* (a `hosts` entry, or `tpl-<theme>.<wildcard>`), read-only (see `demoGuard`). A theme has a demo once its
|
|
4
|
+
* template is complete, so the pickers only link to demos that exist.
|
|
5
|
+
*/
|
|
6
|
+
import { hasTemplate } from "./clone-run.ts";
|
|
7
|
+
import { templateId } from "./rules.ts";
|
|
8
|
+
import { siteUrl, type SitesConfig } from "./sites.ts";
|
|
9
|
+
|
|
10
|
+
const TTL_MS = 60_000;
|
|
11
|
+
let memo: { at: number; urls: Record<string, string> } | undefined;
|
|
12
|
+
|
|
13
|
+
/** Theme id -> demo URL, for the themes that have a complete template and an address. */
|
|
14
|
+
export async function demoUrls(sites: SitesConfig): Promise<Record<string, string>> {
|
|
15
|
+
if (memo && Date.now() - memo.at < TTL_MS) return memo.urls;
|
|
16
|
+
const urls: Record<string, string> = {};
|
|
17
|
+
for (const theme of Object.keys(sites.themes ?? {})) {
|
|
18
|
+
const url = siteUrl(templateId(theme), sites);
|
|
19
|
+
if (url && (await hasTemplate(theme))) urls[theme] = url;
|
|
20
|
+
}
|
|
21
|
+
memo = { at: Date.now(), urls };
|
|
22
|
+
return urls;
|
|
23
|
+
}
|