@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
@@ -1,8 +1,14 @@
1
1
  import type { Session, AccessContext, PrismaFilter } from './types.js'
2
- import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
2
+ import type { OpenSaasConfig, FieldConfig, ListConfig } from '../config/types.js'
3
3
  import { checkAccess, getRelatedListConfig } from './engine.js'
4
4
  import { READ_INCLUDE_MAX_DEPTH } from './depth-limits.js'
5
- import { AccessScopeDepthExceededError } from './errors.js'
5
+ import { AccessScopeDepthExceededError, RelationFilterAccessDeniedError } from './errors.js'
6
+ import {
7
+ LOGICAL_OPERATORS,
8
+ RELATION_QUANTIFIERS,
9
+ resolveQueryField,
10
+ walkWhereReadAccess,
11
+ } from './query-validation.js'
6
12
 
7
13
  /**
8
14
  * Access Filter — phase 1 of the two-phase read (pre-query).
@@ -18,199 +24,44 @@ import { AccessScopeDepthExceededError } from './errors.js'
18
24
  * the fetched row, neither of which is expressible in SQL. See
19
25
  * `docs/adr/0001-access-control-is-a-two-phase-read.md` and the access-control
20
26
  * glossary in `CONTEXT.md`.
21
- */
22
-
23
- /** A single relation entry in a Prisma `include` object (see below). */
24
- type IncludeEntry = boolean | { where?: PrismaFilter; include?: IncludeObject; take?: number }
25
- type IncludeObject = Record<string, IncludeEntry>
26
-
27
- /**
28
- * The result of trying to compute an access-controlled include for a list's
29
- * fields. `buildIncludeWithAccessControl` used to collapse three unrelated
30
- * outcomes into a single overloaded `undefined`: "inside a resolveOutput
31
- * context", "hit the depth cap", and "no relationships to scope" all looked
32
- * identical to callers, which is what let a depth-capped relation pass
33
- * through unscoped (issue #830). This discriminated result keeps them
34
- * distinguishable all the way to `mergeIncludeWithAccessControl`, which is the
35
- * only place that knows whether a caller actually asked for the part that
36
- * couldn't be scoped.
37
- *
38
- * - `scoped`: relationships were found and (to the extent depth allows)
39
- * access-controlled; `include` is the resulting tree.
40
- * - `nothing-to-scope`: the list genuinely has no relationships to scope, OR
41
- * we are inside a resolveOutput/virtual-field context and deliberately did
42
- * not descend into a relation's own nested relations. Passing the caller's
43
- * include through unchanged here is correct, not a leak.
44
- * - `depth-exceeded`: we could not evaluate this level at all because it sits
45
- * at or past `READ_INCLUDE_MAX_DEPTH`. This is a denial: a caller `include`
46
- * that reaches here must be rejected, not passed through.
47
- */
48
- export type AccessIncludeResult =
49
- | { kind: 'scoped'; include: RichIncludeObject }
50
- | { kind: 'nothing-to-scope' }
51
- | { kind: 'depth-exceeded' }
52
-
53
- /** A relation entry in the rich, provenance-carrying tree `buildIncludeWithAccessControl` builds internally. */
54
- type RichIncludeEntry = { where?: PrismaFilter; nested: AccessIncludeResult }
55
- type RichIncludeObject = Record<string, RichIncludeEntry>
56
-
57
- /**
58
- * Collapse a rich, provenance-carrying include entry down to the plain
59
- * Prisma-shaped form. A `nested` result that is `depth-exceeded` or
60
- * `nothing-to-scope` contributes no `include` key — this is the AUTO-include
61
- * silently stopping, which is correct when no caller asked for anything past
62
- * this point (see `AccessIncludeResult` doc comment).
63
- */
64
- function toPrismaEntry(entry: RichIncludeEntry): IncludeEntry {
65
- const result: { where?: PrismaFilter; include?: IncludeObject } = {}
66
- if (entry.where) result.where = entry.where
67
- if (entry.nested.kind === 'scoped') {
68
- const nestedInclude = toPrismaInclude(entry.nested)
69
- if (nestedInclude && Object.keys(nestedInclude).length > 0) {
70
- result.include = nestedInclude
71
- }
72
- }
73
- return Object.keys(result).length > 0 ? result : true
74
- }
75
-
76
- /**
77
- * Collapse an `AccessIncludeResult` to the plain Prisma `include` shape used
78
- * when there is no caller-supplied include to merge against (the direct
79
- * auto-include path). `nothing-to-scope` and `depth-exceeded` both become
80
- * `undefined` here — at this call site nothing was explicitly requested past
81
- * either boundary, so there is nothing to deny.
82
- */
83
- export function toPrismaInclude(result: AccessIncludeResult): IncludeObject | undefined {
84
- if (result.kind !== 'scoped') return undefined
85
- const out: IncludeObject = {}
86
- for (const [key, entry] of Object.entries(result.include)) {
87
- out[key] = toPrismaEntry(entry)
88
- }
89
- return out
90
- }
91
-
92
- /**
93
- * Build the access-controlled include for a list's fields.
94
27
  *
95
- * This allows us to filter relationships at the database level instead of in
96
- * memory. Returns an {@link AccessIncludeResult} rather than a plain include
97
- * object so that `mergeIncludeWithAccessControl` can tell a genuine "nothing
98
- * to scope" apart from "the engine hit its depth cap" (see that type's doc
99
- * comment and ADR-0022).
28
+ * **Caller-directed (ADR-0026).** `buildAccessScopedInclude` walks only the
29
+ * branches `requestedInclude` names the caller's own `include`, a fragment
30
+ * `query`'s projection, or `foldDeclaredDependencies`'s fold of a field's
31
+ * `needs` (`declared-dependencies.ts`), all resolved before this module ever
32
+ * runs. Naming a relation fetches that relation's own columns and stops
33
+ * (the "One hop" rule, see `CONTEXT.md`); reaching further means the request
34
+ * named a nested `include` there too. A relation nobody named never has its
35
+ * list's `query` access evaluated at all — there is no separate "build the
36
+ * whole tree, then reconcile against what was asked for" pass to walk it.
100
37
  */
