@momentumcms/server-analog 0.5.9 → 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.
Files changed (4) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/index.cjs +610 -132
  3. package/index.js +610 -132
  4. package/package.json +33 -44
package/index.js CHANGED
@@ -145,7 +145,7 @@ function s3StorageAdapter(options) {
145
145
  baseUrl,
146
146
  forcePathStyle = false,
147
147
  acl = "private",
148
- presignedUrlExpiry = 3600
148
+ presignedUrlExpiry = 900
149
149
  } = options;
150
150
  let client = null;
151
151
  async function getClient() {
@@ -1854,6 +1854,21 @@ var VersionOperationsImpl = class {
1854
1854
  });
1855
1855
  }
1856
1856
  }
1857
+ if (!this.context.overrideAccess && hasFieldAccessControl(this.collectionConfig.fields)) {
1858
+ for (let i = 0; i < docs.length; i++) {
1859
+ const version = docs[i].version;
1860
+ if (version && typeof version === "object") {
1861
+ const versionRecord = version;
1862
+ const filtered = await filterReadableFields(
1863
+ this.collectionConfig.fields,
1864
+ versionRecord,
1865
+ this.buildRequestContext()
1866
+ );
1867
+ const filteredAsT = filtered;
1868
+ docs[i] = { ...docs[i], version: filteredAsT };
1869
+ }
1870
+ }
1871
+ }
1857
1872
  const countOptions = {
1858
1873
  includeAutosave: options?.includeAutosave,
1859
1874
  status: options?.status
@@ -1883,9 +1898,21 @@ var VersionOperationsImpl = class {
1883
1898
  if (parsedVersion === null) {
1884
1899
  return null;
1885
1900
  }
1901
+ let filteredVersion = parsedVersion;
1902
+ if (!this.context.overrideAccess && hasFieldAccessControl(this.collectionConfig.fields)) {
1903
+ if (filteredVersion && typeof filteredVersion === "object") {
1904
+ const versionRecord = filteredVersion;
1905
+ const filtered = await filterReadableFields(
1906
+ this.collectionConfig.fields,
1907
+ versionRecord,
1908
+ this.buildRequestContext()
1909
+ );
1910
+ filteredVersion = filtered;
1911
+ }
1912
+ }
1886
1913
  return {
1887
1914
  ...version,
1888
- version: parsedVersion
1915
+ version: filteredVersion
1889
1916
  };
1890
1917
  }
1891
1918
  async restore(options) {
@@ -2107,6 +2134,415 @@ var VersionOperationsImpl = class {
2107
2134
  }
2108
2135
  };
2109
2136
 
2137
+ // libs/server-core/src/lib/where-clause.ts
2138
+ var OPERATOR_MAP = {
2139
+ equals: "$eq",
2140
+ gt: "$gt",
2141
+ gte: "$gte",
2142
+ lt: "$lt",
2143
+ lte: "$lte",
2144
+ not_equals: "$ne",
2145
+ like: "$like",
2146
+ contains: "$contains",
2147
+ in: "$in",
2148
+ not_in: "$nin",
2149
+ exists: "$exists"
2150
+ };
2151
+ var MAX_WHERE_CONDITIONS = 20;
2152
+ var MAX_JOINS = 5;
2153
+ var MAX_WHERE_NESTING_DEPTH = 5;
2154
+ var MAX_PAGE_LIMIT = 1e3;
2155
+ var MAX_PAGE = 1e6;
2156
+ var VALID_OPERATORS = new Set(Object.keys(OPERATOR_MAP));
2157
+ function countWhereConditions(where, depth = 0) {
2158
+ if (depth > MAX_WHERE_NESTING_DEPTH) {
2159
+ throw new ValidationError([
2160
+ {
2161
+ field: "where",
2162
+ message: `Where clause nesting depth exceeds maximum of ${MAX_WHERE_NESTING_DEPTH} levels.`
2163
+ }
2164
+ ]);
2165
+ }
2166
+ let count = 0;
2167
+ for (const [key, value] of Object.entries(where)) {
2168
+ if ((key === "and" || key === "or") && Array.isArray(value)) {
2169
+ for (const sub of value) {
2170
+ if (typeof sub === "object" && sub !== null) {
2171
+ count += countWhereConditions(sub, depth + 1);
2172
+ }
2173
+ }
2174
+ } else {
2175
+ count++;
2176
+ }
2177
+ }
2178
+ return count;
2179
+ }
2180
+ function flattenWhereClause(where) {
2181
+ if (!where)
2182
+ return {};
2183
+ const fieldCount = countWhereConditions(where);
2184
+ if (fieldCount > MAX_WHERE_CONDITIONS) {
2185
+ throw new ValidationError([
2186
+ {
2187
+ field: "where",
2188
+ message: `Where clause exceeds maximum of ${MAX_WHERE_CONDITIONS} conditions (got ${fieldCount}).`
2189
+ }
2190
+ ]);
2191
+ }
2192
+ return flattenWhereRecursive(where, 0);
2193
+ }
2194
+ function flattenWhereRecursive(where, depth) {
2195
+ if (depth > MAX_WHERE_NESTING_DEPTH) {
2196
+ throw new ValidationError([
2197
+ {
2198
+ field: "where",
2199
+ message: `Where clause nesting depth exceeds maximum of ${MAX_WHERE_NESTING_DEPTH} levels.`
2200
+ }
2201
+ ]);
2202
+ }
2203
+ const result = {};
2204
+ for (const [field, condition] of Object.entries(where)) {
2205
+ if (field === "and" || field === "or") {
2206
+ if (!Array.isArray(condition)) {
2207
+ throw new ValidationError([
2208
+ {
2209
+ field,
2210
+ message: `The "${field}" operator requires an array of conditions.`
2211
+ }
2212
+ ]);
2213
+ }
2214
+ const internalKey = field === "and" ? "$and" : "$or";
2215
+ result[internalKey] = condition.filter((sub) => typeof sub === "object" && sub !== null).map((sub) => {
2216
+ if ("$join" in sub)
2217
+ return sub;
2218
+ return flattenWhereRecursive(sub, depth + 1);
2219
+ });
2220
+ continue;
2221
+ }
2222
+ if (typeof condition !== "object" || condition === null) {
2223
+ result[field] = condition;
2224
+ continue;
2225
+ }
2226
+ const condObj = condition;
2227
+ const ops = {};
2228
+ let hasOp = false;
2229
+ for (const [userOp, internalOp] of Object.entries(OPERATOR_MAP)) {
2230
+ if (userOp in condObj) {
2231
+ let value = condObj[userOp];
2232
+ if (internalOp === "$exists" && typeof value === "string") {
2233
+ value = value === "true";
2234
+ }
2235
+ if (typeof value === "string") {
2236
+ value = value.replace(/\0/g, "");
2237
+ }
2238
+ if (Array.isArray(value)) {
2239
+ value = value.map(
2240
+ (item) => typeof item === "string" ? item.replace(/\0/g, "") : item
2241
+ );
2242
+ }
2243
+ ops[internalOp] = value;
2244
+ hasOp = true;
2245
+ }
2246
+ }
2247
+ for (const key of Object.keys(condObj)) {
2248
+ if (!VALID_OPERATORS.has(key)) {
2249
+ throw new ValidationError([
2250
+ {
2251
+ field,
2252
+ message: `Unknown operator "${key}". Valid operators: ${[...VALID_OPERATORS].sort().join(", ")}`
2253
+ }
2254
+ ]);
2255
+ }
2256
+ }
2257
+ if (hasOp) {
2258
+ result[field] = ops;
2259
+ } else {
2260
+ result[field] = condition;
2261
+ }
2262
+ }
2263
+ return result;
2264
+ }
2265
+ function extractRelationshipJoins(where, fields, allCollections) {
2266
+ if (!where)
2267
+ return { cleanedWhere: void 0, joins: [], allJoins: [] };
2268
+ const dataFields = flattenDataFields(fields);
2269
+ const fieldMap = new Map(dataFields.map((f) => [f.name, f]));
2270
+ const joins = [];
2271
+ const allJoins = [];
2272
+ const cleanedWhere = {};
2273
+ for (const [key, condition] of Object.entries(where)) {
2274
+ if (key === "and" || key === "or") {
2275
+ if (Array.isArray(condition)) {
2276
+ const cleanedArray = [];
2277
+ for (const sub of condition) {
2278
+ if (typeof sub === "object" && sub !== null) {
2279
+ const {
2280
+ cleanedWhere: subCleaned,
2281
+ joins: subTopJoins,
2282
+ allJoins: subAllJoins
2283
+ } = extractRelationshipJoins(
2284
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- WhereClause sub-object
2285
+ sub,
2286
+ fields,
2287
+ allCollections
2288
+ );
2289
+ if (subCleaned)
2290
+ cleanedArray.push(subCleaned);
2291
+ for (const join2 of subTopJoins) {
2292
+ cleanedArray.push({ ["$join"]: join2 });
2293
+ }
2294
+ allJoins.push(...subAllJoins);
2295
+ }
2296
+ }
2297
+ if (cleanedArray.length > 0) {
2298
+ cleanedWhere[key] = cleanedArray;
2299
+ }
2300
+ }
2301
+ continue;
2302
+ }
2303
+ let field = fieldMap.get(key);
2304
+ if (!field && key.includes(".")) {
2305
+ const [rootKey, ...subPath] = key.split(".");
2306
+ const rootField = fieldMap.get(rootKey);
2307
+ if (rootField && rootField.type === "relationship" && subPath.length > 0 && condition !== null) {
2308
+ let nested = condition;
2309
+ for (let i = subPath.length - 1; i >= 0; i--) {
2310
+ nested = { [subPath[i]]: nested };
2311
+ }
2312
+ field = rootField;
2313
+ const {
2314
+ cleanedWhere: subCleaned,
2315
+ joins: subJoins,
2316
+ allJoins: subAllJoins
2317
+ } = extractRelationshipJoins(
2318
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- reconstructed nested where
2319
+ { [rootKey]: nested },
2320
+ fields,
2321
+ allCollections
2322
+ );
2323
+ if (subCleaned)
2324
+ Object.assign(cleanedWhere, subCleaned);
2325
+ joins.push(...subJoins);
2326
+ allJoins.push(...subAllJoins);
2327
+ continue;
2328
+ }
2329
+ }
2330
+ if (!field || field.type !== "relationship" || typeof condition !== "object" || condition === null) {
2331
+ cleanedWhere[key] = condition;
2332
+ continue;
2333
+ }
2334
+ const condObj = condition;
2335
+ const condKeys = Object.keys(condObj);
2336
+ const hasOperatorKeys = condKeys.some((k) => VALID_OPERATORS.has(k));
2337
+ const hasNonOperatorKeys = condKeys.some((k) => !VALID_OPERATORS.has(k));
2338
+ if (hasOperatorKeys && hasNonOperatorKeys) {
2339
+ throw new ValidationError([
2340
+ {
2341
+ field: key,
2342
+ 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.`
2343
+ }
2344
+ ]);
2345
+ }
2346
+ if (!hasNonOperatorKeys) {
2347
+ cleanedWhere[key] = condition;
2348
+ continue;
2349
+ }
2350
+ const relField = field;
2351
+ let targetSlug;
2352
+ try {
2353
+ const targetConfig = relField.collection();
2354
+ if (targetConfig && typeof targetConfig === "object" && "slug" in targetConfig) {
2355
+ targetSlug = targetConfig.slug;
2356
+ }
2357
+ } catch {
2358
+ }
2359
+ if (!targetSlug) {
2360
+ throw new ValidationError([
2361
+ {
2362
+ field: key,
2363
+ message: `Cannot resolve target collection for relationship field "${key}".`
2364
+ }
2365
+ ]);
2366
+ }
2367
+ const targetCollection = allCollections.find((c) => c.slug === targetSlug);
2368
+ if (!targetCollection) {
2369
+ throw new ValidationError([
2370
+ {
2371
+ field: key,
2372
+ message: `Target collection "${targetSlug}" not found for relationship field "${key}".`
2373
+ }
2374
+ ]);
2375
+ }
2376
+ const subWhere = condition;
2377
+ const flattenedConditions = flattenWhereClause(subWhere);
2378
+ const targetTable = targetCollection.dbName ?? targetCollection.slug;
2379
+ const joinSpec = {
2380
+ targetTable,
2381
+ localField: key,
2382
+ targetField: "id",
2383
+ conditions: flattenedConditions,
2384
+ rawWhere: subWhere
2385
+ };
2386
+ joins.push(joinSpec);
2387
+ allJoins.push(joinSpec);
2388
+ }
2389
+ return {
2390
+ cleanedWhere: Object.keys(cleanedWhere).length > 0 ? cleanedWhere : void 0,
2391
+ joins,
2392
+ allJoins
2393
+ };
2394
+ }
2395
+ var SYSTEM_QUERYABLE_FIELDS = /* @__PURE__ */ new Set(["id", "createdAt", "updatedAt", "_status"]);
2396
+ async function validateWhereFields(where, fields, req) {
2397
+ if (!where)
2398
+ return;
2399
+ const dataFields = flattenDataFields(fields);
2400
+ const fieldMap = new Map(dataFields.map((f) => [f.name, f]));
2401
+ for (const fieldName of Object.keys(where)) {
2402
+ if (fieldName === "and" || fieldName === "or") {
2403
+ const subs = where[fieldName];
2404
+ if (Array.isArray(subs)) {
2405
+ for (const sub of subs) {
2406
+ if (typeof sub === "object" && sub !== null) {
2407
+ await validateWhereFields(sub, fields, req);
2408
+ }
2409
+ }
2410
+ }
2411
+ continue;
2412
+ }
2413
+ const baseName = fieldName.includes(".") ? fieldName.split(".")[0] : fieldName;
2414
+ if (SYSTEM_QUERYABLE_FIELDS.has(baseName))
2415
+ continue;
2416
+ const field = fieldMap.get(baseName);
2417
+ if (!field) {
2418
+ throw new ValidationError([{ field: fieldName, message: `Unknown field: ${baseName}` }]);
2419
+ }
2420
+ if (!field.access?.read)
2421
+ continue;
2422
+ const allowed = await Promise.resolve(field.access.read({ req }));
2423
+ if (!allowed) {
2424
+ throw new AccessDeniedError("read", baseName);
2425
+ }
2426
+ }
2427
+ }
2428
+ async function validateSortField(sort, fields, req) {
2429
+ if (!sort)
2430
+ return;
2431
+ const fieldName = sort.startsWith("-") ? sort.slice(1) : sort;
2432
+ const baseName = fieldName.includes(".") ? fieldName.split(".")[0] : fieldName;
2433
+ const dataFields = flattenDataFields(fields);
2434
+ const field = dataFields.find((f) => f.name === baseName);
2435
+ if (!field?.access?.read)
2436
+ return;
2437
+ const allowed = await Promise.resolve(field.access.read({ req }));
2438
+ if (!allowed) {
2439
+ throw new AccessDeniedError("read", baseName);
2440
+ }
2441
+ }
2442
+
2443
+ // libs/server-core/src/lib/api-utils.ts
2444
+ function deepEqual(a, b) {
2445
+ if (a === b)
2446
+ return true;
2447
+ if (a == null || b == null)
2448
+ return false;
2449
+ if (typeof a !== "object" || typeof b !== "object")
2450
+ return false;
2451
+ if (Array.isArray(a)) {
2452
+ if (!Array.isArray(b) || a.length !== b.length)
2453
+ return false;
2454
+ return a.every((item, i) => deepEqual(item, b[i]));
2455
+ }
2456
+ if (Array.isArray(b))
2457
+ return false;
2458
+ const aKeys = Object.keys(a);
2459
+ const bKeys = Object.keys(b);
2460
+ if (aKeys.length !== bKeys.length)
2461
+ return false;
2462
+ const aRec = a;
2463
+ const bRec = b;
2464
+ return aKeys.every(
2465
+ (key) => Object.prototype.hasOwnProperty.call(bRec, key) && deepEqual(aRec[key], bRec[key])
2466
+ );
2467
+ }
2468
+ function stripTransientKeys(data) {
2469
+ const result = {};
2470
+ for (const [key, value] of Object.entries(data)) {
2471
+ if (!key.startsWith("_")) {
2472
+ result[key] = value;
2473
+ }
2474
+ }
2475
+ return result;
2476
+ }
2477
+
2478
+ // libs/server-core/src/lib/collection-access.ts
2479
+ async function checkSingleCollectionAdminAccess(collection, user) {
2480
+ const adminFn = collection.access?.admin;
2481
+ if (!adminFn) {
2482
+ return !!user;
2483
+ }
2484
+ const accessArgs = {
2485
+ req: { user }
2486
+ };
2487
+ return Promise.resolve(adminFn(accessArgs));
2488
+ }
2489
+ async function checkAccessFunction(accessFn, user, defaultIfUndefined) {
2490
+ if (!accessFn) {
2491
+ return defaultIfUndefined;
2492
+ }
2493
+ const accessArgs = {
2494
+ req: { user }
2495
+ };
2496
+ return Promise.resolve(accessFn(accessArgs));
2497
+ }
2498
+ async function getCollectionPermissions(config, user) {
2499
+ const results = await Promise.all(
2500
+ config.collections.map(async (collection) => {
2501
+ const isManaged = collection.managed === true;
2502
+ const [canAccess, canCreate, canRead, canUpdate, canDelete] = await Promise.all([
2503
+ checkSingleCollectionAdminAccess(collection, user),
2504
+ isManaged ? Promise.resolve(false) : checkAccessFunction(collection.access?.create, user, !!user),
2505
+ checkAccessFunction(collection.access?.read, user, true),
2506
+ isManaged ? Promise.resolve(false) : checkAccessFunction(collection.access?.update, user, !!user),
2507
+ isManaged ? Promise.resolve(false) : checkAccessFunction(collection.access?.delete, user, !!user)
2508
+ ]);
2509
+ return {
2510
+ slug: collection.slug,
2511
+ canAccess,
2512
+ canCreate,
2513
+ canRead,
2514
+ canUpdate,
2515
+ canDelete,
2516
+ ...isManaged ? { managed: true } : {}
2517
+ };
2518
+ })
2519
+ );
2520
+ return results;
2521
+ }
2522
+ var ACCESS_OPS = ["read", "create", "update", "delete"];
2523
+ var ACCESS_DEFAULTS = {
2524
+ read: "public (anyone)",
2525
+ create: "any authenticated user",
2526
+ update: "any authenticated user",
2527
+ delete: "any authenticated user"
2528
+ };
2529
+ function warnInsecureDefaults(collections) {
2530
+ const logger = createLogger("Security");
2531
+ const warnings = [];
2532
+ for (const collection of collections) {
2533
+ if (collection.managed)
2534
+ continue;
2535
+ const missing = ACCESS_OPS.filter((op) => !collection.access?.[op]);
2536
+ if (missing.length > 0) {
2537
+ const details = missing.map((op) => `${op} (${ACCESS_DEFAULTS[op]})`).join(", ");
2538
+ const msg = `Collection "${collection.slug}" has no explicit access control for: ${details}. Define access functions to restrict.`;
2539
+ logger.warn(msg);
2540
+ warnings.push(msg);
2541
+ }
2542
+ }
2543
+ return warnings;
2544
+ }
2545
+
2110
2546
  // libs/server-core/src/lib/momentum-api.ts
2111
2547
  var momentumApiInstance = null;
2112
2548
  function initializeMomentumAPI(config) {
@@ -2114,6 +2550,7 @@ function initializeMomentumAPI(config) {
2114
2550
  createLogger("API").warn("Already initialized, returning existing instance");
2115
2551
  return momentumApiInstance;
2116
2552
  }
2553
+ warnInsecureDefaults(config.collections);
2117
2554
  momentumApiInstance = new MomentumAPIImpl(config);
2118
2555
  return momentumApiInstance;
2119
2556
  }
@@ -2171,70 +2608,6 @@ var MomentumAPIImpl = class _MomentumAPIImpl {
2171
2608
  return { ...this.context };
2172
2609
  }
2173
2610
  };
2174
- function deepEqual(a, b) {
2175
- if (a === b)
2176
- return true;
2177
- if (a == null || b == null)
2178
- return false;
2179
- if (typeof a !== "object" || typeof b !== "object")
2180
- return false;
2181
- if (Array.isArray(a)) {
2182
- if (!Array.isArray(b) || a.length !== b.length)
2183
- return false;
2184
- return a.every((item, i) => deepEqual(item, b[i]));
2185
- }
2186
- if (Array.isArray(b))
2187
- return false;
2188
- const aKeys = Object.keys(a);
2189
- const bKeys = Object.keys(b);
2190
- if (aKeys.length !== bKeys.length)
2191
- return false;
2192
- const aRec = a;
2193
- const bRec = b;
2194
- return aKeys.every(
2195
- (key) => Object.prototype.hasOwnProperty.call(bRec, key) && deepEqual(aRec[key], bRec[key])
2196
- );
2197
- }
2198
- function stripTransientKeys(data) {
2199
- const result = {};
2200
- for (const [key, value] of Object.entries(data)) {
2201
- if (!key.startsWith("_")) {
2202
- result[key] = value;
2203
- }
2204
- }
2205
- return result;
2206
- }
2207
- var COMPARISON_OPS = ["gt", "gte", "lt", "lte"];
2208
- function flattenWhereClause(where) {
2209
- if (!where)
2210
- return {};
2211
- const result = {};
2212
- for (const [field, condition] of Object.entries(where)) {
2213
- if (typeof condition !== "object" || condition === null) {
2214
- result[field] = condition;
2215
- continue;
2216
- }
2217
- const condObj = condition;
2218
- if ("equals" in condObj) {
2219
- result[field] = condObj["equals"];
2220
- continue;
2221
- }
2222
- const ops = {};
2223
- let hasComparisonOp = false;
2224
- for (const op of COMPARISON_OPS) {
2225
- if (op in condObj) {
2226
- ops[`$${op}`] = condObj[op];
2227
- hasComparisonOp = true;
2228
- }
2229
- }
2230
- if (hasComparisonOp) {
2231
- result[field] = ops;
2232
- } else {
2233
- result[field] = condition;
2234
- }
2235
- }
2236
- return result;
2237
- }
2238
2611
  var CollectionOperationsImpl = class _CollectionOperationsImpl {
2239
2612
  constructor(slug2, collectionConfig, adapter, context, allCollections = []) {
2240
2613
  this.slug = slug2;
@@ -2245,11 +2618,37 @@ var CollectionOperationsImpl = class _CollectionOperationsImpl {
2245
2618
  }
2246
2619
  async find(options = {}) {
2247
2620
  await this.checkAccess("read");
2621
+ if (!this.context.overrideAccess) {
2622
+ await validateWhereFields(
2623
+ options.where,
2624
+ this.collectionConfig.fields,
2625
+ this.buildRequestContext()
2626
+ );
2627
+ await validateSortField(
2628
+ options.sort,
2629
+ this.collectionConfig.fields,
2630
+ this.buildRequestContext()
2631
+ );
2632
+ }
2248
2633
  await this.runBeforeReadHooks();
2249
- const limit = options.limit ?? 10;
2250
- const page = options.page ?? 1;
2634
+ const rawLimit = options.limit ?? 10;
2635
+ const rawPage = options.page ?? 1;
2636
+ const limit = Math.max(
2637
+ 1,
2638
+ Math.min(Number.isFinite(rawLimit) ? Math.floor(rawLimit) : 10, MAX_PAGE_LIMIT)
2639
+ );
2640
+ const page = Math.max(
2641
+ 1,
2642
+ Math.min(Number.isFinite(rawPage) ? Math.floor(rawPage) : 1, MAX_PAGE)
2643
+ );
2251
2644
  const { depth: _depth, where, withDeleted: _wd, onlyDeleted: _od, ...queryOptions } = options;
2252
- const whereParams = flattenWhereClause(where);
2645
+ const { cleanedWhere, joins, allJoins } = extractRelationshipJoins(
2646
+ where,
2647
+ this.collectionConfig.fields,
2648
+ this.allCollections
2649
+ );
2650
+ await this.enforceWhereLimits(cleanedWhere, allJoins);
2651
+ const whereParams = flattenWhereClause(cleanedWhere);
2253
2652
  const softDeleteField = getSoftDeleteField(this.collectionConfig);
2254
2653
  if (softDeleteField && !options.withDeleted && !options.onlyDeleted) {
2255
2654
  whereParams[softDeleteField] = null;
@@ -2274,6 +2673,9 @@ var CollectionOperationsImpl = class _CollectionOperationsImpl {
2274
2673
  limit,
2275
2674
  page
2276
2675
  };
2676
+ if (joins.length > 0) {
2677
+ query["$joins"] = joins.map(({ rawWhere: _rw, ...rest }) => rest);
2678
+ }
2277
2679
  const docs = await this.adapter.find(this.slug, query);
2278
2680
  let afterHookDocs = await this.processAfterReadHooks(docs);
2279
2681
  const MAX_RELATIONSHIP_DEPTH = 10;
@@ -2297,14 +2699,15 @@ var CollectionOperationsImpl = class _CollectionOperationsImpl {
2297
2699
  );
2298
2700
  }
2299
2701
  const countQuery = { ...queryOptions, ...whereParams };
2702
+ if (joins.length > 0) {
2703
+ countQuery["$joins"] = joins.map(({ rawWhere: _rw, ...rest }) => rest);
2704
+ }
2300
2705
  delete countQuery["limit"];
2301
2706
  delete countQuery["page"];
2302
- const allDocs = await this.adapter.find(this.slug, {
2303
- ...countQuery,
2304
- limit: 0
2305
- // Signal to adapter: count-only (returns all if not supported)
2306
- });
2307
- const totalDocs = allDocs.length;
2707
+ const totalDocs = this.adapter.count ? await this.adapter.count(this.slug, countQuery) : (
2708
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Adapter returns Record<string, unknown>[], safe cast to T[]
2709
+ (await this.adapter.find(this.slug, { ...countQuery, limit: 0 })).length
2710
+ );
2308
2711
  const totalPages = Math.ceil(totalDocs / limit) || 1;
2309
2712
  return {
2310
2713
  docs: afterHookDocs,
@@ -2541,7 +2944,12 @@ var CollectionOperationsImpl = class _CollectionOperationsImpl {
2541
2944
  async restore(id) {
2542
2945
  const softDeleteField = getSoftDeleteField(this.collectionConfig);
2543
2946
  if (!softDeleteField) {
2544
- throw new Error(`Collection "${this.slug}" does not have soft delete enabled`);
2947
+ throw new ValidationError([
2948
+ {
2949
+ field: "_softDelete",
2950
+ message: `Collection "${this.slug}" does not have soft delete enabled`
2951
+ }
2952
+ ]);
2545
2953
  }
2546
2954
  const restoreAccessFn = this.collectionConfig.access?.restore;
2547
2955
  if (restoreAccessFn) {
@@ -2599,20 +3007,43 @@ var CollectionOperationsImpl = class _CollectionOperationsImpl {
2599
3007
  if (softDeleteField) {
2600
3008
  softDeleteFilter[softDeleteField] = null;
2601
3009
  }
3010
+ const defaultWhereFilter = {};
3011
+ if (this.collectionConfig.defaultWhere) {
3012
+ const constraints = this.collectionConfig.defaultWhere(this.buildRequestContext());
3013
+ if (constraints) {
3014
+ Object.assign(defaultWhereFilter, constraints);
3015
+ }
3016
+ }
2602
3017
  let docs;
2603
3018
  if (this.adapter.search) {
2604
3019
  docs = await this.adapter.search(this.slug, query, searchFields, { limit, page });
2605
3020
  if (softDeleteField) {
2606
3021
  docs = docs.filter((doc) => !doc[softDeleteField]);
2607
3022
  }
3023
+ if (Object.keys(defaultWhereFilter).length > 0) {
3024
+ docs = docs.filter((doc) => {
3025
+ return Object.entries(defaultWhereFilter).every(([key, value]) => {
3026
+ if (value && typeof value === "object" && !Array.isArray(value)) {
3027
+ return JSON.stringify(doc[key]) === JSON.stringify(value);
3028
+ }
3029
+ return doc[key] === value;
3030
+ });
3031
+ });
3032
+ }
2608
3033
  } else {
2609
- docs = await this.adapter.find(this.slug, { ...softDeleteFilter, limit, page });
3034
+ docs = await this.adapter.find(this.slug, {
3035
+ ...softDeleteFilter,
3036
+ ...defaultWhereFilter,
3037
+ limit,
3038
+ page
3039
+ });
2610
3040
  }
2611
3041
  const resolvedDocs = docs;
2612
- const totalDocs = resolvedDocs.length;
3042
+ const afterHookDocs = await this.processAfterReadHooks(resolvedDocs);
3043
+ const totalDocs = afterHookDocs.length;
2613
3044
  const totalPages = Math.max(1, Math.ceil(totalDocs / limit));
2614
3045
  return {
2615
- docs: resolvedDocs,
3046
+ docs: afterHookDocs,
2616
3047
  totalDocs,
2617
3048
  totalPages,
2618
3049
  page,
@@ -2625,13 +3056,40 @@ var CollectionOperationsImpl = class _CollectionOperationsImpl {
2625
3056
  }
2626
3057
  async count(where, options) {
2627
3058
  await this.checkAccess("read");
2628
- const whereParams = flattenWhereClause(where);
3059
+ if (!this.context.overrideAccess) {
3060
+ await validateWhereFields(where, this.collectionConfig.fields, this.buildRequestContext());
3061
+ }
3062
+ const { cleanedWhere, joins, allJoins } = extractRelationshipJoins(
3063
+ where,
3064
+ this.collectionConfig.fields,
3065
+ this.allCollections
3066
+ );
3067
+ await this.enforceWhereLimits(cleanedWhere, allJoins);
3068
+ const whereParams = flattenWhereClause(cleanedWhere);
2629
3069
  const softDeleteField = getSoftDeleteField(this.collectionConfig);
2630
3070
  if (softDeleteField && !options?.withDeleted) {
2631
3071
  whereParams[softDeleteField] = null;
2632
3072
  }
2633
- const query = { ...whereParams, limit: 0 };
2634
- const docs = await this.adapter.find(this.slug, query);
3073
+ if (this.collectionConfig.defaultWhere) {
3074
+ const constraints = this.collectionConfig.defaultWhere(this.buildRequestContext());
3075
+ if (constraints) {
3076
+ Object.assign(whereParams, constraints);
3077
+ }
3078
+ }
3079
+ if (hasVersionDrafts(this.collectionConfig) && !this.context.overrideAccess) {
3080
+ const canSeeDrafts = await this.canReadDrafts();
3081
+ if (!canSeeDrafts) {
3082
+ whereParams["_status"] = "published";
3083
+ }
3084
+ }
3085
+ const query = { ...whereParams };
3086
+ if (joins.length > 0) {
3087
+ query["$joins"] = joins.map(({ rawWhere: _rw, ...rest }) => rest);
3088
+ }
3089
+ if (this.adapter.count) {
3090
+ return this.adapter.count(this.slug, query);
3091
+ }
3092
+ const docs = await this.adapter.find(this.slug, { ...query, limit: 0 });
2635
3093
  return docs.length;
2636
3094
  }
2637
3095
  async batchCreate(items) {
@@ -2791,6 +3249,46 @@ var CollectionOperationsImpl = class _CollectionOperationsImpl {
2791
3249
  return false;
2792
3250
  }
2793
3251
  }
3252
+ async enforceWhereLimits(cleanedWhere, joins) {
3253
+ if (joins.length > MAX_JOINS) {
3254
+ throw new ValidationError([
3255
+ {
3256
+ field: "where",
3257
+ message: `Number of relationship joins (${joins.length}) exceeds maximum of ${MAX_JOINS}.`
3258
+ }
3259
+ ]);
3260
+ }
3261
+ const mainCount = cleanedWhere ? countWhereConditions(cleanedWhere) : 0;
3262
+ const joinCount = joins.reduce((sum, j) => sum + countWhereConditions(j.rawWhere), 0);
3263
+ const totalConditions = mainCount + joinCount;
3264
+ if (totalConditions > MAX_WHERE_CONDITIONS) {
3265
+ throw new ValidationError([
3266
+ {
3267
+ field: "where",
3268
+ message: `Where clause exceeds maximum of ${MAX_WHERE_CONDITIONS} conditions (got ${totalConditions} across main query and ${joins.length} join(s)).`
3269
+ }
3270
+ ]);
3271
+ }
3272
+ if (!this.context.overrideAccess) {
3273
+ for (const join2 of joins) {
3274
+ const targetCol = this.allCollections.find(
3275
+ (c) => (c.dbName ?? c.slug) === join2.targetTable
3276
+ );
3277
+ if (targetCol) {
3278
+ const collectionAccessFn = targetCol.access?.read;
3279
+ if (collectionAccessFn) {
3280
+ const allowed = await Promise.resolve(
3281
+ collectionAccessFn({ req: this.buildRequestContext() })
3282
+ );
3283
+ if (!allowed) {
3284
+ throw new AccessDeniedError("read", targetCol.slug);
3285
+ }
3286
+ }
3287
+ await validateWhereFields(join2.rawWhere, targetCol.fields, this.buildRequestContext());
3288
+ }
3289
+ }
3290
+ }
3291
+ }
2794
3292
  buildRequestContext() {
2795
3293
  return {
2796
3294
  user: this.context.user
@@ -3199,7 +3697,14 @@ function createMomentumHandlers(config) {
3199
3697
  });
3200
3698
  return {
3201
3699
  docs: result.docs,
3202
- totalDocs: result.totalDocs
3700
+ totalDocs: result.totalDocs,
3701
+ totalPages: result.totalPages,
3702
+ page: result.page,
3703
+ limit: result.limit,
3704
+ hasNextPage: result.hasNextPage,
3705
+ hasPrevPage: result.hasPrevPage,
3706
+ nextPage: result.nextPage,
3707
+ prevPage: result.prevPage
3203
3708
  };
3204
3709
  } catch (error) {
3205
3710
  return handleError(error);
@@ -3287,7 +3792,14 @@ function createMomentumHandlers(config) {
3287
3792
  const result = await api.collection(request.collectionSlug).search(q, { fields, limit, page });
3288
3793
  return {
3289
3794
  docs: result.docs,
3290
- totalDocs: result.totalDocs
3795
+ totalDocs: result.totalDocs,
3796
+ totalPages: result.totalPages,
3797
+ page: result.page,
3798
+ limit: result.limit,
3799
+ hasNextPage: result.hasNextPage,
3800
+ hasPrevPage: result.hasPrevPage,
3801
+ nextPage: result.nextPage,
3802
+ prevPage: result.prevPage
3291
3803
  };
3292
3804
  } catch (error) {
3293
3805
  return handleError(error);
@@ -3352,51 +3864,6 @@ function handleError(error) {
3352
3864
  return { error: "Unknown error", status: 500 };
3353
3865
  }
3354
3866
 
3355
- // libs/server-core/src/lib/collection-access.ts
3356
- async function checkSingleCollectionAdminAccess(collection, user) {
3357
- const adminFn = collection.access?.admin;
3358
- if (!adminFn) {
3359
- return !!user;
3360
- }
3361
- const accessArgs = {
3362
- req: { user }
3363
- };
3364
- return Promise.resolve(adminFn(accessArgs));
3365
- }
3366
- async function checkAccessFunction(accessFn, user, defaultIfUndefined) {
3367
- if (!accessFn) {
3368
- return defaultIfUndefined;
3369
- }
3370
- const accessArgs = {
3371
- req: { user }
3372
- };
3373
- return Promise.resolve(accessFn(accessArgs));
3374
- }
3375
- async function getCollectionPermissions(config, user) {
3376
- const results = await Promise.all(
3377
- config.collections.map(async (collection) => {
3378
- const isManaged = collection.managed === true;
3379
- const [canAccess, canCreate, canRead, canUpdate, canDelete] = await Promise.all([
3380
- checkSingleCollectionAdminAccess(collection, user),
3381
- isManaged ? Promise.resolve(false) : checkAccessFunction(collection.access?.create, user, !!user),
3382
- checkAccessFunction(collection.access?.read, user, true),
3383
- isManaged ? Promise.resolve(false) : checkAccessFunction(collection.access?.update, user, !!user),
3384
- isManaged ? Promise.resolve(false) : checkAccessFunction(collection.access?.delete, user, !!user)
3385
- ]);
3386
- return {
3387
- slug: collection.slug,
3388
- canAccess,
3389
- canCreate,
3390
- canRead,
3391
- canUpdate,
3392
- canDelete,
3393
- ...isManaged ? { managed: true } : {}
3394
- };
3395
- })
3396
- );
3397
- return results;
3398
- }
3399
-
3400
3867
  // libs/server-core/src/lib/webhooks.ts
3401
3868
  var webhookLogger = createLogger("Webhook");
3402
3869
 
@@ -3875,7 +4342,7 @@ function checkDepth(node, currentDepth, maxDepth, context) {
3875
4342
  }
3876
4343
  }
3877
4344
  }
3878
- async function executeGraphQL(schema, requestBody, context) {
4345
+ async function executeGraphQL(schema, requestBody, context, options) {
3879
4346
  if (!requestBody.query) {
3880
4347
  return {
3881
4348
  status: 400,
@@ -3891,6 +4358,17 @@ async function executeGraphQL(schema, requestBody, context) {
3891
4358
  body: { errors: [{ message: "Query parsing failed" }] }
3892
4359
  };
3893
4360
  }
4361
+ if (options?.readOnly) {
4362
+ const hasMutation = document.definitions.some(
4363
+ (def) => def.kind === "OperationDefinition" && def.operation === "mutation"
4364
+ );
4365
+ if (hasMutation) {
4366
+ return {
4367
+ status: 405,
4368
+ body: { errors: [{ message: "Mutations are not allowed via GET requests" }] }
4369
+ };
4370
+ }
4371
+ }
3894
4372
  const depthErrors = validate(schema, document, [depthLimitRule(MAX_QUERY_DEPTH)]);
3895
4373
  if (depthErrors.length > 0) {
3896
4374
  return {
@@ -5555,8 +6033,8 @@ function createComprehensiveMomentumHandler(config) {
5555
6033
  return { error: "API key not found" };
5556
6034
  }
5557
6035
  if (existingKey.createdBy !== String(user.id)) {
5558
- utils.setResponseStatus(event, 403);
5559
- return { error: "You can only delete your own API keys" };
6036
+ utils.setResponseStatus(event, 404);
6037
+ return { error: "API key not found" };
5560
6038
  }
5561
6039
  }
5562
6040
  try {