@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.
Files changed (41) hide show
  1. package/LICENSE.md +6 -0
  2. package/README.md +50 -0
  3. package/dist/data-model.d.mts +231 -156
  4. package/dist/data-model.d.ts +231 -156
  5. package/dist/index.d.mts +1191 -811
  6. package/dist/index.d.ts +1191 -811
  7. package/dist/index.mjs +20 -15
  8. package/dist/packem_shared/{LunoraEnvError-DjFkpkSP.mjs → LunoraEnvError-BGmd1Qs0.mjs} +4 -4
  9. package/dist/packem_shared/LunoraError-WbxmrpxR.mjs +9 -0
  10. package/dist/packem_shared/{PRESENCE_DEFAULT_TTL_MS-UQuUI5sV.mjs → PRESENCE_DEFAULT_TTL_MS-Cr0i4mTv.mjs} +5 -5
  11. package/dist/packem_shared/{bindOrm-Ce57S3N9.mjs → bindOrm-CaY7Wq9Z.mjs} +42 -23
  12. package/dist/packem_shared/buildRlsReadRegistry-2uk_GfiH.mjs +107 -0
  13. package/dist/packem_shared/{composePluginMiddleware-Ck5_TUO8.mjs → composePluginMiddleware-z62dttBo.mjs} +11 -8
  14. package/dist/packem_shared/{createPolicyDsl-De67zPDS.mjs → createPolicyDsl-By3QB4he.mjs} +4 -1
  15. package/dist/packem_shared/createSecrets-DwaR2rNG.mjs +58 -0
  16. package/dist/packem_shared/{defineAggregateIndex-B20MIOmj.mjs → defineAggregateIndex-cdo0g-un.mjs} +108 -8
  17. package/dist/packem_shared/defineIdentity-DiX4zM9x.mjs +35 -0
  18. package/dist/packem_shared/{defineMigration-CAJLr6fx.mjs → defineMigration-Hx01yIht.mjs} +3 -1
  19. package/dist/packem_shared/defineMutator-EIXAWhs9.mjs +11 -0
  20. package/dist/packem_shared/defineShape-C5scNOrf.mjs +18 -0
  21. package/dist/packem_shared/{defineStorageRule-qu0mpilX.mjs → defineStorageRule-B5nL4Z1P.mjs} +4 -1
  22. package/dist/packem_shared/functions-Di9FUNkf.mjs +5 -0
  23. package/dist/packem_shared/{httpAction-B7FYUEgr.mjs → httpAction-DCXoYPIk.mjs} +42 -27
  24. package/dist/packem_shared/{initLunora-CATvPsVt.mjs → initLunora-D0Wuki7S.mjs} +36 -7
  25. package/dist/packem_shared/{mask-eCUYOwhd.mjs → mask-BepaW7YN.mjs} +132 -9
  26. package/dist/packem_shared/policy-tag-DvpVH2tv.mjs +13 -0
  27. package/dist/packem_shared/{protectPublic-BjFkQ_Or.mjs → protectPublic-BlcGpiRc.mjs} +1 -1
  28. package/dist/packem_shared/{rls-Bi9HiyDC.mjs → rls-BaDQf7MG.mjs} +34 -6
  29. package/dist/packem_shared/{run-middleware-CYQOuoV6.mjs → run-middleware-I6EiQfxL.mjs} +3 -1
  30. package/dist/packem_shared/{storageRules-4a30FSpI.mjs → storageRules-6QxzDOcx.mjs} +1 -1
  31. package/dist/packem_shared/types.d-C4CMJK8x.d.mts +141 -0
  32. package/dist/packem_shared/types.d-DdYF8E18.d.ts +141 -0
  33. package/dist/rls/testing.d.mts +31 -31
  34. package/dist/rls/testing.d.ts +31 -31
  35. package/dist/rls/testing.mjs +1 -1
  36. package/dist/types.d.mts +957 -433
  37. package/dist/types.d.ts +957 -433
  38. package/package.json +6 -5
  39. package/dist/packem_shared/LunoraError-DhggBJZF.mjs +0 -51
  40. package/dist/packem_shared/types.d-BDY0FYHK.d.ts +0 -135
  41. package/dist/packem_shared/types.d-DmvyEMD6.d.mts +0 -135
@@ -1,5 +1,5 @@
1
- import { LunoraError } from './LunoraError-DhggBJZF.mjs';
2
- import { bindTableFacade, bindOrm } from './bindOrm-Ce57S3N9.mjs';
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
- withIndex: (indexName, range) => wrapReader(reader.withIndex(indexName, range), columns),
82
- withSearchIndex: (indexName, search) => wrapReader(reader.withSearchIndex(indexName, search), columns)
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,4 +1,4 @@
1
- import { r as runMiddlewareChain } from './run-middleware-CYQOuoV6.mjs';
1
+ import { r as runMiddlewareChain } from './run-middleware-I6EiQfxL.mjs';
2
2
 
3
3
  const protectPublic = (options) => {
4
4
  const chain = [options.rateLimit, options.captcha, ...options.use ?? []].filter(
@@ -1,5 +1,6 @@
1
- import { LunoraError } from './LunoraError-DhggBJZF.mjs';
2
- import { bindTableFacade, bindOrm } from './bindOrm-Ce57S3N9.mjs';
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} on "${located.tableName}" denied by policy`);
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 || !baseWhere) {
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
- return async ({ ctx, next }) => {
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 Error("middleware next() called multiple times");
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-DhggBJZF.mjs';
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&lt;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&lt;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 };