@opensaas/stack-core 0.40.0 → 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 (86) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +39 -0
  3. package/CLAUDE.md +52 -0
  4. package/dist/access/access-filter.d.ts +88 -18
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +343 -36
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +759 -6
  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 +11 -0
  15. package/dist/access/engine.d.ts.map +1 -1
  16. package/dist/access/engine.js +26 -0
  17. package/dist/access/engine.js.map +1 -1
  18. package/dist/access/errors.d.ts +34 -0
  19. package/dist/access/errors.d.ts.map +1 -1
  20. package/dist/access/errors.js +47 -0
  21. package/dist/access/errors.js.map +1 -1
  22. package/dist/access/field-visibility.d.ts +2 -2
  23. package/dist/access/field-visibility.d.ts.map +1 -1
  24. package/dist/access/field-visibility.js +73 -23
  25. package/dist/access/field-visibility.js.map +1 -1
  26. package/dist/access/index.d.ts +7 -3
  27. package/dist/access/index.d.ts.map +1 -1
  28. package/dist/access/index.js +9 -2
  29. package/dist/access/index.js.map +1 -1
  30. package/dist/access/multi-column-read-write.test.js.map +1 -1
  31. package/dist/access/query-validation.d.ts +31 -0
  32. package/dist/access/query-validation.d.ts.map +1 -1
  33. package/dist/access/query-validation.js +75 -16
  34. package/dist/access/query-validation.js.map +1 -1
  35. package/dist/access/relationship-count.d.ts +41 -3
  36. package/dist/access/relationship-count.d.ts.map +1 -1
  37. package/dist/access/relationship-count.js +44 -7
  38. package/dist/access/relationship-count.js.map +1 -1
  39. package/dist/access/relationship-count.test.js +62 -0
  40. package/dist/access/relationship-count.test.js.map +1 -1
  41. package/dist/access/synthetic-include-read.test.d.ts +2 -0
  42. package/dist/access/synthetic-include-read.test.d.ts.map +1 -0
  43. package/dist/access/synthetic-include-read.test.js +121 -0
  44. package/dist/access/synthetic-include-read.test.js.map +1 -0
  45. package/dist/config/types.d.ts +22 -22
  46. package/dist/config/types.d.ts.map +1 -1
  47. package/dist/context/hook-pipeline.d.ts +2 -2
  48. package/dist/context/hook-pipeline.d.ts.map +1 -1
  49. package/dist/context/hook-pipeline.js.map +1 -1
  50. package/dist/context/index.d.ts +16 -1
  51. package/dist/context/index.d.ts.map +1 -1
  52. package/dist/context/index.js +76 -26
  53. package/dist/context/index.js.map +1 -1
  54. package/dist/context/nested-operations.d.ts +2 -2
  55. package/dist/context/nested-operations.d.ts.map +1 -1
  56. package/dist/context/nested-operations.js +21 -3
  57. package/dist/context/nested-operations.js.map +1 -1
  58. package/dist/context/write-pipeline.d.ts.map +1 -1
  59. package/dist/context/write-pipeline.js +23 -29
  60. package/dist/context/write-pipeline.js.map +1 -1
  61. package/dist/hooks/index.d.ts +16 -15
  62. package/dist/hooks/index.d.ts.map +1 -1
  63. package/dist/hooks/index.js.map +1 -1
  64. package/package.json +2 -2
  65. package/src/access/access-filter.test.ts +1403 -159
  66. package/src/access/access-filter.ts +525 -47
  67. package/src/access/denied-relation-visibility.test.ts +173 -0
  68. package/src/access/engine.ts +28 -0
  69. package/src/access/errors.ts +55 -0
  70. package/src/access/field-visibility.ts +105 -24
  71. package/src/access/index.ts +11 -0
  72. package/src/access/multi-column-read-write.test.ts +3 -2
  73. package/src/access/query-validation.ts +136 -9
  74. package/src/access/relationship-count.test.ts +75 -0
  75. package/src/access/relationship-count.ts +53 -10
  76. package/src/access/synthetic-include-read.test.ts +179 -0
  77. package/src/config/types.ts +22 -22
  78. package/src/context/hook-pipeline.ts +4 -2
  79. package/src/context/index.ts +120 -39
  80. package/src/context/nested-operations.ts +35 -11
  81. package/src/context/write-pipeline.ts +52 -32
  82. package/src/hooks/index.ts +22 -15
  83. package/tests/context.test.ts +193 -5
  84. package/tests/hook-context-secured.test.ts +424 -0
  85. package/tests/nested-access-and-hooks.test.ts +324 -0
  86. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,173 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { filterReadableFields } from './field-visibility.js'
