@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/index.js CHANGED
@@ -155,7 +155,7 @@ function s3StorageAdapter(options) {
155
155
  baseUrl,
156
156
  forcePathStyle = false,
157
157
  acl = "private",
158
- presignedUrlExpiry = 3600
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: parsedVersion
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 limit = options.limit ?? 10;
3141
- const page = options.page ?? 1;
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 whereParams = flattenWhereClause(where);
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 allDocs = await this.adapter.find(this.slug, {
3194
- ...countQuery,
3195
- limit: 0
3196
- // Signal to adapter: count-only (returns all if not supported)
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 Error(`Collection "${this.slug}" does not have soft delete enabled`);
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, { ...softDeleteFilter, limit, page });
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 totalDocs = resolvedDocs.length;
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: resolvedDocs,
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
- const whereParams = flattenWhereClause(where);
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
- const query = { ...whereParams, limit: 0 };
3525
- const docs = await this.adapter.find(this.slug, query);
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
- let allowOrigin;
6775
- if (origins.length === 0) {
6776
- allowOrigin = "*";
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(graphqlSchema, { query: queryParam }, { user });
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
- res.status(500).json({ error: "Failed to get status", message });
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
- const docs = await txAdapter.find(slug2, {});
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
- const status = error instanceof Error && error.name === "ValidationError" ? 400 : 500;
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
- res.status(500).json({ error: message });
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 (error) {
8474
- const message = error instanceof Error ? error.message : "Failed to create admin user";
8475
- res.status(500).json({ error: { message } });
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
- const validRoles = ["admin", "editor", "user", "viewer"];
8552
- if (!validRoles.includes(role)) {
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(403).json({ error: "You can only delete your own API keys" });
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,