@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,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
|
+
}
|