@opensaas/stack-core 0.33.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 (61) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +35 -0
  3. package/dist/access/access-filter.d.ts.map +1 -1
  4. package/dist/access/access-filter.js +12 -7
  5. package/dist/access/access-filter.js.map +1 -1
  6. package/dist/access/access-filter.test.js +5 -1
  7. package/dist/access/access-filter.test.js.map +1 -1
  8. package/dist/access/depth-limits.d.ts +14 -0
  9. package/dist/access/depth-limits.d.ts.map +1 -1
  10. package/dist/access/depth-limits.js +14 -0
  11. package/dist/access/depth-limits.js.map +1 -1
  12. package/dist/access/errors.d.ts +24 -0
  13. package/dist/access/errors.d.ts.map +1 -1
  14. package/dist/access/errors.js +26 -0
  15. package/dist/access/errors.js.map +1 -1
  16. package/dist/access/field-visibility.d.ts.map +1 -1
  17. package/dist/access/field-visibility.js +72 -17
  18. package/dist/access/field-visibility.js.map +1 -1
  19. package/dist/access/index.d.ts +1 -0
  20. package/dist/access/index.d.ts.map +1 -1
  21. package/dist/access/index.js +2 -0
  22. package/dist/access/index.js.map +1 -1
  23. package/dist/access/multi-column-read-write.test.js +1 -1
  24. package/dist/access/multi-column-read-write.test.js.map +1 -1
  25. package/dist/access/relationship-count.test.js +1 -1
  26. package/dist/access/relationship-count.test.js.map +1 -1
  27. package/dist/access/relationship-label-filter.test.js +1 -1
  28. package/dist/access/relationship-label-filter.test.js.map +1 -1
  29. package/dist/access/types.d.ts +15 -7
  30. package/dist/access/types.d.ts.map +1 -1
  31. package/dist/context/index.js +1 -1
  32. package/dist/context/index.js.map +1 -1
  33. package/dist/context/write-pipeline.d.ts.map +1 -1
  34. package/dist/context/write-pipeline.js +5 -4
  35. package/dist/context/write-pipeline.js.map +1 -1
  36. package/dist/index.d.ts +1 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +4 -0
  39. package/dist/index.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/access/access-filter.test.ts +5 -1
  42. package/src/access/access-filter.ts +12 -7
  43. package/src/access/depth-limits.ts +15 -0
  44. package/src/access/errors.ts +30 -0
  45. package/src/access/field-visibility.ts +87 -18
  46. package/src/access/index.ts +2 -0
  47. package/src/access/multi-column-read-write.test.ts +1 -1
  48. package/src/access/relationship-count.test.ts +1 -1
  49. package/src/access/relationship-label-filter.test.ts +1 -1
  50. package/src/access/types.ts +12 -5
  51. package/src/context/index.ts +1 -1
  52. package/src/context/write-pipeline.ts +5 -4
  53. package/src/index.ts +5 -0
  54. package/tests/access-relationships.test.ts +1 -1
  55. package/tests/context.test.ts +2 -2
  56. package/tests/default-value-create.test.ts +1 -1
  57. package/tests/hook-pipeline.test.ts +1 -1
  58. package/tests/nav-count.test.ts +2 -2
  59. package/tests/resolve-chain.test.ts +394 -0
  60. package/tests/write-pipeline.test.ts +1 -1
  61. package/tsconfig.tsbuildinfo +1 -1
@@ -2,6 +2,15 @@ import type { Session, AccessContext } from './types.js'
2
2
  import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
3
3
  import { getRelatedListConfig } from './engine.js'
4
4
  import { checkFieldAccess } from './field-access.js'
