@dyrected/core 2.5.59 → 2.5.61

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.
Files changed (39) hide show
  1. package/dist/app-config-9hs8DRED.d.cts +1718 -0
  2. package/dist/app-config-9hs8DRED.d.ts +1718 -0
  3. package/dist/app-config-C0twGIaF.d.cts +1597 -0
  4. package/dist/app-config-C0twGIaF.d.ts +1597 -0
  5. package/dist/app-config-C0uxEU7Q.d.cts +1874 -0
  6. package/dist/app-config-C0uxEU7Q.d.ts +1874 -0
  7. package/dist/app-config-CBOn8IyZ.d.cts +1838 -0
  8. package/dist/app-config-CBOn8IyZ.d.ts +1838 -0
  9. package/dist/app-config-CstocO9M.d.cts +1580 -0
  10. package/dist/app-config-CstocO9M.d.ts +1580 -0
  11. package/dist/app-config-CwaU1de2.d.cts +1868 -0
  12. package/dist/app-config-CwaU1de2.d.ts +1868 -0
  13. package/dist/app-config-DIwvzbpe.d.cts +1555 -0
  14. package/dist/app-config-DIwvzbpe.d.ts +1555 -0
  15. package/dist/app-config-DVdSospO.d.cts +1842 -0
  16. package/dist/app-config-DVdSospO.d.ts +1842 -0
  17. package/dist/app-config-Dkndq5ni.d.cts +1580 -0
  18. package/dist/app-config-Dkndq5ni.d.ts +1580 -0
  19. package/dist/app-config-P-SF5nnR.d.cts +1568 -0
  20. package/dist/app-config-P-SF5nnR.d.ts +1568 -0
  21. package/dist/app-config-ouBRb6Bf.d.cts +1716 -0
  22. package/dist/app-config-ouBRb6Bf.d.ts +1716 -0
  23. package/dist/chunk-2WODKOUB.js +1627 -0
  24. package/dist/chunk-35NM2WPO.js +1658 -0
  25. package/dist/chunk-3CMG2BH2.js +1618 -0
  26. package/dist/chunk-CUOPCOU2.js +1644 -0
  27. package/dist/chunk-DCY7QHRB.js +1637 -0
  28. package/dist/chunk-EOW4YMAV.js +1637 -0
  29. package/dist/chunk-JFA22YFB.js +1625 -0
  30. package/dist/chunk-WF5A6PDJ.js +1618 -0
  31. package/dist/index.cjs +209 -47
  32. package/dist/index.d.cts +79 -4
  33. package/dist/index.d.ts +79 -4
  34. package/dist/index.js +42 -4
  35. package/dist/server.cjs +388 -56
  36. package/dist/server.d.cts +1 -1
  37. package/dist/server.d.ts +1 -1
  38. package/dist/server.js +224 -13
  39. package/package.json +1 -1
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
  }
@@ -822,6 +827,20 @@ function mergeWhereConstraint(where, constraint) {
822
827
  // src/workflows.ts
823
828
  var WORKFLOW_HISTORY_COLLECTION = "__workflow_history";
824
829
  var LIFECYCLE_EVENTS_COLLECTION = "__lifecycle_events";
830
+ function simplePublishingWorkflow() {
831
+ return {
832
+ initialState: "draft",
833
+ draftState: "draft",
834
+ states: [
835
+ { name: "draft", label: "Draft", color: "neutral" },
836
+ { name: "published", label: "Published", color: "success", published: true }
837
+ ],
838
+ transitions: [
839
+ { name: "publish", label: "Publish", from: "draft", to: "published" },
840
+ { name: "unpublish", label: "Unpublish", from: "published", to: "draft", unpublish: true }
841
+ ]
842
+ };
843
+ }
825
844
  function publicMetadata(meta) {
826
845
  const { availableTransitions: _availableTransitions, ...safe } = meta;
827
846
  return safe;
@@ -855,9 +874,23 @@ function initializeWorkflowDocument(data, workflow) {
855
874
  __workflow: { state: workflow.initialState, revision: 1 }
856
875
  };
857
876
  }
877
+ function publishedStateName(workflow) {
878
+ return workflow.states.find((state) => state.published)?.name ?? workflow.states[workflow.states.length - 1]?.name ?? workflow.initialState;
879
+ }
858
880
  function materializeWorkflowDocument(doc, workflow, user) {
859
881
  const meta = doc.__workflow;
860
- if (!meta) return doc;
882
+ if (!meta) {
883
+ const { __published: _legacyPublished, __workflow: _legacyWorkflow, ...working2 } = doc;
884
+ const state = publishedStateName(workflow);
885
+ return {
886
+ ...working2,
887
+ _workflow: {
888
+ state,
889
+ revision: 1,
890
+ availableTransitions: availableWorkflowTransitions(workflow, state, user).map((item) => item.name)
891
+ }
892
+ };
893
+ }
861
894
  const { __published, __workflow, ...working } = doc;
862
895
  if (!canViewWorkflowDraft(workflow, user)) {
863
896
  if (!__published || typeof __published !== "object") return null;
@@ -1117,9 +1150,9 @@ var CollectionController = class {
1117
1150
  });
1118
1151
  }
1119
1152
  const readonlyDb = createReadonlyDb(db);
1120
- const limit = Number(c.req.query("limit")) || 10;
1153
+ const limit = Math.min(Number(c.req.query("limit")) || 10, 100);
1121
1154
  const page = Number(c.req.query("page")) || 1;
1122
- const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 2;
1155
+ const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 1;
1123
1156
  const sort = c.req.query("sort") || void 0;
1124
1157
  const user = c.get("user");
1125
1158
  let where = void 0;
@@ -1165,7 +1198,8 @@ var CollectionController = class {
1165
1198
  limit,
1166
1199
  page,
1167
1200
  sort,
1168
- where
1201
+ where,
1202
+ fields: this.collection.fields
1169
1203
  });
