@dyrected/core 2.5.12 → 2.5.13

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.
@@ -0,0 +1,2065 @@
1
+ // src/utils/config.ts
2
+ var AUDIT_COLLECTION_SLUG = "__audit";
3
+ var SYSTEM_FIELDS = [
4
+ {
5
+ name: "createdAt",
6
+ type: "date",
7
+ label: "Created At",
8
+ admin: { readOnly: true, hidden: true }
9
+ },
10
+ {
11
+ name: "updatedAt",
12
+ type: "date",
13
+ label: "Updated At",
14
+ admin: { readOnly: true, hidden: true }
15
+ },
16
+ {
17
+ name: "createdBy",
18
+ type: "text",
19
+ label: "Created By",
20
+ admin: { readOnly: true, hidden: true }
21
+ },
22
+ {
23
+ name: "updatedBy",
24
+ type: "text",
25
+ label: "Updated By",
26
+ admin: { readOnly: true, hidden: true }
27
+ }
28
+ ];
29
+ var AUDIT_COLLECTION = {
30
+ slug: AUDIT_COLLECTION_SLUG,
31
+ labels: { singular: "Audit Log", plural: "Audit Logs" },
32
+ fields: [
33
+ { name: "collection", type: "text", label: "Collection", required: true },
34
+ { name: "documentId", type: "text", label: "Document ID" },
35
+ { name: "operation", type: "select", label: "Operation", options: ["create", "update", "delete"], required: true },
36
+ { name: "user", type: "text", label: "User ID" },
37
+ { name: "timestamp", type: "date", label: "Timestamp", required: true },
38
+ { name: "changes", type: "json", label: "Changes" }
39
+ ],
40
+ admin: { hidden: true }
41
+ };
42
+ function normalizeConfig(config) {
43
+ const collections = config?.collections || [];
44
+ const globals = config?.globals || [];
45
+ const needsAudit = collections.some((col) => col.audit);
46
+ const normalizedCollections = collections.map((col) => {
47
+ let fields = col.fields || [];
48
+ const existingFieldNames = new Set(fields.map((f) => f.name));
49
+ if (col.auth) {
50
+ if (!existingFieldNames.has("email")) {
51
+ fields = [
52
+ ...fields,
53
+ {
54
+ name: "email",
55
+ type: "email",
56
+ label: "Email",
57
+ required: true,
58
+ unique: true,
59
+ promoted: true,
60
+ access: {
61
+ update: "!id"
62
+ }
63
+ }
64
+ ];
65
+ }
66
+ if (!existingFieldNames.has("password")) {
67
+ fields = [
68
+ ...fields,
69
+ {
70
+ admin: { tab: "security" },
71
+ name: "password",
72
+ type: "text",
73
+ label: "Password",
74
+ required: true,
75
+ access: {
76
+ update: "!id || user.id == id"
77
+ }
78
+ }
79
+ ];
80
+ }
81
+ if (!existingFieldNames.has("roles")) {
82
+ fields = [
83
+ ...fields,
84
+ {
85
+ name: "roles",
86
+ type: "multiSelect",
87
+ label: "Roles",
88
+ defaultValue: [],
89
+ options: ["admin", "editor", "viewer"],
90
+ access: {
91
+ update: "user.roles && 'admin' in user.roles"
92
+ }
93
+ }
94
+ ];
95
+ }
96
+ fields = fields.map((field) => {
97
+ if (field.name === "email") {
98
+ return {
99
+ ...field,
100
+ access: {
101
+ ...field.access || {},
102
+ update: "!id"
103
+ }
104
+ };
105
+ }
106
+ if (field.name === "password") {
107
+ return {
108
+ ...field,
109
+ admin: { tab: "security", ...field.admin || {} },
110
+ access: {
111
+ ...field.access || {},
112
+ update: "!id || user.id == id"
113
+ }
114
+ };
115
+ }
116
+ if (field.name === "roles") {
117
+ return {
118
+ ...field,
119
+ access: {
120
+ ...field.access || {},
121
+ update: "user.roles && 'admin' in user.roles"
122
+ }
123
+ };
124
+ }
125
+ return field;
126
+ });
127
+ }
128
+ const updatedFieldNames = new Set(fields.map((f) => f.name));
129
+ const fieldsToInject = SYSTEM_FIELDS.filter((f) => !updatedFieldNames.has(f.name));
130
+ return {
131
+ ...col,
132
+ fields: [...fields, ...fieldsToInject]
133
+ };
134
+ });
135
+ const hasAuditCollection = normalizedCollections.some((col) => col.slug === AUDIT_COLLECTION_SLUG);
136
+ return {
137
+ ...config,
138
+ collections: [...normalizedCollections, ...needsAudit && !hasAuditCollection ? [AUDIT_COLLECTION] : []],
139
+ globals
140
+ };
141
+ }
142
+
143
+ // src/services/defaults.service.ts
144
+ var DefaultsService = class {
145
+ /**
146
+ * Recursively apply default values to a data object based on field definitions.
147
+ */
148
+ static apply(fields, data = {}) {
149
+ const result = { ...data || {} };
150
+ fields.forEach((field) => {
151
+ if (field.type === "join") return;
152
+ if (field.type === "row" && field.fields) {
153
+ Object.assign(result, this.apply(field.fields, data));
154
+ return;
155
+ }
156
+ if (!field.name) return;
157
+ let value = result[field.name];
158
+ if ((value === void 0 || value === null) && field.renameTo) {
159
+ const legacyValue = result[field.renameTo];
160
+ if (legacyValue !== void 0 && legacyValue !== null) {
161
+ value = legacyValue;
162
+ result[field.name] = legacyValue;
163
+ }
164
+ }
165
+ if (value === void 0 || value === null) {
166
+ if (field.defaultValue !== void 0) {
167
+ result[field.name] = field.defaultValue;
168
+ } else {
169
+ if (field.type === "boolean") result[field.name] = false;
170
+ else if (field.type === "array") result[field.name] = [];
171
+ else if (field.type === "multiSelect") result[field.name] = [];
172
+ else if (field.type === "object") {
173
+ result[field.name] = this.apply(field.fields || [], {});
174
+ }
175
+ }
176
+ } else if (field.type === "object" && field.fields) {
177
+ result[field.name] = this.apply(field.fields, value);
178
+ } else if (field.type === "array" && field.fields && Array.isArray(value)) {
179
+ result[field.name] = value.map((item) => this.apply(field.fields, item));
180
+ }
181
+ });
182
+ return result;
183
+ }
184
+ };
185
+
186
+ // src/services/population.service.ts
187
+ var PopulationService = class {
188
+ db;
189
+ collections;
190
+ constructor(db, collections) {
191
+ this.db = db;
192
+ this.collections = collections;
193
+ }
194
+ /**
195
+ * Recursively populate relationship fields in a document or array of documents.
196
+ */
197
+ async populate(args) {
198
+ const { data, fields, currentDepth = 0, maxDepth = 10 } = args;
199
+ if (currentDepth >= maxDepth || !data) {
200
+ return data;
201
+ }
202
+ if (Array.isArray(data)) {
203
+ return Promise.all(data.map((item) => this.populate({ ...args, data: item })));
204
+ }
205
+ const populatedDoc = { ...data };
206
+ for (const field of fields) {
207
+ if (field.type === "join") continue;
208
+ if (field.type === "row" && field.fields) {
209
+ const rowPopulated = await this.populate({ data, fields: field.fields, currentDepth, maxDepth });
210
+ Object.assign(populatedDoc, rowPopulated);
211
+ continue;
212
+ }
213
+ if (!field.name) continue;
214
+ const value = populatedDoc[field.name];
215
+ if (field.type === "relationship" && field.relationTo && value) {
216
+ const relatedCollection = this.collections.find((c) => c.slug === field.relationTo);
217
+ if (!relatedCollection) continue;
218
+ if (Array.isArray(value)) {
219
+ populatedDoc[field.name] = await Promise.all(
220
+ value.map(async (id) => {
221
+ if (!id) return id;
222
+ let doc = id;
223
+ if (typeof id === "string") {
224
+ doc = await this.db.findOne({ collection: field.relationTo, id });
225
+ }
226
+ if (!doc || typeof doc !== "object") return id;
227
+ const docWithDefaults = DefaultsService.apply(relatedCollection.fields, doc);
228
+ return this.populate({
229
+ data: docWithDefaults,
230
+ fields: relatedCollection.fields,
231
+ currentDepth: currentDepth + 1,
232
+ maxDepth
233
+ });
234
+ })
235
+ );
236
+ } else if (value) {
237
+ let doc = value;
238
+ if (typeof value === "string") {
239
+ doc = await this.db.findOne({ collection: field.relationTo, id: value });
240
+ }
241
+ if (doc && typeof doc === "object") {
242
+ const docWithDefaults = DefaultsService.apply(relatedCollection.fields, doc);
243
+ populatedDoc[field.name] = await this.populate({
244
+ data: docWithDefaults,
245
+ fields: relatedCollection.fields,
246
+ currentDepth: currentDepth + 1,
247
+ maxDepth
248
+ });
249
+ }
250
+ }
251
+ }
252
+ if (field.type === "url" && value && typeof value === "object" && value.type === "internal" && value.relationTo && value.value) {
253
+ const relatedCollection = this.collections.find((c) => c.slug === value.relationTo);
254
+ if (relatedCollection) {
255
+ const doc = await this.db.findOne({ collection: value.relationTo, id: value.value });
256
+ if (doc && typeof doc === "object") {
257
+ const docWithDefaults = DefaultsService.apply(relatedCollection.fields, doc);
258
+ const populatedDocValue = await this.populate({
259
+ data: docWithDefaults,
260
+ fields: relatedCollection.fields,
261
+ currentDepth: currentDepth + 1,
262
+ maxDepth
263
+ });
264
+ const identifier = docWithDefaults.slug || docWithDefaults.id;
265
+ const resolvedUrl = `/collections/${value.relationTo}/${identifier}`;
266
+ populatedDoc[field.name] = {
267
+ ...value,
268
+ url: resolvedUrl,
269
+ doc: populatedDocValue
270
+ };
271
+ }
272
+ }
273
+ }
274
+ if ((field.type === "array" || field.type === "object") && field.fields && value) {
275
+ populatedDoc[field.name] = await this.populate({
276
+ data: value,
277
+ fields: field.fields,
278
+ currentDepth,
279
+ // Nested fields don't consume depth, only relationships do
280
+ maxDepth
281
+ });
282
+ }
283
+ if (field.type === "blocks" && field.blocks && Array.isArray(value)) {
284
+ populatedDoc[field.name] = await Promise.all(
285
+ value.map(async (blockData) => {
286
+ const blockConfig = field.blocks.find((b) => b.slug === blockData.blockType);
287
+ if (!blockConfig) return blockData;
288
+ return this.populate({
289
+ data: blockData,
290
+ fields: blockConfig.fields,
291
+ currentDepth,
292
+ maxDepth
293
+ });
294
+ })
295
+ );
296
+ }
297
+ }
298
+ return populatedDoc;
299
+ }
300
+ /**
301
+ * Helper to populate a PaginatedResult
302
+ */
303
+ async populateResult(result, fields, maxDepth) {
304
+ if (maxDepth <= 0) return result;
305
+ const populatedDocs = await this.populate({
306
+ data: result.docs,
307
+ fields,
308
+ currentDepth: 0,
309
+ maxDepth
310
+ });
311
+ return {
312
+ ...result,
313
+ docs: populatedDocs
314
+ };
315
+ }
316
+ };
317
+
318
+ // src/services/audit.service.ts
319
+ var AuditService = class {
320
+ /**
321
+ * Writes a single entry to the __audit collection.
322
+ * Called without await — runs asynchronously and never blocks the primary operation.
323
+ */
324
+ static async log(db, args) {
325
+ try {
326
+ await db.create({
327
+ collection: "__audit",
328
+ data: {
329
+ collection: args.collection,
330
+ documentId: args.documentId ?? null,
331
+ operation: args.operation,
332
+ user: args.user?.id ?? null,
333
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
334
+ changes: JSON.stringify({
335
+ before: args.before ?? null,
336
+ after: args.after ?? null
337
+ })
338
+ }
339
+ });
340
+ } catch (err) {
341
+ console.error("[dyrected/audit] Failed to write audit log:", err);
342
+ }
343
+ }
344
+ };
345
+
346
+ // src/auth/password.ts
347
+ import { promisify } from "util";
348
+ import { scrypt, randomBytes, timingSafeEqual } from "crypto";
349
+ var scryptAsync = promisify(scrypt);
350
+ var SALT_LEN = 16;
351
+ var KEY_LEN = 64;
352
+ async function hashPassword(plain) {
353
+ const salt = randomBytes(SALT_LEN).toString("hex");
354
+ const derivedKey = await scryptAsync(plain, salt, KEY_LEN);
355
+ return `${salt}:${derivedKey.toString("hex")}`;
356
+ }
357
+ async function verifyPassword2(plain, stored) {
358
+ const [salt, storedHash] = stored.split(":");
359
+ if (!salt || !storedHash) return false;
360
+ const derivedKey = await scryptAsync(plain, salt, KEY_LEN);
361
+ const storedBuffer = Buffer.from(storedHash, "hex");
362
+ if (derivedKey.length !== storedBuffer.length) return false;
363
+ return timingSafeEqual(derivedKey, storedBuffer);
364
+ }
365
+
366
+ // src/controllers/collection.controller.ts
367
+ var CollectionController = class {
368
+ collection;
369
+ constructor(collection) {
370
+ this.collection = collection;
371
+ }
372
+ async find(c) {
373
+ const config = c.get("config");
374
+ const db = config.db;
375
+ if (!db) return c.json({ message: "Database not configured" }, 500);
376
+ const limit = Number(c.req.query("limit")) || 10;
377
+ const page = Number(c.req.query("page")) || 1;
378
+ const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 2;
379
+ const sort = c.req.query("sort") || void 0;
380
+ let where = void 0;
381
+ const whereRaw = c.req.query("where");
382
+ if (whereRaw) {
383
+ try {
384
+ where = JSON.parse(decodeURIComponent(whereRaw));
385
+ } catch {
386
+ }
387
+ }
388
+ let result = await db.find({
389
+ collection: this.collection.slug,
390
+ limit,
391
+ page,
392
+ sort,
393
+ where
394
+ });
395
+ if (result.total === 0 && this.collection.initialData && !where && page === 1) {
396
+ console.log(`[dyrected/core] Auto-seeding collection "${this.collection.slug}" from config.initialData`);
397
+ for (const data of this.collection.initialData) {
398
+ await db.create({ collection: this.collection.slug, data });
399
+ }
400
+ result = await db.find({
401
+ collection: this.collection.slug,
402
+ limit,
403
+ page,
404
+ sort,
405
+ where
406
+ });
407
+ }
408
+ result.docs = result.docs.map((doc) => DefaultsService.apply(this.collection.fields, doc));
409
+ if (depth > 0) {
410
+ const populationService = new PopulationService(db, config.collections);
411
+ result = await populationService.populateResult(result, this.collection.fields, depth);
412
+ }
413
+ return c.json(result);
414
+ }
415
+ async findOne(c) {
416
+ const config = c.get("config");
417
+ const db = config.db;
418
+ if (!db) return c.json({ message: "Database not configured" }, 500);
419
+ const id = c.req.param("id");
420
+ const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 10;
421
+ if (!id) return c.json({ message: "Missing ID" }, 400);
422
+ const doc = await db.findOne({ collection: this.collection.slug, id });
423
+ if (!doc) return c.json({ message: "Not Found" }, 404);
424
+ const docWithDefaults = DefaultsService.apply(this.collection.fields, doc);
425
+ if (depth > 0 && docWithDefaults) {
426
+ const populationService = new PopulationService(db, config.collections);
427
+ const populatedDoc = await populationService.populate({
428
+ data: docWithDefaults,
429
+ fields: this.collection.fields,
430
+ currentDepth: 0,
431
+ maxDepth: depth
432
+ });
433
+ return c.json(populatedDoc);
434
+ }
435
+ return c.json(docWithDefaults);
436
+ }
437
+ async create(c) {
438
+ const config = c.get("config");
439
+ const db = config.db;
440
+ if (!db) return c.json({ message: "Database not configured" }, 500);
441
+ const contentType = c.req.header("Content-Type") || "";
442
+ if (contentType.toLowerCase().includes("multipart/form-data")) {
443
+ return this.upload(c);
444
+ }
445
+ const body = await c.req.json();
446
+ const user = c.get("user");
447
+ const now = (/* @__PURE__ */ new Date()).toISOString();
448
+ const data = {
449
+ ...body,
450
+ createdAt: now,
451
+ updatedAt: now,
452
+ createdBy: user?.sub ?? null,
453
+ updatedBy: user?.sub ?? null
454
+ };
455
+ if (this.collection.auth && data.password) {
456
+ data.password = await hashPassword(data.password);
457
+ }
458
+ const doc = await db.create({ collection: this.collection.slug, data });
459
+ if (this.collection.audit && db) {
460
+ AuditService.log(db, {
461
+ operation: "create",
462
+ collection: this.collection.slug,
463
+ documentId: doc.id,
464
+ user: user ? { id: user.sub, collection: user.collection, email: user.email } : void 0,
465
+ before: null,
466
+ after: doc
467
+ });
468
+ }
469
+ return c.json(doc, 201);
470
+ }
471
+ async upload(c) {
472
+ const config = c.get("config");
473
+ const storage = config.storage;
474
+ if (!storage) return c.json({ message: "Storage not configured" }, 500);
475
+ const formData = await c.req.formData();
476
+ const file = formData.get("file");
477
+ if (!file) return c.json({ message: "No file uploaded" }, 400);
478
+ const buffer = new Uint8Array(await file.arrayBuffer());
479
+ const siteId = c.get("siteId");
480
+ const workspaceId = c.get("workspaceId");
481
+ const prefix = workspaceId ? `${workspaceId}/${siteId}` : siteId;
482
+ const fileData = await storage.upload({
483
+ filename: file.name,
484
+ buffer,
485
+ mimeType: file.type,
486
+ prefix
487
+ });
488
+ const otherData = {};
489
+ formData.forEach((value, key) => {
490
+ if (key !== "file" && typeof value === "string") {
491
+ otherData[key] = value;
492
+ }
493
+ });
494
+ const user = c.get("user");
495
+ const now = (/* @__PURE__ */ new Date()).toISOString();
496
+ const doc = await config.db.create({
497
+ collection: this.collection.slug,
498
+ data: {
499
+ ...otherData,
500
+ ...fileData,
501
+ createdAt: now,
502
+ updatedAt: now,
503
+ createdBy: user?.sub ?? null,
504
+ updatedBy: user?.sub ?? null
505
+ }
506
+ });
507
+ return c.json(doc, 201);
508
+ }
509
+ async update(c) {
510
+ const config = c.get("config");
511
+ const db = config.db;
512
+ if (!db) return c.json({ message: "Database not configured" }, 500);
513
+ const id = c.req.param("id");
514
+ if (!id) return c.json({ message: "Missing ID" }, 400);
515
+ const body = await c.req.json();
516
+ const user = c.get("user");
517
+ const data = { ...body };
518
+ if (this.collection.auth) {
519
+ delete data.password;
520
+ delete data.oldPassword;
521
+ delete data.confirmPassword;
522
+ }
523
+ Object.assign(data, {
524
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
525
+ updatedBy: user?.sub ?? null
526
+ });
527
+ let before = null;
528
+ if (this.collection.audit) {
529
+ before = await db.findOne({ collection: this.collection.slug, id });
530
+ }
531
+ const doc = await db.update({ collection: this.collection.slug, id, data });
532
+ if (this.collection.audit && db) {
533
+ AuditService.log(db, {
534
+ operation: "update",
535
+ collection: this.collection.slug,
536
+ documentId: id,
537
+ user: user ? { id: user.sub, collection: user.collection, email: user.email } : void 0,
538
+ before,
539
+ after: doc
540
+ });
541
+ }
542
+ return c.json(doc);
543
+ }
544
+ /**
545
+ * POST /api/collections/:slug/:id/change-password
546
+ *
547
+ * Dedicated endpoint for password changes. Requires the caller to supply:
548
+ * { oldPassword, newPassword, confirmPassword }
549
+ *
550
+ * Rules:
551
+ * - Only the account owner or an admin may change the password.
552
+ * - Non-admin callers MUST provide a valid oldPassword.
553
+ * - newPassword and confirmPassword must match.
554
+ */
555
+ async changePassword(c) {
556
+ const config = c.get("config");
557
+ const db = config.db;
558
+ if (!db) return c.json({ message: "Database not configured" }, 500);
559
+ if (!this.collection.auth) {
560
+ return c.json({ message: "This collection does not support authentication" }, 400);
561
+ }
562
+ const id = c.req.param("id");
563
+ if (!id) return c.json({ message: "Missing ID" }, 400);
564
+ const user = c.get("user");
565
+ if (!user) return c.json({ message: "Authentication required" }, 401);
566
+ const body = await c.req.json().catch(() => null);
567
+ const { oldPassword, newPassword, confirmPassword } = body ?? {};
568
+ if (!newPassword) {
569
+ return c.json({ message: "newPassword is required" }, 400);
570
+ }
571
+ if (newPassword !== confirmPassword) {
572
+ return c.json({ message: "Passwords do not match" }, 400);
573
+ }
574
+ if (newPassword.length < 8) {
575
+ return c.json({ message: "Password must be at least 8 characters" }, 400);
576
+ }
577
+ const isAdmin = Array.isArray(user.roles) && user.roles.includes("admin");
578
+ const isSelf = user.sub === id;
579
+ if (!isAdmin && !isSelf) {
580
+ return c.json({ message: "You are not authorised to change this password" }, 403);
581
+ }
582
+ if (!isAdmin) {
583
+ if (!oldPassword) {
584
+ return c.json({ message: "Current password is required" }, 400);
585
+ }
586
+ const existing = await db.findOne({ collection: this.collection.slug, id });
587
+ if (!existing) return c.json({ message: "User not found" }, 404);
588
+ const valid = await verifyPassword(oldPassword, existing.password);
589
+ if (!valid) {
590
+ return c.json({ message: "Invalid current password" }, 400);
591
+ }
592
+ }
593
+ const hashed = await hashPassword(newPassword);
594
+ const doc = await db.update({
595
+ collection: this.collection.slug,
596
+ id,
597
+ data: {
598
+ password: hashed,
599
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
600
+ updatedBy: user.sub
601
+ }
602
+ });
603
+ if (this.collection.audit) {
604
+ AuditService.log(db, {
605
+ operation: "update",
606
+ collection: this.collection.slug,
607
+ documentId: id,
608
+ user: { id: user.sub, collection: user.collection, email: user.email },
609
+ before: null,
610
+ after: { id }
611
+ });
612
+ }
613
+ return c.json({ success: true, message: "Password updated successfully" });
614
+ }
615
+ async delete(c) {
616
+ const config = c.get("config");
617
+ const db = config.db;
618
+ if (!db) return c.json({ message: "Database not configured" }, 500);
619
+ const id = c.req.param("id");
620
+ if (!id) return c.json({ message: "Missing ID" }, 400);
621
+ const user = c.get("user");
622
+ let before = null;
623
+ if (this.collection.audit) {
624
+ before = await db.findOne({ collection: this.collection.slug, id });
625
+ }
626
+ await db.delete({ collection: this.collection.slug, id });
627
+ if (this.collection.audit && db) {
628
+ AuditService.log(db, {
629
+ operation: "delete",
630
+ collection: this.collection.slug,
631
+ documentId: id,
632
+ user: user ? { id: user.sub, collection: user.collection, email: user.email } : void 0,
633
+ before,
634
+ after: null
635
+ });
636
+ }
637
+ return c.json({ message: "Deleted" });
638
+ }
639
+ async deleteMany(c) {
640
+ const config = c.get("config");
641
+ const db = config.db;
642
+ if (!db) return c.json({ message: "Database not configured" }, 500);
643
+ const user = c.get("user");
644
+ let ids = [];
645
+ try {
646
+ const body = await c.req.json().catch(() => null);
647
+ if (body?.ids && Array.isArray(body.ids)) {
648
+ ids = body.ids;
649
+ }
650
+ } catch {
651
+ }
652
+ if (!ids.length) {
653
+ const raw = c.req.queries("ids") ?? c.req.queries("ids[]") ?? [];
654
+ ids = raw.filter(Boolean);
655
+ }
656
+ if (!ids.length) return c.json({ message: "No IDs provided" }, 400);
657
+ const deleted = [];
658
+ const failed = [];
659
+ for (const id of ids) {
660
+ try {
661
+ let before = null;
662
+ if (this.collection.audit) {
663
+ before = await db.findOne({ collection: this.collection.slug, id });
664
+ }
665
+ await db.delete({ collection: this.collection.slug, id });
666
+ deleted.push(id);
667
+ if (this.collection.audit) {
668
+ AuditService.log(db, {
669
+ operation: "delete",
670
+ collection: this.collection.slug,
671
+ documentId: id,
672
+ user: user ? { id: user.sub, collection: user.collection, email: user.email } : void 0,
673
+ before,
674
+ after: null
675
+ });
676
+ }
677
+ } catch (err) {
678
+ failed.push({ id, error: err?.message ?? "Unknown error" });
679
+ }
680
+ }
681
+ return c.json({
682
+ message: `Deleted ${deleted.length} document(s)`,
683
+ deleted,
684
+ ...failed.length ? { failed } : {}
685
+ });
686
+ }
687
+ async seed(c) {
688
+ const config = c.get("config");
689
+ const db = config.db;
690
+ if (!db) return c.json({ message: "Database not configured" }, 500);
691
+ const body = await c.req.json();
692
+ const initialData = body.data;
693
+ if (!initialData || !Array.isArray(initialData)) {
694
+ return c.json({ message: "Invalid initial data" }, 400);
695
+ }
696
+ const result = await db.find({ collection: this.collection.slug, limit: 1 });
697
+ if (result.total > 0) {
698
+ return c.json({ message: "Collection is not empty, skipping seed" });
699
+ }
700
+ console.log(`[dyrected/core] Auto-seeding collection: ${this.collection.slug}`);
701
+ const createdDocs = [];
702
+ for (const data of initialData) {
703
+ const doc = await db.create({ collection: this.collection.slug, data });
704
+ createdDocs.push(doc);
705
+ }
706
+ return c.json({ message: "Seed successful", count: createdDocs.length }, 201);
707
+ }
708
+ };
709
+
710
+ // src/controllers/global.controller.ts
711
+ var GlobalController = class {
712
+ global;
713
+ constructor(global) {
714
+ this.global = global;
715
+ }
716
+ async get(c) {
717
+ const config = c.get("config");
718
+ const db = config.db;
719
+ if (!db) return c.json({ message: "Database not configured" }, 500);
720
+ const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 10;
721
+ let data = await db.getGlobal({ slug: this.global.slug });
722
+ const isEmpty = !data || Object.keys(data).length === 0;
723
+ if (isEmpty && this.global.initialData) {
724
+ console.log(`[dyrected/core] Auto-seeding global "${this.global.slug}" from config.initialData`);
725
+ await db.updateGlobal({ slug: this.global.slug, data: this.global.initialData });
726
+ data = this.global.initialData;
727
+ }
728
+ const dataWithDefaults = DefaultsService.apply(this.global.fields, data);
729
+ if (depth > 0 && dataWithDefaults) {
730
+ const populationService = new PopulationService(db, config.collections);
731
+ const populatedData = await populationService.populate({
732
+ data: dataWithDefaults,
733
+ fields: this.global.fields,
734
+ currentDepth: 0,
735
+ maxDepth: depth
736
+ });
737
+ return c.json(populatedData);
738
+ }
739
+ return c.json(dataWithDefaults);
740
+ }
741
+ async update(c) {
742
+ const db = c.get("config").db;
743
+ if (!db) return c.json({ message: "Database not configured" }, 500);
744
+ const body = await c.req.json();
745
+ const data = await db.updateGlobal({ slug: this.global.slug, data: body });
746
+ return c.json(data);
747
+ }
748
+ async seed(c) {
749
+ const config = c.get("config");
750
+ const db = config.db;
751
+ if (!db) return c.json({ message: "Database not configured" }, 500);
752
+ const body = await c.req.json();
753
+ const initialData = body.data;
754
+ if (!initialData) {
755
+ return c.json({ message: "Invalid initial data" }, 400);
756
+ }
757
+ const existing = await db.getGlobal({ slug: this.global.slug });
758
+ if (existing && Object.keys(existing).length > 0) {
759
+ return c.json({ message: "Global is not empty, skipping seed" });
760
+ }
761
+ console.log(`[dyrected/core] Auto-seeding global: ${this.global.slug}`);
762
+ await db.updateGlobal({ slug: this.global.slug, data: initialData });
763
+ return c.json({ message: "Seed successful", data: initialData }, 201);
764
+ }
765
+ };
766
+
767
+ // src/controllers/media.controller.ts
768
+ var MediaController = class {
769
+ collection;
770
+ constructor(collection = "media") {
771
+ this.collection = collection;
772
+ }
773
+ async upload(c) {
774
+ const config = c.get("config");
775
+ const storage = config.storage;
776
+ const imageService = config.image;
777
+ if (!storage) {
778
+ return c.json({ message: "Storage not configured" }, 500);
779
+ }
780
+ const body = await c.req.parseBody();
781
+ const file = body["file"];
782
+ const focalPointStr = body["focalPoint"];
783
+ const focalPoint = focalPointStr ? JSON.parse(focalPointStr) : void 0;
784
+ if (!file) {
785
+ return c.json({ message: "No file uploaded" }, 400);
786
+ }
787
+ const buffer = new Uint8Array(await file.arrayBuffer());
788
+ const siteId = c.get("siteId");
789
+ const workspaceId = c.get("workspaceId");
790
+ const prefix = workspaceId ? `${workspaceId}/${siteId}` : siteId || "default";
791
+ let imageMetadata = {};
792
+ let imageSizes = {};
793
+ if (imageService && file.type.startsWith("image/")) {
794
+ let colConfig = config.collections.find((col) => col.slug === this.collection);
795
+ if (!colConfig && config.onSchemaFetch && siteId) {
796
+ const dynamic = await config.onSchemaFetch(siteId);
797
+ colConfig = dynamic.collections?.find((col) => col.slug === this.collection);
798
+ }
799
+ try {
800
+ const processed = await imageService.process({
801
+ buffer,
802
+ mimeType: file.type,
803
+ config: colConfig?.upload,
804
+ focalPoint
805
+ });
806
+ imageMetadata = processed.metadata;
807
+ imageSizes = processed.sizes;
808
+ } catch (err) {
809
+ console.error("[MediaController] Image processing failed:", err);
810
+ }
811
+ }
812
+ const fileData = await storage.upload({
813
+ filename: file.name,
814
+ buffer,
815
+ mimeType: file.type,
816
+ prefix
817
+ });
818
+ const finalFileData = {
819
+ ...fileData,
820
+ ...imageMetadata,
821
+ focalPoint,
822
+ sizes: {}
823
+ };
824
+ if (imageSizes) {
825
+ for (const [sizeName, sizeData] of Object.entries(imageSizes)) {
826
+ const ext = file.name.split(".").pop();
827
+ const baseName = file.name.substring(0, file.name.lastIndexOf("."));
828
+ const sizeFilename = `${baseName}-${sizeName}.${ext}`;
829
+ try {
830
+ const sizeFileData = await storage.upload({
831
+ filename: sizeFilename,
832
+ buffer: sizeData.buffer,
833
+ mimeType: file.type,
834
+ prefix
835
+ });
836
+ finalFileData.sizes[sizeName] = {
837
+ ...sizeFileData,
838
+ width: sizeData.width,
839
+ height: sizeData.height
840
+ };
841
+ } catch (err) {
842
+ console.error(`[MediaController] Failed to upload size ${sizeName}:`, err);
843
+ }
844
+ }
845
+ }
846
+ const db = config.db;
847
+ if (!db) return c.json({ message: "Database not configured" }, 500);
848
+ const doc = await db.create({
849
+ collection: this.collection,
850
+ data: finalFileData
851
+ });
852
+ return c.json(doc, 201);
853
+ }
854
+ async find(c) {
855
+ const db = c.get("config").db;
856
+ if (!db) return c.json({ message: "Database not configured" }, 500);
857
+ const limit = Number(c.req.query("limit")) || 10;
858
+ const page = Number(c.req.query("page")) || 1;
859
+ const result = await db.find({
860
+ collection: this.collection,
861
+ limit,
862
+ page
863
+ });
864
+ return c.json(result);
865
+ }
866
+ async delete(c) {
867
+ const config = c.get("config");
868
+ const storage = config.storage;
869
+ const db = config.db;
870
+ if (!db) return c.json({ message: "Database not configured" }, 500);
871
+ const id = c.req.param("id");
872
+ if (!id) return c.json({ message: "Missing ID" }, 400);
873
+ const doc = await db.findOne({ collection: this.collection, id });
874
+ if (!doc) return c.json({ message: "Not Found" }, 404);
875
+ if (storage) {
876
+ await storage.delete({ filename: doc.filename });
877
+ if (doc.sizes) {
878
+ for (const size of Object.values(doc.sizes)) {
879
+ if (size.filename) {
880
+ await storage.delete({ filename: size.filename });
881
+ }
882
+ }
883
+ }
884
+ }
885
+ await db.delete({ collection: this.collection, id });
886
+ return c.json({ message: "Deleted" });
887
+ }
888
+ async serve(c) {
889
+ const config = c.get("config");
890
+ const storage = config.storage;
891
+ if (!storage || !storage.resolve) {
892
+ return c.json({ message: "Storage not configured for serving" }, 404);
893
+ }
894
+ const filename = c.req.param("filename");
895
+ if (!filename) return c.json({ message: "Missing filename" }, 400);
896
+ let res = await storage.resolve({ filename });
897
+ if (!res && !filename.includes("/")) {
898
+ res = await storage.resolve({ filename: `default/${filename}` });
899
+ }
900
+ if (!res) return c.json({ message: "Not Found" }, 404);
901
+ c.header("Content-Type", res.mimeType);
902
+ return c.body(res.buffer);
903
+ }
904
+ };
905
+
906
+ // src/auth/token.ts
907
+ import { SignJWT, jwtVerify, decodeJwt } from "jose";
908
+ import { TextEncoder } from "util";
909
+ function getSecret() {
910
+ const secret = process.env.DYRECTED_JWT_SECRET || process.env.JWT_SECRET;
911
+ if (!secret) {
912
+ throw new Error(
913
+ "[dyrected/core] DYRECTED_JWT_SECRET is not set. Add it to your environment variables to enable auth collections."
914
+ );
915
+ }
916
+ return new TextEncoder().encode(secret);
917
+ }
918
+ var DEFAULT_EXPIRY = "7d";
919
+ async function signCollectionToken(payload, expiresIn = DEFAULT_EXPIRY) {
920
+ return new SignJWT({ ...payload }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(expiresIn).sign(getSecret());
921
+ }
922
+ async function verifyCollectionToken(token) {
923
+ const { payload } = await jwtVerify(token, getSecret());
924
+ return payload;
925
+ }
926
+
927
+ // src/services/email.service.ts
928
+ var _devSend = null;
929
+ var _devSendPromise = null;
930
+ async function getDevSend() {
931
+ if (_devSend) return _devSend;
932
+ if (_devSendPromise) return _devSendPromise;
933
+ _devSendPromise = (async () => {
934
+ try {
935
+ const nodemailer = await import("nodemailer");
936
+ const account = await nodemailer.default.createTestAccount();
937
+ const transport = nodemailer.default.createTransport({
938
+ host: "smtp.ethereal.email",
939
+ port: 587,
940
+ auth: { user: account.user, pass: account.pass }
941
+ });
942
+ console.log("[dyrected/core] No email config \u2014 using Ethereal for dev email preview.");
943
+ console.log(`[dyrected/core] Ethereal login: https://ethereal.email user: ${account.user} pass: ${account.pass}`);
944
+ _devSend = async ({ to, subject, html }) => {
945
+ const info = await transport.sendMail({ from: '"Dyrected Dev" <dev@dyrected.local>', to, subject, html });
946
+ console.log(`[dyrected/core] Email preview URL: ${nodemailer.default.getTestMessageUrl(info)}`);
947
+ };
948
+ return _devSend;
949
+ } catch {
950
+ console.warn("[dyrected/core] nodemailer not available \u2014 emails will not be sent in dev.");
951
+ return null;
952
+ }
953
+ })();
954
+ return _devSendPromise;
955
+ }
956
+ async function sendEmail(config, payload) {
957
+ if (config.email) {
958
+ await config.email.send(payload);
959
+ return;
960
+ }
961
+ if (process.env.NODE_ENV !== "production") {
962
+ const devSend = await getDevSend();
963
+ await devSend?.(payload);
964
+ }
965
+ }
966
+ function buildWelcomeEmail(config, args) {
967
+ const custom = config.email?.templates?.welcome?.(args);
968
+ return {
969
+ subject: custom?.subject ?? "Welcome \u2014 your account is ready",
970
+ html: custom?.html ?? `
971
+ <div style="font-family:sans-serif;max-width:600px;margin:0 auto">
972
+ <h2>Welcome!</h2>
973
+ <p>Your account has been created. You can now log in with:</p>
974
+ <p><strong>${args.email}</strong></p>
975
+ </div>`
976
+ };
977
+ }
978
+ function buildInviteEmail(config, args) {
979
+ const custom = config.email?.templates?.invite?.(args);
980
+ return {
981
+ subject: custom?.subject ?? "You've been invited",
982
+ html: custom?.html ?? `
983
+ <div style="font-family:sans-serif;max-width:600px;margin:0 auto">
984
+ <h2>You've been invited</h2>
985
+ ${args.invitedByEmail ? `<p>Invited by <strong>${args.invitedByEmail}</strong>.</p>` : ""}
986
+ <p>Use the token below to accept your invitation. It expires in 7 days.</p>
987
+ <pre style="background:#f4f4f4;padding:12px;border-radius:4px;word-break:break-all">${args.token}</pre>
988
+ </div>`
989
+ };
990
+ }
991
+ function buildResetPasswordEmail(config, args) {
992
+ const custom = config.email?.templates?.resetPassword?.(args);
993
+ return {
994
+ subject: custom?.subject ?? "Reset your password",
995
+ html: custom?.html ?? `
996
+ <div style="font-family:sans-serif;max-width:600px;margin:0 auto">
997
+ <h2>Reset your password</h2>
998
+ <p>Use the token below to reset your password. It expires in 1 hour.</p>
999
+ <pre style="background:#f4f4f4;padding:12px;border-radius:4px;word-break:break-all">${args.token}</pre>
1000
+ </div>`
1001
+ };
1002
+ }
1003
+ function buildPasswordChangedEmail(config, args) {
1004
+ const custom = config.email?.templates?.passwordChanged?.(args);
1005
+ return {
1006
+ subject: custom?.subject ?? "Your password has been changed",
1007
+ html: custom?.html ?? `
1008
+ <div style="font-family:sans-serif;max-width:600px;margin:0 auto">
1009
+ <h2>Password changed</h2>
1010
+ <p>The password for <strong>${args.email}</strong> was just changed.</p>
1011
+ <p>If you did not make this change, please contact support immediately.</p>
1012
+ </div>`
1013
+ };
1014
+ }
1015
+
1016
+ // src/controllers/auth.controller.ts
1017
+ var AuthController = class {
1018
+ collection;
1019
+ constructor(collection) {
1020
+ this.collection = collection;
1021
+ }
1022
+ // ---------------------------------------------------------------------------
1023
+ // GET /init
1024
+ // Checks if the first user needs to be created.
1025
+ // ---------------------------------------------------------------------------
1026
+ async init(c) {
1027
+ const db = c.get("config").db;
1028
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1029
+ const result = await db.find({
1030
+ collection: this.collection.slug,
1031
+ limit: 1
1032
+ });
1033
+ return c.json({
1034
+ initialized: result.total > 0
1035
+ });
1036
+ }
1037
+ // ---------------------------------------------------------------------------
1038
+ // POST /first-user
1039
+ // Creates the first user if none exist.
1040
+ // ---------------------------------------------------------------------------
1041
+ async registerFirstUser(c) {
1042
+ const config = c.get("config");
1043
+ const db = config.db;
1044
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1045
+ const check = await db.find({
1046
+ collection: this.collection.slug,
1047
+ limit: 1
1048
+ });
1049
+ if (check.total > 0) {
1050
+ return c.json({ error: true, message: "Initial user already exists." }, 403);
1051
+ }
1052
+ const body = await c.req.json().catch(() => null);
1053
+ if (!body?.email || !body?.password) {
1054
+ return c.json({ error: true, message: "email and password are required." }, 400);
1055
+ }
1056
+ const hashedPassword = await hashPassword(body.password);
1057
+ const user = await db.create({
1058
+ collection: this.collection.slug,
1059
+ data: {
1060
+ ...body,
1061
+ password: hashedPassword,
1062
+ roles: ["admin"]
1063
+ // Default first user to admin
1064
+ }
1065
+ });
1066
+ const token = await signCollectionToken({
1067
+ sub: user.id,
1068
+ email: user.email,
1069
+ collection: this.collection.slug
1070
+ });
1071
+ const { subject, html } = buildWelcomeEmail(config, { email: body.email });
1072
+ sendEmail(config, { to: body.email, subject, html }).catch(
1073
+ (err) => console.error("[dyrected/core] Failed to send welcome email:", err)
1074
+ );
1075
+ const { password: _, ...safeUser } = user;
1076
+ return c.json({ token, user: safeUser });
1077
+ }
1078
+ // ---------------------------------------------------------------------------
1079
+ // POST /login
1080
+ // ---------------------------------------------------------------------------
1081
+ async login(c) {
1082
+ const db = c.get("config").db;
1083
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1084
+ const body = await c.req.json().catch(() => null);
1085
+ if (!body?.email || !body?.password) {
1086
+ return c.json({ error: true, message: "email and password are required." }, 400);
1087
+ }
1088
+ const result = await db.find({
1089
+ collection: this.collection.slug,
1090
+ where: { email: body.email },
1091
+ limit: 1
1092
+ });
1093
+ const user = result.docs[0];
1094
+ if (!user) {
1095
+ return c.json({ error: true, message: "Invalid email or password." }, 401);
1096
+ }
1097
+ const valid = await verifyPassword2(body.password, user.password);
1098
+ if (!valid) {
1099
+ return c.json({ error: true, message: "Invalid email or password." }, 401);
1100
+ }
1101
+ const token = await signCollectionToken({
1102
+ sub: user.id,
1103
+ email: user.email,
1104
+ collection: this.collection.slug
1105
+ });
1106
+ const { password: _, ...safeUser } = user;
1107
+ return c.json({ token, user: safeUser });
1108
+ }
1109
+ // ---------------------------------------------------------------------------
1110
+ // POST /logout
1111
+ // Auth collections use stateless JWTs — logout is handled client-side.
1112
+ // This endpoint exists so clients have a consistent API surface.
1113
+ // ---------------------------------------------------------------------------
1114
+ async logout(c) {
1115
+ return c.json({ success: true, message: "Logged out. Discard your token." });
1116
+ }
1117
+ // ---------------------------------------------------------------------------
1118
+ // GET /me
1119
+ // ---------------------------------------------------------------------------
1120
+ async me(c) {
1121
+ const db = c.get("config").db;
1122
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1123
+ const requestUser = c.get("user");
1124
+ if (!requestUser) {
1125
+ return c.json({ error: true, message: "Authentication required." }, 401);
1126
+ }
1127
+ const user = await db.findOne({ collection: this.collection.slug, id: requestUser.sub });
1128
+ if (!user) {
1129
+ return c.json({ error: true, message: "User not found." }, 404);
1130
+ }
1131
+ const { password: _, ...safeUser } = user;
1132
+ return c.json(safeUser);
1133
+ }
1134
+ // ---------------------------------------------------------------------------
1135
+ // POST /refresh-token
1136
+ // ---------------------------------------------------------------------------
1137
+ async refreshToken(c) {
1138
+ const requestUser = c.get("user");
1139
+ if (!requestUser) {
1140
+ return c.json({ error: true, message: "Authentication required." }, 401);
1141
+ }
1142
+ const token = await signCollectionToken({
1143
+ sub: requestUser.sub,
1144
+ email: requestUser.email,
1145
+ collection: this.collection.slug
1146
+ });
1147
+ return c.json({ token });
1148
+ }
1149
+ // ---------------------------------------------------------------------------
1150
+ // POST /forgot-password
1151
+ // Requires config.email to be set. Silently succeeds if email not found
1152
+ // to prevent email enumeration.
1153
+ // ---------------------------------------------------------------------------
1154
+ async forgotPassword(c) {
1155
+ const config = c.get("config");
1156
+ const db = config.db;
1157
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1158
+ const body = await c.req.json().catch(() => null);
1159
+ if (!body?.email) {
1160
+ return c.json({ error: true, message: "email is required." }, 400);
1161
+ }
1162
+ const result = await db.find({
1163
+ collection: this.collection.slug,
1164
+ where: { email: body.email },
1165
+ limit: 1
1166
+ });
1167
+ const user = result.docs[0];
1168
+ if (user) {
1169
+ const resetToken = await signCollectionToken(
1170
+ { sub: user.id, email: user.email, collection: this.collection.slug, purpose: "reset" },
1171
+ "1h"
1172
+ );
1173
+ try {
1174
+ const { subject, html } = buildResetPasswordEmail(config, { token: resetToken });
1175
+ await sendEmail(config, { to: user.email, subject, html });
1176
+ } catch (err) {
1177
+ console.error("[dyrected/core] Failed to send password reset email:", err);
1178
+ }
1179
+ }
1180
+ return c.json({
1181
+ success: true,
1182
+ message: "If an account with that email exists, a reset link has been sent."
1183
+ });
1184
+ }
1185
+ // ---------------------------------------------------------------------------
1186
+ // POST /reset-password
1187
+ // Expects { token: string, password: string } in body.
1188
+ // The token is the reset JWT issued by /forgot-password.
1189
+ // ---------------------------------------------------------------------------
1190
+ async resetPassword(c) {
1191
+ const config = c.get("config");
1192
+ const db = config.db;
1193
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1194
+ const body = await c.req.json().catch(() => null);
1195
+ if (!body?.token || !body?.password) {
1196
+ return c.json({ error: true, message: "token and password are required." }, 400);
1197
+ }
1198
+ let payload;
1199
+ try {
1200
+ payload = await verifyCollectionToken(body.token);
1201
+ } catch {
1202
+ return c.json({ error: true, message: "Reset token is invalid or has expired." }, 400);
1203
+ }
1204
+ if (payload.collection !== this.collection.slug || payload.purpose !== "reset") {
1205
+ return c.json({ error: true, message: "Reset token is invalid or has expired." }, 400);
1206
+ }
1207
+ const hashedPassword = await hashPassword(body.password);
1208
+ await db.update({
1209
+ collection: this.collection.slug,
1210
+ id: payload.sub,
1211
+ data: { password: hashedPassword }
1212
+ });
1213
+ const { subject, html } = buildPasswordChangedEmail(config, { email: payload.email });
1214
+ sendEmail(config, { to: payload.email, subject, html }).catch(
1215
+ (err) => console.error("[dyrected/core] Failed to send password-changed email:", err)
1216
+ );
1217
+ return c.json({ success: true, message: "Password has been reset. You can now log in." });
1218
+ }
1219
+ // ---------------------------------------------------------------------------
1220
+ // POST /invite
1221
+ // Requires auth. Issues a signed invite token and emails it to the invitee.
1222
+ // ---------------------------------------------------------------------------
1223
+ async invite(c) {
1224
+ const config = c.get("config");
1225
+ const db = config.db;
1226
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1227
+ const requestUser = c.get("user");
1228
+ if (!requestUser) {
1229
+ return c.json({ error: true, message: "Authentication required." }, 401);
1230
+ }
1231
+ const body = await c.req.json().catch(() => null);
1232
+ if (!body?.email) {
1233
+ return c.json({ error: true, message: "email is required." }, 400);
1234
+ }
1235
+ const existing = await db.find({
1236
+ collection: this.collection.slug,
1237
+ where: { email: body.email },
1238
+ limit: 1
1239
+ });
1240
+ if (existing.total > 0) {
1241
+ return c.json({ error: true, message: "An account with that email already exists." }, 409);
1242
+ }
1243
+ const inviteToken = await signCollectionToken(
1244
+ { sub: body.email, email: body.email, collection: this.collection.slug, purpose: "invite" },
1245
+ "7d"
1246
+ );
1247
+ try {
1248
+ const { subject, html } = buildInviteEmail(config, {
1249
+ token: inviteToken,
1250
+ invitedByEmail: requestUser.email
1251
+ });
1252
+ await sendEmail(config, { to: body.email, subject, html });
1253
+ } catch (err) {
1254
+ console.error("[dyrected/core] Failed to send invite email:", err);
1255
+ }
1256
+ return c.json({ success: true, message: `Invite sent to ${body.email}.` });
1257
+ }
1258
+ // ---------------------------------------------------------------------------
1259
+ // POST /accept-invite
1260
+ // Public. Validates the invite token and creates the user account.
1261
+ // Body: { token, password, ...extraFields }
1262
+ // ---------------------------------------------------------------------------
1263
+ async acceptInvite(c) {
1264
+ const config = c.get("config");
1265
+ const db = config.db;
1266
+ if (!db) return c.json({ message: "Database not configured" }, 500);
1267
+ const body = await c.req.json().catch(() => null);
1268
+ if (!body?.token || !body?.password) {
1269
+ return c.json({ error: true, message: "token and password are required." }, 400);
1270
+ }
1271
+ let payload;
1272
+ try {
1273
+ payload = await verifyCollectionToken(body.token);
1274
+ } catch {
1275
+ return c.json({ error: true, message: "Invite token is invalid or has expired." }, 400);
1276
+ }
1277
+ if (payload.collection !== this.collection.slug || payload.purpose !== "invite") {
1278
+ return c.json({ error: true, message: "Invite token is invalid or has expired." }, 400);
1279
+ }
1280
+ const inviteeEmail = payload.sub;
1281
+ const existing = await db.find({
1282
+ collection: this.collection.slug,
1283
+ where: { email: inviteeEmail },
1284
+ limit: 1
1285
+ });
1286
+ if (existing.total > 0) {
1287
+ return c.json({ error: true, message: "An account with that email already exists." }, 409);
1288
+ }
1289
+ const { token: _t, password: _p, ...extraFields } = body;
1290
+ const hashedPassword = await hashPassword(body.password);
1291
+ const user = await db.create({
1292
+ collection: this.collection.slug,
1293
+ data: { ...extraFields, email: inviteeEmail, password: hashedPassword }
1294
+ });
1295
+ const sessionToken = await signCollectionToken({
1296
+ sub: user.id,
1297
+ email: inviteeEmail,
1298
+ collection: this.collection.slug
1299
+ });
1300
+ const { subject, html } = buildWelcomeEmail(config, { email: inviteeEmail });
1301
+ sendEmail(config, { to: inviteeEmail, subject, html }).catch(
1302
+ (err) => console.error("[dyrected/core] Failed to send welcome email:", err)
1303
+ );
1304
+ const { password: _, ...safeUser } = user;
1305
+ return c.json({ token: sessionToken, user: safeUser }, 201);
1306
+ }
1307
+ };
1308
+
1309
+ // src/controllers/preview.controller.ts
1310
+ import { SignJWT as SignJWT2, jwtVerify as jwtVerify2 } from "jose";
1311
+ import { TextEncoder as TextEncoder2 } from "util";
1312
+ var PreviewController = class {
1313
+ getSecret() {
1314
+ const secret = process.env.DYRECTED_JWT_SECRET || process.env.JWT_SECRET || "dyrected-preview-secret-change-me";
1315
+ return new TextEncoder2().encode(secret);
1316
+ }
1317
+ /**
1318
+ * POST /api/preview-token
1319
+ * Generates a short-lived token for previewing unsaved data.
1320
+ */
1321
+ async createToken(c) {
1322
+ const body = await c.req.json().catch(() => null);
1323
+ if (!body?.collectionSlug || !body?.data) {
1324
+ return c.json({ error: true, message: "collectionSlug and data are required." }, 400);
1325
+ }
1326
+ const token = await new SignJWT2({
1327
+ collectionSlug: body.collectionSlug,
1328
+ documentId: body.documentId,
1329
+ data: body.data
1330
+ }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime("15m").sign(this.getSecret());
1331
+ const expiresAt = new Date(Date.now() + 15 * 60 * 1e3).toISOString();
1332
+ return c.json({ token, expiresAt });
1333
+ }
1334
+ /**
1335
+ * GET /api/preview-data?token=<jwt>
1336
+ * Returns the data stored in the preview token.
1337
+ */
1338
+ async getData(c) {
1339
+ const token = c.req.query("token");
1340
+ if (!token) {
1341
+ return c.json({ error: true, message: "token query parameter is required." }, 400);
1342
+ }
1343
+ try {
1344
+ const { payload } = await jwtVerify2(token, this.getSecret());
1345
+ return c.json(payload);
1346
+ } catch (err) {
1347
+ return c.json({ error: true, message: "Invalid or expired preview token." }, 401);
1348
+ }
1349
+ }
1350
+ };
1351
+
1352
+ // src/middleware/auth.ts
1353
+ function requireAuth() {
1354
+ return async (c, next) => {
1355
+ const authHeader = c.req.header("Authorization");
1356
+ const token = authHeader?.replace(/^Bearer\s+/i, "");
1357
+ if (!token) {
1358
+ return c.json({ error: true, message: "Authentication required." }, 401);
1359
+ }
1360
+ try {
1361
+ const user = await verifyCollectionToken(token);
1362
+ c.set("user", user);
1363
+ await next();
1364
+ } catch {
1365
+ return c.json({ error: true, message: "Invalid or expired token." }, 401);
1366
+ }
1367
+ };
1368
+ }
1369
+ function optionalAuth() {
1370
+ return async (c, next) => {
1371
+ const authHeader = c.req.header("Authorization");
1372
+ const token = authHeader?.replace(/^Bearer\s+/i, "");
1373
+ if (token) {
1374
+ try {
1375
+ const user = await verifyCollectionToken(token);
1376
+ c.set("user", user);
1377
+ } catch {
1378
+ }
1379
+ }
1380
+ await next();
1381
+ };
1382
+ }
1383
+
1384
+ // src/utils/openapi.ts
1385
+ function generateOpenApi(config) {
1386
+ const spec = {
1387
+ openapi: "3.0.0",
1388
+ info: {
1389
+ title: "Dyrected API",
1390
+ version: "1.0.0",
1391
+ description: "Automatically generated OpenAPI specification for the Dyrected project."
1392
+ },
1393
+ components: {
1394
+ schemas: {},
1395
+ securitySchemes: {
1396
+ ApiKeyAuth: {
1397
+ type: "apiKey",
1398
+ in: "header",
1399
+ name: "x-api-key"
1400
+ }
1401
+ }
1402
+ },
1403
+ paths: {},
1404
+ security: [{ ApiKeyAuth: [] }]
1405
+ };
1406
+ for (const collection of config.collections) {
1407
+ spec.components.schemas[collection.slug] = collectionToSchema(collection);
1408
+ }
1409
+ for (const global of config.globals) {
1410
+ spec.components.schemas[global.slug] = globalToSchema(global);
1411
+ }
1412
+ for (const collection of config.collections) {
1413
+ const slug = collection.slug;
1414
+ const path = `/api/collections/${slug}`;
1415
+ const labels = collection.labels || { singular: slug, plural: `${slug}s` };
1416
+ spec.paths[path] = {
1417
+ get: {
1418
+ tags: ["Collections"],
1419
+ summary: `Find ${labels.plural}`,
1420
+ parameters: [
1421
+ { name: "limit", in: "query", schema: { type: "integer", default: 10 } },
1422
+ { name: "page", in: "query", schema: { type: "integer", default: 1 } },
1423
+ { name: "where", in: "query", schema: { type: "string" }, description: "JSON filter" },
1424
+ { name: "sort", in: "query", schema: { type: "string" }, description: "Sort field (e.g. -createdAt)" }
1425
+ ],
1426
+ responses: {
1427
+ 200: {
1428
+ description: "Success",
1429
+ content: {
1430
+ "application/json": {
1431
+ schema: {
1432
+ type: "object",
1433
+ properties: {
1434
+ docs: { type: "array", items: { $ref: `#/components/schemas/${slug}` } },
1435
+ total: { type: "integer" },
1436
+ limit: { type: "integer" },
1437
+ page: { type: "integer" }
1438
+ }
1439
+ }
1440
+ }
1441
+ }
1442
+ }
1443
+ }
1444
+ },
1445
+ post: {
1446
+ tags: ["Collections"],
1447
+ summary: `Create ${labels.singular}`,
1448
+ requestBody: {
1449
+ required: true,
1450
+ content: {
1451
+ "application/json": {
1452
+ schema: { $ref: `#/components/schemas/${slug}` }
1453
+ }
1454
+ }
1455
+ },
1456
+ responses: {
1457
+ 201: {
1458
+ description: "Created",
1459
+ content: {
1460
+ "application/json": {
1461
+ schema: { $ref: `#/components/schemas/${slug}` }
1462
+ }
1463
+ }
1464
+ }
1465
+ }
1466
+ }
1467
+ };
1468
+ spec.paths[`${path}/{id}`] = {
1469
+ get: {
1470
+ tags: ["Collections"],
1471
+ summary: `Get a single ${labels.singular}`,
1472
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
1473
+ responses: {
1474
+ 200: {
1475
+ description: "Success",
1476
+ content: {
1477
+ "application/json": {
1478
+ schema: { $ref: `#/components/schemas/${slug}` }
1479
+ }
1480
+ }
1481
+ }
1482
+ }
1483
+ },
1484
+ patch: {
1485
+ tags: ["Collections"],
1486
+ summary: `Update ${labels.singular}`,
1487
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
1488
+ requestBody: {
1489
+ required: true,
1490
+ content: {
1491
+ "application/json": {
1492
+ schema: { $ref: `#/components/schemas/${slug}` }
1493
+ }
1494
+ }
1495
+ },
1496
+ responses: {
1497
+ 200: {
1498
+ description: "Updated",
1499
+ content: {
1500
+ "application/json": {
1501
+ schema: { $ref: `#/components/schemas/${slug}` }
1502
+ }
1503
+ }
1504
+ }
1505
+ }
1506
+ },
1507
+ delete: {
1508
+ tags: ["Collections"],
1509
+ summary: `Delete ${labels.singular}`,
1510
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
1511
+ responses: {
1512
+ 204: { description: "Deleted" }
1513
+ }
1514
+ }
1515
+ };
1516
+ }
1517
+ for (const global of config.globals) {
1518
+ const slug = global.slug;
1519
+ const path = `/api/globals/${slug}`;
1520
+ spec.paths[path] = {
1521
+ get: {
1522
+ tags: ["Globals"],
1523
+ summary: `Get ${global.label || slug}`,
1524
+ responses: {
1525
+ 200: {
1526
+ description: "Success",
1527
+ content: {
1528
+ "application/json": {
1529
+ schema: { $ref: `#/components/schemas/${slug}` }
1530
+ }
1531
+ }
1532
+ }
1533
+ }
1534
+ },
1535
+ patch: {
1536
+ tags: ["Globals"],
1537
+ summary: `Update ${global.label || slug}`,
1538
+ requestBody: {
1539
+ required: true,
1540
+ content: {
1541
+ "application/json": {
1542
+ schema: { $ref: `#/components/schemas/${slug}` }
1543
+ }
1544
+ }
1545
+ },
1546
+ responses: {
1547
+ 200: {
1548
+ description: "Updated",
1549
+ content: {
1550
+ "application/json": {
1551
+ schema: { $ref: `#/components/schemas/${slug}` }
1552
+ }
1553
+ }
1554
+ }
1555
+ }
1556
+ }
1557
+ };
1558
+ }
1559
+ if (config.storage) {
1560
+ spec.paths["/api/media"] = {
1561
+ get: {
1562
+ tags: ["Media"],
1563
+ summary: "List Media",
1564
+ responses: {
1565
+ 200: {
1566
+ description: "Success",
1567
+ content: {
1568
+ "application/json": {
1569
+ schema: {
1570
+ type: "object",
1571
+ properties: {
1572
+ docs: { type: "array", items: { type: "object", additionalProperties: true } }
1573
+ }
1574
+ }
1575
+ }
1576
+ }
1577
+ }
1578
+ }
1579
+ },
1580
+ post: {
1581
+ tags: ["Media"],
1582
+ summary: "Upload Media",
1583
+ requestBody: {
1584
+ content: {
1585
+ "multipart/form-data": {
1586
+ schema: {
1587
+ type: "object",
1588
+ properties: {
1589
+ file: { type: "string", format: "binary" }
1590
+ }
1591
+ }
1592
+ }
1593
+ }
1594
+ },
1595
+ responses: {
1596
+ 201: { description: "Uploaded" }
1597
+ }
1598
+ }
1599
+ };
1600
+ }
1601
+ return spec;
1602
+ }
1603
+ function collectionToSchema(collection) {
1604
+ const { properties, required } = fieldsToProperties(collection.fields);
1605
+ return {
1606
+ type: "object",
1607
+ properties: {
1608
+ id: { type: "string" },
1609
+ createdAt: { type: "string", format: "date-time" },
1610
+ updatedAt: { type: "string", format: "date-time" },
1611
+ ...properties
1612
+ },
1613
+ required: ["id", ...required]
1614
+ };
1615
+ }
1616
+ function globalToSchema(global) {
1617
+ const { properties, required } = fieldsToProperties(global.fields);
1618
+ return {
1619
+ type: "object",
1620
+ properties,
1621
+ required
1622
+ };
1623
+ }
1624
+ function fieldsToProperties(fields) {
1625
+ const props = {};
1626
+ const required = [];
1627
+ for (const field of fields) {
1628
+ if (!field.name || field.type === "join" || field.type === "row") continue;
1629
+ props[field.name] = fieldToSchema(field);
1630
+ if (field.required) {
1631
+ required.push(field.name);
1632
+ }
1633
+ }
1634
+ return { properties: props, required };
1635
+ }
1636
+ function fieldToSchema(field) {
1637
+ let schema = {};
1638
+ switch (field.type) {
1639
+ case "text":
1640
+ case "textarea":
1641
+ case "email":
1642
+ case "url":
1643
+ schema = { type: "string" };
1644
+ break;
1645
+ case "number":
1646
+ schema = { type: "number" };
1647
+ break;
1648
+ case "boolean":
1649
+ schema = { type: "boolean" };
1650
+ break;
1651
+ case "date":
1652
+ schema = { type: "string", format: "date-time" };
1653
+ break;
1654
+ case "select":
1655
+ schema = { type: "string", enum: field.options?.map((o) => typeof o === "string" ? o : o.value) };
1656
+ break;
1657
+ case "multiSelect":
1658
+ schema = {
1659
+ type: "array",
1660
+ items: { type: "string", enum: field.options?.map((o) => typeof o === "string" ? o : o.value) }
1661
+ };
1662
+ break;
1663
+ case "relationship":
1664
+ schema = { type: "string", description: `ID of a ${field.relationTo} record` };
1665
+ break;
1666
+ case "object": {
1667
+ const { properties, required } = fieldsToProperties(field.fields || []);
1668
+ schema = { type: "object", properties, required };
1669
+ break;
1670
+ }
1671
+ case "array": {
1672
+ const { properties, required } = fieldsToProperties(field.fields || []);
1673
+ schema = { type: "array", items: { type: "object", properties, required } };
1674
+ break;
1675
+ }
1676
+ case "json":
1677
+ case "richText":
1678
+ schema = { type: "object", additionalProperties: true };
1679
+ break;
1680
+ case "blocks":
1681
+ schema = {
1682
+ type: "array",
1683
+ items: {
1684
+ oneOf: field.blocks?.map((block) => {
1685
+ const { properties, required } = fieldsToProperties(block.fields);
1686
+ return {
1687
+ type: "object",
1688
+ properties: {
1689
+ blockType: { type: "string", enum: [block.slug] },
1690
+ ...properties
1691
+ },
1692
+ required: ["blockType", ...required]
1693
+ };
1694
+ })
1695
+ }
1696
+ };
1697
+ break;
1698
+ default:
1699
+ schema = { type: "string" };
1700
+ }
1701
+ if (field.label) schema.description = field.label;
1702
+ return schema;
1703
+ }
1704
+
1705
+ // src/utils/swagger.ts
1706
+ function getSwaggerHtml(specUrl = "/api/openapi.json") {
1707
+ return `
1708
+ <!DOCTYPE html>
1709
+ <html lang="en">
1710
+ <head>
1711
+ <meta charset="utf-8" />
1712
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1713
+ <meta name="description" content="SwaggerUI" />
1714
+ <title>Dyrected API Documentation</title>
1715
+ <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui.css" />
1716
+ </head>
1717
+ <body>
1718
+ <div id="swagger-ui"></div>
1719
+ <script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-bundle.js" charset="UTF-8"></script>
1720
+ <script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-standalone-preset.js" charset="UTF-8"></script>
1721
+ <script>
1722
+ window.onload = () => {
1723
+ // Forward the apikey query param when loading the spec and making API calls
1724
+ const params = new URLSearchParams(window.location.search);
1725
+ const apiKey = params.get('apikey');
1726
+ const specUrlWithKey = apiKey ? '${specUrl}?apikey=' + encodeURIComponent(apiKey) : '${specUrl}';
1727
+
1728
+ window.ui = SwaggerUIBundle({
1729
+ url: specUrlWithKey,
1730
+ dom_id: '#swagger-ui',
1731
+ presets: [
1732
+ SwaggerUIBundle.presets.apis,
1733
+ SwaggerUIStandalonePreset
1734
+ ],
1735
+ layout: "BaseLayout",
1736
+ deepLinking: true,
1737
+ showExtensions: true,
1738
+ showCommonExtensions: true,
1739
+ // Inject x-api-key header on every request made from the Swagger UI
1740
+ requestInterceptor: (request) => {
1741
+ if (apiKey) {
1742
+ request.headers['x-api-key'] = apiKey;
1743
+ }
1744
+ return request;
1745
+ }
1746
+ });
1747
+ };
1748
+ </script>
1749
+ </body>
1750
+ </html>
1751
+ `;
1752
+ }
1753
+
1754
+ // src/auth/jexl.ts
1755
+ import jexl from "jexl";
1756
+ async function evaluateAccess(expression, context) {
1757
+ if (expression === void 0 || expression === null) return false;
1758
+ if (typeof expression === "boolean") return expression;
1759
+ try {
1760
+ const result = await jexl.eval(expression, context);
1761
+ return !!result;
1762
+ } catch (err) {
1763
+ console.error("[dyrected/core] Jexl evaluation failed:", err);
1764
+ return false;
1765
+ }
1766
+ }
1767
+
1768
+ // src/router.ts
1769
+ function accessGate(target, action) {
1770
+ return async (c, next) => {
1771
+ const user = c.get("user");
1772
+ const accessExpr = target.access?.[action];
1773
+ if (accessExpr === void 0 || accessExpr === null) {
1774
+ return await next();
1775
+ }
1776
+ const accessArgs = { user, req: c.req, doc: null };
1777
+ const allowed = await evaluateAccess(accessExpr, accessArgs);
1778
+ if (!allowed) {
1779
+ return c.json({ error: true, message: `Access denied: ${action} on ${target.slug}` }, 403);
1780
+ }
1781
+ await next();
1782
+ };
1783
+ }
1784
+ function registerRoutes(app, config) {
1785
+ app.get("/api/schemas", optionalAuth(), async (c) => {
1786
+ const siteId = c.req.header("X-Site-Id");
1787
+ let collections = [...config.collections];
1788
+ let globals = [...config.globals];
1789
+ if (siteId && config.onSchemaFetch) {
1790
+ const dynamic = await config.onSchemaFetch(siteId);
1791
+ if (dynamic.collections) collections = [...collections, ...dynamic.collections];
1792
+ if (dynamic.globals) globals = [...globals, ...dynamic.globals];
1793
+ if (dynamic.admin) {
1794
+ config.admin = { ...config.admin, ...dynamic.admin };
1795
+ }
1796
+ }
1797
+ const user = c.get("user");
1798
+ const accessArgs = { user, req: c.req, doc: null };
1799
+ const resolveAccess = async (access) => {
1800
+ if (access === void 0 || access === null) return true;
1801
+ if (typeof access === "function") {
1802
+ try {
1803
+ const result = await access(accessArgs);
1804
+ return typeof result === "boolean" ? result : !!result;
1805
+ } catch (err) {
1806
+ console.error("[dyrected/core] Functional access check failed:", err);
1807
+ return false;
1808
+ }
1809
+ }
1810
+ if (typeof access === "string" || typeof access === "boolean") {
1811
+ return evaluateAccess(access, accessArgs);
1812
+ }
1813
+ return true;
1814
+ };
1815
+ const serializeAccess = async (access) => {
1816
+ if (typeof access === "string") return access;
1817
+ if (typeof access === "boolean") return access;
1818
+ return resolveAccess(access);
1819
+ };
1820
+ const filteredCollections = await Promise.all(collections.filter((col) => !siteId || col.shared || !col.siteId || col.siteId === siteId).map(async (col) => ({
1821
+ slug: col.slug,
1822
+ labels: col.labels,
1823
+ access: {
1824
+ read: await serializeAccess(col.access?.read),
1825
+ create: await serializeAccess(col.access?.create),
1826
+ update: await serializeAccess(col.access?.update),
1827
+ delete: await serializeAccess(col.access?.delete)
1828
+ },
1829
+ fields: await Promise.all(col.fields.map(async (f) => ({
1830
+ name: f.name,
1831
+ type: f.type,
1832
+ label: f.label,
1833
+ required: f.required,
1834
+ defaultValue: f.defaultValue,
1835
+ options: f.options,
1836
+ relationTo: f.relationTo,
1837
+ hasMany: f.hasMany,
1838
+ fields: f.fields,
1839
+ blocks: f.blocks,
1840
+ admin: f.admin,
1841
+ access: {
1842
+ read: await serializeAccess(f.access?.read),
1843
+ update: await serializeAccess(f.access?.update)
1844
+ }
1845
+ }))),
1846
+ upload: !!col.upload,
1847
+ auth: !!col.auth,
1848
+ admin: col.admin
1849
+ })));
1850
+ const filteredGlobals = await Promise.all(globals.filter((glb) => !siteId || glb.shared || !glb.siteId || glb.siteId === siteId).map(async (glb) => ({
1851
+ slug: glb.slug,
1852
+ label: glb.label,
1853
+ access: {
1854
+ read: await serializeAccess(glb.access?.read),
1855
+ update: await serializeAccess(glb.access?.update)
1856
+ },
1857
+ fields: await Promise.all(glb.fields.map(async (f) => ({
1858
+ name: f.name,
1859
+ type: f.type,
1860
+ label: f.label,
1861
+ required: f.required,
1862
+ defaultValue: f.defaultValue,
1863
+ options: f.options,
1864
+ relationTo: f.relationTo,
1865
+ hasMany: f.hasMany,
1866
+ fields: f.fields,
1867
+ blocks: f.blocks,
1868
+ admin: f.admin,
1869
+ access: {
1870
+ read: await serializeAccess(f.access?.read),
1871
+ update: await serializeAccess(f.access?.update)
1872
+ }
1873
+ }))),
1874
+ admin: glb.admin
1875
+ })));
1876
+ return c.json({
1877
+ collections: filteredCollections,
1878
+ globals: filteredGlobals,
1879
+ admin: config.admin || {}
1880
+ });
1881
+ });
1882
+ app.get("/api/openapi.json", (c) => {
1883
+ return c.json(generateOpenApi(config));
1884
+ });
1885
+ app.get("/api/docs", (c) => {
1886
+ return c.html(getSwaggerHtml());
1887
+ });
1888
+ app.get("/api/media/:filename{.+$}", async (c) => {
1889
+ const mediaController = new MediaController("media");
1890
+ return mediaController.serve(c);
1891
+ });
1892
+ app.get("/media/:filename{.+$}", async (c) => {
1893
+ const mediaController = new MediaController("media");
1894
+ return mediaController.serve(c);
1895
+ });
1896
+ if (config.storage) {
1897
+ const uploadCollections = config.collections.filter((c) => c.upload);
1898
+ for (const col of uploadCollections) {
1899
+ const mediaController = new MediaController(col.slug);
1900
+ const prefix = `/api/collections/${col.slug}`;
1901
+ app.get(`${prefix}/media`, accessGate(col, "read"), (c) => mediaController.find(c));
1902
+ app.get(`${prefix}/media/:filename{.+$}`, (c) => mediaController.serve(c));
1903
+ app.post(`${prefix}/media`, accessGate(col, "create"), (c) => mediaController.upload(c));
1904
+ app.delete(`${prefix}/media/:id`, accessGate(col, "delete"), (c) => mediaController.delete(c));
1905
+ }
1906
+ }
1907
+ for (const collection of config.collections) {
1908
+ if (!collection.auth) continue;
1909
+ const path = `/api/collections/${collection.slug}`;
1910
+ const authController = new AuthController(collection);
1911
+ app.post(`${path}/login`, (c) => authController.login(c));
1912
+ app.post(`${path}/logout`, (c) => authController.logout(c));
1913
+ app.get(`${path}/init`, (c) => authController.init(c));
1914
+ app.post(`${path}/first-user`, (c) => authController.registerFirstUser(c));
1915
+ app.get(`${path}/me`, requireAuth(), (c) => authController.me(c));
1916
+ app.post(`${path}/refresh-token`, requireAuth(), (c) => authController.refreshToken(c));
1917
+ app.post(`${path}/forgot-password`, (c) => authController.forgotPassword(c));
1918
+ app.post(`${path}/reset-password`, (c) => authController.resetPassword(c));
1919
+ app.post(`${path}/invite`, requireAuth(), (c) => authController.invite(c));
1920
+ app.post(`${path}/accept-invite`, (c) => authController.acceptInvite(c));
1921
+ }
1922
+ for (const collection of config.collections) {
1923
+ const path = `/api/collections/${collection.slug}`;
1924
+ const controller = new CollectionController(collection);
1925
+ app.get(path, accessGate(collection, "read"), (c) => controller.find(c));
1926
+ app.post(path, accessGate(collection, "create"), (c) => controller.create(c));
1927
+ app.post(`${path}/media`, accessGate(collection, "create"), (c) => controller.create(c));
1928
+ app.delete(`${path}/delete-many`, accessGate(collection, "delete"), (c) => controller.deleteMany(c));
1929
+ app.get(`${path}/:id`, accessGate(collection, "read"), (c) => controller.findOne(c));
1930
+ app.patch(`${path}/:id`, accessGate(collection, "update"), (c) => controller.update(c));
1931
+ app.delete(`${path}/:id`, accessGate(collection, "delete"), (c) => controller.delete(c));
1932
+ app.post(`${path}/seed`, (c) => controller.seed(c));
1933
+ if (collection.auth) {
1934
+ app.post(`${path}/:id/change-password`, requireAuth(), (c) => controller.changePassword(c));
1935
+ }
1936
+ }
1937
+ for (const global of config.globals) {
1938
+ const path = `/api/globals/${global.slug}`;
1939
+ const controller = new GlobalController(global);
1940
+ app.get(path, accessGate(global, "read"), (c) => controller.get(c));
1941
+ app.patch(path, accessGate(global, "update"), (c) => controller.update(c));
1942
+ app.post(`${path}/seed`, (c) => controller.seed(c));
1943
+ }
1944
+ const previewController = new PreviewController();
1945
+ app.post("/api/preview-token", requireAuth(), (c) => previewController.createToken(c));
1946
+ app.get("/api/preview-data", (c) => previewController.getData(c));
1947
+ app.all("/api/collections/:slug/:id?", async (c) => {
1948
+ const slug = c.req.param("slug");
1949
+ const id = c.req.param("id");
1950
+ const siteId = c.req.header("X-Site-Id") || c.get("siteId");
1951
+ const config2 = c.get("config");
1952
+ if (config2.collections.some((col) => col.slug === slug)) {
1953
+ return c.json({ message: "Method Not Allowed" }, 405);
1954
+ }
1955
+ if (config2.onSchemaFetch && siteId) {
1956
+ const dynamic = await config2.onSchemaFetch(siteId);
1957
+ let collection = dynamic.collections?.find((col) => col.slug === slug);
1958
+ if (!collection && slug === "media") {
1959
+ collection = {
1960
+ slug: "media",
1961
+ labels: { singular: "Media", plural: "Media" },
1962
+ upload: true,
1963
+ fields: []
1964
+ };
1965
+ }
1966
+ if (collection) {
1967
+ if (collection.auth && id) {
1968
+ const authController = new AuthController(collection);
1969
+ const method2 = c.req.method;
1970
+ if (method2 === "POST" && id === "login") return authController.login(c);
1971
+ if (method2 === "POST" && id === "logout") return authController.logout(c);
1972
+ if (method2 === "GET" && id === "me") return authController.me(c);
1973
+ if (method2 === "POST" && id === "refresh-token") return authController.refreshToken(c);
1974
+ if (method2 === "POST" && id === "forgot-password") return authController.forgotPassword(c);
1975
+ if (method2 === "POST" && id === "reset-password") return authController.resetPassword(c);
1976
+ }
1977
+ const controller = new CollectionController(collection);
1978
+ const method = c.req.method;
1979
+ if (id) {
1980
+ if (method === "GET") return controller.findOne(c);
1981
+ if (method === "PATCH") return controller.update(c);
1982
+ if (method === "DELETE" && id === "delete-many") return controller.deleteMany(c);
1983
+ if (method === "DELETE") return controller.delete(c);
1984
+ if (method === "POST" && id === "media") return controller.create(c);
1985
+ if (method === "POST" && id === "seed") return controller.seed(c);
1986
+ } else {
1987
+ if (method === "GET") return controller.find(c);
1988
+ if (method === "POST") return controller.create(c);
1989
+ }
1990
+ }
1991
+ }
1992
+ return c.json({ message: `Collection "${slug}" not found` }, 404);
1993
+ });
1994
+ app.all("/api/globals/:slug", async (c) => {
1995
+ const slug = c.req.param("slug");
1996
+ const siteId = c.req.header("X-Site-Id") || c.get("siteId");
1997
+ const config2 = c.get("config");
1998
+ if (config2.globals.some((glb) => glb.slug === slug)) {
1999
+ return c.json({ message: "Method Not Allowed" }, 405);
2000
+ }
2001
+ if (config2.onSchemaFetch && siteId) {
2002
+ const dynamic = await config2.onSchemaFetch(siteId);
2003
+ const global = dynamic.globals?.find((glb) => glb.slug === slug);
2004
+ if (global) {
2005
+ const controller = new GlobalController(global);
2006
+ if (c.req.method === "GET") return controller.get(c);
2007
+ if (c.req.method === "PATCH") return controller.update(c);
2008
+ }
2009
+ }
2010
+ return c.json({ message: `Global "${slug}" not found` }, 404);
2011
+ });
2012
+ }
2013
+
2014
+ // src/app.ts
2015
+ import { Hono } from "hono";
2016
+ import { cors } from "hono/cors";
2017
+ import { requestId } from "hono/request-id";
2018
+ async function createDyrectedApp(rawConfig) {
2019
+ const config = normalizeConfig(rawConfig);
2020
+ const app = new Hono();
2021
+ if (config.db?.sync) {
2022
+ await config.db.sync(config.collections, config.globals);
2023
+ }
2024
+ app.use("*", requestId());
2025
+ app.use("*", optionalAuth());
2026
+ app.use("*", async (c, next) => {
2027
+ const start = Date.now();
2028
+ await next();
2029
+ const ms = Date.now() - start;
2030
+ console.log(`[dyrected/api] ${c.req.method} ${c.req.path} ${c.res.status} - ${ms}ms`);
2031
+ });
2032
+ app.use("*", cors());
2033
+ app.use("*", async (c, next) => {
2034
+ c.set("config", config);
2035
+ if (!c.get("siteId")) {
2036
+ c.set("siteId", "default");
2037
+ }
2038
+ await next();
2039
+ });
2040
+ app.get("/health", (c) => c.json({ status: "ok", version: "0.0.1" }));
2041
+ app.get("/routes", (c) => {
2042
+ const routes = app.routes.map((r) => ({ method: r.method, path: r.path }));
2043
+ return c.json({ routes });
2044
+ });
2045
+ app.onError((err, c) => {
2046
+ console.error(`[dyrected/core] Uncaught Error:`, err);
2047
+ return c.json({
2048
+ message: err.message || "Internal Server Error",
2049
+ stack: process.env.NODE_ENV === "development" ? err.stack : void 0
2050
+ }, 500);
2051
+ });
2052
+ registerRoutes(app, config);
2053
+ return app;
2054
+ }
2055
+
2056
+ export {
2057
+ normalizeConfig,
2058
+ CollectionController,
2059
+ GlobalController,
2060
+ MediaController,
2061
+ AuthController,
2062
+ PreviewController,
2063
+ registerRoutes,
2064
+ createDyrectedApp
2065
+ };