@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,945 @@
1
+ import { WorkerEntrypoint, env, exports } from "cloudflare:workers";
2
+ import { ulid } from "emdash";
3
+
4
+ //#region src/sandbox/bridge.ts
5
+ /**
6
+ * PluginBridge WorkerEntrypoint
7
+ *
8
+ * Provides controlled access to database operations for sandboxed plugins.
9
+ * The sandbox gets a SERVICE BINDING to this entrypoint, not direct DB access.
10
+ * All operations are validated and scoped to the plugin.
11
+ *
12
+ */
13
+ /** Regex to validate collection names (prevent SQL injection) */
14
+ const COLLECTION_NAME_REGEX = /^[a-z][a-z0-9_]*$/;
15
+ /** Regex to validate file extensions (simple alphanumeric, 1-10 chars) */
16
+ const FILE_EXT_REGEX = /^\.[a-z0-9]{1,10}$/i;
17
+ /** System columns that plugins cannot directly write to */
18
+ const SYSTEM_COLUMNS = new Set([
19
+ "id",
20
+ "slug",
21
+ "status",
22
+ "author_id",
23
+ "created_at",
24
+ "updated_at",
25
+ "published_at",
26
+ "scheduled_at",
27
+ "deleted_at",
28
+ "version",
29
+ "live_revision_id",
30
+ "draft_revision_id"
31
+ ]);
32
+ /**
33
+ * Module-level email send callback.
34
+ *
35
+ * The bridge runs in the host process (same worker), so we can use a
36
+ * module-level callback that the runner sets before creating bridge bindings.
37
+ * This avoids the need to pass non-serializable functions through props.
38
+ *
39
+ * @see runner.ts setEmailSendCallback()
40
+ */
41
+ let emailSendCallback = null;
42
+ /**
43
+ * Set the email send callback for all bridge instances.
44
+ * Called by the runner when the EmailPipeline is available.
45
+ */
46
+ function setEmailSendCallback(callback) {
47
+ emailSendCallback = callback;
48
+ }
49
+ /**
50
+ * Serialize a value for D1 storage.
51
+ * Mirrors core's serializeValue: objects/arrays → JSON strings,
52
+ * booleans → 0/1, null/undefined → null, everything else passthrough.
53
+ */
54
+ function serializeValue(value) {
55
+ if (value === null || value === void 0) return null;
56
+ if (typeof value === "boolean") return value ? 1 : 0;
57
+ if (typeof value === "object") return JSON.stringify(value);
58
+ return value;
59
+ }
60
+ /**
61
+ * Deserialize a row from D1 into a content response shape.
62
+ * Extracts system columns and bundles remaining columns into data.
63
+ */
64
+ /**
65
+ * Deserialize a row from D1 into a ContentItem matching core's plugin API.
66
+ * Extracts system columns, deserializes JSON fields, and returns the
67
+ * canonical shape: { id, type, data, createdAt, updatedAt }.
68
+ */
69
+ function rowToContentItem(collection, row) {
70
+ const data = {};
71
+ for (const [key, value] of Object.entries(row)) if (!SYSTEM_COLUMNS.has(key)) {
72
+ if (typeof value === "string" && (value.startsWith("{") || value.startsWith("["))) try {
73
+ data[key] = JSON.parse(value);
74
+ } catch {
75
+ data[key] = value;
76
+ }
77
+ else if (value !== null) data[key] = value;
78
+ }
79
+ return {
80
+ id: typeof row.id === "string" ? row.id : String(row.id),
81
+ type: collection,
82
+ data,
83
+ createdAt: typeof row.created_at === "string" ? row.created_at : (/* @__PURE__ */ new Date()).toISOString(),
84
+ updatedAt: typeof row.updated_at === "string" ? row.updated_at : (/* @__PURE__ */ new Date()).toISOString()
85
+ };
86
+ }
87
+ /**
88
+ * PluginBridge WorkerEntrypoint
89
+ *
90
+ * Provides the context API to sandboxed plugins via RPC.
91
+ * All methods validate capabilities and scope operations to the plugin.
92
+ *
93
+ * Usage:
94
+ * 1. Export this class from your worker entrypoint
95
+ * 2. Sandboxed plugins get a binding to it via ctx.exports.PluginBridge({...})
96
+ * 3. Plugins call bridge methods which validate and proxy to the database
97
+ */
98
+ var PluginBridge = class extends WorkerEntrypoint {
99
+ /**
100
+ * KV operations use _plugin_storage with a special "__kv" collection.
101
+ * This provides consistent storage across sandboxed and non-sandboxed modes.
102
+ */
103
+ async kvGet(key) {
104
+ const { pluginId } = this.ctx.props;
105
+ const result = await this.env.DB.prepare("SELECT data FROM _plugin_storage WHERE plugin_id = ? AND collection = '__kv' AND id = ?").bind(pluginId, key).first();
106
+ if (!result) return null;
107
+ try {
108
+ return JSON.parse(result.data);
109
+ } catch {
110
+ return result.data;
111
+ }
112
+ }
113
+ async kvSet(key, value) {
114
+ const { pluginId } = this.ctx.props;
115
+ await this.env.DB.prepare("INSERT OR REPLACE INTO _plugin_storage (plugin_id, collection, id, data, updated_at) VALUES (?, '__kv', ?, ?, datetime('now'))").bind(pluginId, key, JSON.stringify(value)).run();
116
+ }
117
+ async kvDelete(key) {
118
+ const { pluginId } = this.ctx.props;
119
+ return ((await this.env.DB.prepare("DELETE FROM _plugin_storage WHERE plugin_id = ? AND collection = '__kv' AND id = ?").bind(pluginId, key).run()).meta?.changes ?? 0) > 0;
120
+ }
121
+ async kvList(prefix = "") {
122
+ const { pluginId } = this.ctx.props;
123
+ return ((await this.env.DB.prepare("SELECT id, data FROM _plugin_storage WHERE plugin_id = ? AND collection = '__kv' AND id LIKE ?").bind(pluginId, prefix + "%").all()).results ?? []).map((row) => ({
124
+ key: row.id,
125
+ value: JSON.parse(row.data)
126
+ }));
127
+ }
128
+ async storageGet(collection, id) {
129
+ const { pluginId, storageCollections } = this.ctx.props;
130
+ if (!storageCollections.includes(collection)) throw new Error(`Storage collection not declared: ${collection}`);
131
+ const result = await this.env.DB.prepare("SELECT data FROM _plugin_storage WHERE plugin_id = ? AND collection = ? AND id = ?").bind(pluginId, collection, id).first();
132
+ if (!result) return null;
133
+ return JSON.parse(result.data);
134
+ }
135
+ async storagePut(collection, id, data) {
136
+ const { pluginId, storageCollections } = this.ctx.props;
137
+ if (!storageCollections.includes(collection)) throw new Error(`Storage collection not declared: ${collection}`);
138
+ await this.env.DB.prepare("INSERT OR REPLACE INTO _plugin_storage (plugin_id, collection, id, data, updated_at) VALUES (?, ?, ?, ?, datetime('now'))").bind(pluginId, collection, id, JSON.stringify(data)).run();
139
+ }
140
+ async storageDelete(collection, id) {
141
+ const { pluginId, storageCollections } = this.ctx.props;
142
+ if (!storageCollections.includes(collection)) throw new Error(`Storage collection not declared: ${collection}`);
143
+ return ((await this.env.DB.prepare("DELETE FROM _plugin_storage WHERE plugin_id = ? AND collection = ? AND id = ?").bind(pluginId, collection, id).run()).meta?.changes ?? 0) > 0;
144
+ }
145
+ async storageQuery(collection, opts = {}) {
146
+ const { pluginId, storageCollections } = this.ctx.props;
147
+ if (!storageCollections.includes(collection)) throw new Error(`Storage collection not declared: ${collection}`);
148
+ const limit = Math.min(opts.limit ?? 50, 1e3);
149
+ const results = await this.env.DB.prepare("SELECT id, data FROM _plugin_storage WHERE plugin_id = ? AND collection = ? LIMIT ?").bind(pluginId, collection, limit + 1).all();
150
+ const items = (results.results ?? []).slice(0, limit).map((row) => ({
151
+ id: row.id,
152
+ data: JSON.parse(row.data)
153
+ }));
154
+ return {
155
+ items,
156
+ hasMore: (results.results ?? []).length > limit,
157
+ cursor: items.length > 0 ? items.at(-1).id : void 0
158
+ };
159
+ }
160
+ async storageCount(collection) {
161
+ const { pluginId, storageCollections } = this.ctx.props;
162
+ if (!storageCollections.includes(collection)) throw new Error(`Storage collection not declared: ${collection}`);
163
+ return (await this.env.DB.prepare("SELECT COUNT(*) as count FROM _plugin_storage WHERE plugin_id = ? AND collection = ?").bind(pluginId, collection).first())?.count ?? 0;
164
+ }
165
+ async storageGetMany(collection, ids) {
166
+ const { pluginId, storageCollections } = this.ctx.props;
167
+ if (!storageCollections.includes(collection)) throw new Error(`Storage collection not declared: ${collection}`);
168
+ if (ids.length === 0) return /* @__PURE__ */ new Map();
169
+ const placeholders = ids.map(() => "?").join(",");
170
+ const results = await this.env.DB.prepare(`SELECT id, data FROM _plugin_storage WHERE plugin_id = ? AND collection = ? AND id IN (${placeholders})`).bind(pluginId, collection, ...ids).all();
171
+ const map = /* @__PURE__ */ new Map();
172
+ for (const row of results.results ?? []) map.set(row.id, JSON.parse(row.data));
173
+ return map;
174
+ }
175
+ async storagePutMany(collection, items) {
176
+ const { pluginId, storageCollections } = this.ctx.props;
177
+ if (!storageCollections.includes(collection)) throw new Error(`Storage collection not declared: ${collection}`);
178
+ if (items.length === 0) return;
179
+ for (const item of items) await this.env.DB.prepare("INSERT OR REPLACE INTO _plugin_storage (plugin_id, collection, id, data, updated_at) VALUES (?, ?, ?, ?, datetime('now'))").bind(pluginId, collection, item.id, JSON.stringify(item.data)).run();
180
+ }
181
+ async storageDeleteMany(collection, ids) {
182
+ const { pluginId, storageCollections } = this.ctx.props;
183
+ if (!storageCollections.includes(collection)) throw new Error(`Storage collection not declared: ${collection}`);
184
+ if (ids.length === 0) return 0;
185
+ let deleted = 0;
186
+ for (const id of ids) {
187
+ const result = await this.env.DB.prepare("DELETE FROM _plugin_storage WHERE plugin_id = ? AND collection = ? AND id = ?").bind(pluginId, collection, id).run();
188
+ deleted += result.meta?.changes ?? 0;
189
+ }
190
+ return deleted;
191
+ }
192
+ async contentGet(collection, id) {
193
+ const { capabilities } = this.ctx.props;
194
+ if (!capabilities.includes("read:content")) throw new Error("Missing capability: read:content");
195
+ if (!COLLECTION_NAME_REGEX.test(collection)) throw new Error(`Invalid collection name: ${collection}`);
196
+ try {
197
+ const result = await this.env.DB.prepare(`SELECT * FROM ec_${collection} WHERE id = ? AND deleted_at IS NULL`).bind(id).first();
198
+ if (!result) return null;
199
+ return rowToContentItem(collection, result);
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+ async contentList(collection, opts = {}) {
205
+ const { capabilities } = this.ctx.props;
206
+ if (!capabilities.includes("read:content")) throw new Error("Missing capability: read:content");
207
+ if (!COLLECTION_NAME_REGEX.test(collection)) throw new Error(`Invalid collection name: ${collection}`);
208
+ const limit = Math.min(opts.limit ?? 50, 100);
209
+ try {
210
+ let sql = `SELECT * FROM ec_${collection} WHERE deleted_at IS NULL`;
211
+ const params = [];
212
+ if (opts.cursor) {
213
+ sql += " AND id < ?";
214
+ params.push(opts.cursor);
215
+ }
216
+ sql += " ORDER BY id DESC LIMIT ?";
217
+ params.push(limit + 1);
218
+ const rows = (await this.env.DB.prepare(sql).bind(...params).all()).results ?? [];
219
+ const items = rows.slice(0, limit).map((row) => rowToContentItem(collection, row));
220
+ const hasMore = rows.length > limit;
221
+ return {
222
+ items,
223
+ cursor: hasMore && items.length > 0 ? items.at(-1).id : void 0,
224
+ hasMore
225
+ };
226
+ } catch {
227
+ return {
228
+ items: [],
229
+ hasMore: false
230
+ };
231
+ }
232
+ }
233
+ async contentCreate(collection, data) {
234
+ const { capabilities } = this.ctx.props;
235
+ if (!capabilities.includes("write:content")) throw new Error("Missing capability: write:content");
236
+ if (!COLLECTION_NAME_REGEX.test(collection)) throw new Error(`Invalid collection name: ${collection}`);
237
+ const id = ulid();
238
+ const now = (/* @__PURE__ */ new Date()).toISOString();
239
+ const columns = [
240
+ "\"id\"",
241
+ "\"slug\"",
242
+ "\"status\"",
243
+ "\"author_id\"",
244
+ "\"created_at\"",
245
+ "\"updated_at\"",
246
+ "\"version\""
247
+ ];
248
+ const values = [
249
+ id,
250
+ typeof data.slug === "string" ? data.slug : null,
251
+ typeof data.status === "string" ? data.status : "draft",
252
+ typeof data.author_id === "string" ? data.author_id : null,
253
+ now,
254
+ now,
255
+ 1
256
+ ];
257
+ for (const [key, value] of Object.entries(data)) if (!SYSTEM_COLUMNS.has(key) && COLLECTION_NAME_REGEX.test(key)) {
258
+ columns.push(`"${key}"`);
259
+ values.push(serializeValue(value));
260
+ }
261
+ const placeholders = columns.map(() => "?").join(", ");
262
+ const columnList = columns.join(", ");
263
+ await this.env.DB.prepare(`INSERT INTO ec_${collection} (${columnList}) VALUES (${placeholders})`).bind(...values).run();
264
+ const created = await this.env.DB.prepare(`SELECT * FROM ec_${collection} WHERE id = ? AND deleted_at IS NULL`).bind(id).first();
265
+ if (!created) return {
266
+ id,
267
+ type: collection,
268
+ data: {},
269
+ createdAt: now,
270
+ updatedAt: now
271
+ };
272
+ return rowToContentItem(collection, created);
273
+ }
274
+ async contentUpdate(collection, id, data) {
275
+ const { capabilities } = this.ctx.props;
276
+ if (!capabilities.includes("write:content")) throw new Error("Missing capability: write:content");
277
+ if (!COLLECTION_NAME_REGEX.test(collection)) throw new Error(`Invalid collection name: ${collection}`);
278
+ const now = (/* @__PURE__ */ new Date()).toISOString();
279
+ const setClauses = ["\"updated_at\" = ?", "\"version\" = \"version\" + 1"];
280
+ const values = [now];
281
+ if (typeof data.status === "string") {
282
+ setClauses.push("\"status\" = ?");
283
+ values.push(data.status);
284
+ }
285
+ if (data.slug !== void 0) {
286
+ setClauses.push("\"slug\" = ?");
287
+ values.push(typeof data.slug === "string" ? data.slug : null);
288
+ }
289
+ for (const [key, value] of Object.entries(data)) if (!SYSTEM_COLUMNS.has(key) && COLLECTION_NAME_REGEX.test(key)) {
290
+ setClauses.push(`"${key}" = ?`);
291
+ values.push(serializeValue(value));
292
+ }
293
+ values.push(id);
294
+ if (((await this.env.DB.prepare(`UPDATE ec_${collection} SET ${setClauses.join(", ")} WHERE "id" = ? AND "deleted_at" IS NULL`).bind(...values).run()).meta?.changes ?? 0) === 0) throw new Error(`Content not found or deleted: ${collection}/${id}`);
295
+ const updated = await this.env.DB.prepare(`SELECT * FROM ec_${collection} WHERE id = ? AND deleted_at IS NULL`).bind(id).first();
296
+ if (!updated) throw new Error(`Content not found: ${collection}/${id}`);
297
+ return rowToContentItem(collection, updated);
298
+ }
299
+ async contentDelete(collection, id) {
300
+ const { capabilities } = this.ctx.props;
301
+ if (!capabilities.includes("write:content")) throw new Error("Missing capability: write:content");
302
+ if (!COLLECTION_NAME_REGEX.test(collection)) throw new Error(`Invalid collection name: ${collection}`);
303
+ const now = (/* @__PURE__ */ new Date()).toISOString();
304
+ return ((await this.env.DB.prepare(`UPDATE ec_${collection} SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`).bind(now, now, id).run()).meta?.changes ?? 0) > 0;
305
+ }
306
+ async mediaGet(id) {
307
+ const { capabilities } = this.ctx.props;
308
+ if (!capabilities.includes("read:media")) throw new Error("Missing capability: read:media");
309
+ const result = await this.env.DB.prepare("SELECT * FROM media WHERE id = ?").bind(id).first();
310
+ if (!result) return null;
311
+ return {
312
+ id: result.id,
313
+ filename: result.filename,
314
+ mimeType: result.mime_type,
315
+ size: result.size,
316
+ url: `/_emdash/api/media/file/${result.storage_key}`,
317
+ createdAt: result.created_at
318
+ };
319
+ }
320
+ async mediaList(opts = {}) {
321
+ const { capabilities } = this.ctx.props;
322
+ if (!capabilities.includes("read:media")) throw new Error("Missing capability: read:media");
323
+ const limit = Math.min(opts.limit ?? 50, 100);
324
+ let sql = "SELECT * FROM media WHERE status = 'ready'";
325
+ const params = [];
326
+ if (opts.mimeType) {
327
+ sql += " AND mime_type LIKE ?";
328
+ params.push(opts.mimeType + "%");
329
+ }
330
+ if (opts.cursor) {
331
+ sql += " AND id < ?";
332
+ params.push(opts.cursor);
333
+ }
334
+ sql += " ORDER BY id DESC LIMIT ?";
335
+ params.push(limit + 1);
336
+ const rows = (await this.env.DB.prepare(sql).bind(...params).all()).results ?? [];
337
+ const items = rows.slice(0, limit).map((row) => ({
338
+ id: row.id,
339
+ filename: row.filename,
340
+ mimeType: row.mime_type,
341
+ size: row.size,
342
+ url: `/_emdash/api/media/file/${row.storage_key}`,
343
+ createdAt: row.created_at
344
+ }));
345
+ const hasMore = rows.length > limit;
346
+ return {
347
+ items,
348
+ cursor: hasMore && items.length > 0 ? items.at(-1).id : void 0,
349
+ hasMore
350
+ };
351
+ }
352
+ /**
353
+ * Create a pending media record and write bytes directly to R2.
354
+ *
355
+ * Unlike the admin UI flow (presigned URL → client PUT → confirm), sandboxed
356
+ * plugins are network-isolated and can't make external requests. The bridge
357
+ * accepts the file bytes directly and writes them to storage.
358
+ *
359
+ * Returns the media ID, storage key, and confirm URL. The plugin should
360
+ * call the confirm endpoint after this to finalize the record.
361
+ */
362
+ async mediaUpload(filename, contentType, bytes) {
363
+ const { capabilities } = this.ctx.props;
364
+ if (!capabilities.includes("write:media")) throw new Error("Missing capability: write:media");
365
+ if (!this.env.MEDIA) throw new Error("Media storage (R2) not configured. Add MEDIA binding to wrangler config.");
366
+ if (![
367
+ "image/",
368
+ "video/",
369
+ "audio/",
370
+ "application/pdf"
371
+ ].some((prefix) => contentType.startsWith(prefix))) throw new Error(`Unsupported content type: ${contentType}. Allowed: image/*, video/*, audio/*, application/pdf`);
372
+ const mediaId = ulid();
373
+ const basename = filename.includes("/") ? filename.slice(filename.lastIndexOf("/") + 1) : filename;
374
+ const rawExt = basename.includes(".") ? basename.slice(basename.lastIndexOf(".")) : "";
375
+ const storageKey = `${mediaId}${FILE_EXT_REGEX.test(rawExt) ? rawExt : ""}`;
376
+ const now = (/* @__PURE__ */ new Date()).toISOString();
377
+ await this.env.MEDIA.put(storageKey, bytes, { httpMetadata: { contentType } });
378
+ try {
379
+ await this.env.DB.prepare("INSERT INTO media (id, filename, mime_type, size, storage_key, status, created_at) VALUES (?, ?, ?, ?, ?, 'ready', ?)").bind(mediaId, filename, contentType, bytes.byteLength, storageKey, now).run();
380
+ } catch (error) {
381
+ try {
382
+ await this.env.MEDIA.delete(storageKey);
383
+ } catch {
384
+ console.warn(`[plugin-bridge] Failed to clean up orphaned R2 object: ${storageKey}`);
385
+ }
386
+ throw error;
387
+ }
388
+ return {
389
+ mediaId,
390
+ storageKey,
391
+ url: `/_emdash/api/media/file/${storageKey}`
392
+ };
393
+ }
394
+ async mediaDelete(id) {
395
+ const { capabilities } = this.ctx.props;
396
+ if (!capabilities.includes("write:media")) throw new Error("Missing capability: write:media");
397
+ const media = await this.env.DB.prepare("SELECT storage_key FROM media WHERE id = ?").bind(id).first();
398
+ if (!media) return false;
399
+ const result = await this.env.DB.prepare("DELETE FROM media WHERE id = ?").bind(id).run();
400
+ if (this.env.MEDIA && media.storage_key) try {
401
+ await this.env.MEDIA.delete(media.storage_key);
402
+ } catch {
403
+ console.warn(`[plugin-bridge] Failed to delete R2 object: ${media.storage_key}`);
404
+ }
405
+ return (result.meta?.changes ?? 0) > 0;
406
+ }
407
+ async httpFetch(url, init) {
408
+ const { capabilities, allowedHosts } = this.ctx.props;
409
+ const hasUnrestricted = capabilities.includes("network:fetch:any");
410
+ if (!(capabilities.includes("network:fetch") || hasUnrestricted)) throw new Error("Missing capability: network:fetch");
411
+ if (!hasUnrestricted) {
412
+ const host = new URL(url).host;
413
+ if (allowedHosts.length === 0) throw new Error(`Plugin has no allowed hosts configured. Add hosts to allowedHosts to enable HTTP requests.`);
414
+ if (!allowedHosts.some((pattern) => {
415
+ if (pattern.startsWith("*.")) return host.endsWith(pattern.slice(1)) || host === pattern.slice(2);
416
+ return host === pattern;
417
+ })) throw new Error(`Host not allowed: ${host}. Allowed: ${allowedHosts.join(", ")}`);
418
+ }
419
+ const response = await fetch(url, init);
420
+ const headers = {};
421
+ response.headers.forEach((value, key) => {
422
+ headers[key] = value;
423
+ });
424
+ return {
425
+ status: response.status,
426
+ headers,
427
+ text: await response.text()
428
+ };
429
+ }
430
+ async userGet(id) {
431
+ const { capabilities } = this.ctx.props;
432
+ if (!capabilities.includes("read:users")) throw new Error("Missing capability: read:users");
433
+ const result = await this.env.DB.prepare("SELECT id, email, name, role, created_at FROM users WHERE id = ?").bind(id).first();
434
+ if (!result) return null;
435
+ return {
436
+ id: result.id,
437
+ email: result.email,
438
+ name: result.name,
439
+ role: result.role,
440
+ createdAt: result.created_at
441
+ };
442
+ }
443
+ async userGetByEmail(email) {
444
+ const { capabilities } = this.ctx.props;
445
+ if (!capabilities.includes("read:users")) throw new Error("Missing capability: read:users");
446
+ const result = await this.env.DB.prepare("SELECT id, email, name, role, created_at FROM users WHERE email = ?").bind(email.toLowerCase()).first();
447
+ if (!result) return null;
448
+ return {
449
+ id: result.id,
450
+ email: result.email,
451
+ name: result.name,
452
+ role: result.role,
453
+ createdAt: result.created_at
454
+ };
455
+ }
456
+ async userList(opts) {
457
+ const { capabilities } = this.ctx.props;
458
+ if (!capabilities.includes("read:users")) throw new Error("Missing capability: read:users");
459
+ const limit = Math.max(1, Math.min(opts?.limit ?? 50, 100));
460
+ let sql = "SELECT id, email, name, role, created_at FROM users";
461
+ const params = [];
462
+ const conditions = [];
463
+ if (opts?.role !== void 0) {
464
+ conditions.push("role = ?");
465
+ params.push(opts.role);
466
+ }
467
+ if (opts?.cursor) {
468
+ conditions.push("id < ?");
469
+ params.push(opts.cursor);
470
+ }
471
+ if (conditions.length > 0) sql += ` WHERE ${conditions.join(" AND ")}`;
472
+ sql += " ORDER BY id DESC LIMIT ?";
473
+ params.push(limit + 1);
474
+ const rows = (await this.env.DB.prepare(sql).bind(...params).all()).results ?? [];
475
+ const items = rows.slice(0, limit).map((row) => ({
476
+ id: row.id,
477
+ email: row.email,
478
+ name: row.name,
479
+ role: row.role,
480
+ createdAt: row.created_at
481
+ }));
482
+ return {
483
+ items,
484
+ nextCursor: rows.length > limit && items.length > 0 ? items.at(-1).id : void 0
485
+ };
486
+ }
487
+ async emailSend(message) {
488
+ const { capabilities, pluginId } = this.ctx.props;
489
+ if (!capabilities.includes("email:send")) throw new Error("Missing capability: email:send");
490
+ if (!emailSendCallback) throw new Error("Email is not configured. No email provider is available.");
491
+ await emailSendCallback(message, pluginId);
492
+ }
493
+ log(level, msg, data) {
494
+ const { pluginId } = this.ctx.props;
495
+ console[level](`[plugin:${pluginId}]`, msg, data ?? "");
496
+ }
497
+ };
498
+
499
+ //#endregion
500
+ //#region src/sandbox/wrapper.ts
501
+ const TRAILING_SLASH_RE = /\/$/;
502
+ const NEWLINE_RE = /[\n\r]/g;
503
+ const COMMENT_CLOSE_RE = /\*\//g;
504
+ function generatePluginWrapper(manifest, options) {
505
+ const storageCollections = Object.keys(manifest.storage || {});
506
+ const site = options?.site ?? {
507
+ name: "",
508
+ url: "",
509
+ locale: "en"
510
+ };
511
+ const hasReadUsers = manifest.capabilities.includes("read:users");
512
+ const hasEmailSend = manifest.capabilities.includes("email:send");
513
+ return `
514
+ // =============================================================================
515
+ // Sandboxed Plugin Wrapper
516
+ // Generated by @emdash-cms/cloudflare
517
+ // Plugin: ${sanitizeComment(manifest.id)}@${sanitizeComment(manifest.version)}
518
+ // =============================================================================
519
+
520
+ import { WorkerEntrypoint } from "cloudflare:workers";
521
+
522
+ // Plugin code lives in a separate module for scope isolation
523
+ import pluginModule from "sandbox-plugin.js";
524
+
525
+ // Extract hooks and routes from the plugin module
526
+ const hooks = pluginModule?.hooks || pluginModule?.default?.hooks || {};
527
+ const routes = pluginModule?.routes || pluginModule?.default?.routes || {};
528
+
529
+ // -----------------------------------------------------------------------------
530
+ // Context Factory - creates ctx that proxies to BRIDGE
531
+ // -----------------------------------------------------------------------------
532
+
533
+ function createContext(env) {
534
+ const bridge = env.BRIDGE;
535
+ const storageCollections = ${JSON.stringify(storageCollections)};
536
+
537
+ // KV - proxies to bridge.kvGet/Set/Delete/List
538
+ const kv = {
539
+ get: (key) => bridge.kvGet(key),
540
+ set: (key, value) => bridge.kvSet(key, value),
541
+ delete: (key) => bridge.kvDelete(key),
542
+ list: (prefix) => bridge.kvList(prefix)
543
+ };
544
+
545
+ // Storage collection factory
546
+ function createStorageCollection(collectionName) {
547
+ return {
548
+ get: (id) => bridge.storageGet(collectionName, id),
549
+ put: (id, data) => bridge.storagePut(collectionName, id, data),
550
+ delete: (id) => bridge.storageDelete(collectionName, id),
551
+ exists: async (id) => (await bridge.storageGet(collectionName, id)) !== null,
552
+ query: (opts) => bridge.storageQuery(collectionName, opts),
553
+ count: (where) => bridge.storageCount(collectionName, where),
554
+ getMany: (ids) => bridge.storageGetMany(collectionName, ids),
555
+ putMany: (items) => bridge.storagePutMany(collectionName, items),
556
+ deleteMany: (ids) => bridge.storageDeleteMany(collectionName, ids)
557
+ };
558
+ }
559
+
560
+ // Storage proxy that creates collections on access
561
+ const storage = new Proxy({}, {
562
+ get(_, collectionName) {
563
+ if (typeof collectionName !== "string") return undefined;
564
+ return createStorageCollection(collectionName);
565
+ }
566
+ });
567
+
568
+ // Content access - proxies to bridge (capability enforced by bridge)
569
+ const content = {
570
+ get: (collection, id) => bridge.contentGet(collection, id),
571
+ list: (collection, opts) => bridge.contentList(collection, opts),
572
+ create: (collection, data) => bridge.contentCreate(collection, data),
573
+ update: (collection, id, data) => bridge.contentUpdate(collection, id, data),
574
+ delete: (collection, id) => bridge.contentDelete(collection, id)
575
+ };
576
+
577
+ // Media access - proxies to bridge (capability enforced by bridge)
578
+ const media = {
579
+ get: (id) => bridge.mediaGet(id),
580
+ list: (opts) => bridge.mediaList(opts),
581
+ upload: (filename, contentType, bytes) => bridge.mediaUpload(filename, contentType, bytes),
582
+ getUploadUrl: () => { throw new Error("getUploadUrl is not available in sandbox mode. Use media.upload(filename, contentType, bytes) instead."); },
583
+ delete: (id) => bridge.mediaDelete(id)
584
+ };
585
+
586
+ // HTTP access - proxies to bridge (capability + host enforced by bridge)
587
+ const http = {
588
+ fetch: async (url, init) => {
589
+ const result = await bridge.httpFetch(url, init);
590
+ // Bridge returns serialized response, reconstruct Response-like object
591
+ return {
592
+ status: result.status,
593
+ ok: result.status >= 200 && result.status < 300,
594
+ headers: new Headers(result.headers),
595
+ text: async () => result.text,
596
+ json: async () => JSON.parse(result.text)
597
+ };
598
+ }
599
+ };
600
+
601
+ // Logger - proxies to bridge
602
+ const log = {
603
+ debug: (msg, data) => bridge.log("debug", msg, data),
604
+ info: (msg, data) => bridge.log("info", msg, data),
605
+ warn: (msg, data) => bridge.log("warn", msg, data),
606
+ error: (msg, data) => bridge.log("error", msg, data)
607
+ };
608
+
609
+ // Site info - injected at wrapper generation time, no RPC needed
610
+ const site = ${JSON.stringify(site)};
611
+
612
+ // URL helper - generates absolute URLs from paths
613
+ const siteBaseUrl = ${JSON.stringify(site.url.replace(TRAILING_SLASH_RE, ""))};
614
+ function url(path) {
615
+ if (!path.startsWith("/")) {
616
+ throw new Error('URL path must start with "/", got: "' + path + '"');
617
+ }
618
+ if (path.startsWith("//")) {
619
+ throw new Error('URL path must not be protocol-relative, got: "' + path + '"');
620
+ }
621
+ return siteBaseUrl + path;
622
+ }
623
+
624
+ // User access - proxies to bridge (capability enforced by bridge)
625
+ const users = ${hasReadUsers} ? {
626
+ get: (id) => bridge.userGet(id),
627
+ getByEmail: (email) => bridge.userGetByEmail(email),
628
+ list: (opts) => bridge.userList(opts)
629
+ } : undefined;
630
+
631
+ // Email access - proxies to bridge (capability enforced by bridge)
632
+ const email = ${hasEmailSend} ? {
633
+ send: (message) => bridge.emailSend(message)
634
+ } : undefined;
635
+
636
+ return {
637
+ plugin: {
638
+ id: env.PLUGIN_ID,
639
+ version: env.PLUGIN_VERSION
640
+ },
641
+ storage,
642
+ kv,
643
+ content,
644
+ media,
645
+ http,
646
+ log,
647
+ site,
648
+ url,
649
+ users,
650
+ email
651
+ };
652
+ }
653
+
654
+ // -----------------------------------------------------------------------------
655
+ // Worker Entrypoint (RPC interface)
656
+ // -----------------------------------------------------------------------------
657
+
658
+ export default class PluginEntrypoint extends WorkerEntrypoint {
659
+ async invokeHook(hookName, event) {
660
+ const ctx = createContext(this.env);
661
+
662
+ // Find the hook handler
663
+ const hookDef = hooks[hookName];
664
+
665
+ if (!hookDef) {
666
+ // No handler for this hook - that's ok, return undefined
667
+ return undefined;
668
+ }
669
+
670
+ // Get the handler (might be wrapped in config object)
671
+ const handler = typeof hookDef === "function" ? hookDef : hookDef.handler;
672
+
673
+ if (typeof handler !== "function") {
674
+ throw new Error(\`Hook \${hookName} handler is not a function\`);
675
+ }
676
+
677
+ // Execute the hook
678
+ return handler(event, ctx);
679
+ }
680
+
681
+ async invokeRoute(routeName, input, serializedRequest) {
682
+ const ctx = createContext(this.env);
683
+
684
+ // Find the route handler
685
+ const route = routes[routeName];
686
+
687
+ if (!route) {
688
+ throw new Error(\`Route not found: \${routeName}\`);
689
+ }
690
+
691
+ // Get handler (might be direct function or object with handler)
692
+ const handler = typeof route === "function" ? route : route.handler;
693
+
694
+ if (typeof handler !== "function") {
695
+ throw new Error(\`Route \${routeName} handler is not a function\`);
696
+ }
697
+
698
+ // Execute the route handler with input, request metadata, and context
699
+ return handler({ input, request: serializedRequest, requestMeta: serializedRequest.meta }, ctx);
700
+ }
701
+ }
702
+ `;
703
+ }
704
+ /**
705
+ * Sanitize a string for inclusion in a JavaScript comment.
706
+ * Prevents comment injection via manifest.id or manifest.version containing
707
+ * newlines or comment-closing sequences.
708
+ */
709
+ function sanitizeComment(s) {
710
+ return s.replace(NEWLINE_RE, " ").replace(COMMENT_CLOSE_RE, "* /");
711
+ }
712
+
713
+ //#endregion
714
+ //#region src/sandbox/runner.ts
715
+ /**
716
+ * Cloudflare Sandbox Runner
717
+ *
718
+ * Uses Worker Loader to run plugins in isolated V8 isolates.
719
+ * Plugins communicate with the host via a BRIDGE service binding
720
+ * that enforces capabilities and scopes operations.
721
+ *
722
+ * This module imports directly from cloudflare:workers to access
723
+ * the LOADER binding and PluginBridge export. It's only loaded
724
+ * when the user configures `sandboxRunner: "@emdash-cms/cloudflare/sandbox"`.
725
+ *
726
+ */
727
+ /**
728
+ * Default resource limits for sandboxed plugins.
729
+ *
730
+ * cpuMs and subrequests are enforced by Worker Loader at the V8 isolate level.
731
+ * wallTimeMs is enforced by the runner via Promise.race.
732
+ * memoryMb is declared for API compatibility but NOT currently enforced —
733
+ * Worker Loader doesn't expose a memory limit option. V8 isolates have a
734
+ * platform-level memory ceiling (~128MB) but it's not configurable per-worker.
735
+ */
736
+ const DEFAULT_LIMITS = {
737
+ cpuMs: 50,
738
+ memoryMb: 128,
739
+ subrequests: 10,
740
+ wallTimeMs: 3e4
741
+ };
742
+ /**
743
+ * Get the Worker Loader binding from env
744
+ */
745
+ function getLoader() {
746
+ return env.LOADER;
747
+ }
748
+ /**
749
+ * Get the PluginBridge from exports (loopback binding)
750
+ */
751
+ function getPluginBridge() {
752
+ return exports.PluginBridge;
753
+ }
754
+ /**
755
+ * Resolve resource limits by merging user-provided overrides with defaults.
756
+ */
757
+ function resolveLimits(limits) {
758
+ return {
759
+ cpuMs: limits?.cpuMs ?? DEFAULT_LIMITS.cpuMs,
760
+ memoryMb: limits?.memoryMb ?? DEFAULT_LIMITS.memoryMb,
761
+ subrequests: limits?.subrequests ?? DEFAULT_LIMITS.subrequests,
762
+ wallTimeMs: limits?.wallTimeMs ?? DEFAULT_LIMITS.wallTimeMs
763
+ };
764
+ }
765
+ /**
766
+ * Cloudflare sandbox runner using Worker Loader.
767
+ */
768
+ var CloudflareSandboxRunner = class {
769
+ plugins = /* @__PURE__ */ new Map();
770
+ options;
771
+ resolvedLimits;
772
+ siteInfo;
773
+ constructor(options) {
774
+ this.options = options;
775
+ this.resolvedLimits = resolveLimits(options.limits);
776
+ this.siteInfo = options.siteInfo;
777
+ setEmailSendCallback(options.emailSend ?? null);
778
+ }
779
+ /**
780
+ * Set the email send callback for sandboxed plugins.
781
+ * Called after the EmailPipeline is created, since the pipeline
782
+ * doesn't exist when the sandbox runner is constructed.
783
+ */
784
+ setEmailSend(callback) {
785
+ setEmailSendCallback(callback);
786
+ }
787
+ /**
788
+ * Check if Worker Loader is available.
789
+ */
790
+ isAvailable() {
791
+ return !!getLoader() && !!getPluginBridge();
792
+ }
793
+ /**
794
+ * Load a sandboxed plugin.
795
+ *
796
+ * @param manifest - Plugin manifest with capabilities and storage declarations
797
+ * @param code - The bundled plugin JavaScript code
798
+ */
799
+ async load(manifest, code) {
800
+ const pluginId = `${manifest.id}:${manifest.version}`;
801
+ const existing = this.plugins.get(pluginId);
802
+ if (existing) return existing;
803
+ const loader = getLoader();
804
+ const pluginBridge = getPluginBridge();
805
+ if (!loader) throw new Error("Worker Loader not available. Add worker_loaders binding to wrangler config.");
806
+ if (!pluginBridge) throw new Error("PluginBridge not available. Export PluginBridge from your worker entrypoint.");
807
+ const plugin = new CloudflareSandboxedPlugin(manifest, code, loader, pluginBridge, this.resolvedLimits, this.siteInfo);
808
+ this.plugins.set(pluginId, plugin);
809
+ return plugin;
810
+ }
811
+ /**
812
+ * Terminate all loaded plugins.
813
+ */
814
+ async terminateAll() {
815
+ for (const plugin of this.plugins.values()) await plugin.terminate();
816
+ this.plugins.clear();
817
+ }
818
+ };
819
+ /**
820
+ * A plugin running in a Worker Loader isolate.
821
+ *
822
+ * IMPORTANT: Worker stubs and bridge bindings are tied to request context.
823
+ * We must create fresh stubs for each invocation to avoid I/O isolation errors:
824
+ * "Cannot perform I/O on behalf of a different request"
825
+ */
826
+ var CloudflareSandboxedPlugin = class {
827
+ id;
828
+ manifest;
829
+ loader;
830
+ createBridge;
831
+ code;
832
+ wrapperCode = null;
833
+ limits;
834
+ siteInfo;
835
+ constructor(manifest, code, loader, createBridge, limits, siteInfo) {
836
+ this.id = `${manifest.id}:${manifest.version}`;
837
+ this.manifest = manifest;
838
+ this.code = code;
839
+ this.loader = loader;
840
+ this.createBridge = createBridge;
841
+ this.limits = limits;
842
+ this.siteInfo = siteInfo;
843
+ }
844
+ /**
845
+ * Create a fresh worker stub for the current request.
846
+ *
847
+ * Worker Loader stubs contain bindings (like BRIDGE) that are tied to the
848
+ * request context in which they were created. Reusing stubs across requests
849
+ * causes "Cannot perform I/O on behalf of a different request" errors.
850
+ *
851
+ * The Worker Loader internally caches the V8 isolate, so we only pay the
852
+ * cost of creating the bridge binding and stub wrapper per request.
853
+ */
854
+ createWorker() {
855
+ if (!this.wrapperCode) this.wrapperCode = generatePluginWrapper(this.manifest, { site: this.siteInfo });
856
+ const bridgeBinding = this.createBridge({ props: {
857
+ pluginId: this.manifest.id,
858
+ pluginVersion: this.manifest.version || "0.0.0",
859
+ capabilities: this.manifest.capabilities || [],
860
+ allowedHosts: this.manifest.allowedHosts || [],
861
+ storageCollections: Object.keys(this.manifest.storage || {})
862
+ } });
863
+ const loaderLimits = {
864
+ cpuMs: this.limits.cpuMs,
865
+ subRequests: this.limits.subrequests
866
+ };
867
+ return this.loader.get(this.id, () => ({
868
+ compatibilityDate: "2025-01-01",
869
+ mainModule: "plugin.js",
870
+ modules: {
871
+ "plugin.js": { js: this.wrapperCode },
872
+ "sandbox-plugin.js": { js: this.code }
873
+ },
874
+ globalOutbound: null,
875
+ limits: loaderLimits,
876
+ env: {
877
+ PLUGIN_ID: this.manifest.id,
878
+ PLUGIN_VERSION: this.manifest.version || "0.0.0",
879
+ BRIDGE: bridgeBinding
880
+ }
881
+ }));
882
+ }
883
+ /**
884
+ * Run a function with wall-time enforcement.
885
+ *
886
+ * CPU limits and subrequest limits are enforced by the Worker Loader
887
+ * at the V8 isolate level. Wall-time is enforced here because Worker
888
+ * Loader doesn't expose a wall-time limit — a plugin could stall
889
+ * indefinitely waiting on network I/O.
890
+ */
891
+ async withWallTimeLimit(operation, fn) {
892
+ const wallTimeMs = this.limits.wallTimeMs;
893
+ let timer;
894
+ const timeout = new Promise((_, reject) => {
895
+ timer = setTimeout(() => {
896
+ reject(/* @__PURE__ */ new Error(`Plugin ${this.manifest.id} exceeded wall-time limit of ${wallTimeMs}ms during ${operation}`));
897
+ }, wallTimeMs);
898
+ });
899
+ try {
900
+ return await Promise.race([fn(), timeout]);
901
+ } finally {
902
+ if (timer !== void 0) clearTimeout(timer);
903
+ }
904
+ }
905
+ /**
906
+ * Invoke a hook in the sandboxed plugin.
907
+ *
908
+ * CPU and subrequest limits are enforced by Worker Loader.
909
+ * Wall-time is enforced here.
910
+ */
911
+ async invokeHook(hookName, event) {
912
+ return this.withWallTimeLimit(`hook:${hookName}`, () => {
913
+ return this.createWorker().getEntrypoint("default").invokeHook(hookName, event);
914
+ });
915
+ }
916
+ /**
917
+ * Invoke an API route in the sandboxed plugin.
918
+ *
919
+ * CPU and subrequest limits are enforced by Worker Loader.
920
+ * Wall-time is enforced here.
921
+ */
922
+ async invokeRoute(routeName, input, request) {
923
+ return this.withWallTimeLimit(`route:${routeName}`, () => {
924
+ return this.createWorker().getEntrypoint("default").invokeRoute(routeName, input, request);
925
+ });
926
+ }
927
+ /**
928
+ * Terminate the sandboxed plugin.
929
+ */
930
+ async terminate() {
931
+ this.wrapperCode = null;
932
+ }
933
+ };
934
+ /**
935
+ * Factory function for creating the Cloudflare sandbox runner.
936
+ *
937
+ * Matches the SandboxRunnerFactory signature. The LOADER and PluginBridge
938
+ * are obtained internally from cloudflare:workers imports.
939
+ */
940
+ const createSandboxRunner = (options) => {
941
+ return new CloudflareSandboxRunner(options);
942
+ };
943
+
944
+ //#endregion
945
+ export { CloudflareSandboxRunner, PluginBridge, createSandboxRunner, generatePluginWrapper, setEmailSendCallback };