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/SETUP.md
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# Setup — wire emdash-multisite into an EmDash + Cloudflare app
|
|
2
|
+
|
|
3
|
+
This is the copy-paste wiring to turn a normal single-site EmDash app (Astro +
|
|
4
|
+
`@astrojs/cloudflare` + D1) into a multi-tenant one: many sites, one Worker, one
|
|
5
|
+
D1 database, subdomain-per-site.
|
|
6
|
+
|
|
7
|
+
> Prefer one command? `npx create-emdash-multisite` scaffolds all of this into an
|
|
8
|
+
> existing EmDash app. This file is the manual reference for what that does and
|
|
9
|
+
> for customising it.
|
|
10
|
+
|
|
11
|
+
**Prerequisites**
|
|
12
|
+
- An EmDash app that already builds and deploys to Cloudflare Workers with a D1
|
|
13
|
+
binding (`@emdash-cms/cloudflare`, `d1({ binding: "DB" })`).
|
|
14
|
+
- `emdash` ≥ 0.30, `kysely` ≥ 0.29, Node ≥ 22.18.
|
|
15
|
+
- Install: `pnpm add emdash-multisite` (or npm/yarn).
|
|
16
|
+
|
|
17
|
+
There are **four** edits. Three are in `astro.config.mjs`; one is `src/worker.ts`
|
|
18
|
+
plus two lines of `wrangler.jsonc`. Read the main `README.md` for what each
|
|
19
|
+
mechanism does and the gotchas; this file is just the wiring.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## 1. `astro.config.mjs` — the three wires
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
// --- add these imports ---
|
|
27
|
+
import { d1, r2 } from "@emdash-cms/cloudflare";
|
|
28
|
+
import { multisitePlugin, withSites } from "emdash-multisite";
|
|
29
|
+
import { multisiteCaches } from "emdash-multisite/vite";
|
|
30
|
+
import emdash from "emdash/astro";
|
|
31
|
+
|
|
32
|
+
export default defineConfig({
|
|
33
|
+
// WIRE 2 — per-site caches. Add the Vite plugin so EmDash's process-wide
|
|
34
|
+
// caches are rewritten to be per-site at build time. Without this, one
|
|
35
|
+
// tenant's cached settings/collections/etc. leak into another. The build
|
|
36
|
+
// fails loudly if a core cache is unclassified (that is the safety net).
|
|
37
|
+
vite: {
|
|
38
|
+
plugins: [multisiteCaches()],
|
|
39
|
+
// ...your other vite plugins
|
|
40
|
+
},
|
|
41
|
+
integrations: [
|
|
42
|
+
emdash({
|
|
43
|
+
// WIRE 1 — database wrapper. withSites() wraps your D1 descriptor so each
|
|
44
|
+
// site reads/writes its own prefixed tables (ec_posts$acme). The "primary"
|
|
45
|
+
// site keeps unprefixed names, so an existing single-site install is
|
|
46
|
+
// unchanged.
|
|
47
|
+
database: withSites(d1({ binding: "DB" }), {
|
|
48
|
+
primary: "main", // the control-plane site (unprefixed tables)
|
|
49
|
+
wildcard: "example.com", // slug.example.com -> site "slug" (production)
|
|
50
|
+
// hosts: { "acme.example.com": "acme" }, // explicit host->site (test/workers.dev)
|
|
51
|
+
// themes, defaultTheme, limits, appUrl, accountTab, authLinks — see README.
|
|
52
|
+
}),
|
|
53
|
+
|
|
54
|
+
// Per-site media in one R2 bucket (keys are namespaced per site).
|
|
55
|
+
storage: { ...r2({ binding: "MEDIA" }), entrypoint: "emdash-multisite/storage" },
|
|
56
|
+
|
|
57
|
+
// WIRE 3 — outer middleware. Resolves the site from the request hostname
|
|
58
|
+
// before EmDash reads anything, and (on the primary site) turns "create a
|
|
59
|
+
// Better Auth organization" into "provision a site".
|
|
60
|
+
middleware: { outer: "emdash-multisite/middleware" },
|
|
61
|
+
|
|
62
|
+
// The provisioning / console / fleet glue (org-create guard, status API,
|
|
63
|
+
// theme switch, customer "My sites" console).
|
|
64
|
+
plugins: [multisitePlugin()],
|
|
65
|
+
}),
|
|
66
|
+
],
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`primary` is the only required field. `wildcard` (production) or `hosts`
|
|
71
|
+
(test / `workers.dev`, which has no wildcard subdomains) decides how a hostname
|
|
72
|
+
maps to a site. Full config reference: `README.md`.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## 2. `src/worker.ts` — the host-aware edge cache
|
|
77
|
+
|
|
78
|
+
Cloudflare Workers Cache keys entries by **path + query, not hostname**, and one
|
|
79
|
+
Worker serves every tenant. So the host must go into the cache key via a cached
|
|
80
|
+
inner entrypoint. This is the one piece that cannot live inside the npm package
|
|
81
|
+
(it is per-project Cloudflare Worker config). Copy this file:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import handler, { createScheduledHandler } from "@emdash-cms/cloudflare/worker";
|
|
85
|
+
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
86
|
+
import {
|
|
87
|
+
EDGE_REQUEST_HEADER,
|
|
88
|
+
cacheableRequest,
|
|
89
|
+
guardResponse,
|
|
90
|
+
normalizeHost,
|
|
91
|
+
} from "emdash-multisite/edge-cache";
|
|
92
|
+
import { perSiteScheduled } from "emdash-multisite/scheduled";
|
|
93
|
+
|
|
94
|
+
/** Inner entrypoint: Workers Cache is ON here only; `props.host` is part of the key. */
|
|
95
|
+
export class PublicCache extends WorkerEntrypoint<Env, { host: string; gpc: boolean }> {
|
|
96
|
+
async fetch(request: Request): Promise<Response> {
|
|
97
|
+
const headers = new Headers(request.headers);
|
|
98
|
+
headers.set(EDGE_REQUEST_HEADER, "1"); // ask the middleware to tag the response with its site
|
|
99
|
+
const res = await handler.fetch(
|
|
100
|
+
new Request(request, { headers }) as never,
|
|
101
|
+
this.env as never,
|
|
102
|
+
this.ctx as never,
|
|
103
|
+
);
|
|
104
|
+
return guardResponse(res as Response); // default-deny: only a clean anonymous 200 is stored
|
|
105
|
+
}
|
|
106
|
+
/** Purges only work from inside the cached entrypoint, so callers RPC here. */
|
|
107
|
+
async purgeTag(tags: string[]) {
|
|
108
|
+
const { cache } = await import("cloudflare:workers");
|
|
109
|
+
return cache.purge({ tags });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
type Gateway = {
|
|
114
|
+
exports: Record<string, (o: { props: { host: string; gpc: boolean } }) => Fetcher>;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export default {
|
|
118
|
+
...handler,
|
|
119
|
+
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
|
120
|
+
const url = new URL(request.url);
|
|
121
|
+
// Not cacheable (signed-in, non-GET, has query, admin/api): straight through, uncached.
|
|
122
|
+
if (!cacheableRequest(request.method, url, request.headers)) {
|
|
123
|
+
return handler.fetch(request as never, env as never, ctx as never);
|
|
124
|
+
}
|
|
125
|
+
const props = { host: normalizeHost(url.hostname), gpc: request.headers.get("sec-gpc") === "1" };
|
|
126
|
+
return (ctx as unknown as Gateway).exports.PublicCache({ props }).fetch(request);
|
|
127
|
+
},
|
|
128
|
+
// EmDash's scheduled handler serves one site; run it once per ready site.
|
|
129
|
+
scheduled: perSiteScheduled(createScheduledHandler() as never) as ExportedHandler["scheduled"],
|
|
130
|
+
} satisfies ExportedHandler;
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
> If you render comments/forms behind Turnstile, or want immutable cache headers
|
|
134
|
+
> on `/_image` and media, see the extended `worker.ts` in the reference host app
|
|
135
|
+
> (link in README). Those are optional add-ons, not required for multi-site.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## 3. `wrangler.jsonc` — point `main` at the Worker and declare the two entrypoints
|
|
140
|
+
|
|
141
|
+
```jsonc
|
|
142
|
+
{
|
|
143
|
+
"main": "./src/worker.ts",
|
|
144
|
+
|
|
145
|
+
// Host-aware edge cache: the default (gateway) entrypoint is uncached; the
|
|
146
|
+
// PublicCache entrypoint is cached, with the host in ctx.props.
|
|
147
|
+
"cache": { "enabled": true },
|
|
148
|
+
"exports": {
|
|
149
|
+
"default": { "type": "worker", "cache": { "enabled": false } },
|
|
150
|
+
"PublicCache": { "type": "worker", "cache": { "enabled": true } }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ...your existing d1_databases (binding "DB"), r2_buckets (binding "MEDIA"),
|
|
154
|
+
// kv, routes / custom_domain, compatibility_date, etc.
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
For production subdomains you also need, on the Cloudflare side:
|
|
159
|
+
- a **custom domain** route for the apex, and a route for `*.example.com`,
|
|
160
|
+
- a **wildcard DNS** record (`*` → the Worker, proxied).
|
|
161
|
+
|
|
162
|
+
On a `workers.dev` test Worker there is no wildcard: map each tenant hostname in
|
|
163
|
+
`hosts` and deploy with `wrangler versions upload --preview-alias <name>`.
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## 4. First admin (run once)
|
|
168
|
+
|
|
169
|
+
Tenant sites are born with an admin, so EmDash's first-run setup wizard never
|
|
170
|
+
runs on them. The **primary** (control-plane) site's first user signs up through
|
|
171
|
+
Better Auth as a normal subscriber — promote them once:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
import { bootstrapControlPlane } from "emdash-multisite/bootstrap";
|
|
175
|
+
// run once against the primary site's DB, with the user's email
|
|
176
|
+
await bootstrapControlPlane(db, "you@example.com");
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
See `README.md` → "First admin & the setup wizard".
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## Verify
|
|
184
|
+
|
|
185
|
+
From the package (Node ≥ 22.18):
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
# cache tripwire: scans your installed emdash + @emdash-cms/* for unclassified caches
|
|
189
|
+
node --no-warnings node_modules/emdash-multisite/scripts/caches.selfcheck.ts ./node_modules
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Then build, deploy, and open **two** hostnames mapping to two sites. Set a
|
|
193
|
+
different site title on each; the titles must not cross. That two-site title test
|
|
194
|
+
under concurrency is the real gate.
|
|
195
|
+
|
|
196
|
+
## Don'ts (the short list — full list in README "Limits & gotchas")
|
|
197
|
+
|
|
198
|
+
- Don't enable Workers Cache any other way (`cacheCloudflare()`, `routeRules`
|
|
199
|
+
maxAge): its key ignores the host and will cross tenants.
|
|
200
|
+
- Don't enable the EmDash object cache or assume R2 is per-site yet (keys are not
|
|
201
|
+
namespaced per site — media could cross).
|
|
202
|
+
- Don't save platform secrets in the control-plane admin settings: provisioning
|
|
203
|
+
copies auth settings into every tenant. Use Worker secrets / env instead.
|
|
204
|
+
- On every EmDash core upgrade, re-run the cache tripwire in staging before
|
|
205
|
+
deploying — a new core cache fails the build until you classify it.
|
package/bin/setup.mjs
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// emdash-multisite setup — scaffold the multi-tenant wiring into an existing
|
|
3
|
+
// EmDash + Cloudflare Astro app.
|
|
4
|
+
//
|
|
5
|
+
// Usage (from your EmDash app root):
|
|
6
|
+
// npx emdash-multisite setup [--force] [--dir .]
|
|
7
|
+
//
|
|
8
|
+
// What it does (idempotent, non-destructive by default):
|
|
9
|
+
// 1. Writes src/worker.ts (host-aware edge cache; the one per-project file)
|
|
10
|
+
// 2. Writes wrangler.multisite.jsonc (the keys to merge into your wrangler.jsonc)
|
|
11
|
+
// 3. Writes astro.config.multisite.mjs (the config to merge into your astro.config)
|
|
12
|
+
// 4. Prints the exact next steps (merge the two snippets, bootstrap the admin)
|
|
13
|
+
//
|
|
14
|
+
// It NEVER overwrites an existing file unless --force. Snippets for files it
|
|
15
|
+
// won't auto-merge (wrangler.jsonc, astro.config.mjs) are written next to them
|
|
16
|
+
// with a `.multisite.` marker so you can diff + merge by hand — merging JSONC
|
|
17
|
+
// and arbitrary astro config safely by machine is not worth the risk.
|
|
18
|
+
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { dirname, join, resolve } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
const args = process.argv.slice(2);
|
|
24
|
+
if (args[0] === "setup") args.shift(); // allow both `emdash-multisite setup` and bare
|
|
25
|
+
const force = args.includes("--force");
|
|
26
|
+
const dirFlag = args.indexOf("--dir");
|
|
27
|
+
const root = resolve(dirFlag >= 0 ? args[dirFlag + 1] : ".");
|
|
28
|
+
const templates = resolve(dirname(fileURLToPath(import.meta.url)), "..", "templates");
|
|
29
|
+
|
|
30
|
+
const C = { gray: "\x1b[90m", green: "\x1b[32m", yellow: "\x1b[33m", bold: "\x1b[1m", reset: "\x1b[0m" };
|
|
31
|
+
const log = (s = "") => process.stdout.write(`${s}\n`);
|
|
32
|
+
const ok = (s) => log(` ${C.green}✓${C.reset} ${s}`);
|
|
33
|
+
const skip = (s) => log(` ${C.yellow}•${C.reset} ${s}`);
|
|
34
|
+
|
|
35
|
+
function sanityCheck() {
|
|
36
|
+
const pkgPath = join(root, "package.json");
|
|
37
|
+
if (!existsSync(pkgPath)) {
|
|
38
|
+
log(`${C.yellow}No package.json in ${root}. Run this from your EmDash app root (or pass --dir).${C.reset}`);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
42
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
43
|
+
if (!deps.emdash) {
|
|
44
|
+
log(`${C.yellow}Warning: "emdash" is not a dependency here. This scaffolder is for an existing EmDash app.${C.reset}`);
|
|
45
|
+
}
|
|
46
|
+
return { pkgPath, pkg, deps };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function copy(fromName, toRel, { marker = false } = {}) {
|
|
50
|
+
const to = join(root, toRel);
|
|
51
|
+
const src = join(templates, fromName);
|
|
52
|
+
if (!existsSync(src)) {
|
|
53
|
+
skip(`template ${fromName} missing in package — skipped`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (existsSync(to) && !force) {
|
|
57
|
+
skip(`${toRel} exists — left as is ${C.gray}(--force to overwrite)${C.reset}`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
61
|
+
writeFileSync(to, readFileSync(src, "utf8"));
|
|
62
|
+
ok(`${marker ? "wrote snippet " : "wrote "}${toRel}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
log(`\n${C.bold}emdash-multisite setup${C.reset} ${C.gray}(${root})${C.reset}\n`);
|
|
66
|
+
const { deps } = sanityCheck();
|
|
67
|
+
|
|
68
|
+
// 1. the one real per-project file
|
|
69
|
+
copy("worker.ts", "src/worker.ts");
|
|
70
|
+
|
|
71
|
+
// 2 + 3. snippets to merge (never auto-edit the user's config/wrangler)
|
|
72
|
+
copy("wrangler.snippet.jsonc", "wrangler.multisite.jsonc", { marker: true });
|
|
73
|
+
copy("astro.config.snippet.mjs", "astro.config.multisite.mjs", { marker: true });
|
|
74
|
+
|
|
75
|
+
log(`\n${C.bold}Next steps${C.reset}`);
|
|
76
|
+
if (!deps["emdash-multisite"]) {
|
|
77
|
+
log(` 1. Install the package: ${C.bold}pnpm add emdash-multisite${C.reset}`);
|
|
78
|
+
}
|
|
79
|
+
log(` ${deps["emdash-multisite"] ? "1" : "2"}. Merge ${C.bold}astro.config.multisite.mjs${C.reset} into your astro.config.mjs (3 wires: database, vite plugin, middleware).`);
|
|
80
|
+
log(` ${deps["emdash-multisite"] ? "2" : "3"}. Merge ${C.bold}wrangler.multisite.jsonc${C.reset} into your wrangler.jsonc (main + cache + exports).`);
|
|
81
|
+
log(` ${deps["emdash-multisite"] ? "3" : "4"}. Bootstrap the control-plane admin once (see README "First admin"): ${C.gray}bootstrapControlPlane(db, "you@example.com")${C.reset}`);
|
|
82
|
+
log(` ${deps["emdash-multisite"] ? "4" : "5"}. Verify: ${C.gray}node --no-warnings node_modules/emdash-multisite/scripts/caches.selfcheck.ts ./node_modules${C.reset}`);
|
|
83
|
+
log(`\nFull guide: ${C.bold}SETUP.md${C.reset} · Reference & gotchas: ${C.bold}README.md${C.reset}\n`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,93 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "emdash-multisite",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Run many EmDash sites from one Cloudflare Worker and one D1 database. Per-site table prefixing in the Kysely dialect, per-site caches via a build-time patch, and hostname-based site resolution. No Workers for Platforms, no fork of EmDash core.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "The Weekend Projects",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/theweekendprojects/emdash-multisite.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/theweekendprojects/emdash-multisite#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/theweekendprojects/emdash-multisite/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"emdash",
|
|
18
|
+
"emdash-cms",
|
|
19
|
+
"cms",
|
|
20
|
+
"multi-tenant",
|
|
21
|
+
"multisite",
|
|
22
|
+
"multi-site",
|
|
23
|
+
"cloudflare",
|
|
24
|
+
"cloudflare-workers",
|
|
25
|
+
"d1",
|
|
26
|
+
"astro",
|
|
27
|
+
"kysely"
|
|
28
|
+
],
|
|
29
|
+
"main": "./src/index.ts",
|
|
30
|
+
"types": "./src/index.ts",
|
|
31
|
+
"bin": {
|
|
32
|
+
"emdash-multisite": "./bin/setup.mjs"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"src",
|
|
36
|
+
"templates",
|
|
37
|
+
"bin",
|
|
38
|
+
"README.md",
|
|
39
|
+
"SETUP.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"exports": {
|
|
43
|
+
".": "./src/index.ts",
|
|
44
|
+
"./db/d1": "./src/db/d1.ts",
|
|
45
|
+
"./db/sqlite": "./src/db/sqlite.ts",
|
|
46
|
+
"./provision": "./src/provision.ts",
|
|
47
|
+
"./vite": "./src/vite.ts",
|
|
48
|
+
"./middleware": "./src/middleware.ts",
|
|
49
|
+
"./bootstrap": "./src/bootstrap.ts",
|
|
50
|
+
"./fleet": "./src/fleet.ts",
|
|
51
|
+
"./scheduled": "./src/scheduled.ts",
|
|
52
|
+
"./storage": "./src/storage.ts",
|
|
53
|
+
"./auth-ui": "./src/auth-ui.ts",
|
|
54
|
+
"./auth-links": "./src/auth-links.ts",
|
|
55
|
+
"./plugin-entry": "./src/plugin-entry.ts",
|
|
56
|
+
"./plugin": "./src/plugin.ts",
|
|
57
|
+
"./edge-cache": "./src/edge-cache.ts"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"selfcheck": "node --no-warnings scripts/selfcheck.ts",
|
|
61
|
+
"typecheck": "tsc -p tsconfig.json"
|
|
62
|
+
},
|
|
63
|
+
"peerDependencies": {
|
|
64
|
+
"emdash": ">=0.30.0",
|
|
65
|
+
"@emdash-cms/cloudflare": "*",
|
|
66
|
+
"kysely": ">=0.29.0",
|
|
67
|
+
"@better-auth-ui/core": "*",
|
|
68
|
+
"react": "*",
|
|
69
|
+
"@heroui/react": ">=3.2.0"
|
|
70
|
+
},
|
|
71
|
+
"peerDependenciesMeta": {
|
|
72
|
+
"@emdash-cms/cloudflare": {
|
|
73
|
+
"optional": true
|
|
74
|
+
},
|
|
75
|
+
"@better-auth-ui/core": {
|
|
76
|
+
"optional": true
|
|
77
|
+
},
|
|
78
|
+
"react": {
|
|
79
|
+
"optional": true
|
|
80
|
+
},
|
|
81
|
+
"@heroui/react": {
|
|
82
|
+
"optional": true
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"devDependencies": {
|
|
86
|
+
"@types/node": "22.20.5",
|
|
87
|
+
"typescript": "5.9.3",
|
|
88
|
+
"kysely": "0.29.2"
|
|
89
|
+
},
|
|
90
|
+
"engines": {
|
|
91
|
+
"node": ">=22.18"
|
|
92
|
+
}
|
|
93
|
+
}
|
package/src/abuse.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brakes on automated abuse of open sign-up. Uses Cloudflare's Rate Limiting
|
|
3
|
+
* bindings when the host declares them, and does nothing when it does not:
|
|
4
|
+
* SIGNUP_LIMITER keyed by client IP (sign-up requests)
|
|
5
|
+
* CREATE_LIMITER keyed by user id (site creation, per user)
|
|
6
|
+
* CREATE_IP_LIMITER keyed by client IP (site creation, per IP - OP-13)
|
|
7
|
+
* Declare them in wrangler.jsonc, e.g.
|
|
8
|
+
* "ratelimits": [{ "name": "SIGNUP_LIMITER", "namespace_id": "1001", "simple": { "limit": 5, "period": 60 } }]
|
|
9
|
+
* ponytail: counters are per Cloudflare location and periods are 10 or 60 s, so this stops
|
|
10
|
+
* bursts, not slow trickles. Upgrade path: Turnstile on sign-up (needs a site key).
|
|
11
|
+
*/
|
|
12
|
+
import { platformEnv } from "./platform-env.ts";
|
|
13
|
+
|
|
14
|
+
export type Limiter = "SIGNUP_LIMITER" | "CREATE_LIMITER" | "CREATE_IP_LIMITER";
|
|
15
|
+
|
|
16
|
+
/** True when `key` is over the limit of the named binding. Fails open (no binding, or it errors). */
|
|
17
|
+
export async function overLimit(name: Limiter, key: string): Promise<boolean> {
|
|
18
|
+
const binding = (await platformEnv())?.[name] as { limit(o: { key: string }): Promise<{ success: boolean }> } | undefined;
|
|
19
|
+
if (!binding) return false;
|
|
20
|
+
try {
|
|
21
|
+
return !(await binding.limit({ key })).success;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* OP-13: Turnstile verification on sign-up.
|
|
29
|
+
* Verifies the Turnstile token sent by the client. When TURNSTILE_SECRET env var
|
|
30
|
+
* is unset (dev/test), the check is skipped (fails open).
|
|
31
|
+
* Returns null if verified, or an error response if failed/missing.
|
|
32
|
+
*/
|
|
33
|
+
export async function verifyTurnstile(token: string | undefined, remoteIp: string | undefined): Promise<Response | null> {
|
|
34
|
+
const secret = (await platformEnv())?.TURNSTILE_SECRET;
|
|
35
|
+
// No secret configured (dev/test, or Turnstile not enabled): skip entirely, including the token check,
|
|
36
|
+
// so the sign-up flow is unchanged until an operator sets TURNSTILE_SECRET and renders the widget.
|
|
37
|
+
if (typeof secret !== "string" || secret === "") return null;
|
|
38
|
+
|
|
39
|
+
const fail = (code: string, message: string) =>
|
|
40
|
+
new Response(JSON.stringify({ code, message }), {
|
|
41
|
+
status: 403,
|
|
42
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
43
|
+
});
|
|
44
|
+
// Secret is set, so the client must send a token.
|
|
45
|
+
if (!token) return fail("TURNSTILE_MISSING", "Security check failed. Please try again.");
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const formData = new FormData();
|
|
49
|
+
formData.append("response", token);
|
|
50
|
+
formData.append("secret", secret);
|
|
51
|
+
if (remoteIp) formData.append("remoteip", remoteIp);
|
|
52
|
+
|
|
53
|
+
const result = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
|
|
54
|
+
method: "POST",
|
|
55
|
+
body: formData,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const outcome = (await result.json()) as { success: boolean; "error-codes"?: string[] };
|
|
59
|
+
if (!outcome.success) return fail("TURNSTILE_FAILED", `Security check failed: ${outcome["error-codes"]?.join(", ") ?? "Invalid token"}`);
|
|
60
|
+
return null;
|
|
61
|
+
} catch {
|
|
62
|
+
// Network error - fail open to avoid blocking legitimate users
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which account links a theme header shows. Pure, so a plain-Node self-check covers it; the host's
|
|
3
|
+
* `AuthLinks.astro` component calls it and prints the result as plain `<a>` elements: no script, no extra request.
|
|
4
|
+
*
|
|
5
|
+
* - visitor, public sign-up on: Sign in, Sign up
|
|
6
|
+
* - visitor, public sign-up off: nothing
|
|
7
|
+
* - signed in: My account, and Admin from the author role (30) up (unless the theme draws its own)
|
|
8
|
+
*/
|
|
9
|
+
export interface AuthLink {
|
|
10
|
+
label: string;
|
|
11
|
+
href: string;
|
|
12
|
+
/** The call to action (Sign up). */
|
|
13
|
+
cta?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function authLinksFor(opts: { signup: boolean; account: string; user: { role?: number } | null | undefined; admin?: boolean }): AuthLink[] {
|
|
17
|
+
if (!opts.user) return opts.signup ? [{ label: "Sign in", href: "/login" }, { label: "Sign up", href: "/signup", cta: true }] : [];
|
|
18
|
+
const links: AuthLink[] = [{ label: "My account", href: opts.account }];
|
|
19
|
+
if (opts.admin !== false && (opts.user.role ?? 0) >= 30) links.push({ label: "Admin", href: "/_emdash/admin" });
|
|
20
|
+
return links;
|
|
21
|
+
}
|