@rebasepro/server-mongo 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.es.js CHANGED
@@ -1,8 +1,8 @@
1
- import { MongoClient, ObjectId } from "mongodb";
1
+ import { MongoClient, MongoServerError, ObjectId } from "mongodb";
2
2
  import { ANONYMOUS_USER_ID, EntityReference, ListLimitError, getCollectionDataPath, isAnonymousUid, resolveClientListLimit } from "@rebasepro/types";
3
- import { assertBeforeQueryIsPostgresOnly, buildPropertyCallbacks, buildSdkData, callbackRefusal, checkOperation, normalizeDriverOrderBy, normalizeEmail, requireCallbackClient, securityRuleToConditions, toCallbackError, toFilterTuples, updateDateAutoValues } from "@rebasepro/common";
3
+ import { assertBeforeQueryIsPostgresOnly, buildPropertyCallbacks, buildSdkData, callbackRefusal, canReadField, checkOperation, normalizeDriverOrderBy, normalizeEmail, requireCallbackClient, restrictedFieldNames, securityRuleToConditions, toCallbackError, toFilterTuples, updateDateAutoValues } from "@rebasepro/common";
4
4
  import { mergeDeep, splitSearchTerms } from "@rebasepro/utils";
5
- import { ApiError, declaredErrorAnswer, logger } from "@rebasepro/server";
5
+ import { ApiError, declaredErrorAnswer, logger, splitFieldOps } from "@rebasepro/server";
6
6
  //#region \0rolldown/runtime.js
7
7
  var __defProp = Object.defineProperty;
