@rebasepro/server-mongo 0.21.2-canary.g1ea48be → 0.23.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/auth/services.d.ts +8 -0
- package/dist/db/MongoConditionBuilder.d.ts +37 -5
- package/dist/db/MongoDataService.d.ts +46 -3
- package/dist/index.es.js +290 -45
- package/dist/index.es.js.map +1 -1
- package/dist/services/MongoDriver.d.ts +16 -2
- package/dist/{websocket-C3iT6srl.js → websocket-BtK4_9ZS.js} +6 -5
- package/dist/websocket-BtK4_9ZS.js.map +1 -0
- package/package.json +5 -5
- package/dist/websocket-C3iT6srl.js.map +0 -1
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 { buildPropertyCallbacks, buildSdkData, callbackRefusal, checkOperation, normalizeDriverOrderBy, normalizeEmail, requireCallbackClient, securityRuleToConditions, toCallbackError, toFilterTuples, updateDateAutoValues } from "@rebasepro/common";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { assertBeforeQueryIsPostgresOnly, buildPropertyCallbacks, buildSdkData, callbackRefusal, canReadField, checkOperation, normalizeDriverOrderBy, normalizeEmail, requireCallbackClient, restrictedFieldNames, securityRuleToConditions, toCallbackError, toFilterTuples, updateDateAutoValues } from "@rebasepro/common";
|
|
4
|
+
import { mergeDeep, splitSearchTerms } from "@rebasepro/utils";
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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,42 +166,76 @@ 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
|
-
* Build search conditions for text search
|
|
200
|
+
* Build search conditions for text search.
|
|
201
|
+
*
|
|
202
|
+
* Terms are split on whitespace and AND-ed, while the fields are OR-ed
|
|
203
|
+
* within each term: a person typing `sebastian melendez` into a search box
|
|
204
|
+
* means both words, and the two live in different fields, so matching the
|
|
205
|
+
* whole string per field finds nothing. See {@link splitSearchTerms}; the
|
|
206
|
+
* Postgres driver's fallback path answers the same way.
|
|
181
207
|
*
|
|
182
208
|
* @param searchString - Text to search for
|
|
183
209
|
* @param properties - The collection's properties, searched for string fields
|
|
184
|
-
* @
|
|
185
|
-
|
|
186
|
-
|
|
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.
|
|
216
|
+
* @returns At most one MongoDB filter — callers OR what they get back, which
|
|
217
|
+
* is right across fields and wrong across terms, so the AND is built here
|
|
218
|
+
*/
|
|
219
|
+
static buildSearchConditions(searchString, properties, viewer) {
|
|
187
220
|
if (!searchString) return [];
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
221
|
+
const searchableFields = [];
|
|
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 } }];
|
|
231
|
+
if (searchableFields.length === 0) return [{ $text: { $search: searchString } }];
|
|
232
|
+
const terms = splitSearchTerms(searchString);
|
|
233
|
+
const perTerm = (terms.length > 0 ? terms : [searchString]).map((term) => {
|
|
234
|
+
const regex = new RegExp(escapeRegExp$1(term), "i");
|
|
235
|
+
return this.combineConditionsWithOr(searchableFields.map((field) => ({ [field]: { $regex: regex } })));
|
|
236
|
+
});
|
|
237
|
+
const combined = this.combineConditionsWithAnd(perTerm);
|
|
238
|
+
return combined ? [combined] : [];
|
|
194
239
|
}
|
|
195
240
|
/**
|
|
196
241
|
* Combine multiple conditions with AND operator
|
|
@@ -229,7 +274,7 @@ var MongoConditionBuilder = class MongoConditionBuilder {
|
|
|
229
274
|
const logicalCondition = this.buildLogicalConditions(options.logical);
|
|
230
275
|
if (logicalCondition) conditions.push(logicalCondition);
|
|
231
276
|
if (options.searchString && options.properties) {
|
|
232
|
-
const searchConditions = this.buildSearchConditions(options.searchString, options.properties);
|
|
277
|
+
const searchConditions = this.buildSearchConditions(options.searchString, options.properties, options.viewer);
|
|
233
278
|
if (searchConditions.length > 0) {
|
|
234
279
|
const searchFilter = this.combineConditionsWithOr(searchConditions);
|
|
235
280
|
if (searchFilter) conditions.push(searchFilter);
|
|
@@ -440,23 +485,56 @@ var MongoDataService = class {
|
|
|
440
485
|
* partial row everywhere it went: the REST response, `afterSave`, the
|
|
441
486
|
* history entry a revert restores from, and the row pushed to realtime
|
|
442
487
|
* subscribers. Postgres returns the whole row here (`RETURNING *`).
|
|
443
|
-
|
|
444
|
-
|
|
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) {
|
|
445
509
|
const collection = this.getCollection(collectionPath);
|
|
446
|
-
const
|
|
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);
|
|
447
514
|
if (id) {
|
|
448
515
|
const objectId = this.toObjectId(id);
|
|
449
|
-
|
|
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
|
+
}
|
|
450
528
|
return await this.readBack(collectionPath, objectId, {
|
|
451
529
|
...values,
|
|
452
530
|
id: id.toString()
|
|
453
531
|
});
|
|
454
532
|
} else {
|
|
455
533
|
const newId = new ObjectId();
|
|
456
|
-
await collection.insertOne({
|
|
534
|
+
await refuseDuplicateKey(collectionPath, void 0, () => collection.insertOne({
|
|
457
535
|
_id: newId,
|
|
458
536
|
...mongoValues
|
|
459
|
-
});
|
|
537
|
+
}));
|
|
460
538
|
return await this.readBack(collectionPath, newId, {
|
|
461
539
|
...values,
|
|
462
540
|
id: newId.toString()
|
|
@@ -464,6 +542,32 @@ var MongoDataService = class {
|
|
|
464
542
|
}
|
|
465
543
|
}
|
|
466
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
|
+
/**
|
|
467
571
|
* Read the document back after a write, falling back to the caller's own
|
|
468
572
|
* values if it has already been removed by a concurrent delete.
|
|
469
573
|
*/
|
|
@@ -487,11 +591,27 @@ var MongoDataService = class {
|
|
|
487
591
|
if ((await collection.deleteOne({ _id: objectId })).deletedCount === 0) throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to delete.`);
|
|
488
592
|
}
|
|
489
593
|
/**
|
|
490
|
-
* 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.
|
|
491
607
|
*/
|
|
492
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 });
|
|
493
613
|
const collection = this.getCollection(collectionPath);
|
|
494
|
-
const query = { [fieldName]: value };
|
|
614
|
+
const query = { [fieldName]: { $eq: value } };
|
|
495
615
|
if (excludeEntityId) query._id = { $ne: this.toObjectId(excludeEntityId) };
|
|
496
616
|
return await collection.countDocuments(query) === 0;
|
|
497
617
|
}
|
|
@@ -502,6 +622,25 @@ var MongoDataService = class {
|
|
|
502
622
|
return new ObjectId().toString();
|
|
503
623
|
}
|
|
504
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
|
+
}
|
|
505
644
|
//#endregion
|
|
506
645
|
//#region src/services/MongoRealtimeService.ts
|
|
507
646
|
/**
|
|
@@ -1580,7 +1719,7 @@ var MongoDriver = class {
|
|
|
1580
1719
|
timestampNowValue: /* @__PURE__ */ new Date()
|
|
1581
1720
|
});
|
|
1582
1721
|
try {
|
|
1583
|
-
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");
|
|
1584
1723
|
if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
|
|
1585
1724
|
if (globalCallbacks?.afterRead) savedRow = await globalCallbacks.afterRead({
|
|
1586
1725
|
collection: resolvedCollection,
|
|
@@ -1818,13 +1957,16 @@ var AuthenticatedMongoDriver = class {
|
|
|
1818
1957
|
}
|
|
1819
1958
|
async fetchCollection(props) {
|
|
1820
1959
|
const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(props.collection, props.path);
|
|
1960
|
+
const viewer = this.viewer();
|
|
1961
|
+
assertQueryFieldsReadable(props, resolvedCollection, viewer);
|
|
1821
1962
|
const rlsFilter = buildMongoFilterFromSecurityRules(resolvedCollection, this.user, "select");
|
|
1822
1963
|
if (rlsFilter === null) return [];
|
|
1823
1964
|
const userQuery = MongoConditionBuilder.buildQuery({
|
|
1824
1965
|
filter: props.filter,
|
|
1825
1966
|
logical: props.logical,
|
|
1826
1967
|
searchString: props.searchString,
|
|
1827
|
-
properties: resolvedCollection?.properties
|
|
1968
|
+
properties: resolvedCollection?.properties,
|
|
1969
|
+
viewer
|
|
1828
1970
|
});
|
|
1829
1971
|
const combinedQuery = Object.keys(rlsFilter).length > 0 ? { $and: [userQuery, rlsFilter] } : userQuery;
|
|
1830
1972
|
const rows = await this.delegate.getDataService().fetchCollection(props.path, {
|
|
@@ -1855,10 +1997,10 @@ var AuthenticatedMongoDriver = class {
|
|
|
1855
1997
|
row: fetched,
|
|
1856
1998
|
context: contextForCallback
|
|
1857
1999
|
}) ?? fetched;
|
|
1858
|
-
return fetched;
|
|
2000
|
+
return stripUnreadable(fetched, resolvedCollection, viewer);
|
|
1859
2001
|
}));
|
|
1860
2002
|
}
|
|
1861
|
-
return rows;
|
|
2003
|
+
return rows.map((row) => stripUnreadable(row, resolvedCollection, viewer));
|
|
1862
2004
|
}
|
|
1863
2005
|
listenCollection(props) {
|
|
1864
2006
|
return this.delegate.listenCollection(props, this.authContext());
|
|
@@ -1879,6 +2021,16 @@ var AuthenticatedMongoDriver = class {
|
|
|
1879
2021
|
};
|
|
1880
2022
|
}
|
|
1881
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
|
+
/**
|
|
1882
2034
|
* Evaluate the collection's rules for one row, fail-closed.
|
|
1883
2035
|
*
|
|
1884
2036
|
* A path with no resolvable collection has no declared rules — the same
|
|
@@ -1897,7 +2049,7 @@ var AuthenticatedMongoDriver = class {
|
|
|
1897
2049
|
assertSecurityRulesEnforceable(resolvedCollection, "select");
|
|
1898
2050
|
const row = await this.delegate.fetchOne(props);
|
|
1899
2051
|
if (row && !this.authorize(resolvedCollection, rowToEntityForCheck(row, props.path), "select")) return;
|
|
1900
|
-
return row;
|
|
2052
|
+
return row && stripUnreadable(row, resolvedCollection, this.viewer());
|
|
1901
2053
|
}
|
|
1902
2054
|
listenOne(props) {
|
|
1903
2055
|
return this.delegate.listenOne(props, this.authContext());
|
|
@@ -1936,10 +2088,10 @@ var AuthenticatedMongoDriver = class {
|
|
|
1936
2088
|
};
|
|
1937
2089
|
if (!this.authorize(resolvedCollection, tempEntity, "insert")) throw ApiError.forbidden("Forbidden");
|
|
1938
2090
|
}
|
|
1939
|
-
return this.delegate.save({
|
|
2091
|
+
return stripUnreadable(await this.delegate.save({
|
|
1940
2092
|
...props,
|
|
1941
2093
|
collection: resolvedCollection
|
|
1942
|
-
});
|
|
2094
|
+
}), resolvedCollection, this.viewer());
|
|
1943
2095
|
}
|
|
1944
2096
|
async delete(props) {
|
|
1945
2097
|
const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(props.collection, props.row.path);
|
|
@@ -1952,21 +2104,35 @@ var AuthenticatedMongoDriver = class {
|
|
|
1952
2104
|
if (!existing || !this.authorize(resolvedCollection, rowToEntityForCheck(existing, props.row.path), "delete")) throw ApiError.forbidden("Forbidden");
|
|
1953
2105
|
return this.delegate.delete(props);
|
|
1954
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
|
+
*/
|
|
1955
2113
|
async checkUniqueField(path, name, value, id, collection) {
|
|
1956
|
-
|
|
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);
|
|
1957
2120
|
}
|
|
1958
2121
|
generateId(path, collection) {
|
|
1959
2122
|
return this.delegate.generateId(path, collection);
|
|
1960
2123
|
}
|
|
1961
2124
|
async count(props) {
|
|
1962
2125
|
const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(props.collection, props.path);
|
|
2126
|
+
const viewer = this.viewer();
|
|
2127
|
+
assertQueryFieldsReadable(props, resolvedCollection, viewer);
|
|
1963
2128
|
const rlsFilter = buildMongoFilterFromSecurityRules(resolvedCollection, this.user, "select");
|
|
1964
2129
|
if (rlsFilter === null) return 0;
|
|
1965
2130
|
const userQuery = MongoConditionBuilder.buildQuery({
|
|
1966
2131
|
filter: props.filter,
|
|
1967
2132
|
logical: props.logical,
|
|
1968
2133
|
searchString: props.searchString,
|
|
1969
|
-
properties: resolvedCollection?.properties
|
|
2134
|
+
properties: resolvedCollection?.properties,
|
|
2135
|
+
viewer
|
|
1970
2136
|
});
|
|
1971
2137
|
const combinedQuery = Object.keys(rlsFilter).length > 0 ? { $and: [userQuery, rlsFilter] } : userQuery;
|
|
1972
2138
|
return this.delegate.getDataService().count(props.path, {
|
|
@@ -1979,6 +2145,54 @@ var AuthenticatedMongoDriver = class {
|
|
|
1979
2145
|
}
|
|
1980
2146
|
};
|
|
1981
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
|
+
/**
|
|
1982
2196
|
* Wrap a flat row into the Entity shape expected by `checkOperation`,
|
|
1983
2197
|
* which evaluates security rules against `row.values`.
|
|
1984
2198
|
*/
|
|
@@ -2315,10 +2529,20 @@ function toUser(doc) {
|
|
|
2315
2529
|
emailVerified: doc.emailVerified ?? false,
|
|
2316
2530
|
emailVerificationToken: doc.emailVerificationToken ?? null,
|
|
2317
2531
|
emailVerificationSentAt: doc.emailVerificationSentAt ? new Date(doc.emailVerificationSentAt) : null,
|
|
2532
|
+
isAnonymous: doc.isAnonymous === true,
|
|
2533
|
+
metadata: doc.metadata ?? {},
|
|
2318
2534
|
createdAt: new Date(doc.createdAt),
|
|
2319
2535
|
updatedAt: new Date(doc.updatedAt)
|
|
2320
2536
|
};
|
|
2321
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
|
+
}
|
|
2322
2546
|
var MongoUserService = class {
|
|
2323
2547
|
db;
|
|
2324
2548
|
constructor(db) {
|
|
@@ -2347,13 +2571,15 @@ var MongoUserService = class {
|
|
|
2347
2571
|
displayName: data.displayName ?? null,
|
|
2348
2572
|
photoUrl: data.photoUrl ?? null,
|
|
2349
2573
|
emailVerified: data.emailVerified ?? false,
|
|
2574
|
+
isAnonymous: data.isAnonymous ?? false,
|
|
2575
|
+
metadata: data.metadata ?? {},
|
|
2350
2576
|
createdAt: now,
|
|
2351
2577
|
updatedAt: now
|
|
2352
2578
|
};
|
|
2353
2579
|
try {
|
|
2354
2580
|
await this.collection.insertOne(doc);
|
|
2355
2581
|
} catch (error) {
|
|
2356
|
-
if (error
|
|
2582
|
+
if (isDuplicateKey(error)) throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
|
|
2357
2583
|
throw error;
|
|
2358
2584
|
}
|
|
2359
2585
|
return toUser(doc);
|
|
@@ -2411,10 +2637,26 @@ var MongoUserService = class {
|
|
|
2411
2637
|
updatedAt: /* @__PURE__ */ new Date()
|
|
2412
2638
|
};
|
|
2413
2639
|
if (typeof updateData.email === "string") updateData.email = normalizeEmail(updateData.email);
|
|
2414
|
-
|
|
2640
|
+
try {
|
|
2641
|
+
await this.collection.updateOne({ id }, { $set: updateData });
|
|
2642
|
+
} catch (error) {
|
|
2643
|
+
if (isDuplicateKey(error)) throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
|
|
2644
|
+
throw error;
|
|
2645
|
+
}
|
|
2415
2646
|
return this.getUserById(id);
|
|
2416
2647
|
}
|
|
2648
|
+
/**
|
|
2649
|
+
* Everything keyed by the user goes with them. Postgres does this by
|
|
2650
|
+
* foreign-key cascade; Mongo has none, so it is spelled out — and the
|
|
2651
|
+
* refresh tokens are the part that matters: left behind, a deleted user's
|
|
2652
|
+
* session kept minting access tokens for a uid that no longer exists.
|
|
2653
|
+
* Sessions first, so a failure part-way leaves an account that cannot sign
|
|
2654
|
+
* in rather than a session with no account.
|
|
2655
|
+
*/
|
|
2417
2656
|
async deleteUser(id) {
|
|
2657
|
+
await this.db.collection("rebase_refresh_tokens").deleteMany({ uid: id });
|
|
2658
|
+
await this.db.collection("rebase_password_reset_tokens").deleteMany({ uid: id });
|
|
2659
|
+
await this.db.collection("magic_link_tokens").deleteMany({ uid: id });
|
|
2418
2660
|
await this.collection.deleteOne({ id });
|
|
2419
2661
|
await this.identitiesCollection.deleteMany({ uid: id });
|
|
2420
2662
|
await this.userRolesCollection.deleteMany({ uid: id });
|
|
@@ -2976,7 +3218,10 @@ function createMongoBootstrapper(mongoConfig) {
|
|
|
2976
3218
|
async initializeDriver(config) {
|
|
2977
3219
|
const { collections } = config;
|
|
2978
3220
|
const registry = new MongoCollectionRegistry();
|
|
2979
|
-
if (collections)
|
|
3221
|
+
if (collections) {
|
|
3222
|
+
assertBeforeQueryIsPostgresOnly(collections);
|
|
3223
|
+
collections.forEach((collection) => registry.register(collection));
|
|
3224
|
+
}
|
|
2980
3225
|
const db = mongoConfig.connection;
|
|
2981
3226
|
const client = mongoConfig.client;
|
|
2982
3227
|
try {
|
|
@@ -3074,7 +3319,7 @@ function createMongoBootstrapper(mongoConfig) {
|
|
|
3074
3319
|
},
|
|
3075
3320
|
mountRoutes() {},
|
|
3076
3321
|
async initializeWebsockets(server, realtimeService, driver, config, authAdapter) {
|
|
3077
|
-
const { createMongoWebSocket } = await import("./websocket-
|
|
3322
|
+
const { createMongoWebSocket } = await import("./websocket-BtK4_9ZS.js");
|
|
3078
3323
|
createMongoWebSocket(server, realtimeService, driver, config, cachedAdmin, authAdapter);
|
|
3079
3324
|
}
|
|
3080
3325
|
};
|