@opensaas/stack-core 0.37.0 → 0.38.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 (62) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +68 -0
  3. package/CLAUDE.md +21 -3
  4. package/dist/access/access-filter.d.ts +30 -118
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +70 -206
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +148 -188
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/declared-dependencies.d.ts +66 -26
  11. package/dist/access/declared-dependencies.d.ts.map +1 -1
  12. package/dist/access/declared-dependencies.js +67 -17
  13. package/dist/access/declared-dependencies.js.map +1 -1
  14. package/dist/access/declared-dependencies.test.d.ts +2 -0
  15. package/dist/access/declared-dependencies.test.d.ts.map +1 -0
  16. package/dist/access/declared-dependencies.test.js +226 -0
  17. package/dist/access/declared-dependencies.test.js.map +1 -0
  18. package/dist/access/depth-limits.d.ts +8 -7
  19. package/dist/access/depth-limits.d.ts.map +1 -1
  20. package/dist/access/depth-limits.js +8 -7
  21. package/dist/access/depth-limits.js.map +1 -1
  22. package/dist/access/errors.d.ts +12 -8
  23. package/dist/access/errors.d.ts.map +1 -1
  24. package/dist/access/errors.js +16 -12
  25. package/dist/access/errors.js.map +1 -1
  26. package/dist/access/field-visibility.d.ts +2 -1
  27. package/dist/access/field-visibility.d.ts.map +1 -1
  28. package/dist/access/field-visibility.js +91 -17
  29. package/dist/access/field-visibility.js.map +1 -1
  30. package/dist/access/index.d.ts +1 -2
  31. package/dist/access/index.d.ts.map +1 -1
  32. package/dist/access/index.js +1 -1
  33. package/dist/access/index.js.map +1 -1
  34. package/dist/access/relationship-count.d.ts +1 -1
  35. package/dist/context/index.d.ts.map +1 -1
  36. package/dist/context/index.js +38 -27
  37. package/dist/context/index.js.map +1 -1
  38. package/dist/query/index.d.ts +29 -0
  39. package/dist/query/index.d.ts.map +1 -1
  40. package/dist/query/index.js +27 -0
  41. package/dist/query/index.js.map +1 -1
  42. package/dist/query/relationship-options.d.ts +1 -1
  43. package/dist/query/relationship-options.js +1 -1
  44. package/package.json +1 -1
  45. package/src/access/access-filter.test.ts +205 -275
  46. package/src/access/access-filter.ts +84 -267
  47. package/src/access/declared-dependencies.test.ts +277 -0
  48. package/src/access/declared-dependencies.ts +122 -37
  49. package/src/access/depth-limits.ts +8 -7
  50. package/src/access/errors.ts +16 -12
  51. package/src/access/field-visibility.ts +99 -14
  52. package/src/access/index.ts +1 -7
  53. package/src/access/relationship-count.ts +1 -1
  54. package/src/context/index.ts +52 -33
  55. package/src/query/index.ts +53 -0
  56. package/src/query/relationship-options.ts +1 -1
  57. package/tests/access-relationships.test.ts +18 -16
  58. package/tests/computed-field-selective-evaluation.test.ts +418 -0
  59. package/tests/context.test.ts +27 -0
  60. package/tests/needs-declared-dependencies.test.ts +7 -4
  61. package/tests/resolve-chain.test.ts +11 -11
  62. package/tsconfig.tsbuildinfo +1 -1
@@ -1,24 +1,27 @@
1
- import { describe, it, expect } from 'vitest'
2
- import {
3
- buildIncludeWithAccessControl,
4
- mergeIncludeWithAccessControl,
5
- toPrismaInclude,
6
- } from './access-filter.js'
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { buildAccessScopedInclude } from './access-filter.js'
7
3
  import { AccessScopeDepthExceededError } from './errors.js'
8
4
  import { READ_INCLUDE_MAX_DEPTH } from './depth-limits.js'
9
5
  import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
10
6
  import type { AccessContext } from './types.js'
11
7
 
