@opensaas/stack-core 0.37.0 → 0.39.0

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 (226) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +191 -0
  3. package/CLAUDE.md +42 -3
  4. package/dist/access/access-filter.d.ts +70 -113
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +183 -210
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +148 -188
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/declared-dependencies.d.ts +66 -26
  11. package/dist/access/declared-dependencies.d.ts.map +1 -1
  12. package/dist/access/declared-dependencies.js +62 -17
  13. package/dist/access/declared-dependencies.js.map +1 -1
  14. package/dist/access/declared-dependencies.test.d.ts +2 -0
  15. package/dist/access/declared-dependencies.test.d.ts.map +1 -0
  16. package/dist/access/declared-dependencies.test.js +226 -0
  17. package/dist/access/declared-dependencies.test.js.map +1 -0
  18. package/dist/access/depth-limits.d.ts +8 -7
  19. package/dist/access/depth-limits.d.ts.map +1 -1
  20. package/dist/access/depth-limits.js +8 -7
  21. package/dist/access/depth-limits.js.map +1 -1
  22. package/dist/access/engine.d.ts +0 -20
  23. package/dist/access/engine.d.ts.map +1 -1
  24. package/dist/access/engine.js +0 -25
  25. package/dist/access/engine.js.map +1 -1
  26. package/dist/access/errors.d.ts +61 -8
  27. package/dist/access/errors.d.ts.map +1 -1
  28. package/dist/access/errors.js +91 -12
  29. package/dist/access/errors.js.map +1 -1
  30. package/dist/access/field-access.d.ts +39 -1
  31. package/dist/access/field-access.d.ts.map +1 -1
  32. package/dist/access/field-access.js +108 -42
  33. package/dist/access/field-access.js.map +1 -1
  34. package/dist/access/field-access.test.js +229 -2
  35. package/dist/access/field-access.test.js.map +1 -1
  36. package/dist/access/field-transforms.d.ts +0 -27
  37. package/dist/access/field-transforms.d.ts.map +1 -1
  38. package/dist/access/field-visibility.d.ts +2 -5
  39. package/dist/access/field-visibility.d.ts.map +1 -1
  40. package/dist/access/field-visibility.js +89 -41
  41. package/dist/access/field-visibility.js.map +1 -1
  42. package/dist/access/index.d.ts +6 -3
  43. package/dist/access/index.d.ts.map +1 -1
  44. package/dist/access/index.js +12 -2
  45. package/dist/access/index.js.map +1 -1
  46. package/dist/access/query-validation.d.ts +121 -0
  47. package/dist/access/query-validation.d.ts.map +1 -0
  48. package/dist/access/query-validation.js +292 -0
  49. package/dist/access/query-validation.js.map +1 -0
  50. package/dist/access/relationship-count.d.ts +1 -1
  51. package/dist/access/relationship-count.d.ts.map +1 -1
  52. package/dist/access/relationship-count.js +2 -10
  53. package/dist/access/relationship-count.js.map +1 -1
  54. package/dist/access/relationship-label-filter.d.ts +23 -23
  55. package/dist/access/relationship-label-filter.d.ts.map +1 -1
  56. package/dist/access/relationship-label-filter.js +5 -76
  57. package/dist/access/relationship-label-filter.js.map +1 -1
  58. package/dist/access/relationship-label-filter.test.js +19 -56
  59. package/dist/access/relationship-label-filter.test.js.map +1 -1
  60. package/dist/access/transaction-registry.d.ts +45 -0
  61. package/dist/access/transaction-registry.d.ts.map +1 -0
  62. package/dist/access/transaction-registry.js +37 -0
  63. package/dist/access/transaction-registry.js.map +1 -0
  64. package/dist/access/types.d.ts +55 -59
  65. package/dist/access/types.d.ts.map +1 -1
  66. package/dist/config/index.d.ts +9 -7
  67. package/dist/config/index.d.ts.map +1 -1
  68. package/dist/config/index.js +10 -18
  69. package/dist/config/index.js.map +1 -1
  70. package/dist/config/label.d.ts +6 -13
  71. package/dist/config/label.d.ts.map +1 -1
  72. package/dist/config/label.js +6 -13
  73. package/dist/config/label.js.map +1 -1
  74. package/dist/config/nav-count.d.ts +11 -19
  75. package/dist/config/nav-count.d.ts.map +1 -1
  76. package/dist/config/nav-count.js +11 -19
  77. package/dist/config/nav-count.js.map +1 -1
  78. package/dist/config/plugin-engine.d.ts +0 -10
  79. package/dist/config/plugin-engine.d.ts.map +1 -1
  80. package/dist/config/plugin-engine.js +1 -37
  81. package/dist/config/plugin-engine.js.map +1 -1
  82. package/dist/config/types.d.ts +94 -109
  83. package/dist/config/types.d.ts.map +1 -1
  84. package/dist/context/apply-defaults.d.ts +8 -31
  85. package/dist/context/apply-defaults.d.ts.map +1 -1
  86. package/dist/context/apply-defaults.js +12 -41
  87. package/dist/context/apply-defaults.js.map +1 -1
  88. package/dist/context/hook-pipeline.d.ts +4 -29
  89. package/dist/context/hook-pipeline.d.ts.map +1 -1
  90. package/dist/context/hook-pipeline.js +8 -41
  91. package/dist/context/hook-pipeline.js.map +1 -1
  92. package/dist/context/index.d.ts +4 -15
  93. package/dist/context/index.d.ts.map +1 -1
  94. package/dist/context/index.js +223 -215
  95. package/dist/context/index.js.map +1 -1
  96. package/dist/context/nested-operations.d.ts +7 -26
  97. package/dist/context/nested-operations.d.ts.map +1 -1
  98. package/dist/context/nested-operations.js +73 -171
  99. package/dist/context/nested-operations.js.map +1 -1
  100. package/dist/context/transaction-boundary.d.ts +36 -47
  101. package/dist/context/transaction-boundary.d.ts.map +1 -1
  102. package/dist/context/transaction-boundary.js +92 -79
  103. package/dist/context/transaction-boundary.js.map +1 -1
  104. package/dist/context/write-pipeline.d.ts +25 -64
  105. package/dist/context/write-pipeline.d.ts.map +1 -1
  106. package/dist/context/write-pipeline.js +81 -111
  107. package/dist/context/write-pipeline.js.map +1 -1
  108. package/dist/fields/format-prisma-default.d.ts.map +1 -1
  109. package/dist/fields/format-prisma-default.js +4 -7
  110. package/dist/fields/format-prisma-default.js.map +1 -1
  111. package/dist/fields/index.d.ts +17 -2
  112. package/dist/fields/index.d.ts.map +1 -1
  113. package/dist/fields/index.js +136 -147
  114. package/dist/fields/index.js.map +1 -1
  115. package/dist/filter/collect.d.ts +26 -3
  116. package/dist/filter/collect.d.ts.map +1 -1
  117. package/dist/filter/collect.js +30 -12
  118. package/dist/filter/collect.js.map +1 -1
  119. package/dist/filter/filter.test.js +98 -15
  120. package/dist/filter/filter.test.js.map +1 -1
  121. package/dist/filter/index.d.ts +1 -0
  122. package/dist/filter/index.d.ts.map +1 -1
  123. package/dist/filter/index.js.map +1 -1
  124. package/dist/filter/map.d.ts.map +1 -1
  125. package/dist/filter/map.js +0 -5
  126. package/dist/filter/map.js.map +1 -1
  127. package/dist/filter/parse.d.ts.map +1 -1
  128. package/dist/filter/parse.js +2 -9
  129. package/dist/filter/parse.js.map +1 -1
  130. package/dist/filter/serialize.d.ts.map +1 -1
  131. package/dist/filter/serialize.js +2 -4
  132. package/dist/filter/serialize.js.map +1 -1
  133. package/dist/hooks/index.d.ts +4 -42
  134. package/dist/hooks/index.d.ts.map +1 -1
  135. package/dist/hooks/index.js +5 -59
  136. package/dist/hooks/index.js.map +1 -1
  137. package/dist/index.d.ts +3 -1
  138. package/dist/index.d.ts.map +1 -1
  139. package/dist/index.js +20 -4
  140. package/dist/index.js.map +1 -1
  141. package/dist/internal.d.ts +1 -0
  142. package/dist/internal.d.ts.map +1 -1
  143. package/dist/internal.js +4 -0
  144. package/dist/internal.js.map +1 -1
  145. package/dist/lib/case-utils.d.ts +0 -32
  146. package/dist/lib/case-utils.d.ts.map +1 -1
  147. package/dist/lib/case-utils.js +0 -32
  148. package/dist/lib/case-utils.js.map +1 -1
  149. package/dist/mcp/handler.d.ts +0 -4
  150. package/dist/mcp/handler.d.ts.map +1 -1
  151. package/dist/mcp/handler.js +12 -69
  152. package/dist/mcp/handler.js.map +1 -1
  153. package/dist/query/index.d.ts +55 -227
  154. package/dist/query/index.d.ts.map +1 -1
  155. package/dist/query/index.js +41 -117
  156. package/dist/query/index.js.map +1 -1
  157. package/dist/query/relationship-options.d.ts +1 -1
  158. package/dist/query/relationship-options.js +1 -1
  159. package/dist/utils/password.d.ts +0 -68
  160. package/dist/utils/password.d.ts.map +1 -1
  161. package/dist/utils/password.js +7 -76
  162. package/dist/utils/password.js.map +1 -1
  163. package/dist/validation/field-config.d.ts.map +1 -1
  164. package/dist/validation/field-config.js +0 -6
  165. package/dist/validation/field-config.js.map +1 -1
  166. package/dist/validation/schema.d.ts +0 -7
  167. package/dist/validation/schema.d.ts.map +1 -1
  168. package/dist/validation/schema.js +0 -10
  169. package/dist/validation/schema.js.map +1 -1
  170. package/package.json +1 -1
  171. package/src/access/access-filter.test.ts +205 -275
  172. package/src/access/access-filter.ts +236 -272
  173. package/src/access/declared-dependencies.test.ts +277 -0
  174. package/src/access/declared-dependencies.ts +117 -37
  175. package/src/access/depth-limits.ts +8 -7
  176. package/src/access/engine.ts +0 -25
  177. package/src/access/errors.ts +97 -12
  178. package/src/access/field-access.test.ts +312 -2
  179. package/src/access/field-access.ts +118 -42
  180. package/src/access/field-transforms.ts +8 -39
  181. package/src/access/field-visibility.ts +99 -45
  182. package/src/access/index.ts +17 -5
  183. package/src/access/query-validation.ts +359 -0
  184. package/src/access/relationship-count.ts +3 -11
  185. package/src/access/relationship-label-filter.test.ts +19 -68
  186. package/src/access/relationship-label-filter.ts +27 -104
  187. package/src/access/transaction-registry.ts +58 -0
  188. package/src/access/types.ts +83 -86
  189. package/src/config/index.ts +10 -22
  190. package/src/config/label.ts +6 -13
  191. package/src/config/nav-count.ts +11 -19
  192. package/src/config/plugin-engine.ts +1 -37
  193. package/src/config/types.ts +95 -110
  194. package/src/context/apply-defaults.ts +12 -44
  195. package/src/context/hook-pipeline.ts +12 -67
  196. package/src/context/index.ts +311 -276
  197. package/src/context/nested-operations.ts +94 -226
  198. package/src/context/transaction-boundary.ts +113 -109
  199. package/src/context/write-pipeline.ts +99 -155
  200. package/src/fields/format-prisma-default.ts +4 -7
  201. package/src/fields/index.ts +155 -147
  202. package/src/filter/collect.ts +48 -12
  203. package/src/filter/filter.test.ts +153 -15
  204. package/src/filter/index.ts +1 -0
  205. package/src/filter/map.ts +0 -5
  206. package/src/filter/parse.ts +2 -9
  207. package/src/filter/serialize.ts +2 -4
  208. package/src/hooks/index.ts +5 -59
  209. package/src/index.ts +23 -4
  210. package/src/internal.ts +5 -0
  211. package/src/lib/case-utils.ts +0 -32
  212. package/src/mcp/handler.ts +13 -71
  213. package/src/query/index.ts +82 -235
  214. package/src/query/relationship-options.ts +1 -1
  215. package/src/utils/password.ts +7 -76
  216. package/src/validation/field-config.ts +0 -6
  217. package/src/validation/schema.ts +0 -10
  218. package/tests/access-relationships.test.ts +18 -16
  219. package/tests/computed-field-selective-evaluation.test.ts +418 -0
  220. package/tests/context.test.ts +727 -1
  221. package/tests/field-types.test.ts +222 -0
  222. package/tests/mcp-handler.test.ts +34 -0
  223. package/tests/needs-declared-dependencies.test.ts +7 -4
  224. package/tests/resolve-chain.test.ts +11 -11
  225. package/tests/transaction-boundary-outermost.test.ts +530 -0
  226. package/tsconfig.tsbuildinfo +1 -1
