@momentumcms/server-express 0.5.10 → 0.5.11
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/CHANGELOG.md +11 -0
- package/index.cjs +819 -278
- package/index.js +696 -156
- package/package.json +43 -54
- package/src/index.d.ts +1 -0
- package/src/lib/rate-limit-middleware.d.ts +18 -0
package/index.js
CHANGED
|
@@ -155,7 +155,7 @@ function s3StorageAdapter(options) {
|
|
|
155
155
|
baseUrl,
|
|
156
156
|
forcePathStyle = false,
|
|
157
157
|
acl = "private",
|
|
158
|
-
presignedUrlExpiry =
|
|
158
|
+
presignedUrlExpiry = 900
|
|
159
159
|
} = options;
|
|
160
160
|
let client = null;
|
|
161
161
|
async function getClient() {
|
|
@@ -2745,6 +2745,21 @@ var VersionOperationsImpl = class {
|
|
|
2745
2745
|
});
|
|
2746
2746
|
}
|
|
2747
2747
|
}
|
|
2748
|
+
if (!this.context.overrideAccess && hasFieldAccessControl(this.collectionConfig.fields)) {
|
|
2749
|
+
for (let i = 0; i < docs.length; i++) {
|
|
2750
|
+
const version = docs[i].version;
|
|
2751
|
+
if (version && typeof version === "object") {
|
|
2752
|
+
const versionRecord = version;
|
|
2753
|
+
const filtered = await filterReadableFields(
|
|
2754
|
+
this.collectionConfig.fields,
|
|
2755
|
+
versionRecord,
|
|
2756
|
+
this.buildRequestContext()
|
|
2757
|
+
);
|
|
2758
|
+
const filteredAsT = filtered;
|
|
2759
|
+
docs[i] = { ...docs[i], version: filteredAsT };
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
}
|
|
2748
2763
|
const countOptions = {
|
|
2749
2764
|
includeAutosave: options?.includeAutosave,
|
|
2750
2765
|
status: options?.status
|
|
@@ -2774,9 +2789,21 @@ var VersionOperationsImpl = class {
|
|
|
2774
2789
|
if (parsedVersion === null) {
|
|
2775
2790
|
return null;
|
|
2776
2791
|
}
|
|
2792
|
+
let filteredVersion = parsedVersion;
|
|
2793
|
+
if (!this.context.overrideAccess && hasFieldAccessControl(this.collectionConfig.fields)) {
|
|
2794
|
+
if (filteredVersion && typeof filteredVersion === "object") {
|
|
2795
|
+
const versionRecord = filteredVersion;
|
|
2796
|
+
const filtered = await filterReadableFields(
|
|
2797
|
+
this.collectionConfig.fields,
|
|
2798
|
+
versionRecord,
|
|
2799
|
+
this.buildRequestContext()
|
|
2800
|
+
);
|
|
2801
|
+
filteredVersion = filtered;
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2777
2804
|
return {
|
|
2778
2805
|
...version,
|
|
2779
|
-
version:
|
|
2806
|
+
version: filteredVersion
|
|
2780
2807
|
};
|
|
2781
2808
|
}
|
|
2782
2809
|
async restore(options) {
|
|
@@ -2998,6 +3025,415 @@ var VersionOperationsImpl = class {
|
|
|
2998
3025
|
}
|
|
2999
3026
|
};
|
|
3000
3027
|
|
|
3028
|
+
// libs/server-core/src/lib/where-clause.ts
|
|
3029
|
+
var OPERATOR_MAP = {
|
|
3030
|
+
equals: "$eq",
|
|
3031
|
+
gt: "$gt",
|
|
3032
|
+
gte: "$gte",
|
|
3033
|
+
lt: "$lt",
|
|
3034
|
+
lte: "$lte",
|
|
3035
|
+
not_equals: "$ne",
|
|
3036
|
+
like: "$like",
|
|
3037
|
+
contains: "$contains",
|
|
3038
|
+
in: "$in",
|
|
3039
|
+
not_in: "$nin",
|
|
3040
|
+
exists: "$exists"
|
|
3041
|
+
};
|
|
3042
|
+
var MAX_WHERE_CONDITIONS = 20;
|
|
3043
|
+
var MAX_JOINS = 5;
|
|
3044
|
+
var MAX_WHERE_NESTING_DEPTH = 5;
|
|
3045
|
+
var MAX_PAGE_LIMIT = 1e3;
|
|
3046
|
+
var MAX_PAGE = 1e6;
|
|
3047
|
+
var VALID_OPERATORS = new Set(Object.keys(OPERATOR_MAP));
|
|
3048
|
+
function countWhereConditions(where, depth = 0) {
|
|
3049
|
+
if (depth > MAX_WHERE_NESTING_DEPTH) {
|
|
3050
|
+
throw new ValidationError([
|
|
3051
|
+
{
|
|
3052
|
+
field: "where",
|
|
3053
|
+
message: `Where clause nesting depth exceeds maximum of ${MAX_WHERE_NESTING_DEPTH} levels.`
|
|
3054
|
+
}
|
|
3055
|
+
]);
|
|
3056
|
+
}
|
|
3057
|
+
let count = 0;
|
|
3058
|
+
for (const [key, value] of Object.entries(where)) {
|
|
3059
|
+
if ((key === "and" || key === "or") && Array.isArray(value)) {
|
|
3060
|
+
for (const sub of value) {
|
|
3061
|
+
if (typeof sub === "object" && sub !== null) {
|
|
3062
|
+
count += countWhereConditions(sub, depth + 1);
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
} else {
|
|
3066
|
+
count++;
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
return count;
|
|
3070
|
+
}
|
|
3071
|
+
function flattenWhereClause(where) {
|
|
3072
|
+
if (!where)
|
|
3073
|
+
return {};
|
|
3074
|
+
const fieldCount = countWhereConditions(where);
|
|
3075
|
+
if (fieldCount > MAX_WHERE_CONDITIONS) {
|
|
3076
|
+
throw new ValidationError([
|
|
3077
|
+
{
|
|
3078
|
+
field: "where",
|
|
3079
|
+
message: `Where clause exceeds maximum of ${MAX_WHERE_CONDITIONS} conditions (got ${fieldCount}).`
|
|
3080
|
+
}
|
|
3081
|
+
]);
|
|
3082
|
+
}
|
|
3083
|
+
return flattenWhereRecursive(where, 0);
|
|
3084
|
+
}
|
|
3085
|
+
function flattenWhereRecursive(where, depth) {
|
|
3086
|
+
if (depth > MAX_WHERE_NESTING_DEPTH) {
|
|
3087
|
+
throw new ValidationError([
|
|
3088
|
+
{
|
|
3089
|
+
field: "where",
|
|
3090
|
+
message: `Where clause nesting depth exceeds maximum of ${MAX_WHERE_NESTING_DEPTH} levels.`
|
|
3091
|
+
}
|
|
3092
|
+
]);
|
|
3093
|
+
}
|
|
3094
|
+
const result = {};
|
|
3095
|
+
for (const [field, condition] of Object.entries(where)) {
|
|
3096
|
+
if (field === "and" || field === "or") {
|
|
3097
|
+
if (!Array.isArray(condition)) {
|
|
3098
|
+
throw new ValidationError([
|
|
3099
|
+
{
|
|
3100
|
+
field,
|
|
3101
|
+
message: `The "${field}" operator requires an array of conditions.`
|
|
3102
|
+
}
|
|
3103
|
+
]);
|
|
3104
|
+
}
|
|
3105
|
+
const internalKey = field === "and" ? "$and" : "$or";
|
|
3106
|
+
result[internalKey] = condition.filter((sub) => typeof sub === "object" && sub !== null).map((sub) => {
|
|
3107
|
+
if ("$join" in sub)
|
|
3108
|
+
return sub;
|
|
3109
|
+
return flattenWhereRecursive(sub, depth + 1);
|
|
3110
|
+
});
|
|
3111
|
+
continue;
|
|
3112
|
+
}
|
|
3113
|
+
if (typeof condition !== "object" || condition === null) {
|
|
3114
|
+
result[field] = condition;
|
|
3115
|
+
continue;
|
|
3116
|
+
}
|
|
3117
|
+
const condObj = condition;
|
|
3118
|
+
const ops = {};
|
|
3119
|
+
let hasOp = false;
|
|
3120
|
+
for (const [userOp, internalOp] of Object.entries(OPERATOR_MAP)) {
|
|
3121
|
+
if (userOp in condObj) {
|
|
3122
|
+
let value = condObj[userOp];
|
|
3123
|
+
if (internalOp === "$exists" && typeof value === "string") {
|
|
3124
|
+
value = value === "true";
|
|
3125
|
+
}
|
|
3126
|
+
if (typeof value === "string") {
|
|
3127
|
+
value = value.replace(/\0/g, "");
|
|
3128
|
+
}
|
|
3129
|
+
if (Array.isArray(value)) {
|
|
3130
|
+
value = value.map(
|
|
3131
|
+
(item) => typeof item === "string" ? item.replace(/\0/g, "") : item
|
|
3132
|
+
);
|
|
3133
|
+
}
|
|
3134
|
+
ops[internalOp] = value;
|
|
3135
|
+
hasOp = true;
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
for (const key of Object.keys(condObj)) {
|
|
3139
|
+
if (!VALID_OPERATORS.has(key)) {
|
|
3140
|
+
throw new ValidationError([
|
|
3141
|
+
{
|
|
3142
|
+
field,
|
|
3143
|
+
message: `Unknown operator "${key}". Valid operators: ${[...VALID_OPERATORS].sort().join(", ")}`
|
|
3144
|
+
}
|
|
3145
|
+
]);
|
|
3146
|
+
}
|
|
3147
|
+
}
|
|
3148
|
+
if (hasOp) {
|
|
3149
|
+
result[field] = ops;
|
|
3150
|
+
} else {
|
|
3151
|
+
result[field] = condition;
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
3154
|
+
return result;
|
|
3155
|
+
}
|
|
3156
|
+
function extractRelationshipJoins(where, fields, allCollections) {
|
|
3157
|
+
if (!where)
|
|
3158
|
+
return { cleanedWhere: void 0, joins: [], allJoins: [] };
|
|
3159
|
+
const dataFields = flattenDataFields(fields);
|
|
3160
|
+
const fieldMap = new Map(dataFields.map((f) => [f.name, f]));
|
|
3161
|
+
const joins = [];
|
|
3162
|
+
const allJoins = [];
|
|
3163
|
+
const cleanedWhere = {};
|
|
3164
|
+
for (const [key, condition] of Object.entries(where)) {
|
|
3165
|
+
if (key === "and" || key === "or") {
|
|
3166
|
+
if (Array.isArray(condition)) {
|
|
3167
|
+
const cleanedArray = [];
|
|
3168
|
+
for (const sub of condition) {
|
|
3169
|
+
if (typeof sub === "object" && sub !== null) {
|
|
3170
|
+
const {
|
|
3171
|
+
cleanedWhere: subCleaned,
|
|
3172
|
+
joins: subTopJoins,
|
|
3173
|
+
allJoins: subAllJoins
|
|
3174
|
+
} = extractRelationshipJoins(
|
|
3175
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- WhereClause sub-object
|
|
3176
|
+
sub,
|
|
3177
|
+
fields,
|
|
3178
|
+
allCollections
|
|
3179
|
+
);
|
|
3180
|
+
if (subCleaned)
|
|
3181
|
+
cleanedArray.push(subCleaned);
|
|
3182
|
+
for (const join2 of subTopJoins) {
|
|
3183
|
+
cleanedArray.push({ ["$join"]: join2 });
|
|
3184
|
+
}
|
|
3185
|
+
allJoins.push(...subAllJoins);
|
|
3186
|
+
}
|
|
3187
|
+
}
|
|
3188
|
+
if (cleanedArray.length > 0) {
|
|
3189
|
+
cleanedWhere[key] = cleanedArray;
|
|
3190
|
+
}
|
|
3191
|
+
}
|
|
3192
|
+
continue;
|
|
3193
|
+
}
|
|
3194
|
+
let field = fieldMap.get(key);
|
|
3195
|
+
if (!field && key.includes(".")) {
|
|
3196
|
+
const [rootKey, ...subPath] = key.split(".");
|
|
3197
|
+
const rootField = fieldMap.get(rootKey);
|
|
3198
|
+
if (rootField && rootField.type === "relationship" && subPath.length > 0 && condition !== null) {
|
|
3199
|
+
let nested = condition;
|
|
3200
|
+
for (let i = subPath.length - 1; i >= 0; i--) {
|
|
3201
|
+
nested = { [subPath[i]]: nested };
|
|
3202
|
+
}
|
|
3203
|
+
field = rootField;
|
|
3204
|
+
const {
|
|
3205
|
+
cleanedWhere: subCleaned,
|
|
3206
|
+
joins: subJoins,
|
|
3207
|
+
allJoins: subAllJoins
|
|
3208
|
+
} = extractRelationshipJoins(
|
|
3209
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- reconstructed nested where
|
|
3210
|
+
{ [rootKey]: nested },
|
|
3211
|
+
fields,
|
|
3212
|
+
allCollections
|
|
3213
|
+
);
|
|
3214
|
+
if (subCleaned)
|
|
3215
|
+
Object.assign(cleanedWhere, subCleaned);
|
|
3216
|
+
joins.push(...subJoins);
|
|
3217
|
+
allJoins.push(...subAllJoins);
|
|
3218
|
+
continue;
|
|
3219
|
+
}
|
|
3220
|
+
}
|
|
3221
|
+
if (!field || field.type !== "relationship" || typeof condition !== "object" || condition === null) {
|
|
3222
|
+
cleanedWhere[key] = condition;
|
|
3223
|
+
continue;
|
|
3224
|
+
}
|
|
3225
|
+
const condObj = condition;
|
|
3226
|
+
const condKeys = Object.keys(condObj);
|
|
3227
|
+
const hasOperatorKeys = condKeys.some((k) => VALID_OPERATORS.has(k));
|
|
3228
|
+
const hasNonOperatorKeys = condKeys.some((k) => !VALID_OPERATORS.has(k));
|
|
3229
|
+
if (hasOperatorKeys && hasNonOperatorKeys) {
|
|
3230
|
+
throw new ValidationError([
|
|
3231
|
+
{
|
|
3232
|
+
field: key,
|
|
3233
|
+
message: `Cannot mix operators and sub-field references on relationship field "${key}". Use either operators (e.g. { equals: 'id' }) or sub-field queries (e.g. { name: { equals: 'value' } }), not both.`
|
|
3234
|
+
}
|
|
3235
|
+
]);
|
|
3236
|
+
}
|
|
3237
|
+
if (!hasNonOperatorKeys) {
|
|
3238
|
+
cleanedWhere[key] = condition;
|
|
3239
|
+
continue;
|
|
3240
|
+
}
|
|
3241
|
+
const relField = field;
|
|
3242
|
+
let targetSlug;
|
|
3243
|
+
try {
|
|
3244
|
+
const targetConfig = relField.collection();
|
|
3245
|
+
if (targetConfig && typeof targetConfig === "object" && "slug" in targetConfig) {
|
|
3246
|
+
targetSlug = targetConfig.slug;
|
|
3247
|
+
}
|
|
3248
|
+
} catch {
|
|
3249
|
+
}
|
|
3250
|
+
if (!targetSlug) {
|
|
3251
|
+
throw new ValidationError([
|
|
3252
|
+
{
|
|
3253
|
+
field: key,
|
|
3254
|
+
message: `Cannot resolve target collection for relationship field "${key}".`
|
|
3255
|
+
}
|
|
3256
|
+
]);
|
|
3257
|
+
}
|
|
3258
|
+
const targetCollection = allCollections.find((c) => c.slug === targetSlug);
|
|
3259
|
+
if (!targetCollection) {
|
|
3260
|
+
throw new ValidationError([
|
|
3261
|
+
{
|
|
3262
|
+
field: key,
|
|
3263
|
+
message: `Target collection "${targetSlug}" not found for relationship field "${key}".`
|
|
3264
|
+
}
|
|
3265
|
+
]);
|
|
3266
|
+
}
|
|
3267
|
+
const subWhere = condition;
|
|
3268
|
+
const flattenedConditions = flattenWhereClause(subWhere);
|
|
3269
|
+
const targetTable = targetCollection.dbName ?? targetCollection.slug;
|
|
3270
|
+
const joinSpec = {
|
|
3271
|
+
targetTable,
|
|
3272
|
+
localField: key,
|
|
3273
|
+
targetField: "id",
|
|
3274
|
+
conditions: flattenedConditions,
|
|
3275
|
+
rawWhere: subWhere
|
|
3276
|
+
};
|
|
3277
|
+
joins.push(joinSpec);
|
|
3278
|
+
allJoins.push(joinSpec);
|
|
3279
|
+
}
|
|
3280
|
+
return {
|
|
3281
|
+
cleanedWhere: Object.keys(cleanedWhere).length > 0 ? cleanedWhere : void 0,
|
|
3282
|
+
joins,
|
|
3283
|
+
allJoins
|
|
3284
|
+
};
|
|
3285
|
+
}
|
|
3286
|
+
var SYSTEM_QUERYABLE_FIELDS = /* @__PURE__ */ new Set(["id", "createdAt", "updatedAt", "_status"]);
|
|
3287
|
+
async function validateWhereFields(where, fields, req) {
|
|
3288
|
+
if (!where)
|
|
3289
|
+
return;
|
|
3290
|
+
const dataFields = flattenDataFields(fields);
|
|
3291
|
+
const fieldMap = new Map(dataFields.map((f) => [f.name, f]));
|
|
3292
|
+
for (const fieldName of Object.keys(where)) {
|
|
3293
|
+
if (fieldName === "and" || fieldName === "or") {
|
|
3294
|
+
const subs = where[fieldName];
|
|
3295
|
+
if (Array.isArray(subs)) {
|
|
3296
|
+
for (const sub of subs) {
|
|
3297
|
+
if (typeof sub === "object" && sub !== null) {
|
|
3298
|
+
await validateWhereFields(sub, fields, req);
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3302
|
+
continue;
|
|
3303
|
+
}
|
|
3304
|
+
const baseName = fieldName.includes(".") ? fieldName.split(".")[0] : fieldName;
|
|
3305
|
+
if (SYSTEM_QUERYABLE_FIELDS.has(baseName))
|
|
3306
|
+
continue;
|
|
3307
|
+
const field = fieldMap.get(baseName);
|
|
3308
|
+
if (!field) {
|
|
3309
|
+
throw new ValidationError([{ field: fieldName, message: `Unknown field: ${baseName}` }]);
|
|
3310
|
+
}
|
|
3311
|
+
if (!field.access?.read)
|
|
3312
|
+
continue;
|
|
3313
|
+
const allowed = await Promise.resolve(field.access.read({ req }));
|
|
3314
|
+
if (!allowed) {
|
|
3315
|
+
throw new AccessDeniedError("read", baseName);
|
|
3316
|
+
}
|
|
3317
|
+
}
|
|
3318
|
+
}
|
|
3319
|
+
async function validateSortField(sort, fields, req) {
|
|
3320
|
+
if (!sort)
|
|
3321
|
+
return;
|
|
3322
|
+
const fieldName = sort.startsWith("-") ? sort.slice(1) : sort;
|
|
3323
|
+
const baseName = fieldName.includes(".") ? fieldName.split(".")[0] : fieldName;
|
|
3324
|
+
const dataFields = flattenDataFields(fields);
|
|
3325
|
+
const field = dataFields.find((f) => f.name === baseName);
|
|
3326
|
+
if (!field?.access?.read)
|
|
3327
|
+
return;
|
|
3328
|
+
const allowed = await Promise.resolve(field.access.read({ req }));
|
|
3329
|
+
if (!allowed) {
|
|
3330
|
+
throw new AccessDeniedError("read", baseName);
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
|
|
3334
|
+
// libs/server-core/src/lib/api-utils.ts
|
|
3335
|
+
function deepEqual(a, b) {
|
|
3336
|
+
if (a === b)
|
|
3337
|
+
return true;
|
|
3338
|
+
if (a == null || b == null)
|
|
3339
|
+
return false;
|
|
3340
|
+
if (typeof a !== "object" || typeof b !== "object")
|
|
3341
|
+
return false;
|
|
3342
|
+
if (Array.isArray(a)) {
|
|
3343
|
+
if (!Array.isArray(b) || a.length !== b.length)
|
|
3344
|
+
return false;
|
|
3345
|
+
return a.every((item, i) => deepEqual(item, b[i]));
|
|
3346
|
+
}
|
|
3347
|
+
if (Array.isArray(b))
|
|
3348
|
+
return false;
|
|
3349
|
+
const aKeys = Object.keys(a);
|
|
3350
|
+
const bKeys = Object.keys(b);
|
|
3351
|
+
if (aKeys.length !== bKeys.length)
|
|
3352
|
+
return false;
|
|
3353
|
+
const aRec = a;
|
|
3354
|
+
const bRec = b;
|
|
3355
|
+
return aKeys.every(
|
|
3356
|
+
(key) => Object.prototype.hasOwnProperty.call(bRec, key) && deepEqual(aRec[key], bRec[key])
|
|
3357
|
+
);
|
|
3358
|
+
}
|
|
3359
|
+
function stripTransientKeys(data) {
|
|
3360
|
+
const result = {};
|
|
3361
|
+
for (const [key, value] of Object.entries(data)) {
|
|
3362
|
+
if (!key.startsWith("_")) {
|
|
3363
|
+
result[key] = value;
|
|
3364
|
+
}
|
|
3365
|
+
}
|
|
3366
|
+
return result;
|
|
3367
|
+
}
|
|
3368
|
+
|
|
3369
|
+
// libs/server-core/src/lib/collection-access.ts
|
|
3370
|
+
async function checkSingleCollectionAdminAccess(collection, user) {
|
|
3371
|
+
const adminFn = collection.access?.admin;
|
|
3372
|
+
if (!adminFn) {
|
|
3373
|
+
return !!user;
|
|
3374
|
+
}
|
|
3375
|
+
const accessArgs = {
|
|
3376
|
+
req: { user }
|
|
3377
|
+
};
|
|
3378
|
+
return Promise.resolve(adminFn(accessArgs));
|
|
3379
|
+
}
|
|
3380
|
+
async function checkAccessFunction(accessFn, user, defaultIfUndefined) {
|
|
3381
|
+
if (!accessFn) {
|
|
3382
|
+
return defaultIfUndefined;
|
|
3383
|
+
}
|
|
3384
|
+
const accessArgs = {
|
|
3385
|
+
req: { user }
|
|
3386
|
+
};
|
|
3387
|
+
return Promise.resolve(accessFn(accessArgs));
|
|
3388
|
+
}
|
|
3389
|
+
async function getCollectionPermissions(config, user) {
|
|
3390
|
+
const results = await Promise.all(
|
|
3391
|
+
config.collections.map(async (collection) => {
|
|
3392
|
+
const isManaged = collection.managed === true;
|
|
3393
|
+
const [canAccess, canCreate, canRead, canUpdate, canDelete] = await Promise.all([
|
|
3394
|
+
checkSingleCollectionAdminAccess(collection, user),
|
|
3395
|
+
isManaged ? Promise.resolve(false) : checkAccessFunction(collection.access?.create, user, !!user),
|
|
3396
|
+
checkAccessFunction(collection.access?.read, user, true),
|
|
3397
|
+
isManaged ? Promise.resolve(false) : checkAccessFunction(collection.access?.update, user, !!user),
|
|
3398
|
+
isManaged ? Promise.resolve(false) : checkAccessFunction(collection.access?.delete, user, !!user)
|
|
3399
|
+
]);
|
|
3400
|
+
return {
|
|
3401
|
+
slug: collection.slug,
|
|
3402
|
+
canAccess,
|
|
3403
|
+
canCreate,
|
|
3404
|
+
canRead,
|
|
3405
|
+
canUpdate,
|
|
3406
|
+
canDelete,
|
|
3407
|
+
...isManaged ? { managed: true } : {}
|
|
3408
|
+
};
|
|
3409
|
+
})
|
|
3410
|
+
);
|
|
3411
|
+
return results;
|
|
3412
|
+
}
|
|
3413
|
+
var ACCESS_OPS = ["read", "create", "update", "delete"];
|
|
3414
|
+
var ACCESS_DEFAULTS = {
|
|
3415
|
+
read: "public (anyone)",
|
|
3416
|
+
create: "any authenticated user",
|
|
3417
|
+
update: "any authenticated user",
|
|
3418
|
+
delete: "any authenticated user"
|
|
3419
|
+
};
|
|
3420
|
+
function warnInsecureDefaults(collections) {
|
|
3421
|
+
const logger = createLogger("Security");
|
|
3422
|
+
const warnings = [];
|
|
3423
|
+
for (const collection of collections) {
|
|
3424
|
+
if (collection.managed)
|
|
3425
|
+
continue;
|
|
3426
|
+
const missing = ACCESS_OPS.filter((op) => !collection.access?.[op]);
|
|
3427
|
+
if (missing.length > 0) {
|
|
3428
|
+
const details = missing.map((op) => `${op} (${ACCESS_DEFAULTS[op]})`).join(", ");
|
|
3429
|
+
const msg = `Collection "${collection.slug}" has no explicit access control for: ${details}. Define access functions to restrict.`;
|
|
3430
|
+
logger.warn(msg);
|
|
3431
|
+
warnings.push(msg);
|
|
3432
|
+
}
|
|
3433
|
+
}
|
|
3434
|
+
return warnings;
|
|
3435
|
+
}
|
|
3436
|
+
|
|
3001
3437
|
// libs/server-core/src/lib/momentum-api.ts
|
|
3002
3438
|
var momentumApiInstance = null;
|
|
3003
3439
|
function initializeMomentumAPI(config) {
|
|
@@ -3005,6 +3441,7 @@ function initializeMomentumAPI(config) {
|
|
|
3005
3441
|
createLogger("API").warn("Already initialized, returning existing instance");
|
|
3006
3442
|
return momentumApiInstance;
|
|
3007
3443
|
}
|
|
3444
|
+
warnInsecureDefaults(config.collections);
|
|
3008
3445
|
momentumApiInstance = new MomentumAPIImpl(config);
|
|
3009
3446
|
return momentumApiInstance;
|
|
3010
3447
|
}
|
|
@@ -3062,70 +3499,6 @@ var MomentumAPIImpl = class _MomentumAPIImpl {
|
|
|
3062
3499
|
return { ...this.context };
|
|
3063
3500
|
}
|
|
3064
3501
|
};
|
|
3065
|
-
function deepEqual(a, b) {
|
|
3066
|
-
if (a === b)
|
|
3067
|
-
return true;
|
|
3068
|
-
if (a == null || b == null)
|
|
3069
|
-
return false;
|
|
3070
|
-
if (typeof a !== "object" || typeof b !== "object")
|
|
3071
|
-
return false;
|
|
3072
|
-
if (Array.isArray(a)) {
|
|
3073
|
-
if (!Array.isArray(b) || a.length !== b.length)
|
|
3074
|
-
return false;
|
|
3075
|
-
return a.every((item, i) => deepEqual(item, b[i]));
|
|
3076
|
-
}
|
|
3077
|
-
if (Array.isArray(b))
|
|
3078
|
-
return false;
|
|
3079
|
-
const aKeys = Object.keys(a);
|
|
3080
|
-
const bKeys = Object.keys(b);
|
|
3081
|
-
if (aKeys.length !== bKeys.length)
|
|
3082
|
-
return false;
|
|
3083
|
-
const aRec = a;
|
|
3084
|
-
const bRec = b;
|
|
3085
|
-
return aKeys.every(
|
|
3086
|
-
(key) => Object.prototype.hasOwnProperty.call(bRec, key) && deepEqual(aRec[key], bRec[key])
|
|
3087
|
-
);
|
|
3088
|
-
}
|
|
3089
|
-
function stripTransientKeys(data) {
|
|
3090
|
-
const result = {};
|
|
3091
|
-
for (const [key, value] of Object.entries(data)) {
|
|
3092
|
-
if (!key.startsWith("_")) {
|
|
3093
|
-
result[key] = value;
|
|
3094
|
-
}
|
|
3095
|
-
}
|
|
3096
|
-
return result;
|
|
3097
|
-
}
|
|
3098
|
-
var COMPARISON_OPS = ["gt", "gte", "lt", "lte"];
|
|
3099
|
-
function flattenWhereClause(where) {
|
|
3100
|
-
if (!where)
|
|
3101
|
-
return {};
|
|
3102
|
-
const result = {};
|
|
3103
|
-
for (const [field, condition] of Object.entries(where)) {
|
|
3104
|
-
if (typeof condition !== "object" || condition === null) {
|
|
3105
|
-
result[field] = condition;
|
|
3106
|
-
continue;
|
|
3107
|
-
}
|
|
3108
|
-
const condObj = condition;
|
|
3109
|
-
if ("equals" in condObj) {
|
|
3110
|
-
result[field] = condObj["equals"];
|
|
3111
|
-
continue;
|
|
3112
|
-
}
|
|
3113
|
-
const ops = {};
|
|
3114
|
-
let hasComparisonOp = false;
|
|
3115
|
-
for (const op of COMPARISON_OPS) {
|
|
3116
|
-
if (op in condObj) {
|
|
3117
|
-
ops[`$${op}`] = condObj[op];
|
|
3118
|
-
hasComparisonOp = true;
|
|
3119
|
-
}
|
|
3120
|
-
}
|
|
3121
|
-
if (hasComparisonOp) {
|
|
3122
|
-
result[field] = ops;
|
|
3123
|
-
} else {
|
|
3124
|
-
result[field] = condition;
|
|
3125
|
-
}
|
|
3126
|
-
}
|
|
3127
|
-
return result;
|
|
3128
|
-
}
|
|
3129
3502
|
var CollectionOperationsImpl = class _CollectionOperationsImpl {
|
|
3130
3503
|
constructor(slug2, collectionConfig, adapter, context, allCollections = []) {
|
|
3131
3504
|
this.slug = slug2;
|
|
@@ -3136,11 +3509,37 @@ var CollectionOperationsImpl = class _CollectionOperationsImpl {
|
|
|
3136
3509
|
}
|
|
3137
3510
|
async find(options = {}) {
|
|
3138
3511
|
await this.checkAccess("read");
|
|
3512
|
+
if (!this.context.overrideAccess) {
|
|
3513
|
+
await validateWhereFields(
|
|
3514
|
+
options.where,
|
|
3515
|
+
this.collectionConfig.fields,
|
|
3516
|
+
this.buildRequestContext()
|
|
3517
|
+
);
|
|
3518
|
+
await validateSortField(
|
|
3519
|
+
options.sort,
|
|
3520
|
+
this.collectionConfig.fields,
|
|
3521
|
+
this.buildRequestContext()
|
|
3522
|
+
);
|
|
3523
|
+
}
|
|
3139
3524
|
await this.runBeforeReadHooks();
|
|
3140
|
-
const
|
|
3141
|
-
const
|
|
3525
|
+
const rawLimit = options.limit ?? 10;
|
|
3526
|
+
const rawPage = options.page ?? 1;
|
|
3527
|
+
const limit = Math.max(
|
|
3528
|
+
1,
|
|
3529
|
+
Math.min(Number.isFinite(rawLimit) ? Math.floor(rawLimit) : 10, MAX_PAGE_LIMIT)
|
|
3530
|
+
);
|
|
3531
|
+
const page = Math.max(
|
|
3532
|
+
1,
|
|
3533
|
+
Math.min(Number.isFinite(rawPage) ? Math.floor(rawPage) : 1, MAX_PAGE)
|
|
3534
|
+
);
|
|
3142
3535
|
const { depth: _depth, where, withDeleted: _wd, onlyDeleted: _od, ...queryOptions } = options;
|
|
3143
|
-
const
|
|
3536
|
+
const { cleanedWhere, joins, allJoins } = extractRelationshipJoins(
|
|
3537
|
+
where,
|
|
3538
|
+
this.collectionConfig.fields,
|
|
3539
|
+
this.allCollections
|
|
3540
|
+
);
|
|
3541
|
+
await this.enforceWhereLimits(cleanedWhere, allJoins);
|
|
3542
|
+
const whereParams = flattenWhereClause(cleanedWhere);
|
|
3144
3543
|
const softDeleteField = getSoftDeleteField(this.collectionConfig);
|
|
3145
3544
|
if (softDeleteField && !options.withDeleted && !options.onlyDeleted) {
|
|
3146
3545
|
whereParams[softDeleteField] = null;
|
|
@@ -3165,6 +3564,9 @@ var CollectionOperationsImpl = class _CollectionOperationsImpl {
|
|
|
3165
3564
|
limit,
|
|
3166
3565
|
page
|
|
3167
3566
|
};
|
|
3567
|
+
if (joins.length > 0) {
|
|
3568
|
+
query["$joins"] = joins.map(({ rawWhere: _rw, ...rest }) => rest);
|
|
3569
|
+
}
|
|
3168
3570
|
const docs = await this.adapter.find(this.slug, query);
|
|
3169
3571
|
let afterHookDocs = await this.processAfterReadHooks(docs);
|
|
3170
3572
|
const MAX_RELATIONSHIP_DEPTH = 10;
|
|
@@ -3188,14 +3590,15 @@ var CollectionOperationsImpl = class _CollectionOperationsImpl {
|
|
|
3188
3590
|
);
|
|
3189
3591
|
}
|
|
3190
3592
|
const countQuery = { ...queryOptions, ...whereParams };
|
|
3593
|
+
if (joins.length > 0) {
|
|
3594
|
+
countQuery["$joins"] = joins.map(({ rawWhere: _rw, ...rest }) => rest);
|
|
3595
|
+
}
|
|
3191
3596
|
delete countQuery["limit"];
|
|
3192
3597
|
delete countQuery["page"];
|
|
3193
|
-
const
|
|
3194
|
-
|
|
3195
|
-
limit: 0
|
|
3196
|
-
|
|
3197
|
-
});
|
|
3198
|
-
const totalDocs = allDocs.length;
|
|
3598
|
+
const totalDocs = this.adapter.count ? await this.adapter.count(this.slug, countQuery) : (
|
|
3599
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Adapter returns Record<string, unknown>[], safe cast to T[]
|
|
3600
|
+
(await this.adapter.find(this.slug, { ...countQuery, limit: 0 })).length
|
|
3601
|
+
);
|
|
3199
3602
|
const totalPages = Math.ceil(totalDocs / limit) || 1;
|
|
3200
3603
|
return {
|
|
3201
3604
|
docs: afterHookDocs,
|
|
@@ -3432,7 +3835,12 @@ var CollectionOperationsImpl = class _CollectionOperationsImpl {
|
|
|
3432
3835
|
async restore(id) {
|
|
3433
3836
|
const softDeleteField = getSoftDeleteField(this.collectionConfig);
|
|
3434
3837
|
if (!softDeleteField) {
|
|
3435
|
-
throw new
|
|
3838
|
+
throw new ValidationError([
|
|
3839
|
+
{
|
|
3840
|
+
field: "_softDelete",
|
|
3841
|
+
message: `Collection "${this.slug}" does not have soft delete enabled`
|
|
3842
|
+
}
|
|
3843
|
+
]);
|
|
3436
3844
|
}
|
|
3437
3845
|
const restoreAccessFn = this.collectionConfig.access?.restore;
|
|
3438
3846
|
if (restoreAccessFn) {
|
|
@@ -3490,20 +3898,43 @@ var CollectionOperationsImpl = class _CollectionOperationsImpl {
|
|
|
3490
3898
|
if (softDeleteField) {
|
|
3491
3899
|
softDeleteFilter[softDeleteField] = null;
|
|
3492
3900
|
}
|
|
3901
|
+
const defaultWhereFilter = {};
|
|
3902
|
+
if (this.collectionConfig.defaultWhere) {
|
|
3903
|
+
const constraints = this.collectionConfig.defaultWhere(this.buildRequestContext());
|
|
3904
|
+
if (constraints) {
|
|
3905
|
+
Object.assign(defaultWhereFilter, constraints);
|
|
3906
|
+
}
|
|
3907
|
+
}
|
|
3493
3908
|
let docs;
|
|
3494
3909
|
if (this.adapter.search) {
|
|
3495
3910
|
docs = await this.adapter.search(this.slug, query, searchFields, { limit, page });
|
|
3496
3911
|
if (softDeleteField) {
|
|
3497
3912
|
docs = docs.filter((doc) => !doc[softDeleteField]);
|
|
3498
3913
|
}
|
|
3914
|
+
if (Object.keys(defaultWhereFilter).length > 0) {
|
|
3915
|
+
docs = docs.filter((doc) => {
|
|
3916
|
+
return Object.entries(defaultWhereFilter).every(([key, value]) => {
|
|
3917
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
3918
|
+
return JSON.stringify(doc[key]) === JSON.stringify(value);
|
|
3919
|
+
}
|
|
3920
|
+
return doc[key] === value;
|
|
3921
|
+
});
|
|
3922
|
+
});
|
|
3923
|
+
}
|
|
3499
3924
|
} else {
|
|
3500
|
-
docs = await this.adapter.find(this.slug, {
|
|
3925
|
+
docs = await this.adapter.find(this.slug, {
|
|
3926
|
+
...softDeleteFilter,
|
|
3927
|
+
...defaultWhereFilter,
|
|
3928
|
+
limit,
|
|
3929
|
+
page
|
|
3930
|
+
});
|
|
3501
3931
|
}
|
|
3502
3932
|
const resolvedDocs = docs;
|
|
3503
|
-
const
|
|
3933
|
+
const afterHookDocs = await this.processAfterReadHooks(resolvedDocs);
|
|
3934
|
+
const totalDocs = afterHookDocs.length;
|
|
3504
3935
|
const totalPages = Math.max(1, Math.ceil(totalDocs / limit));
|
|
3505
3936
|
return {
|
|
3506
|
-
docs:
|
|
3937
|
+
docs: afterHookDocs,
|
|
3507
3938
|
totalDocs,
|
|
3508
3939
|
totalPages,
|
|
3509
3940
|
page,
|
|
@@ -3516,13 +3947,40 @@ var CollectionOperationsImpl = class _CollectionOperationsImpl {
|
|
|
3516
3947
|
}
|
|
3517
3948
|
async count(where, options) {
|
|
3518
3949
|
await this.checkAccess("read");
|
|
3519
|
-
|
|
3950
|
+
if (!this.context.overrideAccess) {
|
|
3951
|
+
await validateWhereFields(where, this.collectionConfig.fields, this.buildRequestContext());
|
|
3952
|
+
}
|
|
3953
|
+
const { cleanedWhere, joins, allJoins } = extractRelationshipJoins(
|
|
3954
|
+
where,
|
|
3955
|
+
this.collectionConfig.fields,
|
|
3956
|
+
this.allCollections
|
|
3957
|
+
);
|
|
3958
|
+
await this.enforceWhereLimits(cleanedWhere, allJoins);
|
|
3959
|
+
const whereParams = flattenWhereClause(cleanedWhere);
|
|
3520
3960
|
const softDeleteField = getSoftDeleteField(this.collectionConfig);
|
|
3521
3961
|
if (softDeleteField && !options?.withDeleted) {
|
|
3522
3962
|
whereParams[softDeleteField] = null;
|
|
3523
3963
|
}
|
|
3524
|
-
|
|
3525
|
-
|
|
3964
|
+
if (this.collectionConfig.defaultWhere) {
|
|
3965
|
+
const constraints = this.collectionConfig.defaultWhere(this.buildRequestContext());
|
|
3966
|
+
if (constraints) {
|
|
3967
|
+
Object.assign(whereParams, constraints);
|
|
3968
|
+
}
|
|
3969
|
+
}
|
|
3970
|
+
if (hasVersionDrafts(this.collectionConfig) && !this.context.overrideAccess) {
|
|
3971
|
+
const canSeeDrafts = await this.canReadDrafts();
|
|
3972
|
+
if (!canSeeDrafts) {
|
|
3973
|
+
whereParams["_status"] = "published";
|
|
3974
|
+
}
|
|
3975
|
+
}
|
|
3976
|
+
const query = { ...whereParams };
|
|
3977
|
+
if (joins.length > 0) {
|
|
3978
|
+
query["$joins"] = joins.map(({ rawWhere: _rw, ...rest }) => rest);
|
|
3979
|
+
}
|
|
3980
|
+
if (this.adapter.count) {
|
|
3981
|
+
return this.adapter.count(this.slug, query);
|
|
3982
|
+
}
|
|
3983
|
+
const docs = await this.adapter.find(this.slug, { ...query, limit: 0 });
|
|
3526
3984
|
return docs.length;
|
|
3527
3985
|
}
|
|
3528
3986
|
async batchCreate(items) {
|
|
@@ -3682,6 +4140,46 @@ var CollectionOperationsImpl = class _CollectionOperationsImpl {
|
|
|
3682
4140
|
return false;
|
|
3683
4141
|
}
|
|
3684
4142
|
}
|
|
4143
|
+
async enforceWhereLimits(cleanedWhere, joins) {
|
|
4144
|
+
if (joins.length > MAX_JOINS) {
|
|
4145
|
+
throw new ValidationError([
|
|
4146
|
+
{
|
|
4147
|
+
field: "where",
|
|
4148
|
+
message: `Number of relationship joins (${joins.length}) exceeds maximum of ${MAX_JOINS}.`
|
|
4149
|
+
}
|
|
4150
|
+
]);
|
|
4151
|
+
}
|
|
4152
|
+
const mainCount = cleanedWhere ? countWhereConditions(cleanedWhere) : 0;
|
|
4153
|
+
const joinCount = joins.reduce((sum, j) => sum + countWhereConditions(j.rawWhere), 0);
|
|
4154
|
+
const totalConditions = mainCount + joinCount;
|
|
4155
|
+
if (totalConditions > MAX_WHERE_CONDITIONS) {
|
|
4156
|
+
throw new ValidationError([
|
|
4157
|
+
{
|
|
4158
|
+
field: "where",
|
|
4159
|
+
message: `Where clause exceeds maximum of ${MAX_WHERE_CONDITIONS} conditions (got ${totalConditions} across main query and ${joins.length} join(s)).`
|
|
4160
|
+
}
|
|
4161
|
+
]);
|
|
4162
|
+
}
|
|
4163
|
+
if (!this.context.overrideAccess) {
|
|
4164
|
+
for (const join2 of joins) {
|
|
4165
|
+
const targetCol = this.allCollections.find(
|
|
4166
|
+
(c) => (c.dbName ?? c.slug) === join2.targetTable
|
|
4167
|
+
);
|
|
4168
|
+
if (targetCol) {
|
|
4169
|
+
const collectionAccessFn = targetCol.access?.read;
|
|
4170
|
+
if (collectionAccessFn) {
|
|
4171
|
+
const allowed = await Promise.resolve(
|
|
4172
|
+
collectionAccessFn({ req: this.buildRequestContext() })
|
|
4173
|
+
);
|
|
4174
|
+
if (!allowed) {
|
|
4175
|
+
throw new AccessDeniedError("read", targetCol.slug);
|
|
4176
|
+
}
|
|
4177
|
+
}
|
|
4178
|
+
await validateWhereFields(join2.rawWhere, targetCol.fields, this.buildRequestContext());
|
|
4179
|
+
}
|
|
4180
|
+
}
|
|
4181
|
+
}
|
|
4182
|
+
}
|
|
3685
4183
|
buildRequestContext() {
|
|
3686
4184
|
return {
|
|
3687
4185
|
user: this.context.user
|
|
@@ -4090,7 +4588,14 @@ function createMomentumHandlers(config) {
|
|
|
4090
4588
|
});
|
|
4091
4589
|
return {
|
|
4092
4590
|
docs: result.docs,
|
|
4093
|
-
totalDocs: result.totalDocs
|
|
4591
|
+
totalDocs: result.totalDocs,
|
|
4592
|
+
totalPages: result.totalPages,
|
|
4593
|
+
page: result.page,
|
|
4594
|
+
limit: result.limit,
|
|
4595
|
+
hasNextPage: result.hasNextPage,
|
|
4596
|
+
hasPrevPage: result.hasPrevPage,
|
|
4597
|
+
nextPage: result.nextPage,
|
|
4598
|
+
prevPage: result.prevPage
|
|
4094
4599
|
};
|
|
4095
4600
|
} catch (error) {
|
|
4096
4601
|
return handleError(error);
|
|
@@ -4178,7 +4683,14 @@ function createMomentumHandlers(config) {
|
|
|
4178
4683
|
const result = await api.collection(request.collectionSlug).search(q, { fields, limit, page });
|
|
4179
4684
|
return {
|
|
4180
4685
|
docs: result.docs,
|
|
4181
|
-
totalDocs: result.totalDocs
|
|
4686
|
+
totalDocs: result.totalDocs,
|
|
4687
|
+
totalPages: result.totalPages,
|
|
4688
|
+
page: result.page,
|
|
4689
|
+
limit: result.limit,
|
|
4690
|
+
hasNextPage: result.hasNextPage,
|
|
4691
|
+
hasPrevPage: result.hasPrevPage,
|
|
4692
|
+
nextPage: result.nextPage,
|
|
4693
|
+
prevPage: result.prevPage
|
|
4182
4694
|
};
|
|
4183
4695
|
} catch (error) {
|
|
4184
4696
|
return handleError(error);
|
|
@@ -4243,51 +4755,6 @@ function handleError(error) {
|
|
|
4243
4755
|
return { error: "Unknown error", status: 500 };
|
|
4244
4756
|
}
|
|
4245
4757
|
|
|
4246
|
-
// libs/server-core/src/lib/collection-access.ts
|
|
4247
|
-
async function checkSingleCollectionAdminAccess(collection, user) {
|
|
4248
|
-
const adminFn = collection.access?.admin;
|
|
4249
|
-
if (!adminFn) {
|
|
4250
|
-
return !!user;
|
|
4251
|
-
}
|
|
4252
|
-
const accessArgs = {
|
|
4253
|
-
req: { user }
|
|
4254
|
-
};
|
|
4255
|
-
return Promise.resolve(adminFn(accessArgs));
|
|
4256
|
-
}
|
|
4257
|
-
async function checkAccessFunction(accessFn, user, defaultIfUndefined) {
|
|
4258
|
-
if (!accessFn) {
|
|
4259
|
-
return defaultIfUndefined;
|
|
4260
|
-
}
|
|
4261
|
-
const accessArgs = {
|
|
4262
|
-
req: { user }
|
|
4263
|
-
};
|
|
4264
|
-
return Promise.resolve(accessFn(accessArgs));
|
|
4265
|
-
}
|
|
4266
|
-
async function getCollectionPermissions(config, user) {
|
|
4267
|
-
const results = await Promise.all(
|
|
4268
|
-
config.collections.map(async (collection) => {
|
|
4269
|
-
const isManaged = collection.managed === true;
|
|
4270
|
-
const [canAccess, canCreate, canRead, canUpdate, canDelete] = await Promise.all([
|
|
4271
|
-
checkSingleCollectionAdminAccess(collection, user),
|
|
4272
|
-
isManaged ? Promise.resolve(false) : checkAccessFunction(collection.access?.create, user, !!user),
|
|
4273
|
-
checkAccessFunction(collection.access?.read, user, true),
|
|
4274
|
-
isManaged ? Promise.resolve(false) : checkAccessFunction(collection.access?.update, user, !!user),
|
|
4275
|
-
isManaged ? Promise.resolve(false) : checkAccessFunction(collection.access?.delete, user, !!user)
|
|
4276
|
-
]);
|
|
4277
|
-
return {
|
|
4278
|
-
slug: collection.slug,
|
|
4279
|
-
canAccess,
|
|
4280
|
-
canCreate,
|
|
4281
|
-
canRead,
|
|
4282
|
-
canUpdate,
|
|
4283
|
-
canDelete,
|
|
4284
|
-
...isManaged ? { managed: true } : {}
|
|
4285
|
-
};
|
|
4286
|
-
})
|
|
4287
|
-
);
|
|
4288
|
-
return results;
|
|
4289
|
-
}
|
|
4290
|
-
|
|
4291
4758
|
// libs/server-core/src/lib/seeding/seed-tracker.ts
|
|
4292
4759
|
import { randomUUID } from "node:crypto";
|
|
4293
4760
|
function createSeedTracker(adapter) {
|
|
@@ -5333,7 +5800,7 @@ function checkDepth(node, currentDepth, maxDepth, context) {
|
|
|
5333
5800
|
}
|
|
5334
5801
|
}
|
|
5335
5802
|
}
|
|
5336
|
-
async function executeGraphQL(schema, requestBody, context) {
|
|
5803
|
+
async function executeGraphQL(schema, requestBody, context, options) {
|
|
5337
5804
|
if (!requestBody.query) {
|
|
5338
5805
|
return {
|
|
5339
5806
|
status: 400,
|
|
@@ -5349,6 +5816,17 @@ async function executeGraphQL(schema, requestBody, context) {
|
|
|
5349
5816
|
body: { errors: [{ message: "Query parsing failed" }] }
|
|
5350
5817
|
};
|
|
5351
5818
|
}
|
|
5819
|
+
if (options?.readOnly) {
|
|
5820
|
+
const hasMutation = document.definitions.some(
|
|
5821
|
+
(def) => def.kind === "OperationDefinition" && def.operation === "mutation"
|
|
5822
|
+
);
|
|
5823
|
+
if (hasMutation) {
|
|
5824
|
+
return {
|
|
5825
|
+
status: 405,
|
|
5826
|
+
body: { errors: [{ message: "Mutations are not allowed via GET requests" }] }
|
|
5827
|
+
};
|
|
5828
|
+
}
|
|
5829
|
+
}
|
|
5352
5830
|
const depthErrors = validate(schema, document, [depthLimitRule(MAX_QUERY_DEPTH)]);
|
|
5353
5831
|
if (depthErrors.length > 0) {
|
|
5354
5832
|
return {
|
|
@@ -6768,24 +7246,29 @@ function momentumApiMiddleware(config) {
|
|
|
6768
7246
|
const router = Router();
|
|
6769
7247
|
const handlers = createMomentumHandlers(config);
|
|
6770
7248
|
router.use(jsonParser());
|
|
7249
|
+
router.use((_req, res, next) => {
|
|
7250
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
7251
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
7252
|
+
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
7253
|
+
res.setHeader("X-Permitted-Cross-Domain-Policies", "none");
|
|
7254
|
+
next();
|
|
7255
|
+
});
|
|
6771
7256
|
router.use((req, res, next) => {
|
|
6772
7257
|
const corsConfig = config.server?.cors ?? {};
|
|
6773
7258
|
const origins = Array.isArray(corsConfig.origin) ? corsConfig.origin : corsConfig.origin ? [corsConfig.origin] : [];
|
|
6774
|
-
|
|
6775
|
-
|
|
6776
|
-
|
|
7259
|
+
if (origins.length === 0 || origins.includes("*")) {
|
|
7260
|
+
if (process.env["NODE_ENV"] === "production") {
|
|
7261
|
+
createLogger("CORS").warn(
|
|
7262
|
+
'Origin is set to "*" in production. Configure explicit origins via config.server.cors.origin.'
|
|
7263
|
+
);
|
|
7264
|
+
}
|
|
7265
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
6777
7266
|
} else {
|
|
6778
7267
|
const requestOrigin = req.headers["origin"] ?? "";
|
|
6779
|
-
allowOrigin = origins.includes(requestOrigin) ? requestOrigin : origins[0];
|
|
6780
|
-
}
|
|
6781
|
-
if (allowOrigin === "*" && process.env["NODE_ENV"] === "production") {
|
|
6782
|
-
createLogger("CORS").warn(
|
|
6783
|
-
'Origin is set to "*" in production. Configure explicit origins via config.server.cors.origin.'
|
|
6784
|
-
);
|
|
6785
|
-
}
|
|
6786
|
-
res.setHeader("Access-Control-Allow-Origin", allowOrigin);
|
|
6787
|
-
if (allowOrigin !== "*") {
|
|
6788
7268
|
res.setHeader("Vary", "Origin");
|
|
7269
|
+
if (origins.includes(requestOrigin)) {
|
|
7270
|
+
res.setHeader("Access-Control-Allow-Origin", requestOrigin);
|
|
7271
|
+
}
|
|
6789
7272
|
}
|
|
6790
7273
|
res.setHeader(
|
|
6791
7274
|
"Access-Control-Allow-Methods",
|
|
@@ -6868,7 +7351,12 @@ function momentumApiMiddleware(config) {
|
|
|
6868
7351
|
res.status(400).json({ errors: [{ message: "Query parameter required" }] });
|
|
6869
7352
|
return;
|
|
6870
7353
|
}
|
|
6871
|
-
const result = await executeGraphQL(
|
|
7354
|
+
const result = await executeGraphQL(
|
|
7355
|
+
graphqlSchema,
|
|
7356
|
+
{ query: queryParam },
|
|
7357
|
+
{ user },
|
|
7358
|
+
{ readOnly: true }
|
|
7359
|
+
);
|
|
6872
7360
|
res.status(result.status).json(result.body);
|
|
6873
7361
|
});
|
|
6874
7362
|
router.get("/globals/:slug", async (req, res) => {
|
|
@@ -7201,7 +7689,14 @@ function momentumApiMiddleware(config) {
|
|
|
7201
7689
|
res.json({ status });
|
|
7202
7690
|
} catch (error) {
|
|
7203
7691
|
const message = sanitizeErrorMessage(error, "Unknown error");
|
|
7204
|
-
|
|
7692
|
+
let status = 500;
|
|
7693
|
+
if (error instanceof Error) {
|
|
7694
|
+
if (error.name === "AccessDeniedError")
|
|
7695
|
+
status = 403;
|
|
7696
|
+
else if (error.name === "DocumentNotFoundError")
|
|
7697
|
+
status = 404;
|
|
7698
|
+
}
|
|
7699
|
+
res.status(status).json({ error: status === 403 ? "Access denied" : "Failed to get status", message });
|
|
7205
7700
|
}
|
|
7206
7701
|
});
|
|
7207
7702
|
async function handlePreviewRequest(req, res) {
|
|
@@ -7390,8 +7885,11 @@ function momentumApiMiddleware(config) {
|
|
|
7390
7885
|
return { docs, totalDocs: docs.length };
|
|
7391
7886
|
},
|
|
7392
7887
|
findById: (slug2, id) => txAdapter.findById(slug2, id),
|
|
7393
|
-
count: async (slug2) => {
|
|
7394
|
-
|
|
7888
|
+
count: async (slug2, where) => {
|
|
7889
|
+
if (txAdapter.count) {
|
|
7890
|
+
return txAdapter.count(slug2, where ?? {});
|
|
7891
|
+
}
|
|
7892
|
+
const docs = await txAdapter.find(slug2, where ?? {});
|
|
7395
7893
|
return docs.length;
|
|
7396
7894
|
},
|
|
7397
7895
|
create: (slug2, data) => txAdapter.create(slug2, data),
|
|
@@ -7433,7 +7931,7 @@ function momentumApiMiddleware(config) {
|
|
|
7433
7931
|
throw err;
|
|
7434
7932
|
}
|
|
7435
7933
|
},
|
|
7436
|
-
count: (slug2) => ctxApi.collection(slug2).count(),
|
|
7934
|
+
count: (slug2, where) => ctxApi.collection(slug2).count(where),
|
|
7437
7935
|
create: async (slug2, data) => {
|
|
7438
7936
|
return await ctxApi.collection(slug2).create(data);
|
|
7439
7937
|
},
|
|
@@ -7456,6 +7954,8 @@ function momentumApiMiddleware(config) {
|
|
|
7456
7954
|
collection,
|
|
7457
7955
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Express body is parsed JSON
|
|
7458
7956
|
body: req.body,
|
|
7957
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Express query params
|
|
7958
|
+
params: req.query,
|
|
7459
7959
|
query: buildQueryHelper(contextApi)
|
|
7460
7960
|
});
|
|
7461
7961
|
res.status(result.status).json(result.body);
|
|
@@ -7523,7 +8023,15 @@ function momentumApiMiddleware(config) {
|
|
|
7523
8023
|
}
|
|
7524
8024
|
} catch (error) {
|
|
7525
8025
|
const message = sanitizeErrorMessage(error, "Batch operation failed");
|
|
7526
|
-
|
|
8026
|
+
let status = 500;
|
|
8027
|
+
if (error instanceof Error) {
|
|
8028
|
+
if (error.name === "ValidationError")
|
|
8029
|
+
status = 400;
|
|
8030
|
+
else if (error.name === "DocumentNotFoundError")
|
|
8031
|
+
status = 404;
|
|
8032
|
+
else if (error.name === "AccessDeniedError")
|
|
8033
|
+
status = 403;
|
|
8034
|
+
}
|
|
7527
8035
|
res.status(status).json({ error: message });
|
|
7528
8036
|
}
|
|
7529
8037
|
});
|
|
@@ -7582,7 +8090,16 @@ function momentumApiMiddleware(config) {
|
|
|
7582
8090
|
}
|
|
7583
8091
|
} catch (error) {
|
|
7584
8092
|
const message = sanitizeErrorMessage(error, "Export failed");
|
|
7585
|
-
|
|
8093
|
+
let status = 500;
|
|
8094
|
+
if (error instanceof Error) {
|
|
8095
|
+
if (error.name === "AccessDeniedError")
|
|
8096
|
+
status = 403;
|
|
8097
|
+
else if (error.name === "ValidationError")
|
|
8098
|
+
status = 400;
|
|
8099
|
+
else if (error.name === "DocumentNotFoundError")
|
|
8100
|
+
status = 404;
|
|
8101
|
+
}
|
|
8102
|
+
res.status(status).json({ error: message });
|
|
7586
8103
|
}
|
|
7587
8104
|
});
|
|
7588
8105
|
router.post("/:collection/import", async (req, res) => {
|
|
@@ -8380,6 +8897,7 @@ function createSetupMiddleware(config) {
|
|
|
8380
8897
|
const dbConfig = isLegacyConfig(config) ? { type: "sqlite", database: config.database } : config.db;
|
|
8381
8898
|
const { auth } = config;
|
|
8382
8899
|
const router = createRouter2();
|
|
8900
|
+
let setupInProgress = false;
|
|
8383
8901
|
router.get("/setup/status", async (_req, res) => {
|
|
8384
8902
|
let hasUsers;
|
|
8385
8903
|
if (dbConfig.type === "sqlite") {
|
|
@@ -8394,6 +8912,13 @@ function createSetupMiddleware(config) {
|
|
|
8394
8912
|
res.json(status);
|
|
8395
8913
|
});
|
|
8396
8914
|
router.post("/setup/create-admin", async (req, res) => {
|
|
8915
|
+
if (setupInProgress) {
|
|
8916
|
+
res.status(409).json({
|
|
8917
|
+
error: { message: "Setup is already in progress." }
|
|
8918
|
+
});
|
|
8919
|
+
return;
|
|
8920
|
+
}
|
|
8921
|
+
setupInProgress = true;
|
|
8397
8922
|
try {
|
|
8398
8923
|
let hasUsers;
|
|
8399
8924
|
if (dbConfig.type === "sqlite") {
|
|
@@ -8470,9 +8995,10 @@ function createSetupMiddleware(config) {
|
|
|
8470
8995
|
updatedAt: userRow.updatedAt
|
|
8471
8996
|
}
|
|
8472
8997
|
});
|
|
8473
|
-
} catch
|
|
8474
|
-
|
|
8475
|
-
|
|
8998
|
+
} catch {
|
|
8999
|
+
res.status(500).json({ error: { message: "Failed to create admin user" } });
|
|
9000
|
+
} finally {
|
|
9001
|
+
setupInProgress = false;
|
|
8476
9002
|
}
|
|
8477
9003
|
});
|
|
8478
9004
|
return router;
|
|
@@ -8548,9 +9074,8 @@ function createApiKeyRoutes(config) {
|
|
|
8548
9074
|
return;
|
|
8549
9075
|
}
|
|
8550
9076
|
const role = body.role ?? "user";
|
|
8551
|
-
|
|
8552
|
-
|
|
8553
|
-
res.status(400).json({ error: `Invalid role. Must be one of: ${validRoles.join(", ")}` });
|
|
9077
|
+
if (!ROLE_HIERARCHY.includes(role)) {
|
|
9078
|
+
res.status(400).json({ error: `Invalid role. Must be one of: ${ROLE_HIERARCHY.join(", ")}` });
|
|
8554
9079
|
return;
|
|
8555
9080
|
}
|
|
8556
9081
|
const userRoleIndex = ROLE_HIERARCHY.indexOf(user.role ?? "viewer");
|
|
@@ -8616,7 +9141,7 @@ function createApiKeyRoutes(config) {
|
|
|
8616
9141
|
return;
|
|
8617
9142
|
}
|
|
8618
9143
|
if (existingKey.createdBy !== user.id) {
|
|
8619
|
-
res.status(
|
|
9144
|
+
res.status(404).json({ error: "API key not found" });
|
|
8620
9145
|
return;
|
|
8621
9146
|
}
|
|
8622
9147
|
}
|
|
@@ -8887,6 +9412,20 @@ function createHealthMiddleware(options = {}) {
|
|
|
8887
9412
|
return router;
|
|
8888
9413
|
}
|
|
8889
9414
|
|
|
9415
|
+
// libs/server-express/src/lib/rate-limit-middleware.ts
|
|
9416
|
+
function createRateLimitMiddleware(limiter) {
|
|
9417
|
+
return (req, res, next) => {
|
|
9418
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
9419
|
+
const key = (forwarded ? forwarded.toString().split(",")[0].trim() : req.ip) ?? "unknown";
|
|
9420
|
+
if (!limiter.isAllowed(key)) {
|
|
9421
|
+
res.setHeader("Retry-After", "60");
|
|
9422
|
+
res.status(429).json({ error: "Too many requests. Please try again later." });
|
|
9423
|
+
return;
|
|
9424
|
+
}
|
|
9425
|
+
next();
|
|
9426
|
+
};
|
|
9427
|
+
}
|
|
9428
|
+
|
|
8890
9429
|
// libs/server-express/src/lib/create-momentum-server.ts
|
|
8891
9430
|
import express from "express";
|
|
8892
9431
|
async function createMomentumServer(options) {
|
|
@@ -8972,6 +9511,7 @@ export {
|
|
|
8972
9511
|
createMomentumServer,
|
|
8973
9512
|
createOpenAPIMiddleware,
|
|
8974
9513
|
createProtectMiddleware,
|
|
9514
|
+
createRateLimitMiddleware,
|
|
8975
9515
|
createSessionResolverMiddleware,
|
|
8976
9516
|
createSetupMiddleware,
|
|
8977
9517
|
getPluginMiddleware,
|