@opensaas/stack-core 0.39.2 → 0.41.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 (165) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +189 -0
  3. package/CLAUDE.md +103 -0
  4. package/dist/access/access-filter.d.ts +157 -14
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +470 -42
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +982 -17
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/denied-relation-visibility.test.d.ts +2 -0
  11. package/dist/access/denied-relation-visibility.test.d.ts.map +1 -0
  12. package/dist/access/denied-relation-visibility.test.js +114 -0
  13. package/dist/access/denied-relation-visibility.test.js.map +1 -0
  14. package/dist/access/engine.d.ts +54 -1
  15. package/dist/access/engine.d.ts.map +1 -1
  16. package/dist/access/engine.js +84 -0
  17. package/dist/access/engine.js.map +1 -1
  18. package/dist/access/engine.test.d.ts +2 -0
  19. package/dist/access/engine.test.d.ts.map +1 -0
  20. package/dist/access/engine.test.js +56 -0
  21. package/dist/access/engine.test.js.map +1 -0
  22. package/dist/access/errors.d.ts +62 -0
  23. package/dist/access/errors.d.ts.map +1 -1
  24. package/dist/access/errors.js +85 -2
  25. package/dist/access/errors.js.map +1 -1
  26. package/dist/access/field-access.d.ts +12 -0
  27. package/dist/access/field-access.d.ts.map +1 -1
  28. package/dist/access/field-access.js +22 -2
  29. package/dist/access/field-access.js.map +1 -1
  30. package/dist/access/field-access.test.js +66 -0
  31. package/dist/access/field-access.test.js.map +1 -1
  32. package/dist/access/field-visibility.d.ts +2 -1
  33. package/dist/access/field-visibility.d.ts.map +1 -1
  34. package/dist/access/field-visibility.js +107 -15
  35. package/dist/access/field-visibility.js.map +1 -1
  36. package/dist/access/index.d.ts +9 -2
  37. package/dist/access/index.d.ts.map +1 -1
  38. package/dist/access/index.js +11 -2
  39. package/dist/access/index.js.map +1 -1
  40. package/dist/access/multi-column-read-write.test.js.map +1 -1
  41. package/dist/access/query-validation.d.ts +31 -0
  42. package/dist/access/query-validation.d.ts.map +1 -1
  43. package/dist/access/query-validation.js +75 -16
  44. package/dist/access/query-validation.js.map +1 -1
  45. package/dist/access/relationship-count.d.ts +41 -3
  46. package/dist/access/relationship-count.d.ts.map +1 -1
  47. package/dist/access/relationship-count.js +44 -7
  48. package/dist/access/relationship-count.js.map +1 -1
  49. package/dist/access/relationship-count.test.js +62 -0
  50. package/dist/access/relationship-count.test.js.map +1 -1
  51. package/dist/access/synthetic-include-read.test.d.ts +2 -0
  52. package/dist/access/synthetic-include-read.test.d.ts.map +1 -0
  53. package/dist/access/synthetic-include-read.test.js +121 -0
  54. package/dist/access/synthetic-include-read.test.js.map +1 -0
  55. package/dist/config/types.d.ts +98 -32
  56. package/dist/config/types.d.ts.map +1 -1
  57. package/dist/context/hook-pipeline.d.ts +2 -2
  58. package/dist/context/hook-pipeline.d.ts.map +1 -1
  59. package/dist/context/hook-pipeline.js.map +1 -1
  60. package/dist/context/index.d.ts +35 -2
  61. package/dist/context/index.d.ts.map +1 -1
  62. package/dist/context/index.js +121 -27
  63. package/dist/context/index.js.map +1 -1
  64. package/dist/context/nested-operations.d.ts +2 -2
  65. package/dist/context/nested-operations.d.ts.map +1 -1
  66. package/dist/context/nested-operations.js +74 -15
  67. package/dist/context/nested-operations.js.map +1 -1
  68. package/dist/context/write-pipeline.d.ts +3 -2
  69. package/dist/context/write-pipeline.d.ts.map +1 -1
  70. package/dist/context/write-pipeline.js +31 -34
  71. package/dist/context/write-pipeline.js.map +1 -1
  72. package/dist/fields/index.d.ts +13 -1
  73. package/dist/fields/index.d.ts.map +1 -1
  74. package/dist/fields/index.js +50 -5
  75. package/dist/fields/index.js.map +1 -1
  76. package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
  77. package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
  78. package/dist/fields/relationship-foreign-key.test.js +18 -0
  79. package/dist/fields/relationship-foreign-key.test.js.map +1 -0
  80. package/dist/hooks/index.d.ts +16 -15
  81. package/dist/hooks/index.d.ts.map +1 -1
  82. package/dist/hooks/index.js.map +1 -1
  83. package/dist/index.d.ts +4 -1
  84. package/dist/index.d.ts.map +1 -1
  85. package/dist/index.js +14 -1
  86. package/dist/index.js.map +1 -1
  87. package/dist/lib/case-utils.d.ts +15 -0
  88. package/dist/lib/case-utils.d.ts.map +1 -1
  89. package/dist/lib/case-utils.js +21 -0
  90. package/dist/lib/case-utils.js.map +1 -1
  91. package/dist/lib/case-utils.test.js +23 -1
  92. package/dist/lib/case-utils.test.js.map +1 -1
  93. package/dist/lib/prisma-errors.d.ts +38 -0
  94. package/dist/lib/prisma-errors.d.ts.map +1 -0
  95. package/dist/lib/prisma-errors.js +53 -0
  96. package/dist/lib/prisma-errors.js.map +1 -0
  97. package/dist/mcp/constants.d.ts +23 -0
  98. package/dist/mcp/constants.d.ts.map +1 -0
  99. package/dist/mcp/constants.js +23 -0
  100. package/dist/mcp/constants.js.map +1 -0
  101. package/dist/mcp/field-schema.d.ts +8 -0
  102. package/dist/mcp/field-schema.d.ts.map +1 -0
  103. package/dist/mcp/field-schema.js +69 -0
  104. package/dist/mcp/field-schema.js.map +1 -0
  105. package/dist/mcp/handler.d.ts.map +1 -1
  106. package/dist/mcp/handler.js +76 -90
  107. package/dist/mcp/handler.js.map +1 -1
  108. package/dist/mcp/projection.d.ts +62 -0
  109. package/dist/mcp/projection.d.ts.map +1 -0
  110. package/dist/mcp/projection.js +390 -0
  111. package/dist/mcp/projection.js.map +1 -0
  112. package/dist/utils/password.d.ts +3 -1
  113. package/dist/utils/password.d.ts.map +1 -1
  114. package/dist/utils/password.js +4 -2
  115. package/dist/utils/password.js.map +1 -1
  116. package/package.json +2 -2
  117. package/src/access/access-filter.test.ts +1528 -18
  118. package/src/access/access-filter.ts +708 -42
  119. package/src/access/denied-relation-visibility.test.ts +173 -0
  120. package/src/access/engine.test.ts +67 -0
  121. package/src/access/engine.ts +109 -1
  122. package/src/access/errors.ts +97 -2
  123. package/src/access/field-access.test.ts +78 -0
  124. package/src/access/field-access.ts +34 -2
  125. package/src/access/field-visibility.ts +171 -25
  126. package/src/access/index.ts +25 -0
  127. package/src/access/multi-column-read-write.test.ts +3 -2
  128. package/src/access/query-validation.ts +136 -9
  129. package/src/access/relationship-count.test.ts +75 -0
  130. package/src/access/relationship-count.ts +53 -10
  131. package/src/access/synthetic-include-read.test.ts +179 -0
  132. package/src/config/types.ts +98 -32
  133. package/src/context/hook-pipeline.ts +4 -2
  134. package/src/context/index.ts +203 -42
  135. package/src/context/nested-operations.ts +96 -27
  136. package/src/context/write-pipeline.ts +60 -36
  137. package/src/fields/index.ts +55 -5
  138. package/src/fields/relationship-foreign-key.test.ts +29 -0
  139. package/src/hooks/index.ts +22 -15
  140. package/src/index.ts +17 -1
  141. package/src/lib/case-utils.test.ts +32 -0
  142. package/src/lib/case-utils.ts +26 -0
  143. package/src/lib/prisma-errors.ts +86 -0
  144. package/src/mcp/constants.ts +24 -0
  145. package/src/mcp/field-schema.ts +84 -0
  146. package/src/mcp/handler.ts +109 -107
  147. package/src/mcp/projection.ts +497 -0
  148. package/src/utils/password.ts +5 -3
  149. package/tests/access-relationships.test.ts +3 -3
  150. package/tests/access.test.ts +83 -0
  151. package/tests/context.test.ts +452 -17
  152. package/tests/field-types.test.ts +113 -0
  153. package/tests/hook-context-secured.test.ts +424 -0
  154. package/tests/mcp-fields-projection-access.test.ts +250 -0
  155. package/tests/mcp-handler.test.ts +695 -4
  156. package/tests/nested-access-and-hooks.test.ts +389 -0
  157. package/tests/nested-write-synthetic-relation.test.ts +437 -0
  158. package/tests/password-types.test.ts +28 -0
  159. package/tests/password.test.ts +16 -2
  160. package/tests/prisma-errors.test.ts +141 -0
  161. package/tests/singleton.test.ts +9 -1
  162. package/tests/to-one-include-access-filter.test.ts +172 -0
  163. package/tests/with-session.test.ts +243 -0
  164. package/tests/write-pipeline.test.ts +29 -0
  165. package/tsconfig.tsbuildinfo +1 -1