101
- export async function buildIncludeWithAccessControl(
102
- fieldConfigs: Record<string, FieldConfig>,
103
- args: {
104
- session: Session | null
105
- context: AccessContext
106
- },
107
- config: OpenSaasConfig,
108
- depth: number = 0,
109
- // List names already on the path from the root to here. Used to detect
110
- // relationship cycles (A → B → … → A) and stop the auto-include from
111
- // re-descending them. Seed it with the root list name at the call site.
112
- visitedLists: readonly string[] = [],
113
- ): Promise<AccessIncludeResult> {
114
- if (depth >= READ_INCLUDE_MAX_DEPTH) {
115
- return { kind: 'depth-exceeded' }
116
- }
117
-
118
- // Inside a resolveOutput/virtual-field context we still scope each immediate
119
- // relation (its own access `where`), but do not auto-expand INTO its nested
120
- // relations — this keeps a single hook-issued read's include from growing
121
- // depth-first. Correction (ADR-0023): this does NOT by itself terminate a
122
- // cycle, despite what an earlier version of this comment claimed. A
123
- // self-referential relation read from inside a hook is a NEW top-level
124
- // read, invisible to this function's own recursion — the loop runs through
125
- // the hook↔read boundary, not through repeated calls to
126
- // `buildIncludeWithAccessControl`. Loop termination is the resolve chain's
127
- // job: `field-visibility.ts`'s `resolveReadableFieldValue` refuses to
128
- // re-enter a `(list, field)` pair already on the chain. What this check
129
- // still does correctly is row-scope the immediate relation rather than
130
- // skipping scoping altogether (issue #830's second trigger).
131
- const insideResolveOutput = args.context._resolveOutputChain.length > 0
132
-
133
- const include: RichIncludeObject = {}
134
- let hasRelationships = false
135
-
136
- for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
137
- if (fieldConfig?.type === 'relationship' && 'ref' in fieldConfig && fieldConfig.ref) {
138
- hasRelationships = true
139
- const relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
140
-
141
- if (relatedConfig) {
142
- // Check query access for the related list
143
- const queryAccess = relatedConfig.listConfig.access?.operation?.query
144
- const accessResult = await checkAccess(queryAccess, {
145
- session: args.session,
146
- context: args.context,
147
- })
148
-
149
- // If access is completely denied, exclude this relationship
150
- if (accessResult === false) {
151
- continue
152
- }
153
-
154
- const where = typeof accessResult === 'object' ? accessResult : undefined
155
-
156
- // Cycle guard: if the related list already appears on the path from the
157
- // root, DO NOT auto-include its relationships again. On a cyclic
158
- // readable-relationship graph (A → B → … → A) the depth-first walk would
159
- // otherwise re-descend the cycle on every branch, and — combined with the
160
- // bare-`true` leaf re-expansion in `mergeIncludeWithAccessControl` — build
161
- // an include tree deep/large enough to overflow the call stack in
162
- // downstream processing (the RSC serializer's recursive `Map.set`). This
163
- // extends the SF-20 (#566) fix: a bare-`true` leaf must resolve to a
164
- // genuine single-level fetch, not a re-expansion of the full auto-include.
165
- // The relation itself is still included (as a FLAT fetch of its own
166
- // columns); only its onward relationships are pruned at the back-edge.
167
- let nested: AccessIncludeResult = { kind: 'nothing-to-scope' }
168
- const relatedListName = relatedConfig.listName
169
- if (!insideResolveOutput && !visitedLists.includes(relatedListName)) {
170
- nested = await buildIncludeWithAccessControl(
171
- relatedConfig.listConfig.fields,
172
- args,
173
- config,
174
- depth + 1,
175
- [...visitedLists, relatedListName],
176
- )
177
- }
178
38
 
179
- include[fieldName] = { where, nested }
180
- }
181
- }
182
- }
39
+ /** The structured (object) form of a relation include entry caller/fold-supplied or produced by this module. */
40
+ type IncludeEntryObject = { where?: PrismaFilter; include?: Record<string, unknown>; take?: number }
183
41
 
