@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.cjs CHANGED
@@ -171,12 +171,16 @@ var PopulationService = class {
171
171
  return data;
172
172
  }
173
173
  if (Array.isArray(data)) {
174
- return Promise.all(data.map((item) => this.populate({ ...args, data: item })));
174
+ return Promise.all(
175
+ data.map((item) => this.populate({ ...args, data: item }))
176
+ );
175
177
  }
176
178
  const populatedDoc = { ...data };
177
179
  for (const field of fields) {
178
180
  if (field.type === "join" && field.collection && field.on && currentDepth === 0) {
179
- const targetCollection = this.collections.find((c) => c.slug === field.collection);
181
+ const targetCollection = this.collections.find(
182
+ (c) => c.slug === field.collection
183
+ );
180
184
  if (targetCollection) {
181
185
  const docId = populatedDoc.id;
182
186
  if (docId) {
@@ -188,7 +192,9 @@ var PopulationService = class {
188
192
  limit: joinLimit
189
193
  });
190
194
  const populatedDocs = await this.populate({
191
- data: result.docs.map((doc) => DefaultsService.apply(targetCollection.fields, doc)),
195
+ data: result.docs.map(
196
+ (doc) => DefaultsService.apply(targetCollection.fields, doc)
197
+ ),
192
198
  fields: targetCollection.fields,
193
199
  currentDepth: 1,
194
200
  maxDepth
@@ -203,14 +209,21 @@ var PopulationService = class {
203
209
  continue;
204
210
  }
205
211
  if (field.type === "row" && field.fields) {
206
- const rowPopulated = await this.populate({ data, fields: field.fields, currentDepth, maxDepth });
212
+ const rowPopulated = await this.populate({
213
+ data,
214
+ fields: field.fields,
215
+ currentDepth,
216
+ maxDepth
217
+ });
207
218
  Object.assign(populatedDoc, rowPopulated);
208
219
  continue;
209
220
  }
210
221
  if (!field.name) continue;
211
222
  const value = populatedDoc[field.name];
212
223
  if (field.type === "relationship" && field.relationTo && value) {
213
- const relatedCollection = this.collections.find((c) => c.slug === field.relationTo);
224
+ const relatedCollection = this.collections.find(
225
+ (c) => c.slug === field.relationTo
226
+ );
214
227
  if (!relatedCollection) continue;
215
228
  if (Array.isArray(value)) {
216
229
  populatedDoc[field.name] = await Promise.all(
@@ -218,10 +231,16 @@ var PopulationService = class {
218
231
  if (!id) return id;
219
232
  let doc = id;
220
233
  if (typeof id === "string") {
221
- doc = await this.db.findOne({ collection: field.relationTo, id });
234
+ doc = await this.db.findOne({
235
+ collection: field.relationTo,
236
+ id
237
+ });
222
238
  }
223
239
  if (!doc || typeof doc !== "object") return id;
224
- const docWithDefaults = DefaultsService.apply(relatedCollection.fields, doc);
240
+ const docWithDefaults = DefaultsService.apply(
241
+ relatedCollection.fields,
242
+ doc
243
+ );
225
244
  return this.populate({
226
245
  data: docWithDefaults,
227
246
  fields: relatedCollection.fields,
@@ -233,10 +252,16 @@ var PopulationService = class {
233
252
  } else if (value) {
234
253
  let doc = value;
235
254
  if (typeof value === "string") {
236
- doc = await this.db.findOne({ collection: field.relationTo, id: value });
255
+ doc = await this.db.findOne({
256
+ collection: field.relationTo,
257
+ id: value
258
+ });
237
259
  }
238
260
  if (doc && typeof doc === "object") {
239
- const docWithDefaults = DefaultsService.apply(relatedCollection.fields, doc);
261
+ const docWithDefaults = DefaultsService.apply(
262
+ relatedCollection.fields,
263
+ doc
264
+ );
240
265
  populatedDoc[field.name] = await this.populate({
241
266
  data: docWithDefaults,
242
267
  fields: relatedCollection.fields,
@@ -247,11 +272,19 @@ var PopulationService = class {
247
272
  }
248
273
  }
249
274
  if (field.type === "url" && value && typeof value === "object" && value.type === "internal" && value.relationTo && value.value) {
250
- const relatedCollection = this.collections.find((c) => c.slug === value.relationTo);
275
+ const relatedCollection = this.collections.find(
276
+ (c) => c.slug === value.relationTo
277
+ );
251
278
  if (relatedCollection) {
252
- const doc = await this.db.findOne({ collection: value.relationTo, id: value.value });
279
+ const doc = await this.db.findOne({
280
+ collection: value.relationTo,
281
+ id: value.value
282
+ });
253
283
  if (doc && typeof doc === "object") {
254
- const docWithDefaults = DefaultsService.apply(relatedCollection.fields, doc);
284
+ const docWithDefaults = DefaultsService.apply(
285
+ relatedCollection.fields,
286
+ doc
287
+ );
255
288
  const populatedDocValue = await this.populate({
256
289
  data: docWithDefaults,
257
290
  fields: relatedCollection.fields,
@@ -269,15 +302,27 @@ var PopulationService = class {
269
302
  }
270
303
  } else if (typeof previewUrlPattern === "string") {
271
304
  if (previewUrlPattern.includes("{{")) {
272
- resolvedUrl = previewUrlPattern.replace(/{{(.*?)}}/g, (_, key) => String(docWithDefaults[key.trim()] || ""));
305
+ resolvedUrl = previewUrlPattern.replace(
306
+ /{{(.*?)}}/g,
307
+ (_, key) => String(docWithDefaults[key.trim()] || "")
308
+ );
273
309
  } else if (previewUrlPattern.includes("+") || previewUrlPattern.includes("?") || previewUrlPattern.includes("==") || previewUrlPattern.includes("siteUrl")) {
274
310
  try {
275
- resolvedUrl = import_jexl.default.evalSync(previewUrlPattern, docWithDefaults);
276
- } catch (err) {
277
- resolvedUrl = previewUrlPattern.replace(/:([a-zA-Z0-9_]+)/g, (_, key) => String(docWithDefaults[key] || ""));
311
+ resolvedUrl = import_jexl.default.evalSync(
312
+ previewUrlPattern,
313
+ docWithDefaults
314
+ );
315
+ } catch (_err) {
316
+ resolvedUrl = previewUrlPattern.replace(
317
+ /:([a-zA-Z0-9_]+)/g,
318
+ (_, key) => String(docWithDefaults[key] || "")
319
+ );
278
320
  }
279
321
  } else {
280
- resolvedUrl = previewUrlPattern.replace(/:([a-zA-Z0-9_]+)/g, (_, key) => String(docWithDefaults[key] || ""));
322
+ resolvedUrl = previewUrlPattern.replace(
323
+ /:([a-zA-Z0-9_]+)/g,
324
+ (_, key) => String(docWithDefaults[key] || "")
325
+ );
281
326
  }
282
327
  }
283
328
  }
@@ -301,7 +346,9 @@ var PopulationService = class {
301
346
  if (field.type === "blocks" && field.blocks && Array.isArray(value)) {
302
347
  populatedDoc[field.name] = await Promise.all(
303
348
  value.map(async (blockData) => {
304
- const blockConfig = field.blocks.find((b) => b.slug === blockData.blockType);
349
+ const blockConfig = field.blocks.find(
350
+ (b) => b.slug === blockData.blockType
351
+ );
305
352
  if (!blockConfig) return blockData;
306
353
  return this.populate({
307
354
  data: blockData,
@@ -333,13 +380,494 @@ var PopulationService = class {
333
380
  }
334
381
  };
335
382
 
383
+ // src/observability.ts
384
+ var import_api = require("@opentelemetry/api");
385
+ var import_exporter_metrics_otlp_http = require("@opentelemetry/exporter-metrics-otlp-http");
386
+ var import_exporter_prometheus = require("@opentelemetry/exporter-prometheus");
387
+ var import_exporter_trace_otlp_http = require("@opentelemetry/exporter-trace-otlp-http");
388
+ var import_resources = require("@opentelemetry/resources");
389
+ var import_sdk_metrics = require("@opentelemetry/sdk-metrics");
390
+ var import_sdk_trace_base = require("@opentelemetry/sdk-trace-base");
391
+ var import_pino = __toESM(require("pino"), 1);
392
+ var import_pino_pretty = __toESM(require("pino-pretty"), 1);
393
+ var import_node_stream = require("stream");
394
+ var DEFAULT_REDACT_HEADERS = ["authorization", "cookie", "set-cookie", "x-api-key"];
395
+ var DEFAULT_REDACT_PATHS = [
396
+ "password",
397
+ "currentPassword",
398
+ "newPassword",
399
+ "confirmPassword",
400
+ "token",
401
+ "refreshToken",
402
+ "accessToken",
403
+ "secret",
404
+ "apiKey",
405
+ "inviteToken",
406
+ "resetToken"
407
+ ];
408
+ var DEFAULT_MAX_BODY_BYTES = 8192;
409
+ var DEFAULT_SUCCESS_SAMPLE_RATE = 0.1;
410
+ var DEFAULT_BODY_SAMPLE_RATE = 0.02;
411
+ var REDACTED_VALUE = "[REDACTED]";
412
+ var runtimeByConfig = /* @__PURE__ */ new WeakMap();
413
+ var fallbackLogger = (0, import_pino.default)({
414
+ name: "dyrected",
415
+ enabled: process.env.DISABLE_LOGGING !== "true",
416
+ timestamp: import_pino.stdTimeFunctions.isoTime
417
+ });
418
+ function resolveObservabilityConfig(config) {
419
+ const requestLogging = config?.requestLogging;
420
+ const sampling = config?.sampling;
421
+ const tracing = config?.tracing;
422
+ const metrics = config?.metrics;
423
+ return {
424
+ requestLogging: {
425
+ enabled: requestLogging?.enabled ?? true,
426
+ logBodies: requestLogging?.logBodies ?? false,
427
+ maxBodyBytes: requestLogging?.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES,
428
+ redactPaths: uniqueValues([
429
+ ...DEFAULT_REDACT_PATHS,
430
+ ...requestLogging?.redactPaths ?? []
431
+ ]),
432
+ includeHeaders: normalizeHeaderNames(requestLogging?.includeHeaders ?? []),
433
+ redactHeaders: uniqueValues(
434
+ normalizeHeaderNames([
435
+ ...DEFAULT_REDACT_HEADERS,
436
+ ...requestLogging?.redactHeaders ?? []
437
+ ])
438
+ )
439
+ },
440
+ sampling: {
441
+ successRate: clampRate(sampling?.successRate ?? DEFAULT_SUCCESS_SAMPLE_RATE),
442
+ traceSuccessRate: clampRate(
443
+ sampling?.traceSuccessRate ?? sampling?.successRate ?? DEFAULT_SUCCESS_SAMPLE_RATE
444
+ ),
445
+ bodySuccessRate: clampRate(
446
+ sampling?.bodySuccessRate ?? Math.min(DEFAULT_BODY_SAMPLE_RATE, sampling?.successRate ?? DEFAULT_SUCCESS_SAMPLE_RATE)
447
+ ),
448
+ alwaysKeep4xx: sampling?.alwaysKeep4xx ?? true,
449
+ alwaysKeep5xx: sampling?.alwaysKeep5xx ?? true
450
+ },
451
+ tracing: {
452
+ enabled: tracing?.enabled ?? false,
453
+ serviceName: tracing?.serviceName ?? "dyrected",
454
+ exporter: tracing?.exporter ?? "none",
455
+ headers: tracing?.headers ?? {},
456
+ endpoint: tracing?.endpoint
457
+ },
458
+ metrics: {
459
+ enabled: metrics?.enabled ?? false,
460
+ exporter: metrics?.exporter ?? "none",
461
+ endpoint: metrics?.endpoint,
462
+ path: metrics?.path ?? "/metrics"
463
+ },
464
+ transports: {
465
+ targets: config?.transports?.targets && config.transports.targets.length > 0 ? config.transports.targets : [{ type: "stdout" }]
466
+ }
467
+ };
468
+ }
469
+ function createObservabilityRuntime(config) {
470
+ const resolved = resolveObservabilityConfig(config.observability);
471
+ const logger = createRootLogger(config.logger, resolved);
472
+ const shutdownTasks = [];
473
+ const meterRuntime = resolved.metrics.enabled || resolved.metrics.exporter === "prometheus" ? createMeterProvider(resolved) : void 0;
474
+ const tracerProvider = resolved.tracing.enabled ? createTracerProvider(resolved) : void 0;
475
+ const tracer = tracerProvider?.getTracer("dyrected");
476
+ const meter = meterRuntime?.provider.getMeter("dyrected");
477
+ const metrics = meter ? {
478
+ requestCount: meter.createCounter("dyrected_http_requests_total", {
479
+ description: "Total HTTP requests served by Dyrected"
480
+ }),
481
+ authFailureCount: meter.createCounter("dyrected_auth_failures_total", {
482
+ description: "Authentication failures"
483
+ }),
484
+ uncaughtErrorCount: meter.createCounter("dyrected_uncaught_errors_total", {
485
+ description: "Uncaught application errors"
486
+ }),
487
+ auditWriteFailureCount: meter.createCounter("dyrected_audit_write_failures_total", {
488
+ description: "Audit log persistence failures"
489
+ }),
490
+ emailSendFailureCount: meter.createCounter("dyrected_email_send_failures_total", {
491
+ description: "Transactional email delivery failures"
492
+ }),
493
+ workflowHookFailureCount: meter.createCounter("dyrected_workflow_hook_failures_total", {
494
+ description: "Workflow hook failures isolated after successful writes"
495
+ }),
496
+ requestDuration: meter.createHistogram("dyrected_http_request_duration_ms", {
497
+ description: "HTTP request duration in milliseconds",
498
+ unit: "ms"
499
+ })
500
+ } : void 0;
501
+ if (meterRuntime) {
502
+ shutdownTasks.push(() => meterRuntime.provider.shutdown());
503
+ }
504
+ if (tracerProvider) {
505
+ shutdownTasks.push(() => tracerProvider.shutdown());
506
+ }
507
+ return {
508
+ logger,
509
+ config: resolved,
510
+ tracer,
511
+ metrics,
512
+ prometheusExporter: meterRuntime?.prometheusExporter,
513
+ shutdown: async () => {
514
+ for (const task of shutdownTasks) {
515
+ await task();
516
+ }
517
+ },
518
+ recordAuthFailure: (attributes) => metrics?.authFailureCount.add(1, attributes),
519
+ recordUncaughtError: (attributes) => metrics?.uncaughtErrorCount.add(1, attributes),
520
+ recordAuditWriteFailure: (attributes) => metrics?.auditWriteFailureCount.add(1, attributes),
521
+ recordEmailSendFailure: (attributes) => metrics?.emailSendFailureCount.add(1, attributes),
522
+ recordWorkflowHookFailure: (attributes) => metrics?.workflowHookFailureCount.add(1, attributes)
523
+ };
524
+ }
525
+ function bindObservabilityRuntime(config, runtime) {
526
+ runtimeByConfig.set(config, runtime);
527
+ }
528
+ function getObservabilityRuntime(config) {
529
+ if (!config || typeof config !== "object") return void 0;
530
+ return runtimeByConfig.get(config);
531
+ }
532
+ function createRootLogger(loggerConfig, observability) {
533
+ if (loggerConfig && !("options" in loggerConfig)) {
534
+ return loggerConfig;
535
+ }
536
+ const options = loggerConfig?.options ?? {};
537
+ const enabled = options.enabled ?? process.env.DISABLE_LOGGING !== "true";
538
+ const name = options.name ?? "dyrected";
539
+ if (loggerConfig?.destination) {
540
+ return (0, import_pino.default)(
541
+ {
542
+ ...options,
543
+ enabled,
544
+ name,
545
+ timestamp: options.timestamp ?? import_pino.stdTimeFunctions.isoTime
546
+ },
547
+ loggerConfig.destination
548
+ );
549
+ }
550
+ const streams = buildTransportStreams(observability);
551
+ const destination = streams.length === 1 ? streams[0] : (0, import_pino.multistream)(streams.map((stream) => ({ stream })));
552
+ return (0, import_pino.default)(
553
+ {
554
+ ...options,
555
+ enabled,
556
+ name,
557
+ timestamp: options.timestamp ?? import_pino.stdTimeFunctions.isoTime
558
+ },
559
+ destination
560
+ );
561
+ }
562
+ function buildTransportStreams(observability) {
563
+ const targets = observability.transports.targets;
564
+ if (targets.length === 0) {
565
+ return [defaultLoggerDestination()];
566
+ }
567
+ return targets.map((target) => {
568
+ switch (target.type) {
569
+ case "stdout":
570
+ return defaultLoggerDestination();
571
+ case "stderr":
572
+ return (0, import_pino.destination)(2);
573
+ case "file":
574
+ return (0, import_pino.destination)(target.path);
575
+ case "otlp":
576
+ return new OtlpLogWritable(target.endpoint, target.headers);
577
+ }
578
+ });
579
+ }
580
+ function defaultLoggerDestination() {
581
+ if (process.env.NODE_ENV !== "production") {
582
+ return (0, import_pino_pretty.default)({
583
+ colorize: true,
584
+ ignore: "pid,hostname",
585
+ translateTime: "SYS:HH:MM:ss",
586
+ destination: 1,
587
+ sync: true
588
+ });
589
+ }
590
+ return (0, import_pino.destination)(1);
591
+ }
592
+ var OtlpLogWritable = class extends import_node_stream.Writable {
593
+ constructor(endpoint, headers = {}) {
594
+ super();
595
+ this.endpoint = endpoint;
596
+ this.headers = headers;
597
+ }
598
+ endpoint;
599
+ headers;
600
+ _write(chunk, _encoding, callback) {
601
+ const payload = chunk.toString();
602
+ void fetch(this.endpoint, {
603
+ method: "POST",
604
+ headers: {
605
+ "content-type": "application/json",
606
+ ...this.headers
607
+ },
608
+ body: JSON.stringify({ logs: payload.trim().split("\n").filter(Boolean) })
609
+ }).then(() => callback()).catch(
610
+ (error) => callback(error instanceof Error ? error : new Error(String(error)))
611
+ );
612
+ }
613
+ };
614
+ function createTracerProvider(observability) {
615
+ const exporter = createTraceExporter(observability);
616
+ const provider = new import_sdk_trace_base.BasicTracerProvider({
617
+ resource: (0, import_resources.resourceFromAttributes)({
618
+ "service.name": observability.tracing.serviceName
619
+ }),
620
+ spanProcessors: exporter ? [new import_sdk_trace_base.BatchSpanProcessor(exporter)] : []
621
+ });
622
+ import_api.trace.setGlobalTracerProvider(provider);
623
+ return provider;
624
+ }
625
+ function createTraceExporter(observability) {
626
+ switch (observability.tracing.exporter) {
627
+ case "console":
628
+ return new import_sdk_trace_base.ConsoleSpanExporter();
629
+ case "otlp":
630
+ return new import_exporter_trace_otlp_http.OTLPTraceExporter({
631
+ url: observability.tracing.endpoint,
632
+ headers: observability.tracing.headers
633
+ });
634
+ default:
635
+ return void 0;
636
+ }
637
+ }
638
+ function createMeterProvider(observability) {
639
+ const readers = [];
640
+ let prometheusExporter;
641
+ switch (observability.metrics.exporter) {
642
+ case "otlp":
643
+ readers.push(
644
+ new import_sdk_metrics.PeriodicExportingMetricReader({
645
+ exporter: new import_exporter_metrics_otlp_http.OTLPMetricExporter({
646
+ url: observability.metrics.endpoint
647
+ })
648
+ })
649
+ );
650
+ break;
651
+ case "prometheus":
652
+ prometheusExporter = new import_exporter_prometheus.PrometheusExporter({
653
+ endpoint: observability.metrics.path,
654
+ preventServerStart: true
655
+ });
656
+ readers.push(prometheusExporter);
657
+ break;
658
+ }
659
+ const provider = new import_sdk_metrics.MeterProvider({
660
+ resource: (0, import_resources.resourceFromAttributes)({
661
+ "service.name": observability.tracing.serviceName
662
+ }),
663
+ readers
664
+ });
665
+ return { provider, prometheusExporter };
666
+ }
667
+ function getConfigLogger(config, component) {
668
+ const base = config?.logger && !("options" in config.logger) ? config.logger : fallbackLogger;
669
+ return base.child({ component });
670
+ }
671
+ function getRequestLogger(c, component) {
672
+ const base = c.get("logger") ?? fallbackLogger;
673
+ return component ? base.child({ component }) : base;
674
+ }
675
+ function buildRequestLogger(runtime, vars, requestId2) {
676
+ const trace2 = vars.requestTrace;
677
+ return runtime.logger.child({
678
+ requestId: requestId2,
679
+ siteId: vars.siteId,
680
+ workspaceId: vars.workspaceId,
681
+ traceId: trace2?.traceId,
682
+ spanId: trace2?.spanId
683
+ });
684
+ }
685
+ function shouldSampleRequest(requestId2, statusCode, sampling, kind) {
686
+ if (statusCode >= 500 && sampling.alwaysKeep5xx) return true;
687
+ if (statusCode >= 400 && sampling.alwaysKeep4xx) return true;
688
+ const rate = kind === "trace" ? sampling.traceSuccessRate : kind === "body" ? sampling.bodySuccessRate : sampling.successRate;
689
+ return deterministicSample(requestId2 ?? "no-request-id", rate);
690
+ }
691
+ function redactHeaders(headers, observability) {
692
+ const includeHeaders = new Set(observability.requestLogging.includeHeaders);
693
+ const redactHeaders2 = new Set(observability.requestLogging.redactHeaders);
694
+ const output = {};
695
+ for (const [key, value] of Object.entries(headers)) {
696
+ const normalizedKey = key.toLowerCase();
697
+ if (includeHeaders.size > 0 && !includeHeaders.has(normalizedKey)) continue;
698
+ output[normalizedKey] = redactHeaders2.has(normalizedKey) ? REDACTED_VALUE : value;
699
+ }
700
+ return output;
701
+ }
702
+ async function captureRequestBody(request, observability) {
703
+ const contentType = request.headers.get("content-type") ?? void 0;
704
+ const contentLength = toNumber(request.headers.get("content-length"));
705
+ const jsonBody = contentType?.includes("application/json");
706
+ if (!jsonBody) {
707
+ return {
708
+ attempted: false,
709
+ contentType,
710
+ contentLength
711
+ };
712
+ }
713
+ try {
714
+ const text = await request.clone().text();
715
+ if (!text) {
716
+ return { attempted: true, contentType, contentLength };
717
+ }
718
+ const truncated = text.length > observability.requestLogging.maxBodyBytes;
719
+ const raw = truncated ? text.slice(0, observability.requestLogging.maxBodyBytes) : text;
720
+ try {
721
+ const parsed = JSON.parse(raw);
722
+ return {
723
+ attempted: true,
724
+ contentType,
725
+ contentLength: contentLength ?? text.length,
726
+ truncated,
727
+ body: redactBody(parsed, observability.requestLogging.redactPaths)
728
+ };
729
+ } catch {
730
+ return {
731
+ attempted: true,
732
+ contentType,
733
+ contentLength: contentLength ?? text.length,
734
+ truncated,
735
+ parseFailed: true
736
+ };
737
+ }
738
+ } catch {
739
+ return {
740
+ attempted: true,
741
+ contentType,
742
+ contentLength,
743
+ parseFailed: true
744
+ };
745
+ }
746
+ }
747
+ function redactBody(value, redactPaths) {
748
+ const cloned = deepClone(value);
749
+ for (const path of redactPaths) {
750
+ applyRedaction(cloned, path.split("."));
751
+ }
752
+ return cloned;
753
+ }
754
+ function applyRedaction(target, segments) {
755
+ if (!target || segments.length === 0) return;
756
+ const [segment, ...rest] = segments;
757
+ if (Array.isArray(target)) {
758
+ for (const item of target) {
759
+ if (segment === "*") {
760
+ applyRedaction(item, rest);
761
+ } else {
762
+ applyRedaction(item, segments);
763
+ }
764
+ }
765
+ return;
766
+ }
767
+ if (typeof target !== "object") return;
768
+ const record = target;
769
+ if (segment === "*") {
770
+ for (const value of Object.values(record)) {
771
+ applyRedaction(value, rest);
772
+ }
773
+ return;
774
+ }
775
+ if (!(segment in record)) return;
776
+ if (rest.length === 0) {
777
+ record[segment] = REDACTED_VALUE;
778
+ return;
779
+ }
780
+ applyRedaction(record[segment], rest);
781
+ }
782
+ function deepClone(value) {
783
+ if (typeof structuredClone === "function") {
784
+ return structuredClone(value);
785
+ }
786
+ return JSON.parse(JSON.stringify(value));
787
+ }
788
+ function createRequestSpan(runtime, name, attributes) {
789
+ if (!runtime.tracer) return void 0;
790
+ const span = runtime.tracer.startSpan(name, { attributes }, import_api.context.active());
791
+ const spanContext = span.spanContext();
792
+ return {
793
+ span,
794
+ traceId: spanContext.traceId,
795
+ spanId: spanContext.spanId,
796
+ sampled: spanContext.traceFlags === import_api.TraceFlags.SAMPLED
797
+ };
798
+ }
799
+ function endRequestSpan(requestTrace, statusCode, error) {
800
+ if (!requestTrace) return;
801
+ requestTrace.span.setAttribute("http.status_code", statusCode);
802
+ if (error) {
803
+ requestTrace.span.recordException(error instanceof Error ? error : new Error(String(error)));
804
+ }
805
+ requestTrace.span.end();
806
+ }
807
+ function attachRequestMetrics(runtime, args) {
808
+ runtime.metrics?.requestCount.add(1, {
809
+ method: args.method,
810
+ route: args.route,
811
+ status_class: `${Math.floor(args.statusCode / 100)}xx`
812
+ });
813
+ runtime.metrics?.requestDuration.record(args.durationMs, {
814
+ method: args.method,
815
+ route: args.route,
816
+ status_class: `${Math.floor(args.statusCode / 100)}xx`
817
+ });
818
+ }
819
+ async function renderPrometheusMetrics(exporter) {
820
+ let statusCode = 200;
821
+ const headers = {};
822
+ let body = "";
823
+ await new Promise((resolve) => {
824
+ const response = {
825
+ statusCode,
826
+ setHeader(name, value) {
827
+ headers[name.toLowerCase()] = value;
828
+ },
829
+ end(chunk) {
830
+ if (chunk) body += chunk;
831
+ statusCode = this.statusCode;
832
+ resolve();
833
+ }
834
+ };
835
+ exporter.getMetricsRequestHandler({}, response);
836
+ });
837
+ return { body, headers, statusCode };
838
+ }
839
+ function normalizeHeaderNames(headers) {
840
+ return headers.map((value) => value.toLowerCase());
841
+ }
842
+ function uniqueValues(values) {
843
+ return [...new Set(values)];
844
+ }
845
+ function clampRate(value) {
846
+ if (Number.isNaN(value)) return 0;
847
+ return Math.max(0, Math.min(1, value));
848
+ }
849
+ function deterministicSample(seed, rate) {
850
+ if (rate >= 1) return true;
851
+ if (rate <= 0) return false;
852
+ let hash = 0;
853
+ for (let index = 0; index < seed.length; index += 1) {
854
+ hash = hash * 31 + seed.charCodeAt(index) >>> 0;
855
+ }
856
+ return hash / 4294967295 <= rate;
857
+ }
858
+ function toNumber(value) {
859
+ if (!value) return void 0;
860
+ const parsed = Number(value);
861
+ return Number.isFinite(parsed) ? parsed : void 0;
862
+ }
863
+
336
864
  // src/services/audit.service.ts
337
865
  var AuditService = class {
338
866
  /**
339
867
  * Writes a single entry to the __audit collection.
340
868
  * Called without await — runs asynchronously and never blocks the primary operation.
341
869
  */
342
- static async log(db, args) {
870
+ static async log(db, args, config) {
343
871
  try {
344
872
  await db.create({
345
873
  collection: "__audit",
@@ -356,7 +884,17 @@ var AuditService = class {
356
884
  }
357
885
  });
358
886
  } catch (err) {
359
- console.error("[dyrected/audit] Failed to write audit log:", err);
887
+ getObservabilityRuntime(config)?.recordAuditWriteFailure({
888
+ collection: args.collection,
889
+ operation: args.operation
890
+ });
891
+ getConfigLogger(config, "audit").error({
892
+ err,
893
+ msg: "Failed to write audit log",
894
+ collection: args.collection,
895
+ operation: args.operation,
896
+ documentId: args.documentId
897
+ });
360
898
  }
361
899
  }
362
900
  };
@@ -381,6 +919,151 @@ async function verifyPassword(plain, stored) {
381
919
  return (0, import_node_crypto.timingSafeEqual)(derivedKey, storedBuffer);
382
920
  }
383
921
 
922
+ // src/auth/token.ts
923
+ var import_jose = require("jose");
924
+ var import_node_util2 = require("util");
925
+ function getSecret() {
926
+ const secret = process.env.DYRECTED_JWT_SECRET;
927
+ if (!secret) {
928
+ throw new Error(
929
+ "[dyrected/core] DYRECTED_JWT_SECRET is not set. Add it to your environment variables to enable auth collections."
930
+ );
931
+ }
932
+ return new import_node_util2.TextEncoder().encode(secret);
933
+ }
934
+ var DEFAULT_EXPIRY = "7d";
935
+ async function signCollectionToken(payload, expiresIn = DEFAULT_EXPIRY) {
936
+ return new import_jose.SignJWT({ ...payload }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(expiresIn).sign(getSecret());
937
+ }
938
+ async function verifyCollectionToken(token) {
939
+ const { payload } = await (0, import_jose.jwtVerify)(token, getSecret());
940
+ return payload;
941
+ }
942
+ function decodeCollectionToken(token) {
943
+ try {
944
+ return (0, import_jose.decodeJwt)(token);
945
+ } catch {
946
+ return null;
947
+ }
948
+ }
949
+
950
+ // src/auth/sessions.ts
951
+ var AUTH_SESSIONS_COLLECTION = "__auth_sessions";
952
+ async function issueAuthSessionToken(args) {
953
+ const sessionId = createSessionId();
954
+ const now = (/* @__PURE__ */ new Date()).toISOString();
955
+ const db = args.config.db;
956
+ if (db) {
957
+ await db.create({
958
+ collection: AUTH_SESSIONS_COLLECTION,
959
+ data: {
960
+ id: sessionId,
961
+ userId: args.userId,
962
+ email: args.email,
963
+ collection: args.collection,
964
+ authSource: args.authSource ?? "local",
965
+ providerId: args.providerId,
966
+ lastIp: args.ip,
967
+ lastSeenAt: now,
968
+ revokedAt: null,
969
+ expiresAt: null
970
+ }
971
+ });
972
+ }
973
+ const token = await signCollectionToken(
974
+ {
975
+ sub: args.userId,
976
+ email: args.email,
977
+ collection: args.collection,
978
+ authSource: args.authSource,
979
+ providerId: args.providerId,
980
+ sid: sessionId
981
+ },
982
+ args.expiresIn
983
+ );
984
+ const payload = decodeCollectionToken(token);
985
+ const expiresAt = payload?.exp != null ? new Date(payload.exp * 1e3).toISOString() : null;
986
+ if (db && expiresAt) {
987
+ await db.update({
988
+ collection: AUTH_SESSIONS_COLLECTION,
989
+ id: sessionId,
990
+ data: {
991
+ expiresAt,
992
+ lastSeenAt: now,
993
+ lastIp: args.ip
994
+ }
995
+ });
996
+ }
997
+ return token;
998
+ }
999
+ async function getAuthSession(config, sessionId) {
1000
+ const db = config.db;
1001
+ if (!db) return null;
1002
+ return await db.findOne({
1003
+ collection: AUTH_SESSIONS_COLLECTION,
1004
+ id: sessionId
1005
+ });
1006
+ }
1007
+ function isAuthSessionActive(session) {
1008
+ if (!session) return false;
1009
+ if (session.revokedAt) return false;
1010
+ if (session.expiresAt && new Date(session.expiresAt).getTime() <= Date.now()) {
1011
+ return false;
1012
+ }
1013
+ return true;
1014
+ }
1015
+ async function touchAuthSession(config, sessionId, args) {
1016
+ const db = config.db;
1017
+ if (!db) return;
1018
+ await db.update({
1019
+ collection: AUTH_SESSIONS_COLLECTION,
1020
+ id: sessionId,
1021
+ data: {
1022
+ lastSeenAt: (/* @__PURE__ */ new Date()).toISOString(),
1023
+ ...args.ip ? { lastIp: args.ip } : {}
1024
+ }
1025
+ });
1026
+ }
1027
+ async function revokeAuthSession(config, sessionId) {
1028
+ const db = config.db;
1029
+ if (!db) return;
1030
+ await db.update({
1031
+ collection: AUTH_SESSIONS_COLLECTION,
1032
+ id: sessionId,
1033
+ data: {
1034
+ revokedAt: (/* @__PURE__ */ new Date()).toISOString()
1035
+ }
1036
+ });
1037
+ }
1038
+ async function revokeAllAuthSessions(config, args) {
1039
+ const db = config.db;
1040
+ if (!db) return 0;
1041
+ let page = 1;
1042
+ let revoked = 0;
1043
+ while (true) {
1044
+ const result = await db.find({
1045
+ collection: AUTH_SESSIONS_COLLECTION,
1046
+ where: {
1047
+ userId: { equals: args.userId },
1048
+ collection: { equals: args.collection }
1049
+ },
1050
+ page,
1051
+ limit: 100
1052
+ });
1053
+ for (const session of result.docs) {
1054
+ if (session.revokedAt) continue;
1055
+ await revokeAuthSession(config, session.id);
1056
+ revoked += 1;
1057
+ }
1058
+ if (!result.hasNextPage) break;
1059
+ page += 1;
1060
+ }
1061
+ return revoked;
1062
+ }
1063
+ function createSessionId() {
1064
+ return globalThis.crypto?.randomUUID?.() ? globalThis.crypto.randomUUID() : `sess_${Date.now()}_${Math.random().toString(36).slice(2)}`;
1065
+ }
1066
+
384
1067
  // src/utils/hooks.ts
385
1068
  async function runCollectionHooks(hooks, args, options = {}) {
386
1069
  if (!hooks || hooks.length === 0) {
@@ -399,7 +1082,10 @@ async function runCollectionHooks(hooks, args, options = {}) {
399
1082
  }
400
1083
  } catch (err) {
401
1084
  if (options.isolated) {
402
- console.error("[dyrected/core] Side-effect hook failed (error isolated \u2014 DB write was successful):", err);
1085
+ getConfigLogger(void 0, "hooks").error({
1086
+ err,
1087
+ msg: "Side-effect hook failed after a successful write"
1088
+ });
403
1089
  } else {
404
1090
  throw err;
405
1091
  }
@@ -444,7 +1130,13 @@ async function executeFieldBeforeChange(fields, data, originalDoc, user, db) {
444
1130
  const item = updatedValue[i];
445
1131
  const origItem = Array.isArray(origValue) ? origValue[i] : null;
446
1132
  arrayResult.push(
447
- await executeFieldBeforeChange(field.fields, item, origItem, user, db)
1133
+ await executeFieldBeforeChange(
1134
+ field.fields,
1135
+ item,
1136
+ origItem,
1137
+ user,
1138
+ db
1139
+ )
448
1140
  );
449
1141
  }
450
1142
  result[field.name] = arrayResult;
@@ -604,7 +1296,11 @@ async function evaluateAccess(expression, context) {
604
1296
  }
605
1297
  return !!result;
606
1298
  } catch (err) {
607
- console.error("[dyrected/core] Jexl evaluation failed:", err);
1299
+ getConfigLogger(context.config, "access").error({
1300
+ err,
1301
+ msg: "Jexl evaluation failed",
1302
+ expression
1303
+ });
608
1304
  return false;
609
1305
  }
610
1306
  }
@@ -616,30 +1312,40 @@ function isNamedAccessPolicy(value) {
616
1312
  async function resolveAccess(config, access, args) {
617
1313
  if (access === void 0 || access === null) return void 0;
618
1314
  if (typeof access === "boolean" || typeof access === "string") {
619
- return evaluateAccess(access, args);
1315
+ return evaluateAccess(access, { ...args, config });
620
1316
  }
621
1317
  if (typeof access === "function") {
622
1318
  try {
623
1319
  return await access(args);
624
1320
  } catch (err) {
625
- console.error("[dyrected/core] Functional access check failed:", err);
1321
+ getConfigLogger(config, "access").error({
1322
+ err,
1323
+ msg: "Functional access check failed"
1324
+ });
626
1325
  return false;
627
1326
  }
628
1327
  }
629
1328
  if (isNamedAccessPolicy(access)) {
630
1329
  const policy = config.accessPolicies?.[access.policy];
631
1330
  if (policy === void 0) {
632
- console.error(`[dyrected/core] Unknown access policy "${access.policy}".`);
1331
+ getConfigLogger(config, "access").error({
1332
+ msg: "Unknown access policy",
1333
+ policy: access.policy
1334
+ });
633
1335
  return false;
634
1336
  }
635
1337
  if (typeof policy === "string" || typeof policy === "boolean") {
636
- return evaluateAccess(policy, args);
1338
+ return evaluateAccess(policy, { ...args, config });
637
1339
  }
638
1340
  try {
639
1341
  const resolver = policy;
640
1342
  return await resolver({ ...args, params: access.params });
641
1343
  } catch (err) {
642
- console.error(`[dyrected/core] Access policy "${access.policy}" failed:`, err);
1344
+ getConfigLogger(config, "access").error({
1345
+ err,
1346
+ msg: "Access policy failed",
1347
+ policy: access.policy
1348
+ });
643
1349
  return false;
644
1350
  }
645
1351
  }
@@ -726,32 +1432,48 @@ async function applyFieldReadAccess(context, doc) {
726
1432
  const result = { ...doc };
727
1433
  for (const field of context.fields) {
728
1434
  if (field.type === "row" && field.fields) {
729
- Object.assign(result, await applyFieldReadAccess({ ...context, fields: field.fields }, result));
1435
+ Object.assign(
1436
+ result,
1437
+ await applyFieldReadAccess(
1438
+ { ...context, fields: field.fields },
1439
+ result
1440
+ )
1441
+ );
730
1442
  continue;
731
1443
  }
732
1444
  if (!field.name) continue;
733
1445
  const value = result[field.name];
734
- const canRead = await resolveBooleanAccess(context.config, field.access?.read, {
735
- user: context.user,
736
- req: context.req,
737
- doc: context.doc,
738
- data: context.data,
739
- id: resolveFieldAccessId(context)
740
- });
1446
+ const canRead = await resolveBooleanAccess(
1447
+ context.config,
1448
+ field.access?.read,
1449
+ {
1450
+ user: context.user,
1451
+ req: context.req,
1452
+ doc: context.doc,
1453
+ data: context.data,
1454
+ id: resolveFieldAccessId(context)
1455
+ }
1456
+ );
741
1457
  if (!canRead) {
742
1458
  delete result[field.name];
743
1459
  continue;
744
1460
  }
745
1461
  if (value === void 0 || value === null) continue;
746
1462
  if (field.type === "object" && field.fields && typeof value === "object" && !Array.isArray(value)) {
747
- result[field.name] = await applyFieldReadAccess({ ...context, fields: field.fields }, value);
1463
+ result[field.name] = await applyFieldReadAccess(
1464
+ { ...context, fields: field.fields },
1465
+ value
1466
+ );
748
1467
  continue;
749
1468
  }
750
1469
  if (field.type === "array" && field.fields && Array.isArray(value)) {
751
1470
  const childFields = field.fields;
752
1471
  result[field.name] = await Promise.all(
753
1472
  value.map(
754
- (item) => typeof item === "object" && item !== null ? applyFieldReadAccess({ ...context, fields: childFields }, item) : item
1473
+ (item) => typeof item === "object" && item !== null ? applyFieldReadAccess(
1474
+ { ...context, fields: childFields },
1475
+ item
1476
+ ) : item
755
1477
  )
756
1478
  );
757
1479
  continue;
@@ -761,9 +1483,14 @@ async function applyFieldReadAccess(context, doc) {
761
1483
  value.map(async (item) => {
762
1484
  if (typeof item !== "object" || item === null) return item;
763
1485
  const typedItem = item;
764
- const block = field.blocks?.find((candidate) => candidate.slug === typedItem.blockType);
1486
+ const block = field.blocks?.find(
1487
+ (candidate) => candidate.slug === typedItem.blockType
1488
+ );
765
1489
  if (!block || !block.fields) return item;
766
- return applyFieldReadAccess({ ...context, fields: block.fields }, typedItem);
1490
+ return applyFieldReadAccess(
1491
+ { ...context, fields: block.fields },
1492
+ typedItem
1493
+ );
767
1494
  })
768
1495
  );
769
1496
  }
@@ -775,7 +1502,13 @@ async function applyFieldWriteAccess(context, data) {
775
1502
  const result = { ...data };
776
1503
  for (const field of context.fields) {
777
1504
  if (field.type === "row" && field.fields) {
778
- Object.assign(result, await applyFieldWriteAccess({ ...context, fields: field.fields }, result));
1505
+ Object.assign(
1506
+ result,
1507
+ await applyFieldWriteAccess(
1508
+ { ...context, fields: field.fields },
1509
+ result
1510
+ )
1511
+ );
779
1512
  continue;
780
1513
  }
781
1514
  if (!field.name || !(field.name in result)) continue;
@@ -794,14 +1527,20 @@ async function applyFieldWriteAccess(context, data) {
794
1527
  const value = result[field.name];
795
1528
  if (value === void 0 || value === null) continue;
796
1529
  if (field.type === "object" && field.fields && typeof value === "object" && !Array.isArray(value)) {
797
- result[field.name] = await applyFieldWriteAccess({ ...context, fields: field.fields }, value);
1530
+ result[field.name] = await applyFieldWriteAccess(
1531
+ { ...context, fields: field.fields },
1532
+ value
1533
+ );
798
1534
  continue;
799
1535
  }
800
1536
  if (field.type === "array" && field.fields && Array.isArray(value)) {
801
1537
  const childFields = field.fields;
802
1538
  result[field.name] = await Promise.all(
803
1539
  value.map(
804
- (item) => typeof item === "object" && item !== null ? applyFieldWriteAccess({ ...context, fields: childFields }, item) : item
1540
+ (item) => typeof item === "object" && item !== null ? applyFieldWriteAccess(
1541
+ { ...context, fields: childFields },
1542
+ item
1543
+ ) : item
805
1544
  )
806
1545
  );
807
1546
  continue;
@@ -811,9 +1550,14 @@ async function applyFieldWriteAccess(context, data) {
811
1550
  value.map(async (item) => {
812
1551
  if (typeof item !== "object" || item === null) return item;
813
1552
  const typedItem = item;
814
- const block = field.blocks?.find((candidate) => candidate.slug === typedItem.blockType);
1553
+ const block = field.blocks?.find(
1554
+ (candidate) => candidate.slug === typedItem.blockType
1555
+ );
815
1556
  if (!block || !block.fields) return item;
816
- return applyFieldWriteAccess({ ...context, fields: block.fields }, typedItem);
1557
+ return applyFieldWriteAccess(
1558
+ { ...context, fields: block.fields },
1559
+ typedItem
1560
+ );
817
1561
  })
818
1562
  );
819
1563
  }
@@ -839,6 +1583,9 @@ function simplePublishingWorkflow() {
839
1583
  { name: "publish", label: "Publish", from: "draft", to: "published" },
840
1584
  { name: "unpublish", label: "Unpublish", from: "published", to: "draft", unpublish: true }
841
1585
  ]
1586
+ // Intentionally no `roles`: this lightweight workflow (synthesized from
1587
+ // `drafts: true`) does no capability-based gating. Draft visibility for a
1588
+ // no-roles workflow is handled in `canViewWorkflowDraft`.
842
1589
  };
843
1590
  }
844
1591
  function publicMetadata(meta) {
@@ -857,9 +1604,13 @@ function canViewWorkflowDraft(workflow, user) {
857
1604
  if (!user) return false;
858
1605
  const capabilities = workflowCapabilities(workflow, user);
859
1606
  if (capabilities.has("entry.edit")) return true;
860
- return workflow.transitions.some(
1607
+ if (workflow.transitions.some(
861
1608
  (transition) => (transition.requiredCapabilities ?? []).some((capability) => capabilities.has(capability))
862
- );
1609
+ )) {
1610
+ return true;
1611
+ }
1612
+ if (!workflow.roles || workflow.roles.length === 0) return true;
1613
+ return false;
863
1614
  }
864
1615
  function availableWorkflowTransitions(workflow, state, user) {
865
1616
  const capabilities = workflowCapabilities(workflow, user);
@@ -1069,7 +1820,17 @@ async function transitionWorkflow(args) {
1069
1820
  try {
1070
1821
  await hook({ ...hookContext, doc: updated, event: events[0] });
1071
1822
  } catch (error) {
1072
- console.error("[dyrected/workflow] afterTransition hook failed:", error);
1823
+ getObservabilityRuntime(config)?.recordWorkflowHookFailure({
1824
+ collection: collection.slug,
1825
+ transition: transition.name
1826
+ });
1827
+ getConfigLogger(config, "workflow").error({
1828
+ err: error,
1829
+ msg: "afterTransition hook failed",
1830
+ collection: collection.slug,
1831
+ transition: transition.name,
1832
+ documentId: id
1833
+ });
1073
1834
  }
1074
1835
  }
1075
1836
  return updated;
@@ -1126,7 +1887,13 @@ var CollectionController = class {
1126
1887
  } catch {
1127
1888
  }
1128
1889
  }
1129
- const paginatedResult = await provider.members.list({ limit: limit2, page: page2, sort: sort2, where: where2, req: hookReq });
1890
+ const paginatedResult = await provider.members.list({
1891
+ limit: limit2,
1892
+ page: page2,
1893
+ sort: sort2,
1894
+ where: where2,
1895
+ req: hookReq
1896
+ });
1130
1897
  const mappedDocs = [];
1131
1898
  for (const m of paginatedResult.docs) {
1132
1899
  let localId = m.id;
@@ -1174,12 +1941,15 @@ var CollectionController = class {
1174
1941
  }
1175
1942
  }
1176
1943
  }
1177
- const beforeReadResult = await runCollectionHooks(this.collection.hooks?.beforeRead, {
1178
- req: c.req,
1179
- query: where,
1180
- user,
1181
- db: readonlyDb
1182
- });
1944
+ const beforeReadResult = await runCollectionHooks(
1945
+ this.collection.hooks?.beforeRead,
1946
+ {
1947
+ req: c.req,
1948
+ query: where,
1949
+ user,
1950
+ db: readonlyDb
1951
+ }
1952
+ );
1183
1953
  if (beforeReadResult !== void 0) {
1184
1954
  where = beforeReadResult;
1185
1955
  }
@@ -1188,7 +1958,13 @@ var CollectionController = class {
1188
1958
  }
1189
1959
  const access = await this.evaluateAccess(c, "read");
1190
1960
  if (!access.allowed) {
1191
- return c.json({ error: true, message: `Access denied: read on ${this.collection.slug}` }, 403);
1961
+ return c.json(
1962
+ {
1963
+ error: true,
1964
+ message: `Access denied: read on ${this.collection.slug}`
1965
+ },
1966
+ 403
1967
+ );
1192
1968
  }
1193
1969
  if (access.constraint) {
1194
1970
  where = mergeWhereConstraint(where, access.constraint);
@@ -1202,7 +1978,10 @@ var CollectionController = class {
1202
1978
  fields: this.collection.fields
1203
1979
  });
1204
1980
  if (result.total === 0 && this.collection.initialData && !where && page === 1) {
1205
- console.log(`[dyrected/core] Auto-seeding collection "${this.collection.slug}" from config.initialData`);
1981
+ getRequestLogger(c, "collection").info({
1982
+ msg: "Auto-seeding collection from config.initialData",
1983
+ collection: this.collection.slug
1984
+ });
1206
1985
  for (const data of this.collection.initialData) {
1207
1986
  await db.create({ collection: this.collection.slug, data });
1208
1987
  }
@@ -1215,31 +1994,54 @@ var CollectionController = class {
1215
1994
  fields: this.collection.fields
1216
1995
  });
1217
1996
  }
1218
- result.docs = result.docs.map((doc) => this.collection.workflow ? materializeWorkflowDocument(doc, this.collection.workflow, user) : doc).filter((doc) => doc !== null);
1997
+ result.docs = result.docs.map(
1998
+ (doc) => this.collection.workflow ? materializeWorkflowDocument(
1999
+ doc,
2000
+ this.collection.workflow,
2001
+ user
2002
+ ) : doc
2003
+ ).filter((doc) => doc !== null);
1219
2004
  const processedDocs = [];
1220
- const readonlyDbForHooks = createReadonlyDb(db);
1221
2005
  for (const doc of result.docs) {
1222
- const docWithDefaults = DefaultsService.apply(this.collection.fields, doc);
1223
- const docWithCollectionHooks = await runCollectionHooks(this.collection.hooks?.afterRead, {
1224
- doc: docWithDefaults,
1225
- req: c.req,
2006
+ const docWithDefaults = DefaultsService.apply(
2007
+ this.collection.fields,
2008
+ doc
2009
+ );
2010
+ const docWithCollectionHooks = await runCollectionHooks(
2011
+ this.collection.hooks?.afterRead,
2012
+ {
2013
+ doc: docWithDefaults,
2014
+ req: c.req,
2015
+ user,
2016
+ db: readonlyDb
2017
+ }
2018
+ );
2019
+ const docWithFieldHooks = await executeFieldAfterRead(
2020
+ this.collection.fields,
2021
+ docWithCollectionHooks,
1226
2022
  user,
1227
- db: readonlyDb
1228
- });
1229
- const docWithFieldHooks = await executeFieldAfterRead(this.collection.fields, docWithCollectionHooks, user, readonlyDb);
1230
- const docWithFieldAccess = await applyFieldReadAccess({
1231
- config,
1232
- fields: this.collection.fields,
1233
- user,
1234
- req: this.toHookRequestContext(c),
1235
- doc: docWithFieldHooks
1236
- }, docWithFieldHooks);
2023
+ readonlyDb
2024
+ );
2025
+ const docWithFieldAccess = await applyFieldReadAccess(
2026
+ {
2027
+ config,
2028
+ fields: this.collection.fields,
2029
+ user,
2030
+ req: this.toHookRequestContext(c),
2031
+ doc: docWithFieldHooks
2032
+ },
2033
+ docWithFieldHooks
2034
+ );
1237
2035
  processedDocs.push(docWithFieldAccess);
1238
2036
  }
1239
2037
  result.docs = processedDocs;
1240
2038
  if (depth > 0) {
1241
2039
  const populationService = new PopulationService(db, config.collections);
1242
- result = await populationService.populateResult(result, this.collection.fields, depth);
2040
+ result = await populationService.populateResult(
2041
+ result,
2042
+ this.collection.fields,
2043
+ depth
2044
+ );
1243
2045
  }
1244
2046
  return c.json(result);
1245
2047
  }
@@ -1252,7 +2054,10 @@ var CollectionController = class {
1252
2054
  const hookReq = this.toHookRequestContext(c);
1253
2055
  const id2 = c.req.param("id");
1254
2056
  if (!id2) return c.json({ message: "Missing ID" }, 400);
1255
- const localDoc = await db.findOne({ collection: this.collection.slug, id: id2 });
2057
+ const localDoc = await db.findOne({
2058
+ collection: this.collection.slug,
2059
+ id: id2
2060
+ });
1256
2061
  const externalSubject = localDoc?.externalSubject || id2;
1257
2062
  let member = null;
1258
2063
  if (provider.members.get) {
@@ -1281,23 +2086,40 @@ var CollectionController = class {
1281
2086
  }
1282
2087
  const access = await this.evaluateAccess(c, "read", { id, doc });
1283
2088
  if (!access.allowed) {
1284
- return c.json({ error: true, message: `Access denied: read on ${this.collection.slug}` }, 403);
2089
+ return c.json(
2090
+ {
2091
+ error: true,
2092
+ message: `Access denied: read on ${this.collection.slug}`
2093
+ },
2094
+ 403
2095
+ );
1285
2096
  }
1286
2097
  const docWithDefaults = DefaultsService.apply(this.collection.fields, doc);
1287
- const docWithCollectionHooks = await runCollectionHooks(this.collection.hooks?.afterRead, {
1288
- doc: docWithDefaults,
1289
- req: c.req,
1290
- user,
1291
- db: readonlyDb
1292
- });
1293
- const docWithFieldHooks = await executeFieldAfterRead(this.collection.fields, docWithCollectionHooks, user, readonlyDb);
1294
- const docWithFieldAccess = await applyFieldReadAccess({
1295
- config,
1296
- fields: this.collection.fields,
2098
+ const docWithCollectionHooks = await runCollectionHooks(
2099
+ this.collection.hooks?.afterRead,
2100
+ {
2101
+ doc: docWithDefaults,
2102
+ req: c.req,
2103
+ user,
2104
+ db: readonlyDb
2105
+ }
2106
+ );
2107
+ const docWithFieldHooks = await executeFieldAfterRead(
2108
+ this.collection.fields,
2109
+ docWithCollectionHooks,
1297
2110
  user,
1298
- req: this.toHookRequestContext(c),
1299
- doc: docWithFieldHooks
1300
- }, docWithFieldHooks);
2111
+ readonlyDb
2112
+ );
2113
+ const docWithFieldAccess = await applyFieldReadAccess(
2114
+ {
2115
+ config,
2116
+ fields: this.collection.fields,
2117
+ user,
2118
+ req: this.toHookRequestContext(c),
2119
+ doc: docWithFieldHooks
2120
+ },
2121
+ docWithFieldHooks
2122
+ );
1301
2123
  if (depth > 0 && docWithFieldAccess) {
1302
2124
  const populationService = new PopulationService(db, config.collections);
1303
2125
  const populatedDoc = await populationService.populate({
@@ -1322,12 +2144,18 @@ var CollectionController = class {
1322
2144
  return this.upload(c);
1323
2145
  }
1324
2146
  const body2 = await c.req.json();
1325
- const member = await provider.members.create({ data: body2, req: hookReq });
1326
- return c.json({
1327
- ...member,
1328
- id: member.id,
1329
- externalSubject: member.id
1330
- }, 201);
2147
+ const member = await provider.members.create({
2148
+ data: body2,
2149
+ req: hookReq
2150
+ });
2151
+ return c.json(
2152
+ {
2153
+ ...member,
2154
+ id: member.id,
2155
+ externalSubject: member.id
2156
+ },
2157
+ 201
2158
+ );
1331
2159
  }
1332
2160
  const readonlyDb = createReadonlyDb(db);
1333
2161
  const contentType = c.req.header("Content-Type") || "";
@@ -1349,20 +2177,35 @@ var CollectionController = class {
1349
2177
  }
1350
2178
  const createAccess = await this.evaluateAccess(c, "create", { data });
1351
2179
  if (!createAccess.allowed) {
1352
- return c.json({ error: true, message: `Access denied: create on ${this.collection.slug}` }, 403);
2180
+ return c.json(
2181
+ {
2182
+ error: true,
2183
+ message: `Access denied: create on ${this.collection.slug}`
2184
+ },
2185
+ 403
2186
+ );
1353
2187
  }
1354
2188
  if (this.collection.auth && data.password) {
1355
2189
  data.password = await hashPassword(data.password);
1356
2190
  }
1357
- data = await applyFieldWriteAccess({
1358
- config,
1359
- fields: this.collection.fields,
1360
- user,
1361
- req: this.toHookRequestContext(c),
2191
+ data = await applyFieldWriteAccess(
2192
+ {
2193
+ config,
2194
+ fields: this.collection.fields,
2195
+ user,
2196
+ req: this.toHookRequestContext(c),
2197
+ data,
2198
+ operation: "create"
2199
+ },
2200
+ data
2201
+ );
2202
+ data = await executeFieldBeforeChange(
2203
+ this.collection.fields,
1362
2204
  data,
1363
- operation: "create"
1364
- }, data);
1365
- data = await executeFieldBeforeChange(this.collection.fields, data, null, user, readonlyDb);
2205
+ null,
2206
+ user,
2207
+ readonlyDb
2208
+ );
1366
2209
  data = await runCollectionHooks(this.collection.hooks?.beforeChange, {
1367
2210
  data,
1368
2211
  req: c.req,
@@ -1370,7 +2213,12 @@ var CollectionController = class {
1370
2213
  operation: "create",
1371
2214
  db: readonlyDb
1372
2215
  });
1373
- const doc = this.collection.workflow ? (await createWorkflowDocument({ config, collection: this.collection, data, user })).doc : await db.create({ collection: this.collection.slug, data });
2216
+ const doc = this.collection.workflow ? (await createWorkflowDocument({
2217
+ config,
2218
+ collection: this.collection,
2219
+ data,
2220
+ user
2221
+ })).doc : await db.create({ collection: this.collection.slug, data });
1374
2222
  if (this.collection.audit && db) {
1375
2223
  AuditService.log(db, {
1376
2224
  operation: "create",
@@ -1379,15 +2227,19 @@ var CollectionController = class {
1379
2227
  user: user ? { id: user.sub, collection: user.collection, email: user.email } : void 0,
1380
2228
  before: null,
1381
2229
  after: doc
1382
- });
2230
+ }, config);
1383
2231
  }
1384
- await runCollectionHooks(this.collection.hooks?.afterChange, {
1385
- doc,
1386
- user,
1387
- req: c.req,
1388
- operation: "create",
1389
- db
1390
- }, { isolated: true });
2232
+ await runCollectionHooks(
2233
+ this.collection.hooks?.afterChange,
2234
+ {
2235
+ doc,
2236
+ user,
2237
+ req: c.req,
2238
+ operation: "create",
2239
+ db
2240
+ },
2241
+ { isolated: true }
2242
+ );
1391
2243
  const responseDoc = this.collection.workflow ? materializeWorkflowDocument(doc, this.collection.workflow, user) : doc;
1392
2244
  const readDoc = await runCollectionHooks(this.collection.hooks?.afterRead, {
1393
2245
  doc: responseDoc,
@@ -1395,14 +2247,22 @@ var CollectionController = class {
1395
2247
  user,
1396
2248
  db: readonlyDb
1397
2249
  });
1398
- const finalDoc = await executeFieldAfterRead(this.collection.fields, readDoc, user, readonlyDb);
1399
- const accessibleDoc = await applyFieldReadAccess({
1400
- config,
1401
- fields: this.collection.fields,
2250
+ const finalDoc = await executeFieldAfterRead(
2251
+ this.collection.fields,
2252
+ readDoc,
1402
2253
  user,
1403
- req: this.toHookRequestContext(c),
1404
- doc: finalDoc
1405
- }, finalDoc);
2254
+ readonlyDb
2255
+ );
2256
+ const accessibleDoc = await applyFieldReadAccess(
2257
+ {
2258
+ config,
2259
+ fields: this.collection.fields,
2260
+ user,
2261
+ req: this.toHookRequestContext(c),
2262
+ doc: finalDoc
2263
+ },
2264
+ finalDoc
2265
+ );
1406
2266
  return c.json(accessibleDoc, 201);
1407
2267
  }
1408
2268
  async upload(c) {
@@ -1418,7 +2278,10 @@ var CollectionController = class {
1418
2278
  const uploadConfig = typeof this.collection.upload === "object" ? this.collection.upload : void 0;
1419
2279
  const validationError = validateUpload(file, uploadConfig);
1420
2280
  if (validationError) {
1421
- return c.json({ message: validationError.message }, validationError.status);
2281
+ return c.json(
2282
+ { message: validationError.message },
2283
+ validationError.status
2284
+ );
1422
2285
  }
1423
2286
  const buffer = new Uint8Array(await file.arrayBuffer());
1424
2287
  const siteId = c.get("siteId");
@@ -1448,17 +2311,32 @@ var CollectionController = class {
1448
2311
  };
1449
2312
  const createAccess = await this.evaluateAccess(c, "create", { data });
1450
2313
  if (!createAccess.allowed) {
1451
- return c.json({ error: true, message: `Access denied: create on ${this.collection.slug}` }, 403);
2314
+ return c.json(
2315
+ {
2316
+ error: true,
2317
+ message: `Access denied: create on ${this.collection.slug}`
2318
+ },
2319
+ 403
2320
+ );
1452
2321
  }
1453
- data = await applyFieldWriteAccess({
1454
- config,
1455
- fields: this.collection.fields,
1456
- user,
1457
- req: this.toHookRequestContext(c),
2322
+ data = await applyFieldWriteAccess(
2323
+ {
2324
+ config,
2325
+ fields: this.collection.fields,
2326
+ user,
2327
+ req: this.toHookRequestContext(c),
2328
+ data,
2329
+ operation: "create"
2330
+ },
2331
+ data
2332
+ );
2333
+ data = await executeFieldBeforeChange(
2334
+ this.collection.fields,
1458
2335
  data,
1459
- operation: "create"
1460
- }, data);
1461
- data = await executeFieldBeforeChange(this.collection.fields, data, null, user, readonlyDb);
2336
+ null,
2337
+ user,
2338
+ readonlyDb
2339
+ );
1462
2340
  data = await runCollectionHooks(this.collection.hooks?.beforeChange, {
1463
2341
  data,
1464
2342
  req: c.req,
@@ -1470,13 +2348,17 @@ var CollectionController = class {
1470
2348
  collection: this.collection.slug,
1471
2349
  data
1472
2350
  });
1473
- await runCollectionHooks(this.collection.hooks?.afterChange, {
1474
- doc,
1475
- user,
1476
- req: c.req,
1477
- operation: "create",
1478
- db
1479
- }, { isolated: true });
2351
+ await runCollectionHooks(
2352
+ this.collection.hooks?.afterChange,
2353
+ {
2354
+ doc,
2355
+ user,
2356
+ req: c.req,
2357
+ operation: "create",
2358
+ db
2359
+ },
2360
+ { isolated: true }
2361
+ );
1480
2362
  const responseDoc = this.collection.workflow ? materializeWorkflowDocument(doc, this.collection.workflow, user) : doc;
1481
2363
  const readDoc = await runCollectionHooks(this.collection.hooks?.afterRead, {
1482
2364
  doc: responseDoc,
@@ -1484,14 +2366,22 @@ var CollectionController = class {
1484
2366
  user,
1485
2367
  db: readonlyDb
1486
2368
  });
1487
- const finalDoc = await executeFieldAfterRead(this.collection.fields, readDoc, user, readonlyDb);
1488
- const accessibleDoc = await applyFieldReadAccess({
1489
- config,
1490
- fields: this.collection.fields,
2369
+ const finalDoc = await executeFieldAfterRead(
2370
+ this.collection.fields,
2371
+ readDoc,
1491
2372
  user,
1492
- req: this.toHookRequestContext(c),
1493
- doc: finalDoc
1494
- }, finalDoc);
2373
+ readonlyDb
2374
+ );
2375
+ const accessibleDoc = await applyFieldReadAccess(
2376
+ {
2377
+ config,
2378
+ fields: this.collection.fields,
2379
+ user,
2380
+ req: this.toHookRequestContext(c),
2381
+ doc: finalDoc
2382
+ },
2383
+ finalDoc
2384
+ );
1495
2385
  return c.json(accessibleDoc, 201);
1496
2386
  }
1497
2387
  async update(c) {
@@ -1504,9 +2394,16 @@ var CollectionController = class {
1504
2394
  const id2 = c.req.param("id");
1505
2395
  if (!id2) return c.json({ message: "Missing ID" }, 400);
1506
2396
  const body2 = await c.req.json();
1507
- const localDoc = await db.findOne({ collection: this.collection.slug, id: id2 });
2397
+ const localDoc = await db.findOne({
2398
+ collection: this.collection.slug,
2399
+ id: id2
2400
+ });
1508
2401
  const externalSubject = localDoc?.externalSubject || id2;
1509
- const member = await provider.members.update({ externalSubject, data: body2, req: hookReq });
2402
+ const member = await provider.members.update({
2403
+ externalSubject,
2404
+ data: body2,
2405
+ req: hookReq
2406
+ });
1510
2407
  return c.json({
1511
2408
  ...member,
1512
2409
  id: localDoc ? localDoc.id : member.id,
@@ -1528,25 +2425,47 @@ var CollectionController = class {
1528
2425
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1529
2426
  updatedBy: user?.sub ?? null
1530
2427
  });
1531
- const originalDoc = await db.findOne({ collection: this.collection.slug, id });
2428
+ const originalDoc = await db.findOne({
2429
+ collection: this.collection.slug,
2430
+ id
2431
+ });
1532
2432
  if (!originalDoc) return c.json({ message: "Not Found" }, 404);
1533
- const updateAccess = await this.evaluateAccess(c, "update", { id, doc: originalDoc, data });
2433
+ const updateAccess = await this.evaluateAccess(c, "update", {
2434
+ id,
2435
+ doc: originalDoc,
2436
+ data
2437
+ });
1534
2438
  if (!updateAccess.allowed) {
1535
- return c.json({ error: true, message: `Access denied: update on ${this.collection.slug}` }, 403);
2439
+ return c.json(
2440
+ {
2441
+ error: true,
2442
+ message: `Access denied: update on ${this.collection.slug}`
2443
+ },
2444
+ 403
2445
+ );
1536
2446
  }
1537
2447
  let before = null;
1538
2448
  if (this.collection.audit) {
1539
2449
  before = originalDoc;
1540
2450
  }
1541
- data = await applyFieldWriteAccess({
1542
- config,
1543
- fields: this.collection.fields,
1544
- user,
1545
- req: this.toHookRequestContext(c),
1546
- doc: originalDoc,
2451
+ data = await applyFieldWriteAccess(
2452
+ {
2453
+ config,
2454
+ fields: this.collection.fields,
2455
+ user,
2456
+ req: this.toHookRequestContext(c),
2457
+ doc: originalDoc,
2458
+ data
2459
+ },
1547
2460
  data
1548
- }, data);
1549
- data = await executeFieldBeforeChange(this.collection.fields, data, originalDoc, user, readonlyDb);
2461
+ );
2462
+ data = await executeFieldBeforeChange(
2463
+ this.collection.fields,
2464
+ data,
2465
+ originalDoc,
2466
+ user,
2467
+ readonlyDb
2468
+ );
1550
2469
  data = await runCollectionHooks(this.collection.hooks?.beforeChange, {
1551
2470
  data,
1552
2471
  doc: originalDoc,
@@ -1555,7 +2474,14 @@ var CollectionController = class {
1555
2474
  operation: "update",
1556
2475
  db: readonlyDb
1557
2476
  });
1558
- 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 });
2477
+ const doc = this.collection.workflow ? (await saveWorkflowDraft({
2478
+ config,
2479
+ collection: this.collection,
2480
+ id,
2481
+ originalDoc,
2482
+ data,
2483
+ user
2484
+ })).doc : await db.update({ collection: this.collection.slug, id, data });
1559
2485
  if (this.collection.audit && db) {
1560
2486
  AuditService.log(db, {
1561
2487
  operation: "update",
@@ -1564,36 +2490,52 @@ var CollectionController = class {
1564
2490
  user: user ? { id: user.sub, collection: user.collection, email: user.email } : void 0,
1565
2491
  before,
1566
2492
  after: doc
1567
- });
2493
+ }, config);
1568
2494
  }
1569
- await runCollectionHooks(this.collection.hooks?.afterChange, {
1570
- doc,
1571
- previousDoc: originalDoc,
1572
- user,
1573
- req: c.req,
1574
- operation: "update",
1575
- db
1576
- }, { isolated: true });
2495
+ await runCollectionHooks(
2496
+ this.collection.hooks?.afterChange,
2497
+ {
2498
+ doc,
2499
+ previousDoc: originalDoc,
2500
+ user,
2501
+ req: c.req,
2502
+ operation: "update",
2503
+ db
2504
+ },
2505
+ { isolated: true }
2506
+ );
1577
2507
  const readDoc = await runCollectionHooks(this.collection.hooks?.afterRead, {
1578
2508
  doc,
1579
2509
  req: c.req,
1580
2510
  user,
1581
2511
  db: readonlyDb
1582
2512
  });
1583
- const finalDoc = await executeFieldAfterRead(this.collection.fields, readDoc, user, readonlyDb);
1584
- const accessibleDoc = await applyFieldReadAccess({
1585
- config,
1586
- fields: this.collection.fields,
2513
+ const finalDoc = await executeFieldAfterRead(
2514
+ this.collection.fields,
2515
+ readDoc,
1587
2516
  user,
1588
- req: this.toHookRequestContext(c),
1589
- doc: finalDoc
1590
- }, finalDoc);
2517
+ readonlyDb
2518
+ );
2519
+ const accessibleDoc = await applyFieldReadAccess(
2520
+ {
2521
+ config,
2522
+ fields: this.collection.fields,
2523
+ user,
2524
+ req: this.toHookRequestContext(c),
2525
+ doc: finalDoc
2526
+ },
2527
+ finalDoc
2528
+ );
1591
2529
  return c.json(accessibleDoc);
1592
2530
  }
1593
2531
  async transition(c) {
1594
2532
  const config = c.get("config");
1595
2533
  if (!config.db) return c.json({ message: "Database not configured" }, 500);
1596
- if (!this.collection.workflow) return c.json({ message: "Workflows are not enabled for this collection" }, 404);
2534
+ if (!this.collection.workflow)
2535
+ return c.json(
2536
+ { message: "Workflows are not enabled for this collection" },
2537
+ 404
2538
+ );
1597
2539
  const id = c.req.param("id");
1598
2540
  const transitionName = c.req.param("transition");
1599
2541
  const body = await c.req.json().catch(() => ({}));
@@ -1608,19 +2550,38 @@ var CollectionController = class {
1608
2550
  user: c.get("user"),
1609
2551
  req: { query: c.req.query(), headers: c.req.header(), raw: c.req.raw }
1610
2552
  });
1611
- return c.json(materializeWorkflowDocument(doc, this.collection.workflow, c.get("user")));
2553
+ return c.json(
2554
+ materializeWorkflowDocument(
2555
+ doc,
2556
+ this.collection.workflow,
2557
+ c.get("user")
2558
+ )
2559
+ );
1612
2560
  } catch (error) {
1613
2561
  const status = typeof error.statusCode === "number" ? error.statusCode : 500;
1614
- return c.json({ error: true, message: error instanceof Error ? error.message : String(error) }, status);
2562
+ return c.json(
2563
+ {
2564
+ error: true,
2565
+ message: error instanceof Error ? error.message : String(error)
2566
+ },
2567
+ status
2568
+ );
1615
2569
  }
1616
2570
  }
1617
2571
  async workflowHistory(c) {
1618
2572
  const config = c.get("config");
1619
2573
  if (!config.db) return c.json({ message: "Database not configured" }, 500);
1620
- if (!this.collection.workflow) return c.json({ message: "Workflows are not enabled for this collection" }, 404);
2574
+ if (!this.collection.workflow)
2575
+ return c.json(
2576
+ { message: "Workflows are not enabled for this collection" },
2577
+ 404
2578
+ );
1621
2579
  const documentId = c.req.param("id");
1622
2580
  if (!documentId) return c.json({ message: "Missing ID" }, 400);
1623
- const document = await config.db.findOne({ collection: this.collection.slug, id: documentId });
2581
+ const document = await config.db.findOne({
2582
+ collection: this.collection.slug,
2583
+ id: documentId
2584
+ });
1624
2585
  if (!document) return c.json({ message: "Not Found" }, 404);
1625
2586
  const readAccess = this.collection.access?.read;
1626
2587
  if (readAccess !== void 0 && readAccess !== null) {
@@ -1635,11 +2596,21 @@ var CollectionController = class {
1635
2596
  });
1636
2597
  allowed = match.total > 0;
1637
2598
  }
1638
- if (!allowed) return c.json({ error: true, message: `Access denied: read on ${this.collection.slug}` }, 403);
2599
+ if (!allowed)
2600
+ return c.json(
2601
+ {
2602
+ error: true,
2603
+ message: `Access denied: read on ${this.collection.slug}`
2604
+ },
2605
+ 403
2606
+ );
1639
2607
  }
1640
2608
  const result = await config.db.find({
1641
2609
  collection: WORKFLOW_HISTORY_COLLECTION,
1642
- where: { collection: { equals: this.collection.slug }, documentId: { equals: documentId } },
2610
+ where: {
2611
+ collection: { equals: this.collection.slug },
2612
+ documentId: { equals: documentId }
2613
+ },
1643
2614
  sort: "-createdAt",
1644
2615
  limit: Math.min(Number(c.req.query("limit")) || 50, 100)
1645
2616
  });
@@ -1661,7 +2632,10 @@ var CollectionController = class {
1661
2632
  const db = config.db;
1662
2633
  if (!db) return c.json({ message: "Database not configured" }, 500);
1663
2634
  if (!this.collection.auth) {
1664
- return c.json({ message: "This collection does not support authentication" }, 400);
2635
+ return c.json(
2636
+ { message: "This collection does not support authentication" },
2637
+ 400
2638
+ );
1665
2639
  }
1666
2640
  const id = c.req.param("id");
1667
2641
  if (!id) return c.json({ message: "Missing ID" }, 400);
@@ -1681,29 +2655,44 @@ var CollectionController = class {
1681
2655
  const isAdmin = Array.isArray(user.roles) && user.roles.includes("admin");
1682
2656
  const isSelf = user.sub === id;
1683
2657
  if (!isAdmin && !isSelf) {
1684
- return c.json({ message: "You are not authorised to change this password" }, 403);
2658
+ return c.json(
2659
+ { message: "You are not authorised to change this password" },
2660
+ 403
2661
+ );
1685
2662
  }
1686
2663
  if (!isAdmin) {
1687
2664
  if (!oldPassword) {
1688
2665
  return c.json({ message: "Current password is required" }, 400);
1689
2666
  }
1690
- const existing = await db.findOne({ collection: this.collection.slug, id });
2667
+ const existing = await db.findOne({
2668
+ collection: this.collection.slug,
2669
+ id
2670
+ });
1691
2671
  if (!existing) return c.json({ message: "User not found" }, 404);
1692
- const valid = await verifyPassword(oldPassword, existing.password);
2672
+ const valid = await verifyPassword(
2673
+ oldPassword,
2674
+ existing.password
2675
+ );
1693
2676
  if (!valid) {
1694
2677
  return c.json({ message: "Invalid current password" }, 400);
1695
2678
  }
1696
2679
  }
1697
2680
  const hashed = await hashPassword(newPassword);
1698
- const doc = await db.update({
2681
+ await db.update({
1699
2682
  collection: this.collection.slug,
1700
2683
  id,
1701
2684
  data: {
1702
2685
  password: hashed,
2686
+ loginAttempts: 0,
2687
+ lockedUntil: null,
1703
2688
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1704
2689
  updatedBy: user.sub
1705
2690
  }
1706
2691
  });
2692
+ await revokeAllAuthSessions(config, {
2693
+ userId: id,
2694
+ collection: this.collection.slug
2695
+ });
1707
2696
  if (this.collection.audit) {
1708
2697
  AuditService.log(db, {
1709
2698
  operation: "update",
@@ -1712,7 +2701,7 @@ var CollectionController = class {
1712
2701
  user: { id: user.sub, collection: user.collection, email: user.email },
1713
2702
  before: null,
1714
2703
  after: { id }
1715
- });
2704
+ }, config);
1716
2705
  }
1717
2706
  return c.json({ success: true, message: "Password updated successfully" });
1718
2707
  }
@@ -1725,7 +2714,10 @@ var CollectionController = class {
1725
2714
  const hookReq = this.toHookRequestContext(c);
1726
2715
  const id2 = c.req.param("id");
1727
2716
  if (!id2) return c.json({ message: "Missing ID" }, 400);
1728
- const localDoc = await db.findOne({ collection: this.collection.slug, id: id2 });
2717
+ const localDoc = await db.findOne({
2718
+ collection: this.collection.slug,
2719
+ id: id2
2720
+ });
1729
2721
  const externalSubject = localDoc?.externalSubject || id2;
1730
2722
  await provider.members.delete({ externalSubject, req: hookReq });
1731
2723
  return c.json({ message: "Deleted" });
@@ -1738,7 +2730,13 @@ var CollectionController = class {
1738
2730
  if (!doc) return c.json({ message: "Not Found" }, 404);
1739
2731
  const deleteAccess = await this.evaluateAccess(c, "delete", { id, doc });
1740
2732
  if (!deleteAccess.allowed) {
1741
- return c.json({ error: true, message: `Access denied: delete on ${this.collection.slug}` }, 403);
2733
+ return c.json(
2734
+ {
2735
+ error: true,
2736
+ message: `Access denied: delete on ${this.collection.slug}`
2737
+ },
2738
+ 403
2739
+ );
1742
2740
  }
1743
2741
  let before = null;
1744
2742
  if (this.collection.audit) {
@@ -1760,15 +2758,19 @@ var CollectionController = class {
1760
2758
  user: user ? { id: user.sub, collection: user.collection, email: user.email } : void 0,
1761
2759
  before,
1762
2760
  after: null
1763
- });
2761
+ }, config);
1764
2762
  }
1765
- await runCollectionHooks(this.collection.hooks?.afterDelete, {
1766
- id,
1767
- doc,
1768
- user,
1769
- req: c.req,
1770
- db
1771
- }, { isolated: true });
2763
+ await runCollectionHooks(
2764
+ this.collection.hooks?.afterDelete,
2765
+ {
2766
+ id,
2767
+ doc,
2768
+ user,
2769
+ req: c.req,
2770
+ db
2771
+ },
2772
+ { isolated: true }
2773
+ );
1772
2774
  return c.json({ message: "Deleted" });
1773
2775
  }
1774
2776
  async deleteMany(c) {
@@ -1799,7 +2801,10 @@ var CollectionController = class {
1799
2801
  failed.push({ id, error: "Not Found" });
1800
2802
  continue;
1801
2803
  }
1802
- const deleteAccess = await this.evaluateAccess(c, "delete", { id, doc });
2804
+ const deleteAccess = await this.evaluateAccess(c, "delete", {
2805
+ id,
2806
+ doc
2807
+ });
1803
2808
  if (!deleteAccess.allowed) {
1804
2809
  failed.push({ id, error: "Access denied" });
1805
2810
  continue;
@@ -1825,15 +2830,19 @@ var CollectionController = class {
1825
2830
  user: user ? { id: user.sub, collection: user.collection, email: user.email } : void 0,
1826
2831
  before,
1827
2832
  after: null
1828
- });
2833
+ }, config);
1829
2834
  }
1830
- await runCollectionHooks(this.collection.hooks?.afterDelete, {
1831
- id,
1832
- doc,
1833
- user,
1834
- req: c.req,
1835
- db
1836
- }, { isolated: true });
2835
+ await runCollectionHooks(
2836
+ this.collection.hooks?.afterDelete,
2837
+ {
2838
+ id,
2839
+ doc,
2840
+ user,
2841
+ req: c.req,
2842
+ db
2843
+ },
2844
+ { isolated: true }
2845
+ );
1837
2846
  } catch (err) {
1838
2847
  failed.push({ id, error: err?.message ?? "Unknown error" });
1839
2848
  }
@@ -1853,17 +2862,26 @@ var CollectionController = class {
1853
2862
  if (!initialData || !Array.isArray(initialData)) {
1854
2863
  return c.json({ message: "Invalid initial data" }, 400);
1855
2864
  }
1856
- const result = await db.find({ collection: this.collection.slug, limit: 1 });
2865
+ const result = await db.find({
2866
+ collection: this.collection.slug,
2867
+ limit: 1
2868
+ });
1857
2869
  if (result.total > 0) {
1858
2870
  return c.json({ message: "Collection is not empty, skipping seed" });
1859
2871
  }
1860
- console.log(`[dyrected/core] Auto-seeding collection: ${this.collection.slug}`);
2872
+ getRequestLogger(c, "collection").info({
2873
+ msg: "Auto-seeding collection",
2874
+ collection: this.collection.slug
2875
+ });
1861
2876
  const createdDocs = [];
1862
2877
  for (const data of initialData) {
1863
2878
  const doc = await db.create({ collection: this.collection.slug, data });
1864
2879
  createdDocs.push(doc);
1865
2880
  }
1866
- return c.json({ message: "Seed successful", count: createdDocs.length }, 201);
2881
+ return c.json(
2882
+ { message: "Seed successful", count: createdDocs.length },
2883
+ 201
2884
+ );
1867
2885
  }
1868
2886
  };
1869
2887
 
@@ -1880,46 +2898,66 @@ var GlobalController = class {
1880
2898
  const readonlyDb = createReadonlyDb(db);
1881
2899
  const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 1;
1882
2900
  const user = c.get("user");
1883
- let query = void 0;
1884
- const beforeReadResult = await runCollectionHooks(this.global.hooks?.beforeRead, {
2901
+ await runCollectionHooks(this.global.hooks?.beforeRead, {
1885
2902
  req: c.req,
1886
- query,
2903
+ query: void 0,
1887
2904
  user,
1888
2905
  db: readonlyDb
1889
2906
  });
1890
- if (beforeReadResult !== void 0) {
1891
- query = beforeReadResult;
1892
- }
1893
2907
  let data = await db.getGlobal({ slug: this.global.slug });
1894
2908
  const isEmpty = !data || isFunctionallyEmpty(data);
1895
2909
  if (isEmpty && this.global.initialData) {
1896
- console.log(`[dyrected/core] Auto-seeding global "${this.global.slug}" from config.initialData`);
1897
- await db.updateGlobal({ slug: this.global.slug, data: this.global.initialData });
2910
+ getRequestLogger(c, "global").info({
2911
+ msg: "Auto-seeding global from config.initialData",
2912
+ global: this.global.slug
2913
+ });
2914
+ await db.updateGlobal({
2915
+ slug: this.global.slug,
2916
+ data: this.global.initialData
2917
+ });
1898
2918
  data = this.global.initialData;
1899
2919
  }
1900
- const canRead = await resolveBooleanAccess(config, this.global.access?.read, {
1901
- user,
1902
- req: toHookRequestContext(c.req),
1903
- doc: data
1904
- });
2920
+ const canRead = await resolveBooleanAccess(
2921
+ config,
2922
+ this.global.access?.read,
2923
+ {
2924
+ user,
2925
+ req: toHookRequestContext(c.req),
2926
+ doc: data
2927
+ }
2928
+ );
1905
2929
  if (!canRead) {
1906
- return c.json({ error: true, message: `Access denied: read on ${this.global.slug}` }, 403);
2930
+ return c.json(
2931
+ { error: true, message: `Access denied: read on ${this.global.slug}` },
2932
+ 403
2933
+ );
1907
2934
  }
1908
2935
  const dataWithDefaults = DefaultsService.apply(this.global.fields, data);
1909
- const docWithCollectionHooks = await runCollectionHooks(this.global.hooks?.afterRead, {
1910
- doc: dataWithDefaults,
1911
- req: c.req,
1912
- user,
1913
- db: readonlyDb
1914
- });
1915
- const docWithFieldHooks = await executeFieldAfterRead(this.global.fields, docWithCollectionHooks, user, readonlyDb);
1916
- const docWithFieldAccess = await applyFieldReadAccess({
1917
- config,
1918
- fields: this.global.fields,
2936
+ const docWithCollectionHooks = await runCollectionHooks(
2937
+ this.global.hooks?.afterRead,
2938
+ {
2939
+ doc: dataWithDefaults,
2940
+ req: c.req,
2941
+ user,
2942
+ db: readonlyDb
2943
+ }
2944
+ );
2945
+ const docWithFieldHooks = await executeFieldAfterRead(
2946
+ this.global.fields,
2947
+ docWithCollectionHooks,
1919
2948
  user,
1920
- req: toHookRequestContext(c.req),
1921
- doc: docWithFieldHooks
1922
- }, docWithFieldHooks);
2949
+ readonlyDb
2950
+ );
2951
+ const docWithFieldAccess = await applyFieldReadAccess(
2952
+ {
2953
+ config,
2954
+ fields: this.global.fields,
2955
+ user,
2956
+ req: toHookRequestContext(c.req),
2957
+ doc: docWithFieldHooks
2958
+ },
2959
+ docWithFieldHooks
2960
+ );
1923
2961
  if (depth > 0 && docWithFieldAccess) {
1924
2962
  const populationService = new PopulationService(db, config.collections);
1925
2963
  const populatedData = await populationService.populate({
@@ -1940,24 +2978,43 @@ var GlobalController = class {
1940
2978
  const body = await c.req.json();
1941
2979
  const user = c.get("user");
1942
2980
  const originalDoc = await db.getGlobal({ slug: this.global.slug }) || {};
1943
- const canUpdate = await resolveBooleanAccess(config, this.global.access?.update, {
1944
- user,
1945
- req: toHookRequestContext(c.req),
1946
- doc: originalDoc,
1947
- data: body
1948
- });
2981
+ const canUpdate = await resolveBooleanAccess(
2982
+ config,
2983
+ this.global.access?.update,
2984
+ {
2985
+ user,
2986
+ req: toHookRequestContext(c.req),
2987
+ doc: originalDoc,
2988
+ data: body
2989
+ }
2990
+ );
1949
2991
  if (!canUpdate) {
1950
- return c.json({ error: true, message: `Access denied: update on ${this.global.slug}` }, 403);
2992
+ return c.json(
2993
+ {
2994
+ error: true,
2995
+ message: `Access denied: update on ${this.global.slug}`
2996
+ },
2997
+ 403
2998
+ );
1951
2999
  }
1952
- let sanitizedBody = await applyFieldWriteAccess({
1953
- config,
1954
- fields: this.global.fields,
3000
+ const sanitizedBody = await applyFieldWriteAccess(
3001
+ {
3002
+ config,
3003
+ fields: this.global.fields,
3004
+ user,
3005
+ req: toHookRequestContext(c.req),
3006
+ doc: originalDoc,
3007
+ data: body
3008
+ },
3009
+ body
3010
+ );
3011
+ let data = await executeFieldBeforeChange(
3012
+ this.global.fields,
3013
+ sanitizedBody,
3014
+ originalDoc,
1955
3015
  user,
1956
- req: toHookRequestContext(c.req),
1957
- doc: originalDoc,
1958
- data: body
1959
- }, body);
1960
- let data = await executeFieldBeforeChange(this.global.fields, sanitizedBody, originalDoc, user, readonlyDb);
3016
+ readonlyDb
3017
+ );
1961
3018
  data = await runCollectionHooks(this.global.hooks?.beforeChange, {
1962
3019
  data,
1963
3020
  doc: originalDoc,
@@ -1967,28 +3024,40 @@ var GlobalController = class {
1967
3024
  db: readonlyDb
1968
3025
  });
1969
3026
  const updated = await db.updateGlobal({ slug: this.global.slug, data });
1970
- await runCollectionHooks(this.global.hooks?.afterChange, {
1971
- doc: updated,
1972
- previousDoc: originalDoc,
1973
- user,
1974
- req: c.req,
1975
- operation: "update",
1976
- db
1977
- }, { isolated: true });
3027
+ await runCollectionHooks(
3028
+ this.global.hooks?.afterChange,
3029
+ {
3030
+ doc: updated,
3031
+ previousDoc: originalDoc,
3032
+ user,
3033
+ req: c.req,
3034
+ operation: "update",
3035
+ db
3036
+ },
3037
+ { isolated: true }
3038
+ );
1978
3039
  const readDoc = await runCollectionHooks(this.global.hooks?.afterRead, {
1979
3040
  doc: updated,
1980
3041
  req: c.req,
1981
3042
  user,
1982
3043
  db: readonlyDb
1983
3044
  });
1984
- const finalDoc = await executeFieldAfterRead(this.global.fields, readDoc, user, readonlyDb);
1985
- const accessibleDoc = await applyFieldReadAccess({
1986
- config,
1987
- fields: this.global.fields,
3045
+ const finalDoc = await executeFieldAfterRead(
3046
+ this.global.fields,
3047
+ readDoc,
1988
3048
  user,
1989
- req: toHookRequestContext(c.req),
1990
- doc: finalDoc
1991
- }, finalDoc);
3049
+ readonlyDb
3050
+ );
3051
+ const accessibleDoc = await applyFieldReadAccess(
3052
+ {
3053
+ config,
3054
+ fields: this.global.fields,
3055
+ user,
3056
+ req: toHookRequestContext(c.req),
3057
+ doc: finalDoc
3058
+ },
3059
+ finalDoc
3060
+ );
1992
3061
  return c.json(accessibleDoc);
1993
3062
  }
1994
3063
  async seed(c) {
@@ -2004,7 +3073,10 @@ var GlobalController = class {
2004
3073
  if (existing && !isFunctionallyEmpty(existing)) {
2005
3074
  return c.json({ message: "Global is not empty, skipping seed" });
2006
3075
  }
2007
- console.log(`[dyrected/core] Auto-seeding global: ${this.global.slug}`);
3076
+ getRequestLogger(c, "global").info({
3077
+ msg: "Auto-seeding global",
3078
+ global: this.global.slug
3079
+ });
2008
3080
  await db.updateGlobal({ slug: this.global.slug, data: initialData });
2009
3081
  return c.json({ message: "Seed successful", data: initialData }, 201);
2010
3082
  }
@@ -2023,9 +3095,116 @@ function isFunctionallyEmpty(obj) {
2023
3095
  return false;
2024
3096
  }
2025
3097
 
3098
+ // src/utils/block-references.ts
3099
+ function mergeUniqueBlocks(blocks) {
3100
+ const seen = /* @__PURE__ */ new Map();
3101
+ for (const block of blocks) {
3102
+ const existing = seen.get(block.slug);
3103
+ if (existing && existing !== block) {
3104
+ throw new Error(
3105
+ `Duplicate block slug "${block.slug}" found in the reusable block registry. Block slugs must be unique.`
3106
+ );
3107
+ }
3108
+ if (!existing) seen.set(block.slug, block);
3109
+ }
3110
+ return Array.from(seen.values());
3111
+ }
3112
+ function buildRegistry(blocks) {
3113
+ return new Map(blocks.map((block) => [block.slug, block]));
3114
+ }
3115
+ function resolveField(field, registry, blockCache) {
3116
+ const next = { ...field };
3117
+ if (next.fields) {
3118
+ next.fields = next.fields.map(
3119
+ (child) => resolveField(child, registry, blockCache)
3120
+ );
3121
+ }
3122
+ if (next.type !== "blocks") return next;
3123
+ const hasInlineBlocks = Array.isArray(next.blocks) && next.blocks.length > 0;
3124
+ const hasReferences = Array.isArray(next.blockReferences) && next.blockReferences.length > 0;
3125
+ if (hasInlineBlocks && hasReferences) {
3126
+ throw new Error(
3127
+ `Blocks field "${next.name ?? "(unnamed)"}" cannot define both "blocks" and "blockReferences". Use one or the other.`
3128
+ );
3129
+ }
3130
+ if (hasReferences) {
3131
+ next.blocks = next.blockReferences.map((slug) => {
3132
+ const block = registry.get(slug);
3133
+ if (!block) {
3134
+ throw new Error(
3135
+ `Unknown block reference "${slug}" on blocks field "${next.name ?? "(unnamed)"}". Add it to defineConfig({ blocks: [...] }).`
3136
+ );
3137
+ }
3138
+ return resolveBlock(block, registry, blockCache);
3139
+ });
3140
+ return next;
3141
+ }
3142
+ if (hasInlineBlocks) {
3143
+ next.blocks = next.blocks.map(
3144
+ (block) => resolveBlock(block, registry, blockCache)
3145
+ );
3146
+ }
3147
+ return next;
3148
+ }
3149
+ function resolveBlock(block, registry, blockCache) {
3150
+ const cached = blockCache.get(block);
3151
+ if (cached) return cached;
3152
+ const resolved = {
3153
+ ...block,
3154
+ fields: []
3155
+ };
3156
+ blockCache.set(block, resolved);
3157
+ resolved.fields = block.fields.map(
3158
+ (field) => resolveField(field, registry, blockCache)
3159
+ );
3160
+ return resolved;
3161
+ }
3162
+ function resolveFields(fields, registry, blockCache) {
3163
+ return fields.map((field) => resolveField(field, registry, blockCache));
3164
+ }
3165
+ function normalizeSchemaFragment(fragment) {
3166
+ const reusableBlocks = mergeUniqueBlocks(fragment.blocks ?? []);
3167
+ const registry = buildRegistry(reusableBlocks);
3168
+ const blockCache = /* @__PURE__ */ new WeakMap();
3169
+ const resolvedBlocks = reusableBlocks.map(
3170
+ (block) => resolveBlock(block, registry, blockCache)
3171
+ );
3172
+ const resolvedCollections = (fragment.collections ?? []).map(
3173
+ (collection) => ({
3174
+ ...collection,
3175
+ fields: resolveFields(collection.fields ?? [], registry, blockCache)
3176
+ })
3177
+ );
3178
+ const resolvedGlobals = (fragment.globals ?? []).map((global) => ({
3179
+ ...global,
3180
+ fields: resolveFields(global.fields ?? [], registry, blockCache)
3181
+ }));
3182
+ return {
3183
+ ...fragment,
3184
+ ...fragment.blocks ? { blocks: resolvedBlocks } : {},
3185
+ ...fragment.collections ? { collections: resolvedCollections } : {},
3186
+ ...fragment.globals ? { globals: resolvedGlobals } : {}
3187
+ };
3188
+ }
3189
+ function mergeDynamicConfig(baseConfig, dynamic) {
3190
+ const normalizedDynamic = normalizeSchemaFragment({
3191
+ ...dynamic,
3192
+ blocks: [...baseConfig.blocks ?? [], ...dynamic.blocks ?? []]
3193
+ });
3194
+ return {
3195
+ ...baseConfig,
3196
+ ...normalizedDynamic.blocks ? { blocks: normalizedDynamic.blocks } : {},
3197
+ ...dynamic.admin ? { admin: { ...baseConfig.admin, ...dynamic.admin } } : {},
3198
+ ...dynamic.adminAuth ? { adminAuth: { ...baseConfig.adminAuth, ...dynamic.adminAuth } } : {},
3199
+ collections: normalizedDynamic.collections ? [...baseConfig.collections, ...normalizedDynamic.collections] : baseConfig.collections,
3200
+ globals: normalizedDynamic.globals ? [...baseConfig.globals, ...normalizedDynamic.globals] : baseConfig.globals
3201
+ };
3202
+ }
3203
+
2026
3204
  // src/controllers/media.controller.ts
2027
3205
  var MediaController = class {
2028
3206
  collection;
3207
+ loggerComponent = "media";
2029
3208
  constructor(collection = "media") {
2030
3209
  this.collection = collection;
2031
3210
  }
@@ -2044,15 +3223,25 @@ var MediaController = class {
2044
3223
  return c.json({ message: "No file uploaded" }, 400);
2045
3224
  }
2046
3225
  const siteId = c.get("siteId");
2047
- let colConfig = config.collections.find((col) => col.slug === this.collection);
3226
+ let colConfig = config.collections.find(
3227
+ (col) => col.slug === this.collection
3228
+ );
2048
3229
  if (!colConfig && config.onSchemaFetch && siteId) {
2049
- const dynamic = await config.onSchemaFetch(siteId);
2050
- colConfig = dynamic.collections?.find((col) => col.slug === this.collection);
3230
+ const requestConfig = mergeDynamicConfig(
3231
+ config,
3232
+ await config.onSchemaFetch(siteId)
3233
+ );
3234
+ colConfig = requestConfig.collections.find(
3235
+ (col) => col.slug === this.collection
3236
+ );
2051
3237
  }
2052
3238
  const uploadConfig = typeof colConfig?.upload === "object" ? colConfig.upload : void 0;
2053
3239
  const validationError = validateUpload(file, uploadConfig);
2054
3240
  if (validationError) {
2055
- return c.json({ message: validationError.message }, validationError.status);
3241
+ return c.json(
3242
+ { message: validationError.message },
3243
+ validationError.status
3244
+ );
2056
3245
  }
2057
3246
  const buffer = new Uint8Array(await file.arrayBuffer());
2058
3247
  const workspaceId = c.get("workspaceId");
@@ -2070,7 +3259,12 @@ var MediaController = class {
2070
3259
  imageMetadata = processed.metadata;
2071
3260
  imageSizes = processed.sizes;
2072
3261
  } catch (err) {
2073
- console.error("[MediaController] Image processing failed:", err);
3262
+ getRequestLogger(c, this.loggerComponent).error({
3263
+ err,
3264
+ msg: "Image processing failed",
3265
+ collection: this.collection,
3266
+ filename: file.name
3267
+ });
2074
3268
  }
2075
3269
  }
2076
3270
  const fileData = await storage.upload({
@@ -2103,7 +3297,13 @@ var MediaController = class {
2103
3297
  height: sizeData.height
2104
3298
  };
2105
3299
  } catch (err) {
2106
- console.error(`[MediaController] Failed to upload size ${sizeName}:`, err);
3300
+ getRequestLogger(c, this.loggerComponent).error({
3301
+ err,
3302
+ msg: "Failed to upload image size",
3303
+ collection: this.collection,
3304
+ filename: file.name,
3305
+ sizeName
3306
+ });
2107
3307
  }
2108
3308
  }
2109
3309
  }
@@ -2167,25 +3367,37 @@ var MediaController = class {
2167
3367
  }
2168
3368
  };
2169
3369
 
2170
- // src/auth/token.ts
2171
- var import_jose = require("jose");
2172
- var import_node_util2 = require("util");
2173
- function getSecret() {
2174
- const secret = process.env.DYRECTED_JWT_SECRET;
2175
- if (!secret) {
2176
- throw new Error(
2177
- "[dyrected/core] DYRECTED_JWT_SECRET is not set. Add it to your environment variables to enable auth collections."
2178
- );
3370
+ // src/auth/lockout.ts
3371
+ var DEFAULT_MAX_LOGIN_ATTEMPTS = 5;
3372
+ var DEFAULT_LOCK_TIME_MS = 10 * 60 * 1e3;
3373
+ function resolveAuthLockoutConfig(collection) {
3374
+ const auth = collection.auth;
3375
+ if (!auth) {
3376
+ return {
3377
+ enabled: false,
3378
+ maxLoginAttempts: DEFAULT_MAX_LOGIN_ATTEMPTS,
3379
+ lockTime: DEFAULT_LOCK_TIME_MS
3380
+ };
2179
3381
  }
2180
- return new import_node_util2.TextEncoder().encode(secret);
2181
- }
2182
- var DEFAULT_EXPIRY = "7d";
2183
- async function signCollectionToken(payload, expiresIn = DEFAULT_EXPIRY) {
2184
- return new import_jose.SignJWT({ ...payload }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(expiresIn).sign(getSecret());
3382
+ if (auth === true) {
3383
+ return {
3384
+ enabled: true,
3385
+ maxLoginAttempts: DEFAULT_MAX_LOGIN_ATTEMPTS,
3386
+ lockTime: DEFAULT_LOCK_TIME_MS
3387
+ };
3388
+ }
3389
+ const maxLoginAttempts = typeof auth.maxLoginAttempts === "number" ? auth.maxLoginAttempts : DEFAULT_MAX_LOGIN_ATTEMPTS;
3390
+ const lockTime = typeof auth.lockTime === "number" && auth.lockTime > 0 ? auth.lockTime : DEFAULT_LOCK_TIME_MS;
3391
+ return {
3392
+ enabled: maxLoginAttempts > 0,
3393
+ maxLoginAttempts,
3394
+ lockTime
3395
+ };
2185
3396
  }
2186
- async function verifyCollectionToken(token) {
2187
- const { payload } = await (0, import_jose.jwtVerify)(token, getSecret());
2188
- return payload;
3397
+ function getLockedUntilMs(value) {
3398
+ if (typeof value !== "string") return null;
3399
+ const parsed = Date.parse(value);
3400
+ return Number.isFinite(parsed) ? parsed : null;
2189
3401
  }
2190
3402
 
2191
3403
  // src/services/email-template.ts
@@ -2285,6 +3497,7 @@ async function getDevSend() {
2285
3497
  if (_devSend) return _devSend;
2286
3498
  if (_devSendPromise) return _devSendPromise;
2287
3499
  _devSendPromise = (async () => {
3500
+ const logger = getConfigLogger(void 0, "email");
2288
3501
  try {
2289
3502
  const nodemailer = await import("nodemailer");
2290
3503
  const account = await nodemailer.default.createTestAccount();
@@ -2293,23 +3506,50 @@ async function getDevSend() {
2293
3506
  port: 587,
2294
3507
  auth: { user: account.user, pass: account.pass }
2295
3508
  });
2296
- console.log("[dyrected/core] No email config \u2014 using Ethereal for dev email preview.");
2297
- console.log(`[dyrected/core] Ethereal login: https://ethereal.email user: ${account.user} pass: ${account.pass}`);
3509
+ logger.info({
3510
+ msg: "No email config; using Ethereal for development email preview"
3511
+ });
3512
+ logger.info({
3513
+ msg: "Ethereal credentials",
3514
+ loginUrl: "https://ethereal.email",
3515
+ user: account.user,
3516
+ password: account.pass
3517
+ });
2298
3518
  _devSend = async ({ to, subject, html }) => {
2299
3519
  const info = await transport.sendMail({ from: '"Dyrected Dev" <dev@dyrected.local>', to, subject, html });
2300
- console.log(`[dyrected/core] Email preview URL: ${nodemailer.default.getTestMessageUrl(info)}`);
3520
+ logger.info({
3521
+ msg: "Email preview URL",
3522
+ previewUrl: nodemailer.default.getTestMessageUrl(info)
3523
+ });
2301
3524
  };
2302
3525
  return _devSend;
2303
3526
  } catch {
2304
- console.warn("[dyrected/core] nodemailer not available \u2014 emails will not be sent in dev.");
3527
+ logger.warn({
3528
+ msg: "nodemailer not available; development emails will not be sent"
3529
+ });
2305
3530
  return null;
2306
3531
  }
2307
3532
  })();
2308
3533
  return _devSendPromise;
2309
3534
  }
2310
3535
  async function sendEmail(config, payload) {
3536
+ const logger = getConfigLogger(config, "email");
3537
+ const observability = getObservabilityRuntime(config);
2311
3538
  if (config.email) {
2312
- await config.email.send(payload);
3539
+ try {
3540
+ await config.email.send(payload);
3541
+ } catch (err) {
3542
+ observability?.recordEmailSendFailure({
3543
+ to: payload.to
3544
+ });
3545
+ logger.error({
3546
+ err,
3547
+ msg: "Email provider send failed",
3548
+ to: payload.to,
3549
+ subject: payload.subject
3550
+ });
3551
+ throw err;
3552
+ }
2313
3553
  return;
2314
3554
  }
2315
3555
  if (process.env.NODE_ENV !== "production") {
@@ -2373,6 +3613,55 @@ var AuthController = class {
2373
3613
  constructor(collection) {
2374
3614
  this.collection = collection;
2375
3615
  }
3616
+ sanitizeUser(user) {
3617
+ const {
3618
+ password: _password,
3619
+ loginAttempts: _loginAttempts,
3620
+ lockedUntil: _lockedUntil,
3621
+ ...safeUser
3622
+ } = user;
3623
+ return safeUser;
3624
+ }
3625
+ clearLockoutState(c, userId) {
3626
+ const db = c.get("config").db;
3627
+ if (!db) return Promise.resolve(null);
3628
+ return db.update({
3629
+ collection: this.collection.slug,
3630
+ id: userId,
3631
+ data: {
3632
+ loginAttempts: 0,
3633
+ lockedUntil: null
3634
+ }
3635
+ });
3636
+ }
3637
+ async recordFailedLogin(c, user) {
3638
+ const db = c.get("config").db;
3639
+ if (!db)
3640
+ return { justLocked: false, retryAfterSeconds: null };
3641
+ const lockout = resolveAuthLockoutConfig(this.collection);
3642
+ if (!lockout.enabled) {
3643
+ return { justLocked: false, retryAfterSeconds: null };
3644
+ }
3645
+ const now = Date.now();
3646
+ const lockedUntilMs = getLockedUntilMs(user.lockedUntil);
3647
+ const lockExpired = lockedUntilMs !== null && lockedUntilMs <= now;
3648
+ const currentAttempts = !lockExpired && typeof user.loginAttempts === "number" && user.loginAttempts > 0 ? user.loginAttempts : 0;
3649
+ const loginAttempts = currentAttempts + 1;
3650
+ const justLocked = loginAttempts >= lockout.maxLoginAttempts;
3651
+ const nextLockedUntil = justLocked ? new Date(now + lockout.lockTime).toISOString() : null;
3652
+ await db.update({
3653
+ collection: this.collection.slug,
3654
+ id: String(user.id),
3655
+ data: {
3656
+ loginAttempts,
3657
+ lockedUntil: nextLockedUntil
3658
+ }
3659
+ });
3660
+ return {
3661
+ justLocked,
3662
+ retryAfterSeconds: justLocked ? Math.max(1, Math.ceil(lockout.lockTime / 1e3)) : null
3663
+ };
3664
+ }
2376
3665
  // ---------------------------------------------------------------------------
2377
3666
  // GET /init
2378
3667
  // Checks if the first user needs to be created.
@@ -2401,11 +3690,17 @@ var AuthController = class {
2401
3690
  limit: 1
2402
3691
  });
2403
3692
  if (check.total > 0) {
2404
- return c.json({ error: true, message: "Initial user already exists." }, 403);
3693
+ return c.json(
3694
+ { error: true, message: "Initial user already exists." },
3695
+ 403
3696
+ );
2405
3697
  }
2406
3698
  const body = await c.req.json().catch(() => null);
2407
3699
  if (!body?.email || !body?.password) {
2408
- return c.json({ error: true, message: "email and password are required." }, 400);
3700
+ return c.json(
3701
+ { error: true, message: "email and password are required." },
3702
+ 400
3703
+ );
2409
3704
  }
2410
3705
  const hashedPassword = await hashPassword(body.password);
2411
3706
  const user = await db.create({
@@ -2417,16 +3712,23 @@ var AuthController = class {
2417
3712
  // Default first user to admin
2418
3713
  }
2419
3714
  });
2420
- const token = await signCollectionToken({
2421
- sub: user.id,
3715
+ const token = await issueAuthSessionToken({
3716
+ config,
3717
+ userId: user.id,
2422
3718
  email: user.email,
2423
- collection: this.collection.slug
3719
+ collection: this.collection.slug,
3720
+ ip: c.get("clientIp"),
3721
+ authSource: "local"
2424
3722
  });
2425
3723
  const { subject, html } = buildWelcomeEmail(config, { email: body.email });
2426
3724
  sendEmail(config, { to: body.email, subject, html }).catch(
2427
- (err) => console.error("[dyrected/core] Failed to send welcome email:", err)
3725
+ (err) => getRequestLogger(c, "auth").error({
3726
+ err,
3727
+ msg: "Failed to send welcome email",
3728
+ email: body.email
3729
+ })
2428
3730
  );
2429
- const { password: _, ...safeUser } = user;
3731
+ const safeUser = this.sanitizeUser(user);
2430
3732
  return c.json({ token, user: safeUser });
2431
3733
  }
2432
3734
  // ---------------------------------------------------------------------------
@@ -2437,7 +3739,10 @@ var AuthController = class {
2437
3739
  if (!db) return c.json({ message: "Database not configured" }, 500);
2438
3740
  const body = await c.req.json().catch(() => null);
2439
3741
  if (!body?.email || !body?.password) {
2440
- return c.json({ error: true, message: "email and password are required." }, 400);
3742
+ return c.json(
3743
+ { error: true, message: "email and password are required." },
3744
+ 400
3745
+ );
2441
3746
  }
2442
3747
  const result = await db.find({
2443
3748
  collection: this.collection.slug,
@@ -2446,27 +3751,107 @@ var AuthController = class {
2446
3751
  });
2447
3752
  const user = result.docs[0];
2448
3753
  if (!user) {
2449
- return c.json({ error: true, message: "Invalid email or password." }, 401);
3754
+ return c.json(
3755
+ { error: true, message: "Invalid email or password." },
3756
+ 401
3757
+ );
3758
+ }
3759
+ const lockedUntilMs = getLockedUntilMs(user.lockedUntil);
3760
+ if (lockedUntilMs !== null && lockedUntilMs > Date.now()) {
3761
+ const retryAfterSeconds = Math.max(
3762
+ 1,
3763
+ Math.ceil((lockedUntilMs - Date.now()) / 1e3)
3764
+ );
3765
+ c.header("Retry-After", String(retryAfterSeconds));
3766
+ return c.json(
3767
+ {
3768
+ error: true,
3769
+ message: "Too many login attempts. Try again later.",
3770
+ retryAfterSeconds
3771
+ },
3772
+ 429
3773
+ );
2450
3774
  }
2451
3775
  const valid = await verifyPassword(body.password, user.password);
2452
3776
  if (!valid) {
2453
- return c.json({ error: true, message: "Invalid email or password." }, 401);
3777
+ const { justLocked, retryAfterSeconds } = await this.recordFailedLogin(
3778
+ c,
3779
+ user
3780
+ );
3781
+ if (justLocked && retryAfterSeconds) {
3782
+ c.header("Retry-After", String(retryAfterSeconds));
3783
+ return c.json(
3784
+ {
3785
+ error: true,
3786
+ message: "Too many login attempts. Try again later.",
3787
+ retryAfterSeconds
3788
+ },
3789
+ 429
3790
+ );
3791
+ }
3792
+ return c.json(
3793
+ { error: true, message: "Invalid email or password." },
3794
+ 401
3795
+ );
3796
+ }
3797
+ if (typeof user.loginAttempts === "number" && user.loginAttempts > 0 || user.lockedUntil != null) {
3798
+ await this.clearLockoutState(c, String(user.id));
2454
3799
  }
2455
- const token = await signCollectionToken({
2456
- sub: user.id,
3800
+ const token = await issueAuthSessionToken({
3801
+ config: c.get("config"),
3802
+ userId: user.id,
2457
3803
  email: user.email,
2458
- collection: this.collection.slug
3804
+ collection: this.collection.slug,
3805
+ ip: c.get("clientIp"),
3806
+ authSource: "local"
2459
3807
  });
2460
- const { password: _, ...safeUser } = user;
3808
+ const safeUser = this.sanitizeUser(user);
2461
3809
  return c.json({ token, user: safeUser });
2462
3810
  }
2463
3811
  // ---------------------------------------------------------------------------
2464
3812
  // POST /logout
2465
- // Auth collections use stateless JWTs logout is handled client-side.
2466
- // This endpoint exists so clients have a consistent API surface.
3813
+ // Revoke the current session by default. Pass `?allSessions=true` to revoke
3814
+ // every active session for the current account.
2467
3815
  // ---------------------------------------------------------------------------
2468
3816
  async logout(c) {
2469
- return c.json({ success: true, message: "Logged out. Discard your token." });
3817
+ const requestUser = c.get("user");
3818
+ const tokenPayload = c.get("authTokenPayload");
3819
+ const allSessions = ["1", "true", "yes"].includes(
3820
+ (c.req.query("allSessions") || "").toLowerCase()
3821
+ );
3822
+ if (!requestUser) {
3823
+ if (allSessions) {
3824
+ return c.json(
3825
+ { error: true, message: "Authentication required." },
3826
+ 401
3827
+ );
3828
+ }
3829
+ return c.json({
3830
+ success: true,
3831
+ message: "Logged out. Discard your token."
3832
+ });
3833
+ }
3834
+ if (allSessions) {
3835
+ await revokeAllAuthSessions(c.get("config"), {
3836
+ userId: requestUser.sub,
3837
+ collection: this.collection.slug
3838
+ });
3839
+ return c.json({
3840
+ success: true,
3841
+ message: "All sessions have been logged out."
3842
+ });
3843
+ }
3844
+ if (tokenPayload?.sid) {
3845
+ await revokeAuthSession(c.get("config"), tokenPayload.sid);
3846
+ return c.json({
3847
+ success: true,
3848
+ message: "Logged out."
3849
+ });
3850
+ }
3851
+ return c.json({
3852
+ success: true,
3853
+ message: "Logged out. Discard your token."
3854
+ });
2470
3855
  }
2471
3856
  // ---------------------------------------------------------------------------
2472
3857
  // GET /me
@@ -2478,11 +3863,14 @@ var AuthController = class {
2478
3863
  if (!requestUser) {
2479
3864
  return c.json({ error: true, message: "Authentication required." }, 401);
2480
3865
  }
2481
- const user = await db.findOne({ collection: this.collection.slug, id: requestUser.sub });
3866
+ const user = await db.findOne({
3867
+ collection: this.collection.slug,
3868
+ id: requestUser.sub
3869
+ });
2482
3870
  if (!user) {
2483
3871
  return c.json({ error: true, message: "User not found." }, 404);
2484
3872
  }
2485
- const { password: _, ...safeUser } = user;
3873
+ const safeUser = this.sanitizeUser(user);
2486
3874
  return c.json(safeUser);
2487
3875
  }
2488
3876
  // ---------------------------------------------------------------------------
@@ -2493,10 +3881,29 @@ var AuthController = class {
2493
3881
  if (!requestUser) {
2494
3882
  return c.json({ error: true, message: "Authentication required." }, 401);
2495
3883
  }
2496
- const token = await signCollectionToken({
2497
- sub: requestUser.sub,
3884
+ if (!requestUser.email) {
3885
+ return c.json(
3886
+ { error: true, message: "Authenticated user is missing an email." },
3887
+ 400
3888
+ );
3889
+ }
3890
+ const tokenPayload = c.get("authTokenPayload");
3891
+ if (tokenPayload?.sid) {
3892
+ const token2 = await signCollectionToken({
3893
+ sub: requestUser.sub,
3894
+ email: requestUser.email,
3895
+ collection: this.collection.slug,
3896
+ sid: tokenPayload.sid
3897
+ });
3898
+ return c.json({ token: token2 });
3899
+ }
3900
+ const token = await issueAuthSessionToken({
3901
+ config: c.get("config"),
3902
+ userId: requestUser.sub,
2498
3903
  email: requestUser.email,
2499
- collection: this.collection.slug
3904
+ collection: this.collection.slug,
3905
+ ip: c.get("clientIp"),
3906
+ authSource: "local"
2500
3907
  });
2501
3908
  return c.json({ token });
2502
3909
  }
@@ -2521,16 +3928,28 @@ var AuthController = class {
2521
3928
  const user = result.docs[0];
2522
3929
  if (user) {
2523
3930
  const resetToken = await signCollectionToken(
2524
- { sub: user.id, email: user.email, collection: this.collection.slug, purpose: "reset" },
3931
+ {
3932
+ sub: user.id,
3933
+ email: user.email,
3934
+ collection: this.collection.slug,
3935
+ purpose: "reset"
3936
+ },
2525
3937
  "1h"
2526
3938
  );
2527
3939
  const resetUrl = body?.resetUrl;
2528
3940
  const url = resetUrl ? `${resetUrl}${resetUrl.includes("?") ? "&" : "?"}token=${encodeURIComponent(resetToken)}` : void 0;
2529
3941
  try {
2530
- const { subject, html } = buildResetPasswordEmail(config, { token: resetToken, url });
3942
+ const { subject, html } = buildResetPasswordEmail(config, {
3943
+ token: resetToken,
3944
+ url
3945
+ });
2531
3946
  await sendEmail(config, { to: user.email, subject, html });
2532
3947
  } catch (err) {
2533
- console.error("[dyrected/core] Failed to send password reset email:", err);
3948
+ getRequestLogger(c, "auth").error({
3949
+ err,
3950
+ msg: "Failed to send password reset email",
3951
+ email: user.email
3952
+ });
2534
3953
  }
2535
3954
  }
2536
3955
  return c.json({
@@ -2549,28 +3968,54 @@ var AuthController = class {
2549
3968
  if (!db) return c.json({ message: "Database not configured" }, 500);
2550
3969
  const body = await c.req.json().catch(() => null);
2551
3970
  if (!body?.token || !body?.password) {
2552
- return c.json({ error: true, message: "token and password are required." }, 400);
3971
+ return c.json(
3972
+ { error: true, message: "token and password are required." },
3973
+ 400
3974
+ );
2553
3975
  }
2554
3976
  let payload;
2555
3977
  try {
2556
3978
  payload = await verifyCollectionToken(body.token);
2557
3979
  } catch {
2558
- return c.json({ error: true, message: "Reset token is invalid or has expired." }, 400);
3980
+ return c.json(
3981
+ { error: true, message: "Reset token is invalid or has expired." },
3982
+ 400
3983
+ );
2559
3984
  }
2560
3985
  if (payload.collection !== this.collection.slug || payload.purpose !== "reset") {
2561
- return c.json({ error: true, message: "Reset token is invalid or has expired." }, 400);
3986
+ return c.json(
3987
+ { error: true, message: "Reset token is invalid or has expired." },
3988
+ 400
3989
+ );
2562
3990
  }
2563
3991
  const hashedPassword = await hashPassword(body.password);
2564
3992
  await db.update({
2565
3993
  collection: this.collection.slug,
2566
3994
  id: payload.sub,
2567
- data: { password: hashedPassword }
3995
+ data: {
3996
+ password: hashedPassword,
3997
+ loginAttempts: 0,
3998
+ lockedUntil: null
3999
+ }
4000
+ });
4001
+ await revokeAllAuthSessions(config, {
4002
+ userId: payload.sub,
4003
+ collection: this.collection.slug
4004
+ });
4005
+ const { subject, html } = buildPasswordChangedEmail(config, {
4006
+ email: payload.email
2568
4007
  });
2569
- const { subject, html } = buildPasswordChangedEmail(config, { email: payload.email });
2570
4008
  sendEmail(config, { to: payload.email, subject, html }).catch(
2571
- (err) => console.error("[dyrected/core] Failed to send password-changed email:", err)
4009
+ (err) => getRequestLogger(c, "auth").error({
4010
+ err,
4011
+ msg: "Failed to send password-changed email",
4012
+ email: payload.email
4013
+ })
2572
4014
  );
2573
- return c.json({ success: true, message: "Password has been reset. You can now log in." });
4015
+ return c.json({
4016
+ success: true,
4017
+ message: "Password has been reset. You can now log in."
4018
+ });
2574
4019
  }
2575
4020
  // ---------------------------------------------------------------------------
2576
4021
  // POST /invite
@@ -2594,10 +4039,18 @@ var AuthController = class {
2594
4039
  limit: 1
2595
4040
  });
2596
4041
  if (existing.total > 0) {
2597
- return c.json({ error: true, message: "An account with that email already exists." }, 409);
4042
+ return c.json(
4043
+ { error: true, message: "An account with that email already exists." },
4044
+ 409
4045
+ );
2598
4046
  }
2599
4047
  const inviteToken = await signCollectionToken(
2600
- { sub: body.email, email: body.email, collection: this.collection.slug, purpose: "invite" },
4048
+ {
4049
+ sub: body.email,
4050
+ email: body.email,
4051
+ collection: this.collection.slug,
4052
+ purpose: "invite"
4053
+ },
2601
4054
  "7d"
2602
4055
  );
2603
4056
  try {
@@ -2607,7 +4060,11 @@ var AuthController = class {
2607
4060
  });
2608
4061
  await sendEmail(config, { to: body.email, subject, html });
2609
4062
  } catch (err) {
2610
- console.error("[dyrected/core] Failed to send invite email:", err);
4063
+ getRequestLogger(c, "auth").error({
4064
+ err,
4065
+ msg: "Failed to send invite email",
4066
+ email: body.email
4067
+ });
2611
4068
  }
2612
4069
  return c.json({ success: true, message: `Invite sent to ${body.email}.` });
2613
4070
  }
@@ -2622,16 +4079,25 @@ var AuthController = class {
2622
4079
  if (!db) return c.json({ message: "Database not configured" }, 500);
2623
4080
  const body = await c.req.json().catch(() => null);
2624
4081
  if (!body?.token || !body?.password) {
2625
- return c.json({ error: true, message: "token and password are required." }, 400);
4082
+ return c.json(
4083
+ { error: true, message: "token and password are required." },
4084
+ 400
4085
+ );
2626
4086
  }
2627
4087
  let payload;
2628
4088
  try {
2629
4089
  payload = await verifyCollectionToken(body.token);
2630
4090
  } catch {
2631
- return c.json({ error: true, message: "Invite token is invalid or has expired." }, 400);
4091
+ return c.json(
4092
+ { error: true, message: "Invite token is invalid or has expired." },
4093
+ 400
4094
+ );
2632
4095
  }
2633
4096
  if (payload.collection !== this.collection.slug || payload.purpose !== "invite") {
2634
- return c.json({ error: true, message: "Invite token is invalid or has expired." }, 400);
4097
+ return c.json(
4098
+ { error: true, message: "Invite token is invalid or has expired." },
4099
+ 400
4100
+ );
2635
4101
  }
2636
4102
  const inviteeEmail = payload.sub;
2637
4103
  const existing = await db.find({
@@ -2640,7 +4106,10 @@ var AuthController = class {
2640
4106
  limit: 1
2641
4107
  });
2642
4108
  if (existing.total > 0) {
2643
- return c.json({ error: true, message: "An account with that email already exists." }, 409);
4109
+ return c.json(
4110
+ { error: true, message: "An account with that email already exists." },
4111
+ 409
4112
+ );
2644
4113
  }
2645
4114
  const { token: _t, password: _p, ...extraFields } = body;
2646
4115
  const hashedPassword = await hashPassword(body.password);
@@ -2648,16 +4117,25 @@ var AuthController = class {
2648
4117
  collection: this.collection.slug,
2649
4118
  data: { ...extraFields, email: inviteeEmail, password: hashedPassword }
2650
4119
  });
2651
- const sessionToken = await signCollectionToken({
2652
- sub: user.id,
4120
+ const sessionToken = await issueAuthSessionToken({
4121
+ config,
4122
+ userId: user.id,
2653
4123
  email: inviteeEmail,
2654
- collection: this.collection.slug
4124
+ collection: this.collection.slug,
4125
+ ip: c.get("clientIp"),
4126
+ authSource: "local"
4127
+ });
4128
+ const { subject, html } = buildWelcomeEmail(config, {
4129
+ email: inviteeEmail
2655
4130
  });
2656
- const { subject, html } = buildWelcomeEmail(config, { email: inviteeEmail });
2657
4131
  sendEmail(config, { to: inviteeEmail, subject, html }).catch(
2658
- (err) => console.error("[dyrected/core] Failed to send welcome email:", err)
4132
+ (err) => getRequestLogger(c, "auth").error({
4133
+ err,
4134
+ msg: "Failed to send welcome email",
4135
+ email: inviteeEmail
4136
+ })
2659
4137
  );
2660
- const { password: _, ...safeUser } = user;
4138
+ const safeUser = this.sanitizeUser(user);
2661
4139
  return c.json({ token: sessionToken, user: safeUser }, 201);
2662
4140
  }
2663
4141
  };
@@ -2673,13 +4151,21 @@ var AdminAuthController = class {
2673
4151
  config;
2674
4152
  async providers(c) {
2675
4153
  const requestConfig = await this.getRequestConfig(c);
2676
- return c.json(getPublicAdminAuthConfig(requestConfig.adminAuth, requestConfig.collections));
4154
+ return c.json(
4155
+ getPublicAdminAuthConfig(
4156
+ requestConfig.adminAuth,
4157
+ requestConfig.collections
4158
+ )
4159
+ );
2677
4160
  }
2678
4161
  async start(c) {
2679
4162
  const requestConfig = await this.getRequestConfig(c);
2680
4163
  const provider = this.getProvider(requestConfig, c.req.param("provider"));
2681
4164
  if (!provider) {
2682
- return c.json({ error: true, message: "Admin auth provider not found." }, 404);
4165
+ return c.json(
4166
+ { error: true, message: "Admin auth provider not found." },
4167
+ 404
4168
+ );
2683
4169
  }
2684
4170
  const siteId = this.getSiteId(c);
2685
4171
  const returnTo = this.normalizeReturnTo(c.req.query("returnTo"), c);
@@ -2693,9 +4179,15 @@ var AdminAuthController = class {
2693
4179
  return c.redirect(redirectUrl, 302);
2694
4180
  }
2695
4181
  if (!provider.startUrl) {
2696
- return c.json({ error: true, message: "This provider does not expose a start URL." }, 400);
4182
+ return c.json(
4183
+ { error: true, message: "This provider does not expose a start URL." },
4184
+ 400
4185
+ );
2697
4186
  }
2698
- const url = this.buildCustomProviderStartUrl(provider.startUrl, new URL(c.req.url).origin);
4187
+ const url = this.buildCustomProviderStartUrl(
4188
+ provider.startUrl,
4189
+ new URL(c.req.url).origin
4190
+ );
2699
4191
  if (!url) {
2700
4192
  return c.json(
2701
4193
  {
@@ -2714,13 +4206,23 @@ var AdminAuthController = class {
2714
4206
  const requestConfig = await this.getRequestConfig(c);
2715
4207
  const provider = this.getProvider(requestConfig, c.req.param("provider"));
2716
4208
  if (!provider) {
2717
- return c.json({ error: true, message: "Admin auth provider not found." }, 404);
4209
+ return c.json(
4210
+ { error: true, message: "Admin auth provider not found." },
4211
+ 404
4212
+ );
2718
4213
  }
2719
4214
  try {
2720
- const exchange = await this.completeProviderAuth(requestConfig, provider, c);
4215
+ const exchange = await this.completeProviderAuth(
4216
+ requestConfig,
4217
+ provider,
4218
+ c
4219
+ );
2721
4220
  const redirectUrl = new URL(exchange.returnTo);
2722
4221
  redirectUrl.searchParams.set("dyrectedExternalToken", exchange.token);
2723
- redirectUrl.searchParams.set("dyrectedAdminCollection", exchange.collectionSlug);
4222
+ redirectUrl.searchParams.set(
4223
+ "dyrectedAdminCollection",
4224
+ exchange.collectionSlug
4225
+ );
2724
4226
  return c.redirect(redirectUrl.toString(), 302);
2725
4227
  } catch (error) {
2726
4228
  const message = error instanceof Error ? error.message : "External authentication failed.";
@@ -2734,10 +4236,17 @@ var AdminAuthController = class {
2734
4236
  const requestConfig = await this.getRequestConfig(c);
2735
4237
  const provider = this.getProvider(requestConfig, c.req.param("provider"));
2736
4238
  if (!provider) {
2737
- return c.json({ error: true, message: "Admin auth provider not found." }, 404);
4239
+ return c.json(
4240
+ { error: true, message: "Admin auth provider not found." },
4241
+ 404
4242
+ );
2738
4243
  }
2739
4244
  try {
2740
- const exchange = await this.completeProviderAuth(requestConfig, provider, c);
4245
+ const exchange = await this.completeProviderAuth(
4246
+ requestConfig,
4247
+ provider,
4248
+ c
4249
+ );
2741
4250
  return c.json({
2742
4251
  token: exchange.token,
2743
4252
  collectionSlug: exchange.collectionSlug,
@@ -2750,7 +4259,44 @@ var AdminAuthController = class {
2750
4259
  }
2751
4260
  }
2752
4261
  async logout(c) {
2753
- return c.json({ success: true, message: "Logged out. Discard your token." });
4262
+ const requestUser = c.get("user");
4263
+ const tokenPayload = c.get("authTokenPayload");
4264
+ const allSessions = ["1", "true", "yes"].includes(
4265
+ (c.req.query("allSessions") || "").toLowerCase()
4266
+ );
4267
+ if (!requestUser) {
4268
+ if (allSessions) {
4269
+ return c.json(
4270
+ { error: true, message: "Authentication required." },
4271
+ 401
4272
+ );
4273
+ }
4274
+ return c.json({
4275
+ success: true,
4276
+ message: "Logged out. Discard your token."
4277
+ });
4278
+ }
4279
+ if (allSessions) {
4280
+ await revokeAllAuthSessions(c.get("config"), {
4281
+ userId: requestUser.sub,
4282
+ collection: requestUser.collection
4283
+ });
4284
+ return c.json({
4285
+ success: true,
4286
+ message: "All sessions have been logged out."
4287
+ });
4288
+ }
4289
+ if (tokenPayload?.sid) {
4290
+ await revokeAuthSession(c.get("config"), tokenPayload.sid);
4291
+ return c.json({
4292
+ success: true,
4293
+ message: "Logged out."
4294
+ });
4295
+ }
4296
+ return c.json({
4297
+ success: true,
4298
+ message: "Logged out. Discard your token."
4299
+ });
2754
4300
  }
2755
4301
  getProvider(config, id) {
2756
4302
  if (config.adminAuth?.mode !== "external") return void 0;
@@ -2766,7 +4312,12 @@ var AdminAuthController = class {
2766
4312
  }
2767
4313
  const db = c.get("config").db;
2768
4314
  if (!db) throw new Error("Database not configured.");
2769
- let user = await this.findUserByExternalIdentity(db, adminCollection.slug, provider.id, identity.sub) ?? (identity.email ? (await db.find({
4315
+ let user = await this.findUserByExternalIdentity(
4316
+ db,
4317
+ adminCollection.slug,
4318
+ provider.id,
4319
+ identity.sub
4320
+ ) ?? (identity.email ? (await db.find({
2770
4321
  collection: adminCollection.slug,
2771
4322
  where: { email: identity.email },
2772
4323
  limit: 1
@@ -2774,9 +4325,18 @@ var AdminAuthController = class {
2774
4325
  const provisioningMode = requestConfig.adminAuth?.provisioningMode ?? "jit_plus_membership_management";
2775
4326
  const allowJitProvisioning = provisioningMode !== "preprovisioned_only" && provider.allowJitProvisioning !== false;
2776
4327
  if (!user && !allowJitProvisioning) {
2777
- throw new Error("This account has not been provisioned for admin access.");
4328
+ throw new Error(
4329
+ "This account has not been provisioned for admin access."
4330
+ );
2778
4331
  }
2779
- const access = await this.resolveAccess(requestConfig, identity, provider.id, siteId, c, user);
4332
+ const access = await this.resolveAccess(
4333
+ requestConfig,
4334
+ identity,
4335
+ provider.id,
4336
+ siteId,
4337
+ c,
4338
+ user
4339
+ );
2780
4340
  if (!access.allowed) {
2781
4341
  throw new Error("Access denied for this site.");
2782
4342
  }
@@ -2784,7 +4344,12 @@ var AdminAuthController = class {
2784
4344
  user = await db.create({
2785
4345
  collection: adminCollection.slug,
2786
4346
  data: {
2787
- ...await this.buildUserData(identity, provider.id, access.roles, access.data),
4347
+ ...await this.buildUserData(
4348
+ identity,
4349
+ provider.id,
4350
+ access.roles,
4351
+ access.data
4352
+ ),
2788
4353
  password: await hashPassword((0, import_node_crypto2.randomBytes)(32).toString("hex"))
2789
4354
  }
2790
4355
  });
@@ -2792,18 +4357,25 @@ var AdminAuthController = class {
2792
4357
  user = await db.update({
2793
4358
  collection: adminCollection.slug,
2794
4359
  id: user.id,
2795
- data: await this.buildUserData(identity, provider.id, access.roles, access.data)
4360
+ data: await this.buildUserData(
4361
+ identity,
4362
+ provider.id,
4363
+ access.roles,
4364
+ access.data
4365
+ )
2796
4366
  });
2797
4367
  }
2798
4368
  if (!user?.email) {
2799
4369
  throw new Error("Authenticated admin user is missing an email address.");
2800
4370
  }
2801
- const token = await signCollectionToken({
2802
- sub: user.id,
4371
+ const token = await issueAuthSessionToken({
4372
+ config: c.get("config"),
4373
+ userId: user.id,
2803
4374
  email: user.email,
2804
4375
  collection: adminCollection.slug,
2805
4376
  providerId: provider.id,
2806
- authSource: "external"
4377
+ authSource: "external",
4378
+ ip: c.get("clientIp")
2807
4379
  });
2808
4380
  return {
2809
4381
  token,
@@ -2857,14 +4429,10 @@ var AdminAuthController = class {
2857
4429
  async getRequestConfig(c) {
2858
4430
  const siteId = this.getSiteId(c);
2859
4431
  if (!siteId || !this.config.onSchemaFetch) return this.config;
2860
- const dynamic = await this.config.onSchemaFetch(siteId);
2861
- return {
2862
- ...this.config,
2863
- ...dynamic.admin ? { admin: { ...this.config.admin, ...dynamic.admin } } : {},
2864
- ...dynamic.adminAuth ? { adminAuth: { ...this.config.adminAuth, ...dynamic.adminAuth } } : {},
2865
- collections: dynamic.collections ? [...this.config.collections, ...dynamic.collections] : this.config.collections,
2866
- globals: dynamic.globals ? [...this.config.globals, ...dynamic.globals] : this.config.globals
2867
- };
4432
+ return mergeDynamicConfig(
4433
+ this.config,
4434
+ await this.config.onSchemaFetch(siteId)
4435
+ );
2868
4436
  }
2869
4437
  async buildOidcStartUrl(provider, returnTo, siteId, origin) {
2870
4438
  const discovery = await this.getOidcDiscovery(provider);
@@ -2875,11 +4443,19 @@ var AdminAuthController = class {
2875
4443
  siteId,
2876
4444
  nonce
2877
4445
  });
2878
- const url = new URL(provider.authorizationEndpoint || discovery.authorization_endpoint);
4446
+ const url = new URL(
4447
+ provider.authorizationEndpoint || discovery.authorization_endpoint
4448
+ );
2879
4449
  url.searchParams.set("response_type", "code");
2880
4450
  url.searchParams.set("client_id", provider.clientId);
2881
- url.searchParams.set("redirect_uri", provider.redirectUri || this.defaultCallbackPath(provider.id, origin));
2882
- url.searchParams.set("scope", (provider.scopes ?? ["openid", "profile", "email"]).join(" "));
4451
+ url.searchParams.set(
4452
+ "redirect_uri",
4453
+ provider.redirectUri || this.defaultCallbackPath(provider.id, origin)
4454
+ );
4455
+ url.searchParams.set(
4456
+ "scope",
4457
+ (provider.scopes ?? ["openid", "profile", "email"]).join(" ")
4458
+ );
2883
4459
  url.searchParams.set("state", state);
2884
4460
  url.searchParams.set("nonce", nonce);
2885
4461
  return url.toString();
@@ -2891,23 +4467,26 @@ var AdminAuthController = class {
2891
4467
  const parsedState = await this.verifyState(state, provider.id);
2892
4468
  const discovery = await this.getOidcDiscovery(provider);
2893
4469
  const redirectUri = provider.redirectUri || this.defaultCallbackPath(provider.id, new URL(c.req.url).origin);
2894
- const tokenResponse = await fetch(provider.tokenEndpoint || discovery.token_endpoint, {
2895
- method: "POST",
2896
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
2897
- body: new URLSearchParams({
2898
- grant_type: "authorization_code",
2899
- code,
2900
- redirect_uri: redirectUri,
2901
- client_id: provider.clientId,
2902
- client_secret: provider.clientSecret
2903
- })
2904
- });
4470
+ const tokenResponse = await fetch(
4471
+ provider.tokenEndpoint || discovery.token_endpoint,
4472
+ {
4473
+ method: "POST",
4474
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
4475
+ body: new URLSearchParams({
4476
+ grant_type: "authorization_code",
4477
+ code,
4478
+ redirect_uri: redirectUri,
4479
+ client_id: provider.clientId,
4480
+ client_secret: provider.clientSecret
4481
+ })
4482
+ }
4483
+ );
2905
4484
  if (!tokenResponse.ok) {
2906
4485
  throw new Error("OIDC token exchange failed.");
2907
4486
  }
2908
4487
  const tokenBody = await tokenResponse.json();
2909
4488
  const idToken = tokenBody.id_token;
2910
- let claims = {};
4489
+ let claims;
2911
4490
  if (idToken) {
2912
4491
  const jwks = (0, import_jose2.createRemoteJWKSet)(new URL(discovery.jwks_uri));
2913
4492
  const { payload } = await (0, import_jose2.jwtVerify)(idToken, jwks, {
@@ -2919,9 +4498,12 @@ var AdminAuthController = class {
2919
4498
  }
2920
4499
  claims = payload;
2921
4500
  } else if (tokenBody.access_token && (provider.userInfoEndpoint || discovery.userinfo_endpoint)) {
2922
- const userInfo = await fetch(provider.userInfoEndpoint || discovery.userinfo_endpoint, {
2923
- headers: { Authorization: `Bearer ${tokenBody.access_token}` }
2924
- });
4501
+ const userInfo = await fetch(
4502
+ provider.userInfoEndpoint || discovery.userinfo_endpoint,
4503
+ {
4504
+ headers: { Authorization: `Bearer ${tokenBody.access_token}` }
4505
+ }
4506
+ );
2925
4507
  if (!userInfo.ok) throw new Error("OIDC userinfo request failed.");
2926
4508
  claims = await userInfo.json();
2927
4509
  } else {
@@ -2966,7 +4548,10 @@ var AdminAuthController = class {
2966
4548
  }
2967
4549
  }
2968
4550
  return {
2969
- identity: this.mapClaims(claims, provider.claimMapping),
4551
+ identity: this.mapClaims(
4552
+ claims,
4553
+ provider.claimMapping
4554
+ ),
2970
4555
  returnTo: this.normalizeReturnTo(
2971
4556
  body?.returnTo || c.req.query("returnTo"),
2972
4557
  c
@@ -2990,9 +4575,15 @@ var AdminAuthController = class {
2990
4575
  email: this.readStringClaim(claims[emailKey]),
2991
4576
  name: this.readStringClaim(claims[nameKey]),
2992
4577
  roles: this.readStringArrayClaim(rolesKey ? claims[rolesKey] : void 0),
2993
- groups: this.readStringArrayClaim(groupsKey ? claims[groupsKey] : void 0),
2994
- siteIds: this.readStringArrayClaim(siteIdsKey ? claims[siteIdsKey] : void 0),
2995
- workspaceIds: this.readStringArrayClaim(workspaceIdsKey ? claims[workspaceIdsKey] : void 0),
4578
+ groups: this.readStringArrayClaim(
4579
+ groupsKey ? claims[groupsKey] : void 0
4580
+ ),
4581
+ siteIds: this.readStringArrayClaim(
4582
+ siteIdsKey ? claims[siteIdsKey] : void 0
4583
+ ),
4584
+ workspaceIds: this.readStringArrayClaim(
4585
+ workspaceIdsKey ? claims[workspaceIdsKey] : void 0
4586
+ ),
2996
4587
  rawClaims: claims
2997
4588
  };
2998
4589
  }
@@ -3002,7 +4593,9 @@ var AdminAuthController = class {
3002
4593
  readStringArrayClaim(value) {
3003
4594
  if (!value) return void 0;
3004
4595
  if (Array.isArray(value)) {
3005
- return value.filter((entry) => typeof entry === "string");
4596
+ return value.filter(
4597
+ (entry) => typeof entry === "string"
4598
+ );
3006
4599
  }
3007
4600
  if (typeof value === "string") return [value];
3008
4601
  return void 0;
@@ -3012,7 +4605,8 @@ var AdminAuthController = class {
3012
4605
  provider.issuer.replace(/\/$/, "") + "/.well-known/openid-configuration"
3013
4606
  );
3014
4607
  const response = await fetch(discoveryUrl.toString());
3015
- if (!response.ok) throw new Error("Failed to load OIDC discovery document.");
4608
+ if (!response.ok)
4609
+ throw new Error("Failed to load OIDC discovery document.");
3016
4610
  return response.json();
3017
4611
  }
3018
4612
  defaultCallbackPath(providerId, origin = "") {
@@ -3069,7 +4663,10 @@ var PreviewController = class {
3069
4663
  async createToken(c) {
3070
4664
  const body = await c.req.json().catch(() => null);
3071
4665
  if (!body?.collectionSlug || !body?.data) {
3072
- return c.json({ error: true, message: "collectionSlug and data are required." }, 400);
4666
+ return c.json(
4667
+ { error: true, message: "collectionSlug and data are required." },
4668
+ 400
4669
+ );
3073
4670
  }
3074
4671
  const token = await new import_jose3.SignJWT({
3075
4672
  collectionSlug: body.collectionSlug,
@@ -3086,13 +4683,19 @@ var PreviewController = class {
3086
4683
  async getData(c) {
3087
4684
  const token = c.req.query("token");
3088
4685
  if (!token) {
3089
- return c.json({ error: true, message: "token query parameter is required." }, 400);
4686
+ return c.json(
4687
+ { error: true, message: "token query parameter is required." },
4688
+ 400
4689
+ );
3090
4690
  }
3091
4691
  try {
3092
4692
  const { payload } = await (0, import_jose3.jwtVerify)(token, this.getSecret());
3093
4693
  return c.json(payload);
3094
- } catch (err) {
3095
- return c.json({ error: true, message: "Invalid or expired preview token." }, 401);
4694
+ } catch (_err) {
4695
+ return c.json(
4696
+ { error: true, message: "Invalid or expired preview token." },
4697
+ 401
4698
+ );
3096
4699
  }
3097
4700
  }
3098
4701
  };
@@ -3168,9 +4771,15 @@ var AuditController = class {
3168
4771
  if (!access.allowed) {
3169
4772
  return { denied: true, where: {} };
3170
4773
  }
3171
- let where = { collection: { equals: collection.slug } };
4774
+ let where = {
4775
+ collection: { equals: collection.slug }
4776
+ };
3172
4777
  if (access.constraint) {
3173
- const ids = await this.collectAccessibleDocumentIds(config.db, collection, access.constraint);
4778
+ const ids = await this.collectAccessibleDocumentIds(
4779
+ config.db,
4780
+ collection,
4781
+ access.constraint
4782
+ );
3174
4783
  where = mergeWhereConstraint(where, { documentId: { in: ids } });
3175
4784
  }
3176
4785
  return { denied: false, where };
@@ -3183,8 +4792,11 @@ var AuditController = class {
3183
4792
  }
3184
4793
  const siteId = c.req.header("X-Site-Id") || c.get("siteId");
3185
4794
  if (config.onSchemaFetch && siteId) {
3186
- const dynamic = await config.onSchemaFetch(siteId);
3187
- for (const collection of dynamic.collections || []) {
4795
+ const requestConfig = mergeDynamicConfig(
4796
+ config,
4797
+ await config.onSchemaFetch(siteId)
4798
+ );
4799
+ for (const collection of requestConfig.collections) {
3188
4800
  if (!collections.has(collection.slug)) {
3189
4801
  collections.set(collection.slug, collection);
3190
4802
  }
@@ -3195,11 +4807,18 @@ var AuditController = class {
3195
4807
  async findForCollection(c, collection) {
3196
4808
  const config = c.get("config");
3197
4809
  if (!config.db) return c.json({ message: "Database not configured" }, 500);
3198
- if (!collection.audit) return c.json({ message: "Audit is not enabled for this collection" }, 404);
4810
+ if (!collection.audit)
4811
+ return c.json(
4812
+ { message: "Audit is not enabled for this collection" },
4813
+ 404
4814
+ );
3199
4815
  const query = this.parseQuery(c);
3200
4816
  const scope = await this.buildCollectionAuditScope(c, collection);
3201
4817
  if (scope.denied) {
3202
- return c.json({ error: true, message: `Access denied: read on ${collection.slug}` }, 403);
4818
+ return c.json(
4819
+ { error: true, message: `Access denied: read on ${collection.slug}` },
4820
+ 403
4821
+ );
3203
4822
  }
3204
4823
  const where = query.where ? mergeWhereConstraint(scope.where, query.where) : scope.where;
3205
4824
  const result = await config.db.find({
@@ -3215,7 +4834,9 @@ var AuditController = class {
3215
4834
  const config = c.get("config");
3216
4835
  if (!config.db) return c.json({ message: "Database not configured" }, 500);
3217
4836
  const query = this.parseQuery(c);
3218
- const collections = (await this.getVisibleCollections(c)).filter((collection) => collection.audit);
4837
+ const collections = (await this.getVisibleCollections(c)).filter(
4838
+ (collection) => collection.audit
4839
+ );
3219
4840
  const scopes = [];
3220
4841
  for (const collection of collections) {
3221
4842
  const scope = await this.buildCollectionAuditScope(c, collection);
@@ -3246,8 +4867,7 @@ function getBearerToken(c) {
3246
4867
  const authHeader = c.req.header("Authorization");
3247
4868
  return authHeader?.replace(/^Bearer\s+/i, "") || void 0;
3248
4869
  }
3249
- async function resolveUser(token, config) {
3250
- const payload = await verifyCollectionToken(token);
4870
+ async function resolveUser(payload, config) {
3251
4871
  if (payload.purpose) {
3252
4872
  return payload;
3253
4873
  }
@@ -3262,7 +4882,12 @@ async function resolveUser(token, config) {
3262
4882
  id: payload.sub
3263
4883
  });
3264
4884
  } catch (err) {
3265
- console.error("[dyrected/core] Failed to hydrate user from token:", err);
4885
+ getConfigLogger(config, "auth").error({
4886
+ err,
4887
+ msg: "Failed to hydrate user from token",
4888
+ collection: payload.collection,
4889
+ userId: payload.sub
4890
+ });
3266
4891
  return payload;
3267
4892
  }
3268
4893
  if (!doc) {
@@ -3276,6 +4901,18 @@ async function resolveUser(token, config) {
3276
4901
  collection: payload.collection
3277
4902
  };
3278
4903
  }
4904
+ async function resolveAuthenticatedRequest(token, config, clientIp) {
4905
+ const payload = await verifyCollectionToken(token);
4906
+ if (payload.sid && config?.db) {
4907
+ const session = await getAuthSession(config, payload.sid);
4908
+ if (!isAuthSessionActive(session) || session.userId !== payload.sub || session.collection !== payload.collection) {
4909
+ throw new Error("Invalid or expired session.");
4910
+ }
4911
+ await touchAuthSession(config, payload.sid, { ip: clientIp });
4912
+ }
4913
+ const user = await resolveUser(payload, config);
4914
+ return { user, payload };
4915
+ }
3279
4916
  function requireAuth(config) {
3280
4917
  return async (c, next) => {
3281
4918
  if (c.get("user")) {
@@ -3283,18 +4920,42 @@ function requireAuth(config) {
3283
4920
  }
3284
4921
  const token = getBearerToken(c);
3285
4922
  if (!token) {
4923
+ c.get("observability")?.recordAuthFailure({
4924
+ reason: "missing_token",
4925
+ path: c.req.path
4926
+ });
3286
4927
  return c.json({ error: true, message: "Authentication required." }, 401);
3287
4928
  }
3288
4929
  let user;
4930
+ let payload;
3289
4931
  try {
3290
- user = await resolveUser(token, config ?? c.get("config"));
3291
- } catch {
4932
+ const resolved = await resolveAuthenticatedRequest(
4933
+ token,
4934
+ config ?? c.get("config"),
4935
+ c.get("clientIp")
4936
+ );
4937
+ user = resolved.user;
4938
+ payload = resolved.payload;
4939
+ } catch (err) {
4940
+ c.get("observability")?.recordAuthFailure({
4941
+ reason: "invalid_token",
4942
+ path: c.req.path
4943
+ });
4944
+ getRequestLogger(c, "auth").warn({
4945
+ err,
4946
+ msg: "Rejected invalid or expired token"
4947
+ });
3292
4948
  return c.json({ error: true, message: "Invalid or expired token." }, 401);
3293
4949
  }
3294
4950
  if (!user) {
4951
+ c.get("observability")?.recordAuthFailure({
4952
+ reason: "missing_user",
4953
+ path: c.req.path
4954
+ });
3295
4955
  return c.json({ error: true, message: "Invalid or expired token." }, 401);
3296
4956
  }
3297
4957
  c.set("user", user);
4958
+ c.set("authTokenPayload", payload);
3298
4959
  await next();
3299
4960
  };
3300
4961
  }
@@ -3306,10 +4967,16 @@ function optionalAuth(config) {
3306
4967
  const token = getBearerToken(c);
3307
4968
  if (token) {
3308
4969
  try {
3309
- const user = await resolveUser(token, config ?? c.get("config"));
4970
+ const resolved = await resolveAuthenticatedRequest(
4971
+ token,
4972
+ config ?? c.get("config"),
4973
+ c.get("clientIp")
4974
+ );
4975
+ const user = resolved.user;
3310
4976
  if (user) {
3311
4977
  c.set("user", user);
3312
4978
  }
4979
+ c.set("authTokenPayload", resolved.payload);
3313
4980
  } catch {
3314
4981
  }
3315
4982
  }
@@ -3358,7 +5025,15 @@ function generateOpenApi(config) {
3358
5025
  },
3359
5026
  WorkflowHistoryEntry: {
3360
5027
  type: "object",
3361
- required: ["collection", "documentId", "transition", "from", "to", "revision", "createdAt"],
5028
+ required: [
5029
+ "collection",
5030
+ "documentId",
5031
+ "transition",
5032
+ "from",
5033
+ "to",
5034
+ "revision",
5035
+ "createdAt"
5036
+ ],
3362
5037
  properties: {
3363
5038
  id: { type: "string" },
3364
5039
  collection: { type: "string" },
@@ -3383,7 +5058,11 @@ function generateOpenApi(config) {
3383
5058
  user: { type: "string", nullable: true },
3384
5059
  timestamp: { type: "string", format: "date-time" },
3385
5060
  changes: {
3386
- oneOf: [{ type: "string" }, { type: "object", additionalProperties: true }, { type: "null" }]
5061
+ oneOf: [
5062
+ { type: "string" },
5063
+ { type: "object", additionalProperties: true },
5064
+ { type: "null" }
5065
+ ]
3387
5066
  }
3388
5067
  }
3389
5068
  },
@@ -3475,13 +5154,17 @@ function generateOpenApi(config) {
3475
5154
  get: {
3476
5155
  tags: ["Preferences"],
3477
5156
  summary: "Get an authenticated user preference",
3478
- parameters: [{ name: "key", in: "path", required: true, schema: { type: "string" } }],
5157
+ parameters: [
5158
+ { name: "key", in: "path", required: true, schema: { type: "string" } }
5159
+ ],
3479
5160
  responses: { 200: { description: "Preference value" } }
3480
5161
  },
3481
5162
  put: {
3482
5163
  tags: ["Preferences"],
3483
5164
  summary: "Set an authenticated user preference",
3484
- parameters: [{ name: "key", in: "path", required: true, schema: { type: "string" } }],
5165
+ parameters: [
5166
+ { name: "key", in: "path", required: true, schema: { type: "string" } }
5167
+ ],
3485
5168
  requestBody: {
3486
5169
  required: true,
3487
5170
  content: { "application/json": { schema: { type: "object" } } }
@@ -3517,10 +5200,23 @@ function generateOpenApi(config) {
3517
5200
  tags: ["Audit"],
3518
5201
  summary: "Get audit entries across all readable audited collections",
3519
5202
  parameters: [
3520
- { name: "limit", in: "query", schema: { type: "integer", default: 50, maximum: 100 } },
5203
+ {
5204
+ name: "limit",
5205
+ in: "query",
5206
+ schema: { type: "integer", default: 50, maximum: 100 }
5207
+ },
3521
5208
  { name: "page", in: "query", schema: { type: "integer", default: 1 } },
3522
- { name: "where", in: "query", schema: { type: "string" }, description: "JSON filter" },
3523
- { name: "sort", in: "query", schema: { type: "string", default: "-timestamp" } }
5209
+ {
5210
+ name: "where",
5211
+ in: "query",
5212
+ schema: { type: "string" },
5213
+ description: "JSON filter"
5214
+ },
5215
+ {
5216
+ name: "sort",
5217
+ in: "query",
5218
+ schema: { type: "string", default: "-timestamp" }
5219
+ }
3524
5220
  ],
3525
5221
  responses: {
3526
5222
  200: {
@@ -3725,10 +5421,27 @@ function generateOpenApi(config) {
3725
5421
  tags: [collectionTag],
3726
5422
  summary: `Get ${labels.singular} audit entries`,
3727
5423
  parameters: [
3728
- { name: "limit", in: "query", schema: { type: "integer", default: 50, maximum: 100 } },
3729
- { name: "page", in: "query", schema: { type: "integer", default: 1 } },
3730
- { name: "where", in: "query", schema: { type: "string" }, description: "JSON filter" },
3731
- { name: "sort", in: "query", schema: { type: "string", default: "-timestamp" } }
5424
+ {
5425
+ name: "limit",
5426
+ in: "query",
5427
+ schema: { type: "integer", default: 50, maximum: 100 }
5428
+ },
5429
+ {
5430
+ name: "page",
5431
+ in: "query",
5432
+ schema: { type: "integer", default: 1 }
5433
+ },
5434
+ {
5435
+ name: "where",
5436
+ in: "query",
5437
+ schema: { type: "string" },
5438
+ description: "JSON filter"
5439
+ },
5440
+ {
5441
+ name: "sort",
5442
+ in: "query",
5443
+ schema: { type: "string", default: "-timestamp" }
5444
+ }
3732
5445
  ],
3733
5446
  responses: {
3734
5447
  200: {
@@ -3854,6 +5567,15 @@ function generateOpenApi(config) {
3854
5567
  post: {
3855
5568
  tags: [collectionTag],
3856
5569
  summary: `Log out of ${labels.plural}`,
5570
+ parameters: [
5571
+ {
5572
+ name: "allSessions",
5573
+ in: "query",
5574
+ required: false,
5575
+ schema: { type: "boolean" },
5576
+ description: "Revoke every active session for this account instead of only the current one."
5577
+ }
5578
+ ],
3857
5579
  responses: { 200: { description: "Logged out" } }
3858
5580
  }
3859
5581
  };
@@ -3879,7 +5601,11 @@ function generateOpenApi(config) {
3879
5601
  post: {
3880
5602
  tags: [collectionTag],
3881
5603
  summary: "Refresh an authentication token",
3882
- responses: { 200: { description: "Refreshed token" } }
5604
+ responses: {
5605
+ 200: {
5606
+ description: "Refreshed token for the current active session"
5607
+ }
5608
+ }
3883
5609
  }
3884
5610
  };
3885
5611
  spec.paths[`${path}/forgot-password`] = {
@@ -3910,7 +5636,11 @@ function generateOpenApi(config) {
3910
5636
  schema: { type: "string" }
3911
5637
  }
3912
5638
  ],
3913
- responses: { 200: { description: "Password changed" } }
5639
+ responses: {
5640
+ 200: {
5641
+ description: "Password changed and active sessions revoked"
5642
+ }
5643
+ }
3914
5644
  }
3915
5645
  };
3916
5646
  }
@@ -4119,7 +5849,7 @@ function fieldsToProperties(fields) {
4119
5849
  return { properties: props, required };
4120
5850
  }
4121
5851
  function fieldToSchema(field) {
4122
- let schema = {};
5852
+ let schema;
4123
5853
  switch (field.type) {
4124
5854
  case "text":
4125
5855
  case "textarea":
@@ -4128,7 +5858,10 @@ function fieldToSchema(field) {
4128
5858
  break;
4129
5859
  case "url":
4130
5860
  schema = {
4131
- oneOf: [{ type: "string" }, { type: "object", additionalProperties: true }]
5861
+ oneOf: [
5862
+ { type: "string" },
5863
+ { type: "object", additionalProperties: true }
5864
+ ]
4132
5865
  };
4133
5866
  break;
4134
5867
  case "icon":
@@ -4290,7 +6023,10 @@ function accessGate(config, target, action) {
4290
6023
  req: toHookRequestContext(c.req)
4291
6024
  });
4292
6025
  if (!allowed) {
4293
- return c.json({ error: true, message: `Access denied: ${action} on ${target.slug}` }, 403);
6026
+ return c.json(
6027
+ { error: true, message: `Access denied: ${action} on ${target.slug}` },
6028
+ 403
6029
+ );
4294
6030
  }
4295
6031
  await next();
4296
6032
  };
@@ -4314,228 +6050,276 @@ function serializeFieldForApi(f) {
4314
6050
  if (serialized.fields) {
4315
6051
  serialized.fields = serialized.fields.map(serializeFieldForApi);
4316
6052
  }
4317
- if (serialized.blocks) {
6053
+ if (serialized.blocks && !serialized.blockReferences?.length) {
4318
6054
  serialized.blocks = serialized.blocks.map((b) => ({
4319
6055
  ...b,
4320
6056
  fields: b.fields?.map(serializeFieldForApi)
4321
6057
  }));
6058
+ } else if (serialized.blockReferences?.length) {
6059
+ delete serialized.blocks;
4322
6060
  }
4323
6061
  return serialized;
4324
6062
  }
6063
+ function serializeBlockForApi(block) {
6064
+ return {
6065
+ ...block,
6066
+ fields: block.fields?.map(serializeFieldForApi)
6067
+ };
6068
+ }
4325
6069
  function registerRoutes(app, config) {
4326
6070
  const optionsCache = /* @__PURE__ */ new Map();
4327
6071
  app.get("/api/schemas", optionalAuth(config), async (c) => {
4328
6072
  const siteId = c.req.header("X-Site-Id");
4329
- let collections = [...config.collections];
4330
- let globals = [...config.globals];
4331
- if (siteId && config.onSchemaFetch) {
4332
- const dynamic = await config.onSchemaFetch(siteId);
4333
- if (dynamic.collections) collections = [...collections, ...dynamic.collections];
4334
- if (dynamic.globals) globals = [...globals, ...dynamic.globals];
4335
- if (dynamic.admin) {
4336
- config.admin = { ...config.admin, ...dynamic.admin };
4337
- }
4338
- if (dynamic.adminAuth) {
4339
- config.adminAuth = { ...config.adminAuth, ...dynamic.adminAuth };
4340
- }
4341
- }
6073
+ const requestConfig = siteId && config.onSchemaFetch ? mergeDynamicConfig(config, await config.onSchemaFetch(siteId)) : config;
6074
+ const collections = [...requestConfig.collections];
6075
+ const globals = [...requestConfig.globals];
4342
6076
  const user = c.get("user");
4343
6077
  const accessArgs = { user, req: toHookRequestContext(c.req) };
4344
6078
  const serializeAccess = async (access) => {
4345
6079
  if (typeof access === "string") return access;
4346
6080
  if (typeof access === "boolean") return access;
4347
6081
  if (access && typeof access === "object" && typeof access.policy === "string") {
4348
- const policy = config.accessPolicies?.[access.policy];
4349
- if (typeof policy === "string" || typeof policy === "boolean") return policy;
6082
+ const policy = requestConfig.accessPolicies?.[access.policy];
6083
+ if (typeof policy === "string" || typeof policy === "boolean")
6084
+ return policy;
4350
6085
  }
4351
- return resolveBooleanAccess(config, access, accessArgs);
6086
+ return resolveBooleanAccess(requestConfig, access, accessArgs);
4352
6087
  };
4353
- const filteredCollections = await Promise.all(collections.filter((col) => !siteId || col.shared || !col.siteId || col.siteId === siteId).map(async (col) => ({
4354
- slug: col.slug,
4355
- labels: col.labels,
4356
- access: {
4357
- read: await serializeAccess(col.access?.read),
4358
- create: await serializeAccess(col.access?.create),
4359
- update: await serializeAccess(col.access?.update),
4360
- delete: await serializeAccess(col.access?.delete)
4361
- },
4362
- fields: await Promise.all(col.fields.map(serializeFieldForApi).map(async (f) => ({
4363
- name: f.name,
4364
- type: f.type,
4365
- label: f.label,
4366
- required: f.required,
4367
- defaultValue: f.defaultValue,
4368
- options: f.options,
4369
- relationTo: f.relationTo,
4370
- hasMany: f.hasMany,
4371
- fields: f.fields,
4372
- blocks: f.blocks,
4373
- collection: f.collection,
4374
- on: f.on,
4375
- limit: f.limit,
4376
- admin: f.admin,
6088
+ const filteredCollections = await Promise.all(
6089
+ collections.filter(
6090
+ (col) => !siteId || col.shared || !col.siteId || col.siteId === siteId
6091
+ ).map(async (col) => ({
6092
+ slug: col.slug,
6093
+ labels: col.labels,
4377
6094
  access: {
4378
- read: await serializeAccess(f.access?.read),
4379
- create: await serializeAccess(f.access?.create),
4380
- update: await serializeAccess(f.access?.update)
4381
- }
4382
- }))),
4383
- upload: !!col.upload,
4384
- auth: !!col.auth,
4385
- audit: !!col.audit,
4386
- drafts: !!col.drafts,
4387
- admin: col.admin,
4388
- workflow: col.workflow ? {
4389
- initialState: col.workflow.initialState,
4390
- draftState: col.workflow.draftState,
4391
- states: col.workflow.states,
4392
- transitions: col.workflow.transitions.map((t) => ({
4393
- name: t.name,
4394
- label: t.label,
4395
- from: t.from,
4396
- to: t.to,
4397
- requiredCapabilities: t.requiredCapabilities,
4398
- requireComment: t.requireComment,
4399
- unpublish: t.unpublish
4400
- })),
4401
- roles: col.workflow.roles
4402
- } : void 0
4403
- })));
4404
- const filteredGlobals = await Promise.all(globals.filter((glb) => !siteId || glb.shared || !glb.siteId || glb.siteId === siteId).map(async (glb) => ({
4405
- slug: glb.slug,
4406
- label: glb.label,
4407
- access: {
4408
- read: await serializeAccess(glb.access?.read),
4409
- update: await serializeAccess(glb.access?.update)
4410
- },
4411
- fields: await Promise.all(glb.fields.map(serializeFieldForApi).map(async (f) => ({
4412
- name: f.name,
4413
- type: f.type,
4414
- label: f.label,
4415
- required: f.required,
4416
- defaultValue: f.defaultValue,
4417
- options: f.options,
4418
- relationTo: f.relationTo,
4419
- hasMany: f.hasMany,
4420
- fields: f.fields,
4421
- blocks: f.blocks,
4422
- collection: f.collection,
4423
- on: f.on,
4424
- limit: f.limit,
4425
- admin: f.admin,
6095
+ read: await serializeAccess(col.access?.read),
6096
+ create: await serializeAccess(col.access?.create),
6097
+ update: await serializeAccess(col.access?.update),
6098
+ delete: await serializeAccess(col.access?.delete)
6099
+ },
6100
+ fields: await Promise.all(
6101
+ col.fields.map(serializeFieldForApi).map(async (f) => ({
6102
+ name: f.name,
6103
+ type: f.type,
6104
+ label: f.label,
6105
+ required: f.required,
6106
+ defaultValue: f.defaultValue,
6107
+ options: f.options,
6108
+ relationTo: f.relationTo,
6109
+ hasMany: f.hasMany,
6110
+ fields: f.fields,
6111
+ blocks: f.blocks,
6112
+ blockReferences: f.blockReferences,
6113
+ collection: f.collection,
6114
+ on: f.on,
6115
+ limit: f.limit,
6116
+ admin: f.admin,
6117
+ access: {
6118
+ read: await serializeAccess(f.access?.read),
6119
+ create: await serializeAccess(f.access?.create),
6120
+ update: await serializeAccess(f.access?.update)
6121
+ }
6122
+ }))
6123
+ ),
6124
+ upload: !!col.upload,
6125
+ auth: !!col.auth,
6126
+ audit: !!col.audit,
6127
+ drafts: !!col.drafts,
6128
+ admin: col.admin,
6129
+ workflow: col.workflow ? {
6130
+ initialState: col.workflow.initialState,
6131
+ draftState: col.workflow.draftState,
6132
+ states: col.workflow.states,
6133
+ transitions: col.workflow.transitions.map((t) => ({
6134
+ name: t.name,
6135
+ label: t.label,
6136
+ from: t.from,
6137
+ to: t.to,
6138
+ requiredCapabilities: t.requiredCapabilities,
6139
+ requireComment: t.requireComment,
6140
+ unpublish: t.unpublish
6141
+ })),
6142
+ roles: col.workflow.roles
6143
+ } : void 0
6144
+ }))
6145
+ );
6146
+ const filteredGlobals = await Promise.all(
6147
+ globals.filter(
6148
+ (glb) => !siteId || glb.shared || !glb.siteId || glb.siteId === siteId
6149
+ ).map(async (glb) => ({
6150
+ slug: glb.slug,
6151
+ label: glb.label,
4426
6152
  access: {
4427
- read: await serializeAccess(f.access?.read),
4428
- update: await serializeAccess(f.access?.update)
4429
- }
4430
- }))),
4431
- admin: glb.admin
4432
- })));
6153
+ read: await serializeAccess(glb.access?.read),
6154
+ update: await serializeAccess(glb.access?.update)
6155
+ },
6156
+ fields: await Promise.all(
6157
+ glb.fields.map(serializeFieldForApi).map(async (f) => ({
6158
+ name: f.name,
6159
+ type: f.type,
6160
+ label: f.label,
6161
+ required: f.required,
6162
+ defaultValue: f.defaultValue,
6163
+ options: f.options,
6164
+ relationTo: f.relationTo,
6165
+ hasMany: f.hasMany,
6166
+ fields: f.fields,
6167
+ blocks: f.blocks,
6168
+ blockReferences: f.blockReferences,
6169
+ collection: f.collection,
6170
+ on: f.on,
6171
+ limit: f.limit,
6172
+ admin: f.admin,
6173
+ access: {
6174
+ read: await serializeAccess(f.access?.read),
6175
+ update: await serializeAccess(f.access?.update)
6176
+ }
6177
+ }))
6178
+ ),
6179
+ admin: glb.admin
6180
+ }))
6181
+ );
4433
6182
  return c.json({
6183
+ blocks: requestConfig.blocks?.map(serializeBlockForApi),
4434
6184
  collections: filteredCollections,
4435
6185
  globals: filteredGlobals,
4436
- admin: config.admin || {},
4437
- adminAuth: getPublicAdminAuthConfig(config.adminAuth, collections)
6186
+ admin: requestConfig.admin || {},
6187
+ adminAuth: getPublicAdminAuthConfig(requestConfig.adminAuth, collections)
4438
6188
  });
4439
6189
  });
4440
- app.get("/api/dyrected/options/:collection/:field", optionalAuth(config), async (c) => {
4441
- const { collection: colSlug, field: fieldName } = c.req.param();
4442
- const siteId = c.req.header("X-Site-Id");
4443
- let collections = [...config.collections];
4444
- if (siteId && config.onSchemaFetch) {
4445
- const dynamic = await config.onSchemaFetch(siteId);
4446
- if (dynamic.collections) collections = [...collections, ...dynamic.collections];
4447
- }
4448
- const user = c.get("user");
4449
- let collection = collections.find((col) => col.slug === colSlug);
4450
- let field;
4451
- if (collection) {
4452
- const accessExpr = collection.access?.read;
4453
- if (accessExpr !== void 0 && accessExpr !== null) {
4454
- const allowed = await resolveBooleanAccess(config, accessExpr, {
4455
- user,
4456
- req: toHookRequestContext(c.req)
4457
- });
4458
- if (!allowed) {
4459
- return c.json({ error: true, message: `Access denied: read on ${colSlug}` }, 403);
6190
+ app.get(
6191
+ "/api/dyrected/options/:collection/:field",
6192
+ optionalAuth(config),
6193
+ async (c) => {
6194
+ const { collection: colSlug, field: fieldName } = c.req.param();
6195
+ const siteId = c.req.header("X-Site-Id");
6196
+ const requestConfig = siteId && config.onSchemaFetch ? mergeDynamicConfig(config, await config.onSchemaFetch(siteId)) : config;
6197
+ let collections = [...requestConfig.collections];
6198
+ const user = c.get("user");
6199
+ let collection = collections.find((col) => col.slug === colSlug);
6200
+ let field;
6201
+ if (collection) {
6202
+ const accessExpr = collection.access?.read;
6203
+ if (accessExpr !== void 0 && accessExpr !== null) {
6204
+ const allowed = await resolveBooleanAccess(config, accessExpr, {
6205
+ user,
6206
+ req: toHookRequestContext(c.req)
6207
+ });
6208
+ if (!allowed) {
6209
+ return c.json(
6210
+ { error: true, message: `Access denied: read on ${colSlug}` },
6211
+ 403
6212
+ );
6213
+ }
6214
+ }
6215
+ field = collection.fields.find((f) => f.name === fieldName);
6216
+ } else {
6217
+ const globals = [...requestConfig.globals];
6218
+ const glb = globals.find((g) => g.slug === colSlug);
6219
+ if (!glb) {
6220
+ return c.json(
6221
+ {
6222
+ error: true,
6223
+ message: `${colSlug} not found as collection or global`
6224
+ },
6225
+ 404
6226
+ );
6227
+ }
6228
+ const accessExpr = glb.access?.read;
6229
+ if (accessExpr !== void 0 && accessExpr !== null) {
6230
+ const allowed = await resolveBooleanAccess(config, accessExpr, {
6231
+ user,
6232
+ req: toHookRequestContext(c.req)
6233
+ });
6234
+ if (!allowed) {
6235
+ return c.json(
6236
+ {
6237
+ error: true,
6238
+ message: `Access denied: read on global ${colSlug}`
6239
+ },
6240
+ 403
6241
+ );
6242
+ }
4460
6243
  }
6244
+ field = glb.fields.find((f) => f.name === fieldName);
4461
6245
  }
4462
- field = collection.fields.find((f) => f.name === fieldName);
4463
- } else {
4464
- let globals = [...config.globals];
4465
- if (siteId && config.onSchemaFetch) {
4466
- const dynamic = await config.onSchemaFetch(siteId);
4467
- if (dynamic.globals) globals = [...globals, ...dynamic.globals];
6246
+ if (!field) {
6247
+ return c.json(
6248
+ {
6249
+ error: true,
6250
+ message: `Field ${fieldName} not found in ${colSlug}`
6251
+ },
6252
+ 404
6253
+ );
6254
+ }
6255
+ let resolver;
6256
+ let cacheTTL;
6257
+ if (typeof field.options === "function") {
6258
+ resolver = field.options;
6259
+ } else if (field.options && typeof field.options === "object" && "resolve" in field.options) {
6260
+ resolver = field.options.resolve;
6261
+ cacheTTL = field.options.cacheTTL;
4468
6262
  }
4469
- const glb = globals.find((g) => g.slug === colSlug);
4470
- if (!glb) {
4471
- return c.json({ error: true, message: `${colSlug} not found as collection or global` }, 404);
6263
+ if (!resolver) {
6264
+ return c.json(
6265
+ {
6266
+ error: true,
6267
+ message: `Field ${fieldName} in ${colSlug} is not dynamic`
6268
+ },
6269
+ 400
6270
+ );
4472
6271
  }
4473
- const accessExpr = glb.access?.read;
4474
- if (accessExpr !== void 0 && accessExpr !== null) {
4475
- const allowed = await resolveBooleanAccess(config, accessExpr, {
6272
+ try {
6273
+ const db = c.get("db") || config.db;
6274
+ const queryParams = c.req.query();
6275
+ const reqContext = {
6276
+ query: queryParams,
6277
+ headers: c.req.header(),
6278
+ raw: c.req.raw
6279
+ };
6280
+ const shouldCache = typeof cacheTTL === "number" && cacheTTL > 0;
6281
+ let cacheKey = "";
6282
+ if (shouldCache) {
6283
+ cacheKey = JSON.stringify([
6284
+ siteId ?? "",
6285
+ colSlug,
6286
+ fieldName,
6287
+ user?.id ?? null,
6288
+ queryParams
6289
+ ]);
6290
+ const hit = optionsCache.get(cacheKey);
6291
+ if (hit && hit.expires > Date.now()) {
6292
+ return c.json(hit.value);
6293
+ }
6294
+ }
6295
+ const result = await resolver({
6296
+ db,
4476
6297
  user,
4477
- req: toHookRequestContext(c.req)
6298
+ req: reqContext
4478
6299
  });
4479
- if (!allowed) {
4480
- return c.json({ error: true, message: `Access denied: read on global ${colSlug}` }, 403);
4481
- }
4482
- }
4483
- field = glb.fields.find((f) => f.name === fieldName);
4484
- }
4485
- if (!field) {
4486
- return c.json({ error: true, message: `Field ${fieldName} not found in ${colSlug}` }, 404);
4487
- }
4488
- let resolver;
4489
- let cacheTTL;
4490
- if (typeof field.options === "function") {
4491
- resolver = field.options;
4492
- } else if (field.options && typeof field.options === "object" && "resolve" in field.options) {
4493
- resolver = field.options.resolve;
4494
- cacheTTL = field.options.cacheTTL;
4495
- }
4496
- if (!resolver) {
4497
- return c.json({ error: true, message: `Field ${fieldName} in ${colSlug} is not dynamic` }, 400);
4498
- }
4499
- try {
4500
- const db = c.get("db") || config.db;
4501
- const queryParams = c.req.query();
4502
- const reqContext = {
4503
- query: queryParams,
4504
- headers: c.req.header(),
4505
- raw: c.req.raw
4506
- };
4507
- const shouldCache = typeof cacheTTL === "number" && cacheTTL > 0;
4508
- let cacheKey = "";
4509
- if (shouldCache) {
4510
- cacheKey = JSON.stringify([
4511
- siteId ?? "",
4512
- colSlug,
4513
- fieldName,
4514
- user?.id ?? null,
4515
- queryParams
4516
- ]);
4517
- const hit = optionsCache.get(cacheKey);
4518
- if (hit && hit.expires > Date.now()) {
4519
- return c.json(hit.value);
6300
+ if (shouldCache) {
6301
+ optionsCache.set(cacheKey, {
6302
+ expires: Date.now() + cacheTTL * 1e3,
6303
+ value: result
6304
+ });
4520
6305
  }
4521
- }
4522
- const result = await resolver({
4523
- db,
4524
- user,
4525
- req: reqContext
4526
- });
4527
- if (shouldCache) {
4528
- optionsCache.set(cacheKey, {
4529
- expires: Date.now() + cacheTTL * 1e3,
4530
- value: result
6306
+ return c.json(result);
6307
+ } catch (err) {
6308
+ getRequestLogger(c, "router").error({
6309
+ err,
6310
+ msg: "Failed to resolve dynamic field options",
6311
+ fieldName
4531
6312
  });
6313
+ return c.json(
6314
+ {
6315
+ error: true,
6316
+ message: err.message || "Failed to resolve dynamic options"
6317
+ },
6318
+ 500
6319
+ );
4532
6320
  }
4533
- return c.json(result);
4534
- } catch (err) {
4535
- console.error(`[dyrected/core] Failed to resolve dynamic options for field ${fieldName}:`, err);
4536
- return c.json({ error: true, message: err.message || "Failed to resolve dynamic options" }, 500);
4537
6321
  }
4538
- });
6322
+ );
4539
6323
  app.get("/api/openapi.json", (c) => {
4540
6324
  return c.json(generateOpenApi(config));
4541
6325
  });
@@ -4548,10 +6332,18 @@ function registerRoutes(app, config) {
4548
6332
  const key = c.req.param("key");
4549
6333
  const scope = c.req.query("scope");
4550
6334
  if (!db) return c.json({ message: "Database not configured" }, 500);
4551
- if (!user?.collection || !user.sub) return c.json({ error: true, message: "Authentication required." }, 401);
4552
- if (!key) return c.json({ error: true, message: "Preference key is required." }, 400);
6335
+ if (!user?.collection || !user.sub)
6336
+ return c.json({ error: true, message: "Authentication required." }, 401);
6337
+ if (!key)
6338
+ return c.json(
6339
+ { error: true, message: "Preference key is required." },
6340
+ 400
6341
+ );
4553
6342
  const getGlobalPreference = async () => {
4554
- const globalDoc = await db.findOne({ collection: "__global_preferences", id: key });
6343
+ const globalDoc = await db.findOne({
6344
+ collection: "__global_preferences",
6345
+ id: key
6346
+ });
4555
6347
  return globalDoc ? globalDoc.value : null;
4556
6348
  };
4557
6349
  if (scope === "global") {
@@ -4573,15 +6365,29 @@ function registerRoutes(app, config) {
4573
6365
  const key = c.req.param("key");
4574
6366
  const scope = c.req.query("scope");
4575
6367
  if (!db) return c.json({ message: "Database not configured" }, 500);
4576
- if (!user?.collection || !user.sub) return c.json({ error: true, message: "Authentication required." }, 401);
4577
- if (!key) return c.json({ error: true, message: "Preference key is required." }, 400);
6368
+ if (!user?.collection || !user.sub)
6369
+ return c.json({ error: true, message: "Authentication required." }, 401);
6370
+ if (!key)
6371
+ return c.json(
6372
+ { error: true, message: "Preference key is required." },
6373
+ 400
6374
+ );
4578
6375
  const body = await c.req.json().catch(() => ({}));
4579
6376
  if (scope === "global") {
4580
6377
  const isAdminUser = Array.isArray(user?.roles) && user.roles.includes("admin");
4581
6378
  if (!isAdminUser) {
4582
- return c.json({ error: true, message: "Only administrators can save global preferences." }, 403);
6379
+ return c.json(
6380
+ {
6381
+ error: true,
6382
+ message: "Only administrators can save global preferences."
6383
+ },
6384
+ 403
6385
+ );
4583
6386
  }
4584
- const existing = await db.findOne({ collection: "__global_preferences", id: key });
6387
+ const existing = await db.findOne({
6388
+ collection: "__global_preferences",
6389
+ id: key
6390
+ });
4585
6391
  if (existing) {
4586
6392
  await db.update({
4587
6393
  collection: "__global_preferences",
@@ -4613,12 +6419,23 @@ function registerRoutes(app, config) {
4613
6419
  const key = c.req.param("key");
4614
6420
  const scope = c.req.query("scope");
4615
6421
  if (!db) return c.json({ message: "Database not configured" }, 500);
4616
- if (!user?.collection || !user.sub) return c.json({ error: true, message: "Authentication required." }, 401);
4617
- if (!key) return c.json({ error: true, message: "Preference key is required." }, 400);
6422
+ if (!user?.collection || !user.sub)
6423
+ return c.json({ error: true, message: "Authentication required." }, 401);
6424
+ if (!key)
6425
+ return c.json(
6426
+ { error: true, message: "Preference key is required." },
6427
+ 400
6428
+ );
4618
6429
  if (scope === "global") {
4619
6430
  const isAdminUser = Array.isArray(user?.roles) && user.roles.includes("admin");
4620
6431
  if (!isAdminUser) {
4621
- return c.json({ error: true, message: "Only administrators can delete global preferences." }, 403);
6432
+ return c.json(
6433
+ {
6434
+ error: true,
6435
+ message: "Only administrators can delete global preferences."
6436
+ },
6437
+ 403
6438
+ );
4622
6439
  }
4623
6440
  await db.delete({ collection: "__global_preferences", id: key });
4624
6441
  return c.json({ success: true });
@@ -4647,17 +6464,41 @@ function registerRoutes(app, config) {
4647
6464
  for (const col of uploadCollections) {
4648
6465
  const mediaController = new MediaController(col.slug);
4649
6466
  const prefix = `/api/collections/${col.slug}`;
4650
- app.get(`${prefix}/media`, accessGate(config, col, "read"), (c) => mediaController.find(c));
4651
- app.get(`${prefix}/media/:filename{.+$}`, (c) => mediaController.serve(c));
4652
- app.post(`${prefix}/media`, accessGate(config, col, "create"), (c) => mediaController.upload(c));
4653
- app.delete(`${prefix}/media/:id`, accessGate(config, col, "delete"), (c) => mediaController.delete(c));
6467
+ app.get(
6468
+ `${prefix}/media`,
6469
+ accessGate(config, col, "read"),
6470
+ (c) => mediaController.find(c)
6471
+ );
6472
+ app.get(
6473
+ `${prefix}/media/:filename{.+$}`,
6474
+ (c) => mediaController.serve(c)
6475
+ );
6476
+ app.post(
6477
+ `${prefix}/media`,
6478
+ accessGate(config, col, "create"),
6479
+ (c) => mediaController.upload(c)
6480
+ );
6481
+ app.delete(
6482
+ `${prefix}/media/:id`,
6483
+ accessGate(config, col, "delete"),
6484
+ (c) => mediaController.delete(c)
6485
+ );
4654
6486
  }
4655
6487
  }
4656
6488
  const adminAuthController = new AdminAuthController(config);
4657
6489
  app.get("/api/admin/auth/providers", (c) => adminAuthController.providers(c));
4658
- app.get("/api/admin/auth/:provider/start", (c) => adminAuthController.start(c));
4659
- app.get("/api/admin/auth/:provider/callback", (c) => adminAuthController.callback(c));
4660
- app.post("/api/admin/auth/:provider/exchange", (c) => adminAuthController.exchange(c));
6490
+ app.get(
6491
+ "/api/admin/auth/:provider/start",
6492
+ (c) => adminAuthController.start(c)
6493
+ );
6494
+ app.get(
6495
+ "/api/admin/auth/:provider/callback",
6496
+ (c) => adminAuthController.callback(c)
6497
+ );
6498
+ app.post(
6499
+ "/api/admin/auth/:provider/exchange",
6500
+ (c) => adminAuthController.exchange(c)
6501
+ );
4661
6502
  app.post("/api/admin/logout", (c) => adminAuthController.logout(c));
4662
6503
  for (const collection of config.collections) {
4663
6504
  if (!collection.auth) continue;
@@ -4668,10 +6509,21 @@ function registerRoutes(app, config) {
4668
6509
  app.get(`${path}/init`, (c) => authController.init(c));
4669
6510
  app.post(`${path}/first-user`, (c) => authController.registerFirstUser(c));
4670
6511
  app.get(`${path}/me`, requireAuth(config), (c) => authController.me(c));
4671
- app.post(`${path}/refresh-token`, requireAuth(config), (c) => authController.refreshToken(c));
4672
- app.post(`${path}/forgot-password`, (c) => authController.forgotPassword(c));
6512
+ app.post(
6513
+ `${path}/refresh-token`,
6514
+ requireAuth(config),
6515
+ (c) => authController.refreshToken(c)
6516
+ );
6517
+ app.post(
6518
+ `${path}/forgot-password`,
6519
+ (c) => authController.forgotPassword(c)
6520
+ );
4673
6521
  app.post(`${path}/reset-password`, (c) => authController.resetPassword(c));
4674
- app.post(`${path}/invite`, requireAuth(config), (c) => authController.invite(c));
6522
+ app.post(
6523
+ `${path}/invite`,
6524
+ requireAuth(config),
6525
+ (c) => authController.invite(c)
6526
+ );
4675
6527
  app.post(`${path}/accept-invite`, (c) => authController.acceptInvite(c));
4676
6528
  }
4677
6529
  const auditController = new AuditController();
@@ -4683,18 +6535,33 @@ function registerRoutes(app, config) {
4683
6535
  app.post(`${path}/media`, (c) => controller.create(c));
4684
6536
  app.delete(`${path}/delete-many`, (c) => controller.deleteMany(c));
4685
6537
  if (collection.audit) {
4686
- app.get(`${path}/__audit`, (c) => auditController.findForCollection(c, collection));
6538
+ app.get(
6539
+ `${path}/__audit`,
6540
+ (c) => auditController.findForCollection(c, collection)
6541
+ );
4687
6542
  }
4688
6543
  app.get(`${path}/:id`, (c) => controller.findOne(c));
4689
6544
  app.patch(`${path}/:id`, (c) => controller.update(c));
4690
6545
  app.delete(`${path}/:id`, (c) => controller.delete(c));
4691
6546
  app.post(`${path}/seed`, (c) => controller.seed(c));
4692
6547
  if (collection.auth) {
4693
- app.post(`${path}/:id/change-password`, requireAuth(config), (c) => controller.changePassword(c));
6548
+ app.post(
6549
+ `${path}/:id/change-password`,
6550
+ requireAuth(config),
6551
+ (c) => controller.changePassword(c)
6552
+ );
4694
6553
  }
4695
6554
  if (collection.workflow) {
4696
- app.post(`${path}/:id/transitions/:transition`, requireAuth(config), (c) => controller.transition(c));
4697
- app.get(`${path}/:id/workflow-history`, requireAuth(config), (c) => controller.workflowHistory(c));
6555
+ app.post(
6556
+ `${path}/:id/transitions/:transition`,
6557
+ requireAuth(config),
6558
+ (c) => controller.transition(c)
6559
+ );
6560
+ app.get(
6561
+ `${path}/:id/workflow-history`,
6562
+ requireAuth(config),
6563
+ (c) => controller.workflowHistory(c)
6564
+ );
4698
6565
  }
4699
6566
  }
4700
6567
  for (const global of config.globals) {
@@ -4705,12 +6572,16 @@ function registerRoutes(app, config) {
4705
6572
  app.post(`${path}/seed`, (c) => controller.seed(c));
4706
6573
  }
4707
6574
  if (!process.env.DYRECTED_JWT_SECRET) {
4708
- console.warn(
4709
- '[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.'
4710
- );
6575
+ getConfigLogger(config, "router").warn({
6576
+ msg: "DYRECTED_JWT_SECRET is not set; token-mode live preview is signing with an insecure default"
6577
+ });
4711
6578
  }
4712
6579
  const previewController = new PreviewController();
4713
- app.post("/api/preview-token", requireAuth(config), (c) => previewController.createToken(c));
6580
+ app.post(
6581
+ "/api/preview-token",
6582
+ requireAuth(config),
6583
+ (c) => previewController.createToken(c)
6584
+ );
4714
6585
  app.get("/api/preview-data", (c) => previewController.getData(c));
4715
6586
  app.get("/api/audit", (c) => auditController.findAll(c));
4716
6587
  app.get("/api/collections/:slug/__audit", async (c) => {
@@ -4723,49 +6594,81 @@ function registerRoutes(app, config) {
4723
6594
  if (!config2.onSchemaFetch || !siteId) {
4724
6595
  return c.json({ message: `Collection "${slug}" not found` }, 404);
4725
6596
  }
4726
- const dynamic = await config2.onSchemaFetch(siteId);
4727
- const collection = dynamic.collections?.find((col) => col.slug === slug);
6597
+ const requestConfig = mergeDynamicConfig(
6598
+ config2,
6599
+ await config2.onSchemaFetch(siteId)
6600
+ );
6601
+ const collection = requestConfig.collections.find(
6602
+ (col) => col.slug === slug
6603
+ );
4728
6604
  if (!collection?.audit) {
4729
- return c.json({ message: `Collection "${slug}" not found or has no audit log` }, 404);
6605
+ return c.json(
6606
+ { message: `Collection "${slug}" not found or has no audit log` },
6607
+ 404
6608
+ );
4730
6609
  }
4731
6610
  return auditController.findForCollection(c, collection);
4732
6611
  });
4733
- app.post("/api/collections/:slug/:id/transitions/:transition", requireAuth(config), async (c) => {
4734
- const slug = c.req.param("slug");
4735
- const siteId = c.req.header("X-Site-Id") || c.get("siteId");
4736
- const config2 = c.get("config");
4737
- if (config2.collections.some((col) => col.slug === slug)) {
4738
- return c.json({ message: "Not Found" }, 404);
4739
- }
4740
- if (!config2.onSchemaFetch || !siteId) {
4741
- return c.json({ message: `Collection "${slug}" not found` }, 404);
4742
- }
4743
- const dynamic = await config2.onSchemaFetch(siteId);
4744
- const collection = dynamic.collections?.find((col) => col.slug === slug);
4745
- if (!collection?.workflow) {
4746
- return c.json({ message: `Collection "${slug}" not found or has no workflow` }, 404);
4747
- }
4748
- const controller = new CollectionController(collection);
4749
- return controller.transition(c);
4750
- });
4751
- app.get("/api/collections/:slug/:id/workflow-history", requireAuth(config), async (c) => {
4752
- const slug = c.req.param("slug");
4753
- const siteId = c.req.header("X-Site-Id") || c.get("siteId");
4754
- const config2 = c.get("config");
4755
- if (config2.collections.some((col) => col.slug === slug)) {
4756
- return c.json({ message: "Not Found" }, 404);
4757
- }
4758
- if (!config2.onSchemaFetch || !siteId) {
4759
- return c.json({ message: `Collection "${slug}" not found` }, 404);
6612
+ app.post(
6613
+ "/api/collections/:slug/:id/transitions/:transition",
6614
+ requireAuth(config),
6615
+ async (c) => {
6616
+ const slug = c.req.param("slug");
6617
+ const siteId = c.req.header("X-Site-Id") || c.get("siteId");
6618
+ const config2 = c.get("config");
6619
+ if (config2.collections.some((col) => col.slug === slug)) {
6620
+ return c.json({ message: "Not Found" }, 404);
6621
+ }
6622
+ if (!config2.onSchemaFetch || !siteId) {
6623
+ return c.json({ message: `Collection "${slug}" not found` }, 404);
6624
+ }
6625
+ const requestConfig = mergeDynamicConfig(
6626
+ config2,
6627
+ await config2.onSchemaFetch(siteId)
6628
+ );
6629
+ const collection = requestConfig.collections.find(
6630
+ (col) => col.slug === slug
6631
+ );
6632
+ if (!collection?.workflow) {
6633
+ return c.json(
6634
+ { message: `Collection "${slug}" not found or has no workflow` },
6635
+ 404
6636
+ );
6637
+ }
6638
+ const controller = new CollectionController(collection);
6639
+ return controller.transition(c);
4760
6640
  }
4761
- const dynamic = await config2.onSchemaFetch(siteId);
4762
- const collection = dynamic.collections?.find((col) => col.slug === slug);
4763
- if (!collection?.workflow) {
4764
- return c.json({ message: `Collection "${slug}" not found or has no workflow` }, 404);
6641
+ );
6642
+ app.get(
6643
+ "/api/collections/:slug/:id/workflow-history",
6644
+ requireAuth(config),
6645
+ async (c) => {
6646
+ const slug = c.req.param("slug");
6647
+ const siteId = c.req.header("X-Site-Id") || c.get("siteId");
6648
+ const config2 = c.get("config");
6649
+ if (config2.collections.some((col) => col.slug === slug)) {
6650
+ return c.json({ message: "Not Found" }, 404);
6651
+ }
6652
+ if (!config2.onSchemaFetch || !siteId) {
6653
+ return c.json({ message: `Collection "${slug}" not found` }, 404);
6654
+ }
6655
+ const requestConfig = mergeDynamicConfig(
6656
+ config2,
6657
+ await config2.onSchemaFetch(siteId)
6658
+ );
6659
+ const collection = requestConfig.collections.find(
6660
+ (col) => col.slug === slug
6661
+ );
6662
+ if (!collection?.workflow) {
6663
+ return c.json(
6664
+ { message: `Collection "${slug}" not found or has no workflow` },
6665
+ 404
6666
+ );
6667
+ }
6668
+ const controller = new CollectionController(collection);
6669
+ return controller.workflowHistory(c);
4765
6670
  }
4766
- const controller = new CollectionController(collection);
4767
- return controller.workflowHistory(c);
4768
- });
6671
+ );
4769
6672
  app.all("/api/collections/:slug/:id?", async (c) => {
4770
6673
  const slug = c.req.param("slug");
4771
6674
  const id = c.req.param("id");
@@ -4775,8 +6678,13 @@ function registerRoutes(app, config) {
4775
6678
  return c.json({ message: "Method Not Allowed" }, 405);
4776
6679
  }
4777
6680
  if (config2.onSchemaFetch && siteId) {
4778
- const dynamic = await config2.onSchemaFetch(siteId);
4779
- let collection = dynamic.collections?.find((col) => col.slug === slug);
6681
+ const requestConfig = mergeDynamicConfig(
6682
+ config2,
6683
+ await config2.onSchemaFetch(siteId)
6684
+ );
6685
+ let collection = requestConfig.collections.find(
6686
+ (col) => col.slug === slug
6687
+ );
4780
6688
  if (!collection && slug === "media") {
4781
6689
  collection = {
4782
6690
  slug: "media",
@@ -4789,19 +6697,25 @@ function registerRoutes(app, config) {
4789
6697
  if (collection.auth && id) {
4790
6698
  const authController = new AuthController(collection);
4791
6699
  const method2 = c.req.method;
4792
- if (method2 === "POST" && id === "login") return authController.login(c);
4793
- if (method2 === "POST" && id === "logout") return authController.logout(c);
6700
+ if (method2 === "POST" && id === "login")
6701
+ return authController.login(c);
6702
+ if (method2 === "POST" && id === "logout")
6703
+ return authController.logout(c);
4794
6704
  if (method2 === "GET" && id === "me") return authController.me(c);
4795
- if (method2 === "POST" && id === "refresh-token") return authController.refreshToken(c);
4796
- if (method2 === "POST" && id === "forgot-password") return authController.forgotPassword(c);
4797
- if (method2 === "POST" && id === "reset-password") return authController.resetPassword(c);
6705
+ if (method2 === "POST" && id === "refresh-token")
6706
+ return authController.refreshToken(c);
6707
+ if (method2 === "POST" && id === "forgot-password")
6708
+ return authController.forgotPassword(c);
6709
+ if (method2 === "POST" && id === "reset-password")
6710
+ return authController.resetPassword(c);
4798
6711
  }
4799
6712
  const controller = new CollectionController(collection);
4800
6713
  const method = c.req.method;
4801
6714
  if (id) {
4802
6715
  if (method === "GET") return controller.findOne(c);
4803
6716
  if (method === "PATCH") return controller.update(c);
4804
- if (method === "DELETE" && id === "delete-many") return controller.deleteMany(c);
6717
+ if (method === "DELETE" && id === "delete-many")
6718
+ return controller.deleteMany(c);
4805
6719
  if (method === "DELETE") return controller.delete(c);
4806
6720
  if (method === "POST" && id === "media") return controller.create(c);
4807
6721
  if (method === "POST" && id === "seed") return controller.seed(c);
@@ -4822,8 +6736,11 @@ function registerRoutes(app, config) {
4822
6736
  return c.json({ message: "Method Not Allowed" }, 405);
4823
6737
  }
4824
6738
  if (config2.onSchemaFetch && siteId) {
4825
- const dynamic = await config2.onSchemaFetch(siteId);
4826
- const global = dynamic.globals?.find((glb) => glb.slug === slug);
6739
+ const requestConfig = mergeDynamicConfig(
6740
+ config2,
6741
+ await config2.onSchemaFetch(siteId)
6742
+ );
6743
+ const global = requestConfig.globals.find((glb) => glb.slug === slug);
4827
6744
  if (global) {
4828
6745
  const controller = new GlobalController(global);
4829
6746
  const method = c.req.method;
@@ -4873,12 +6790,23 @@ var AUDIT_COLLECTION = {
4873
6790
  fields: [
4874
6791
  { name: "collection", type: "text", label: "Collection", required: true },
4875
6792
  { name: "documentId", type: "text", label: "Document ID" },
4876
- { name: "operation", type: "select", label: "Operation", options: ["create", "update", "delete"], required: true },
6793
+ {
6794
+ name: "operation",
6795
+ type: "select",
6796
+ label: "Operation",
6797
+ options: ["create", "update", "delete"],
6798
+ required: true
6799
+ },
4877
6800
  { name: "user", type: "text", label: "User ID" },
4878
6801
  { name: "timestamp", type: "date", label: "Timestamp", required: true },
4879
6802
  { name: "changes", type: "json", label: "Changes" }
4880
6803
  ],
4881
- access: { read: () => false, create: () => false, update: () => false, delete: () => false },
6804
+ access: {
6805
+ read: () => false,
6806
+ create: () => false,
6807
+ update: () => false,
6808
+ delete: () => false
6809
+ },
4882
6810
  admin: { hidden: true }
4883
6811
  };
4884
6812
  var WORKFLOW_HISTORY_COLLECTION_CONFIG = {
@@ -4895,7 +6823,12 @@ var WORKFLOW_HISTORY_COLLECTION_CONFIG = {
4895
6823
  { name: "actorId", type: "text" },
4896
6824
  { name: "createdAt", type: "date", required: true }
4897
6825
  ],
4898
- access: { read: ({ user }) => !!user, create: () => false, update: () => false, delete: () => false },
6826
+ access: {
6827
+ read: ({ user }) => !!user,
6828
+ create: () => false,
6829
+ update: () => false,
6830
+ delete: () => false
6831
+ },
4899
6832
  admin: { hidden: true }
4900
6833
  };
4901
6834
  var LIFECYCLE_EVENTS_COLLECTION_CONFIG = {
@@ -4909,22 +6842,56 @@ var LIFECYCLE_EVENTS_COLLECTION_CONFIG = {
4909
6842
  { name: "actorId", type: "text" },
4910
6843
  { name: "payload", type: "json", required: true },
4911
6844
  { name: "attempts", type: "number", required: true },
4912
- { name: "status", type: "select", options: ["pending", "processing", "delivered", "failed"], required: true },
6845
+ {
6846
+ name: "status",
6847
+ type: "select",
6848
+ options: ["pending", "processing", "delivered", "failed"],
6849
+ required: true
6850
+ },
4913
6851
  { name: "nextAttemptAt", type: "date" },
4914
6852
  { name: "deliveredAt", type: "date" },
4915
6853
  { name: "lastError", type: "textarea" }
4916
6854
  ],
4917
- access: { read: ({ user }) => !!user?.roles?.includes("admin"), create: () => false, update: () => false, delete: () => false },
6855
+ access: {
6856
+ read: ({ user }) => !!user?.roles?.includes("admin"),
6857
+ create: () => false,
6858
+ update: () => false,
6859
+ delete: () => false
6860
+ },
6861
+ admin: { hidden: true }
6862
+ };
6863
+ var AUTH_SESSIONS_COLLECTION_CONFIG = {
6864
+ slug: AUTH_SESSIONS_COLLECTION,
6865
+ labels: { singular: "Auth session", plural: "Auth sessions" },
6866
+ fields: [
6867
+ { name: "userId", type: "text", required: true },
6868
+ { name: "email", type: "email", required: true },
6869
+ { name: "collection", type: "text", required: true },
6870
+ { name: "authSource", type: "text" },
6871
+ { name: "providerId", type: "text" },
6872
+ { name: "lastIp", type: "text" },
6873
+ { name: "lastSeenAt", type: "date" },
6874
+ { name: "expiresAt", type: "date" },
6875
+ { name: "revokedAt", type: "date" }
6876
+ ],
6877
+ access: {
6878
+ read: () => false,
6879
+ create: () => false,
6880
+ update: () => false,
6881
+ delete: () => false
6882
+ },
4918
6883
  admin: { hidden: true }
4919
6884
  };
4920
6885
  function normalizeConfig(config) {
4921
- const collections = config?.collections || [];
4922
- const globals = config?.globals || [];
6886
+ const schemaAwareConfig = normalizeSchemaFragment(config);
6887
+ const collections = schemaAwareConfig?.collections || [];
6888
+ const globals = schemaAwareConfig?.globals || [];
4923
6889
  const needsAudit = collections.some((col) => col.audit);
4924
6890
  const needsWorkflow = collections.some((col) => col.workflow || col.drafts);
6891
+ const needsAuthSessions = collections.some((col) => !!col.auth);
4925
6892
  const adminAuthCollectionSlug = getAdminAuthCollection({
4926
6893
  collections,
4927
- adminAuth: config.adminAuth
6894
+ adminAuth: schemaAwareConfig.adminAuth
4928
6895
  })?.slug;
4929
6896
  const normalizedCollections = collections.map((col) => {
4930
6897
  let fields = col.fields || [];
@@ -5021,7 +6988,7 @@ function normalizeConfig(config) {
5021
6988
  return field;
5022
6989
  });
5023
6990
  }
5024
- if (adminAuthCollectionSlug && col.slug === adminAuthCollectionSlug && config.adminAuth?.mode === "external") {
6991
+ if (adminAuthCollectionSlug && col.slug === adminAuthCollectionSlug && schemaAwareConfig.adminAuth?.mode === "external") {
5025
6992
  const externalAdminFields = [
5026
6993
  {
5027
6994
  name: "authProvider",
@@ -5055,7 +7022,9 @@ function normalizeConfig(config) {
5055
7022
  }
5056
7023
  }
5057
7024
  const updatedFieldNames = new Set(fields.map((f) => f.name));
5058
- const fieldsToInject = SYSTEM_FIELDS.filter((f) => !updatedFieldNames.has(f.name));
7025
+ const fieldsToInject = SYSTEM_FIELDS.filter(
7026
+ (f) => !updatedFieldNames.has(f.name)
7027
+ );
5059
7028
  const workflow = col.workflow || (col.drafts ? simplePublishingWorkflow() : void 0);
5060
7029
  return {
5061
7030
  ...col,
@@ -5063,52 +7032,309 @@ function normalizeConfig(config) {
5063
7032
  fields: [...fields, ...fieldsToInject]
5064
7033
  };
5065
7034
  });
5066
- const hasAuditCollection = normalizedCollections.some((col) => col.slug === AUDIT_COLLECTION_SLUG);
7035
+ const hasAuditCollection = normalizedCollections.some(
7036
+ (col) => col.slug === AUDIT_COLLECTION_SLUG
7037
+ );
5067
7038
  const systemCollections = [];
5068
- if (needsAudit && !hasAuditCollection) systemCollections.push(AUDIT_COLLECTION);
5069
- if (needsWorkflow && !normalizedCollections.some((col) => col.slug === WORKFLOW_HISTORY_COLLECTION)) {
7039
+ if (needsAudit && !hasAuditCollection)
7040
+ systemCollections.push(AUDIT_COLLECTION);
7041
+ if (needsWorkflow && !normalizedCollections.some(
7042
+ (col) => col.slug === WORKFLOW_HISTORY_COLLECTION
7043
+ )) {
5070
7044
  systemCollections.push(WORKFLOW_HISTORY_COLLECTION_CONFIG);
5071
7045
  }
5072
- if (needsWorkflow && !normalizedCollections.some((col) => col.slug === LIFECYCLE_EVENTS_COLLECTION)) {
7046
+ if (needsWorkflow && !normalizedCollections.some(
7047
+ (col) => col.slug === LIFECYCLE_EVENTS_COLLECTION
7048
+ )) {
5073
7049
  systemCollections.push(LIFECYCLE_EVENTS_COLLECTION_CONFIG);
5074
7050
  }
7051
+ if (needsAuthSessions && !normalizedCollections.some((col) => col.slug === AUTH_SESSIONS_COLLECTION)) {
7052
+ systemCollections.push(AUTH_SESSIONS_COLLECTION_CONFIG);
7053
+ }
5075
7054
  return {
5076
- ...config,
7055
+ ...schemaAwareConfig,
5077
7056
  collections: [...normalizedCollections, ...systemCollections],
5078
7057
  globals
5079
7058
  };
5080
7059
  }
5081
7060
 
7061
+ // src/network.ts
7062
+ var DIRECT_IP_HEADERS = [
7063
+ "cf-connecting-ip",
7064
+ "fly-client-ip",
7065
+ "true-client-ip",
7066
+ "x-real-ip",
7067
+ "x-client-ip",
7068
+ "fastly-client-ip"
7069
+ ];
7070
+ function toHookRequestContext2(c) {
7071
+ const headers = Object.fromEntries(
7072
+ Object.entries(c.req.header()).map(([key, value]) => [key.toLowerCase(), value])
7073
+ );
7074
+ return {
7075
+ query: c.req.query(),
7076
+ headers,
7077
+ raw: c.req.raw
7078
+ };
7079
+ }
7080
+ function resolveClientIp(req, trustProxy = false) {
7081
+ for (const header of DIRECT_IP_HEADERS) {
7082
+ const value = normalizeIp(req.headers[header]);
7083
+ if (value) return value;
7084
+ }
7085
+ if (trustProxy) {
7086
+ const forwardedFor = pickForwardedFor(req.headers, trustProxy);
7087
+ if (forwardedFor) return forwardedFor;
7088
+ }
7089
+ return "127.0.0.1";
7090
+ }
7091
+ function pickForwardedFor(headers, trustProxy) {
7092
+ const forwardedHeader = headers["x-forwarded-for"];
7093
+ if (forwardedHeader) {
7094
+ const chain2 = forwardedHeader.split(",").map((entry) => normalizeIp(entry)).filter((entry) => !!entry);
7095
+ if (chain2.length > 0) {
7096
+ if (trustProxy === true) return chain2[0];
7097
+ const trustedHops2 = Math.max(1, Math.floor(trustProxy));
7098
+ const candidateIndex2 = Math.max(0, chain2.length - trustedHops2);
7099
+ return chain2[candidateIndex2] ?? chain2[0];
7100
+ }
7101
+ }
7102
+ const forwarded = headers.forwarded;
7103
+ if (!forwarded) return null;
7104
+ const matches = [...forwarded.matchAll(/for=(?:"?\[?([^;\],"]+)\]?"?)/gi)];
7105
+ if (matches.length === 0) return null;
7106
+ const chain = matches.map((match) => normalizeIp(match[1])).filter((entry) => !!entry);
7107
+ if (chain.length === 0) return null;
7108
+ if (trustProxy === true) return chain[0];
7109
+ const trustedHops = Math.max(1, Math.floor(trustProxy));
7110
+ const candidateIndex = Math.max(0, chain.length - trustedHops);
7111
+ return chain[candidateIndex] ?? chain[0];
7112
+ }
7113
+ function normalizeIp(value) {
7114
+ if (!value) return null;
7115
+ const trimmed = value.trim();
7116
+ if (!trimmed || trimmed.toLowerCase() === "unknown") return null;
7117
+ return trimmed.replace(/^\[(.*)\]$/, "$1");
7118
+ }
7119
+
7120
+ // src/middleware/rate-limit.ts
7121
+ var DEFAULT_WINDOW_MS = 15 * 60 * 1e3;
7122
+ var DEFAULT_MAX_REQUESTS = 500;
7123
+ var DEFAULT_PATHS = ["/api"];
7124
+ function createRateLimitMiddleware(config) {
7125
+ const settings = normalizeRateLimitConfig(config.rateLimit);
7126
+ const buckets = /* @__PURE__ */ new Map();
7127
+ return async (c, next) => {
7128
+ const req = toHookRequestContext2(c);
7129
+ const ip = resolveClientIp(req, settings.trustProxy);
7130
+ c.set("clientIp", ip);
7131
+ if (!settings.enabled) {
7132
+ return next();
7133
+ }
7134
+ const path = c.req.path;
7135
+ if (!matchesLimitedPath(path, settings.paths)) {
7136
+ return next();
7137
+ }
7138
+ if (settings.skip && await settings.skip({
7139
+ ip,
7140
+ path,
7141
+ method: c.req.method,
7142
+ req
7143
+ })) {
7144
+ return next();
7145
+ }
7146
+ const now = Date.now();
7147
+ const key = ip;
7148
+ const existing = buckets.get(key);
7149
+ const bucket = existing && existing.resetAt > now ? existing : { count: 0, resetAt: now + settings.window };
7150
+ bucket.count += 1;
7151
+ buckets.set(key, bucket);
7152
+ pruneExpiredBuckets(buckets, now);
7153
+ const remaining = Math.max(0, settings.max - bucket.count);
7154
+ const resetSeconds = Math.max(1, Math.ceil((bucket.resetAt - now) / 1e3));
7155
+ c.res.headers.set("X-RateLimit-Limit", String(settings.max));
7156
+ c.res.headers.set("X-RateLimit-Remaining", String(remaining));
7157
+ c.res.headers.set("X-RateLimit-Reset", String(Math.ceil(bucket.resetAt / 1e3)));
7158
+ if (bucket.count > settings.max) {
7159
+ c.res.headers.set("Retry-After", String(resetSeconds));
7160
+ return c.json(
7161
+ {
7162
+ error: true,
7163
+ message: "Too many requests. Try again later.",
7164
+ retryAfterSeconds: resetSeconds
7165
+ },
7166
+ 429
7167
+ );
7168
+ }
7169
+ await next();
7170
+ };
7171
+ }
7172
+ function normalizeRateLimitConfig(config) {
7173
+ return {
7174
+ enabled: config?.enabled ?? true,
7175
+ window: config?.window ?? DEFAULT_WINDOW_MS,
7176
+ max: config?.max ?? DEFAULT_MAX_REQUESTS,
7177
+ paths: config?.paths?.length ? config.paths : DEFAULT_PATHS,
7178
+ skip: config?.skip,
7179
+ trustProxy: config?.trustProxy ?? false
7180
+ };
7181
+ }
7182
+ function matchesLimitedPath(path, prefixes) {
7183
+ return prefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
7184
+ }
7185
+ function pruneExpiredBuckets(buckets, now) {
7186
+ if (buckets.size < 1e3) return;
7187
+ for (const [key, bucket] of buckets) {
7188
+ if (bucket.resetAt <= now) {
7189
+ buckets.delete(key);
7190
+ }
7191
+ }
7192
+ }
7193
+
5082
7194
  // src/app.ts
5083
7195
  async function createDyrectedApp(rawConfig) {
5084
7196
  const config = normalizeConfig(rawConfig);
7197
+ const observability = createObservabilityRuntime(config);
7198
+ bindObservabilityRuntime(config, observability);
7199
+ config.logger = observability.logger;
5085
7200
  const app = new import_hono.Hono();
5086
7201
  if (config.db?.sync) {
5087
7202
  await config.db.sync(config.collections, config.globals);
5088
7203
  }
5089
7204
  app.use("*", (0, import_request_id.requestId)());
5090
- app.use("*", optionalAuth(config));
5091
- app.use("*", async (c, next) => {
5092
- const start = Date.now();
5093
- await next();
5094
- const ms = Date.now() - start;
5095
- console.log(`[dyrected/api] ${c.req.method} ${c.req.path} ${c.res.status} - ${ms}ms`);
5096
- });
5097
- app.use("*", (0, import_cors.cors)());
5098
7205
  app.use("*", async (c, next) => {
5099
7206
  c.set("config", config);
7207
+ c.set("observability", observability);
5100
7208
  if (!c.get("siteId")) {
5101
7209
  c.set("siteId", "default");
5102
7210
  }
7211
+ c.set("logger", buildRequestLogger(observability, c.var, c.get("requestId")));
5103
7212
  await next();
5104
7213
  });
7214
+ app.use("*", async (c, next) => {
7215
+ const start = Date.now();
7216
+ const method = c.req.method;
7217
+ const path = c.req.path;
7218
+ const requestId2 = c.get("requestId");
7219
+ const observabilityConfig = observability.config;
7220
+ const traceSampled = shouldSampleRequest(
7221
+ requestId2,
7222
+ 200,
7223
+ observabilityConfig.sampling,
7224
+ "trace"
7225
+ );
7226
+ const requestTrace = traceSampled ? createRequestSpan(observability, `${method} ${path}`, {
7227
+ "http.method": method,
7228
+ "http.route": path,
7229
+ requestId: requestId2,
7230
+ siteId: c.get("siteId"),
7231
+ workspaceId: c.get("workspaceId")
7232
+ }) : void 0;
7233
+ if (requestTrace) {
7234
+ c.set("requestTrace", requestTrace);
7235
+ c.set("logger", buildRequestLogger(observability, c.var, requestId2));
7236
+ }
7237
+ let bodyCapturePromise;
7238
+ if (observabilityConfig.requestLogging.logBodies) {
7239
+ const bodySampled = shouldSampleRequest(
7240
+ requestId2,
7241
+ 200,
7242
+ observabilityConfig.sampling,
7243
+ "body"
7244
+ );
7245
+ if (bodySampled) {
7246
+ bodyCapturePromise = captureRequestBody(c.req.raw, observabilityConfig);
7247
+ }
7248
+ }
7249
+ let caughtError;
7250
+ try {
7251
+ await next();
7252
+ } catch (error) {
7253
+ caughtError = error;
7254
+ throw error;
7255
+ } finally {
7256
+ const durationMs = Date.now() - start;
7257
+ const statusCode = c.res.status || (caughtError ? 500 : 200);
7258
+ const sampled = shouldSampleRequest(
7259
+ requestId2,
7260
+ statusCode,
7261
+ observabilityConfig.sampling,
7262
+ "log"
7263
+ );
7264
+ const requestLogger = getRequestLogger(c, "api");
7265
+ const requestHeaders = redactHeaders(
7266
+ Object.fromEntries(c.req.raw.headers.entries()),
7267
+ observabilityConfig
7268
+ );
7269
+ const requestBodyCapture = bodyCapturePromise ? await bodyCapturePromise : void 0;
7270
+ if (requestBodyCapture) {
7271
+ c.set("requestBodyCapture", requestBodyCapture);
7272
+ }
7273
+ attachRequestMetrics(observability, {
7274
+ method,
7275
+ route: path,
7276
+ statusCode,
7277
+ durationMs
7278
+ });
7279
+ if (observabilityConfig.requestLogging.enabled && sampled) {
7280
+ const level = statusCode >= 500 ? "error" : statusCode >= 400 ? "warn" : "info";
7281
+ requestLogger[level]({
7282
+ msg: "HTTP request completed",
7283
+ method,
7284
+ path,
7285
+ statusCode,
7286
+ durationMs,
7287
+ requestId: requestId2,
7288
+ siteId: c.get("siteId"),
7289
+ workspaceId: c.get("workspaceId"),
7290
+ contentType: c.req.header("content-type") ?? void 0,
7291
+ contentLength: c.req.header("content-length") ? Number(c.req.header("content-length")) : void 0,
7292
+ sampled,
7293
+ headers: Object.keys(requestHeaders).length > 0 ? requestHeaders : void 0,
7294
+ body: requestBodyCapture?.body,
7295
+ bodyCapture: requestBodyCapture && requestBodyCapture.body === void 0 ? {
7296
+ attempted: requestBodyCapture.attempted,
7297
+ contentType: requestBodyCapture.contentType,
7298
+ contentLength: requestBodyCapture.contentLength,
7299
+ truncated: requestBodyCapture.truncated,
7300
+ parseFailed: requestBodyCapture.parseFailed
7301
+ } : void 0
7302
+ });
7303
+ }
7304
+ endRequestSpan(requestTrace, statusCode, caughtError);
7305
+ }
7306
+ });
7307
+ app.use("*", createRateLimitMiddleware(config));
7308
+ app.use("*", optionalAuth(config));
7309
+ app.use("*", (0, import_cors.cors)(config.cors ? { origin: config.cors.origins } : {}));
5105
7310
  app.get("/health", (c) => c.json({ status: "ok", version: "0.0.1" }));
7311
+ if (observability.config.metrics.enabled && observability.config.metrics.exporter === "prometheus" && observability.prometheusExporter) {
7312
+ app.get(observability.config.metrics.path, async (c) => {
7313
+ const metrics = await renderPrometheusMetrics(
7314
+ observability.prometheusExporter
7315
+ );
7316
+ return new Response(metrics.body, {
7317
+ status: metrics.statusCode,
7318
+ headers: metrics.headers
7319
+ });
7320
+ });
7321
+ }
5106
7322
  app.get("/routes", (c) => {
5107
7323
  const routes = app.routes.map((r) => ({ method: r.method, path: r.path }));
5108
7324
  return c.json({ routes });
5109
7325
  });
5110
7326
  app.onError((err, c) => {
5111
- console.error(`[dyrected/core] Uncaught Error:`, err);
7327
+ const logger = getRequestLogger(c, "core");
7328
+ logger.error({
7329
+ err,
7330
+ msg: "Uncaught error",
7331
+ path: c.req.path,
7332
+ method: c.req.method
7333
+ });
7334
+ observability.recordUncaughtError({
7335
+ path: c.req.path,
7336
+ method: c.req.method
7337
+ });
5112
7338
  return c.json({
5113
7339
  message: err.message || "Internal Server Error",
5114
7340
  stack: process.env.NODE_ENV === "development" ? err.stack : void 0