@opensaas/stack-core 0.32.0 → 0.34.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 (73) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +61 -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 +102 -70
  6. package/dist/access/access-filter.js.map +1 -1
  7. package/dist/access/access-filter.test.js +175 -10
  8. package/dist/access/access-filter.test.js.map +1 -1
  9. package/dist/access/depth-limits.d.ts +26 -0
  10. package/dist/access/depth-limits.d.ts.map +1 -0
  11. package/dist/access/depth-limits.js +26 -0
  12. package/dist/access/depth-limits.js.map +1 -0
  13. package/dist/access/errors.d.ts +43 -0
  14. package/dist/access/errors.d.ts.map +1 -0
  15. package/dist/access/errors.js +55 -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 +82 -20
  19. package/dist/access/field-visibility.js.map +1 -1
  20. package/dist/access/index.d.ts +4 -1
  21. package/dist/access/index.d.ts.map +1 -1
  22. package/dist/access/index.js +5 -1
  23. package/dist/access/index.js.map +1 -1
  24. package/dist/access/multi-column-read-write.test.js +1 -1
  25. package/dist/access/multi-column-read-write.test.js.map +1 -1
  26. package/dist/access/relationship-count.test.js +1 -1
  27. package/dist/access/relationship-count.test.js.map +1 -1
  28. package/dist/access/relationship-label-filter.test.js +1 -1
  29. package/dist/access/relationship-label-filter.test.js.map +1 -1
  30. package/dist/access/types.d.ts +15 -7
  31. package/dist/access/types.d.ts.map +1 -1
  32. package/dist/context/index.d.ts.map +1 -1
  33. package/dist/context/index.js +15 -9
  34. package/dist/context/index.js.map +1 -1
  35. package/dist/context/nested-operations.d.ts +1 -1
  36. package/dist/context/nested-operations.d.ts.map +1 -1
  37. package/dist/context/nested-operations.js +1 -5
  38. package/dist/context/nested-operations.js.map +1 -1
  39. package/dist/context/transaction-boundary.d.ts.map +1 -1
  40. package/dist/context/transaction-boundary.js +43 -6
  41. package/dist/context/transaction-boundary.js.map +1 -1
  42. package/dist/context/write-pipeline.d.ts.map +1 -1
  43. package/dist/context/write-pipeline.js +5 -4
  44. package/dist/context/write-pipeline.js.map +1 -1
  45. package/dist/index.d.ts +2 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +9 -0
  48. package/dist/index.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/access/access-filter.test.ts +258 -7
  51. package/src/access/access-filter.ts +146 -72
  52. package/src/access/depth-limits.ts +26 -0
  53. package/src/access/errors.ts +62 -0
  54. package/src/access/field-visibility.ts +97 -21
  55. package/src/access/index.ts +6 -0
  56. package/src/access/multi-column-read-write.test.ts +1 -1
  57. package/src/access/relationship-count.test.ts +1 -1
  58. package/src/access/relationship-label-filter.test.ts +1 -1
  59. package/src/access/types.ts +12 -5
  60. package/src/context/index.ts +15 -6
  61. package/src/context/nested-operations.ts +0 -7
  62. package/src/context/transaction-boundary.ts +48 -7
  63. package/src/context/write-pipeline.ts +5 -4
  64. package/src/index.ts +11 -0
  65. package/tests/access-relationships.test.ts +78 -64
  66. package/tests/context.test.ts +106 -24
  67. package/tests/default-value-create.test.ts +1 -1
  68. package/tests/hook-pipeline.test.ts +1 -1
  69. package/tests/nav-count.test.ts +2 -2
  70. package/tests/resolve-chain.test.ts +394 -0
  71. package/tests/transaction-boundary-hooks.test.ts +246 -1
  72. package/tests/write-pipeline.test.ts +1 -1
  73. package/tsconfig.tsbuildinfo +1 -1
@@ -1,5 +1,11 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { buildIncludeWithAccessControl, mergeIncludeWithAccessControl } from './access-filter.js'
2
+ import {
3
+ buildIncludeWithAccessControl,
4
+ mergeIncludeWithAccessControl,
5
+ toPrismaInclude,
6
+ } from './access-filter.js'
7
+ import { AccessScopeDepthExceededError } from './errors.js'
8
+ import { READ_INCLUDE_MAX_DEPTH } from './depth-limits.js'
3
9
  import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
4
10
  import type { AccessContext } from './types.js'
5
11
 
@@ -43,11 +49,15 @@ function cyclicConfig(): OpenSaasConfig {
43
49
  } as any
44
50
  }
