@dyrected/core 2.5.56 → 2.5.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -346,6 +346,9 @@ async function evaluateAccess(expression, context) {
346
346
  if (typeof expression === "boolean") return expression;
347
347
  try {
348
348
  const result = await jexl2.eval(expression, context);
349
+ if (result && typeof result === "object" && !Array.isArray(result)) {
350
+ return result;
351
+ }
349
352
  return !!result;
350
353
  } catch (err) {
351
354
  console.error("[dyrected/core] Jexl evaluation failed:", err);
@@ -353,6 +356,181 @@ async function evaluateAccess(expression, context) {
353
356
  }
354
357
  }
355
358
 
359
+ // src/auth/access.ts
360
+ function isNamedAccessPolicy(value) {
361
+ return !!value && typeof value === "object" && "policy" in value && typeof value.policy === "string";
362
+ }
363
+ async function resolveAccess(config, access, args) {
364
+ if (access === void 0 || access === null) return void 0;
365
+ if (typeof access === "boolean" || typeof access === "string") {
366
+ return evaluateAccess(access, args);
367
+ }
368
+ if (typeof access === "function") {
369
+ try {
370
+ return await access(args);
371
+ } catch (err) {
372
+ console.error("[dyrected/core] Functional access check failed:", err);
373
+ return false;
374
+ }
375
+ }
376
+ if (isNamedAccessPolicy(access)) {
377
+ const resolver = config.accessPolicies?.[access.policy];
378
+ if (!resolver) {
379
+ console.error(`[dyrected/core] Unknown access policy "${access.policy}".`);
380
+ return false;
381
+ }
382
+ try {
383
+ return await resolver({ ...args, params: access.params });
384
+ } catch (err) {
385
+ console.error(`[dyrected/core] Access policy "${access.policy}" failed:`, err);
386
+ return false;
387
+ }
388
+ }
389
+ return false;
390
+ }
391
+
392
+ // src/utils/access-control.ts
393
+ function toHookRequestContext(req) {
394
+ return {
395
+ query: req.query(),
396
+ headers: req.header(),
397
+ raw: req.raw
398
+ };
399
+ }
400
+ async function resolveBooleanAccess(config, access, args) {
401
+ const result = await resolveAccess(config, access, args);
402
+ if (result === void 0) return true;
403
+ return typeof result === "boolean" ? result : false;
404
+ }
405
+ async function matchesAccessConstraint(config, collection, id, constraint) {
406
+ if (!config.db) return false;
407
+ const match = await config.db.find({
408
+ collection,
409
+ where: { AND: [{ id: { equals: id } }, constraint] },
410
+ limit: 1
411
+ });
412
+ return match.total > 0;
413
+ }
414
+ async function resolveCollectionAccess(config, collection, action, access, args) {
415
+ const result = await resolveAccess(config, access, args);
416
+ if (result === void 0) return { allowed: true };
417
+ if (typeof result === "boolean") return { allowed: result };
418
+ if (action === "read" && !args.id) {
419
+ return { allowed: true, constraint: result };
420
+ }
421
+ if (!args.id) {
422
+ return { allowed: false };
423
+ }
424
+ return {
425
+ allowed: await matchesAccessConstraint(config, collection, args.id, result)
426
+ };
427
+ }
428
+ function resolveFieldAccessId(context) {
429
+ const candidate = context.id ?? context.doc?.id ?? context.data?.id;
430
+ return typeof candidate === "string" && candidate.length > 0 ? candidate : void 0;
431
+ }
432
+ async function applyFieldReadAccess(context, doc) {
433
+ if (!doc || typeof doc !== "object") return doc;
434
+ const result = { ...doc };
435
+ for (const field of context.fields) {
436
+ if (field.type === "row" && field.fields) {
437
+ Object.assign(result, await applyFieldReadAccess({ ...context, fields: field.fields }, result));
438
+ continue;
439
+ }
440
+ if (!field.name) continue;
441
+ const value = result[field.name];
442
+ const canRead = await resolveBooleanAccess(context.config, field.access?.read, {
443
+ user: context.user,
444
+ req: context.req,
445
+ doc: context.doc,
446
+ data: context.data,
447
+ id: resolveFieldAccessId(context)
448
+ });
449
+ if (!canRead) {
450
+ delete result[field.name];
451
+ continue;
452
+ }
453
+ if (value === void 0 || value === null) continue;
454
+ if (field.type === "object" && field.fields && typeof value === "object" && !Array.isArray(value)) {
455
+ result[field.name] = await applyFieldReadAccess({ ...context, fields: field.fields }, value);
456
+ continue;
457
+ }
458
+ if (field.type === "array" && field.fields && Array.isArray(value)) {
459
+ const childFields = field.fields;
460
+ result[field.name] = await Promise.all(
461
+ value.map(
462
+ (item) => typeof item === "object" && item !== null ? applyFieldReadAccess({ ...context, fields: childFields }, item) : item
463
+ )
464
+ );
465
+ continue;
466
+ }
467
+ if (field.type === "blocks" && field.blocks && Array.isArray(value)) {
468
+ result[field.name] = await Promise.all(
469
+ value.map(async (item) => {
470
+ if (typeof item !== "object" || item === null) return item;
471
+ const typedItem = item;
472
+ const block = field.blocks?.find((candidate) => candidate.slug === typedItem.blockType);
473
+ if (!block || !block.fields) return item;
474
+ return applyFieldReadAccess({ ...context, fields: block.fields }, typedItem);
475
+ })
476
+ );
477
+ }
478
+ }
479
+ return result;
480
+ }
481
+ async function applyFieldWriteAccess(context, data) {
482
+ if (!data || typeof data !== "object") return data;
483
+ const result = { ...data };
484
+ for (const field of context.fields) {
485
+ if (field.type === "row" && field.fields) {
486
+ Object.assign(result, await applyFieldWriteAccess({ ...context, fields: field.fields }, result));
487
+ continue;
488
+ }
489
+ if (!field.name || !(field.name in result)) continue;
490
+ const canUpdate = await resolveBooleanAccess(context.config, field.access?.update, {
491
+ user: context.user,
492
+ req: context.req,
493
+ doc: context.doc,
494
+ data: context.data,
495
+ id: resolveFieldAccessId(context)
496
+ });
497
+ if (!canUpdate) {
498
+ delete result[field.name];
499
+ continue;
500
+ }
501
+ const value = result[field.name];
502
+ if (value === void 0 || value === null) continue;
503
+ if (field.type === "object" && field.fields && typeof value === "object" && !Array.isArray(value)) {
504
+ result[field.name] = await applyFieldWriteAccess({ ...context, fields: field.fields }, value);
505
+ continue;
506
+ }
507
+ if (field.type === "array" && field.fields && Array.isArray(value)) {
508
+ const childFields = field.fields;
509
+ result[field.name] = await Promise.all(
510
+ value.map(
511
+ (item) => typeof item === "object" && item !== null ? applyFieldWriteAccess({ ...context, fields: childFields }, item) : item
512
+ )
513
+ );
514
+ continue;
515
+ }
516
+ if (field.type === "blocks" && field.blocks && Array.isArray(value)) {
517
+ result[field.name] = await Promise.all(
518
+ value.map(async (item) => {
519
+ if (typeof item !== "object" || item === null) return item;
520
+ const typedItem = item;
521
+ const block = field.blocks?.find((candidate) => candidate.slug === typedItem.blockType);
522
+ if (!block || !block.fields) return item;
523
+ return applyFieldWriteAccess({ ...context, fields: block.fields }, typedItem);
524
+ })
525
+ );
526
+ }
527
+ }
528
+ return result;
529
+ }
530
+ function mergeWhereConstraint(where, constraint) {
531
+ return where ? { AND: [where, constraint] } : constraint;
532
+ }
533
+
356
534
  // src/controllers/collection.controller.ts
357
535
  var CollectionController = class {
358
536
  collection;
@@ -368,11 +546,23 @@ var CollectionController = class {
368
546
  return config.adminAuth?.providers?.find((p) => p.members) || null;
369
547
  }
370
548
  toHookRequestContext(c) {
371
- return {
372
- query: c.req.query(),
373
- headers: c.req.header(),
374
- raw: c.req.raw
375
- };
549
+ return toHookRequestContext(c.req);
550
+ }
551
+ async evaluateAccess(c, action, options = {}) {
552
+ const config = c.get("config");
553
+ return resolveCollectionAccess(
554
+ config,
555
+ this.collection.slug,
556
+ action,
557
+ this.collection.access?.[action],
558
+ {
559
+ id: options.id,
560
+ user: c.get("user"),
561
+ req: this.toHookRequestContext(c),
562
+ doc: options.doc ?? void 0,
563
+ data: options.data
564
+ }
565
+ );
376
566
  }
377
567
  async find(c) {
378
568
  const config = c.get("config");
@@ -452,6 +642,13 @@ var CollectionController = class {
452
642
  if (this.collection.workflow && !canViewWorkflowDraft(this.collection.workflow, user)) {
453
643
  where = where ? { AND: [where, { __published: { exists: true } }] } : { __published: { exists: true } };
454
644
  }
645
+ const access = await this.evaluateAccess(c, "read");
646
+ if (!access.allowed) {
647
+ return c.json({ error: true, message: `Access denied: read on ${this.collection.slug}` }, 403);
648
+ }
649
+ if (access.constraint) {
650
+ where = mergeWhereConstraint(where, access.constraint);
651
+ }
455
652
  let result = await db.find({
456
653
  collection: this.collection.slug,
457
654
  limit,
@@ -484,7 +681,14 @@ var CollectionController = class {
484
681
  db: readonlyDb
485
682
  });
486
683
  const docWithFieldHooks = await executeFieldAfterRead(this.collection.fields, docWithCollectionHooks, user, readonlyDb);
487
- processedDocs.push(docWithFieldHooks);
684
+ const docWithFieldAccess = await applyFieldReadAccess({
685
+ config,
686
+ fields: this.collection.fields,
687
+ user,
688
+ req: this.toHookRequestContext(c),
689
+ doc: docWithFieldHooks
690
+ }, docWithFieldHooks);
691
+ processedDocs.push(docWithFieldAccess);
488
692
  }
489
693
  result.docs = processedDocs;
490
694
  if (depth > 0) {
@@ -529,6 +733,10 @@ var CollectionController = class {
529
733
  doc = materializeWorkflowDocument(doc, this.collection.workflow, user);
530
734
  if (!doc) return c.json({ message: "Not Found" }, 404);
531
735
  }
736
+ const access = await this.evaluateAccess(c, "read", { id, doc });
737
+ if (!access.allowed) {
738
+ return c.json({ error: true, message: `Access denied: read on ${this.collection.slug}` }, 403);
739
+ }
532
740
  const docWithDefaults = DefaultsService.apply(this.collection.fields, doc);
533
741
  const docWithCollectionHooks = await runCollectionHooks(this.collection.hooks?.afterRead, {
534
742
  doc: docWithDefaults,
@@ -537,17 +745,24 @@ var CollectionController = class {
537
745
  db: readonlyDb
538
746
  });
539
747
  const docWithFieldHooks = await executeFieldAfterRead(this.collection.fields, docWithCollectionHooks, user, readonlyDb);
540
- if (depth > 0 && docWithFieldHooks) {
748
+ const docWithFieldAccess = await applyFieldReadAccess({
749
+ config,
750
+ fields: this.collection.fields,
751
+ user,
752
+ req: this.toHookRequestContext(c),
753
+ doc: docWithFieldHooks
754
+ }, docWithFieldHooks);
755
+ if (depth > 0 && docWithFieldAccess) {
541
756
  const populationService = new PopulationService(db, config.collections);
542
757
  const populatedDoc = await populationService.populate({
543
- data: docWithFieldHooks,
758
+ data: docWithFieldAccess,
544
759
  fields: this.collection.fields,
545
760
  currentDepth: 0,
546
761
  maxDepth: depth
547
762
  });
548
763
  return c.json(populatedDoc);
549
764
  }
550
- return c.json(docWithFieldHooks);
765
+ return c.json(docWithFieldAccess);
551
766
  }
552
767
  async create(c) {
553
768
  const config = c.get("config");
@@ -586,9 +801,20 @@ var CollectionController = class {
586
801
  if (this.collection.workflow) {
587
802
  data = initializeWorkflowDocument(data, this.collection.workflow);
588
803
  }
804
+ const createAccess = await this.evaluateAccess(c, "create", { data });
805
+ if (!createAccess.allowed) {
806
+ return c.json({ error: true, message: `Access denied: create on ${this.collection.slug}` }, 403);
807
+ }
589
808
  if (this.collection.auth && data.password) {
590
809
  data.password = await hashPassword(data.password);
591
810
  }
811
+ data = await applyFieldWriteAccess({
812
+ config,
813
+ fields: this.collection.fields,
814
+ user,
815
+ req: this.toHookRequestContext(c),
816
+ data
817
+ }, data);
592
818
  data = await executeFieldBeforeChange(this.collection.fields, data, null, user, readonlyDb);
593
819
  data = await runCollectionHooks(this.collection.hooks?.beforeChange, {
594
820
  data,
@@ -623,7 +849,14 @@ var CollectionController = class {
623
849
  db: readonlyDb
624
850
  });
625
851
  const finalDoc = await executeFieldAfterRead(this.collection.fields, readDoc, user, readonlyDb);
626
- return c.json(finalDoc, 201);
852
+ const accessibleDoc = await applyFieldReadAccess({
853
+ config,
854
+ fields: this.collection.fields,
855
+ user,
856
+ req: this.toHookRequestContext(c),
857
+ doc: finalDoc
858
+ }, finalDoc);
859
+ return c.json(accessibleDoc, 201);
627
860
  }
628
861
  async upload(c) {
629
862
  const config = c.get("config");
@@ -666,6 +899,17 @@ var CollectionController = class {
666
899
  createdBy: user?.sub ?? null,
667
900
  updatedBy: user?.sub ?? null
668
901
  };
902
+ const createAccess = await this.evaluateAccess(c, "create", { data });
903
+ if (!createAccess.allowed) {
904
+ return c.json({ error: true, message: `Access denied: create on ${this.collection.slug}` }, 403);
905
+ }
906
+ data = await applyFieldWriteAccess({
907
+ config,
908
+ fields: this.collection.fields,
909
+ user,
910
+ req: this.toHookRequestContext(c),
911
+ data
912
+ }, data);
669
913
  data = await executeFieldBeforeChange(this.collection.fields, data, null, user, readonlyDb);
670
914
  data = await runCollectionHooks(this.collection.hooks?.beforeChange, {
671
915
  data,
@@ -693,7 +937,14 @@ var CollectionController = class {
693
937
  db: readonlyDb
694
938
  });
695
939
  const finalDoc = await executeFieldAfterRead(this.collection.fields, readDoc, user, readonlyDb);
696
- return c.json(finalDoc, 201);
940
+ const accessibleDoc = await applyFieldReadAccess({
941
+ config,
942
+ fields: this.collection.fields,
943
+ user,
944
+ req: this.toHookRequestContext(c),
945
+ doc: finalDoc
946
+ }, finalDoc);
947
+ return c.json(accessibleDoc, 201);
697
948
  }
698
949
  async update(c) {
699
950
  const config = c.get("config");
@@ -731,10 +982,22 @@ var CollectionController = class {
731
982
  });
732
983
  const originalDoc = await db.findOne({ collection: this.collection.slug, id });
733
984
  if (!originalDoc) return c.json({ message: "Not Found" }, 404);
985
+ const updateAccess = await this.evaluateAccess(c, "update", { id, doc: originalDoc, data });
986
+ if (!updateAccess.allowed) {
987
+ return c.json({ error: true, message: `Access denied: update on ${this.collection.slug}` }, 403);
988
+ }
734
989
  let before = null;
735
990
  if (this.collection.audit) {
736
991
  before = originalDoc;
737
992
  }
993
+ data = await applyFieldWriteAccess({
994
+ config,
995
+ fields: this.collection.fields,
996
+ user,
997
+ req: this.toHookRequestContext(c),
998
+ doc: originalDoc,
999
+ data
1000
+ }, data);
738
1001
  data = await executeFieldBeforeChange(this.collection.fields, data, originalDoc, user, readonlyDb);
739
1002
  data = await runCollectionHooks(this.collection.hooks?.beforeChange, {
740
1003
  data,
@@ -770,7 +1033,14 @@ var CollectionController = class {
770
1033
  db: readonlyDb
771
1034
  });
772
1035
  const finalDoc = await executeFieldAfterRead(this.collection.fields, readDoc, user, readonlyDb);
773
- return c.json(finalDoc);
1036
+ const accessibleDoc = await applyFieldReadAccess({
1037
+ config,
1038
+ fields: this.collection.fields,
1039
+ user,
1040
+ req: this.toHookRequestContext(c),
1041
+ doc: finalDoc
1042
+ }, finalDoc);
1043
+ return c.json(accessibleDoc);
774
1044
  }
775
1045
  async transition(c) {
776
1046
  const config = c.get("config");
@@ -807,7 +1077,7 @@ var CollectionController = class {
807
1077
  const readAccess = this.collection.access?.read;
808
1078
  if (readAccess !== void 0 && readAccess !== null) {
809
1079
  const args = { user: c.get("user"), req: c.req, doc: document };
810
- const result2 = typeof readAccess === "function" ? await readAccess(args) : await evaluateAccess(readAccess, args);
1080
+ const result2 = await resolveAccess(config, readAccess, args);
811
1081
  let allowed = result2 === true;
812
1082
  if (result2 && typeof result2 === "object") {
813
1083
  const match = await config.db.find({
@@ -918,6 +1188,10 @@ var CollectionController = class {
918
1188
  const user = c.get("user");
919
1189
  const doc = await db.findOne({ collection: this.collection.slug, id });
920
1190
  if (!doc) return c.json({ message: "Not Found" }, 404);
1191
+ const deleteAccess = await this.evaluateAccess(c, "delete", { id, doc });
1192
+ if (!deleteAccess.allowed) {
1193
+ return c.json({ error: true, message: `Access denied: delete on ${this.collection.slug}` }, 403);
1194
+ }
921
1195
  let before = null;
922
1196
  if (this.collection.audit) {
923
1197
  before = doc;
@@ -977,6 +1251,11 @@ var CollectionController = class {
977
1251
  failed.push({ id, error: "Not Found" });
978
1252
  continue;
979
1253
  }
1254
+ const deleteAccess = await this.evaluateAccess(c, "delete", { id, doc });
1255
+ if (!deleteAccess.allowed) {
1256
+ failed.push({ id, error: "Access denied" });
1257
+ continue;
1258
+ }
980
1259
  let before = null;
981
1260
  if (this.collection.audit) {
982
1261
  before = doc;
@@ -1070,6 +1349,14 @@ var GlobalController = class {
1070
1349
  await db.updateGlobal({ slug: this.global.slug, data: this.global.initialData });
1071
1350
  data = this.global.initialData;
1072
1351
  }
1352
+ const canRead = await resolveBooleanAccess(config, this.global.access?.read, {
1353
+ user,
1354
+ req: toHookRequestContext(c.req),
1355
+ doc: data
1356
+ });
1357
+ if (!canRead) {
1358
+ return c.json({ error: true, message: `Access denied: read on ${this.global.slug}` }, 403);
1359
+ }
1073
1360
  const dataWithDefaults = DefaultsService.apply(this.global.fields, data);
1074
1361
  const docWithCollectionHooks = await runCollectionHooks(this.global.hooks?.afterRead, {
1075
1362
  doc: dataWithDefaults,
@@ -1078,17 +1365,24 @@ var GlobalController = class {
1078
1365
  db: readonlyDb
1079
1366
  });
1080
1367
  const docWithFieldHooks = await executeFieldAfterRead(this.global.fields, docWithCollectionHooks, user, readonlyDb);
1081
- if (depth > 0 && docWithFieldHooks) {
1368
+ const docWithFieldAccess = await applyFieldReadAccess({
1369
+ config,
1370
+ fields: this.global.fields,
1371
+ user,
1372
+ req: toHookRequestContext(c.req),
1373
+ doc: docWithFieldHooks
1374
+ }, docWithFieldHooks);
1375
+ if (depth > 0 && docWithFieldAccess) {
1082
1376
  const populationService = new PopulationService(db, config.collections);
1083
1377
  const populatedData = await populationService.populate({
1084
- data: docWithFieldHooks,
1378
+ data: docWithFieldAccess,
1085
1379
  fields: this.global.fields,
1086
1380
  currentDepth: 0,
1087
1381
  maxDepth: depth
1088
1382
  });
1089
1383
  return c.json(populatedData);
1090
1384
  }
1091
- return c.json(docWithFieldHooks);
1385
+ return c.json(docWithFieldAccess);
1092
1386
  }
1093
1387
  async update(c) {
1094
1388
  const config = c.get("config");
@@ -1098,7 +1392,24 @@ var GlobalController = class {
1098
1392
  const body = await c.req.json();
1099
1393
  const user = c.get("user");
1100
1394
  const originalDoc = await db.getGlobal({ slug: this.global.slug }) || {};
1101
- let data = await executeFieldBeforeChange(this.global.fields, body, originalDoc, user, readonlyDb);
1395
+ const canUpdate = await resolveBooleanAccess(config, this.global.access?.update, {
1396
+ user,
1397
+ req: toHookRequestContext(c.req),
1398
+ doc: originalDoc,
1399
+ data: body
1400
+ });
1401
+ if (!canUpdate) {
1402
+ return c.json({ error: true, message: `Access denied: update on ${this.global.slug}` }, 403);
1403
+ }
1404
+ let sanitizedBody = await applyFieldWriteAccess({
1405
+ config,
1406
+ fields: this.global.fields,
1407
+ user,
1408
+ req: toHookRequestContext(c.req),
1409
+ doc: originalDoc,
1410
+ data: body
1411
+ }, body);
1412
+ let data = await executeFieldBeforeChange(this.global.fields, sanitizedBody, originalDoc, user, readonlyDb);
1102
1413
  data = await runCollectionHooks(this.global.hooks?.beforeChange, {
1103
1414
  data,
1104
1415
  doc: originalDoc,
@@ -1123,7 +1434,14 @@ var GlobalController = class {
1123
1434
  db: readonlyDb
1124
1435
  });
1125
1436
  const finalDoc = await executeFieldAfterRead(this.global.fields, readDoc, user, readonlyDb);
1126
- return c.json(finalDoc);
1437
+ const accessibleDoc = await applyFieldReadAccess({
1438
+ config,
1439
+ fields: this.global.fields,
1440
+ user,
1441
+ req: toHookRequestContext(c.req),
1442
+ doc: finalDoc
1443
+ }, finalDoc);
1444
+ return c.json(accessibleDoc);
1127
1445
  }
1128
1446
  async seed(c) {
1129
1447
  const config = c.get("config");
@@ -1305,7 +1623,7 @@ var MediaController = class {
1305
1623
  import { SignJWT, jwtVerify, decodeJwt } from "jose";
1306
1624
  import { TextEncoder } from "util";
1307
1625
  function getSecret() {
1308
- const secret = process.env.DYRECTED_JWT_SECRET || process.env.JWT_SECRET;
1626
+ const secret = process.env.DYRECTED_JWT_SECRET;
1309
1627
  if (!secret) {
1310
1628
  throw new Error(
1311
1629
  "[dyrected/core] DYRECTED_JWT_SECRET is not set. Add it to your environment variables to enable auth collections."
@@ -2180,7 +2498,7 @@ var AdminAuthController = class {
2180
2498
  return state;
2181
2499
  }
2182
2500
  getStateSecret() {
2183
- const secret = process.env.DYRECTED_JWT_SECRET || process.env.JWT_SECRET;
2501
+ const secret = process.env.DYRECTED_JWT_SECRET;
2184
2502
  if (!secret) {
2185
2503
  throw new Error("[dyrected/core] DYRECTED_JWT_SECRET is not set.");
2186
2504
  }
@@ -2198,7 +2516,7 @@ import { SignJWT as SignJWT3, jwtVerify as jwtVerify3 } from "jose";
2198
2516
  import { TextEncoder as TextEncoder3 } from "util";
2199
2517
  var PreviewController = class {
2200
2518
  getSecret() {
2201
- const secret = process.env.DYRECTED_JWT_SECRET || process.env.JWT_SECRET || "dyrected-preview-secret-change-me";
2519
+ const secret = process.env.DYRECTED_JWT_SECRET || "dyrected-preview-secret-change-me";
2202
2520
  return new TextEncoder3().encode(secret);
2203
2521
  }
2204
2522
  /**
@@ -2237,30 +2555,74 @@ var PreviewController = class {
2237
2555
  };
2238
2556
 
2239
2557
  // src/middleware/auth.ts
2240
- function requireAuth() {
2558
+ function getBearerToken(c) {
2559
+ const authHeader = c.req.header("Authorization");
2560
+ return authHeader?.replace(/^Bearer\s+/i, "") || void 0;
2561
+ }
2562
+ async function resolveUser(token, config) {
2563
+ const payload = await verifyCollectionToken(token);
2564
+ if (payload.purpose) {
2565
+ return payload;
2566
+ }
2567
+ const db = config?.db;
2568
+ if (!db) {
2569
+ return payload;
2570
+ }
2571
+ let doc;
2572
+ try {
2573
+ doc = await db.findOne({
2574
+ collection: payload.collection,
2575
+ id: payload.sub
2576
+ });
2577
+ } catch (err) {
2578
+ console.error("[dyrected/core] Failed to hydrate user from token:", err);
2579
+ return payload;
2580
+ }
2581
+ if (!doc) {
2582
+ return null;
2583
+ }
2584
+ const { password: _password, ...safe } = doc;
2585
+ return {
2586
+ ...safe,
2587
+ sub: payload.sub,
2588
+ email: payload.email ?? safe.email,
2589
+ collection: payload.collection
2590
+ };
2591
+ }
2592
+ function requireAuth(config) {
2241
2593
  return async (c, next) => {
2242
- const authHeader = c.req.header("Authorization");
2243
- const token = authHeader?.replace(/^Bearer\s+/i, "");
2594
+ if (c.get("user")) {
2595
+ return next();
2596
+ }
2597
+ const token = getBearerToken(c);
2244
2598
  if (!token) {
2245
2599
  return c.json({ error: true, message: "Authentication required." }, 401);
2246
2600
  }
2601
+ let user;
2247
2602
  try {
2248
- const user = await verifyCollectionToken(token);
2249
- c.set("user", user);
2250
- await next();
2603
+ user = await resolveUser(token, config ?? c.get("config"));
2251
2604
  } catch {
2252
2605
  return c.json({ error: true, message: "Invalid or expired token." }, 401);
2253
2606
  }
2607
+ if (!user) {
2608
+ return c.json({ error: true, message: "Invalid or expired token." }, 401);
2609
+ }
2610
+ c.set("user", user);
2611
+ await next();
2254
2612
  };
2255
2613
  }
2256
- function optionalAuth() {
2614
+ function optionalAuth(config) {
2257
2615
  return async (c, next) => {
2258
- const authHeader = c.req.header("Authorization");
2259
- const token = authHeader?.replace(/^Bearer\s+/i, "");
2616
+ if (c.get("user")) {
2617
+ return next();
2618
+ }
2619
+ const token = getBearerToken(c);
2260
2620
  if (token) {
2261
2621
  try {
2262
- const user = await verifyCollectionToken(token);
2263
- c.set("user", user);
2622
+ const user = await resolveUser(token, config ?? c.get("config"));
2623
+ if (user) {
2624
+ c.set("user", user);
2625
+ }
2264
2626
  } catch {
2265
2627
  }
2266
2628
  }
@@ -2323,37 +2685,23 @@ function getSwaggerHtml(specUrl) {
2323
2685
  }
2324
2686
 
2325
2687
  // src/router.ts
2326
- function accessGate(target, action) {
2688
+ function accessGate(config, target, action) {
2327
2689
  return async (c, next) => {
2328
2690
  const user = c.get("user");
2329
2691
  const accessExpr = target.access?.[action];
2330
2692
  if (accessExpr === void 0 || accessExpr === null) {
2331
2693
  return await next();
2332
2694
  }
2333
- const accessArgs = { user, req: c.req, doc: null };
2334
- const allowed = await evaluateAccess(accessExpr, accessArgs);
2695
+ const allowed = await resolveBooleanAccess(config, accessExpr, {
2696
+ user,
2697
+ req: toHookRequestContext(c.req)
2698
+ });
2335
2699
  if (!allowed) {
2336
2700
  return c.json({ error: true, message: `Access denied: ${action} on ${target.slug}` }, 403);
2337
2701
  }
2338
2702
  await next();
2339
2703
  };
2340
2704
  }
2341
- async function checkAccess(access, accessArgs) {
2342
- if (access === void 0 || access === null) return true;
2343
- if (typeof access === "function") {
2344
- try {
2345
- const result = await access(accessArgs);
2346
- return typeof result === "boolean" ? result : !!result;
2347
- } catch (err) {
2348
- console.error("[dyrected/core] Functional access check failed:", err);
2349
- return false;
2350
- }
2351
- }
2352
- if (typeof access === "string" || typeof access === "boolean") {
2353
- return evaluateAccess(access, accessArgs);
2354
- }
2355
- return true;
2356
- }
2357
2705
  function serializeFieldForApi(f) {
2358
2706
  if (!f) return f;
2359
2707
  const serialized = { ...f };
@@ -2382,7 +2730,7 @@ function serializeFieldForApi(f) {
2382
2730
  return serialized;
2383
2731
  }
2384
2732
  function registerRoutes(app, config) {
2385
- app.get("/api/schemas", optionalAuth(), async (c) => {
2733
+ app.get("/api/schemas", optionalAuth(config), async (c) => {
2386
2734
  const siteId = c.req.header("X-Site-Id");
2387
2735
  let collections = [...config.collections];
2388
2736
  let globals = [...config.globals];
@@ -2398,11 +2746,11 @@ function registerRoutes(app, config) {
2398
2746
  }
2399
2747
  }
2400
2748
  const user = c.get("user");
2401
- const accessArgs = { user, req: c.req, doc: null };
2749
+ const accessArgs = { user, req: toHookRequestContext(c.req) };
2402
2750
  const serializeAccess = async (access) => {
2403
2751
  if (typeof access === "string") return access;
2404
2752
  if (typeof access === "boolean") return access;
2405
- return checkAccess(access, accessArgs);
2753
+ return resolveBooleanAccess(config, access, accessArgs);
2406
2754
  };
2407
2755
  const filteredCollections = await Promise.all(collections.filter((col) => !siteId || col.shared || !col.siteId || col.siteId === siteId).map(async (col) => ({
2408
2756
  slug: col.slug,
@@ -2488,7 +2836,7 @@ function registerRoutes(app, config) {
2488
2836
  adminAuth: getPublicAdminAuthConfig(config.adminAuth, collections)
2489
2837
  });
2490
2838
  });
2491
- app.get("/api/dyrected/options/:collection/:field", optionalAuth(), async (c) => {
2839
+ app.get("/api/dyrected/options/:collection/:field", optionalAuth(config), async (c) => {
2492
2840
  const { collection: colSlug, field: fieldName } = c.req.param();
2493
2841
  const siteId = c.req.header("X-Site-Id");
2494
2842
  let collections = [...config.collections];
@@ -2502,8 +2850,10 @@ function registerRoutes(app, config) {
2502
2850
  if (collection) {
2503
2851
  const accessExpr = collection.access?.read;
2504
2852
  if (accessExpr !== void 0 && accessExpr !== null) {
2505
- const accessArgs = { user, req: c.req, doc: null };
2506
- const allowed = await checkAccess(accessExpr, accessArgs);
2853
+ const allowed = await resolveBooleanAccess(config, accessExpr, {
2854
+ user,
2855
+ req: toHookRequestContext(c.req)
2856
+ });
2507
2857
  if (!allowed) {
2508
2858
  return c.json({ error: true, message: `Access denied: read on ${colSlug}` }, 403);
2509
2859
  }
@@ -2521,8 +2871,10 @@ function registerRoutes(app, config) {
2521
2871
  }
2522
2872
  const accessExpr = glb.access?.read;
2523
2873
  if (accessExpr !== void 0 && accessExpr !== null) {
2524
- const accessArgs = { user, req: c.req, doc: null };
2525
- const allowed = await checkAccess(accessExpr, accessArgs);
2874
+ const allowed = await resolveBooleanAccess(config, accessExpr, {
2875
+ user,
2876
+ req: toHookRequestContext(c.req)
2877
+ });
2526
2878
  if (!allowed) {
2527
2879
  return c.json({ error: true, message: `Access denied: read on global ${colSlug}` }, 403);
2528
2880
  }
@@ -2566,7 +2918,7 @@ function registerRoutes(app, config) {
2566
2918
  app.get("/api/docs", (c) => {
2567
2919
  return c.html(getSwaggerHtml());
2568
2920
  });
2569
- app.get("/api/preferences/:key", requireAuth(), async (c) => {
2921
+ app.get("/api/preferences/:key", requireAuth(config), async (c) => {
2570
2922
  const db = config.db;
2571
2923
  const user = c.get("user");
2572
2924
  const key = c.req.param("key");
@@ -2591,7 +2943,7 @@ function registerRoutes(app, config) {
2591
2943
  const globalValue = await getGlobalPreference();
2592
2944
  return c.json({ key, value: globalValue });
2593
2945
  });
2594
- app.put("/api/preferences/:key", requireAuth(), async (c) => {
2946
+ app.put("/api/preferences/:key", requireAuth(config), async (c) => {
2595
2947
  const db = config.db;
2596
2948
  const user = c.get("user");
2597
2949
  const key = c.req.param("key");
@@ -2631,7 +2983,7 @@ function registerRoutes(app, config) {
2631
2983
  });
2632
2984
  return c.json({ key, value: body.value });
2633
2985
  });
2634
- app.delete("/api/preferences/:key", requireAuth(), async (c) => {
2986
+ app.delete("/api/preferences/:key", requireAuth(config), async (c) => {
2635
2987
  const db = config.db;
2636
2988
  const user = c.get("user");
2637
2989
  const key = c.req.param("key");
@@ -2671,10 +3023,10 @@ function registerRoutes(app, config) {
2671
3023
  for (const col of uploadCollections) {
2672
3024
  const mediaController = new MediaController(col.slug);
2673
3025
  const prefix = `/api/collections/${col.slug}`;
2674
- app.get(`${prefix}/media`, accessGate(col, "read"), (c) => mediaController.find(c));
3026
+ app.get(`${prefix}/media`, accessGate(config, col, "read"), (c) => mediaController.find(c));
2675
3027
  app.get(`${prefix}/media/:filename{.+$}`, (c) => mediaController.serve(c));
2676
- app.post(`${prefix}/media`, accessGate(col, "create"), (c) => mediaController.upload(c));
2677
- app.delete(`${prefix}/media/:id`, accessGate(col, "delete"), (c) => mediaController.delete(c));
3028
+ app.post(`${prefix}/media`, accessGate(config, col, "create"), (c) => mediaController.upload(c));
3029
+ app.delete(`${prefix}/media/:id`, accessGate(config, col, "delete"), (c) => mediaController.delete(c));
2678
3030
  }
2679
3031
  }
2680
3032
  const adminAuthController = new AdminAuthController(config);
@@ -2691,43 +3043,43 @@ function registerRoutes(app, config) {
2691
3043
  app.post(`${path}/logout`, (c) => authController.logout(c));
2692
3044
  app.get(`${path}/init`, (c) => authController.init(c));
2693
3045
  app.post(`${path}/first-user`, (c) => authController.registerFirstUser(c));
2694
- app.get(`${path}/me`, requireAuth(), (c) => authController.me(c));
2695
- app.post(`${path}/refresh-token`, requireAuth(), (c) => authController.refreshToken(c));
3046
+ app.get(`${path}/me`, requireAuth(config), (c) => authController.me(c));
3047
+ app.post(`${path}/refresh-token`, requireAuth(config), (c) => authController.refreshToken(c));
2696
3048
  app.post(`${path}/forgot-password`, (c) => authController.forgotPassword(c));
2697
3049
  app.post(`${path}/reset-password`, (c) => authController.resetPassword(c));
2698
- app.post(`${path}/invite`, requireAuth(), (c) => authController.invite(c));
3050
+ app.post(`${path}/invite`, requireAuth(config), (c) => authController.invite(c));
2699
3051
  app.post(`${path}/accept-invite`, (c) => authController.acceptInvite(c));
2700
3052
  }
2701
3053
  for (const collection of config.collections) {
2702
3054
  const path = `/api/collections/${collection.slug}`;
2703
3055
  const controller = new CollectionController(collection);
2704
- app.get(path, accessGate(collection, "read"), (c) => controller.find(c));
2705
- app.post(path, accessGate(collection, "create"), (c) => controller.create(c));
2706
- app.post(`${path}/media`, accessGate(collection, "create"), (c) => controller.create(c));
2707
- app.delete(`${path}/delete-many`, accessGate(collection, "delete"), (c) => controller.deleteMany(c));
2708
- app.get(`${path}/:id`, accessGate(collection, "read"), (c) => controller.findOne(c));
2709
- app.patch(`${path}/:id`, accessGate(collection, "update"), (c) => controller.update(c));
2710
- app.delete(`${path}/:id`, accessGate(collection, "delete"), (c) => controller.delete(c));
3056
+ app.get(path, (c) => controller.find(c));
3057
+ app.post(path, (c) => controller.create(c));
3058
+ app.post(`${path}/media`, (c) => controller.create(c));
3059
+ app.delete(`${path}/delete-many`, (c) => controller.deleteMany(c));
3060
+ app.get(`${path}/:id`, (c) => controller.findOne(c));
3061
+ app.patch(`${path}/:id`, (c) => controller.update(c));
3062
+ app.delete(`${path}/:id`, (c) => controller.delete(c));
2711
3063
  app.post(`${path}/seed`, (c) => controller.seed(c));
2712
3064
  if (collection.auth) {
2713
- app.post(`${path}/:id/change-password`, requireAuth(), (c) => controller.changePassword(c));
3065
+ app.post(`${path}/:id/change-password`, requireAuth(config), (c) => controller.changePassword(c));
2714
3066
  }
2715
3067
  if (collection.workflow) {
2716
- app.post(`${path}/:id/transitions/:transition`, requireAuth(), (c) => controller.transition(c));
2717
- app.get(`${path}/:id/workflow-history`, requireAuth(), (c) => controller.workflowHistory(c));
3068
+ app.post(`${path}/:id/transitions/:transition`, requireAuth(config), (c) => controller.transition(c));
3069
+ app.get(`${path}/:id/workflow-history`, requireAuth(config), (c) => controller.workflowHistory(c));
2718
3070
  }
2719
3071
  }
2720
3072
  for (const global of config.globals) {
2721
3073
  const path = `/api/globals/${global.slug}`;
2722
3074
  const controller = new GlobalController(global);
2723
- app.get(path, accessGate(global, "read"), (c) => controller.get(c));
2724
- app.patch(path, accessGate(global, "update"), (c) => controller.update(c));
3075
+ app.get(path, (c) => controller.get(c));
3076
+ app.patch(path, (c) => controller.update(c));
2725
3077
  app.post(`${path}/seed`, (c) => controller.seed(c));
2726
3078
  }
2727
3079
  const previewController = new PreviewController();
2728
- app.post("/api/preview-token", requireAuth(), (c) => previewController.createToken(c));
3080
+ app.post("/api/preview-token", requireAuth(config), (c) => previewController.createToken(c));
2729
3081
  app.get("/api/preview-data", (c) => previewController.getData(c));
2730
- app.post("/api/collections/:slug/:id/transitions/:transition", requireAuth(), async (c) => {
3082
+ app.post("/api/collections/:slug/:id/transitions/:transition", requireAuth(config), async (c) => {
2731
3083
  const slug = c.req.param("slug");
2732
3084
  const siteId = c.req.header("X-Site-Id") || c.get("siteId");
2733
3085
  const config2 = c.get("config");
@@ -2745,7 +3097,7 @@ function registerRoutes(app, config) {
2745
3097
  const controller = new CollectionController(collection);
2746
3098
  return controller.transition(c);
2747
3099
  });
2748
- app.get("/api/collections/:slug/:id/workflow-history", requireAuth(), async (c) => {
3100
+ app.get("/api/collections/:slug/:id/workflow-history", requireAuth(config), async (c) => {
2749
3101
  const slug = c.req.param("slug");
2750
3102
  const siteId = c.req.header("X-Site-Id") || c.get("siteId");
2751
3103
  const config2 = c.get("config");
@@ -2844,7 +3196,7 @@ async function createDyrectedApp(rawConfig) {
2844
3196
  await config.db.sync(config.collections, config.globals);
2845
3197
  }
2846
3198
  app.use("*", requestId());
2847
- app.use("*", optionalAuth());
3199
+ app.use("*", optionalAuth(config));
2848
3200
  app.use("*", async (c, next) => {
2849
3201
  const start = Date.now();
2850
3202
  await next();