@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,12 +1,18 @@
1
1
  import type { Session, AccessContext } from './types.js'
2
- import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
3
- import { getRelatedListConfig } from './engine.js'
2
+ import type { OpenSaasConfig, FieldConfig, ListConfig } from '../config/types.js'
3
+ import { getRelatedListConfig, resolveSyntheticReverseRelation } from './engine.js'
4
4
  import { checkFieldAccess } from './field-access.js'
5
5
  import { RESOLVE_CHAIN_MAX_LENGTH } from './depth-limits.js'
6
6
  import { ResolveOutputCycleError } from './errors.js'
7
7
  import type { DeclaredOnlyTree } from './declared-dependencies.js'
8
8
  import { emptyDeclaredOnlyTree } from './declared-dependencies.js'
9
9
  import type { FieldSelectionScope } from '../query/index.js'
10
+ import type { ToOneAccessVisibilityTree, CountAccessDenialTree } from './access-filter.js'
11
+ import {
12
+ emptyToOneAccessVisibilityTree,
13
+ isToOneRelationship,
14
+ emptyCountAccessDenialTree,
15
+ } from './access-filter.js'
10
16
  // NOTE: `context/index.ts` imports `filterReadableFields` from this module
11
17
  // (via the `access/index.ts` barrel) — this is an intentional cyclic
12
18
  // dependency, the same shape and for the same reason as the one documented in
@@ -36,6 +42,42 @@ import { buildDbDelegate } from '../context/index.js'
36
42
  * Phase 1 (pre-query row/relation scoping) lives in `access-filter.ts`. See
37
43
  * `docs/adr/0001-access-control-is-a-two-phase-read.md` and the access-control
38
44
  * glossary in `CONTEXT.md`.
