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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +681 -2
  3. package/SETUP.md +205 -0
  4. package/bin/setup.mjs +83 -0
  5. package/package.json +91 -4
  6. package/src/abuse.ts +65 -0
  7. package/src/auth-links.ts +21 -0
  8. package/src/auth-ui.ts +390 -0
  9. package/src/backup.ts +137 -0
  10. package/src/bootstrap.ts +51 -0
  11. package/src/clone-run.ts +57 -0
  12. package/src/clone.ts +77 -0
  13. package/src/config.ts +17 -0
  14. package/src/console-page.ts +318 -0
  15. package/src/console.ts +141 -0
  16. package/src/db/d1.ts +9 -0
  17. package/src/db/sqlite.ts +6 -0
  18. package/src/demo.ts +23 -0
  19. package/src/dialect.ts +109 -0
  20. package/src/edge-cache.ts +249 -0
  21. package/src/fleet-actions.ts +44 -0
  22. package/src/fleet-blocks.ts +115 -0
  23. package/src/fleet.ts +151 -0
  24. package/src/handoff.ts +58 -0
  25. package/src/index.ts +35 -0
  26. package/src/log.ts +6 -0
  27. package/src/media-bucket.ts +44 -0
  28. package/src/middleware.ts +607 -0
  29. package/src/orgs.ts +343 -0
  30. package/src/passwords.ts +60 -0
  31. package/src/platform-env.ts +10 -0
  32. package/src/plugin-entry.ts +145 -0
  33. package/src/plugin.ts +26 -0
  34. package/src/provision.ts +17 -0
  35. package/src/purge.ts +105 -0
  36. package/src/registry.ts +421 -0
  37. package/src/rewrite.ts +199 -0
  38. package/src/rules.ts +521 -0
  39. package/src/runtime.ts +69 -0
  40. package/src/scheduled.ts +107 -0
  41. package/src/scope.ts +178 -0
  42. package/src/sites.ts +97 -0
  43. package/src/slots.ts +38 -0
  44. package/src/storage.ts +57 -0
  45. package/src/vite.ts +256 -0
  46. package/templates/astro.config.snippet.mjs +42 -0
  47. package/templates/worker.ts +61 -0
  48. 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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }