@dyrected/core 2.5.61 → 2.5.62

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/server.js CHANGED
@@ -1,19 +1,41 @@
1
1
  import {
2
2
  WORKFLOW_HISTORY_COLLECTION,
3
+ attachRequestMetrics,
4
+ bindObservabilityRuntime,
5
+ buildRequestLogger,
3
6
  canViewWorkflowDraft,
7
+ captureRequestBody,
8
+ createObservabilityRuntime,
9
+ createRequestSpan,
4
10
  createWorkflowDocument,
11
+ endRequestSpan,
5
12
  executeFieldAfterRead,
6
13
  executeFieldBeforeChange,
7
14
  generateOpenApi,
8
15
  getAdminAuthCollection,
16
+ getAuthSession,
17
+ getConfigLogger,
18
+ getObservabilityRuntime,
9
19
  getPublicAdminAuthConfig,
20
+ getRequestLogger,
10
21
  initializeWorkflowDocument,
22
+ isAuthSessionActive,
23
+ issueAuthSessionToken,
11
24
  materializeWorkflowDocument,
25
+ mergeDynamicConfig,
12
26
  normalizeConfig,
27
+ redactHeaders,
28
+ renderPrometheusMetrics,
29
+ revokeAllAuthSessions,
30
+ revokeAuthSession,
13
31
  runCollectionHooks,
14
32
  saveWorkflowDraft,
15
- transitionWorkflow
16
- } from "./chunk-35NM2WPO.js";
33
+ shouldSampleRequest,
34
+ signCollectionToken,
35
+ touchAuthSession,
36
+ transitionWorkflow,
37
+ verifyCollectionToken
38
+ } from "./chunk-BQV3QW3Y.js";
17
39
 
18
40
  // src/app.ts
19
41
  import { Hono } from "hono";
@@ -81,12 +103,16 @@ var PopulationService = class {
81
103
  return data;
82
104
  }
83
105
  if (Array.isArray(data)) {
84
- return Promise.all(data.map((item) => this.populate({ ...args, data: item })));
106
+ return Promise.all(
107
+ data.map((item) => this.populate({ ...args, data: item }))
108
+ );
85
109
  }
86
110
  const populatedDoc = { ...data };
