emdash-multisite 0.0.0-stage → 0.8.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/handoff.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signed one-time handoff from the control plane to a tenant site.
|
|
3
|
+
*
|
|
4
|
+
* Cookies are per host, so a user signed in on the control plane is a stranger
|
|
5
|
+
* on `acme.example.com`. "Open admin" therefore goes through a short-lived link
|
|
6
|
+
* the control plane signs for (user, site); the tenant checks it and starts the
|
|
7
|
+
* user's session there. Pure crypto only, so it runs under plain Node for the
|
|
8
|
+
* self-check; storage of the secret and of used nonces lives in registry.ts.
|
|
9
|
+
*/
|
|
10
|
+
const enc = new TextEncoder();
|
|
11
|
+
|
|
12
|
+
export interface HandoffClaims {
|
|
13
|
+
/** The user's id (the same id on the control plane and in the tenant copy). */
|
|
14
|
+
uid: string;
|
|
15
|
+
/** The tenant site the link is for. */
|
|
16
|
+
site: string;
|
|
17
|
+
/** Expiry, ms since epoch. */
|
|
18
|
+
exp: number;
|
|
19
|
+
/** Random id, accepted once. */
|
|
20
|
+
nonce: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A signed link lives this long. It only has to survive one redirect. */
|
|
24
|
+
export const HANDOFF_TTL_MS = 60_000;
|
|
25
|
+
|
|
26
|
+
const b64url = (bytes: Uint8Array): string => btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
27
|
+
const fromB64url = (s: string): Uint8Array<ArrayBuffer> => Uint8Array.from(atob(s.replaceAll("-", "+").replaceAll("_", "/")), (c) => c.charCodeAt(0));
|
|
28
|
+
|
|
29
|
+
async function hmac(secret: string): Promise<CryptoKey> {
|
|
30
|
+
return crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function signHandoff(secret: string, claims: Omit<HandoffClaims, "nonce" | "exp">, now = Date.now()): Promise<string> {
|
|
34
|
+
const full: HandoffClaims = { ...claims, exp: now + HANDOFF_TTL_MS, nonce: b64url(crypto.getRandomValues(new Uint8Array(16))) };
|
|
35
|
+
const body = b64url(enc.encode(JSON.stringify(full)));
|
|
36
|
+
const sig = new Uint8Array(await crypto.subtle.sign("HMAC", await hmac(secret), enc.encode(body)));
|
|
37
|
+
return `${body}.${b64url(sig)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The claims if the token is genuine, unexpired and for `site`; otherwise null. */
|
|
41
|
+
export async function verifyHandoff(secret: string, token: string, site: string, now = Date.now()): Promise<HandoffClaims | null> {
|
|
42
|
+
const [body, sig, extra] = token.split(".");
|
|
43
|
+
if (!body || !sig || extra !== undefined) return null;
|
|
44
|
+
try {
|
|
45
|
+
const ok = await crypto.subtle.verify("HMAC", await hmac(secret), fromB64url(sig), enc.encode(body));
|
|
46
|
+
if (!ok) return null;
|
|
47
|
+
const claims = JSON.parse(new TextDecoder().decode(fromB64url(body))) as Partial<HandoffClaims>;
|
|
48
|
+
if (typeof claims.uid !== "string" || typeof claims.nonce !== "string" || typeof claims.exp !== "number") return null;
|
|
49
|
+
if (claims.site !== site || claims.exp < now) return null;
|
|
50
|
+
return claims as HandoffClaims;
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Where a handoff may land: an admin path on the tenant only, never another host. */
|
|
57
|
+
export const safeNext = (v: string | null): string =>
|
|
58
|
+
v && /^\/_emdash\/[\w\-./?=&%]*$/.test(v) && !v.includes("//") && !v.includes("..") ? v : "/_emdash/admin";
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { DatabaseDescriptor } from "emdash/db";
|
|
2
|
+
import { assertSitesConfig, type SitesConfig } from "./sites.ts";
|
|
3
|
+
|
|
4
|
+
export { runAsSite } from "./dialect.ts";
|
|
5
|
+
export { multisitePlugin } from "./plugin.ts";
|
|
6
|
+
export { resolveSite, type SitesConfig } from "./sites.ts";
|
|
7
|
+
|
|
8
|
+
/** Inner adapter entrypoint -> our wrapping entrypoint. */
|
|
9
|
+
const WRAPPERS: Record<string, string> = {
|
|
10
|
+
"emdash/db/sqlite": "emdash-multisite/db/sqlite",
|
|
11
|
+
"@emdash-cms/cloudflare/db/d1": "emdash-multisite/db/d1",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Wrap a database descriptor so one database serves many sites.
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* emdash({ database: withSites(d1({ binding: "DB" }), { primary: "main", wildcard: "example.com" }) })
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export function withSites(inner: DatabaseDescriptor, sites: SitesConfig): DatabaseDescriptor {
|
|
22
|
+
assertSitesConfig(sites);
|
|
23
|
+
const entrypoint = WRAPPERS[inner.entrypoint];
|
|
24
|
+
if (!entrypoint) {
|
|
25
|
+
throw new Error(`[multisite] unsupported database adapter: ${inner.entrypoint}`);
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
entrypoint,
|
|
29
|
+
config: { inner: inner.config, sites },
|
|
30
|
+
type: inner.type,
|
|
31
|
+
supportsRequestScope: true,
|
|
32
|
+
// Omitted on purpose: `migrations` (deploy-time runner and coalescing and
|
|
33
|
+
// the collection-deletion guard address one site's tables only).
|
|
34
|
+
};
|
|
35
|
+
}
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One JSON line per notable event, so Workers Logs (set `"observability": { "enabled": true }` in wrangler.jsonc)
|
|
3
|
+
* can filter and count them: `event = "site.ready"`, `event = "signup.rate_limited"`, ...
|
|
4
|
+
* Never log secrets, passwords, tokens or full email addresses.
|
|
5
|
+
*/
|
|
6
|
+
export const log = (event: string, fields: Record<string, unknown> = {}): void => console.log(JSON.stringify({ app: "multisite", event, ...fields }));
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The media R2 bucket as a raw binding, for work outside a request's storage wrapper:
|
|
3
|
+
* deleting a site's files on purge, and keeping site backups. Shared code, so it reaches the
|
|
4
|
+
* binding through `platformEnv()` (no static `cloudflare:workers` import).
|
|
5
|
+
*/
|
|
6
|
+
// @ts-ignore - virtual module provided by EmDash's integration
|
|
7
|
+
import emdashConfig from "virtual:emdash/config";
|
|
8
|
+
import { platformEnv } from "./platform-env.ts";
|
|
9
|
+
import { backupPrefix, mediaKey } from "./rules.ts";
|
|
10
|
+
|
|
11
|
+
export interface Bucket {
|
|
12
|
+
list(o: object): Promise<{ objects: { key: string }[]; truncated: boolean; cursor?: string }>;
|
|
13
|
+
delete(keys: string | string[]): Promise<void>;
|
|
14
|
+
put(key: string, value: string): Promise<unknown>;
|
|
15
|
+
get(key: string): Promise<{ text(): Promise<string> } | null>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The bucket named by the host's `storage` config, or undefined (no R2, or not on Workers). */
|
|
19
|
+
export async function mediaBucket(): Promise<Bucket | undefined> {
|
|
20
|
+
const binding = (emdashConfig as { storage?: { config?: { binding?: string } } }).storage?.config?.binding;
|
|
21
|
+
return binding ? ((await platformEnv())?.[binding] as Bucket | undefined) : undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Delete every object under a prefix. Returns how many were removed. */
|
|
25
|
+
async function deletePrefix(bucket: Bucket, prefix: string): Promise<number> {
|
|
26
|
+
let n = 0, cursor: string | undefined;
|
|
27
|
+
do {
|
|
28
|
+
const page = await bucket.list({ prefix, cursor, limit: 1000 });
|
|
29
|
+
if (page.objects.length) await bucket.delete(page.objects.map((o) => o.key));
|
|
30
|
+
n += page.objects.length;
|
|
31
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
32
|
+
} while (cursor);
|
|
33
|
+
return n;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Delete a site's uploaded files and its backups (used on purge: a deleted customer's data must not
|
|
38
|
+
* outlive the grace period). Returns the number removed; 0 when no R2 storage is configured.
|
|
39
|
+
*/
|
|
40
|
+
export async function purgeSiteMedia(slug: string): Promise<number> {
|
|
41
|
+
const bucket = await mediaBucket();
|
|
42
|
+
if (!bucket) return 0;
|
|
43
|
+
return (await deletePrefix(bucket, mediaKey(slug, ""))) + (await deletePrefix(bucket, backupPrefix(slug)));
|
|
44
|
+
}
|