@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/app-config-C0twGIaF.d.cts +1597 -0
- package/dist/app-config-C0twGIaF.d.ts +1597 -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/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 +36 -3
- package/dist/index.d.ts +36 -3
- package/dist/index.js +8 -1
- package/dist/server.cjs +344 -51
- package/dist/server.d.cts +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.js +212 -10
- 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);
|
|
@@ -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
|
|
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 (!
|
|
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")) :
|
|
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")) :
|
|
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")) :
|
|
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
|
|
3527
|
+
const labels = getCollectionLabels(collection);
|
|
3528
|
+
const collectionTag = getCollectionTag(collection);
|
|
3332
3529
|
spec.paths[path] = {
|
|
3333
3530
|
get: {
|
|
3334
|
-
tags: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
3739
|
+
tags: [collectionTag],
|
|
3506
3740
|
summary: `List ${labels.plural}`,
|
|
3507
3741
|
responses: { 200: { description: "Paginated media documents" } }
|
|
3508
3742
|
},
|
|
3509
3743
|
post: {
|
|
3510
|
-
tags: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
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 || {},
|