@opensaas/stack-core 0.38.0 → 0.39.1

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 (204) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +125 -0
  3. package/CLAUDE.md +21 -0
  4. package/dist/access/access-filter.d.ts +46 -1
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +113 -4
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/declared-dependencies.d.ts.map +1 -1
  9. package/dist/access/declared-dependencies.js +4 -9
  10. package/dist/access/declared-dependencies.js.map +1 -1
  11. package/dist/access/engine.d.ts +0 -20
  12. package/dist/access/engine.d.ts.map +1 -1
  13. package/dist/access/engine.js +0 -25
  14. package/dist/access/engine.js.map +1 -1
  15. package/dist/access/errors.d.ts +49 -0
  16. package/dist/access/errors.d.ts.map +1 -1
  17. package/dist/access/errors.js +75 -0
  18. package/dist/access/errors.js.map +1 -1
  19. package/dist/access/field-access.d.ts +39 -1
  20. package/dist/access/field-access.d.ts.map +1 -1
  21. package/dist/access/field-access.js +108 -42
  22. package/dist/access/field-access.js.map +1 -1
  23. package/dist/access/field-access.test.js +229 -2
  24. package/dist/access/field-access.test.js.map +1 -1
  25. package/dist/access/field-transforms.d.ts +0 -27
  26. package/dist/access/field-transforms.d.ts.map +1 -1
  27. package/dist/access/field-visibility.d.ts +0 -4
  28. package/dist/access/field-visibility.d.ts.map +1 -1
  29. package/dist/access/field-visibility.js +13 -39
  30. package/dist/access/field-visibility.js.map +1 -1
  31. package/dist/access/index.d.ts +6 -2
  32. package/dist/access/index.d.ts.map +1 -1
  33. package/dist/access/index.js +12 -2
  34. package/dist/access/index.js.map +1 -1
  35. package/dist/access/query-validation.d.ts +121 -0
  36. package/dist/access/query-validation.d.ts.map +1 -0
  37. package/dist/access/query-validation.js +292 -0
  38. package/dist/access/query-validation.js.map +1 -0
  39. package/dist/access/relationship-count.d.ts.map +1 -1
  40. package/dist/access/relationship-count.js +2 -10
  41. package/dist/access/relationship-count.js.map +1 -1
  42. package/dist/access/relationship-label-filter.d.ts +23 -23
  43. package/dist/access/relationship-label-filter.d.ts.map +1 -1
  44. package/dist/access/relationship-label-filter.js +5 -76
  45. package/dist/access/relationship-label-filter.js.map +1 -1
  46. package/dist/access/relationship-label-filter.test.js +19 -56
  47. package/dist/access/relationship-label-filter.test.js.map +1 -1
  48. package/dist/access/transaction-registry.d.ts +45 -0
  49. package/dist/access/transaction-registry.d.ts.map +1 -0
  50. package/dist/access/transaction-registry.js +37 -0
  51. package/dist/access/transaction-registry.js.map +1 -0
  52. package/dist/access/types.d.ts +55 -59
  53. package/dist/access/types.d.ts.map +1 -1
  54. package/dist/config/index.d.ts +9 -7
  55. package/dist/config/index.d.ts.map +1 -1
  56. package/dist/config/index.js +10 -18
  57. package/dist/config/index.js.map +1 -1
  58. package/dist/config/label.d.ts +6 -13
  59. package/dist/config/label.d.ts.map +1 -1
  60. package/dist/config/label.js +6 -13
  61. package/dist/config/label.js.map +1 -1
  62. package/dist/config/nav-count.d.ts +11 -19
  63. package/dist/config/nav-count.d.ts.map +1 -1
  64. package/dist/config/nav-count.js +11 -19
  65. package/dist/config/nav-count.js.map +1 -1
  66. package/dist/config/plugin-engine.d.ts +0 -10
  67. package/dist/config/plugin-engine.d.ts.map +1 -1
  68. package/dist/config/plugin-engine.js +1 -37
  69. package/dist/config/plugin-engine.js.map +1 -1
  70. package/dist/config/types.d.ts +94 -109
  71. package/dist/config/types.d.ts.map +1 -1
  72. package/dist/context/apply-defaults.d.ts +8 -31
  73. package/dist/context/apply-defaults.d.ts.map +1 -1
  74. package/dist/context/apply-defaults.js +12 -41
  75. package/dist/context/apply-defaults.js.map +1 -1
  76. package/dist/context/hook-pipeline.d.ts +4 -29
  77. package/dist/context/hook-pipeline.d.ts.map +1 -1
  78. package/dist/context/hook-pipeline.js +8 -41
  79. package/dist/context/hook-pipeline.js.map +1 -1
  80. package/dist/context/index.d.ts +4 -15
  81. package/dist/context/index.d.ts.map +1 -1
  82. package/dist/context/index.js +190 -193
  83. package/dist/context/index.js.map +1 -1
  84. package/dist/context/nested-operations.d.ts +7 -26
  85. package/dist/context/nested-operations.d.ts.map +1 -1
  86. package/dist/context/nested-operations.js +73 -171
  87. package/dist/context/nested-operations.js.map +1 -1
  88. package/dist/context/transaction-boundary.d.ts +36 -47
  89. package/dist/context/transaction-boundary.d.ts.map +1 -1
  90. package/dist/context/transaction-boundary.js +92 -79
  91. package/dist/context/transaction-boundary.js.map +1 -1
  92. package/dist/context/write-pipeline.d.ts +25 -64
  93. package/dist/context/write-pipeline.d.ts.map +1 -1
  94. package/dist/context/write-pipeline.js +81 -111
  95. package/dist/context/write-pipeline.js.map +1 -1
  96. package/dist/fields/format-prisma-default.d.ts.map +1 -1
  97. package/dist/fields/format-prisma-default.js +4 -7
  98. package/dist/fields/format-prisma-default.js.map +1 -1
  99. package/dist/fields/index.d.ts +17 -2
  100. package/dist/fields/index.d.ts.map +1 -1
  101. package/dist/fields/index.js +136 -147
  102. package/dist/fields/index.js.map +1 -1
  103. package/dist/filter/collect.d.ts +26 -3
  104. package/dist/filter/collect.d.ts.map +1 -1
  105. package/dist/filter/collect.js +30 -12
  106. package/dist/filter/collect.js.map +1 -1
  107. package/dist/filter/filter.test.js +98 -15
  108. package/dist/filter/filter.test.js.map +1 -1
  109. package/dist/filter/index.d.ts +1 -0
  110. package/dist/filter/index.d.ts.map +1 -1
  111. package/dist/filter/index.js.map +1 -1
  112. package/dist/filter/map.d.ts.map +1 -1
  113. package/dist/filter/map.js +0 -5
  114. package/dist/filter/map.js.map +1 -1
  115. package/dist/filter/parse.d.ts.map +1 -1
  116. package/dist/filter/parse.js +2 -9
  117. package/dist/filter/parse.js.map +1 -1
  118. package/dist/filter/serialize.d.ts.map +1 -1
  119. package/dist/filter/serialize.js +2 -4
  120. package/dist/filter/serialize.js.map +1 -1
  121. package/dist/hooks/index.d.ts +4 -42
  122. package/dist/hooks/index.d.ts.map +1 -1
  123. package/dist/hooks/index.js +5 -59
  124. package/dist/hooks/index.js.map +1 -1
  125. package/dist/index.d.ts +3 -1
  126. package/dist/index.d.ts.map +1 -1
  127. package/dist/index.js +20 -4
  128. package/dist/index.js.map +1 -1
  129. package/dist/internal.d.ts +1 -0
  130. package/dist/internal.d.ts.map +1 -1
  131. package/dist/internal.js +4 -0
  132. package/dist/internal.js.map +1 -1
  133. package/dist/lib/case-utils.d.ts +0 -32
  134. package/dist/lib/case-utils.d.ts.map +1 -1
  135. package/dist/lib/case-utils.js +0 -32
  136. package/dist/lib/case-utils.js.map +1 -1
  137. package/dist/mcp/handler.d.ts +0 -4
  138. package/dist/mcp/handler.d.ts.map +1 -1
  139. package/dist/mcp/handler.js +12 -69
  140. package/dist/mcp/handler.js.map +1 -1
  141. package/dist/query/index.d.ts +26 -227
  142. package/dist/query/index.d.ts.map +1 -1
  143. package/dist/query/index.js +14 -117
  144. package/dist/query/index.js.map +1 -1
  145. package/dist/utils/password.d.ts +0 -68
  146. package/dist/utils/password.d.ts.map +1 -1
  147. package/dist/utils/password.js +7 -76
  148. package/dist/utils/password.js.map +1 -1
  149. package/dist/validation/field-config.d.ts.map +1 -1
  150. package/dist/validation/field-config.js +0 -6
  151. package/dist/validation/field-config.js.map +1 -1
  152. package/dist/validation/schema.d.ts +0 -7
  153. package/dist/validation/schema.d.ts.map +1 -1
  154. package/dist/validation/schema.js +0 -10
  155. package/dist/validation/schema.js.map +1 -1
  156. package/package.json +1 -1
  157. package/src/access/access-filter.ts +152 -5
  158. package/src/access/declared-dependencies.ts +4 -9
  159. package/src/access/engine.ts +0 -25
  160. package/src/access/errors.ts +81 -0
  161. package/src/access/field-access.test.ts +312 -2
  162. package/src/access/field-access.ts +118 -42
  163. package/src/access/field-transforms.ts +8 -39
  164. package/src/access/field-visibility.ts +16 -47
  165. package/src/access/index.ts +20 -2
  166. package/src/access/query-validation.ts +359 -0
  167. package/src/access/relationship-count.ts +2 -10
  168. package/src/access/relationship-label-filter.test.ts +19 -68
  169. package/src/access/relationship-label-filter.ts +27 -104
  170. package/src/access/transaction-registry.ts +58 -0
  171. package/src/access/types.ts +83 -86
  172. package/src/config/index.ts +10 -22
  173. package/src/config/label.ts +6 -13
  174. package/src/config/nav-count.ts +11 -19
  175. package/src/config/plugin-engine.ts +1 -37
  176. package/src/config/types.ts +95 -110
  177. package/src/context/apply-defaults.ts +12 -44
  178. package/src/context/hook-pipeline.ts +12 -67
  179. package/src/context/index.ts +263 -247
  180. package/src/context/nested-operations.ts +94 -226
  181. package/src/context/transaction-boundary.ts +113 -109
  182. package/src/context/write-pipeline.ts +99 -155
  183. package/src/fields/format-prisma-default.ts +4 -7
  184. package/src/fields/index.ts +155 -147
  185. package/src/filter/collect.ts +48 -12
  186. package/src/filter/filter.test.ts +153 -15
  187. package/src/filter/index.ts +1 -0
  188. package/src/filter/map.ts +0 -5
  189. package/src/filter/parse.ts +2 -9
  190. package/src/filter/serialize.ts +2 -4
  191. package/src/hooks/index.ts +5 -59
  192. package/src/index.ts +23 -4
  193. package/src/internal.ts +5 -0
  194. package/src/lib/case-utils.ts +0 -32
  195. package/src/mcp/handler.ts +13 -71
  196. package/src/query/index.ts +29 -235
  197. package/src/utils/password.ts +7 -76
  198. package/src/validation/field-config.ts +0 -6
  199. package/src/validation/schema.ts +0 -10
  200. package/tests/context.test.ts +700 -1
  201. package/tests/field-types.test.ts +222 -0
  202. package/tests/mcp-handler.test.ts +34 -0
  203. package/tests/transaction-boundary-outermost.test.ts +530 -0
  204. package/tsconfig.tsbuildinfo +1 -1