1170
1204
  if (result.total === 0 && this.collection.initialData && !where && page === 1) {
1171
1205
  console.log(`[dyrected/core] Auto-seeding collection "${this.collection.slug}" from config.initialData`);
@@ -1177,7 +1211,8 @@ var CollectionController = class {
1177
1211
  limit,
1178
1212
  page,
1179
1213
  sort,
1180
- where
1214
+ where,
1215
+ fields: this.collection.fields
1181
1216
  });
1182
1217
  }
1183
1218
  result.docs = result.docs.map((doc) => this.collection.workflow ? materializeWorkflowDocument(doc, this.collection.workflow, user) : doc).filter((doc) => doc !== null);
@@ -1235,7 +1270,7 @@ var CollectionController = class {
1235
1270
  }
1236
1271
  const readonlyDb = createReadonlyDb(db);
1237
1272
  const id = c.req.param("id");
1238
- const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 10;
1273
+ const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 1;
1239
1274
  const user = c.get("user");
1240
1275
  if (!id) return c.json({ message: "Missing ID" }, 400);
1241
1276
  let doc = await db.findOne({ collection: this.collection.slug, id });
@@ -1324,7 +1359,8 @@ var CollectionController = class {
1324
1359
  fields: this.collection.fields,
1325
1360
  user,
1326
1361
  req: this.toHookRequestContext(c),
1327
- data
1362
+ data,
1363
+ operation: "create"
1328
1364
  }, data);
1329
1365
  data = await executeFieldBeforeChange(this.collection.fields, data, null, user, readonlyDb);