45
51
 
46
- function makeContext(): AccessContext {
52
+ function makeContext(resolveOutputDepth = 0): AccessContext {
53
+ const chain = Array.from({ length: resolveOutputDepth }, (_, i) => ({
54
+ listKey: 'TestHook',
55
+ fieldKey: `hook${i}`,
56
+ }))
47
57
  return {
48
58
  session: null,
49
59
  _isSudo: false,
50
- _resolveOutputCounter: { depth: 0 },
60
+ _resolveOutputChain: chain,
51
61
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
52
62
  } as any
53
63
  }
@@ -68,16 +78,67 @@ function includeDepth(include: unknown): number {
68
78
  return max
69
79
  }
70
80
 
81
+ // A straight-line chain of `count` lists, each with a scalar field and a
82
+ // single relationship to the next list: L0 → L1 → … → L(count-1). Every list
83
+ // past the root is query-scoped with a filter unique to it, so a test can
84
+ // assert the merged tree actually carries the right `where` at a given hop
85
+ // (not just that it happens not to throw).
86
+ function chainConfig(count: number): OpenSaasConfig {
87
+ const lists: Record<string, { fields: Record<string, FieldConfig>; access: unknown }> = {}
88
+ for (let i = 0; i < count; i++) {
89
+ const listName = `L${i}`
90
+ const fields: Record<string, FieldConfig> = { name: { type: 'text' } as FieldConfig }
91
+ if (i < count - 1) {
92
+ fields.next = rel(`L${i + 1}.prev`)
93
+ }
94
+ if (i > 0) {
95
+ fields.prev = rel(`L${i - 1}.next`)
96
+ }
97
+ const filter = { ownerId: { equals: listName } }
98
+ lists[listName] = {
99
+ fields,
100
+ access: { operation: { query: i === 0 ? () => true : () => filter } },
101
+ }
102
+ }
103
+ return {
104
+ db: { provider: 'sqlite', url: 'file:./dev.db' },
105
+ lists,
106
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
107
+ } as any
108
+ }
109
+
110
+ // A caller `include` value selecting `next` `hops` more times beyond the
111
+ // point at which this value is attached, ending in a bare `true` leaf. E.g.
112
+ // `nestedInclude(0) === true` (stop here); `{ next: nestedInclude(2) }` at the
113
+ // top level names 3 hops total (the outer `next` plus 2 more).
114
+ function nestedInclude(hops: number): Record<string, unknown> {
115
+ if (hops <= 0) return true as unknown as Record<string, unknown>
116
+ return { include: { next: nestedInclude(hops - 1) } }
117
+ }
118
+
119
+ // Read the `where` at the end of a chain of nested `next` includes (used to
120
+ // assert row-scoping survived down to a specific hop).
121
+ function whereAtHop(merged: Record<string, unknown>, hops: number): unknown {
122
+ let current: unknown = merged
123
+ for (let i = 0; i < hops; i++) {
124
+ const entry = (current as { next?: unknown })?.next
125
+ if (i === hops - 1) return (entry as { where?: unknown })?.where
126
+ current = (entry as { include?: unknown })?.include
127
+ }
128
+ return undefined
129
+ }
130
+
71
131
  describe('buildIncludeWithAccessControl — cyclic graph', () => {
72
132
  it('stops re-descending a relationship cycle instead of walking to MAX_DEPTH', async () => {
73
133
  const config = cyclicConfig()
74
- const include = await buildIncludeWithAccessControl(
134
+ const result = await buildIncludeWithAccessControl(
75
135
  config.lists.A.fields,
76
136
  { session: null, context: makeContext() },
77
137
  config,
78
138
  0,
79
139
  ['A'],
80
140
  )
141
+ const include = toPrismaInclude(result)
81
142
 
82
143
  // A → B → C, then C.a closes the cycle back to A → flat (no further nesting).
83
144
  expect(include).toEqual({
@@ -91,7 +152,7 @@ describe('buildIncludeWithAccessControl — cyclic graph', () => {
91
152
  it('flattens a self-referential relationship to a single level', async () => {
92
153
  const allowQuery = () => true
93
154
  const config = {
94
- db: { provider: 'sqlite', url: 'file:./dev.db' },
155
+ db: { provider: 'sqlite' },
95
156
  lists: {
96
157
  Category: {
97
158
  fields: {
@@ -105,13 +166,14 @@ describe('buildIncludeWithAccessControl — cyclic graph', () => {
105
166
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
106
167
  } as any as OpenSaasConfig
107
168
 
108
- const include = await buildIncludeWithAccessControl(
169
+ const result = await buildIncludeWithAccessControl(
109
170
  config.lists.Category.fields,
110
171
  { session: null, context: makeContext() },
111
172
  config,
112
173
  0,
113
174
  ['Category'],
114
175
  )
176
+ const include = toPrismaInclude(result)
115
177
 
116
178
  // Both self-references come back flat — no infinite parent/children descent.
117
179
  expect(include).toEqual({ parent: true, children: true })
@@ -138,6 +200,7 @@ describe('mergeIncludeWithAccessControl — bare-true leaf on a cyclic graph', (
138
200
  accessControlledInclude,
139
201
  config.lists.A.fields,
140
202
  config,
203
+ 'A',
141
204
  )
142
205
 
143
206
  expect(merged).toEqual({
@@ -159,7 +222,7 @@ describe('mergeIncludeWithAccessControl — caller take on a to-many relation (i
159
222
  // A one-list config whose `posts` to-many is query-scoped by an access filter.
160
223
  function scopedConfig(): OpenSaasConfig {
161
224
  return {
162
- db: { provider: 'sqlite', url: 'file:./dev.db' },
225
+ db: { provider: 'sqlite' },
163
226
  lists: {
164
227
  User: {
165
228
  fields: { name: { type: 'text' } as FieldConfig, posts: rel('Post.author', true) },
@@ -190,6 +253,7 @@ describe('mergeIncludeWithAccessControl — caller take on a to-many relation (i
190
253
  accessControlledInclude,
191
254
  config.lists.User.fields,
192
255
  config,
256
+ 'User',
193
257
  )
194
258
 
195
259
  // The bound rides on top of the access filter — neither is dropped. The
@@ -220,9 +284,196 @@ describe('mergeIncludeWithAccessControl — caller take on a to-many relation (i
220
284
  accessControlledInclude,
221
285
  config.lists.User.fields,
222
286
  config,
287
+ 'User',
223
288
  )
224
289
 
225
290
  // A denied relation is dropped wholesale — the take cannot resurrect it.
226
291
  expect(merged).toEqual({})
227
292
  })
228
293
  })
294
+
295
+ /**
296
+ * Regression coverage for issue #830: the read pipeline used to FAIL OPEN past
297
+ * `READ_INCLUDE_MAX_DEPTH` — a caller-supplied `include` nested deeper than the
298
+ * engine could scope was passed through unscoped rather than denied. These
299
+ * tests pin the exact boundary the fix introduces (ADR-0022): a caller include
300
+ * one level past the cap throws, the same include one level shallower still
301
+ * works, and an unrequested auto-include past the cap stays silent.
302
+ */
303
+ describe('mergeIncludeWithAccessControl — fail-closed at the read-include depth cap (#830)', () => {
304
+ // A relation `hops` hops from the root sits AT the cap boundary (correctly
305
+ // where-scoped, matching the triage report's "F" list); one hop further is
306
+ // the first one the engine cannot scope ("G"). With
307
+ // READ_INCLUDE_MAX_DEPTH = 5 that boundary is hop 5 / hop 6.
308
+ const HOPS_AT_CAP = READ_INCLUDE_MAX_DEPTH
309
+
310
+ it('throws AccessScopeDepthExceededError when the caller include reaches past the cap', async () => {
311
+ // L0 → L1 → … → L6 (one more list than HOPS_AT_CAP + 1) so a caller
312
+ // include can walk `next` one hop past the boundary.
313
+ const chainLength = HOPS_AT_CAP + 2
314
+ const config = chainConfig(chainLength)
315
+
316
+ const accessControlledInclude = await buildIncludeWithAccessControl(
317
+ config.lists.L0.fields,
318
+ { session: null, context: makeContext() },
319
+ config,
320
+ 0,
321
+ ['L0'],
322
+ )
323
+
324
+ // Outer `next` (hop 1) + nestedInclude(HOPS_AT_CAP) (HOPS_AT_CAP more) = HOPS_AT_CAP + 1 hops.
325
+ const callerInclude = { next: nestedInclude(HOPS_AT_CAP) }
326
+
327
+ expect(() =>
328
+ mergeIncludeWithAccessControl(
329
+ callerInclude,
330
+ accessControlledInclude,
331
+ config.lists.L0.fields,
332
+ config,
333
+ 'L0',
334
+ ),
335
+ ).toThrow(AccessScopeDepthExceededError)
336
+ })
337
+
338
+ it('still row-scopes a caller include one level shallower than the cap', async () => {
339
+ const chainLength = HOPS_AT_CAP + 1
340
+ const config = chainConfig(chainLength)
341
+
342
+ const accessControlledInclude = await buildIncludeWithAccessControl(
343
+ config.lists.L0.fields,
344
+ { session: null, context: makeContext() },
345
+ config,
346
+ 0,
347
+ ['L0'],
348
+ )
349
+
350
+ // Outer `next` (hop 1) + nestedInclude(HOPS_AT_CAP - 1) = HOPS_AT_CAP hops total — right at the boundary.
351
+ const callerInclude = { next: nestedInclude(HOPS_AT_CAP - 1) }
352
+
353
+ // Must NOT throw — this depth is within what the engine can scope.
354
+ const merged = mergeIncludeWithAccessControl(
355
+ callerInclude,
356
+ accessControlledInclude,
357
+ config.lists.L0.fields,
358
+ config,
359
+ 'L0',
360
+ )
361
+
362
+ expect(includeDepth(merged)).toBe(HOPS_AT_CAP)
363
+ // The last list in the chain (at the boundary) is genuinely row-scoped —
364
+ // not just present without a throw.
365
+ expect(whereAtHop(merged, HOPS_AT_CAP)).toEqual({ ownerId: { equals: `L${HOPS_AT_CAP}` } })
366
+ })
367
+
368
+ it('does not throw for an ordinary read with no caller include, even on a deep schema', async () => {
369
+ // No caller include at all — the auto-include just stops at the cap
370
+ // silently. This must never throw; only an EXPLICIT caller selection past
371
+ // the cap is a denial.
372
+ const chainLength = HOPS_AT_CAP + 3
373
+ const config = chainConfig(chainLength)
374
+
375
+ const result = await buildIncludeWithAccessControl(
376
+ config.lists.L0.fields,
377
+ { session: null, context: makeContext() },
378
+ config,
379
+ 0,
380
+ ['L0'],
381
+ )
382
+
383
+ expect(() => toPrismaInclude(result)).not.toThrow()
384
+ const include = toPrismaInclude(result)
385
+ expect(includeDepth(include)).toBeLessThanOrEqual(HOPS_AT_CAP)
386
+ })
387
+
388
+ it('a list with no relationships still passes the caller include through unchanged', async () => {
389
+ const config = {
390
+ db: { provider: 'sqlite' },
391
+ lists: {
392
+ Leaf: {
393
+ fields: { name: { type: 'text' } as FieldConfig },
394
+ access: { operation: { query: () => true } },
395
+ },
396
+ },
397
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
398
+ } as any as OpenSaasConfig
399
+
400
+ const accessControlledInclude = await buildIncludeWithAccessControl(
401
+ config.lists.Leaf.fields,
402
+ { session: null, context: makeContext() },
403
+ config,
404
+ 0,
405
+ ['Leaf'],
406
+ )
407
+
408
+ expect(accessControlledInclude).toEqual({ kind: 'nothing-to-scope' })
409
+
410
+ // An arbitrary (non-declared-relationship) key passed through unchanged —
411
+ // access control does not govern keys it doesn't recognize as relationships.
412
+ const merged = mergeIncludeWithAccessControl(
413
+ { someUnrelatedKey: true },
414
+ accessControlledInclude,
415
+ config.lists.Leaf.fields,
416
+ config,
417
+ 'Leaf',
418
+ )
419
+ expect(merged).toEqual({ someUnrelatedKey: true })
420
+ })
421
+ })
422
+
423
+ /**
424
+ * Regression coverage for the resolveOutput/virtual-field trigger of #830: a
425
+ * read issued from inside a resolveOutput hook used to lose relation row
426
+ * scoping ENTIRELY (whole-object passthrough) rather than scoping the
427
+ * immediate relation and simply not auto-expanding further. The loop guard
428
+ * that motivated the original passthrough (hooks making DB queries that
429
+ * include relationships back to the same entity) must still hold.
430
+ */
431
+ describe('buildIncludeWithAccessControl — inside a resolveOutput context', () => {
432
+ it('scopes the immediate relation with its access where but does not auto-expand nested relations', async () => {
433
+ const config = chainConfig(4) // L0 → L1 → L2 → L3
434
+ config.lists.L1.access = { operation: { query: () => ({ tenantId: { equals: 'mine' } }) } }
435
+
436
+ const result = await buildIncludeWithAccessControl(
437
+ config.lists.L0.fields,
438
+ { session: null, context: makeContext(1) }, // depth > 0 → inside resolveOutput
439
+ config,
440
+ 0,
441
+ ['L0'],
442
+ )
443
+
444
+ expect(result.kind).toBe('scoped')
445
+ const include = toPrismaInclude(result)
446
+ // L1's own access `where` is applied...
447
+ expect(include).toEqual({ next: { where: { tenantId: { equals: 'mine' } } } })
448
+ // ...but L1's own nested relations (`next` → L2) are NOT auto-included.
449
+ expect(includeDepth(include)).toBe(1)
450
+ })
451
+
452
+ it('terminates on a self-referential relationship instead of looping', async () => {
453
+ const config = {
454
+ db: { provider: 'sqlite' },
455
+ lists: {
456
+ Category: {
457
+ fields: {
458
+ name: { type: 'text' } as FieldConfig,
459
+ parent: rel('Category.children'),
460
+ children: rel('Category.parent', true),
461
+ },
462
+ access: { operation: { query: () => true } },
463
+ },
464
+ },
465
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
466
+ } as any as OpenSaasConfig
467
+
468
+ const result = await buildIncludeWithAccessControl(
469
+ config.lists.Category.fields,
470
+ { session: null, context: makeContext(1) },
471
+ config,
472
+ 0,
473
+ ['Category'],
474
+ )
475
+
476
+ const include = toPrismaInclude(result)
477
+ expect(include).toEqual({ parent: true, children: true })
478
+ })
479
+ })
@@ -1,6 +1,8 @@
1
1
  import type { Session, AccessContext, PrismaFilter } from './types.js'
2
2
  import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
3
3
  import { checkAccess, getRelatedListConfig } from './engine.js'
4
+ import { READ_INCLUDE_MAX_DEPTH } from './depth-limits.js'
5
+ import { AccessScopeDepthExceededError } from './errors.js'
4
6
 
5
7
  /**
6
8
  * Access Filter — phase 1 of the two-phase read (pre-query).
@@ -18,9 +20,83 @@ import { checkAccess, getRelatedListConfig } from './engine.js'
18
20
  * glossary in `CONTEXT.md`.
19
21
  */
20
22
 
23
+ /** A single relation entry in a Prisma `include` object (see below). */
24
+ type IncludeEntry = boolean | { where?: PrismaFilter; include?: IncludeObject; take?: number }
25
+ type IncludeObject = Record<string, IncludeEntry>
26
+
27
+ /**
28
+ * The result of trying to compute an access-controlled include for a list's
29
+ * fields. `buildIncludeWithAccessControl` used to collapse three unrelated
30
+ * outcomes into a single overloaded `undefined`: "inside a resolveOutput
31
+ * context", "hit the depth cap", and "no relationships to scope" all looked
32
+ * identical to callers, which is what let a depth-capped relation pass
33
+ * through unscoped (issue #830). This discriminated result keeps them
34
+ * distinguishable all the way to `mergeIncludeWithAccessControl`, which is the
35
+ * only place that knows whether a caller actually asked for the part that
36
+ * couldn't be scoped.
37
+ *
38
+ * - `scoped`: relationships were found and (to the extent depth allows)
39
+ * access-controlled; `include` is the resulting tree.
40
+ * - `nothing-to-scope`: the list genuinely has no relationships to scope, OR
41
+ * we are inside a resolveOutput/virtual-field context and deliberately did
42
+ * not descend into a relation's own nested relations. Passing the caller's
43
+ * include through unchanged here is correct, not a leak.
44
+ * - `depth-exceeded`: we could not evaluate this level at all because it sits
45
+ * at or past `READ_INCLUDE_MAX_DEPTH`. This is a denial: a caller `include`
46
+ * that reaches here must be rejected, not passed through.
47
+ */
48
+ export type AccessIncludeResult =
49
+ | { kind: 'scoped'; include: RichIncludeObject }
50
+ | { kind: 'nothing-to-scope' }
51
+ | { kind: 'depth-exceeded' }
52
+
53
+ /** A relation entry in the rich, provenance-carrying tree `buildIncludeWithAccessControl` builds internally. */
54
+ type RichIncludeEntry = { where?: PrismaFilter; nested: AccessIncludeResult }
55
+ type RichIncludeObject = Record<string, RichIncludeEntry>
56
+
57
+ /**
58
+ * Collapse a rich, provenance-carrying include entry down to the plain
59
+ * Prisma-shaped form. A `nested` result that is `depth-exceeded` or
60
+ * `nothing-to-scope` contributes no `include` key — this is the AUTO-include
61
+ * silently stopping, which is correct when no caller asked for anything past
62
+ * this point (see `AccessIncludeResult` doc comment).
63
+ */
64
+ function toPrismaEntry(entry: RichIncludeEntry): IncludeEntry {
65
+ const result: { where?: PrismaFilter; include?: IncludeObject } = {}
66
+ if (entry.where) result.where = entry.where
67
+ if (entry.nested.kind === 'scoped') {
68
+ const nestedInclude = toPrismaInclude(entry.nested)
69
+ if (nestedInclude && Object.keys(nestedInclude).length > 0) {
70
+ result.include = nestedInclude
71
+ }
72
+ }
73
+ return Object.keys(result).length > 0 ? result : true
74
+ }
75
+
21
76
  /**
22
- * Build Prisma include object with access control filters
23
- * This allows us to filter relationships at the database level instead of in memory
77
+ * Collapse an `AccessIncludeResult` to the plain Prisma `include` shape used
78
+ * when there is no caller-supplied include to merge against (the direct
79
+ * auto-include path). `nothing-to-scope` and `depth-exceeded` both become
80
+ * `undefined` here — at this call site nothing was explicitly requested past
81
+ * either boundary, so there is nothing to deny.
82
+ */
83
+ export function toPrismaInclude(result: AccessIncludeResult): IncludeObject | undefined {
84
+ if (result.kind !== 'scoped') return undefined
85
+ const out: IncludeObject = {}
86
+ for (const [key, entry] of Object.entries(result.include)) {
87
+ out[key] = toPrismaEntry(entry)
88
+ }
89
+ return out
90
+ }
91
+
92
+ /**
93
+ * Build the access-controlled include for a list's fields.
94
+ *
95
+ * This allows us to filter relationships at the database level instead of in
96
+ * memory. Returns an {@link AccessIncludeResult} rather than a plain include
97
+ * object so that `mergeIncludeWithAccessControl` can tell a genuine "nothing
98
+ * to scope" apart from "the engine hit its depth cap" (see that type's doc
99
+ * comment and ADR-0022).
24
100
  */
25
101
  export async function buildIncludeWithAccessControl(
26
102
  fieldConfigs: Record<string, FieldConfig>,
@@ -34,23 +110,27 @@ export async function buildIncludeWithAccessControl(
34
110
  // relationship cycles (A → B → … → A) and stop the auto-include from
35
111
  // re-descending them. Seed it with the root list name at the call site.
36
112
  visitedLists: readonly string[] = [],
37
- ) {
38
- const MAX_DEPTH = 5
39
- if (depth >= MAX_DEPTH) {
40
- return undefined
113
+ ): Promise<AccessIncludeResult> {
114
+ if (depth >= READ_INCLUDE_MAX_DEPTH) {
115
+ return { kind: 'depth-exceeded' }
41
116
  }
42
117
 
43
- // Skip auto-including relationships when inside a resolveOutput hook
44
- // This prevents infinite loops when hooks make DB queries that include
45
- // relationships back to the same entity (e.g., User virtual field queries Posts
46
- // which includes author back to User, triggering the virtual field again)
47
- if (args.context._resolveOutputCounter.depth > 0) {
48
- return undefined
49
- }
50
-
51
- type IncludeEntry = boolean | { where?: PrismaFilter; include?: Record<string, IncludeEntry> }
52
-
53
- const include: Record<string, IncludeEntry> = {}
118
+ // Inside a resolveOutput/virtual-field context we still scope each immediate
119
+ // relation (its own access `where`), but do not auto-expand INTO its nested
120
+ // relations this keeps a single hook-issued read's include from growing
121
+ // depth-first. Correction (ADR-0023): this does NOT by itself terminate a
122
+ // cycle, despite what an earlier version of this comment claimed. A
123
+ // self-referential relation read from inside a hook is a NEW top-level
124
+ // read, invisible to this function's own recursion — the loop runs through
125
+ // the hook↔read boundary, not through repeated calls to
126
+ // `buildIncludeWithAccessControl`. Loop termination is the resolve chain's
127
+ // job: `field-visibility.ts`'s `resolveReadableFieldValue` refuses to
128
+ // re-enter a `(list, field)` pair already on the chain. What this check
129
+ // still does correctly is row-scope the immediate relation rather than
130
+ // skipping scoping altogether (issue #830's second trigger).
131
+ const insideResolveOutput = args.context._resolveOutputChain.length > 0
132
+
133
+ const include: RichIncludeObject = {}
54
134
  let hasRelationships = false
55
135
 
56
136
  for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
@@ -71,13 +151,7 @@ export async function buildIncludeWithAccessControl(
71
151
  continue
72
152
  }
73
153
 
74
- // Build the include entry
75
- const includeEntry: Record<string, unknown> = {}
76
-
77
- // If access returns a filter, add it to the where clause
78
- if (typeof accessResult === 'object') {
79
- includeEntry.where = accessResult
80
- }
154
+ const where = typeof accessResult === 'object' ? accessResult : undefined
81
155
 
82
156
  // Cycle guard: if the related list already appears on the path from the
83
157
  // root, DO NOT auto-include its relationships again. On a cyclic
@@ -90,39 +164,26 @@ export async function buildIncludeWithAccessControl(
90
164
  // genuine single-level fetch, not a re-expansion of the full auto-include.
91
165
  // The relation itself is still included (as a FLAT fetch of its own
92
166
  // columns); only its onward relationships are pruned at the back-edge.
167
+ let nested: AccessIncludeResult = { kind: 'nothing-to-scope' }
93
168
  const relatedListName = relatedConfig.listName
94
- if (!visitedLists.includes(relatedListName)) {
95
- // Recursively build nested includes
96
- const nestedInclude = await buildIncludeWithAccessControl(
169
+ if (!insideResolveOutput && !visitedLists.includes(relatedListName)) {
170
+ nested = await buildIncludeWithAccessControl(
97
171
  relatedConfig.listConfig.fields,
98
172
  args,
99
173
  config,
100
174
  depth + 1,
101
175
  [...visitedLists, relatedListName],
102
176
  )
103
-
104
- if (nestedInclude && Object.keys(nestedInclude).length > 0) {
105
- includeEntry.include = nestedInclude
106
- }
107
177
  }
108
178
 
109
- // Add to include object
110
- include[fieldName] = Object.keys(includeEntry).length > 0 ? includeEntry : true
179
+ include[fieldName] = { where, nested }
111
180
  }
112
181
  }
113
182
  }
114
183
 
115
- return hasRelationships ? include : undefined
184
+ return hasRelationships ? { kind: 'scoped', include } : { kind: 'nothing-to-scope' }
116
185
  }
117
186
 
118
- /**
119
- * A single relation entry in a Prisma `include` object: either a bare `true`
120
- * (fetch with no extra constraints) or an object that scopes the fetch with a
121
- * `where` filter and/or a nested `include`.
122
- */
123
- type IncludeEntry = boolean | { where?: PrismaFilter; include?: IncludeObject; take?: number }
124
- type IncludeObject = Record<string, IncludeEntry>
125
-
126
187
  /** The structured (object) form of a relation include entry. */
127
188
  type IncludeEntryObject = { where?: PrismaFilter; include?: IncludeObject; take?: number }
128
189
 
@@ -193,38 +254,46 @@ function andWhere(
193
254
  * - If the caller names a key that is NOT a config-declared relationship, it is
194
255
  * passed through unchanged (access control does not govern it).
195
256
  *
196
- * The access-controlled include is recursive to `MAX_DEPTH` (see
197
- * `buildIncludeWithAccessControl`); beyond that depth no auto-include exists, so
198
- * deeper caller selections pass through unscoped consistent with the existing
199
- * auto-include behaviour.
257
+ * `accessControlledInclude` is the {@link AccessIncludeResult} for THIS level:
258
+ * - `nothing-to-scope` nothing to merge against (the list has no
259
+ * relationships, or we're inside a resolveOutput context where the caller
260
+ * include is irrelevant to begin with). Pass the caller's include through
261
+ * unchanged — this is a non-denial outcome, not "every relation denied".
262
+ * - `depth-exceeded` → the engine could not compute a scope for THIS level at
263
+ * all because it sits at or past `READ_INCLUDE_MAX_DEPTH`. If the caller
264
+ * named anything here, that is exactly the case that used to pass through
265
+ * unscoped (issue #830): throw `AccessScopeDepthExceededError` instead. An
266
+ * empty caller include at this level (nothing further requested) is not an
267
+ * error — there's simply nothing to do.
268
+ * - `scoped` → the normal per-relation merge below: a declared relationship
269
+ * ABSENT from the access include was denied (drop it); one PRESENT is used
270
+ * as the base, AND-combining `where`s and recursing into nested includes.
200
271
  *
201
- * `accessControlledInclude` being `undefined` is NOT "every relation denied". It
202
- * means no access-controlled include was computed at all — a non-denial outcome
203
- * that `buildIncludeWithAccessControl` returns when inside a `resolveOutput`/
204
- * virtual-field context, at `MAX_DEPTH`, or when the list has no relationships.
205
- * In every one of those cases there is nothing to merge against, so the caller's
206
- * `include` is passed through unchanged (matching the prior `args.include || …`
207
- * fallback). This is distinct from an `undefined` ENTRY inside a defined access
208
- * include, which DOES mean the relation was denied and must be dropped (see the
209
- * per-relation loop below). Only the whole-object `undefined` is a passthrough.
272
+ * `listKey` and `depth` are carried only to build a useful
273
+ * `AccessScopeDepthExceededError` message; they do not affect merge behaviour.
210
274
  */
211
275
  export function mergeIncludeWithAccessControl(
212
276
  callerInclude: Record<string, unknown>,
213
- accessControlledInclude: Record<string, unknown> | undefined,
277
+ accessControlledInclude: AccessIncludeResult,
214
278
  fieldConfigs: Record<string, FieldConfig>,
215
279
  config: OpenSaasConfig,
280
+ listKey: string,
281
+ depth: number = 0,
216
282
  ): Record<string, unknown> {
217
- // No access-controlled include was computed (resolveOutput/virtual context,
218
- // MAX_DEPTH, or a list with no relationships) → nothing to scope against, so
219
- // pass the caller's include through unchanged. Dropping relations here would be
220
- // fail-closed data loss, not a denial. Denied relations are dropped only when a
221
- // defined access include OMITS them (handled per-relation below).
222
- if (accessControlledInclude === undefined) {
283
+ if (accessControlledInclude.kind === 'nothing-to-scope') {
223
284
  return callerInclude
224
285
  }
225
286
 
287
+ if (accessControlledInclude.kind === 'depth-exceeded') {
288
+ const [firstRelationName] = Object.keys(callerInclude)
289
+ if (firstRelationName !== undefined) {
290
+ throw new AccessScopeDepthExceededError(listKey, firstRelationName, depth)
291
+ }
292
+ return {}
293
+ }
294
+
226
295
  const merged: Record<string, unknown> = {}
227
- const accessInclude = accessControlledInclude
296
+ const accessInclude = accessControlledInclude.include
228
297
 
229
298
  for (const [relationName, callerValue] of Object.entries(callerInclude)) {
230
299
  const fieldConfig = fieldConfigs[relationName]
@@ -237,36 +306,41 @@ export function mergeIncludeWithAccessControl(
237
306
  continue
238
307
  }
239
308
 
240
- const accessValue = accessInclude[relationName]
309
+ const accessEntry = accessInclude[relationName]
241
310
 
242
311
  // Declared relationship absent from the access include → query access denied → drop it.
243
- if (accessValue === undefined) {
312
+ if (accessEntry === undefined) {
244
313
  continue
245
314
  }
246
315
 
247
- const accessEntry = asEntryObject(accessValue)
248
316
  const callerEntry = asEntryObject(callerValue)
249
317
 
250
318
  // Resolve the related list's field configs so nested includes merge recursively.
251
319
  const relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
252
320
  const relatedFields = relatedConfig?.listConfig.fields
253
321
 
254
- const mergedWhere = andWhere(accessEntry?.where, callerEntry?.where)
322
+ const mergedWhere = andWhere(accessEntry.where, callerEntry?.where)
255
323
 
256
324
  let mergedNested: Record<string, unknown> | undefined
257
- if (callerEntry?.include && relatedFields) {
258
- // Recurse: scope the caller's nested selection against the nested access include.
325
+ if (callerEntry?.include && relatedFields && relatedConfig) {
326
+ // Recurse: scope the caller's nested selection against the nested access
327
+ // result. If that result is 'depth-exceeded', the recursive call itself
328
+ // throws — the caller named something the engine cannot scope.
259
329
  mergedNested = mergeIncludeWithAccessControl(
260
330
  callerEntry.include,
261
- accessEntry?.include,
331
+ accessEntry.nested,
262
332
  relatedFields,
263
333
  config,
334
+ relatedConfig.listName,
335
+ depth + 1,
264
336
  )
265
- } else if (accessEntry?.include) {
337
+ } else if (accessEntry.nested.kind === 'scoped') {
266
338
  // Caller selected the relation bare (no nested include); keep the
267
339
  // access-controlled nested include so deeper relations stay filtered.
268
- mergedNested = accessEntry.include
340
+ mergedNested = toPrismaInclude(accessEntry.nested)
269
341
  }
342
+ // If the caller's entry has no nested include, a 'depth-exceeded' nested
343
+ // result is silent here too — nothing was asked for past this point.
270
344
 
271
345
  const entry: { where?: PrismaFilter; include?: Record<string, unknown>; take?: number } = {}
272
346
  if (mergedWhere) entry.where = mergedWhere