3
+ import { emptyDeclaredOnlyTree } from './declared-dependencies.js'
4
+ import type { ToOneAccessVisibilityTree } from './access-filter.js'
5
+ import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
6
+ import type { AccessContext } from './types.js'
7
+
8
+ /**
9
+ * Regression coverage for the `filterReadableFields` half of issue #1103: a
10
+ * relation `buildAccessScopedInclude` denied outright is absent from the raw
11
+ * row (Prisma never fetched it), so the main per-field loop — which only
12
+ * ever visits keys `Object.entries(workingItem)` contains — never sees it.
13
+ * The dedicated post-query pass this file exercises is what forces the key
14
+ * present anyway: `null` for a to-one relation (issue #974, pre-existing),
15
+ * `[]` for a to-many one (issue #1103, this fix) — never a silently absent
16
+ * key, which is what previously broke the fragment API's typed contract
17
+ * (`ResultOf` types a to-many relation as an array).
18
+ */
19
+
20
+ function rel(ref: string, many = false): FieldConfig {
21
+ return { type: 'relationship', ref, many } as unknown as FieldConfig
22
+ }
23
+
24
+ function makeContext(): AccessContext {
25
+ return {
26
+ session: null,
27
+ _isSudo: false,
28
+ _resolveOutputChain: [],
29
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
30
+ } as any
31
+ }
32
+
33
+ function config(): OpenSaasConfig {
34
+ return {
35
+ db: { provider: 'sqlite' },
36
+ lists: {
37
+ Author: {
38
+ fields: {
39
+ name: { type: 'text' } as FieldConfig,
40
+ secrets: rel('Secret.author', true),
41
+ profile: rel('Profile.author', false),
42
+ hiddenSecrets: {
43
+ ...rel('Secret.author', true),
44
+ access: { read: () => false },
45
+ } as unknown as FieldConfig,
46
+ },
47
+ access: { operation: { query: () => true } },
48
+ },
49
+ Secret: {
50
+ fields: { value: { type: 'text' } as FieldConfig },
51
+ access: { operation: { query: () => false } },
52
+ },
53
+ Profile: {
54
+ fields: { bio: { type: 'text' } as FieldConfig },
55
+ access: { operation: { query: () => false } },
56
+ },
57
+ },
58
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
59
+ } as any as OpenSaasConfig
60
+ }
61
+
62
+ // Term ← Bill.term (list-only ref, no field on Term) — the synthetic
63
+ // back-relation case, always to-many (#1082).
64
+ function syntheticConfig(): OpenSaasConfig {
65
+ return {
66
+ db: { provider: 'sqlite' },
67
+ lists: {
68
+ Term: {
69
+ fields: { name: { type: 'text' } as FieldConfig },
70
+ access: { operation: { query: () => true } },
71
+ },
72
+ Bill: {
73
+ fields: { amount: { type: 'integer' } as FieldConfig, term: rel('Term') },
74
+ access: { operation: { query: () => false } },
75
+ },
76
+ },
77
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
78
+ } as any as OpenSaasConfig
79
+ }
80
+
81
+ describe('filterReadableFields — denied relations forced present (issues #974 / #1103)', () => {
82
+ it('forces a denied to-many relation to [], not an absent key', async () => {
83
+ const cfg = config()
84
+ const authorRow = { id: 'a1', name: 'A' } // `secrets` absent — never fetched by Prisma
85
+ const toOneVisibility: ToOneAccessVisibilityTree = {
86
+ filters: { secrets: { kind: 'denied' } },
87
+ nested: {},
88
+ }
89
+
90
+ const result = await filterReadableFields(
91
+ authorRow,
92
+ cfg.lists.Author.fields,
93
+ { session: null, context: makeContext() },
94
+ cfg,
95
+ 0,
96
+ 'Author',
97
+ emptyDeclaredOnlyTree(),
98
+ undefined,
99
+ toOneVisibility,
100
+ )
101
+
102
+ expect((result as Record<string, unknown>).secrets).toEqual([])
103
+ })
104
+
105
+ it('still forces a denied to-one relation to null (regression check, issue #974)', async () => {
106
+ const cfg = config()
107
+ const authorRow = { id: 'a1', name: 'A' } // `profile` absent — never fetched by Prisma
108
+ const toOneVisibility: ToOneAccessVisibilityTree = {
109
+ filters: { profile: { kind: 'denied' } },
110
+ nested: {},
111
+ }
112
+
113
+ const result = await filterReadableFields(
114
+ authorRow,
115
+ cfg.lists.Author.fields,
116
+ { session: null, context: makeContext() },
117
+ cfg,
118
+ 0,
119
+ 'Author',
120
+ emptyDeclaredOnlyTree(),
121
+ undefined,
122
+ toOneVisibility,
123
+ )
124
+
125
+ expect((result as Record<string, unknown>).profile).toBeNull()
126
+ })
127
+
128
+ it('forces a denied synthetic back-relation to [] — always to-many (#1082 interaction)', async () => {
129
+ const cfg = syntheticConfig()
130
+ const termRow = { id: 't1', name: 'Term 1' } // `from_Bill_term` absent — never fetched
131
+ const toOneVisibility: ToOneAccessVisibilityTree = {
132
+ filters: { from_Bill_term: { kind: 'denied' } },
133
+ nested: {},
134
+ }
135
+
136
+ const result = await filterReadableFields(
137
+ termRow,
138
+ cfg.lists.Term.fields,
139
+ { session: null, context: makeContext() },
140
+ cfg,
141
+ 0,
142
+ 'Term',
143
+ emptyDeclaredOnlyTree(),
144
+ undefined,
145
+ toOneVisibility,
146
+ )
147
+
148
+ expect((result as Record<string, unknown>).from_Bill_term).toEqual([])
149
+ })
150
+
151
+ it('omits a denied relation entirely when field-level read access also denies it, rather than forcing []', async () => {
152
+ const cfg = config()
153
+ const authorRow = { id: 'a1', name: 'A' }
154
+ const toOneVisibility: ToOneAccessVisibilityTree = {
155
+ filters: { hiddenSecrets: { kind: 'denied' } },
156
+ nested: {},
157
+ }
158
+
159
+ const result = await filterReadableFields(
160
+ authorRow,
161
+ cfg.lists.Author.fields,
162
+ { session: null, context: makeContext() },
163
+ cfg,
164
+ 0,
165
+ 'Author',
166
+ emptyDeclaredOnlyTree(),
167
+ undefined,
168
+ toOneVisibility,
169
+ )
170
+
171
+ expect('hiddenSecrets' in result).toBe(false)
172
+ })
173
+ })
@@ -94,6 +94,34 @@ export function resolveSyntheticReverseRelation(
94
94
  return null
95
95
  }
