@dyrected/core 2.5.59 → 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);
@@ -775,14 +779,15 @@ async function applyFieldWriteAccess(context, data) {
775
779
  continue;
776
780
  }
777
781
  if (!field.name || !(field.name in result)) continue;
778
- 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, {
779
784
  user: context.user,
780
785
  req: context.req,
781
786
  doc: context.doc,
782
787
  data: context.data,
783
788
  id: resolveFieldAccessId(context)
784
789
  });
785
- if (!canUpdate) {
790
+ if (!canWrite) {
786
791
  delete result[field.name];
787
792
  continue;
788
793
  }
@@ -1119,7 +1124,7 @@ var CollectionController = class {
1119
1124
  const readonlyDb = createReadonlyDb(db);
1120
1125
  const limit = Number(c.req.query("limit")) || 10;
1121
1126
  const page = Number(c.req.query("page")) || 1;
1122
- 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;
1123
1128
  const sort = c.req.query("sort") || void 0;
1124
1129
  const user = c.get("user");
1125
1130
  let where = void 0;
@@ -1235,7 +1240,7 @@ var CollectionController = class {
1235
1240
  }
1236
1241
  const readonlyDb = createReadonlyDb(db);
1237
1242
  const id = c.req.param("id");
1238
- 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;
1239
1244
  const user = c.get("user");
1240
1245
  if (!id) return c.json({ message: "Missing ID" }, 400);
1241
1246
  let doc = await db.findOne({ collection: this.collection.slug, id });
@@ -1324,7 +1329,8 @@ var CollectionController = class {
1324
1329
  fields: this.collection.fields,
1325
1330
  user,
1326
1331
  req: this.toHookRequestContext(c),
1327
- data
1332
+ data,
1333
+ operation: "create"
1328
1334
  }, data);
1329
1335
  data = await executeFieldBeforeChange(this.collection.fields, data, null, user, readonlyDb);
1330
1336
  data = await runCollectionHooks(this.collection.hooks?.beforeChange, {
@@ -1419,7 +1425,8 @@ var CollectionController = class {
1419
1425
  fields: this.collection.fields,
1420
1426
  user,
1421
1427
  req: this.toHookRequestContext(c),
1422
- data
1428
+ data,
1429
+ operation: "create"
1423
1430
  }, data);
1424
1431
  data = await executeFieldBeforeChange(this.collection.fields, data, null, user, readonlyDb);
1425
1432
  data = await runCollectionHooks(this.collection.hooks?.beforeChange, {
@@ -1841,7 +1848,7 @@ var GlobalController = class {
1841
1848
  const db = config.db;
1842
1849
  if (!db) return c.json({ message: "Database not configured" }, 500);
1843
1850
  const readonlyDb = createReadonlyDb(db);
1844
- 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;
1845
1852
  const user = c.get("user");
1846
1853
  let query = void 0;
1847
1854
  const beforeReadResult = await runCollectionHooks(this.global.hooks?.beforeRead, {
@@ -3060,6 +3067,150 @@ var PreviewController = class {
3060
3067
  }
3061
3068
  };
3062
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
+
3063
3214
  // src/middleware/auth.ts
3064
3215
  function getBearerToken(c) {
3065
3216
  const authHeader = c.req.header("Authorization");
@@ -3137,6 +3288,15 @@ function optionalAuth(config) {
3137
3288
  }
3138
3289
 
3139
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
+ }
3140
3300
  function generateOpenApi(config) {
3141
3301
  const spec = {
3142
3302
  openapi: "3.0.0",
@@ -3168,15 +3328,7 @@ function generateOpenApi(config) {
3168
3328
  },
3169
3329
  WorkflowHistoryEntry: {
3170
3330
  type: "object",
3171
- required: [
3172
- "collection",
3173
- "documentId",
3174
- "transition",
3175
- "from",
3176
- "to",
3177
- "revision",
3178
- "createdAt"
3179
- ],
3331
+ required: ["collection", "documentId", "transition", "from", "to", "revision", "createdAt"],
3180
3332
  properties: {
3181
3333
  id: { type: "string" },
3182
3334
  collection: { type: "string" },
@@ -3190,6 +3342,21 @@ function generateOpenApi(config) {
3190
3342
  createdAt: { type: "string", format: "date-time" }
3191
3343
  }
3192
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
+ },
3193
3360
  Error: {
3194
3361
  type: "object",
3195
3362
  properties: {
@@ -3278,17 +3445,13 @@ function generateOpenApi(config) {
3278
3445
  get: {
3279
3446
  tags: ["Preferences"],
3280
3447
  summary: "Get an authenticated user preference",
3281
- parameters: [
3282
- { name: "key", in: "path", required: true, schema: { type: "string" } }
3283
- ],
3448
+ parameters: [{ name: "key", in: "path", required: true, schema: { type: "string" } }],
3284
3449
  responses: { 200: { description: "Preference value" } }
3285
3450
  },
3286
3451
  put: {
3287
3452
  tags: ["Preferences"],
3288
3453
  summary: "Set an authenticated user preference",
3289
- parameters: [
3290
- { name: "key", in: "path", required: true, schema: { type: "string" } }
3291
- ],
3454
+ parameters: [{ name: "key", in: "path", required: true, schema: { type: "string" } }],
3292
3455
  requestBody: {
3293
3456
  required: true,
3294
3457
  content: { "application/json": { schema: { type: "object" } } }
@@ -3319,6 +3482,39 @@ function generateOpenApi(config) {
3319
3482
  responses: { 200: { description: "Preview document data" } }
3320
3483
  }
3321
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
+ };
3322
3518
  for (const collection of config.collections) {
3323
3519
  spec.components.schemas[collection.slug] = collectionToSchema(collection);
3324
3520
  }
@@ -3328,10 +3524,11 @@ function generateOpenApi(config) {
3328
3524
  for (const collection of config.collections) {
3329
3525
  const slug = collection.slug;
3330
3526
  const path = `/api/collections/${slug}`;
3331
- const labels = collection.labels || { singular: slug, plural: `${slug}s` };
3527
+ const labels = getCollectionLabels(collection);
3528
+ const collectionTag = getCollectionTag(collection);
3332
3529
  spec.paths[path] = {
3333
3530
  get: {
3334
- tags: ["Collections"],
3531
+ tags: [collectionTag],
3335
3532
  summary: `Find ${labels.plural}`,
3336
3533
  parameters: [
3337
3534
  {
@@ -3380,7 +3577,7 @@ function generateOpenApi(config) {
3380
3577
  }
3381
3578
  },
3382
3579
  post: {
3383
- tags: ["Collections"],
3580
+ tags: [collectionTag],
3384
3581
  summary: `Create ${labels.singular}`,
3385
3582
  requestBody: {
3386
3583
  required: true,
@@ -3404,7 +3601,7 @@ function generateOpenApi(config) {
3404
3601
  };
3405
3602
  spec.paths[`${path}/{id}`] = {
3406
3603
  get: {
3407
- tags: ["Collections"],
3604
+ tags: [collectionTag],
3408
3605
  summary: `Get a single ${labels.singular}`,
3409
3606
  parameters: [
3410
3607
  {
@@ -3426,7 +3623,7 @@ function generateOpenApi(config) {
3426
3623
  }
3427
3624
  },
3428
3625
  patch: {
3429
- tags: ["Collections"],
3626
+ tags: [collectionTag],
3430
3627
  summary: `Update ${labels.singular}`,
3431
3628
  parameters: [
3432
3629
  {
@@ -3456,7 +3653,7 @@ function generateOpenApi(config) {
3456
3653
  }
3457
3654
  },
3458
3655
  delete: {
3459
- tags: ["Collections"],
3656
+ tags: [collectionTag],
3460
3657
  summary: `Delete ${labels.singular}`,
3461
3658
  parameters: [
3462
3659
  {
@@ -3473,7 +3670,7 @@ function generateOpenApi(config) {
3473
3670
  };
3474
3671
  spec.paths[`${path}/delete-many`] = {
3475
3672
  delete: {
3476
- tags: ["Collections"],
3673
+ tags: [collectionTag],
3477
3674
  summary: `Delete multiple ${labels.plural}`,
3478
3675
  requestBody: {
3479
3676
  required: true,
@@ -3492,9 +3689,46 @@ function generateOpenApi(config) {
3492
3689
  responses: { 200: { description: "Deleted and failed document IDs" } }
3493
3690
  }
3494
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
+ }
3495
3729
  spec.paths[`${path}/seed`] = {
3496
3730
  post: {
3497
- tags: ["Collections"],
3731
+ tags: [collectionTag],
3498
3732
  summary: `Seed initial ${labels.plural}`,
3499
3733
  responses: { 200: { description: "Seeded documents" } }
3500
3734
  }
@@ -3502,12 +3736,12 @@ function generateOpenApi(config) {
3502
3736
  if (collection.upload) {
3503
3737
  spec.paths[`${path}/media`] = {
3504
3738
  get: {
3505
- tags: ["Media"],
3739
+ tags: [collectionTag],
3506
3740
  summary: `List ${labels.plural}`,
3507
3741
  responses: { 200: { description: "Paginated media documents" } }
3508
3742
  },
3509
3743
  post: {
3510
- tags: ["Media"],
3744
+ tags: [collectionTag],
3511
3745
  summary: `Upload ${labels.singular}`,
3512
3746
  requestBody: {
3513
3747
  required: true,
@@ -3526,7 +3760,7 @@ function generateOpenApi(config) {
3526
3760
  };
3527
3761
  spec.paths[`${path}/media/{filename}`] = {
3528
3762
  get: {
3529
- tags: ["Media"],
3763
+ tags: [collectionTag],
3530
3764
  summary: `Serve ${labels.singular} bytes`,
3531
3765
  parameters: [
3532
3766
  {
@@ -3546,7 +3780,7 @@ function generateOpenApi(config) {
3546
3780
  }
3547
3781
  if (collection.auth) {
3548
3782
  const publicAuthPost = (summary) => ({
3549
- tags: ["Authentication"],
3783
+ tags: [collectionTag],
3550
3784
  summary,
3551
3785
  security: [],
3552
3786
  requestBody: {
@@ -3588,14 +3822,14 @@ function generateOpenApi(config) {
3588
3822
  };
3589
3823
  spec.paths[`${path}/logout`] = {
3590
3824
  post: {
3591
- tags: ["Authentication"],
3825
+ tags: [collectionTag],
3592
3826
  summary: `Log out of ${labels.plural}`,
3593
3827
  responses: { 200: { description: "Logged out" } }
3594
3828
  }
3595
3829
  };
3596
3830
  spec.paths[`${path}/init`] = {
3597
3831
  get: {
3598
- tags: ["Authentication"],
3832
+ tags: [collectionTag],
3599
3833
  summary: `Get ${labels.plural} initialization state`,
3600
3834
  security: [],
3601
3835
  responses: { 200: { description: "Initialization state" } }
@@ -3606,14 +3840,14 @@ function generateOpenApi(config) {
3606
3840
  };
3607
3841
  spec.paths[`${path}/me`] = {
3608
3842
  get: {
3609
- tags: ["Authentication"],
3843
+ tags: [collectionTag],
3610
3844
  summary: `Get the current ${labels.singular}`,
3611
3845
  responses: { 200: { description: "Authenticated user" } }
3612
3846
  }
3613
3847
  };
3614
3848
  spec.paths[`${path}/refresh-token`] = {
3615
3849
  post: {
3616
- tags: ["Authentication"],
3850
+ tags: [collectionTag],
3617
3851
  summary: "Refresh an authentication token",
3618
3852
  responses: { 200: { description: "Refreshed token" } }
3619
3853
  }
@@ -3626,7 +3860,7 @@ function generateOpenApi(config) {
3626
3860
  };
3627
3861
  spec.paths[`${path}/invite`] = {
3628
3862
  post: {
3629
- tags: ["Authentication"],
3863
+ tags: [collectionTag],
3630
3864
  summary: `Invite a ${labels.singular}`,
3631
3865
  responses: { 200: { description: "Invitation sent" } }
3632
3866
  }
@@ -3636,7 +3870,7 @@ function generateOpenApi(config) {
3636
3870
  };
3637
3871
  spec.paths[`${path}/{id}/change-password`] = {
3638
3872
  post: {
3639
- tags: ["Authentication"],
3873
+ tags: [collectionTag],
3640
3874
  summary: `Change a ${labels.singular} password`,
3641
3875
  parameters: [
3642
3876
  {
@@ -3653,7 +3887,7 @@ function generateOpenApi(config) {
3653
3887
  if (collection.workflow) {
3654
3888
  spec.paths[`${path}/{id}/transitions/{transition}`] = {
3655
3889
  post: {
3656
- tags: ["Workflows"],
3890
+ tags: [collectionTag],
3657
3891
  summary: `Transition ${labels.singular} workflow`,
3658
3892
  parameters: [
3659
3893
  {
@@ -3698,7 +3932,7 @@ function generateOpenApi(config) {
3698
3932
  };
3699
3933
  spec.paths[`${path}/{id}/workflow-history`] = {
3700
3934
  get: {
3701
- tags: ["Workflows"],
3935
+ tags: [collectionTag],
3702
3936
  summary: `Get ${labels.singular} workflow history`,
3703
3937
  parameters: [
3704
3938
  {
@@ -3744,9 +3978,10 @@ function generateOpenApi(config) {
3744
3978
  for (const global of config.globals) {
3745
3979
  const slug = global.slug;
3746
3980
  const path = `/api/globals/${slug}`;
3981
+ const globalTag = getGlobalTag(global);
3747
3982
  spec.paths[path] = {
3748
3983
  get: {
3749
- tags: ["Globals"],
3984
+ tags: [globalTag],
3750
3985
  summary: `Get ${global.label || slug}`,
3751
3986
  responses: {
3752
3987
  200: {
@@ -3760,7 +3995,7 @@ function generateOpenApi(config) {
3760
3995
  }
3761
3996
  },
3762
3997
  patch: {
3763
- tags: ["Globals"],
3998
+ tags: [globalTag],
3764
3999
  summary: `Update ${global.label || slug}`,
3765
4000
  requestBody: {
3766
4001
  required: true,
@@ -3784,7 +4019,7 @@ function generateOpenApi(config) {
3784
4019
  };
3785
4020
  spec.paths[`${path}/seed`] = {
3786
4021
  post: {
3787
- tags: ["Globals"],
4022
+ tags: [globalTag],
3788
4023
  summary: `Seed ${global.label || slug}`,
3789
4024
  responses: { 200: { description: "Seeded global" } }
3790
4025
  }
@@ -3863,10 +4098,7 @@ function fieldToSchema(field) {
3863
4098
  break;
3864
4099
  case "url":
3865
4100
  schema = {
3866
- oneOf: [
3867
- { type: "string" },
3868
- { type: "object", additionalProperties: true }
3869
- ]
4101
+ oneOf: [{ type: "string" }, { type: "object", additionalProperties: true }]
3870
4102
  };
3871
4103
  break;
3872
4104
  case "icon":
@@ -4061,6 +4293,7 @@ function serializeFieldForApi(f) {
4061
4293
  return serialized;
4062
4294
  }
4063
4295
  function registerRoutes(app, config) {
4296
+ const optionsCache = /* @__PURE__ */ new Map();
4064
4297
  app.get("/api/schemas", optionalAuth(config), async (c) => {
4065
4298
  const siteId = c.req.header("X-Site-Id");
4066
4299
  let collections = [...config.collections];
@@ -4081,6 +4314,10 @@ function registerRoutes(app, config) {
4081
4314
  const serializeAccess = async (access) => {
4082
4315
  if (typeof access === "string") return access;
4083
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
+ }
4084
4321
  return resolveBooleanAccess(config, access, accessArgs);
4085
4322
  };
4086
4323
  const filteredCollections = await Promise.all(collections.filter((col) => !siteId || col.shared || !col.siteId || col.siteId === siteId).map(async (col) => ({
@@ -4109,6 +4346,7 @@ function registerRoutes(app, config) {
4109
4346
  admin: f.admin,
4110
4347
  access: {
4111
4348
  read: await serializeAccess(f.access?.read),
4349
+ create: await serializeAccess(f.access?.create),
4112
4350
  update: await serializeAccess(f.access?.update)
4113
4351
  }
4114
4352
  }))),
@@ -4216,10 +4454,12 @@ function registerRoutes(app, config) {
4216
4454
  return c.json({ error: true, message: `Field ${fieldName} not found in ${colSlug}` }, 404);
4217
4455
  }
4218
4456
  let resolver;
4457
+ let cacheTTL;
4219
4458
  if (typeof field.options === "function") {
4220
4459
  resolver = field.options;
4221
4460
  } else if (field.options && typeof field.options === "object" && "resolve" in field.options) {
4222
4461
  resolver = field.options.resolve;
4462
+ cacheTTL = field.options.cacheTTL;
4223
4463
  }
4224
4464
  if (!resolver) {
4225
4465
  return c.json({ error: true, message: `Field ${fieldName} in ${colSlug} is not dynamic` }, 400);
@@ -4232,11 +4472,32 @@ function registerRoutes(app, config) {
4232
4472
  headers: c.req.header(),
4233
4473
  raw: c.req.raw
4234
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
+ }
4235
4490
  const result = await resolver({
4236
4491
  db,
4237
4492
  user,
4238
4493
  req: reqContext
4239
4494
  });
4495
+ if (shouldCache) {
4496
+ optionsCache.set(cacheKey, {
4497
+ expires: Date.now() + cacheTTL * 1e3,
4498
+ value: result
4499
+ });
4500
+ }
4240
4501
  return c.json(result);
4241
4502
  } catch (err) {
4242
4503
  console.error(`[dyrected/core] Failed to resolve dynamic options for field ${fieldName}:`, err);
@@ -4381,6 +4642,7 @@ function registerRoutes(app, config) {
4381
4642
  app.post(`${path}/invite`, requireAuth(config), (c) => authController.invite(c));
4382
4643
  app.post(`${path}/accept-invite`, (c) => authController.acceptInvite(c));
4383
4644
  }
4645
+ const auditController = new AuditController();
4384
4646
  for (const collection of config.collections) {
4385
4647
  const path = `/api/collections/${collection.slug}`;
4386
4648
  const controller = new CollectionController(collection);
@@ -4388,6 +4650,9 @@ function registerRoutes(app, config) {
4388
4650
  app.post(path, (c) => controller.create(c));
4389
4651
  app.post(`${path}/media`, (c) => controller.create(c));
4390
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
+ }
4391
4656
  app.get(`${path}/:id`, (c) => controller.findOne(c));
4392
4657
  app.patch(`${path}/:id`, (c) => controller.update(c));
4393
4658
  app.delete(`${path}/:id`, (c) => controller.delete(c));
@@ -4410,6 +4675,24 @@ function registerRoutes(app, config) {
4410
4675
  const previewController = new PreviewController();
4411
4676
  app.post("/api/preview-token", requireAuth(config), (c) => previewController.createToken(c));
4412
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
+ });
4413
4696
  app.post("/api/collections/:slug/:id/transitions/:transition", requireAuth(config), async (c) => {
4414
4697
  const slug = c.req.param("slug");
4415
4698
  const siteId = c.req.header("X-Site-Id") || c.get("siteId");
@@ -4558,6 +4841,7 @@ var AUDIT_COLLECTION = {
4558
4841
  { name: "timestamp", type: "date", label: "Timestamp", required: true },
4559
4842
  { name: "changes", type: "json", label: "Changes" }
4560
4843
  ],
4844
+ access: { read: () => false, create: () => false, update: () => false, delete: () => false },
4561
4845
  admin: { hidden: true }
4562
4846
  };
4563
4847
  var WORKFLOW_HISTORY_COLLECTION_CONFIG = {
@@ -4662,6 +4946,12 @@ function normalizeConfig(config) {
4662
4946
  if (field.name === "email") {
4663
4947
  return {
4664
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,
4665
4955
  access: {
4666
4956
  ...field.access || {},
4667
4957
  update: "!id"
@@ -4671,6 +4961,9 @@ function normalizeConfig(config) {
4671
4961
  if (field.name === "password") {
4672
4962
  return {
4673
4963
  ...field,
4964
+ // Password is required to authenticate; enforce it regardless of
4965
+ // how the field was declared.
4966
+ required: true,
4674
4967
  admin: { ...field.admin || {} },
4675
4968
  access: {
4676
4969
  ...field.access || {},