5
+ import { RESOLVE_CHAIN_MAX_LENGTH } from './depth-limits.js'
6
+ import { ResolveOutputCycleError } from './errors.js'
7
+ // NOTE: `context/index.ts` imports `filterReadableFields` from this module
8
+ // (via the `access/index.ts` barrel) — this is an intentional cyclic
9
+ // dependency, the same shape and for the same reason as the one documented in
10
+ // `context/write-pipeline.ts`. `buildDbDelegate` is only INVOKED when a
11
+ // `resolveOutput` hook actually runs (never during module evaluation), so by
12
+ // the time it runs the export is fully initialised.
13
+ import { buildDbDelegate } from '../context/index.js'
5
14
 
6
15
  /**
7
16
  * Field Visibility — phase 2 of the two-phase read (post-query).
@@ -36,6 +45,44 @@ type FieldVisibilityArgs = {
36
45
  context: AccessContext & { _isSudo?: boolean }
37
46
  }
38
47
 
48
+ /**
49
+ * Derive the context passed to a single `resolveOutput` hook invocation: a
50
+ * NEW context object whose `_resolveOutputChain` extends the caller's chain
51
+ * with this hook's own `(list, field)` link. The chain is never mutated in
52
+ * place — this is what lets concurrent hook invocations (e.g. sibling rows in
53
+ * a to-many relation, filtered via `Promise.all`) each see their own chain
54
+ * rather than racing on one shared value (ADR-0023).
55
+ *
56
+ * A plain `{ ...context, _resolveOutputChain }` spread is not enough on its
57
+ * own: `context.db`'s operations capture their `context` at construction
58
+ * (see `populateDbDelegate`), so a hook that calls `context.db.x.findMany(…)`
59
+ * would otherwise reach the ORIGINAL closures — bound to the ORIGINAL
60
+ * context — and its read would silently fall back to the un-extended chain,
61
+ * defeating the cycle guard entirely. Rebuilding `db` via `buildDbDelegate`
62
+ * against the derived context is what makes a hook-issued read's own nested
63
+ * hooks actually observe the extended chain.
64
+ *
65
+ * `config` is required to rebuild `db`; callers that cannot supply one (e.g. a
66
+ * narrow unit test exercising field access in isolation) still get a correct
67
+ * chain for THIS hook's own cycle/cap check, but a read that hook issues
68
+ * would not carry the chain any further — those callers are not exercising
69
+ * the read pipeline, so there is nothing for it to reach.
70
+ */
71
+ function deriveResolveOutputContext(
72
+ context: AccessContext & { _isSudo?: boolean },
73
+ link: { listKey: string; fieldKey: string },
74
+ config: OpenSaasConfig | undefined,
75
+ ): AccessContext & { _isSudo?: boolean } {
76
+ const derived: AccessContext & { _isSudo?: boolean } = {
77
+ ...context,
78
+ _resolveOutputChain: [...context._resolveOutputChain, link],
79
+ }
80
+ if (config) {
81
+ derived.db = buildDbDelegate(config, context.prisma, derived)
82
+ }
83
+ return derived
84
+ }
85
+
39
86
  /**
40
87
  * The core Field Visibility step for a single field: check read access and, if
41
88
  * granted, produce the output value by running any `resolveOutput` hook.
@@ -58,8 +105,9 @@ async function resolveReadableFieldValue(params: {
58
105
  hookItem: Record<string, unknown>
59
106
  listKey: string | undefined
60
107
  args: FieldVisibilityArgs
108
+ config: OpenSaasConfig | undefined
61
109
  }): Promise<{ readable: false } | { readable: true; value: unknown }> {
62
- const { fieldConfig, fieldName, value, accessItem, hookItem, listKey, args } = params
110
+ const { fieldConfig, fieldName, value, accessItem, hookItem, listKey, args, config } = params
63
111
 
64
112
  // Check field access (checkFieldAccess already handles sudo mode)
65
113
  const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
@@ -76,25 +124,44 @@ async function resolveReadableFieldValue(params: {
76
124
  // Cast to runtime type for generic execution
77
125
  // At runtime, the hook will receive the correct value type for the field
78
126
  const hook = fieldConfig.hooks.resolveOutput as unknown as ResolveOutputHookRuntime
79
- // Increment depth counter to prevent infinite loops from hooks making DB queries
80
- // that include relationships back to the same entity
81
- args.context._resolveOutputCounter.depth++
82
- try {
83
- // Use Promise.resolve() to handle both sync and async hooks
84
- const resolved = await Promise.resolve(
85
- hook({
86
- value,
87
- operation: 'query',
88
- fieldName,
89
- listKey,
90
- item: hookItem,
91
- context: args.context,
92
- }),
127
+ const link = { listKey, fieldKey: fieldName }
128
+ const chain = args.context._resolveOutputChain
129
+
130
+ // Cycle guard: a hook that would re-enter a (list, field) pair already on
131
+ // its own chain cannot terminate refuse loudly rather than recurse
132
+ // until the process runs out of memory (issue #844, ADR-0023).
133
+ const alreadyOnChain = chain.some(
134
+ (entry) => entry.listKey === link.listKey && entry.fieldKey === link.fieldKey,
135
+ )
136
+ if (alreadyOnChain) {
137
+ throw new ResolveOutputCycleError([...chain, link])
138
+ }
139
+
140
+ // Cost cap: a chain this long is refused only as a cost limit, never a
141
+ // correctness one — an acyclic chain that works today can legitimately
142
+ // reach this. Omit the field and warn instead of throwing.
143
+ if (chain.length >= RESOLVE_CHAIN_MAX_LENGTH) {
144
+ const path = [...chain, link].map((entry) => `${entry.listKey}.${entry.fieldKey}`).join(' → ')
145
+ console.warn(
146
+ `resolveOutput: omitting "${listKey}.${fieldName}" — its resolve chain exceeded ` +
147
+ `RESOLVE_CHAIN_MAX_LENGTH (${RESOLVE_CHAIN_MAX_LENGTH}): ${path}. This is a cost limit, ` +
148
+ `not an access denial.`,
93
149
  )
94
- return { readable: true, value: resolved }
95
- } finally {
96
- args.context._resolveOutputCounter.depth--
150
+ return { readable: false }
97
151
  }
152
+
153
+ // Use Promise.resolve() to handle both sync and async hooks
154
+ const resolved = await Promise.resolve(
155
+ hook({
156
+ value,
157
+ operation: 'query',
158
+ fieldName,
159
+ listKey,
160
+ item: hookItem,
161
+ context: deriveResolveOutputContext(args.context, link, config),
162
+ }),
163
+ )
164
+ return { readable: true, value: resolved }
98
165
  }
99
166
 
100
167
  return { readable: true, value }
@@ -227,6 +294,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
227
294
  hookItem: workingItem,
228
295
  listKey,
229
296
  args,
297
+ config,
230
298
  })
231
299
 
232
300
  if (result.readable) {
@@ -265,6 +333,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
265
333
  hookItem: filtered,
266
334
  listKey,
267
335
  args,
336
+ config,
268
337
  })
269
338
 
270
339
  if (result.readable) {
@@ -33,3 +33,5 @@ export type { AccessIncludeResult } from './access-filter.js'
33
33
  export { filterReadableFields } from './field-visibility.js'
34
34
  // Thrown when a caller include reaches past the depth the Access Filter can scope.
35
35
  export { AccessScopeDepthExceededError } from './errors.js'
36
+ // Thrown when a resolveOutput hook's own resolve chain cycles back into itself.
37
+ export { ResolveOutputCycleError } from './errors.js'
@@ -52,7 +52,7 @@ function makeContext(overrides: { isSudo?: boolean } = {}): AccessContext {
52
52
  return {
53
53
  session: null,
54
54
  _isSudo: overrides.isSudo ?? false,
55
- _resolveOutputCounter: { depth: 0 },
55
+ _resolveOutputChain: [],
56
56
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
57
57
  } as any
58
58
  }
@@ -51,7 +51,7 @@ function makeContext(
51
51
  return {
52
52
  session: null,
53
53
  _isSudo: false,
54
- _resolveOutputCounter: { depth: 0 },
54
+ _resolveOutputChain: [],
55
55
  db: findMany ? { user: { findMany } } : {},
56
56
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
57
57
  } as any
@@ -46,7 +46,7 @@ function makeContext(): AccessContext {
46
46
  return {
47
47
  session: null,
48
48
  _isSudo: false,
49
- _resolveOutputCounter: { depth: 0 },
49
+ _resolveOutputChain: [],
50
50
  db: {},
51
51
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal context for unit test
52
52
  } as any
@@ -290,12 +290,19 @@ export interface AccessContext<TPrisma extends PrismaClientLike = PrismaClientLi
290
290
  plugins: Record<string, unknown>
291
291
  _isSudo: boolean
292
292
  /**
293
- * Internal mutable counter to track resolveOutput hook depth.
294
- * When depth > 0, we skip auto-including relationships to prevent infinite loops
295
- * when hooks make database queries that include relationships back to the original entity.
296
- * We use a mutable object so that spreading the context preserves the reference.
293
+ * The resolve chain: the ordered sequence of `resolveOutput` hook
294
+ * `(listKey, fieldKey)` pairs a read has entered on the way to here. A
295
+ * top-level read starts with an empty chain. Each hook invocation is given
296
+ * a NEW context whose chain extends this one by its own pair — the chain is
297
+ * never mutated in place, so concurrent hook invocations (e.g. sibling rows
298
+ * in a to-many relation processed via `Promise.all`) never observe each
299
+ * other's chain. A hook that would re-enter a pair already on its own chain
300
+ * is refused (`ResolveOutputCycleError`) rather than left to recurse
301
+ * forever; a chain longer than `RESOLVE_CHAIN_MAX_LENGTH` is a separate,
302
+ * non-fatal cost limit. See ADR-0023 and the "Resolve chain" glossary entry
303
+ * in CONTEXT.md.
297
304
  */