184
- return hasRelationships ? { kind: 'scoped', include } : { kind: 'nothing-to-scope' }
42
+ /** A plain object excludes `null` and arrays, which `typeof x === 'object'` alone would admit. */
43
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
44
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
185
45
  }
186
46
 
187
- /** The structured (object) form of a relation include entry. */
188
- type IncludeEntryObject = { where?: PrismaFilter; include?: IncludeObject; take?: number }
189
-
190
47
  /**
191
48
  * Narrow an unknown include value to the structured object form (vs bare `true`
192
49
  * or any other primitive). Caller-supplied includes arrive untyped at the
193
- * runtime boundary, so we validate the shape here rather than casting.
50
+ * runtime boundary: narrow to a plain object first, then validate each field's
51
+ * own type before trusting it, rather than casting the whole value wholesale.
194
52
  *
195
53
  * A numeric `take` on a to-many relation include (a caller-supplied row bound,
196
54
  * issue #752) is carried through: it only ever NARROWS the fetched rows and can
197
- * never widen past the access `where`, so preserving it is access-neutral. The
198
- * access-controlled include never sets `take` itself — it originates solely
199
- * from the caller — so `mergeIncludeWithAccessControl` re-attaches it below.
55
+ * never widen past the access `where`, so preserving it is access-neutral.
200
56
  */
201
57
  function asEntryObject(value: unknown): IncludeEntryObject | null {
202
- if (value && typeof value === 'object') {
203
- const obj = value as Record<string, unknown>
204
- const where = obj.where
205
- const include = obj.include
206
- const take = obj.take
207
- const entry: IncludeEntryObject = {}
208
- if (where && typeof where === 'object') entry.where = where as PrismaFilter
209
- if (include && typeof include === 'object') entry.include = include as IncludeObject
210
- if (typeof take === 'number') entry.take = take
211
- return entry
212
- }
213
- return null
58
+ if (!isPlainObject(value)) return null
59
+ const { where, include, take } = value
60
+ const entry: IncludeEntryObject = {}
61
+ if (isPlainObject(where)) entry.where = where
62
+ if (isPlainObject(include)) entry.include = include
63
+ if (typeof take === 'number') entry.take = take
64
+ return entry
214
65
  }
215
66
 
216
67
  /**
@@ -218,8 +69,7 @@ function asEntryObject(value: unknown): IncludeEntryObject | null {
218
69
  *
219
70
  * The access filter is authoritative: the caller's filter may only NARROW the
220
71
  * result further, never widen past what access permits. We therefore wrap both
221
- * in a Prisma `AND` so neither can override the other. If only one side is
222
- * present, it is returned as-is; if neither is present, the result is undefined.
72
+ * in a Prisma `AND` so neither can override the other.
223
73
  */