@@ -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
+ }
@@ -15,7 +15,7 @@ import { getDbKey } from '../lib/case-utils.js'
15
15
  * per-row query is issued and the count can never include rows the session
16
16
  * cannot read. This module is the single place the related list's
17
17
  * operation-level `query` access is folded into that `_count`, mirroring how
18
- * `buildIncludeWithAccessControl` folds it into relation includes.
18
+ * `buildAccessScopedInclude` folds it into relation includes.
19
19
  *
20
20
  * It also resolves the count Filter spec's markers: Prisma cannot compare a
21
21
  * relation count in a `where`, so a to-many relationship's Filter spec emits a
@@ -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))
@@ -9,15 +9,16 @@ import {
9
9
  } from './relationship-label-filter.js'
10
10
 
11
11
  /**
12
- * Access-scoped to-one relationship label filters (issue #749). Verifies that
13
- * `author:Ada` `{ author: { is: { name: { contains: 'Ada' } } } }` gets the
14
- * related list's `query` access folded into the nested `is` clause, so a
15
- * session can never distinguish parent rows by a related field it cannot
16
- * itself read.
12
+ * `resolveRelationshipLabelFilters` used to fold the related list's `query`
13
+ * access into a to-one relationship label filter's nested `is` clause
14
+ * (issue #749). Since #916, the engine itself scopes every relation filter in
15
+ * `where` (`buildAccessScopedWhere`), including this exact `{ is: {...} } }`
16
+ * shape, so this resolver's own fold is redundant and has been removed — it
17
+ * is now a pass-through, kept exported only for API compatibility. These
18
+ * tests pin that pass-through behavior; `access-filter.test.ts` and
19
+ * `context.test.ts` cover the actual access-scoping this resolver used to do.
17
20
  */