8
8
  var __exportAll = (all, no_symbols) => {
@@ -134,10 +134,21 @@ var MongoConditionBuilder = class MongoConditionBuilder {
134
134
  *
135
135
  * Always returns a condition or throws: there is no operator this can be
136
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 } };
137
+ *
138
+ * `negated` asks for the documents where the condition is *false* — not
139
+ * merely not true. The two differ by the documents where it is unknown,
140
+ * which in SQL is a comparison against NULL: `NOT (status = 'draft')` does
141
+ * not return a row whose status is NULL, and the Postgres driver is the
142
+ * reference every caller of this API writes against. So a comparison is
143
+ * false only where the field holds a value and the comparison fails on it.
144
+ * The null tests are never unknown, and negate to each other.
145
+ */
146
+ static buildCondition(field, op, value, negated = false) {
147
+ if (op === "is-null" || op === "is-not-null") {
148
+ const isNull = op === "is-null" !== negated;
149
+ return { [field]: isNull ? { $eq: null } : { $ne: null } };
150
+ }
151
+ if (negated) return { $and: [{ [field]: { $ne: null } }, { $nor: [this.buildCondition(field, op, value)] }] };
141
152
  if (op === "like" || op === "ilike" || op === "not-like" || op === "not-ilike") {
142
153
  const regex = likePatternToRegExp(value, op === "ilike" || op === "not-ilike");
143
154
  const negated = op === "not-like" || op === "not-ilike";
@@ -155,26 +166,35 @@ var MongoConditionBuilder = class MongoConditionBuilder {
155
166
  return { [field]: { [mongoOp]: value } };
156
167
  }
157
168
  /**
158
- * Translate an `or(...)` / `and(...)` group, nesting included.
169
+ * Translate an `or(...)` / `and(...)` / `not(...)` group, nesting included.
170
+ *
171
+ * `not` negates the conjunction of its conditions — the rule stated on
172
+ * `LogicalCondition`. It used to fall through to the `and` branch, so
173
+ * `not(status = 'draft')` returned exactly the drafts. It is compiled by
174
+ * pushing the negation down to the leaves (De Morgan), each of which knows
175
+ * which documents make it false rather than merely not true — see
176
+ * {@link buildCondition}. A `$nor` around the group would be shorter and
177
+ * wrong: it also matches every document where the condition is unknown.
159
178
  *
160
179
  * Returns `undefined` for a group with nothing in it. `$or: []` is an error
161
180
  * in Mongo and `$and: []` matches every document, so neither is a
162
181
  * defensible reading of "no conditions".
163
182
  */
164
- static buildLogicalConditions(logical) {
183
+ static buildLogicalConditions(logical, negated = false) {
165
184
  if (!logical || !Array.isArray(logical.conditions)) return void 0;
185
+ const negateParts = logical.type === "not" ? !negated : negated;
166
186
  const parts = [];
167
187
  for (const entry of logical.conditions) {
168
188
  if (!entry) continue;
169
189
  if ("type" in entry && "conditions" in entry) {
170
- const nested = this.buildLogicalConditions(entry);
190
+ const nested = this.buildLogicalConditions(entry, negateParts);
171
191
  if (nested) parts.push(nested);
172
192
  continue;
173
193
  }
174
194
  const { column, operator, value } = entry;
175
- parts.push(this.buildCondition(column, operator, value));
195
+ parts.push(this.buildCondition(column, operator, value, negateParts));
176
196
  }
177
- return logical.type === "or" ? this.combineConditionsWithOr(parts) : this.combineConditionsWithAnd(parts);
197
+ return logical.type === "or" !== negateParts ? this.combineConditionsWithOr(parts) : this.combineConditionsWithAnd(parts);
178
198
  }
179
199
  /**
180
200
  * Build search conditions for text search.
@@ -187,13 +207,27 @@ var MongoConditionBuilder = class MongoConditionBuilder {
187
207
  *
188
208
  * @param searchString - Text to search for
189
209
  * @param properties - The collection's properties, searched for string fields
210
+ * @param viewer - Who the search is for. A field they cannot read is not
211
+ * one they may search: the strip keeps its value out of the response, and
212
+ * without this the value is still recoverable a substring at a time by
213
+ * watching which searches return the row. Absent is the trusted server
214
+ * plane, which still does not search an `excludeFromApi` field — the same
215
+ * answer the Postgres builder gives.
190
216
  * @returns At most one MongoDB filter — callers OR what they get back, which
191
217
  * is right across fields and wrong across terms, so the AND is built here
192
218
  */
193
- static buildSearchConditions(searchString, properties) {
219
+ static buildSearchConditions(searchString, properties, viewer) {
194
220
  if (!searchString) return [];
195
221
  const searchableFields = [];
196
- for (const [key, prop] of Object.entries(properties)) if (prop?.type === "string" || typeof prop === "string") searchableFields.push(key);
222
+ let withheldStringFields = 0;
223
+ for (const [key, prop] of Object.entries(properties)) {
224
+ if (prop?.type === "string" && !canReadField(prop, viewer)) {
225
+ withheldStringFields++;
226
+ continue;
227
+ }
228
+ if (prop?.type === "string" || typeof prop === "string") searchableFields.push(key);
229
+ }
230
+ if (searchableFields.length === 0 && withheldStringFields > 0) return [{ _id: { $exists: false } }];
197
231
  if (searchableFields.length === 0) return [{ $text: { $search: searchString } }];
198
232
  const terms = splitSearchTerms(searchString);
199
233
  const perTerm = (terms.length > 0 ? terms : [searchString]).map((term) => {
@@ -240,7 +274,7 @@ var MongoConditionBuilder = class MongoConditionBuilder {
240
274
  const logicalCondition = this.buildLogicalConditions(options.logical);
241
275
  if (logicalCondition) conditions.push(logicalCondition);
242
276
  if (options.searchString && options.properties) {
243
- const searchConditions = this.buildSearchConditions(options.searchString, options.properties);
277
+ const searchConditions = this.buildSearchConditions(options.searchString, options.properties, options.viewer);
244
278
  if (searchConditions.length > 0) {
245
279
  const searchFilter = this.combineConditionsWithOr(searchConditions);
246
280
  if (searchFilter) conditions.push(searchFilter);
@@ -451,23 +485,56 @@ var MongoDataService = class {
451
485
  * partial row everywhere it went: the REST response, `afterSave`, the
452
486
  * history entry a revert restores from, and the row pushed to realtime
453
487
  * subscribers. Postgres returns the whole row here (`RETURNING *`).
454
- */
455
- async save(collectionPath, values, id, _databaseId) {
488
+ *
489
+ * `mode` is which write the caller means when it names an `id`:
490
+ *
491
+ * - `"create"` inserts under that id, and a taken id is a 409. The driver
492
+ * authorizes a create as an insert, against the values sent — so a
493
+ * create that could land on an existing row is an update no update rule
494
+ * ever saw. It was one: an upsert with `$set`, which let a caller name
495
+ * another user's id with `status: "new"` and rewrite their row.
496
+ * - `"update"` updates that row and nothing else, and a missing one is a
497
+ * 404 — never a row conjured under an id nobody created.
498
+ * - absent, the repository's own "create or update": an upsert. Only the
499
+ * repository's direct callers reach it; the driver always says which.
500
+ *
501
+ * `values` may carry field operations (`{ views: { $inc: 1 } }`), which
502
+ * change the stored value in the same write rather than replace it — see
503
+ * {@link compileFieldOp}. They were written into the document as literal
504
+ * objects, so a counter became `{ "$inc": 1 }` behind a 200. Only an
505
+ * update can carry one: there is no stored value for an insert to act on,
506
+ * and an upsert may be an insert.
507
+ */
508
+ async save(collectionPath, values, id, _databaseId, mode) {
456
509
  const collection = this.getCollection(collectionPath);
457
- const mongoValues = this.convertToMongoValues(values);
510
+ const { values: plainValues, fieldOps } = splitFieldOps(values);
511
+ const opFields = Object.keys(fieldOps);
512
+ if (opFields.length > 0 && !(id && mode === "update")) throw ApiError.badRequest(`Field operations (${opFields.map((k) => `'${k}'`).join(", ")}) need an existing row to act on, and this write to "${collectionPath}" ${id && mode === void 0 ? "may be" : "is"} an insert. Send the value itself instead.`, "INVALID_FIELD_OPERATION");
513
+ const mongoValues = this.convertToMongoValues(plainValues);
458
514
  if (id) {
459
515
  const objectId = this.toObjectId(id);
460
- await collection.updateOne({ _id: objectId }, { $set: mongoValues }, { upsert: true });
516
+ if (mode === "create") await refuseDuplicateKey(collectionPath, id, () => collection.insertOne({
517
+ ...mongoValues,
518
+ _id: objectId
519
+ }));
520
+ else {
521
+ const update = opFields.length > 0 ? [{ $set: {
522
+ ...Object.fromEntries(Object.entries(mongoValues).map(([key, value]) => [key, { $literal: value }])),
523
+ ...Object.fromEntries(opFields.map((key) => [key, this.compileFieldOp(key, fieldOps[key])]))
524
+ } }] : { $set: mongoValues };
525
+ const result = await refuseDuplicateKey(collectionPath, id, () => collection.updateOne({ _id: objectId }, update, { upsert: mode !== "update" }));
526
+ if (mode === "update" && result.matchedCount === 0) throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to update.`);
527
+ }
461
528
  return await this.readBack(collectionPath, objectId, {
462
529
  ...values,
463
530
  id: id.toString()
464
531
  });
465
532
  } else {
466
533
  const newId = new ObjectId();
467
- await collection.insertOne({
534
+ await refuseDuplicateKey(collectionPath, void 0, () => collection.insertOne({
468
535
  _id: newId,
469
536
  ...mongoValues
470
- });
537
+ }));
471
538
  return await this.readBack(collectionPath, newId, {
472
539
  ...values,
473
540
  id: newId.toString()
@@ -475,6 +542,32 @@ var MongoDataService = class {
475
542
  }
476
543
  }
477
544
  /**
545
+ * The aggregation expression one field operation compiles to, in an update
546
+ * pipeline's `$set`.
547
+ *
548
+ * The same meaning the Postgres compiler gives each one, NULL-safety
549
+ * included: an unset or null counter increments from zero and an absent
550
+ * array is pushed onto as an empty one, rather than the write failing on
551
+ * the first use of a field. `$push` and `$pull` take a value or a list of
552
+ * values, `$pull` removes every occurrence, and `$merge` is shallow — a
553
+ * nested object replaces, it is not merged into.
554
+ */
555
+ compileFieldOp(field, op) {
556
+ const current = `$${field}`;
557
+ const operand = this.convertToMongoValue(op.operand);
558
+ const asList = (value) => Array.isArray(value) ? value : [value];
559
+ switch (op.operator) {
560
+ case "$inc": return { $add: [{ $ifNull: [current, 0] }, { $literal: operand }] };
561
+ case "$push": return { $concatArrays: [{ $ifNull: [current, []] }, { $literal: asList(operand) }] };
562
+ case "$pull": return { $filter: {
563
+ input: { $ifNull: [current, []] },
564
+ as: "element",
565
+ cond: { $not: [{ $in: ["$$element", { $literal: asList(operand) }] }] }
566
+ } };
567
+ case "$merge": return { $mergeObjects: [{ $ifNull: [current, {}] }, { $literal: operand }] };
568
+ }
569
+ }
570
+ /**
478
571
  * Read the document back after a write, falling back to the caller's own
479
572
  * values if it has already been removed by a concurrent delete.
480
573
  */
@@ -498,11 +591,27 @@ var MongoDataService = class {
498
591
  if ((await collection.deleteOne({ _id: objectId })).deletedCount === 0) throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to delete.`);
499
592
  }
500
593
  /**
501
- * Check if a field value is unique in a collection
594
+ * Check if a field value is unique in a collection.
595
+ *
596
+ * An equality test on one field, and nothing else a query can express. The
597
+ * name and the value arrive from a socket frame, and this used to be
598
+ * `countDocuments({ [name]: value })`: a value of `{ $regex: "^123" }`
599
+ * answered, one prefix at a time, what a field of a row the caller cannot
600
+ * see begins with, and a name of `$expr` evaluated an aggregation
601
+ * expression. So an operator name is refused, the value must be a scalar,
602
+ * and it is compared with `$eq` — which reads an object as a literal even
603
+ * if one got this far.
604
+ *
605
+ * An absent value is unique, as it is on Postgres: an empty optional field
606
+ * is not "taken" by every row that also left it empty.
502
607
  */
503
608
  async checkUniqueField(collectionPath, fieldName, value, excludeEntityId, _databaseId) {
609
+ if (typeof fieldName !== "string" || fieldName === "" || fieldName.startsWith("$")) throw ApiError.badRequest(`A uniqueness check names a field, and ${JSON.stringify(fieldName)} is not one.`, "INVALID_UNIQUE_CHECK");
610
+ if (excludeEntityId !== void 0 && typeof excludeEntityId !== "string" && typeof excludeEntityId !== "number") throw ApiError.badRequest("A uniqueness check excludes one row, named by its id.", "INVALID_UNIQUE_CHECK");
611
+ if (value === void 0 || value === null) return true;
612
+ if (!(typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value instanceof Date)) throw ApiError.badRequest(`A uniqueness check on '${fieldName}' compares one value — a string, number, boolean or date — and was given something else.`, "INVALID_UNIQUE_CHECK", { field: fieldName });
504
613
  const collection = this.getCollection(collectionPath);
505
- const query = { [fieldName]: value };
614
+ const query = { [fieldName]: { $eq: value } };
506
615
  if (excludeEntityId) query._id = { $ne: this.toObjectId(excludeEntityId) };
507
616
  return await collection.countDocuments(query) === 0;
508
617
  }
@@ -513,6 +622,25 @@ var MongoDataService = class {
513
622
  return new ObjectId().toString();
514
623
  }
515
624
  };
625
+ /**
626
+ * Run a write, answering a duplicate key (E11000) as the 409 it is.
627
+ *
628
+ * A unique index refusing a write is the request's fault, not the server's —
629
+ * Postgres answers its `23505` the same way. On `_id` it means the row a
630
+ * create named is already there; on any other index it names the fields that
631
+ * collided, never the values.
632
+ */
633
+ async function refuseDuplicateKey(collectionPath, id, write) {
634
+ try {
635
+ return await write();
636
+ } catch (error) {
637
+ if (!(error instanceof MongoServerError) || error.code !== 11e3) throw error;
638
+ const pattern = error.keyPattern;
639
+ const fields = pattern && typeof pattern === "object" ? Object.keys(pattern) : [];
640
+ if (id !== void 0 && (fields.length === 0 || fields.includes("_id"))) throw ApiError.conflict(`A row "${id}" already exists in "${collectionPath}".`);
641
+ throw ApiError.conflict(`This write to "${collectionPath}" would duplicate a value that must be unique` + (fields.length > 0 ? ` (${fields.join(", ")}).` : "."), "CONFLICT", fields.length > 0 ? { fields } : void 0);
642
+ }
643
+ }
516
644
  //#endregion
517
645
  //#region src/services/MongoRealtimeService.ts
518
646
  /**
@@ -1591,7 +1719,7 @@ var MongoDriver = class {
1591
1719
  timestampNowValue: /* @__PURE__ */ new Date()
1592
1720
  });
1593
1721
  try {
1594
- let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId);
1722
+ let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId, status === "existing" ? "update" : "create");
1595
1723
  if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
1596
1724
  if (globalCallbacks?.afterRead) savedRow = await globalCallbacks.afterRead({
1597
1725
  collection: resolvedCollection,
@@ -1829,13 +1957,16 @@ var AuthenticatedMongoDriver = class {
1829
1957
  }
1830
1958
  async fetchCollection(props) {
1831
1959
  const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(props.collection, props.path);
1960
+ const viewer = this.viewer();
1961
+ assertQueryFieldsReadable(props, resolvedCollection, viewer);
1832
1962
  const rlsFilter = buildMongoFilterFromSecurityRules(resolvedCollection, this.user, "select");
1833
1963
  if (rlsFilter === null) return [];
1834
1964
  const userQuery = MongoConditionBuilder.buildQuery({
1835
1965
  filter: props.filter,
1836
1966
  logical: props.logical,
1837
1967
  searchString: props.searchString,
1838
- properties: resolvedCollection?.properties
1968
+ properties: resolvedCollection?.properties,
1969
+ viewer
1839
1970
  });
1840
1971
  const combinedQuery = Object.keys(rlsFilter).length > 0 ? { $and: [userQuery, rlsFilter] } : userQuery;
1841
1972
  const rows = await this.delegate.getDataService().fetchCollection(props.path, {
@@ -1866,10 +1997,10 @@ var AuthenticatedMongoDriver = class {
1866
1997
  row: fetched,
1867
1998
  context: contextForCallback
1868
1999
  }) ?? fetched;
1869
- return fetched;
2000
+ return stripUnreadable(fetched, resolvedCollection, viewer);
1870
2001
  }));
1871
2002
  }
1872
- return rows;
2003
+ return rows.map((row) => stripUnreadable(row, resolvedCollection, viewer));
1873
2004
  }
1874
2005
  listenCollection(props) {
1875
2006
  return this.delegate.listenCollection(props, this.authContext());
@@ -1890,6 +2021,16 @@ var AuthenticatedMongoDriver = class {
1890
2021
  };
1891
2022
  }
1892
2023
  /**
2024
+ * The caller per-field `access` is judged against.
2025
+ *
2026
+ * Never `undefined`: that is the trusted server plane, which satisfies
2027
+ * every non-empty role list, and every call through this driver is made
2028
+ * for a user — an anonymous one included, as `roles: ["anon"]`.
2029
+ */
2030
+ viewer() {
2031
+ return { roles: this.user.roles ?? [] };
2032
+ }
2033
+ /**
1893
2034
  * Evaluate the collection's rules for one row, fail-closed.
1894
2035
  *
1895
2036
  * A path with no resolvable collection has no declared rules — the same
@@ -1908,7 +2049,7 @@ var AuthenticatedMongoDriver = class {
1908
2049
  assertSecurityRulesEnforceable(resolvedCollection, "select");
1909
2050
  const row = await this.delegate.fetchOne(props);
1910
2051
  if (row && !this.authorize(resolvedCollection, rowToEntityForCheck(row, props.path), "select")) return;
1911
- return row;
2052
+ return row && stripUnreadable(row, resolvedCollection, this.viewer());
1912
2053
  }
1913
2054
  listenOne(props) {
1914
2055
  return this.delegate.listenOne(props, this.authContext());
@@ -1947,10 +2088,10 @@ var AuthenticatedMongoDriver = class {
1947
2088
  };
1948
2089
  if (!this.authorize(resolvedCollection, tempEntity, "insert")) throw ApiError.forbidden("Forbidden");
1949
2090
  }
1950
- return this.delegate.save({
2091
+ return stripUnreadable(await this.delegate.save({
1951
2092
  ...props,
1952
2093
  collection: resolvedCollection
1953
- });
2094
+ }), resolvedCollection, this.viewer());
1954
2095
  }
1955
2096
  async delete(props) {
1956
2097
  const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(props.collection, props.row.path);
@@ -1963,21 +2104,35 @@ var AuthenticatedMongoDriver = class {
1963
2104
  if (!existing || !this.authorize(resolvedCollection, rowToEntityForCheck(existing, props.row.path), "delete")) throw ApiError.forbidden("Forbidden");
1964
2105
  return this.delegate.delete(props);
1965
2106
  }
2107
+ /**
2108
+ * Whether `value` is free in `name`, asked about a field this caller could
2109
+ * have read — anything else is an oracle over a value the strip withholds,
2110
+ * one guess at a time. A registered collection names its fields, so an
2111
+ * undeclared name is refused rather than counted.
2112
+ */
1966
2113
  async checkUniqueField(path, name, value, id, collection) {
1967
- return this.delegate.checkUniqueField(path, name, value, id, collection);
2114
+ const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(collection, path);
2115
+ if (resolvedCollection?.properties) {
2116
+ if (typeof name !== "string" || !Object.prototype.hasOwnProperty.call(resolvedCollection.properties, name)) throw ApiError.badRequest(`'${String(name)}' is not a declared property of '${resolvedCollection.slug}', so it has no uniqueness to check.`, "INVALID_UNIQUE_CHECK", { field: name });
2117
+ assertQueryFieldsReadable({ filter: { [name]: ["==", value] } }, resolvedCollection, this.viewer());
2118
+ }
2119
+ return this.delegate.checkUniqueField(path, name, value, id, resolvedCollection);
1968
2120
  }
1969
2121
  generateId(path, collection) {
1970
2122
  return this.delegate.generateId(path, collection);
1971
2123
  }
1972
2124
  async count(props) {
1973
2125
  const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(props.collection, props.path);
2126
+ const viewer = this.viewer();
2127
+ assertQueryFieldsReadable(props, resolvedCollection, viewer);
1974
2128
  const rlsFilter = buildMongoFilterFromSecurityRules(resolvedCollection, this.user, "select");
1975
2129
  if (rlsFilter === null) return 0;
1976
2130
  const userQuery = MongoConditionBuilder.buildQuery({
1977
2131
  filter: props.filter,
1978
2132
  logical: props.logical,
1979
2133
  searchString: props.searchString,
1980
- properties: resolvedCollection?.properties
2134
+ properties: resolvedCollection?.properties,
2135
+ viewer
1981
2136
  });
1982
2137
  const combinedQuery = Object.keys(rlsFilter).length > 0 ? { $and: [userQuery, rlsFilter] } : userQuery;
1983
2138
  return this.delegate.getDataService().count(props.path, {
@@ -1990,6 +2145,54 @@ var AuthenticatedMongoDriver = class {
1990
2145
  }
1991
2146
  };
1992
2147
  /**
2148
+ * Drop every field `viewer` may not read — what the Postgres row pipeline's
2149
+ * `stripUnreadable` does, applied where rows leave this driver for a caller.
2150
+ *
2151
+ * `excludeFromApi` needs no branch of its own: `restrictedFieldNames` reads it
2152
+ * as `read: []`, which no caller satisfies. Deleted, never nulled — a withheld
2153
+ * value that arrives as `null` is indistinguishable from a stored one, and an
2154
+ * update that echoes the row back would overwrite the real value with it.
2155
+ */
2156
+ function stripUnreadable(row, collection, viewer) {
2157
+ if (!collection) return row;
2158
+ const { refused } = restrictedFieldNames(collection, viewer, "read");
2159
+ if (refused.size === 0) return row;
2160
+ const visible = { ...row };
2161
+ for (const name of refused) delete visible[name];
2162
+ return visible;
2163
+ }
2164
+ /**
2165
+ * Refuse a read that filters or sorts on a field the caller cannot read.
2166
+ *
2167
+ * The strip keeps the value off the wire; this keeps it from being read a bit
2168
+ * at a time — `salary > 100000` returns exactly the rows whose withheld salary
2169
+ * is above it, and a sort on it returns them in its order. REST refuses the
2170
+ * same request before it reaches a driver; the socket and subscriptions reach
2171
+ * this driver directly, so the rule is applied here too, in the same words.
2172
+ */
2173
+ function assertQueryFieldsReadable(props, collection, viewer) {
2174
+ if (!collection) return;
2175
+ const { refused } = restrictedFieldNames(collection, viewer, "read");
2176
+ if (refused.size === 0) return;
2177
+ const named = /* @__PURE__ */ new Set();
2178
+ const consider = (field) => {
2179
+ if (typeof field === "string" && refused.has(field)) named.add(field);
2180
+ };
2181
+ Object.keys(props.filter ?? {}).forEach(consider);
2182
+ const walk = (logical) => {
2183
+ for (const entry of logical?.conditions ?? []) if (entry && "conditions" in entry) walk(entry);
2184
+ else consider(entry?.column);
2185
+ };
2186
+ walk(props.logical);
2187
+ for (const key of normalizeDriverOrderBy(props.orderBy, props.order) ?? []) consider(Array.isArray(key) ? key[0] : key);
2188
+ if (named.size === 0) return;
2189
+ const fields = [...named];
2190
+ throw ApiError.badRequest(`${fields.map((f) => `'${f}'`).join(", ")} ${fields.length > 1 ? "are" : "is"} not readable on '${collection.slug}' with your roles, so ${fields.length > 1 ? "they" : "it"} cannot be used to filter or sort.`, "FIELD_NOT_READABLE", {
2191
+ collection: collection.slug,
2192
+ fields
2193
+ });
2194
+ }
2195
+ /**
1993
2196
  * Wrap a flat row into the Entity shape expected by `checkOperation`,
1994
2197
  * which evaluates security rules against `row.values`.
1995
2198
  */
@@ -2326,10 +2529,20 @@ function toUser(doc) {
2326
2529
  emailVerified: doc.emailVerified ?? false,
2327
2530
  emailVerificationToken: doc.emailVerificationToken ?? null,
2328
2531
  emailVerificationSentAt: doc.emailVerificationSentAt ? new Date(doc.emailVerificationSentAt) : null,
2532
+ isAnonymous: doc.isAnonymous === true,
2533
+ metadata: doc.metadata ?? {},
2329
2534
  createdAt: new Date(doc.createdAt),
2330
2535
  updatedAt: new Date(doc.updatedAt)
2331
2536
  };
2332
2537
  }
2538
+ /**
2539
+ * Mongo's duplicate-key error. The unique index on `email` in
2540
+ * `ensure-collections.ts` is what raises it, and the answer to it is the same
2541
+ * 409 Postgres gives for its 23505 — see `UserRepository.createUser`.
2542
+ */
2543
+ function isDuplicateKey(error) {
2544
+ return error?.code === 11e3;
2545
+ }
2333
2546
  var MongoUserService = class {
2334
2547
  db;
2335
2548
  constructor(db) {
@@ -2344,9 +2557,6 @@ var MongoUserService = class {
2344
2557
  get userRolesCollection() {
2345
2558
  return this.db.collection("rebase_user_roles");
2346
2559
  }
2347
- get rolesCollection() {
2348
- return this.db.collection("rebase_roles");
2349
- }
2350
2560
  async createUser(data) {
2351
2561
  const id = new ObjectId().toString();
2352
2562
  const now = /* @__PURE__ */ new Date();
@@ -2358,13 +2568,15 @@ var MongoUserService = class {
2358
2568
  displayName: data.displayName ?? null,
2359
2569
  photoUrl: data.photoUrl ?? null,
2360
2570
  emailVerified: data.emailVerified ?? false,
2571
+ isAnonymous: data.isAnonymous ?? false,
2572
+ metadata: data.metadata ?? {},
2361
2573
  createdAt: now,
2362
2574
  updatedAt: now
2363
2575
  };
2364
2576
  try {
2365
2577
  await this.collection.insertOne(doc);
2366
2578
  } catch (error) {
2367
- if (error?.code === 11e3) throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
2579
+ if (isDuplicateKey(error)) throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
2368
2580
  throw error;
2369
2581
  }
2370
2582
  return toUser(doc);
@@ -2422,10 +2634,26 @@ var MongoUserService = class {
2422
2634
  updatedAt: /* @__PURE__ */ new Date()
2423
2635
  };
2424
2636
  if (typeof updateData.email === "string") updateData.email = normalizeEmail(updateData.email);
2425
- await this.collection.updateOne({ id }, { $set: updateData });
2637
+ try {
2638
+ await this.collection.updateOne({ id }, { $set: updateData });
2639
+ } catch (error) {
2640
+ if (isDuplicateKey(error)) throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
2641
+ throw error;
2642
+ }
2426
2643
  return this.getUserById(id);
2427
2644
  }
2645
+ /**
2646
+ * Everything keyed by the user goes with them. Postgres does this by
2647
+ * foreign-key cascade; Mongo has none, so it is spelled out — and the
2648
+ * refresh tokens are the part that matters: left behind, a deleted user's
2649
+ * session kept minting access tokens for a uid that no longer exists.
2650
+ * Sessions first, so a failure part-way leaves an account that cannot sign
2651
+ * in rather than a session with no account.
2652
+ */
2428
2653
  async deleteUser(id) {
2654
+ await this.db.collection("rebase_refresh_tokens").deleteMany({ uid: id });
2655
+ await this.db.collection("rebase_password_reset_tokens").deleteMany({ uid: id });
2656
+ await this.db.collection("magic_link_tokens").deleteMany({ uid: id });
2429
2657
  await this.collection.deleteOne({ id });
2430
2658
  await this.identitiesCollection.deleteMany({ uid: id });
2431
2659
  await this.userRolesCollection.deleteMany({ uid: id });
@@ -2486,17 +2714,6 @@ var MongoUserService = class {
2486
2714
  const doc = await this.collection.findOne({ emailVerificationToken: token });
2487
2715
  return doc ? toUser(doc) : null;
2488
2716
  }
2489
- async getUserRoles(uid) {
2490
- const roleIds = (await this.userRolesCollection.find({ uid }).toArray()).map((ur) => ur.roleId);
2491
- if (roleIds.length === 0) return [];
2492
- return (await this.rolesCollection.find({ id: { $in: roleIds } }).toArray()).map((r) => ({
2493
- id: r.id,
2494
- name: r.name,
2495
- isAdmin: r.isAdmin ?? false,
2496
- defaultPermissions: r.defaultPermissions ?? null,
2497
- collectionPermissions: r.collectionPermissions ?? null
2498
- }));
2499
- }
2500
2717
  async getUserRoleIds(uid) {
2501
2718
  return (await this.userRolesCollection.find({ uid }).toArray()).map((ur) => ur.roleId);
2502
2719
  }
@@ -2526,57 +2743,8 @@ var MongoUserService = class {
2526
2743
  if (!user) return null;
2527
2744
  return {
2528
2745
  user,
2529
- roles: await this.getUserRoles(uid)
2530
- };
2531
- }
2532
- };
2533
- var MongoRoleService = class {
2534
- db;
2535
- constructor(db) {
2536
- this.db = db;
2537
- }
2538
- get collection() {
2539
- return this.db.collection("rebase_roles");
2540
- }
2541
- async getRoleById(id) {
2542
- const doc = await this.collection.findOne({ id });
2543
- if (!doc) return null;
2544
- return {
2545
- id: doc.id,
2546
- name: doc.name,
2547
- isAdmin: doc.isAdmin ?? false,
2548
- defaultPermissions: doc.defaultPermissions ?? null,
2549
- collectionPermissions: doc.collectionPermissions ?? null
2550
- };
2551
- }
2552
- async listRoles() {
2553
- return (await this.collection.find().sort({ name: 1 }).toArray()).map((doc) => ({
2554
- id: doc.id,
2555
- name: doc.name,
2556
- isAdmin: doc.isAdmin ?? false,
2557
- defaultPermissions: doc.defaultPermissions ?? null,
2558
- collectionPermissions: doc.collectionPermissions ?? null
2559
- }));
2560
- }
2561
- async createRole(data) {
2562
- const doc = {
2563
- _id: data.id,
2564
- id: data.id,
2565
- name: data.name,
2566
- isAdmin: data.isAdmin ?? false,
2567
- defaultPermissions: data.defaultPermissions ?? null,
2568
- collectionPermissions: data.collectionPermissions ?? null
2746
+ roles: await this.getUserRoleIds(uid)
2569
2747
  };
2570
- await this.collection.insertOne(doc);
2571
- return { ...doc };
2572
- }
2573
- async updateRole(id, data) {
2574
- await this.collection.updateOne({ id }, { $set: data });
2575
- return this.getRoleById(id);
2576
- }
2577
- async deleteRole(id) {
2578
- await this.collection.deleteOne({ id });
2579
- await this.db.collection("rebase_user_roles").deleteMany({ roleId: id });
2580
2748
  }
2581
2749
  };
2582
2750
  var MongoRefreshTokenService = class {
@@ -2803,12 +2971,10 @@ var MongoTokenRepository = class {
2803
2971
  var MongoAuthRepository = class {
2804
2972
  db;
2805
2973
  userService;
2806
- roleService;
2807
2974
  tokenRepository;
2808
2975
  constructor(db) {
2809
2976
  this.db = db;
2810
2977
  this.userService = new MongoUserService(db);
2811
- this.roleService = new MongoRoleService(db);
2812
2978
  this.tokenRepository = new MongoTokenRepository(db);
2813
2979
  }
2814
2980
  async createUser(data) {
@@ -2853,9 +3019,6 @@ var MongoAuthRepository = class {
2853
3019
  async getUserByVerificationToken(token) {
2854
3020
  return this.userService.getUserByVerificationToken(token);
2855
3021
  }
2856
- async getUserRoles(uid) {
2857
- return this.userService.getUserRoles(uid);
2858
- }
2859
3022
  async getUserRoleIds(uid) {
2860
3023
  return this.userService.getUserRoleIds(uid);
2861
3024
  }
@@ -2868,21 +3031,6 @@ var MongoAuthRepository = class {
2868
3031
  async getUserWithRoles(uid) {
2869
3032
  return this.userService.getUserWithRoles(uid);
2870
3033
  }
2871
- async getRoleById(id) {
2872
- return this.roleService.getRoleById(id);
2873
- }
2874
- async listRoles() {
2875
- return this.roleService.listRoles();
2876
- }
2877
- async createRole(data) {
2878
- return this.roleService.createRole(data);
2879
- }
2880
- async updateRole(id, data) {
2881
- return this.roleService.updateRole(id, data);
2882
- }
2883
- async deleteRole(id) {
2884
- await this.roleService.deleteRole(id);
2885
- }
2886
3034
  async createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session) {
2887
3035
  await this.tokenRepository.createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session);
2888
3036
  }
@@ -3015,7 +3163,7 @@ function createMongoBootstrapper(mongoConfig) {
3015
3163
  },
3016
3164
  async initializeAuth(config, driverResult) {
3017
3165
  const db = driverResult.internals.db;
3018
- const { ensureAuthCollectionsExist } = await import("./ensure-collections-Bl12bSwO.js");
3166
+ const { ensureAuthCollectionsExist } = await import("./ensure-collections-DHTVMlxf.js");
3019
3167
  await ensureAuthCollectionsExist(db);
3020
3168
  const { createEmailService } = await import("@rebasepro/server");
3021
3169
  const authConfig = config;
@@ -3023,7 +3171,6 @@ function createMongoBootstrapper(mongoConfig) {
3023
3171
  if (authConfig?.email) emailService = createEmailService(authConfig.email);
3024
3172
  return {
3025
3173
  userService: new MongoUserService(db),
3026
- roleService: new MongoRoleService(db),
3027
3174
  authRepository: new MongoAuthRepository(db),
3028
3175
  emailService
3029
3176
  };
@@ -3088,7 +3235,7 @@ function createMongoBootstrapper(mongoConfig) {
3088
3235
  },
3089
3236
  mountRoutes() {},
3090
3237
  async initializeWebsockets(server, realtimeService, driver, config, authAdapter) {
3091
- const { createMongoWebSocket } = await import("./websocket-C3iT6srl.js");
3238
+ const { createMongoWebSocket } = await import("./websocket-CXSVz4oq.js");
3092
3239
  createMongoWebSocket(server, realtimeService, driver, config, cachedAdmin, authAdapter);
3093
3240
  }
3094
3241
  };