@lunora/server 1.0.0-alpha.3 → 1.0.0-alpha.31
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 +6 -0
- package/README.md +50 -0
- package/dist/data-model.d.mts +231 -156
- package/dist/data-model.d.ts +231 -156
- package/dist/index.d.mts +1191 -811
- package/dist/index.d.ts +1191 -811
- package/dist/index.mjs +20 -15
- package/dist/packem_shared/{LunoraEnvError-DjFkpkSP.mjs → LunoraEnvError-BGmd1Qs0.mjs} +4 -4
- package/dist/packem_shared/LunoraError-WbxmrpxR.mjs +9 -0
- package/dist/packem_shared/{PRESENCE_DEFAULT_TTL_MS-UQuUI5sV.mjs → PRESENCE_DEFAULT_TTL_MS-Cr0i4mTv.mjs} +5 -5
- package/dist/packem_shared/{bindOrm-Ce57S3N9.mjs → bindOrm-CaY7Wq9Z.mjs} +42 -23
- package/dist/packem_shared/buildRlsReadRegistry-2uk_GfiH.mjs +107 -0
- package/dist/packem_shared/{composePluginMiddleware-Ck5_TUO8.mjs → composePluginMiddleware-z62dttBo.mjs} +11 -8
- package/dist/packem_shared/{createPolicyDsl-De67zPDS.mjs → createPolicyDsl-By3QB4he.mjs} +4 -1
- package/dist/packem_shared/createSecrets-DwaR2rNG.mjs +58 -0
- package/dist/packem_shared/{defineAggregateIndex-B20MIOmj.mjs → defineAggregateIndex-cdo0g-un.mjs} +108 -8
- package/dist/packem_shared/defineIdentity-DiX4zM9x.mjs +35 -0
- package/dist/packem_shared/{defineMigration-CAJLr6fx.mjs → defineMigration-Hx01yIht.mjs} +3 -1
- package/dist/packem_shared/defineMutator-EIXAWhs9.mjs +11 -0
- package/dist/packem_shared/defineShape-C5scNOrf.mjs +18 -0
- package/dist/packem_shared/{defineStorageRule-qu0mpilX.mjs → defineStorageRule-B5nL4Z1P.mjs} +4 -1
- package/dist/packem_shared/functions-Di9FUNkf.mjs +5 -0
- package/dist/packem_shared/{httpAction-B7FYUEgr.mjs → httpAction-DCXoYPIk.mjs} +42 -27
- package/dist/packem_shared/{initLunora-CATvPsVt.mjs → initLunora-D0Wuki7S.mjs} +36 -7
- package/dist/packem_shared/{mask-eCUYOwhd.mjs → mask-BepaW7YN.mjs} +132 -9
- package/dist/packem_shared/policy-tag-DvpVH2tv.mjs +13 -0
- package/dist/packem_shared/{protectPublic-BjFkQ_Or.mjs → protectPublic-BlcGpiRc.mjs} +1 -1
- package/dist/packem_shared/{rls-Bi9HiyDC.mjs → rls-BaDQf7MG.mjs} +34 -6
- package/dist/packem_shared/{run-middleware-CYQOuoV6.mjs → run-middleware-I6EiQfxL.mjs} +3 -1
- package/dist/packem_shared/{storageRules-4a30FSpI.mjs → storageRules-6QxzDOcx.mjs} +1 -1
- package/dist/packem_shared/types.d-C4CMJK8x.d.mts +141 -0
- package/dist/packem_shared/types.d-DdYF8E18.d.ts +141 -0
- package/dist/rls/testing.d.mts +31 -31
- package/dist/rls/testing.d.ts +31 -31
- package/dist/rls/testing.mjs +1 -1
- package/dist/types.d.mts +957 -433
- package/dist/types.d.ts +957 -433
- package/package.json +6 -5
- package/dist/packem_shared/LunoraError-DhggBJZF.mjs +0 -51
- package/dist/packem_shared/types.d-BDY0FYHK.d.ts +0 -135
- package/dist/packem_shared/types.d-DmvyEMD6.d.mts +0 -135
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { LunoraError } from './LunoraError-
|
|
2
|
-
import { bindTableFacade, bindOrm } from './bindOrm-
|
|
1
|
+
import { LunoraError } from './LunoraError-WbxmrpxR.mjs';
|
|
2
|
+
import { bindTableFacade, bindOrm } from './bindOrm-CaY7Wq9Z.mjs';
|
|
3
3
|
|
|
4
4
|
const permissionName = (permission) => typeof permission === "string" ? permission : permission.name;
|
|
5
5
|
const indexRolePermissions = (roles) => {
|
|
@@ -46,6 +46,29 @@ const maskRow = (row, columns, base) => {
|
|
|
46
46
|
const maskPage = (page, columns, base) => {
|
|
47
47
|
return { ...page, page: page.page.map((row) => maskRow(row, columns, base)) };
|
|
48
48
|
};
|
|
49
|
+
const assertIndexFieldsAllowed = (builderCallback, columns, tableName, method) => {
|
|
50
|
+
if (typeof builderCallback !== "function") {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const referenced = /* @__PURE__ */ new Set();
|
|
54
|
+
const makeRecorder = () => /* @__PURE__ */ new Proxy(
|
|
55
|
+
{},
|
|
56
|
+
{
|
|
57
|
+
get: () => (field) => {
|
|
58
|
+
if (typeof field === "string") {
|
|
59
|
+
referenced.add(field);
|
|
60
|
+
}
|
|
61
|
+
return makeRecorder();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
);
|
|
65
|
+
builderCallback(makeRecorder());
|
|
66
|
+
for (const field of referenced) {
|
|
67
|
+
if (field in columns) {
|
|
68
|
+
throw new LunoraError("MASK_UNSUPPORTED", `${method}() filtering "${tableName}" by masked column "${field}" is not supported`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
49
72
|
const isFacadeEntry = (value) => {
|
|
50
73
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
51
74
|
return false;
|
|
@@ -54,21 +77,27 @@ const isFacadeEntry = (value) => {
|
|
|
54
77
|
return typeof candidate["findMany"] === "function" && typeof candidate["withSearchIndex"] === "function";
|
|
55
78
|
};
|
|
56
79
|
const wrapDatabase = (base, perTable, context) => {
|
|
57
|
-
const wrapReader = (reader, columns) => {
|
|
80
|
+
const wrapReader = (reader, columns, tableName) => {
|
|
58
81
|
return {
|
|
59
82
|
collect: async () => {
|
|
60
83
|
const rows = await reader.collect();
|
|
61
84
|
return rows.map((row) => maskRow(row, columns, context));
|
|
62
85
|
},
|
|
86
|
+
// SECURITY (value oracle): the predicate must see the MASKED row, not
|
|
87
|
+
// the raw stored row — otherwise a caller can `.filter(d => d.ssn ===
|
|
88
|
+
// guess)` to read the value the mask hides. Masking before the
|
|
89
|
+
// predicate keeps filtering on non-masked columns working while
|
|
90
|
+
// redacting masked cells the predicate can observe.
|
|
63
91
|
filter: (predicate) => wrapReader(
|
|
64
|
-
reader.filter((document) => predicate(document)),
|
|
65
|
-
columns
|
|
92
|
+
reader.filter((document) => predicate(maskRow(document, columns, context))),
|
|
93
|
+
columns,
|
|
94
|
+
tableName
|
|
66
95
|
),
|
|
67
96
|
first: async () => {
|
|
68
97
|
const row = await reader.first();
|
|
69
98
|
return row ? maskRow(row, columns, context) : null;
|
|
70
99
|
},
|
|
71
|
-
order: (direction) => wrapReader(reader.order(direction), columns),
|
|
100
|
+
order: (direction) => wrapReader(reader.order(direction), columns, tableName),
|
|
72
101
|
paginate: async (options) => maskPage(await reader.paginate(options), columns, context),
|
|
73
102
|
take: async (limit) => {
|
|
74
103
|
const rows = await reader.take(limit);
|
|
@@ -78,8 +107,22 @@ const wrapDatabase = (base, perTable, context) => {
|
|
|
78
107
|
const row = await reader.unique();
|
|
79
108
|
return row ? maskRow(row, columns, context) : null;
|
|
80
109
|
},
|
|
81
|
-
|
|
82
|
-
|
|
110
|
+
// SECURITY (value oracle): reject before delegating when the range /
|
|
111
|
+
// search references a masked column — an index range or search term
|
|
112
|
+
// over a masked column is the same value oracle as a masked-column
|
|
113
|
+
// `where`, so it must fail closed (see `assertIndexFieldsAllowed`).
|
|
114
|
+
// Reads over NON-masked columns pass through and still mask output.
|
|
115
|
+
withIndex: (indexName, range) => {
|
|
116
|
+
assertIndexFieldsAllowed(range, columns, tableName, "withIndex");
|
|
117
|
+
return wrapReader(reader.withIndex(indexName, range), columns, tableName);
|
|
118
|
+
},
|
|
119
|
+
withSearchIndex: (indexName, search) => {
|
|
120
|
+
assertIndexFieldsAllowed(search, columns, tableName, "withSearchIndex");
|
|
121
|
+
return wrapReader(reader.withSearchIndex(indexName, search), columns, tableName);
|
|
122
|
+
},
|
|
123
|
+
// A geo query's builder (`.near`/`.within`) exposes no column name, so
|
|
124
|
+
// there's no masked-column value oracle to guard — just mask the output.
|
|
125
|
+
withGeoIndex: (indexName, build) => wrapReader(reader.withGeoIndex(indexName, build), columns, tableName)
|
|
83
126
|
};
|
|
84
127
|
};
|
|
85
128
|
const locate = async (id, expectedTable) => {
|
|
@@ -120,23 +163,102 @@ const wrapDatabase = (base, perTable, context) => {
|
|
|
120
163
|
throw new LunoraError("MASK_UNSUPPORTED", `${method}() over masked column "${offending}" on "${tableName}" is not supported`);
|
|
121
164
|
}
|
|
122
165
|
};
|
|
166
|
+
const collectWhereFields = (where, into) => {
|
|
167
|
+
if (!where || typeof where !== "object" || Array.isArray(where)) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
for (const [key, value] of Object.entries(where)) {
|
|
171
|
+
if (key === "AND" || key === "OR") {
|
|
172
|
+
if (Array.isArray(value)) {
|
|
173
|
+
for (const clause of value) {
|
|
174
|
+
collectWhereFields(clause, into);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
} else if (key === "NOT") {
|
|
178
|
+
collectWhereFields(value, into);
|
|
179
|
+
} else if (!key.startsWith("__")) {
|
|
180
|
+
into.add(key);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
const assertWhereAllowed = (tableName, where, method) => {
|
|
185
|
+
const columns = perTable.get(tableName);
|
|
186
|
+
if (!columns || where === void 0) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const referenced = /* @__PURE__ */ new Set();
|
|
190
|
+
collectWhereFields(where, referenced);
|
|
191
|
+
for (const field of referenced) {
|
|
192
|
+
if (field in columns) {
|
|
193
|
+
throw new LunoraError("MASK_UNSUPPORTED", `${method}() filtering "${tableName}" by masked column "${field}" is not supported`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
const assertOrderByAllowed = (tableName, orderBy, method) => {
|
|
198
|
+
const columns = perTable.get(tableName);
|
|
199
|
+
if (!columns || !Array.isArray(orderBy)) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
for (const entry of orderBy) {
|
|
203
|
+
if (entry && typeof entry === "object" && !Array.isArray(entry)) {
|
|
204
|
+
for (const field of Object.keys(entry)) {
|
|
205
|
+
if (field in columns) {
|
|
206
|
+
throw new LunoraError("MASK_UNSUPPORTED", `${method}() ordering "${tableName}" by masked column "${field}" is not supported`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
};
|
|
123
212
|
const wrapped = {
|
|
124
213
|
...base,
|
|
214
|
+
async deleteWhere(tableName, where, options) {
|
|
215
|
+
assertWhereAllowed(tableName, where, "deleteMany({ where })");
|
|
216
|
+
if (base.deleteWhere === void 0) {
|
|
217
|
+
throw new LunoraError("INTERNAL", `ctx.db.${tableName}.deleteMany({ where }) is unavailable: this writer has no where-based delete`);
|
|
218
|
+
}
|
|
219
|
+
return base.deleteWhere(tableName, where, options);
|
|
220
|
+
},
|
|
221
|
+
async patchWhere(tableName, args, options) {
|
|
222
|
+
assertWhereAllowed(tableName, args.where, "patchMany({ where })");
|
|
223
|
+
if (base.patchWhere === void 0) {
|
|
224
|
+
throw new LunoraError("INTERNAL", `ctx.db.${tableName}.patchMany({ where }) is unavailable: this writer has no where-based patch`);
|
|
225
|
+
}
|
|
226
|
+
return base.patchWhere(tableName, args, options);
|
|
227
|
+
},
|
|
125
228
|
aggregate(tableName, options) {
|
|
126
229
|
assertReductionAllowed(tableName, [options.field], "aggregate");
|
|
230
|
+
assertWhereAllowed(tableName, options.where, "aggregate");
|
|
127
231
|
return base.aggregate(tableName, options);
|
|
128
232
|
},
|
|
233
|
+
count(tableName, whereOrArgs) {
|
|
234
|
+
const wrapper = whereOrArgs && typeof whereOrArgs === "object" && !Array.isArray(whereOrArgs) ? whereOrArgs : void 0;
|
|
235
|
+
const where = wrapper && ("where" in wrapper || "baseWhere" in wrapper || "restrictsCounts" in wrapper) ? wrapper.where : whereOrArgs;
|
|
236
|
+
assertWhereAllowed(tableName, where, "count");
|
|
237
|
+
if (wrapper) {
|
|
238
|
+
assertWhereAllowed(tableName, wrapper.baseWhere, "count");
|
|
239
|
+
}
|
|
240
|
+
return base.count(tableName, whereOrArgs);
|
|
241
|
+
},
|
|
129
242
|
async findFirst(tableName, args) {
|
|
243
|
+
assertWhereAllowed(tableName, args?.where, "findFirst");
|
|
244
|
+
assertWhereAllowed(tableName, args?.baseWhere, "findFirst");
|
|
245
|
+
assertOrderByAllowed(tableName, args?.orderBy, "findFirst");
|
|
130
246
|
const row = await base.findFirst(tableName, args);
|
|
131
247
|
const columns = perTable.get(tableName);
|
|
132
248
|
return row && columns ? maskRow(row, columns, context) : row;
|
|
133
249
|
},
|
|
134
250
|
async findFirstOrThrow(tableName, args) {
|
|
251
|
+
assertWhereAllowed(tableName, args?.where, "findFirstOrThrow");
|
|
252
|
+
assertWhereAllowed(tableName, args?.baseWhere, "findFirstOrThrow");
|
|
253
|
+
assertOrderByAllowed(tableName, args?.orderBy, "findFirstOrThrow");
|
|
135
254
|
const row = await base.findFirstOrThrow(tableName, args);
|
|
136
255
|
const columns = perTable.get(tableName);
|
|
137
256
|
return columns ? maskRow(row, columns, context) : row;
|
|
138
257
|
},
|
|
139
258
|
async findMany(tableName, args) {
|
|
259
|
+
assertWhereAllowed(tableName, args?.where, "findMany");
|
|
260
|
+
assertWhereAllowed(tableName, args?.baseWhere, "findMany");
|
|
261
|
+
assertOrderByAllowed(tableName, args?.orderBy, "findMany");
|
|
140
262
|
const page = await base.findMany(tableName, args);
|
|
141
263
|
const columns = perTable.get(tableName);
|
|
142
264
|
return columns ? maskPage(page, columns, context) : page;
|
|
@@ -151,12 +273,13 @@ const wrapDatabase = (base, perTable, context) => {
|
|
|
151
273
|
},
|
|
152
274
|
groupBy(tableName, options) {
|
|
153
275
|
assertReductionAllowed(tableName, [...options.by, options.agg?.field], "groupBy");
|
|
276
|
+
assertWhereAllowed(tableName, options.where, "groupBy");
|
|
154
277
|
return base.groupBy(tableName, options);
|
|
155
278
|
},
|
|
156
279
|
query(tableName) {
|
|
157
280
|
const reader = base.query(tableName);
|
|
158
281
|
const columns = perTable.get(tableName);
|
|
159
|
-
return columns ? wrapReader(reader, columns) : reader;
|
|
282
|
+
return columns ? wrapReader(reader, columns, tableName) : reader;
|
|
160
283
|
},
|
|
161
284
|
async rankPage(tableName, indexName, options) {
|
|
162
285
|
const page = await base.rankPage(tableName, indexName, options);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const RLS_TAG = /* @__PURE__ */ Symbol.for("lunora.rls.middleware-policies");
|
|
2
|
+
const tagRlsMiddleware = (middleware, tag) => {
|
|
3
|
+
Object.defineProperty(middleware, RLS_TAG, { configurable: true, enumerable: false, value: tag });
|
|
4
|
+
return middleware;
|
|
5
|
+
};
|
|
6
|
+
const readRlsTag = (middleware) => {
|
|
7
|
+
if (middleware === null || typeof middleware !== "function" && typeof middleware !== "object") {
|
|
8
|
+
return void 0;
|
|
9
|
+
}
|
|
10
|
+
return middleware[RLS_TAG];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export { readRlsTag as r, tagRlsMiddleware as t };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { LunoraError } from './LunoraError-
|
|
2
|
-
import { bindTableFacade, bindOrm } from './bindOrm-
|
|
1
|
+
import { LunoraError } from './LunoraError-WbxmrpxR.mjs';
|
|
2
|
+
import { bindTableFacade, bindOrm } from './bindOrm-CaY7Wq9Z.mjs';
|
|
3
|
+
import { t as tagRlsMiddleware } from './policy-tag-DvpVH2tv.mjs';
|
|
3
4
|
|
|
4
5
|
const DEFAULT_BATCH_LIMIT = 500;
|
|
5
6
|
const assertBatchLimit = (count, limit, op) => {
|
|
@@ -244,7 +245,6 @@ const isFacadeEntry = (value) => {
|
|
|
244
245
|
return typeof candidate["findMany"] === "function" && typeof candidate["withSearchIndex"] === "function";
|
|
245
246
|
};
|
|
246
247
|
const wrapDatabase = (base, raw, perTable, context) => {
|
|
247
|
-
const route = (tableName) => perTable.has(tableName) ? raw : base;
|
|
248
248
|
const readBaseCache = /* @__PURE__ */ new Map();
|
|
249
249
|
const readBase = (tableName) => {
|
|
250
250
|
const cached = readBaseCache.get(tableName);
|
|
@@ -262,6 +262,7 @@ const wrapDatabase = (base, raw, perTable, context) => {
|
|
|
262
262
|
readBaseCache.set(tableName, result);
|
|
263
263
|
return result;
|
|
264
264
|
};
|
|
265
|
+
const route = (tableName) => readBase(tableName).restricts ? raw : base;
|
|
265
266
|
const relationReadFilter = (table) => readBase(table).baseWhere;
|
|
266
267
|
const locateRow = async (id, expectedTable) => {
|
|
267
268
|
if (raw.lookupById) {
|
|
@@ -299,7 +300,7 @@ const wrapDatabase = (base, raw, perTable, context) => {
|
|
|
299
300
|
const nextRow = computeNextRow ? computeNextRow(located.row) : void 0;
|
|
300
301
|
const writeOk = evaluateWrite(policies, op, { ...context, row: located.row }, nextRow);
|
|
301
302
|
if (!writeOk) {
|
|
302
|
-
throw new LunoraError("FORBIDDEN", `${op}
|
|
303
|
+
throw new LunoraError("FORBIDDEN", `${op} denied by policy`);
|
|
303
304
|
}
|
|
304
305
|
}
|
|
305
306
|
return perform(raw);
|
|
@@ -332,6 +333,16 @@ const wrapDatabase = (base, raw, perTable, context) => {
|
|
|
332
333
|
}
|
|
333
334
|
return { deleted: ids.length };
|
|
334
335
|
},
|
|
336
|
+
async deleteWhere(tableName, where, options) {
|
|
337
|
+
const { baseWhere } = readBase(tableName);
|
|
338
|
+
const resolved = await route(tableName).findMany(tableName, {
|
|
339
|
+
baseWhere: mergeBaseWhere(where, baseWhere),
|
|
340
|
+
relationBaseWhere: relationReadFilter
|
|
341
|
+
});
|
|
342
|
+
const ids = resolved.page.map((row) => String(row["_id"]));
|
|
343
|
+
assertBatchLimit(ids.length, options?.limit, "deleteWhere");
|
|
344
|
+
return wrapped.deleteMany(ids, options);
|
|
345
|
+
},
|
|
335
346
|
async findFirst(tableName, args) {
|
|
336
347
|
const { baseWhere } = readBase(tableName);
|
|
337
348
|
return route(tableName).findFirst(tableName, {
|
|
@@ -365,7 +376,10 @@ const wrapDatabase = (base, raw, perTable, context) => {
|
|
|
365
376
|
return base.get(id, expectedTable);
|
|
366
377
|
}
|
|
367
378
|
const { baseWhere, restricts } = readBase(located.tableName);
|
|
368
|
-
if (!restricts
|
|
379
|
+
if (!restricts) {
|
|
380
|
+
return base.get(id, expectedTable);
|
|
381
|
+
}
|
|
382
|
+
if (!baseWhere) {
|
|
369
383
|
return located.row;
|
|
370
384
|
}
|
|
371
385
|
const allowed = await raw.findFirst(located.tableName, { baseWhere, limit: 1, where: { _id: id } });
|
|
@@ -432,6 +446,19 @@ const wrapDatabase = (base, raw, perTable, context) => {
|
|
|
432
446
|
expectedTable
|
|
433
447
|
);
|
|
434
448
|
}
|
|
449
|
+
return { patched: patches.length };
|
|
450
|
+
},
|
|
451
|
+
async patchWhere(tableName, args, options) {
|
|
452
|
+
const { baseWhere } = readBase(tableName);
|
|
453
|
+
const resolved = await route(tableName).findMany(tableName, {
|
|
454
|
+
baseWhere: mergeBaseWhere(args.where, baseWhere),
|
|
455
|
+
relationBaseWhere: relationReadFilter
|
|
456
|
+
});
|
|
457
|
+
const patches = resolved.page.map((row) => {
|
|
458
|
+
return { id: String(row["_id"]), patch: args.patch };
|
|
459
|
+
});
|
|
460
|
+
assertBatchLimit(patches.length, options?.limit, "patchWhere");
|
|
461
|
+
return wrapped.patchMany(patches, options);
|
|
435
462
|
},
|
|
436
463
|
query(tableName) {
|
|
437
464
|
const { baseWhere } = readBase(tableName);
|
|
@@ -532,7 +559,7 @@ const indexRolePermissions = (roles) => {
|
|
|
532
559
|
const rls = (policies, options = {}) => {
|
|
533
560
|
const perTable = indexByTable(policies);
|
|
534
561
|
const rolePermissions = indexRolePermissions(options.roles);
|
|
535
|
-
|
|
562
|
+
const middleware = async ({ ctx, next }) => {
|
|
536
563
|
const auth = ctx.auth ?? {};
|
|
537
564
|
const identity = await auth.getIdentity?.() ?? null;
|
|
538
565
|
const roles = auth.roles ?? [];
|
|
@@ -562,6 +589,7 @@ const rls = (policies, options = {}) => {
|
|
|
562
589
|
}
|
|
563
590
|
return next({ ctx: extension });
|
|
564
591
|
};
|
|
592
|
+
return tagRlsMiddleware(middleware, { policies, roles: options.roles ?? [] });
|
|
565
593
|
};
|
|
566
594
|
|
|
567
595
|
export { computeReadBaseWhere, evaluateWrite, indexRolePermissions, matchesWhere, permissionName, rls };
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { LunoraError as LunoraError$1 } from '@lunora/errors';
|
|
2
|
+
|
|
1
3
|
const runMiddlewareChain = async (middlewares, baseContext, terminal) => {
|
|
2
4
|
let lastIndex = -1;
|
|
3
5
|
const dispatch = async (index, context) => {
|
|
4
6
|
if (index <= lastIndex) {
|
|
5
|
-
throw new
|
|
7
|
+
throw new LunoraError$1("INTERNAL", "middleware next() called multiple times");
|
|
6
8
|
}
|
|
7
9
|
lastIndex = index;
|
|
8
10
|
const middleware = middlewares[index];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { LunoraError } from './LunoraError-
|
|
1
|
+
import { LunoraError } from './LunoraError-WbxmrpxR.mjs';
|
|
2
2
|
|
|
3
3
|
const permissionName = (permission) => typeof permission === "string" ? permission : permission.name;
|
|
4
4
|
const indexRolePermissions = (roles = []) => {
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { WhereOf } from "../data-model.mjs";
|
|
2
|
+
/** Structural mirror of `@lunora/do`'s `WhereInput`. */
|
|
3
|
+
interface WhereInput {
|
|
4
|
+
[field: string]: unknown;
|
|
5
|
+
AND?: WhereInput[];
|
|
6
|
+
NOT?: WhereInput;
|
|
7
|
+
OR?: WhereInput[];
|
|
8
|
+
}
|
|
9
|
+
/** Operations a policy can gate. `read` covers `get`/`findMany`/`query`/`count`. */
|
|
10
|
+
type PolicyOperation = "delete" | "insert" | "read" | "update";
|
|
11
|
+
/**
|
|
12
|
+
* A policy's `when` decision:
|
|
13
|
+
*
|
|
14
|
+
* - `WhereInput`: a row-shape predicate. On reads it is AND-merged into every
|
|
15
|
+
* query against the table — the row is invisible unless it matches. On writes
|
|
16
|
+
* it is evaluated against the candidate document (`insert`) or the pre-write
|
|
17
|
+
* row (`update`/`delete`); a mismatch denies the write with
|
|
18
|
+
* `LunoraError("FORBIDDEN")`. Same operator set as the SQL compiler
|
|
19
|
+
* (`eq`/`ne`/`in`/`notIn`/`lt`/`lte`/`gt`/`gte`/`isNull`/`contains` +
|
|
20
|
+
* `AND`/`OR`/`NOT`).
|
|
21
|
+
* - `true`: unrestricted. On reads no predicate is merged; on writes the row is
|
|
22
|
+
* allowed.
|
|
23
|
+
* - `false`: deny. On reads the table is forced to match zero rows (a sentinel
|
|
24
|
+
* predicate); on writes the operation throws `LunoraError("FORBIDDEN")`.
|
|
25
|
+
*
|
|
26
|
+
* Returning `undefined` opts this specific policy out (rare; useful when
|
|
27
|
+
* branching on `ctx.auth.roles`).
|
|
28
|
+
*/
|
|
29
|
+
type PolicyDecision = WhereInput | boolean | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* Relation-aware twin of {@link PolicyDecision}, parameterized over the
|
|
32
|
+
* generated `DataModel` (`DM`) + `Relations` (`REL`) maps and a table `T`. A
|
|
33
|
+
* read policy may now return a Prisma-style relation predicate (the
|
|
34
|
+
* `@lunora/do` pre-resolver resolves it via a semijoin), so the typed
|
|
35
|
+
* authoring surface accepts `WhereOf<DM, REL, T>` — column predicates **and**
|
|
36
|
+
* `is`/`isNot`/`some`/`none`/`every` over `T`'s declared relations — in
|
|
37
|
+
* addition to the `boolean`/`undefined` decisions. Used by the project-bound
|
|
38
|
+
* `definePolicy` from `createPolicyDsl`.
|
|
39
|
+
*/
|
|
40
|
+
type PolicyDecisionOf<DM, REL extends Record<keyof DM, object>, T extends keyof DM> = WhereOf<DM, REL, T> | boolean | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* Relation-aware input for a project-bound `definePolicy` (see
|
|
43
|
+
* `createPolicyDsl`). `table` is constrained to a real table name and
|
|
44
|
+
* `when`'s return type is the table-specific {@link PolicyDecisionOf} — so an
|
|
45
|
+
* unknown table, a stray column, or a relation predicate naming a relation the
|
|
46
|
+
* table does not declare is a compile error rather than a silent runtime deny.
|
|
47
|
+
*/
|
|
48
|
+
interface TypedDefinePolicyInput<DM, REL extends Record<keyof DM, object>, T extends keyof DM, Context = unknown, Identity = Record<string, unknown>> {
|
|
49
|
+
on: PolicyOperation;
|
|
50
|
+
table: T;
|
|
51
|
+
when: (context: PolicyContext<Context, Identity>) => PolicyDecisionOf<DM, REL, T>;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Context handed to a policy. `auth.roles` is the per-request role list,
|
|
55
|
+
* sourced from the identity resolver (better-auth claims today). `auth.can`
|
|
56
|
+
* answers whether any of those roles grants a permission (see
|
|
57
|
+
* {@link Permission} / {@link RlsOptions}). `row` is present only on write
|
|
58
|
+
* policies (`insert`/`update`/`delete`) — for `update` and `delete` it is the
|
|
59
|
+
* pre-write row; for `insert` it is the candidate document. `ctx` is the full
|
|
60
|
+
* procedure context the middleware closed over.
|
|
61
|
+
*/
|
|
62
|
+
interface PolicyContext<Context = unknown, Identity = Record<string, unknown>> {
|
|
63
|
+
readonly auth: {
|
|
64
|
+
/**
|
|
65
|
+
* `true` when any of the request's `roles` grants `permission` (passed
|
|
66
|
+
* by {@link Permission} object or its `name`). Always `false` when no
|
|
67
|
+
* roles were handed to the middleware (`rls(policies, { roles })`), or
|
|
68
|
+
* when none of the request's roles lists the permission.
|
|
69
|
+
*/
|
|
70
|
+
readonly can: (permission: Permission | string) => boolean;
|
|
71
|
+
/**
|
|
72
|
+
* The resolved identity, typed to the `Identity` type parameter bound on
|
|
73
|
+
* `createPolicyDsl` — e.g. the app's `defineIdentity(...)` claim type via
|
|
74
|
+
* the `InferIdentity` helper; otherwise the untyped claim bag.
|
|
75
|
+
* `undefined`/`null` when the request is anonymous.
|
|
76
|
+
*/
|
|
77
|
+
readonly identity?: Identity | null;
|
|
78
|
+
readonly roles: ReadonlyArray<string>;
|
|
79
|
+
readonly userId: null | string;
|
|
80
|
+
};
|
|
81
|
+
readonly ctx: Context;
|
|
82
|
+
readonly row?: Record<string, unknown>;
|
|
83
|
+
}
|
|
84
|
+
/** A registered policy as stored in the policy table. */
|
|
85
|
+
interface Policy<Context = unknown> {
|
|
86
|
+
readonly on: PolicyOperation;
|
|
87
|
+
readonly table: string;
|
|
88
|
+
readonly when: (context: PolicyContext<Context>) => PolicyDecision;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Input accepted by `definePolicy`. The branded result is the same
|
|
92
|
+
* shape; we keep the input/output split so callers can read JSDoc on the
|
|
93
|
+
* constructor without the type re-exposing `Policy` internals.
|
|
94
|
+
*/
|
|
95
|
+
interface DefinePolicyInput<Context = unknown> {
|
|
96
|
+
on: PolicyOperation;
|
|
97
|
+
/** Logical table name the policy applies to. */
|
|
98
|
+
table: string;
|
|
99
|
+
/**
|
|
100
|
+
* Decision function. Returning a `WhereInput` (read only) AND-merges the
|
|
101
|
+
* predicate; `true` allows; `false` denies; `undefined` skips this policy.
|
|
102
|
+
*
|
|
103
|
+
* NOTE: `count()` is **unsupported** on a policy-restricted table — the
|
|
104
|
+
* reader throws `LunoraError("COUNT_RLS_UNSUPPORTED")` (422). This mirrors
|
|
105
|
+
* kitcn's documented behavior.
|
|
106
|
+
*/
|
|
107
|
+
when: (context: PolicyContext<Context>) => PolicyDecision;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* A named, abstract capability a policy can check with `ctx.auth.can(...)`,
|
|
111
|
+
* instead of branching on raw role strings. Declare one with
|
|
112
|
+
* `definePermission`; grant it to a role via {@link Role.permissions};
|
|
113
|
+
* register the roles with the middleware via {@link RlsOptions.roles}.
|
|
114
|
+
*/
|
|
115
|
+
interface Permission {
|
|
116
|
+
readonly description?: string;
|
|
117
|
+
readonly name: string;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* A role declaration. Roles are string labels attached to the request's
|
|
121
|
+
* identity (via better-auth claims today). `permissions` lists the
|
|
122
|
+
* capabilities the role grants — at request time the middleware unions the
|
|
123
|
+
* permissions of every role in `ctx.auth.roles` so a policy can ask
|
|
124
|
+
* `ctx.auth.can(permission)` rather than enumerate roles.
|
|
125
|
+
*/
|
|
126
|
+
interface Role {
|
|
127
|
+
readonly description?: string;
|
|
128
|
+
readonly name: string;
|
|
129
|
+
/** Permissions this role grants — by {@link Permission} object or bare name. */
|
|
130
|
+
readonly permissions?: ReadonlyArray<Permission | string>;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Options for the `rls(policies, options)` middleware. `roles` registers the
|
|
134
|
+
* role→permission grants that back `ctx.auth.can(...)`; a role not listed here
|
|
135
|
+
* grants no permissions (so `can` is conservative — it fails closed for
|
|
136
|
+
* unknown roles).
|
|
137
|
+
*/
|
|
138
|
+
interface RlsOptions {
|
|
139
|
+
readonly roles?: ReadonlyArray<Role>;
|
|
140
|
+
}
|
|
141
|
+
export { DefinePolicyInput as D, PolicyOperation as P, Role as R, TypedDefinePolicyInput as T, WhereInput as W, Policy as a, Permission as b, RlsOptions as c, PolicyContext as d, PolicyDecision as e, PolicyDecisionOf as f };
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { WhereOf } from "../data-model.js";
|
|
2
|
+
/** Structural mirror of `@lunora/do`'s `WhereInput`. */
|
|
3
|
+
interface WhereInput {
|
|
4
|
+
[field: string]: unknown;
|
|
5
|
+
AND?: WhereInput[];
|
|
6
|
+
NOT?: WhereInput;
|
|
7
|
+
OR?: WhereInput[];
|
|
8
|
+
}
|
|
9
|
+
/** Operations a policy can gate. `read` covers `get`/`findMany`/`query`/`count`. */
|
|
10
|
+
type PolicyOperation = "delete" | "insert" | "read" | "update";
|
|
11
|
+
/**
|
|
12
|
+
* A policy's `when` decision:
|
|
13
|
+
*
|
|
14
|
+
* - `WhereInput`: a row-shape predicate. On reads it is AND-merged into every
|
|
15
|
+
* query against the table — the row is invisible unless it matches. On writes
|
|
16
|
+
* it is evaluated against the candidate document (`insert`) or the pre-write
|
|
17
|
+
* row (`update`/`delete`); a mismatch denies the write with
|
|
18
|
+
* `LunoraError("FORBIDDEN")`. Same operator set as the SQL compiler
|
|
19
|
+
* (`eq`/`ne`/`in`/`notIn`/`lt`/`lte`/`gt`/`gte`/`isNull`/`contains` +
|
|
20
|
+
* `AND`/`OR`/`NOT`).
|
|
21
|
+
* - `true`: unrestricted. On reads no predicate is merged; on writes the row is
|
|
22
|
+
* allowed.
|
|
23
|
+
* - `false`: deny. On reads the table is forced to match zero rows (a sentinel
|
|
24
|
+
* predicate); on writes the operation throws `LunoraError("FORBIDDEN")`.
|
|
25
|
+
*
|
|
26
|
+
* Returning `undefined` opts this specific policy out (rare; useful when
|
|
27
|
+
* branching on `ctx.auth.roles`).
|
|
28
|
+
*/
|
|
29
|
+
type PolicyDecision = WhereInput | boolean | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* Relation-aware twin of {@link PolicyDecision}, parameterized over the
|
|
32
|
+
* generated `DataModel` (`DM`) + `Relations` (`REL`) maps and a table `T`. A
|
|
33
|
+
* read policy may now return a Prisma-style relation predicate (the
|
|
34
|
+
* `@lunora/do` pre-resolver resolves it via a semijoin), so the typed
|
|
35
|
+
* authoring surface accepts `WhereOf<DM, REL, T>` — column predicates **and**
|
|
36
|
+
* `is`/`isNot`/`some`/`none`/`every` over `T`'s declared relations — in
|
|
37
|
+
* addition to the `boolean`/`undefined` decisions. Used by the project-bound
|
|
38
|
+
* `definePolicy` from `createPolicyDsl`.
|
|
39
|
+
*/
|
|
40
|
+
type PolicyDecisionOf<DM, REL extends Record<keyof DM, object>, T extends keyof DM> = WhereOf<DM, REL, T> | boolean | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* Relation-aware input for a project-bound `definePolicy` (see
|
|
43
|
+
* `createPolicyDsl`). `table` is constrained to a real table name and
|
|
44
|
+
* `when`'s return type is the table-specific {@link PolicyDecisionOf} — so an
|
|
45
|
+
* unknown table, a stray column, or a relation predicate naming a relation the
|
|
46
|
+
* table does not declare is a compile error rather than a silent runtime deny.
|
|
47
|
+
*/
|
|
48
|
+
interface TypedDefinePolicyInput<DM, REL extends Record<keyof DM, object>, T extends keyof DM, Context = unknown, Identity = Record<string, unknown>> {
|
|
49
|
+
on: PolicyOperation;
|
|
50
|
+
table: T;
|
|
51
|
+
when: (context: PolicyContext<Context, Identity>) => PolicyDecisionOf<DM, REL, T>;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Context handed to a policy. `auth.roles` is the per-request role list,
|
|
55
|
+
* sourced from the identity resolver (better-auth claims today). `auth.can`
|
|
56
|
+
* answers whether any of those roles grants a permission (see
|
|
57
|
+
* {@link Permission} / {@link RlsOptions}). `row` is present only on write
|
|
58
|
+
* policies (`insert`/`update`/`delete`) — for `update` and `delete` it is the
|
|
59
|
+
* pre-write row; for `insert` it is the candidate document. `ctx` is the full
|
|
60
|
+
* procedure context the middleware closed over.
|
|
61
|
+
*/
|
|
62
|
+
interface PolicyContext<Context = unknown, Identity = Record<string, unknown>> {
|
|
63
|
+
readonly auth: {
|
|
64
|
+
/**
|
|
65
|
+
* `true` when any of the request's `roles` grants `permission` (passed
|
|
66
|
+
* by {@link Permission} object or its `name`). Always `false` when no
|
|
67
|
+
* roles were handed to the middleware (`rls(policies, { roles })`), or
|
|
68
|
+
* when none of the request's roles lists the permission.
|
|
69
|
+
*/
|
|
70
|
+
readonly can: (permission: Permission | string) => boolean;
|
|
71
|
+
/**
|
|
72
|
+
* The resolved identity, typed to the `Identity` type parameter bound on
|
|
73
|
+
* `createPolicyDsl` — e.g. the app's `defineIdentity(...)` claim type via
|
|
74
|
+
* the `InferIdentity` helper; otherwise the untyped claim bag.
|
|
75
|
+
* `undefined`/`null` when the request is anonymous.
|
|
76
|
+
*/
|
|
77
|
+
readonly identity?: Identity | null;
|
|
78
|
+
readonly roles: ReadonlyArray<string>;
|
|
79
|
+
readonly userId: null | string;
|
|
80
|
+
};
|
|
81
|
+
readonly ctx: Context;
|
|
82
|
+
readonly row?: Record<string, unknown>;
|
|
83
|
+
}
|
|
84
|
+
/** A registered policy as stored in the policy table. */
|
|
85
|
+
interface Policy<Context = unknown> {
|
|
86
|
+
readonly on: PolicyOperation;
|
|
87
|
+
readonly table: string;
|
|
88
|
+
readonly when: (context: PolicyContext<Context>) => PolicyDecision;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Input accepted by `definePolicy`. The branded result is the same
|
|
92
|
+
* shape; we keep the input/output split so callers can read JSDoc on the
|
|
93
|
+
* constructor without the type re-exposing `Policy` internals.
|
|
94
|
+
*/
|
|
95
|
+
interface DefinePolicyInput<Context = unknown> {
|
|
96
|
+
on: PolicyOperation;
|
|
97
|
+
/** Logical table name the policy applies to. */
|
|
98
|
+
table: string;
|
|
99
|
+
/**
|
|
100
|
+
* Decision function. Returning a `WhereInput` (read only) AND-merges the
|
|
101
|
+
* predicate; `true` allows; `false` denies; `undefined` skips this policy.
|
|
102
|
+
*
|
|
103
|
+
* NOTE: `count()` is **unsupported** on a policy-restricted table — the
|
|
104
|
+
* reader throws `LunoraError("COUNT_RLS_UNSUPPORTED")` (422). This mirrors
|
|
105
|
+
* kitcn's documented behavior.
|
|
106
|
+
*/
|
|
107
|
+
when: (context: PolicyContext<Context>) => PolicyDecision;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* A named, abstract capability a policy can check with `ctx.auth.can(...)`,
|
|
111
|
+
* instead of branching on raw role strings. Declare one with
|
|
112
|
+
* `definePermission`; grant it to a role via {@link Role.permissions};
|
|
113
|
+
* register the roles with the middleware via {@link RlsOptions.roles}.
|
|
114
|
+
*/
|
|
115
|
+
interface Permission {
|
|
116
|
+
readonly description?: string;
|
|
117
|
+
readonly name: string;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* A role declaration. Roles are string labels attached to the request's
|
|
121
|
+
* identity (via better-auth claims today). `permissions` lists the
|
|
122
|
+
* capabilities the role grants — at request time the middleware unions the
|
|
123
|
+
* permissions of every role in `ctx.auth.roles` so a policy can ask
|
|
124
|
+
* `ctx.auth.can(permission)` rather than enumerate roles.
|
|
125
|
+
*/
|
|
126
|
+
interface Role {
|
|
127
|
+
readonly description?: string;
|
|
128
|
+
readonly name: string;
|
|
129
|
+
/** Permissions this role grants — by {@link Permission} object or bare name. */
|
|
130
|
+
readonly permissions?: ReadonlyArray<Permission | string>;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Options for the `rls(policies, options)` middleware. `roles` registers the
|
|
134
|
+
* role→permission grants that back `ctx.auth.can(...)`; a role not listed here
|
|
135
|
+
* grants no permissions (so `can` is conservative — it fails closed for
|
|
136
|
+
* unknown roles).
|
|
137
|
+
*/
|
|
138
|
+
interface RlsOptions {
|
|
139
|
+
readonly roles?: ReadonlyArray<Role>;
|
|
140
|
+
}
|
|
141
|
+
export { DefinePolicyInput as D, PolicyOperation as P, Role as R, TypedDefinePolicyInput as T, WhereInput as W, Policy as a, Permission as b, RlsOptions as c, PolicyContext as d, PolicyDecision as e, PolicyDecisionOf as f };
|