18
21
 
19
- // Post is fully open; User (the `author` relation target) is scoped so only
20
- // active users are queryable; Widget ships fully closed (no access block).
21
22
  function makeConfig(): OpenSaasConfig {
22
23
  return {
23
24
  db: { provider: 'sqlite', prismaClientConstructor: () => null as never },
@@ -63,8 +64,8 @@ describe('isToOneRelationshipField', () => {
63
64
  })
64
65
  })
65
66
 
66
- describe('resolveRelationshipLabelFilters', () => {
67
- it('returns the where unchanged when there are no label-filter members', async () => {
67
+ describe('resolveRelationshipLabelFilters (pass-through since #916)', () => {
68
+ it('returns a plain where unchanged', async () => {
68
69
  const config = makeConfig()
69
70
  const where = { title: { contains: 'hello' } }
70
71
  const resolved = await resolveRelationshipLabelFilters(
@@ -76,7 +77,7 @@ describe('resolveRelationshipLabelFilters', () => {
76
77
  expect(resolved).toBe(where)
77
78
  })
78
79
 
79
- it("ANDs the related list's access filter into the nested `is` clause", async () => {
80
+ it('returns a to-one label-filter `is` clause unchanged the engine scopes it now', async () => {
80
81
  const config = makeConfig()
81
82
  const where = { author: { is: { name: { contains: 'Ada' } } } }
82
83
  const resolved = await resolveRelationshipLabelFilters(
@@ -85,31 +86,10 @@ describe('resolveRelationshipLabelFilters', () => {
85
86
  { session: null, context: makeContext() },
86
87
  config,
87
88
  )
88
- expect(resolved).toEqual({
89
- author: { is: { AND: [{ active: { equals: 1 } }, { name: { contains: 'Ada' } }] } },
90
- })
91
- })
92
-
93
- it('leaves the member unchanged when the related list is fully readable', async () => {
94
- const config: OpenSaasConfig = {
95
- db: { provider: 'sqlite', prismaClientConstructor: () => null as never },
96
- lists: {
97
- Tag: list({ fields: { name: text() }, access: { operation: { query: () => true } } }),
98
- Post: list({ fields: { title: text(), tag: relationship({ ref: 'Tag' }) } }),
99
- },
100
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
101
- } as any
102
- const where = { tag: { is: { name: { contains: 'news' } } } }
103
- const resolved = await resolveRelationshipLabelFilters(
104
- where,
105
- config.lists.Post,
106
- { session: null, context: makeContext() },
107
- config,
108
- )
109
- expect(resolved).toEqual(where)
89
+ expect(resolved).toBe(where)
110
90
  })
111
91
 
112
- it('resolves a fully denied related list to a never-matching member (no leak)', async () => {
92
+ it('returns a label filter against a fully denied related list unchanged too', async () => {
113
93
  const config = makeConfig()
114
94
  const where = { widget: { is: { name: { contains: 'Gadget' } } } }
115
95
  const resolved = await resolveRelationshipLabelFilters(
@@ -118,26 +98,10 @@ describe('resolveRelationshipLabelFilters', () => {
118
98
  { session: null, context: makeContext() },
119
99
  config,
120
100
  )
121
- expect(resolved).toEqual({ id: { in: [] } })
122
- })
123
-
124
- it('preserves sibling conditions, ANDing the resolved id constraint for a denied member', async () => {
125
- const config = makeConfig()
126
- const where = {
127
- AND: [{ title: { contains: 'hello' } }, { widget: { is: { name: { contains: 'Gadget' } } } }],
128
- }
129
- const resolved = await resolveRelationshipLabelFilters(
130
- where,
131
- config.lists.Post,
132
- { session: null, context: makeContext() },
133
- config,
134
- )
135
- expect(resolved).toEqual({
136
- AND: [{ title: { contains: 'hello' } }, { id: { in: [] } }],
137
- })
101
+ expect(resolved).toBe(where)
138
102
  })
139
103
 
140
- it('preserves sibling conditions alongside the access-scoped nested `is` clause', async () => {
104
+ it('returns a where with nested AND/label-filter members unchanged', async () => {
141
105
  const config = makeConfig()
142
106
  const where = {
143
107
  AND: [{ title: { contains: 'hello' } }, { author: { is: { name: { contains: 'Ada' } } } }],
@@ -148,30 +112,17 @@ describe('resolveRelationshipLabelFilters', () => {
148
112
  { session: null, context: makeContext() },
149
113
  config,
150
114
  )
151
- expect(resolved).toEqual({
152
- AND: [
153
- { title: { contains: 'hello' } },
154
- { author: { is: { AND: [{ active: { equals: 1 } }, { name: { contains: 'Ada' } }] } } },
155
- ],
156
- })
115
+ expect(resolved).toBe(where)
157
116
  })
158
117
 
159
- it('composes with a to-many count marker member left untouched by this resolver', async () => {
118
+ it('returns undefined unchanged when there is no where at all', async () => {
160
119
  const config = makeConfig()
161
- const where = {
162
- AND: [{ author: { is: { name: { contains: 'Ada' } } } }, { views: { gt: 10 } }],
163
- }
164
120
  const resolved = await resolveRelationshipLabelFilters(
165
- where,
121
+ undefined,
166
122
  config.lists.Post,
167
123
  { session: null, context: makeContext() },
168
124
  config,
169
125
  )
170
- expect(resolved).toEqual({
171
- AND: [
172
- { author: { is: { AND: [{ active: { equals: 1 } }, { name: { contains: 'Ada' } }] } } },
173
- { views: { gt: 10 } },
174
- ],
175
- })
126
+ expect(resolved).toBeUndefined()
176
127
  })
177
128
  })