87
111
  for (const field of fields) {
88
112
  if (field.type === "join" && field.collection && field.on && currentDepth === 0) {
89
- const targetCollection = this.collections.find((c) => c.slug === field.collection);
113
+ const targetCollection = this.collections.find(
114
+ (c) => c.slug === field.collection
115
+ );
90
116
  if (targetCollection) {
91
117
  const docId = populatedDoc.id;
92
118
  if (docId) {
@@ -98,7 +124,9 @@ var PopulationService = class {
98
124
  limit: joinLimit
99
125
  });
100
126
  const populatedDocs = await this.populate({
101
- data: result.docs.map((doc) => DefaultsService.apply(targetCollection.fields, doc)),
127
+ data: result.docs.map(
128
+ (doc) => DefaultsService.apply(targetCollection.fields, doc)
129
+ ),
102
130
  fields: targetCollection.fields,
103
131
  currentDepth: 1,
104
132
  maxDepth
@@ -113,14 +141,21 @@ var PopulationService = class {
113
141
  continue;
114
142
  }
115
143
  if (field.type === "row" && field.fields) {
116
- const rowPopulated = await this.populate({ data, fields: field.fields, currentDepth, maxDepth });
144
+ const rowPopulated = await this.populate({
145
+ data,
146
+ fields: field.fields,
147
+ currentDepth,
148
+ maxDepth
149
+ });
117
150
  Object.assign(populatedDoc, rowPopulated);
118
151
  continue;
119
152
  }
120
153
  if (!field.name) continue;
121
154
  const value = populatedDoc[field.name];
122
155
  if (field.type === "relationship" && field.relationTo && value) {
123
- const relatedCollection = this.collections.find((c) => c.slug === field.relationTo);
156
+ const relatedCollection = this.collections.find(
157
+ (c) => c.slug === field.relationTo
158
+ );
124
159
  if (!relatedCollection) continue;
125
160
  if (Array.isArray(value)) {
126
161
  populatedDoc[field.name] = await Promise.all(
@@ -128,10 +163,16 @@ var PopulationService = class {
128
163
  if (!id) return id;
129
164
  let doc = id;
130
165
  if (typeof id === "string") {
131
- doc = await this.db.findOne({ collection: field.relationTo, id });
166
+ doc = await this.db.findOne({
167
+ collection: field.relationTo,
168
+ id
169
+ });
132
170
  }
133
171
  if (!doc || typeof doc !== "object") return id;
134
- const docWithDefaults = DefaultsService.apply(relatedCollection.fields, doc);
172
+ const docWithDefaults = DefaultsService.apply(
173
+ relatedCollection.fields,
174
+ doc
175
+ );
135
176
  return this.populate({
136
177
  data: docWithDefaults,
137
178
  fields: relatedCollection.fields,
@@ -143,10 +184,16 @@ var PopulationService = class {
143
184
  } else if (value) {
144
185
  let doc = value;
145
186
  if (typeof value === "string") {
146
- doc = await this.db.findOne({ collection: field.relationTo, id: value });
187
+ doc = await this.db.findOne({
188
+ collection: field.relationTo,
189
+ id: value
190
+ });
147
191
  }
148
192
  if (doc && typeof doc === "object") {
149
- const docWithDefaults = DefaultsService.apply(relatedCollection.fields, doc);
193
+ const docWithDefaults = DefaultsService.apply(
194
+ relatedCollection.fields,
195
+ doc
196
+ );
150
197
  populatedDoc[field.name] = await this.populate({
151
198
  data: docWithDefaults,
152
199
  fields: relatedCollection.fields,
@@ -157,11 +204,19 @@ var PopulationService = class {
157
204
  }
158
205
  }
159
206
  if (field.type === "url" && value && typeof value === "object" && value.type === "internal" && value.relationTo && value.value) {
160
- const relatedCollection = this.collections.find((c) => c.slug === value.relationTo);
207
+ const relatedCollection = this.collections.find(
208
+ (c) => c.slug === value.relationTo
209
+ );
161
210
  if (relatedCollection) {
162
- const doc = await this.db.findOne({ collection: value.relationTo, id: value.value });
211
+ const doc = await this.db.findOne({
212
+ collection: value.relationTo,
213
+ id: value.value
214
+ });
163
215
  if (doc && typeof doc === "object") {
164
- const docWithDefaults = DefaultsService.apply(relatedCollection.fields, doc);
216
+ const docWithDefaults = DefaultsService.apply(
217
+ relatedCollection.fields,
218
+ doc
219
+ );
165
220
  const populatedDocValue = await this.populate({
166
221
  data: docWithDefaults,
167
222
  fields: relatedCollection.fields,
@@ -179,15 +234,27 @@ var PopulationService = class {
179
234
  }
180
235
  } else if (typeof previewUrlPattern === "string") {
181
236
  if (previewUrlPattern.includes("{{")) {
182
- resolvedUrl = previewUrlPattern.replace(/{{(.*?)}}/g, (_, key) => String(docWithDefaults[key.trim()] || ""));
237
+ resolvedUrl = previewUrlPattern.replace(
238
+ /{{(.*?)}}/g,
239
+ (_, key) => String(docWithDefaults[key.trim()] || "")
240
+ );
183
241
  } else if (previewUrlPattern.includes("+") || previewUrlPattern.includes("?") || previewUrlPattern.includes("==") || previewUrlPattern.includes("siteUrl")) {
184
242
  try {
185
- resolvedUrl = jexl.evalSync(previewUrlPattern, docWithDefaults);
186
- } catch (err) {
187
- resolvedUrl = previewUrlPattern.replace(/:([a-zA-Z0-9_]+)/g, (_, key) => String(docWithDefaults[key] || ""));
243
+ resolvedUrl = jexl.evalSync(
244
+ previewUrlPattern,
245
+ docWithDefaults
246
+ );
247
+ } catch (_err) {
248
+ resolvedUrl = previewUrlPattern.replace(
249
+ /:([a-zA-Z0-9_]+)/g,
250
+ (_, key) => String(docWithDefaults[key] || "")
251
+ );
188
252
  }
189
253
  } else {
190
- resolvedUrl = previewUrlPattern.replace(/:([a-zA-Z0-9_]+)/g, (_, key) => String(docWithDefaults[key] || ""));
254
+ resolvedUrl = previewUrlPattern.replace(
255
+ /:([a-zA-Z0-9_]+)/g,
256
+ (_, key) => String(docWithDefaults[key] || "")
257
+ );
191
258
  }
192
259
  }
193
260
  }
@@ -211,7 +278,9 @@ var PopulationService = class {
211
278
  if (field.type === "blocks" && field.blocks && Array.isArray(value)) {
212
279
  populatedDoc[field.name] = await Promise.all(
213
280
  value.map(async (blockData) => {
214
- const blockConfig = field.blocks.find((b) => b.slug === blockData.blockType);
281
+ const blockConfig = field.blocks.find(
282
+ (b) => b.slug === blockData.blockType
283
+ );
215
284
  if (!blockConfig) return blockData;
216
285
  return this.populate({
217
286
  data: blockData,
@@ -249,7 +318,7 @@ var AuditService = class {
249
318
  * Writes a single entry to the __audit collection.
250
319
  * Called without await — runs asynchronously and never blocks the primary operation.
251
320
  */
252
- static async log(db, args) {
321
+ static async log(db, args, config) {
253
322
  try {
254
323
  await db.create({
255
324
  collection: "__audit",
@@ -266,7 +335,17 @@ var AuditService = class {
266
335
  }
267
336
  });
268
337
  } catch (err) {
269
- console.error("[dyrected/audit] Failed to write audit log:", err);
338
+ getObservabilityRuntime(config)?.recordAuditWriteFailure({
339
+ collection: args.collection,
340
+ operation: args.operation
341
+ });
342
+ getConfigLogger(config, "audit").error({
343
+ err,
344
+ msg: "Failed to write audit log",
345
+ collection: args.collection,
346
+ operation: args.operation,
347
+ documentId: args.documentId
348
+ });
270
349
  }
271
350
  }
272
351
  };
@@ -351,7 +430,11 @@ async function evaluateAccess(expression, context) {
351
430
  }
352
431
  return !!result;
353
432
  } catch (err) {
354
- console.error("[dyrected/core] Jexl evaluation failed:", err);
433
+ getConfigLogger(context.config, "access").error({
434
+ err,
435
+ msg: "Jexl evaluation failed",
436
+ expression
437
+ });
355
438
  return false;
356
439
  }
357
440
  }
@@ -363,30 +446,40 @@ function isNamedAccessPolicy(value) {
363
446
  async function resolveAccess(config, access, args) {
364
447
  if (access === void 0 || access === null) return void 0;
365
448
  if (typeof access === "boolean" || typeof access === "string") {
366
- return evaluateAccess(access, args);
449
+ return evaluateAccess(access, { ...args, config });
367
450
  }
368
451
  if (typeof access === "function") {
369
452
  try {
370
453
  return await access(args);
371
454
  } catch (err) {
372
- console.error("[dyrected/core] Functional access check failed:", err);
455
+ getConfigLogger(config, "access").error({
456
+ err,
457
+ msg: "Functional access check failed"
458
+ });
373
459
  return false;
374
460
  }
375
461
  }
376
462
  if (isNamedAccessPolicy(access)) {
377
463
  const policy = config.accessPolicies?.[access.policy];
378
464
  if (policy === void 0) {
379
- console.error(`[dyrected/core] Unknown access policy "${access.policy}".`);
465
+ getConfigLogger(config, "access").error({
466
+ msg: "Unknown access policy",
467
+ policy: access.policy
468
+ });
380
469
  return false;
381
470
  }
382
471
  if (typeof policy === "string" || typeof policy === "boolean") {
383
- return evaluateAccess(policy, args);
472
+ return evaluateAccess(policy, { ...args, config });
384
473
  }
385
474
  try {
386
475
  const resolver = policy;
387
476
  return await resolver({ ...args, params: access.params });
388
477
  } catch (err) {
389
- console.error(`[dyrected/core] Access policy "${access.policy}" failed:`, err);
478
+ getConfigLogger(config, "access").error({
479
+ err,
480
+ msg: "Access policy failed",
481
+ policy: access.policy
482
+ });
390
483
  return false;
391
484
  }
392
485
  }
@@ -438,32 +531,48 @@ async function applyFieldReadAccess(context, doc) {
438
531
  const result = { ...doc };
439
532
  for (const field of context.fields) {
440
533
  if (field.type === "row" && field.fields) {
441
- Object.assign(result, await applyFieldReadAccess({ ...context, fields: field.fields }, result));
534
+ Object.assign(
535
+ result,
536
+ await applyFieldReadAccess(
537
+ { ...context, fields: field.fields },
538
+ result
539
+ )
540
+ );
442
541
  continue;
443
542
  }
444
543
  if (!field.name) continue;
445
544
  const value = result[field.name];
446
- const canRead = await resolveBooleanAccess(context.config, field.access?.read, {
447
- user: context.user,
448
- req: context.req,
449
- doc: context.doc,
450
- data: context.data,
451
- id: resolveFieldAccessId(context)
452
- });
545
+ const canRead = await resolveBooleanAccess(
546
+ context.config,
547
+ field.access?.read,
548
+ {
549
+ user: context.user,
550
+ req: context.req,
551
+ doc: context.doc,
552
+ data: context.data,
553
+ id: resolveFieldAccessId(context)
554
+ }
555
+ );
453
556
  if (!canRead) {
454
557
  delete result[field.name];
455
558
  continue;
456
559
  }
457
560
  if (value === void 0 || value === null) continue;
458
561
  if (field.type === "object" && field.fields && typeof value === "object" && !Array.isArray(value)) {
459
- result[field.name] = await applyFieldReadAccess({ ...context, fields: field.fields }, value);
562
+ result[field.name] = await applyFieldReadAccess(
563
+ { ...context, fields: field.fields },
564
+ value
565
+ );
460
566
  continue;
461
567
  }
462
568
  if (field.type === "array" && field.fields && Array.isArray(value)) {
463
569
  const childFields = field.fields;
464
570
  result[field.name] = await Promise.all(
465
571
  value.map(
466
- (item) => typeof item === "object" && item !== null ? applyFieldReadAccess({ ...context, fields: childFields }, item) : item
572
+ (item) => typeof item === "object" && item !== null ? applyFieldReadAccess(
573
+ { ...context, fields: childFields },
574
+ item
575
+ ) : item
467
576
  )
468
577
  );
469
578
  continue;
@@ -473,9 +582,14 @@ async function applyFieldReadAccess(context, doc) {
473
582
  value.map(async (item) => {
474
583
  if (typeof item !== "object" || item === null) return item;
475
584
  const typedItem = item;
476
- const block = field.blocks?.find((candidate) => candidate.slug === typedItem.blockType);
585
+ const block = field.blocks?.find(
586
+ (candidate) => candidate.slug === typedItem.blockType
587
+ );
477
588
  if (!block || !block.fields) return item;
478
- return applyFieldReadAccess({ ...context, fields: block.fields }, typedItem);
589
+ return applyFieldReadAccess(
590
+ { ...context, fields: block.fields },
591
+ typedItem
592
+ );
479
593
  })
480
594
  );
481
595
  }
@@ -487,7 +601,13 @@ async function applyFieldWriteAccess(context, data) {
487
601
  const result = { ...data };
488
602
  for (const field of context.fields) {
489
603
  if (field.type === "row" && field.fields) {
490
- Object.assign(result, await applyFieldWriteAccess({ ...context, fields: field.fields }, result));
604
+ Object.assign(
605
+ result,
606
+ await applyFieldWriteAccess(
607
+ { ...context, fields: field.fields },
608
+ result
609
+ )
610
+ );
491
611
  continue;
492
612
  }
493
613
  if (!field.name || !(field.name in result)) continue;
@@ -506,14 +626,20 @@ async function applyFieldWriteAccess(context, data) {
506
626
  const value = result[field.name];
507
627
  if (value === void 0 || value === null) continue;
508
628
  if (field.type === "object" && field.fields && typeof value === "object" && !Array.isArray(value)) {
509
- result[field.name] = await applyFieldWriteAccess({ ...context, fields: field.fields }, value);
629
+ result[field.name] = await applyFieldWriteAccess(
630
+ { ...context, fields: field.fields },
631
+ value
632
+ );
510
633
  continue;
511
634
  }
512
635
  if (field.type === "array" && field.fields && Array.isArray(value)) {
513
636
  const childFields = field.fields;
514
637
  result[field.name] = await Promise.all(
515
638
  value.map(
516
- (item) => typeof item === "object" && item !== null ? applyFieldWriteAccess({ ...context, fields: childFields }, item) : item
639
+ (item) => typeof item === "object" && item !== null ? applyFieldWriteAccess(
640
+ { ...context, fields: childFields },
641
+ item
642
+ ) : item
517
643
  )
518
644
  );
519
645
  continue;
@@ -523,9 +649,14 @@ async function applyFieldWriteAccess(context, data) {
523
649
  value.map(async (item) => {
524
650
  if (typeof item !== "object" || item === null) return item;
525
651
  const typedItem = item;
526
- const block = field.blocks?.find((candidate) => candidate.slug === typedItem.blockType);
652
+ const block = field.blocks?.find(
653
+ (candidate) => candidate.slug === typedItem.blockType
654
+ );
527
655
  if (!block || !block.fields) return item;
528
- return applyFieldWriteAccess({ ...context, fields: block.fields }, typedItem);
656
+ return applyFieldWriteAccess(
657
+ { ...context, fields: block.fields },
658
+ typedItem
659
+ );
529
660
  })
530
661
  );
531
662
  }
@@ -587,7 +718,13 @@ var CollectionController = class {
587
718
  } catch {
588
719
  }
589
720
  }
590
- const paginatedResult = await provider.members.list({ limit: limit2, page: page2, sort: sort2, where: where2, req: hookReq });
721
+ const paginatedResult = await provider.members.list({
722
+ limit: limit2,
723
+ page: page2,
724
+ sort: sort2,
725
+ where: where2,
726
+ req: hookReq
727
+ });
591
728
  const mappedDocs = [];
592
729
  for (const m of paginatedResult.docs) {
593
730
  let localId = m.id;
@@ -635,12 +772,15 @@ var CollectionController = class {
635
772
  }
636
773
  }
637
774
  }
638
- const beforeReadResult = await runCollectionHooks(this.collection.hooks?.beforeRead, {
639
- req: c.req,
640
- query: where,
641
- user,
642
- db: readonlyDb
643
- });
775
+ const beforeReadResult = await runCollectionHooks(
776
+ this.collection.hooks?.beforeRead,
777
+ {
778
+ req: c.req,
779
+ query: where,
780
+ user,
781
+ db: readonlyDb
782
+ }
783
+ );
644
784
  if (beforeReadResult !== void 0) {
645
785
  where = beforeReadResult;
646
786
  }
@@ -649,7 +789,13 @@ var CollectionController = class {
649
789
  }
650
790
  const access = await this.evaluateAccess(c, "read");
651
791
  if (!access.allowed) {
652
- return c.json({ error: true, message: `Access denied: read on ${this.collection.slug}` }, 403);
792
+ return c.json(
793
+ {
794
+ error: true,
795
+ message: `Access denied: read on ${this.collection.slug}`
796
+ },
797
+ 403
798
+ );
653
799
  }
654
800
  if (access.constraint) {
655
801
  where = mergeWhereConstraint(where, access.constraint);
@@ -663,7 +809,10 @@ var CollectionController = class {
663
809
  fields: this.collection.fields
664
810
  });
665
811
  if (result.total === 0 && this.collection.initialData && !where && page === 1) {
666
- console.log(`[dyrected/core] Auto-seeding collection "${this.collection.slug}" from config.initialData`);
812
+ getRequestLogger(c, "collection").info({
813
+ msg: "Auto-seeding collection from config.initialData",
814
+ collection: this.collection.slug
815
+ });
667
816
  for (const data of this.collection.initialData) {
668
817
  await db.create({ collection: this.collection.slug, data });
669
818
  }
@@ -676,31 +825,54 @@ var CollectionController = class {
676
825
  fields: this.collection.fields
677
826
  });
678
827
  }
679
- result.docs = result.docs.map((doc) => this.collection.workflow ? materializeWorkflowDocument(doc, this.collection.workflow, user) : doc).filter((doc) => doc !== null);
828
+ result.docs = result.docs.map(
829
+ (doc) => this.collection.workflow ? materializeWorkflowDocument(
830
+ doc,
831
+ this.collection.workflow,
832
+ user
833
+ ) : doc
834
+ ).filter((doc) => doc !== null);
680
835
  const processedDocs = [];
681
- const readonlyDbForHooks = createReadonlyDb(db);
682
836
  for (const doc of result.docs) {
683
- const docWithDefaults = DefaultsService.apply(this.collection.fields, doc);
684
- const docWithCollectionHooks = await runCollectionHooks(this.collection.hooks?.afterRead, {
685
- doc: docWithDefaults,
686
- req: c.req,
687
- user,
688
- db: readonlyDb
689
- });
690
- const docWithFieldHooks = await executeFieldAfterRead(this.collection.fields, docWithCollectionHooks, user, readonlyDb);
691
- const docWithFieldAccess = await applyFieldReadAccess({
692
- config,
693
- fields: this.collection.fields,
837
+ const docWithDefaults = DefaultsService.apply(
838
+ this.collection.fields,
839
+ doc
840
+ );
841
+ const docWithCollectionHooks = await runCollectionHooks(
842
+ this.collection.hooks?.afterRead,
843
+ {
844
+ doc: docWithDefaults,
845
+ req: c.req,
846
+ user,
847
+ db: readonlyDb
848
+ }
849
+ );
850
+ const docWithFieldHooks = await executeFieldAfterRead(
851
+ this.collection.fields,
852
+ docWithCollectionHooks,
694
853
  user,
695
- req: this.toHookRequestContext(c),
696
- doc: docWithFieldHooks
697
- }, docWithFieldHooks);
854
+ readonlyDb
855
+ );
856
+ const docWithFieldAccess = await applyFieldReadAccess(
857
+ {
858
+ config,
859
+ fields: this.collection.fields,
860
+ user,
861
+ req: this.toHookRequestContext(c),
862
+ doc: docWithFieldHooks
863
+ },
864
+ docWithFieldHooks
865
+ );
698
866
  processedDocs.push(docWithFieldAccess);
699
867
  }
700
868
  result.docs = processedDocs;
701
869
  if (depth > 0) {
702
870
  const populationService = new PopulationService(db, config.collections);
703
- result = await populationService.populateResult(result, this.collection.fields, depth);
871
+ result = await populationService.populateResult(
872
+ result,
873
+ this.collection.fields,
874
+ depth
875
+ );
704
876
  }
705
877
  return c.json(result);
706
878
  }
@@ -713,7 +885,10 @@ var CollectionController = class {
713
885
  const hookReq = this.toHookRequestContext(c);
714
886
  const id2 = c.req.param("id");
715
887
  if (!id2) return c.json({ message: "Missing ID" }, 400);
716
- const localDoc = await db.findOne({ collection: this.collection.slug, id: id2 });
888
+ const localDoc = await db.findOne({
889
+ collection: this.collection.slug,
890
+ id: id2
891
+ });
717
892
  const externalSubject = localDoc?.externalSubject || id2;
718
893
  let member = null;
719
894
  if (provider.members.get) {
@@ -742,23 +917,40 @@ var CollectionController = class {
742
917
  }
743
918
  const access = await this.evaluateAccess(c, "read", { id, doc });
744
919
  if (!access.allowed) {
745
- return c.json({ error: true, message: `Access denied: read on ${this.collection.slug}` }, 403);
920
+ return c.json(
921
+ {
922
+ error: true,
923
+ message: `Access denied: read on ${this.collection.slug}`
924
+ },
925
+ 403
926
+ );
746
927
  }
747
928
  const docWithDefaults = DefaultsService.apply(this.collection.fields, doc);
748
- const docWithCollectionHooks = await runCollectionHooks(this.collection.hooks?.afterRead, {
749
- doc: docWithDefaults,
750
- req: c.req,
751
- user,
752
- db: readonlyDb
753
- });
754
- const docWithFieldHooks = await executeFieldAfterRead(this.collection.fields, docWithCollectionHooks, user, readonlyDb);
755
- const docWithFieldAccess = await applyFieldReadAccess({
756
- config,
757
- fields: this.collection.fields,
929
+ const docWithCollectionHooks = await runCollectionHooks(
930
+ this.collection.hooks?.afterRead,
931
+ {
932
+ doc: docWithDefaults,
933
+ req: c.req,
934
+ user,
935
+ db: readonlyDb
936
+ }
937
+ );
938
+ const docWithFieldHooks = await executeFieldAfterRead(
939
+ this.collection.fields,
940
+ docWithCollectionHooks,
758
941
  user,
759
- req: this.toHookRequestContext(c),
760
- doc: docWithFieldHooks
761
- }, docWithFieldHooks);
942
+ readonlyDb
943
+ );
944
+ const docWithFieldAccess = await applyFieldReadAccess(
945
+ {
946
+ config,
947
+ fields: this.collection.fields,
948
+ user,
949
+ req: this.toHookRequestContext(c),
950
+ doc: docWithFieldHooks
951
+ },
952
+ docWithFieldHooks
953
+ );
762
954
  if (depth > 0 && docWithFieldAccess) {
763
955
  const populationService = new PopulationService(db, config.collections);
764
956
  const populatedDoc = await populationService.populate({
@@ -783,12 +975,18 @@ var CollectionController = class {
783
975
  return this.upload(c);
784
976
  }
785
977
  const body2 = await c.req.json();
786
- const member = await provider.members.create({ data: body2, req: hookReq });
787
- return c.json({
788
- ...member,
789
- id: member.id,
790
- externalSubject: member.id
791
- }, 201);
978
+ const member = await provider.members.create({
979
+ data: body2,
980
+ req: hookReq
981
+ });
982
+ return c.json(
983
+ {
984
+ ...member,
985
+ id: member.id,
986
+ externalSubject: member.id
987
+ },
988
+ 201
989
+ );
792
990
  }
793
991
  const readonlyDb = createReadonlyDb(db);
794
992
  const contentType = c.req.header("Content-Type") || "";
@@ -810,20 +1008,35 @@ var CollectionController = class {
810
1008
  }
811
1009
  const createAccess = await this.evaluateAccess(c, "create", { data });
812
1010
  if (!createAccess.allowed) {
813
- return c.json({ error: true, message: `Access denied: create on ${this.collection.slug}` }, 403);
1011
+ return c.json(
1012
+ {
1013
+ error: true,
1014
+ message: `Access denied: create on ${this.collection.slug}`
1015
+ },
1016
+ 403
1017
+ );
814
1018
  }
815
1019
  if (this.collection.auth && data.password) {
816
1020
  data.password = await hashPassword(data.password);
817
1021
  }
818
- data = await applyFieldWriteAccess({
819
- config,
820
- fields: this.collection.fields,
821
- user,
822
- req: this.toHookRequestContext(c),
1022
+ data = await applyFieldWriteAccess(
1023
+ {
1024
+ config,
1025
+ fields: this.collection.fields,
1026
+ user,
1027
+ req: this.toHookRequestContext(c),
1028
+ data,
1029
+ operation: "create"
1030
+ },
1031
+ data
1032
+ );
1033
+ data = await executeFieldBeforeChange(
1034
+ this.collection.fields,
823
1035
  data,
824
- operation: "create"
825
- }, data);
826
- data = await executeFieldBeforeChange(this.collection.fields, data, null, user, readonlyDb);
1036
+ null,
1037
+ user,
1038
+ readonlyDb
1039
+ );
827
1040
  data = await runCollectionHooks(this.collection.hooks?.beforeChange, {
828
1041
  data,
829
1042
  req: c.req,
@@ -831,7 +1044,12 @@ var CollectionController = class {
831
1044
  operation: "create",
832
1045
  db: readonlyDb
833
1046
  });
834
- const doc = this.collection.workflow ? (await createWorkflowDocument({ config, collection: this.collection, data, user })).doc : await db.create({ collection: this.collection.slug, data });
1047
+ const doc = this.collection.workflow ? (await createWorkflowDocument({
1048
+ config,
1049
+ collection: this.collection,
1050
+ data,
1051
+ user
1052
+ })).doc : await db.create({ collection: this.collection.slug, data });
835
1053
  if (this.collection.audit && db) {
836
1054
  AuditService.log(db, {
837
1055
  operation: "create",
@@ -840,15 +1058,19 @@ var CollectionController = class {
840
1058
  user: user ? { id: user.sub, collection: user.collection, email: user.email } : void 0,
841
1059
  before: null,
842
1060
  after: doc
843
- });
1061
+ }, config);
844
1062
  }
845
- await runCollectionHooks(this.collection.hooks?.afterChange, {
846
- doc,
847
- user,
848
- req: c.req,
849
- operation: "create",
850
- db
851
- }, { isolated: true });
1063
+ await runCollectionHooks(
1064
+ this.collection.hooks?.afterChange,
1065
+ {
1066
+ doc,
1067
+ user,
1068
+ req: c.req,
1069
+ operation: "create",
1070
+ db
1071
+ },
1072
+ { isolated: true }
1073
+ );
852
1074
  const responseDoc = this.collection.workflow ? materializeWorkflowDocument(doc, this.collection.workflow, user) : doc;
853
1075
  const readDoc = await runCollectionHooks(this.collection.hooks?.afterRead, {
854
1076
  doc: responseDoc,
@@ -856,14 +1078,22 @@ var CollectionController = class {
856
1078
  user,
857
1079
  db: readonlyDb
858
1080
  });
859
- const finalDoc = await executeFieldAfterRead(this.collection.fields, readDoc, user, readonlyDb);
860
- const accessibleDoc = await applyFieldReadAccess({
861
- config,
862
- fields: this.collection.fields,
1081
+ const finalDoc = await executeFieldAfterRead(
1082
+ this.collection.fields,
1083
+ readDoc,
863
1084
  user,
864
- req: this.toHookRequestContext(c),
865
- doc: finalDoc
866
- }, finalDoc);
1085
+ readonlyDb
1086
+ );
1087
+ const accessibleDoc = await applyFieldReadAccess(
1088
+ {
1089
+ config,
1090
+ fields: this.collection.fields,
1091
+ user,
1092
+ req: this.toHookRequestContext(c),
1093
+ doc: finalDoc
1094
+ },
1095
+ finalDoc
1096
+ );
867
1097
  return c.json(accessibleDoc, 201);
868
1098
  }
869
1099
  async upload(c) {
@@ -879,7 +1109,10 @@ var CollectionController = class {
879
1109
  const uploadConfig = typeof this.collection.upload === "object" ? this.collection.upload : void 0;
880
1110
  const validationError = validateUpload(file, uploadConfig);
881
1111
  if (validationError) {
882
- return c.json({ message: validationError.message }, validationError.status);
1112
+ return c.json(
1113
+ { message: validationError.message },
1114
+ validationError.status
1115
+ );
883
1116
  }
884
1117
  const buffer = new Uint8Array(await file.arrayBuffer());
885
1118
  const siteId = c.get("siteId");
@@ -909,17 +1142,32 @@ var CollectionController = class {
909
1142
  };
910
1143
  const createAccess = await this.evaluateAccess(c, "create", { data });
911
1144
  if (!createAccess.allowed) {
912
- return c.json({ error: true, message: `Access denied: create on ${this.collection.slug}` }, 403);
1145
+ return c.json(
1146
+ {
1147
+ error: true,
1148
+ message: `Access denied: create on ${this.collection.slug}`
1149
+ },
1150
+ 403
1151
+ );
913
1152
  }
914
- data = await applyFieldWriteAccess({
915
- config,
916
- fields: this.collection.fields,
917
- user,
918
- req: this.toHookRequestContext(c),
1153
+ data = await applyFieldWriteAccess(
1154
+ {
1155
+ config,
1156
+ fields: this.collection.fields,
1157
+ user,
1158
+ req: this.toHookRequestContext(c),
1159
+ data,
1160
+ operation: "create"
1161
+ },
1162
+ data
1163
+ );
1164
+ data = await executeFieldBeforeChange(
1165
+ this.collection.fields,
919
1166
  data,
920
- operation: "create"
921
- }, data);
922
- data = await executeFieldBeforeChange(this.collection.fields, data, null, user, readonlyDb);
1167
+ null,
1168
+ user,
1169
+ readonlyDb
1170
+ );
923
1171
  data = await runCollectionHooks(this.collection.hooks?.beforeChange, {
924
1172
  data,
925
1173
  req: c.req,
@@ -931,13 +1179,17 @@ var CollectionController = class {
931
1179
  collection: this.collection.slug,
932
1180
  data
933
1181
  });
934
- await runCollectionHooks(this.collection.hooks?.afterChange, {
935
- doc,
936
- user,
937
- req: c.req,
938
- operation: "create",
939
- db
940
- }, { isolated: true });
1182
+ await runCollectionHooks(
1183
+ this.collection.hooks?.afterChange,
1184
+ {
1185
+ doc,
1186
+ user,
1187
+ req: c.req,
1188
+ operation: "create",
1189
+ db
1190
+ },
1191
+ { isolated: true }
1192
+ );
941
1193
  const responseDoc = this.collection.workflow ? materializeWorkflowDocument(doc, this.collection.workflow, user) : doc;
942
1194
  const readDoc = await runCollectionHooks(this.collection.hooks?.afterRead, {
943
1195
  doc: responseDoc,
@@ -945,14 +1197,22 @@ var CollectionController = class {
945
1197
  user,
946
1198
  db: readonlyDb
947
1199
  });
948
- const finalDoc = await executeFieldAfterRead(this.collection.fields, readDoc, user, readonlyDb);
949
- const accessibleDoc = await applyFieldReadAccess({
950
- config,
951
- fields: this.collection.fields,
1200
+ const finalDoc = await executeFieldAfterRead(
1201
+ this.collection.fields,
1202
+ readDoc,
952
1203
  user,
953
- req: this.toHookRequestContext(c),
954
- doc: finalDoc
955
- }, finalDoc);
1204
+ readonlyDb
1205
+ );
1206
+ const accessibleDoc = await applyFieldReadAccess(
1207
+ {
1208
+ config,
1209
+ fields: this.collection.fields,
1210
+ user,
1211
+ req: this.toHookRequestContext(c),
1212
+ doc: finalDoc
1213
+ },
1214
+ finalDoc
1215
+ );
956
1216
  return c.json(accessibleDoc, 201);
957
1217
  }
958
1218
  async update(c) {
@@ -965,9 +1225,16 @@ var CollectionController = class {
965
1225
  const id2 = c.req.param("id");
966
1226
  if (!id2) return c.json({ message: "Missing ID" }, 400);
967
1227
  const body2 = await c.req.json();
968
- const localDoc = await db.findOne({ collection: this.collection.slug, id: id2 });
1228
+ const localDoc = await db.findOne({
1229
+ collection: this.collection.slug,
1230
+ id: id2
1231
+ });
969
1232
  const externalSubject = localDoc?.externalSubject || id2;
970
- const member = await provider.members.update({ externalSubject, data: body2, req: hookReq });
1233
+ const member = await provider.members.update({
1234
+ externalSubject,
1235
+ data: body2,
1236
+ req: hookReq
1237
+ });
971
1238
  return c.json({
972
1239
  ...member,
973
1240
  id: localDoc ? localDoc.id : member.id,
@@ -989,25 +1256,47 @@ var CollectionController = class {
989
1256
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
990
1257
  updatedBy: user?.sub ?? null
991
1258
  });
992
- const originalDoc = await db.findOne({ collection: this.collection.slug, id });
1259
+ const originalDoc = await db.findOne({
1260
+ collection: this.collection.slug,
1261
+ id
1262
+ });
993
1263
  if (!originalDoc) return c.json({ message: "Not Found" }, 404);
994
- const updateAccess = await this.evaluateAccess(c, "update", { id, doc: originalDoc, data });
1264
+ const updateAccess = await this.evaluateAccess(c, "update", {
1265
+ id,
1266
+ doc: originalDoc,
1267
+ data
1268
+ });
995
1269
  if (!updateAccess.allowed) {
996
- return c.json({ error: true, message: `Access denied: update on ${this.collection.slug}` }, 403);
1270
+ return c.json(
1271
+ {
1272
+ error: true,
1273
+ message: `Access denied: update on ${this.collection.slug}`
1274
+ },
1275
+ 403
1276
+ );
997
1277
  }
998
1278
  let before = null;
999
1279
  if (this.collection.audit) {
1000
1280
  before = originalDoc;
1001
1281
  }
1002
- data = await applyFieldWriteAccess({
1003
- config,
1004
- fields: this.collection.fields,
1005
- user,
1006
- req: this.toHookRequestContext(c),
1007
- doc: originalDoc,
1282
+ data = await applyFieldWriteAccess(
1283
+ {
1284
+ config,
1285
+ fields: this.collection.fields,
1286
+ user,
1287
+ req: this.toHookRequestContext(c),
1288
+ doc: originalDoc,
1289
+ data
1290
+ },
1008
1291
  data
1009
- }, data);
1010
- data = await executeFieldBeforeChange(this.collection.fields, data, originalDoc, user, readonlyDb);
1292
+ );
1293
+ data = await executeFieldBeforeChange(
1294
+ this.collection.fields,
1295
+ data,
1296
+ originalDoc,
1297
+ user,
1298
+ readonlyDb
1299
+ );
1011
1300
  data = await runCollectionHooks(this.collection.hooks?.beforeChange, {
1012
1301
  data,
1013
1302
  doc: originalDoc,
@@ -1016,7 +1305,14 @@ var CollectionController = class {
1016
1305
  operation: "update",
1017
1306
  db: readonlyDb
1018
1307
  });
1019
- const doc = this.collection.workflow ? (await saveWorkflowDraft({ config, collection: this.collection, id, originalDoc, data, user })).doc : await db.update({ collection: this.collection.slug, id, data });
1308
+ const doc = this.collection.workflow ? (await saveWorkflowDraft({
1309
+ config,
1310
+ collection: this.collection,
1311
+ id,
1312
+ originalDoc,
1313
+ data,
1314
+ user
1315
+ })).doc : await db.update({ collection: this.collection.slug, id, data });
1020
1316
  if (this.collection.audit && db) {
1021
1317
  AuditService.log(db, {
1022
1318
  operation: "update",
@@ -1025,36 +1321,52 @@ var CollectionController = class {
1025
1321
  user: user ? { id: user.sub, collection: user.collection, email: user.email } : void 0,
1026
1322
  before,
1027
1323
  after: doc
1028
- });
1324
+ }, config);
1029
1325
  }
1030
- await runCollectionHooks(this.collection.hooks?.afterChange, {
1031
- doc,
1032
- previousDoc: originalDoc,
1033
- user,
1034
- req: c.req,
1035
- operation: "update",
1036
- db
1037
- }, { isolated: true });
1326
+ await runCollectionHooks(
1327
+ this.collection.hooks?.afterChange,
1328
+ {
1329
+ doc,
1330
+ previousDoc: originalDoc,
1331
+ user,
1332
+ req: c.req,
1333
+ operation: "update",
1334
+ db
1335
+ },
1336
+ { isolated: true }
1337
+ );
1038
1338
  const readDoc = await runCollectionHooks(this.collection.hooks?.afterRead, {
1039
1339
  doc,
1040
1340
  req: c.req,
1041
1341
  user,
1042
1342
  db: readonlyDb
1043
1343
  });
1044
- const finalDoc = await executeFieldAfterRead(this.collection.fields, readDoc, user, readonlyDb);
1045
- const accessibleDoc = await applyFieldReadAccess({
1046
- config,
1047
- fields: this.collection.fields,
1344
+ const finalDoc = await executeFieldAfterRead(
1345
+ this.collection.fields,
1346
+ readDoc,
1048
1347
  user,
1049
- req: this.toHookRequestContext(c),
1050
- doc: finalDoc
1051
- }, finalDoc);
1348
+ readonlyDb
1349
+ );
1350
+ const accessibleDoc = await applyFieldReadAccess(
1351
+ {
1352
+ config,
1353
+ fields: this.collection.fields,
1354
+ user,
1355
+ req: this.toHookRequestContext(c),
1356
+ doc: finalDoc
1357
+ },
1358
+ finalDoc
1359
+ );
1052
1360
  return c.json(accessibleDoc);
1053
1361
  }
1054
1362
  async transition(c) {
1055
1363
  const config = c.get("config");
1056
1364
  if (!config.db) return c.json({ message: "Database not configured" }, 500);
1057
- if (!this.collection.workflow) return c.json({ message: "Workflows are not enabled for this collection" }, 404);
1365
+ if (!this.collection.workflow)
1366
+ return c.json(
1367
+ { message: "Workflows are not enabled for this collection" },
1368
+ 404
1369
+ );
1058
1370
  const id = c.req.param("id");
1059
1371
  const transitionName = c.req.param("transition");
1060
1372
  const body = await c.req.json().catch(() => ({}));
@@ -1069,19 +1381,38 @@ var CollectionController = class {
1069
1381
  user: c.get("user"),
1070
1382
  req: { query: c.req.query(), headers: c.req.header(), raw: c.req.raw }
1071
1383
  });
1072
- return c.json(materializeWorkflowDocument(doc, this.collection.workflow, c.get("user")));
1384
+ return c.json(
1385
+ materializeWorkflowDocument(
1386
+ doc,
1387
+ this.collection.workflow,
1388
+ c.get("user")
1389
+ )
1390
+ );
1073
1391
  } catch (error) {
1074
1392
  const status = typeof error.statusCode === "number" ? error.statusCode : 500;
1075
- return c.json({ error: true, message: error instanceof Error ? error.message : String(error) }, status);
1393
+ return c.json(
1394
+ {
1395
+ error: true,
1396
+ message: error instanceof Error ? error.message : String(error)
1397
+ },
1398
+ status
1399
+ );
1076
1400
  }
1077
1401
  }
1078
1402
  async workflowHistory(c) {
1079
1403
  const config = c.get("config");
1080
1404
  if (!config.db) return c.json({ message: "Database not configured" }, 500);
1081
- if (!this.collection.workflow) return c.json({ message: "Workflows are not enabled for this collection" }, 404);
1405
+ if (!this.collection.workflow)
1406
+ return c.json(
1407
+ { message: "Workflows are not enabled for this collection" },
1408
+ 404
1409
+ );
1082
1410
  const documentId = c.req.param("id");
1083
1411
  if (!documentId) return c.json({ message: "Missing ID" }, 400);
1084
- const document = await config.db.findOne({ collection: this.collection.slug, id: documentId });
1412
+ const document = await config.db.findOne({
1413
+ collection: this.collection.slug,
1414
+ id: documentId
1415
+ });
1085
1416
  if (!document) return c.json({ message: "Not Found" }, 404);
1086
1417
  const readAccess = this.collection.access?.read;
1087
1418
  if (readAccess !== void 0 && readAccess !== null) {
@@ -1096,11 +1427,21 @@ var CollectionController = class {
1096
1427
  });
1097
1428
  allowed = match.total > 0;
1098
1429
  }
1099
- if (!allowed) return c.json({ error: true, message: `Access denied: read on ${this.collection.slug}` }, 403);
1430
+ if (!allowed)
1431
+ return c.json(
1432
+ {
1433
+ error: true,
1434
+ message: `Access denied: read on ${this.collection.slug}`
1435
+ },
1436
+ 403
1437
+ );
1100
1438
  }
1101
1439
  const result = await config.db.find({
1102
1440
  collection: WORKFLOW_HISTORY_COLLECTION,
1103
- where: { collection: { equals: this.collection.slug }, documentId: { equals: documentId } },
1441
+ where: {
1442
+ collection: { equals: this.collection.slug },
1443
+ documentId: { equals: documentId }
1444
+ },
1104
1445
  sort: "-createdAt",
1105
1446
  limit: Math.min(Number(c.req.query("limit")) || 50, 100)
1106
1447
  });
@@ -1122,7 +1463,10 @@ var CollectionController = class {
1122
1463
  const db = config.db;
1123
1464
  if (!db) return c.json({ message: "Database not configured" }, 500);
1124
1465
  if (!this.collection.auth) {
1125
- return c.json({ message: "This collection does not support authentication" }, 400);
1466
+ return c.json(
1467
+ { message: "This collection does not support authentication" },
1468
+ 400
1469
+ );
1126
1470
  }
1127
1471
  const id = c.req.param("id");
1128
1472
  if (!id) return c.json({ message: "Missing ID" }, 400);
@@ -1142,29 +1486,44 @@ var CollectionController = class {
1142
1486
  const isAdmin = Array.isArray(user.roles) && user.roles.includes("admin");
1143
1487
  const isSelf = user.sub === id;
1144
1488
  if (!isAdmin && !isSelf) {
1145
- return c.json({ message: "You are not authorised to change this password" }, 403);
1489
+ return c.json(
1490
+ { message: "You are not authorised to change this password" },
1491
+ 403
1492
+ );
1146
1493
  }
1147
1494
  if (!isAdmin) {
1148
1495
  if (!oldPassword) {
1149
1496
  return c.json({ message: "Current password is required" }, 400);
1150
1497
  }
1151
- const existing = await db.findOne({ collection: this.collection.slug, id });
1498
+ const existing = await db.findOne({
1499
+ collection: this.collection.slug,
1500
+ id
1501
+ });
1152
1502
  if (!existing) return c.json({ message: "User not found" }, 404);
1153
- const valid = await verifyPassword(oldPassword, existing.password);
1503
+ const valid = await verifyPassword(
1504
+ oldPassword,
1505
+ existing.password
1506
+ );
1154
1507
  if (!valid) {
1155
1508
  return c.json({ message: "Invalid current password" }, 400);
1156
1509
  }
1157
1510
  }
1158
1511
  const hashed = await hashPassword(newPassword);
1159
- const doc = await db.update({
1512
+ await db.update({
1160
1513
  collection: this.collection.slug,
1161
1514
  id,
1162
1515
  data: {
1163
1516
  password: hashed,
1517
+ loginAttempts: 0,
1518
+ lockedUntil: null,
1164
1519
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1165
1520
  updatedBy: user.sub
1166
1521
  }
1167
1522
  });
1523
+ await revokeAllAuthSessions(config, {
1524
+ userId: id,
1525
+ collection: this.collection.slug
1526
+ });
1168
1527
  if (this.collection.audit) {
1169
1528
  AuditService.log(db, {
1170
1529
  operation: "update",
@@ -1173,7 +1532,7 @@ var CollectionController = class {
1173
1532
  user: { id: user.sub, collection: user.collection, email: user.email },
1174
1533
  before: null,
1175
1534
  after: { id }
1176
- });
1535
+ }, config);
1177
1536
  }
1178
1537
  return c.json({ success: true, message: "Password updated successfully" });
1179
1538
  }
@@ -1186,7 +1545,10 @@ var CollectionController = class {
1186
1545
  const hookReq = this.toHookRequestContext(c);
1187
1546
  const id2 = c.req.param("id");
1188
1547
  if (!id2) return c.json({ message: "Missing ID" }, 400);
1189
- const localDoc = await db.findOne({ collection: this.collection.slug, id: id2 });
1548
+ const localDoc = await db.findOne({
1549
+ collection: this.collection.slug,
1550
+ id: id2
1551
+ });
1190
1552
  const externalSubject = localDoc?.externalSubject || id2;
1191
1553
  await provider.members.delete({ externalSubject, req: hookReq });
1192
1554
  return c.json({ message: "Deleted" });
@@ -1199,7 +1561,13 @@ var CollectionController = class {
1199
1561
  if (!doc) return c.json({ message: "Not Found" }, 404);
1200
1562
  const deleteAccess = await this.evaluateAccess(c, "delete", { id, doc });
1201
1563
  if (!deleteAccess.allowed) {
1202
- return c.json({ error: true, message: `Access denied: delete on ${this.collection.slug}` }, 403);
1564
+ return c.json(
1565
+ {
1566
+ error: true,
1567
+ message: `Access denied: delete on ${this.collection.slug}`
1568
+ },
1569
+ 403
1570
+ );
1203
1571
  }
1204
1572
  let before = null;
1205
1573
  if (this.collection.audit) {
@@ -1221,15 +1589,19 @@ var CollectionController = class {
1221
1589
  user: user ? { id: user.sub, collection: user.collection, email: user.email } : void 0,
1222
1590
  before,
1223
1591
  after: null
1224
- });
1592
+ }, config);
1225
1593
  }
1226
- await runCollectionHooks(this.collection.hooks?.afterDelete, {
1227
- id,
1228
- doc,
1229
- user,
1230
- req: c.req,
1231
- db
1232
- }, { isolated: true });
1594
+ await runCollectionHooks(
1595
+ this.collection.hooks?.afterDelete,
1596
+ {
1597
+ id,
1598
+ doc,
1599
+ user,
1600
+ req: c.req,
1601
+ db
1602
+ },
1603
+ { isolated: true }
1604
+ );
1233
1605
  return c.json({ message: "Deleted" });
1234
1606
  }
1235
1607
  async deleteMany(c) {
@@ -1260,7 +1632,10 @@ var CollectionController = class {
1260
1632
  failed.push({ id, error: "Not Found" });
1261
1633
  continue;
1262
1634
  }
1263
- const deleteAccess = await this.evaluateAccess(c, "delete", { id, doc });
1635
+ const deleteAccess = await this.evaluateAccess(c, "delete", {
1636
+ id,
1637
+ doc
1638
+ });
1264
1639
  if (!deleteAccess.allowed) {
1265
1640
  failed.push({ id, error: "Access denied" });
1266
1641
  continue;
@@ -1286,15 +1661,19 @@ var CollectionController = class {
1286
1661
  user: user ? { id: user.sub, collection: user.collection, email: user.email } : void 0,
1287
1662
  before,
1288
1663
  after: null
1289
- });
1664
+ }, config);
1290
1665
  }
1291
- await runCollectionHooks(this.collection.hooks?.afterDelete, {
1292
- id,
1293
- doc,
1294
- user,
1295
- req: c.req,
1296
- db
1297
- }, { isolated: true });
1666
+ await runCollectionHooks(
1667
+ this.collection.hooks?.afterDelete,
1668
+ {
1669
+ id,
1670
+ doc,
1671
+ user,
1672
+ req: c.req,
1673
+ db
1674
+ },
1675
+ { isolated: true }
1676
+ );
1298
1677
  } catch (err) {
1299
1678
  failed.push({ id, error: err?.message ?? "Unknown error" });
1300
1679
  }
@@ -1314,17 +1693,26 @@ var CollectionController = class {
1314
1693
  if (!initialData || !Array.isArray(initialData)) {
1315
1694
  return c.json({ message: "Invalid initial data" }, 400);
1316
1695
  }
1317
- const result = await db.find({ collection: this.collection.slug, limit: 1 });
1696
+ const result = await db.find({
1697
+ collection: this.collection.slug,
1698
+ limit: 1
1699
+ });
1318
1700
  if (result.total > 0) {
1319
1701
  return c.json({ message: "Collection is not empty, skipping seed" });
1320
1702
  }
1321
- console.log(`[dyrected/core] Auto-seeding collection: ${this.collection.slug}`);
1703
+ getRequestLogger(c, "collection").info({
1704
+ msg: "Auto-seeding collection",
1705
+ collection: this.collection.slug
1706
+ });
1322
1707
  const createdDocs = [];
1323
1708
  for (const data of initialData) {
1324
1709
  const doc = await db.create({ collection: this.collection.slug, data });
1325
1710
  createdDocs.push(doc);
1326
1711
  }
1327
- return c.json({ message: "Seed successful", count: createdDocs.length }, 201);
1712
+ return c.json(
1713
+ { message: "Seed successful", count: createdDocs.length },
1714
+ 201
1715
+ );
1328
1716
  }
1329
1717
  };
1330
1718
 
@@ -1341,46 +1729,66 @@ var GlobalController = class {
1341
1729
  const readonlyDb = createReadonlyDb(db);
1342
1730
  const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 1;
1343
1731
  const user = c.get("user");
1344
- let query = void 0;
1345
- const beforeReadResult = await runCollectionHooks(this.global.hooks?.beforeRead, {
1732
+ await runCollectionHooks(this.global.hooks?.beforeRead, {
1346
1733
  req: c.req,
1347
- query,
1734
+ query: void 0,
1348
1735
  user,
1349
1736
  db: readonlyDb
1350
1737
  });
1351
- if (beforeReadResult !== void 0) {
1352
- query = beforeReadResult;
1353
- }
1354
1738
  let data = await db.getGlobal({ slug: this.global.slug });
1355
1739
  const isEmpty = !data || isFunctionallyEmpty(data);
1356
1740
  if (isEmpty && this.global.initialData) {
1357
- console.log(`[dyrected/core] Auto-seeding global "${this.global.slug}" from config.initialData`);
1358
- await db.updateGlobal({ slug: this.global.slug, data: this.global.initialData });
1741
+ getRequestLogger(c, "global").info({
1742
+ msg: "Auto-seeding global from config.initialData",
1743
+ global: this.global.slug
1744
+ });
1745
+ await db.updateGlobal({
1746
+ slug: this.global.slug,
1747
+ data: this.global.initialData
1748
+ });
1359
1749
  data = this.global.initialData;
1360
1750
  }
1361
- const canRead = await resolveBooleanAccess(config, this.global.access?.read, {
1362
- user,
1363
- req: toHookRequestContext(c.req),
1364
- doc: data
1365
- });
1751
+ const canRead = await resolveBooleanAccess(
1752
+ config,
1753
+ this.global.access?.read,
1754
+ {
1755
+ user,
1756
+ req: toHookRequestContext(c.req),
1757
+ doc: data
1758
+ }
1759
+ );
1366
1760
  if (!canRead) {
1367
- return c.json({ error: true, message: `Access denied: read on ${this.global.slug}` }, 403);
1761
+ return c.json(
1762
+ { error: true, message: `Access denied: read on ${this.global.slug}` },
1763
+ 403
1764
+ );
1368
1765
  }
1369
1766
  const dataWithDefaults = DefaultsService.apply(this.global.fields, data);
1370
- const docWithCollectionHooks = await runCollectionHooks(this.global.hooks?.afterRead, {
1371
- doc: dataWithDefaults,
1372
- req: c.req,
1373
- user,
1374
- db: readonlyDb
1375
- });
1376
- const docWithFieldHooks = await executeFieldAfterRead(this.global.fields, docWithCollectionHooks, user, readonlyDb);
1377
- const docWithFieldAccess = await applyFieldReadAccess({
1378
- config,
1379
- fields: this.global.fields,
1767
+ const docWithCollectionHooks = await runCollectionHooks(
1768
+ this.global.hooks?.afterRead,
1769
+ {
1770
+ doc: dataWithDefaults,
1771
+ req: c.req,
1772
+ user,
1773
+ db: readonlyDb
1774
+ }
1775
+ );
1776
+ const docWithFieldHooks = await executeFieldAfterRead(
1777
+ this.global.fields,
1778
+ docWithCollectionHooks,
1380
1779
  user,
1381
- req: toHookRequestContext(c.req),
1382
- doc: docWithFieldHooks
1383
- }, docWithFieldHooks);
1780
+ readonlyDb
1781
+ );
1782
+ const docWithFieldAccess = await applyFieldReadAccess(
1783
+ {
1784
+ config,
1785
+ fields: this.global.fields,
1786
+ user,
1787
+ req: toHookRequestContext(c.req),
1788
+ doc: docWithFieldHooks
1789
+ },
1790
+ docWithFieldHooks
1791
+ );
1384
1792
  if (depth > 0 && docWithFieldAccess) {
1385
1793
  const populationService = new PopulationService(db, config.collections);
1386
1794
  const populatedData = await populationService.populate({
@@ -1401,24 +1809,43 @@ var GlobalController = class {
1401
1809
  const body = await c.req.json();
1402
1810
  const user = c.get("user");
1403
1811
  const originalDoc = await db.getGlobal({ slug: this.global.slug }) || {};
1404
- const canUpdate = await resolveBooleanAccess(config, this.global.access?.update, {
1405
- user,
1406
- req: toHookRequestContext(c.req),
1407
- doc: originalDoc,
1408
- data: body
1409
- });
1812
+ const canUpdate = await resolveBooleanAccess(
1813
+ config,
1814
+ this.global.access?.update,
1815
+ {
1816
+ user,
1817
+ req: toHookRequestContext(c.req),
1818
+ doc: originalDoc,
1819
+ data: body
1820
+ }
1821
+ );
1410
1822
  if (!canUpdate) {
1411
- return c.json({ error: true, message: `Access denied: update on ${this.global.slug}` }, 403);
1823
+ return c.json(
1824
+ {
1825
+ error: true,
1826
+ message: `Access denied: update on ${this.global.slug}`
1827
+ },
1828
+ 403
1829
+ );
1412
1830
  }
1413
- let sanitizedBody = await applyFieldWriteAccess({
1414
- config,
1415
- fields: this.global.fields,
1831
+ const sanitizedBody = await applyFieldWriteAccess(
1832
+ {
1833
+ config,
1834
+ fields: this.global.fields,
1835
+ user,
1836
+ req: toHookRequestContext(c.req),
1837
+ doc: originalDoc,
1838
+ data: body
1839
+ },
1840
+ body
1841
+ );
1842
+ let data = await executeFieldBeforeChange(
1843
+ this.global.fields,
1844
+ sanitizedBody,
1845
+ originalDoc,
1416
1846
  user,
1417
- req: toHookRequestContext(c.req),
1418
- doc: originalDoc,
1419
- data: body
1420
- }, body);
1421
- let data = await executeFieldBeforeChange(this.global.fields, sanitizedBody, originalDoc, user, readonlyDb);
1847
+ readonlyDb
1848
+ );
1422
1849
  data = await runCollectionHooks(this.global.hooks?.beforeChange, {
1423
1850
  data,
1424
1851
  doc: originalDoc,
@@ -1428,28 +1855,40 @@ var GlobalController = class {
1428
1855
  db: readonlyDb
1429
1856
  });
1430
1857
  const updated = await db.updateGlobal({ slug: this.global.slug, data });
1431
- await runCollectionHooks(this.global.hooks?.afterChange, {
1432
- doc: updated,
1433
- previousDoc: originalDoc,
1434
- user,
1435
- req: c.req,
1436
- operation: "update",
1437
- db
1438
- }, { isolated: true });
1858
+ await runCollectionHooks(
1859
+ this.global.hooks?.afterChange,
1860
+ {
1861
+ doc: updated,
1862
+ previousDoc: originalDoc,
1863
+ user,
1864
+ req: c.req,
1865
+ operation: "update",
1866
+ db
1867
+ },
1868
+ { isolated: true }
1869
+ );
1439
1870
  const readDoc = await runCollectionHooks(this.global.hooks?.afterRead, {
1440
1871
  doc: updated,
1441
1872
  req: c.req,
1442
1873
  user,
1443
1874
  db: readonlyDb
1444
1875
  });
1445
- const finalDoc = await executeFieldAfterRead(this.global.fields, readDoc, user, readonlyDb);
1446
- const accessibleDoc = await applyFieldReadAccess({
1447
- config,
1448
- fields: this.global.fields,
1876
+ const finalDoc = await executeFieldAfterRead(
1877
+ this.global.fields,
1878
+ readDoc,
1449
1879
  user,
1450
- req: toHookRequestContext(c.req),
1451
- doc: finalDoc
1452
- }, finalDoc);
1880
+ readonlyDb
1881
+ );
1882
+ const accessibleDoc = await applyFieldReadAccess(
1883
+ {
1884
+ config,
1885
+ fields: this.global.fields,
1886
+ user,
1887
+ req: toHookRequestContext(c.req),
1888
+ doc: finalDoc
1889
+ },
1890
+ finalDoc
1891
+ );
1453
1892
  return c.json(accessibleDoc);
1454
1893
  }
1455
1894
  async seed(c) {
@@ -1465,7 +1904,10 @@ var GlobalController = class {
1465
1904
  if (existing && !isFunctionallyEmpty(existing)) {
1466
1905
  return c.json({ message: "Global is not empty, skipping seed" });
1467
1906
  }
1468
- console.log(`[dyrected/core] Auto-seeding global: ${this.global.slug}`);
1907
+ getRequestLogger(c, "global").info({
1908
+ msg: "Auto-seeding global",
1909
+ global: this.global.slug
1910
+ });
1469
1911
  await db.updateGlobal({ slug: this.global.slug, data: initialData });
1470
1912
  return c.json({ message: "Seed successful", data: initialData }, 201);
1471
1913
  }
@@ -1487,6 +1929,7 @@ function isFunctionallyEmpty(obj) {
1487
1929
  // src/controllers/media.controller.ts
1488
1930
  var MediaController = class {
1489
1931
  collection;
1932
+ loggerComponent = "media";
1490
1933
  constructor(collection = "media") {
1491
1934
  this.collection = collection;
1492
1935
  }
@@ -1505,15 +1948,25 @@ var MediaController = class {
1505
1948
  return c.json({ message: "No file uploaded" }, 400);
1506
1949
  }
1507
1950
  const siteId = c.get("siteId");
1508
- let colConfig = config.collections.find((col) => col.slug === this.collection);
1951
+ let colConfig = config.collections.find(
1952
+ (col) => col.slug === this.collection
1953
+ );
1509
1954
  if (!colConfig && config.onSchemaFetch && siteId) {
1510
- const dynamic = await config.onSchemaFetch(siteId);
1511
- colConfig = dynamic.collections?.find((col) => col.slug === this.collection);
1955
+ const requestConfig = mergeDynamicConfig(
1956
+ config,
1957
+ await config.onSchemaFetch(siteId)
1958
+ );
1959
+ colConfig = requestConfig.collections.find(
1960
+ (col) => col.slug === this.collection
1961
+ );
1512
1962
  }
1513
1963
  const uploadConfig = typeof colConfig?.upload === "object" ? colConfig.upload : void 0;
1514
1964
  const validationError = validateUpload(file, uploadConfig);
1515
1965
  if (validationError) {
1516
- return c.json({ message: validationError.message }, validationError.status);
1966
+ return c.json(
1967
+ { message: validationError.message },
1968
+ validationError.status
1969
+ );
1517
1970
  }
1518
1971
  const buffer = new Uint8Array(await file.arrayBuffer());
1519
1972
  const workspaceId = c.get("workspaceId");
@@ -1531,7 +1984,12 @@ var MediaController = class {
1531
1984
  imageMetadata = processed.metadata;
1532
1985
  imageSizes = processed.sizes;
1533
1986
  } catch (err) {
1534
- console.error("[MediaController] Image processing failed:", err);
1987
+ getRequestLogger(c, this.loggerComponent).error({
1988
+ err,
1989
+ msg: "Image processing failed",
1990
+ collection: this.collection,
1991
+ filename: file.name
1992
+ });
1535
1993
  }
1536
1994
  }
1537
1995
  const fileData = await storage.upload({
@@ -1564,7 +2022,13 @@ var MediaController = class {
1564
2022
  height: sizeData.height
1565
2023
  };
1566
2024
  } catch (err) {
1567
- console.error(`[MediaController] Failed to upload size ${sizeName}:`, err);
2025
+ getRequestLogger(c, this.loggerComponent).error({
2026
+ err,
2027
+ msg: "Failed to upload image size",
2028
+ collection: this.collection,
2029
+ filename: file.name,
2030
+ sizeName
2031
+ });
1568
2032
  }
1569
2033
  }
1570
2034
  }
@@ -1628,25 +2092,37 @@ var MediaController = class {
1628
2092
  }
1629
2093
  };
1630
2094
 
1631
- // src/auth/token.ts
1632
- import { SignJWT, jwtVerify, decodeJwt } from "jose";
1633
- import { TextEncoder } from "util";
1634
- function getSecret() {
1635
- const secret = process.env.DYRECTED_JWT_SECRET;
1636
- if (!secret) {
1637
- throw new Error(
1638
- "[dyrected/core] DYRECTED_JWT_SECRET is not set. Add it to your environment variables to enable auth collections."
1639
- );
2095
+ // src/auth/lockout.ts
2096
+ var DEFAULT_MAX_LOGIN_ATTEMPTS = 5;
2097
+ var DEFAULT_LOCK_TIME_MS = 10 * 60 * 1e3;
2098
+ function resolveAuthLockoutConfig(collection) {
2099
+ const auth = collection.auth;
2100
+ if (!auth) {
2101
+ return {
2102
+ enabled: false,
2103
+ maxLoginAttempts: DEFAULT_MAX_LOGIN_ATTEMPTS,
2104
+ lockTime: DEFAULT_LOCK_TIME_MS
2105
+ };
1640
2106
  }
1641
- return new TextEncoder().encode(secret);
1642
- }
1643
- var DEFAULT_EXPIRY = "7d";
1644
- async function signCollectionToken(payload, expiresIn = DEFAULT_EXPIRY) {
1645
- return new SignJWT({ ...payload }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(expiresIn).sign(getSecret());
2107
+ if (auth === true) {
2108
+ return {
2109
+ enabled: true,
2110
+ maxLoginAttempts: DEFAULT_MAX_LOGIN_ATTEMPTS,
2111
+ lockTime: DEFAULT_LOCK_TIME_MS
2112
+ };
2113
+ }
2114
+ const maxLoginAttempts = typeof auth.maxLoginAttempts === "number" ? auth.maxLoginAttempts : DEFAULT_MAX_LOGIN_ATTEMPTS;
2115
+ const lockTime = typeof auth.lockTime === "number" && auth.lockTime > 0 ? auth.lockTime : DEFAULT_LOCK_TIME_MS;
2116
+ return {
2117
+ enabled: maxLoginAttempts > 0,
2118
+ maxLoginAttempts,
2119
+ lockTime
2120
+ };
1646
2121
  }
1647
- async function verifyCollectionToken(token) {
1648
- const { payload } = await jwtVerify(token, getSecret());
1649
- return payload;
2122
+ function getLockedUntilMs(value) {
2123
+ if (typeof value !== "string") return null;
2124
+ const parsed = Date.parse(value);
2125
+ return Number.isFinite(parsed) ? parsed : null;
1650
2126
  }
1651
2127
 
1652
2128
  // src/services/email-template.ts
@@ -1746,6 +2222,7 @@ async function getDevSend() {
1746
2222
  if (_devSend) return _devSend;
1747
2223
  if (_devSendPromise) return _devSendPromise;
1748
2224
  _devSendPromise = (async () => {
2225
+ const logger = getConfigLogger(void 0, "email");
1749
2226
  try {
1750
2227
  const nodemailer = await import("nodemailer");
1751
2228
  const account = await nodemailer.default.createTestAccount();
@@ -1754,23 +2231,50 @@ async function getDevSend() {
1754
2231
  port: 587,
1755
2232
  auth: { user: account.user, pass: account.pass }
1756
2233
  });
1757
- console.log("[dyrected/core] No email config \u2014 using Ethereal for dev email preview.");
1758
- console.log(`[dyrected/core] Ethereal login: https://ethereal.email user: ${account.user} pass: ${account.pass}`);
2234
+ logger.info({
2235
+ msg: "No email config; using Ethereal for development email preview"
2236
+ });
2237
+ logger.info({
2238
+ msg: "Ethereal credentials",
2239
+ loginUrl: "https://ethereal.email",
2240
+ user: account.user,
2241
+ password: account.pass
2242
+ });
1759
2243
  _devSend = async ({ to, subject, html }) => {
1760
2244
  const info = await transport.sendMail({ from: '"Dyrected Dev" <dev@dyrected.local>', to, subject, html });
1761
- console.log(`[dyrected/core] Email preview URL: ${nodemailer.default.getTestMessageUrl(info)}`);
2245
+ logger.info({
2246
+ msg: "Email preview URL",
2247
+ previewUrl: nodemailer.default.getTestMessageUrl(info)
2248
+ });
1762
2249
  };
1763
2250
  return _devSend;
1764
2251
  } catch {
1765
- console.warn("[dyrected/core] nodemailer not available \u2014 emails will not be sent in dev.");
2252
+ logger.warn({
2253
+ msg: "nodemailer not available; development emails will not be sent"
2254
+ });
1766
2255
  return null;
1767
2256
  }
1768
2257
  })();
1769
2258
  return _devSendPromise;
1770
2259
  }
1771
2260
  async function sendEmail(config, payload) {
2261
+ const logger = getConfigLogger(config, "email");
2262
+ const observability = getObservabilityRuntime(config);
1772
2263
  if (config.email) {
1773
- await config.email.send(payload);
2264
+ try {
2265
+ await config.email.send(payload);
2266
+ } catch (err) {
2267
+ observability?.recordEmailSendFailure({
2268
+ to: payload.to
2269
+ });
2270
+ logger.error({
2271
+ err,
2272
+ msg: "Email provider send failed",
2273
+ to: payload.to,
2274
+ subject: payload.subject
2275
+ });
2276
+ throw err;
2277
+ }
1774
2278
  return;
1775
2279
  }
1776
2280
  if (process.env.NODE_ENV !== "production") {
@@ -1834,6 +2338,55 @@ var AuthController = class {
1834
2338
  constructor(collection) {
1835
2339
  this.collection = collection;
1836
2340
  }
2341
+ sanitizeUser(user) {
2342
+ const {
2343
+ password: _password,
2344
+ loginAttempts: _loginAttempts,
2345
+ lockedUntil: _lockedUntil,
2346
+ ...safeUser
2347
+ } = user;
2348
+ return safeUser;
2349
+ }
2350
+ clearLockoutState(c, userId) {
2351
+ const db = c.get("config").db;
2352
+ if (!db) return Promise.resolve(null);
2353
+ return db.update({
2354
+ collection: this.collection.slug,
2355
+ id: userId,
2356
+ data: {
2357
+ loginAttempts: 0,
2358
+ lockedUntil: null
2359
+ }
2360
+ });
2361
+ }
2362
+ async recordFailedLogin(c, user) {
2363
+ const db = c.get("config").db;
2364
+ if (!db)
2365
+ return { justLocked: false, retryAfterSeconds: null };
2366
+ const lockout = resolveAuthLockoutConfig(this.collection);
2367
+ if (!lockout.enabled) {
2368
+ return { justLocked: false, retryAfterSeconds: null };
2369
+ }
2370
+ const now = Date.now();
2371
+ const lockedUntilMs = getLockedUntilMs(user.lockedUntil);
2372
+ const lockExpired = lockedUntilMs !== null && lockedUntilMs <= now;
2373
+ const currentAttempts = !lockExpired && typeof user.loginAttempts === "number" && user.loginAttempts > 0 ? user.loginAttempts : 0;
2374
+ const loginAttempts = currentAttempts + 1;
2375
+ const justLocked = loginAttempts >= lockout.maxLoginAttempts;
2376
+ const nextLockedUntil = justLocked ? new Date(now + lockout.lockTime).toISOString() : null;
2377
+ await db.update({
2378
+ collection: this.collection.slug,
2379
+ id: String(user.id),
2380
+ data: {
2381
+ loginAttempts,
2382
+ lockedUntil: nextLockedUntil
2383
+ }
2384
+ });
2385
+ return {
2386
+ justLocked,
2387
+ retryAfterSeconds: justLocked ? Math.max(1, Math.ceil(lockout.lockTime / 1e3)) : null
2388
+ };
2389
+ }
1837
2390
  // ---------------------------------------------------------------------------
1838
2391
  // GET /init
1839
2392
  // Checks if the first user needs to be created.
@@ -1862,11 +2415,17 @@ var AuthController = class {
1862
2415
  limit: 1
1863
2416
  });
1864
2417
  if (check.total > 0) {
1865
- return c.json({ error: true, message: "Initial user already exists." }, 403);
2418
+ return c.json(
2419
+ { error: true, message: "Initial user already exists." },
2420
+ 403
2421
+ );
1866
2422
  }
1867
2423
  const body = await c.req.json().catch(() => null);
1868
2424
  if (!body?.email || !body?.password) {
1869
- return c.json({ error: true, message: "email and password are required." }, 400);
2425
+ return c.json(
2426
+ { error: true, message: "email and password are required." },
2427
+ 400
2428
+ );
1870
2429
  }
1871
2430
  const hashedPassword = await hashPassword(body.password);
1872
2431
  const user = await db.create({
@@ -1878,16 +2437,23 @@ var AuthController = class {
1878
2437
  // Default first user to admin
1879
2438
  }
1880
2439
  });
1881
- const token = await signCollectionToken({
1882
- sub: user.id,
2440
+ const token = await issueAuthSessionToken({
2441
+ config,
2442
+ userId: user.id,
1883
2443
  email: user.email,
1884
- collection: this.collection.slug
2444
+ collection: this.collection.slug,
2445
+ ip: c.get("clientIp"),
2446
+ authSource: "local"
1885
2447
  });
1886
2448
  const { subject, html } = buildWelcomeEmail(config, { email: body.email });
1887
2449
  sendEmail(config, { to: body.email, subject, html }).catch(
1888
- (err) => console.error("[dyrected/core] Failed to send welcome email:", err)
2450
+ (err) => getRequestLogger(c, "auth").error({
2451
+ err,
2452
+ msg: "Failed to send welcome email",
2453
+ email: body.email
2454
+ })
1889
2455
  );
1890
- const { password: _, ...safeUser } = user;
2456
+ const safeUser = this.sanitizeUser(user);
1891
2457
  return c.json({ token, user: safeUser });
1892
2458
  }
1893
2459
  // ---------------------------------------------------------------------------
@@ -1898,7 +2464,10 @@ var AuthController = class {
1898
2464
  if (!db) return c.json({ message: "Database not configured" }, 500);
1899
2465
  const body = await c.req.json().catch(() => null);
1900
2466
  if (!body?.email || !body?.password) {
1901
- return c.json({ error: true, message: "email and password are required." }, 400);
2467
+ return c.json(
2468
+ { error: true, message: "email and password are required." },
2469
+ 400
2470
+ );
1902
2471
  }
1903
2472
  const result = await db.find({
1904
2473
  collection: this.collection.slug,
@@ -1907,27 +2476,107 @@ var AuthController = class {
1907
2476
  });
1908
2477
  const user = result.docs[0];
1909
2478
  if (!user) {
1910
- return c.json({ error: true, message: "Invalid email or password." }, 401);
2479
+ return c.json(
2480
+ { error: true, message: "Invalid email or password." },
2481
+ 401
2482
+ );
2483
+ }
2484
+ const lockedUntilMs = getLockedUntilMs(user.lockedUntil);
2485
+ if (lockedUntilMs !== null && lockedUntilMs > Date.now()) {
2486
+ const retryAfterSeconds = Math.max(
2487
+ 1,
2488
+ Math.ceil((lockedUntilMs - Date.now()) / 1e3)
2489
+ );
2490
+ c.header("Retry-After", String(retryAfterSeconds));
2491
+ return c.json(
2492
+ {
2493
+ error: true,
2494
+ message: "Too many login attempts. Try again later.",
2495
+ retryAfterSeconds
2496
+ },
2497
+ 429
2498
+ );
1911
2499
  }
1912
2500
  const valid = await verifyPassword(body.password, user.password);
1913
2501
  if (!valid) {
1914
- return c.json({ error: true, message: "Invalid email or password." }, 401);
2502
+ const { justLocked, retryAfterSeconds } = await this.recordFailedLogin(
2503
+ c,
2504
+ user
2505
+ );
2506
+ if (justLocked && retryAfterSeconds) {
2507
+ c.header("Retry-After", String(retryAfterSeconds));
2508
+ return c.json(
2509
+ {
2510
+ error: true,
2511
+ message: "Too many login attempts. Try again later.",
2512
+ retryAfterSeconds
2513
+ },
2514
+ 429
2515
+ );
2516
+ }
2517
+ return c.json(
2518
+ { error: true, message: "Invalid email or password." },
2519
+ 401
2520
+ );
1915
2521
  }
1916
- const token = await signCollectionToken({
1917
- sub: user.id,
2522
+ if (typeof user.loginAttempts === "number" && user.loginAttempts > 0 || user.lockedUntil != null) {
2523
+ await this.clearLockoutState(c, String(user.id));
2524
+ }
2525
+ const token = await issueAuthSessionToken({
2526
+ config: c.get("config"),
2527
+ userId: user.id,
1918
2528
  email: user.email,
1919
- collection: this.collection.slug
2529
+ collection: this.collection.slug,
2530
+ ip: c.get("clientIp"),
2531
+ authSource: "local"
1920
2532
  });
1921
- const { password: _, ...safeUser } = user;
2533
+ const safeUser = this.sanitizeUser(user);
1922
2534
  return c.json({ token, user: safeUser });
1923
2535
  }
1924
2536
  // ---------------------------------------------------------------------------
1925
2537
  // POST /logout
1926
- // Auth collections use stateless JWTs logout is handled client-side.
1927
- // This endpoint exists so clients have a consistent API surface.
2538
+ // Revoke the current session by default. Pass `?allSessions=true` to revoke
2539
+ // every active session for the current account.
1928
2540
  // ---------------------------------------------------------------------------
1929
2541
  async logout(c) {
1930
- return c.json({ success: true, message: "Logged out. Discard your token." });
2542
+ const requestUser = c.get("user");
2543
+ const tokenPayload = c.get("authTokenPayload");
2544
+ const allSessions = ["1", "true", "yes"].includes(
2545
+ (c.req.query("allSessions") || "").toLowerCase()
2546
+ );
2547
+ if (!requestUser) {
2548
+ if (allSessions) {
2549
+ return c.json(
2550
+ { error: true, message: "Authentication required." },
2551
+ 401
2552
+ );
2553
+ }
2554
+ return c.json({
2555
+ success: true,
2556
+ message: "Logged out. Discard your token."
2557
+ });
2558
+ }
2559
+ if (allSessions) {
2560
+ await revokeAllAuthSessions(c.get("config"), {
2561
+ userId: requestUser.sub,
2562
+ collection: this.collection.slug
2563
+ });
2564
+ return c.json({
2565
+ success: true,
2566
+ message: "All sessions have been logged out."
2567
+ });
2568
+ }
2569
+ if (tokenPayload?.sid) {
2570
+ await revokeAuthSession(c.get("config"), tokenPayload.sid);
2571
+ return c.json({
2572
+ success: true,
2573
+ message: "Logged out."
2574
+ });
2575
+ }
2576
+ return c.json({
2577
+ success: true,
2578
+ message: "Logged out. Discard your token."
2579
+ });
1931
2580
  }
1932
2581
  // ---------------------------------------------------------------------------
1933
2582
  // GET /me
@@ -1939,11 +2588,14 @@ var AuthController = class {
1939
2588
  if (!requestUser) {
1940
2589
  return c.json({ error: true, message: "Authentication required." }, 401);
1941
2590
  }
1942
- const user = await db.findOne({ collection: this.collection.slug, id: requestUser.sub });
2591
+ const user = await db.findOne({
2592
+ collection: this.collection.slug,
2593
+ id: requestUser.sub
2594
+ });
1943
2595
  if (!user) {
1944
2596
  return c.json({ error: true, message: "User not found." }, 404);
1945
2597
  }
1946
- const { password: _, ...safeUser } = user;
2598
+ const safeUser = this.sanitizeUser(user);
1947
2599
  return c.json(safeUser);
1948
2600
  }
1949
2601
  // ---------------------------------------------------------------------------
@@ -1954,10 +2606,29 @@ var AuthController = class {
1954
2606
  if (!requestUser) {
1955
2607
  return c.json({ error: true, message: "Authentication required." }, 401);
1956
2608
  }
1957
- const token = await signCollectionToken({
1958
- sub: requestUser.sub,
2609
+ if (!requestUser.email) {
2610
+ return c.json(
2611
+ { error: true, message: "Authenticated user is missing an email." },
2612
+ 400
2613
+ );
2614
+ }
2615
+ const tokenPayload = c.get("authTokenPayload");
2616
+ if (tokenPayload?.sid) {
2617
+ const token2 = await signCollectionToken({
2618
+ sub: requestUser.sub,
2619
+ email: requestUser.email,
2620
+ collection: this.collection.slug,
2621
+ sid: tokenPayload.sid
2622
+ });
2623
+ return c.json({ token: token2 });
2624
+ }
2625
+ const token = await issueAuthSessionToken({
2626
+ config: c.get("config"),
2627
+ userId: requestUser.sub,
1959
2628
  email: requestUser.email,
1960
- collection: this.collection.slug
2629
+ collection: this.collection.slug,
2630
+ ip: c.get("clientIp"),
2631
+ authSource: "local"
1961
2632
  });
1962
2633
  return c.json({ token });
1963
2634
  }
@@ -1982,16 +2653,28 @@ var AuthController = class {
1982
2653
  const user = result.docs[0];
1983
2654
  if (user) {
1984
2655
  const resetToken = await signCollectionToken(
1985
- { sub: user.id, email: user.email, collection: this.collection.slug, purpose: "reset" },
2656
+ {
2657
+ sub: user.id,
2658
+ email: user.email,
2659
+ collection: this.collection.slug,
2660
+ purpose: "reset"
2661
+ },
1986
2662
  "1h"
1987
2663
  );
1988
2664
  const resetUrl = body?.resetUrl;
1989
2665
  const url = resetUrl ? `${resetUrl}${resetUrl.includes("?") ? "&" : "?"}token=${encodeURIComponent(resetToken)}` : void 0;
1990
2666
  try {
1991
- const { subject, html } = buildResetPasswordEmail(config, { token: resetToken, url });
2667
+ const { subject, html } = buildResetPasswordEmail(config, {
2668
+ token: resetToken,
2669
+ url
2670
+ });
1992
2671
  await sendEmail(config, { to: user.email, subject, html });
1993
2672
  } catch (err) {
1994
- console.error("[dyrected/core] Failed to send password reset email:", err);
2673
+ getRequestLogger(c, "auth").error({
2674
+ err,
2675
+ msg: "Failed to send password reset email",
2676
+ email: user.email
2677
+ });
1995
2678
  }
1996
2679
  }
1997
2680
  return c.json({
@@ -2010,28 +2693,54 @@ var AuthController = class {
2010
2693
  if (!db) return c.json({ message: "Database not configured" }, 500);
2011
2694
  const body = await c.req.json().catch(() => null);
2012
2695
  if (!body?.token || !body?.password) {
2013
- return c.json({ error: true, message: "token and password are required." }, 400);
2696
+ return c.json(
2697
+ { error: true, message: "token and password are required." },
2698
+ 400
2699
+ );
2014
2700
  }
2015
2701
  let payload;
2016
2702
  try {
2017
2703
  payload = await verifyCollectionToken(body.token);
2018
2704
  } catch {
2019
- return c.json({ error: true, message: "Reset token is invalid or has expired." }, 400);
2705
+ return c.json(
2706
+ { error: true, message: "Reset token is invalid or has expired." },
2707
+ 400
2708
+ );
2020
2709
  }
2021
2710
  if (payload.collection !== this.collection.slug || payload.purpose !== "reset") {
2022
- return c.json({ error: true, message: "Reset token is invalid or has expired." }, 400);
2711
+ return c.json(
2712
+ { error: true, message: "Reset token is invalid or has expired." },
2713
+ 400
2714
+ );
2023
2715
  }
2024
2716
  const hashedPassword = await hashPassword(body.password);
2025
2717
  await db.update({
2026
2718
  collection: this.collection.slug,
2027
2719
  id: payload.sub,
2028
- data: { password: hashedPassword }
2720
+ data: {
2721
+ password: hashedPassword,
2722
+ loginAttempts: 0,
2723
+ lockedUntil: null
2724
+ }
2725
+ });
2726
+ await revokeAllAuthSessions(config, {
2727
+ userId: payload.sub,
2728
+ collection: this.collection.slug
2729
+ });
2730
+ const { subject, html } = buildPasswordChangedEmail(config, {
2731
+ email: payload.email
2029
2732
  });
2030
- const { subject, html } = buildPasswordChangedEmail(config, { email: payload.email });
2031
2733
  sendEmail(config, { to: payload.email, subject, html }).catch(
2032
- (err) => console.error("[dyrected/core] Failed to send password-changed email:", err)
2734
+ (err) => getRequestLogger(c, "auth").error({
2735
+ err,
2736
+ msg: "Failed to send password-changed email",
2737
+ email: payload.email
2738
+ })
2033
2739
  );
2034
- return c.json({ success: true, message: "Password has been reset. You can now log in." });
2740
+ return c.json({
2741
+ success: true,
2742
+ message: "Password has been reset. You can now log in."
2743
+ });
2035
2744
  }
2036
2745
  // ---------------------------------------------------------------------------
2037
2746
  // POST /invite
@@ -2055,10 +2764,18 @@ var AuthController = class {
2055
2764
  limit: 1
2056
2765
  });
2057
2766
  if (existing.total > 0) {
2058
- return c.json({ error: true, message: "An account with that email already exists." }, 409);
2767
+ return c.json(
2768
+ { error: true, message: "An account with that email already exists." },
2769
+ 409
2770
+ );
2059
2771
  }
2060
2772
  const inviteToken = await signCollectionToken(
2061
- { sub: body.email, email: body.email, collection: this.collection.slug, purpose: "invite" },
2773
+ {
2774
+ sub: body.email,
2775
+ email: body.email,
2776
+ collection: this.collection.slug,
2777
+ purpose: "invite"
2778
+ },
2062
2779
  "7d"
2063
2780
  );
2064
2781
  try {
@@ -2068,7 +2785,11 @@ var AuthController = class {
2068
2785
  });
2069
2786
  await sendEmail(config, { to: body.email, subject, html });
2070
2787
  } catch (err) {
2071
- console.error("[dyrected/core] Failed to send invite email:", err);
2788
+ getRequestLogger(c, "auth").error({
2789
+ err,
2790
+ msg: "Failed to send invite email",
2791
+ email: body.email
2792
+ });
2072
2793
  }
2073
2794
  return c.json({ success: true, message: `Invite sent to ${body.email}.` });
2074
2795
  }
@@ -2083,16 +2804,25 @@ var AuthController = class {
2083
2804
  if (!db) return c.json({ message: "Database not configured" }, 500);
2084
2805
  const body = await c.req.json().catch(() => null);
2085
2806
  if (!body?.token || !body?.password) {
2086
- return c.json({ error: true, message: "token and password are required." }, 400);
2807
+ return c.json(
2808
+ { error: true, message: "token and password are required." },
2809
+ 400
2810
+ );
2087
2811
  }
2088
2812
  let payload;
2089
2813
  try {
2090
2814
  payload = await verifyCollectionToken(body.token);
2091
2815
  } catch {
2092
- return c.json({ error: true, message: "Invite token is invalid or has expired." }, 400);
2816
+ return c.json(
2817
+ { error: true, message: "Invite token is invalid or has expired." },
2818
+ 400
2819
+ );
2093
2820
  }
2094
2821
  if (payload.collection !== this.collection.slug || payload.purpose !== "invite") {
2095
- return c.json({ error: true, message: "Invite token is invalid or has expired." }, 400);
2822
+ return c.json(
2823
+ { error: true, message: "Invite token is invalid or has expired." },
2824
+ 400
2825
+ );
2096
2826
  }
2097
2827
  const inviteeEmail = payload.sub;
2098
2828
  const existing = await db.find({
@@ -2101,7 +2831,10 @@ var AuthController = class {
2101
2831
  limit: 1
2102
2832
  });
2103
2833
  if (existing.total > 0) {
2104
- return c.json({ error: true, message: "An account with that email already exists." }, 409);
2834
+ return c.json(
2835
+ { error: true, message: "An account with that email already exists." },
2836
+ 409
2837
+ );
2105
2838
  }
2106
2839
  const { token: _t, password: _p, ...extraFields } = body;
2107
2840
  const hashedPassword = await hashPassword(body.password);
@@ -2109,28 +2842,37 @@ var AuthController = class {
2109
2842
  collection: this.collection.slug,
2110
2843
  data: { ...extraFields, email: inviteeEmail, password: hashedPassword }
2111
2844
  });
2112
- const sessionToken = await signCollectionToken({
2113
- sub: user.id,
2845
+ const sessionToken = await issueAuthSessionToken({
2846
+ config,
2847
+ userId: user.id,
2114
2848
  email: inviteeEmail,
2115
- collection: this.collection.slug
2849
+ collection: this.collection.slug,
2850
+ ip: c.get("clientIp"),
2851
+ authSource: "local"
2852
+ });
2853
+ const { subject, html } = buildWelcomeEmail(config, {
2854
+ email: inviteeEmail
2116
2855
  });
2117
- const { subject, html } = buildWelcomeEmail(config, { email: inviteeEmail });
2118
2856
  sendEmail(config, { to: inviteeEmail, subject, html }).catch(
2119
- (err) => console.error("[dyrected/core] Failed to send welcome email:", err)
2857
+ (err) => getRequestLogger(c, "auth").error({
2858
+ err,
2859
+ msg: "Failed to send welcome email",
2860
+ email: inviteeEmail
2861
+ })
2120
2862
  );
2121
- const { password: _, ...safeUser } = user;
2863
+ const safeUser = this.sanitizeUser(user);
2122
2864
  return c.json({ token: sessionToken, user: safeUser }, 201);
2123
2865
  }
2124
2866
  };
2125
2867
 
2126
2868
  // src/controllers/admin-auth.controller.ts
2127
2869
  import { randomBytes as randomBytes2 } from "crypto";
2128
- import { TextEncoder as TextEncoder2 } from "util";
2870
+ import { TextEncoder } from "util";
2129
2871
  import {
2130
- SignJWT as SignJWT2,
2872
+ SignJWT,
2131
2873
  createRemoteJWKSet,
2132
- jwtVerify as jwtVerify2,
2133
- decodeJwt as decodeJwt2
2874
+ jwtVerify,
2875
+ decodeJwt
2134
2876
  } from "jose";
2135
2877
  var AdminAuthController = class {
2136
2878
  constructor(config) {
@@ -2139,13 +2881,21 @@ var AdminAuthController = class {
2139
2881
  config;
2140
2882
  async providers(c) {
2141
2883
  const requestConfig = await this.getRequestConfig(c);
2142
- return c.json(getPublicAdminAuthConfig(requestConfig.adminAuth, requestConfig.collections));
2884
+ return c.json(
2885
+ getPublicAdminAuthConfig(
2886
+ requestConfig.adminAuth,
2887
+ requestConfig.collections
2888
+ )
2889
+ );
2143
2890
  }
2144
2891
  async start(c) {
2145
2892
  const requestConfig = await this.getRequestConfig(c);
2146
2893
  const provider = this.getProvider(requestConfig, c.req.param("provider"));
2147
2894
  if (!provider) {
2148
- return c.json({ error: true, message: "Admin auth provider not found." }, 404);
2895
+ return c.json(
2896
+ { error: true, message: "Admin auth provider not found." },
2897
+ 404
2898
+ );
2149
2899
  }
2150
2900
  const siteId = this.getSiteId(c);
2151
2901
  const returnTo = this.normalizeReturnTo(c.req.query("returnTo"), c);
@@ -2159,9 +2909,15 @@ var AdminAuthController = class {
2159
2909
  return c.redirect(redirectUrl, 302);
2160
2910
  }
2161
2911
  if (!provider.startUrl) {
2162
- return c.json({ error: true, message: "This provider does not expose a start URL." }, 400);
2912
+ return c.json(
2913
+ { error: true, message: "This provider does not expose a start URL." },
2914
+ 400
2915
+ );
2163
2916
  }
2164
- const url = this.buildCustomProviderStartUrl(provider.startUrl, new URL(c.req.url).origin);
2917
+ const url = this.buildCustomProviderStartUrl(
2918
+ provider.startUrl,
2919
+ new URL(c.req.url).origin
2920
+ );
2165
2921
  if (!url) {
2166
2922
  return c.json(
2167
2923
  {
@@ -2180,13 +2936,23 @@ var AdminAuthController = class {
2180
2936
  const requestConfig = await this.getRequestConfig(c);
2181
2937
  const provider = this.getProvider(requestConfig, c.req.param("provider"));
2182
2938
  if (!provider) {
2183
- return c.json({ error: true, message: "Admin auth provider not found." }, 404);
2939
+ return c.json(
2940
+ { error: true, message: "Admin auth provider not found." },
2941
+ 404
2942
+ );
2184
2943
  }
2185
2944
  try {
2186
- const exchange = await this.completeProviderAuth(requestConfig, provider, c);
2945
+ const exchange = await this.completeProviderAuth(
2946
+ requestConfig,
2947
+ provider,
2948
+ c
2949
+ );
2187
2950
  const redirectUrl = new URL(exchange.returnTo);
2188
2951
  redirectUrl.searchParams.set("dyrectedExternalToken", exchange.token);
2189
- redirectUrl.searchParams.set("dyrectedAdminCollection", exchange.collectionSlug);
2952
+ redirectUrl.searchParams.set(
2953
+ "dyrectedAdminCollection",
2954
+ exchange.collectionSlug
2955
+ );
2190
2956
  return c.redirect(redirectUrl.toString(), 302);
2191
2957
  } catch (error) {
2192
2958
  const message = error instanceof Error ? error.message : "External authentication failed.";
@@ -2200,10 +2966,17 @@ var AdminAuthController = class {
2200
2966
  const requestConfig = await this.getRequestConfig(c);
2201
2967
  const provider = this.getProvider(requestConfig, c.req.param("provider"));
2202
2968
  if (!provider) {
2203
- return c.json({ error: true, message: "Admin auth provider not found." }, 404);
2969
+ return c.json(
2970
+ { error: true, message: "Admin auth provider not found." },
2971
+ 404
2972
+ );
2204
2973
  }
2205
2974
  try {
2206
- const exchange = await this.completeProviderAuth(requestConfig, provider, c);
2975
+ const exchange = await this.completeProviderAuth(
2976
+ requestConfig,
2977
+ provider,
2978
+ c
2979
+ );
2207
2980
  return c.json({
2208
2981
  token: exchange.token,
2209
2982
  collectionSlug: exchange.collectionSlug,
@@ -2214,9 +2987,46 @@ var AdminAuthController = class {
2214
2987
  const status = message === "Access denied for this site." ? 403 : 401;
2215
2988
  return c.json({ error: true, message }, status);
2216
2989
  }
2217
- }
2218
- async logout(c) {
2219
- return c.json({ success: true, message: "Logged out. Discard your token." });
2990
+ }
2991
+ async logout(c) {
2992
+ const requestUser = c.get("user");
2993
+ const tokenPayload = c.get("authTokenPayload");
2994
+ const allSessions = ["1", "true", "yes"].includes(
2995
+ (c.req.query("allSessions") || "").toLowerCase()
2996
+ );
2997
+ if (!requestUser) {
2998
+ if (allSessions) {
2999
+ return c.json(
3000
+ { error: true, message: "Authentication required." },
3001
+ 401
3002
+ );
3003
+ }
3004
+ return c.json({
3005
+ success: true,
3006
+ message: "Logged out. Discard your token."
3007
+ });
3008
+ }
3009
+ if (allSessions) {
3010
+ await revokeAllAuthSessions(c.get("config"), {
3011
+ userId: requestUser.sub,
3012
+ collection: requestUser.collection
3013
+ });
3014
+ return c.json({
3015
+ success: true,
3016
+ message: "All sessions have been logged out."
3017
+ });
3018
+ }
3019
+ if (tokenPayload?.sid) {
3020
+ await revokeAuthSession(c.get("config"), tokenPayload.sid);
3021
+ return c.json({
3022
+ success: true,
3023
+ message: "Logged out."
3024
+ });
3025
+ }
3026
+ return c.json({
3027
+ success: true,
3028
+ message: "Logged out. Discard your token."
3029
+ });
2220
3030
  }
2221
3031
  getProvider(config, id) {
2222
3032
  if (config.adminAuth?.mode !== "external") return void 0;
@@ -2232,7 +3042,12 @@ var AdminAuthController = class {
2232
3042
  }
2233
3043
  const db = c.get("config").db;
2234
3044
  if (!db) throw new Error("Database not configured.");
2235
- let user = await this.findUserByExternalIdentity(db, adminCollection.slug, provider.id, identity.sub) ?? (identity.email ? (await db.find({
3045
+ let user = await this.findUserByExternalIdentity(
3046
+ db,
3047
+ adminCollection.slug,
3048
+ provider.id,
3049
+ identity.sub
3050
+ ) ?? (identity.email ? (await db.find({
2236
3051
  collection: adminCollection.slug,
2237
3052
  where: { email: identity.email },
2238
3053
  limit: 1
@@ -2240,9 +3055,18 @@ var AdminAuthController = class {
2240
3055
  const provisioningMode = requestConfig.adminAuth?.provisioningMode ?? "jit_plus_membership_management";
2241
3056
  const allowJitProvisioning = provisioningMode !== "preprovisioned_only" && provider.allowJitProvisioning !== false;
2242
3057
  if (!user && !allowJitProvisioning) {
2243
- throw new Error("This account has not been provisioned for admin access.");
3058
+ throw new Error(
3059
+ "This account has not been provisioned for admin access."
3060
+ );
2244
3061
  }
2245
- const access = await this.resolveAccess(requestConfig, identity, provider.id, siteId, c, user);
3062
+ const access = await this.resolveAccess(
3063
+ requestConfig,
3064
+ identity,
3065
+ provider.id,
3066
+ siteId,
3067
+ c,
3068
+ user
3069
+ );
2246
3070
  if (!access.allowed) {
2247
3071
  throw new Error("Access denied for this site.");
2248
3072
  }
@@ -2250,7 +3074,12 @@ var AdminAuthController = class {
2250
3074
  user = await db.create({
2251
3075
  collection: adminCollection.slug,
2252
3076
  data: {
2253
- ...await this.buildUserData(identity, provider.id, access.roles, access.data),
3077
+ ...await this.buildUserData(
3078
+ identity,
3079
+ provider.id,
3080
+ access.roles,
3081
+ access.data
3082
+ ),
2254
3083
  password: await hashPassword(randomBytes2(32).toString("hex"))
2255
3084
  }
2256
3085
  });
@@ -2258,18 +3087,25 @@ var AdminAuthController = class {
2258
3087
  user = await db.update({
2259
3088
  collection: adminCollection.slug,
2260
3089
  id: user.id,
2261
- data: await this.buildUserData(identity, provider.id, access.roles, access.data)
3090
+ data: await this.buildUserData(
3091
+ identity,
3092
+ provider.id,
3093
+ access.roles,
3094
+ access.data
3095
+ )
2262
3096
  });
2263
3097
  }
2264
3098
  if (!user?.email) {
2265
3099
  throw new Error("Authenticated admin user is missing an email address.");
2266
3100
  }
2267
- const token = await signCollectionToken({
2268
- sub: user.id,
3101
+ const token = await issueAuthSessionToken({
3102
+ config: c.get("config"),
3103
+ userId: user.id,
2269
3104
  email: user.email,
2270
3105
  collection: adminCollection.slug,
2271
3106
  providerId: provider.id,
2272
- authSource: "external"
3107
+ authSource: "external",
3108
+ ip: c.get("clientIp")
2273
3109
  });
2274
3110
  return {
2275
3111
  token,
@@ -2323,14 +3159,10 @@ var AdminAuthController = class {
2323
3159
  async getRequestConfig(c) {
2324
3160
  const siteId = this.getSiteId(c);
2325
3161
  if (!siteId || !this.config.onSchemaFetch) return this.config;
2326
- const dynamic = await this.config.onSchemaFetch(siteId);
2327
- return {
2328
- ...this.config,
2329
- ...dynamic.admin ? { admin: { ...this.config.admin, ...dynamic.admin } } : {},
2330
- ...dynamic.adminAuth ? { adminAuth: { ...this.config.adminAuth, ...dynamic.adminAuth } } : {},
2331
- collections: dynamic.collections ? [...this.config.collections, ...dynamic.collections] : this.config.collections,
2332
- globals: dynamic.globals ? [...this.config.globals, ...dynamic.globals] : this.config.globals
2333
- };
3162
+ return mergeDynamicConfig(
3163
+ this.config,
3164
+ await this.config.onSchemaFetch(siteId)
3165
+ );
2334
3166
  }
2335
3167
  async buildOidcStartUrl(provider, returnTo, siteId, origin) {
2336
3168
  const discovery = await this.getOidcDiscovery(provider);
@@ -2341,11 +3173,19 @@ var AdminAuthController = class {
2341
3173
  siteId,
2342
3174
  nonce
2343
3175
  });
2344
- const url = new URL(provider.authorizationEndpoint || discovery.authorization_endpoint);
3176
+ const url = new URL(
3177
+ provider.authorizationEndpoint || discovery.authorization_endpoint
3178
+ );
2345
3179
  url.searchParams.set("response_type", "code");
2346
3180
  url.searchParams.set("client_id", provider.clientId);
2347
- url.searchParams.set("redirect_uri", provider.redirectUri || this.defaultCallbackPath(provider.id, origin));
2348
- url.searchParams.set("scope", (provider.scopes ?? ["openid", "profile", "email"]).join(" "));
3181
+ url.searchParams.set(
3182
+ "redirect_uri",
3183
+ provider.redirectUri || this.defaultCallbackPath(provider.id, origin)
3184
+ );
3185
+ url.searchParams.set(
3186
+ "scope",
3187
+ (provider.scopes ?? ["openid", "profile", "email"]).join(" ")
3188
+ );
2349
3189
  url.searchParams.set("state", state);
2350
3190
  url.searchParams.set("nonce", nonce);
2351
3191
  return url.toString();
@@ -2357,26 +3197,29 @@ var AdminAuthController = class {
2357
3197
  const parsedState = await this.verifyState(state, provider.id);
2358
3198
  const discovery = await this.getOidcDiscovery(provider);
2359
3199
  const redirectUri = provider.redirectUri || this.defaultCallbackPath(provider.id, new URL(c.req.url).origin);
2360
- const tokenResponse = await fetch(provider.tokenEndpoint || discovery.token_endpoint, {
2361
- method: "POST",
2362
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
2363
- body: new URLSearchParams({
2364
- grant_type: "authorization_code",
2365
- code,
2366
- redirect_uri: redirectUri,
2367
- client_id: provider.clientId,
2368
- client_secret: provider.clientSecret
2369
- })
2370
- });
3200
+ const tokenResponse = await fetch(
3201
+ provider.tokenEndpoint || discovery.token_endpoint,
3202
+ {
3203
+ method: "POST",
3204
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
3205
+ body: new URLSearchParams({
3206
+ grant_type: "authorization_code",
3207
+ code,
3208
+ redirect_uri: redirectUri,
3209
+ client_id: provider.clientId,
3210
+ client_secret: provider.clientSecret
3211
+ })
3212
+ }
3213
+ );
2371
3214
  if (!tokenResponse.ok) {
2372
3215
  throw new Error("OIDC token exchange failed.");
2373
3216
  }
2374
3217
  const tokenBody = await tokenResponse.json();
2375
3218
  const idToken = tokenBody.id_token;
2376
- let claims = {};
3219
+ let claims;
2377
3220
  if (idToken) {
2378
3221
  const jwks = createRemoteJWKSet(new URL(discovery.jwks_uri));
2379
- const { payload } = await jwtVerify2(idToken, jwks, {
3222
+ const { payload } = await jwtVerify(idToken, jwks, {
2380
3223
  issuer: provider.issuer,
2381
3224
  audience: provider.clientId
2382
3225
  });
@@ -2385,9 +3228,12 @@ var AdminAuthController = class {
2385
3228
  }
2386
3229
  claims = payload;
2387
3230
  } else if (tokenBody.access_token && (provider.userInfoEndpoint || discovery.userinfo_endpoint)) {
2388
- const userInfo = await fetch(provider.userInfoEndpoint || discovery.userinfo_endpoint, {
2389
- headers: { Authorization: `Bearer ${tokenBody.access_token}` }
2390
- });
3231
+ const userInfo = await fetch(
3232
+ provider.userInfoEndpoint || discovery.userinfo_endpoint,
3233
+ {
3234
+ headers: { Authorization: `Bearer ${tokenBody.access_token}` }
3235
+ }
3236
+ );
2391
3237
  if (!userInfo.ok) throw new Error("OIDC userinfo request failed.");
2392
3238
  claims = await userInfo.json();
2393
3239
  } else {
@@ -2407,21 +3253,21 @@ var AdminAuthController = class {
2407
3253
  }
2408
3254
  let claims;
2409
3255
  if (provider.secret) {
2410
- const secret = new TextEncoder2().encode(provider.secret);
2411
- const verified = await jwtVerify2(token, secret, {
3256
+ const secret = new TextEncoder().encode(provider.secret);
3257
+ const verified = await jwtVerify(token, secret, {
2412
3258
  issuer: provider.issuer,
2413
3259
  audience: provider.audience
2414
3260
  });
2415
3261
  claims = verified.payload;
2416
3262
  } else if (provider.jwksUri) {
2417
3263
  const jwks = createRemoteJWKSet(new URL(provider.jwksUri));
2418
- const verified = await jwtVerify2(token, jwks, {
3264
+ const verified = await jwtVerify(token, jwks, {
2419
3265
  issuer: provider.issuer,
2420
3266
  audience: provider.audience
2421
3267
  });
2422
3268
  claims = verified.payload;
2423
3269
  } else {
2424
- claims = decodeJwt2(token);
3270
+ claims = decodeJwt(token);
2425
3271
  if (provider.issuer && claims.iss !== provider.issuer) {
2426
3272
  throw new Error("External auth issuer mismatch.");
2427
3273
  }
@@ -2432,7 +3278,10 @@ var AdminAuthController = class {
2432
3278
  }
2433
3279
  }
2434
3280
  return {
2435
- identity: this.mapClaims(claims, provider.claimMapping),
3281
+ identity: this.mapClaims(
3282
+ claims,
3283
+ provider.claimMapping
3284
+ ),
2436
3285
  returnTo: this.normalizeReturnTo(
2437
3286
  body?.returnTo || c.req.query("returnTo"),
2438
3287
  c
@@ -2456,9 +3305,15 @@ var AdminAuthController = class {
2456
3305
  email: this.readStringClaim(claims[emailKey]),
2457
3306
  name: this.readStringClaim(claims[nameKey]),
2458
3307
  roles: this.readStringArrayClaim(rolesKey ? claims[rolesKey] : void 0),
2459
- groups: this.readStringArrayClaim(groupsKey ? claims[groupsKey] : void 0),
2460
- siteIds: this.readStringArrayClaim(siteIdsKey ? claims[siteIdsKey] : void 0),
2461
- workspaceIds: this.readStringArrayClaim(workspaceIdsKey ? claims[workspaceIdsKey] : void 0),
3308
+ groups: this.readStringArrayClaim(
3309
+ groupsKey ? claims[groupsKey] : void 0
3310
+ ),
3311
+ siteIds: this.readStringArrayClaim(
3312
+ siteIdsKey ? claims[siteIdsKey] : void 0
3313
+ ),
3314
+ workspaceIds: this.readStringArrayClaim(
3315
+ workspaceIdsKey ? claims[workspaceIdsKey] : void 0
3316
+ ),
2462
3317
  rawClaims: claims
2463
3318
  };
2464
3319
  }
@@ -2468,7 +3323,9 @@ var AdminAuthController = class {
2468
3323
  readStringArrayClaim(value) {
2469
3324
  if (!value) return void 0;
2470
3325
  if (Array.isArray(value)) {
2471
- return value.filter((entry) => typeof entry === "string");
3326
+ return value.filter(
3327
+ (entry) => typeof entry === "string"
3328
+ );
2472
3329
  }
2473
3330
  if (typeof value === "string") return [value];
2474
3331
  return void 0;
@@ -2478,7 +3335,8 @@ var AdminAuthController = class {
2478
3335
  provider.issuer.replace(/\/$/, "") + "/.well-known/openid-configuration"
2479
3336
  );
2480
3337
  const response = await fetch(discoveryUrl.toString());
2481
- if (!response.ok) throw new Error("Failed to load OIDC discovery document.");
3338
+ if (!response.ok)
3339
+ throw new Error("Failed to load OIDC discovery document.");
2482
3340
  return response.json();
2483
3341
  }
2484
3342
  defaultCallbackPath(providerId, origin = "") {
@@ -2496,10 +3354,10 @@ var AdminAuthController = class {
2496
3354
  }
2497
3355
  }
2498
3356
  async signState(payload) {
2499
- return new SignJWT2(payload).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime("15m").sign(this.getStateSecret());
3357
+ return new SignJWT(payload).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime("15m").sign(this.getStateSecret());
2500
3358
  }
2501
3359
  async verifyState(token, providerId) {
2502
- const { payload } = await jwtVerify2(token, this.getStateSecret());
3360
+ const { payload } = await jwtVerify(token, this.getStateSecret());
2503
3361
  const state = payload;
2504
3362
  if (state.providerId !== providerId) {
2505
3363
  throw new Error("OIDC state verification failed.");
@@ -2511,7 +3369,7 @@ var AdminAuthController = class {
2511
3369
  if (!secret) {
2512
3370
  throw new Error("[dyrected/core] DYRECTED_JWT_SECRET is not set.");
2513
3371
  }
2514
- return new TextEncoder2().encode(secret);
3372
+ return new TextEncoder().encode(secret);
2515
3373
  }
2516
3374
  normalizeReturnTo(returnTo, c) {
2517
3375
  if (returnTo) return returnTo;
@@ -2521,12 +3379,12 @@ var AdminAuthController = class {
2521
3379
  };
2522
3380
 
2523
3381
  // src/controllers/preview.controller.ts
2524
- import { SignJWT as SignJWT3, jwtVerify as jwtVerify3 } from "jose";
2525
- import { TextEncoder as TextEncoder3 } from "util";
3382
+ import { SignJWT as SignJWT2, jwtVerify as jwtVerify2 } from "jose";
3383
+ import { TextEncoder as TextEncoder2 } from "util";
2526
3384
  var PreviewController = class {
2527
3385
  getSecret() {
2528
3386
  const secret = process.env.DYRECTED_JWT_SECRET || "dyrected-preview-secret-change-me";
2529
- return new TextEncoder3().encode(secret);
3387
+ return new TextEncoder2().encode(secret);
2530
3388
  }
2531
3389
  /**
2532
3390
  * POST /api/preview-token
@@ -2535,9 +3393,12 @@ var PreviewController = class {
2535
3393
  async createToken(c) {
2536
3394
  const body = await c.req.json().catch(() => null);
2537
3395
  if (!body?.collectionSlug || !body?.data) {
2538
- return c.json({ error: true, message: "collectionSlug and data are required." }, 400);
3396
+ return c.json(
3397
+ { error: true, message: "collectionSlug and data are required." },
3398
+ 400
3399
+ );
2539
3400
  }
2540
- const token = await new SignJWT3({
3401
+ const token = await new SignJWT2({
2541
3402
  collectionSlug: body.collectionSlug,
2542
3403
  documentId: body.documentId,
2543
3404
  data: body.data
@@ -2552,13 +3413,19 @@ var PreviewController = class {
2552
3413
  async getData(c) {
2553
3414
  const token = c.req.query("token");
2554
3415
  if (!token) {
2555
- return c.json({ error: true, message: "token query parameter is required." }, 400);
3416
+ return c.json(
3417
+ { error: true, message: "token query parameter is required." },
3418
+ 400
3419
+ );
2556
3420
  }
2557
3421
  try {
2558
- const { payload } = await jwtVerify3(token, this.getSecret());
3422
+ const { payload } = await jwtVerify2(token, this.getSecret());
2559
3423
  return c.json(payload);
2560
- } catch (err) {
2561
- return c.json({ error: true, message: "Invalid or expired preview token." }, 401);
3424
+ } catch (_err) {
3425
+ return c.json(
3426
+ { error: true, message: "Invalid or expired preview token." },
3427
+ 401
3428
+ );
2562
3429
  }
2563
3430
  }
2564
3431
  };
@@ -2634,9 +3501,15 @@ var AuditController = class {
2634
3501
  if (!access.allowed) {
2635
3502
  return { denied: true, where: {} };
2636
3503
  }
2637
- let where = { collection: { equals: collection.slug } };
3504
+ let where = {
3505
+ collection: { equals: collection.slug }
3506
+ };
2638
3507
  if (access.constraint) {
2639
- const ids = await this.collectAccessibleDocumentIds(config.db, collection, access.constraint);
3508
+ const ids = await this.collectAccessibleDocumentIds(
3509
+ config.db,
3510
+ collection,
3511
+ access.constraint
3512
+ );
2640
3513
  where = mergeWhereConstraint(where, { documentId: { in: ids } });
2641
3514
  }
2642
3515
  return { denied: false, where };
@@ -2649,8 +3522,11 @@ var AuditController = class {
2649
3522
  }
2650
3523
  const siteId = c.req.header("X-Site-Id") || c.get("siteId");
2651
3524
  if (config.onSchemaFetch && siteId) {
2652
- const dynamic = await config.onSchemaFetch(siteId);
2653
- for (const collection of dynamic.collections || []) {
3525
+ const requestConfig = mergeDynamicConfig(
3526
+ config,
3527
+ await config.onSchemaFetch(siteId)
3528
+ );
3529
+ for (const collection of requestConfig.collections) {
2654
3530
  if (!collections.has(collection.slug)) {
2655
3531
  collections.set(collection.slug, collection);
2656
3532
  }
@@ -2661,11 +3537,18 @@ var AuditController = class {
2661
3537
  async findForCollection(c, collection) {
2662
3538
  const config = c.get("config");
2663
3539
  if (!config.db) return c.json({ message: "Database not configured" }, 500);
2664
- if (!collection.audit) return c.json({ message: "Audit is not enabled for this collection" }, 404);
3540
+ if (!collection.audit)
3541
+ return c.json(
3542
+ { message: "Audit is not enabled for this collection" },
3543
+ 404
3544
+ );
2665
3545
  const query = this.parseQuery(c);
2666
3546
  const scope = await this.buildCollectionAuditScope(c, collection);
2667
3547
  if (scope.denied) {
2668
- return c.json({ error: true, message: `Access denied: read on ${collection.slug}` }, 403);
3548
+ return c.json(
3549
+ { error: true, message: `Access denied: read on ${collection.slug}` },
3550
+ 403
3551
+ );
2669
3552
  }
2670
3553
  const where = query.where ? mergeWhereConstraint(scope.where, query.where) : scope.where;
2671
3554
  const result = await config.db.find({
@@ -2681,7 +3564,9 @@ var AuditController = class {
2681
3564
  const config = c.get("config");
2682
3565
  if (!config.db) return c.json({ message: "Database not configured" }, 500);
2683
3566
  const query = this.parseQuery(c);
2684
- const collections = (await this.getVisibleCollections(c)).filter((collection) => collection.audit);
3567
+ const collections = (await this.getVisibleCollections(c)).filter(
3568
+ (collection) => collection.audit
3569
+ );
2685
3570
  const scopes = [];
2686
3571
  for (const collection of collections) {
2687
3572
  const scope = await this.buildCollectionAuditScope(c, collection);
@@ -2712,8 +3597,7 @@ function getBearerToken(c) {
2712
3597
  const authHeader = c.req.header("Authorization");
2713
3598
  return authHeader?.replace(/^Bearer\s+/i, "") || void 0;
2714
3599
  }
2715
- async function resolveUser(token, config) {
2716
- const payload = await verifyCollectionToken(token);
3600
+ async function resolveUser(payload, config) {
2717
3601
  if (payload.purpose) {
2718
3602
  return payload;
2719
3603
  }
@@ -2728,7 +3612,12 @@ async function resolveUser(token, config) {
2728
3612
  id: payload.sub
2729
3613
  });
2730
3614
  } catch (err) {
2731
- console.error("[dyrected/core] Failed to hydrate user from token:", err);
3615
+ getConfigLogger(config, "auth").error({
3616
+ err,
3617
+ msg: "Failed to hydrate user from token",
3618
+ collection: payload.collection,
3619
+ userId: payload.sub
3620
+ });
2732
3621
  return payload;
2733
3622
  }
2734
3623
  if (!doc) {
@@ -2742,6 +3631,18 @@ async function resolveUser(token, config) {
2742
3631
  collection: payload.collection
2743
3632
  };
2744
3633
  }
3634
+ async function resolveAuthenticatedRequest(token, config, clientIp) {
3635
+ const payload = await verifyCollectionToken(token);
3636
+ if (payload.sid && config?.db) {
3637
+ const session = await getAuthSession(config, payload.sid);
3638
+ if (!isAuthSessionActive(session) || session.userId !== payload.sub || session.collection !== payload.collection) {
3639
+ throw new Error("Invalid or expired session.");
3640
+ }
3641
+ await touchAuthSession(config, payload.sid, { ip: clientIp });
3642
+ }
3643
+ const user = await resolveUser(payload, config);
3644
+ return { user, payload };
3645
+ }
2745
3646
  function requireAuth(config) {
2746
3647
  return async (c, next) => {
2747
3648
  if (c.get("user")) {
@@ -2749,18 +3650,42 @@ function requireAuth(config) {
2749
3650
  }
2750
3651
  const token = getBearerToken(c);
2751
3652
  if (!token) {
3653
+ c.get("observability")?.recordAuthFailure({
3654
+ reason: "missing_token",
3655
+ path: c.req.path
3656
+ });
2752
3657
  return c.json({ error: true, message: "Authentication required." }, 401);
2753
3658
  }
2754
3659
  let user;
3660
+ let payload;
2755
3661
  try {
2756
- user = await resolveUser(token, config ?? c.get("config"));
2757
- } catch {
3662
+ const resolved = await resolveAuthenticatedRequest(
3663
+ token,
3664
+ config ?? c.get("config"),
3665
+ c.get("clientIp")
3666
+ );
3667
+ user = resolved.user;
3668
+ payload = resolved.payload;
3669
+ } catch (err) {
3670
+ c.get("observability")?.recordAuthFailure({
3671
+ reason: "invalid_token",
3672
+ path: c.req.path
3673
+ });
3674
+ getRequestLogger(c, "auth").warn({
3675
+ err,
3676
+ msg: "Rejected invalid or expired token"
3677
+ });
2758
3678
  return c.json({ error: true, message: "Invalid or expired token." }, 401);
2759
3679
  }
2760
3680
  if (!user) {
3681
+ c.get("observability")?.recordAuthFailure({
3682
+ reason: "missing_user",
3683
+ path: c.req.path
3684
+ });
2761
3685
  return c.json({ error: true, message: "Invalid or expired token." }, 401);
2762
3686
  }
2763
3687
  c.set("user", user);
3688
+ c.set("authTokenPayload", payload);
2764
3689
  await next();
2765
3690
  };
2766
3691
  }
@@ -2772,10 +3697,16 @@ function optionalAuth(config) {
2772
3697
  const token = getBearerToken(c);
2773
3698
  if (token) {
2774
3699
  try {
2775
- const user = await resolveUser(token, config ?? c.get("config"));
3700
+ const resolved = await resolveAuthenticatedRequest(
3701
+ token,
3702
+ config ?? c.get("config"),
3703
+ c.get("clientIp")
3704
+ );
3705
+ const user = resolved.user;
2776
3706
  if (user) {
2777
3707
  c.set("user", user);
2778
3708
  }
3709
+ c.set("authTokenPayload", resolved.payload);
2779
3710
  } catch {
2780
3711
  }
2781
3712
  }
@@ -2850,7 +3781,10 @@ function accessGate(config, target, action) {
2850
3781
  req: toHookRequestContext(c.req)
2851
3782
  });
2852
3783
  if (!allowed) {
2853
- return c.json({ error: true, message: `Access denied: ${action} on ${target.slug}` }, 403);
3784
+ return c.json(
3785
+ { error: true, message: `Access denied: ${action} on ${target.slug}` },
3786
+ 403
3787
+ );
2854
3788
  }
2855
3789
  await next();
2856
3790
  };
@@ -2874,228 +3808,276 @@ function serializeFieldForApi(f) {
2874
3808
  if (serialized.fields) {
2875
3809
  serialized.fields = serialized.fields.map(serializeFieldForApi);
2876
3810
  }
2877
- if (serialized.blocks) {
3811
+ if (serialized.blocks && !serialized.blockReferences?.length) {
2878
3812
  serialized.blocks = serialized.blocks.map((b) => ({
2879
3813
  ...b,
2880
3814
  fields: b.fields?.map(serializeFieldForApi)
2881
3815
  }));
3816
+ } else if (serialized.blockReferences?.length) {
3817
+ delete serialized.blocks;
2882
3818
  }
2883
3819
  return serialized;
2884
3820
  }
3821
+ function serializeBlockForApi(block) {
3822
+ return {
3823
+ ...block,
3824
+ fields: block.fields?.map(serializeFieldForApi)
3825
+ };
3826
+ }
2885
3827
  function registerRoutes(app, config) {
2886
3828
  const optionsCache = /* @__PURE__ */ new Map();
2887
3829
  app.get("/api/schemas", optionalAuth(config), async (c) => {
2888
3830
  const siteId = c.req.header("X-Site-Id");
2889
- let collections = [...config.collections];
2890
- let globals = [...config.globals];
2891
- if (siteId && config.onSchemaFetch) {
2892
- const dynamic = await config.onSchemaFetch(siteId);
2893
- if (dynamic.collections) collections = [...collections, ...dynamic.collections];
2894
- if (dynamic.globals) globals = [...globals, ...dynamic.globals];
2895
- if (dynamic.admin) {
2896
- config.admin = { ...config.admin, ...dynamic.admin };
2897
- }
2898
- if (dynamic.adminAuth) {
2899
- config.adminAuth = { ...config.adminAuth, ...dynamic.adminAuth };
2900
- }
2901
- }
3831
+ const requestConfig = siteId && config.onSchemaFetch ? mergeDynamicConfig(config, await config.onSchemaFetch(siteId)) : config;
3832
+ const collections = [...requestConfig.collections];
3833
+ const globals = [...requestConfig.globals];
2902
3834
  const user = c.get("user");
2903
3835
  const accessArgs = { user, req: toHookRequestContext(c.req) };
2904
3836
  const serializeAccess = async (access) => {
2905
3837
  if (typeof access === "string") return access;
2906
3838
  if (typeof access === "boolean") return access;
2907
3839
  if (access && typeof access === "object" && typeof access.policy === "string") {
2908
- const policy = config.accessPolicies?.[access.policy];
2909
- if (typeof policy === "string" || typeof policy === "boolean") return policy;
3840
+ const policy = requestConfig.accessPolicies?.[access.policy];
3841
+ if (typeof policy === "string" || typeof policy === "boolean")
3842
+ return policy;
2910
3843
  }
2911
- return resolveBooleanAccess(config, access, accessArgs);
3844
+ return resolveBooleanAccess(requestConfig, access, accessArgs);
2912
3845
  };
2913
- const filteredCollections = await Promise.all(collections.filter((col) => !siteId || col.shared || !col.siteId || col.siteId === siteId).map(async (col) => ({
2914
- slug: col.slug,
2915
- labels: col.labels,
2916
- access: {
2917
- read: await serializeAccess(col.access?.read),
2918
- create: await serializeAccess(col.access?.create),
2919
- update: await serializeAccess(col.access?.update),
2920
- delete: await serializeAccess(col.access?.delete)
2921
- },
2922
- fields: await Promise.all(col.fields.map(serializeFieldForApi).map(async (f) => ({
2923
- name: f.name,
2924
- type: f.type,
2925
- label: f.label,
2926
- required: f.required,
2927
- defaultValue: f.defaultValue,
2928
- options: f.options,
2929
- relationTo: f.relationTo,
2930
- hasMany: f.hasMany,
2931
- fields: f.fields,
2932
- blocks: f.blocks,
2933
- collection: f.collection,
2934
- on: f.on,
2935
- limit: f.limit,
2936
- admin: f.admin,
3846
+ const filteredCollections = await Promise.all(
3847
+ collections.filter(
3848
+ (col) => !siteId || col.shared || !col.siteId || col.siteId === siteId
3849
+ ).map(async (col) => ({
3850
+ slug: col.slug,
3851
+ labels: col.labels,
2937
3852
  access: {
2938
- read: await serializeAccess(f.access?.read),
2939
- create: await serializeAccess(f.access?.create),
2940
- update: await serializeAccess(f.access?.update)
2941
- }
2942
- }))),
2943
- upload: !!col.upload,
2944
- auth: !!col.auth,
2945
- audit: !!col.audit,
2946
- drafts: !!col.drafts,
2947
- admin: col.admin,
2948
- workflow: col.workflow ? {
2949
- initialState: col.workflow.initialState,
2950
- draftState: col.workflow.draftState,
2951
- states: col.workflow.states,
2952
- transitions: col.workflow.transitions.map((t) => ({
2953
- name: t.name,
2954
- label: t.label,
2955
- from: t.from,
2956
- to: t.to,
2957
- requiredCapabilities: t.requiredCapabilities,
2958
- requireComment: t.requireComment,
2959
- unpublish: t.unpublish
2960
- })),
2961
- roles: col.workflow.roles
2962
- } : void 0
2963
- })));
2964
- const filteredGlobals = await Promise.all(globals.filter((glb) => !siteId || glb.shared || !glb.siteId || glb.siteId === siteId).map(async (glb) => ({
2965
- slug: glb.slug,
2966
- label: glb.label,
2967
- access: {
2968
- read: await serializeAccess(glb.access?.read),
2969
- update: await serializeAccess(glb.access?.update)
2970
- },
2971
- fields: await Promise.all(glb.fields.map(serializeFieldForApi).map(async (f) => ({
2972
- name: f.name,
2973
- type: f.type,
2974
- label: f.label,
2975
- required: f.required,
2976
- defaultValue: f.defaultValue,
2977
- options: f.options,
2978
- relationTo: f.relationTo,
2979
- hasMany: f.hasMany,
2980
- fields: f.fields,
2981
- blocks: f.blocks,
2982
- collection: f.collection,
2983
- on: f.on,
2984
- limit: f.limit,
2985
- admin: f.admin,
3853
+ read: await serializeAccess(col.access?.read),
3854
+ create: await serializeAccess(col.access?.create),
3855
+ update: await serializeAccess(col.access?.update),
3856
+ delete: await serializeAccess(col.access?.delete)
3857
+ },
3858
+ fields: await Promise.all(
3859
+ col.fields.map(serializeFieldForApi).map(async (f) => ({
3860
+ name: f.name,
3861
+ type: f.type,
3862
+ label: f.label,
3863
+ required: f.required,
3864
+ defaultValue: f.defaultValue,
3865
+ options: f.options,
3866
+ relationTo: f.relationTo,
3867
+ hasMany: f.hasMany,
3868
+ fields: f.fields,
3869
+ blocks: f.blocks,
3870
+ blockReferences: f.blockReferences,
3871
+ collection: f.collection,
3872
+ on: f.on,
3873
+ limit: f.limit,
3874
+ admin: f.admin,
3875
+ access: {
3876
+ read: await serializeAccess(f.access?.read),
3877
+ create: await serializeAccess(f.access?.create),
3878
+ update: await serializeAccess(f.access?.update)
3879
+ }
3880
+ }))
3881
+ ),
3882
+ upload: !!col.upload,
3883
+ auth: !!col.auth,
3884
+ audit: !!col.audit,
3885
+ drafts: !!col.drafts,
3886
+ admin: col.admin,
3887
+ workflow: col.workflow ? {
3888
+ initialState: col.workflow.initialState,
3889
+ draftState: col.workflow.draftState,
3890
+ states: col.workflow.states,
3891
+ transitions: col.workflow.transitions.map((t) => ({
3892
+ name: t.name,
3893
+ label: t.label,
3894
+ from: t.from,
3895
+ to: t.to,
3896
+ requiredCapabilities: t.requiredCapabilities,
3897
+ requireComment: t.requireComment,
3898
+ unpublish: t.unpublish
3899
+ })),
3900
+ roles: col.workflow.roles
3901
+ } : void 0
3902
+ }))
3903
+ );
3904
+ const filteredGlobals = await Promise.all(
3905
+ globals.filter(
3906
+ (glb) => !siteId || glb.shared || !glb.siteId || glb.siteId === siteId
3907
+ ).map(async (glb) => ({
3908
+ slug: glb.slug,
3909
+ label: glb.label,
2986
3910
  access: {
2987
- read: await serializeAccess(f.access?.read),
2988
- update: await serializeAccess(f.access?.update)
2989
- }
2990
- }))),
2991
- admin: glb.admin
2992
- })));
3911
+ read: await serializeAccess(glb.access?.read),
3912
+ update: await serializeAccess(glb.access?.update)
3913
+ },
3914
+ fields: await Promise.all(
3915
+ glb.fields.map(serializeFieldForApi).map(async (f) => ({
3916
+ name: f.name,
3917
+ type: f.type,
3918
+ label: f.label,
3919
+ required: f.required,
3920
+ defaultValue: f.defaultValue,
3921
+ options: f.options,
3922
+ relationTo: f.relationTo,
3923
+ hasMany: f.hasMany,
3924
+ fields: f.fields,
3925
+ blocks: f.blocks,
3926
+ blockReferences: f.blockReferences,
3927
+ collection: f.collection,
3928
+ on: f.on,
3929
+ limit: f.limit,
3930
+ admin: f.admin,
3931
+ access: {
3932
+ read: await serializeAccess(f.access?.read),
3933
+ update: await serializeAccess(f.access?.update)
3934
+ }
3935
+ }))
3936
+ ),
3937
+ admin: glb.admin
3938
+ }))
3939
+ );
2993
3940
  return c.json({
3941
+ blocks: requestConfig.blocks?.map(serializeBlockForApi),
2994
3942
  collections: filteredCollections,
2995
3943
  globals: filteredGlobals,
2996
- admin: config.admin || {},
2997
- adminAuth: getPublicAdminAuthConfig(config.adminAuth, collections)
3944
+ admin: requestConfig.admin || {},
3945
+ adminAuth: getPublicAdminAuthConfig(requestConfig.adminAuth, collections)
2998
3946
  });
2999
3947
  });
3000
- app.get("/api/dyrected/options/:collection/:field", optionalAuth(config), async (c) => {
3001
- const { collection: colSlug, field: fieldName } = c.req.param();
3002
- const siteId = c.req.header("X-Site-Id");
3003
- let collections = [...config.collections];
3004
- if (siteId && config.onSchemaFetch) {
3005
- const dynamic = await config.onSchemaFetch(siteId);
3006
- if (dynamic.collections) collections = [...collections, ...dynamic.collections];
3007
- }
3008
- const user = c.get("user");
3009
- let collection = collections.find((col) => col.slug === colSlug);
3010
- let field;
3011
- if (collection) {
3012
- const accessExpr = collection.access?.read;
3013
- if (accessExpr !== void 0 && accessExpr !== null) {
3014
- const allowed = await resolveBooleanAccess(config, accessExpr, {
3015
- user,
3016
- req: toHookRequestContext(c.req)
3017
- });
3018
- if (!allowed) {
3019
- return c.json({ error: true, message: `Access denied: read on ${colSlug}` }, 403);
3948
+ app.get(
3949
+ "/api/dyrected/options/:collection/:field",
3950
+ optionalAuth(config),
3951
+ async (c) => {
3952
+ const { collection: colSlug, field: fieldName } = c.req.param();
3953
+ const siteId = c.req.header("X-Site-Id");
3954
+ const requestConfig = siteId && config.onSchemaFetch ? mergeDynamicConfig(config, await config.onSchemaFetch(siteId)) : config;
3955
+ let collections = [...requestConfig.collections];
3956
+ const user = c.get("user");
3957
+ let collection = collections.find((col) => col.slug === colSlug);
3958
+ let field;
3959
+ if (collection) {
3960
+ const accessExpr = collection.access?.read;
3961
+ if (accessExpr !== void 0 && accessExpr !== null) {
3962
+ const allowed = await resolveBooleanAccess(config, accessExpr, {
3963
+ user,
3964
+ req: toHookRequestContext(c.req)
3965
+ });
3966
+ if (!allowed) {
3967
+ return c.json(
3968
+ { error: true, message: `Access denied: read on ${colSlug}` },
3969
+ 403
3970
+ );
3971
+ }
3972
+ }
3973
+ field = collection.fields.find((f) => f.name === fieldName);
3974
+ } else {
3975
+ const globals = [...requestConfig.globals];
3976
+ const glb = globals.find((g) => g.slug === colSlug);
3977
+ if (!glb) {
3978
+ return c.json(
3979
+ {
3980
+ error: true,
3981
+ message: `${colSlug} not found as collection or global`
3982
+ },
3983
+ 404
3984
+ );
3020
3985
  }
3986
+ const accessExpr = glb.access?.read;
3987
+ if (accessExpr !== void 0 && accessExpr !== null) {
3988
+ const allowed = await resolveBooleanAccess(config, accessExpr, {
3989
+ user,
3990
+ req: toHookRequestContext(c.req)
3991
+ });
3992
+ if (!allowed) {
3993
+ return c.json(
3994
+ {
3995
+ error: true,
3996
+ message: `Access denied: read on global ${colSlug}`
3997
+ },
3998
+ 403
3999
+ );
4000
+ }
4001
+ }
4002
+ field = glb.fields.find((f) => f.name === fieldName);
3021
4003
  }
3022
- field = collection.fields.find((f) => f.name === fieldName);
3023
- } else {
3024
- let globals = [...config.globals];
3025
- if (siteId && config.onSchemaFetch) {
3026
- const dynamic = await config.onSchemaFetch(siteId);
3027
- if (dynamic.globals) globals = [...globals, ...dynamic.globals];
3028
- }
3029
- const glb = globals.find((g) => g.slug === colSlug);
3030
- if (!glb) {
3031
- return c.json({ error: true, message: `${colSlug} not found as collection or global` }, 404);
3032
- }
3033
- const accessExpr = glb.access?.read;
3034
- if (accessExpr !== void 0 && accessExpr !== null) {
3035
- const allowed = await resolveBooleanAccess(config, accessExpr, {
4004
+ if (!field) {
4005
+ return c.json(
4006
+ {
4007
+ error: true,
4008
+ message: `Field ${fieldName} not found in ${colSlug}`
4009
+ },
4010
+ 404
4011
+ );
4012
+ }
4013
+ let resolver;
4014
+ let cacheTTL;
4015
+ if (typeof field.options === "function") {
4016
+ resolver = field.options;
4017
+ } else if (field.options && typeof field.options === "object" && "resolve" in field.options) {
4018
+ resolver = field.options.resolve;
4019
+ cacheTTL = field.options.cacheTTL;
4020
+ }
4021
+ if (!resolver) {
4022
+ return c.json(
4023
+ {
4024
+ error: true,
4025
+ message: `Field ${fieldName} in ${colSlug} is not dynamic`
4026
+ },
4027
+ 400
4028
+ );
4029
+ }
4030
+ try {
4031
+ const db = c.get("db") || config.db;
4032
+ const queryParams = c.req.query();
4033
+ const reqContext = {
4034
+ query: queryParams,
4035
+ headers: c.req.header(),
4036
+ raw: c.req.raw
4037
+ };
4038
+ const shouldCache = typeof cacheTTL === "number" && cacheTTL > 0;
4039
+ let cacheKey = "";
4040
+ if (shouldCache) {
4041
+ cacheKey = JSON.stringify([
4042
+ siteId ?? "",
4043
+ colSlug,
4044
+ fieldName,
4045
+ user?.id ?? null,
4046
+ queryParams
4047
+ ]);
4048
+ const hit = optionsCache.get(cacheKey);
4049
+ if (hit && hit.expires > Date.now()) {
4050
+ return c.json(hit.value);
4051
+ }
4052
+ }
4053
+ const result = await resolver({
4054
+ db,
3036
4055
  user,
3037
- req: toHookRequestContext(c.req)
4056
+ req: reqContext
3038
4057
  });
3039
- if (!allowed) {
3040
- return c.json({ error: true, message: `Access denied: read on global ${colSlug}` }, 403);
3041
- }
3042
- }
3043
- field = glb.fields.find((f) => f.name === fieldName);
3044
- }
3045
- if (!field) {
3046
- return c.json({ error: true, message: `Field ${fieldName} not found in ${colSlug}` }, 404);
3047
- }
3048
- let resolver;
3049
- let cacheTTL;
3050
- if (typeof field.options === "function") {
3051
- resolver = field.options;
3052
- } else if (field.options && typeof field.options === "object" && "resolve" in field.options) {
3053
- resolver = field.options.resolve;
3054
- cacheTTL = field.options.cacheTTL;
3055
- }
3056
- if (!resolver) {
3057
- return c.json({ error: true, message: `Field ${fieldName} in ${colSlug} is not dynamic` }, 400);
3058
- }
3059
- try {
3060
- const db = c.get("db") || config.db;
3061
- const queryParams = c.req.query();
3062
- const reqContext = {
3063
- query: queryParams,
3064
- headers: c.req.header(),
3065
- raw: c.req.raw
3066
- };
3067
- const shouldCache = typeof cacheTTL === "number" && cacheTTL > 0;
3068
- let cacheKey = "";
3069
- if (shouldCache) {
3070
- cacheKey = JSON.stringify([
3071
- siteId ?? "",
3072
- colSlug,
3073
- fieldName,
3074
- user?.id ?? null,
3075
- queryParams
3076
- ]);
3077
- const hit = optionsCache.get(cacheKey);
3078
- if (hit && hit.expires > Date.now()) {
3079
- return c.json(hit.value);
4058
+ if (shouldCache) {
4059
+ optionsCache.set(cacheKey, {
4060
+ expires: Date.now() + cacheTTL * 1e3,
4061
+ value: result
4062
+ });
3080
4063
  }
3081
- }
3082
- const result = await resolver({
3083
- db,
3084
- user,
3085
- req: reqContext
3086
- });
3087
- if (shouldCache) {
3088
- optionsCache.set(cacheKey, {
3089
- expires: Date.now() + cacheTTL * 1e3,
3090
- value: result
4064
+ return c.json(result);
4065
+ } catch (err) {
4066
+ getRequestLogger(c, "router").error({
4067
+ err,
4068
+ msg: "Failed to resolve dynamic field options",
4069
+ fieldName
3091
4070
  });
4071
+ return c.json(
4072
+ {
4073
+ error: true,
4074
+ message: err.message || "Failed to resolve dynamic options"
4075
+ },
4076
+ 500
4077
+ );
3092
4078
  }
3093
- return c.json(result);
3094
- } catch (err) {
3095
- console.error(`[dyrected/core] Failed to resolve dynamic options for field ${fieldName}:`, err);
3096
- return c.json({ error: true, message: err.message || "Failed to resolve dynamic options" }, 500);
3097
4079
  }
3098
- });
4080
+ );
3099
4081
  app.get("/api/openapi.json", (c) => {
3100
4082
  return c.json(generateOpenApi(config));
3101
4083
  });
@@ -3108,10 +4090,18 @@ function registerRoutes(app, config) {
3108
4090
  const key = c.req.param("key");
3109
4091
  const scope = c.req.query("scope");
3110
4092
  if (!db) return c.json({ message: "Database not configured" }, 500);
3111
- if (!user?.collection || !user.sub) return c.json({ error: true, message: "Authentication required." }, 401);
3112
- if (!key) return c.json({ error: true, message: "Preference key is required." }, 400);
4093
+ if (!user?.collection || !user.sub)
4094
+ return c.json({ error: true, message: "Authentication required." }, 401);
4095
+ if (!key)
4096
+ return c.json(
4097
+ { error: true, message: "Preference key is required." },
4098
+ 400
4099
+ );
3113
4100
  const getGlobalPreference = async () => {
3114
- const globalDoc = await db.findOne({ collection: "__global_preferences", id: key });
4101
+ const globalDoc = await db.findOne({
4102
+ collection: "__global_preferences",
4103
+ id: key
4104
+ });
3115
4105
  return globalDoc ? globalDoc.value : null;
3116
4106
  };
3117
4107
  if (scope === "global") {
@@ -3133,15 +4123,29 @@ function registerRoutes(app, config) {
3133
4123
  const key = c.req.param("key");
3134
4124
  const scope = c.req.query("scope");
3135
4125
  if (!db) return c.json({ message: "Database not configured" }, 500);
3136
- if (!user?.collection || !user.sub) return c.json({ error: true, message: "Authentication required." }, 401);
3137
- if (!key) return c.json({ error: true, message: "Preference key is required." }, 400);
4126
+ if (!user?.collection || !user.sub)
4127
+ return c.json({ error: true, message: "Authentication required." }, 401);
4128
+ if (!key)
4129
+ return c.json(
4130
+ { error: true, message: "Preference key is required." },
4131
+ 400
4132
+ );
3138
4133
  const body = await c.req.json().catch(() => ({}));
3139
4134
  if (scope === "global") {
3140
4135
  const isAdminUser = Array.isArray(user?.roles) && user.roles.includes("admin");
3141
4136
  if (!isAdminUser) {
3142
- return c.json({ error: true, message: "Only administrators can save global preferences." }, 403);
4137
+ return c.json(
4138
+ {
4139
+ error: true,
4140
+ message: "Only administrators can save global preferences."
4141
+ },
4142
+ 403
4143
+ );
3143
4144
  }
3144
- const existing = await db.findOne({ collection: "__global_preferences", id: key });
4145
+ const existing = await db.findOne({
4146
+ collection: "__global_preferences",
4147
+ id: key
4148
+ });
3145
4149
  if (existing) {
3146
4150
  await db.update({
3147
4151
  collection: "__global_preferences",
@@ -3173,12 +4177,23 @@ function registerRoutes(app, config) {
3173
4177
  const key = c.req.param("key");
3174
4178
  const scope = c.req.query("scope");
3175
4179
  if (!db) return c.json({ message: "Database not configured" }, 500);
3176
- if (!user?.collection || !user.sub) return c.json({ error: true, message: "Authentication required." }, 401);
3177
- if (!key) return c.json({ error: true, message: "Preference key is required." }, 400);
4180
+ if (!user?.collection || !user.sub)
4181
+ return c.json({ error: true, message: "Authentication required." }, 401);
4182
+ if (!key)
4183
+ return c.json(
4184
+ { error: true, message: "Preference key is required." },
4185
+ 400
4186
+ );
3178
4187
  if (scope === "global") {
3179
4188
  const isAdminUser = Array.isArray(user?.roles) && user.roles.includes("admin");
3180
4189
  if (!isAdminUser) {
3181
- return c.json({ error: true, message: "Only administrators can delete global preferences." }, 403);
4190
+ return c.json(
4191
+ {
4192
+ error: true,
4193
+ message: "Only administrators can delete global preferences."
4194
+ },
4195
+ 403
4196
+ );
3182
4197
  }
3183
4198
  await db.delete({ collection: "__global_preferences", id: key });
3184
4199
  return c.json({ success: true });
@@ -3207,17 +4222,41 @@ function registerRoutes(app, config) {
3207
4222
  for (const col of uploadCollections) {
3208
4223
  const mediaController = new MediaController(col.slug);
3209
4224
  const prefix = `/api/collections/${col.slug}`;
3210
- app.get(`${prefix}/media`, accessGate(config, col, "read"), (c) => mediaController.find(c));
3211
- app.get(`${prefix}/media/:filename{.+$}`, (c) => mediaController.serve(c));
3212
- app.post(`${prefix}/media`, accessGate(config, col, "create"), (c) => mediaController.upload(c));
3213
- app.delete(`${prefix}/media/:id`, accessGate(config, col, "delete"), (c) => mediaController.delete(c));
4225
+ app.get(
4226
+ `${prefix}/media`,
4227
+ accessGate(config, col, "read"),
4228
+ (c) => mediaController.find(c)
4229
+ );
4230
+ app.get(
4231
+ `${prefix}/media/:filename{.+$}`,
4232
+ (c) => mediaController.serve(c)
4233
+ );
4234
+ app.post(
4235
+ `${prefix}/media`,
4236
+ accessGate(config, col, "create"),
4237
+ (c) => mediaController.upload(c)
4238
+ );
4239
+ app.delete(
4240
+ `${prefix}/media/:id`,
4241
+ accessGate(config, col, "delete"),
4242
+ (c) => mediaController.delete(c)
4243
+ );
3214
4244
  }
3215
4245
  }
3216
4246
  const adminAuthController = new AdminAuthController(config);
3217
4247
  app.get("/api/admin/auth/providers", (c) => adminAuthController.providers(c));
3218
- app.get("/api/admin/auth/:provider/start", (c) => adminAuthController.start(c));
3219
- app.get("/api/admin/auth/:provider/callback", (c) => adminAuthController.callback(c));
3220
- app.post("/api/admin/auth/:provider/exchange", (c) => adminAuthController.exchange(c));
4248
+ app.get(
4249
+ "/api/admin/auth/:provider/start",
4250
+ (c) => adminAuthController.start(c)
4251
+ );
4252
+ app.get(
4253
+ "/api/admin/auth/:provider/callback",
4254
+ (c) => adminAuthController.callback(c)
4255
+ );
4256
+ app.post(
4257
+ "/api/admin/auth/:provider/exchange",
4258
+ (c) => adminAuthController.exchange(c)
4259
+ );
3221
4260
  app.post("/api/admin/logout", (c) => adminAuthController.logout(c));
3222
4261
  for (const collection of config.collections) {
3223
4262
  if (!collection.auth) continue;
@@ -3228,10 +4267,21 @@ function registerRoutes(app, config) {
3228
4267
  app.get(`${path}/init`, (c) => authController.init(c));
3229
4268
  app.post(`${path}/first-user`, (c) => authController.registerFirstUser(c));
3230
4269
  app.get(`${path}/me`, requireAuth(config), (c) => authController.me(c));
3231
- app.post(`${path}/refresh-token`, requireAuth(config), (c) => authController.refreshToken(c));
3232
- app.post(`${path}/forgot-password`, (c) => authController.forgotPassword(c));
4270
+ app.post(
4271
+ `${path}/refresh-token`,
4272
+ requireAuth(config),
4273
+ (c) => authController.refreshToken(c)
4274
+ );
4275
+ app.post(
4276
+ `${path}/forgot-password`,
4277
+ (c) => authController.forgotPassword(c)
4278
+ );
3233
4279
  app.post(`${path}/reset-password`, (c) => authController.resetPassword(c));
3234
- app.post(`${path}/invite`, requireAuth(config), (c) => authController.invite(c));
4280
+ app.post(
4281
+ `${path}/invite`,
4282
+ requireAuth(config),
4283
+ (c) => authController.invite(c)
4284
+ );
3235
4285
  app.post(`${path}/accept-invite`, (c) => authController.acceptInvite(c));
3236
4286
  }
3237
4287
  const auditController = new AuditController();
@@ -3243,18 +4293,33 @@ function registerRoutes(app, config) {
3243
4293
  app.post(`${path}/media`, (c) => controller.create(c));
3244
4294
  app.delete(`${path}/delete-many`, (c) => controller.deleteMany(c));
3245
4295
  if (collection.audit) {
3246
- app.get(`${path}/__audit`, (c) => auditController.findForCollection(c, collection));
4296
+ app.get(
4297
+ `${path}/__audit`,
4298
+ (c) => auditController.findForCollection(c, collection)
4299
+ );
3247
4300
  }
3248
4301
  app.get(`${path}/:id`, (c) => controller.findOne(c));
3249
4302
  app.patch(`${path}/:id`, (c) => controller.update(c));
3250
4303
  app.delete(`${path}/:id`, (c) => controller.delete(c));
3251
4304
  app.post(`${path}/seed`, (c) => controller.seed(c));
3252
4305
  if (collection.auth) {
3253
- app.post(`${path}/:id/change-password`, requireAuth(config), (c) => controller.changePassword(c));
4306
+ app.post(
4307
+ `${path}/:id/change-password`,
4308
+ requireAuth(config),
4309
+ (c) => controller.changePassword(c)
4310
+ );
3254
4311
  }
3255
4312
  if (collection.workflow) {
3256
- app.post(`${path}/:id/transitions/:transition`, requireAuth(config), (c) => controller.transition(c));
3257
- app.get(`${path}/:id/workflow-history`, requireAuth(config), (c) => controller.workflowHistory(c));
4313
+ app.post(
4314
+ `${path}/:id/transitions/:transition`,
4315
+ requireAuth(config),
4316
+ (c) => controller.transition(c)
4317
+ );
4318
+ app.get(
4319
+ `${path}/:id/workflow-history`,
4320
+ requireAuth(config),
4321
+ (c) => controller.workflowHistory(c)
4322
+ );
3258
4323
  }
3259
4324
  }
3260
4325
  for (const global of config.globals) {
@@ -3265,12 +4330,16 @@ function registerRoutes(app, config) {
3265
4330
  app.post(`${path}/seed`, (c) => controller.seed(c));
3266
4331
  }
3267
4332
  if (!process.env.DYRECTED_JWT_SECRET) {
3268
- console.warn(
3269
- '[dyrected] DYRECTED_JWT_SECRET is not set \u2014 token-mode live preview is signing with an insecure default. Set DYRECTED_JWT_SECRET before relying on `previewMode: "token"` in production.'
3270
- );
4333
+ getConfigLogger(config, "router").warn({
4334
+ msg: "DYRECTED_JWT_SECRET is not set; token-mode live preview is signing with an insecure default"
4335
+ });
3271
4336
  }
3272
4337
  const previewController = new PreviewController();
3273
- app.post("/api/preview-token", requireAuth(config), (c) => previewController.createToken(c));
4338
+ app.post(
4339
+ "/api/preview-token",
4340
+ requireAuth(config),
4341
+ (c) => previewController.createToken(c)
4342
+ );
3274
4343
  app.get("/api/preview-data", (c) => previewController.getData(c));
3275
4344
  app.get("/api/audit", (c) => auditController.findAll(c));
3276
4345
  app.get("/api/collections/:slug/__audit", async (c) => {
@@ -3283,49 +4352,81 @@ function registerRoutes(app, config) {
3283
4352
  if (!config2.onSchemaFetch || !siteId) {
3284
4353
  return c.json({ message: `Collection "${slug}" not found` }, 404);
3285
4354
  }
3286
- const dynamic = await config2.onSchemaFetch(siteId);
3287
- const collection = dynamic.collections?.find((col) => col.slug === slug);
4355
+ const requestConfig = mergeDynamicConfig(
4356
+ config2,
4357
+ await config2.onSchemaFetch(siteId)
4358
+ );
4359
+ const collection = requestConfig.collections.find(
4360
+ (col) => col.slug === slug
4361
+ );
3288
4362
  if (!collection?.audit) {
3289
- return c.json({ message: `Collection "${slug}" not found or has no audit log` }, 404);
4363
+ return c.json(
4364
+ { message: `Collection "${slug}" not found or has no audit log` },
4365
+ 404
4366
+ );
3290
4367
  }
3291
4368
  return auditController.findForCollection(c, collection);
3292
4369
  });
3293
- app.post("/api/collections/:slug/:id/transitions/:transition", requireAuth(config), async (c) => {
3294
- const slug = c.req.param("slug");
3295
- const siteId = c.req.header("X-Site-Id") || c.get("siteId");
3296
- const config2 = c.get("config");
3297
- if (config2.collections.some((col) => col.slug === slug)) {
3298
- return c.json({ message: "Not Found" }, 404);
3299
- }
3300
- if (!config2.onSchemaFetch || !siteId) {
3301
- return c.json({ message: `Collection "${slug}" not found` }, 404);
3302
- }
3303
- const dynamic = await config2.onSchemaFetch(siteId);
3304
- const collection = dynamic.collections?.find((col) => col.slug === slug);
3305
- if (!collection?.workflow) {
3306
- return c.json({ message: `Collection "${slug}" not found or has no workflow` }, 404);
3307
- }
3308
- const controller = new CollectionController(collection);
3309
- return controller.transition(c);
3310
- });
3311
- app.get("/api/collections/:slug/:id/workflow-history", requireAuth(config), async (c) => {
3312
- const slug = c.req.param("slug");
3313
- const siteId = c.req.header("X-Site-Id") || c.get("siteId");
3314
- const config2 = c.get("config");
3315
- if (config2.collections.some((col) => col.slug === slug)) {
3316
- return c.json({ message: "Not Found" }, 404);
3317
- }
3318
- if (!config2.onSchemaFetch || !siteId) {
3319
- return c.json({ message: `Collection "${slug}" not found` }, 404);
3320
- }
3321
- const dynamic = await config2.onSchemaFetch(siteId);
3322
- const collection = dynamic.collections?.find((col) => col.slug === slug);
3323
- if (!collection?.workflow) {
3324
- return c.json({ message: `Collection "${slug}" not found or has no workflow` }, 404);
4370
+ app.post(
4371
+ "/api/collections/:slug/:id/transitions/:transition",
4372
+ requireAuth(config),
4373
+ async (c) => {
4374
+ const slug = c.req.param("slug");
4375
+ const siteId = c.req.header("X-Site-Id") || c.get("siteId");
4376
+ const config2 = c.get("config");
4377
+ if (config2.collections.some((col) => col.slug === slug)) {
4378
+ return c.json({ message: "Not Found" }, 404);
4379
+ }
4380
+ if (!config2.onSchemaFetch || !siteId) {
4381
+ return c.json({ message: `Collection "${slug}" not found` }, 404);
4382
+ }
4383
+ const requestConfig = mergeDynamicConfig(
4384
+ config2,
4385
+ await config2.onSchemaFetch(siteId)
4386
+ );
4387
+ const collection = requestConfig.collections.find(
4388
+ (col) => col.slug === slug
4389
+ );
4390
+ if (!collection?.workflow) {
4391
+ return c.json(
4392
+ { message: `Collection "${slug}" not found or has no workflow` },
4393
+ 404
4394
+ );
4395
+ }
4396
+ const controller = new CollectionController(collection);
4397
+ return controller.transition(c);
4398
+ }
4399
+ );
4400
+ app.get(
4401
+ "/api/collections/:slug/:id/workflow-history",
4402
+ requireAuth(config),
4403
+ async (c) => {
4404
+ const slug = c.req.param("slug");
4405
+ const siteId = c.req.header("X-Site-Id") || c.get("siteId");
4406
+ const config2 = c.get("config");
4407
+ if (config2.collections.some((col) => col.slug === slug)) {
4408
+ return c.json({ message: "Not Found" }, 404);
4409
+ }
4410
+ if (!config2.onSchemaFetch || !siteId) {
4411
+ return c.json({ message: `Collection "${slug}" not found` }, 404);
4412
+ }
4413
+ const requestConfig = mergeDynamicConfig(
4414
+ config2,
4415
+ await config2.onSchemaFetch(siteId)
4416
+ );
4417
+ const collection = requestConfig.collections.find(
4418
+ (col) => col.slug === slug
4419
+ );
4420
+ if (!collection?.workflow) {
4421
+ return c.json(
4422
+ { message: `Collection "${slug}" not found or has no workflow` },
4423
+ 404
4424
+ );
4425
+ }
4426
+ const controller = new CollectionController(collection);
4427
+ return controller.workflowHistory(c);
3325
4428
  }
3326
- const controller = new CollectionController(collection);
3327
- return controller.workflowHistory(c);
3328
- });
4429
+ );
3329
4430
  app.all("/api/collections/:slug/:id?", async (c) => {
3330
4431
  const slug = c.req.param("slug");
3331
4432
  const id = c.req.param("id");
@@ -3335,8 +4436,13 @@ function registerRoutes(app, config) {
3335
4436
  return c.json({ message: "Method Not Allowed" }, 405);
3336
4437
  }
3337
4438
  if (config2.onSchemaFetch && siteId) {
3338
- const dynamic = await config2.onSchemaFetch(siteId);
3339
- let collection = dynamic.collections?.find((col) => col.slug === slug);
4439
+ const requestConfig = mergeDynamicConfig(
4440
+ config2,
4441
+ await config2.onSchemaFetch(siteId)
4442
+ );
4443
+ let collection = requestConfig.collections.find(
4444
+ (col) => col.slug === slug
4445
+ );
3340
4446
  if (!collection && slug === "media") {
3341
4447
  collection = {
3342
4448
  slug: "media",
@@ -3349,19 +4455,25 @@ function registerRoutes(app, config) {
3349
4455
  if (collection.auth && id) {
3350
4456
  const authController = new AuthController(collection);
3351
4457
  const method2 = c.req.method;
3352
- if (method2 === "POST" && id === "login") return authController.login(c);
3353
- if (method2 === "POST" && id === "logout") return authController.logout(c);
4458
+ if (method2 === "POST" && id === "login")
4459
+ return authController.login(c);
4460
+ if (method2 === "POST" && id === "logout")
4461
+ return authController.logout(c);
3354
4462
  if (method2 === "GET" && id === "me") return authController.me(c);
3355
- if (method2 === "POST" && id === "refresh-token") return authController.refreshToken(c);
3356
- if (method2 === "POST" && id === "forgot-password") return authController.forgotPassword(c);
3357
- if (method2 === "POST" && id === "reset-password") return authController.resetPassword(c);
4463
+ if (method2 === "POST" && id === "refresh-token")
4464
+ return authController.refreshToken(c);
4465
+ if (method2 === "POST" && id === "forgot-password")
4466
+ return authController.forgotPassword(c);
4467
+ if (method2 === "POST" && id === "reset-password")
4468
+ return authController.resetPassword(c);
3358
4469
  }
3359
4470
  const controller = new CollectionController(collection);
3360
4471
  const method = c.req.method;
3361
4472
  if (id) {
3362
4473
  if (method === "GET") return controller.findOne(c);
3363
4474
  if (method === "PATCH") return controller.update(c);
3364
- if (method === "DELETE" && id === "delete-many") return controller.deleteMany(c);
4475
+ if (method === "DELETE" && id === "delete-many")
4476
+ return controller.deleteMany(c);
3365
4477
  if (method === "DELETE") return controller.delete(c);
3366
4478
  if (method === "POST" && id === "media") return controller.create(c);
3367
4479
  if (method === "POST" && id === "seed") return controller.seed(c);
@@ -3382,8 +4494,11 @@ function registerRoutes(app, config) {
3382
4494
  return c.json({ message: "Method Not Allowed" }, 405);
3383
4495
  }
3384
4496
  if (config2.onSchemaFetch && siteId) {
3385
- const dynamic = await config2.onSchemaFetch(siteId);
3386
- const global = dynamic.globals?.find((glb) => glb.slug === slug);
4497
+ const requestConfig = mergeDynamicConfig(
4498
+ config2,
4499
+ await config2.onSchemaFetch(siteId)
4500
+ );
4501
+ const global = requestConfig.globals.find((glb) => glb.slug === slug);
3387
4502
  if (global) {
3388
4503
  const controller = new GlobalController(global);
3389
4504
  const method = c.req.method;
@@ -3399,36 +4514,283 @@ function registerRoutes(app, config) {
3399
4514
  });
3400
4515
  }
3401
4516
 
4517
+ // src/network.ts
4518
+ var DIRECT_IP_HEADERS = [
4519
+ "cf-connecting-ip",
4520
+ "fly-client-ip",
4521
+ "true-client-ip",
4522
+ "x-real-ip",
4523
+ "x-client-ip",
4524
+ "fastly-client-ip"
4525
+ ];
4526
+ function toHookRequestContext2(c) {
4527
+ const headers = Object.fromEntries(
4528
+ Object.entries(c.req.header()).map(([key, value]) => [key.toLowerCase(), value])
4529
+ );
4530
+ return {
4531
+ query: c.req.query(),
4532
+ headers,
4533
+ raw: c.req.raw
4534
+ };
4535
+ }
4536
+ function resolveClientIp(req, trustProxy = false) {
4537
+ for (const header of DIRECT_IP_HEADERS) {
4538
+ const value = normalizeIp(req.headers[header]);
4539
+ if (value) return value;
4540
+ }
4541
+ if (trustProxy) {
4542
+ const forwardedFor = pickForwardedFor(req.headers, trustProxy);
4543
+ if (forwardedFor) return forwardedFor;
4544
+ }
4545
+ return "127.0.0.1";
4546
+ }
4547
+ function pickForwardedFor(headers, trustProxy) {
4548
+ const forwardedHeader = headers["x-forwarded-for"];
4549
+ if (forwardedHeader) {
4550
+ const chain2 = forwardedHeader.split(",").map((entry) => normalizeIp(entry)).filter((entry) => !!entry);
4551
+ if (chain2.length > 0) {
4552
+ if (trustProxy === true) return chain2[0];
4553
+ const trustedHops2 = Math.max(1, Math.floor(trustProxy));
4554
+ const candidateIndex2 = Math.max(0, chain2.length - trustedHops2);
4555
+ return chain2[candidateIndex2] ?? chain2[0];
4556
+ }
4557
+ }
4558
+ const forwarded = headers.forwarded;
4559
+ if (!forwarded) return null;
4560
+ const matches = [...forwarded.matchAll(/for=(?:"?\[?([^;\],"]+)\]?"?)/gi)];
4561
+ if (matches.length === 0) return null;
4562
+ const chain = matches.map((match) => normalizeIp(match[1])).filter((entry) => !!entry);
4563
+ if (chain.length === 0) return null;
4564
+ if (trustProxy === true) return chain[0];
4565
+ const trustedHops = Math.max(1, Math.floor(trustProxy));
4566
+ const candidateIndex = Math.max(0, chain.length - trustedHops);
4567
+ return chain[candidateIndex] ?? chain[0];
4568
+ }
4569
+ function normalizeIp(value) {
4570
+ if (!value) return null;
4571
+ const trimmed = value.trim();
4572
+ if (!trimmed || trimmed.toLowerCase() === "unknown") return null;
4573
+ return trimmed.replace(/^\[(.*)\]$/, "$1");
4574
+ }
4575
+
4576
+ // src/middleware/rate-limit.ts
4577
+ var DEFAULT_WINDOW_MS = 15 * 60 * 1e3;
4578
+ var DEFAULT_MAX_REQUESTS = 500;
4579
+ var DEFAULT_PATHS = ["/api"];
4580
+ function createRateLimitMiddleware(config) {
4581
+ const settings = normalizeRateLimitConfig(config.rateLimit);
4582
+ const buckets = /* @__PURE__ */ new Map();
4583
+ return async (c, next) => {
4584
+ const req = toHookRequestContext2(c);
4585
+ const ip = resolveClientIp(req, settings.trustProxy);
4586
+ c.set("clientIp", ip);
4587
+ if (!settings.enabled) {
4588
+ return next();
4589
+ }
4590
+ const path = c.req.path;
4591
+ if (!matchesLimitedPath(path, settings.paths)) {
4592
+ return next();
4593
+ }
4594
+ if (settings.skip && await settings.skip({
4595
+ ip,
4596
+ path,
4597
+ method: c.req.method,
4598
+ req
4599
+ })) {
4600
+ return next();
4601
+ }
4602
+ const now = Date.now();
4603
+ const key = ip;
4604
+ const existing = buckets.get(key);
4605
+ const bucket = existing && existing.resetAt > now ? existing : { count: 0, resetAt: now + settings.window };
4606
+ bucket.count += 1;
4607
+ buckets.set(key, bucket);
4608
+ pruneExpiredBuckets(buckets, now);
4609
+ const remaining = Math.max(0, settings.max - bucket.count);
4610
+ const resetSeconds = Math.max(1, Math.ceil((bucket.resetAt - now) / 1e3));
4611
+ c.res.headers.set("X-RateLimit-Limit", String(settings.max));
4612
+ c.res.headers.set("X-RateLimit-Remaining", String(remaining));
4613
+ c.res.headers.set("X-RateLimit-Reset", String(Math.ceil(bucket.resetAt / 1e3)));
4614
+ if (bucket.count > settings.max) {
4615
+ c.res.headers.set("Retry-After", String(resetSeconds));
4616
+ return c.json(
4617
+ {
4618
+ error: true,
4619
+ message: "Too many requests. Try again later.",
4620
+ retryAfterSeconds: resetSeconds
4621
+ },
4622
+ 429
4623
+ );
4624
+ }
4625
+ await next();
4626
+ };
4627
+ }
4628
+ function normalizeRateLimitConfig(config) {
4629
+ return {
4630
+ enabled: config?.enabled ?? true,
4631
+ window: config?.window ?? DEFAULT_WINDOW_MS,
4632
+ max: config?.max ?? DEFAULT_MAX_REQUESTS,
4633
+ paths: config?.paths?.length ? config.paths : DEFAULT_PATHS,
4634
+ skip: config?.skip,
4635
+ trustProxy: config?.trustProxy ?? false
4636
+ };
4637
+ }
4638
+ function matchesLimitedPath(path, prefixes) {
4639
+ return prefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
4640
+ }
4641
+ function pruneExpiredBuckets(buckets, now) {
4642
+ if (buckets.size < 1e3) return;
4643
+ for (const [key, bucket] of buckets) {
4644
+ if (bucket.resetAt <= now) {
4645
+ buckets.delete(key);
4646
+ }
4647
+ }
4648
+ }
4649
+
3402
4650
  // src/app.ts
3403
4651
  async function createDyrectedApp(rawConfig) {
3404
4652
  const config = normalizeConfig(rawConfig);
4653
+ const observability = createObservabilityRuntime(config);
4654
+ bindObservabilityRuntime(config, observability);
4655
+ config.logger = observability.logger;
3405
4656
  const app = new Hono();
3406
4657
  if (config.db?.sync) {
3407
4658
  await config.db.sync(config.collections, config.globals);
3408
4659
  }
3409
4660
  app.use("*", requestId());
3410
- app.use("*", optionalAuth(config));
3411
- app.use("*", async (c, next) => {
3412
- const start = Date.now();
3413
- await next();
3414
- const ms = Date.now() - start;
3415
- console.log(`[dyrected/api] ${c.req.method} ${c.req.path} ${c.res.status} - ${ms}ms`);
3416
- });
3417
- app.use("*", cors());
3418
4661
  app.use("*", async (c, next) => {
3419
4662
  c.set("config", config);
4663
+ c.set("observability", observability);
3420
4664
  if (!c.get("siteId")) {
3421
4665
  c.set("siteId", "default");
3422
4666
  }
4667
+ c.set("logger", buildRequestLogger(observability, c.var, c.get("requestId")));
3423
4668
  await next();
3424
4669
  });
4670
+ app.use("*", async (c, next) => {
4671
+ const start = Date.now();
4672
+ const method = c.req.method;
4673
+ const path = c.req.path;
4674
+ const requestId2 = c.get("requestId");
4675
+ const observabilityConfig = observability.config;
4676
+ const traceSampled = shouldSampleRequest(
4677
+ requestId2,
4678
+ 200,
4679
+ observabilityConfig.sampling,
4680
+ "trace"
4681
+ );
4682
+ const requestTrace = traceSampled ? createRequestSpan(observability, `${method} ${path}`, {
4683
+ "http.method": method,
4684
+ "http.route": path,
4685
+ requestId: requestId2,
4686
+ siteId: c.get("siteId"),
4687
+ workspaceId: c.get("workspaceId")
4688
+ }) : void 0;
4689
+ if (requestTrace) {
4690
+ c.set("requestTrace", requestTrace);
4691
+ c.set("logger", buildRequestLogger(observability, c.var, requestId2));
4692
+ }
4693
+ let bodyCapturePromise;
4694
+ if (observabilityConfig.requestLogging.logBodies) {
4695
+ const bodySampled = shouldSampleRequest(
4696
+ requestId2,
4697
+ 200,
4698
+ observabilityConfig.sampling,
4699
+ "body"
4700
+ );
4701
+ if (bodySampled) {
4702
+ bodyCapturePromise = captureRequestBody(c.req.raw, observabilityConfig);
4703
+ }
4704
+ }
4705
+ let caughtError;
4706
+ try {
4707
+ await next();
4708
+ } catch (error) {
4709
+ caughtError = error;
4710
+ throw error;
4711
+ } finally {
4712
+ const durationMs = Date.now() - start;
4713
+ const statusCode = c.res.status || (caughtError ? 500 : 200);
4714
+ const sampled = shouldSampleRequest(
4715
+ requestId2,
4716
+ statusCode,
4717
+ observabilityConfig.sampling,
4718
+ "log"
4719
+ );
4720
+ const requestLogger = getRequestLogger(c, "api");
4721
+ const requestHeaders = redactHeaders(
4722
+ Object.fromEntries(c.req.raw.headers.entries()),
4723
+ observabilityConfig
4724
+ );
4725
+ const requestBodyCapture = bodyCapturePromise ? await bodyCapturePromise : void 0;
4726
+ if (requestBodyCapture) {
4727
+ c.set("requestBodyCapture", requestBodyCapture);
4728
+ }
4729
+ attachRequestMetrics(observability, {
4730
+ method,
4731
+ route: path,
4732
+ statusCode,
4733
+ durationMs
4734
+ });
4735
+ if (observabilityConfig.requestLogging.enabled && sampled) {
4736
+ const level = statusCode >= 500 ? "error" : statusCode >= 400 ? "warn" : "info";
4737
+ requestLogger[level]({
4738
+ msg: "HTTP request completed",
4739
+ method,
4740
+ path,
4741
+ statusCode,
4742
+ durationMs,
4743
+ requestId: requestId2,
4744
+ siteId: c.get("siteId"),
4745
+ workspaceId: c.get("workspaceId"),
4746
+ contentType: c.req.header("content-type") ?? void 0,
4747
+ contentLength: c.req.header("content-length") ? Number(c.req.header("content-length")) : void 0,
4748
+ sampled,
4749
+ headers: Object.keys(requestHeaders).length > 0 ? requestHeaders : void 0,
4750
+ body: requestBodyCapture?.body,
4751
+ bodyCapture: requestBodyCapture && requestBodyCapture.body === void 0 ? {
4752
+ attempted: requestBodyCapture.attempted,
4753
+ contentType: requestBodyCapture.contentType,
4754
+ contentLength: requestBodyCapture.contentLength,
4755
+ truncated: requestBodyCapture.truncated,
4756
+ parseFailed: requestBodyCapture.parseFailed
4757
+ } : void 0
4758
+ });
4759
+ }
4760
+ endRequestSpan(requestTrace, statusCode, caughtError);
4761
+ }
4762
+ });
4763
+ app.use("*", createRateLimitMiddleware(config));
4764
+ app.use("*", optionalAuth(config));
4765
+ app.use("*", cors(config.cors ? { origin: config.cors.origins } : {}));
3425
4766
  app.get("/health", (c) => c.json({ status: "ok", version: "0.0.1" }));
4767
+ if (observability.config.metrics.enabled && observability.config.metrics.exporter === "prometheus" && observability.prometheusExporter) {
4768
+ app.get(observability.config.metrics.path, async (c) => {
4769
+ const metrics = await renderPrometheusMetrics(
4770
+ observability.prometheusExporter
4771
+ );
4772
+ return new Response(metrics.body, {
4773
+ status: metrics.statusCode,
4774
+ headers: metrics.headers
4775
+ });
4776
+ });
4777
+ }
3426
4778
  app.get("/routes", (c) => {
3427
4779
  const routes = app.routes.map((r) => ({ method: r.method, path: r.path }));
3428
4780
  return c.json({ routes });
3429
4781
  });
3430
4782
  app.onError((err, c) => {
3431
- console.error(`[dyrected/core] Uncaught Error:`, err);
4783
+ const logger = getRequestLogger(c, "core");
4784
+ logger.error({
4785
+ err,
4786
+ msg: "Uncaught error",
4787
+ path: c.req.path,
4788
+ method: c.req.method
4789
+ });
4790
+ observability.recordUncaughtError({
4791
+ path: c.req.path,
4792
+ method: c.req.method
4793
+ });
3432
4794
  return c.json({
3433
4795
  message: err.message || "Internal Server Error",
3434
4796
  stack: process.env.NODE_ENV === "development" ? err.stack : void 0