@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
@@ -2,15 +2,19 @@ import { READ_INCLUDE_MAX_DEPTH } from './depth-limits.js'
2
2
 
3
3
  /**
4
4
  * Thrown when a caller-supplied `include` names a relation nested deeper than
5
- * the Access Filter can scope (see `READ_INCLUDE_MAX_DEPTH`). Deliberately
6
- * distinct from `ValidationError`: this is not bad user input, it is the
7
- * engine refusing to return data it cannot prove is row/field scoped. Code
8
- * that catches `ValidationError` to report form errors must not silently
9
- * swallow this.
5
+ * `READ_INCLUDE_MAX_DEPTH`. Deliberately distinct from `ValidationError`: this
6
+ * is not bad user input, it is the engine declining to serve a tree this
7
+ * expensive. Code that catches `ValidationError` to report form errors must
8
+ * not silently swallow this.
10
9
  *
11
- * Only an explicit caller selection past the depth cap triggers this — the
12
- * auto-include silently stopping at the cap (no caller `include` involved)
13
- * never throws. See ADR-0022 and issue #830.
10
+ * Only an explicit request naming something at or past the cap triggers this
11
+ * a read that simply doesn't reach this deep never throws. Before
12
+ * ADR-0026 made the read pipeline caller-directed, this cap was the engine's
13
+ * last line of defense against returning a relation it could not prove was
14
+ * row/field scoped (ADR-0022, issue #830); a request naming anything at this
15
+ * depth is now scoped exactly like every other named relation; the cap
16
+ * exists solely to bound how deep a request may cost the engine to serve. See
17
+ * ADR-0026 and `docs/adr/0022-access-control-fails-closed-when-it-cannot-scope.md`.
14
18
  */
15
19
  export class AccessScopeDepthExceededError extends Error {
16
20
  public listKey: string
@@ -19,10 +23,10 @@ export class AccessScopeDepthExceededError extends Error {
19
23
 
20
24
  constructor(listKey: string, fieldKey: string, depth: number) {
21
25
  super(
22
- `Cannot compute an access scope for "${listKey}.${fieldKey}" at include depth ${depth}: ` +
23
- `this exceeds the Access Filter's maximum read-include depth (${READ_INCLUDE_MAX_DEPTH}). ` +
24
- `A caller-supplied include this deep cannot be row- and field-scoped, so the read is denied ` +
25
- `rather than returned unscoped. Restructure the query to fetch this relation separately.`,
26
+ `Cannot include "${listKey}.${fieldKey}" at include depth ${depth}: this exceeds the read ` +
27
+ `pipeline's maximum include depth (${READ_INCLUDE_MAX_DEPTH}). This is a cost limit, not an ` +
28
+ `inability to scope the engine declines to serve a tree this deep rather than returning ` +
29
+ `it. Restructure the query to fetch this relation separately.`,
26
30
  )
27
31
  this.name = 'AccessScopeDepthExceededError'
28
32
  this.listKey = listKey
@@ -60,3 +64,84 @@ export class ResolveOutputCycleError extends Error {
60
64
  this.chain = chain
61
65
  }
62
66
  }
67
+
68
+ /**
69
+ * Thrown when a relation filter in a caller's `where` (`some`/`every`/`none`/
70
+ * `is`/`isNot`) names a relationship whose related list denies operation-level
71
+ * `query` access outright (`=== false`). Deliberately a loud failure, not a
72
+ * silently narrowed `{ id: { in: [] } }` or a pass-through: ADR-0022 requires
73
+ * an engine that cannot compute a scope to deny, never pass through, and here
74
+ * the engine CAN compute the scope (denied) — passing that through as a
75
+ * silently-empty match would itself be a distinguishable signal for a caller
76
+ * probing which relations they may filter on. `include`'s equivalent case
77
+ * (`buildAccessScopedInclude`) drops the relation silently instead, because a
78
+ * `null`/missing key in a response is indistinguishable from "not requested";
79
+ * a `where` predicate has no such neutral outcome, so a relation filter on a
80
+ * fully denied relation is refused instead. `sudo` bypasses this check
81
+ * entirely, matching every other access-control escape hatch. See #916 and
82
+ * `docs/adr/0022-access-control-fails-closed-when-it-cannot-scope.md`.
83
+ */
84
+ export class RelationFilterAccessDeniedError extends Error {
85
+ public listKey: string
86
+ public fieldKey: string
87
+ public relatedListKey: string
88
+
89
+ constructor(listKey: string, fieldKey: string, relatedListKey: string) {
90
+ super(
91
+ `Cannot filter "${listKey}.${fieldKey}" — the related list "${relatedListKey}" denies ` +
92
+ `query access to this session, so this relation filter cannot be scoped. Denial is loud ` +
93
+ `rather than silently narrowed to match nothing (ADR-0022). Use sudo to bypass.`,
94
+ )
95
+ this.name = 'RelationFilterAccessDeniedError'
96
+ this.listKey = listKey
97
+ this.fieldKey = fieldKey
98
+ this.relatedListKey = relatedListKey
99
+ }
100
+ }
101
+
102
+ function describeFieldAccessResult(result: unknown): string {
103
+ if (result === null) return 'null'
104
+ if (result === undefined) return 'undefined'
105
+ if (typeof result === 'object') return 'an object (e.g. a Prisma filter)'
106
+ return `a ${typeof result}`
107
+ }
108
+
109
+ /**
110
+ * Thrown when a field-level access control function returns anything other
111
+ * than a strict `boolean`. `FieldAccessControl` is typed to return `boolean`
112
+ * only: field access is a single per-field visibility decision, not a row
113
+ * filter, and a denied field is removed rather than used to scope rows (see
114
+ * the "Field-level access" glossary entry in `CONTEXT.md`, ADR-0001, and
115
+ * ADR-0030).
116
+ *
117
+ * A rule that type-checks can never reach this — the only way here is a
118
+ * caller that bypasses the type (an untyped JS config, or a value forced past
119
+ * the checker), most notably a Prisma filter, which is the shape
120
+ * operation-level `AccessControl` accepts but `FieldAccessControl` does not.
121
+ * Before #913 this fell through to an unconditional `return true`, silently
122
+ * granting the field blanket access; it now fails loudly and closed instead,
123
+ * for `read`, `create`, and `update` alike.
124
+ *
125
+ * Deliberately does not expose the offending result as a public field: unlike
126
+ * `AccessScopeDepthExceededError`/`ResolveOutputCycleError`'s fields, there is
127
+ * no concretely-typed shape to give it (the whole problem is that it isn't
128
+ * the `boolean` the caller's rule promised), and the root CLAUDE.md forbids
129
+ * exposing `unknown`/`any` as part of a package's external API. A description
130
+ * of what was returned instead is folded into the message text.
131
+ */
132
+ export class InvalidFieldAccessResultError extends Error {
133
+ public operation: 'read' | 'create' | 'update'
134
+
135
+ constructor(operation: 'read' | 'create' | 'update', result: unknown) {
136
+ super(
137
+ `Field-level access control for operation "${operation}" returned ` +
138
+ `${describeFieldAccessResult(result)}, not a boolean. Field access is a per-field ` +
139
+ `visibility decision — it must return true or false, and (unlike operation-level access) ` +
140
+ `cannot scope which rows are affected. If you meant to restrict access based on the row or ` +
141
+ `the write payload, evaluate the condition yourself and return a boolean, e.g. ` +
142
+ `\`({ item, session }) => item?.ownerId === session?.userId\`.`,
143
+ )
144
+ this.name = 'InvalidFieldAccessResultError'
145
+ this.operation = operation
146
+ }
147
+ }
@@ -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' },