@lunora/server 1.0.0-alpha.11 → 1.0.0-alpha.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -17,11 +17,11 @@ export { defineShape } from './packem_shared/defineShape-CJ27Wx7o.mjs';
17
17
  export { anyApi } from './types.mjs';
18
18
  export { cronJobs } from '@lunora/scheduler';
19
19
  export { ValidationError, v } from '@lunora/values';
20
- export { buildRlsReadRegistry, composeShapeReadWhere } from './packem_shared/buildRlsReadRegistry-1jexWrb3.mjs';
20
+ export { buildRlsReadRegistry, composeShapeReadWhere } from './packem_shared/buildRlsReadRegistry-Do1CSBTr.mjs';
21
21
  export { createPolicyDsl, definePermission, definePolicies, definePolicy, defineRole } from './packem_shared/createPolicyDsl-De67zPDS.mjs';
22
22
  export { defineStorageRule, defineStorageRules } from './packem_shared/defineStorageRule-qu0mpilX.mjs';
23
- export { mask } from './packem_shared/mask-BV_jNzsN.mjs';
24
- export { rls } from './packem_shared/rls-2Jhd0uev.mjs';
23
+ export { mask } from './packem_shared/mask-E8MgAS3N.mjs';
24
+ export { rls } from './packem_shared/rls-DhNgKFeW.mjs';
25
25
  export { storageRules } from './packem_shared/storageRules-Cje6Woea.mjs';
26
26
 
27
27
  const VERSION = "0.0.0";
@@ -1,4 +1,4 @@
1
- import { indexRolePermissions, computeReadBaseWhere, permissionName } from './rls-2Jhd0uev.mjs';
1
+ import { indexRolePermissions, computeReadBaseWhere, permissionName } from './rls-DhNgKFeW.mjs';
2
2
  import { r as readRlsTag } from './policy-tag-DvpVH2tv.mjs';
3
3
 
4
4
  const FALSE_PREDICATE = { OR: [] };
@@ -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,19 @@ 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
+ }
83
123
  };
84
124
  };
85
125
  const locate = async (id, expectedTable) => {
@@ -120,23 +160,63 @@ const wrapDatabase = (base, perTable, context) => {
120
160
  throw new LunoraError("MASK_UNSUPPORTED", `${method}() over masked column "${offending}" on "${tableName}" is not supported`);
121
161
  }
122
162
  };
