@rebasepro/server-mongo 0.13.0 → 0.13.1-canary.g06dbe5b

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 (35) hide show
  1. package/dist/MongoBootstrapper.d.ts.map +1 -1
  2. package/dist/auth/ensure-collections.d.ts.map +1 -1
  3. package/dist/db/MongoConditionBuilder.d.ts +28 -1
  4. package/dist/db/MongoConditionBuilder.d.ts.map +1 -1
  5. package/dist/db/MongoDataService.d.ts +19 -1
  6. package/dist/db/MongoDataService.d.ts.map +1 -1
  7. package/dist/db/securityRuleFilter.d.ts +65 -0
  8. package/dist/db/securityRuleFilter.d.ts.map +1 -0
  9. package/dist/ensure-collections-Bl12bSwO.js +130 -0
  10. package/dist/ensure-collections-Bl12bSwO.js.map +1 -0
  11. package/dist/factory.d.ts +11 -1
  12. package/dist/factory.d.ts.map +1 -1
  13. package/dist/index.es.js +487 -249
  14. package/dist/index.es.js.map +1 -1
  15. package/dist/services/MongoDriver.d.ts +37 -5
  16. package/dist/services/MongoDriver.d.ts.map +1 -1
  17. package/dist/services/MongoRealtimeService.d.ts +23 -14
  18. package/dist/services/MongoRealtimeService.d.ts.map +1 -1
  19. package/dist/{websocket-74DDOdK0.js → websocket-Bs54-gR-.js} +32 -11
  20. package/dist/websocket-Bs54-gR-.js.map +1 -0
  21. package/dist/websocket.d.ts +2 -2
  22. package/dist/websocket.d.ts.map +1 -1
  23. package/package.json +5 -5
  24. package/src/MongoBootstrapper.ts +10 -2
  25. package/src/auth/ensure-collections.ts +61 -13
  26. package/src/db/MongoConditionBuilder.ts +112 -40
  27. package/src/db/MongoDataService.ts +51 -14
  28. package/src/db/securityRuleFilter.ts +398 -0
  29. package/src/factory.ts +17 -4
  30. package/src/services/MongoDriver.ts +101 -262
  31. package/src/services/MongoRealtimeService.ts +124 -66
  32. package/src/websocket.ts +66 -15
  33. package/dist/ensure-collections-Bkx_O5CQ.js +0 -94
  34. package/dist/ensure-collections-Bkx_O5CQ.js.map +0 -1
  35. package/dist/websocket-74DDOdK0.js.map +0 -1
package/dist/index.es.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { MongoClient, ObjectId } from "mongodb";
2
- import { EntityReference } from "@rebasepro/types";
2
+ import { ANONYMOUS_USER_ID, EntityReference, ListLimitError, getCollectionDataPath, isAnonymousUid, resolveClientListLimit } from "@rebasepro/types";
3
+ import { buildPropertyCallbacks, buildSdkData, checkOperation, normalizeEmail, securityRuleToConditions, toFilterTuples, updateDateAutoValues } from "@rebasepro/common";
3
4
  import { ApiError, logger } from "@rebasepro/server";
4
- import { buildPropertyCallbacks, buildSdkData, checkOperation, normalizeEmail, updateDateAutoValues } from "@rebasepro/common";
5
5
  import { mergeDeep } from "@rebasepro/utils";
6
6
  //#region \0rolldown/runtime.js
7
7
  var __defProp = Object.defineProperty;