224
74
  function andWhere(
225
75
  accessWhere: PrismaFilter | undefined,
@@ -232,128 +82,243 @@ function andWhere(
232
82
  }
233
83
 
234
84
  /**
235
- * Merge a caller-supplied `include` with the access-controlled include — phase-1
236
- * row/relation scoping for explicit caller selections.
237
- *
238
- * The caller's `include` decides WHICH relations to fetch; access control decides
239
- * WHETHER each relation may be fetched and WITH WHAT filter. Replacing the
240
- * access-controlled include with the caller's wholesale (the bug in #566) drops
241
- * every per-relation access `where` and denied-relation exclusion, silently
242
- * bypassing row-level access on any non-sudo read that passes `include`.
85
+ * Build the access-scoped `include` for exactly the relations a read
86
+ * requested, recursing only into branches `requestedInclude` itself names.
243
87
  *
244
- * For each relation the caller asks to include:
245
- * - If the relation is a config-declared relationship but is ABSENT from the
246
- * access-controlled include, its `query` access returned `false` it is DROPPED
247
- * (not fetched).
248
- * - If it is present (allowed, possibly with a filter), the access entry is used
249
- * as the base: the access `where` is AND-combined with any caller-supplied
250
- * nested `where`, and nested includes are recursively merged using the related
251
- * list's field configs (so deeply-nested selections are filtered at every
252
- * level). A bare caller `true` becomes the access-controlled shape (filter +
253
- * nested filtered include), never bare `true`.
254
- * - If the caller names a key that is NOT a config-declared relationship, it is
255
- * passed through unchanged (access control does not govern it).
88
+ * For each key in `requestedInclude`:
89
+ * - Not a config-declared relationship access control does not govern it;
90
+ * passed through unchanged (e.g. a fragment/caller key that isn't a
91
+ * relationship at all).
92
+ * - A declared relationship whose related list's `query` access denies it
93
+ * (`=== false`) dropped entirely, no matter what the request asked for
94
+ * nested beneath it (#566): the caller chooses *which* relations, access
95
+ * control chooses *whether* and *with what filter*.
96
+ * - Otherwise the access `where` is AND-combined with any caller-supplied
97
+ * nested `where` (never replaced — the other half of #566), a
98
+ * caller-supplied `take` rides through unchanged (#752), and the "One
99
+ * hop" rule (ADR-0026) nested relations are scoped ONLY if
100
+ * `requestedInclude` itself named a nested `include` here. A bare relation
101
+ * (or one with no nested `include`) fetches its own columns and stops: no
102
+ * recursive call, no access evaluation on anything beneath it.
256
103
  *
257
- * `accessControlledInclude` is the {@link AccessIncludeResult} for THIS level:
258
- * - `nothing-to-scope` nothing to merge against (the list has no
259
- * relationships, or we're inside a resolveOutput context where the caller
260
- * include is irrelevant to begin with). Pass the caller's include through
261
- * unchanged this is a non-denial outcome, not "every relation denied".
262
- * - `depth-exceeded` the engine could not compute a scope for THIS level at
263
- * all because it sits at or past `READ_INCLUDE_MAX_DEPTH`. If the caller
264
- * named anything here, that is exactly the case that used to pass through
265
- * unscoped (issue #830): throw `AccessScopeDepthExceededError` instead. An
266
- * empty caller include at this level (nothing further requested) is not an
267
- * error — there's simply nothing to do.
268
- * - `scoped` → the normal per-relation merge below: a declared relationship
269
- * ABSENT from the access include was denied (drop it); one PRESENT is used
270
- * as the base, AND-combining `where`s and recursing into nested includes.
271
- *
272
- * `listKey` and `depth` are carried only to build a useful
273
- * `AccessScopeDepthExceededError` message; they do not affect merge behaviour.
104
+ * **Depth is a cost limit, not a cycle guard (ADR-0026).** A `requestedInclude`
105
+ * is always a finite literal the caller's own object, or
106
+ * `foldDeclaredDependencies`'s already-cycle-guarded fold so this recursion
107
+ * cannot loop unboundedly on its own; nothing here walks the relationship
108
+ * graph unprompted. `READ_INCLUDE_MAX_DEPTH` still bounds how deep a request
109
+ * may reach, fail-closed per ADR-0022: a request naming anything at or past
110
+ * the cap throws `AccessScopeDepthExceededError` rather than silently
111
+ * returning less than what was asked for.
274
112
  */
275
- export function mergeIncludeWithAccessControl(
276
- callerInclude: Record<string, unknown>,
277
- accessControlledInclude: AccessIncludeResult,
113
+ export async function buildAccessScopedInclude(
114
+ requestedInclude: Record<string, unknown>,
278
115
  fieldConfigs: Record<string, FieldConfig>,
116
+ args: {
117
+ session: Session | null
118
+ context: AccessContext
119
+ },
279
120
  config: OpenSaasConfig,
280
121
  listKey: string,
281
122
  depth: number = 0,
282
- ): Record<string, unknown> {
283
- if (accessControlledInclude.kind === 'nothing-to-scope') {
284
- return callerInclude
123
+ ): Promise<Record<string, unknown>> {
124
+ const requestedKeys = Object.keys(requestedInclude)
125
+ if (depth >= READ_INCLUDE_MAX_DEPTH && requestedKeys.length > 0) {
126
+ throw new AccessScopeDepthExceededError(listKey, requestedKeys[0], depth)
285
127
  }
286
128
 
287
- if (accessControlledInclude.kind === 'depth-exceeded') {
288
- const [firstRelationName] = Object.keys(callerInclude)
289
- if (firstRelationName !== undefined) {
290
- throw new AccessScopeDepthExceededError(listKey, firstRelationName, depth)
291
- }
292
- return {}
293
- }
294
-
295
- const merged: Record<string, unknown> = {}
296
- const accessInclude = accessControlledInclude.include
129
+ const result: Record<string, unknown> = {}
297
130
 
298
- for (const [relationName, callerValue] of Object.entries(callerInclude)) {
131
+ for (const [relationName, requestedValue] of Object.entries(requestedInclude)) {
299
132
  const fieldConfig = fieldConfigs[relationName]
300
133
  const isDeclaredRelationship =
301
134
  fieldConfig?.type === 'relationship' && 'ref' in fieldConfig && !!fieldConfig.ref
302
135
 
303
- // Not a config-declared relationship → access control does not govern it; pass through unchanged.
304
136
  if (!isDeclaredRelationship) {
305
- merged[relationName] = callerValue
137
+ result[relationName] = requestedValue
306
138
  continue
307
139
  }
308
140
 
309
- const accessEntry = accessInclude[relationName]
141
+ const relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
142
+ if (!relatedConfig) continue
143
+
144
+ const queryAccess = relatedConfig.listConfig.access?.operation?.query
145
+ const accessResult = await checkAccess(queryAccess, {
146
+ session: args.session,
147
+ context: args.context,
148
+ })
310
149
 
311
- // Declared relationship absent from the access include → query access denied → drop it.
312
- if (accessEntry === undefined) {
150
+ if (accessResult === false) {
313
151
  continue
314
152
  }
315
153
 
316
- const callerEntry = asEntryObject(callerValue)
154
+ const accessWhere = typeof accessResult === 'object' ? accessResult : undefined
155
+ const requestedEntry = asEntryObject(requestedValue)
156
+ const mergedWhere = andWhere(accessWhere, requestedEntry?.where)
317
157
 
318
- // Resolve the related list's field configs so nested includes merge recursively.
319
- const relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
320
- const relatedFields = relatedConfig?.listConfig.fields
321
-
322
- const mergedWhere = andWhere(accessEntry.where, callerEntry?.where)
323
-
324
- let mergedNested: Record<string, unknown> | undefined
325
- if (callerEntry?.include && relatedFields && relatedConfig) {
326
- // Recurse: scope the caller's nested selection against the nested access
327
- // result. If that result is 'depth-exceeded', the recursive call itself
328
- // throws — the caller named something the engine cannot scope.
329
- mergedNested = mergeIncludeWithAccessControl(
330
- callerEntry.include,
331
- accessEntry.nested,
332
- relatedFields,
158
+ let nestedInclude: Record<string, unknown> | undefined
159
+ if (requestedEntry?.include) {
160
+ nestedInclude = await buildAccessScopedInclude(
161
+ requestedEntry.include,
162
+ relatedConfig.listConfig.fields,
163
+ args,
333
164
  config,
334
165
  relatedConfig.listName,
335
166
  depth + 1,
336
167
  )
337
- } else if (accessEntry.nested.kind === 'scoped') {
338
- // Caller selected the relation bare (no nested include); keep the
339
- // access-controlled nested include so deeper relations stay filtered.
340
- mergedNested = toPrismaInclude(accessEntry.nested)
341
168
  }
342
- // If the caller's entry has no nested include, a 'depth-exceeded' nested
343
- // result is silent here too — nothing was asked for past this point.
344
169
 
345
170
  const entry: { where?: PrismaFilter; include?: Record<string, unknown>; take?: number } = {}
346
171
  if (mergedWhere) entry.where = mergedWhere
347
- if (mergedNested && Object.keys(mergedNested).length > 0) entry.include = mergedNested
348
- // Preserve a caller-supplied row bound on the relation (issue #752). It only
349
- // narrows, never widens, so it rides on top of the access `where`/include.
350
- if (callerEntry?.take !== undefined) entry.take = callerEntry.take
172
+ if (nestedInclude && Object.keys(nestedInclude).length > 0) entry.include = nestedInclude
173
+ if (requestedEntry?.take !== undefined) entry.take = requestedEntry.take
174
+
175
+ result[relationName] = Object.keys(entry).length > 0 ? entry : true
176
+ }
177
+
178
+ return result
179
+ }
180
+
181
+ /**
182
+ * Scope every relation filter (`some`/`every`/`none`/`is`/`isNot`) nested in a
183
+ * caller's `where` by the related list's own `query` access — the `where`
184
+ * counterpart to `buildAccessScopedInclude` above, closing #916 (the
185
+ * "unclosed half of ADR-0022"). `include` and `where` are fundamentally
186
+ * different requests (which relations come back, vs. which parent rows
187
+ * match), so this is a distinct function, but it shares every primitive that
188
+ * matters: `checkAccess`/`getRelatedListConfig` (the same access-evaluation
189
+ * calls `buildAccessScopedInclude` makes), `andWhere` (the same AND-fold), and
190
+ * `resolveQueryField`/`LOGICAL_OPERATORS`/`RELATION_QUANTIFIERS`/
191
+ * `walkWhereReadAccess` (the same shape-recognition and field-read check
192
+ * `query-validation.ts` already uses for #912/#915) — there is no second,
193
+ * parallel implementation of any of those decisions.
194
+ *
195
+ * For each relationship key found (at any depth — the walk recurses through
196
+ * `AND`/`OR`/`NOT` and through every hop of a chain):
197
+ * - Not a declared relationship, or the key #912 already rejected (this walk
198
+ * runs strictly after that check) → passed through unchanged.
199
+ * - The related list's `query` access denies it (`=== false`) → THROWS
200
+ * `RelationFilterAccessDeniedError`. Unlike `buildAccessScopedInclude`'s
201
+ * silent drop, this is a loud failure: a `where` predicate has no neutral
202
+ * "not requested" outcome the way a missing `include` key does, so a
203
+ * silently-empty match would itself be a distinguishable signal (ADR-0022).
204
+ * - Otherwise → the access filter (if any) is AND-combined into the relation
205
+ * quantifier's nested clause (never replacing the caller's own condition,
206
+ * mirroring `andWhere`'s include-side contract), keys inside that nested
207
+ * clause are checked against the RELATED list's field-level `read` access
208
+ * (`walkWhereReadAccess`, closing #915's stated gap for this path), and the
209
+ * walk recurses into the related list's own fields for a further hop.
210
+ *
211
+ * One quantifier is deliberately conservative rather than exactly precise:
212
+ * folding the access filter into `every`'s nested clause with a plain AND
213
+ * makes `every` require every related row to be BOTH access-visible AND
214
+ * matching, not "every access-visible row matches" (the latter needs a
215
+ * `NOT`/`OR` transform this does not attempt). The conservative version can
216
+ * only reject a query that the precise version would allow — it never
217
+ * widens what a caller can learn — so it is the safe direction to ship; a
218
+ * caller can observe that an inaccessible related row exists (an `every`
219
+ * that "should" pass instead fails), but never that row's field values,
220
+ * which is the property this ticket exists to close.
221
+ */
222
+ export async function buildAccessScopedWhere(
223
+ where: unknown,
224
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
225
+ listConfig: ListConfig<any>,
226
+ listName: string,
227
+ config: OpenSaasConfig,
228
+ args: {
229
+ session: Session | null
230
+ context: AccessContext
231
+ },
232
+ ): Promise<unknown> {
233
+ if (where === null || typeof where !== 'object') return where
351
234
 
352
- // A bare-`true` relation with no access filter, nested include, or row bound stays `true`.
353
- merged[relationName] = Object.keys(entry).length > 0 ? entry : true
235
+ if (Array.isArray(where)) {
236
+ return Promise.all(
237
+ where.map((entry) => buildAccessScopedWhere(entry, listConfig, listName, config, args)),
238
+ )
354
239
  }
355
240
 
356
- return merged
241
+ const result: Record<string, unknown> = {}
242
+
243
+ for (const [key, value] of Object.entries(where as Record<string, unknown>)) {
244
+ if (LOGICAL_OPERATORS.has(key)) {
245
+ result[key] = await buildAccessScopedWhere(value, listConfig, listName, config, args)
246
+ continue
247
+ }
248
+
249
+ const resolved = resolveQueryField(key, listConfig.fields)
250
+ if (
251
+ !resolved ||
252
+ !resolved.isRelationship ||
253
+ value === null ||
254
+ typeof value !== 'object' ||
255
+ Array.isArray(value)
256
+ ) {
257
+ result[key] = value
258
+ continue
259
+ }
260
+
261
+ const related = getRelatedListConfig(resolved.fieldConfig.ref, config)
262
+ if (!related) {
263
+ result[key] = value
264
+ continue
265
+ }
266
+
267
+ const queryAccess = related.listConfig.access?.operation?.query
268
+ const accessResult = await checkAccess(queryAccess, {
269
+ session: args.session,
270
+ context: args.context,
271
+ })
272
+
273
+ if (accessResult === false) {
274
+ throw new RelationFilterAccessDeniedError(listName, key, related.listName)
275
+ }
276
+
277
+ const accessWhere = typeof accessResult === 'object' ? accessResult : undefined
278
+ const relationEntries = Object.entries(value as Record<string, unknown>)
279
+ const hasQuantifier = relationEntries.some(([k]) => RELATION_QUANTIFIERS.has(k))
280
+
281
+ if (hasQuantifier) {
282
+ const nestedEntry: Record<string, unknown> = {}
283
+ for (const [quantifier, quantifierValue] of relationEntries) {
284
+ if (!RELATION_QUANTIFIERS.has(quantifier)) {
285
+ nestedEntry[quantifier] = quantifierValue
286
+ continue
287
+ }
288
+ await walkWhereReadAccess(quantifierValue, related.listConfig, related.listName, args)
289
+ const scopedNested = await buildAccessScopedWhere(
290
+ quantifierValue,
291
+ related.listConfig,
292
+ related.listName,
293
+ config,
294
+ args,
295
+ )
296
+ nestedEntry[quantifier] = accessWhere
297
+ ? andWhere(accessWhere, scopedNested as PrismaFilter | undefined)
298
+ : scopedNested
299
+ }
300
+ result[key] = nestedEntry
301
+ } else {
302
+ // Prisma's direct-nesting to-one form: the whole value IS the nested
303
+ // WHERE clause, with no `is` wrapper. Only wrap it in `is` when there is
304
+ // an access filter to fold in — an unwrapped value that needs no fold
305
+ // (fully-allowed related list) is returned exactly as the caller wrote
306
+ // it, so an already-permitted query is not perturbed by this pass.
307
+ await walkWhereReadAccess(value, related.listConfig, related.listName, args)
308
+ const scopedNested = await buildAccessScopedWhere(
309
+ value,
310
+ related.listConfig,
311
+ related.listName,
312
+ config,
313
+ args,
314
+ )
315
+ result[key] = accessWhere
316
+ ? { is: andWhere(accessWhere, scopedNested as PrismaFilter | undefined) }
317
+ : scopedNested
318
+ }
319
+ }
320
+
321
+ return result
357
322
  }
358
323
 
359
324
  /**
@@ -384,7 +349,6 @@ export function stripVirtualFieldsFromInclude(
384
349
  for (const [key, value] of Object.entries(include)) {
385
350
  const fieldConfig = fieldConfigs[key]
386
351
 
387
- // Virtual fields have no database column — drop them from the include.
388
352
  if (fieldConfig?.virtual) continue
389
353
 
390
354
  const isDeclaredRelationship =