12
8
  /**
13
- * Regression coverage for the cyclic readable-relationship auto-include.
9
+ * Regression coverage for `buildAccessScopedInclude`, the caller-directed
10
+ * access-scoping walk introduced by ADR-0026. It replaces the old two-step
11
+ * "auto-walk every relationship of the list, then reconcile against whatever
12
+ * the caller asked for" pipeline (`buildIncludeWithAccessControl` +
13
+ * `mergeIncludeWithAccessControl`): the walk now recurses ONLY into the
14
+ * branches a request (`requestedInclude`) itself names, and never evaluates
15
+ * `query` access on a relation nobody asked for.
14
16
  *
15
- * On a relationship graph that contains a cycle (A → B → C → A) the depth-first
16
- * auto-include used to descend the cycle on every branch to `MAX_DEPTH`, and
17
- * `mergeIncludeWithAccessControl` re-expanded any bare-`true` leaf back into that
18
- * same auto-include. The resulting include tree was deep/large enough to
19
- * stack-overflow downstream processing (the RSC serializer). The fix seeds a
20
- * cycle guard with the root list and stops the walk at cycle back-edges, so a
21
- * relation that closes a cycle comes back FLAT (own columns only).
17
+ * The scenarios below carry forward the guarantees the old two-function
18
+ * pipeline encoded #566 (caller include augments, never replaces, the
19
+ * access-controlled scope), #752 (a caller `take` survives the merge), #830
20
+ * (fail-closed past the read-include depth cap) expressed against the new,
21
+ * single-function API. New coverage (the point of ADR-0026 itself): a
22
+ * relation the request doesn't name never has its list's `query` access
23
+ * invoked, and naming a relation fetches its own columns and stops (the "One
24
+ * hop" rule) at every level, not just the root.
22
25
  */
23
26
 
24
27
  // A relationship field pointing at another list.
@@ -26,38 +29,11 @@ function rel(ref: string, many = false): FieldConfig {
26
29
  return { type: 'relationship', ref, many } as unknown as FieldConfig
27
30
  }
28
31
 