@@ -90,9 +90,16 @@ function escapeRegExp$1(str) {
90
90
  */
91
91
  function likePatternToRegExp(pattern, caseInsensitive) {
92
92
  let body = "";
93
- for (const ch of String(pattern)) if (ch === "%") body += ".*";
94
- else if (ch === "_") body += ".";
95
- else body += escapeRegExp$1(ch);
93
+ let lastWasWildcard = false;
94
+ for (const ch of String(pattern)) {
95
+ if (ch === "%") {
96
+ if (!lastWasWildcard) body += ".*";
97
+ lastWasWildcard = true;
98
+ continue;
99
+ }
100
+ body += ch === "_" ? "." : escapeRegExp$1(ch);
101
+ lastWasWildcard = false;
102
+ }
96
103
  return new RegExp(`^${body}$`, caseInsensitive ? "i" : "");
97
104
  }
98
105
  /**
@@ -113,30 +120,61 @@ var MongoConditionBuilder = class {
113
120
  const conditions = [];
114
121
  for (const [field, filterParam] of Object.entries(filter)) {
115
122
  if (!filterParam) continue;
116
- const [op, value] = filterParam;
117
- if (op === "is-null") {
118
- conditions.push({ [field]: { $eq: null } });
119
- continue;
120
- }
121
- if (op === "is-not-null") {
122
- conditions.push({ [field]: { $ne: null } });
123
- continue;
124
- }
125
- if (op === "like" || op === "ilike" || op === "not-like" || op === "not-ilike") {
126
- const regex = likePatternToRegExp(value, op === "ilike" || op === "not-ilike");
127
- const negated = op === "not-like" || op === "not-ilike";
128
- conditions.push({ [field]: negated ? { $not: regex } : { $regex: regex } });
129
- continue;
130
- }
131
- const mongoOp = REBASE_TO_MONGO_OP[op];
132
- if (!mongoOp) {
133
- logger.warn(`Unsupported filter operator: ${op}`);
123
+ for (const [op, value] of toFilterTuples(filterParam)) conditions.push(this.buildCondition(field, op, value));
124
+ }
125
+ return conditions;
126
+ }
127
+ /**
128
+ * One field, one operator, one value.
129
+ *
130
+ * Extracted so that `logical` groups translate through exactly this code.
131
+ * A group written as a second dialect is a group where `array-contains` or
132
+ * `ilike` quietly means something else than it does in `filter`, which is
133
+ * the kind of difference nobody finds until a query returns the wrong rows.
134
+ *
135
+ * Always returns a condition or throws: there is no operator this can be
136
+ * given that legitimately means "no condition".
137
+ */
138
+ static buildCondition(field, op, value) {
139
+ if (op === "is-null") return { [field]: { $eq: null } };
140
+ if (op === "is-not-null") return { [field]: { $ne: null } };
141
+ if (op === "like" || op === "ilike" || op === "not-like" || op === "not-ilike") {
142
+ const regex = likePatternToRegExp(value, op === "ilike" || op === "not-ilike");
143
+ const negated = op === "not-like" || op === "not-ilike";
144
+ return { [field]: negated ? { $not: regex } : { $regex: regex } };
145
+ }
146
+ const mongoOp = REBASE_TO_MONGO_OP[op];
147
+ if (!mongoOp) {
148
+ logger.warn(`Unsupported filter operator '${op}' on field '${field}'`);
149
+ throw ApiError.badRequest(`Operator '${op}' is not supported on field '${field}' by the MongoDB driver.`, "UNSUPPORTED_FILTER_OPERATOR", {
150
+ field,
151
+ operator: op
152
+ });
153
+ }
154
+ if (op === "array-contains") return { [field]: { $elemMatch: { $eq: value } } };
155
+ return { [field]: { [mongoOp]: value } };
156
+ }
157
+ /**
158
+ * Translate an `or(...)` / `and(...)` group, nesting included.
159
+ *
160
+ * Returns `undefined` for a group with nothing in it. `$or: []` is an error
161
+ * in Mongo and `$and: []` matches every document, so neither is a
162
+ * defensible reading of "no conditions".
163
+ */
164
+ static buildLogicalConditions(logical) {
165
+ if (!logical || !Array.isArray(logical.conditions)) return void 0;
166
+ const parts = [];
167
+ for (const entry of logical.conditions) {
168
+ if (!entry) continue;
169
+ if ("type" in entry && "conditions" in entry) {
170
+ const nested = this.buildLogicalConditions(entry);
171
+ if (nested) parts.push(nested);
134
172
  continue;
135
173
  }
136
- if (op === "array-contains") conditions.push({ [field]: { $elemMatch: { $eq: value } } });
137
- else conditions.push({ [field]: { [mongoOp]: value } });
174
+ const { column, operator, value } = entry;
175
+ parts.push(this.buildCondition(column, operator, value));
138
176
  }
139
- return conditions;
177
+ return logical.type === "or" ? this.combineConditionsWithOr(parts) : this.combineConditionsWithAnd(parts);
140
178
  }
141
179
  /**
142
180
  * Build search conditions for text search
@@ -188,6 +226,8 @@ var MongoConditionBuilder = class {
188
226
  const filterConditions = this.buildFilterConditions(options.filter);
189
227
  conditions.push(...filterConditions);
190
228
  }
229
+ const logicalCondition = this.buildLogicalConditions(options.logical);
230
+ if (logicalCondition) conditions.push(logicalCondition);
191
231
  if (options.searchString && options.properties) {
192
232
  const searchConditions = this.buildSearchConditions(options.searchString, options.properties);
193
233
  if (searchConditions.length > 0) {
@@ -327,6 +367,7 @@ var MongoDataService = class {
327
367
  const collection = this.getCollection(collectionPath);
328
368
  const query = options.rawQuery ?? MongoConditionBuilder.buildQuery({
329
369
  filter: options.filter,
370
+ logical: options.logical,
330
371
  searchString: options.searchString,
331
372
  properties: options.collection?.properties ?? {}
332
373
  });
@@ -335,6 +376,7 @@ var MongoDataService = class {
335
376
  if (sort) findOptions.sort = sort;
336
377
  if (options.limit) findOptions.limit = options.limit;
337
378
  if (options.startAfter !== void 0) findOptions.skip = Number(options.startAfter);
379
+ else if (options.offset) findOptions.skip = options.offset;
338
380
  return (await collection.find(query, findOptions).toArray()).map((doc) => this.documentToRow(doc));
339
381
  }
340
382
  /**
@@ -351,11 +393,22 @@ var MongoDataService = class {
351
393
  */
352
394
  async count(collectionPath, options = {}) {
353
395
  const collection = this.getCollection(collectionPath);
354
- const query = options.rawQuery ?? (options.filter ? MongoConditionBuilder.buildQuery({ filter: options.filter }) : {});
396
+ const query = options.rawQuery ?? MongoConditionBuilder.buildQuery({
397
+ filter: options.filter,
398
+ logical: options.logical,
399
+ searchString: options.searchString,
400
+ properties: options.collection?.properties ?? {}
401
+ });
355
402
  return collection.countDocuments(query);
356
403
  }
357
404
  /**
358
405
  * Save an row (create or update)
406
+ *
407
+ * Returns the **stored** document, not the values that were sent. A partial
408
+ * update sends only the fields that changed, and returning those was a
409
+ * partial row everywhere it went: the REST response, `afterSave`, the
410
+ * history entry a revert restores from, and the row pushed to realtime
411
+ * subscribers. Postgres returns the whole row here (`RETURNING *`).
359
412
  */
360
413
  async save(collectionPath, values, id, _databaseId) {
361
414
  const collection = this.getCollection(collectionPath);
@@ -363,23 +416,31 @@ var MongoDataService = class {
363
416
  if (id) {
364
417
  const objectId = this.toObjectId(id);
365
418
  await collection.updateOne({ _id: objectId }, { $set: mongoValues }, { upsert: true });
366
- return {
419
+ return await this.readBack(collectionPath, objectId, {
367
420
  ...values,
368
421
  id: id.toString()
369
- };
422
+ });
370
423
  } else {
371
424
  const newId = new ObjectId();
372
425
  await collection.insertOne({
373
426
  _id: newId,
374
427
  ...mongoValues
375
428
  });
376
- return {
429
+ return await this.readBack(collectionPath, newId, {
377
430
  ...values,
378
431
  id: newId.toString()
379
- };
432
+ });
380
433
  }
381
434
  }
382
435
  /**
436
+ * Read the document back after a write, falling back to the caller's own
437
+ * values if it has already been removed by a concurrent delete.
438
+ */
439
+ async readBack(collectionPath, objectId, fallback) {
440
+ const doc = await this.getCollection(collectionPath).findOne({ _id: objectId });
441
+ return doc ? this.documentToRow(doc) : fallback;
442
+ }
443
+ /**
383
444
  * Delete an row by ID
384
445
  */
385
446
  async delete(collectionPath, id, _databaseId) {
@@ -421,11 +482,9 @@ var MongoRealtimeService = class {
421
482
  db;
422
483
  subscriptions = /* @__PURE__ */ new Map();
423
484
  clients = /* @__PURE__ */ new Map();
424
- dataService;
425
485
  driver;
426
486
  constructor(db) {
427
487
  this.db = db;
428
- this.dataService = new MongoDataService(db);
429
488
  }
430
489
  setDataDriver(driver) {
431
490
  this.driver = driver;
@@ -456,8 +515,7 @@ var MongoRealtimeService = class {
456
515
  type: "collection",
457
516
  config,
458
517
  changeStream,
459
- callback,
460
- authContext: config.authContext
518
+ callback
461
519
  };
462
520
  this.subscriptions.set(subscriptionId, subscription);
463
521
  this.fetchAndNotifyCollection(subscriptionId, config, callback);
@@ -472,8 +530,7 @@ var MongoRealtimeService = class {
472
530
  const subscription = {
473
531
  type: "collection",
474
532
  config,
475
- callback,
476
- authContext: config.authContext
533
+ callback
477
534
  };
478
535
  this.subscriptions.set(subscriptionId, subscription);
479
536
  this.fetchAndNotifyCollection(subscriptionId, config, callback);
@@ -484,31 +541,16 @@ var MongoRealtimeService = class {
484
541
  */
485
542
  async fetchAndNotifyCollection(subscriptionId, config, callback) {
486
543
  try {
487
- let rows;
488
544
  const registryCollection = this.driver?.registry?.getCollectionByPath(config.path);
489
- if (config.authContext && this.driver) {
490
- const mockUser = {
491
- uid: config.authContext.uid,
492
- roles: config.authContext.roles
493
- };
494
- rows = await (await this.driver.withAuth(mockUser)).fetchCollection({
495
- path: config.path,
496
- collection: registryCollection,
497
- filter: config.filter,
498
- orderBy: config.orderBy,
499
- order: config.order,
500
- limit: config.limit,
501
- startAfter: config.startAfter,
502
- searchString: config.searchString
503
- });
504
- } else rows = await this.dataService.fetchCollection(config.path, {
545
+ const rows = await (await this.scopedDriver(config.authContext)).fetchCollection({
546
+ path: config.path,
547
+ collection: registryCollection,
505
548
  filter: config.filter,
506
549
  orderBy: config.orderBy,
507
550
  order: config.order,
508
551
  limit: config.limit,
509
552
  startAfter: config.startAfter,
510
- searchString: config.searchString,
511
- collection: registryCollection
553
+ searchString: config.searchString
512
554
  });
513
555
  if (callback) callback(rows);
514
556
  } catch (error) {
@@ -516,6 +558,20 @@ var MongoRealtimeService = class {
516
558
  }
517
559
  }
518
560
  /**
561
+ * The driver scoped to a subscriber.
562
+ *
563
+ * Never the bare repository: everything a subscription delivers has to pass
564
+ * the same row authorization an HTTP read does.
565
+ */
566
+ async scopedDriver(authContext) {
567
+ if (!this.driver) throw new Error("MongoRealtimeService has no data driver — subscriptions cannot be authorized");
568
+ const user = {
569
+ uid: authContext?.uid ?? ANONYMOUS_USER_ID,
570
+ roles: authContext?.roles ?? []
571
+ };
572
+ return this.driver.withAuth(user);
573
+ }
574
+ /**
519
575
  * Subscribe to single row changes
520
576
  */
521
577
  subscribeToOne(subscriptionId, config, callback) {
@@ -537,8 +593,7 @@ var MongoRealtimeService = class {
537
593
  type: "single",
538
594
  config,
539
595
  changeStream,
540
- callback,
541
- authContext: config.authContext
596
+ callback
542
597
  };
543
598
  this.subscriptions.set(subscriptionId, subscription);
544
599
  this.fetchAndNotifyOne(subscriptionId, config, callback);
@@ -555,8 +610,7 @@ var MongoRealtimeService = class {
555
610
  const subscription = {
556
611
  type: "single",
557
612
  config,
558
- callback,
559
- authContext: config.authContext
613
+ callback
560
614
  };
561
615
  this.subscriptions.set(subscriptionId, subscription);
562
616
  this.fetchAndNotifyOne(subscriptionId, config, callback);
@@ -567,19 +621,12 @@ var MongoRealtimeService = class {
567
621
  */
568
622
  async fetchAndNotifyOne(subscriptionId, config, callback) {
569
623
  try {
570
- let row;
571
624
  const registryCollection = this.driver?.registry?.getCollectionByPath(config.path);
572
- if (config.authContext && this.driver) {
573
- const mockUser = {
574
- uid: config.authContext.uid,
575
- roles: config.authContext.roles
576
- };
577
- row = await (await this.driver.withAuth(mockUser)).fetchOne({
578
- path: config.path,
579
- id: config.id,
580
- collection: registryCollection
581
- });
582
- } else row = await this.dataService.fetchOne(config.path, config.id);
625
+ const row = await (await this.scopedDriver(config.authContext)).fetchOne({
626
+ path: config.path,
627
+ id: config.id,
628
+ collection: registryCollection
629
+ });
583
630
  if (callback) callback(row || null);
584
631
  } catch (error) {
585
632
  logger.error(`Error fetching row for subscription ${subscriptionId}`, { error });
@@ -602,9 +649,8 @@ var MongoRealtimeService = class {
602
649
  async notifyUpdate(path, id, row, _databaseId) {
603
650
  for (const [subscriptionId, subscription] of this.subscriptions) if (subscription.type === "single") {
604
651
  const config = subscription.config;
605
- if (config.path === path && config.id.toString() === id) {
606
- if (subscription.callback) subscription.callback(row);
607
- }
652
+ if (config.path === path && config.id.toString() === id) if (row === null) subscription.callback?.(null);
653
+ else await this.fetchAndNotifyOne(subscriptionId, config, subscription.callback);
608
654
  } else if (subscription.type === "collection") {
609
655
  const config = subscription.config;
610
656
  if (config.path === path) await this.fetchAndNotifyCollection(subscriptionId, config, subscription.callback);
@@ -658,13 +704,30 @@ var MongoRealtimeService = class {
658
704
  case "subscribe_collection": {
659
705
  const subscriptionId = message.payload?.subscriptionId ?? message.subscriptionId;
660
706
  if (!subscriptionId) return;
707
+ let boundedLimit;
708
+ try {
709
+ boundedLimit = resolveClientListLimit(message.payload?.limit);
710
+ } catch (e) {
711
+ if (!(e instanceof ListLimitError)) throw e;
712
+ logger.warn(`⚠️ [MongoRealtime] Refused subscription to '${message.payload?.path}': ${e.message}`);
713
+ ws.send(JSON.stringify({
714
+ type: "ERROR",
715
+ subscriptionId,
716
+ payload: { error: {
717
+ message: e.message,
718
+ code: "INVALID_LIMIT"
719
+ } },
720
+ error: e.message
721
+ }));
722
+ return;
723
+ }
661
724
  this.subscribeToCollection(subscriptionId, {
662
725
  clientId,
663
726
  path: message.payload?.path,
664
727
  filter: message.payload?.filter,
665
728
  orderBy: message.payload?.orderBy,
666
729
  order: message.payload?.order,
667
- limit: message.payload?.limit,
730
+ limit: boundedLimit,
668
731
  startAfter: message.payload?.startAfter,
669
732
  searchString: message.payload?.searchString,
670
733
  authContext
@@ -699,6 +762,18 @@ var MongoRealtimeService = class {
699
762
  if (subscriptionId) this.unsubscribe(subscriptionId);
700
763
  break;
701
764
  }
765
+ default:
766
+ logger.warn(`⚠️ [MongoRealtime] Unhandled realtime message type "${message.type}" — channels, presence and broadcast are not implemented by the Mongo driver.`);
767
+ ws.send(JSON.stringify({
768
+ type: "ERROR",
769
+ subscriptionId: message.subscriptionId,
770
+ payload: { error: {
771
+ message: `Realtime message type "${message.type}" is not supported by the Mongo driver`,
772
+ code: "REALTIME_UNSUPPORTED"
773
+ } },
774
+ error: `Realtime message type "${message.type}" is not supported by the Mongo driver`
775
+ }));
776
+ break;
702
777
  }
703
778
  }
704
779
  };
@@ -811,6 +886,251 @@ var MongoHistoryService = class {
811
886
  }
812
887
  };
813
888
  //#endregion
889
+ //#region src/db/securityRuleFilter.ts
890
+ /** Matches every document. */
891
+ var MATCH_ALL = {};
892
+ /**
893
+ * Matches no document. A distinct object rather than a `false` sentinel so it
894
+ * can be nested inside `$and`/`$or` like any other filter; identity is what
895
+ * {@link isMatchNone} tests, so never mutate or copy it.
896
+ */
897
+ var MATCH_NONE = { _id: { $exists: false } };
898
+ /** The expression has no MongoDB equivalent — the caller must refuse. */
899
+ var UNTRANSLATABLE = "untranslatable";
900
+ function isMatchAll(f) {
901
+ return f !== UNTRANSLATABLE && f !== MATCH_NONE && Object.keys(f).length === 0;
902
+ }
903
+ function isMatchNone(f) {
904
+ return f === MATCH_NONE;
905
+ }
906
+ /** The error code a caller sees when a rule cannot be honoured. */
907
+ var SECURITY_RULE_UNSUPPORTED = "SECURITY_RULE_UNSUPPORTED";
908
+ /**
909
+ * The refusal. Names the collection, the clause and the expression, because the
910
+ * only useful thing an operator can do with this is rewrite that rule — or move
911
+ * the collection to an engine that can enforce it.
912
+ */
913
+ function securityRuleUnsupported(collectionSlug, clause, detail) {
914
+ return ApiError.internal(`This collection's data source (MongoDB) cannot enforce a security rule on "${collectionSlug}": the \`${clause}\` expression ${detail} has no MongoDB equivalent. The request was refused rather than served without row authorization. Express the rule with \`access\`, \`ownerField\`, \`roles\`, or a structured \`condition\`/\`check\`, or move this collection to a Postgres data source.`, SECURITY_RULE_UNSUPPORTED);
915
+ }
916
+ /** Describe an expression well enough for the refusal message to be actionable. */
917
+ function describe(expr) {
918
+ switch (expr.kind) {
919
+ case "raw": return `\`${expr.sql}\``;
920
+ case "existsIn": return `a membership subquery over \`${expr.collection}\``;
921
+ case "not": return "a negated row predicate";
922
+ default: return `a \`${expr.kind}\` node`;
923
+ }
924
+ }
925
+ /** The first node of an expression tree this driver cannot translate, if any. */
926
+ function findUntranslatable(expr, hasRow) {
927
+ switch (expr.kind) {
928
+ case "and":
929
+ case "or":
930
+ for (const operand of expr.operands) {
931
+ const found = findUntranslatable(operand, hasRow);
932
+ if (found) return found;
933
+ }
934
+ return;
935
+ case "not": return hasRow ? findUntranslatable(expr.operand, hasRow) : referencesField(expr.operand) ? expr : void 0;
936
+ case "compare": return operandUntranslatable(expr.left) || operandUntranslatable(expr.right) ? expr : void 0;
937
+ case "existsIn":
938
+ case "raw": return expr;
939
+ default: return;
940
+ }
941
+ }
942
+ function operandUntranslatable(operand) {
943
+ return operand.kind === "outerField";
944
+ }
945
+ function referencesField(expr) {
946
+ switch (expr.kind) {
947
+ case "and":
948
+ case "or": return expr.operands.some(referencesField);
949
+ case "not": return referencesField(expr.operand);
950
+ case "compare": return expr.left.kind === "field" || expr.right.kind === "field" || expr.left.kind === "outerField" || expr.right.kind === "outerField";
951
+ default: return false;
952
+ }
953
+ }
954
+ function userContext(user) {
955
+ return {
956
+ uid: user?.uid || ANONYMOUS_USER_ID,
957
+ roles: user?.roles ?? []
958
+ };
959
+ }
960
+ var COMPARE_TO_MONGO = {
961
+ eq: "$eq",
962
+ neq: "$ne",
963
+ lt: "$lt",
964
+ lte: "$lte",
965
+ gt: "$gt",
966
+ gte: "$gte"
967
+ };
968
+ var INVERTED_COMPARE = {
969
+ eq: "eq",
970
+ neq: "neq",
971
+ lt: "gt",
972
+ lte: "gte",
973
+ gt: "lt",
974
+ gte: "lte"
975
+ };
976
+ function resolveOperand(operand, ctx) {
977
+ switch (operand.kind) {
978
+ case "literal": return {
979
+ kind: "value",
980
+ value: operand.value
981
+ };
982
+ case "authUid": return {
983
+ kind: "value",
984
+ value: ctx.uid
985
+ };
986
+ case "authRoles": return {
987
+ kind: "value",
988
+ value: ctx.roles
989
+ };
990
+ case "field": return {
991
+ kind: "field",
992
+ name: operand.name
993
+ };
994
+ case "outerField": return { kind: "unknown" };
995
+ }
996
+ }
997
+ /**
998
+ * Translate one {@link PolicyExpression} into a MongoDB filter, or
999
+ * {@link UNTRANSLATABLE}.
1000
+ *
1001
+ * The JavaScript twin of `evaluatePolicy`, one level up: where that decides a
1002
+ * single row, this narrows a query. `"unknown"` there and `UNTRANSLATABLE` here
1003
+ * are the same condition, and both are resolved fail-closed by their callers.
1004
+ */
1005
+ function policyToMongoFilter(expr, user) {
1006
+ const ctx = userContext(user);
1007
+ switch (expr.kind) {
1008
+ case "true": return MATCH_ALL;
1009
+ case "false": return MATCH_NONE;
1010
+ case "and": {
1011
+ const parts = expr.operands.map((o) => policyToMongoFilter(o, user));
1012
+ if (parts.some(isMatchNone)) return MATCH_NONE;
1013
+ if (parts.some((p) => p === UNTRANSLATABLE)) return UNTRANSLATABLE;
1014
+ const clauses = parts.filter((p) => !isMatchAll(p));
1015
+ if (clauses.length === 0) return MATCH_ALL;
1016
+ if (clauses.length === 1) return clauses[0];
1017
+ return { $and: clauses };
1018
+ }
1019
+ case "or": {
1020
+ const parts = expr.operands.map((o) => policyToMongoFilter(o, user));
1021
+ if (parts.some(isMatchAll)) return MATCH_ALL;
1022
+ if (parts.some((p) => p === UNTRANSLATABLE)) return UNTRANSLATABLE;
1023
+ const clauses = parts.filter((p) => !isMatchNone(p));
1024
+ if (clauses.length === 0) return MATCH_NONE;
1025
+ if (clauses.length === 1) return clauses[0];
1026
+ return { $or: clauses };
1027
+ }
1028
+ case "not": {
1029
+ const inner = policyToMongoFilter(expr.operand, user);
1030
+ if (isMatchAll(inner)) return MATCH_NONE;
1031
+ if (isMatchNone(inner)) return MATCH_ALL;
1032
+ return UNTRANSLATABLE;
1033
+ }
1034
+ case "compare": {
1035
+ const left = resolveOperand(expr.left, ctx);
1036
+ const right = resolveOperand(expr.right, ctx);
1037
+ if (left.kind === "unknown" || right.kind === "unknown") return UNTRANSLATABLE;
1038
+ if (left.kind === "field" && right.kind === "value") return { [left.name]: { [COMPARE_TO_MONGO[expr.op]]: right.value } };
1039
+ if (left.kind === "value" && right.kind === "field") return { [right.name]: { [COMPARE_TO_MONGO[INVERTED_COMPARE[expr.op]]]: left.value } };
1040
+ if (left.kind === "field" && right.kind === "field") return { $expr: { [COMPARE_TO_MONGO[expr.op]]: [`$${left.name}`, `$${right.name}`] } };
1041
+ if (left.kind === "value" && right.kind === "value") return compareValues(expr.op, left.value, right.value);
1042
+ return UNTRANSLATABLE;
1043
+ }
1044
+ case "rolesOverlap": return expr.roles.some((r) => r === "public" || ctx.roles.includes(r)) ? MATCH_ALL : MATCH_NONE;
1045
+ case "rolesContain": return expr.roles.every((r) => r === "public" || ctx.roles.includes(r)) ? MATCH_ALL : MATCH_NONE;
1046
+ case "authenticated": return !isAnonymousUid(ctx.uid) ? MATCH_ALL : MATCH_NONE;
1047
+ case "serverContext": return MATCH_NONE;
1048
+ case "existsIn":
1049
+ case "raw": return UNTRANSLATABLE;
1050
+ }
1051
+ }
1052
+ function compareValues(op, a, b) {
1053
+ if (op === "eq") return a === b ? MATCH_ALL : MATCH_NONE;
1054
+ if (op === "neq") return a !== b ? MATCH_ALL : MATCH_NONE;
1055
+ if (typeof a === "string" && typeof b === "string" || typeof a === "number" && typeof b === "number") return (op === "lt" ? a < b : op === "lte" ? a <= b : op === "gt" ? a > b : a >= b) ? MATCH_ALL : MATCH_NONE;
1056
+ return UNTRANSLATABLE;
1057
+ }
1058
+ /** The rules that apply to `targetOperation`, mirroring `checkOperation`. */
1059
+ function applicableRules(collection, targetOperation) {
1060
+ const rules = collection?.securityRules;
1061
+ if (!rules || rules.length === 0) return [];
1062
+ return rules.filter((rule) => {
1063
+ const ops = rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
1064
+ return ops.includes(targetOperation) || ops.includes("all");
1065
+ });
1066
+ }
1067
+ /** Which clause of a rule constrains `targetOperation` — Postgres's own split. */
1068
+ function clauseFor(targetOperation) {
1069
+ return targetOperation === "insert" ? "withCheck" : "using";
1070
+ }
1071
+ /**
1072
+ * Refuse up front when this collection's rules cannot be enforced for
1073
+ * `targetOperation`.
1074
+ *
1075
+ * The row-in-hand paths (`fetchOne`, `save`, `delete`) resolve an undecidable
1076
+ * rule through `checkOperation`'s `onUnknown: "deny"`, which is safe but
1077
+ * indistinguishable from a plain "you may not do that". Calling this first
1078
+ * turns the same condition into the refusal an operator can act on.
1079
+ */
1080
+ function assertSecurityRulesEnforceable(collection, targetOperation) {
1081
+ for (const rule of applicableRules(collection, targetOperation)) {
1082
+ const conditions = securityRuleToConditions(rule);
1083
+ const clauses = targetOperation === "insert" ? ["withCheck"] : targetOperation === "update" ? ["using", "withCheck"] : ["using"];
1084
+ for (const clause of clauses) {
1085
+ const expr = clause === "using" ? conditions.usingExpr : conditions.withCheckExpr;
1086
+ if (!expr) continue;
1087
+ const offending = findUntranslatable(expr, true);
1088
+ if (offending) throw securityRuleUnsupported(collection?.slug ?? "unknown", clause, describe(offending));
1089
+ }
1090
+ }
1091
+ }
1092
+ /**
1093
+ * Build the MongoDB filter that narrows a query to the rows `user` may see
1094
+ * under `collection`'s security rules.
1095
+ *
1096
+ * Returns `null` when no row can qualify (the caller answers with an empty
1097
+ * result), `{}` when the rules impose no narrowing, and throws
1098
+ * {@link SECURITY_RULE_UNSUPPORTED} when a rule cannot be translated.
1099
+ */
1100
+ function buildMongoFilterFromSecurityRules(collection, user, targetOperation) {
1101
+ const rules = applicableRules(collection, targetOperation);
1102
+ if (!collection?.securityRules || collection.securityRules.length === 0) return MATCH_ALL;
1103
+ if (rules.length === 0) return null;
1104
+ const clause = clauseFor(targetOperation);
1105
+ const permissive = [];
1106
+ const restrictive = [];
1107
+ for (const rule of rules) {
1108
+ const conditions = securityRuleToConditions(rule);
1109
+ const expr = clause === "using" ? conditions.usingExpr : conditions.withCheckExpr;
1110
+ const filter = expr === null ? MATCH_NONE : policyToMongoFilter(expr, user);
1111
+ if (filter === UNTRANSLATABLE) {
1112
+ const offending = expr === null ? void 0 : findUntranslatable(expr, false);
1113
+ throw securityRuleUnsupported(collection.slug, clause, offending ? describe(offending) : "this rule");
1114
+ }
1115
+ if ((rule.mode || "permissive") === "restrictive") restrictive.push(filter);
1116
+ else permissive.push(filter);
1117
+ }
1118
+ if (permissive.length === 0) return null;
1119
+ const parts = [];
1120
+ if (!permissive.some(isMatchAll)) {
1121
+ const granting = permissive.filter((p) => !isMatchNone(p));
1122
+ if (granting.length === 0) return null;
1123
+ parts.push(granting.length === 1 ? granting[0] : { $or: granting });
1124
+ }
1125
+ for (const rf of restrictive) {
1126
+ if (isMatchNone(rf)) return null;
1127
+ if (!isMatchAll(rf)) parts.push(rf);
1128
+ }
1129
+ if (parts.length === 0) return MATCH_ALL;
1130
+ if (parts.length === 1) return parts[0];
1131
+ return { $and: parts };
1132
+ }
1133
+ //#endregion
814
1134
  //#region src/services/MongoDriver.ts
815
1135
  /**
816
1136
  * MongoDB DataDriver Delegate
@@ -876,14 +1196,10 @@ var MongoDriver = class {
876
1196
  /**
877
1197
  * Fetch a collection of rows
878
1198
  */
879
- async fetchCollection({ path, collection, filter, limit, startAfter, orderBy, searchString, order }) {
1199
+ async fetchCollection(props) {
1200
+ const { path, collection, ...query } = props;
880
1201
  const rows = await this.dataService.fetchCollection(path, {
881
- filter,
882
- limit,
883
- startAfter,
884
- orderBy,
885
- order,
886
- searchString,
1202
+ ...query,
887
1203
  collection
888
1204
  });
889
1205
  const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
@@ -921,9 +1237,15 @@ var MongoDriver = class {
921
1237
  return rows;
922
1238
  }
923
1239
  /**
924
- * Listen to collection changes
1240
+ * Listen to collection changes.
1241
+ *
1242
+ * `authContext` is not part of `ListenCollectionProps`; it is supplied by
1243
+ * {@link AuthenticatedMongoDriver}, which is the only caller that has one.
1244
+ * It has to travel *into* the subscription config, because that config is
1245
+ * what every re-fetch reads — the wrapper used to stamp the field on the
1246
+ * `Subscription` object instead, and nothing has ever read that one.
925
1247
  */
926
- listenCollection({ path, collection, filter, limit, startAfter, orderBy, searchString, order, onUpdate, onError }) {
1248
+ listenCollection({ path, collection, filter, limit, startAfter, orderBy, searchString, order, onUpdate, onError }, authContext) {
927
1249
  const subscriptionId = this.generateSubscriptionId();
928
1250
  const callback = (rows) => {
929
1251
  try {
@@ -941,7 +1263,8 @@ var MongoDriver = class {
941
1263
  order,
942
1264
  limit,
943
1265
  startAfter,
944
- searchString
1266
+ searchString,
1267
+ authContext
945
1268
  }, callback);
946
1269
  return () => {
947
1270
  this.realtimeService.unsubscribe(subscriptionId);
@@ -987,7 +1310,7 @@ var MongoDriver = class {
987
1310
  /**
988
1311
  * Listen to row changes
989
1312
  */
990
- listenOne({ path, id, collection, onUpdate, onError }) {
1313
+ listenOne({ path, id, collection, onUpdate, onError }, authContext) {
991
1314
  const subscriptionId = this.generateSubscriptionId();
992
1315
  const callback = (row) => {
993
1316
  try {
@@ -1000,7 +1323,8 @@ var MongoDriver = class {
1000
1323
  this.realtimeService.subscribeToOne(subscriptionId, {
1001
1324
  clientId: "driver",
1002
1325
  path,
1003
- id
1326
+ id,
1327
+ authContext
1004
1328
  }, callback);
1005
1329
  return () => {
1006
1330
  this.realtimeService.unsubscribe(subscriptionId);
@@ -1257,8 +1581,13 @@ var MongoDriver = class {
1257
1581
  /**
1258
1582
  * Count rows in a collection
1259
1583
  */
1260
- async count({ path, collection, filter }) {
1261
- return this.dataService.count(path, { filter });
1584
+ async count({ path, collection, filter, logical, searchString }) {
1585
+ return this.dataService.count(path, {
1586
+ filter,
1587
+ logical,
1588
+ searchString,
1589
+ collection
1590
+ });
1262
1591
  }
1263
1592
  /**
1264
1593
  * Generate a unique subscription ID
@@ -1311,6 +1640,7 @@ var AuthenticatedMongoDriver = class {
1311
1640
  if (rlsFilter === null) return [];
1312
1641
  const userQuery = MongoConditionBuilder.buildQuery({
1313
1642
  filter: props.filter,
1643
+ logical: props.logical,
1314
1644
  searchString: props.searchString,
1315
1645
  properties: resolvedCollection?.properties
1316
1646
  });
@@ -1355,67 +1685,87 @@ var AuthenticatedMongoDriver = class {
1355
1685
  return rows;
1356
1686
  }
1357
1687
  listenCollection(props) {
1358
- const unsubscribe = this.delegate.listenCollection(props);
1359
- const authContext = {
1688
+ return this.delegate.listenCollection(props, this.authContext());
1689
+ }
1690
+ /** The acting user, in the shape the realtime subscriptions carry. */
1691
+ authContext() {
1692
+ return {
1360
1693
  uid: this.user.uid,
1361
1694
  roles: this.user.roles ?? []
1362
1695
  };
1363
- const subscriptions = this.delegate.getRealtimeService().getSubscriptions();
1364
- const lastSub = Array.from(subscriptions.entries()).pop()?.[1];
1365
- if (lastSub && lastSub.config.clientId === "driver") lastSub.authContext = authContext;
1366
- return unsubscribe;
1696
+ }
1697
+ /**
1698
+ * Evaluate the collection's rules for one row, fail-closed.
1699
+ *
1700
+ * A path with no resolvable collection has no declared rules — the same
1701
+ * answer `buildMongoFilterFromSecurityRules` gives a listing on such a path,
1702
+ * so the two never disagree about whether this engine has row security.
1703
+ */
1704
+ authorize(collection, entity, operation, clauses) {
1705
+ if (!collection) return true;
1706
+ return checkOperation(collection, { user: this.user }, entity, operation, {
1707
+ onUnknown: "deny",
1708
+ clauses
1709
+ });
1367
1710
  }
1368
1711
  async fetchOne(props) {
1369
1712
  const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(props.collection, props.path);
1713
+ assertSecurityRulesEnforceable(resolvedCollection, "select");
1370
1714
  const row = await this.delegate.fetchOne(props);
1371
- if (row) {
1372
- if (!checkOperation(resolvedCollection, { user: this.user }, rowToEntityForCheck(row, props.path), "select", { onUnknown: "deny" })) return;
1373
- }
1715
+ if (row && !this.authorize(resolvedCollection, rowToEntityForCheck(row, props.path), "select")) return;
1374
1716
  return row;
1375
1717
  }
1376
1718
  listenOne(props) {
1377
- const unsubscribe = this.delegate.listenOne(props);
1378
- const authContext = {
1379
- uid: this.user.uid,
1380
- roles: this.user.roles ?? []
1381
- };
1382
- const subscriptions = this.delegate.getRealtimeService().getSubscriptions();
1383
- const lastSub = Array.from(subscriptions.entries()).pop()?.[1];
1384
- if (lastSub && lastSub.config.clientId === "driver") lastSub.authContext = authContext;
1385
- return unsubscribe;
1719
+ return this.delegate.listenOne(props, this.authContext());
1386
1720
  }
1721
+ /**
1722
+ * Save, with both halves of the rule checked *before* the write.
1723
+ *
1724
+ * There is no transaction here, so a check that runs after
1725
+ * `delegate.save` cannot undo anything: the document is written, history is
1726
+ * recorded and subscribers have been notified by then, and a 403 at that
1727
+ * point only misleads the caller about what happened. Postgres evaluates
1728
+ * `WITH CHECK` inside the transaction; the closest this driver can get is to
1729
+ * evaluate it against the row as it *will* be, and refuse before writing.
1730
+ */
1387
1731
  async save(props) {
1388
1732
  const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(props.collection, props.path);
1389
1733
  if (props.status === "existing" && props.id) {
1734
+ assertSecurityRulesEnforceable(resolvedCollection, "update");
1390
1735
  const existing = await this.delegate.fetchOne({
1391
1736
  path: props.path,
1392
1737
  id: props.id,
1393
1738
  collection: resolvedCollection
1394
1739
  });
1395
- if (!existing || !checkOperation(resolvedCollection, { user: this.user }, rowToEntityForCheck(existing, props.path), "update", { onUnknown: "deny" })) throw ApiError.forbidden("Forbidden");
1740
+ const projected = rowToEntityForCheck({
1741
+ ...existing,
1742
+ ...props.values,
1743
+ id: props.id
1744
+ }, props.path);
1745
+ if (!existing || !this.authorize(resolvedCollection, rowToEntityForCheck(existing, props.path), "update", "using") || !this.authorize(resolvedCollection, projected, "update", "withCheck")) throw ApiError.forbidden("Forbidden");
1396
1746
  } else {
1747
+ assertSecurityRulesEnforceable(resolvedCollection, "insert");
1397
1748
  const tempEntity = {
1398
1749
  id: props.id || "new",
1399
1750
  path: props.path,
1400
1751
  values: props.values
1401
1752
  };
1402
- if (!checkOperation(resolvedCollection, { user: this.user }, tempEntity, "insert", { onUnknown: "deny" })) throw ApiError.forbidden("Forbidden");
1753
+ if (!this.authorize(resolvedCollection, tempEntity, "insert")) throw ApiError.forbidden("Forbidden");
1403
1754
  }
1404
- const saved = await this.delegate.save({
1755
+ return this.delegate.save({
1405
1756
  ...props,
1406
1757
  collection: resolvedCollection
1407
1758
  });
1408
- if (!checkOperation(resolvedCollection, { user: this.user }, rowToEntityForCheck(saved, props.path), props.status === "existing" ? "update" : "insert", { onUnknown: "deny" })) throw ApiError.forbidden("Forbidden");
1409
- return saved;
1410
1759
  }
1411
1760
  async delete(props) {
1412
1761
  const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(props.collection, props.row.path);
1762
+ assertSecurityRulesEnforceable(resolvedCollection, "delete");
1413
1763
  const existing = await this.delegate.fetchOne({
1414
1764
  path: props.row.path,
1415
1765
  id: props.row.id,
1416
1766
  collection: resolvedCollection
1417
1767
  });
1418
- if (!existing || !checkOperation(resolvedCollection, { user: this.user }, rowToEntityForCheck(existing, props.row.path), "delete", { onUnknown: "deny" })) throw ApiError.forbidden("Forbidden");
1768
+ if (!existing || !this.authorize(resolvedCollection, rowToEntityForCheck(existing, props.row.path), "delete")) throw ApiError.forbidden("Forbidden");
1419
1769
  return this.delegate.delete(props);
1420
1770
  }
1421
1771
  async checkUniqueField(path, name, value, id, collection) {
@@ -1430,6 +1780,7 @@ var AuthenticatedMongoDriver = class {
1430
1780
  if (rlsFilter === null) return 0;
1431
1781
  const userQuery = MongoConditionBuilder.buildQuery({
1432
1782
  filter: props.filter,
1783
+ logical: props.logical,
1433
1784
  searchString: props.searchString,
1434
1785
  properties: resolvedCollection?.properties
1435
1786
  });
@@ -1454,147 +1805,34 @@ function rowToEntityForCheck(row, path) {
1454
1805
  values: row
1455
1806
  };
1456
1807
  }
1457
- function getMongoFilterForSQL(sqlString, user) {
1458
- let cleanedSQL = sqlString.trim();
1459
- while (cleanedSQL.startsWith("(") && cleanedSQL.endsWith(")")) {
1460
- let openCount = 0;
1461
- let isEnclosing = true;
1462
- for (let i = 0; i < cleanedSQL.length - 1; i++) {
1463
- if (cleanedSQL[i] === "(") openCount++;
1464
- else if (cleanedSQL[i] === ")") openCount--;
1465
- if (openCount === 0) {
1466
- isEnclosing = false;
1467
- break;
1468
- }
1469
- }
1470
- if (isEnclosing) cleanedSQL = cleanedSQL.substring(1, cleanedSQL.length - 1).trim();
1471
- else break;
1472
- }
1473
- const splitByTopLevel = (str, delimiter) => {
1474
- const parts = [];
1475
- let current = "";
1476
- let openCount = 0;
1477
- let i = 0;
1478
- while (i < str.length) {
1479
- if (str[i] === "(") openCount++;
1480
- else if (str[i] === ")") openCount--;
1481
- if (openCount === 0 && str.substring(i).toUpperCase().startsWith(delimiter)) {
1482
- parts.push(current);
1483
- current = "";
1484
- i += delimiter.length;
1485
- } else {
1486
- current += str[i];
1487
- i++;
1488
- }
1489
- }
1490
- parts.push(current);
1491
- return parts;
1492
- };
1493
- const orParts = splitByTopLevel(cleanedSQL, " OR ");
1494
- if (orParts.length > 1) {
1495
- const subFilters = orParts.map((part) => getMongoFilterForSQL(part, user)).filter((f) => f !== null);
1496
- if (subFilters.length === 0) return null;
1497
- if (subFilters.length === 1) return subFilters[0];
1498
- return { $or: subFilters };
1499
- }
1500
- const andParts = splitByTopLevel(cleanedSQL, " AND ");
1501
- if (andParts.length > 1) {
1502
- const subFilters = andParts.map((part) => getMongoFilterForSQL(part, user)).filter((f) => f !== null);
1503
- if (subFilters.length === 0) return null;
1504
- if (subFilters.length === 1) return subFilters[0];
1505
- return { $and: subFilters };
1506
- }
1507
- const roleIntersectMatch = cleanedSQL.match(/string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\[(.*?)\]/i);
1508
- if (roleIntersectMatch && roleIntersectMatch[1]) {
1509
- const requiredRoles = roleIntersectMatch[1].split(",").map((r) => r.trim().replace(/'/g, ""));
1510
- const userRoles = user.roles || [];
1511
- return requiredRoles.some((r) => userRoles.includes(r)) ? {} : { _id: { $exists: false } };
1512
- }
1513
- const roleContainMatch = cleanedSQL.match(/string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\[(.*?)\]/i);
1514
- if (roleContainMatch && roleContainMatch[1]) {
1515
- const requiredRoles = roleContainMatch[1].split(",").map((r) => r.trim().replace(/'/g, ""));
1516
- const userRoles = user.roles || [];
1517
- return requiredRoles.every((r) => userRoles.includes(r)) ? {} : { _id: { $exists: false } };
1518
- }
1519
- const pattern1 = /* @__PURE__ */ new RegExp("^\\{?([a-zA-Z0-9_]+)\\}?\\s*=\\s*(?:current_setting\\s*\\(\\s*'app\\.user_id'\\s*\\)|auth\\.uid\\(\\))");
1520
- const pattern2 = /* @__PURE__ */ new RegExp("^(?:current_setting\\s*\\(\\s*'app\\.user_id'\\s*\\)|auth\\.uid\\(\\))\\s*=\\s*\\{?([a-zA-Z0-9_]+)\\}?");
1521
- const match1 = cleanedSQL.match(pattern1);
1522
- if (match1 && match1[1]) return { [match1[1]]: user.uid };
1523
- const match2 = cleanedSQL.match(pattern2);
1524
- if (match2 && match2[1]) return { [match2[1]]: user.uid };
1525
- const simpleEqualityMatch = cleanedSQL.match(/^\{?([\w_]+)\}?\s*(=|!=)\s*'([^']+)'$/i);
1526
- if (simpleEqualityMatch) {
1527
- const field = simpleEqualityMatch[1];
1528
- const operator = simpleEqualityMatch[2];
1529
- const value = simpleEqualityMatch[3];
1530
- if (operator === "=") return { [field]: value };
1531
- if (operator === "!=") return { [field]: { $ne: value } };
1532
- }
1533
- return {};
1534
- }
1535
- function getMongoFilterForRule(rule, user) {
1536
- if (rule.access === "public") return {};
1537
- const filters = [];
1538
- if (rule.ownerField) filters.push({ [rule.ownerField]: user.uid });
1539
- if (rule.using) {
1540
- const f = getMongoFilterForSQL(rule.using, user);
1541
- if (f) filters.push(f);
1542
- }
1543
- if (rule.withCheck) {
1544
- const f = getMongoFilterForSQL(rule.withCheck, user);
1545
- if (f) filters.push(f);
1546
- }
1547
- if (filters.length === 0) return {};
1548
- if (filters.length === 1) return filters[0];
1549
- return { $and: filters };
1550
- }
1551
- function buildMongoFilterFromSecurityRules(collection, user, targetOperation) {
1552
- if (!collection || !collection.securityRules || collection.securityRules.length === 0) return {};
1553
- const applicableRules = collection.securityRules.filter((r) => r.operation === targetOperation || r.operation === "all" || r.operations?.includes(targetOperation) || r.operations?.includes("all"));
1554
- if (applicableRules.length === 0) return null;
1555
- const userRoles = [...user.roles ?? [], "public"];
1556
- const roleApplicableRules = applicableRules.filter((rule) => {
1557
- if (!rule.roles || rule.roles.length === 0) return true;
1558
- return rule.roles.some((r) => userRoles.includes(r));
1559
- });
1560
- if (roleApplicableRules.length === 0) return null;
1561
- const permissiveFilters = [];
1562
- const restrictiveFilters = [];
1563
- for (const rule of roleApplicableRules) {
1564
- const mode = rule.mode || "permissive";
1565
- const filter = getMongoFilterForRule(rule, user);
1566
- if (filter === null) {
1567
- if (mode === "restrictive") return null;
1568
- continue;
1569
- }
1570
- if (mode === "restrictive") restrictiveFilters.push(filter);
1571
- else permissiveFilters.push(filter);
1572
- }
1573
- const finalAnds = [];
1574
- if (permissiveFilters.length > 0) {
1575
- if (!permissiveFilters.some((f) => Object.keys(f).length === 0)) if (permissiveFilters.length === 1) finalAnds.push(permissiveFilters[0]);
1576
- else finalAnds.push({ $or: permissiveFilters });
1577
- } else return null;
1578
- if (restrictiveFilters.length > 0) {
1579
- for (const rf of restrictiveFilters) if (Object.keys(rf).length > 0) finalAnds.push(rf);
1580
- }
1581
- if (finalAnds.length === 0) return {};
1582
- if (finalAnds.length === 1) return finalAnds[0];
1583
- return { $and: finalAnds };
1584
- }
1585
1808
  //#endregion
1586
1809
  //#region src/factory.ts
1587
1810
  /**
1588
1811
  * Simple in-memory collection registry for MongoDB.
1589
1812
  */
1590
1813
  var MongoCollectionRegistry = class {
1814
+ /** Every addressable key → collection. See {@link register}. */
1591
1815
  collections = /* @__PURE__ */ new Map();
1816
+ /** Registration order, so `getCollections()` returns each collection once. */
1817
+ registered = [];
1592
1818
  _globalCallbacks;
1593
1819
  /**
1594
- * Register a collection
1820
+ * Register a collection under every name it can be addressed by.
1821
+ *
1822
+ * A Mongo collection has up to three: `slug` (the routing key), `path` (the
1823
+ * MongoDB collection-name override, which is what `getCollectionDataPath`
1824
+ * hands the driver) and `name` (the human label). Registering only `name`
1825
+ * meant every `getCollectionByPath` lookup missed — and the realtime path,
1826
+ * whose only source of the collection is this registry, ran with no
1827
+ * `securityRules`, no `properties` and no callbacks.
1595
1828
  */
1596
1829
  register(collection) {
1597
- this.collections.set(collection.name, collection);
1830
+ this.registered.push(collection);
1831
+ for (const key of [
1832
+ getCollectionDataPath(collection),
1833
+ collection.slug,
1834
+ collection.name
1835
+ ]) if (key) this.collections.set(key, collection);
1598
1836
  }
1599
1837
  /**
1600
1838
  * Get a collection by its path
@@ -1606,7 +1844,7 @@ var MongoCollectionRegistry = class {
1606
1844
  * Get all registered collections
1607
1845
  */
1608
1846
  getCollections() {
1609
- return Array.from(this.collections.values());
1847
+ return [...this.registered];
1610
1848
  }
1611
1849
  /**
1612
1850
  * Get the currently registered global callbacks, if any.
@@ -2481,7 +2719,7 @@ function createMongoBootstrapper(mongoConfig) {
2481
2719
  },
2482
2720
  async initializeAuth(config, driverResult) {
2483
2721
  const db = driverResult.internals.db;
2484
- const { ensureAuthCollectionsExist } = await import("./ensure-collections-Bkx_O5CQ.js");
2722
+ const { ensureAuthCollectionsExist } = await import("./ensure-collections-Bl12bSwO.js");
2485
2723
  await ensureAuthCollectionsExist(db);
2486
2724
  const { createEmailService } = await import("@rebasepro/server");
2487
2725
  const authConfig = config;
@@ -2553,9 +2791,9 @@ function createMongoBootstrapper(mongoConfig) {
2553
2791
  return admin;
2554
2792
  },
2555
2793
  mountRoutes() {},
2556
- async initializeWebsockets(server, realtimeService, driver, config) {
2557
- const { createMongoWebSocket } = await import("./websocket-74DDOdK0.js");
2558
- createMongoWebSocket(server, realtimeService, driver, config, cachedAdmin);
2794
+ async initializeWebsockets(server, realtimeService, driver, config, authAdapter) {
2795
+ const { createMongoWebSocket } = await import("./websocket-Bs54-gR-.js");
2796
+ createMongoWebSocket(server, realtimeService, driver, config, cachedAdmin, authAdapter);
2559
2797
  }
2560
2798
  };
2561
2799
  }