@emdash-cms/cloudflare 0.0.1

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 (67) hide show
  1. package/dist/auth/index.d.mts +81 -0
  2. package/dist/auth/index.mjs +147 -0
  3. package/dist/cache/config.d.mts +52 -0
  4. package/dist/cache/config.mjs +55 -0
  5. package/dist/cache/runtime.d.mts +40 -0
  6. package/dist/cache/runtime.mjs +191 -0
  7. package/dist/d1-introspector-bZf0_ylK.mjs +57 -0
  8. package/dist/db/d1.d.mts +43 -0
  9. package/dist/db/d1.mjs +74 -0
  10. package/dist/db/do.d.mts +96 -0
  11. package/dist/db/do.mjs +489 -0
  12. package/dist/db/playground-middleware.d.mts +20 -0
  13. package/dist/db/playground-middleware.mjs +533 -0
  14. package/dist/db/playground.d.mts +39 -0
  15. package/dist/db/playground.mjs +26 -0
  16. package/dist/do-class-DY2Ba2RJ.mjs +174 -0
  17. package/dist/do-class-x5Xh_G62.d.mts +73 -0
  18. package/dist/do-dialect-BhFcRSFQ.mjs +58 -0
  19. package/dist/do-playground-routes-CmwFeGwJ.mjs +49 -0
  20. package/dist/do-types-CY0G0oyh.d.mts +14 -0
  21. package/dist/images-4RT9Ag8_.d.mts +76 -0
  22. package/dist/index.d.mts +200 -0
  23. package/dist/index.mjs +214 -0
  24. package/dist/media/images-runtime.d.mts +10 -0
  25. package/dist/media/images-runtime.mjs +215 -0
  26. package/dist/media/stream-runtime.d.mts +10 -0
  27. package/dist/media/stream-runtime.mjs +218 -0
  28. package/dist/plugins/index.d.mts +32 -0
  29. package/dist/plugins/index.mjs +163 -0
  30. package/dist/sandbox/index.d.mts +255 -0
  31. package/dist/sandbox/index.mjs +945 -0
  32. package/dist/storage/r2.d.mts +31 -0
  33. package/dist/storage/r2.mjs +116 -0
  34. package/dist/stream-DdbcvKi0.d.mts +78 -0
  35. package/package.json +109 -0
  36. package/src/auth/cloudflare-access.ts +303 -0
  37. package/src/auth/index.ts +16 -0
  38. package/src/cache/config.ts +81 -0
  39. package/src/cache/runtime.ts +328 -0
  40. package/src/cloudflare.d.ts +31 -0
  41. package/src/db/d1-introspector.ts +120 -0
  42. package/src/db/d1.ts +112 -0
  43. package/src/db/do-class.ts +275 -0
  44. package/src/db/do-dialect.ts +125 -0
  45. package/src/db/do-playground-routes.ts +65 -0
  46. package/src/db/do-preview-routes.ts +48 -0
  47. package/src/db/do-preview-sign.ts +100 -0
  48. package/src/db/do-preview.ts +268 -0
  49. package/src/db/do-types.ts +12 -0
  50. package/src/db/do.ts +62 -0
  51. package/src/db/playground-middleware.ts +340 -0
  52. package/src/db/playground-toolbar.ts +341 -0
  53. package/src/db/playground.ts +49 -0
  54. package/src/db/preview-toolbar.ts +220 -0
  55. package/src/index.ts +285 -0
  56. package/src/media/images-runtime.ts +353 -0
  57. package/src/media/images.ts +114 -0
  58. package/src/media/stream-runtime.ts +392 -0
  59. package/src/media/stream.ts +118 -0
  60. package/src/plugins/index.ts +7 -0
  61. package/src/plugins/vectorize-search.ts +393 -0
  62. package/src/sandbox/bridge.ts +1008 -0
  63. package/src/sandbox/index.ts +13 -0
  64. package/src/sandbox/runner.ts +357 -0
  65. package/src/sandbox/types.ts +181 -0
  66. package/src/sandbox/wrapper.ts +238 -0
  67. package/src/storage/r2.ts +200 -0
