@rebasepro/server-mongo 0.13.0 → 0.13.1-canary.g1822133
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/MongoBootstrapper.d.ts.map +1 -1
- package/dist/auth/ensure-collections.d.ts.map +1 -1
- package/dist/db/MongoConditionBuilder.d.ts +28 -1
- package/dist/db/MongoConditionBuilder.d.ts.map +1 -1
- package/dist/db/MongoDataService.d.ts +27 -2
- package/dist/db/MongoDataService.d.ts.map +1 -1
- package/dist/db/securityRuleFilter.d.ts +65 -0
- package/dist/db/securityRuleFilter.d.ts.map +1 -0
- package/dist/ensure-collections-Bl12bSwO.js +130 -0
- package/dist/ensure-collections-Bl12bSwO.js.map +1 -0
- package/dist/factory.d.ts +11 -1
- package/dist/factory.d.ts.map +1 -1
- package/dist/index.es.js +496 -251
- package/dist/index.es.js.map +1 -1
- package/dist/services/MongoDriver.d.ts +37 -5
- package/dist/services/MongoDriver.d.ts.map +1 -1
- package/dist/services/MongoRealtimeService.d.ts +23 -14
- package/dist/services/MongoRealtimeService.d.ts.map +1 -1
- package/dist/{websocket-74DDOdK0.js → websocket-Bs54-gR-.js} +32 -11
- package/dist/websocket-Bs54-gR-.js.map +1 -0
- package/dist/websocket.d.ts +2 -2
- package/dist/websocket.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/MongoBootstrapper.ts +10 -2
- package/src/auth/ensure-collections.ts +61 -13
- package/src/db/MongoConditionBuilder.ts +112 -40
- package/src/db/MongoDataService.ts +61 -17
- package/src/db/securityRuleFilter.ts +398 -0
- package/src/factory.ts +17 -4
- package/src/services/MongoDriver.ts +101 -262
- package/src/services/MongoRealtimeService.ts +124 -66
- package/src/websocket.ts +66 -15
- package/dist/ensure-collections-Bkx_O5CQ.js +0 -94
- package/dist/ensure-collections-Bkx_O5CQ.js.map +0 -1
- 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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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]
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
137
|
-
|
|
174
|
+
const { column, operator, value } = entry;
|
|
175
|
+
parts.push(this.buildCondition(column, operator, value));
|
|
138
176
|
}
|
|
139
|
-
return
|
|
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 ??
|
|
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,29 +416,44 @@ 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
|
/**
|
|
383
|
-
*
|
|
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
|
+
/**
|
|
444
|
+
* Delete a row by ID.
|
|
445
|
+
*
|
|
446
|
+
* Rejects when nothing matched, which is the `DataDriver.delete` contract:
|
|
447
|
+
* resolving means the row is gone because this call removed it. This used
|
|
448
|
+
* to log a warning and resolve, so a caller could not tell a delete from a
|
|
449
|
+
* no-op — and the same call on the Postgres driver threw. The wording is
|
|
450
|
+
* the Postgres driver's, verbatim, because two spellings of one answer is
|
|
451
|
+
* how the two came apart in the first place.
|
|
384
452
|
*/
|
|
385
453
|
async delete(collectionPath, id, _databaseId) {
|
|
386
454
|
const collection = this.getCollection(collectionPath);
|
|
387
455
|
const objectId = this.toObjectId(id);
|
|
388
|
-
if ((await collection.deleteOne({ _id: objectId })).deletedCount === 0)
|
|
456
|
+
if ((await collection.deleteOne({ _id: objectId })).deletedCount === 0) throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to delete.`);
|
|
389
457
|
}
|
|
390
458
|
/**
|
|
391
459
|
* Check if a field value is unique in a collection
|
|
@@ -421,11 +489,9 @@ var MongoRealtimeService = class {
|
|
|
421
489
|
db;
|
|
422
490
|
subscriptions = /* @__PURE__ */ new Map();
|
|
423
491
|
clients = /* @__PURE__ */ new Map();
|
|
424
|
-
dataService;
|
|
425
492
|
driver;
|
|
426
493
|
constructor(db) {
|
|
427
494
|
this.db = db;
|
|
428
|
-
this.dataService = new MongoDataService(db);
|
|
429
495
|
}
|
|
430
496
|
setDataDriver(driver) {
|
|
431
497
|
this.driver = driver;
|
|
@@ -456,8 +522,7 @@ var MongoRealtimeService = class {
|
|
|
456
522
|
type: "collection",
|
|
457
523
|
config,
|
|
458
524
|
changeStream,
|
|
459
|
-
callback
|
|
460
|
-
authContext: config.authContext
|
|
525
|
+
callback
|
|
461
526
|
};
|
|
462
527
|
this.subscriptions.set(subscriptionId, subscription);
|
|
463
528
|
this.fetchAndNotifyCollection(subscriptionId, config, callback);
|
|
@@ -472,8 +537,7 @@ var MongoRealtimeService = class {
|
|
|
472
537
|
const subscription = {
|
|
473
538
|
type: "collection",
|
|
474
539
|
config,
|
|
475
|
-
callback
|
|
476
|
-
authContext: config.authContext
|
|
540
|
+
callback
|
|
477
541
|
};
|
|
478
542
|
this.subscriptions.set(subscriptionId, subscription);
|
|
479
543
|
this.fetchAndNotifyCollection(subscriptionId, config, callback);
|
|
@@ -484,31 +548,16 @@ var MongoRealtimeService = class {
|
|
|
484
548
|
*/
|
|
485
549
|
async fetchAndNotifyCollection(subscriptionId, config, callback) {
|
|
486
550
|
try {
|
|
487
|
-
let rows;
|
|
488
551
|
const registryCollection = this.driver?.registry?.getCollectionByPath(config.path);
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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, {
|
|
552
|
+
const rows = await (await this.scopedDriver(config.authContext)).fetchCollection({
|
|
553
|
+
path: config.path,
|
|
554
|
+
collection: registryCollection,
|
|
505
555
|
filter: config.filter,
|
|
506
556
|
orderBy: config.orderBy,
|
|
507
557
|
order: config.order,
|
|
508
558
|
limit: config.limit,
|
|
509
559
|
startAfter: config.startAfter,
|
|
510
|
-
searchString: config.searchString
|
|
511
|
-
collection: registryCollection
|
|
560
|
+
searchString: config.searchString
|
|
512
561
|
});
|
|
513
562
|
if (callback) callback(rows);
|
|
514
563
|
} catch (error) {
|
|
@@ -516,6 +565,20 @@ var MongoRealtimeService = class {
|
|
|
516
565
|
}
|
|
517
566
|
}
|
|
518
567
|
/**
|
|
568
|
+
* The driver scoped to a subscriber.
|
|
569
|
+
*
|
|
570
|
+
* Never the bare repository: everything a subscription delivers has to pass
|
|
571
|
+
* the same row authorization an HTTP read does.
|
|
572
|
+
*/
|
|
573
|
+
async scopedDriver(authContext) {
|
|
574
|
+
if (!this.driver) throw new Error("MongoRealtimeService has no data driver — subscriptions cannot be authorized");
|
|
575
|
+
const user = {
|
|
576
|
+
uid: authContext?.uid ?? ANONYMOUS_USER_ID,
|
|
577
|
+
roles: authContext?.roles ?? []
|
|
578
|
+
};
|
|
579
|
+
return this.driver.withAuth(user);
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
519
582
|
* Subscribe to single row changes
|
|
520
583
|
*/
|
|
521
584
|
subscribeToOne(subscriptionId, config, callback) {
|
|
@@ -537,8 +600,7 @@ var MongoRealtimeService = class {
|
|
|
537
600
|
type: "single",
|
|
538
601
|
config,
|
|
539
602
|
changeStream,
|
|
540
|
-
callback
|
|
541
|
-
authContext: config.authContext
|
|
603
|
+
callback
|
|
542
604
|
};
|
|
543
605
|
this.subscriptions.set(subscriptionId, subscription);
|
|
544
606
|
this.fetchAndNotifyOne(subscriptionId, config, callback);
|
|
@@ -555,8 +617,7 @@ var MongoRealtimeService = class {
|
|
|
555
617
|
const subscription = {
|
|
556
618
|
type: "single",
|
|
557
619
|
config,
|
|
558
|
-
callback
|
|
559
|
-
authContext: config.authContext
|
|
620
|
+
callback
|
|
560
621
|
};
|
|
561
622
|
this.subscriptions.set(subscriptionId, subscription);
|
|
562
623
|
this.fetchAndNotifyOne(subscriptionId, config, callback);
|
|
@@ -567,19 +628,12 @@ var MongoRealtimeService = class {
|
|
|
567
628
|
*/
|
|
568
629
|
async fetchAndNotifyOne(subscriptionId, config, callback) {
|
|
569
630
|
try {
|
|
570
|
-
let row;
|
|
571
631
|
const registryCollection = this.driver?.registry?.getCollectionByPath(config.path);
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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);
|
|
632
|
+
const row = await (await this.scopedDriver(config.authContext)).fetchOne({
|
|
633
|
+
path: config.path,
|
|
634
|
+
id: config.id,
|
|
635
|
+
collection: registryCollection
|
|
636
|
+
});
|
|
583
637
|
if (callback) callback(row || null);
|
|
584
638
|
} catch (error) {
|
|
585
639
|
logger.error(`Error fetching row for subscription ${subscriptionId}`, { error });
|
|
@@ -602,9 +656,8 @@ var MongoRealtimeService = class {
|
|
|
602
656
|
async notifyUpdate(path, id, row, _databaseId) {
|
|
603
657
|
for (const [subscriptionId, subscription] of this.subscriptions) if (subscription.type === "single") {
|
|
604
658
|
const config = subscription.config;
|
|
605
|
-
if (config.path === path && config.id.toString() === id)
|
|
606
|
-
|
|
607
|
-
}
|
|
659
|
+
if (config.path === path && config.id.toString() === id) if (row === null) subscription.callback?.(null);
|
|
660
|
+
else await this.fetchAndNotifyOne(subscriptionId, config, subscription.callback);
|
|
608
661
|
} else if (subscription.type === "collection") {
|
|
609
662
|
const config = subscription.config;
|
|
610
663
|
if (config.path === path) await this.fetchAndNotifyCollection(subscriptionId, config, subscription.callback);
|
|
@@ -658,13 +711,30 @@ var MongoRealtimeService = class {
|
|
|
658
711
|
case "subscribe_collection": {
|
|
659
712
|
const subscriptionId = message.payload?.subscriptionId ?? message.subscriptionId;
|
|
660
713
|
if (!subscriptionId) return;
|
|
714
|
+
let boundedLimit;
|
|
715
|
+
try {
|
|
716
|
+
boundedLimit = resolveClientListLimit(message.payload?.limit);
|
|
717
|
+
} catch (e) {
|
|
718
|
+
if (!(e instanceof ListLimitError)) throw e;
|
|
719
|
+
logger.warn(`⚠️ [MongoRealtime] Refused subscription to '${message.payload?.path}': ${e.message}`);
|
|
720
|
+
ws.send(JSON.stringify({
|
|
721
|
+
type: "ERROR",
|
|
722
|
+
subscriptionId,
|
|
723
|
+
payload: { error: {
|
|
724
|
+
message: e.message,
|
|
725
|
+
code: "INVALID_LIMIT"
|
|
726
|
+
} },
|
|
727
|
+
error: e.message
|
|
728
|
+
}));
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
661
731
|
this.subscribeToCollection(subscriptionId, {
|
|
662
732
|
clientId,
|
|
663
733
|
path: message.payload?.path,
|
|
664
734
|
filter: message.payload?.filter,
|
|
665
735
|
orderBy: message.payload?.orderBy,
|
|
666
736
|
order: message.payload?.order,
|
|
667
|
-
limit:
|
|
737
|
+
limit: boundedLimit,
|
|
668
738
|
startAfter: message.payload?.startAfter,
|
|
669
739
|
searchString: message.payload?.searchString,
|
|
670
740
|
authContext
|
|
@@ -699,6 +769,18 @@ var MongoRealtimeService = class {
|
|
|
699
769
|
if (subscriptionId) this.unsubscribe(subscriptionId);
|
|
700
770
|
break;
|
|
701
771
|
}
|
|
772
|
+
default:
|
|
773
|
+
logger.warn(`⚠️ [MongoRealtime] Unhandled realtime message type "${message.type}" — channels, presence and broadcast are not implemented by the Mongo driver.`);
|
|
774
|
+
ws.send(JSON.stringify({
|
|
775
|
+
type: "ERROR",
|
|
776
|
+
subscriptionId: message.subscriptionId,
|
|
777
|
+
payload: { error: {
|
|
778
|
+
message: `Realtime message type "${message.type}" is not supported by the Mongo driver`,
|
|
779
|
+
code: "REALTIME_UNSUPPORTED"
|
|
780
|
+
} },
|
|
781
|
+
error: `Realtime message type "${message.type}" is not supported by the Mongo driver`
|
|
782
|
+
}));
|
|
783
|
+
break;
|
|
702
784
|
}
|
|
703
785
|
}
|
|
704
786
|
};
|
|
@@ -811,6 +893,251 @@ var MongoHistoryService = class {
|
|
|
811
893
|
}
|
|
812
894
|
};
|
|
813
895
|
//#endregion
|
|
896
|
+
//#region src/db/securityRuleFilter.ts
|
|
897
|
+
/** Matches every document. */
|
|
898
|
+
var MATCH_ALL = {};
|
|
899
|
+
/**
|
|
900
|
+
* Matches no document. A distinct object rather than a `false` sentinel so it
|
|
901
|
+
* can be nested inside `$and`/`$or` like any other filter; identity is what
|
|
902
|
+
* {@link isMatchNone} tests, so never mutate or copy it.
|
|
903
|
+
*/
|
|
904
|
+
var MATCH_NONE = { _id: { $exists: false } };
|
|
905
|
+
/** The expression has no MongoDB equivalent — the caller must refuse. */
|
|
906
|
+
var UNTRANSLATABLE = "untranslatable";
|
|
907
|
+
function isMatchAll(f) {
|
|
908
|
+
return f !== UNTRANSLATABLE && f !== MATCH_NONE && Object.keys(f).length === 0;
|
|
909
|
+
}
|
|
910
|
+
function isMatchNone(f) {
|
|
911
|
+
return f === MATCH_NONE;
|
|
912
|
+
}
|
|
913
|
+
/** The error code a caller sees when a rule cannot be honoured. */
|
|
914
|
+
var SECURITY_RULE_UNSUPPORTED = "SECURITY_RULE_UNSUPPORTED";
|
|
915
|
+
/**
|
|
916
|
+
* The refusal. Names the collection, the clause and the expression, because the
|
|
917
|
+
* only useful thing an operator can do with this is rewrite that rule — or move
|
|
918
|
+
* the collection to an engine that can enforce it.
|
|
919
|
+
*/
|
|
920
|
+
function securityRuleUnsupported(collectionSlug, clause, detail) {
|
|
921
|
+
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);
|
|
922
|
+
}
|
|
923
|
+
/** Describe an expression well enough for the refusal message to be actionable. */
|
|
924
|
+
function describe(expr) {
|
|
925
|
+
switch (expr.kind) {
|
|
926
|
+
case "raw": return `\`${expr.sql}\``;
|
|
927
|
+
case "existsIn": return `a membership subquery over \`${expr.collection}\``;
|
|
928
|
+
case "not": return "a negated row predicate";
|
|
929
|
+
default: return `a \`${expr.kind}\` node`;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
/** The first node of an expression tree this driver cannot translate, if any. */
|
|
933
|
+
function findUntranslatable(expr, hasRow) {
|
|
934
|
+
switch (expr.kind) {
|
|
935
|
+
case "and":
|
|
936
|
+
case "or":
|
|
937
|
+
for (const operand of expr.operands) {
|
|
938
|
+
const found = findUntranslatable(operand, hasRow);
|
|
939
|
+
if (found) return found;
|
|
940
|
+
}
|
|
941
|
+
return;
|
|
942
|
+
case "not": return hasRow ? findUntranslatable(expr.operand, hasRow) : referencesField(expr.operand) ? expr : void 0;
|
|
943
|
+
case "compare": return operandUntranslatable(expr.left) || operandUntranslatable(expr.right) ? expr : void 0;
|
|
944
|
+
case "existsIn":
|
|
945
|
+
case "raw": return expr;
|
|
946
|
+
default: return;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
function operandUntranslatable(operand) {
|
|
950
|
+
return operand.kind === "outerField";
|
|
951
|
+
}
|
|
952
|
+
function referencesField(expr) {
|
|
953
|
+
switch (expr.kind) {
|
|
954
|
+
case "and":
|
|
955
|
+
case "or": return expr.operands.some(referencesField);
|
|
956
|
+
case "not": return referencesField(expr.operand);
|
|
957
|
+
case "compare": return expr.left.kind === "field" || expr.right.kind === "field" || expr.left.kind === "outerField" || expr.right.kind === "outerField";
|
|
958
|
+
default: return false;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
function userContext(user) {
|
|
962
|
+
return {
|
|
963
|
+
uid: user?.uid || ANONYMOUS_USER_ID,
|
|
964
|
+
roles: user?.roles ?? []
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
var COMPARE_TO_MONGO = {
|
|
968
|
+
eq: "$eq",
|
|
969
|
+
neq: "$ne",
|
|
970
|
+
lt: "$lt",
|
|
971
|
+
lte: "$lte",
|
|
972
|
+
gt: "$gt",
|
|
973
|
+
gte: "$gte"
|
|
974
|
+
};
|
|
975
|
+
var INVERTED_COMPARE = {
|
|
976
|
+
eq: "eq",
|
|
977
|
+
neq: "neq",
|
|
978
|
+
lt: "gt",
|
|
979
|
+
lte: "gte",
|
|
980
|
+
gt: "lt",
|
|
981
|
+
gte: "lte"
|
|
982
|
+
};
|
|
983
|
+
function resolveOperand(operand, ctx) {
|
|
984
|
+
switch (operand.kind) {
|
|
985
|
+
case "literal": return {
|
|
986
|
+
kind: "value",
|
|
987
|
+
value: operand.value
|
|
988
|
+
};
|
|
989
|
+
case "authUid": return {
|
|
990
|
+
kind: "value",
|
|
991
|
+
value: ctx.uid
|
|
992
|
+
};
|
|
993
|
+
case "authRoles": return {
|
|
994
|
+
kind: "value",
|
|
995
|
+
value: ctx.roles
|
|
996
|
+
};
|
|
997
|
+
case "field": return {
|
|
998
|
+
kind: "field",
|
|
999
|
+
name: operand.name
|
|
1000
|
+
};
|
|
1001
|
+
case "outerField": return { kind: "unknown" };
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Translate one {@link PolicyExpression} into a MongoDB filter, or
|
|
1006
|
+
* {@link UNTRANSLATABLE}.
|
|
1007
|
+
*
|
|
1008
|
+
* The JavaScript twin of `evaluatePolicy`, one level up: where that decides a
|
|
1009
|
+
* single row, this narrows a query. `"unknown"` there and `UNTRANSLATABLE` here
|
|
1010
|
+
* are the same condition, and both are resolved fail-closed by their callers.
|
|
1011
|
+
*/
|
|
1012
|
+
function policyToMongoFilter(expr, user) {
|
|
1013
|
+
const ctx = userContext(user);
|
|
1014
|
+
switch (expr.kind) {
|
|
1015
|
+
case "true": return MATCH_ALL;
|
|
1016
|
+
case "false": return MATCH_NONE;
|
|
1017
|
+
case "and": {
|
|
1018
|
+
const parts = expr.operands.map((o) => policyToMongoFilter(o, user));
|
|
1019
|
+
if (parts.some(isMatchNone)) return MATCH_NONE;
|
|
1020
|
+
if (parts.some((p) => p === UNTRANSLATABLE)) return UNTRANSLATABLE;
|
|
1021
|
+
const clauses = parts.filter((p) => !isMatchAll(p));
|
|
1022
|
+
if (clauses.length === 0) return MATCH_ALL;
|
|
1023
|
+
if (clauses.length === 1) return clauses[0];
|
|
1024
|
+
return { $and: clauses };
|
|
1025
|
+
}
|
|
1026
|
+
case "or": {
|
|
1027
|
+
const parts = expr.operands.map((o) => policyToMongoFilter(o, user));
|
|
1028
|
+
if (parts.some(isMatchAll)) return MATCH_ALL;
|
|
1029
|
+
if (parts.some((p) => p === UNTRANSLATABLE)) return UNTRANSLATABLE;
|
|
1030
|
+
const clauses = parts.filter((p) => !isMatchNone(p));
|
|
1031
|
+
if (clauses.length === 0) return MATCH_NONE;
|
|
1032
|
+
if (clauses.length === 1) return clauses[0];
|
|
1033
|
+
return { $or: clauses };
|
|
1034
|
+
}
|
|
1035
|
+
case "not": {
|
|
1036
|
+
const inner = policyToMongoFilter(expr.operand, user);
|
|
1037
|
+
if (isMatchAll(inner)) return MATCH_NONE;
|
|
1038
|
+
if (isMatchNone(inner)) return MATCH_ALL;
|
|
1039
|
+
return UNTRANSLATABLE;
|
|
1040
|
+
}
|
|
1041
|
+
case "compare": {
|
|
1042
|
+
const left = resolveOperand(expr.left, ctx);
|
|
1043
|
+
const right = resolveOperand(expr.right, ctx);
|
|
1044
|
+
if (left.kind === "unknown" || right.kind === "unknown") return UNTRANSLATABLE;
|
|
1045
|
+
if (left.kind === "field" && right.kind === "value") return { [left.name]: { [COMPARE_TO_MONGO[expr.op]]: right.value } };
|
|
1046
|
+
if (left.kind === "value" && right.kind === "field") return { [right.name]: { [COMPARE_TO_MONGO[INVERTED_COMPARE[expr.op]]]: left.value } };
|
|
1047
|
+
if (left.kind === "field" && right.kind === "field") return { $expr: { [COMPARE_TO_MONGO[expr.op]]: [`$${left.name}`, `$${right.name}`] } };
|
|
1048
|
+
if (left.kind === "value" && right.kind === "value") return compareValues(expr.op, left.value, right.value);
|
|
1049
|
+
return UNTRANSLATABLE;
|
|
1050
|
+
}
|
|
1051
|
+
case "rolesOverlap": return expr.roles.some((r) => r === "public" || ctx.roles.includes(r)) ? MATCH_ALL : MATCH_NONE;
|
|
1052
|
+
case "rolesContain": return expr.roles.every((r) => r === "public" || ctx.roles.includes(r)) ? MATCH_ALL : MATCH_NONE;
|
|
1053
|
+
case "authenticated": return !isAnonymousUid(ctx.uid) ? MATCH_ALL : MATCH_NONE;
|
|
1054
|
+
case "serverContext": return MATCH_NONE;
|
|
1055
|
+
case "existsIn":
|
|
1056
|
+
case "raw": return UNTRANSLATABLE;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
function compareValues(op, a, b) {
|
|
1060
|
+
if (op === "eq") return a === b ? MATCH_ALL : MATCH_NONE;
|
|
1061
|
+
if (op === "neq") return a !== b ? MATCH_ALL : MATCH_NONE;
|
|
1062
|
+
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;
|
|
1063
|
+
return UNTRANSLATABLE;
|
|
1064
|
+
}
|
|
1065
|
+
/** The rules that apply to `targetOperation`, mirroring `checkOperation`. */
|
|
1066
|
+
function applicableRules(collection, targetOperation) {
|
|
1067
|
+
const rules = collection?.securityRules;
|
|
1068
|
+
if (!rules || rules.length === 0) return [];
|
|
1069
|
+
return rules.filter((rule) => {
|
|
1070
|
+
const ops = rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
|
|
1071
|
+
return ops.includes(targetOperation) || ops.includes("all");
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
/** Which clause of a rule constrains `targetOperation` — Postgres's own split. */
|
|
1075
|
+
function clauseFor(targetOperation) {
|
|
1076
|
+
return targetOperation === "insert" ? "withCheck" : "using";
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* Refuse up front when this collection's rules cannot be enforced for
|
|
1080
|
+
* `targetOperation`.
|
|
1081
|
+
*
|
|
1082
|
+
* The row-in-hand paths (`fetchOne`, `save`, `delete`) resolve an undecidable
|
|
1083
|
+
* rule through `checkOperation`'s `onUnknown: "deny"`, which is safe but
|
|
1084
|
+
* indistinguishable from a plain "you may not do that". Calling this first
|
|
1085
|
+
* turns the same condition into the refusal an operator can act on.
|
|
1086
|
+
*/
|
|
1087
|
+
function assertSecurityRulesEnforceable(collection, targetOperation) {
|
|
1088
|
+
for (const rule of applicableRules(collection, targetOperation)) {
|
|
1089
|
+
const conditions = securityRuleToConditions(rule);
|
|
1090
|
+
const clauses = targetOperation === "insert" ? ["withCheck"] : targetOperation === "update" ? ["using", "withCheck"] : ["using"];
|
|
1091
|
+
for (const clause of clauses) {
|
|
1092
|
+
const expr = clause === "using" ? conditions.usingExpr : conditions.withCheckExpr;
|
|
1093
|
+
if (!expr) continue;
|
|
1094
|
+
const offending = findUntranslatable(expr, true);
|
|
1095
|
+
if (offending) throw securityRuleUnsupported(collection?.slug ?? "unknown", clause, describe(offending));
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* Build the MongoDB filter that narrows a query to the rows `user` may see
|
|
1101
|
+
* under `collection`'s security rules.
|
|
1102
|
+
*
|
|
1103
|
+
* Returns `null` when no row can qualify (the caller answers with an empty
|
|
1104
|
+
* result), `{}` when the rules impose no narrowing, and throws
|
|
1105
|
+
* {@link SECURITY_RULE_UNSUPPORTED} when a rule cannot be translated.
|
|
1106
|
+
*/
|
|
1107
|
+
function buildMongoFilterFromSecurityRules(collection, user, targetOperation) {
|
|
1108
|
+
const rules = applicableRules(collection, targetOperation);
|
|
1109
|
+
if (!collection?.securityRules || collection.securityRules.length === 0) return MATCH_ALL;
|
|
1110
|
+
if (rules.length === 0) return null;
|
|
1111
|
+
const clause = clauseFor(targetOperation);
|
|
1112
|
+
const permissive = [];
|
|
1113
|
+
const restrictive = [];
|
|
1114
|
+
for (const rule of rules) {
|
|
1115
|
+
const conditions = securityRuleToConditions(rule);
|
|
1116
|
+
const expr = clause === "using" ? conditions.usingExpr : conditions.withCheckExpr;
|
|
1117
|
+
const filter = expr === null ? MATCH_NONE : policyToMongoFilter(expr, user);
|
|
1118
|
+
if (filter === UNTRANSLATABLE) {
|
|
1119
|
+
const offending = expr === null ? void 0 : findUntranslatable(expr, false);
|
|
1120
|
+
throw securityRuleUnsupported(collection.slug, clause, offending ? describe(offending) : "this rule");
|
|
1121
|
+
}
|
|
1122
|
+
if ((rule.mode || "permissive") === "restrictive") restrictive.push(filter);
|
|
1123
|
+
else permissive.push(filter);
|
|
1124
|
+
}
|
|
1125
|
+
if (permissive.length === 0) return null;
|
|
1126
|
+
const parts = [];
|
|
1127
|
+
if (!permissive.some(isMatchAll)) {
|
|
1128
|
+
const granting = permissive.filter((p) => !isMatchNone(p));
|
|
1129
|
+
if (granting.length === 0) return null;
|
|
1130
|
+
parts.push(granting.length === 1 ? granting[0] : { $or: granting });
|
|
1131
|
+
}
|
|
1132
|
+
for (const rf of restrictive) {
|
|
1133
|
+
if (isMatchNone(rf)) return null;
|
|
1134
|
+
if (!isMatchAll(rf)) parts.push(rf);
|
|
1135
|
+
}
|
|
1136
|
+
if (parts.length === 0) return MATCH_ALL;
|
|
1137
|
+
if (parts.length === 1) return parts[0];
|
|
1138
|
+
return { $and: parts };
|
|
1139
|
+
}
|
|
1140
|
+
//#endregion
|
|
814
1141
|
//#region src/services/MongoDriver.ts
|
|
815
1142
|
/**
|
|
816
1143
|
* MongoDB DataDriver Delegate
|
|
@@ -876,14 +1203,10 @@ var MongoDriver = class {
|
|
|
876
1203
|
/**
|
|
877
1204
|
* Fetch a collection of rows
|
|
878
1205
|
*/
|
|
879
|
-
async fetchCollection(
|
|
1206
|
+
async fetchCollection(props) {
|
|
1207
|
+
const { path, collection, ...query } = props;
|
|
880
1208
|
const rows = await this.dataService.fetchCollection(path, {
|
|
881
|
-
|
|
882
|
-
limit,
|
|
883
|
-
startAfter,
|
|
884
|
-
orderBy,
|
|
885
|
-
order,
|
|
886
|
-
searchString,
|
|
1209
|
+
...query,
|
|
887
1210
|
collection
|
|
888
1211
|
});
|
|
889
1212
|
const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
|
|
@@ -921,9 +1244,15 @@ var MongoDriver = class {
|
|
|
921
1244
|
return rows;
|
|
922
1245
|
}
|
|
923
1246
|
/**
|
|
924
|
-
* Listen to collection changes
|
|
1247
|
+
* Listen to collection changes.
|
|
1248
|
+
*
|
|
1249
|
+
* `authContext` is not part of `ListenCollectionProps`; it is supplied by
|
|
1250
|
+
* {@link AuthenticatedMongoDriver}, which is the only caller that has one.
|
|
1251
|
+
* It has to travel *into* the subscription config, because that config is
|
|
1252
|
+
* what every re-fetch reads — the wrapper used to stamp the field on the
|
|
1253
|
+
* `Subscription` object instead, and nothing has ever read that one.
|
|
925
1254
|
*/
|
|
926
|
-
listenCollection({ path, collection, filter, limit, startAfter, orderBy, searchString, order, onUpdate, onError }) {
|
|
1255
|
+
listenCollection({ path, collection, filter, limit, startAfter, orderBy, searchString, order, onUpdate, onError }, authContext) {
|
|
927
1256
|
const subscriptionId = this.generateSubscriptionId();
|
|
928
1257
|
const callback = (rows) => {
|
|
929
1258
|
try {
|
|
@@ -941,7 +1270,8 @@ var MongoDriver = class {
|
|
|
941
1270
|
order,
|
|
942
1271
|
limit,
|
|
943
1272
|
startAfter,
|
|
944
|
-
searchString
|
|
1273
|
+
searchString,
|
|
1274
|
+
authContext
|
|
945
1275
|
}, callback);
|
|
946
1276
|
return () => {
|
|
947
1277
|
this.realtimeService.unsubscribe(subscriptionId);
|
|
@@ -987,7 +1317,7 @@ var MongoDriver = class {
|
|
|
987
1317
|
/**
|
|
988
1318
|
* Listen to row changes
|
|
989
1319
|
*/
|
|
990
|
-
listenOne({ path, id, collection, onUpdate, onError }) {
|
|
1320
|
+
listenOne({ path, id, collection, onUpdate, onError }, authContext) {
|
|
991
1321
|
const subscriptionId = this.generateSubscriptionId();
|
|
992
1322
|
const callback = (row) => {
|
|
993
1323
|
try {
|
|
@@ -1000,7 +1330,8 @@ var MongoDriver = class {
|
|
|
1000
1330
|
this.realtimeService.subscribeToOne(subscriptionId, {
|
|
1001
1331
|
clientId: "driver",
|
|
1002
1332
|
path,
|
|
1003
|
-
id
|
|
1333
|
+
id,
|
|
1334
|
+
authContext
|
|
1004
1335
|
}, callback);
|
|
1005
1336
|
return () => {
|
|
1006
1337
|
this.realtimeService.unsubscribe(subscriptionId);
|
|
@@ -1257,8 +1588,13 @@ var MongoDriver = class {
|
|
|
1257
1588
|
/**
|
|
1258
1589
|
* Count rows in a collection
|
|
1259
1590
|
*/
|
|
1260
|
-
async count({ path, collection, filter }) {
|
|
1261
|
-
return this.dataService.count(path, {
|
|
1591
|
+
async count({ path, collection, filter, logical, searchString }) {
|
|
1592
|
+
return this.dataService.count(path, {
|
|
1593
|
+
filter,
|
|
1594
|
+
logical,
|
|
1595
|
+
searchString,
|
|
1596
|
+
collection
|
|
1597
|
+
});
|
|
1262
1598
|
}
|
|
1263
1599
|
/**
|
|
1264
1600
|
* Generate a unique subscription ID
|
|
@@ -1311,6 +1647,7 @@ var AuthenticatedMongoDriver = class {
|
|
|
1311
1647
|
if (rlsFilter === null) return [];
|
|
1312
1648
|
const userQuery = MongoConditionBuilder.buildQuery({
|
|
1313
1649
|
filter: props.filter,
|
|
1650
|
+
logical: props.logical,
|
|
1314
1651
|
searchString: props.searchString,
|
|
1315
1652
|
properties: resolvedCollection?.properties
|
|
1316
1653
|
});
|
|
@@ -1355,67 +1692,87 @@ var AuthenticatedMongoDriver = class {
|
|
|
1355
1692
|
return rows;
|
|
1356
1693
|
}
|
|
1357
1694
|
listenCollection(props) {
|
|
1358
|
-
|
|
1359
|
-
|
|
1695
|
+
return this.delegate.listenCollection(props, this.authContext());
|
|
1696
|
+
}
|
|
1697
|
+
/** The acting user, in the shape the realtime subscriptions carry. */
|
|
1698
|
+
authContext() {
|
|
1699
|
+
return {
|
|
1360
1700
|
uid: this.user.uid,
|
|
1361
1701
|
roles: this.user.roles ?? []
|
|
1362
1702
|
};
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1703
|
+
}
|
|
1704
|
+
/**
|
|
1705
|
+
* Evaluate the collection's rules for one row, fail-closed.
|
|
1706
|
+
*
|
|
1707
|
+
* A path with no resolvable collection has no declared rules — the same
|
|
1708
|
+
* answer `buildMongoFilterFromSecurityRules` gives a listing on such a path,
|
|
1709
|
+
* so the two never disagree about whether this engine has row security.
|
|
1710
|
+
*/
|
|
1711
|
+
authorize(collection, entity, operation, clauses) {
|
|
1712
|
+
if (!collection) return true;
|
|
1713
|
+
return checkOperation(collection, { user: this.user }, entity, operation, {
|
|
1714
|
+
onUnknown: "deny",
|
|
1715
|
+
clauses
|
|
1716
|
+
});
|
|
1367
1717
|
}
|
|
1368
1718
|
async fetchOne(props) {
|
|
1369
1719
|
const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(props.collection, props.path);
|
|
1720
|
+
assertSecurityRulesEnforceable(resolvedCollection, "select");
|
|
1370
1721
|
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
|
-
}
|
|
1722
|
+
if (row && !this.authorize(resolvedCollection, rowToEntityForCheck(row, props.path), "select")) return;
|
|
1374
1723
|
return row;
|
|
1375
1724
|
}
|
|
1376
1725
|
listenOne(props) {
|
|
1377
|
-
|
|
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;
|
|
1726
|
+
return this.delegate.listenOne(props, this.authContext());
|
|
1386
1727
|
}
|
|
1728
|
+
/**
|
|
1729
|
+
* Save, with both halves of the rule checked *before* the write.
|
|
1730
|
+
*
|
|
1731
|
+
* There is no transaction here, so a check that runs after
|
|
1732
|
+
* `delegate.save` cannot undo anything: the document is written, history is
|
|
1733
|
+
* recorded and subscribers have been notified by then, and a 403 at that
|
|
1734
|
+
* point only misleads the caller about what happened. Postgres evaluates
|
|
1735
|
+
* `WITH CHECK` inside the transaction; the closest this driver can get is to
|
|
1736
|
+
* evaluate it against the row as it *will* be, and refuse before writing.
|
|
1737
|
+
*/
|
|
1387
1738
|
async save(props) {
|
|
1388
1739
|
const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(props.collection, props.path);
|
|
1389
1740
|
if (props.status === "existing" && props.id) {
|
|
1741
|
+
assertSecurityRulesEnforceable(resolvedCollection, "update");
|
|
1390
1742
|
const existing = await this.delegate.fetchOne({
|
|
1391
1743
|
path: props.path,
|
|
1392
1744
|
id: props.id,
|
|
1393
1745
|
collection: resolvedCollection
|
|
1394
1746
|
});
|
|
1395
|
-
|
|
1747
|
+
const projected = rowToEntityForCheck({
|
|
1748
|
+
...existing,
|
|
1749
|
+
...props.values,
|
|
1750
|
+
id: props.id
|
|
1751
|
+
}, props.path);
|
|
1752
|
+
if (!existing || !this.authorize(resolvedCollection, rowToEntityForCheck(existing, props.path), "update", "using") || !this.authorize(resolvedCollection, projected, "update", "withCheck")) throw ApiError.forbidden("Forbidden");
|
|
1396
1753
|
} else {
|
|
1754
|
+
assertSecurityRulesEnforceable(resolvedCollection, "insert");
|
|
1397
1755
|
const tempEntity = {
|
|
1398
1756
|
id: props.id || "new",
|
|
1399
1757
|
path: props.path,
|
|
1400
1758
|
values: props.values
|
|
1401
1759
|
};
|
|
1402
|
-
if (!
|
|
1760
|
+
if (!this.authorize(resolvedCollection, tempEntity, "insert")) throw ApiError.forbidden("Forbidden");
|
|
1403
1761
|
}
|
|
1404
|
-
|
|
1762
|
+
return this.delegate.save({
|
|
1405
1763
|
...props,
|
|
1406
1764
|
collection: resolvedCollection
|
|
1407
1765
|
});
|
|
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
1766
|
}
|
|
1411
1767
|
async delete(props) {
|
|
1412
1768
|
const { collection: resolvedCollection } = this.delegate.resolveCollectionCallbacks(props.collection, props.row.path);
|
|
1769
|
+
assertSecurityRulesEnforceable(resolvedCollection, "delete");
|
|
1413
1770
|
const existing = await this.delegate.fetchOne({
|
|
1414
1771
|
path: props.row.path,
|
|
1415
1772
|
id: props.row.id,
|
|
1416
1773
|
collection: resolvedCollection
|
|
1417
1774
|
});
|
|
1418
|
-
if (!existing || !
|
|
1775
|
+
if (!existing || !this.authorize(resolvedCollection, rowToEntityForCheck(existing, props.row.path), "delete")) throw ApiError.forbidden("Forbidden");
|
|
1419
1776
|
return this.delegate.delete(props);
|
|
1420
1777
|
}
|
|
1421
1778
|
async checkUniqueField(path, name, value, id, collection) {
|
|
@@ -1430,6 +1787,7 @@ var AuthenticatedMongoDriver = class {
|
|
|
1430
1787
|
if (rlsFilter === null) return 0;
|
|
1431
1788
|
const userQuery = MongoConditionBuilder.buildQuery({
|
|
1432
1789
|
filter: props.filter,
|
|
1790
|
+
logical: props.logical,
|
|
1433
1791
|
searchString: props.searchString,
|
|
1434
1792
|
properties: resolvedCollection?.properties
|
|
1435
1793
|
});
|
|
@@ -1454,147 +1812,34 @@ function rowToEntityForCheck(row, path) {
|
|
|
1454
1812
|
values: row
|
|
1455
1813
|
};
|
|
1456
1814
|
}
|
|
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
1815
|
//#endregion
|
|
1586
1816
|
//#region src/factory.ts
|
|
1587
1817
|
/**
|
|
1588
1818
|
* Simple in-memory collection registry for MongoDB.
|
|
1589
1819
|
*/
|
|
1590
1820
|
var MongoCollectionRegistry = class {
|
|
1821
|
+
/** Every addressable key → collection. See {@link register}. */
|
|
1591
1822
|
collections = /* @__PURE__ */ new Map();
|
|
1823
|
+
/** Registration order, so `getCollections()` returns each collection once. */
|
|
1824
|
+
registered = [];
|
|
1592
1825
|
_globalCallbacks;
|
|
1593
1826
|
/**
|
|
1594
|
-
* Register a collection
|
|
1827
|
+
* Register a collection under every name it can be addressed by.
|
|
1828
|
+
*
|
|
1829
|
+
* A Mongo collection has up to three: `slug` (the routing key), `path` (the
|
|
1830
|
+
* MongoDB collection-name override, which is what `getCollectionDataPath`
|
|
1831
|
+
* hands the driver) and `name` (the human label). Registering only `name`
|
|
1832
|
+
* meant every `getCollectionByPath` lookup missed — and the realtime path,
|
|
1833
|
+
* whose only source of the collection is this registry, ran with no
|
|
1834
|
+
* `securityRules`, no `properties` and no callbacks.
|
|
1595
1835
|
*/
|
|
1596
1836
|
register(collection) {
|
|
1597
|
-
this.
|
|
1837
|
+
this.registered.push(collection);
|
|
1838
|
+
for (const key of [
|
|
1839
|
+
getCollectionDataPath(collection),
|
|
1840
|
+
collection.slug,
|
|
1841
|
+
collection.name
|
|
1842
|
+
]) if (key) this.collections.set(key, collection);
|
|
1598
1843
|
}
|
|
1599
1844
|
/**
|
|
1600
1845
|
* Get a collection by its path
|
|
@@ -1606,7 +1851,7 @@ var MongoCollectionRegistry = class {
|
|
|
1606
1851
|
* Get all registered collections
|
|
1607
1852
|
*/
|
|
1608
1853
|
getCollections() {
|
|
1609
|
-
return
|
|
1854
|
+
return [...this.registered];
|
|
1610
1855
|
}
|
|
1611
1856
|
/**
|
|
1612
1857
|
* Get the currently registered global callbacks, if any.
|
|
@@ -2481,7 +2726,7 @@ function createMongoBootstrapper(mongoConfig) {
|
|
|
2481
2726
|
},
|
|
2482
2727
|
async initializeAuth(config, driverResult) {
|
|
2483
2728
|
const db = driverResult.internals.db;
|
|
2484
|
-
const { ensureAuthCollectionsExist } = await import("./ensure-collections-
|
|
2729
|
+
const { ensureAuthCollectionsExist } = await import("./ensure-collections-Bl12bSwO.js");
|
|
2485
2730
|
await ensureAuthCollectionsExist(db);
|
|
2486
2731
|
const { createEmailService } = await import("@rebasepro/server");
|
|
2487
2732
|
const authConfig = config;
|
|
@@ -2553,9 +2798,9 @@ function createMongoBootstrapper(mongoConfig) {
|
|
|
2553
2798
|
return admin;
|
|
2554
2799
|
},
|
|
2555
2800
|
mountRoutes() {},
|
|
2556
|
-
async initializeWebsockets(server, realtimeService, driver, config) {
|
|
2557
|
-
const { createMongoWebSocket } = await import("./websocket-
|
|
2558
|
-
createMongoWebSocket(server, realtimeService, driver, config, cachedAdmin);
|
|
2801
|
+
async initializeWebsockets(server, realtimeService, driver, config, authAdapter) {
|
|
2802
|
+
const { createMongoWebSocket } = await import("./websocket-Bs54-gR-.js");
|
|
2803
|
+
createMongoWebSocket(server, realtimeService, driver, config, cachedAdmin, authAdapter);
|
|
2559
2804
|
}
|
|
2560
2805
|
};
|
|
2561
2806
|
}
|