@@ -1,14 +1,30 @@
1
1
  import type { Session, AccessContext, PrismaFilter } from './types.js'
2
2
  import type { OpenSaasConfig, FieldConfig, ListConfig } from '../config/types.js'
3
- import { checkAccess, getRelatedListConfig } from './engine.js'
3
+ import {
4
+ checkAccess,
5
+ getRelatedListConfig,
6
+ resolveSyntheticReverseRelation,
7
+ listSyntheticReverseRelationNames,
8
+ } from './engine.js'
4
9
  import { READ_INCLUDE_MAX_DEPTH } from './depth-limits.js'
5
- import { AccessScopeDepthExceededError, RelationFilterAccessDeniedError } from './errors.js'
10
+ import {
11
+ AccessScopeDepthExceededError,
12
+ RelationFilterAccessDeniedError,
13
+ UndeclaredCountKeyError,
14
+ UndeclaredIncludeKeyError,
15
+ } from './errors.js'
6
16
  import {
7
17
  LOGICAL_OPERATORS,
8
18
  RELATION_QUANTIFIERS,
9
19
  resolveQueryField,
20
+ validateQueryFieldReadAccess,
21
+ validateQueryKeys,
10
22
  walkWhereReadAccess,
23
+ type ResolveSyntheticRelation,
24
+ type SyntheticRelationTarget,
11
25
  } from './query-validation.js'
26
+ import { isToManyRelationshipField, resolveCountAccessEntryForList } from './relationship-count.js'
27
+ import { getDbKey } from '../lib/case-utils.js'
12
28
 
13
29
  /**
14
30
  * Access Filter — phase 1 of the two-phase read (pre-query).
@@ -34,10 +50,91 @@ import {
34
50
  * named a nested `include` there too. A relation nobody named never has its
35
51
  * list's `query` access evaluated at all — there is no separate "build the
36
52
  * whole tree, then reconcile against what was asked for" pass to walk it.
53
+ *
54
+ * **To-one relations are scoped after the query, not inside it (issue #974).**
55
+ * Prisma accepts a nested `where` on an `include` entry only for a to-**many**
56
+ * relation; the same shape on a to-**one** relation raises
57
+ * `PrismaClientValidationError`. So for a to-one relation whose related
58
+ * list's `query` access resolves to a filter, `buildAccessScopedInclude`
59
+ * does NOT attach that filter as `where` — it fetches the row unscoped and
60
+ * records the filter in the returned `toOneAccessFilters` tree instead.
61
+ * `resolveToOneAccessVisibility` (below) turns that tree into the set of
62
+ * related ids the session may actually see, via ONE batched `id IN (...)`
63
+ * existence check per (relation, nesting level) across every row in the
64
+ * read — never a per-row query, and never a hand-rolled evaluation of the
65
+ * access filter (it is handed to Prisma exactly as `checkAccess` produced
66
+ * it). `field-visibility.ts`'s `filterReadableFields` is where the result
67
+ * actually becomes `null` for a row the check excludes — see its module doc.
68
+ * A to-one relation whose related list's `query` access is `true` (no
69
+ * filter) or `false` (denied outright) needs no existence check at all: the
70
+ * former is left as `true` in `include`, unchanged from before; the latter
71
+ * is recorded as `{ kind: 'denied' }`, and `field-visibility.ts` forces the
72
+ * key to `null` without ever asking Prisma for it.
73
+ *
74
+ * **A denied to-many relation is recorded the same way (issue #1103).** An
75
+ * outright `query` denial (`=== false`) drops the relation from `include`
76
+ * regardless of arity — a to-many relation has no more of a `where` shape
77
+ * for "denied" than a to-one one does. Before this, only the to-one branch
78
+ * recorded anything; a denied to-many key was simply absent from the raw
79
+ * row, and `filterReadableFields`'s main loop — which only ever visits keys
80
+ * `Object.entries(workingItem)` actually contains — had nothing to force it
81
+ * with, so it stayed missing from the result instead of coming back `[]`.
82
+ * Now both arities record `{ kind: 'denied' }` in `toOneAccessFilters`, and
83
+ * `field-visibility.ts`'s post-query pass forces the key present using the
84
+ * field's own declared arity: `null` for a to-one relation, `[]` for a
85
+ * to-many one.
86
+ *
87
+ * **A synthetic back-relation is a declared relationship wherever a caller
88
+ * can name one (issue #1082).** A list-only `ref` (`ref: 'Other'`,
89
+ * no target field) makes schema generation synthesize a back-relation on
90
+ * `Other` (`from_<List>_<field>`) because the ORM requires an opposite field
91
+ * there — but no list config declares it, so it is absent from `Other`'s own
92
+ * `fieldConfigs`. An include key that fails the declared-relationship test is
93
+ * resolved via `resolveSyntheticReverseRelation` before being treated as
94
+ * unrecognised; a hit is scoped exactly like the declared relationship field
95
+ * it stands for (its owning list's `query` access, folded `where`, nested
96
+ * recursion, depth), always as a to-many relation — a list-only ref has one
97
+ * construction site and no arity branch. A key that resolves to neither a
98
+ * declared relationship nor a synthetic one is **rejected** (`_count` is
99
+ * handled separately, scoped per named relation rather than resolved as a
100
+ * single relationship key — see `buildAccessScopedCountSelect` below and
101
+ * issue #1087), restoring this module's own denial rule for the one key
102
+ * shape that used to fail open.
103
+ *
104
+ * **A `where`/`orderBy` a caller nests inside an `include` entry is validated
105
+ * against the RELATED list, before it is AND-folded (issue #1092).** Before
106
+ * this, the AND-fold below merged a caller's nested `where` with the access
107
+ * filter and passed the result through unexamined — a key naming a field the
108
+ * session cannot read reached Prisma, a probing oracle over exactly the
109
+ * fields #915 exists to close one level up. `validateQueryKeys`/
110
+ * `validateQueryFieldReadAccess` (`query-validation.ts`, #912/#915) are
111
+ * reused as-is, called here against `relatedConfig` instead of the current
112
+ * list — the same two checks the top-level `where`/`orderBy` already gets.
113
+ * The one addition this position needs: an unresolved key is tried against
114
+ * `resolveSyntheticReverseRelation` (via `validateQueryKeys`'s
115
+ * `resolveSyntheticRelation` hook) before being rejected, so a nested
116
+ * predicate naming a synthetic back-relation resolves rather than throwing.
117
+ *
118
+ * Those two checks only reach the entry's OWN top-level keys. A relation
119
+ * quantifier (`some`/`every`/`none`/`is`/`isNot`) nested inside that `where`
120
+ * names a list one hop further away, which needs the same treatment the
121
+ * top-level `where` already gets from `buildAccessScopedWhere` (#916,
122
+ * below): scope it by THAT list's own `query` access and check its fields'
123
+ * read access, recursing through every further hop. Skipping this for a
124
+ * to-many entry would just move #1092's oracle one hop further out instead
125
+ * of closing it — `buildAccessScopedWhere` is called on `requestedEntry.where`
126
+ * before the AND-fold, with its own `resolveSyntheticRelation` param (below)
127
+ * so a synthetic key stays resolved at this deeper level too.
37
128
  */
