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