45
+ *
46
+ * **To-one relation nulling (issue #974).** A to-one relation whose related
47
+ * list's `query` access resolves to a filter cannot be scoped by Prisma's own
48
+ * `where` (it only accepts one on a to-many include — see the "To-one
49
+ * relations" section of `access-filter.ts`'s module doc), so
50
+ * `buildAccessScopedInclude` fetches it unscoped and hands this module a
51
+ * `ToOneAccessVisibilityTree` — already resolved, via one batched existence
52
+ * check per relation across the whole read, by
53
+ * `resolveToOneAccessVisibility`. This module is where that resolution
54
+ * actually becomes the caller-visible `null`: a `kind: 'denied'` key is
55
+ * forced to `null` even though it was never fetched at all (the key is
56
+ * absent from `workingItem`), and a `kind: 'visible'` key's fetched row is
57
+ * nulled out unless its id survived the existence check — in both branches,
58
+ * before any field-level access check or `resolveOutput` hook runs, so the
59
+ * rest of the pipeline sees exactly what a denied to-one read has always
60
+ * meant elsewhere: `null`, never a thrown error.
61
+ *
62
+ * **A denied to-many relation is forced to `[]`, the same way (issue
63
+ * #1103).** `buildAccessScopedInclude` drops a to-many relation from
64
+ * `include` on the same outright `query` denial as a to-one one, and records
65
+ * the same `kind: 'denied'` entry for it. The key is therefore just as absent
66
+ * from `workingItem` as a denied to-one key, and the fix is the same fixup
67
+ * loop — it now forces the key present using the field's own declared
68
+ * arity: `null` for a to-one relation (unchanged), `[]` for a to-many one,
69
+ * rather than leaving a to-many key silently missing where the fragment
70
+ * API's `ResultOf` type (`query/index.ts`) promises an array.
71
+ *
72
+ * **`_count` denial injection (issue #1087).** A caller-supplied `_count.select`
73
+ * key whose related list denies `query` access outright is omitted from the
74
+ * select `buildAccessScopedInclude` sends to Prisma — there is no way to ask
75
+ * Prisma for a guaranteed `0`, and no query is needed to know one (unlike the
76
+ * to-one existence check above). This module is where that becomes the
77
+ * caller-visible `0`: every key in a `CountAccessDenialTree` at this level is
78
+ * written into `filtered._count` as `0`, whether or not `_count` came back
79
+ * from the database at all — a count is a session-relative value, and `0` is
80
+ * what "no visible rows" means for it, never an absent key.
39
81
  */
40
82
 
41
83
  type ResolveOutputHookRuntime = (args: {
@@ -193,6 +235,15 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
193
235
  // The fragment scope this level was reached under (ADR-0027, see module doc
194
236
  // above), and the same tree one level down for each nested relation.
195
237
  selection?: FieldSelectionScope,
238
+ // Resolved to-one existence checks at THIS level (issue #974, see module
239
+ // doc above), and the same tree one level down for each nested relation —
240
+ // regardless of that relation's own arity, since a filtered to-one can sit
241
+ // beneath a to-many hop.
242
+ toOneVisibility: ToOneAccessVisibilityTree = emptyToOneAccessVisibilityTree(),
243
+ // `_count.select` keys denied outright at THIS level (issue #1087, see
244
+ // module doc above), and the same tree one level down for each nested
245
+ // relation whose own nested include named a further `_count`.
246
+ countDenials: CountAccessDenialTree = emptyCountAccessDenialTree(),
196
247
  ): Promise<Partial<T>> {
197
248
  const filtered: Record<string, unknown> = {}
198
249
 
@@ -256,25 +307,49 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
256
307
  // cap used to let a relation be scoped correctly at the DB level while
257
308
  // still returning with unfiltered fields past this function's own,
258
309
  // separately-tracked limit (issue #830).
310
+ const isDeclaredRelationshipField =
311
+ fieldConfig?.type === 'relationship' && 'ref' in fieldConfig && !!fieldConfig.ref
312
+ // A synthetic back-relation (#1082) — no declared field of its own on
313
+ // this list, so resolved by name against the owning list's relationship
314
+ // field instead. `undefined` (not `fieldConfig`) is the signal it's
315
+ // worth trying: a declared-but-non-relationship field (e.g. a scalar or
316
+ // virtual) must fall through to the generic path below, unchanged.
317
+ const synthetic =
318
+ !isDeclaredRelationshipField && fieldConfig === undefined && config && listKey
319
+ ? resolveSyntheticReverseRelation(fieldName, listKey, config)
320
+ : null
321
+
259
322
  if (
260
323
  config &&
261
- fieldConfig?.type === 'relationship' &&
262
- 'ref' in fieldConfig &&
263
- fieldConfig.ref &&
324
+ (isDeclaredRelationshipField || synthetic) &&
264
325
  value !== null &&
265
326
  value !== undefined
266
327
  ) {
267
- const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
268
- ...args,
269
- item: workingItem,
270
- })
271
-
272
- if (!canRead) {
273
- accessDeniedKeys.add(fieldName)
274
- continue
275
- }
328
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
329
+ let relatedConfig: { listName: string; listConfig: ListConfig<any> } | null = null
330
+
331
+ if (isDeclaredRelationshipField) {
332
+ const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
333
+ ...args,
334
+ item: workingItem,
335
+ })
336
+
337
+ if (!canRead) {
338
+ accessDeniedKeys.add(fieldName)
339
+ continue
340
+ }
276
341
 
277
- const relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
342
+ relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
343
+ } else if (synthetic) {
344
+ // No declared field means no field-level `read` gate of its own to
345
+ // check here — the owning list's OWN field-level access is enforced
346
+ // by the recursive `filterReadableFields` call below, exactly as it
347
+ // would be for a declared relationship's related rows.
348
+ relatedConfig = {
349
+ listName: synthetic.sourceListName,
350
+ listConfig: synthetic.sourceListConfig,
351
+ }
352
+ }
278
353
  // The declared-only tree for whatever THIS relation's own list computes,
279
354
  // e.g. a field on the related list that declares its own `needs`. Falls
280
355
  // back to an empty tree when this relation isn't declaration-related at
@@ -286,6 +361,20 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
286
361
  // unrestricted — matching what naming a relation without narrowing it
287
362
  // further has always meant.
288
363
  const nestedSelection = selection?.nested[fieldName]
364
+ // This relation's own resolved to-one visibility, if
365
+ // `buildAccessScopedInclude` flagged anything beneath it (issue #974).
366
+ // Falls back to empty — the common case for a relation with no
367
+ // filtered to-one anywhere in its own nested include.
368
+ const nestedToOneVisibility =
369
+ toOneVisibility.nested[fieldName] ?? emptyToOneAccessVisibilityTree()
370
+ // This key's OWN to-one existence check, if `fieldName` itself is a
371
+ // filtered to-one relation (as opposed to one further down its tree).
372
+ const toOneEntry = toOneVisibility.filters[fieldName]
373
+ // This relation's own denied `_count` keys, if `buildAccessScopedInclude`
374
+ // flagged any beneath it (issue #1087). Falls back to empty — the
375
+ // common case for a relation with no denied `_count` anywhere in its
376
+ // own nested include.
377
+ const nestedCountDenials = countDenials.nested[fieldName] ?? emptyCountAccessDenialTree()
289
378
 
290
379
  if (relatedConfig) {
291
380
  if (Array.isArray(value)) {
@@ -300,20 +389,30 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
300
389
  relatedConfig.listName,
301
390
  nestedDeclaredOnly,
302
391
  nestedSelection,
392
+ nestedToOneVisibility,
393
+ nestedCountDenials,
303
394
  ),
304
395
  ),
305
396
  )
306
397
  } else if (typeof value === 'object') {
307
- filtered[fieldName] = await filterReadableFields(
308
- value as Record<string, unknown>,
309
- relatedConfig.listConfig.fields,
310
- args,
311
- config,
312
- depth + 1,
313
- relatedConfig.listName,
314
- nestedDeclaredOnly,
315
- nestedSelection,
316
- )
398
+ const relatedId = (value as Record<string, unknown>).id
399
+ const isVisible =
400
+ !toOneEntry || toOneEntry.kind !== 'visible' || toOneEntry.ids.has(String(relatedId))
401
+
402
+ filtered[fieldName] = isVisible
403
+ ? await filterReadableFields(
404
+ value as Record<string, unknown>,
405
+ relatedConfig.listConfig.fields,
406
+ args,
407
+ config,
408
+ depth + 1,
409
+ relatedConfig.listName,
410
+ nestedDeclaredOnly,
411
+ nestedSelection,
412
+ nestedToOneVisibility,
413
+ nestedCountDenials,
414
+ )
415
+ : null
317
416
  }
318
417
  } else {
319
418
  filtered[fieldName] = value
@@ -341,6 +440,53 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
341
440
  }