163
+ const collectWhereFields = (where, into) => {
164
+ if (!where || typeof where !== "object" || Array.isArray(where)) {
165
+ return;
166
+ }
167
+ for (const [key, value] of Object.entries(where)) {
168
+ if (key === "AND" || key === "OR") {
169
+ if (Array.isArray(value)) {
170
+ for (const clause of value) {
171
+ collectWhereFields(clause, into);
172
+ }
173
+ }
174
+ } else if (key === "NOT") {
175
+ collectWhereFields(value, into);
176
+ } else if (!key.startsWith("__")) {
177
+ into.add(key);
178
+ }
179
+ }
180
+ };
181
+ const assertWhereAllowed = (tableName, where, method) => {
182
+ const columns = perTable.get(tableName);
183
+ if (!columns || where === void 0) {
184
+ return;
185
+ }
186
+ const referenced = /* @__PURE__ */ new Set();
187
+ collectWhereFields(where, referenced);
188
+ for (const field of referenced) {
189
+ if (field in columns) {
190
+ throw new LunoraError("MASK_UNSUPPORTED", `${method}() filtering "${tableName}" by masked column "${field}" is not supported`);
191
+ }
192
+ }
193
+ };
123
194
  const wrapped = {
124
195
  ...base,
125
196
  aggregate(tableName, options) {
126
197
  assertReductionAllowed(tableName, [options.field], "aggregate");
127
198
  return base.aggregate(tableName, options);
128
199
  },
200
+ count(tableName, whereOrArgs) {
201
+ const wrapper = whereOrArgs && typeof whereOrArgs === "object" && !Array.isArray(whereOrArgs) ? whereOrArgs : void 0;
202
+ const where = wrapper && ("where" in wrapper || "baseWhere" in wrapper || "restrictsCounts" in wrapper) ? wrapper.where : whereOrArgs;
203
+ assertWhereAllowed(tableName, where, "count");
204
+ return base.count(tableName, whereOrArgs);
205
+ },
129
206
  async findFirst(tableName, args) {
207
+ assertWhereAllowed(tableName, args?.where, "findFirst");
130
208
  const row = await base.findFirst(tableName, args);
131
209
  const columns = perTable.get(tableName);
132
210
  return row && columns ? maskRow(row, columns, context) : row;
133
211
  },
134
212
  async findFirstOrThrow(tableName, args) {
213
+ assertWhereAllowed(tableName, args?.where, "findFirstOrThrow");
135
214
  const row = await base.findFirstOrThrow(tableName, args);
136
215
  const columns = perTable.get(tableName);
137
216
  return columns ? maskRow(row, columns, context) : row;
138
217
  },
139
218
  async findMany(tableName, args) {
219
+ assertWhereAllowed(tableName, args?.where, "findMany");
140
220
  const page = await base.findMany(tableName, args);
141
221
  const columns = perTable.get(tableName);
142
222
  return columns ? maskPage(page, columns, context) : page;
@@ -156,7 +236,7 @@ const wrapDatabase = (base, perTable, context) => {
156
236
  query(tableName) {
157
237
  const reader = base.query(tableName);
158
238
  const columns = perTable.get(tableName);
159
- return columns ? wrapReader(reader, columns) : reader;
239
+ return columns ? wrapReader(reader, columns, tableName) : reader;
160
240
  },
161
241
  async rankPage(tableName, indexName, options) {
162
242
  const page = await base.rankPage(tableName, indexName, options);
@@ -245,7 +245,6 @@ const isFacadeEntry = (value) => {
245
245
  return typeof candidate["findMany"] === "function" && typeof candidate["withSearchIndex"] === "function";
246
246
  };
247
247
  const wrapDatabase = (base, raw, perTable, context) => {
248
- const route = (tableName) => perTable.has(tableName) ? raw : base;
249
248
  const readBaseCache = /* @__PURE__ */ new Map();
250
249
  const readBase = (tableName) => {
251
250
  const cached = readBaseCache.get(tableName);
@@ -263,6 +262,7 @@ const wrapDatabase = (base, raw, perTable, context) => {
263
262
  readBaseCache.set(tableName, result);
264
263
  return result;
265
264
  };
265
+ const route = (tableName) => readBase(tableName).restricts ? raw : base;
266
266
  const relationReadFilter = (table) => readBase(table).baseWhere;
267
267
  const locateRow = async (id, expectedTable) => {
268
268
  if (raw.lookupById) {
@@ -300,7 +300,7 @@ const wrapDatabase = (base, raw, perTable, context) => {
300
300
  const nextRow = computeNextRow ? computeNextRow(located.row) : void 0;
301
301
  const writeOk = evaluateWrite(policies, op, { ...context, row: located.row }, nextRow);
302
302
  if (!writeOk) {
303
- throw new LunoraError("FORBIDDEN", `${op} on "${located.tableName}" denied by policy`);
303
+ throw new LunoraError("FORBIDDEN", `${op} denied by policy`);
304
304
  }
305
305
  }
306
306
  return perform(raw);
@@ -366,7 +366,10 @@ const wrapDatabase = (base, raw, perTable, context) => {
366
366
  return base.get(id, expectedTable);
367
367
  }
368
368
  const { baseWhere, restricts } = readBase(located.tableName);
369
- if (!restricts || !baseWhere) {
369
+ if (!restricts) {
370
+ return base.get(id, expectedTable);
371
+ }
372
+ if (!baseWhere) {
370
373
  return located.row;
371
374
  }
372
375
  const allowed = await raw.findFirst(located.tableName, { baseWhere, limit: 1, where: { _id: id } });
@@ -1,4 +1,4 @@
1
- import { indexRolePermissions, computeReadBaseWhere, matchesWhere, evaluateWrite, permissionName } from '../packem_shared/rls-2Jhd0uev.mjs';
1
+ import { indexRolePermissions, computeReadBaseWhere, matchesWhere, evaluateWrite, permissionName } from '../packem_shared/rls-DhNgKFeW.mjs';
2
2
 
3
3
  const expectPolicy = (policies, options = {}) => {
4
4
  const rolePermissions = indexRolePermissions(options.roles);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/server",
3
- "version": "1.0.0-alpha.11",
3
+ "version": "1.0.0-alpha.13",
4
4
  "description": "Server primitives for Lunora: defineSchema, defineTable, query, mutation, and action",
5
5
  "keywords": [
6
6
  "backend",