@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +61 -0
- package/dist/access/access-filter.d.ts +76 -20
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +102 -70
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +175 -10
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/depth-limits.d.ts +26 -0
- package/dist/access/depth-limits.d.ts.map +1 -0
- package/dist/access/depth-limits.js +26 -0
- package/dist/access/depth-limits.js.map +1 -0
- package/dist/access/errors.d.ts +43 -0
- package/dist/access/errors.d.ts.map +1 -0
- package/dist/access/errors.js +55 -0
- package/dist/access/errors.js.map +1 -0
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +82 -20
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +4 -1
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +5 -1
- package/dist/access/index.js.map +1 -1
- package/dist/access/multi-column-read-write.test.js +1 -1
- package/dist/access/multi-column-read-write.test.js.map +1 -1
- package/dist/access/relationship-count.test.js +1 -1
- package/dist/access/relationship-count.test.js.map +1 -1
- package/dist/access/relationship-label-filter.test.js +1 -1
- package/dist/access/relationship-label-filter.test.js.map +1 -1
- package/dist/access/types.d.ts +15 -7
- package/dist/access/types.d.ts.map +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +15 -9
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +1 -5
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/transaction-boundary.d.ts.map +1 -1
- package/dist/context/transaction-boundary.js +43 -6
- package/dist/context/transaction-boundary.js.map +1 -1
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +5 -4
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +258 -7
- package/src/access/access-filter.ts +146 -72
- package/src/access/depth-limits.ts +26 -0
- package/src/access/errors.ts +62 -0
- package/src/access/field-visibility.ts +97 -21
- package/src/access/index.ts +6 -0
- package/src/access/multi-column-read-write.test.ts +1 -1
- package/src/access/relationship-count.test.ts +1 -1
- package/src/access/relationship-label-filter.test.ts +1 -1
- package/src/access/types.ts +12 -5
- package/src/context/index.ts +15 -6
- package/src/context/nested-operations.ts +0 -7
- package/src/context/transaction-boundary.ts +48 -7
- package/src/context/write-pipeline.ts +5 -4
- package/src/index.ts +11 -0
- package/tests/access-relationships.test.ts +78 -64
- package/tests/context.test.ts +106 -24
- package/tests/default-value-create.test.ts +1 -1
- package/tests/hook-pipeline.test.ts +1 -1
- package/tests/nav-count.test.ts +2 -2
- package/tests/resolve-chain.test.ts +394 -0
- package/tests/transaction-boundary-hooks.test.ts +246 -1
- package/tests/write-pipeline.test.ts +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maximum nesting depth of relation `include`s that the Access Filter
|
|
3
|
+
* (`buildIncludeWithAccessControl`) will auto-scope on a read.
|
|
4
|
+
*
|
|
5
|
+
* Security implication: this is an access-control boundary, not just a cost
|
|
6
|
+
* bound. Past this depth the engine cannot compute a row/field scope for a
|
|
7
|
+
* relation, so a caller-supplied `include` naming a relation at or beyond it
|
|
8
|
+
* must be treated as a denial (see `AccessScopeDepthExceededError`) rather
|
|
9
|
+
* than passed through unscoped — see ADR-0022 and issue #830.
|
|
10
|
+
*/
|
|
11
|
+
export const READ_INCLUDE_MAX_DEPTH = 5
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Maximum length of the resolve chain — the ordered sequence of `resolveOutput`
|
|
15
|
+
* hooks a read has entered, each entry extended by a hook that issues its own
|
|
16
|
+
* read (see `_resolveOutputChain` on `AccessContext`).
|
|
17
|
+
*
|
|
18
|
+
* This is a COST limit, not an access-control boundary: an acyclic chain
|
|
19
|
+
* genuinely can exceed it and still be correct (e.g. a virtual `Order.total`
|
|
20
|
+
* reading a virtual `LineItem.subtotal` reading further virtuals), so
|
|
21
|
+
* exceeding it omits the field and emits a single `console.warn` rather than
|
|
22
|
+
* throwing. Termination itself is guaranteed by the separate cycle guard,
|
|
23
|
+
* which refuses to re-enter a `(list, field)` pair already on the chain
|
|
24
|
+
* regardless of this cap. See ADR-0023.
|
|
25
|
+
*/
|
|
26
|
+
export const RESOLVE_CHAIN_MAX_LENGTH = 5
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Thrown when a `resolveOutput` hook re-enters a `(list, field)` pair already
|
|
36
|
+
* on its own resolve chain — a hook whose own read (directly or transitively)
|
|
37
|
+
* comes back around to itself. Deliberately distinct from `ValidationError`
|
|
38
|
+
* (same reasoning as `AccessScopeDepthExceededError`): this is not bad user
|
|
39
|
+
* input, it is the engine refusing to run a hook chain that cannot terminate.
|
|
40
|
+
*
|
|
41
|
+
* This is a loud failure, not a Silent one, on purpose: a repeated pair never
|
|
42
|
+
* terminated before this guard existed, so no working application can depend
|
|
43
|
+
* on the old (hanging) behaviour, and staying silent here would return
|
|
44
|
+
* `undefined` for a field with nothing in the logs to explain why. Contrast
|
|
45
|
+
* with exceeding `RESOLVE_CHAIN_MAX_LENGTH`, which can happen on a chain that
|
|
46
|
+
* would otherwise terminate correctly and therefore only warns. See ADR-0023.
|
|
47
|
+
*/
|
|
48
|
+
export class ResolveOutputCycleError extends Error {
|
|
49
|
+
public chain: readonly { listKey: string; fieldKey: string }[]
|
|
50
|
+
|
|
51
|
+
constructor(chain: readonly { listKey: string; fieldKey: string }[]) {
|
|
52
|
+
const path = chain.map((link) => `${link.listKey}.${link.fieldKey}`).join(' → ')
|
|
53
|
+
super(
|
|
54
|
+
`resolveOutput cycle detected: ${path}. A hook that re-enters a (list, field) pair ` +
|
|
55
|
+
`already on its own resolve chain cannot terminate, so the read is refused rather than ` +
|
|
56
|
+
`left to recurse until the process runs out of memory. Restructure the hooks so the read ` +
|
|
57
|
+
`does not loop back into itself.`,
|
|
58
|
+
)
|
|
59
|
+
this.name = 'ResolveOutputCycleError'
|
|
60
|
+
this.chain = chain
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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:
|
|
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 }
|
|
@@ -116,7 +183,6 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
116
183
|
listKey?: string,
|
|
117
184
|
): Promise<Partial<T>> {
|
|
118
185
|
const filtered: Record<string, unknown> = {}
|
|
119
|
-
const MAX_DEPTH = 5 // Prevent infinite recursion
|
|
120
186
|
|
|
121
187
|
// Multi-column fields (e.g. storage image()/file() in Keystone-parity mode)
|
|
122
188
|
// back several physical columns rather than one. Before the per-field pass,
|
|
@@ -153,14 +219,22 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
153
219
|
// Handle relationship fields - recursively filter fields within related items
|
|
154
220
|
// Note: Access control filtering is now done at database level via buildIncludeWithAccessControl
|
|
155
221
|
// This only handles field-level access (hiding sensitive fields)
|
|
222
|
+
//
|
|
223
|
+
// Deliberately uncapped: the row/relation scoping in access-filter.ts bounds
|
|
224
|
+
// what gets FETCHED (a caller include past its depth cap is now a denial,
|
|
225
|
+
// not a passthrough — see ADR-0022), so by the time a result reaches this
|
|
226
|
+
// function it is already a finite, acyclic tree whose depth was decided at
|
|
227
|
+
// the pre-query phase. Capping recursion again here independently of that
|
|
228
|
+
// cap used to let a relation be scoped correctly at the DB level while
|
|
229
|
+
// still returning with unfiltered fields past this function's own,
|
|
230
|
+
// separately-tracked limit (issue #830).
|
|
156
231
|
if (
|
|
157
232
|
config &&
|
|
158
233
|
fieldConfig?.type === 'relationship' &&
|
|
159
234
|
'ref' in fieldConfig &&
|
|
160
235
|
fieldConfig.ref &&
|
|
161
236
|
value !== null &&
|
|
162
|
-
value !== undefined
|
|
163
|
-
depth < MAX_DEPTH
|
|
237
|
+
value !== undefined
|
|
164
238
|
) {
|
|
165
239
|
// Gate the relationship on read access before recursing.
|
|
166
240
|
const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
|
|
@@ -220,6 +294,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
220
294
|
hookItem: workingItem,
|
|
221
295
|
listKey,
|
|
222
296
|
args,
|
|
297
|
+
config,
|
|
223
298
|
})
|
|
224
299
|
|
|
225
300
|
if (result.readable) {
|
|
@@ -258,6 +333,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
258
333
|
hookItem: filtered,
|
|
259
334
|
listKey,
|
|
260
335
|
args,
|
|
336
|
+
config,
|
|
261
337
|
})
|
|
262
338
|
|
|
263
339
|
if (result.readable) {
|
package/src/access/index.ts
CHANGED
|
@@ -26,6 +26,12 @@ 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'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/access/types.ts
CHANGED
|
@@ -290,12 +290,19 @@ export interface AccessContext<TPrisma extends PrismaClientLike = PrismaClientLi
|
|
|
290
290
|
plugins: Record<string, unknown>
|
|
291
291
|
_isSudo: boolean
|
|
292
292
|
/**
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
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
|
-
|
|
305
|
+
_resolveOutputChain: readonly { listKey: string; fieldKey: string }[]
|
|
299
306
|
}
|
|
300
307
|
|
|
301
308
|
/**
|
package/src/context/index.ts
CHANGED
|
@@ -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'
|
|
@@ -433,7 +434,7 @@ export function getContext<
|
|
|
433
434
|
// client, otherwise start empty and populate via plugin runtimes below.
|
|
434
435
|
plugins: _sharedPlugins ?? {},
|
|
435
436
|
_isSudo,
|
|
436
|
-
|
|
437
|
+
_resolveOutputChain: [],
|
|
437
438
|
}
|
|
438
439
|
|
|
439
440
|
// Create access-controlled operations for each list, populating `db` in place.
|
|
@@ -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
|
-
|
|
156
|
+
maxPairs: number,
|
|
121
157
|
): void {
|
|
122
|
-
|
|
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,
|
|
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,
|
|
233
|
+
walkNested(inputData, listConfig.fields, config, out, seen, maxPairs)
|
|
193
234
|
|
|
194
235
|
return out
|
|
195
236
|
}
|
|
@@ -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
|
|
301
|
-
*
|
|
302
|
-
* `plugins` object 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
|
-
|
|
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
|
@@ -62,6 +62,17 @@ 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
|
+
|
|
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
|
+
|
|
65
76
|
// Field self-containment validation — checks each field implements the
|
|
66
77
|
// generation contract (getPrismaType / getTypeScriptType / getZodSchema, or
|
|
67
78
|
// getPrismaRelation for relationships) so a misimplemented field fails early
|