@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +35 -0
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +12 -7
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +5 -1
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/depth-limits.d.ts +14 -0
- package/dist/access/depth-limits.d.ts.map +1 -1
- package/dist/access/depth-limits.js +14 -0
- package/dist/access/depth-limits.js.map +1 -1
- package/dist/access/errors.d.ts +24 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +26 -0
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +72 -17
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +1 -0
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +2 -0
- 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.js +1 -1
- package/dist/context/index.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 +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +5 -1
- package/src/access/access-filter.ts +12 -7
- package/src/access/depth-limits.ts +15 -0
- package/src/access/errors.ts +30 -0
- package/src/access/field-visibility.ts +87 -18
- package/src/access/index.ts +2 -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 +1 -1
- package/src/context/write-pipeline.ts +5 -4
- package/src/index.ts +5 -0
- package/tests/access-relationships.test.ts +1 -1
- package/tests/context.test.ts +2 -2
- 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/write-pipeline.test.ts +1 -1
- 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
|
-
|
|
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 }
|
|
@@ -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) {
|
package/src/access/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
|
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
|
@@ -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
|
package/tests/context.test.ts
CHANGED
|
@@ -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
|
-
// `
|
|
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
|
-
//
|
|
1442
|
+
// _resolveOutputChain.length > 0.
|
|
1443
1443
|
function configWithResolveOutputProbe(
|
|
1444
1444
|
callerInclude: Record<string, unknown>,
|
|
1445
1445
|
capture: (include: unknown) => void,
|
package/tests/nav-count.test.ts
CHANGED
|
@@ -43,7 +43,7 @@ function makeContext(counts: Record<string, number>): {
|
|
|
43
43
|
storage: {},
|
|
44
44
|
plugins: {},
|
|
45
45
|
_isSudo: false,
|
|
46
|
-
|
|
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
|
-
|
|
171
|
+
_resolveOutputChain: [],
|
|
172
172
|
} as unknown as AccessContext
|
|
173
173
|
|
|
174
174
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|