@opensaas/stack-core 0.38.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 (204) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +123 -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
@@ -1,6 +1,12 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { filterWritableFields } from './field-access.js'
1
+ import { describe, it, expect, expectTypeOf } from 'vitest'
2
+ import {
3
+ checkFieldAccess,
4
+ filterWritableFields,
5
+ isFieldReadableForPredicate,
6
+ } from './field-access.js'
7
+ import { InvalidFieldAccessResultError } from './errors.js'
3
8
  import { ValidationError } from '../hooks/index.js'
9
+ import type { FieldAccess, FieldAccessControl } from './types.js'
4
10
 
5
11
  // A non-sudo access context. The cast is localized to test setup (mirrors the
6
12
  // existing sudo-context casts in this file): the runtime AccessContext carries
@@ -21,6 +27,283 @@ function sudoContext() {
21
27
  } as any
22
28
  }
23
29
 
30
+ // ── #914: `FieldAccess['read']`'s `item` must type as present, matching what
31
+ // Field Visibility (field-visibility.ts) actually passes ──
32
+
33
+ describe("FieldAccess['read'] item typing (issue #914)", () => {
34
+ type Item = { ownerId: string }
35
+
36
+ it('types item as present for a read rule that reads a property off it — no cast, no `any`', () => {
37
+ const fieldAccess: FieldAccess<Item> = {
38
+ read: ({ session, item }) => item.ownerId === session?.userId,
39
+ }
40
+ expect(typeof fieldAccess.read).toBe('function')
41
+ })
42
+
43
+ it('still compiles a read rule that ignores item entirely', () => {
44
+ const fieldAccess: FieldAccess<Item> = {
45
+ read: () => true,
46
+ }
47
+ expect(typeof fieldAccess.read).toBe('function')
48
+ })
49
+
50
+ it('leaves the create branch unchanged — item is still absent, there genuinely is no row yet', () => {
51
+ // Pinned against `FieldAccessControl`'s discriminated union directly
52
+ // (rather than `FieldAccess['create']`'s destructured callback), because
53
+ // `create`/`update` — unlike `read` — are untouched by this fix and keep
54
+ // accepting the full `FieldAccessControl` union in `FieldAccess`.
55
+ type CreateArgs = Extract<Parameters<FieldAccessControl<Item>>[0], { operation: 'create' }>
56
+ expectTypeOf<CreateArgs['item']>().toEqualTypeOf<undefined>()
57
+ })
58
+
59
+ // Type-level pin: `resolveReadableFieldValue` (field-visibility.ts) is the
60
+ // sole caller of `checkFieldAccess` for `operation: 'read'`, and always
61
+ // supplies `item: accessItem` — a full row, never `undefined`. This
62
+ // assertion has no runtime effect (`expectTypeOf` is a no-op outside
63
+ // `vitest --typecheck`); its value is that `pnpm build`/`tsc` fails on this
64
+ // file the moment `FieldAccess['read']`'s `item` type drifts back to
65
+ // optional/absent, so the declared type and the call site cannot silently
66
+ // diverge again.
67
+ it("pins FieldAccess['read']'s item type against the field-visibility.ts call site", () => {
68
+ type ReadArgs = Parameters<NonNullable<FieldAccess<Item>['read']>>[0]
69
+ expectTypeOf<ReadArgs['item']>().toEqualTypeOf<Item>()
70
+ expectTypeOf<ReadArgs['operation']>().toEqualTypeOf<'read'>()
71
+ })
72
+ })
73
+
74
+ // ── #913: a field rule returning a filter must not be granted blanket access ──
75
+
76
+ describe('checkFieldAccess', () => {
77
+ it('allows when the rule returns true', async () => {
78
+ const allowed = await checkFieldAccess({ read: () => true }, 'read', {
79
+ session: null,
80
+ item: { ownerId: 'someone-else' },
81
+ context: nonSudoContext(),
82
+ })
83
+ expect(allowed).toBe(true)
84
+ })
85
+
86
+ it('denies when the rule returns false', async () => {
87
+ const allowed = await checkFieldAccess({ read: () => false }, 'read', {
88
+ session: null,
89
+ item: { ownerId: 'someone-else' },
90
+ context: nonSudoContext(),
91
+ })
92
+ expect(allowed).toBe(false)
93
+ })
94
+
95
+ it('allows when no field access is configured', async () => {
96
+ const allowed = await checkFieldAccess(undefined, 'read', {
97
+ session: null,
98
+ context: nonSudoContext(),
99
+ })
100
+ expect(allowed).toBe(true)
101
+ })
102
+
103
+ it('allows when no rule is configured for the operation', async () => {
104
+ const allowed = await checkFieldAccess({ update: () => false }, 'read', {
105
+ session: null,
106
+ context: nonSudoContext(),
107
+ })
108
+ expect(allowed).toBe(true)
109
+ })
110
+
111
+ it('throws InvalidFieldAccessResultError, not allow, when a read rule returns a filter', async () => {
112
+ // The exact reproduction from the issue: a rule written to scope a field
113
+ // by row, which the previous fail-open default granted full access to.
114
+ const fieldAccess = {
115
+ read: () => ({ ownerId: { equals: 'someone-else' } }),
116
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
117
+ } as any
118
+
119
+ await expect(
120
+ checkFieldAccess(fieldAccess, 'read', {
121
+ session: null,
122
+ item: { ownerId: 'the-owner' },
123
+ context: nonSudoContext(),
124
+ }),
125
+ ).rejects.toThrow(InvalidFieldAccessResultError)
126
+ })
127
+
128
+ it('throws for a filter-returning rule on create, where there is no item to test it against', async () => {
129
+ const fieldAccess = {
130
+ create: () => ({ ownerId: { equals: 'someone-else' } }),
131
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
132
+ } as any
133
+
134
+ await expect(
135
+ checkFieldAccess(fieldAccess, 'create', {
136
+ session: null,
137
+ context: nonSudoContext(),
138
+ inputData: { ownerId: 'the-owner' },
139
+ }),
140
+ ).rejects.toThrow(InvalidFieldAccessResultError)
141
+ })
142
+
143
+ it('throws for a filter-returning rule on update', async () => {
144
+ const fieldAccess = {
145
+ update: () => ({ ownerId: { equals: 'someone-else' } }),
146
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
147
+ } as any
148
+
149
+ await expect(
150
+ checkFieldAccess(fieldAccess, 'update', {
151
+ session: null,
152
+ item: { ownerId: 'the-owner' },
153
+ context: nonSudoContext(),
154
+ inputData: { ownerId: 'someone-else' },
155
+ }),
156
+ ).rejects.toThrow(InvalidFieldAccessResultError)
157
+ })
158
+
159
+ it('throws for a non-boolean, non-filter result too (e.g. undefined)', async () => {
160
+ const fieldAccess = {
161
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
162
+ read: () => undefined as any,
163
+ }
164
+
165
+ await expect(
166
+ checkFieldAccess(fieldAccess, 'read', {
167
+ session: null,
168
+ item: {},
169
+ context: nonSudoContext(),
170
+ }),
171
+ ).rejects.toThrow(InvalidFieldAccessResultError)
172
+ })
173
+
174
+ it('throws with a descriptive message for null, and for other primitive results', async () => {
175
+ const nullAccess = {
176
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
177
+ read: () => null as any,
178
+ }
179
+ await expect(
180
+ checkFieldAccess(nullAccess, 'read', {
181
+ session: null,
182
+ item: {},
183
+ context: nonSudoContext(),
184
+ }),
185
+ ).rejects.toThrow(/returned null, not a boolean/)
186
+
187
+ const numberAccess = {
188
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
189
+ read: () => 42 as any,
190
+ }
191
+ await expect(
192
+ checkFieldAccess(numberAccess, 'read', {
193
+ session: null,
194
+ item: {},
195
+ context: nonSudoContext(),
196
+ }),
197
+ ).rejects.toThrow(/returned a number, not a boolean/)
198
+ })
199
+
200
+ it('sudo bypasses the rule entirely, so a filter-returning rule never reaches the throw', async () => {
201
+ const fieldAccess = {
202
+ read: () => ({ ownerId: { equals: 'someone-else' } }),
203
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
204
+ } as any
205
+
206
+ const allowed = await checkFieldAccess(fieldAccess, 'read', {
207
+ session: null,
208
+ item: { ownerId: 'the-owner' },
209
+ context: sudoContext(),
210
+ })
211
+ expect(allowed).toBe(true)
212
+ })
213
+ })
214
+
215
+ describe('isFieldReadableForPredicate (#915)', () => {
216
+ it('allows when there is no field access configured', async () => {
217
+ const readable = await isFieldReadableForPredicate(undefined, {
218
+ session: null,
219
+ context: nonSudoContext(),
220
+ })
221
+ expect(readable).toBe(true)
222
+ })
223
+
224
+ it('allows when the rule only inspects session (never touches item)', async () => {
225
+ const readable = await isFieldReadableForPredicate(
226
+ { read: ({ session }) => session?.userId === 'admin' },
227
+ { session: { userId: 'admin' }, context: nonSudoContext() },
228
+ )
229
+ expect(readable).toBe(true)
230
+ })
231
+
232
+ it('denies when the rule returns false', async () => {
233
+ const readable = await isFieldReadableForPredicate(
234
+ { read: () => false },
235
+ { session: null, context: nonSudoContext() },
236
+ )
237
+ expect(readable).toBe(false)
238
+ })
239
+
240
+ it('denies a row-dependent rule that dereferences `item` directly', async () => {
241
+ const readable = await isFieldReadableForPredicate(
242
+ { read: ({ item, session }) => item.ownerId === session?.userId },
243
+ { session: { userId: 'user-1' }, context: nonSudoContext() },
244
+ )
245
+ expect(readable).toBe(false)
246
+ })
247
+
248
+ it('denies a row-dependent rule even when it reads `item` via optional chaining', async () => {
249
+ // This is the exact idiom `InvalidFieldAccessResultError`'s own message
250
+ // recommends (`item?.ownerId === session?.userId`) — it must still deny,
251
+ // not silently misevaluate `undefined === session?.userId` against a
252
+ // poisoned `item`.
253
+ const readable = await isFieldReadableForPredicate(
254
+ { read: ({ item, session }) => item?.ownerId === session?.userId },
255
+ { session: null, context: nonSudoContext() },
256
+ )
257
+ expect(readable).toBe(false)
258
+ })
259
+
260
+ it('denies a row-dependent rule that only enumerates `item`s keys', async () => {
261
+ const readable = await isFieldReadableForPredicate(
262
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
263
+ { read: ({ item }) => Object.keys(item as any).length > 0 },
264
+ { session: null, context: nonSudoContext() },
265
+ )
266
+ expect(readable).toBe(false)
267
+ })
268
+
269
+ it('propagates InvalidFieldAccessResultError instead of folding it into a denial', async () => {
270
+ const fieldAccess = {
271
+ read: () => ({ ownerId: { equals: 'someone-else' } }),
272
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
273
+ } as any
274
+
275
+ await expect(
276
+ isFieldReadableForPredicate(fieldAccess, { session: null, context: nonSudoContext() }),
277
+ ).rejects.toThrow(InvalidFieldAccessResultError)
278
+ })
279
+
280
+ it('propagates a genuine error the rule throws for its own reasons, not just non-boolean results', async () => {
281
+ // The rule never touches `item` — it throws for a reason of its own.
282
+ // This must NOT be folded into an ordinary `false` denial (that would
283
+ // mask a real bug in the rule as "field not readable"), matching how the
284
+ // post-query path (`filterReadableFields`) already lets such a throw
285
+ // propagate unchanged.
286
+ const fieldAccess: FieldAccess = {
287
+ read: ({ session }) => {
288
+ if (session === null) throw new Error('unexpected anonymous access')
289
+ return true
290
+ },
291
+ }
292
+
293
+ await expect(
294
+ isFieldReadableForPredicate(fieldAccess, { session: null, context: nonSudoContext() }),
295
+ ).rejects.toThrow('unexpected anonymous access')
296
+ })
297
+
298
+ it('sudo bypasses the rule entirely, so a row-dependent rule never denies', async () => {
299
+ const readable = await isFieldReadableForPredicate(
300
+ { read: ({ item, session }) => item.ownerId === session?.userId },
301
+ { session: null, context: sudoContext() },
302
+ )
303
+ expect(readable).toBe(true)
304
+ })
305
+ })
306
+
24
307
  describe('filterWritableFields', () => {
25
308
  it('should filter out foreign key fields when their corresponding relationship field exists', async () => {
26
309
  // Setup: Define field configs with a relationship field
@@ -361,6 +644,33 @@ describe('filterWritableFields', () => {
361
644
  ).rejects.toThrow(/status/)
362
645
  })
363
646
 
647
+ // ── #913: a field rule returning a filter must not grant blanket write access ──
648
+
649
+ it('throws InvalidFieldAccessResultError, not a blanket write, when field access returns a filter (non-sudo)', async () => {
650
+ const fieldConfigs = {
651
+ title: { type: 'text' },
652
+ status: {
653
+ type: 'text',
654
+ access: {
655
+ // Written as a row-scoping rule; field access does not honour filters.
656
+ update: () => ({ status: { equals: 'draft' } }),
657
+ create: () => ({ status: { equals: 'draft' } }),
658
+ },
659
+ },
660
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
661
+ } as any
662
+ const data = { title: 'Test', status: 'published' }
663
+
664
+ await expect(
665
+ filterWritableFields(data, fieldConfigs, 'update', {
666
+ session: null,
667
+ item: { id: 'post-1', status: 'draft' },
668
+ context: nonSudoContext(),
669
+ inputData: data,
670
+ }),
671
+ ).rejects.toThrow(InvalidFieldAccessResultError)
672
+ })
673
+
364
674
  it('does NOT throw on a would-be-denied field under sudo', async () => {
365
675
  const fieldConfigs = {
366
676
  title: { type: 'text' },
@@ -1,9 +1,48 @@
1
1
  import type { Session, AccessContext } from './types.js'
2
- import type { FieldAccess } from './types.js'
2
+ import type { FieldAccess, FieldAccessControl } from './types.js'
3
3
  // `ValidationError` is referenced only inside function bodies (call-time), never
4
4
  // at module-evaluation time, so the field-access ⇄ hooks import cycle is safe
5
5
  // under ESM live bindings.
6
6
  import { ValidationError } from '../hooks/index.js'
7
+ import { InvalidFieldAccessResultError } from './errors.js'
8
+
9
+ /**
10
+ * Marks a throw caused by touching {@link createPoisonedItem}'s `item`, as
11
+ * opposed to some other error a field rule legitimately raises. Not exported
12
+ * — callers only ever see its effect (a `false` from
13
+ * `isFieldReadableForPredicate`), never the class itself.
14
+ */
15
+ class PredicateTimeItemAccessError extends Error {}
16
+
17
+ /**
18
+ * An `item` that throws {@link PredicateTimeItemAccessError} on ANY attempt to
19
+ * read a property off it — including via optional chaining (`item?.x`),
20
+ * since the Proxy itself is a truthy object and optional chaining still
21
+ * performs the property read once its base is non-nullish. Used by
22
+ * `isFieldReadableForPredicate` to detect a read rule that depends on the
23
+ * fetched row: there is no row yet at predicate-evaluation time (see that
24
+ * function's doc), so any rule that reaches into `item` at all cannot be
25
+ * answered here.
26
+ */
27
+ function createPoisonedItem(): Record<string, unknown> {
28
+ return new Proxy(
29
+ {},
30
+ {
31
+ get(_target, prop) {
32
+ throw new PredicateTimeItemAccessError(String(prop))
33
+ },
34
+ has(_target, prop) {
35
+ throw new PredicateTimeItemAccessError(String(prop))
36
+ },
37
+ ownKeys() {
38
+ throw new PredicateTimeItemAccessError('ownKeys')
39
+ },
40
+ getOwnPropertyDescriptor(_target, prop) {
41
+ throw new PredicateTimeItemAccessError(String(prop))
42
+ },
43
+ },
44
+ ) as Record<string, unknown>
45
+ }
7
46
 
8
47
  /**
9
48
  * Shared field-level access evaluation.
@@ -24,6 +63,10 @@ import { ValidationError } from '../hooks/index.js'
24
63
  * (read/update/delete), and on the `inputData` being written (create/update),
25
64
  * so all of those are accepted. Do not introduce a parallel evaluator with a
26
65
  * narrower signature.
66
+ *
67
+ * Returns a strict boolean, never a filter: a rule that returns anything else
68
+ * throws `InvalidFieldAccessResultError` rather than defaulting to allow (see
69
+ * that error's doc, ADR-0001, and ADR-0030).
27
70
  */
28
71
  export async function checkFieldAccess(
29
72
  fieldAccess: FieldAccess | undefined,
@@ -35,18 +78,25 @@ export async function checkFieldAccess(
35
78
  inputData?: Record<string, unknown>
36
79
  },
37
80
  ): Promise<boolean> {
38
- // Skip access check in sudo mode
39
81
  if (args.context._isSudo) {
40
82
  return true
41
83
  }
42
84
 
43
85
  if (!fieldAccess) {
44
- return true // No field access means allow
86
+ return true
45
87
  }
46
88
 
47
- const accessControl = fieldAccess[operation]
89
+ // `FieldAccess['read']` is narrower than `FieldAccess['create'/'update']` (it
90
+ // only accepts the single `operation: 'read'` call shape, see `types.ts`),
91
+ // so indexing by a not-yet-narrowed `operation` union produces a callable
92
+ // whose effective parameter type collapses to an intersection TypeScript
93
+ // can't satisfy generically here. Widen back to the general
94
+ // `FieldAccessControl` — the same shape this function has always built and
95
+ // passed below — since by construction the args object always matches
96
+ // whichever operation is actually requested.
97
+ const accessControl = fieldAccess[operation] as FieldAccessControl | undefined
48
98
  if (!accessControl) {
49
- return true // No specific access control means allow
99
+ return true
50
100
  }
51
101
 
52
102
  const result = await accessControl({
@@ -57,51 +107,79 @@ export async function checkFieldAccess(
57
107
  operation,
58
108
  } as Parameters<typeof accessControl>[0])
59
109
 
60
- // If result is false, deny access
61
110
  if (result === false) {
62
111
  return false
63
112
  }
64
113
 
65
- // If result is true, allow access
66
114
  if (result === true) {
67
115
  return true
68
116
  }
69
117
 
70
- // Default to allowing access if we can't determine
71
- return true
118
+ // `FieldAccessControl` is typed to return `boolean` only field access is a
119
+ // per-field visibility decision, not a row filter (ADR-0001, ADR-0030). A
120
+ // well-typed rule can never reach this line; getting here means a caller
121
+ // bypassed the type (most notably by returning a Prisma filter, the shape
122
+ // operation-level `AccessControl` accepts but this does not). Fail loudly
123
+ // and closed — this operation applies identically whether or not `item` is
124
+ // available, so `create` (which has no `item` to evaluate a filter against)
125
+ // needs no separate answer: neither operation ever honours a non-boolean.
126
+ throw new InvalidFieldAccessResultError(operation, result)
72
127
  }
73
128
 
74
129
  /**
75
- * Simple filter matching for field-level access
76
- * Checks if an item matches a Prisma-like filter object
130
+ * Whether a field's `read` access allows it to be NAMED in a `where`/`orderBy`
131
+ * predicate, evaluated BEFORE the query runs (#915).
132
+ *
133
+ * Field-level `read` access has always been a post-query check (Field
134
+ * Visibility, `field-visibility.ts`): it strips a denied key from a row that
135
+ * has already been fetched. That leaves the predicate itself unconstrained —
136
+ * a caller can still filter or sort by a field whose value they could never
137
+ * read, and recover it (or its relative order) by probing. This function is
138
+ * the pre-query counterpart, called from `query-validation.ts`'s `where`/
139
+ * `orderBy` walk for every key that resolves to a declared field.
140
+ *
141
+ * It delegates to the same evaluator Field Visibility uses (`checkFieldAccess`),
142
+ * per this module's canonical-evaluator rule above. The one difference is the
143
+ * `item` it hands the rule: there is no fetched row
144
+ * yet, so a rule that depends on one (the shape `FieldAccess['read']`
145
+ * documents as the norm, e.g. `item?.ownerId === session?.userId`) cannot be
146
+ * answered here. Rather than skip the check for such a rule — which would
147
+ * reopen exactly the hole this closes for the fields most likely to be
148
+ * sensitive — it is handed a poisoned `item` that throws on any property
149
+ * read, and that throw is caught and resolved to `false`: a row-dependent
150
+ * `read` rule always denies at predicate time, documented and deliberate
151
+ * (see docs/adr/0031). A rule that never touches `item` (checking only
152
+ * `session`, the common case for a field meant to be filterable/sortable at
153
+ * all) evaluates normally and returns its real answer.
154
+ *
155
+ * A rule that returns a non-boolean is a distinct, louder failure (#913,
156
+ * ADR-0030) — `InvalidFieldAccessResultError` — and is deliberately NOT
157
+ * folded into the `false` here; it propagates so the config bug it signals is
158
+ * never mistaken for an ordinary field-level denial.
77
159
  */
78
- function matchesFilter(item: Record<string, unknown>, filter: Record<string, unknown>): boolean {
79
- for (const [key, condition] of Object.entries(filter)) {
80
- if (typeof condition === 'object' && condition !== null) {
81
- // Handle nested conditions like { equals: value }
82
- if ('equals' in condition) {
83
- if (item[key] !== condition.equals) {
84
- return false
85
- }
86
- } else if ('not' in condition) {
87
- if (item[key] === condition.not) {
88
- return false
89
- }
90
- }
91
- // Add more condition types as needed
92
- } else {
93
- // Direct equality check
94
- if (item[key] !== condition) {
95
- return false
96
- }
97
- }
160
+ export async function isFieldReadableForPredicate(
161
+ fieldAccess: FieldAccess | undefined,
162
+ args: {
163
+ session: Session | null
164
+ context: AccessContext & { _isSudo?: boolean }
165
+ },
166
+ ): Promise<boolean> {
167
+ try {
168
+ return await checkFieldAccess(fieldAccess, 'read', {
169
+ session: args.session,
170
+ context: args.context,
171
+ item: createPoisonedItem(),
172
+ })
173
+ } catch (err) {
174
+ // Only the poisoned-item signal means "row-dependent, deny". Anything
175
+ // else including `InvalidFieldAccessResultError` and a genuine bug in
176
+ // the rule itself — propagates unchanged rather than being silently
177
+ // folded into an ordinary denial (found in review of #925).
178
+ if (err instanceof PredicateTimeItemAccessError) return false
179
+ throw err
98
180
  }
99
- return true
100
181
  }
101
182
 
102
- /**
103
- * Filter fields from input data based on write access (create/update)
104
- */
105
183
  export async function filterWritableFields<T extends Record<string, unknown>>(
106
184
  data: T,
107
185
  fieldConfigs: Record<
@@ -122,8 +200,7 @@ export async function filterWritableFields<T extends Record<string, unknown>>(
122
200
  ): Promise<Partial<T>> {
123
201
  const filtered: Record<string, unknown> = {}
124
202
 
125
- // Build a set of foreign key field names to exclude
126
- // Foreign keys should not be in the data when using Prisma's relation syntax
203
+ // Foreign keys must not appear in `data` when using Prisma's relation syntax.
127
204
  const foreignKeyFields = new Set<string>()
128
205
  // Map each raw per-part column name contributed by a multi-column field
129
206
  // (e.g. storage image()/file() in Keystone-parity mode) back to its OWNING
@@ -162,19 +239,18 @@ export async function filterWritableFields<T extends Record<string, unknown>>(
162
239
  for (const [fieldName, value] of Object.entries(data)) {
163
240
  const fieldConfig = fieldConfigs[fieldName]
164
241
 
165
- // Skip system fields
166
242
  if (['id', 'createdAt', 'updatedAt'].includes(fieldName)) {
167
243
  continue
168
244
  }
169
245
 
170
- // Skip virtual fields - they don't store in database
171
- // Virtual fields with resolveInput hooks handle side effects separately
246
+ // Virtual fields don't store in the database — skipped here, but their
247
+ // resolveInput hooks still run as a separate side-effect step.
172
248
  if (fieldConfig && 'virtual' in fieldConfig && fieldConfig.virtual) {
173
249
  continue
174
250
  }
175
251
 
176
- // Skip foreign key fields (e.g., authorId) when their corresponding relationship field exists
177
- // This prevents conflicts when using Prisma's relation syntax (e.g., author: { connect: { id } })
252
+ // Prevents conflicts with Prisma's relation syntax (e.g.,
253
+ // `author: { connect: { id } }`).
178
254
  if (foreignKeyFields.has(fieldName)) {
179
255
  continue
180
256
  }
@@ -1,29 +1,20 @@
1
1
  import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
2
2
  import type { HashedPassword } from '../utils/password.js'
3
3
 
4
- /**
5
- * Extract the return type of a field's afterOperation hook
6
- * If the field has an afterOperation hook, infer its return type
7
- * Otherwise, use the original type
8
- */
9
4
  export type InferFieldReadType<TField extends FieldConfig, TOriginal> = TField extends {
10
- // Generic `any` is required here for TypeScript's conditional type inference to work correctly
11
- // This allows us to infer the exact return type `R` from hooks of any signature
5
+ // Uses `any` here so TypeScript's conditional type inference can pick up the
6
+ // exact return type `R` from a hook of any signature.
12
7
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
13
8
  hooks?: { afterOperation?: (...args: any[]) => infer R }
14
9
  }
15
10
  ? R extends never
16
- ? TOriginal // No hook defined
17
- : R // Hook return type
18
- : TOriginal // No hooks at all
11
+ ? TOriginal
12
+ : R
13
+ : TOriginal
19
14
 
20
- /**
21
- * Transform a Prisma model's field types based on OpenSaas field configs
22
- * This applies afterOperation hook transformations to field types
23
- */
24
15
  export type TransformModelFields<
25
- // Generic constraint requires `any` to allow indexing by string keys from Prisma models
26
- // This is necessary for mapped types to work with Prisma's generated model types
16
+ // Uses `any` so this constraint accepts Prisma's generated model types, which are
17
+ // indexed by string keys.
27
18
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
19
  TModel extends Record<string, any>,
29
20
  TFields extends Record<string, FieldConfig>,
@@ -33,34 +24,22 @@ export type TransformModelFields<
33
24
  : TModel[K]
34
25
  }
35
26
 
36
- /**
37
- * Get the field configs for a specific list from the OpenSaas config
38
- */
39
27
  export type GetListFields<
40
28
  TConfig extends OpenSaasConfig,
41
29
  TListKey extends keyof TConfig['lists'],
42
30
  > = TConfig['lists'][TListKey]['fields']
43
31
 
44
- /**
45
- * Transform a Prisma model result based on OpenSaas config
46
- * Applies field hooks transformations
47
- */
48
32
  export type TransformResult<
49
33
  TConfig extends OpenSaasConfig,
50
34
  TListKey extends keyof TConfig['lists'],
51
35
  TResult,
52
36
  > =
53
- // Generic constraint requires `any` to check if TResult is an object type that can be transformed
54
- // This pattern is standard in TypeScript for conditional types on object shapes
37
+ // Uses `any` to check whether TResult is an object type that can be transformed.
55
38
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
56
39
  TResult extends Record<string, any>
57
40
  ? TransformModelFields<TResult, GetListFields<TConfig, TListKey>>
58
41
  : TResult
59
42
 
60
- /**
61
- * Transform a Prisma operation's return type
62
- * Handles single results, arrays, and null cases
63
- */
64
43
  export type TransformOperationResult<
65
44
  TConfig extends OpenSaasConfig,
66
45
  TListKey extends keyof TConfig['lists'],
@@ -76,20 +55,10 @@ export type TransformOperationResult<
76
55
  >
77
56
  : never
78
57
 
79
- /**
80
- * Known field type mappings for afterOperation hooks
81
- * These provide concrete type hints for common field transformations
82
- */
83
58
  export interface FieldTypeTransforms {
84
59
  password: HashedPassword
85
- // Future field types can be added here
86
- // richText: TiptapContent
87
- // json: JSONValue
88
60
  }
89
61
 
90
- /**
91
- * Helper to infer field type based on field config type
92
- */
93
62
  export type InferFieldTypeTransform<TField extends FieldConfig> = TField extends {
94
63
  type: infer TType
95
64
  }