@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,1008 @@
1
+ /**
2
+ * PluginBridge WorkerEntrypoint
3
+ *
4
+ * Provides controlled access to database operations for sandboxed plugins.
5
+ * The sandbox gets a SERVICE BINDING to this entrypoint, not direct DB access.
6
+ * All operations are validated and scoped to the plugin.
7
+ *
8
+ */
9
+
10
+ import { WorkerEntrypoint } from "cloudflare:workers";
11
+ import type { SandboxEmailSendCallback } from "emdash";
12
+ import { ulid } from "emdash";
13
+
14
+ /** Regex to validate collection names (prevent SQL injection) */
15
+ const COLLECTION_NAME_REGEX = /^[a-z][a-z0-9_]*$/;
16
+
17
+ /** Regex to validate file extensions (simple alphanumeric, 1-10 chars) */
18
+ const FILE_EXT_REGEX = /^\.[a-z0-9]{1,10}$/i;
19
+
20
+ /** System columns that plugins cannot directly write to */
21
+ const SYSTEM_COLUMNS = new Set([
22
+ "id",
23
+ "slug",
24
+ "status",
25
+ "author_id",
26
+ "created_at",
27
+ "updated_at",
28
+ "published_at",
29
+ "scheduled_at",
30
+ "deleted_at",
31
+ "version",
32
+ "live_revision_id",
33
+ "draft_revision_id",
34
+ ]);
35
+
36
+ /**
37
+ * Module-level email send callback.
38
+ *
39
+ * The bridge runs in the host process (same worker), so we can use a
40
+ * module-level callback that the runner sets before creating bridge bindings.
41
+ * This avoids the need to pass non-serializable functions through props.
42
+ *
43
+ * @see runner.ts setEmailSendCallback()
44
+ */
45
+ let emailSendCallback: SandboxEmailSendCallback | null = null;
46
+
47
+ /**
48
+ * Set the email send callback for all bridge instances.
49
+ * Called by the runner when the EmailPipeline is available.
50
+ */
51
+ export function setEmailSendCallback(callback: SandboxEmailSendCallback | null): void {
52
+ emailSendCallback = callback;
53
+ }
54
+
55
+ /**
56
+ * Serialize a value for D1 storage.
57
+ * Mirrors core's serializeValue: objects/arrays → JSON strings,
58
+ * booleans → 0/1, null/undefined → null, everything else passthrough.
59
+ */
60
+ function serializeValue(value: unknown): unknown {
61
+ if (value === null || value === undefined) return null;
62
+ if (typeof value === "boolean") return value ? 1 : 0;
63
+ if (typeof value === "object") return JSON.stringify(value);
64
+ return value;
65
+ }
66
+
67
+ /**
68
+ * Deserialize a row from D1 into a content response shape.
69
+ * Extracts system columns and bundles remaining columns into data.
70
+ */
71
+ /**
72
+ * Deserialize a row from D1 into a ContentItem matching core's plugin API.
73
+ * Extracts system columns, deserializes JSON fields, and returns the
74
+ * canonical shape: { id, type, data, createdAt, updatedAt }.
75
+ */
76
+ function rowToContentItem(
77
+ collection: string,
78
+ row: Record<string, unknown>,
79
+ ): {
80
+ id: string;
81
+ type: string;
82
+ data: Record<string, unknown>;
83
+ createdAt: string;
84
+ updatedAt: string;
85
+ } {
86
+ const data: Record<string, unknown> = {};
87
+ for (const [key, value] of Object.entries(row)) {
88
+ if (!SYSTEM_COLUMNS.has(key)) {
89
+ // Attempt to parse JSON strings back to objects
90
+ if (typeof value === "string" && (value.startsWith("{") || value.startsWith("["))) {
91
+ try {
92
+ data[key] = JSON.parse(value);
93
+ } catch {
94
+ data[key] = value;
95
+ }
96
+ } else if (value !== null) {
97
+ data[key] = value;
98
+ }
99
+ }
100
+ }
101
+
102
+ return {
103
+ id: typeof row.id === "string" ? row.id : String(row.id),
104
+ type: collection,
105
+ data,
106
+ createdAt: typeof row.created_at === "string" ? row.created_at : new Date().toISOString(),
107
+ updatedAt: typeof row.updated_at === "string" ? row.updated_at : new Date().toISOString(),
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Environment bindings required by PluginBridge
113
+ */
114
+ export interface PluginBridgeEnv {
115
+ DB: D1Database;
116
+ MEDIA?: R2Bucket;
117
+ }
118
+
119
+ /**
120
+ * Props passed to the bridge via ctx.props when creating the loopback binding
121
+ */
122
+ export interface PluginBridgeProps {
123
+ pluginId: string;
124
+ pluginVersion: string;
125
+ capabilities: string[];
126
+ allowedHosts: string[];
127
+ storageCollections: string[];
128
+ }
129
+
130
+ /**
131
+ * PluginBridge WorkerEntrypoint
132
+ *
133
+ * Provides the context API to sandboxed plugins via RPC.
134
+ * All methods validate capabilities and scope operations to the plugin.
135
+ *
136
+ * Usage:
137
+ * 1. Export this class from your worker entrypoint
138
+ * 2. Sandboxed plugins get a binding to it via ctx.exports.PluginBridge({...})
139
+ * 3. Plugins call bridge methods which validate and proxy to the database
140
+ */
141
+ export class PluginBridge extends WorkerEntrypoint<PluginBridgeEnv, PluginBridgeProps> {
142
+ // =========================================================================
143
+ // KV Operations - scoped to plugin namespace
144
+ // =========================================================================
145
+
146
+ /**
147
+ * KV operations use _plugin_storage with a special "__kv" collection.
148
+ * This provides consistent storage across sandboxed and non-sandboxed modes.
149
+ */
150
+ async kvGet(key: string): Promise<unknown> {
151
+ const { pluginId } = this.ctx.props;
152
+ const result = await this.env.DB.prepare(
153
+ "SELECT data FROM _plugin_storage WHERE plugin_id = ? AND collection = '__kv' AND id = ?",
154
+ )
155
+ .bind(pluginId, key)
156
+ .first<{ data: string }>();
157
+ if (!result) return null;
158
+ try {
159
+ return JSON.parse(result.data);
160
+ } catch {
161
+ return result.data;
162
+ }
163
+ }
164
+
165
+ async kvSet(key: string, value: unknown): Promise<void> {
166
+ const { pluginId } = this.ctx.props;
167
+ await this.env.DB.prepare(
168
+ "INSERT OR REPLACE INTO _plugin_storage (plugin_id, collection, id, data, updated_at) VALUES (?, '__kv', ?, ?, datetime('now'))",
169
+ )
170
+ .bind(pluginId, key, JSON.stringify(value))
171
+ .run();
172
+ }
173
+
174
+ async kvDelete(key: string): Promise<boolean> {
175
+ const { pluginId } = this.ctx.props;
176
+ const result = await this.env.DB.prepare(
177
+ "DELETE FROM _plugin_storage WHERE plugin_id = ? AND collection = '__kv' AND id = ?",
178
+ )
179
+ .bind(pluginId, key)
180
+ .run();
181
+ return (result.meta?.changes ?? 0) > 0;
182
+ }
183
+
184
+ async kvList(prefix: string = ""): Promise<Array<{ key: string; value: unknown }>> {
185
+ const { pluginId } = this.ctx.props;
186
+ const results = await this.env.DB.prepare(
187
+ "SELECT id, data FROM _plugin_storage WHERE plugin_id = ? AND collection = '__kv' AND id LIKE ?",
188
+ )
189
+ .bind(pluginId, prefix + "%")
190
+ .all<{ id: string; data: string }>();
191
+
192
+ return (results.results ?? []).map((row) => ({
193
+ key: row.id,
194
+ value: JSON.parse(row.data),
195
+ }));
196
+ }
197
+
198
+ // =========================================================================
199
+ // Storage Operations - scoped to plugin + collection validation
200
+ // =========================================================================
201
+
202
+ async storageGet(collection: string, id: string): Promise<unknown> {
203
+ const { pluginId, storageCollections } = this.ctx.props;
204
+ if (!storageCollections.includes(collection)) {
205
+ throw new Error(`Storage collection not declared: ${collection}`);
206
+ }
207
+ const result = await this.env.DB.prepare(
208
+ "SELECT data FROM _plugin_storage WHERE plugin_id = ? AND collection = ? AND id = ?",
209
+ )
210
+ .bind(pluginId, collection, id)
211
+ .first<{ data: string }>();
212
+ if (!result) return null;
213
+ return JSON.parse(result.data);
214
+ }
215
+
216
+ async storagePut(collection: string, id: string, data: unknown): Promise<void> {
217
+ const { pluginId, storageCollections } = this.ctx.props;
218
+ if (!storageCollections.includes(collection)) {
219
+ throw new Error(`Storage collection not declared: ${collection}`);
220
+ }
221
+ await this.env.DB.prepare(
222
+ "INSERT OR REPLACE INTO _plugin_storage (plugin_id, collection, id, data, updated_at) VALUES (?, ?, ?, ?, datetime('now'))",
223
+ )
224
+ .bind(pluginId, collection, id, JSON.stringify(data))
225
+ .run();
226
+ }
227
+
228
+ async storageDelete(collection: string, id: string): Promise<boolean> {
229
+ const { pluginId, storageCollections } = this.ctx.props;
230
+ if (!storageCollections.includes(collection)) {
231
+ throw new Error(`Storage collection not declared: ${collection}`);
232
+ }
233
+ const result = await this.env.DB.prepare(
234
+ "DELETE FROM _plugin_storage WHERE plugin_id = ? AND collection = ? AND id = ?",
235
+ )
236
+ .bind(pluginId, collection, id)
237
+ .run();
238
+ return (result.meta?.changes ?? 0) > 0;
239
+ }
240
+
241
+ async storageQuery(
242
+ collection: string,
243
+ opts: { limit?: number; cursor?: string } = {},
244
+ ): Promise<{
245
+ items: Array<{ id: string; data: unknown }>;
246
+ hasMore: boolean;
247
+ cursor?: string;
248
+ }> {
249
+ const { pluginId, storageCollections } = this.ctx.props;
250
+ if (!storageCollections.includes(collection)) {
251
+ throw new Error(`Storage collection not declared: ${collection}`);
252
+ }
253
+ const limit = Math.min(opts.limit ?? 50, 1000);
254
+ const results = await this.env.DB.prepare(
255
+ "SELECT id, data FROM _plugin_storage WHERE plugin_id = ? AND collection = ? LIMIT ?",
256
+ )
257
+ .bind(pluginId, collection, limit + 1)
258
+ .all<{ id: string; data: string }>();
259
+
260
+ const items = (results.results ?? []).slice(0, limit).map((row) => ({
261
+ id: row.id,
262
+ data: JSON.parse(row.data),
263
+ }));
264
+ return {
265
+ items,
266
+ hasMore: (results.results ?? []).length > limit,
267
+ cursor: items.length > 0 ? items.at(-1)!.id : undefined,
268
+ };
269
+ }
270
+
271
+ async storageCount(collection: string): Promise<number> {
272
+ const { pluginId, storageCollections } = this.ctx.props;
273
+ if (!storageCollections.includes(collection)) {
274
+ throw new Error(`Storage collection not declared: ${collection}`);
275
+ }
276
+ const result = await this.env.DB.prepare(
277
+ "SELECT COUNT(*) as count FROM _plugin_storage WHERE plugin_id = ? AND collection = ?",
278
+ )
279
+ .bind(pluginId, collection)
280
+ .first<{ count: number }>();
281
+ return result?.count ?? 0;
282
+ }
283
+
284
+ async storageGetMany(collection: string, ids: string[]): Promise<Map<string, unknown>> {
285
+ const { pluginId, storageCollections } = this.ctx.props;
286
+ if (!storageCollections.includes(collection)) {
287
+ throw new Error(`Storage collection not declared: ${collection}`);
288
+ }
289
+ if (ids.length === 0) return new Map();
290
+
291
+ const placeholders = ids.map(() => "?").join(",");
292
+ const results = await this.env.DB.prepare(
293
+ `SELECT id, data FROM _plugin_storage WHERE plugin_id = ? AND collection = ? AND id IN (${placeholders})`,
294
+ )
295
+ .bind(pluginId, collection, ...ids)
296
+ .all<{ id: string; data: string }>();
297
+
298
+ const map = new Map<string, unknown>();
299
+ for (const row of results.results ?? []) {
300
+ map.set(row.id, JSON.parse(row.data));
301
+ }
302
+ return map;
303
+ }
304
+
305
+ async storagePutMany(
306
+ collection: string,
307
+ items: Array<{ id: string; data: unknown }>,
308
+ ): Promise<void> {
309
+ const { pluginId, storageCollections } = this.ctx.props;
310
+ if (!storageCollections.includes(collection)) {
311
+ throw new Error(`Storage collection not declared: ${collection}`);
312
+ }
313
+ if (items.length === 0) return;
314
+
315
+ // D1 doesn't support batch in prepare, so we do individual inserts
316
+ // In future, we could use batch API
317
+ for (const item of items) {
318
+ await this.env.DB.prepare(
319
+ "INSERT OR REPLACE INTO _plugin_storage (plugin_id, collection, id, data, updated_at) VALUES (?, ?, ?, ?, datetime('now'))",
320
+ )
321
+ .bind(pluginId, collection, item.id, JSON.stringify(item.data))
322
+ .run();
323
+ }
324
+ }
325
+
326
+ async storageDeleteMany(collection: string, ids: string[]): Promise<number> {
327
+ const { pluginId, storageCollections } = this.ctx.props;
328
+ if (!storageCollections.includes(collection)) {
329
+ throw new Error(`Storage collection not declared: ${collection}`);
330
+ }
331
+ if (ids.length === 0) return 0;
332
+
333
+ let deleted = 0;
334
+ for (const id of ids) {
335
+ const result = await this.env.DB.prepare(
336
+ "DELETE FROM _plugin_storage WHERE plugin_id = ? AND collection = ? AND id = ?",
337
+ )
338
+ .bind(pluginId, collection, id)
339
+ .run();
340
+ deleted += result.meta?.changes ?? 0;
341
+ }
342
+ return deleted;
343
+ }
344
+
345
+ // =========================================================================
346
+ // Content Operations - capability-gated
347
+ // =========================================================================
348
+
349
+ async contentGet(
350
+ collection: string,
351
+ id: string,
352
+ ): Promise<{
353
+ id: string;
354
+ type: string;
355
+ data: Record<string, unknown>;
356
+ createdAt: string;
357
+ updatedAt: string;
358
+ } | null> {
359
+ const { capabilities } = this.ctx.props;
360
+ if (!capabilities.includes("read:content")) {
361
+ throw new Error("Missing capability: read:content");
362
+ }
363
+ // Validate collection name to prevent SQL injection
364
+ if (!COLLECTION_NAME_REGEX.test(collection)) {
365
+ throw new Error(`Invalid collection name: ${collection}`);
366
+ }
367
+ try {
368
+ // Content tables use ec_${collection} naming (no leading underscore)
369
+ // Exclude soft-deleted items
370
+ const result = await this.env.DB.prepare(
371
+ `SELECT * FROM ec_${collection} WHERE id = ? AND deleted_at IS NULL`,
372
+ )
373
+ .bind(id)
374
+ .first();
375
+ if (!result) return null;
376
+ return rowToContentItem(collection, result);
377
+ } catch {
378
+ return null;
379
+ }
380
+ }
381
+
382
+ async contentList(
383
+ collection: string,
384
+ opts: { limit?: number; cursor?: string } = {},
385
+ ): Promise<{
386
+ items: Array<{
387
+ id: string;
388
+ type: string;
389
+ data: Record<string, unknown>;
390
+ createdAt: string;
391
+ updatedAt: string;
392
+ }>;
393
+ cursor?: string;
394
+ hasMore: boolean;
395
+ }> {
396
+ const { capabilities } = this.ctx.props;
397
+ if (!capabilities.includes("read:content")) {
398
+ throw new Error("Missing capability: read:content");
399
+ }
400
+ // Validate collection name to prevent SQL injection
401
+ if (!COLLECTION_NAME_REGEX.test(collection)) {
402
+ throw new Error(`Invalid collection name: ${collection}`);
403
+ }
404
+ const limit = Math.min(opts.limit ?? 50, 100);
405
+ try {
406
+ // Content tables use ec_${collection} naming (no leading underscore)
407
+ // Exclude soft-deleted items. Ordered by ULID (id DESC) for deterministic
408
+ // cursor pagination. ULIDs are time-sortable so this approximates created_at DESC.
409
+ let sql = `SELECT * FROM ec_${collection} WHERE deleted_at IS NULL`;
410
+ const params: unknown[] = [];
411
+
412
+ if (opts.cursor) {
413
+ sql += " AND id < ?";
414
+ params.push(opts.cursor);
415
+ }
416
+
417
+ sql += " ORDER BY id DESC LIMIT ?";
418
+ params.push(limit + 1);
419
+
420
+ const results = await this.env.DB.prepare(sql)
421
+ .bind(...params)
422
+ .all();
423
+
424
+ const rows = results.results ?? [];
425
+ const pageRows = rows.slice(0, limit);
426
+ const items = pageRows.map((row) => rowToContentItem(collection, row));
427
+ const hasMore = rows.length > limit;
428
+
429
+ return {
430
+ items,
431
+ cursor: hasMore && items.length > 0 ? items.at(-1)!.id : undefined,
432
+ hasMore,
433
+ };
434
+ } catch {
435
+ return { items: [], hasMore: false };
436
+ }
437
+ }
438
+
439
+ async contentCreate(
440
+ collection: string,
441
+ data: Record<string, unknown>,
442
+ ): Promise<{
443
+ id: string;
444
+ type: string;
445
+ data: Record<string, unknown>;
446
+ createdAt: string;
447
+ updatedAt: string;
448
+ }> {
449
+ const { capabilities } = this.ctx.props;
450
+ if (!capabilities.includes("write:content")) {
451
+ throw new Error("Missing capability: write:content");
452
+ }
453
+ if (!COLLECTION_NAME_REGEX.test(collection)) {
454
+ throw new Error(`Invalid collection name: ${collection}`);
455
+ }
456
+
457
+ const id = ulid();
458
+ const now = new Date().toISOString();
459
+
460
+ // Build columns and values arrays — quote identifiers to avoid SQL keyword collisions
461
+ const columns: string[] = [
462
+ '"id"',
463
+ '"slug"',
464
+ '"status"',
465
+ '"author_id"',
466
+ '"created_at"',
467
+ '"updated_at"',
468
+ '"version"',
469
+ ];
470
+ const values: unknown[] = [
471
+ id,
472
+ typeof data.slug === "string" ? data.slug : null,
473
+ typeof data.status === "string" ? data.status : "draft",
474
+ typeof data.author_id === "string" ? data.author_id : null,
475
+ now,
476
+ now,
477
+ 1,
478
+ ];
479
+
480
+ // Append user data fields (skip system columns, quote identifiers)
481
+ for (const [key, value] of Object.entries(data)) {
482
+ if (!SYSTEM_COLUMNS.has(key) && COLLECTION_NAME_REGEX.test(key)) {
483
+ columns.push(`"${key}"`);
484
+ values.push(serializeValue(value));
485
+ }
486
+ }
487
+
488
+ const placeholders = columns.map(() => "?").join(", ");
489
+ const columnList = columns.join(", ");
490
+
491
+ await this.env.DB.prepare(
492
+ `INSERT INTO ec_${collection} (${columnList}) VALUES (${placeholders})`,
493
+ )
494
+ .bind(...values)
495
+ .run();
496
+
497
+ // Re-read the created row
498
+ const created = await this.env.DB.prepare(
499
+ `SELECT * FROM ec_${collection} WHERE id = ? AND deleted_at IS NULL`,
500
+ )
501
+ .bind(id)
502
+ .first();
503
+
504
+ if (!created) {
505
+ return { id, type: collection, data: {}, createdAt: now, updatedAt: now };
506
+ }
507
+ return rowToContentItem(collection, created);
508
+ }
509
+
510
+ async contentUpdate(
511
+ collection: string,
512
+ id: string,
513
+ data: Record<string, unknown>,
514
+ ): Promise<{
515
+ id: string;
516
+ type: string;
517
+ data: Record<string, unknown>;
518
+ createdAt: string;
519
+ updatedAt: string;
520
+ }> {
521
+ const { capabilities } = this.ctx.props;
522
+ if (!capabilities.includes("write:content")) {
523
+ throw new Error("Missing capability: write:content");
524
+ }
525
+ if (!COLLECTION_NAME_REGEX.test(collection)) {
526
+ throw new Error(`Invalid collection name: ${collection}`);
527
+ }
528
+
529
+ const now = new Date().toISOString();
530
+ // Quote identifiers to avoid SQL keyword collisions
531
+ const setClauses: string[] = ['"updated_at" = ?', '"version" = "version" + 1'];
532
+ const values: unknown[] = [now];
533
+
534
+ // System field updates (only if provided)
535
+ if (typeof data.status === "string") {
536
+ setClauses.push('"status" = ?');
537
+ values.push(data.status);
538
+ }
539
+ if (data.slug !== undefined) {
540
+ setClauses.push('"slug" = ?');
541
+ values.push(typeof data.slug === "string" ? data.slug : null);
542
+ }
543
+
544
+ // User data fields (quote identifiers)
545
+ for (const [key, value] of Object.entries(data)) {
546
+ if (!SYSTEM_COLUMNS.has(key) && COLLECTION_NAME_REGEX.test(key)) {
547
+ setClauses.push(`"${key}" = ?`);
548
+ values.push(serializeValue(value));
549
+ }
550
+ }
551
+
552
+ // WHERE clause: match by id and not soft-deleted
553
+ values.push(id);
554
+
555
+ const result = await this.env.DB.prepare(
556
+ `UPDATE ec_${collection} SET ${setClauses.join(", ")} WHERE "id" = ? AND "deleted_at" IS NULL`,
557
+ )
558
+ .bind(...values)
559
+ .run();
560
+
561
+ if ((result.meta?.changes ?? 0) === 0) {
562
+ throw new Error(`Content not found or deleted: ${collection}/${id}`);
563
+ }
564
+
565
+ // Re-read the updated row (with soft-delete guard)
566
+ const updated = await this.env.DB.prepare(
567
+ `SELECT * FROM ec_${collection} WHERE id = ? AND deleted_at IS NULL`,
568
+ )
569
+ .bind(id)
570
+ .first();
571
+
572
+ if (!updated) {
573
+ throw new Error(`Content not found: ${collection}/${id}`);
574
+ }
575
+ return rowToContentItem(collection, updated);
576
+ }
577
+
578
+ async contentDelete(collection: string, id: string): Promise<boolean> {
579
+ const { capabilities } = this.ctx.props;
580
+ if (!capabilities.includes("write:content")) {
581
+ throw new Error("Missing capability: write:content");
582
+ }
583
+ if (!COLLECTION_NAME_REGEX.test(collection)) {
584
+ throw new Error(`Invalid collection name: ${collection}`);
585
+ }
586
+
587
+ // Soft-delete: set deleted_at timestamp
588
+ const now = new Date().toISOString();
589
+ const result = await this.env.DB.prepare(
590
+ `UPDATE ec_${collection} SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`,
591
+ )
592
+ .bind(now, now, id)
593
+ .run();
594
+ return (result.meta?.changes ?? 0) > 0;
595
+ }
596
+
597
+ // =========================================================================
598
+ // Media Operations - capability-gated
599
+ // =========================================================================
600
+
601
+ async mediaGet(id: string): Promise<{
602
+ id: string;
603
+ filename: string;
604
+ mimeType: string;
605
+ size: number | null;
606
+ url: string;
607
+ createdAt: string;
608
+ } | null> {
609
+ const { capabilities } = this.ctx.props;
610
+ if (!capabilities.includes("read:media")) {
611
+ throw new Error("Missing capability: read:media");
612
+ }
613
+ const result = await this.env.DB.prepare("SELECT * FROM media WHERE id = ?").bind(id).first<{
614
+ id: string;
615
+ filename: string;
616
+ mime_type: string;
617
+ size: number | null;
618
+ storage_key: string;
619
+ created_at: string;
620
+ }>();
621
+ if (!result) return null;
622
+ return {
623
+ id: result.id,
624
+ filename: result.filename,
625
+ mimeType: result.mime_type,
626
+ size: result.size,
627
+ url: `/_emdash/api/media/file/${result.storage_key}`,
628
+ createdAt: result.created_at,
629
+ };
630
+ }
631
+
632
+ async mediaList(opts: { limit?: number; cursor?: string; mimeType?: string } = {}): Promise<{
633
+ items: Array<{
634
+ id: string;
635
+ filename: string;
636
+ mimeType: string;
637
+ size: number | null;
638
+ url: string;
639
+ createdAt: string;
640
+ }>;
641
+ cursor?: string;
642
+ hasMore: boolean;
643
+ }> {
644
+ const { capabilities } = this.ctx.props;
645
+ if (!capabilities.includes("read:media")) {
646
+ throw new Error("Missing capability: read:media");
647
+ }
648
+ const limit = Math.min(opts.limit ?? 50, 100);
649
+ // Only return ready items (matching core's MediaRepository.findMany default)
650
+ let sql = "SELECT * FROM media WHERE status = 'ready'";
651
+ const params: unknown[] = [];
652
+
653
+ if (opts.mimeType) {
654
+ sql += " AND mime_type LIKE ?";
655
+ params.push(opts.mimeType + "%");
656
+ }
657
+
658
+ if (opts.cursor) {
659
+ sql += " AND id < ?";
660
+ params.push(opts.cursor);
661
+ }
662
+
663
+ sql += " ORDER BY id DESC LIMIT ?";
664
+ params.push(limit + 1);
665
+
666
+ const results = await this.env.DB.prepare(sql)
667
+ .bind(...params)
668
+ .all<{
669
+ id: string;
670
+ filename: string;
671
+ mime_type: string;
672
+ size: number | null;
673
+ storage_key: string;
674
+ created_at: string;
675
+ }>();
676
+
677
+ const rows = results.results ?? [];
678
+ const pageRows = rows.slice(0, limit);
679
+ const items = pageRows.map((row) => ({
680
+ id: row.id,
681
+ filename: row.filename,
682
+ mimeType: row.mime_type,
683
+ size: row.size,
684
+ url: `/_emdash/api/media/file/${row.storage_key}`,
685
+ createdAt: row.created_at,
686
+ }));
687
+ const hasMore = rows.length > limit;
688
+
689
+ return {
690
+ items,
691
+ cursor: hasMore && items.length > 0 ? items.at(-1)!.id : undefined,
692
+ hasMore,
693
+ };
694
+ }
695
+
696
+ /**
697
+ * Create a pending media record and write bytes directly to R2.
698
+ *
699
+ * Unlike the admin UI flow (presigned URL → client PUT → confirm), sandboxed
700
+ * plugins are network-isolated and can't make external requests. The bridge
701
+ * accepts the file bytes directly and writes them to storage.
702
+ *
703
+ * Returns the media ID, storage key, and confirm URL. The plugin should
704
+ * call the confirm endpoint after this to finalize the record.
705
+ */
706
+ async mediaUpload(
707
+ filename: string,
708
+ contentType: string,
709
+ bytes: ArrayBuffer,
710
+ ): Promise<{ mediaId: string; storageKey: string; url: string }> {
711
+ const { capabilities } = this.ctx.props;
712
+ if (!capabilities.includes("write:media")) {
713
+ throw new Error("Missing capability: write:media");
714
+ }
715
+
716
+ if (!this.env.MEDIA) {
717
+ throw new Error("Media storage (R2) not configured. Add MEDIA binding to wrangler config.");
718
+ }
719
+
720
+ // Validate MIME type — only allow image, video, audio, and PDF
721
+ const ALLOWED_MIME_PREFIXES = ["image/", "video/", "audio/", "application/pdf"];
722
+ if (!ALLOWED_MIME_PREFIXES.some((prefix) => contentType.startsWith(prefix))) {
723
+ throw new Error(
724
+ `Unsupported content type: ${contentType}. Allowed: image/*, video/*, audio/*, application/pdf`,
725
+ );
726
+ }
727
+
728
+ const mediaId = ulid();
729
+ // Derive extension from basename only, validate it's a simple extension
730
+ const basename = filename.includes("/")
731
+ ? filename.slice(filename.lastIndexOf("/") + 1)
732
+ : filename;
733
+ const rawExt = basename.includes(".") ? basename.slice(basename.lastIndexOf(".")) : "";
734
+ const ext = FILE_EXT_REGEX.test(rawExt) ? rawExt : "";
735
+ // Flat storage key matching core convention: ${ulid}${ext}
736
+ const storageKey = `${mediaId}${ext}`;
737
+ const now = new Date().toISOString();
738
+
739
+ // Write bytes to R2 first, then create DB record.
740
+ // If DB insert fails, clean up the R2 object to prevent orphans.
741
+ await this.env.MEDIA.put(storageKey, bytes, {
742
+ httpMetadata: { contentType },
743
+ });
744
+
745
+ try {
746
+ // Create confirmed media record with ISO timestamp (matching core)
747
+ await this.env.DB.prepare(
748
+ "INSERT INTO media (id, filename, mime_type, size, storage_key, status, created_at) VALUES (?, ?, ?, ?, ?, 'ready', ?)",
749
+ )
750
+ .bind(mediaId, filename, contentType, bytes.byteLength, storageKey, now)
751
+ .run();
752
+ } catch (error) {
753
+ // Clean up R2 object on DB failure to prevent orphans
754
+ try {
755
+ await this.env.MEDIA.delete(storageKey);
756
+ } catch {
757
+ // Best-effort cleanup — log and continue
758
+ console.warn(`[plugin-bridge] Failed to clean up orphaned R2 object: ${storageKey}`);
759
+ }
760
+ throw error;
761
+ }
762
+
763
+ return {
764
+ mediaId,
765
+ storageKey,
766
+ url: `/_emdash/api/media/file/${storageKey}`,
767
+ };
768
+ }
769
+
770
+ async mediaDelete(id: string): Promise<boolean> {
771
+ const { capabilities } = this.ctx.props;
772
+ if (!capabilities.includes("write:media")) {
773
+ throw new Error("Missing capability: write:media");
774
+ }
775
+
776
+ // Look up the storage key before deleting
777
+ const media = await this.env.DB.prepare("SELECT storage_key FROM media WHERE id = ?")
778
+ .bind(id)
779
+ .first<{ storage_key: string }>();
780
+
781
+ if (!media) return false;
782
+
783
+ // Delete the DB row
784
+ const result = await this.env.DB.prepare("DELETE FROM media WHERE id = ?").bind(id).run();
785
+
786
+ // Delete from R2 if the binding is available
787
+ if (this.env.MEDIA && media.storage_key) {
788
+ try {
789
+ await this.env.MEDIA.delete(media.storage_key);
790
+ } catch {
791
+ // Log but don't fail - the DB row is already deleted
792
+ console.warn(`[plugin-bridge] Failed to delete R2 object: ${media.storage_key}`);
793
+ }
794
+ }
795
+
796
+ return (result.meta?.changes ?? 0) > 0;
797
+ }
798
+
799
+ // =========================================================================
800
+ // Network Operations - capability-gated + host validation
801
+ // =========================================================================
802
+
803
+ async httpFetch(
804
+ url: string,
805
+ init?: RequestInit,
806
+ ): Promise<{
807
+ status: number;
808
+ headers: Record<string, string>;
809
+ text: string;
810
+ }> {
811
+ const { capabilities, allowedHosts } = this.ctx.props;
812
+ const hasUnrestricted = capabilities.includes("network:fetch:any");
813
+ const hasFetch = capabilities.includes("network:fetch") || hasUnrestricted;
814
+ if (!hasFetch) {
815
+ throw new Error("Missing capability: network:fetch");
816
+ }
817
+
818
+ if (!hasUnrestricted) {
819
+ const host = new URL(url).host;
820
+ if (allowedHosts.length === 0) {
821
+ throw new Error(
822
+ `Plugin has no allowed hosts configured. Add hosts to allowedHosts to enable HTTP requests.`,
823
+ );
824
+ }
825
+ const allowed = allowedHosts.some((pattern) => {
826
+ if (pattern.startsWith("*.")) {
827
+ return host.endsWith(pattern.slice(1)) || host === pattern.slice(2);
828
+ }
829
+ return host === pattern;
830
+ });
831
+ if (!allowed) {
832
+ throw new Error(`Host not allowed: ${host}. Allowed: ${allowedHosts.join(", ")}`);
833
+ }
834
+ }
835
+
836
+ const response = await fetch(url, init);
837
+ const headers: Record<string, string> = {};
838
+ response.headers.forEach((value, key) => {
839
+ headers[key] = value;
840
+ });
841
+
842
+ return {
843
+ status: response.status,
844
+ headers,
845
+ text: await response.text(),
846
+ };
847
+ }
848
+
849
+ // =========================================================================
850
+ // User Operations - capability-gated (read:users)
851
+ // =========================================================================
852
+
853
+ async userGet(id: string): Promise<{
854
+ id: string;
855
+ email: string;
856
+ name: string | null;
857
+ role: number;
858
+ createdAt: string;
859
+ } | null> {
860
+ const { capabilities } = this.ctx.props;
861
+ if (!capabilities.includes("read:users")) {
862
+ throw new Error("Missing capability: read:users");
863
+ }
864
+ const result = await this.env.DB.prepare(
865
+ "SELECT id, email, name, role, created_at FROM users WHERE id = ?",
866
+ )
867
+ .bind(id)
868
+ .first<{
869
+ id: string;
870
+ email: string;
871
+ name: string | null;
872
+ role: number;
873
+ created_at: string;
874
+ }>();
875
+ if (!result) return null;
876
+ return {
877
+ id: result.id,
878
+ email: result.email,
879
+ name: result.name,
880
+ role: result.role,
881
+ createdAt: result.created_at,
882
+ };
883
+ }
884
+
885
+ async userGetByEmail(email: string): Promise<{
886
+ id: string;
887
+ email: string;
888
+ name: string | null;
889
+ role: number;
890
+ createdAt: string;
891
+ } | null> {
892
+ const { capabilities } = this.ctx.props;
893
+ if (!capabilities.includes("read:users")) {
894
+ throw new Error("Missing capability: read:users");
895
+ }
896
+ const result = await this.env.DB.prepare(
897
+ "SELECT id, email, name, role, created_at FROM users WHERE email = ?",
898
+ )
899
+ .bind(email.toLowerCase())
900
+ .first<{
901
+ id: string;
902
+ email: string;
903
+ name: string | null;
904
+ role: number;
905
+ created_at: string;
906
+ }>();
907
+ if (!result) return null;
908
+ return {
909
+ id: result.id,
910
+ email: result.email,
911
+ name: result.name,
912
+ role: result.role,
913
+ createdAt: result.created_at,
914
+ };
915
+ }
916
+
917
+ async userList(opts?: { role?: number; limit?: number; cursor?: string }): Promise<{
918
+ items: Array<{
919
+ id: string;
920
+ email: string;
921
+ name: string | null;
922
+ role: number;
923
+ createdAt: string;
924
+ }>;
925
+ nextCursor?: string;
926
+ }> {
927
+ const { capabilities } = this.ctx.props;
928
+ if (!capabilities.includes("read:users")) {
929
+ throw new Error("Missing capability: read:users");
930
+ }
931
+ const limit = Math.max(1, Math.min(opts?.limit ?? 50, 100));
932
+ let sql = "SELECT id, email, name, role, created_at FROM users";
933
+ const params: unknown[] = [];
934
+ const conditions: string[] = [];
935
+
936
+ if (opts?.role !== undefined) {
937
+ conditions.push("role = ?");
938
+ params.push(opts.role);
939
+ }
940
+
941
+ if (opts?.cursor) {
942
+ conditions.push("id < ?");
943
+ params.push(opts.cursor);
944
+ }
945
+
946
+ if (conditions.length > 0) {
947
+ sql += ` WHERE ${conditions.join(" AND ")}`;
948
+ }
949
+
950
+ sql += " ORDER BY id DESC LIMIT ?";
951
+ params.push(limit + 1);
952
+
953
+ const results = await this.env.DB.prepare(sql)
954
+ .bind(...params)
955
+ .all<{
956
+ id: string;
957
+ email: string;
958
+ name: string | null;
959
+ role: number;
960
+ created_at: string;
961
+ }>();
962
+
963
+ const rows = results.results ?? [];
964
+ const pageRows = rows.slice(0, limit);
965
+ const items = pageRows.map((row) => ({
966
+ id: row.id,
967
+ email: row.email,
968
+ name: row.name,
969
+ role: row.role,
970
+ createdAt: row.created_at,
971
+ }));
972
+ const hasMore = rows.length > limit;
973
+
974
+ return {
975
+ items,
976
+ nextCursor: hasMore && items.length > 0 ? items.at(-1)!.id : undefined,
977
+ };
978
+ }
979
+
980
+ // =========================================================================
981
+ // Email Operations - capability-gated
982
+ // =========================================================================
983
+
984
+ async emailSend(message: {
985
+ to: string;
986
+ subject: string;
987
+ text: string;
988
+ html?: string;
989
+ }): Promise<void> {
990
+ const { capabilities, pluginId } = this.ctx.props;
991
+ if (!capabilities.includes("email:send")) {
992
+ throw new Error("Missing capability: email:send");
993
+ }
994
+ if (!emailSendCallback) {
995
+ throw new Error("Email is not configured. No email provider is available.");
996
+ }
997
+ await emailSendCallback(message, pluginId);
998
+ }
999
+
1000
+ // =========================================================================
1001
+ // Logging
1002
+ // =========================================================================
1003
+
1004
+ log(level: "debug" | "info" | "warn" | "error", msg: string, data?: unknown): void {
1005
+ const { pluginId } = this.ctx.props;
1006
+ console[level](`[plugin:${pluginId}]`, msg, data ?? "");
1007
+ }
1008
+ }