@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/app-config-C0twGIaF.d.cts +1597 -0
- package/dist/app-config-C0twGIaF.d.ts +1597 -0
- package/dist/app-config-C3X5BAyK.d.cts +1539 -0
- package/dist/app-config-C3X5BAyK.d.ts +1539 -0
- package/dist/app-config-CstocO9M.d.cts +1580 -0
- package/dist/app-config-CstocO9M.d.ts +1580 -0
- package/dist/app-config-DIwvzbpe.d.cts +1555 -0
- package/dist/app-config-DIwvzbpe.d.ts +1555 -0
- package/dist/app-config-Dkndq5ni.d.cts +1580 -0
- package/dist/app-config-Dkndq5ni.d.ts +1580 -0
- package/dist/app-config-P-SF5nnR.d.cts +1568 -0
- package/dist/app-config-P-SF5nnR.d.ts +1568 -0
- package/dist/app-config-wnLtFu6c.d.cts +1538 -0
- package/dist/app-config-wnLtFu6c.d.ts +1538 -0
- package/dist/chunk-2WODKOUB.js +1627 -0
- package/dist/chunk-3CMG2BH2.js +1618 -0
- package/dist/chunk-DCY7QHRB.js +1637 -0
- package/dist/chunk-EOW4YMAV.js +1637 -0
- package/dist/chunk-JFA22YFB.js +1625 -0
- package/dist/chunk-WF5A6PDJ.js +1618 -0
- package/dist/index.cjs +141 -42
- package/dist/index.d.cts +37 -4
- package/dist/index.d.ts +37 -4
- package/dist/index.js +8 -1
- package/dist/server.cjs +358 -57
- package/dist/server.d.cts +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.js +226 -16
- 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
|
|
631
|
-
if (
|
|
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:
|
|
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
|
|
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 (!
|
|
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:
|
|
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")) :
|
|
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")) :
|
|
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")) :
|
|
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
|
|
3527
|
+
const labels = getCollectionLabels(collection);
|
|
3528
|
+
const collectionTag = getCollectionTag(collection);
|
|
3324
3529
|
spec.paths[path] = {
|
|
3325
3530
|
get: {
|
|
3326
|
-
tags: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
3739
|
+
tags: [collectionTag],
|
|
3498
3740
|
summary: `List ${labels.plural}`,
|
|
3499
3741
|
responses: { 200: { description: "Paginated media documents" } }
|
|
3500
3742
|
},
|
|
3501
3743
|
post: {
|
|
3502
|
-
tags: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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 || {},
|