@@ -0,0 +1,275 @@
1
+ /**
2
+ * EmDashPreviewDB — Durable Object for preview databases
3
+ *
4
+ * Each preview session gets its own DO with isolated SQLite storage.
5
+ * The DO is populated from a snapshot of the source EmDash site
6
+ * and serves read-only queries until its TTL expires.
7
+ *
8
+ * Not used in production — preview only.
9
+ */
10
+
11
+ import { DurableObject } from "cloudflare:workers";
12
+
13
+ /** Default TTL for preview data (1 hour) */
14
+ const DEFAULT_TTL_MS = 60 * 60 * 1000;
15
+
16
+ /** Valid identifier pattern for snapshot table/column names */
17
+ const SAFE_IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
18
+
19
+ /** SQL command prefixes that indicate read-only statements */
20
+ const READ_PREFIXES = ["SELECT", "PRAGMA", "EXPLAIN", "WITH"];
21
+
22
+ /** Result shape returned by query() */
23
+ export interface QueryResult {
24
+ rows: Record<string, unknown>[];
25
+ /** Number of rows written. Undefined for read-only queries. */
26
+ changes?: number;
27
+ }
28
+
29
+ /** A single statement for batch execution */
30
+ export interface BatchStatement {
31
+ sql: string;
32
+ params?: unknown[];
33
+ }
34
+
35
+ /** Snapshot shape received from the source site */
36
+ interface Snapshot {
37
+ tables: Record<string, Record<string, unknown>[]>;
38
+ schema?: Record<
39
+ string,
40
+ {
41
+ columns: string[];
42
+ types?: Record<string, string>;
43
+ }
44
+ >;
45
+ generatedAt: string;
46
+ }
47
+
48
+ export class EmDashPreviewDB extends DurableObject {
49
+ /**
50
+ * Execute a single SQL statement.
51
+ *
52
+ * Called via RPC from the Kysely driver connection.
53
+ */
54
+ query(sql: string, params?: unknown[]): QueryResult {
55
+ const cursor = params?.length
56
+ ? this.ctx.storage.sql.exec(sql, ...params)
57
+ : this.ctx.storage.sql.exec(sql);
58
+
59
+ const rows: Record<string, unknown>[] = [];
60
+ for (const row of cursor) {
61
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- SqlStorageCursor yields record-like objects
62
+ rows.push(row as Record<string, unknown>);
63
+ }
64
+
65
+ const isRead = READ_PREFIXES.some((p) => sql.trimStart().toUpperCase().startsWith(p));
66
+
67
+ return {
68
+ rows,
69
+ changes: isRead ? undefined : cursor.rowsWritten,
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Execute multiple statements in a single synchronous transaction.
75
+ *
76
+ * Used for snapshot import.
77
+ */
78
+ batch(statements: BatchStatement[]): void {
79
+ this.ctx.storage.transactionSync(() => {
80
+ for (const stmt of statements) {
81
+ if (stmt.params?.length) {
82
+ this.ctx.storage.sql.exec(stmt.sql, ...stmt.params);
83
+ } else {
84
+ this.ctx.storage.sql.exec(stmt.sql);
85
+ }
86
+ }
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Invalidate the cached snapshot so the next populateFromSnapshot call
92
+ * re-fetches from the source site.
93
+ */
94
+ invalidateSnapshot(): void {
95
+ try {
96
+ this.ctx.storage.sql.exec("DELETE FROM _emdash_do_meta WHERE key = 'snapshot_fetched_at'");
97
+ } catch {
98
+ // Table doesn't exist — nothing to invalidate
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Get snapshot metadata (generated-at timestamp).
104
+ * Returns null if the DO has no snapshot loaded.
105
+ */
106
+ getSnapshotMeta(): { generatedAt: string } | null {
107
+ try {
108
+ const row = this.ctx.storage.sql
109
+ .exec("SELECT value FROM _emdash_do_meta WHERE key = 'snapshot_generated_at'")
110
+ .one();
111
+ const value = row.value;
112
+ if (typeof value !== "string") return null;
113
+ return { generatedAt: value };
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Populate from a snapshot (preview mode).
121
+ *
122
+ * Fetches content from a source EmDash site and loads it into
123
+ * this DO's SQLite. Sets a TTL alarm for cleanup.
124
+ */
125
+ async populateFromSnapshot(
126
+ sourceUrl: string,
127
+ signature: string,
128
+ options?: { drafts?: boolean; ttl?: number },
129
+ ): Promise<{ generatedAt: string }> {
130
+ const ttlMs = (options?.ttl ?? DEFAULT_TTL_MS / 1000) * 1000;
131
+
132
+ // Check if already populated and fresh
133
+ try {
134
+ const meta = this.ctx.storage.sql
135
+ .exec("SELECT value FROM _emdash_do_meta WHERE key = 'snapshot_fetched_at'")
136
+ .one();
137
+ const fetchedAt = Number(meta.value);
138
+ if (Date.now() - fetchedAt < ttlMs) {
139
+ // Refresh alarm so active sessions aren't killed
140
+ void this.ctx.storage.setAlarm(Date.now() + ttlMs);
141
+ const gen = this.ctx.storage.sql
142
+ .exec("SELECT value FROM _emdash_do_meta WHERE key = 'snapshot_generated_at'")
143
+ .one();
144
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- SqlStorageCursor yields loosely-typed rows
145
+ return { generatedAt: String(gen.value as string | number) };
146
+ }
147
+ } catch (error) {
148
+ // Only swallow "no such table" — surface all other errors
149
+ if (!(error instanceof Error) || !error.message.includes("no such table")) {
150
+ throw error;
151
+ }
152
+ // _emdash_do_meta doesn't exist yet — first population
153
+ }
154
+
155
+ // Fetch snapshot with timeout
156
+ const url = `${sourceUrl}/_emdash/api/snapshot${options?.drafts ? "?drafts=true" : ""}`;
157
+ const response = await fetch(url, {
158
+ headers: { "X-Preview-Signature": signature },
159
+ signal: AbortSignal.timeout(10_000),
160
+ });
161
+ if (!response.ok) {
162
+ const body = await response.text().catch(() => "");
163
+ throw new Error(
164
+ `Snapshot fetch failed: ${response.status} ${response.statusText}${body ? ` — ${body}` : ""}`,
165
+ );
166
+ }
167
+ const snapshot: Snapshot = await response.json();
168
+
169
+ // Wipe and repopulate in a single transaction so partial applies
170
+ // can't leave the database in an inconsistent state.
171
+ // ctx.storage.deleteAll() only clears KV storage, not SQLite.
172
+ this.ctx.storage.transactionSync(() => {
173
+ this.dropAllTables();
174
+ this.applySnapshot(snapshot);
175
+ });
176
+
177
+ // Set cleanup alarm
178
+ void this.ctx.storage.setAlarm(Date.now() + ttlMs);
179
+
180
+ return { generatedAt: snapshot.generatedAt };
181
+ }
182
+
183
+ /**
184
+ * Set a cleanup alarm after the given number of seconds.
185
+ *
186
+ * Used by the playground middleware to set TTL after initialization
187
+ * is complete (initialization runs on the Worker side via RPC).
188
+ */
189
+ setTtlAlarm(ttlSeconds: number): void {
190
+ void this.ctx.storage.setAlarm(Date.now() + ttlSeconds * 1000);
191
+ }
192
+
193
+ /**
194
+ * Alarm handler — clean up expired preview/playground data.
195
+ *
196
+ * Drops all user tables to reclaim storage.
197
+ */
198
+ override alarm(): void {
199
+ this.dropAllTables();
200
+ }
201
+
202
+ /**
203
+ * Drop all user tables in the DO's SQLite database.
204
+ * Preserves SQLite and Cloudflare internal tables.
205
+ */
206
+ private dropAllTables(): void {
207
+ const tables = [
208
+ ...this.ctx.storage.sql.exec(
209
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_cf_%'",
210
+ ),
211
+ ];
212
+ for (const row of tables) {
213
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- SqlStorageCursor yields loosely-typed rows
214
+ const name = String(row.name as string);
215
+ if (!SAFE_IDENTIFIER.test(name)) {
216
+ // Skip tables with unsafe names rather than interpolating them
217
+ continue;
218
+ }
219
+ this.ctx.storage.sql.exec(`DROP TABLE IF EXISTS "${name}"`);
220
+ }
221
+ }
222
+
223
+ private applySnapshot(snapshot: Snapshot): void {
224
+ const validateSnapshotIdentifier = (name: string, context: string) => {
225
+ if (!SAFE_IDENTIFIER.test(name)) {
226
+ throw new Error(`Invalid ${context} in snapshot: ${JSON.stringify(name)}`);
227
+ }
228
+ };
229
+
230
+ // Create meta table
231
+ this.ctx.storage.sql.exec(`
232
+ CREATE TABLE IF NOT EXISTS _emdash_do_meta (key TEXT PRIMARY KEY, value TEXT)
233
+ `);
234
+
235
+ // Create tables and insert data from snapshot
236
+ for (const [tableName, rows] of Object.entries(snapshot.tables)) {
237
+ if (tableName === "_emdash_do_meta") continue;
238
+ if (!rows.length) continue;
239
+
240
+ validateSnapshotIdentifier(tableName, "table name");
241
+
242
+ const schemaInfo = snapshot.schema?.[tableName];
243
+ const columns = schemaInfo?.columns ?? Object.keys(rows[0]!);
244
+ columns.forEach((c) => validateSnapshotIdentifier(c, `column name in ${tableName}`));
245
+
246
+ const colDefs = columns
247
+ .map((c) => {
248
+ const colType = schemaInfo?.types?.[c] ?? "TEXT";
249
+ const safeType = ["TEXT", "INTEGER", "REAL", "BLOB", "JSON"].includes(
250
+ colType.toUpperCase(),
251
+ )
252
+ ? colType.toUpperCase()
253
+ : "TEXT";
254
+ return `"${c}" ${safeType}`;
255
+ })
256
+ .join(", ");
257
+ this.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS "${tableName}" (${colDefs})`);
258
+
259
+ // Batch insert
260
+ const placeholders = columns.map(() => "?").join(", ");
261
+ const insertSql = `INSERT INTO "${tableName}" (${columns.map((c) => `"${c}"`).join(", ")}) VALUES (${placeholders})`;
262
+ for (const row of rows) {
263
+ const values = columns.map((c) => row[c] ?? null);
264
+ this.ctx.storage.sql.exec(insertSql, ...values);
265
+ }
266
+ }
267
+
268
+ // Record metadata
269
+ this.ctx.storage.sql.exec(
270
+ `INSERT OR REPLACE INTO _emdash_do_meta VALUES ('snapshot_fetched_at', ?), ('snapshot_generated_at', ?)`,
271
+ String(Date.now()),
272
+ snapshot.generatedAt,
273
+ );
274
+ }
275
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Kysely dialect for Durable Object preview databases
3
+ *
4
+ * Proxies all queries to an EmDashPreviewDB DO instance via RPC.
5
+ * Preview mode is read-only — no transaction support needed.
6
+ */
7
+
8
+ import type {
9
+ CompiledQuery,
10
+ DatabaseConnection,
11
+ DatabaseIntrospector,
12
+ Dialect,
13
+ Driver,
14
+ Kysely,
15
+ QueryResult,
16
+ } from "kysely";
17
+ import { SqliteAdapter, SqliteQueryCompiler } from "kysely";
18
+
19
+ import { D1Introspector } from "./d1-introspector.js";
20
+ import type { QueryResult as DOQueryResult } from "./do-class.js";
21
+
22
+ /**
23
+ * Minimal interface for the DO stub's RPC methods.
24
+ *
25
+ * We define this instead of using DurableObjectStub<EmDashPreviewDB> directly
26
+ * because Rpc.Result<T> resolves to `never` when the return type contains
27
+ * `unknown` (Record<string, unknown> in QueryResult.rows). This interface
28
+ * gives us clean typing without fighting the Rpc type system.
29
+ */
30
+ export interface PreviewDBStub {
31
+ query(sql: string, params?: unknown[]): Promise<DOQueryResult>;
32
+ }
33
+
34
+ export interface PreviewDODialectConfig {
35
+ /**
36
+ * Factory that returns a fresh DO stub on each call.
37
+ *
38
+ * DO stubs are bound to the request context that created them.
39
+ * Since the Kysely instance may be cached across requests, we can't
40
+ * hold a single stub — each connection must get a fresh one via
41
+ * namespace.get(id), which is cheap (no RPC, just a local ref).
42
+ */
43
+ getStub: () => PreviewDBStub;
44
+ }
45
+
46
+ export class PreviewDODialect implements Dialect {
47
+ readonly #config: PreviewDODialectConfig;
48
+
49
+ constructor(config: PreviewDODialectConfig) {
50
+ this.#config = config;
51
+ }
52
+
53
+ createAdapter(): SqliteAdapter {
54
+ return new SqliteAdapter();
55
+ }
56
+
57
+ createDriver(): Driver {
58
+ return new PreviewDODriver(this.#config);
59
+ }
60
+
61
+ createQueryCompiler(): SqliteQueryCompiler {
62
+ return new SqliteQueryCompiler();
63
+ }
64
+
65
+ createIntrospector(db: Kysely<any>): DatabaseIntrospector {
66
+ return new D1Introspector(db);
67
+ }
68
+ }
69
+
70
+ class PreviewDODriver implements Driver {
71
+ readonly #config: PreviewDODialectConfig;
72
+
73
+ constructor(config: PreviewDODialectConfig) {
74
+ this.#config = config;
75
+ }
76
+
77
+ async init(): Promise<void> {}
78
+
79
+ async acquireConnection(): Promise<DatabaseConnection> {
80
+ return new PreviewDOConnection(this.#config.getStub());
81
+ }
82
+
83
+ async beginTransaction(): Promise<void> {
84
+ // No-op. Preview is read-only.
85
+ }
86
+
87
+ async commitTransaction(): Promise<void> {
88
+ // No-op.
89
+ }
90
+
91
+ async rollbackTransaction(): Promise<void> {
92
+ // No-op.
93
+ }
94
+
95
+ async releaseConnection(): Promise<void> {}
96
+
97
+ async destroy(): Promise<void> {}
98
+ }
99
+
100
+ class PreviewDOConnection implements DatabaseConnection {
101
+ readonly #stub: PreviewDBStub;
102
+
103
+ constructor(stub: PreviewDBStub) {
104
+ this.#stub = stub;
105
+ }
106
+
107
+ async executeQuery<O>(compiledQuery: CompiledQuery): Promise<QueryResult<O>> {
108
+ const sqlText = compiledQuery.sql;
109
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- CompiledQuery.parameters is ReadonlyArray<unknown>, stub expects unknown[]
110
+ const params = compiledQuery.parameters as unknown[];
111
+
112
+ const result = await this.#stub.query(sqlText, params);
113
+
114
+ return {
115
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Kysely generic O is the caller's row type; we trust the DB returned matching rows
116
+ rows: result.rows as O[],
117
+ numAffectedRows: result.changes !== undefined ? BigInt(result.changes) : undefined,
118
+ };
119
+ }
120
+
121
+ // eslint-disable-next-line require-yield -- interface requires AsyncIterableIterator but DO doesn't support streaming
122
+ async *streamQuery<O>(): AsyncIterableIterator<QueryResult<O>> {
123
+ throw new Error("Preview DO dialect does not support streaming");
124
+ }
125
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Playground mode route gating.
3
+ *
4
+ * Unlike preview mode (which blocks everything except read-only API routes),
5
+ * playground mode allows most routes including the admin UI and write APIs.
6
+ * Only auth, setup, and abuse-prone routes are blocked.
7
+ *
8
+ * Pure function -- no Worker or Cloudflare dependencies.
9
+ */
10
+
11
+ /**
12
+ * Routes blocked in playground mode.
13
+ *
14
+ * These are either security-sensitive (auth, setup, tokens, OAuth),
15
+ * abuse-prone (media upload, plugin install), or pointless in a
16
+ * temporary playground (snapshot export, user management).
17
+ */
18
+ /**
19
+ * Auth routes that ARE allowed in playground mode.
20
+ * /auth/me is needed by the admin UI to identify the current user.
21
+ */
22
+ const AUTH_ALLOWLIST = new Set(["/_emdash/api/auth/me"]);
23
+
24
+ const BLOCKED_PREFIXES = [
25
+ // Auth -- playground has no real auth (except /auth/me for admin UI)
26
+ "/_emdash/api/auth/",
27
+ // Setup -- playground is pre-configured
28
+ "/_emdash/api/setup/",
29
+ // OAuth provider routes
30
+ "/_emdash/api/oauth/",
31
+ // API token management
32
+ "/_emdash/api/tokens/",
33
+ // User management (can't invite/create real users)
34
+ "/_emdash/api/users/invite",
35
+ // Plugin installation (security boundary)
36
+ "/_emdash/api/plugins/install",
37
+ "/_emdash/api/plugins/marketplace",
38
+ // Media uploads (abuse vector -- no storage in playground)
39
+ "/_emdash/api/media/upload",
40
+ // Snapshot export (no point exporting a playground)
41
+ "/_emdash/api/snapshot",
42
+ ];
43
+
44
+ /**
45
+ * Check whether a request should be blocked in playground mode.
46
+ *
47
+ * Playground allows most CMS functionality: content CRUD, schema editing,
48
+ * taxonomies, menus, widgets, search, settings, and the full admin UI.
49
+ * Only auth, setup, user management, media uploads, and plugin
50
+ * installation are blocked.
51
+ */
52
+ export function isBlockedInPlayground(pathname: string): boolean {
53
+ // Check allowlist first -- specific routes that must work despite
54
+ // their parent prefix being blocked (e.g. /auth/me for admin UI)
55
+ if (AUTH_ALLOWLIST.has(pathname)) {
56
+ return false;
57
+ }
58
+
59
+ for (const prefix of BLOCKED_PREFIXES) {
60
+ if (pathname === prefix || pathname.startsWith(prefix)) {
61
+ return true;
62
+ }
63
+ }
64
+ return false;
65
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Preview mode route gating.
3
+ *
4
+ * Pure function — no Worker or Cloudflare dependencies.
5
+ * Extracted so it can be tested without mocking cloudflare:workers.
6
+ */
7
+
8
+ /**
9
+ * API route prefixes allowed in preview mode (read-only).
10
+ * Everything else under /_emdash/ is blocked.
11
+ */
12
+ const ALLOWED_API_PREFIXES = [
13
+ "/_emdash/api/content/",
14
+ "/_emdash/api/schema",
15
+ "/_emdash/api/manifest",
16
+ "/_emdash/api/dashboard",
17
+ "/_emdash/api/search",
18
+ "/_emdash/api/media",
19
+ "/_emdash/api/taxonomies",
20
+ "/_emdash/api/menus",
21
+ "/_emdash/api/snapshot",
22
+ ];
23
+
24
+ /**
25
+ * Check whether a request should be blocked in preview mode.
26
+ *
27
+ * Preview is read-only with no authenticated user. All /_emdash/
28
+ * routes are blocked by default (admin UI, auth, setup, write APIs).
29
+ * Only specific read-only API prefixes are allowlisted.
30
+ *
31
+ * Non-emdash routes (site pages, assets) are always allowed.
32
+ */
33
+ export function isBlockedInPreview(pathname: string): boolean {
34
+ // Non-emdash routes are always allowed (site pages, assets, etc.)
35
+ if (!pathname.startsWith("/_emdash/")) {
36
+ return false;
37
+ }
38
+
39
+ // Check allowlist for API routes
40
+ for (const prefix of ALLOWED_API_PREFIXES) {
41
+ if (pathname === prefix || pathname.startsWith(prefix)) {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ // Everything else under /_emdash/ is blocked
47
+ return true;
48
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Preview URL signing utilities.
3
+ *
4
+ * Pure functions using Web Crypto — no Worker or Cloudflare dependencies.
5
+ * Used by the source site to generate signed preview URLs and by the
6
+ * preview service to verify them.
7
+ */
8
+
9
+ /** Matches a lowercase hex string */
10
+ const HEX_PATTERN = /^[0-9a-f]+$/;
11
+
12
+ /**
13
+ * Compute HMAC-SHA256 over a message and return the hex-encoded signature.
14
+ */
15
+ async function hmacSign(message: string, secret: string): Promise<string> {
16
+ const encoder = new TextEncoder();
17
+
18
+ const key = await crypto.subtle.importKey(
19
+ "raw",
20
+ encoder.encode(secret),
21
+ { name: "HMAC", hash: "SHA-256" },
22
+ false,
23
+ ["sign"],
24
+ );
25
+
26
+ const buffer = await crypto.subtle.sign("HMAC", key, encoder.encode(message));
27
+ return Array.from(new Uint8Array(buffer), (b) => b.toString(16).padStart(2, "0")).join("");
28
+ }
29
+
30
+ /**
31
+ * Generate a signed preview URL.
32
+ *
33
+ * The source site calls this to create a link that opens the preview service.
34
+ * The preview service validates the signature and populates the DO from a
35
+ * snapshot of the source site.
36
+ *
37
+ * @param previewBase - Base URL of the preview service (e.g. "https://theme-x.preview.emdashcms.com")
38
+ * @param source - URL of the source site providing the snapshot (e.g. "https://mysite.com")
39
+ * @param secret - Shared HMAC secret (same value configured on both sides)
40
+ * @param ttl - Link validity in seconds (default: 3600 = 1 hour)
41
+ * @returns Fully signed preview URL
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * const url = await signPreviewUrl(
46
+ * "https://theme-x.preview.emdashcms.com",
47
+ * "https://mysite.com",
48
+ * import.meta.env.PREVIEW_SECRET,
49
+ * );
50
+ * // => "https://theme-x.preview.emdashcms.com/?source=https%3A%2F%2Fmysite.com&exp=1709164800&sig=abc123..."
51
+ * ```
52
+ */
53
+ export async function signPreviewUrl(
54
+ previewBase: string,
55
+ source: string,
56
+ secret: string,
57
+ ttl = 3600,
58
+ ): Promise<string> {
59
+ const exp = Math.floor(Date.now() / 1000) + ttl;
60
+ const sig = await hmacSign(`${source}:${exp}`, secret);
61
+
62
+ const url = new URL(previewBase);
63
+ url.searchParams.set("source", source);
64
+ url.searchParams.set("exp", String(exp));
65
+ url.searchParams.set("sig", sig);
66
+
67
+ return url.toString();
68
+ }
69
+
70
+ /**
71
+ * Verify an HMAC-SHA256 signature on a preview URL.
72
+ *
73
+ * Uses crypto.subtle.verify for constant-time comparison.
74
+ *
75
+ * @returns true if the signature is valid
76
+ */
77
+ export async function verifyPreviewSignature(
78
+ source: string,
79
+ exp: number,
80
+ sig: string,
81
+ secret: string,
82
+ ): Promise<boolean> {
83
+ // Decode hex signature to ArrayBuffer
84
+ if (sig.length !== 64 || !HEX_PATTERN.test(sig)) return false;
85
+ const sigBytes = new Uint8Array(32);
86
+ for (let i = 0; i < 64; i += 2) {
87
+ sigBytes[i / 2] = parseInt(sig.substring(i, i + 2), 16);
88
+ }
89
+
90
+ const encoder = new TextEncoder();
91
+ const key = await crypto.subtle.importKey(
92
+ "raw",
93
+ encoder.encode(secret),
94
+ { name: "HMAC", hash: "SHA-256" },
95
+ false,
96
+ ["verify"],
97
+ );
98
+
99
+ return crypto.subtle.verify("HMAC", key, sigBytes, encoder.encode(`${source}:${exp}`));
100
+ }