38
129
 
39
130
  /** 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 }
131
+ type IncludeEntryObject = {
132
+ where?: PrismaFilter
133
+ include?: Record<string, unknown>
134
+ take?: number
135
+ orderBy?: PrismaFilter | PrismaFilter[]
136
+ skip?: number
137
+ }
41
138
 
42
139
  /** A plain object — excludes `null` and arrays, which `typeof x === 'object'` alone would admit. */
43
140
  function isPlainObject(value: unknown): value is Record<string, unknown> {
@@ -51,16 +148,22 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
51
148
  * own type before trusting it, rather than casting the whole value wholesale.
52
149
  *
53
150
  * A numeric `take` on a to-many relation include (a caller-supplied row bound,
54
- * issue #752) is carried through: it only ever NARROWS the fetched rows and can
55
- * never widen past the access `where`, so preserving it is access-neutral.
151
+ * issue #752), and a caller-supplied `orderBy`/`skip` (#851), are carried
152
+ * through the same way: none of the three can ever widen the result past the
153
+ * access `where` — they only narrow or reorder rows the access filter already
154
+ * admits — so preserving them is access-neutral.
56
155
  */
57
156
  function asEntryObject(value: unknown): IncludeEntryObject | null {
58
157
  if (!isPlainObject(value)) return null
59
- const { where, include, take } = value
158
+ const { where, include, take, orderBy, skip } = value
60
159
  const entry: IncludeEntryObject = {}
61
160
  if (isPlainObject(where)) entry.where = where
62
161
  if (isPlainObject(include)) entry.include = include
63
162
  if (typeof take === 'number') entry.take = take
163
+ if (isPlainObject(orderBy) || Array.isArray(orderBy)) {
164
+ entry.orderBy = orderBy as PrismaFilter | PrismaFilter[]
165
+ }
166
+ if (typeof skip === 'number') entry.skip = skip
64
167
  return entry
65
168
  }
66
169
 
@@ -81,25 +184,321 @@ function andWhere(
81
184
  return accessWhere ?? callerWhere
82
185
  }
83
186
 
187
+ /**
188
+ * One relation's recorded access filter, or an outright denial — see the
189
+ * module doc's "To-one relations" section. `kind: 'scoped'` is to-one only
190
+ * (a to-many filter is attached as Prisma `where` instead, never recorded
191
+ * here); `kind: 'denied'` is recorded for BOTH arities (issue #1103) — a
192
+ * to-many relation has no `where`-based way to record "zero rows, and the
193
+ * key itself absent" either, so it shares the same post-query mechanism a
194
+ * denied to-one already used.
195
+ */
196
+ export type ToOneAccessFilterEntry =
197
+ { kind: 'scoped'; relatedListName: string; accessWhere: PrismaFilter } | { kind: 'denied' }
198
+
199
+ /**
200
+ * Which relations, at which nesting level of an `include`, need a post-query
201
+ * fixup rather than a Prisma-side `where` — a to-one relation whose related
202
+ * list's `query` access resolved to a filter (`kind: 'scoped'`) or a denial
203
+ * (`kind: 'denied'`), Prisma cannot express either as a nested `where` on a
204
+ * to-one include; a to-many relation whose related list denies `query`
205
+ * access outright (`kind: 'denied'` only — a to-many filter is attached as
206
+ * `where` and never reaches this tree) is dropped from `include` entirely,
207
+ * so nothing marks its key present in the raw row either (issue #1103).
208
+ * `resolveToOneAccessVisibility` consumes this tree; `filterReadableFields`
209
+ * (`field-visibility.ts`) applies its result — forcing a denied key to `null`
210
+ * for a to-one relation, `[]` for a to-many one.
211
+ */
212
+ export type ToOneAccessFilterTree = {
213
+ /** Relation keys at THIS level needing a post-query fixup. */
214
+ filters: Record<string, ToOneAccessFilterEntry>
215
+ /** Per-key trees for relations present in the include for other reasons, whose own nested include may contain further filters. */
216
+ nested: Record<string, ToOneAccessFilterTree>
217
+ }
218
+
219
+ export function emptyToOneAccessFilterTree(): ToOneAccessFilterTree {
220
+ return { filters: {}, nested: {} }
221
+ }
222
+
223
+ function isToOneAccessFilterTreeEmpty(tree: ToOneAccessFilterTree): boolean {
224
+ return Object.keys(tree.filters).length === 0 && Object.keys(tree.nested).length === 0
225
+ }
226
+
227
+ /**
228
+ * Whether a relationship field is to-one (at most one related row) rather
229
+ * than to-many. Exported so `field-visibility.ts` can pick the same `null`
230
+ * (to-one) vs `[]` (to-many) shape for a denied relation's forced value
231
+ * (issue #1103) that this module used to decide whether to record the
232
+ * denial in the first place — one source of truth for arity, not two.
233
+ */
234
+ export function isToOneRelationship(fieldConfig: FieldConfig): boolean {
235
+ return !('many' in fieldConfig && fieldConfig.many === true)
236
+ }
237
+
238
+ /**
239
+ * Which `_count.select` keys, at which nesting level of an `include`, were
240
+ * denied outright by their related list's `query` access — omitted from the
241
+ * `_count.select` sent to Prisma (issue #1087), so the row Prisma returns
242
+ * either lacks the key entirely or lacks a `_count` object at all. Consumed
243
+ * post-query by `filterReadableFields` (`field-visibility.ts`), which injects
244
+ * `0` for each — a count is a session-relative value, and `0` is what "no
245
+ * visible rows" means for it, never an absent key (mirroring the to-one
246
+ * `null` injection this module already does for issue #974, though a denied
247
+ * count needs no existence check: `0` requires no query at all).
248
+ */
249
+ export type CountAccessDenialTree = {
250
+ /** `_count.select` keys denied at THIS level. */
251
+ keys: Set<string>
252
+ /** Per-relation trees for relations present in the include for other reasons, whose own nested include may contain a further `_count`. */
253
+ nested: Record<string, CountAccessDenialTree>
254
+ }
255
+
256
+ export function emptyCountAccessDenialTree(): CountAccessDenialTree {
257
+ return { keys: new Set(), nested: {} }
258
+ }
259
+
260
+ function isCountAccessDenialTreeEmpty(tree: CountAccessDenialTree): boolean {
261
+ return tree.keys.size === 0 && Object.keys(tree.nested).length === 0
262
+ }
263
+
264
+ /**
265
+ * Normalize a caller's `_count` include value to the `_count.select` map it
266
+ * names. `true` (Prisma's "count every relation" shorthand) expands to every
267
+ * countable relation Prisma itself carries on this model — every DECLARED
268
+ * to-many relationship, plus every synthetic back-relation a list-only `ref`
269
+ * elsewhere in the config synthesizes onto it (issue #1082): Prisma's own
270
+ * `_count: true` has always counted both, and dropping the synthetic ones
271
+ * here would silently stop counting a relation the caller used to get a
272
+ * (previously unscoped) count for. Returns `null` for a shape that requests
273
+ * nothing countable (`false`, or an object with no usable `select`).
274
+ */
275
+ function normalizeCountSelect(
276
+ requestedValue: unknown,
277
+ fieldConfigs: Record<string, FieldConfig>,
278
+ listKey: string,
279
+ config: OpenSaasConfig,
280
+ ): Record<string, unknown> | null {
281
+ if (requestedValue === true) {
282
+ const expanded: Record<string, unknown> = {}
283
+ for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
284
+ if (isToManyRelationshipField(fieldConfig)) expanded[fieldName] = true
285
+ }
286
+ for (const syntheticName of listSyntheticReverseRelationNames(listKey, config)) {
287
+ expanded[syntheticName] = true
288
+ }
289
+ return expanded
290
+ }
291
+ if (isPlainObject(requestedValue) && isPlainObject(requestedValue.select)) {
292
+ return requestedValue.select
293
+ }
294
+ return null
295
+ }
296
+
297
+ /**
298
+ * Scope a caller-supplied `_count` include value by each named relation's own
299
+ * `query` access — the `_count` counterpart to the rest of this module's
300
+ * relation scoping (issue #1087, closing the one key `buildAccessScopedInclude`
301
+ * used to allowlist through unscoped, #1082's "Out of scope").
302
+ *
303
+ * For each key in the caller's `_count.select` (or, for bare `_count: true`,
304
+ * every declared to-many relation — see `normalizeCountSelect`):
305
+ * - Not a declared to-many relationship and not a synthetic back-relation
306
+ * (#1082 — always genuinely countable) → THROWN as
307
+ * `UndeclaredCountKeyError`, matching `buildAccessScopedInclude`'s own
308
+ * rejection for the ordinary walk. A declared to-many relationship whose
309
+ * `ref` cannot be resolved is skipped instead, matching that same walk's
310
+ * handling of a config-level dangling ref (not a caller error).
311
+ * - The counting list's OWN field-level `read` access on the relationship
312
+ * field being counted denies it, or the related list's `query` access
313
+ * denies it (`=== false`) → omitted from the select sent to Prisma and
314
+ * added to the returned `deniedKeys`, so `filterReadableFields` can inject
315
+ * `0` post-query (a count is session-relative; denial doesn't mean "no such
316
+ * relation"). The field-level check has nothing to evaluate for a synthetic
317
+ * back-relation (no field of its own on this list) and is skipped for it —
318
+ * `resolveCountAccessEntryForList` (issue #1111). Both checks run BEFORE
319
+ * any caller-supplied nested `where` is validated — validating first would
320
+ * let a caller who cannot read a single row of the related list learn its
321
+ * field names and field-level read rules from a thrown `ValidationError`
322
+ * alone, reopening the exact oracle #915/ADR-0031 closed for a top-level
323
+ * predicate.
324
+ * - Otherwise → the caller-supplied nested `where` at that key (if any) is
325
+ * key- and read-access-validated against the RELATED list via the same
326
+ * `validateQueryKeys`/`validateQueryFieldReadAccess` primitives
327
+ * `createFindMany` already runs on a top-level `where` (#912/#915), then
328
+ * run through `buildAccessScopedWhere` — the same fold `createFindMany`
329
+ * applies to a top-level `where` (#916) — so a relation filter nested
330
+ * inside IT (e.g. `_count.select.posts.where.comments.some`) is scoped by
331
+ * THAT further list's own `query` access too, not just the counted
332
+ * relation's. The result is AND-combined with the counted relation's own
333
+ * access filter (if any) — reusing `andWhere`, never replacing the
334
+ * caller's condition, mirroring `buildAccessScopedInclude` itself.
335
+ */
336
+ async function buildAccessScopedCountSelect(
337
+ requestedValue: unknown,
338
+ fieldConfigs: Record<string, FieldConfig>,
339
+ args: { session: Session | null; context: AccessContext },
340
+ config: OpenSaasConfig,
341
+ listKey: string,
342
+ ): Promise<{ select: Record<string, unknown> | undefined; deniedKeys: Set<string> }> {
343
+ const requestedSelect = normalizeCountSelect(requestedValue, fieldConfigs, listKey, config)
344
+ const deniedKeys = new Set<string>()
345
+ if (!requestedSelect) return { select: undefined, deniedKeys }
346
+
347
+ const select: Record<string, unknown> = {}
348
+
349
+ for (const [key, entryValue] of Object.entries(requestedSelect)) {
350
+ // A caller can explicitly exclude a key from the `true`-expanded set the
351
+ // same way Prisma's own `select` excludes a field.
352
+ if (entryValue === false) continue
353
+
354
+ const fieldConfig = fieldConfigs[key]
355
+ const isDeclaredToMany = isToManyRelationshipField(fieldConfig)
356
+
357
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
358
+ let relatedConfig: { listName: string; listConfig: ListConfig<any> } | null = null
359
+ if (isDeclaredToMany && fieldConfig && 'ref' in fieldConfig) {
360
+ relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
361
+ }
362
+ if (!relatedConfig && !isDeclaredToMany) {
363
+ const synthetic = resolveSyntheticReverseRelation(key, listKey, config)
364
+ if (synthetic) {
365
+ relatedConfig = {
366
+ listName: synthetic.sourceListName,
367
+ listConfig: synthetic.sourceListConfig,
368
+ }
369
+ }
370
+ }
371
+ if (!relatedConfig) {
372
+ // A declared to-many field whose `ref` didn't resolve is a config
373
+ // issue, not a caller error — skip it exactly like the ordinary
374
+ // include walk does for the same case.
375
+ if (isDeclaredToMany) continue
376
+ throw new UndeclaredCountKeyError(listKey, key)
377
+ }
378
+
379
+ // `fieldConfig` is undefined for a synthetic back-relation (#1082) — it has
380
+ // no field of its own on THIS list, so there is no field-level `read`
381
+ // access to fold in; `resolveCountAccessEntryForList` treats a missing
382
+ // `fieldAccess` as exempt, matching the ordinary include path's own lack
383
+ // of a field-level gate for a synthetic key (issue #1111).
384
+ const accessEntry = await resolveCountAccessEntryForList(
385
+ relatedConfig.listConfig,
386
+ args,
387
+ fieldConfig?.access,
388
+ )
389
+
390
+ // Denial is checked BEFORE the caller's nested `where` is validated —
391
+ // mirroring `buildAccessScopedWhere`'s own ordering below. A fully denied
392
+ // relation counts `0` no matter what `where` the caller supplied, so
393
+ // validating it first would let a caller who cannot read a single row of
394
+ // the related list learn its field names and field-level read rules from
395
+ // a `ValidationError`'s message alone — the exact oracle #915/ADR-0031
396
+ // closed for a top-level predicate, reopened here if this ran first.
397
+ if (accessEntry.kind === 'denied') {
398
+ deniedKeys.add(key)
399
+ continue
400
+ }
401
+
402
+ const requestedWhere =
403
+ isPlainObject(entryValue) && isPlainObject(entryValue.where)
404
+ ? (entryValue.where as Record<string, unknown>)
405
+ : undefined
406
+
407
+ let scopedRequestedWhere: Record<string, unknown> | undefined
408
+ if (requestedWhere) {
409
+ // Mirrors `buildAccessScopedInclude`'s own `resolveSyntheticRelation`
410
+ // closure (#1092/#916) — a synthetic back-relation (#1082) named inside
411
+ // this count entry's own `where` resolves against its source list
412
+ // rather than being rejected as undeclared, at every hop.
413
+ const resolveSyntheticRelation = (
414
+ syntheticKey: string,
415
+ fromListName: string,
416
+ ): SyntheticRelationTarget | null => {
417
+ const synthetic = resolveSyntheticReverseRelation(syntheticKey, fromListName, config)
418
+ return synthetic
419
+ ? { listConfig: synthetic.sourceListConfig, listName: synthetic.sourceListName }
420
+ : null
421
+ }
422
+ validateQueryKeys({
423
+ where: requestedWhere,
424
+ listConfig: relatedConfig.listConfig,
425
+ listName: relatedConfig.listName,
426
+ config,
427
+ isSudo: false,
428
+ resolveSyntheticRelation,
429
+ })
430
+ await validateQueryFieldReadAccess({
431
+ where: requestedWhere,
432
+ listConfig: relatedConfig.listConfig,
433
+ listName: relatedConfig.listName,
434
+ session: args.session,
435
+ context: args.context,
436
+ isSudo: false,
437
+ })
438
+ // A relation filter (`some`/`every`/`none`/`is`/`isNot`) nested inside
439
+ // the caller's own `where` names a THIRD list one hop further out —
440
+ // e.g. `_count.select.posts.where.comments.some`. Validation above only
441
+ // checked keys against the counted relation's OWN fields; without this,
442
+ // that nested relation would reach Prisma unscoped by ITS list's
443
+ // `query` access, letting the resulting count reveal whether
444
+ // inaccessible rows over there exist. `buildAccessScopedWhere` is the
445
+ // same fold `createFindMany` runs on an ordinary top-level `where`
446
+ // (#916) — reused here rather than re-derived.
447
+ scopedRequestedWhere = (await buildAccessScopedWhere(
448
+ requestedWhere,
449
+ relatedConfig.listConfig,
450
+ relatedConfig.listName,
451
+ config,
452
+ args,
453
+ resolveSyntheticRelation,
454
+ )) as Record<string, unknown> | undefined
455
+ }
456
+
457
+ const scopedWhere = andWhere(
458
+ accessEntry.kind === 'scoped' ? accessEntry.where : undefined,
459
+ scopedRequestedWhere,
460
+ )
461
+ select[key] = scopedWhere ? { where: scopedWhere } : true
462
+ }
463
+
464
+ return { select: Object.keys(select).length > 0 ? select : undefined, deniedKeys }
465
+ }
466
+
84
467
  /**
85
468
  * Build the access-scoped `include` for exactly the relations a read
86
469
  * requested, recursing only into branches `requestedInclude` itself names.
87
470
  *
88
471
  * 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).
472
+ * - A declared field that isn't a relationship (scalar, virtual, …) → access
473
+ * control does not govern it; passed through unchanged (a virtual key is
474
+ * stripped later by `stripVirtualFieldsFromInclude`, #628).
475
+ * - Not declared at all → resolved via `resolveSyntheticReverseRelation`
476
+ * (the synthetic-back-relation case above); `_count` is scoped by
477
+ * `buildAccessScopedCountSelect` (issue #1087 — each named relation's own
478
+ * `query` access, exactly like any other relation this walk scopes; a
479
+ * denied one is recorded for `filterReadableFields` to inject `0` for,
480
+ * post-query, since Prisma cannot be asked for a guaranteed `0`); anything
481
+ * else throws `UndeclaredIncludeKeyError` rather than reaching the
482
+ * database unscoped.
92
483
  * - A declared relationship whose related list's `query` access denies it
93
484
  * (`=== false`) → dropped entirely, no matter what the request asked for
94
485
  * 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.
486
+ * control chooses *whether* and *with what filter*. This denial is also
487
+ * recorded in `toOneAccessFilters` (`kind: 'denied'`), for either arity, so
488
+ * `filterReadableFields` can still surface an explicit `null` (to-one,
489
+ * issue #974) or `[]` (to-many, issue #1103) for it rather than an absent
490
+ * key.
491
+ * - Otherwise, for a to-**many** relation the access `where` is
492
+ * AND-combined with any caller-supplied nested `where` (never replaced
493
+ * the other half of #566), and a caller-supplied `take` rides through
494
+ * unchanged (#752). For a to-**one** relation → the access filter (if any)
495
+ * is recorded in `toOneAccessFilters` instead of attached as `where`,
496
+ * because Prisma only accepts a nested `where` on a to-many include
497
+ * (issue #974) — the entry itself never carries a `where` for a to-one key.
498
+ * - Either way — the "One hop" rule (ADR-0026) — nested relations are scoped
499
+ * ONLY if `requestedInclude` itself named a nested `include` here. A bare
500
+ * relation (or one with no nested `include`) fetches its own columns and
501
+ * stops: no recursive call, no access evaluation on anything beneath it.
103
502
  *
104
503
  * **Depth is a cost limit, not a cycle guard (ADR-0026).** A `requestedInclude`
105
504
  * is always a finite literal — the caller's own object, or
@@ -120,27 +519,72 @@ export async function buildAccessScopedInclude(
120
519
  config: OpenSaasConfig,
121
520
  listKey: string,
122
521
  depth: number = 0,
123
- ): Promise<Record<string, unknown>> {
522
+ ): Promise<{
523
+ include: Record<string, unknown>
524
+ toOneAccessFilters: ToOneAccessFilterTree
525
+ countDenials: CountAccessDenialTree
526
+ }> {
124
527
  const requestedKeys = Object.keys(requestedInclude)
125
528
  if (depth >= READ_INCLUDE_MAX_DEPTH && requestedKeys.length > 0) {
126
529
  throw new AccessScopeDepthExceededError(listKey, requestedKeys[0], depth)
127
530
  }
128
531
 
129
532
  const result: Record<string, unknown> = {}
533
+ const toOneAccessFilters = emptyToOneAccessFilterTree()
534
+ const countDenials = emptyCountAccessDenialTree()
130
535
 
131
536
  for (const [relationName, requestedValue] of Object.entries(requestedInclude)) {
537
+ // A caller can explicitly opt a relation OUT of a Prisma include with
538
+ // `false` (its `Include` type allows a bare `boolean`), or end up with an
539
+ // `undefined` value from a conditionally-built object
540
+ // (`{ posts: cond ? true : undefined }`) — Prisma treats both the same as
541
+ // the key being absent. Treat them identically here too, before this key
542
+ // ever reaches access evaluation: an explicitly-disabled relation was
543
+ // never actually requested, so it must not be evaluated, dropped as
544
+ // "denied", or recorded as needing a post-query fixup (issue #1103 code
545
+ // review) — any of those would surface a value (`[]`/`null`) for a key
546
+ // the caller deliberately excluded.
547
+ if (requestedValue === false || requestedValue === undefined) continue
548
+
132
549
  const fieldConfig = fieldConfigs[relationName]
133
550
  const isDeclaredRelationship =
134
551
  fieldConfig?.type === 'relationship' && 'ref' in fieldConfig && !!fieldConfig.ref
135
552
 
136
- if (!isDeclaredRelationship) {
553
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
554
+ let relatedConfig: { listName: string; listConfig: ListConfig<any> } | null
555
+ let isToOne: boolean
556
+
557
+ if (isDeclaredRelationship) {
558
+ relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
559
+ if (!relatedConfig) continue
560
+ isToOne = isToOneRelationship(fieldConfig)
561
+ } else if (fieldConfig) {
562
+ // A declared field that is not a relationship — access control does
563
+ // not govern it here; passed through unchanged.
137
564
  result[relationName] = requestedValue
138
565
  continue
566
+ } else if (relationName === '_count') {
567
+ const { select, deniedKeys } = await buildAccessScopedCountSelect(
568
+ requestedValue,
569
+ fieldConfigs,
570
+ args,
571
+ config,
572
+ listKey,
573
+ )
574
+ if (select) result[relationName] = { select }
575
+ if (deniedKeys.size > 0) countDenials.keys = deniedKeys
576
+ continue
577
+ } else {
578
+ const synthetic = resolveSyntheticReverseRelation(relationName, listKey, config)
579
+ if (!synthetic) {
580
+ throw new UndeclaredIncludeKeyError(listKey, relationName)
581
+ }
582
+ relatedConfig = { listName: synthetic.sourceListName, listConfig: synthetic.sourceListConfig }
583
+ // Always to-many — a list-only ref has one construction site and no
584
+ // arity branch, and one-to-one is structurally impossible for it.
585
+ isToOne = false
139
586
  }
140
587
 
141
- const relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
142
- if (!relatedConfig) continue
143
-
144
588
  const queryAccess = relatedConfig.listConfig.access?.operation?.query
145
589
  const accessResult = await checkAccess(queryAccess, {
146
590
  session: args.session,
@@ -148,16 +592,76 @@ export async function buildAccessScopedInclude(
148
592
  })
149
593
 
150
594
  if (accessResult === false) {
595
+ // Recorded for either arity (issue #1103) — a to-many relation is
596
+ // dropped from `include` here exactly like a to-one one, and needs the
597
+ // same post-query fixup so its key comes back `[]`, not silently
598
+ // absent from the row.
599
+ toOneAccessFilters.filters[relationName] = { kind: 'denied' }
151
600
  continue
152
601
  }
153
602
 
154
603
  const accessWhere = typeof accessResult === 'object' ? accessResult : undefined
155
604
  const requestedEntry = asEntryObject(requestedValue)
156
- const mergedWhere = andWhere(accessWhere, requestedEntry?.where)
605
+
606
+ // #1092 — a nested `where`/`orderBy` gets the same #912/#915 checks the
607
+ // top-level `where`/`orderBy` already gets, resolved against the RELATED
608
+ // list (`relatedConfig`) rather than the current one, and run only once
609
+ // the relation is known to be accessible at all (same ordering reason as
610
+ // the top-level checks: don't leak a field's name/read-gating status to a
611
+ // caller who has zero access to the relation to begin with).
612
+ const resolveSyntheticRelation = (
613
+ key: string,
614
+ fromListName: string,
615
+ ): SyntheticRelationTarget | null => {
616
+ const synthetic = resolveSyntheticReverseRelation(key, fromListName, config)
617
+ return synthetic
618
+ ? { listConfig: synthetic.sourceListConfig, listName: synthetic.sourceListName }
619
+ : null
620
+ }
621
+ validateQueryKeys({
622
+ where: requestedEntry?.where,
623
+ orderBy: requestedEntry?.orderBy,
624
+ listConfig: relatedConfig.listConfig,
625
+ listName: relatedConfig.listName,
626
+ config,
627
+ isSudo: false,
628
+ resolveSyntheticRelation,
629
+ })
630
+ await validateQueryFieldReadAccess({
631
+ where: requestedEntry?.where,
632
+ orderBy: requestedEntry?.orderBy,
633
+ listConfig: relatedConfig.listConfig,
634
+ listName: relatedConfig.listName,
635
+ session: args.session,
636
+ context: args.context,
637
+ isSudo: false,
638
+ })
639
+
640
+ // The two checks above only reach the entry's own top-level keys — a
641
+ // relation quantifier (`some`/`every`/`none`/`is`/`isNot`) nested inside
642
+ // this `where` names a DEEPER related list, which needs the same
643
+ // treatment the top-level `where` already gets from `buildAccessScopedWhere`
644
+ // (#916): scope it by that deeper list's own `query` access and check ITS
645
+ // fields' read access, recursing through every further hop. Only for
646
+ // to-many — a to-one entry never carries `requestedEntry.where` through to
647
+ // Prisma at all (see below), so there is nothing here to scope.
648
+ const scopedRequestedWhere =
649
+ !isToOne && requestedEntry?.where !== undefined
650
+ ? ((await buildAccessScopedWhere(
651
+ requestedEntry.where,
652
+ relatedConfig.listConfig,
653
+ relatedConfig.listName,
654
+ config,
655
+ args,
656
+ resolveSyntheticRelation,
657
+ )) as PrismaFilter)
658
+ : requestedEntry?.where
157
659
 
158
660
  let nestedInclude: Record<string, unknown> | undefined
661
+ let nestedToOneFilters: ToOneAccessFilterTree | undefined
662
+ let nestedCountDenials: CountAccessDenialTree | undefined
159
663
  if (requestedEntry?.include) {
160
- nestedInclude = await buildAccessScopedInclude(
664
+ const nested = await buildAccessScopedInclude(
161
665
  requestedEntry.include,
162
666
  relatedConfig.listConfig.fields,
163
667
  args,
@@ -165,17 +669,137 @@ export async function buildAccessScopedInclude(
165
669
  relatedConfig.listName,
166
670
  depth + 1,
167
671
  )
672
+ nestedInclude = nested.include
673
+ nestedToOneFilters = nested.toOneAccessFilters
674
+ nestedCountDenials = nested.countDenials
168
675
  }
169
676
 
170
- const entry: { where?: PrismaFilter; include?: Record<string, unknown>; take?: number } = {}
171
- if (mergedWhere) entry.where = mergedWhere
677
+ const entry: IncludeEntryObject = {}
678
+ if (isToOne) {
679
+ if (accessWhere) {
680
+ toOneAccessFilters.filters[relationName] = {
681
+ kind: 'scoped',
682
+ relatedListName: relatedConfig.listName,
683
+ accessWhere,
684
+ }
685
+ }
686
+ } else {
687
+ const mergedWhere = andWhere(accessWhere, scopedRequestedWhere)
688
+ if (mergedWhere) entry.where = mergedWhere
689
+ if (requestedEntry?.take !== undefined) entry.take = requestedEntry.take
690
+ if (requestedEntry?.orderBy !== undefined) entry.orderBy = requestedEntry.orderBy
691
+ if (requestedEntry?.skip !== undefined) entry.skip = requestedEntry.skip
692
+ }
172
693
  if (nestedInclude && Object.keys(nestedInclude).length > 0) entry.include = nestedInclude
173
- if (requestedEntry?.take !== undefined) entry.take = requestedEntry.take
694
+ if (nestedToOneFilters && !isToOneAccessFilterTreeEmpty(nestedToOneFilters)) {
695
+ toOneAccessFilters.nested[relationName] = nestedToOneFilters
696
+ }
697
+ if (nestedCountDenials && !isCountAccessDenialTreeEmpty(nestedCountDenials)) {
698
+ countDenials.nested[relationName] = nestedCountDenials
699
+ }
174
700
 
175
701
  result[relationName] = Object.keys(entry).length > 0 ? entry : true
176
702
  }
177
703
 
178
- return result
704
+ return { include: result, toOneAccessFilters, countDenials }
705
+ }
706
+
707
+ /**
708
+ * One relation's resolved post-query visibility — see
709
+ * `resolveToOneAccessVisibility`. `kind: 'visible'` (an existence check
710
+ * against a set of ids) only ever arises for a to-one relation, since only a
711
+ * to-one `kind: 'scoped'` filter entry produces one; `kind: 'denied'` passes
712
+ * straight through unresolved for either arity.
713
+ */
714
+ export type ToOneVisibility = { kind: 'denied' } | { kind: 'visible'; ids: ReadonlySet<string> }
715
+
716
+ /** The resolved counterpart to {@link ToOneAccessFilterTree}, produced by `resolveToOneAccessVisibility`. */
717
+ export type ToOneAccessVisibilityTree = {
718
+ filters: Record<string, ToOneVisibility>
719
+ nested: Record<string, ToOneAccessVisibilityTree>
720
+ }
721
+
722
+ export function emptyToOneAccessVisibilityTree(): ToOneAccessVisibilityTree {
723
+ return { filters: {}, nested: {} }
724
+ }
725
+
726
+ /**
727
+ * Resolve a `ToOneAccessFilterTree` against the RAW rows Prisma already
728
+ * fetched (unscoped for the flagged to-one relations — see
729
+ * `buildAccessScopedInclude`) into the set of related ids the session may
730
+ * actually see, one batched `id IN (...)` existence check per (relation,
731
+ * nesting level) across every row in `items` — never once per row.
732
+ *
733
+ * For each `filters` entry at a level:
734
+ * - `kind: 'denied'` → carried straight through; no query, nothing to check.
735
+ * - `kind: 'scoped'` → every id present at this key across ALL of `items` is
736
+ * collected first (an empty set skips the query entirely — nothing to
737
+ * check), then ONE `findMany` through the RAW `prisma` client (not
738
+ * `context.db`, which would re-evaluate the same access-control function a
739
+ * second time) asks which of those ids also satisfy `accessWhere` — the
740
+ * exact `PrismaFilter` `checkAccess` already produced, handed to Prisma
741
+ * unmodified rather than interpreted by hand.
742
+ *
743
+ * Recurses into `nested` by flattening the related items reached through
744
+ * each key across every row in `items` (a to-many hop contributes every one
745
+ * of its rows; a to-one hop contributes its single row, if any) into the
746
+ * next level's own `items` array, so a to-one relation nested arbitrarily
747
+ * deep is still resolved with one batched query per node, not per parent row.
748
+ */
749
+ export async function resolveToOneAccessVisibility(
750
+ items: readonly unknown[],
751
+ tree: ToOneAccessFilterTree,
752
+ args: {
753
+ session: Session | null
754
+ context: AccessContext
755
+ },
756
+ ): Promise<ToOneAccessVisibilityTree> {
757
+ const resolved = emptyToOneAccessVisibilityTree()
758
+
759
+ for (const [key, entry] of Object.entries(tree.filters)) {
760
+ if (entry.kind === 'denied') {
761
+ resolved.filters[key] = { kind: 'denied' }
762
+ continue
763
+ }
764
+
765
+ const ids = new Set<string>()
766
+ for (const item of items) {
767
+ if (!item || typeof item !== 'object') continue
768
+ const value = (item as Record<string, unknown>)[key]
769
+ if (value && typeof value === 'object' && 'id' in value) {
770
+ ids.add(String((value as Record<string, unknown>).id))
771
+ }
772
+ }
773
+
774
+ if (ids.size === 0) {
775
+ resolved.filters[key] = { kind: 'visible', ids: new Set() }
776
+ continue
777
+ }
778
+
779
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic model access by list name, mirroring the rest of the read pipeline
780
+ const model = (args.context.prisma as any)[getDbKey(entry.relatedListName)]
781
+ const visibleRows = await model.findMany({
782
+ where: { AND: [entry.accessWhere, { id: { in: [...ids] } }] },
783
+ select: { id: true },
784
+ })
785
+ const visibleIds = new Set<string>(
786
+ Array.isArray(visibleRows) ? visibleRows.map((row: { id: unknown }) => String(row.id)) : [],
787
+ )
788
+ resolved.filters[key] = { kind: 'visible', ids: visibleIds }
789
+ }
790
+
791
+ for (const [key, nestedTree] of Object.entries(tree.nested)) {
792
+ const nestedItems: unknown[] = []
793
+ for (const item of items) {
794
+ if (!item || typeof item !== 'object') continue
795
+ const value = (item as Record<string, unknown>)[key]
796
+ if (Array.isArray(value)) nestedItems.push(...value)
797
+ else if (value && typeof value === 'object') nestedItems.push(value)
798
+ }
799
+ resolved.nested[key] = await resolveToOneAccessVisibility(nestedItems, nestedTree, args)
800
+ }
801
+
802
+ return resolved
179
803
  }
180
804
 
181
805
  /**
@@ -218,6 +842,20 @@ export async function buildAccessScopedInclude(
218
842
  * caller can observe that an inaccessible related row exists (an `every`
219
843
  * that "should" pass instead fails), but never that row's field values,
220
844
  * which is the property this ticket exists to close.
845
+ *
846
+ * A quantifier's value of literal `null` (`is: null`/`isNot: null`, a to-one
847
+ * relation's existence check) is passed through untouched rather than folded:
848
+ * it names no fields to read-check or scope, and AND-folding an access filter
849
+ * into it would silently invert the caller's own predicate (see the inline
850
+ * comment at that branch).
851
+ *
852
+ * `resolveSyntheticRelation` (#1092/#1108) extends this to a key that
853
+ * resolves to a synthetic back-relation (#1082) rather than a declared
854
+ * field, recursing against its SOURCE list. Only `buildAccessScopedInclude`
855
+ * passes it, for the include-nested `where` position; the top-level `where`
856
+ * this function was originally built for (`context/index.ts`) omits it, so
857
+ * a synthetic key there is unaffected — matching #1092's own scope, which
858
+ * deliberately left the top-level checks unchanged.
221
859
  */
222
860
  export async function buildAccessScopedWhere(
223
861
  where: unknown,
@@ -229,12 +867,19 @@ export async function buildAccessScopedWhere(
229
867
  session: Session | null
230
868
  context: AccessContext
231
869
  },
870
+ // #1092/#1108 — the include-nested position's own addition, exactly
871
+ // mirroring `validateQueryKeys`'s `resolveSyntheticRelation` (see that
872
+ // module's doc comment): every top-level `where` caller omits this, so
873
+ // top-level behavior is unchanged.
874
+ resolveSyntheticRelation?: ResolveSyntheticRelation,
232
875
  ): Promise<unknown> {
233
876
  if (where === null || typeof where !== 'object') return where
234
877
 
235
878
  if (Array.isArray(where)) {
236
879
  return Promise.all(
237
- where.map((entry) => buildAccessScopedWhere(entry, listConfig, listName, config, args)),
880
+ where.map((entry) =>
881
+ buildAccessScopedWhere(entry, listConfig, listName, config, args, resolveSyntheticRelation),
882
+ ),
238
883
  )
239
884
  }
240
885
 
@@ -242,24 +887,33 @@ export async function buildAccessScopedWhere(
242
887
 
243
888
  for (const [key, value] of Object.entries(where as Record<string, unknown>)) {
244
889
  if (LOGICAL_OPERATORS.has(key)) {
245
- result[key] = await buildAccessScopedWhere(value, listConfig, listName, config, args)
890
+ result[key] = await buildAccessScopedWhere(
891
+ value,
892
+ listConfig,
893
+ listName,
894
+ config,
895
+ args,
896
+ resolveSyntheticRelation,
897
+ )
246
898
  continue
247
899
  }
248
900
 
249
901
  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
- }
902
+ // A synthetic back-relation (#1082) carries no `ref` of its own to
903
+ // follow — its "related" list for recursion is the SOURCE list it
904
+ // stands for, given directly by the resolver, not `getRelatedListConfig`.
905
+ const related = resolved?.isRelationship
906
+ ? getRelatedListConfig(resolved.fieldConfig.ref, config)
907
+ : !resolved
908
+ ? (() => {
909
+ const synthetic = resolveSyntheticRelation?.(key, listName)
910
+ return synthetic
911
+ ? { listConfig: synthetic.listConfig, listName: synthetic.listName }
912
+ : null
913
+ })()
914
+ : null
260
915
 
261
- const related = getRelatedListConfig(resolved.fieldConfig.ref, config)
262
- if (!related) {
916
+ if (!related || value === null || typeof value !== 'object' || Array.isArray(value)) {
263
917
  result[key] = value
264
918
  continue
265
919
  }
@@ -285,6 +939,16 @@ export async function buildAccessScopedWhere(
285
939
  nestedEntry[quantifier] = quantifierValue
286
940
  continue
287
941
  }
942
+ if (quantifierValue === null) {
943
+ // `is: null` / `isNot: null` tests EXISTENCE of a to-one relation,
944
+ // not its fields — there is nothing to read-check or scope, and
945
+ // AND-folding the access filter in here would silently invert the
946
+ // caller's predicate: `is: null` ("has no related row") would
947
+ // become `is: <accessWhere>` ("has a related row matching the
948
+ // filter"), the opposite of what was asked. Passed through as-is.
949
+ nestedEntry[quantifier] = null
950
+ continue
951
+ }
288
952
  await walkWhereReadAccess(quantifierValue, related.listConfig, related.listName, args)
289
953
  const scopedNested = await buildAccessScopedWhere(
290
954
  quantifierValue,
@@ -292,6 +956,7 @@ export async function buildAccessScopedWhere(
292
956
  related.listName,
293
957
  config,
294
958
  args,
959
+ resolveSyntheticRelation,
295
960
  )
296
961
  nestedEntry[quantifier] = accessWhere
297
962
  ? andWhere(accessWhere, scopedNested as PrismaFilter | undefined)
@@ -311,6 +976,7 @@ export async function buildAccessScopedWhere(
311
976
  related.listName,
312
977
  config,
313
978
  args,
979
+ resolveSyntheticRelation,
314
980
  )
315
981
  result[key] = accessWhere
316
982
  ? { is: andWhere(accessWhere, scopedNested as PrismaFilter | undefined) }