298
- _resolveOutputCounter: { depth: number }
305
+ _resolveOutputChain: readonly { listKey: string; fieldKey: string }[]
299
306
  }
300
307
 
301
308
  /**
@@ -434,7 +434,7 @@ export function getContext<
434
434
  // client, otherwise start empty and populate via plugin runtimes below.
435
435
  plugins: _sharedPlugins ?? {},
436
436
  _isSudo,
437
- _resolveOutputCounter: { depth: 0 },
437
+ _resolveOutputChain: [],
438
438
  }
439
439
 
440
440
  // Create access-controlled operations for each list, populating `db` in place.
@@ -297,9 +297,10 @@ export async function runWritePipeline<TPrisma extends PrismaClientLike>(
297
297
  * construction, so the request-time `context.db` is bound to the ORIGINAL
298
298
  * client. We rebuild the delegates against `tx` via {@link buildDbDelegate},
299
299
  * reusing the request context's `session`, `storage`, `plugins`, `_isSudo`, and
300
- * the shared `_resolveOutputCounter` reference (so resolveOutput depth tracking
301
- * is preserved). Plugin runtimes are NOT re-executed; the existing
302
- * `plugins` object is reused as-is.
300
+ * the current `_resolveOutputChain` value (carried through unchanged, so a
301
+ * write issued from inside a `resolveOutput` hook keeps that hook's chain).
302
+ * Plugin runtimes are NOT re-executed; the existing `plugins` object is
303
+ * reused as-is.
303
304
  */
