@opensaas/stack-core 0.32.0 → 0.33.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 (52) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +26 -0
  3. package/dist/access/access-filter.d.ts +76 -20
  4. package/dist/access/access-filter.d.ts.map +1 -1
  5. package/dist/access/access-filter.js +97 -70
  6. package/dist/access/access-filter.js.map +1 -1
  7. package/dist/access/access-filter.test.js +171 -10
  8. package/dist/access/access-filter.test.js.map +1 -1
  9. package/dist/access/depth-limits.d.ts +12 -0
  10. package/dist/access/depth-limits.d.ts.map +1 -0
  11. package/dist/access/depth-limits.js +12 -0
  12. package/dist/access/depth-limits.js.map +1 -0
  13. package/dist/access/errors.d.ts +19 -0
  14. package/dist/access/errors.d.ts.map +1 -0
  15. package/dist/access/errors.js +29 -0
  16. package/dist/access/errors.js.map +1 -0
  17. package/dist/access/field-visibility.d.ts.map +1 -1
  18. package/dist/access/field-visibility.js +10 -3
  19. package/dist/access/field-visibility.js.map +1 -1
  20. package/dist/access/index.d.ts +3 -1
  21. package/dist/access/index.d.ts.map +1 -1
  22. package/dist/access/index.js +3 -1
  23. package/dist/access/index.js.map +1 -1
  24. package/dist/context/index.d.ts.map +1 -1
  25. package/dist/context/index.js +14 -8
  26. package/dist/context/index.js.map +1 -1
  27. package/dist/context/nested-operations.d.ts +1 -1
  28. package/dist/context/nested-operations.d.ts.map +1 -1
  29. package/dist/context/nested-operations.js +1 -5
  30. package/dist/context/nested-operations.js.map +1 -1
  31. package/dist/context/transaction-boundary.d.ts.map +1 -1
  32. package/dist/context/transaction-boundary.js +43 -6
  33. package/dist/context/transaction-boundary.js.map +1 -1
  34. package/dist/index.d.ts +1 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +5 -0
  37. package/dist/index.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/access/access-filter.test.ts +254 -7
  40. package/src/access/access-filter.ts +141 -72
  41. package/src/access/depth-limits.ts +11 -0
  42. package/src/access/errors.ts +32 -0
  43. package/src/access/field-visibility.ts +10 -3
  44. package/src/access/index.ts +4 -0
  45. package/src/context/index.ts +14 -5
  46. package/src/context/nested-operations.ts +0 -7
  47. package/src/context/transaction-boundary.ts +48 -7
  48. package/src/index.ts +6 -0
  49. package/tests/access-relationships.test.ts +77 -63
  50. package/tests/context.test.ts +106 -24
  51. package/tests/transaction-boundary-hooks.test.ts +246 -1
  52. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,32 @@
1
+ import { READ_INCLUDE_MAX_DEPTH } from './depth-limits.js'
2
+
3
+ /**
4
+ * Thrown when a caller-supplied `include` names a relation nested deeper than
5
+ * the Access Filter can scope (see `READ_INCLUDE_MAX_DEPTH`). Deliberately
6
+ * distinct from `ValidationError`: this is not bad user input, it is the
7
+ * engine refusing to return data it cannot prove is row/field scoped. Code
8
+ * that catches `ValidationError` to report form errors must not silently
9
+ * swallow this.
10
+ *
11
+ * Only an explicit caller selection past the depth cap triggers this — the
12
+ * auto-include silently stopping at the cap (no caller `include` involved)
13
+ * never throws. See ADR-0022 and issue #830.
14
+ */
15
+ export class AccessScopeDepthExceededError extends Error {
16
+ public listKey: string
17
+ public fieldKey: string
18
+ public depth: number
19
+
20
+ constructor(listKey: string, fieldKey: string, depth: number) {
21
+ super(
22
+ `Cannot compute an access scope for "${listKey}.${fieldKey}" at include depth ${depth}: ` +
23
+ `this exceeds the Access Filter's maximum read-include depth (${READ_INCLUDE_MAX_DEPTH}). ` +
24
+ `A caller-supplied include this deep cannot be row- and field-scoped, so the read is denied ` +
25
+ `rather than returned unscoped. Restructure the query to fetch this relation separately.`,
26
+ )
27
+ this.name = 'AccessScopeDepthExceededError'
28
+ this.listKey = listKey
29
+ this.fieldKey = fieldKey
30
+ this.depth = depth
31
+ }
32
+ }
@@ -116,7 +116,6 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
116
116
  listKey?: string,