29
- // A cyclic config: A → B → C → A (plus a scalar on each list).
30
- function cyclicConfig(): OpenSaasConfig {
31
- const allowQuery = () => true
32
- return {
33
- db: { provider: 'sqlite', url: 'file:./dev.db' },
34
- lists: {
35
- A: {
36
- fields: { name: { type: 'text' } as FieldConfig, b: rel('B.a') },
37
- access: { operation: { query: allowQuery } },
38
- },
39
- B: {
40
- fields: { name: { type: 'text' } as FieldConfig, c: rel('C.b') },
41
- access: { operation: { query: allowQuery } },
42
- },
43
- C: {
44
- fields: { name: { type: 'text' } as FieldConfig, a: rel('A.c') },
45
- access: { operation: { query: allowQuery } },
46
- },
47
- },
48
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
49
- } as any
50
- }
51
-
52
- function makeContext(resolveOutputDepth = 0): AccessContext {
53
- const chain = Array.from({ length: resolveOutputDepth }, (_, i) => ({
54
- listKey: 'TestHook',
55
- fieldKey: `hook${i}`,
56
- }))
32
+ function makeContext(): AccessContext {
57
33
  return {
58
34
  session: null,
59
35
  _isSudo: false,
60
- _resolveOutputChain: chain,
36
+ _resolveOutputChain: [],
61
37
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
62
38
  } as any
63
39
  }
@@ -81,10 +57,16 @@ function includeDepth(include: unknown): number {
81
57
  // A straight-line chain of `count` lists, each with a scalar field and a
82
58
  // single relationship to the next list: L0 → L1 → … → L(count-1). Every list
83
59
  // 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 {
60
+ // assert the scoped tree actually carries the right `where` at a given hop
61
+ // (not just that it happens not to throw). `access.operation.query` is a
62
+ // `vi.fn()` on every list so tests can additionally assert which lists' access
63
+ // functions were (or were not) invoked.
64
+ function chainConfig(count: number): {
65
+ config: OpenSaasConfig
66
+ queryFns: Record<string, ReturnType<typeof vi.fn>>
67
+ } {
87
68
  const lists: Record<string, { fields: Record<string, FieldConfig>; access: unknown }> = {}
69
+ const queryFns: Record<string, ReturnType<typeof vi.fn>> = {}
88
70
  for (let i = 0; i < count; i++) {
89
71
  const listName = `L${i}`
90
72
  const fields: Record<string, FieldConfig> = { name: { type: 'text' } as FieldConfig }
@@ -95,22 +77,24 @@ function chainConfig(count: number): OpenSaasConfig {
95
77
  fields.prev = rel(`L${i - 1}.next`)
96
78
  }
97
79
  const filter = { ownerId: { equals: listName } }
98
- lists[listName] = {
99
- fields,
100
- access: { operation: { query: i === 0 ? () => true : () => filter } },
101
- }
80
+ const queryFn = vi.fn(i === 0 ? () => true : () => filter)
81
+ queryFns[listName] = queryFn
82
+ lists[listName] = { fields, access: { operation: { query: queryFn } } }
102
83
  }
103
84
  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
85
+ config: {
86
+ db: { provider: 'sqlite', url: 'file:./dev.db' },
87
+ lists,
88
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
89
+ } as any,
90
+ queryFns,
91
+ }
108
92
  }
109
93
 
110
- // A caller `include` value selecting `next` `hops` more times beyond the
94
+ // A requested `include` value selecting `next` `hops` more times beyond the
111
95
  // 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).
96
+ // `nestedInclude(0) === true` (stop here); `{ include: { next: nestedInclude(2) } }`
97
+ // at the top level names 3 hops total (the outer `next` plus 2 more).
114
98
  function nestedInclude(hops: number): Record<string, unknown> {
115
99
  if (hops <= 0) return true as unknown as Record<string, unknown>
116
100
  return { include: { next: nestedInclude(hops - 1) } }
@@ -118,8 +102,8 @@ function nestedInclude(hops: number): Record<string, unknown> {
118
102
 
119
103
  // Read the `where` at the end of a chain of nested `next` includes (used to
120
104
  // assert row-scoping survived down to a specific hop).
121
- function whereAtHop(merged: Record<string, unknown>, hops: number): unknown {
122
- let current: unknown = merged
105
+ function whereAtHop(scoped: Record<string, unknown>, hops: number): unknown {
106
+ let current: unknown = scoped
123
107
  for (let i = 0; i < hops; i++) {
124
108
  const entry = (current as { next?: unknown })?.next
125
109
  if (i === hops - 1) return (entry as { where?: unknown })?.where
@@ -128,98 +112,127 @@ function whereAtHop(merged: Record<string, unknown>, hops: number): unknown {
128
112
  return undefined
129
113
  }
130
114
 
131
- describe('buildIncludeWithAccessControlcyclic graph', () => {
132
- it('stops re-descending a relationship cycle instead of walking to MAX_DEPTH', async () => {
133
- const config = cyclicConfig()
134
- const result = await buildIncludeWithAccessControl(
135
- config.lists.A.fields,
136
- { session: null, context: makeContext() },
137
- config,
138
- 0,
139
- ['A'],
140
- )
141
- const include = toPrismaInclude(result)
142
-
143
- // A → B → C, then C.a closes the cycle back to A → flat (no further nesting).
144
- expect(include).toEqual({
145
- b: { include: { c: { include: { a: true } } } },
146
- })
147
- // Three distinct lists → depth 3, not the old MAX_DEPTH=5 (which on a cycle
148
- // could recurse A→B→C→A→B).
149
- expect(includeDepth(include)).toBe(3)
150
- })
151
-
152
- it('flattens a self-referential relationship to a single level', async () => {
153
- const allowQuery = () => true
115
+ describe('buildAccessScopedIncludecaller-directed walk (ADR-0026)', () => {
116
+ it('does not invoke query access on a relation the request never named', async () => {
117
+ // A B, A → C (siblings). Requesting only `b` must never touch C's
118
+ // access function the core guarantee #852 introduces: a request
119
+ // naming one relation no longer walks (and access-checks) every other
120
+ // relationship of the list.
121
+ const queryB = vi.fn(() => true)
122
+ const queryC = vi.fn(() => true)
154
123
  const config = {
155
124
  db: { provider: 'sqlite' },
156
125
  lists: {
157
- Category: {
158
- fields: {
159
- name: { type: 'text' } as FieldConfig,
160
- parent: rel('Category.children'),
161
- children: rel('Category.parent', true),
162
- },
163
- access: { operation: { query: allowQuery } },
126
+ A: {
127
+ fields: { b: rel('B.a'), c: rel('C.a') },
128
+ access: { operation: { query: () => true } },
129
+ },
130
+ B: {
131
+ fields: { name: { type: 'text' } as FieldConfig },
132
+ access: { operation: { query: queryB } },
133
+ },
134
+ C: {
135
+ fields: { name: { type: 'text' } as FieldConfig },
136
+ access: { operation: { query: queryC } },
164
137
  },
165
138
  },
166
139
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
167
140
  } as any as OpenSaasConfig
168
141
 
169
- const result = await buildIncludeWithAccessControl(
170
- config.lists.Category.fields,
142
+ const include = await buildAccessScopedInclude(
143
+ { b: true },
144
+ config.lists.A.fields,
145
+ { session: null, context: makeContext() },
146
+ config,
147
+ 'A',
148
+ )
149
+
150
+ expect(include).toEqual({ b: true })
151
+ expect(queryB).toHaveBeenCalledTimes(1)
152
+ expect(queryC).not.toHaveBeenCalled()
153
+ })
154
+
155
+ it("fetches a named relation's own columns and stops — does not auto-expand its subtree (One hop)", async () => {
156
+ const { config, queryFns } = chainConfig(4) // L0 → L1 → L2 → L3
157
+
158
+ const include = await buildAccessScopedInclude(
159
+ { next: true },
160
+ config.lists.L0.fields,
171
161
  { session: null, context: makeContext() },
172
162
  config,
173
- 0,
174
- ['Category'],
163
+ 'L0',
175
164
  )
176
- const include = toPrismaInclude(result)
177
165
 
178
- // Both self-references come back flat no infinite parent/children descent.
179
- expect(include).toEqual({ parent: true, children: true })
166
+ // L1 is fetched and where-scoped; L2/L3 are never reached because the
167
+ // request named `next` bare, with no nested `include` beneath it.
168
+ expect(include).toEqual({ next: { where: { ownerId: { equals: 'L1' } } } })
180
169
  expect(includeDepth(include)).toBe(1)
170
+ expect(queryFns.L1).toHaveBeenCalledTimes(1)
171
+ expect(queryFns.L2).not.toHaveBeenCalled()
172
+ expect(queryFns.L3).not.toHaveBeenCalled()
181
173
  })
182
- })
183
174
 
184
- describe('mergeIncludeWithAccessControl bare-true leaf on a cyclic graph', () => {
185
- it('does not re-expand a bare-true leaf beyond the cycle-bounded auto-include', async () => {
186
- const config = cyclicConfig()
187
- const accessControlledInclude = await buildIncludeWithAccessControl(
188
- config.lists.A.fields,
175
+ it('scopes a nested path at every level the request names it', async () => {
176
+ const { config, queryFns } = chainConfig(4) // L0 L1 L2 → L3
177
+
178
+ const include = await buildAccessScopedInclude(
179
+ { next: { include: { next: { include: { next: true } } } } },
180
+ config.lists.L0.fields,
189
181
  { session: null, context: makeContext() },
190
182
  config,
191
- 0,
192
- ['A'],
183
+ 'L0',
193
184
  )
194
185
 
195
- // Caller asks for A → b with a bare-`true` leaf. The merge must keep the
196
- // access-controlled (cycle-bounded) nested include rather than re-expanding
197
- // the leaf into an unbounded auto-include.
198
- const merged = mergeIncludeWithAccessControl(
199
- { b: true },
200
- accessControlledInclude,
186
+ expect(include).toEqual({
187
+ next: {
188
+ where: { ownerId: { equals: 'L1' } },
189
+ include: {
190
+ next: {
191
+ where: { ownerId: { equals: 'L2' } },
192
+ include: { next: { where: { ownerId: { equals: 'L3' } } } },
193
+ },
194
+ },
195
+ },
196
+ })
197
+ expect(queryFns.L1).toHaveBeenCalledTimes(1)
198
+ expect(queryFns.L2).toHaveBeenCalledTimes(1)
199
+ expect(queryFns.L3).toHaveBeenCalledTimes(1)
200
+ })
201
+
202
+ it('scopes a full cyclic path exactly as requested, one hop stopping the cycle', async () => {
203
+ // A → B → C → A. The caller explicitly names the whole cyclic path; the
204
+ // walk simply follows the finite literal it was given (no cycle guard
205
+ // needed here — that only matters for the declared-dependency fold, see
206
+ // declared-dependencies.ts).
207
+ const allowQuery = () => true
208
+ const config = {
209
+ db: { provider: 'sqlite' },
210
+ lists: {
211
+ A: { fields: { b: rel('B.a') }, access: { operation: { query: allowQuery } } },
212
+ B: { fields: { c: rel('C.b') }, access: { operation: { query: allowQuery } } },
213
+ C: { fields: { a: rel('A.c') }, access: { operation: { query: allowQuery } } },
214
+ },
215
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
216
+ } as any as OpenSaasConfig
217
+
218
+ const include = await buildAccessScopedInclude(
219
+ { b: { include: { c: { include: { a: true } } } } },
201
220
  config.lists.A.fields,
221
+ { session: null, context: makeContext() },
202
222
  config,
203
223
  'A',
204
224
  )
205
225
 
206
- expect(merged).toEqual({
207
- b: { include: { c: { include: { a: true } } } },
208
- })
209
- // Still bounded to the acyclic path length.
210
- expect(includeDepth(merged)).toBeLessThanOrEqual(3)
226
+ expect(include).toEqual({ b: { include: { c: { include: { a: true } } } } })
211
227
  })
212
228
  })
213
229
 
214
230
  /**
215
- * A caller-supplied `take` on a to-many relation include must survive the
216
- * access-controlled merge (issue #752). It only narrows the fetched rows and can
217
- * never widen past the access `where`, so the merge re-attaches it on top of the
218
- * per-relation access filter — powering the item view's bounded relationship
219
- * tables without dropping row-level access.
231
+ * A caller-supplied `take` on a to-many relation include must survive
232
+ * scoping (issue #752). It only narrows the fetched rows and can never widen
233
+ * past the access `where`, so it rides on top of the access filter unchanged.
220
234
  */
221
- describe('mergeIncludeWithAccessControl — caller take on a to-many relation (issue #752)', () => {
222
- // A one-list config whose `posts` to-many is query-scoped by an access filter.
235
+ describe('buildAccessScopedInclude — caller take on a to-many relation (issue #752)', () => {
223
236
  function scopedConfig(): OpenSaasConfig {
224
237
  return {
225
238
  db: { provider: 'sqlite' },
@@ -230,7 +243,6 @@ describe('mergeIncludeWithAccessControl — caller take on a to-many relation (i
230
243
  },
231
244
  Post: {
232
245
  fields: { title: { type: 'text' } as FieldConfig, author: rel('User.posts') },
233
- // Scoped read access → the merge must fold this into the relation include.
234
246
  access: { operation: { query: () => ({ published: { equals: true } }) } },
235
247
  },
236
248
  },
@@ -240,28 +252,21 @@ describe('mergeIncludeWithAccessControl — caller take on a to-many relation (i
240
252
 
241
253
  it('preserves the take and AND-combines it with the access where', async () => {
242
254
  const config = scopedConfig()
243
- const accessControlledInclude = await buildIncludeWithAccessControl(
244
- config.lists.User.fields,
245
- { session: null, context: makeContext() },
246
- config,
247
- 0,
248
- ['User'],
249
- )
250
255
 
251
- const merged = mergeIncludeWithAccessControl(
256
+ const include = await buildAccessScopedInclude(
252
257
  { posts: { take: 10 } },
253
- accessControlledInclude,
254
258
  config.lists.User.fields,
259
+ { session: null, context: makeContext() },
255
260
  config,
256
261
  'User',
257
262
  )
258
263
 
259
264
  // The bound rides on top of the access filter — neither is dropped. The
260
- // access-controlled include also auto-nests Post's `author` back-relation.
261
- expect(merged).toEqual({
265
+ // Post→author back-relation is NOT auto-nested (One hop, ADR-0026) since
266
+ // the request didn't name it.
267
+ expect(include).toEqual({
262
268
  posts: {
263
269
  where: { published: { equals: true } },
264
- include: { author: true },
265
270
  take: 10,
266
271
  },
267
272
  })
@@ -269,26 +274,18 @@ describe('mergeIncludeWithAccessControl — caller take on a to-many relation (i
269
274
 
270
275
  it('drops a take for a relation whose query access is denied', async () => {
271
276
  const config = scopedConfig()
272
- // Deny Post reads entirely.
273
277
  config.lists.Post.access = { operation: { query: () => false } }
274
- const accessControlledInclude = await buildIncludeWithAccessControl(
275
- config.lists.User.fields,
276
- { session: null, context: makeContext() },
277
- config,
278
- 0,
279
- ['User'],
280
- )
281
278
 
282
- const merged = mergeIncludeWithAccessControl(
279
+ const include = await buildAccessScopedInclude(
283
280
  { posts: { take: 10 } },
284
- accessControlledInclude,
285
281
  config.lists.User.fields,
282
+ { session: null, context: makeContext() },
286
283
  config,
287
284
  'User',
288
285
  )
289
286
 
290
287
  // A denied relation is dropped wholesale — the take cannot resurrect it.
291
- expect(merged).toEqual({})
288
+ expect(include).toEqual({})
292
289
  })
293
290
  })
294
291
 
@@ -296,96 +293,97 @@ describe('mergeIncludeWithAccessControl — caller take on a to-many relation (i
296
293
  * Regression coverage for issue #830: the read pipeline used to FAIL OPEN past
297
294
  * `READ_INCLUDE_MAX_DEPTH` — a caller-supplied `include` nested deeper than the
298
295
  * 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.
296
+ * tests pin the exact boundary ADR-0022 introduced and ADR-0026 preserves: a
297
+ * request one level past the cap throws, the same request one level
298
+ * shallower still works, and a request that never reaches the cap is
299
+ * unaffected.
302
300
  */
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.
301
+ describe('buildAccessScopedInclude — fail-closed at the read-include depth cap (#830)', () => {
308
302
  const HOPS_AT_CAP = READ_INCLUDE_MAX_DEPTH
309
303
 
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.
304
+ it('throws AccessScopeDepthExceededError when the request reaches past the cap', async () => {
313
305
  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
- )
306
+ const { config } = chainConfig(chainLength)
323
307
 
324
308
  // 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) }
309
+ const requested = { next: nestedInclude(HOPS_AT_CAP) }
326
310
 
327
- expect(() =>
328
- mergeIncludeWithAccessControl(
329
- callerInclude,
330
- accessControlledInclude,
311
+ await expect(
312
+ buildAccessScopedInclude(
313
+ requested,
331
314
  config.lists.L0.fields,
315
+ { session: null, context: makeContext() },
332
316
  config,
333
317
  'L0',
334
318
  ),
335
- ).toThrow(AccessScopeDepthExceededError)
319
+ ).rejects.toThrow(AccessScopeDepthExceededError)
336
320
  })
337
321
 
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)
322
+ it('describes a cost refusal, not an inability to scope', async () => {
323
+ const chainLength = HOPS_AT_CAP + 2
324
+ const { config } = chainConfig(chainLength)
325
+ const requested = { next: nestedInclude(HOPS_AT_CAP) }
341
326
 
342
- const accessControlledInclude = await buildIncludeWithAccessControl(
343
- config.lists.L0.fields,
344
- { session: null, context: makeContext() },
345
- config,
346
- 0,
347
- ['L0'],
348
- )
327
+ await expect(
328
+ buildAccessScopedInclude(
329
+ requested,
330
+ config.lists.L0.fields,
331
+ { session: null, context: makeContext() },
332
+ config,
333
+ 'L0',
334
+ ),
335
+ ).rejects.toThrow(/cost limit/)
336
+ })
337
+
338
+ it('still row-scopes a request one level shallower than the cap', async () => {
339
+ const chainLength = HOPS_AT_CAP + 1
340
+ const { config } = chainConfig(chainLength)
349
341
 
350
342
  // 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) }
343
+ const requested = { next: nestedInclude(HOPS_AT_CAP - 1) }
352
344
 
353
- // Must NOT throw — this depth is within what the engine can scope.
354
- const merged = mergeIncludeWithAccessControl(
355
- callerInclude,
356
- accessControlledInclude,
345
+ const include = await buildAccessScopedInclude(
346
+ requested,
357
347
  config.lists.L0.fields,
348
+ { session: null, context: makeContext() },
358
349
  config,
359
350
  'L0',
360
351
  )
361
352
 
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}` } })
353
+ expect(includeDepth(include)).toBe(HOPS_AT_CAP)
354
+ expect(whereAtHop(include, HOPS_AT_CAP)).toEqual({ ownerId: { equals: `L${HOPS_AT_CAP}` } })
366
355
  })
367
356
 
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.
357
+ it('does not throw for a request that never reaches the cap', async () => {
372
358
  const chainLength = HOPS_AT_CAP + 3
373
- const config = chainConfig(chainLength)
359
+ const { config } = chainConfig(chainLength)
374
360
 
375
- const result = await buildIncludeWithAccessControl(
361
+ await expect(
362
+ buildAccessScopedInclude(
363
+ { next: true },
364
+ config.lists.L0.fields,
365
+ { session: null, context: makeContext() },
366
+ config,
367
+ 'L0',
368
+ ),
369
+ ).resolves.not.toThrow()
370
+ })
371
+
372
+ it('an empty request never throws, even on a deep schema', async () => {
373
+ const chainLength = HOPS_AT_CAP + 3
374
+ const { config } = chainConfig(chainLength)
375
+
376
+ const include = await buildAccessScopedInclude(
377
+ {},
376
378
  config.lists.L0.fields,
377
379
  { session: null, context: makeContext() },
378
380
  config,
379
- 0,
380
- ['L0'],
381
+ 'L0',
381
382
  )
382
-
383
- expect(() => toPrismaInclude(result)).not.toThrow()
384
- const include = toPrismaInclude(result)
385
- expect(includeDepth(include)).toBeLessThanOrEqual(HOPS_AT_CAP)
383
+ expect(include).toEqual({})
386
384
  })
387
385
 
388
- it('a list with no relationships still passes the caller include through unchanged', async () => {
386
+ it('a list with no relationships passes an unrelated requested key through unchanged', async () => {
389
387
  const config = {
390
388
  db: { provider: 'sqlite' },
391
389
  lists: {
@@ -397,83 +395,15 @@ describe('mergeIncludeWithAccessControl — fail-closed at the read-include dept
397
395
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal config for unit test
398
396
  } as any as OpenSaasConfig
399
397
 
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
398
  // An arbitrary (non-declared-relationship) key passed through unchanged —
411
399
  // access control does not govern keys it doesn't recognize as relationships.
412
- const merged = mergeIncludeWithAccessControl(
400
+ const include = await buildAccessScopedInclude(
413
401
  { someUnrelatedKey: true },
414
- accessControlledInclude,
415
402
  config.lists.Leaf.fields,
403
+ { session: null, context: makeContext() },
416
404
  config,
417
405
  'Leaf',
418
406
  )
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 })
407
+ expect(include).toEqual({ someUnrelatedKey: true })
478
408
  })
479
409
  })