304
305
  function bindContextToTransaction<TPrisma extends PrismaClientLike>(
305
306
  args: WritePipelineArgs<TPrisma>,
@@ -313,7 +314,7 @@ function bindContextToTransaction<TPrisma extends PrismaClientLike>(
313
314
  storage: context.storage,
314
315
  plugins: context.plugins,
315
316
  _isSudo: context._isSudo,
316
- _resolveOutputCounter: context._resolveOutputCounter,
317
+ _resolveOutputChain: context._resolveOutputChain,
317
318
  }
318
319
  // Rebuild the db delegate against `tx`, pointing back at `txContext` so hooks
319
320
  // reached through it also see the transactional context.
package/src/index.ts CHANGED
@@ -68,6 +68,11 @@ export { ValidationError } from './hooks/index.js'
68
68
  // a user-input validation failure.
69
69
  export { AccessScopeDepthExceededError } from './access/index.js'
70
70
 
71
+ // Thrown by a `resolveOutput` hook whose own read cycles back into a
72
+ // `(list, field)` pair already on its resolve chain (see ADR-0023). Distinct
73
+ // from `ValidationError` for the same reason as `AccessScopeDepthExceededError`.
74
+ export { ResolveOutputCycleError } from './access/index.js'
75
+
71
76
  // Field self-containment validation — checks each field implements the
72
77
  // generation contract (getPrismaType / getTypeScriptType / getZodSchema, or
73
78
  // getPrismaRelation for relationships) so a misimplemented field fails early
@@ -23,7 +23,7 @@ describe('Relationship Access Control', () => {
23
23
  },
24
24
  plugins: {},
25
25
  _isSudo: false,
26
- _resolveOutputCounter: { depth: 0 },
26
+ _resolveOutputChain: [],
27
27
  }
28
28
 
29
29
  describe('getRelatedListConfig', () => {
@@ -1430,7 +1430,7 @@ describe('getContext', () => {
1430
1430
  // Regression for issue #830: a read issued from inside a `resolveOutput`
1431
1431
  // hook used to lose relation row scoping ENTIRELY — `buildIncludeWithAccessControl`
1432
1432
  // returned a whole-object `undefined` for the inner read (any
1433
- // `_resolveOutputCounter.depth > 0`), which `mergeIncludeWithAccessControl`
1433
+ // `_resolveOutputChain.length > 0`), which `mergeIncludeWithAccessControl`
1434
1434
  // treated as "nothing to merge against" and passed the caller's include
1435
1435
  // through completely unscoped. The fix scopes each immediate relation with
1436
1436
  // its own access `where` while still not auto-EXPANDING into that
@@ -1439,7 +1439,7 @@ describe('getContext', () => {
1439
1439
  describe('scopes (without expanding) a caller include used inside a resolveOutput hook (#830)', () => {
1440
1440
  // Build an Author config with a virtual field whose resolveOutput issues a
1441
1441
  // read WITH an explicit include. While that hook runs,
1442
- // _resolveOutputCounter.depth > 0.
1442
+ // _resolveOutputChain.length > 0.
1443
1443
  function configWithResolveOutputProbe(
1444
1444
  callerInclude: Record<string, unknown>,
1445
1445
  capture: (include: unknown) => void,
@@ -33,7 +33,7 @@ function makeContext(): AccessContext {
33
33
  storage: {} as any,
34
34
  plugins: {},
35
35
  _isSudo: false,
36
- _resolveOutputCounter: { depth: 0 },
36
+ _resolveOutputChain: [],
37
37
  }
38
38
  }
39
39
 
@@ -37,7 +37,7 @@ function makeContext(): AccessContext {
37
37
  storage: {} as any,
38
38
  plugins: {},
39
39
  _isSudo: false,
40
- _resolveOutputCounter: { depth: 0 },
40
+ _resolveOutputChain: [],
41
41
  }
42
42
  }
43
43
 
@@ -43,7 +43,7 @@ function makeContext(counts: Record<string, number>): {
43
43
  storage: {},
44
44
  plugins: {},
45
45
  _isSudo: false,
46
- _resolveOutputCounter: { depth: 0 },
46
+ _resolveOutputChain: [],
47
47
  } as unknown as AccessContext
48
48
  return { context, spies }
49
49
  }
@@ -168,7 +168,7 @@ describe('resolveNavCounts', () => {
168
168
  storage: {},
169
169
  plugins: {},
170
170
  _isSudo: false,
171
- _resolveOutputCounter: { depth: 0 },
171
+ _resolveOutputChain: [],
172
172
  } as unknown as AccessContext
173
173
 
174
174
  const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})