117
117
  ): Promise<Partial<T>> {
118
118
  const filtered: Record<string, unknown> = {}
119
- const MAX_DEPTH = 5 // Prevent infinite recursion
120
119
 
121
120
  // Multi-column fields (e.g. storage image()/file() in Keystone-parity mode)
122
121
  // back several physical columns rather than one. Before the per-field pass,
@@ -153,14 +152,22 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
153
152
  // Handle relationship fields - recursively filter fields within related items
154
153
  // Note: Access control filtering is now done at database level via buildIncludeWithAccessControl
155
154
  // This only handles field-level access (hiding sensitive fields)
155
+ //
156
+ // Deliberately uncapped: the row/relation scoping in access-filter.ts bounds
157
+ // what gets FETCHED (a caller include past its depth cap is now a denial,
158
+ // not a passthrough — see ADR-0022), so by the time a result reaches this
159
+ // function it is already a finite, acyclic tree whose depth was decided at
160
+ // the pre-query phase. Capping recursion again here independently of that
161
+ // cap used to let a relation be scoped correctly at the DB level while
162
+ // still returning with unfiltered fields past this function's own,
163
+ // separately-tracked limit (issue #830).
156
164
  if (
157
165
  config &&
158
166
  fieldConfig?.type === 'relationship' &&
159
167
  'ref' in fieldConfig &&
160
168
  fieldConfig.ref &&
161
169
  value !== null &&
162
- value !== undefined &&
163
- depth < MAX_DEPTH
170
+ value !== undefined
164
171
  ) {
165
172
  // Gate the relationship on read access before recursing.
166
173
  const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
@@ -26,6 +26,10 @@ export {
26
26
  buildIncludeWithAccessControl,
27
27
  mergeIncludeWithAccessControl,
28
28
  stripVirtualFieldsFromInclude,
29
+ toPrismaInclude,
29
30
  } from './access-filter.js'
31
+ export type { AccessIncludeResult } from './access-filter.js'
30
32
  // Phase 2 — Field Visibility (post-query field stripping + resolveOutput).
31
33
  export { filterReadableFields } from './field-visibility.js'
34
+ // Thrown when a caller include reaches past the depth the Access Filter can scope.
35
+ export { AccessScopeDepthExceededError } from './errors.js'
@@ -7,6 +7,7 @@ import {
7
7
  buildIncludeWithAccessControl,
8
8
  mergeIncludeWithAccessControl,
9
9
  stripVirtualFieldsFromInclude,
10
+ toPrismaInclude,
10
11
  } from '../access/index.js'
11
12
  import { ValidationError, DatabaseError } from '../hooks/index.js'
12
13
  import { getDbKey } from '../lib/case-utils.js'
@@ -994,15 +995,19 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
994
995
  // MERGE (not replace) a caller-supplied include with the access-controlled
995
996
  // include: the caller selects WHICH relations to fetch, access control
996
997
  // decides WHETHER and WITH WHAT filter (#566). A bare auto-include (no
997
- // caller include) still uses the access-controlled include directly.
998
+ // caller include) still uses the access-controlled include directly. A
999
+ // caller include naming a relation past the depth the engine can scope
1000
+ // throws `AccessScopeDepthExceededError` (issue #830) rather than being
1001
+ // returned unscoped.
998
1002
  include = args.include
999
1003
  ? mergeIncludeWithAccessControl(
1000
1004
  args.include,
1001
1005
  accessControlledInclude,
1002
1006
  listConfig.fields,
1003
1007
  config,
1008
+ listName,
1004
1009
  )
1005
- : accessControlledInclude
1010
+ : toPrismaInclude(accessControlledInclude)
1006
1011
  }
1007
1012
 
1008
1013
  // Virtual fields have no database column. Whichever path produced
@@ -1128,15 +1133,19 @@ function createFindMany<TPrisma extends PrismaClientLike>(
1128
1133
  // MERGE (not replace) a caller-supplied include with the access-controlled
1129
1134
  // include: the caller selects WHICH relations to fetch, access control
1130
1135
  // decides WHETHER and WITH WHAT filter (#566). A bare auto-include (no
1131
- // caller include) still uses the access-controlled include directly.
1136
+ // caller include) still uses the access-controlled include directly. A
1137
+ // caller include naming a relation past the depth the engine can scope
1138
+ // throws `AccessScopeDepthExceededError` (issue #830) rather than being
1139
+ // returned unscoped.
1132
1140
  include = args?.include
1133
1141
  ? mergeIncludeWithAccessControl(
1134
1142
  args.include,
1135
1143
  accessControlledInclude,
1136
1144
  listConfig.fields,
1137
1145
  config,
1146
+ listName,
1138
1147
  )
1139
- : accessControlledInclude
1148
+ : toPrismaInclude(accessControlledInclude)
1140
1149
  }
1141
1150
 
1142
1151
  // Virtual fields have no database column. Whichever path produced
@@ -1445,7 +1454,7 @@ function createGet<TPrisma extends PrismaClientLike>(
1445
1454
  // Try to find the record
1446
1455
  const item = await model.findFirst({
1447
1456
  where,
1448
- include: accessControlledInclude,
1457
+ include: toPrismaInclude(accessControlledInclude),
1449
1458
  })
1450
1459
 
1451
1460
  // If record exists, return it
@@ -1312,17 +1312,10 @@ export async function processNestedOperations(
1312
1312
  // finding) so item-/inputData-dependent field-access rules cannot diverge between
1313
1313
  // Phase 5 and the connect site. `undefined` is tolerated (defaults to `{}`).
1314
1314
  parentInputData: Record<string, unknown> | undefined = undefined,
1315
- depth: number = 0,
1316
1315
  ): Promise<NestedOpsResult> {
1317
- const MAX_DEPTH = 5
1318
-
1319
1316
  const afterTasks: AfterTask[] = []
1320
1317
  const includeFields = new Set<string>()
1321
1318
 
1322
- if (depth >= MAX_DEPTH) {
1323
- return { data, afterTasks, includeFields }
1324
- }
1325
-
1326
1319
  const processed: Record<string, unknown> = {}
1327
1320
 
1328
1321
  for (const [fieldName, value] of Object.entries(data)) {
@@ -55,9 +55,6 @@ export interface InvolvedList {
55
55
  originalItem: Record<string, unknown> | undefined
56
56
  }
57
57
 
58
- /** Max nesting depth walked when enumerating involved lists (matches nested-operations). */
59
- const MAX_DEPTH = 5
60
-
61
58
  /** Nested-op kinds whose payloads imply an involved list + operation. */
62
59
  const NESTED_OP_OPERATIONS: ReadonlyArray<{ kind: string; operation: WriteOperation }> = [
63
60
  { kind: 'create', operation: 'create' },
@@ -67,10 +64,49 @@ const NESTED_OP_OPERATIONS: ReadonlyArray<{ kind: string; operation: WriteOperat
67
64
  { kind: 'connectOrCreate', operation: 'create' },
68
65
  ]
69
66
 
67
+ /** Distinct dedupe-key operations `NESTED_OP_OPERATIONS` can produce (create/update/delete). */
68
+ const DISTINCT_OPERATION_COUNT = new Set(NESTED_OP_OPERATIONS.map((o) => o.operation)).size
69
+
70
70
  function isRelationshipField(fieldConfig: FieldConfig | undefined): boolean {
71
71
  return fieldConfig?.type === 'relationship'
72
72
  }
73
73
 
74
+ /**
75
+ * The number of distinct (listKey, operation) involvement pairs the walk
76
+ * could ever record starting from `startListName` — computed from the
77
+ * CONFIG's relationship graph (not the payload), so it bounds the walk by
78
+ * what the schema can reach rather than by an arbitrary depth.
79
+ *
80
+ * Used as the saturation bound: once `walkNested` has recorded this many
81
+ * pairs, no further pair can be new, so it stops descending. This replaces
82
+ * the old depth cap as the cost bound (#835) — a payload nesting the config's
83
+ * lists more deeply than any previous cap no longer loses their
84
+ * transaction-boundary hooks, while a payload that repeats the same few
85
+ * lists still terminates promptly instead of walking every entry.
86
+ */
87
+ function countReachableInvolvementPairs(
88
+ startListName: string,
89
+ startListConfig: ListConfig<any>, // eslint-disable-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
90
+ config: OpenSaasConfig,
91
+ ): number {
92
+ const visited = new Set<string>([startListName])
93
+ const queue: Array<ListConfig<any>> = [startListConfig] // eslint-disable-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
94
+
95
+ while (queue.length > 0) {
96
+ const current = queue.shift()!
97
+ for (const fieldConfig of Object.values(current.fields)) {
98
+ if (!isRelationshipField(fieldConfig)) continue
99
+ const relationshipField = fieldConfig as { type: 'relationship'; ref: string }
100
+ const related = getRelatedListConfig(relationshipField.ref, config)
101
+ if (!related || visited.has(related.listName)) continue
102
+ visited.add(related.listName)
103
+ queue.push(related.listConfig)
104
+ }
105
+ }
106
+
107
+ return visited.size * DISTINCT_OPERATION_COUNT
108
+ }
109
+
74
110
  function asRecordArray(value: unknown): Array<Record<string, unknown>> {
75
111
  if (value == null) return []
76
112
  if (Array.isArray(value)) return value.filter((v) => v && typeof v === 'object')
@@ -117,9 +153,11 @@ function walkNested(
117
153
  config: OpenSaasConfig,
118
154
  out: InvolvedList[],
119
155
  seen: Set<string>,
120
- depth: number,
156
+ maxPairs: number,
121
157
  ): void {
122
- if (!data || depth >= MAX_DEPTH) return
158
+ // Every reachable pair is already recorded — no further recursion can add
159
+ // anything new, so stop instead of re-walking the rest of the payload.
160
+ if (!data || seen.size >= maxPairs) return
123
161
 
124
162
  for (const [fieldName, value] of Object.entries(data)) {
125
163
  const fieldConfig = fieldConfigs[fieldName]
@@ -150,10 +188,12 @@ function walkNested(
150
188
  })
151
189
  }
152
190
 
191
+ if (seen.size >= maxPairs) return
192
+
153
193
  // Recurse into each nested entry's own relationship payload.
154
194
  for (const entry of entries) {
155
195
  const childData = nestedInputData(kind, entry)
156
- walkNested(childData, relatedListConfig.fields, config, out, seen, depth + 1)
196
+ walkNested(childData, relatedListConfig.fields, config, out, seen, maxPairs)
157
197
  }
158
198
  }
159
199
  }
@@ -187,9 +227,10 @@ export function enumerateInvolvedLists(args: {
187
227
  },
188
228
  ]
189
229
  const seen = new Set<string>([`${listName}:${operation}`])
230
+ const maxPairs = countReachableInvolvementPairs(listName, listConfig, config)
190
231
 
191
232
  // Delete has no nested payload to walk (inputData is undefined).
192
- walkNested(inputData, listConfig.fields, config, out, seen, 0)
233
+ walkNested(inputData, listConfig.fields, config, out, seen, maxPairs)
193
234
 
194
235
  return out
195
236
  }
package/src/index.ts CHANGED
@@ -62,6 +62,12 @@ export { resolveNavCounts, isListQueryStaticallyDenied } from './config/nav-coun
62
62
  // Validation error surfaced by write operations
63
63
  export { ValidationError } from './hooks/index.js'
64
64
 
65
+ // Thrown by a read when a caller-supplied `include` names a relation nested
66
+ // deeper than the Access Filter can scope (see ADR-0022). Distinct from
67
+ // `ValidationError` — this is the engine refusing to return unscoped data, not
68
+ // a user-input validation failure.
69
+ export { AccessScopeDepthExceededError } from './access/index.js'
70
+
65
71
  // Field self-containment validation — checks each field implements the
66
72
  // generation contract (getPrismaType / getTypeScriptType / getZodSchema, or
67
73
  // getPrismaRelation for relationships) so a misimplemented field fails early
@@ -319,9 +319,10 @@ describe('Relationship Access Control', () => {
319
319
  }
320
320
 
321
321
  // Test that buildIncludeWithAccessControl creates the right where clause
322
- const { buildIncludeWithAccessControl } = await import('../src/access/index.js')
322
+ const { buildIncludeWithAccessControl, toPrismaInclude } =
323
+ await import('../src/access/index.js')
323
324
 
324
- const include = await buildIncludeWithAccessControl(
325
+ const result = await buildIncludeWithAccessControl(
325
326
  config.lists.User.fields,
326
327
  {
327
328
  session: null,
@@ -329,6 +330,7 @@ describe('Relationship Access Control', () => {
329
330
  },
330
331
  config,
331
332
  )
333
+ const include = toPrismaInclude(result)
332
334
 
333
335
  // Should include posts with a where filter
334
336
  expect(include).toBeDefined()
@@ -486,9 +488,10 @@ describe('Relationship Access Control', () => {
486
488
  }
487
489
 
488
490
  // Test that buildIncludeWithAccessControl creates session-based where clause
489
- const { buildIncludeWithAccessControl } = await import('../src/access/index.js')
491
+ const { buildIncludeWithAccessControl, toPrismaInclude } =
492
+ await import('../src/access/index.js')
490
493
 
491
- const include = await buildIncludeWithAccessControl(
494
+ const result = await buildIncludeWithAccessControl(
492
495
  config.lists.User.fields,
493
496
  {
494
497
  session: { userId: '1' },
@@ -496,6 +499,7 @@ describe('Relationship Access Control', () => {
496
499
  },
497
500
  config,
498
501
  )
502
+ const include = toPrismaInclude(result)
499
503
 
500
504
  // Should include posts with session-based where filter
501
505
  expect(include).toBeDefined()
@@ -505,75 +509,85 @@ describe('Relationship Access Control', () => {
505
509
  })
506
510
  })
507
511
 
508
- describe('depth limiting', () => {
509
- it('should prevent infinite recursion with depth limit', async () => {
510
- const config: OpenSaasConfig = {
511
- db: {
512
- provider: 'postgresql',
513
- url: 'postgresql://localhost:5432/test',
514
- },
515
- lists: {
516
- User: {
517
- fields: {
518
- name: { type: 'text' },
519
- posts: {
520
- type: 'relationship',
521
- ref: 'Post.author',
522
- many: true,
523
- },
524
- },
525
- access: {
526
- operation: {
527
- query: () => true,
528
- },
529
- },
512
+ describe('no depth cap on an acyclic chain (issue #830)', () => {
513
+ /**
514
+ * `filterReadableFields` used to stop recursing into relationships past
515
+ * `MAX_DEPTH` (5), so a nested row past that depth was returned with its
516
+ * field-level `read` access never evaluated and its `resolveOutput` hooks
517
+ * never run — even though the row-scoping half of the pipeline
518
+ * (`buildIncludeWithAccessControl`) may have correctly scoped it. The fix
519
+ * removes this cap: by the time a result reaches this function it is
520
+ * already a finite, ACYCLIC tree bounded by whatever the (now fail-closed)
521
+ * pre-query phase permitted, so there is no infinite-recursion risk left
522
+ * to guard against. This test builds an 8-level-deep chain — deeper than
523
+ * the old cap — with a read-denied field and a resolveOutput hook on the
524
+ * deepest list, and asserts both are applied at every level.
525
+ */
526
+ it('applies field-level read access and resolveOutput at depth 6+', async () => {
527
+ const allowQuery = () => true
528
+ const chainLength = 8
529
+ const lists: OpenSaasConfig['lists'] = {}
530
+ for (let i = 0; i < chainLength; i++) {
531
+ const isLast = i === chainLength - 1
532
+ lists[`L${i}`] = {
533
+ fields: {
534
+ name: { type: 'text' },
535
+ ...(isLast
536
+ ? {
537
+ secret: {
538
+ type: 'text',
539
+ access: { read: () => false },
540
+ },
541
+ label: {
542
+ type: 'text',
543
+ hooks: {
544
+ resolveOutput: ({ value }: { value: unknown }) => `resolved:${value}`,
545
+ },
546
+ },
547
+ }
548
+ : {}),
549
+ ...(i < chainLength - 1
550
+ ? { next: { type: 'relationship', ref: `L${i + 1}.prev` } }
551
+ : {}),
552
+ ...(i > 0 ? { prev: { type: 'relationship', ref: `L${i - 1}.next` } } : {}),
530
553
  },
531
- Post: {
532
- fields: {
533
- title: { type: 'text' },
534
- author: {
535
- type: 'relationship',
536
- ref: 'User.posts',
537
- },
538
- },
539
- access: {
540
- operation: {
541
- query: () => true,
542
- },
543
- },
544
- },
545
- },
554
+ access: { operation: { query: allowQuery } },
555
+ }
546
556
  }
547
-
548
- // Create circular reference structure
549
- const user = {
550
- id: '1',
551
- name: 'John Doe',
552
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
553
- posts: [] as any[],
557
+ const config: OpenSaasConfig = {
558
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
559
+ lists,
554
560
  }
555
561
 
556
- const post = {
557
- id: '1',
558
- title: 'Test Post',
559
- author: user,
562
+ // Build a genuinely acyclic, materialized nested object — exactly what
563
+ // Prisma would return for this include shape (a fresh object per level,
564
+ // no shared/back references).
565
+ let deepest: Record<string, unknown> = {
566
+ id: `${chainLength - 1}`,
567
+ name: `L${chainLength - 1}`,
568
+ secret: 'TOP-SECRET',
569
+ label: 'raw-value',
570
+ }
571
+ for (let i = chainLength - 2; i >= 0; i--) {
572
+ deepest = { id: `${i}`, name: `L${i}`, next: deepest }
560
573
  }
561
-
562
- user.posts = [post]
563
574
 
564
575
  const result = await filterReadableFields(
565
- user,
566
- config.lists.User.fields,
567
- {
568
- session: null,
569
- context: mockContext,
570
- },
576
+ deepest,
577
+ config.lists.L0.fields,
578
+ { session: null, context: mockContext },
571
579
  config,
572
580
  )
573
581
 
574
- // Should not throw stack overflow error
575
- expect(result).toBeDefined()
576
- expect(result.posts).toBeDefined()
582
+ // Walk down to the deepest level in the filtered result.
583
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
584
+ let current: any = result
585
+ for (let i = 0; i < chainLength - 1; i++) {
586
+ current = current.next
587
+ }
588
+
589
+ expect(current.secret).toBeUndefined() // read-denied field stripped
590
+ expect(current.label).toBe('resolved:raw-value') // resolveOutput ran
577
591
  })
578
592
  })
579
593
 
@@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
2
  import { getContext } from '../src/context/index.js'
3
3
  import { defineFragment } from '../src/query/index.js'
4
4
  import { virtual } from '../src/fields/index.js'
5
+ import { AccessScopeDepthExceededError } from '../src/access/index.js'
6
+ import { READ_INCLUDE_MAX_DEPTH } from '../src/access/depth-limits.js'
5
7
  import type { OpenSaasConfig } from '../src/config/types.js'
6
8
 
7
9
  describe('getContext', () => {
@@ -1425,20 +1427,19 @@ describe('getContext', () => {
1425
1427
  expect(call.include).toEqual({ posts: true })
1426
1428
  })
1427
1429
 
1428
- // Regression: when buildIncludeWithAccessControl returns `undefined` (a
1429
- // NON-denial outcome), the caller include must PASS THROUGH unchanged
1430
- // rather than every declared relation being silently dropped. The original
1431
- // #566 merge treated `undefined` as "all relations denied" (fail-closed
1432
- // data loss). `undefined` is returned in three non-denial cases:
1433
- // 1. inside a resolveOutput hook / virtual-field context,
1434
- // 2. at MAX_DEPTH,
1435
- // 3. when a list has no relationships.
1436
- describe('passes caller include through when no access include is computed', () => {
1430
+ // Regression for issue #830: a read issued from inside a `resolveOutput`
1431
+ // hook used to lose relation row scoping ENTIRELY — `buildIncludeWithAccessControl`
1432
+ // returned a whole-object `undefined` for the inner read (any
1433
+ // `_resolveOutputCounter.depth > 0`), which `mergeIncludeWithAccessControl`
1434
+ // treated as "nothing to merge against" and passed the caller's include
1435
+ // through completely unscoped. The fix scopes each immediate relation with
1436
+ // its own access `where` while still not auto-EXPANDING into that
1437
+ // relation's own nested relations (preserving the original loop-prevention
1438
+ // see the self-referential coverage in `access-filter.test.ts`).
1439
+ describe('scopes (without expanding) a caller include used inside a resolveOutput hook (#830)', () => {
1437
1440
  // Build an Author config with a virtual field whose resolveOutput issues a
1438
1441
  // read WITH an explicit include. While that hook runs,
1439
- // _resolveOutputCounter.depth > 0, so buildIncludeWithAccessControl
1440
- // returns undefined for the inner read — exercising the
1441
- // `accessControlledInclude === undefined` passthrough path.
1442
+ // _resolveOutputCounter.depth > 0.
1442
1443
  function configWithResolveOutputProbe(
1443
1444
  callerInclude: Record<string, unknown>,
1444
1445
  capture: (include: unknown) => void,
@@ -1467,7 +1468,7 @@ describe('getContext', () => {
1467
1468
  }
1468
1469
  }
1469
1470
 
1470
- it('findUnique inside a resolveOutput hook keeps the caller include (not dropped)', async () => {
1471
+ it('findUnique inside a resolveOutput hook row-scopes the relation instead of dropping the where', async () => {
1471
1472
  let innerIncludeSeen: unknown
1472
1473
  const hookConfig = configWithResolveOutputProbe({ post: true }, (include) => {
1473
1474
  innerIncludeSeen = include
@@ -1479,12 +1480,12 @@ describe('getContext', () => {
1479
1480
  const context = await getContext(hookConfig, relPrisma, null)
1480
1481
  await context.db.author.findUnique({ where: { id: 'a1' } })
1481
1482
 
1482
- // The caller include `{ post: true }` survives: the declared `post`
1483
- // relation is NOT dropped despite the access include being undefined here.
1484
- expect(innerIncludeSeen).toEqual({ post: true })
1483
+ // `post` is still fetched (not dropped) but now carries Post's own
1484
+ // query-access `where` it is no longer a bare, unscoped `true`.
1485
+ expect(innerIncludeSeen).toEqual({ post: { where: { status: { equals: 'published' } } } })
1485
1486
  })
1486
1487
 
1487
- it('findMany inside a resolveOutput hook passes a NESTED caller include through whole', async () => {
1488
+ it('findMany inside a resolveOutput hook scopes the relation but does not auto-expand its nested include', async () => {
1488
1489
  let innerIncludeSeen: unknown
1489
1490
  const hookConfig = configWithResolveOutputProbe(
1490
1491
  { post: { include: { author: true } } },
@@ -1499,15 +1500,96 @@ describe('getContext', () => {
1499
1500
  const context = await getContext(hookConfig, relPrisma, null)
1500
1501
  await context.db.author.findMany()
1501
1502
 
1502
- // The whole nested caller include passes through untouched (no access
1503
- // include exists to merge against in resolveOutput context).
1504
- expect(innerIncludeSeen).toEqual({ post: { include: { author: true } } })
1503
+ // The caller's own nested selection (`post.include.author`) is honoured
1504
+ // as-is (access control does not auto-descend further here), while
1505
+ // `post` itself picks up its access `where`.
1506
+ expect(innerIncludeSeen).toEqual({
1507
+ post: { where: { status: { equals: 'published' } }, include: { author: true } },
1508
+ })
1505
1509
  })
1510
+ })
1511
+
1512
+ // Regression for issue #830: a caller `include` nested deeper than the
1513
+ // Access Filter can scope used to be returned unscoped rather than
1514
+ // denied. This exercises the fix end-to-end through `context.db`,
1515
+ // matching the reproduction in the issue: a chain of lists deep enough
1516
+ // to cross `READ_INCLUDE_MAX_DEPTH`, read as a non-privileged session.
1517
+ describe('fail-closed at the read-include depth cap through context.db (#830)', () => {
1518
+ function chainListConfig(count: number): OpenSaasConfig['lists'] {
1519
+ const lists: OpenSaasConfig['lists'] = {}
1520
+ for (let i = 0; i < count; i++) {
1521
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
1522
+ const fields: Record<string, any> = { name: { type: 'text' } }
1523
+ if (i < count - 1) fields.next = { type: 'relationship', ref: `C${i + 1}.prev` }
1524
+ if (i > 0) fields.prev = { type: 'relationship', ref: `C${i - 1}.next` }
1525
+ lists[`C${i}`] = {
1526
+ fields,
1527
+ access: {
1528
+ operation: { query: () => (i === 0 ? true : { ownerId: { equals: `C${i}` } }) },
1529
+ },
1530
+ }
1531
+ }
1532
+ return lists
1533
+ }
1534
+
1535
+ // A caller include selecting `next` `hops` more times, ending bare.
1536
+ function nestedCallerInclude(hops: number): Record<string, unknown> {
1537
+ if (hops <= 0) return true as unknown as Record<string, unknown>
1538
+ return { include: { next: nestedCallerInclude(hops - 1) } }
1539
+ }
1540
+
1541
+ it('throws AccessScopeDepthExceededError for a caller include one hop past the cap', async () => {
1542
+ const chainLength = READ_INCLUDE_MAX_DEPTH + 2
1543
+ const chainConfig: OpenSaasConfig = {
1544
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
1545
+ lists: chainListConfig(chainLength),
1546
+ }
1547
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1548
+ const chainPrisma: any = {}
1549
+ for (let i = 0; i < chainLength; i++) {
1550
+ chainPrisma[`c${i}`] = { findMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn() }
1551
+ }
1552
+
1553
+ const context = await getContext(chainConfig, chainPrisma, null)
1506
1554
 
1507
- // The MAX_DEPTH and no-relationships cases also make
1508
- // buildIncludeWithAccessControl return undefined; they flow through the
1509
- // identical `accessControlledInclude === undefined` branch verified above,
1510
- // so the resolveOutput probe covers all three non-denial cases.
1555
+ await expect(
1556
+ context.db.c0.findMany({
1557
+ include: { next: nestedCallerInclude(READ_INCLUDE_MAX_DEPTH) },
1558
+ }),
1559
+ ).rejects.toThrow(AccessScopeDepthExceededError)
1560
+
1561
+ // The database is never even queried — the denial happens before the
1562
+ // Prisma call, not as a post-hoc filter on returned data.
1563
+ expect(chainPrisma.c0.findMany).not.toHaveBeenCalled()
1564
+ })
1565
+
1566
+ it('still returns correctly row-scoped data for the same include one hop shallower', async () => {
1567
+ const chainLength = READ_INCLUDE_MAX_DEPTH + 1
1568
+ const chainConfig: OpenSaasConfig = {
1569
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
1570
+ lists: chainListConfig(chainLength),
1571
+ }
1572
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1573
+ const chainPrisma: any = {}
1574
+ for (let i = 0; i < chainLength; i++) {
1575
+ chainPrisma[`c${i}`] = { findMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn() }
1576
+ }
1577
+ chainPrisma.c0.findMany.mockResolvedValue([])
1578
+
1579
+ const context = await getContext(chainConfig, chainPrisma, null)
1580
+
1581
+ await context.db.c0.findMany({
1582
+ include: { next: nestedCallerInclude(READ_INCLUDE_MAX_DEPTH - 1) },
1583
+ })
1584
+
1585
+ // Walk the built include down to the last list — it must carry that
1586
+ // list's own access `where`, proving row scoping, not just "no throw".
1587
+ let entry = chainPrisma.c0.findMany.mock.calls[0][0].include.next
1588
+ for (let i = 1; i < READ_INCLUDE_MAX_DEPTH; i++) {
1589
+ entry = entry.include.next
1590
+ }
1591
+ expect(entry.where).toEqual({ ownerId: { equals: `C${READ_INCLUDE_MAX_DEPTH}` } })
1592
+ })
1511
1593
  })
1512
1594
 
1513
1595
  // Regression for #628: a virtual field named in `include` used to be