@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,7 +1,9 @@
1
- import { checkAccess, getRelatedListConfig } from './engine.js';
1
+ import { checkAccess, getRelatedListConfig, resolveSyntheticReverseRelation, listSyntheticReverseRelationNames, } from './engine.js';
2
2
  import { READ_INCLUDE_MAX_DEPTH } from './depth-limits.js';
3
- import { AccessScopeDepthExceededError, RelationFilterAccessDeniedError } from './errors.js';
4
- import { LOGICAL_OPERATORS, RELATION_QUANTIFIERS, resolveQueryField, walkWhereReadAccess, } from './query-validation.js';
3
+ import { AccessScopeDepthExceededError, RelationFilterAccessDeniedError, UndeclaredCountKeyError, UndeclaredIncludeKeyError, } from './errors.js';
4
+ import { LOGICAL_OPERATORS, RELATION_QUANTIFIERS, resolveQueryField, validateQueryFieldReadAccess, validateQueryKeys, walkWhereReadAccess, } from './query-validation.js';
5
+ import { isToManyRelationshipField, resolveCountAccessEntryForList } from './relationship-count.js';
6
+ import { getDbKey } from '../lib/case-utils.js';
5
7
  /** A plain object — excludes `null` and arrays, which `typeof x === 'object'` alone would admit. */
