@dyrected/core 2.5.58 → 2.5.60

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
@@ -627,12 +627,16 @@ async function resolveAccess(config, access, args) {
627
627
  }
628
628
  }
629
629
  if (isNamedAccessPolicy(access)) {
630
- const resolver = config.accessPolicies?.[access.policy];
631
- if (!resolver) {
630
+ const policy = config.accessPolicies?.[access.policy];
631
+ if (policy === void 0) {
632
632
  console.error(`[dyrected/core] Unknown access policy "${access.policy}".`);
633
633
  return false;
634
634
  }
635
+ if (typeof policy === "string" || typeof policy === "boolean") {
636
+ return evaluateAccess(policy, args);
637
+ }
635
638
  try {
639
+ const resolver = policy;
636
640
  return await resolver({ ...args, params: access.params });
637
641
  } catch (err) {
638
642
  console.error(`[dyrected/core] Access policy "${access.policy}" failed:`, err);
@@ -713,6 +717,10 @@ async function resolveCollectionAccess(config, collection, action, access, args)
713
717
  allowed: await matchesAccessConstraint(config, collection, args.id, result)
714
718
  };
715
719
  }
720
+ function resolveFieldAccessId(context) {
721
+ const candidate = context.id ?? context.doc?.id ?? context.data?.id;
722
+ return typeof candidate === "string" && candidate.length > 0 ? candidate : void 0;
723
+ }
716
724
  async function applyFieldReadAccess(context, doc) {
717
725
  if (!doc || typeof doc !== "object") return doc;
718
726
  const result = { ...doc };
@@ -727,7 +735,8 @@ async function applyFieldReadAccess(context, doc) {
727
735
  user: context.user,
728
736
  req: context.req,
729
737
  doc: context.doc,
730
- data: context.data
738
+ data: context.data,
739
+ id: resolveFieldAccessId(context)
731
740
  });
732
741
  if (!canRead) {
733
742
  delete result[field.name];
@@ -739,9 +748,10 @@ async function applyFieldReadAccess(context, doc) {
739
748
  continue;
740
749
  }
741
750
  if (field.type === "array" && field.fields && Array.isArray(value)) {
751
+ const childFields = field.fields;
742
752
  result[field.name] = await Promise.all(
743
753
  value.map(
744
- (item) => typeof item === "object" && item !== null ? applyFieldReadAccess({ ...context, fields: field.fields }, item) : item
754
+ (item) => typeof item === "object" && item !== null ? applyFieldReadAccess({ ...context, fields: childFields }, item) : item
745
755
  )
746
756
  );
747
757
  continue;
@@ -752,7 +762,7 @@ async function applyFieldReadAccess(context, doc) {
752
762
  if (typeof item !== "object" || item === null) return item;
753
763
  const typedItem = item;
754
764
  const block = field.blocks?.find((candidate) => candidate.slug === typedItem.blockType);
755
- if (!block) return item;
765
+ if (!block || !block.fields) return item;
756
766
  return applyFieldReadAccess({ ...context, fields: block.fields }, typedItem);
757
767
  })
758
768
  );
@@ -769,13 +779,15 @@ async function applyFieldWriteAccess(context, data) {
769
779
  continue;
770
780
  }
771
781
  if (!field.name || !(field.name in result)) continue;
772
- const canUpdate = await resolveBooleanAccess(context.config, field.access?.update, {
782
+ const writeRule = context.operation === "create" ? field.access?.create ?? field.access?.update : field.access?.update;
783
+ const canWrite = await resolveBooleanAccess(context.config, writeRule, {
773
784
  user: context.user,
774
785
  req: context.req,
775
786
  doc: context.doc,
776
- data: context.data
787
+ data: context.data,
788
+ id: resolveFieldAccessId(context)
777
789
  });
778
- if (!canUpdate) {
790
+ if (!canWrite) {
779
791
  delete result[field.name];
780
792
  continue;
781
793
  }
@@ -786,9 +798,10 @@ async function applyFieldWriteAccess(context, data) {
786
798
  continue;
787
799
  }
788
800
  if (field.type === "array" && field.fields && Array.isArray(value)) {
801
+ const childFields = field.fields;
789
802
  result[field.name] = await Promise.all(
790
803
  value.map(
791
- (item) => typeof item === "object" && item !== null ? applyFieldWriteAccess({ ...context, fields: field.fields }, item) : item
804
+ (item) => typeof item === "object" && item !== null ? applyFieldWriteAccess({ ...context, fields: childFields }, item) : item
792
805
  )
793
806
  );
794
807
  continue;
@@ -799,7 +812,7 @@ async function applyFieldWriteAccess(context, data) {
799
812
  if (typeof item !== "object" || item === null) return item;
800
813
  const typedItem = item;
801
814
  const block = field.blocks?.find((candidate) => candidate.slug === typedItem.blockType);
802
- if (!block) return item;
815
+ if (!block || !block.fields) return item;
803
816
  return applyFieldWriteAccess({ ...context, fields: block.fields }, typedItem);
804
817
  })
805
818
  );
@@ -1111,7 +1124,7 @@ var CollectionController = class {
1111
1124
  const readonlyDb = createReadonlyDb(db);
1112
1125
  const limit = Number(c.req.query("limit")) || 10;
1113
1126
  const page = Number(c.req.query("page")) || 1;
1114
- const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 2;
1127
+ const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 1;
1115
1128
  const sort = c.req.query("sort") || void 0;
1116
1129
  const user = c.get("user");
1117
1130
  let where = void 0;
@@ -1227,7 +1240,7 @@ var CollectionController = class {
1227
1240
  }
1228
1241
  const readonlyDb = createReadonlyDb(db);
1229
1242
  const id = c.req.param("id");
1230
- const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 10;
1243
+ const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 1;
1231
1244
  const user = c.get("user");
1232
1245
  if (!id) return c.json({ message: "Missing ID" }, 400);
1233
1246
  let doc = await db.findOne({ collection: this.collection.slug, id });
@@ -1316,7 +1329,8 @@ var CollectionController = class {
1316
1329
  fields: this.collection.fields,
1317
1330
  user,
1318
1331
  req: this.toHookRequestContext(c),
1319
- data
1332
+ data,
1333
+ operation: "create"
1320
1334
  }, data);
1321
1335
  data = await executeFieldBeforeChange(this.collection.fields, data, null, user, readonlyDb);
1322
1336
  data = await runCollectionHooks(this.collection.hooks?.beforeChange, {
@@ -1411,7 +1425,8 @@ var CollectionController = class {
1411
1425
  fields: this.collection.fields,
1412
1426
  user,
1413
1427
  req: this.toHookRequestContext(c),
1414
- data
1428
+ data,
1429
+ operation: "create"
1415
1430
  }, data);
1416
1431
  data = await executeFieldBeforeChange(this.collection.fields, data, null, user, readonlyDb);
1417
1432
  data = await runCollectionHooks(this.collection.hooks?.beforeChange, {
@@ -1833,7 +1848,7 @@ var GlobalController = class {
1833
1848
  const db = config.db;
1834
1849
  if (!db) return c.json({ message: "Database not configured" }, 500);
1835
1850
  const readonlyDb = createReadonlyDb(db);
1836
- const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 10;
1851
+ const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 1;
1837
1852
  const user = c.get("user");
1838
1853
  let query = void 0;
1839
1854
  const beforeReadResult = await runCollectionHooks(this.global.hooks?.beforeRead, {
@@ -3052,6 +3067,150 @@ var PreviewController = class {
3052
3067
  }
3053
3068
  };
3054
3069
 
3070
+ // src/controllers/audit.controller.ts
3071
+ var AuditController = class {
3072
+ parseQuery(c) {
3073
+ const limit = Math.min(Number(c.req.query("limit")) || 50, 100);
3074
+ const page = Math.max(Number(c.req.query("page")) || 1, 1);
3075
+ const sort = c.req.query("sort") || "-timestamp";
3076
+ const whereRaw = c.req.query("where");
3077
+ if (!whereRaw) {
3078
+ return { limit, page, sort };
3079
+ }
3080
+ try {
3081
+ return {
3082
+ limit,
3083
+ page,
3084
+ sort,
3085
+ where: JSON.parse(decodeURIComponent(whereRaw))
3086
+ };
3087
+ } catch {
3088
+ return { limit, page, sort };
3089
+ }
3090
+ }
3091
+ emptyResult(limit, page) {
3092
+ return {
3093
+ docs: [],
3094
+ total: 0,
3095
+ limit,
3096
+ page,
3097
+ totalPages: 0,
3098
+ hasNextPage: false,
3099
+ hasPrevPage: page > 1
3100
+ };
3101
+ }
3102
+ async collectAccessibleDocumentIds(db, collection, constraint) {
3103
+ const ids = [];
3104
+ const limit = 100;
3105
+ let page = 1;
3106
+ while (true) {
3107
+ const result = await db.find({
3108
+ collection: collection.slug,
3109
+ where: constraint,
3110
+ limit,
3111
+ page
3112
+ });
3113
+ for (const doc of result.docs) {
3114
+ if (typeof doc?.id === "string" && doc.id.length > 0) {
3115
+ ids.push(doc.id);
3116
+ }
3117
+ }
3118
+ if (!result.hasNextPage || page >= result.totalPages) {
3119
+ break;
3120
+ }
3121
+ page += 1;
3122
+ }
3123
+ return ids;
3124
+ }
3125
+ async buildCollectionAuditScope(c, collection) {
3126
+ const config = c.get("config");
3127
+ const auditAccess = collection.access?.readAudit ?? collection.access?.read;
3128
+ const access = await resolveCollectionAccess(
3129
+ config,
3130
+ collection.slug,
3131
+ "read",
3132
+ auditAccess,
3133
+ {
3134
+ user: c.get("user"),
3135
+ req: toHookRequestContext(c.req)
3136
+ }
3137
+ );
3138
+ if (!access.allowed) {
3139
+ return { denied: true, where: {} };
3140
+ }
3141
+ let where = { collection: { equals: collection.slug } };
3142
+ if (access.constraint) {
3143
+ const ids = await this.collectAccessibleDocumentIds(config.db, collection, access.constraint);
3144
+ where = mergeWhereConstraint(where, { documentId: { in: ids } });
3145
+ }
3146
+ return { denied: false, where };
3147
+ }
3148
+ async getVisibleCollections(c) {
3149
+ const config = c.get("config");
3150
+ const collections = /* @__PURE__ */ new Map();
3151
+ for (const collection of config.collections) {
3152
+ collections.set(collection.slug, collection);
3153
+ }
3154
+ const siteId = c.req.header("X-Site-Id") || c.get("siteId");
3155
+ if (config.onSchemaFetch && siteId) {
3156
+ const dynamic = await config.onSchemaFetch(siteId);
3157
+ for (const collection of dynamic.collections || []) {
3158
+ if (!collections.has(collection.slug)) {
3159
+ collections.set(collection.slug, collection);
3160
+ }
3161
+ }
3162
+ }
3163
+ return Array.from(collections.values());
3164
+ }
3165
+ async findForCollection(c, collection) {
3166
+ const config = c.get("config");
3167
+ if (!config.db) return c.json({ message: "Database not configured" }, 500);
3168
+ if (!collection.audit) return c.json({ message: "Audit is not enabled for this collection" }, 404);
3169
+ const query = this.parseQuery(c);
3170
+ const scope = await this.buildCollectionAuditScope(c, collection);
3171
+ if (scope.denied) {
3172
+ return c.json({ error: true, message: `Access denied: read on ${collection.slug}` }, 403);
3173
+ }
3174
+ const where = query.where ? mergeWhereConstraint(scope.where, query.where) : scope.where;
3175
+ const result = await config.db.find({
3176
+ collection: "__audit",
3177
+ where,
3178
+ limit: query.limit,
3179
+ page: query.page,
3180
+ sort: query.sort
3181
+ });
3182
+ return c.json(result);
3183
+ }
3184
+ async findAll(c) {
3185
+ const config = c.get("config");
3186
+ if (!config.db) return c.json({ message: "Database not configured" }, 500);
3187
+ const query = this.parseQuery(c);
3188
+ const collections = (await this.getVisibleCollections(c)).filter((collection) => collection.audit);
3189
+ const scopes = [];
3190
+ for (const collection of collections) {
3191
+ const scope = await this.buildCollectionAuditScope(c, collection);
3192
+ if (!scope.denied) {
3193
+ scopes.push(scope.where);
3194
+ }
3195
+ }
3196
+ if (scopes.length === 0) {
3197
+ return c.json(this.emptyResult(query.limit, query.page));
3198
+ }
3199
+ let where = scopes.length === 1 ? scopes[0] : { OR: scopes };
3200
+ if (query.where) {
3201
+ where = mergeWhereConstraint(where, query.where);
3202
+ }
3203
+ const result = await config.db.find({
3204
+ collection: "__audit",
3205
+ where,
3206
+ limit: query.limit,
3207
+ page: query.page,
3208
+ sort: query.sort
3209
+ });
3210
+ return c.json(result);
3211
+ }
3212
+ };
3213
+
3055
3214
  // src/middleware/auth.ts
3056
3215
  function getBearerToken(c) {
3057
3216
  const authHeader = c.req.header("Authorization");
@@ -3129,6 +3288,15 @@ function optionalAuth(config) {
3129
3288
  }
3130
3289
 
3131
3290
  // src/utils/openapi.ts
3291
+ function getCollectionLabels(collection) {
3292
+ return collection.labels || { singular: collection.slug, plural: collection.slug };
3293
+ }
3294
+ function getCollectionTag(collection) {
3295
+ return `Collection: ${getCollectionLabels(collection).plural}`;
3296
+ }
3297
+ function getGlobalTag(global) {
3298
+ return `Global: ${global.label || global.slug}`;
3299
+ }
3132
3300
  function generateOpenApi(config) {
3133
3301
  const spec = {
3134
3302
  openapi: "3.0.0",
@@ -3160,15 +3328,7 @@ function generateOpenApi(config) {
3160
3328
  },
3161
3329
  WorkflowHistoryEntry: {
3162
3330
  type: "object",
3163
- required: [
3164
- "collection",
3165
- "documentId",
3166
- "transition",
3167
- "from",
3168
- "to",
3169
- "revision",
3170
- "createdAt"
3171
- ],
3331
+ required: ["collection", "documentId", "transition", "from", "to", "revision", "createdAt"],
3172
3332
  properties: {
3173
3333
  id: { type: "string" },
3174
3334
  collection: { type: "string" },
@@ -3182,6 +3342,21 @@ function generateOpenApi(config) {
3182
3342
  createdAt: { type: "string", format: "date-time" }
3183
3343
  }
3184
3344
  },
3345
+ AuditEntry: {
3346
+ type: "object",
3347
+ required: ["collection", "operation", "timestamp"],
3348
+ properties: {
3349
+ id: { type: "string" },
3350
+ collection: { type: "string" },
3351
+ documentId: { type: "string", nullable: true },
3352
+ operation: { type: "string" },
3353
+ user: { type: "string", nullable: true },
3354
+ timestamp: { type: "string", format: "date-time" },
3355
+ changes: {
3356
+ oneOf: [{ type: "string" }, { type: "object", additionalProperties: true }, { type: "null" }]
3357
+ }
3358
+ }
3359
+ },
3185
3360
  Error: {
3186
3361
  type: "object",
3187
3362
  properties: {
@@ -3270,17 +3445,13 @@ function generateOpenApi(config) {
3270
3445
  get: {
3271
3446
  tags: ["Preferences"],
3272
3447
  summary: "Get an authenticated user preference",
3273
- parameters: [
3274
- { name: "key", in: "path", required: true, schema: { type: "string" } }
3275
- ],
3448
+ parameters: [{ name: "key", in: "path", required: true, schema: { type: "string" } }],
3276
3449
  responses: { 200: { description: "Preference value" } }
3277
3450
  },
3278
3451
  put: {
3279
3452
  tags: ["Preferences"],
3280
3453
  summary: "Set an authenticated user preference",
3281
- parameters: [
3282
- { name: "key", in: "path", required: true, schema: { type: "string" } }
3283
- ],
3454
+ parameters: [{ name: "key", in: "path", required: true, schema: { type: "string" } }],
3284
3455
  requestBody: {
3285
3456
  required: true,
3286
3457
  content: { "application/json": { schema: { type: "object" } } }
@@ -3311,6 +3482,39 @@ function generateOpenApi(config) {
3311
3482
  responses: { 200: { description: "Preview document data" } }
3312
3483
  }
3313
3484
  };
3485
+ spec.paths["/api/audit"] = {
3486
+ get: {
3487
+ tags: ["Audit"],
3488
+ summary: "Get audit entries across all readable audited collections",
3489
+ parameters: [
3490
+ { name: "limit", in: "query", schema: { type: "integer", default: 50, maximum: 100 } },
3491
+ { name: "page", in: "query", schema: { type: "integer", default: 1 } },
3492
+ { name: "where", in: "query", schema: { type: "string" }, description: "JSON filter" },
3493
+ { name: "sort", in: "query", schema: { type: "string", default: "-timestamp" } }
3494
+ ],
3495
+ responses: {
3496
+ 200: {
3497
+ description: "Audit entries",
3498
+ content: {
3499
+ "application/json": {
3500
+ schema: {
3501
+ type: "object",
3502
+ properties: {
3503
+ docs: {
3504
+ type: "array",
3505
+ items: { $ref: "#/components/schemas/AuditEntry" }
3506
+ },
3507
+ total: { type: "integer" },
3508
+ limit: { type: "integer" },
3509
+ page: { type: "integer" }
3510
+ }
3511
+ }
3512
+ }
3513
+ }
3514
+ }
3515
+ }
3516
+ }
3517
+ };
3314
3518
  for (const collection of config.collections) {
3315
3519
  spec.components.schemas[collection.slug] = collectionToSchema(collection);
3316
3520
  }
@@ -3320,10 +3524,11 @@ function generateOpenApi(config) {
3320
3524
  for (const collection of config.collections) {
3321
3525
  const slug = collection.slug;
3322
3526
  const path = `/api/collections/${slug}`;
3323
- const labels = collection.labels || { singular: slug, plural: `${slug}s` };
3527
+ const labels = getCollectionLabels(collection);
3528
+ const collectionTag = getCollectionTag(collection);
3324
3529
  spec.paths[path] = {
3325
3530
  get: {
3326
- tags: ["Collections"],
3531
+ tags: [collectionTag],
3327
3532
  summary: `Find ${labels.plural}`,
3328
3533
  parameters: [
3329
3534
  {
@@ -3372,7 +3577,7 @@ function generateOpenApi(config) {
3372
3577
  }
3373
3578
  },
3374
3579
  post: {
3375
- tags: ["Collections"],
3580
+ tags: [collectionTag],
3376
3581
  summary: `Create ${labels.singular}`,
3377
3582
  requestBody: {
3378
3583
  required: true,
@@ -3396,7 +3601,7 @@ function generateOpenApi(config) {
3396
3601
  };
3397
3602
  spec.paths[`${path}/{id}`] = {
3398
3603
  get: {
3399
- tags: ["Collections"],
3604
+ tags: [collectionTag],
3400
3605
  summary: `Get a single ${labels.singular}`,
3401
3606
  parameters: [
3402
3607
  {
@@ -3418,7 +3623,7 @@ function generateOpenApi(config) {
3418
3623
  }
3419
3624
  },
3420
3625
  patch: {
3421
- tags: ["Collections"],
3626
+ tags: [collectionTag],
3422
3627
  summary: `Update ${labels.singular}`,
3423
3628
  parameters: [
3424
3629
  {
@@ -3448,7 +3653,7 @@ function generateOpenApi(config) {
3448
3653
  }
3449
3654
  },
3450
3655
  delete: {
3451
- tags: ["Collections"],
3656
+ tags: [collectionTag],
3452
3657
  summary: `Delete ${labels.singular}`,
3453
3658
  parameters: [
3454
3659
  {
@@ -3465,7 +3670,7 @@ function generateOpenApi(config) {
3465
3670
  };
3466
3671
  spec.paths[`${path}/delete-many`] = {
3467
3672
  delete: {
3468
- tags: ["Collections"],
3673
+ tags: [collectionTag],
3469
3674
  summary: `Delete multiple ${labels.plural}`,
3470
3675
  requestBody: {
3471
3676
  required: true,
@@ -3484,9 +3689,46 @@ function generateOpenApi(config) {
3484
3689
  responses: { 200: { description: "Deleted and failed document IDs" } }
3485
3690
  }
3486
3691
  };
3692
+ if (collection.audit) {
3693
+ spec.paths[`${path}/__audit`] = {
3694
+ get: {
3695
+ tags: [collectionTag],
3696
+ summary: `Get ${labels.singular} audit entries`,
3697
+ parameters: [
3698
+ { name: "limit", in: "query", schema: { type: "integer", default: 50, maximum: 100 } },
3699
+ { name: "page", in: "query", schema: { type: "integer", default: 1 } },
3700
+ { name: "where", in: "query", schema: { type: "string" }, description: "JSON filter" },
3701
+ { name: "sort", in: "query", schema: { type: "string", default: "-timestamp" } }
3702
+ ],
3703
+ responses: {
3704
+ 200: {
3705
+ description: "Audit entries for this collection",
3706
+ content: {
3707
+ "application/json": {
3708
+ schema: {
3709
+ type: "object",
3710
+ properties: {
3711
+ docs: {
3712
+ type: "array",
3713
+ items: { $ref: "#/components/schemas/AuditEntry" }
3714
+ },
3715
+ total: { type: "integer" },
3716
+ limit: { type: "integer" },
3717
+ page: { type: "integer" }
3718
+ }
3719
+ }
3720
+ }
3721
+ }
3722
+ },
3723
+ 403: { description: "Collection read access denied" },
3724
+ 404: { description: "Audit is not enabled for this collection" }
3725
+ }
3726
+ }
3727
+ };
3728
+ }
3487
3729
  spec.paths[`${path}/seed`] = {
3488
3730
  post: {
3489
- tags: ["Collections"],
3731
+ tags: [collectionTag],
3490
3732
  summary: `Seed initial ${labels.plural}`,
3491
3733
  responses: { 200: { description: "Seeded documents" } }
3492
3734
  }
@@ -3494,12 +3736,12 @@ function generateOpenApi(config) {
3494
3736
  if (collection.upload) {
3495
3737
  spec.paths[`${path}/media`] = {
3496
3738
  get: {
3497
- tags: ["Media"],
3739
+ tags: [collectionTag],
3498
3740
  summary: `List ${labels.plural}`,
3499
3741
  responses: { 200: { description: "Paginated media documents" } }
3500
3742
  },
3501
3743
  post: {
3502
- tags: ["Media"],
3744
+ tags: [collectionTag],
3503
3745
  summary: `Upload ${labels.singular}`,
3504
3746
  requestBody: {
3505
3747
  required: true,
@@ -3518,7 +3760,7 @@ function generateOpenApi(config) {
3518
3760
  };
3519
3761
  spec.paths[`${path}/media/{filename}`] = {
3520
3762
  get: {
3521
- tags: ["Media"],
3763
+ tags: [collectionTag],
3522
3764
  summary: `Serve ${labels.singular} bytes`,
3523
3765
  parameters: [
3524
3766
  {
@@ -3538,7 +3780,7 @@ function generateOpenApi(config) {
3538
3780
  }
3539
3781
  if (collection.auth) {
3540
3782
  const publicAuthPost = (summary) => ({
3541
- tags: ["Authentication"],
3783
+ tags: [collectionTag],
3542
3784
  summary,
3543
3785
  security: [],
3544
3786
  requestBody: {
@@ -3580,14 +3822,14 @@ function generateOpenApi(config) {
3580
3822
  };
3581
3823
  spec.paths[`${path}/logout`] = {
3582
3824
  post: {
3583
- tags: ["Authentication"],
3825
+ tags: [collectionTag],
3584
3826
  summary: `Log out of ${labels.plural}`,
3585
3827
  responses: { 200: { description: "Logged out" } }
3586
3828
  }
3587
3829
  };
3588
3830
  spec.paths[`${path}/init`] = {
3589
3831
  get: {
3590
- tags: ["Authentication"],
3832
+ tags: [collectionTag],
3591
3833
  summary: `Get ${labels.plural} initialization state`,
3592
3834
  security: [],
3593
3835
  responses: { 200: { description: "Initialization state" } }
@@ -3598,14 +3840,14 @@ function generateOpenApi(config) {
3598
3840
  };
3599
3841
  spec.paths[`${path}/me`] = {
3600
3842
  get: {
3601
- tags: ["Authentication"],
3843
+ tags: [collectionTag],
3602
3844
  summary: `Get the current ${labels.singular}`,
3603
3845
  responses: { 200: { description: "Authenticated user" } }
3604
3846
  }
3605
3847
  };
3606
3848
  spec.paths[`${path}/refresh-token`] = {
3607
3849
  post: {
3608
- tags: ["Authentication"],
3850
+ tags: [collectionTag],
3609
3851
  summary: "Refresh an authentication token",
3610
3852
  responses: { 200: { description: "Refreshed token" } }
3611
3853
  }
@@ -3618,7 +3860,7 @@ function generateOpenApi(config) {
3618
3860
  };
3619
3861
  spec.paths[`${path}/invite`] = {
3620
3862
  post: {
3621
- tags: ["Authentication"],
3863
+ tags: [collectionTag],
3622
3864
  summary: `Invite a ${labels.singular}`,
3623
3865
  responses: { 200: { description: "Invitation sent" } }
3624
3866
  }
@@ -3628,7 +3870,7 @@ function generateOpenApi(config) {
3628
3870
  };
3629
3871
  spec.paths[`${path}/{id}/change-password`] = {
3630
3872
  post: {
3631
- tags: ["Authentication"],
3873
+ tags: [collectionTag],
3632
3874
  summary: `Change a ${labels.singular} password`,
3633
3875
  parameters: [
3634
3876
  {
@@ -3645,7 +3887,7 @@ function generateOpenApi(config) {
3645
3887
  if (collection.workflow) {
3646
3888
  spec.paths[`${path}/{id}/transitions/{transition}`] = {
3647
3889
  post: {
3648
- tags: ["Workflows"],
3890
+ tags: [collectionTag],
3649
3891
  summary: `Transition ${labels.singular} workflow`,
3650
3892
  parameters: [
3651
3893
  {
@@ -3690,7 +3932,7 @@ function generateOpenApi(config) {
3690
3932
  };
3691
3933
  spec.paths[`${path}/{id}/workflow-history`] = {
3692
3934
  get: {
3693
- tags: ["Workflows"],
3935
+ tags: [collectionTag],
3694
3936
  summary: `Get ${labels.singular} workflow history`,
3695
3937
  parameters: [
3696
3938
  {
@@ -3736,9 +3978,10 @@ function generateOpenApi(config) {
3736
3978
  for (const global of config.globals) {
3737
3979
  const slug = global.slug;
3738
3980
  const path = `/api/globals/${slug}`;
3981
+ const globalTag = getGlobalTag(global);
3739
3982
  spec.paths[path] = {
3740
3983
  get: {
3741
- tags: ["Globals"],
3984
+ tags: [globalTag],
3742
3985
  summary: `Get ${global.label || slug}`,
3743
3986
  responses: {
3744
3987
  200: {
@@ -3752,7 +3995,7 @@ function generateOpenApi(config) {
3752
3995
  }
3753
3996
  },
3754
3997
  patch: {
3755
- tags: ["Globals"],
3998
+ tags: [globalTag],
3756
3999
  summary: `Update ${global.label || slug}`,
3757
4000
  requestBody: {
3758
4001
  required: true,
@@ -3776,7 +4019,7 @@ function generateOpenApi(config) {
3776
4019
  };
3777
4020
  spec.paths[`${path}/seed`] = {
3778
4021
  post: {
3779
- tags: ["Globals"],
4022
+ tags: [globalTag],
3780
4023
  summary: `Seed ${global.label || slug}`,
3781
4024
  responses: { 200: { description: "Seeded global" } }
3782
4025
  }
@@ -3855,10 +4098,7 @@ function fieldToSchema(field) {
3855
4098
  break;
3856
4099
  case "url":
3857
4100
  schema = {
3858
- oneOf: [
3859
- { type: "string" },
3860
- { type: "object", additionalProperties: true }
3861
- ]
4101
+ oneOf: [{ type: "string" }, { type: "object", additionalProperties: true }]
3862
4102
  };
3863
4103
  break;
3864
4104
  case "icon":
@@ -4053,6 +4293,7 @@ function serializeFieldForApi(f) {
4053
4293
  return serialized;
4054
4294
  }
4055
4295
  function registerRoutes(app, config) {
4296
+ const optionsCache = /* @__PURE__ */ new Map();
4056
4297
  app.get("/api/schemas", optionalAuth(config), async (c) => {
4057
4298
  const siteId = c.req.header("X-Site-Id");
4058
4299
  let collections = [...config.collections];
@@ -4073,6 +4314,10 @@ function registerRoutes(app, config) {
4073
4314
  const serializeAccess = async (access) => {
4074
4315
  if (typeof access === "string") return access;
4075
4316
  if (typeof access === "boolean") return access;
4317
+ if (access && typeof access === "object" && typeof access.policy === "string") {
4318
+ const policy = config.accessPolicies?.[access.policy];
4319
+ if (typeof policy === "string" || typeof policy === "boolean") return policy;
4320
+ }
4076
4321
  return resolveBooleanAccess(config, access, accessArgs);
4077
4322
  };
4078
4323
  const filteredCollections = await Promise.all(collections.filter((col) => !siteId || col.shared || !col.siteId || col.siteId === siteId).map(async (col) => ({
@@ -4101,6 +4346,7 @@ function registerRoutes(app, config) {
4101
4346
  admin: f.admin,
4102
4347
  access: {
4103
4348
  read: await serializeAccess(f.access?.read),
4349
+ create: await serializeAccess(f.access?.create),
4104
4350
  update: await serializeAccess(f.access?.update)
4105
4351
  }
4106
4352
  }))),
@@ -4208,10 +4454,12 @@ function registerRoutes(app, config) {
4208
4454
  return c.json({ error: true, message: `Field ${fieldName} not found in ${colSlug}` }, 404);
4209
4455
  }
4210
4456
  let resolver;
4457
+ let cacheTTL;
4211
4458
  if (typeof field.options === "function") {
4212
4459
  resolver = field.options;
4213
4460
  } else if (field.options && typeof field.options === "object" && "resolve" in field.options) {
4214
4461
  resolver = field.options.resolve;
4462
+ cacheTTL = field.options.cacheTTL;
4215
4463
  }
4216
4464
  if (!resolver) {
4217
4465
  return c.json({ error: true, message: `Field ${fieldName} in ${colSlug} is not dynamic` }, 400);
@@ -4224,11 +4472,32 @@ function registerRoutes(app, config) {
4224
4472
  headers: c.req.header(),
4225
4473
  raw: c.req.raw
4226
4474
  };
4475
+ const shouldCache = typeof cacheTTL === "number" && cacheTTL > 0;
4476
+ let cacheKey = "";
4477
+ if (shouldCache) {
4478
+ cacheKey = JSON.stringify([
4479
+ siteId ?? "",
4480
+ colSlug,
4481
+ fieldName,
4482
+ user?.id ?? null,
4483
+ queryParams
4484
+ ]);
4485
+ const hit = optionsCache.get(cacheKey);
4486
+ if (hit && hit.expires > Date.now()) {
4487
+ return c.json(hit.value);
4488
+ }
4489
+ }
4227
4490
  const result = await resolver({
4228
4491
  db,
4229
4492
  user,
4230
4493
  req: reqContext
4231
4494
  });
4495
+ if (shouldCache) {
4496
+ optionsCache.set(cacheKey, {
4497
+ expires: Date.now() + cacheTTL * 1e3,
4498
+ value: result
4499
+ });
4500
+ }
4232
4501
  return c.json(result);
4233
4502
  } catch (err) {
4234
4503
  console.error(`[dyrected/core] Failed to resolve dynamic options for field ${fieldName}:`, err);
@@ -4373,6 +4642,7 @@ function registerRoutes(app, config) {
4373
4642
  app.post(`${path}/invite`, requireAuth(config), (c) => authController.invite(c));
4374
4643
  app.post(`${path}/accept-invite`, (c) => authController.acceptInvite(c));
4375
4644
  }
4645
+ const auditController = new AuditController();
4376
4646
  for (const collection of config.collections) {
4377
4647
  const path = `/api/collections/${collection.slug}`;
4378
4648
  const controller = new CollectionController(collection);
@@ -4380,6 +4650,9 @@ function registerRoutes(app, config) {
4380
4650
  app.post(path, (c) => controller.create(c));
4381
4651
  app.post(`${path}/media`, (c) => controller.create(c));
4382
4652
  app.delete(`${path}/delete-many`, (c) => controller.deleteMany(c));
4653
+ if (collection.audit) {
4654
+ app.get(`${path}/__audit`, (c) => auditController.findForCollection(c, collection));
4655
+ }
4383
4656
  app.get(`${path}/:id`, (c) => controller.findOne(c));
4384
4657
  app.patch(`${path}/:id`, (c) => controller.update(c));
4385
4658
  app.delete(`${path}/:id`, (c) => controller.delete(c));
@@ -4402,6 +4675,24 @@ function registerRoutes(app, config) {
4402
4675
  const previewController = new PreviewController();
4403
4676
  app.post("/api/preview-token", requireAuth(config), (c) => previewController.createToken(c));
4404
4677
  app.get("/api/preview-data", (c) => previewController.getData(c));
4678
+ app.get("/api/audit", (c) => auditController.findAll(c));
4679
+ app.get("/api/collections/:slug/__audit", async (c) => {
4680
+ const slug = c.req.param("slug");
4681
+ const siteId = c.req.header("X-Site-Id") || c.get("siteId");
4682
+ const config2 = c.get("config");
4683
+ if (config2.collections.some((col) => col.slug === slug)) {
4684
+ return c.json({ message: "Not Found" }, 404);
4685
+ }
4686
+ if (!config2.onSchemaFetch || !siteId) {
4687
+ return c.json({ message: `Collection "${slug}" not found` }, 404);
4688
+ }
4689
+ const dynamic = await config2.onSchemaFetch(siteId);
4690
+ const collection = dynamic.collections?.find((col) => col.slug === slug);
4691
+ if (!collection?.audit) {
4692
+ return c.json({ message: `Collection "${slug}" not found or has no audit log` }, 404);
4693
+ }
4694
+ return auditController.findForCollection(c, collection);
4695
+ });
4405
4696
  app.post("/api/collections/:slug/:id/transitions/:transition", requireAuth(config), async (c) => {
4406
4697
  const slug = c.req.param("slug");
4407
4698
  const siteId = c.req.header("X-Site-Id") || c.get("siteId");
@@ -4550,6 +4841,7 @@ var AUDIT_COLLECTION = {
4550
4841
  { name: "timestamp", type: "date", label: "Timestamp", required: true },
4551
4842
  { name: "changes", type: "json", label: "Changes" }
4552
4843
  ],
4844
+ access: { read: () => false, create: () => false, update: () => false, delete: () => false },
4553
4845
  admin: { hidden: true }
4554
4846
  };
4555
4847
  var WORKFLOW_HISTORY_COLLECTION_CONFIG = {
@@ -4654,6 +4946,12 @@ function normalizeConfig(config) {
4654
4946
  if (field.name === "email") {
4655
4947
  return {
4656
4948
  ...field,
4949
+ // Email is the login identifier. Keep the integrity constraints
4950
+ // auth relies on even when the field is explicitly redefined, so a
4951
+ // custom `email` field can never silently drop uniqueness.
4952
+ required: true,
4953
+ unique: true,
4954
+ promoted: true,
4657
4955
  access: {
4658
4956
  ...field.access || {},
4659
4957
  update: "!id"
@@ -4663,6 +4961,9 @@ function normalizeConfig(config) {
4663
4961
  if (field.name === "password") {
4664
4962
  return {
4665
4963
  ...field,
4964
+ // Password is required to authenticate; enforce it regardless of
4965
+ // how the field was declared.
4966
+ required: true,
4666
4967
  admin: { ...field.admin || {} },
4667
4968
  access: {
4668
4969
  ...field.access || {},