1330
1366
  data = await runCollectionHooks(this.collection.hooks?.beforeChange, {
@@ -1419,7 +1455,8 @@ var CollectionController = class {
1419
1455
  fields: this.collection.fields,
1420
1456
  user,
1421
1457
  req: this.toHookRequestContext(c),
1422
- data
1458
+ data,
1459
+ operation: "create"
1423
1460
  }, data);
1424
1461
  data = await executeFieldBeforeChange(this.collection.fields, data, null, user, readonlyDb);
1425
1462
  data = await runCollectionHooks(this.collection.hooks?.beforeChange, {
@@ -1841,7 +1878,7 @@ var GlobalController = class {
1841
1878
  const db = config.db;
1842
1879
  if (!db) return c.json({ message: "Database not configured" }, 500);
1843
1880
  const readonlyDb = createReadonlyDb(db);
1844
- const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 10;
1881
+ const depth = c.req.query("depth") !== void 0 ? Number(c.req.query("depth")) : 1;
1845
1882
  const user = c.get("user");
1846
1883
  let query = void 0;
1847
1884
  const beforeReadResult = await runCollectionHooks(this.global.hooks?.beforeRead, {
@@ -3060,6 +3097,150 @@ var PreviewController = class {
3060
3097
  }
3061
3098
  };
3062
3099
 
3100
+ // src/controllers/audit.controller.ts
3101
+ var AuditController = class {
3102
+ parseQuery(c) {
3103
+ const limit = Math.min(Number(c.req.query("limit")) || 50, 100);
3104
+ const page = Math.max(Number(c.req.query("page")) || 1, 1);
3105
+ const sort = c.req.query("sort") || "-timestamp";
3106
+ const whereRaw = c.req.query("where");
3107
+ if (!whereRaw) {
3108
+ return { limit, page, sort };
3109
+ }
3110
+ try {
3111
+ return {
3112
+ limit,
3113
+ page,
3114
+ sort,
3115
+ where: JSON.parse(decodeURIComponent(whereRaw))
3116
+ };
3117
+ } catch {
3118
+ return { limit, page, sort };
3119
+ }
3120
+ }
3121
+ emptyResult(limit, page) {
3122
+ return {
3123
+ docs: [],
3124
+ total: 0,
3125
+ limit,
3126
+ page,
3127
+ totalPages: 0,
3128
+ hasNextPage: false,
3129
+ hasPrevPage: page > 1
3130
+ };
3131
+ }
3132
+ async collectAccessibleDocumentIds(db, collection, constraint) {
3133
+ const ids = [];
3134
+ const limit = 100;
3135
+ let page = 1;
3136
+ while (true) {
3137
+ const result = await db.find({
3138
+ collection: collection.slug,
3139
+ where: constraint,
3140
+ limit,
3141
+ page
3142
+ });
3143
+ for (const doc of result.docs) {
3144
+ if (typeof doc?.id === "string" && doc.id.length > 0) {
3145
+ ids.push(doc.id);
3146
+ }
3147
+ }
3148
+ if (!result.hasNextPage || page >= result.totalPages) {
3149
+ break;
3150
+ }
3151
+ page += 1;
3152
+ }
3153
+ return ids;
3154
+ }
3155
+ async buildCollectionAuditScope(c, collection) {
3156
+ const config = c.get("config");
3157
+ const auditAccess = collection.access?.readAudit ?? collection.access?.read;
3158
+ const access = await resolveCollectionAccess(
3159
+ config,
3160
+ collection.slug,
3161
+ "read",
3162
+ auditAccess,
3163
+ {
3164
+ user: c.get("user"),
3165
+ req: toHookRequestContext(c.req)
3166
+ }
3167
+ );
3168
+ if (!access.allowed) {
3169
+ return { denied: true, where: {} };
3170
+ }
3171
+ let where = { collection: { equals: collection.slug } };
3172
+ if (access.constraint) {
3173
+ const ids = await this.collectAccessibleDocumentIds(config.db, collection, access.constraint);
3174
+ where = mergeWhereConstraint(where, { documentId: { in: ids } });
3175
+ }
3176
+ return { denied: false, where };
3177
+ }
3178
+ async getVisibleCollections(c) {
3179
+ const config = c.get("config");
3180
+ const collections = /* @__PURE__ */ new Map();
3181
+ for (const collection of config.collections) {
3182
+ collections.set(collection.slug, collection);
3183
+ }
3184
+ const siteId = c.req.header("X-Site-Id") || c.get("siteId");
3185
+ if (config.onSchemaFetch && siteId) {
3186
+ const dynamic = await config.onSchemaFetch(siteId);
3187
+ for (const collection of dynamic.collections || []) {
3188
+ if (!collections.has(collection.slug)) {
3189
+ collections.set(collection.slug, collection);
3190
+ }
3191
+ }
3192
+ }
3193
+ return Array.from(collections.values());
3194
+ }
3195
+ async findForCollection(c, collection) {
3196
+ const config = c.get("config");
3197
+ if (!config.db) return c.json({ message: "Database not configured" }, 500);
3198
+ if (!collection.audit) return c.json({ message: "Audit is not enabled for this collection" }, 404);
3199
+ const query = this.parseQuery(c);
3200
+ const scope = await this.buildCollectionAuditScope(c, collection);
3201
+ if (scope.denied) {
3202
+ return c.json({ error: true, message: `Access denied: read on ${collection.slug}` }, 403);
3203
+ }
3204
+ const where = query.where ? mergeWhereConstraint(scope.where, query.where) : scope.where;
3205
+ const result = await config.db.find({
3206
+ collection: "__audit",
3207
+ where,
3208
+ limit: query.limit,
3209
+ page: query.page,
3210
+ sort: query.sort
3211
+ });
3212
+ return c.json(result);
3213
+ }
3214
+ async findAll(c) {
3215
+ const config = c.get("config");
3216
+ if (!config.db) return c.json({ message: "Database not configured" }, 500);
3217
+ const query = this.parseQuery(c);
3218
+ const collections = (await this.getVisibleCollections(c)).filter((collection) => collection.audit);
3219
+ const scopes = [];
3220
+ for (const collection of collections) {
3221
+ const scope = await this.buildCollectionAuditScope(c, collection);
3222
+ if (!scope.denied) {
3223
+ scopes.push(scope.where);
3224
+ }
3225
+ }
3226
+ if (scopes.length === 0) {
3227
+ return c.json(this.emptyResult(query.limit, query.page));
3228
+ }
3229
+ let where = scopes.length === 1 ? scopes[0] : { OR: scopes };
3230
+ if (query.where) {
3231
+ where = mergeWhereConstraint(where, query.where);
3232
+ }
3233
+ const result = await config.db.find({
3234
+ collection: "__audit",
3235
+ where,
3236
+ limit: query.limit,
3237
+ page: query.page,
3238
+ sort: query.sort
3239
+ });
3240
+ return c.json(result);
3241
+ }
3242
+ };
3243
+
3063
3244
  // src/middleware/auth.ts
3064
3245
  function getBearerToken(c) {
3065
3246
  const authHeader = c.req.header("Authorization");
@@ -3137,6 +3318,15 @@ function optionalAuth(config) {
3137
3318
  }
3138
3319
 
3139
3320
  // src/utils/openapi.ts
3321
+ function getCollectionLabels(collection) {
3322
+ return collection.labels || { singular: collection.slug, plural: collection.slug };
3323
+ }
3324
+ function getCollectionTag(collection) {
3325
+ return `Collection: ${getCollectionLabels(collection).plural}`;
3326
+ }
3327
+ function getGlobalTag(global) {
3328
+ return `Global: ${global.label || global.slug}`;
3329
+ }
3140
3330
  function generateOpenApi(config) {
3141
3331
  const spec = {
3142
3332
  openapi: "3.0.0",
@@ -3168,15 +3358,7 @@ function generateOpenApi(config) {
3168
3358
  },
3169
3359
  WorkflowHistoryEntry: {
3170
3360
  type: "object",
3171
- required: [
3172
- "collection",
3173
- "documentId",
3174
- "transition",
3175
- "from",
3176
- "to",
3177
- "revision",
3178
- "createdAt"
3179
- ],
3361
+ required: ["collection", "documentId", "transition", "from", "to", "revision", "createdAt"],
3180
3362
  properties: {
3181
3363
  id: { type: "string" },
3182
3364
  collection: { type: "string" },
@@ -3190,6 +3372,21 @@ function generateOpenApi(config) {
3190
3372
  createdAt: { type: "string", format: "date-time" }
3191
3373
  }
3192
3374
  },
3375
+ AuditEntry: {
3376
+ type: "object",
3377
+ required: ["collection", "operation", "timestamp"],
3378
+ properties: {
3379
+ id: { type: "string" },
3380
+ collection: { type: "string" },
3381
+ documentId: { type: "string", nullable: true },
3382
+ operation: { type: "string" },
3383
+ user: { type: "string", nullable: true },
3384
+ timestamp: { type: "string", format: "date-time" },
3385
+ changes: {
3386
+ oneOf: [{ type: "string" }, { type: "object", additionalProperties: true }, { type: "null" }]
3387
+ }
3388
+ }
3389
+ },
3193
3390
  Error: {
3194
3391
  type: "object",
3195
3392
  properties: {
@@ -3278,17 +3475,13 @@ function generateOpenApi(config) {
3278
3475
  get: {
3279
3476
  tags: ["Preferences"],
3280
3477
  summary: "Get an authenticated user preference",
3281
- parameters: [
3282
- { name: "key", in: "path", required: true, schema: { type: "string" } }
3283
- ],
3478
+ parameters: [{ name: "key", in: "path", required: true, schema: { type: "string" } }],
3284
3479
  responses: { 200: { description: "Preference value" } }
3285
3480
  },
3286
3481
  put: {
3287
3482
  tags: ["Preferences"],
3288
3483
  summary: "Set an authenticated user preference",
3289
- parameters: [
3290
- { name: "key", in: "path", required: true, schema: { type: "string" } }
3291
- ],
3484
+ parameters: [{ name: "key", in: "path", required: true, schema: { type: "string" } }],
3292
3485
  requestBody: {
3293
3486
  required: true,
3294
3487
  content: { "application/json": { schema: { type: "object" } } }
@@ -3319,6 +3512,39 @@ function generateOpenApi(config) {
3319
3512
  responses: { 200: { description: "Preview document data" } }
3320
3513
  }
3321
3514
  };
3515
+ spec.paths["/api/audit"] = {
3516
+ get: {
3517
+ tags: ["Audit"],
3518
+ summary: "Get audit entries across all readable audited collections",
3519
+ parameters: [
3520
+ { name: "limit", in: "query", schema: { type: "integer", default: 50, maximum: 100 } },
3521
+ { name: "page", in: "query", schema: { type: "integer", default: 1 } },
3522
+ { name: "where", in: "query", schema: { type: "string" }, description: "JSON filter" },
3523
+ { name: "sort", in: "query", schema: { type: "string", default: "-timestamp" } }
3524
+ ],
3525
+ responses: {
3526
+ 200: {
3527
+ description: "Audit entries",
3528
+ content: {
3529
+ "application/json": {
3530
+ schema: {
3531
+ type: "object",
3532
+ properties: {
3533
+ docs: {
3534
+ type: "array",
3535
+ items: { $ref: "#/components/schemas/AuditEntry" }
3536
+ },
3537
+ total: { type: "integer" },
3538
+ limit: { type: "integer" },
3539
+ page: { type: "integer" }
3540
+ }
3541
+ }
3542
+ }
3543
+ }
3544
+ }
3545
+ }
3546
+ }
3547
+ };
3322
3548
  for (const collection of config.collections) {
3323
3549
  spec.components.schemas[collection.slug] = collectionToSchema(collection);
3324
3550
  }
@@ -3328,10 +3554,11 @@ function generateOpenApi(config) {
3328
3554
  for (const collection of config.collections) {
3329
3555
  const slug = collection.slug;
3330
3556
  const path = `/api/collections/${slug}`;
3331
- const labels = collection.labels || { singular: slug, plural: `${slug}s` };
3557
+ const labels = getCollectionLabels(collection);
3558
+ const collectionTag = getCollectionTag(collection);
3332
3559
  spec.paths[path] = {
3333
3560
  get: {
3334
- tags: ["Collections"],
3561
+ tags: [collectionTag],
3335
3562
  summary: `Find ${labels.plural}`,
3336
3563
  parameters: [
3337
3564
  {
@@ -3380,7 +3607,7 @@ function generateOpenApi(config) {
3380
3607
  }
3381
3608
  },
3382
3609
  post: {
3383
- tags: ["Collections"],
3610
+ tags: [collectionTag],
3384
3611
  summary: `Create ${labels.singular}`,
3385
3612
  requestBody: {
3386
3613
  required: true,
@@ -3404,7 +3631,7 @@ function generateOpenApi(config) {
3404
3631
  };
3405
3632
  spec.paths[`${path}/{id}`] = {
3406
3633
  get: {
3407
- tags: ["Collections"],
3634
+ tags: [collectionTag],
3408
3635
  summary: `Get a single ${labels.singular}`,
3409
3636
  parameters: [
3410
3637
  {
@@ -3426,7 +3653,7 @@ function generateOpenApi(config) {
3426
3653
  }
3427
3654
  },
3428
3655
  patch: {
3429
- tags: ["Collections"],
3656
+ tags: [collectionTag],
3430
3657
  summary: `Update ${labels.singular}`,
3431
3658
  parameters: [
3432
3659
  {
@@ -3456,7 +3683,7 @@ function generateOpenApi(config) {
3456
3683
  }
3457
3684
  },
3458
3685
  delete: {
3459
- tags: ["Collections"],
3686
+ tags: [collectionTag],
3460
3687
  summary: `Delete ${labels.singular}`,
3461
3688
  parameters: [
3462
3689
  {
@@ -3473,7 +3700,7 @@ function generateOpenApi(config) {
3473
3700
  };
3474
3701
  spec.paths[`${path}/delete-many`] = {
3475
3702
  delete: {
3476
- tags: ["Collections"],
3703
+ tags: [collectionTag],
3477
3704
  summary: `Delete multiple ${labels.plural}`,
3478
3705
  requestBody: {
3479
3706
  required: true,
@@ -3492,9 +3719,46 @@ function generateOpenApi(config) {
3492
3719
  responses: { 200: { description: "Deleted and failed document IDs" } }
3493
3720
  }
3494
3721
  };
3722
+ if (collection.audit) {
3723
+ spec.paths[`${path}/__audit`] = {
3724
+ get: {
3725
+ tags: [collectionTag],
3726
+ summary: `Get ${labels.singular} audit entries`,
3727
+ parameters: [
3728
+ { name: "limit", in: "query", schema: { type: "integer", default: 50, maximum: 100 } },
3729
+ { name: "page", in: "query", schema: { type: "integer", default: 1 } },
3730
+ { name: "where", in: "query", schema: { type: "string" }, description: "JSON filter" },
3731
+ { name: "sort", in: "query", schema: { type: "string", default: "-timestamp" } }
3732
+ ],
3733
+ responses: {
3734
+ 200: {
3735
+ description: "Audit entries for this collection",
3736
+ content: {
3737
+ "application/json": {
3738
+ schema: {
3739
+ type: "object",
3740
+ properties: {
3741
+ docs: {
3742
+ type: "array",
3743
+ items: { $ref: "#/components/schemas/AuditEntry" }
3744
+ },
3745
+ total: { type: "integer" },
3746
+ limit: { type: "integer" },
3747
+ page: { type: "integer" }
3748
+ }
3749
+ }
3750
+ }
3751
+ }
3752
+ },
3753
+ 403: { description: "Collection read access denied" },
3754
+ 404: { description: "Audit is not enabled for this collection" }
3755
+ }
3756
+ }
3757
+ };
3758
+ }
3495
3759
  spec.paths[`${path}/seed`] = {
3496
3760
  post: {
3497
- tags: ["Collections"],
3761
+ tags: [collectionTag],
3498
3762
  summary: `Seed initial ${labels.plural}`,
3499
3763
  responses: { 200: { description: "Seeded documents" } }
3500
3764
  }
@@ -3502,12 +3766,12 @@ function generateOpenApi(config) {
3502
3766
  if (collection.upload) {
3503
3767
  spec.paths[`${path}/media`] = {
3504
3768
  get: {
3505
- tags: ["Media"],
3769
+ tags: [collectionTag],
3506
3770
  summary: `List ${labels.plural}`,
3507
3771
  responses: { 200: { description: "Paginated media documents" } }
3508
3772
  },
3509
3773
  post: {
3510
- tags: ["Media"],
3774
+ tags: [collectionTag],
3511
3775
  summary: `Upload ${labels.singular}`,
3512
3776
  requestBody: {
3513
3777
  required: true,
@@ -3526,7 +3790,7 @@ function generateOpenApi(config) {
3526
3790
  };
3527
3791
  spec.paths[`${path}/media/{filename}`] = {
3528
3792
  get: {
3529
- tags: ["Media"],
3793
+ tags: [collectionTag],
3530
3794
  summary: `Serve ${labels.singular} bytes`,
3531
3795
  parameters: [
3532
3796
  {
@@ -3546,7 +3810,7 @@ function generateOpenApi(config) {
3546
3810
  }
3547
3811
  if (collection.auth) {
3548
3812
  const publicAuthPost = (summary) => ({
3549
- tags: ["Authentication"],
3813
+ tags: [collectionTag],
3550
3814
  summary,
3551
3815
  security: [],
3552
3816
  requestBody: {
@@ -3588,14 +3852,14 @@ function generateOpenApi(config) {
3588
3852
  };
3589
3853
  spec.paths[`${path}/logout`] = {
3590
3854
  post: {
3591
- tags: ["Authentication"],
3855
+ tags: [collectionTag],
3592
3856
  summary: `Log out of ${labels.plural}`,
3593
3857
  responses: { 200: { description: "Logged out" } }
3594
3858
  }
3595
3859
  };
3596
3860
  spec.paths[`${path}/init`] = {
3597
3861
  get: {
3598
- tags: ["Authentication"],
3862
+ tags: [collectionTag],
3599
3863
  summary: `Get ${labels.plural} initialization state`,
3600
3864
  security: [],
3601
3865
  responses: { 200: { description: "Initialization state" } }
@@ -3606,14 +3870,14 @@ function generateOpenApi(config) {
3606
3870
  };
3607
3871
  spec.paths[`${path}/me`] = {
3608
3872
  get: {
3609
- tags: ["Authentication"],
3873
+ tags: [collectionTag],
3610
3874
  summary: `Get the current ${labels.singular}`,
3611
3875
  responses: { 200: { description: "Authenticated user" } }
3612
3876
  }
3613
3877
  };
3614
3878
  spec.paths[`${path}/refresh-token`] = {
3615
3879
  post: {
3616
- tags: ["Authentication"],
3880
+ tags: [collectionTag],
3617
3881
  summary: "Refresh an authentication token",
3618
3882
  responses: { 200: { description: "Refreshed token" } }
3619
3883
  }
@@ -3626,7 +3890,7 @@ function generateOpenApi(config) {
3626
3890
  };
3627
3891
  spec.paths[`${path}/invite`] = {
3628
3892
  post: {
3629
- tags: ["Authentication"],
3893
+ tags: [collectionTag],
3630
3894
  summary: `Invite a ${labels.singular}`,
3631
3895
  responses: { 200: { description: "Invitation sent" } }
3632
3896
  }
@@ -3636,7 +3900,7 @@ function generateOpenApi(config) {
3636
3900
  };
3637
3901
  spec.paths[`${path}/{id}/change-password`] = {
3638
3902
  post: {
3639
- tags: ["Authentication"],
3903
+ tags: [collectionTag],
3640
3904
  summary: `Change a ${labels.singular} password`,
3641
3905
  parameters: [
3642
3906
  {
@@ -3653,7 +3917,7 @@ function generateOpenApi(config) {
3653
3917
  if (collection.workflow) {
3654
3918
  spec.paths[`${path}/{id}/transitions/{transition}`] = {
3655
3919
  post: {
3656
- tags: ["Workflows"],
3920
+ tags: [collectionTag],
3657
3921
  summary: `Transition ${labels.singular} workflow`,
3658
3922
  parameters: [
3659
3923
  {
@@ -3698,7 +3962,7 @@ function generateOpenApi(config) {
3698
3962
  };
3699
3963
  spec.paths[`${path}/{id}/workflow-history`] = {
3700
3964
  get: {
3701
- tags: ["Workflows"],
3965
+ tags: [collectionTag],
3702
3966
  summary: `Get ${labels.singular} workflow history`,
3703
3967
  parameters: [
3704
3968
  {
@@ -3744,9 +4008,10 @@ function generateOpenApi(config) {
3744
4008
  for (const global of config.globals) {
3745
4009
  const slug = global.slug;
3746
4010
  const path = `/api/globals/${slug}`;
4011
+ const globalTag = getGlobalTag(global);
3747
4012
  spec.paths[path] = {
3748
4013
  get: {
3749
- tags: ["Globals"],
4014
+ tags: [globalTag],
3750
4015
  summary: `Get ${global.label || slug}`,
3751
4016
  responses: {
3752
4017
  200: {
@@ -3760,7 +4025,7 @@ function generateOpenApi(config) {
3760
4025
  }
3761
4026
  },
3762
4027
  patch: {
3763
- tags: ["Globals"],
4028
+ tags: [globalTag],
3764
4029
  summary: `Update ${global.label || slug}`,
3765
4030
  requestBody: {
3766
4031
  required: true,
@@ -3784,7 +4049,7 @@ function generateOpenApi(config) {
3784
4049
  };
3785
4050
  spec.paths[`${path}/seed`] = {
3786
4051
  post: {
3787
- tags: ["Globals"],
4052
+ tags: [globalTag],
3788
4053
  summary: `Seed ${global.label || slug}`,
3789
4054
  responses: { 200: { description: "Seeded global" } }
3790
4055
  }
@@ -3863,10 +4128,7 @@ function fieldToSchema(field) {
3863
4128
  break;
3864
4129
  case "url":
3865
4130
  schema = {
3866
- oneOf: [
3867
- { type: "string" },
3868
- { type: "object", additionalProperties: true }
3869
- ]
4131
+ oneOf: [{ type: "string" }, { type: "object", additionalProperties: true }]
3870
4132
  };
3871
4133
  break;
3872
4134
  case "icon":
@@ -4061,6 +4323,7 @@ function serializeFieldForApi(f) {
4061
4323
  return serialized;
4062
4324
  }
4063
4325
  function registerRoutes(app, config) {
4326
+ const optionsCache = /* @__PURE__ */ new Map();
4064
4327
  app.get("/api/schemas", optionalAuth(config), async (c) => {
4065
4328
  const siteId = c.req.header("X-Site-Id");
4066
4329
  let collections = [...config.collections];
@@ -4081,6 +4344,10 @@ function registerRoutes(app, config) {
4081
4344
  const serializeAccess = async (access) => {
4082
4345
  if (typeof access === "string") return access;
4083
4346
  if (typeof access === "boolean") return access;
4347
+ if (access && typeof access === "object" && typeof access.policy === "string") {
4348
+ const policy = config.accessPolicies?.[access.policy];
4349
+ if (typeof policy === "string" || typeof policy === "boolean") return policy;
4350
+ }
4084
4351
  return resolveBooleanAccess(config, access, accessArgs);
4085
4352
  };
4086
4353
  const filteredCollections = await Promise.all(collections.filter((col) => !siteId || col.shared || !col.siteId || col.siteId === siteId).map(async (col) => ({
@@ -4109,11 +4376,14 @@ function registerRoutes(app, config) {
4109
4376
  admin: f.admin,
4110
4377
  access: {
4111
4378
  read: await serializeAccess(f.access?.read),
4379
+ create: await serializeAccess(f.access?.create),
4112
4380
  update: await serializeAccess(f.access?.update)
4113
4381
  }
4114
4382
  }))),
4115
4383
  upload: !!col.upload,
4116
4384
  auth: !!col.auth,
4385
+ audit: !!col.audit,
4386
+ drafts: !!col.drafts,
4117
4387
  admin: col.admin,
4118
4388
  workflow: col.workflow ? {
4119
4389
  initialState: col.workflow.initialState,
@@ -4216,10 +4486,12 @@ function registerRoutes(app, config) {
4216
4486
  return c.json({ error: true, message: `Field ${fieldName} not found in ${colSlug}` }, 404);
4217
4487
  }
4218
4488
  let resolver;
4489
+ let cacheTTL;
4219
4490
  if (typeof field.options === "function") {
4220
4491
  resolver = field.options;
4221
4492
  } else if (field.options && typeof field.options === "object" && "resolve" in field.options) {
4222
4493
  resolver = field.options.resolve;
4494
+ cacheTTL = field.options.cacheTTL;
4223
4495
  }
4224
4496
  if (!resolver) {
4225
4497
  return c.json({ error: true, message: `Field ${fieldName} in ${colSlug} is not dynamic` }, 400);
@@ -4232,11 +4504,32 @@ function registerRoutes(app, config) {
4232
4504
  headers: c.req.header(),
4233
4505
  raw: c.req.raw
4234
4506
  };
4507
+ const shouldCache = typeof cacheTTL === "number" && cacheTTL > 0;
4508
+ let cacheKey = "";
4509
+ if (shouldCache) {
4510
+ cacheKey = JSON.stringify([
4511
+ siteId ?? "",
4512
+ colSlug,
4513
+ fieldName,
4514
+ user?.id ?? null,
4515
+ queryParams
4516
+ ]);
4517
+ const hit = optionsCache.get(cacheKey);
4518
+ if (hit && hit.expires > Date.now()) {
4519
+ return c.json(hit.value);
4520
+ }
4521
+ }
4235
4522
  const result = await resolver({
4236
4523
  db,
4237
4524
  user,
4238
4525
  req: reqContext
4239
4526
  });
4527
+ if (shouldCache) {
4528
+ optionsCache.set(cacheKey, {
4529
+ expires: Date.now() + cacheTTL * 1e3,
4530
+ value: result
4531
+ });
4532
+ }
4240
4533
  return c.json(result);
4241
4534
  } catch (err) {
4242
4535
  console.error(`[dyrected/core] Failed to resolve dynamic options for field ${fieldName}:`, err);
@@ -4381,6 +4674,7 @@ function registerRoutes(app, config) {
4381
4674
  app.post(`${path}/invite`, requireAuth(config), (c) => authController.invite(c));
4382
4675
  app.post(`${path}/accept-invite`, (c) => authController.acceptInvite(c));
4383
4676
  }
4677
+ const auditController = new AuditController();
4384
4678
  for (const collection of config.collections) {
4385
4679
  const path = `/api/collections/${collection.slug}`;
4386
4680
  const controller = new CollectionController(collection);
@@ -4388,6 +4682,9 @@ function registerRoutes(app, config) {
4388
4682
  app.post(path, (c) => controller.create(c));
4389
4683
  app.post(`${path}/media`, (c) => controller.create(c));
4390
4684
  app.delete(`${path}/delete-many`, (c) => controller.deleteMany(c));
4685
+ if (collection.audit) {
4686
+ app.get(`${path}/__audit`, (c) => auditController.findForCollection(c, collection));
4687
+ }
4391
4688
  app.get(`${path}/:id`, (c) => controller.findOne(c));
4392
4689
  app.patch(`${path}/:id`, (c) => controller.update(c));
4393
4690
  app.delete(`${path}/:id`, (c) => controller.delete(c));
@@ -4407,9 +4704,32 @@ function registerRoutes(app, config) {
4407
4704
  app.patch(path, (c) => controller.update(c));
4408
4705
  app.post(`${path}/seed`, (c) => controller.seed(c));
4409
4706
  }
4707
+ if (!process.env.DYRECTED_JWT_SECRET) {
4708
+ console.warn(
4709
+ '[dyrected] DYRECTED_JWT_SECRET is not set \u2014 token-mode live preview is signing with an insecure default. Set DYRECTED_JWT_SECRET before relying on `previewMode: "token"` in production.'
4710
+ );
4711
+ }
4410
4712
  const previewController = new PreviewController();
4411
4713
  app.post("/api/preview-token", requireAuth(config), (c) => previewController.createToken(c));
4412
4714
  app.get("/api/preview-data", (c) => previewController.getData(c));
4715
+ app.get("/api/audit", (c) => auditController.findAll(c));
4716
+ app.get("/api/collections/:slug/__audit", async (c) => {
4717
+ const slug = c.req.param("slug");
4718
+ const siteId = c.req.header("X-Site-Id") || c.get("siteId");
4719
+ const config2 = c.get("config");
4720
+ if (config2.collections.some((col) => col.slug === slug)) {
4721
+ return c.json({ message: "Not Found" }, 404);
4722
+ }
4723
+ if (!config2.onSchemaFetch || !siteId) {
4724
+ return c.json({ message: `Collection "${slug}" not found` }, 404);
4725
+ }
4726
+ const dynamic = await config2.onSchemaFetch(siteId);
4727
+ const collection = dynamic.collections?.find((col) => col.slug === slug);
4728
+ if (!collection?.audit) {
4729
+ return c.json({ message: `Collection "${slug}" not found or has no audit log` }, 404);
4730
+ }
4731
+ return auditController.findForCollection(c, collection);
4732
+ });
4413
4733
  app.post("/api/collections/:slug/:id/transitions/:transition", requireAuth(config), async (c) => {
4414
4734
  const slug = c.req.param("slug");
4415
4735
  const siteId = c.req.header("X-Site-Id") || c.get("siteId");
@@ -4558,6 +4878,7 @@ var AUDIT_COLLECTION = {
4558
4878
  { name: "timestamp", type: "date", label: "Timestamp", required: true },
4559
4879
  { name: "changes", type: "json", label: "Changes" }
4560
4880
  ],
4881
+ access: { read: () => false, create: () => false, update: () => false, delete: () => false },
4561
4882
  admin: { hidden: true }
4562
4883
  };
4563
4884
  var WORKFLOW_HISTORY_COLLECTION_CONFIG = {
@@ -4600,7 +4921,7 @@ function normalizeConfig(config) {
4600
4921
  const collections = config?.collections || [];
4601
4922
  const globals = config?.globals || [];
4602
4923
  const needsAudit = collections.some((col) => col.audit);
4603
- const needsWorkflow = collections.some((col) => col.workflow);
4924
+ const needsWorkflow = collections.some((col) => col.workflow || col.drafts);
4604
4925
  const adminAuthCollectionSlug = getAdminAuthCollection({
4605
4926
  collections,
4606
4927
  adminAuth: config.adminAuth
@@ -4662,6 +4983,12 @@ function normalizeConfig(config) {
4662
4983
  if (field.name === "email") {
4663
4984
  return {
4664
4985
  ...field,
4986
+ // Email is the login identifier. Keep the integrity constraints
4987
+ // auth relies on even when the field is explicitly redefined, so a
4988
+ // custom `email` field can never silently drop uniqueness.
4989
+ required: true,
4990
+ unique: true,
4991
+ promoted: true,
4665
4992
  access: {
4666
4993
  ...field.access || {},
4667
4994
  update: "!id"
@@ -4671,6 +4998,9 @@ function normalizeConfig(config) {
4671
4998
  if (field.name === "password") {
4672
4999
  return {
4673
5000
  ...field,
5001
+ // Password is required to authenticate; enforce it regardless of
5002
+ // how the field was declared.
5003
+ required: true,
4674
5004
  admin: { ...field.admin || {} },
4675
5005
  access: {
4676
5006
  ...field.access || {},
@@ -4726,8 +5056,10 @@ function normalizeConfig(config) {
4726
5056
  }
4727
5057
  const updatedFieldNames = new Set(fields.map((f) => f.name));
4728
5058
  const fieldsToInject = SYSTEM_FIELDS.filter((f) => !updatedFieldNames.has(f.name));
5059
+ const workflow = col.workflow || (col.drafts ? simplePublishingWorkflow() : void 0);
4729
5060
  return {
4730
5061
  ...col,
5062
+ workflow,
4731
5063
  fields: [...fields, ...fieldsToInject]
4732
5064
  };
4733
5065
  });