@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.
- package/dist/auth/index.d.mts +81 -0
- package/dist/auth/index.mjs +147 -0
- package/dist/cache/config.d.mts +52 -0
- package/dist/cache/config.mjs +55 -0
- package/dist/cache/runtime.d.mts +40 -0
- package/dist/cache/runtime.mjs +191 -0
- package/dist/d1-introspector-bZf0_ylK.mjs +57 -0
- package/dist/db/d1.d.mts +43 -0
- package/dist/db/d1.mjs +74 -0
- package/dist/db/do.d.mts +96 -0
- package/dist/db/do.mjs +489 -0
- package/dist/db/playground-middleware.d.mts +20 -0
- package/dist/db/playground-middleware.mjs +533 -0
- package/dist/db/playground.d.mts +39 -0
- package/dist/db/playground.mjs +26 -0
- package/dist/do-class-DY2Ba2RJ.mjs +174 -0
- package/dist/do-class-x5Xh_G62.d.mts +73 -0
- package/dist/do-dialect-BhFcRSFQ.mjs +58 -0
- package/dist/do-playground-routes-CmwFeGwJ.mjs +49 -0
- package/dist/do-types-CY0G0oyh.d.mts +14 -0
- package/dist/images-4RT9Ag8_.d.mts +76 -0
- package/dist/index.d.mts +200 -0
- package/dist/index.mjs +214 -0
- package/dist/media/images-runtime.d.mts +10 -0
- package/dist/media/images-runtime.mjs +215 -0
- package/dist/media/stream-runtime.d.mts +10 -0
- package/dist/media/stream-runtime.mjs +218 -0
- package/dist/plugins/index.d.mts +32 -0
- package/dist/plugins/index.mjs +163 -0
- package/dist/sandbox/index.d.mts +255 -0
- package/dist/sandbox/index.mjs +945 -0
- package/dist/storage/r2.d.mts +31 -0
- package/dist/storage/r2.mjs +116 -0
- package/dist/stream-DdbcvKi0.d.mts +78 -0
- package/package.json +109 -0
- package/src/auth/cloudflare-access.ts +303 -0
- package/src/auth/index.ts +16 -0
- package/src/cache/config.ts +81 -0
- package/src/cache/runtime.ts +328 -0
- package/src/cloudflare.d.ts +31 -0
- package/src/db/d1-introspector.ts +120 -0
- package/src/db/d1.ts +112 -0
- package/src/db/do-class.ts +275 -0
- package/src/db/do-dialect.ts +125 -0
- package/src/db/do-playground-routes.ts +65 -0
- package/src/db/do-preview-routes.ts +48 -0
- package/src/db/do-preview-sign.ts +100 -0
- package/src/db/do-preview.ts +268 -0
- package/src/db/do-types.ts +12 -0
- package/src/db/do.ts +62 -0
- package/src/db/playground-middleware.ts +340 -0
- package/src/db/playground-toolbar.ts +341 -0
- package/src/db/playground.ts +49 -0
- package/src/db/preview-toolbar.ts +220 -0
- package/src/index.ts +285 -0
- package/src/media/images-runtime.ts +353 -0
- package/src/media/images.ts +114 -0
- package/src/media/stream-runtime.ts +392 -0
- package/src/media/stream.ts +118 -0
- package/src/plugins/index.ts +7 -0
- package/src/plugins/vectorize-search.ts +393 -0
- package/src/sandbox/bridge.ts +1008 -0
- package/src/sandbox/index.ts +13 -0
- package/src/sandbox/runner.ts +357 -0
- package/src/sandbox/types.ts +181 -0
- package/src/sandbox/wrapper.ts +238 -0
- 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
|
+
}
|