342
441
  }
343
442
 
443
+ // Relations `buildAccessScopedInclude` denied outright (to-one: issue #974,
444
+ // to-many: issue #1103) were never asked of Prisma at all, so `fieldName`
445
+ // has no entry in `workingItem` and the loop above never visits it. Force
446
+ // it present here — matching what a denied read means everywhere else in
447
+ // the context — rather than leaving the key silently absent: `null` for a
448
+ // to-one relation, `[]` for a to-many one. A synthetic back-relation
449
+ // (`fieldConfig` undefined — #1082) is always to-many, the same arity
450
+ // `buildAccessScopedInclude` assumes for it.
451
+ for (const [fieldName, entry] of Object.entries(toOneVisibility.filters)) {
452
+ if (entry.kind !== 'denied') continue
453
+ if (fieldName in filtered || fieldName in workingItem) continue
454
+ if (selection?.fields && !selection.fields.has(fieldName)) continue
455
+
456
+ const fieldConfig = fieldConfigs[fieldName]
457
+ const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
458
+ ...args,
459
+ item: workingItem,
460
+ })
461
+
462
+ if (!canRead) {
463
+ accessDeniedKeys.add(fieldName)
464
+ continue
465
+ }
466
+
467
+ const isToMany = !fieldConfig || !isToOneRelationship(fieldConfig)
468
+ filtered[fieldName] = isToMany ? [] : null
469
+ }
470
+
471
+ // `_count.select` keys `buildAccessScopedInclude` denied outright (issue
472
+ // #1087) were omitted from the select sent to Prisma, so `_count` may be
473
+ // absent from `workingItem` entirely, or present but missing exactly these
474
+ // keys. Write each denied key in as `0` — matching what a denied count has
475
+ // always meant for the admin list view's own scoped counts — unless a
476
+ // fragment's own selection excluded `_count` altogether, in which case
477
+ // there is nothing to inject it into.
478
+ if (countDenials.keys.size > 0 && !(selection?.fields && !selection.fields.has('_count'))) {
479
+ const existingCount =
480
+ filtered._count && typeof filtered._count === 'object'
481
+ ? (filtered._count as Record<string, unknown>)
482
+ : {}
483
+ const mergedCount = { ...existingCount }
484
+ for (const key of countDenials.keys) {
485
+ mergedCount[key] = 0
486
+ }
487
+ filtered._count = mergedCount
488
+ }
489
+
344
490
  // The item a virtual field's hook sees: stored columns and fetched