96
96
 
97
+ /**
98
+ * Enumerate every synthetic back-relation name a list-only `ref` elsewhere in
99
+ * the config synthesizes onto `parentListName` — the same relations
100
+ * `resolveSyntheticReverseRelation` resolves one at a time given a candidate
101
+ * key, returned here as the full set for a caller that instead needs "every
102
+ * relation this list carries" with no candidate to check (e.g. `_count:
103
+ * true`'s "count every relation" expansion in `access-filter.ts`, issue
104
+ * #1087 — a bare `_count: true` must include a synthetic back-relation's
105
+ * count exactly as it always has, not only a caller-named one).
106
+ */
107
+ export function listSyntheticReverseRelationNames(
108
+ parentListName: string,
109
+ config: OpenSaasConfig,
110
+ ): string[] {
111
+ const names: string[] = []
112
+ for (const [sourceListName, sourceListConfig] of Object.entries(config.lists)) {
113
+ for (const [sourceFieldName, sourceFieldConfig] of Object.entries(sourceListConfig.fields)) {
114
+ if (sourceFieldConfig.type !== 'relationship') continue
115
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- RelationshipField must accept any TypeInfo
116
+ const rel = sourceFieldConfig as RelationshipField<any>
117
+ const refParts = rel.ref.split('.')
118
+ if (refParts.length !== 1 || refParts[0] !== parentListName) continue
119
+ names.push(getSyntheticFieldName(sourceListName, sourceFieldName))
120
+ }
121
+ }
122
+ return names
123
+ }
124
+
97
125
  export async function checkAccess<T = Record<string, unknown>>(
98
126
  accessControl: AccessControl<T> | undefined,
99
127
  args: {
@@ -99,6 +99,61 @@ export class RelationFilterAccessDeniedError extends Error {
99
99
  }
100
100
  }
101
101
 
102
+ /**
103
+ * Thrown when a caller-supplied `include` names a key that resolves to
104
+ * neither a declared relationship, a synthetic back-relation (a list-only
105
+ * `ref`'s ORM-required opposite field, which no list config declares — see
106
+ * `resolveSyntheticReverseRelation`), nor `_count`. Before this,
107
+ * `buildAccessScopedInclude`'s scoping walk passed an unrecognised key
108
+ * straight through unscoped — the one surface among `data`/`where`/`orderBy`/
109
+ * `include` that failed open rather than closed (issue #1082). `sudo` never
110
+ * reaches this: it skips `buildAccessScopedInclude` entirely (see
111
+ * `resolveReadInclude` in `context/index.ts`), matching every other
112
+ * access-control escape hatch.
113
+ */
114
+ export class UndeclaredIncludeKeyError extends Error {
115
+ public listKey: string
116
+ public fieldKey: string
117
+
118
+ constructor(listKey: string, fieldKey: string) {
119
+ super(
120
+ `Cannot include "${listKey}.${fieldKey}" — it is not a field of this list. ` +
121
+ `Undeclared include keys are rejected, matching data/where/orderBy keys.`,
122
+ )
123
+ this.name = 'UndeclaredIncludeKeyError'
124
+ this.listKey = listKey
125
+ this.fieldKey = fieldKey
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Thrown when a caller-supplied `_count.select` in `include` names a key that
131
+ * is not a countable to-many relation — undeclared, a scalar, a virtual, or a
132
+ * to-one relationship. Mirrors `UndeclaredIncludeKeyError`'s rejection for the
133
+ * ordinary `include` walk, for the one key shape that was allowlisted through
134
+ * unscoped rather than rejected (#1082's "Out of scope", closed here by
135
+ * #1087). A key naming a synthetic back-relation (#1082) is NOT rejected — it
136
+ * is a genuine countable to-many, resolved the same way the ordinary
137
+ * `include` walk resolves it. `sudo` never reaches this: it skips
138
+ * `buildAccessScopedInclude` entirely (see `resolveReadInclude` in
139
+ * `context/index.ts`), matching every other access-control escape hatch.
140
+ */
141
+ export class UndeclaredCountKeyError extends Error {
142
+ public listKey: string
143
+ public fieldKey: string
144
+
145
+ constructor(listKey: string, fieldKey: string) {
146
+ super(
147
+ `Cannot count "${listKey}.${fieldKey}" — it is not a countable to-many relationship on this ` +
148
+ `list. A \`_count.select\` key must name a declared to-many relationship or a synthetic ` +
149
+ `back-relation, matching the ordinary include keys this list accepts.`,
150
+ )
151
+ this.name = 'UndeclaredCountKeyError'
152
+ this.listKey = listKey
153
+ this.fieldKey = fieldKey
154
+ }
155
+ }
156
+
102
157
  function describeAccessResult(result: unknown): string {
103
158
  if (result === null) return 'null'
104
159
  if (result === undefined) return 'undefined'
@@ -1,14 +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 } from './access-filter.js'
11
- import { emptyToOneAccessVisibilityTree } from './access-filter.js'
10
+ import type { ToOneAccessVisibilityTree, CountAccessDenialTree } from './access-filter.js'
11
+ import {
12
+ emptyToOneAccessVisibilityTree,
13
+ isToOneRelationship,
14
+ emptyCountAccessDenialTree,
15
+ } from './access-filter.js'
12
16
  // NOTE: `context/index.ts` imports `filterReadableFields` from this module
13
17
  // (via the `access/index.ts` barrel) — this is an intentional cyclic
14
18
  // dependency, the same shape and for the same reason as the one documented in
@@ -54,6 +58,26 @@ import { buildDbDelegate } from '../context/index.js'
54
58
  * before any field-level access check or `resolveOutput` hook runs, so the
55
59
  * rest of the pipeline sees exactly what a denied to-one read has always
56
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.
57
81
  */
58
82
 
59
83
  type ResolveOutputHookRuntime = (args: {
@@ -216,6 +240,10 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
216
240
  // regardless of that relation's own arity, since a filtered to-one can sit
217
241
  // beneath a to-many hop.
218
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(),
219
247
  ): Promise<Partial<T>> {
220
248
  const filtered: Record<string, unknown> = {}
221
249
 
@@ -279,25 +307,49 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
279
307
  // cap used to let a relation be scoped correctly at the DB level while
280
308
  // still returning with unfiltered fields past this function's own,
281
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
+
282
322
  if (
283
323
  config &&
284
- fieldConfig?.type === 'relationship' &&
285
- 'ref' in fieldConfig &&
286
- fieldConfig.ref &&
324
+ (isDeclaredRelationshipField || synthetic) &&
287
325
  value !== null &&
288
326
  value !== undefined
289
327
  ) {
290
- const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
291
- ...args,
292
- item: workingItem,
293
- })
294
-
295
- if (!canRead) {
296
- accessDeniedKeys.add(fieldName)
297
- continue
298
- }
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
+ }
299
341
 
300
- 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
+ }
301
353
  // The declared-only tree for whatever THIS relation's own list computes,
302
354
  // e.g. a field on the related list that declares its own `needs`. Falls
303
355
  // back to an empty tree when this relation isn't declaration-related at
@@ -318,6 +370,11 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
318
370
  // This key's OWN to-one existence check, if `fieldName` itself is a
319
371
  // filtered to-one relation (as opposed to one further down its tree).
320
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()
321
378
 
322
379
  if (relatedConfig) {
323
380
  if (Array.isArray(value)) {
@@ -333,6 +390,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
333
390
  nestedDeclaredOnly,
334
391
  nestedSelection,
335
392
  nestedToOneVisibility,
393
+ nestedCountDenials,
336
394
  ),
337
395
  ),
338
396
  )
@@ -352,6 +410,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
352
410
  nestedDeclaredOnly,
353
411
  nestedSelection,
354
412
  nestedToOneVisibility,
413
+ nestedCountDenials,
355
414
  )
356
415
  : null
357
416
  }