6
8
  function isPlainObject(value) {
7
9
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -13,13 +15,15 @@ function isPlainObject(value) {
13
15
  * own type before trusting it, rather than casting the whole value wholesale.
14
16
  *
15
17
  * A numeric `take` on a to-many relation include (a caller-supplied row bound,
16
- * issue #752) is carried through: it only ever NARROWS the fetched rows and can
17
- * never widen past the access `where`, so preserving it is access-neutral.
18
+ * issue #752), and a caller-supplied `orderBy`/`skip` (#851), are carried
19
+ * through the same way: none of the three can ever widen the result past the
20
+ * access `where` — they only narrow or reorder rows the access filter already
21
+ * admits — so preserving them is access-neutral.
18
22
  */
19
23
  function asEntryObject(value) {
20
24
  if (!isPlainObject(value))
21
25
  return null;
22
- const { where, include, take } = value;
26
+ const { where, include, take, orderBy, skip } = value;
23
27
  const entry = {};
24
28
  if (isPlainObject(where))
25
29
  entry.where = where;
@@ -27,6 +31,11 @@ function asEntryObject(value) {
27
31
  entry.include = include;
28
32
  if (typeof take === 'number')
29
33
  entry.take = take;
34
+ if (isPlainObject(orderBy) || Array.isArray(orderBy)) {
35
+ entry.orderBy = orderBy;
36
+ }
37
+ if (typeof skip === 'number')
38
+ entry.skip = skip;
30
39
  return entry;
31
40
  }
32
41
  /**
@@ -42,25 +51,229 @@ function andWhere(accessWhere, callerWhere) {
42
51
  }
43
52
  return accessWhere ?? callerWhere;
44
53
  }
54
+ export function emptyToOneAccessFilterTree() {
55
+ return { filters: {}, nested: {} };
56
+ }
57
+ function isToOneAccessFilterTreeEmpty(tree) {
58
+ return Object.keys(tree.filters).length === 0 && Object.keys(tree.nested).length === 0;
59
+ }
60
+ /**
61
+ * Whether a relationship field is to-one (at most one related row) rather
62
+ * than to-many. Exported so `field-visibility.ts` can pick the same `null`
63
+ * (to-one) vs `[]` (to-many) shape for a denied relation's forced value
64
+ * (issue #1103) that this module used to decide whether to record the
65
+ * denial in the first place — one source of truth for arity, not two.
66
+ */
67
+ export function isToOneRelationship(fieldConfig) {
68
+ return !('many' in fieldConfig && fieldConfig.many === true);
69
+ }
70
+ export function emptyCountAccessDenialTree() {
71
+ return { keys: new Set(), nested: {} };
72
+ }
73
+ function isCountAccessDenialTreeEmpty(tree) {
74
+ return tree.keys.size === 0 && Object.keys(tree.nested).length === 0;
75
+ }
76
+ /**
77
+ * Normalize a caller's `_count` include value to the `_count.select` map it
78
+ * names. `true` (Prisma's "count every relation" shorthand) expands to every
79
+ * countable relation Prisma itself carries on this model — every DECLARED
80
+ * to-many relationship, plus every synthetic back-relation a list-only `ref`
81
+ * elsewhere in the config synthesizes onto it (issue #1082): Prisma's own
82
+ * `_count: true` has always counted both, and dropping the synthetic ones
83
+ * here would silently stop counting a relation the caller used to get a
84
+ * (previously unscoped) count for. Returns `null` for a shape that requests
85
+ * nothing countable (`false`, or an object with no usable `select`).
86
+ */
87
+ function normalizeCountSelect(requestedValue, fieldConfigs, listKey, config) {
88
+ if (requestedValue === true) {
89
+ const expanded = {};
90
+ for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
91
+ if (isToManyRelationshipField(fieldConfig))
92
+ expanded[fieldName] = true;
93
+ }
94
+ for (const syntheticName of listSyntheticReverseRelationNames(listKey, config)) {
95
+ expanded[syntheticName] = true;
96
+ }
97
+ return expanded;
98
+ }
99
+ if (isPlainObject(requestedValue) && isPlainObject(requestedValue.select)) {
100
+ return requestedValue.select;
101
+ }
102
+ return null;
103
+ }
104
+ /**
105
+ * Scope a caller-supplied `_count` include value by each named relation's own
106
+ * `query` access — the `_count` counterpart to the rest of this module's
107
+ * relation scoping (issue #1087, closing the one key `buildAccessScopedInclude`
108
+ * used to allowlist through unscoped, #1082's "Out of scope").
109
+ *
110
+ * For each key in the caller's `_count.select` (or, for bare `_count: true`,
111
+ * every declared to-many relation — see `normalizeCountSelect`):
112
+ * - Not a declared to-many relationship and not a synthetic back-relation
113
+ * (#1082 — always genuinely countable) → THROWN as
114
+ * `UndeclaredCountKeyError`, matching `buildAccessScopedInclude`'s own
115
+ * rejection for the ordinary walk. A declared to-many relationship whose
116
+ * `ref` cannot be resolved is skipped instead, matching that same walk's
117
+ * handling of a config-level dangling ref (not a caller error).
118
+ * - The counting list's OWN field-level `read` access on the relationship
119
+ * field being counted denies it, or the related list's `query` access
120
+ * denies it (`=== false`) → omitted from the select sent to Prisma and
121
+ * added to the returned `deniedKeys`, so `filterReadableFields` can inject
122
+ * `0` post-query (a count is session-relative; denial doesn't mean "no such
123
+ * relation"). The field-level check has nothing to evaluate for a synthetic
124
+ * back-relation (no field of its own on this list) and is skipped for it —
125
+ * `resolveCountAccessEntryForList` (issue #1111). Both checks run BEFORE
126
+ * any caller-supplied nested `where` is validated — validating first would
127
+ * let a caller who cannot read a single row of the related list learn its
128
+ * field names and field-level read rules from a thrown `ValidationError`
129
+ * alone, reopening the exact oracle #915/ADR-0031 closed for a top-level
130
+ * predicate.
131
+ * - Otherwise → the caller-supplied nested `where` at that key (if any) is
132
+ * key- and read-access-validated against the RELATED list via the same
133
+ * `validateQueryKeys`/`validateQueryFieldReadAccess` primitives
134
+ * `createFindMany` already runs on a top-level `where` (#912/#915), then
135
+ * run through `buildAccessScopedWhere` — the same fold `createFindMany`
136
+ * applies to a top-level `where` (#916) — so a relation filter nested
137
+ * inside IT (e.g. `_count.select.posts.where.comments.some`) is scoped by
138
+ * THAT further list's own `query` access too, not just the counted
139
+ * relation's. The result is AND-combined with the counted relation's own
140
+ * access filter (if any) — reusing `andWhere`, never replacing the
141
+ * caller's condition, mirroring `buildAccessScopedInclude` itself.
142
+ */
143
+ async function buildAccessScopedCountSelect(requestedValue, fieldConfigs, args, config, listKey) {
144
+ const requestedSelect = normalizeCountSelect(requestedValue, fieldConfigs, listKey, config);
145
+ const deniedKeys = new Set();
146
+ if (!requestedSelect)
147
+ return { select: undefined, deniedKeys };
148
+ const select = {};
149
+ for (const [key, entryValue] of Object.entries(requestedSelect)) {
150
+ // A caller can explicitly exclude a key from the `true`-expanded set the
151
+ // same way Prisma's own `select` excludes a field.
152
+ if (entryValue === false)
153
+ continue;
154
+ const fieldConfig = fieldConfigs[key];
155
+ const isDeclaredToMany = isToManyRelationshipField(fieldConfig);
156
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
157
+ let relatedConfig = null;
158
+ if (isDeclaredToMany && fieldConfig && 'ref' in fieldConfig) {
159
+ relatedConfig = getRelatedListConfig(fieldConfig.ref, config);
160
+ }
161
+ if (!relatedConfig && !isDeclaredToMany) {
162
+ const synthetic = resolveSyntheticReverseRelation(key, listKey, config);
163
+ if (synthetic) {
164
+ relatedConfig = {
165
+ listName: synthetic.sourceListName,
166
+ listConfig: synthetic.sourceListConfig,
167
+ };
168
+ }
169
+ }
170
+ if (!relatedConfig) {
171
+ // A declared to-many field whose `ref` didn't resolve is a config
172
+ // issue, not a caller error — skip it exactly like the ordinary
173
+ // include walk does for the same case.
174
+ if (isDeclaredToMany)
175
+ continue;
176
+ throw new UndeclaredCountKeyError(listKey, key);
177
+ }
178
+ // `fieldConfig` is undefined for a synthetic back-relation (#1082) — it has
179
+ // no field of its own on THIS list, so there is no field-level `read`
180
+ // access to fold in; `resolveCountAccessEntryForList` treats a missing
181
+ // `fieldAccess` as exempt, matching the ordinary include path's own lack
182
+ // of a field-level gate for a synthetic key (issue #1111).
183
+ const accessEntry = await resolveCountAccessEntryForList(relatedConfig.listConfig, args, fieldConfig?.access);
184
+ // Denial is checked BEFORE the caller's nested `where` is validated —
185
+ // mirroring `buildAccessScopedWhere`'s own ordering below. A fully denied
186
+ // relation counts `0` no matter what `where` the caller supplied, so
187
+ // validating it first would let a caller who cannot read a single row of
188
+ // the related list learn its field names and field-level read rules from
189
+ // a `ValidationError`'s message alone — the exact oracle #915/ADR-0031
190
+ // closed for a top-level predicate, reopened here if this ran first.
191
+ if (accessEntry.kind === 'denied') {
192
+ deniedKeys.add(key);
193
+ continue;
194
+ }
195
+ const requestedWhere = isPlainObject(entryValue) && isPlainObject(entryValue.where)
196
+ ? entryValue.where
197
+ : undefined;
198
+ let scopedRequestedWhere;
199
+ if (requestedWhere) {
200
+ // Mirrors `buildAccessScopedInclude`'s own `resolveSyntheticRelation`
201
+ // closure (#1092/#916) — a synthetic back-relation (#1082) named inside
202
+ // this count entry's own `where` resolves against its source list
203
+ // rather than being rejected as undeclared, at every hop.
204
+ const resolveSyntheticRelation = (syntheticKey, fromListName) => {
205
+ const synthetic = resolveSyntheticReverseRelation(syntheticKey, fromListName, config);
206
+ return synthetic
207
+ ? { listConfig: synthetic.sourceListConfig, listName: synthetic.sourceListName }
208
+ : null;
209
+ };
210
+ validateQueryKeys({
211
+ where: requestedWhere,
212
+ listConfig: relatedConfig.listConfig,
213
+ listName: relatedConfig.listName,
214
+ config,
215
+ isSudo: false,
216
+ resolveSyntheticRelation,
217
+ });
218
+ await validateQueryFieldReadAccess({
219
+ where: requestedWhere,
220
+ listConfig: relatedConfig.listConfig,
221
+ listName: relatedConfig.listName,
222
+ session: args.session,
223
+ context: args.context,
224
+ isSudo: false,
225
+ });
226
+ // A relation filter (`some`/`every`/`none`/`is`/`isNot`) nested inside
227
+ // the caller's own `where` names a THIRD list one hop further out —
228
+ // e.g. `_count.select.posts.where.comments.some`. Validation above only
229
+ // checked keys against the counted relation's OWN fields; without this,
230
+ // that nested relation would reach Prisma unscoped by ITS list's
231
+ // `query` access, letting the resulting count reveal whether
232
+ // inaccessible rows over there exist. `buildAccessScopedWhere` is the
233
+ // same fold `createFindMany` runs on an ordinary top-level `where`
234
+ // (#916) — reused here rather than re-derived.
235
+ scopedRequestedWhere = (await buildAccessScopedWhere(requestedWhere, relatedConfig.listConfig, relatedConfig.listName, config, args, resolveSyntheticRelation));
236
+ }
237
+ const scopedWhere = andWhere(accessEntry.kind === 'scoped' ? accessEntry.where : undefined, scopedRequestedWhere);
238
+ select[key] = scopedWhere ? { where: scopedWhere } : true;
239
+ }
240
+ return { select: Object.keys(select).length > 0 ? select : undefined, deniedKeys };
241
+ }
45
242
  /**
46
243
  * Build the access-scoped `include` for exactly the relations a read
47
244
  * requested, recursing only into branches `requestedInclude` itself names.
48
245
  *
49
246
  * For each key in `requestedInclude`:
50
- * - Not a config-declared relationship access control does not govern it;
51
- * passed through unchanged (e.g. a fragment/caller key that isn't a
52
- * relationship at all).
247
+ * - A declared field that isn't a relationship (scalar, virtual, …) → access
248
+ * control does not govern it; passed through unchanged (a virtual key is
249
+ * stripped later by `stripVirtualFieldsFromInclude`, #628).
250
+ * - Not declared at all → resolved via `resolveSyntheticReverseRelation`
251
+ * (the synthetic-back-relation case above); `_count` is scoped by
252
+ * `buildAccessScopedCountSelect` (issue #1087 — each named relation's own
253
+ * `query` access, exactly like any other relation this walk scopes; a
254
+ * denied one is recorded for `filterReadableFields` to inject `0` for,
255
+ * post-query, since Prisma cannot be asked for a guaranteed `0`); anything
256
+ * else throws `UndeclaredIncludeKeyError` rather than reaching the
257
+ * database unscoped.
53
258
  * - A declared relationship whose related list's `query` access denies it
54
259
  * (`=== false`) → dropped entirely, no matter what the request asked for
55
260
  * nested beneath it (#566): the caller chooses *which* relations, access
56
- * control chooses *whether* and *with what filter*.
57
- * - Otherwise the access `where` is AND-combined with any caller-supplied
58
- * nested `where` (never replaced the other half of #566), a
59
- * caller-supplied `take` rides through unchanged (#752), and the "One
60
- * hop" rule (ADR-0026) — nested relations are scoped ONLY if
61
- * `requestedInclude` itself named a nested `include` here. A bare relation
62
- * (or one with no nested `include`) fetches its own columns and stops: no
63
- * recursive call, no access evaluation on anything beneath it.
261
+ * control chooses *whether* and *with what filter*. This denial is also
262
+ * recorded in `toOneAccessFilters` (`kind: 'denied'`), for either arity, so
263
+ * `filterReadableFields` can still surface an explicit `null` (to-one,
264
+ * issue #974) or `[]` (to-many, issue #1103) for it rather than an absent
265
+ * key.
266
+ * - Otherwise, for a to-**many** relation the access `where` is
267
+ * AND-combined with any caller-supplied nested `where` (never replaced
268
+ * the other half of #566), and a caller-supplied `take` rides through
269
+ * unchanged (#752). For a to-**one** relation → the access filter (if any)
270
+ * is recorded in `toOneAccessFilters` instead of attached as `where`,
271
+ * because Prisma only accepts a nested `where` on a to-many include
272
+ * (issue #974) — the entry itself never carries a `where` for a to-one key.
273
+ * - Either way — the "One hop" rule (ADR-0026) — nested relations are scoped
274
+ * ONLY if `requestedInclude` itself named a nested `include` here. A bare
275
+ * relation (or one with no nested `include`) fetches its own columns and
276
+ * stops: no recursive call, no access evaluation on anything beneath it.
64
277
  *
65
278
  * **Depth is a cost limit, not a cycle guard (ADR-0026).** A `requestedInclude`
66
279
  * is always a finite literal — the caller's own object, or
@@ -77,41 +290,223 @@ export async function buildAccessScopedInclude(requestedInclude, fieldConfigs, a
77
290
  throw new AccessScopeDepthExceededError(listKey, requestedKeys[0], depth);
78
291
  }
79
292
  const result = {};
293
+ const toOneAccessFilters = emptyToOneAccessFilterTree();
294
+ const countDenials = emptyCountAccessDenialTree();
80
295
  for (const [relationName, requestedValue] of Object.entries(requestedInclude)) {
296
+ // A caller can explicitly opt a relation OUT of a Prisma include with
297
+ // `false` (its `Include` type allows a bare `boolean`), or end up with an
298
+ // `undefined` value from a conditionally-built object
299
+ // (`{ posts: cond ? true : undefined }`) — Prisma treats both the same as
300
+ // the key being absent. Treat them identically here too, before this key
301
+ // ever reaches access evaluation: an explicitly-disabled relation was
302
+ // never actually requested, so it must not be evaluated, dropped as
303
+ // "denied", or recorded as needing a post-query fixup (issue #1103 code
304
+ // review) — any of those would surface a value (`[]`/`null`) for a key
305
+ // the caller deliberately excluded.
306
+ if (requestedValue === false || requestedValue === undefined)
307
+ continue;
81
308
  const fieldConfig = fieldConfigs[relationName];
82
309
  const isDeclaredRelationship = fieldConfig?.type === 'relationship' && 'ref' in fieldConfig && !!fieldConfig.ref;
83
- if (!isDeclaredRelationship) {
310
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
311
+ let relatedConfig;
312
+ let isToOne;
313
+ if (isDeclaredRelationship) {
314
+ relatedConfig = getRelatedListConfig(fieldConfig.ref, config);
315
+ if (!relatedConfig)
316
+ continue;
317
+ isToOne = isToOneRelationship(fieldConfig);
318
+ }
319
+ else if (fieldConfig) {
320
+ // A declared field that is not a relationship — access control does
321
+ // not govern it here; passed through unchanged.
84
322
  result[relationName] = requestedValue;
85
323
  continue;
86
324
  }
87
- const relatedConfig = getRelatedListConfig(fieldConfig.ref, config);
88
- if (!relatedConfig)
325
+ else if (relationName === '_count') {
326
+ const { select, deniedKeys } = await buildAccessScopedCountSelect(requestedValue, fieldConfigs, args, config, listKey);
327
+ if (select)
328
+ result[relationName] = { select };
329
+ if (deniedKeys.size > 0)
330
+ countDenials.keys = deniedKeys;
89
331
  continue;
332
+ }
333
+ else {
334
+ const synthetic = resolveSyntheticReverseRelation(relationName, listKey, config);
335
+ if (!synthetic) {
336
+ throw new UndeclaredIncludeKeyError(listKey, relationName);
337
+ }
338
+ relatedConfig = { listName: synthetic.sourceListName, listConfig: synthetic.sourceListConfig };
339
+ // Always to-many — a list-only ref has one construction site and no
340
+ // arity branch, and one-to-one is structurally impossible for it.
341
+ isToOne = false;
342
+ }
90
343
  const queryAccess = relatedConfig.listConfig.access?.operation?.query;
91
344
  const accessResult = await checkAccess(queryAccess, {
92
345
  session: args.session,
93
346
  context: args.context,
94
347
  });
95
348
  if (accessResult === false) {
349
+ // Recorded for either arity (issue #1103) — a to-many relation is
350
+ // dropped from `include` here exactly like a to-one one, and needs the
351
+ // same post-query fixup so its key comes back `[]`, not silently
352
+ // absent from the row.
353
+ toOneAccessFilters.filters[relationName] = { kind: 'denied' };
96
354
  continue;
97
355
  }
98
356
  const accessWhere = typeof accessResult === 'object' ? accessResult : undefined;
99
357
  const requestedEntry = asEntryObject(requestedValue);
100
- const mergedWhere = andWhere(accessWhere, requestedEntry?.where);
358
+ // #1092 a nested `where`/`orderBy` gets the same #912/#915 checks the
359
+ // top-level `where`/`orderBy` already gets, resolved against the RELATED
360
+ // list (`relatedConfig`) rather than the current one, and run only once
361
+ // the relation is known to be accessible at all (same ordering reason as
362
+ // the top-level checks: don't leak a field's name/read-gating status to a
363
+ // caller who has zero access to the relation to begin with).
364
+ const resolveSyntheticRelation = (key, fromListName) => {
365
+ const synthetic = resolveSyntheticReverseRelation(key, fromListName, config);
366
+ return synthetic
367
+ ? { listConfig: synthetic.sourceListConfig, listName: synthetic.sourceListName }
368
+ : null;
369
+ };
370
+ validateQueryKeys({
371
+ where: requestedEntry?.where,
372
+ orderBy: requestedEntry?.orderBy,
373
+ listConfig: relatedConfig.listConfig,
374
+ listName: relatedConfig.listName,
375
+ config,
376
+ isSudo: false,
377
+ resolveSyntheticRelation,
378
+ });
379
+ await validateQueryFieldReadAccess({
380
+ where: requestedEntry?.where,
381
+ orderBy: requestedEntry?.orderBy,
382
+ listConfig: relatedConfig.listConfig,
383
+ listName: relatedConfig.listName,
384
+ session: args.session,
385
+ context: args.context,
386
+ isSudo: false,
387
+ });
388
+ // The two checks above only reach the entry's own top-level keys — a
389
+ // relation quantifier (`some`/`every`/`none`/`is`/`isNot`) nested inside
390
+ // this `where` names a DEEPER related list, which needs the same
391
+ // treatment the top-level `where` already gets from `buildAccessScopedWhere`
392
+ // (#916): scope it by that deeper list's own `query` access and check ITS
393
+ // fields' read access, recursing through every further hop. Only for
394
+ // to-many — a to-one entry never carries `requestedEntry.where` through to
395
+ // Prisma at all (see below), so there is nothing here to scope.
396
+ const scopedRequestedWhere = !isToOne && requestedEntry?.where !== undefined
397
+ ? (await buildAccessScopedWhere(requestedEntry.where, relatedConfig.listConfig, relatedConfig.listName, config, args, resolveSyntheticRelation))
398
+ : requestedEntry?.where;
101
399
  let nestedInclude;
400
+ let nestedToOneFilters;
401
+ let nestedCountDenials;
102
402
  if (requestedEntry?.include) {
103
- nestedInclude = await buildAccessScopedInclude(requestedEntry.include, relatedConfig.listConfig.fields, args, config, relatedConfig.listName, depth + 1);
403
+ const nested = await buildAccessScopedInclude(requestedEntry.include, relatedConfig.listConfig.fields, args, config, relatedConfig.listName, depth + 1);
404
+ nestedInclude = nested.include;
405
+ nestedToOneFilters = nested.toOneAccessFilters;
406
+ nestedCountDenials = nested.countDenials;
104
407
  }
105
408
  const entry = {};
106
- if (mergedWhere)
107
- entry.where = mergedWhere;
409
+ if (isToOne) {
410
+ if (accessWhere) {
411
+ toOneAccessFilters.filters[relationName] = {
412
+ kind: 'scoped',
413
+ relatedListName: relatedConfig.listName,
414
+ accessWhere,
415
+ };
416
+ }
417
+ }
418
+ else {
419
+ const mergedWhere = andWhere(accessWhere, scopedRequestedWhere);
420
+ if (mergedWhere)
421
+ entry.where = mergedWhere;
422
+ if (requestedEntry?.take !== undefined)
423
+ entry.take = requestedEntry.take;
424
+ if (requestedEntry?.orderBy !== undefined)
425
+ entry.orderBy = requestedEntry.orderBy;
426
+ if (requestedEntry?.skip !== undefined)
427
+ entry.skip = requestedEntry.skip;
428
+ }
108
429
  if (nestedInclude && Object.keys(nestedInclude).length > 0)
109
430
  entry.include = nestedInclude;
110
- if (requestedEntry?.take !== undefined)
111
- entry.take = requestedEntry.take;
431
+ if (nestedToOneFilters && !isToOneAccessFilterTreeEmpty(nestedToOneFilters)) {
432
+ toOneAccessFilters.nested[relationName] = nestedToOneFilters;
433
+ }
434
+ if (nestedCountDenials && !isCountAccessDenialTreeEmpty(nestedCountDenials)) {
435
+ countDenials.nested[relationName] = nestedCountDenials;
436
+ }
112
437
  result[relationName] = Object.keys(entry).length > 0 ? entry : true;
113
438
  }
114
- return result;
439
+ return { include: result, toOneAccessFilters, countDenials };
440
+ }
441
+ export function emptyToOneAccessVisibilityTree() {
442
+ return { filters: {}, nested: {} };
443
+ }
444
+ /**
445
+ * Resolve a `ToOneAccessFilterTree` against the RAW rows Prisma already
446
+ * fetched (unscoped for the flagged to-one relations — see
447
+ * `buildAccessScopedInclude`) into the set of related ids the session may
448
+ * actually see, one batched `id IN (...)` existence check per (relation,
449
+ * nesting level) across every row in `items` — never once per row.
450
+ *
451
+ * For each `filters` entry at a level:
452
+ * - `kind: 'denied'` → carried straight through; no query, nothing to check.
453
+ * - `kind: 'scoped'` → every id present at this key across ALL of `items` is
454
+ * collected first (an empty set skips the query entirely — nothing to
455
+ * check), then ONE `findMany` through the RAW `prisma` client (not
456
+ * `context.db`, which would re-evaluate the same access-control function a
457
+ * second time) asks which of those ids also satisfy `accessWhere` — the
458
+ * exact `PrismaFilter` `checkAccess` already produced, handed to Prisma
459
+ * unmodified rather than interpreted by hand.
460
+ *
461
+ * Recurses into `nested` by flattening the related items reached through
462
+ * each key across every row in `items` (a to-many hop contributes every one
463
+ * of its rows; a to-one hop contributes its single row, if any) into the
464
+ * next level's own `items` array, so a to-one relation nested arbitrarily
465
+ * deep is still resolved with one batched query per node, not per parent row.
466
+ */
467
+ export async function resolveToOneAccessVisibility(items, tree, args) {
468
+ const resolved = emptyToOneAccessVisibilityTree();
469
+ for (const [key, entry] of Object.entries(tree.filters)) {
470
+ if (entry.kind === 'denied') {
471
+ resolved.filters[key] = { kind: 'denied' };
472
+ continue;
473
+ }
474
+ const ids = new Set();
475
+ for (const item of items) {
476
+ if (!item || typeof item !== 'object')
477
+ continue;
478
+ const value = item[key];
479
+ if (value && typeof value === 'object' && 'id' in value) {
480
+ ids.add(String(value.id));
481
+ }
482
+ }
483
+ if (ids.size === 0) {
484
+ resolved.filters[key] = { kind: 'visible', ids: new Set() };
485
+ continue;
486
+ }
487
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic model access by list name, mirroring the rest of the read pipeline
488
+ const model = args.context.prisma[getDbKey(entry.relatedListName)];
489
+ const visibleRows = await model.findMany({
490
+ where: { AND: [entry.accessWhere, { id: { in: [...ids] } }] },
491
+ select: { id: true },
492
+ });
493
+ const visibleIds = new Set(Array.isArray(visibleRows) ? visibleRows.map((row) => String(row.id)) : []);
494
+ resolved.filters[key] = { kind: 'visible', ids: visibleIds };
495
+ }
496
+ for (const [key, nestedTree] of Object.entries(tree.nested)) {
497
+ const nestedItems = [];
498
+ for (const item of items) {
499
+ if (!item || typeof item !== 'object')
500
+ continue;
501
+ const value = item[key];
502
+ if (Array.isArray(value))
503
+ nestedItems.push(...value);
504
+ else if (value && typeof value === 'object')
505
+ nestedItems.push(value);
506
+ }
507
+ resolved.nested[key] = await resolveToOneAccessVisibility(nestedItems, nestedTree, args);
508
+ }
509
+ return resolved;
115
510
  }
116
511
  /**
117
512
  * Scope every relation filter (`some`/`every`/`none`/`is`/`isNot`) nested in a
@@ -153,32 +548,55 @@ export async function buildAccessScopedInclude(requestedInclude, fieldConfigs, a
153
548
  * caller can observe that an inaccessible related row exists (an `every`
154
549
  * that "should" pass instead fails), but never that row's field values,
155
550
  * which is the property this ticket exists to close.
551
+ *
552
+ * A quantifier's value of literal `null` (`is: null`/`isNot: null`, a to-one
553
+ * relation's existence check) is passed through untouched rather than folded:
554
+ * it names no fields to read-check or scope, and AND-folding an access filter
555
+ * into it would silently invert the caller's own predicate (see the inline
556
+ * comment at that branch).
557
+ *
558
+ * `resolveSyntheticRelation` (#1092/#1108) extends this to a key that
559
+ * resolves to a synthetic back-relation (#1082) rather than a declared
560
+ * field, recursing against its SOURCE list. Only `buildAccessScopedInclude`
561
+ * passes it, for the include-nested `where` position; the top-level `where`
562
+ * this function was originally built for (`context/index.ts`) omits it, so
563
+ * a synthetic key there is unaffected — matching #1092's own scope, which
564
+ * deliberately left the top-level checks unchanged.
156
565
  */
157
566
  export async function buildAccessScopedWhere(where,
158
567
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
159
- listConfig, listName, config, args) {
568
+ listConfig, listName, config, args,
569
+ // #1092/#1108 — the include-nested position's own addition, exactly
570
+ // mirroring `validateQueryKeys`'s `resolveSyntheticRelation` (see that
571
+ // module's doc comment): every top-level `where` caller omits this, so
572
+ // top-level behavior is unchanged.
573
+ resolveSyntheticRelation) {
160
574
  if (where === null || typeof where !== 'object')
161
575
  return where;
162
576
  if (Array.isArray(where)) {
163
- return Promise.all(where.map((entry) => buildAccessScopedWhere(entry, listConfig, listName, config, args)));
577
+ return Promise.all(where.map((entry) => buildAccessScopedWhere(entry, listConfig, listName, config, args, resolveSyntheticRelation)));
164
578
  }
165
579
  const result = {};
166
580
  for (const [key, value] of Object.entries(where)) {
167
581
  if (LOGICAL_OPERATORS.has(key)) {
168
- result[key] = await buildAccessScopedWhere(value, listConfig, listName, config, args);
582
+ result[key] = await buildAccessScopedWhere(value, listConfig, listName, config, args, resolveSyntheticRelation);
169
583
  continue;
170
584
  }
171
585
  const resolved = resolveQueryField(key, listConfig.fields);
172
- if (!resolved ||
173
- !resolved.isRelationship ||
174
- value === null ||
175
- typeof value !== 'object' ||
176
- Array.isArray(value)) {
177
- result[key] = value;
178
- continue;
179
- }
180
- const related = getRelatedListConfig(resolved.fieldConfig.ref, config);
181
- if (!related) {
586
+ // A synthetic back-relation (#1082) carries no `ref` of its own to
587
+ // follow — its "related" list for recursion is the SOURCE list it
588
+ // stands for, given directly by the resolver, not `getRelatedListConfig`.
589
+ const related = resolved?.isRelationship
590
+ ? getRelatedListConfig(resolved.fieldConfig.ref, config)
591
+ : !resolved
592
+ ? (() => {
593
+ const synthetic = resolveSyntheticRelation?.(key, listName);
594
+ return synthetic
595
+ ? { listConfig: synthetic.listConfig, listName: synthetic.listName }
596
+ : null;
597
+ })()
598
+ : null;
599
+ if (!related || value === null || typeof value !== 'object' || Array.isArray(value)) {
182
600
  result[key] = value;
183
601
  continue;
184
602
  }
@@ -200,8 +618,18 @@ listConfig, listName, config, args) {
200
618
  nestedEntry[quantifier] = quantifierValue;
201
619
  continue;
202
620
  }
621
+ if (quantifierValue === null) {
622
+ // `is: null` / `isNot: null` tests EXISTENCE of a to-one relation,
623
+ // not its fields — there is nothing to read-check or scope, and
624
+ // AND-folding the access filter in here would silently invert the
625
+ // caller's predicate: `is: null` ("has no related row") would
626
+ // become `is: <accessWhere>` ("has a related row matching the
627
+ // filter"), the opposite of what was asked. Passed through as-is.
628
+ nestedEntry[quantifier] = null;
629
+ continue;
630
+ }
203
631
  await walkWhereReadAccess(quantifierValue, related.listConfig, related.listName, args);
204
- const scopedNested = await buildAccessScopedWhere(quantifierValue, related.listConfig, related.listName, config, args);
632
+ const scopedNested = await buildAccessScopedWhere(quantifierValue, related.listConfig, related.listName, config, args, resolveSyntheticRelation);
205
633
  nestedEntry[quantifier] = accessWhere
206
634
  ? andWhere(accessWhere, scopedNested)
207
635
  : scopedNested;
@@ -215,7 +643,7 @@ listConfig, listName, config, args) {
215
643
  // (fully-allowed related list) is returned exactly as the caller wrote
216
644
  // it, so an already-permitted query is not perturbed by this pass.
217
645
  await walkWhereReadAccess(value, related.listConfig, related.listName, args);
218
- const scopedNested = await buildAccessScopedWhere(value, related.listConfig, related.listName, config, args);
646
+ const scopedNested = await buildAccessScopedWhere(value, related.listConfig, related.listName, config, args, resolveSyntheticRelation);
219
647
  result[key] = accessWhere
220
648
  ? { is: andWhere(accessWhere, scopedNested) }
221
649
  : scopedNested;