345
491
  // relations (from `workingItem`, never a resolved value — no hook's output
346
492
  // is ever written back into `workingItem`). A key is visible here if it
@@ -14,11 +14,15 @@ export type {
14
14
  // Operation-level access primitives and shared ref-parsing helper.
15
15
  export {
16
16
  checkAccess,
17
+ checkCreateAccess,
17
18
  mergeFilters,
18
19
  isBoolean,
19
20
  isPrismaFilter,
20
21
  getRelatedListConfig,
22
+ resolveSyntheticReverseRelation,
23
+ listSyntheticReverseRelationNames,
21
24
  } from './engine.js'
25
+ export type { SyntheticReverseRelation } from './engine.js'
22
26
  // Canonical field-level access evaluation (shared by read and write paths).
23
27
  export {
24
28
  checkFieldAccess,
@@ -36,7 +40,22 @@ export {
36
40
  buildAccessScopedInclude,
37
41
  buildAccessScopedWhere,
38
42
  stripVirtualFieldsFromInclude,
43
+ resolveToOneAccessVisibility,
44
+ emptyToOneAccessFilterTree,
45
+ emptyToOneAccessVisibilityTree,
46
+ emptyCountAccessDenialTree,
39
47
  } from './access-filter.js'
48
+ export type {
49
+ ToOneAccessFilterTree,
50
+ ToOneAccessFilterEntry,
51
+ ToOneAccessVisibilityTree,
52
+ ToOneVisibility,
53
+ CountAccessDenialTree,
54
+ } from './access-filter.js'
55
+ // Access-scoped to-many relationship counts (admin list view, issue #732)
56
+ // and the shared per-relation resolver `_count` scoping (issue #1087) reuses.
57
+ export { isToManyRelationshipField, resolveCountAccessEntryForList } from './relationship-count.js'
58
+ export type { CountAccessEntry } from './relationship-count.js'
40
59
  // Phase 2 — Field Visibility (post-query field stripping + resolveOutput).
41
60
  export { filterReadableFields } from './field-visibility.js'
42
61
  // Declared Dependencies — folding `needs` into an include without widening
@@ -53,5 +72,11 @@ export { AccessScopeDepthExceededError } from './errors.js'
53
72
  export { ResolveOutputCycleError } from './errors.js'
54
73
  // Thrown when a field-level access control function returns a non-boolean result.
55
74
  export { InvalidFieldAccessResultError } from './errors.js'
75
+ // Thrown when operation-level `create` access control returns a non-boolean result (#1009).
76
+ export { InvalidCreateAccessResultError } from './errors.js'
56
77
  // Thrown when a relation filter's related list denies query access outright (#916).
57
78
  export { RelationFilterAccessDeniedError } from './errors.js'
79
+ // Thrown when a caller `include` names a key that is neither declared, synthetic, nor `_count` (#1082).
80
+ export { UndeclaredIncludeKeyError } from './errors.js'
81
+ // Thrown when a caller `_count.select` names a key that is not a countable to-many relation (#1087).
82
+ export { UndeclaredCountKeyError } from './errors.js'
@@ -2,7 +2,8 @@ import { describe, it, expect } from 'vitest'
2
2
  import { filterReadableFields } from './field-visibility.js'
3
3
  import { executeFieldResolveInputHooks, splitMultiColumnFields } from '../hooks/index.js'
4
4
  import type { FieldConfig } from '../config/types.js'
5
- import type { AccessContext, FieldAccess } from './types.js'
5
+ import type { FieldAccess } from './types.js'
6
+ import type { StackContext } from '../context/index.js'
6
7
 
7
8
  /**
8
9
  * Generic core wiring for multi-column fields (the contract storage
@@ -48,7 +49,7 @@ function multiColumnField(access?: FieldAccess): FieldConfig {
48
49
  } as unknown as FieldConfig
49
50
  }
50
51
 
51
- function makeContext(overrides: { isSudo?: boolean } = {}): AccessContext {
52
+ function makeContext(overrides: { isSudo?: boolean } = {}): StackContext {
52
53
  return {
53
54
  session: null,
54
55
  _isSudo: overrides.isSudo ?? false,
@@ -46,6 +46,20 @@ import { ValidationError } from '../hooks/index.js'
46
46
  * deliberately no second copy of either the shape-recognition or the
47
47
  * field-read check: `access-filter.ts` supplies the RELATED list at each
48
48
  * hop and calls back into the same primitives this module already owns.
49
+ *
50
+ * #1092 — `validateQueryKeys` is also the include-nested counterpart: a
51
+ * `where`/`orderBy` a caller nests inside an `include` entry gets the same
52
+ * #912 check, called by `buildAccessScopedInclude` against the RELATED list
53
+ * instead of the current one — no second key-existence walker for that
54
+ * position. `validateQueryFieldReadAccess` needs no equivalent call there:
55
+ * `checkKeyReadableOrThrow` already treats a key `resolveQueryField` cannot
56
+ * resolve as "already handled elsewhere" and skips it, which is exactly the
57
+ * synthetic-back-relation tolerance below needs — nothing to change. The one
58
+ * thing this position needs that the top-level `where`/`orderBy` never did:
59
+ * a key neither call resolves is tried against the optional
60
+ * `resolveSyntheticRelation` hook before being rejected, because a synthetic
61
+ * back-relation (#1082) is nameable in a nested predicate too. Every
62
+ * existing caller omits the hook, so top-level behavior is unchanged.
49
63
  */
50
64
 
51
65
  // Prisma's logical combinators for a WHERE clause — never field names.
@@ -68,6 +82,23 @@ export interface ResolvedQueryField {
68
82
  isRelationship: boolean
69
83
  }
70
84
 
85
+ /**
86
+ * Where an unresolved key's nested predicate should recurse when it turns out
87
+ * to name a synthetic back-relation (#1082) rather than a declared field —
88
+ * the source list the back-relation stands for, NOT `getRelatedListConfig`'s
89
+ * target (a synthetic field carries no `ref` of its own to follow).
90
+ */
91
+ export interface SyntheticRelationTarget {
92
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
93
+ listConfig: ListConfig<any>
94
+ listName: string
95
+ }
96
+
97
+ export type ResolveSyntheticRelation = (
98
+ key: string,
99
+ listName: string,
100
+ ) => SyntheticRelationTarget | null
101
+
71
102
  /**
72
103
  * Resolve a `where`/`orderBy` key against a list's declared fields.
73
104
  *
@@ -133,22 +164,61 @@ function walkWhere(
133
164
  listName: string,
134
165
  config: OpenSaasConfig,
135
166
  isSudo: boolean,
167
+ resolveSyntheticRelation?: ResolveSyntheticRelation,
136
168
  ): void {
137
169
  if (where === null || typeof where !== 'object') return
138
170
 
139
171
  if (Array.isArray(where)) {
140
- for (const entry of where) walkWhere(entry, listConfig, listName, config, isSudo)
172
+ for (const entry of where) {
173
+ walkWhere(entry, listConfig, listName, config, isSudo, resolveSyntheticRelation)
174
+ }
141
175
  return
142
176
  }
143
177
 
144
178
  for (const [key, value] of Object.entries(where as Record<string, unknown>)) {
145
179
  if (LOGICAL_OPERATORS.has(key)) {
146
- walkWhere(value, listConfig, listName, config, isSudo)
180
+ walkWhere(value, listConfig, listName, config, isSudo, resolveSyntheticRelation)
147
181
  continue
148
182
  }
149
183
 
150
184
  const resolved = resolveQueryField(key, listConfig.fields)
151
185
  if (!resolved) {
186
+ const synthetic = resolveSyntheticRelation?.(key, listName)
187
+ if (synthetic) {
188
+ // A synthetic back-relation is always to-many (#1082's own
189
+ // construction site has no arity branch), so its nested value takes
190
+ // the same two shapes a declared to-many relationship's filter does
191
+ // — walk it exactly like the resolved-relationship branch below,
192
+ // against the synthetic's source list instead of `getRelatedListConfig`.
193
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
194
+ const syntheticEntries = Object.entries(value as Record<string, unknown>)
195
+ const syntheticHasQuantifier = syntheticEntries.some(([k]) => RELATION_QUANTIFIERS.has(k))
196
+ if (syntheticHasQuantifier) {
197
+ for (const [quantifier, quantifierValue] of syntheticEntries) {
198
+ if (RELATION_QUANTIFIERS.has(quantifier)) {
199
+ walkWhere(
200
+ quantifierValue,
201
+ synthetic.listConfig,
202
+ synthetic.listName,
203
+ config,
204
+ isSudo,
205
+ resolveSyntheticRelation,
206
+ )
207
+ }
208
+ }
209
+ } else {
210
+ walkWhere(
211
+ value,
212
+ synthetic.listConfig,
213
+ synthetic.listName,
214
+ config,
215
+ isSudo,
216
+ resolveSyntheticRelation,
217
+ )
218
+ }
219
+ }
220
+ continue
221
+ }
152
222
  if (isSudo) continue
153
223
  rejectUndeclaredKey(listName, key, 'where')
154
224
  }
@@ -175,7 +245,14 @@ function walkWhere(
175
245
  // related list.
176
246
  for (const [quantifier, quantifierValue] of relationEntries) {
177
247
  if (RELATION_QUANTIFIERS.has(quantifier)) {
178
- walkWhere(quantifierValue, related.listConfig, related.listName, config, isSudo)
248
+ walkWhere(
249
+ quantifierValue,
250
+ related.listConfig,
251
+ related.listName,
252
+ config,
253
+ isSudo,
254
+ resolveSyntheticRelation,
255
+ )
179
256
  }
180
257
  }
181
258
  } else {
@@ -185,7 +262,14 @@ function walkWhere(
185
262
  // whole value object IS the nested WHERE clause here — walk it
186
263
  // directly, or an undeclared key reached exactly this way (one hop
187
264
  // through a to-one relation) would pass through unchecked.
188
- walkWhere(value, related.listConfig, related.listName, config, isSudo)
265
+ walkWhere(
266
+ value,
267
+ related.listConfig,
268
+ related.listName,
269
+ config,
270
+ isSudo,
271
+ resolveSyntheticRelation,
272
+ )
189
273
  }
190
274
  }
191
275
  }
@@ -198,17 +282,41 @@ function walkOrderBy(
198
282
  listName: string,
199
283
  config: OpenSaasConfig,
200
284
  isSudo: boolean,
285
+ resolveSyntheticRelation?: ResolveSyntheticRelation,
201
286
  ): void {
202
287
  if (orderBy === null || typeof orderBy !== 'object') return
203
288
 
204
289
  if (Array.isArray(orderBy)) {
205
- for (const entry of orderBy) walkOrderBy(entry, listConfig, listName, config, isSudo)
290
+ for (const entry of orderBy) {
291
+ walkOrderBy(entry, listConfig, listName, config, isSudo, resolveSyntheticRelation)
292
+ }
206
293
  return
207
294
  }
208
295
 
209
296
  for (const [key, value] of Object.entries(orderBy as Record<string, unknown>)) {
210
297
  const resolved = resolveQueryField(key, listConfig.fields)
211
298
  if (!resolved) {
299
+ const synthetic = resolveSyntheticRelation?.(key, listName)
300
+ if (synthetic) {
301
+ // `{ relation: { _count: 'asc' } }` orders by an aggregate — no
302
+ // nested field name to resolve, matching the declared-relationship
303
+ // branch below.
304
+ if (
305
+ value !== null &&
306
+ typeof value === 'object' &&
307
+ !('_count' in (value as Record<string, unknown>))
308
+ ) {
309
+ walkOrderBy(
310
+ value,
311
+ synthetic.listConfig,
312
+ synthetic.listName,
313
+ config,
314
+ isSudo,
315
+ resolveSyntheticRelation,
316
+ )
317
+ }
318
+ continue
319
+ }
212
320
  if (isSudo) continue
213
321
  rejectUndeclaredKey(listName, key, 'orderBy')
214
322
  }
@@ -220,7 +328,16 @@ function walkOrderBy(
220
328
  if ('_count' in (value as Record<string, unknown>)) continue
221
329
 
222
330
  const related = getRelatedListConfig(resolved.fieldConfig.ref, config)
223
- if (related) walkOrderBy(value, related.listConfig, related.listName, config, isSudo)
331
+ if (related) {
332
+ walkOrderBy(
333
+ value,
334
+ related.listConfig,
335
+ related.listName,
336
+ config,
337
+ isSudo,
338
+ resolveSyntheticRelation,
339
+ )
340
+ }
224
341
  }
225
342
  }
226
343
  }
@@ -231,6 +348,11 @@ function walkOrderBy(
231
348
  * Throws a `ValidationError` naming the list and the offending key on the
232
349
  * first undeclared key found. `isSudo` bypasses the check entirely, matching
233
350
  * the write path's `sudo` escape hatch.
351
+ *
352
+ * `resolveSyntheticRelation` is the include-nested position's own addition
353
+ * (#1092, see module doc comment) — omit it (every top-level `where`/`orderBy`
354
+ * caller does) and a key `resolveQueryField` can't resolve rejects exactly as
355
+ * before.
234
356
  */
235
357
  export function validateQueryKeys(args: {
236
358
  where?: unknown
@@ -240,10 +362,15 @@ export function validateQueryKeys(args: {
240
362
  listName: string
241
363
  config: OpenSaasConfig
242
364
  isSudo: boolean
365
+ resolveSyntheticRelation?: ResolveSyntheticRelation
243
366
  }): void {
244
- const { where, orderBy, listConfig, listName, config, isSudo } = args
245
- if (where !== undefined) walkWhere(where, listConfig, listName, config, isSudo)
246
- if (orderBy !== undefined) walkOrderBy(orderBy, listConfig, listName, config, isSudo)
367
+ const { where, orderBy, listConfig, listName, config, isSudo, resolveSyntheticRelation } = args
368
+ if (where !== undefined) {
369
+ walkWhere(where, listConfig, listName, config, isSudo, resolveSyntheticRelation)
370
+ }
371
+ if (orderBy !== undefined) {
372
+ walkOrderBy(orderBy, listConfig, listName, config, isSudo, resolveSyntheticRelation)
373
+ }
247
374
  }
248
375
 
249
376
  /**
@@ -111,6 +111,41 @@ describe('buildRelationshipCountSelect', () => {
111
111
  )
112
112
  expect(select).toBeUndefined()
113
113
  })
114
+
115
+ // Issue #1111: a relationship hidden via field-level `read` access must not
116
+ // leak its true count, even when the related list's rows are otherwise
117
+ // fully readable — the same denial an ordinary `include` of that field
118
+ // already gets from `filterReadableFields`'s `checkFieldAccess` call.
119
+ it('omits a relation whose field-level `read` access denies it, even though the related list is fully readable', async () => {
120
+ const config: OpenSaasConfig = {
121
+ db: { provider: 'sqlite', prismaClientConstructor: () => null as never },
122
+ lists: {
123
+ User: list({
124
+ fields: {
125
+ name: text(),
126
+ posts: relationship({
127
+ ref: 'Post.author',
128
+ many: true,
129
+ access: { read: () => false },
130
+ }),
131
+ },
132
+ access: { operation: { query: () => true } },
133
+ }),
134
+ Post: list({
135
+ fields: { title: text(), author: relationship({ ref: 'User.posts' }) },
136
+ access: { operation: { query: () => true } },
137
+ }),
138
+ },
139
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
140
+ } as any
141
+
142
+ const select = await buildRelationshipCountSelect(
143
+ config.lists.User,
144
+ { session: null, context: makeContext() },
145
+ config,
146
+ )
147
+ expect(select).toBeUndefined()
148
+ })
114
149
  })
115
150
 
116
151
  describe('resolveRelationshipCountFilters', () => {
@@ -186,6 +221,46 @@ describe('resolveRelationshipCountFilters', () => {
186
221
  expect(findMany).not.toHaveBeenCalled()
187
222
  })
188
223
 
224
+ // Issue #1111: a relationship denied at the FIELD level (as opposed to the
225
+ // related list's operation-level `query` access, covered above) must also
226
+ // resolve to "always 0, no query needed" — the related list here (Post) is
227
+ // otherwise fully readable, so only the field-level check on `posts` itself
228
+ // can be what's denying it.
229
+ it('resolves a field-level-denied relationship without any query: count is always 0', async () => {
230
+ const config: OpenSaasConfig = {
231
+ db: { provider: 'sqlite', prismaClientConstructor: () => null as never },
232
+ lists: {
233
+ User: list({
234
+ fields: {
235
+ name: text(),
236
+ posts: relationship({
237
+ ref: 'Post.author',
238
+ many: true,
239
+ access: { read: () => false },
240
+ }),
241
+ },
242
+ access: { operation: { query: () => true } },
243
+ }),
244
+ Post: list({
245
+ fields: { title: text(), author: relationship({ ref: 'User.posts' }) },
246
+ access: { operation: { query: () => true } },
247
+ }),
248
+ },
249
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
250
+ } as any
251
+ const findMany = vi.fn(async () => [])
252
+ const where = { posts: { [RELATIONSHIP_COUNT_FILTER_KEY]: { operator: 'gt', value: 0 } } }
253
+ const resolved = await resolveRelationshipCountFilters(
254
+ where,
255
+ config.lists.User,
256
+ 'User',
257
+ { session: null, context: makeContext(findMany) },
258
+ config,
259
+ )
260
+ expect(resolved).toEqual({ id: { in: [] } })
261
+ expect(findMany).not.toHaveBeenCalled()
262
+ })
263
+
189
264
  it('preserves sibling conditions, ANDing the resolved id constraint', async () => {
190
265
  const config = makeConfig()
191
266
  const findMany = vi.fn(async () => [{ id: 'u2', _count: { posts: 7 } }])