@lunora/server 0.0.0 → 1.0.0-alpha.2
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/LICENSE.md +105 -0
- package/README.md +130 -9
- package/__assets__/package-og.svg +14 -0
- package/dist/data-model.d.mts +328 -0
- package/dist/data-model.d.ts +328 -0
- package/dist/data-model.mjs +1 -0
- package/dist/drizzle.d.mts +1 -0
- package/dist/drizzle.d.ts +1 -0
- package/dist/drizzle.mjs +1 -0
- package/dist/index.d.mts +1741 -0
- package/dist/index.d.ts +1741 -0
- package/dist/index.mjs +24 -0
- package/dist/packem_shared/LunoraEnvError-DjFkpkSP.mjs +187 -0
- package/dist/packem_shared/LunoraError-DhggBJZF.mjs +51 -0
- package/dist/packem_shared/PRESENCE_DEFAULT_TTL_MS-BZYd5-uo.mjs +114 -0
- package/dist/packem_shared/asBucketStorage-Cnxd9y2q.mjs +11 -0
- package/dist/packem_shared/bindOrm-DCuyr46L.mjs +71 -0
- package/dist/packem_shared/composePluginMiddleware-Ck5_TUO8.mjs +100 -0
- package/dist/packem_shared/createPolicyDsl-De67zPDS.mjs +29 -0
- package/dist/packem_shared/defineAggregateIndex-DxSso0rH.mjs +236 -0
- package/dist/packem_shared/defineMigration-CAJLr6fx.mjs +8 -0
- package/dist/packem_shared/defineStorageRule-qu0mpilX.mjs +20 -0
- package/dist/packem_shared/httpAction-B7FYUEgr.mjs +340 -0
- package/dist/packem_shared/initLunora-CATvPsVt.mjs +86 -0
- package/dist/packem_shared/mask-Jc84C_hK.mjs +211 -0
- package/dist/packem_shared/onConnect-CIPXKPyw.mjs +13 -0
- package/dist/packem_shared/protectPublic-BjFkQ_Or.mjs +15 -0
- package/dist/packem_shared/rls-BDKRbMCA.mjs +551 -0
- package/dist/packem_shared/run-middleware-CYQOuoV6.mjs +18 -0
- package/dist/packem_shared/storageRules-4a30FSpI.mjs +88 -0
- package/dist/packem_shared/types.d-BDY0FYHK.d.ts +135 -0
- package/dist/packem_shared/types.d-DmvyEMD6.d.mts +135 -0
- package/dist/rls/testing.d.mts +63 -0
- package/dist/rls/testing.d.ts +63 -0
- package/dist/rls/testing.mjs +49 -0
- package/dist/types.d.mts +1051 -0
- package/dist/types.d.ts +1051 -0
- package/dist/types.mjs +31 -0
- package/package.json +59 -17
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
import { LunoraError } from './LunoraError-DhggBJZF.mjs';
|
|
2
|
+
import { bindTableFacade, bindOrm } from './bindOrm-DCuyr46L.mjs';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_BATCH_LIMIT = 500;
|
|
5
|
+
const assertBatchLimit = (count, limit, op) => {
|
|
6
|
+
const cap = limit ?? DEFAULT_BATCH_LIMIT;
|
|
7
|
+
if (count > cap) {
|
|
8
|
+
throw new LunoraError("BAD_REQUEST", `${op}: batch of ${String(count)} exceeds the limit of ${String(cap)} (raise options.limit or chunk the call)`);
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
const FALSE_PREDICATE = { OR: [] };
|
|
12
|
+
const RLS_UNWRAP_SYMBOL = /* @__PURE__ */ Symbol.for("lunora.ctxdb.rls-unwrap");
|
|
13
|
+
const indexByTable = (policies) => {
|
|
14
|
+
const map = /* @__PURE__ */ new Map();
|
|
15
|
+
for (const policy of policies) {
|
|
16
|
+
const existing = map.get(policy.table) ?? [];
|
|
17
|
+
existing.push(policy);
|
|
18
|
+
map.set(policy.table, existing);
|
|
19
|
+
}
|
|
20
|
+
return map;
|
|
21
|
+
};
|
|
22
|
+
const computeReadBaseWhere = (policies, context) => {
|
|
23
|
+
const predicates = [];
|
|
24
|
+
let sawTrue = false;
|
|
25
|
+
for (const policy of policies) {
|
|
26
|
+
if (policy.on !== "read") {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const decision = policy.when(context);
|
|
30
|
+
if (decision === void 0) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (decision === true) {
|
|
34
|
+
sawTrue = true;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
if (decision === false) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
predicates.push(decision);
|
|
41
|
+
}
|
|
42
|
+
if (sawTrue) {
|
|
43
|
+
return void 0;
|
|
44
|
+
}
|
|
45
|
+
if (predicates.length === 0) {
|
|
46
|
+
return FALSE_PREDICATE;
|
|
47
|
+
}
|
|
48
|
+
return predicates.length === 1 ? predicates[0] : { OR: predicates };
|
|
49
|
+
};
|
|
50
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
51
|
+
const OPERATOR_KEYS = ["contains", "eq", "gt", "gte", "in", "isNull", "lt", "lte", "ne", "notIn"];
|
|
52
|
+
const isOrderable = (value) => {
|
|
53
|
+
const type = typeof value;
|
|
54
|
+
return type === "number" || type === "string" || type === "bigint";
|
|
55
|
+
};
|
|
56
|
+
const matchesOrderedOperators = (documentValue, operators) => {
|
|
57
|
+
if ("lt" in operators && (!isOrderable(documentValue) || !isOrderable(operators["lt"]) || documentValue >= operators["lt"])) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
if ("lte" in operators && (!isOrderable(documentValue) || !isOrderable(operators["lte"]) || documentValue > operators["lte"])) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
if ("gt" in operators && (!isOrderable(documentValue) || !isOrderable(operators["gt"]) || documentValue <= operators["gt"])) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
if ("gte" in operators && (!isOrderable(documentValue) || !isOrderable(operators["gte"]) || documentValue < operators["gte"])) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
return true;
|
|
70
|
+
};
|
|
71
|
+
const matchesMembershipOperators = (documentValue, operators) => {
|
|
72
|
+
if ("in" in operators) {
|
|
73
|
+
const list = operators["in"];
|
|
74
|
+
if (!Array.isArray(list) || !list.includes(documentValue)) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if ("notIn" in operators) {
|
|
79
|
+
const list = operators["notIn"];
|
|
80
|
+
if (Array.isArray(list) && list.includes(documentValue)) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if ("contains" in operators) {
|
|
85
|
+
const needle = operators["contains"];
|
|
86
|
+
if (typeof documentValue !== "string" || typeof needle !== "string" || !documentValue.includes(needle)) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if ("isNull" in operators) {
|
|
91
|
+
const expectsNull = operators["isNull"] === true;
|
|
92
|
+
if (expectsNull !== (documentValue === null || documentValue === void 0)) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
97
|
+
};
|
|
98
|
+
const matchesOperators = (documentValue, operators) => {
|
|
99
|
+
if ("eq" in operators && documentValue !== operators["eq"]) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
if ("ne" in operators && documentValue === operators["ne"]) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
return matchesMembershipOperators(documentValue, operators) && matchesOrderedOperators(documentValue, operators);
|
|
106
|
+
};
|
|
107
|
+
const matchesCombinator = (document, key, value, recurse) => {
|
|
108
|
+
if (key === "AND") {
|
|
109
|
+
return Array.isArray(value) && value.every((branch) => recurse(document, branch));
|
|
110
|
+
}
|
|
111
|
+
if (key === "OR") {
|
|
112
|
+
return Array.isArray(value) && value.some((branch) => recurse(document, branch));
|
|
113
|
+
}
|
|
114
|
+
return !recurse(document, value ?? {});
|
|
115
|
+
};
|
|
116
|
+
const isCombinatorKey = (key) => key === "AND" || key === "OR" || key === "NOT";
|
|
117
|
+
const isOperatorBag = (value) => isPlainObject(value) && Object.keys(value).every((k) => OPERATOR_KEYS.includes(k));
|
|
118
|
+
const RELATION_OPERATOR_KEYS = ["every", "is", "isNot", "none", "some"];
|
|
119
|
+
const isRelationPredicateValue = (value) => isPlainObject(value) && Object.keys(value).length > 0 && Object.keys(value).every((k) => RELATION_OPERATOR_KEYS.includes(k));
|
|
120
|
+
const containsRelationPredicate = (where) => Object.keys(where).some((key) => {
|
|
121
|
+
const value = where[key];
|
|
122
|
+
if (isCombinatorKey(key)) {
|
|
123
|
+
if (key === "NOT") {
|
|
124
|
+
return containsRelationPredicate(value ?? {});
|
|
125
|
+
}
|
|
126
|
+
return Array.isArray(value) && value.some((branch) => containsRelationPredicate(branch ?? {}));
|
|
127
|
+
}
|
|
128
|
+
return isRelationPredicateValue(value);
|
|
129
|
+
});
|
|
130
|
+
const matchesWhere = (document, where) => {
|
|
131
|
+
for (const key of Object.keys(where)) {
|
|
132
|
+
const value = where[key];
|
|
133
|
+
if (isCombinatorKey(key)) {
|
|
134
|
+
if (!matchesCombinator(document, key, value, matchesWhere)) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const documentValue = document[key];
|
|
140
|
+
if (isOperatorBag(value)) {
|
|
141
|
+
if (!matchesOperators(documentValue, value)) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (documentValue !== value) {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return true;
|
|
151
|
+
};
|
|
152
|
+
const satisfiesPostImage = (policy, context, nextRow) => {
|
|
153
|
+
const postDecision = policy.when({ ...context, row: nextRow });
|
|
154
|
+
if (postDecision === void 0 || postDecision === true) {
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
if (postDecision === false) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
if (containsRelationPredicate(postDecision)) {
|
|
161
|
+
throw new LunoraError(
|
|
162
|
+
"RELATION_PREDICATE_UNSUPPORTED",
|
|
163
|
+
"relation predicates are not supported in write policies; use a read policy or a flat column check"
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return matchesWhere(nextRow, postDecision);
|
|
167
|
+
};
|
|
168
|
+
const evaluateWritePolicy = (policy, context, nextRow) => {
|
|
169
|
+
const decision = policy.when(context);
|
|
170
|
+
if (decision === void 0 || decision === true) {
|
|
171
|
+
return nextRow === void 0 || satisfiesPostImage(policy, context, nextRow);
|
|
172
|
+
}
|
|
173
|
+
if (decision === false) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
if (containsRelationPredicate(decision)) {
|
|
177
|
+
throw new LunoraError(
|
|
178
|
+
"RELATION_PREDICATE_UNSUPPORTED",
|
|
179
|
+
"relation predicates are not supported in write policies; use a read policy or a flat column check"
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (!context.row || !matchesWhere(context.row, decision)) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
return nextRow === void 0 || matchesWhere(nextRow, decision);
|
|
186
|
+
};
|
|
187
|
+
const evaluateWrite = (policies, op, context, nextRow) => {
|
|
188
|
+
let sawWritePolicy = false;
|
|
189
|
+
for (const policy of policies) {
|
|
190
|
+
if (policy.on !== op) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
sawWritePolicy = true;
|
|
194
|
+
if (!evaluateWritePolicy(policy, context, nextRow)) {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return sawWritePolicy;
|
|
199
|
+
};
|
|
200
|
+
const intoQueryArgs = (args) => args ?? {};
|
|
201
|
+
const COUNT_ARGS_KEYS = /* @__PURE__ */ new Set(["baseWhere", "relationBaseWhere", "restrictsCounts", "where"]);
|
|
202
|
+
const isCountArgsValue = (key, value) => {
|
|
203
|
+
if (value === void 0) {
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
if (key === "restrictsCounts") {
|
|
207
|
+
return typeof value === "boolean";
|
|
208
|
+
}
|
|
209
|
+
if (key === "relationBaseWhere") {
|
|
210
|
+
return typeof value === "function";
|
|
211
|
+
}
|
|
212
|
+
return isPlainObject(value);
|
|
213
|
+
};
|
|
214
|
+
const looksLikeCountArgs = (argument) => {
|
|
215
|
+
const keys = Object.keys(argument);
|
|
216
|
+
if (keys.length === 0) {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
return keys.every((key) => COUNT_ARGS_KEYS.has(key) && isCountArgsValue(key, argument[key]));
|
|
220
|
+
};
|
|
221
|
+
const intoCountArgs = (argument) => {
|
|
222
|
+
if (argument === void 0) {
|
|
223
|
+
return {};
|
|
224
|
+
}
|
|
225
|
+
if (isPlainObject(argument) && looksLikeCountArgs(argument)) {
|
|
226
|
+
return argument;
|
|
227
|
+
}
|
|
228
|
+
return { where: argument };
|
|
229
|
+
};
|
|
230
|
+
const mergeBaseWhere = (caller, injected) => {
|
|
231
|
+
if (!injected || Object.keys(injected).length === 0) {
|
|
232
|
+
return caller;
|
|
233
|
+
}
|
|
234
|
+
if (!caller || Object.keys(caller).length === 0) {
|
|
235
|
+
return injected;
|
|
236
|
+
}
|
|
237
|
+
return { AND: [injected, caller] };
|
|
238
|
+
};
|
|
239
|
+
const isFacadeEntry = (value) => {
|
|
240
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
const candidate = value;
|
|
244
|
+
return typeof candidate["findMany"] === "function" && typeof candidate["withSearchIndex"] === "function";
|
|
245
|
+
};
|
|
246
|
+
const wrapDatabase = (base, raw, perTable, context) => {
|
|
247
|
+
const route = (tableName) => perTable.has(tableName) ? raw : base;
|
|
248
|
+
const readBaseCache = /* @__PURE__ */ new Map();
|
|
249
|
+
const readBase = (tableName) => {
|
|
250
|
+
const cached = readBaseCache.get(tableName);
|
|
251
|
+
if (cached) {
|
|
252
|
+
return cached;
|
|
253
|
+
}
|
|
254
|
+
const policies = perTable.get(tableName);
|
|
255
|
+
if (!policies || policies.length === 0 || !policies.some((policy) => policy.on === "read")) {
|
|
256
|
+
const result2 = { baseWhere: void 0, restricts: false };
|
|
257
|
+
readBaseCache.set(tableName, result2);
|
|
258
|
+
return result2;
|
|
259
|
+
}
|
|
260
|
+
const baseWhere = computeReadBaseWhere(policies, context);
|
|
261
|
+
const result = { baseWhere, restricts: true };
|
|
262
|
+
readBaseCache.set(tableName, result);
|
|
263
|
+
return result;
|
|
264
|
+
};
|
|
265
|
+
const relationReadFilter = (table) => readBase(table).baseWhere;
|
|
266
|
+
const locateRow = async (id, expectedTable) => {
|
|
267
|
+
if (raw.lookupById) {
|
|
268
|
+
const located = await raw.lookupById(id, expectedTable);
|
|
269
|
+
if (!located) {
|
|
270
|
+
return { row: null, tableName: void 0 };
|
|
271
|
+
}
|
|
272
|
+
return { row: located.row, tableName: perTable.has(located.tableName) ? located.tableName : void 0 };
|
|
273
|
+
}
|
|
274
|
+
const row = await raw.get(id, expectedTable);
|
|
275
|
+
if (!row) {
|
|
276
|
+
return { row: null, tableName: void 0 };
|
|
277
|
+
}
|
|
278
|
+
const pinnedProbe = expectedTable !== void 0 && perTable.has(expectedTable) ? [expectedTable] : [];
|
|
279
|
+
const probeTables = expectedTable === void 0 ? [...perTable.keys()] : pinnedProbe;
|
|
280
|
+
const probes = await Promise.all(
|
|
281
|
+
probeTables.map(async (tableName) => {
|
|
282
|
+
const probe = await raw.findFirst(tableName, { limit: 1, where: { _id: id } });
|
|
283
|
+
return probe?.["_id"] === id ? tableName : void 0;
|
|
284
|
+
})
|
|
285
|
+
);
|
|
286
|
+
return { row, tableName: probes.find((entry) => entry !== void 0) };
|
|
287
|
+
};
|
|
288
|
+
const findRowTable = async (id, expectedTable) => {
|
|
289
|
+
const located = await locateRow(id, expectedTable);
|
|
290
|
+
return located.row && located.tableName !== void 0 ? { row: located.row, tableName: located.tableName } : void 0;
|
|
291
|
+
};
|
|
292
|
+
const gateById = async (id, op, perform, computeNextRow, expectedTable) => {
|
|
293
|
+
const located = await findRowTable(id, expectedTable);
|
|
294
|
+
if (!located) {
|
|
295
|
+
return perform(base);
|
|
296
|
+
}
|
|
297
|
+
const policies = perTable.get(located.tableName);
|
|
298
|
+
if (policies) {
|
|
299
|
+
const nextRow = computeNextRow ? computeNextRow(located.row) : void 0;
|
|
300
|
+
const writeOk = evaluateWrite(policies, op, { ...context, row: located.row }, nextRow);
|
|
301
|
+
if (!writeOk) {
|
|
302
|
+
throw new LunoraError("FORBIDDEN", `${op} on "${located.tableName}" denied by policy`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return perform(raw);
|
|
306
|
+
};
|
|
307
|
+
const requireUnrestrictedReadBase = (tableName, method) => {
|
|
308
|
+
const { baseWhere, restricts } = readBase(tableName);
|
|
309
|
+
if (restricts) {
|
|
310
|
+
throw new LunoraError("COUNT_RLS_UNSUPPORTED", `${method}() is not supported on "${tableName}" inside an RLS-restricted context`);
|
|
311
|
+
}
|
|
312
|
+
return baseWhere;
|
|
313
|
+
};
|
|
314
|
+
const baseRankBefore = base.rankBefore;
|
|
315
|
+
const wrapped = {
|
|
316
|
+
...base,
|
|
317
|
+
async count(tableName, whereOrArgs) {
|
|
318
|
+
const { baseWhere, restricts } = readBase(tableName);
|
|
319
|
+
const args = intoCountArgs(whereOrArgs);
|
|
320
|
+
return route(tableName).count(tableName, {
|
|
321
|
+
...args,
|
|
322
|
+
baseWhere: mergeBaseWhere(args.baseWhere, baseWhere),
|
|
323
|
+
relationBaseWhere: relationReadFilter,
|
|
324
|
+
restrictsCounts: (args.restrictsCounts ?? false) || restricts
|
|
325
|
+
});
|
|
326
|
+
},
|
|
327
|
+
delete: (id, expectedTable) => gateById(id, "delete", (writer) => writer.delete(id, expectedTable), void 0, expectedTable),
|
|
328
|
+
async deleteMany(ids, options, expectedTable) {
|
|
329
|
+
assertBatchLimit(ids.length, options?.limit, "deleteMany");
|
|
330
|
+
for (const id of ids) {
|
|
331
|
+
await gateById(id, "delete", (writer) => writer.delete(id, expectedTable), void 0, expectedTable);
|
|
332
|
+
}
|
|
333
|
+
return { deleted: ids.length };
|
|
334
|
+
},
|
|
335
|
+
async findFirst(tableName, args) {
|
|
336
|
+
const { baseWhere } = readBase(tableName);
|
|
337
|
+
return route(tableName).findFirst(tableName, {
|
|
338
|
+
...intoQueryArgs(args),
|
|
339
|
+
baseWhere: mergeBaseWhere(args?.baseWhere, baseWhere),
|
|
340
|
+
relationBaseWhere: relationReadFilter
|
|
341
|
+
});
|
|
342
|
+
},
|
|
343
|
+
async findFirstOrThrow(tableName, args) {
|
|
344
|
+
const { baseWhere } = readBase(tableName);
|
|
345
|
+
return route(tableName).findFirstOrThrow(tableName, {
|
|
346
|
+
...intoQueryArgs(args),
|
|
347
|
+
baseWhere: mergeBaseWhere(args?.baseWhere, baseWhere),
|
|
348
|
+
relationBaseWhere: relationReadFilter
|
|
349
|
+
});
|
|
350
|
+
},
|
|
351
|
+
async findMany(tableName, args) {
|
|
352
|
+
const { baseWhere } = readBase(tableName);
|
|
353
|
+
return route(tableName).findMany(tableName, {
|
|
354
|
+
...intoQueryArgs(args),
|
|
355
|
+
baseWhere: mergeBaseWhere(args?.baseWhere, baseWhere),
|
|
356
|
+
relationBaseWhere: relationReadFilter
|
|
357
|
+
});
|
|
358
|
+
},
|
|
359
|
+
async get(id, expectedTable) {
|
|
360
|
+
const located = await locateRow(id, expectedTable);
|
|
361
|
+
if (!located.row) {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
if (located.tableName === void 0) {
|
|
365
|
+
return base.get(id, expectedTable);
|
|
366
|
+
}
|
|
367
|
+
const { baseWhere, restricts } = readBase(located.tableName);
|
|
368
|
+
if (!restricts || !baseWhere) {
|
|
369
|
+
return located.row;
|
|
370
|
+
}
|
|
371
|
+
const allowed = await raw.findFirst(located.tableName, { baseWhere, limit: 1, where: { _id: id } });
|
|
372
|
+
return allowed?.["_id"] === id ? allowed : null;
|
|
373
|
+
},
|
|
374
|
+
async insert(tableName, document) {
|
|
375
|
+
const policies = perTable.get(tableName);
|
|
376
|
+
if (policies) {
|
|
377
|
+
const writeOk = evaluateWrite(policies, "insert", { ...context, row: document });
|
|
378
|
+
if (!writeOk) {
|
|
379
|
+
throw new LunoraError("FORBIDDEN", `insert on "${tableName}" denied by policy`);
|
|
380
|
+
}
|
|
381
|
+
return raw.insert(tableName, document);
|
|
382
|
+
}
|
|
383
|
+
return base.insert(tableName, document);
|
|
384
|
+
},
|
|
385
|
+
async insertMany(tableName, documents, options) {
|
|
386
|
+
assertBatchLimit(documents.length, options?.limit, "insertMany");
|
|
387
|
+
const policies = perTable.get(tableName);
|
|
388
|
+
if (policies) {
|
|
389
|
+
for (const document of documents) {
|
|
390
|
+
const writeOk = evaluateWrite(policies, "insert", { ...context, row: document });
|
|
391
|
+
if (!writeOk) {
|
|
392
|
+
throw new LunoraError("FORBIDDEN", `insert on "${tableName}" denied by policy`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return raw.insertMany(tableName, documents, options);
|
|
396
|
+
}
|
|
397
|
+
return base.insertMany(tableName, documents, options);
|
|
398
|
+
},
|
|
399
|
+
async insertManyUnsafe(tableName, documents, options) {
|
|
400
|
+
assertBatchLimit(documents.length, options?.limit, "insertManyUnsafe");
|
|
401
|
+
const policies = perTable.get(tableName);
|
|
402
|
+
if (policies) {
|
|
403
|
+
for (const document of documents) {
|
|
404
|
+
const writeOk = evaluateWrite(policies, "insert", { ...context, row: document });
|
|
405
|
+
if (!writeOk) {
|
|
406
|
+
throw new LunoraError("FORBIDDEN", `insert on "${tableName}" denied by policy`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return raw.insertManyUnsafe(tableName, documents, options);
|
|
410
|
+
}
|
|
411
|
+
return base.insertManyUnsafe(tableName, documents, options);
|
|
412
|
+
},
|
|
413
|
+
patch: (id, patch, expectedTable) => gateById(
|
|
414
|
+
id,
|
|
415
|
+
"update",
|
|
416
|
+
(writer) => writer.patch(id, patch, expectedTable),
|
|
417
|
+
(preRow) => {
|
|
418
|
+
return { ...preRow, ...patch };
|
|
419
|
+
},
|
|
420
|
+
expectedTable
|
|
421
|
+
),
|
|
422
|
+
async patchMany(patches, options, expectedTable) {
|
|
423
|
+
assertBatchLimit(patches.length, options?.limit, "patchMany");
|
|
424
|
+
for (const entry of patches) {
|
|
425
|
+
await gateById(
|
|
426
|
+
entry.id,
|
|
427
|
+
"update",
|
|
428
|
+
(writer) => writer.patch(entry.id, entry.patch, expectedTable),
|
|
429
|
+
(preRow) => {
|
|
430
|
+
return { ...preRow, ...entry.patch };
|
|
431
|
+
},
|
|
432
|
+
expectedTable
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
},
|
|
436
|
+
query(tableName) {
|
|
437
|
+
const { baseWhere } = readBase(tableName);
|
|
438
|
+
const reader = route(tableName).query(tableName);
|
|
439
|
+
if (!baseWhere) {
|
|
440
|
+
return reader;
|
|
441
|
+
}
|
|
442
|
+
return reader.filter((document) => matchesWhere(document, baseWhere));
|
|
443
|
+
},
|
|
444
|
+
replace: (id, document, expectedTable) => gateById(
|
|
445
|
+
id,
|
|
446
|
+
"update",
|
|
447
|
+
(writer) => writer.replace(id, document, expectedTable),
|
|
448
|
+
// Mirror the writer's post-image: `_id` always comes from the
|
|
449
|
+
// existing row; `_creationTime` honors a caller-supplied value
|
|
450
|
+
// (the writer keeps `document._creationTime` when present) and
|
|
451
|
+
// otherwise falls back to the existing row's value.
|
|
452
|
+
(preRow) => {
|
|
453
|
+
return {
|
|
454
|
+
...document,
|
|
455
|
+
_creationTime: document["_creationTime"] ?? preRow["_creationTime"],
|
|
456
|
+
_id: preRow["_id"]
|
|
457
|
+
};
|
|
458
|
+
},
|
|
459
|
+
expectedTable
|
|
460
|
+
),
|
|
461
|
+
// Analytical reads — plain methods (the writer always implements them;
|
|
462
|
+
// see the required signatures on `DatabaseWriterLike`). `aggregate` /
|
|
463
|
+
// `groupBy` are scoped to `where`, so the read `baseWhere` is AND-merged
|
|
464
|
+
// and the reduction only sees policy-visible rows. `rank` / `rankPage`
|
|
465
|
+
// are counts-of-partition that can't be safely narrowed, so they fail
|
|
466
|
+
// closed under a read policy (see `requireUnrestrictedReadBase`).
|
|
467
|
+
aggregate(tableName, options) {
|
|
468
|
+
const { baseWhere } = readBase(tableName);
|
|
469
|
+
return route(tableName).aggregate(tableName, {
|
|
470
|
+
...options,
|
|
471
|
+
baseWhere: mergeBaseWhere(options.baseWhere, baseWhere),
|
|
472
|
+
relationBaseWhere: relationReadFilter
|
|
473
|
+
});
|
|
474
|
+
},
|
|
475
|
+
groupBy(tableName, options) {
|
|
476
|
+
const { baseWhere } = readBase(tableName);
|
|
477
|
+
return route(tableName).groupBy(tableName, {
|
|
478
|
+
...options,
|
|
479
|
+
baseWhere: mergeBaseWhere(options.baseWhere, baseWhere),
|
|
480
|
+
relationBaseWhere: relationReadFilter
|
|
481
|
+
});
|
|
482
|
+
},
|
|
483
|
+
rank(tableName, indexName, options) {
|
|
484
|
+
const baseWhere = requireUnrestrictedReadBase(tableName, "rank");
|
|
485
|
+
return route(tableName).rank(tableName, indexName, { ...options, baseWhere: mergeBaseWhere(options.baseWhere, baseWhere) });
|
|
486
|
+
},
|
|
487
|
+
rankPage(tableName, indexName, options) {
|
|
488
|
+
const baseWhere = requireUnrestrictedReadBase(tableName, "rankPage");
|
|
489
|
+
return route(tableName).rankPage(tableName, indexName, { ...options, baseWhere: mergeBaseWhere(options?.baseWhere, baseWhere) });
|
|
490
|
+
},
|
|
491
|
+
// `rankBefore` is the one optional method (the D1 twin omits it).
|
|
492
|
+
...baseRankBefore ? {
|
|
493
|
+
rankBefore: (tableName, indexName, options) => {
|
|
494
|
+
requireUnrestrictedReadBase(tableName, "rankBefore");
|
|
495
|
+
const target = route(tableName);
|
|
496
|
+
return (target.rankBefore ?? baseRankBefore)(tableName, indexName, options);
|
|
497
|
+
}
|
|
498
|
+
} : {}
|
|
499
|
+
};
|
|
500
|
+
const writableFacade = wrapped;
|
|
501
|
+
for (const tableName of perTable.keys()) {
|
|
502
|
+
if (isFacadeEntry(base[tableName])) {
|
|
503
|
+
writableFacade[tableName] = bindTableFacade(wrapped, tableName);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return wrapped;
|
|
507
|
+
};
|
|
508
|
+
const permissionName = (permission) => typeof permission === "string" ? permission : permission.name;
|
|
509
|
+
const indexRolePermissions = (roles) => {
|
|
510
|
+
const map = /* @__PURE__ */ new Map();
|
|
511
|
+
for (const role of roles ?? []) {
|
|
512
|
+
map.set(role.name, new Set((role.permissions ?? []).map((permission) => permissionName(permission))));
|
|
513
|
+
}
|
|
514
|
+
return map;
|
|
515
|
+
};
|
|
516
|
+
const rls = (policies, options = {}) => {
|
|
517
|
+
const perTable = indexByTable(policies);
|
|
518
|
+
const rolePermissions = indexRolePermissions(options.roles);
|
|
519
|
+
return async ({ ctx, next }) => {
|
|
520
|
+
const auth = ctx.auth ?? {};
|
|
521
|
+
const identity = await auth.getIdentity?.() ?? null;
|
|
522
|
+
const roles = auth.roles ?? [];
|
|
523
|
+
const granted = /* @__PURE__ */ new Set();
|
|
524
|
+
for (const roleName of roles) {
|
|
525
|
+
for (const name of rolePermissions.get(roleName) ?? []) {
|
|
526
|
+
granted.add(name);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const policyContext = {
|
|
530
|
+
auth: {
|
|
531
|
+
can: (permission) => granted.has(permissionName(permission)),
|
|
532
|
+
identity,
|
|
533
|
+
roles,
|
|
534
|
+
// eslint-disable-next-line unicorn/no-null -- PolicyContext.auth.userId is a public `null | string` type
|
|
535
|
+
userId: auth.userId ?? null
|
|
536
|
+
},
|
|
537
|
+
ctx
|
|
538
|
+
};
|
|
539
|
+
const guarded = ctx.db;
|
|
540
|
+
const raw = guarded[RLS_UNWRAP_SYMBOL] ?? guarded;
|
|
541
|
+
const wrapped = wrapDatabase(guarded, raw, perTable, policyContext);
|
|
542
|
+
const extension = { db: wrapped };
|
|
543
|
+
const { orm } = ctx;
|
|
544
|
+
if (orm !== null && typeof orm === "object") {
|
|
545
|
+
extension.orm = bindOrm(wrapped);
|
|
546
|
+
}
|
|
547
|
+
return next({ ctx: extension });
|
|
548
|
+
};
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
export { computeReadBaseWhere, evaluateWrite, indexRolePermissions, matchesWhere, permissionName, rls };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const runMiddlewareChain = async (middlewares, baseContext, terminal) => {
|
|
2
|
+
let lastIndex = -1;
|
|
3
|
+
const dispatch = async (index, context) => {
|
|
4
|
+
if (index <= lastIndex) {
|
|
5
|
+
throw new Error("middleware next() called multiple times");
|
|
6
|
+
}
|
|
7
|
+
lastIndex = index;
|
|
8
|
+
const middleware = middlewares[index];
|
|
9
|
+
if (!middleware) {
|
|
10
|
+
return terminal(context);
|
|
11
|
+
}
|
|
12
|
+
const next = ((options) => dispatch(index + 1, options?.ctx ? { ...context, ...options.ctx } : context));
|
|
13
|
+
return await middleware({ ctx: context, next });
|
|
14
|
+
};
|
|
15
|
+
return dispatch(0, baseContext);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export { runMiddlewareChain as r };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { LunoraError } from './LunoraError-DhggBJZF.mjs';
|
|
2
|
+
|
|
3
|
+
const permissionName = (permission) => typeof permission === "string" ? permission : permission.name;
|
|
4
|
+
const indexRolePermissions = (roles = []) => {
|
|
5
|
+
const byRole = /* @__PURE__ */ new Map();
|
|
6
|
+
for (const role of roles) {
|
|
7
|
+
const granted = /* @__PURE__ */ new Set();
|
|
8
|
+
for (const permission of role.permissions ?? []) {
|
|
9
|
+
granted.add(permissionName(permission));
|
|
10
|
+
}
|
|
11
|
+
byRole.set(role.name, granted);
|
|
12
|
+
}
|
|
13
|
+
return byRole;
|
|
14
|
+
};
|
|
15
|
+
const prefixMatches = (prefix, key) => prefix === void 0 || key.startsWith(prefix);
|
|
16
|
+
const resolveSignedUrlOperation = (args) => {
|
|
17
|
+
const options = args[1];
|
|
18
|
+
const method = typeof options === "object" && options !== null ? options.method : void 0;
|
|
19
|
+
return typeof method === "string" && method.toUpperCase() === "PUT" ? "write" : "read";
|
|
20
|
+
};
|
|
21
|
+
const GUARDED_METHODS = [
|
|
22
|
+
["delete", "delete"],
|
|
23
|
+
["download", "read"],
|
|
24
|
+
["generateUploadUrl", "write"],
|
|
25
|
+
["getMetadata", "read"],
|
|
26
|
+
["getSignedUrl", resolveSignedUrlOperation],
|
|
27
|
+
["getUrl", "read"],
|
|
28
|
+
["store", "write"]
|
|
29
|
+
];
|
|
30
|
+
const storageRules = (rules, options = {}) => {
|
|
31
|
+
const rolePermissions = indexRolePermissions(options.roles);
|
|
32
|
+
return async ({ ctx, next }) => {
|
|
33
|
+
const auth = ctx.auth ?? {};
|
|
34
|
+
const identity = await auth.getIdentity?.() ?? null;
|
|
35
|
+
const roles = auth.roles ?? [];
|
|
36
|
+
const granted = /* @__PURE__ */ new Set();
|
|
37
|
+
for (const roleName of roles) {
|
|
38
|
+
for (const name of rolePermissions.get(roleName) ?? []) {
|
|
39
|
+
granted.add(name);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const authContext = {
|
|
43
|
+
can: (permission) => granted.has(permissionName(permission)),
|
|
44
|
+
identity,
|
|
45
|
+
roles,
|
|
46
|
+
// eslint-disable-next-line unicorn/no-null -- StorageRuleContext.auth.userId is a public `null | string` type
|
|
47
|
+
userId: auth.userId ?? null
|
|
48
|
+
};
|
|
49
|
+
const assertAllowed = (op, key, bucketName) => {
|
|
50
|
+
const applicable = rules.filter((rule) => rule.on === op && rule.bucket === bucketName);
|
|
51
|
+
if (applicable.length === 0) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const context = { auth: authContext, ctx, key };
|
|
55
|
+
const allowed = applicable.some((rule) => prefixMatches(rule.prefix, key) && rule.when(context) === true);
|
|
56
|
+
if (!allowed) {
|
|
57
|
+
throw new LunoraError("FORBIDDEN", `storage ${op} on "${key}" in bucket "${bucketName}" denied by access rule`);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const wrapStorage = (storage2) => {
|
|
61
|
+
const bucketName = storage2.bucketName ?? "default";
|
|
62
|
+
const wrapped = { bucketName };
|
|
63
|
+
for (const [method, op] of GUARDED_METHODS) {
|
|
64
|
+
const original = storage2[method];
|
|
65
|
+
if (typeof original === "function") {
|
|
66
|
+
wrapped[method] = (...args) => {
|
|
67
|
+
const key = typeof args[0] === "string" ? args[0] : "";
|
|
68
|
+
const operation = typeof op === "function" ? op(args) : op;
|
|
69
|
+
assertAllowed(operation, key, bucketName);
|
|
70
|
+
return original(...args);
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const { bucket } = storage2;
|
|
75
|
+
if (typeof bucket === "function") {
|
|
76
|
+
wrapped.bucket = (name) => wrapStorage(bucket(name));
|
|
77
|
+
}
|
|
78
|
+
return wrapped;
|
|
79
|
+
};
|
|
80
|
+
const storage = ctx.storage;
|
|
81
|
+
if (storage === void 0) {
|
|
82
|
+
return next();
|
|
83
|
+
}
|
|
84
|
+
return next({ ctx: { storage: wrapStorage(storage) } });
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export { storageRules };
|