@@ -381,12 +440,14 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
381
440
  }
382
441
  }
383
442
 
384
- // To-one relations `buildAccessScopedInclude` denied outright (issue #974)
385
- // were never asked of Prisma at all, so `fieldName` has no entry in
386
- // `workingItem` and the loop above never visits it. Force it present as an
387
- // explicit `null` here — matching what a denied single-record read means
388
- // everywhere else in the context — rather than leaving the key silently
389
- // absent.
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.
390
451
  for (const [fieldName, entry] of Object.entries(toOneVisibility.filters)) {
391
452
  if (entry.kind !== 'denied') continue
392
453
  if (fieldName in filtered || fieldName in workingItem) continue
@@ -403,7 +464,27 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
403
464
  continue
404
465
  }
405
466
 
406
- filtered[fieldName] = null
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
407
488
  }
408
489
 
409
490
  // The item a virtual field's hook sees: stored columns and fetched
@@ -20,6 +20,7 @@ export {
20
20
  isPrismaFilter,
21
21
  getRelatedListConfig,
22
22
  resolveSyntheticReverseRelation,
23
+ listSyntheticReverseRelationNames,
23
24
  } from './engine.js'
24
25
  export type { SyntheticReverseRelation } from './engine.js'
25
26
  // Canonical field-level access evaluation (shared by read and write paths).
@@ -42,13 +43,19 @@ export {
42
43
  resolveToOneAccessVisibility,
43
44
  emptyToOneAccessFilterTree,
44
45
  emptyToOneAccessVisibilityTree,
46
+ emptyCountAccessDenialTree,
45
47
  } from './access-filter.js'
46
48
  export type {
47
49
  ToOneAccessFilterTree,
48
50
  ToOneAccessFilterEntry,
49
51
  ToOneAccessVisibilityTree,
50
52
  ToOneVisibility,
53
+ CountAccessDenialTree,
51
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'
52
59
  // Phase 2 — Field Visibility (post-query field stripping + resolveOutput).
53
60
  export { filterReadableFields } from './field-visibility.js'
54
61
  // Declared Dependencies — folding `needs` into an include without widening
@@ -69,3 +76,7 @@ export { InvalidFieldAccessResultError } from './errors.js'
69
76
  export { InvalidCreateAccessResultError } from './errors.js'
70
77
  // Thrown when a relation filter's related list denies query access outright (#916).
71
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,