@@ -30,18 +30,14 @@ import { buildDbDelegate } from '../context/index.js'
30
30
  * level this way; a bare or `include`-based read, and any relation reached
31
31
  * purely to satisfy a `needs` declaration, still compute every field, as
32
32
  * before. A field the read is not going to return does no work at all —
33
- * neither its read-access evaluation nor its hook.
33
+ * neither its read-access evaluation nor its hook. The projection-aware skip
34
+ * below shows this rule at each of the two places it applies.
34
35
  *
35
36
  * Phase 1 (pre-query row/relation scoping) lives in `access-filter.ts`. See
36
37
  * `docs/adr/0001-access-control-is-a-two-phase-read.md` and the access-control
37
38
  * glossary in `CONTEXT.md`.
38
39
  */
39
40
 
40
- /**
41
- * Runtime type for resolveOutput hooks
42
- * Used when we need to call hooks generically without knowing the specific field type
43
- * Supports both sync and async implementations
44
- */
45
41
  type ResolveOutputHookRuntime = (args: {
46
42
  operation: 'query'
47
43
  value: unknown
@@ -123,7 +119,6 @@ async function resolveReadableFieldValue(params: {
123
119
  }): Promise<{ readable: false } | { readable: true; value: unknown }> {
124
120
  const { fieldConfig, fieldName, value, accessItem, hookItem, listKey, args, config } = params
125
121
 
126
- // Check field access (checkFieldAccess already handles sudo mode)
127
122
  const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
128
123
  ...args,
129
124
  item: accessItem,
@@ -133,10 +128,9 @@ async function resolveReadableFieldValue(params: {
133
128
  return { readable: false }
134
129
  }
135
130
 
136
- // Apply resolveOutput hook if present
137
131
  if (fieldConfig?.hooks?.resolveOutput && listKey) {
138
- // Cast to runtime type for generic execution
139
- // At runtime, the hook will receive the correct value type for the field
132
+ // The hook is erased to this runtime shape here; at the actual call it
133
+ // receives the value typed for its own field.
140
134
  const hook = fieldConfig.hooks.resolveOutput as unknown as ResolveOutputHookRuntime
141
135
  const link = { listKey, fieldKey: fieldName }
142
136
  const chain = args.context._resolveOutputChain
@@ -164,7 +158,6 @@ async function resolveReadableFieldValue(params: {
164
158
  return { readable: false }
165
159
  }
166
160
 
167
- // Use Promise.resolve() to handle both sync and async hooks
168
161
  const resolved = await Promise.resolve(
169
162
  hook({
170
163
  value,
@@ -181,10 +174,6 @@ async function resolveReadableFieldValue(params: {
181
174
  return { readable: true, value }
182
175
  }
183
176
 
184
- /**
185
- * Filter fields from an object based on read access
186
- * Recursively applies access control to nested relationships
187
- */
188
177
  export async function filterReadableFields<T extends Record<string, unknown>>(
189
178
  item: T,
190
179
  fieldConfigs: Record<string, FieldConfig>,
@@ -201,12 +190,8 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
201
190
  // returned — after resolveOutput has had a chance to read them — so a
202
191
  // declared dependency never widens what the caller receives.
203
192
  declaredOnly: DeclaredOnlyTree = emptyDeclaredOnlyTree(),
204
- // The fragment scope this level was reached under (ADR-0027), and the same
205
- // tree one level down for each nested relation. `undefined` — the default,
206
- // and what a bare/`include`-based read passes at every level — means
207
- // unrestricted: every field on the list is computed, unchanged from
208
- // before ADR-0027. Only a `query` fragment's own field selection ever
209
- // restricts a level.
193
+ // The fragment scope this level was reached under (ADR-0027, see module doc
194
+ // above), and the same tree one level down for each nested relation.
210
195
  selection?: FieldSelectionScope,
211
196
  ): Promise<Partial<T>> {
212
197
  const filtered: Record<string, unknown> = {}
@@ -246,26 +231,22 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
246
231
  for (const [fieldName, value] of Object.entries(workingItem)) {
247
232
  const fieldConfig = fieldConfigs[fieldName]
248
233
 
249
- // Always include id, createdAt, updatedAt
250
234
  if (['id', 'createdAt', 'updatedAt'].includes(fieldName)) {
251
235
  filtered[fieldName] = value
252
236
  continue
253
237
  }
254
238
 
255
- // Projection-aware skip (ADR-0027): a fragment read that does not select
256
- // this field does no work for it at all — no field-level read-access
257
- // check, no resolveOutput, no recursion into a relation — because the
258
- // read is never going to return it. `selection` is only ever restricted
259
- // by a fragment's own field selection; a bare/`include`-based read, and a
260
- // relation reached only to satisfy another field's `needs`, pass no
261
- // selection at all and compute every field here, unchanged.
239
+ // Projection-aware skip (ADR-0027, see module doc above): a field this
240
+ // level's fragment did not select gets no read-access check, no
241
+ // resolveOutput, and no recursion into a relation — none of that work
242
+ // happens for a value the read isn't going to return.
262
243
  if (selection?.fields && !selection.fields.has(fieldName)) {
263
244
  continue
264
245
  }
265
246
 
266
- // Handle relationship fields - recursively filter fields within related items
267
- // Note: Access control filtering is now done at database level via buildAccessScopedInclude
268
- // This only handles field-level access (hiding sensitive fields)
247
+ // Row/relation-level access is already scoped at the DB level via
248
+ // buildAccessScopedInclude; this only handles field-level access (hiding
249
+ // sensitive fields).
269
250
  //
270
251
  // Deliberately uncapped: the row/relation scoping in access-filter.ts bounds
271
252
  // what gets FETCHED (a caller include past its depth cap is now a denial,
@@ -283,7 +264,6 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
283
264
  value !== null &&
284
265
  value !== undefined
285
266
  ) {
286
- // Gate the relationship on read access before recursing.
287
267
  const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
288
268
  ...args,
289
269
  item: workingItem,
@@ -308,8 +288,6 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
308
288
  const nestedSelection = selection?.nested[fieldName]
309
289
 
310
290
  if (relatedConfig) {
311
- // For many relationships (arrays) - recursively filter fields in each item
312
- // The recursive call already handles applying resolveOutput hooks
313
291
  if (Array.isArray(value)) {
314
292
  filtered[fieldName] = await Promise.all(
315
293
  value.map((relatedItem) =>
@@ -325,10 +303,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
325
303
  ),
326
304
  ),
327
305
  )
328
- }
329
- // For single relationships (objects) - recursively filter fields
330
- // The recursive call already handles applying resolveOutput hooks
331
- else if (typeof value === 'object') {
306
+ } else if (typeof value === 'object') {
332
307
  filtered[fieldName] = await filterReadableFields(
333
308
  value as Record<string, unknown>,
334
309
  relatedConfig.listConfig.fields,
@@ -341,14 +316,13 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
341
316
  )
342
317
  }
343
318
  } else {
344
- // Related config not found, include the value as-is
345
319
  filtered[fieldName] = value
346
320
  }
347
321
  continue
348
322
  }
349
323
 
350
- // Non-relationship field (or relationship without an includable value):
351
- // check read access and apply resolveOutput via the shared helper.
324
+ // Non-relationship field, or a relationship field whose value is not
325
+ // includable (null/undefined) falls through to the shared helper.
352
326
  const result = await resolveReadableFieldValue({
353
327
  fieldConfig,
354
328
  fieldName,
@@ -394,15 +368,11 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
394
368
  delete computedFieldItem[key]
395
369
  }
396
370
 
397
- // Process virtual fields - compute values from other fields
398
- // Virtual fields don't exist in the database result, so we need to compute them separately
399
371
  for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
400
- // Skip if already processed (from database result)
401
372
  if (fieldName in filtered) {
402
373
  continue
403
374
  }
404
375
 
405
- // Only process virtual fields
406
376
  if (!fieldConfig.virtual) {
407
377
  continue
408
378
  }
@@ -423,7 +393,6 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
423
393
  continue
424
394
  }
425
395
 
426
- // Check read access and compute the value via the shared helper.
427
396
  const result = await resolveReadableFieldValue({
428
397
  fieldConfig,
429
398
  fieldName,
@@ -20,9 +20,23 @@ export {
20
20
  getRelatedListConfig,
21
21
  } from './engine.js'
22
22
  // Canonical field-level access evaluation (shared by read and write paths).
23
- export { checkFieldAccess, filterWritableFields } from './field-access.js'
23
+ export {
24
+ checkFieldAccess,
25
+ filterWritableFields,
26
+ isFieldReadableForPredicate,
27
+ } from './field-access.js'
28
+ // Read-path key validation — the `findMany`/`count` counterpart to the write
29
+ // path's #564 undeclared-key reject.
30
+ export { validateQueryKeys } from './query-validation.js'
31
+ // Read-path field-level access on `where`/`orderBy` keys — a field the
32
+ // session cannot read cannot be named in a predicate either (#915).
33
+ export { validateQueryFieldReadAccess } from './query-validation.js'
24
34
  // Phase 1 — Access Filter (pre-query row/relation scoping).
25
- export { buildAccessScopedInclude, stripVirtualFieldsFromInclude } from './access-filter.js'
35
+ export {
36
+ buildAccessScopedInclude,
37
+ buildAccessScopedWhere,
38
+ stripVirtualFieldsFromInclude,
39
+ } from './access-filter.js'
26
40
  // Phase 2 — Field Visibility (post-query field stripping + resolveOutput).
27
41
  export { filterReadableFields } from './field-visibility.js'
28
42
  // Declared Dependencies — folding `needs` into an include without widening
@@ -37,3 +51,7 @@ export type { DeclaredOnlyTree } from './declared-dependencies.js'
37
51
  export { AccessScopeDepthExceededError } from './errors.js'
38
52
  // Thrown when a resolveOutput hook's own resolve chain cycles back into itself.
39
53
  export { ResolveOutputCycleError } from './errors.js'
54
+ // Thrown when a field-level access control function returns a non-boolean result.
55
+ export { InvalidFieldAccessResultError } from './errors.js'
56
+ // Thrown when a relation filter's related list denies query access outright (#916).
57
+ export { RelationFilterAccessDeniedError } from './errors.js'
@@ -0,0 +1,359 @@
1
+ import type { ListConfig, OpenSaasConfig } from '../config/types.js'
2
+ import type { Session, AccessContext } from './types.js'
3
+ import { getRelatedListConfig } from './engine.js'
4
+ import { isFieldReadableForPredicate } from './field-access.js'
5
+ import { ValidationError } from '../hooks/index.js'
6
+
7
+ /**
8
+ * #912 — read-path key validation.
9
+ *
10
+ * The write path settled #564: an undeclared key in `data` throws, because the
11
+ * generated Prisma model has strictly more fields than the config declares (most
12
+ * notably back-relations — Prisma emits one for every inbound foreign key, whether
13
+ * or not the list config declares the reverse relationship). Reads had no
14
+ * equivalent — a caller's `where`/`orderBy` reached Prisma unchanged, so an
15
+ * anonymous caller could filter or order by a relation the config never exposed.
16
+ *
17
+ * This module is the read-path counterpart: every key named in a caller's `where`
18
+ * or `orderBy` is resolved against the list config before the query runs. A key
19
+ * with no entry in the config throws, naming the list and the key. `sudo` is the
20
+ * single trusted bypass, mirroring the write path.
21
+ *
22
+ * Deliberately NOT walked: the access filter produced by the list's own `query`
23
+ * access control. That filter is trusted config authored by the same person who
24
+ * declares the fields — walking it would make this an access-control decision
25
+ * (that's #915/#916), not the key-existence seam this ticket establishes.
26
+ *
27
+ * `validateQueryFieldReadAccess` below is that access-control decision for
28
+ * #915: it re-walks `where`/`orderBy` (reusing `resolveQueryField`) and checks
29
+ * each resolved field's `read` access via the canonical evaluator, so a field
30
+ * the session cannot read cannot be named in a predicate either. It runs
31
+ * strictly after this module's key-existence check — an undeclared key is
32
+ * #912's rejection, not a field-access decision — and stays scoped to the
33
+ * CURRENT list, deliberately not recursing into a related list's fields
34
+ * nested inside a relation filter itself (that recursion is #916's job, not
35
+ * this module's — see below).
36
+ *
37
+ * #916 — the relation-filter counterpart — scopes a relation filter itself
38
+ * (`some`/`every`/`none`/`is`/`isNot`) by the RELATED list's own `query`
39
+ * access, folding it into the nested clause exactly like
40
+ * `buildAccessScopedInclude` folds it into `include` (`access-filter.ts`).
41
+ * It reuses this module's shape-recognition (`resolveQueryField`,
42
+ * `LOGICAL_OPERATORS`, `RELATION_QUANTIFIERS`, exported below) and calls
43
+ * `walkWhereReadAccess` once per hop — against the RELATED list's own
44
+ * config — for the field-read half of the same job this module's
45
+ * `validateQueryFieldReadAccess` already does for the CURRENT list. There is
46
+ * deliberately no second copy of either the shape-recognition or the
47
+ * field-read check: `access-filter.ts` supplies the RELATED list at each
48
+ * hop and calls back into the same primitives this module already owns.
49
+ */
50
+
51
+ // Prisma's logical combinators for a WHERE clause — never field names.
52
+ export const LOGICAL_OPERATORS = new Set(['AND', 'OR', 'NOT'])
53
+
54
+ // Prisma's relation quantifiers. The value nested under one of these is itself a
55
+ // WHERE clause for the RELATED list, and is walked against that list's fields.
56
+ export const RELATION_QUANTIFIERS = new Set(['some', 'every', 'none', 'is', 'isNot'])
57
+
58
+ // Always present, never declared in a list's `fields` — the write path
59
+ // (`filterWritableFields`) excludes the same three names from `fieldConfigs`.
60
+ const SYSTEM_FIELDS = new Set(['id', 'createdAt', 'updatedAt'])
61
+
62
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- field configs are heterogeneous across field types
63
+ type FieldConfigMap = Record<string, any>
64
+
65
+ export interface ResolvedQueryField {
66
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- field configs are heterogeneous across field types
67
+ fieldConfig: any
68
+ isRelationship: boolean
69
+ }
70
+
71
+ /**
72
+ * Resolve a `where`/`orderBy` key against a list's declared fields.
73
+ *
74
+ * A key is valid when it is:
75
+ * - a system field (`id`, `createdAt`, `updatedAt`) — always present, and never
76
+ * declared in `fields` (the write path excludes them from `fieldConfigs` the
77
+ * same way), or
78
+ * - a field declared directly in the list config, or
79
+ * - the foreign-key scalar a to-one `relationship` field implies (e.g. `authorId`
80
+ * for `author: relationship(...)`) — the config never names this column
81
+ * directly, but Prisma always generates it, and the write path
82
+ * (`filterWritableFields`) grants it the same pass, or
83
+ * - a raw per-part column a multi-column field's `splitColumns` contributes (e.g.
84
+ * storage `image()`/`file()` in Keystone-parity mode) — undeclared by design,
85
+ * mirroring the write path's `splitColumnOwners` allowance (#568/#789).
86
+ *
87
+ * Anything else — most importantly a Prisma-generated back-relation the config
88
+ * never declares — resolves to `undefined` and is rejected by the caller.
89
+ */
90
+ export function resolveQueryField(
91
+ key: string,
92
+ fields: FieldConfigMap,
93
+ ): ResolvedQueryField | undefined {
94
+ if (SYSTEM_FIELDS.has(key)) {
95
+ return { fieldConfig: undefined, isRelationship: false }
96
+ }
97
+
98
+ const fieldConfig = fields[key]
99
+ if (fieldConfig) {
100
+ return { fieldConfig, isRelationship: fieldConfig.type === 'relationship' }
101
+ }
102
+
103
+ if (key.endsWith('Id')) {
104
+ const baseField = fields[key.slice(0, -2)]
105
+ if (baseField && baseField.type === 'relationship' && !baseField.many) {
106
+ return { fieldConfig: baseField, isRelationship: false }
107
+ }
108
+ }
109
+
110
+ for (const [ownerName, owner] of Object.entries(fields)) {
111
+ if (owner && typeof owner.getColumnNames === 'function') {
112
+ const columns: string[] = owner.getColumnNames(ownerName)
113
+ if (columns.includes(key)) {
114
+ return { fieldConfig: owner, isRelationship: false }
115
+ }
116
+ }
117
+ }
118
+
119
+ return undefined
120
+ }
121
+
122
+ function rejectUndeclaredKey(listName: string, key: string, kind: 'where' | 'orderBy'): never {
123
+ throw new ValidationError([
124
+ `Cannot query "${listName}" — "${key}" is not a field of this list. ` +
125
+ `Undeclared ${kind} keys are rejected (use sudo to bypass).`,
126
+ ])
127
+ }
128
+
129
+ function walkWhere(
130
+ where: unknown,
131
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
132
+ listConfig: ListConfig<any>,
133
+ listName: string,
134
+ config: OpenSaasConfig,
135
+ isSudo: boolean,
136
+ ): void {
137
+ if (where === null || typeof where !== 'object') return
138
+
139
+ if (Array.isArray(where)) {
140
+ for (const entry of where) walkWhere(entry, listConfig, listName, config, isSudo)
141
+ return
142
+ }
143
+
144
+ for (const [key, value] of Object.entries(where as Record<string, unknown>)) {
145
+ if (LOGICAL_OPERATORS.has(key)) {
146
+ walkWhere(value, listConfig, listName, config, isSudo)
147
+ continue
148
+ }
149
+
150
+ const resolved = resolveQueryField(key, listConfig.fields)
151
+ if (!resolved) {
152
+ if (isSudo) continue
153
+ rejectUndeclaredKey(listName, key, 'where')
154
+ }
155
+
156
+ // Scalar field filters use Prisma's own operator vocabulary (`equals`,
157
+ // `contains`, `in`, …) and never nest another field name — trusted as-is,
158
+ // no further walk needed. Only a relationship field's filter nests a
159
+ // WHERE clause for another list.
160
+ if (
161
+ resolved.isRelationship &&
162
+ value !== null &&
163
+ typeof value === 'object' &&
164
+ !Array.isArray(value)
165
+ ) {
166
+ const related = getRelatedListConfig(resolved.fieldConfig.ref, config)
167
+ if (!related) continue
168
+
169
+ const relationEntries = Object.entries(value as Record<string, unknown>)
170
+ const hasQuantifier = relationEntries.some(([k]) => RELATION_QUANTIFIERS.has(k))
171
+
172
+ if (hasQuantifier) {
173
+ // Wrapped form: `{ author: { is: {...} } }` / `{ posts: { some: {...} } }`.
174
+ // Only the quantifier's own value is a nested WHERE clause for the
175
+ // related list.
176
+ for (const [quantifier, quantifierValue] of relationEntries) {
177
+ if (RELATION_QUANTIFIERS.has(quantifier)) {
178
+ walkWhere(quantifierValue, related.listConfig, related.listName, config, isSudo)
179
+ }
180
+ }
181
+ } else {
182
+ // Direct-nesting form: Prisma's documented default for a to-one
183
+ // relation filter nests the related list's own fields with no `is`
184
+ // wrapper at all (`{ author: { email: { contains: '...' } } }`). The
185
+ // whole value object IS the nested WHERE clause here — walk it
186
+ // directly, or an undeclared key reached exactly this way (one hop
187
+ // through a to-one relation) would pass through unchecked.
188
+ walkWhere(value, related.listConfig, related.listName, config, isSudo)
189
+ }
190
+ }
191
+ }
192
+ }
193
+
194
+ function walkOrderBy(
195
+ orderBy: unknown,
196
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
197
+ listConfig: ListConfig<any>,
198
+ listName: string,
199
+ config: OpenSaasConfig,
200
+ isSudo: boolean,
201
+ ): void {
202
+ if (orderBy === null || typeof orderBy !== 'object') return
203
+
204
+ if (Array.isArray(orderBy)) {
205
+ for (const entry of orderBy) walkOrderBy(entry, listConfig, listName, config, isSudo)
206
+ return
207
+ }
208
+
209
+ for (const [key, value] of Object.entries(orderBy as Record<string, unknown>)) {
210
+ const resolved = resolveQueryField(key, listConfig.fields)
211
+ if (!resolved) {
212
+ if (isSudo) continue
213
+ rejectUndeclaredKey(listName, key, 'orderBy')
214
+ }
215
+
216
+ if (resolved.isRelationship && value !== null && typeof value === 'object') {
217
+ // `{ relation: { _count: 'asc' } }` orders by an aggregate — no nested
218
+ // field name to resolve. `{ relation: { name: 'asc' } }` orders by a
219
+ // field on a to-one related list — walk it against that list's fields.
220
+ if ('_count' in (value as Record<string, unknown>)) continue
221
+
222
+ const related = getRelatedListConfig(resolved.fieldConfig.ref, config)
223
+ if (related) walkOrderBy(value, related.listConfig, related.listName, config, isSudo)
224
+ }
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Validate a caller-supplied `where`/`orderBy` against the list config,
230
+ * recursing into logical operators (`AND`/`OR`/`NOT`) and relation filters.
231
+ * Throws a `ValidationError` naming the list and the offending key on the
232
+ * first undeclared key found. `isSudo` bypasses the check entirely, matching
233
+ * the write path's `sudo` escape hatch.
234
+ */
235
+ export function validateQueryKeys(args: {
236
+ where?: unknown
237
+ orderBy?: unknown
238
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
239
+ listConfig: ListConfig<any>
240
+ listName: string
241
+ config: OpenSaasConfig
242
+ isSudo: boolean
243
+ }): void {
244
+ const { where, orderBy, listConfig, listName, config, isSudo } = args
245
+ if (where !== undefined) walkWhere(where, listConfig, listName, config, isSudo)
246
+ if (orderBy !== undefined) walkOrderBy(orderBy, listConfig, listName, config, isSudo)
247
+ }
248
+
249
+ /**
250
+ * #915 — the predicate-time counterpart to `checkFieldAccess`'s post-query
251
+ * check: reject a `where`/`orderBy` key that names a field the session cannot
252
+ * read, BEFORE the query runs. See this module's top doc comment for how this
253
+ * relates to `validateQueryKeys` (#912) and `isFieldReadableForPredicate`'s
254
+ * doc (in `field-access.ts`) for how a row-dependent `read` rule is handled.
255
+ *
256
+ * A key `resolveQueryField` cannot resolve is skipped here — #912 has already
257
+ * rejected it (or, under `sudo`, deliberately let it through) by the time
258
+ * this runs. A system field (`id`/`createdAt`/`updatedAt`) resolves with no
259
+ * `fieldConfig` and carries no field-level access control, so it is always
260
+ * readable and skipped too.
261
+ */
262
+ async function checkKeyReadableOrThrow(
263
+ key: string,
264
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
265
+ listConfig: ListConfig<any>,
266
+ listName: string,
267
+ args: { session: Session | null; context: AccessContext & { _isSudo?: boolean } },
268
+ kind: 'where' | 'orderBy',
269
+ ): Promise<void> {
270
+ const resolved = resolveQueryField(key, listConfig.fields)
271
+ if (!resolved || resolved.fieldConfig === undefined) return
272
+
273
+ const readable = await isFieldReadableForPredicate(resolved.fieldConfig.access, args)
274
+ if (!readable) {
275
+ throw new ValidationError([
276
+ `Cannot query "${listName}" — "${key}" is denied by field-level read access. ` +
277
+ `A field the session cannot read cannot be named in a ${kind} (use sudo to bypass).`,
278
+ ])
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Check field-level `read` access for every key at ONE level of a `where`
284
+ * clause, recursing only into logical operators (`AND`/`OR`/`NOT`) — never
285
+ * into a relationship field's own nested value. Exported for `access-filter.ts`'s
286
+ * #916 reuse (see module doc comment above) — called once per hop against the
287
+ * RELATED list's own config.
288
+ */
289
+ export async function walkWhereReadAccess(
290
+ where: unknown,
291
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
292
+ listConfig: ListConfig<any>,
293
+ listName: string,
294
+ args: { session: Session | null; context: AccessContext & { _isSudo?: boolean } },
295
+ ): Promise<void> {
296
+ if (where === null || typeof where !== 'object') return
297
+
298
+ if (Array.isArray(where)) {
299
+ for (const entry of where) await walkWhereReadAccess(entry, listConfig, listName, args)
300
+ return
301
+ }
302
+
303
+ for (const [key, value] of Object.entries(where as Record<string, unknown>)) {
304
+ if (LOGICAL_OPERATORS.has(key)) {
305
+ await walkWhereReadAccess(value, listConfig, listName, args)
306
+ continue
307
+ }
308
+ // Deliberately does not recurse into a relationship field's own nested
309
+ // value: it checks whether THIS list's relationship field may be named
310
+ // (its own `read` access) — a field on the RELATED list nested inside it
311
+ // is checked by the CALLER re-invoking this function against the related
312
+ // list's config (see #916 in the module doc comment above).
313
+ await checkKeyReadableOrThrow(key, listConfig, listName, args, 'where')
314
+ }
315
+ }
316
+
317
+ async function walkOrderByReadAccess(
318
+ orderBy: unknown,
319
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
320
+ listConfig: ListConfig<any>,
321
+ listName: string,
322
+ args: { session: Session | null; context: AccessContext & { _isSudo?: boolean } },
323
+ ): Promise<void> {
324
+ if (orderBy === null || typeof orderBy !== 'object') return
325
+
326
+ if (Array.isArray(orderBy)) {
327
+ for (const entry of orderBy) await walkOrderByReadAccess(entry, listConfig, listName, args)
328
+ return
329
+ }
330
+
331
+ for (const key of Object.keys(orderBy as Record<string, unknown>)) {
332
+ await checkKeyReadableOrThrow(key, listConfig, listName, args, 'orderBy')
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Validate a caller-supplied `where`/`orderBy` against field-level `read`
338
+ * access, recursing into logical operators (`AND`/`OR`/`NOT`) the same way
339
+ * `validateQueryKeys` does. Throws a `ValidationError` naming the list and
340
+ * the offending key on the first read-denied field found. `isSudo` bypasses
341
+ * the check entirely, matching `validateQueryKeys` and the write path's
342
+ * `sudo` escape hatch.
343
+ */
344
+ export async function validateQueryFieldReadAccess(args: {
345
+ where?: unknown
346
+ orderBy?: unknown
347
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
348
+ listConfig: ListConfig<any>
349
+ listName: string
350
+ session: Session | null
351
+ context: AccessContext & { _isSudo?: boolean }
352
+ isSudo: boolean
353
+ }): Promise<void> {
354
+ const { where, orderBy, listConfig, listName, session, context, isSudo } = args
355
+ if (isSudo) return
356
+ const evalArgs = { session, context }
357
+ if (where !== undefined) await walkWhereReadAccess(where, listConfig, listName, evalArgs)
358
+ if (orderBy !== undefined) await walkOrderByReadAccess(orderBy, listConfig, listName, evalArgs)
359
+ }
@@ -99,7 +99,6 @@ export async function buildRelationshipCountSelect(
99
99
  return Object.keys(select).length > 0 ? select : undefined
100
100
  }
101
101
 
102
- /** Read a to-many relationship's count off a fetched row's `_count` payload. */
103
102
  function readRelationshipCount(row: Record<string, unknown>, fieldName: string): number {
104
103
  const counts = row._count
105
104
  if (counts && typeof counts === 'object') {
@@ -109,7 +108,6 @@ function readRelationshipCount(row: Record<string, unknown>, fieldName: string):
109
108
  return 0
110
109
  }
111
110
 
112
- /** Whether a count satisfies a Filter operator/value comparison. */
113
111
  function matchesCount(count: number, operator: FilterOperator, value: number): boolean {
114
112
  switch (operator) {
115
113
  case 'eq':
@@ -140,7 +138,6 @@ function asCountDelegate(value: unknown): CountFindManyDelegate | null {
140
138
  return null
141
139
  }
142
140
 
143
- /** Extract the `RelationshipCountFilterMarker` from a condition value, if present. */
144
141
  function readCountMarker(value: unknown): RelationshipCountFilterMarker | null {
145
142
  if (!value || typeof value !== 'object') return null
146
143
  const marker = (value as Record<string, unknown>)[RELATIONSHIP_COUNT_FILTER_KEY]
@@ -291,13 +288,8 @@ export async function resolveRelationshipCountFilters(
291
288
  args,
292
289
  config,
293
290
  )
294
- // Preserve any sibling conditions co-present on this member rather than
295
- // replacing it wholesale with the resolved `{ id: { in } }`. The filter engine
296
- // currently guarantees each AND-member (and the no-AND single object) carries
297
- // exactly one field condition, so `siblings` is empty today and this equals the
298
- // previous wholesale replacement — but if a future engine change ever merged
299
- // multiple conditions into one member, spreading keeps the marker's siblings
300
- // from being silently dropped.
291
+ // Preserve any sibling conditions rather than replacing the member
292
+ // wholesale see `mergeResolvedMember` above for why.
301
293
  const siblings: Record<string, unknown> = { ...member }
302
294
  delete siblings[found.field]
303
295
  resolvedMembers.push(mergeResolvedMember(siblings, resolved))