@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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +68 -0
- package/CLAUDE.md +21 -3
- package/dist/access/access-filter.d.ts +30 -118
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +70 -206
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +148 -188
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/declared-dependencies.d.ts +66 -26
- package/dist/access/declared-dependencies.d.ts.map +1 -1
- package/dist/access/declared-dependencies.js +67 -17
- package/dist/access/declared-dependencies.js.map +1 -1
- package/dist/access/declared-dependencies.test.d.ts +2 -0
- package/dist/access/declared-dependencies.test.d.ts.map +1 -0
- package/dist/access/declared-dependencies.test.js +226 -0
- package/dist/access/declared-dependencies.test.js.map +1 -0
- package/dist/access/depth-limits.d.ts +8 -7
- package/dist/access/depth-limits.d.ts.map +1 -1
- package/dist/access/depth-limits.js +8 -7
- package/dist/access/depth-limits.js.map +1 -1
- package/dist/access/errors.d.ts +12 -8
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +16 -12
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-visibility.d.ts +2 -1
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +91 -17
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +1 -2
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +1 -1
- package/dist/access/index.js.map +1 -1
- package/dist/access/relationship-count.d.ts +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +38 -27
- package/dist/context/index.js.map +1 -1
- package/dist/query/index.d.ts +29 -0
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js +27 -0
- package/dist/query/index.js.map +1 -1
- package/dist/query/relationship-options.d.ts +1 -1
- package/dist/query/relationship-options.js +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +205 -275
- package/src/access/access-filter.ts +84 -267
- package/src/access/declared-dependencies.test.ts +277 -0
- package/src/access/declared-dependencies.ts +122 -37
- package/src/access/depth-limits.ts +8 -7
- package/src/access/errors.ts +16 -12
- package/src/access/field-visibility.ts +99 -14
- package/src/access/index.ts +1 -7
- package/src/access/relationship-count.ts +1 -1
- package/src/context/index.ts +52 -33
- package/src/query/index.ts +53 -0
- package/src/query/relationship-options.ts +1 -1
- package/tests/access-relationships.test.ts +18 -16
- package/tests/computed-field-selective-evaluation.test.ts +418 -0
- package/tests/context.test.ts +27 -0
- package/tests/needs-declared-dependencies.test.ts +7 -4
- package/tests/resolve-chain.test.ts +11 -11
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -18,199 +18,44 @@ import { AccessScopeDepthExceededError } from './errors.js'
|
|
|
18
18
|
* the fetched row, neither of which is expressible in SQL. See
|
|
19
19
|
* `docs/adr/0001-access-control-is-a-two-phase-read.md` and the access-control
|
|
20
20
|
* glossary in `CONTEXT.md`.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
/** A single relation entry in a Prisma `include` object (see below). */
|
|
24
|
-
type IncludeEntry = boolean | { where?: PrismaFilter; include?: IncludeObject; take?: number }
|
|
25
|
-
type IncludeObject = Record<string, IncludeEntry>
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* The result of trying to compute an access-controlled include for a list's
|
|
29
|
-
* fields. `buildIncludeWithAccessControl` used to collapse three unrelated
|
|
30
|
-
* outcomes into a single overloaded `undefined`: "inside a resolveOutput
|
|
31
|
-
* context", "hit the depth cap", and "no relationships to scope" all looked
|
|
32
|
-
* identical to callers, which is what let a depth-capped relation pass
|
|
33
|
-
* through unscoped (issue #830). This discriminated result keeps them
|
|
34
|
-
* distinguishable all the way to `mergeIncludeWithAccessControl`, which is the
|
|
35
|
-
* only place that knows whether a caller actually asked for the part that
|
|
36
|
-
* couldn't be scoped.
|
|
37
21
|
*
|
|
38
|
-
* - `
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*/
|
|
48
|
-
export type AccessIncludeResult =
|
|
49
|
-
| { kind: 'scoped'; include: RichIncludeObject }
|
|
50
|
-
| { kind: 'nothing-to-scope' }
|
|
51
|
-
| { kind: 'depth-exceeded' }
|
|
52
|
-
|
|
53
|
-
/** A relation entry in the rich, provenance-carrying tree `buildIncludeWithAccessControl` builds internally. */
|
|
54
|
-
type RichIncludeEntry = { where?: PrismaFilter; nested: AccessIncludeResult }
|
|
55
|
-
type RichIncludeObject = Record<string, RichIncludeEntry>
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Collapse a rich, provenance-carrying include entry down to the plain
|
|
59
|
-
* Prisma-shaped form. A `nested` result that is `depth-exceeded` or
|
|
60
|
-
* `nothing-to-scope` contributes no `include` key — this is the AUTO-include
|
|
61
|
-
* silently stopping, which is correct when no caller asked for anything past
|
|
62
|
-
* this point (see `AccessIncludeResult` doc comment).
|
|
22
|
+
* **Caller-directed (ADR-0026).** `buildAccessScopedInclude` walks only the
|
|
23
|
+
* branches `requestedInclude` names — the caller's own `include`, a fragment
|
|
24
|
+
* `query`'s projection, or `foldDeclaredDependencies`'s fold of a field's
|
|
25
|
+
* `needs` (`declared-dependencies.ts`), all resolved before this module ever
|
|
26
|
+
* runs. Naming a relation fetches that relation's own columns and stops
|
|
27
|
+
* (the "One hop" rule, see `CONTEXT.md`); reaching further means the request
|
|
28
|
+
* named a nested `include` there too. A relation nobody named never has its
|
|
29
|
+
* list's `query` access evaluated at all — there is no separate "build the
|
|
30
|
+
* whole tree, then reconcile against what was asked for" pass to walk it.
|
|
63
31
|
*/
|
|
64
|
-
function toPrismaEntry(entry: RichIncludeEntry): IncludeEntry {
|
|
65
|
-
const result: { where?: PrismaFilter; include?: IncludeObject } = {}
|
|
66
|
-
if (entry.where) result.where = entry.where
|
|
67
|
-
if (entry.nested.kind === 'scoped') {
|
|
68
|
-
const nestedInclude = toPrismaInclude(entry.nested)
|
|
69
|
-
if (nestedInclude && Object.keys(nestedInclude).length > 0) {
|
|
70
|
-
result.include = nestedInclude
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
return Object.keys(result).length > 0 ? result : true
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Collapse an `AccessIncludeResult` to the plain Prisma `include` shape used
|
|
78
|
-
* when there is no caller-supplied include to merge against (the direct
|
|
79
|
-
* auto-include path). `nothing-to-scope` and `depth-exceeded` both become
|
|
80
|
-
* `undefined` here — at this call site nothing was explicitly requested past
|
|
81
|
-
* either boundary, so there is nothing to deny.
|
|
82
|
-
*/
|
|
83
|
-
export function toPrismaInclude(result: AccessIncludeResult): IncludeObject | undefined {
|
|
84
|
-
if (result.kind !== 'scoped') return undefined
|
|
85
|
-
const out: IncludeObject = {}
|
|
86
|
-
for (const [key, entry] of Object.entries(result.include)) {
|
|
87
|
-
out[key] = toPrismaEntry(entry)
|
|
88
|
-
}
|
|
89
|
-
return out
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Build the access-controlled include for a list's fields.
|
|
94
|
-
*
|
|
95
|
-
* This allows us to filter relationships at the database level instead of in
|
|
96
|
-
* memory. Returns an {@link AccessIncludeResult} rather than a plain include
|
|
97
|
-
* object so that `mergeIncludeWithAccessControl` can tell a genuine "nothing
|
|
98
|
-
* to scope" apart from "the engine hit its depth cap" (see that type's doc
|
|
99
|
-
* comment and ADR-0022).
|
|
100
|
-
*/
|
|
101
|
-
export async function buildIncludeWithAccessControl(
|
|
102
|
-
fieldConfigs: Record<string, FieldConfig>,
|
|
103
|
-
args: {
|
|
104
|
-
session: Session | null
|
|
105
|
-
context: AccessContext
|
|
106
|
-
},
|
|
107
|
-
config: OpenSaasConfig,
|
|
108
|
-
depth: number = 0,
|
|
109
|
-
// List names already on the path from the root to here. Used to detect
|
|
110
|
-
// relationship cycles (A → B → … → A) and stop the auto-include from
|
|
111
|
-
// re-descending them. Seed it with the root list name at the call site.
|
|
112
|
-
visitedLists: readonly string[] = [],
|
|
113
|
-
): Promise<AccessIncludeResult> {
|
|
114
|
-
if (depth >= READ_INCLUDE_MAX_DEPTH) {
|
|
115
|
-
return { kind: 'depth-exceeded' }
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Inside a resolveOutput/virtual-field context we still scope each immediate
|
|
119
|
-
// relation (its own access `where`), but do not auto-expand INTO its nested
|
|
120
|
-
// relations — this keeps a single hook-issued read's include from growing
|
|
121
|
-
// depth-first. Correction (ADR-0023): this does NOT by itself terminate a
|
|
122
|
-
// cycle, despite what an earlier version of this comment claimed. A
|
|
123
|
-
// self-referential relation read from inside a hook is a NEW top-level
|
|
124
|
-
// read, invisible to this function's own recursion — the loop runs through
|
|
125
|
-
// the hook↔read boundary, not through repeated calls to
|
|
126
|
-
// `buildIncludeWithAccessControl`. Loop termination is the resolve chain's
|
|
127
|
-
// job: `field-visibility.ts`'s `resolveReadableFieldValue` refuses to
|
|
128
|
-
// re-enter a `(list, field)` pair already on the chain. What this check
|
|
129
|
-
// still does correctly is row-scope the immediate relation rather than
|
|
130
|
-
// skipping scoping altogether (issue #830's second trigger).
|
|
131
|
-
const insideResolveOutput = args.context._resolveOutputChain.length > 0
|
|
132
32
|
|
|
133
|
-
|
|
134
|
-
|
|
33
|
+
/** The structured (object) form of a relation include entry — caller/fold-supplied or produced by this module. */
|
|
34
|
+
type IncludeEntryObject = { where?: PrismaFilter; include?: Record<string, unknown>; take?: number }
|
|
135
35
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
|
|
140
|
-
|
|
141
|
-
if (relatedConfig) {
|
|
142
|
-
// Check query access for the related list
|
|
143
|
-
const queryAccess = relatedConfig.listConfig.access?.operation?.query
|
|
144
|
-
const accessResult = await checkAccess(queryAccess, {
|
|
145
|
-
session: args.session,
|
|
146
|
-
context: args.context,
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
// If access is completely denied, exclude this relationship
|
|
150
|
-
if (accessResult === false) {
|
|
151
|
-
continue
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const where = typeof accessResult === 'object' ? accessResult : undefined
|
|
155
|
-
|
|
156
|
-
// Cycle guard: if the related list already appears on the path from the
|
|
157
|
-
// root, DO NOT auto-include its relationships again. On a cyclic
|
|
158
|
-
// readable-relationship graph (A → B → … → A) the depth-first walk would
|
|
159
|
-
// otherwise re-descend the cycle on every branch, and — combined with the
|
|
160
|
-
// bare-`true` leaf re-expansion in `mergeIncludeWithAccessControl` — build
|
|
161
|
-
// an include tree deep/large enough to overflow the call stack in
|
|
162
|
-
// downstream processing (the RSC serializer's recursive `Map.set`). This
|
|
163
|
-
// extends the SF-20 (#566) fix: a bare-`true` leaf must resolve to a
|
|
164
|
-
// genuine single-level fetch, not a re-expansion of the full auto-include.
|
|
165
|
-
// The relation itself is still included (as a FLAT fetch of its own
|
|
166
|
-
// columns); only its onward relationships are pruned at the back-edge.
|
|
167
|
-
let nested: AccessIncludeResult = { kind: 'nothing-to-scope' }
|
|
168
|
-
const relatedListName = relatedConfig.listName
|
|
169
|
-
if (!insideResolveOutput && !visitedLists.includes(relatedListName)) {
|
|
170
|
-
nested = await buildIncludeWithAccessControl(
|
|
171
|
-
relatedConfig.listConfig.fields,
|
|
172
|
-
args,
|
|
173
|
-
config,
|
|
174
|
-
depth + 1,
|
|
175
|
-
[...visitedLists, relatedListName],
|
|
176
|
-
)
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
include[fieldName] = { where, nested }
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
return hasRelationships ? { kind: 'scoped', include } : { kind: 'nothing-to-scope' }
|
|
36
|
+
/** A plain object — excludes `null` and arrays, which `typeof x === 'object'` alone would admit. */
|
|
37
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
38
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
185
39
|
}
|
|
186
40
|
|
|
187
|
-
/** The structured (object) form of a relation include entry. */
|
|
188
|
-
type IncludeEntryObject = { where?: PrismaFilter; include?: IncludeObject; take?: number }
|
|
189
|
-
|
|
190
41
|
/**
|
|
191
42
|
* Narrow an unknown include value to the structured object form (vs bare `true`
|
|
192
43
|
* or any other primitive). Caller-supplied includes arrive untyped at the
|
|
193
|
-
* runtime boundary
|
|
44
|
+
* runtime boundary: narrow to a plain object first, then validate each field's
|
|
45
|
+
* own type before trusting it, rather than casting the whole value wholesale.
|
|
194
46
|
*
|
|
195
47
|
* A numeric `take` on a to-many relation include (a caller-supplied row bound,
|
|
196
48
|
* issue #752) is carried through: it only ever NARROWS the fetched rows and can
|
|
197
|
-
* never widen past the access `where`, so preserving it is access-neutral.
|
|
198
|
-
* access-controlled include never sets `take` itself — it originates solely
|
|
199
|
-
* from the caller — so `mergeIncludeWithAccessControl` re-attaches it below.
|
|
49
|
+
* never widen past the access `where`, so preserving it is access-neutral.
|
|
200
50
|
*/
|
|
201
51
|
function asEntryObject(value: unknown): IncludeEntryObject | null {
|
|
202
|
-
if (value
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (include && typeof include === 'object') entry.include = include as IncludeObject
|
|
210
|
-
if (typeof take === 'number') entry.take = take
|
|
211
|
-
return entry
|
|
212
|
-
}
|
|
213
|
-
return null
|
|
52
|
+
if (!isPlainObject(value)) return null
|
|
53
|
+
const { where, include, take } = value
|
|
54
|
+
const entry: IncludeEntryObject = {}
|
|
55
|
+
if (isPlainObject(where)) entry.where = where
|
|
56
|
+
if (isPlainObject(include)) entry.include = include
|
|
57
|
+
if (typeof take === 'number') entry.take = take
|
|
58
|
+
return entry
|
|
214
59
|
}
|
|
215
60
|
|
|
216
61
|
/**
|
|
@@ -232,128 +77,100 @@ function andWhere(
|
|
|
232
77
|
}
|
|
233
78
|
|
|
234
79
|
/**
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
* The caller's `include` decides WHICH relations to fetch; access control decides
|
|
239
|
-
* WHETHER each relation may be fetched and WITH WHAT filter. Replacing the
|
|
240
|
-
* access-controlled include with the caller's wholesale (the bug in #566) drops
|
|
241
|
-
* every per-relation access `where` and denied-relation exclusion, silently
|
|
242
|
-
* bypassing row-level access on any non-sudo read that passes `include`.
|
|
243
|
-
*
|
|
244
|
-
* For each relation the caller asks to include:
|
|
245
|
-
* - If the relation is a config-declared relationship but is ABSENT from the
|
|
246
|
-
* access-controlled include, its `query` access returned `false` → it is DROPPED
|
|
247
|
-
* (not fetched).
|
|
248
|
-
* - If it is present (allowed, possibly with a filter), the access entry is used
|
|
249
|
-
* as the base: the access `where` is AND-combined with any caller-supplied
|
|
250
|
-
* nested `where`, and nested includes are recursively merged using the related
|
|
251
|
-
* list's field configs (so deeply-nested selections are filtered at every
|
|
252
|
-
* level). A bare caller `true` becomes the access-controlled shape (filter +
|
|
253
|
-
* nested filtered include), never bare `true`.
|
|
254
|
-
* - If the caller names a key that is NOT a config-declared relationship, it is
|
|
255
|
-
* passed through unchanged (access control does not govern it).
|
|
80
|
+
* Build the access-scoped `include` for exactly the relations a read
|
|
81
|
+
* requested, recursing only into branches `requestedInclude` itself names.
|
|
256
82
|
*
|
|
257
|
-
*
|
|
258
|
-
* -
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
* -
|
|
269
|
-
*
|
|
270
|
-
*
|
|
83
|
+
* For each key in `requestedInclude`:
|
|
84
|
+
* - Not a config-declared relationship → access control does not govern it;
|
|
85
|
+
* passed through unchanged (e.g. a fragment/caller key that isn't a
|
|
86
|
+
* relationship at all).
|
|
87
|
+
* - A declared relationship whose related list's `query` access denies it
|
|
88
|
+
* (`=== false`) → dropped entirely, no matter what the request asked for
|
|
89
|
+
* nested beneath it (#566): the caller chooses *which* relations, access
|
|
90
|
+
* control chooses *whether* and *with what filter*.
|
|
91
|
+
* - Otherwise → the access `where` is AND-combined with any caller-supplied
|
|
92
|
+
* nested `where` (never replaced — the other half of #566), a
|
|
93
|
+
* caller-supplied `take` rides through unchanged (#752), and — the "One
|
|
94
|
+
* hop" rule (ADR-0026) — nested relations are scoped ONLY if
|
|
95
|
+
* `requestedInclude` itself named a nested `include` here. A bare relation
|
|
96
|
+
* (or one with no nested `include`) fetches its own columns and stops: no
|
|
97
|
+
* recursive call, no access evaluation on anything beneath it.
|
|
271
98
|
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
99
|
+
* **Depth is a cost limit, not a cycle guard (ADR-0026).** A `requestedInclude`
|
|
100
|
+
* is always a finite literal — the caller's own object, or
|
|
101
|
+
* `foldDeclaredDependencies`'s already-cycle-guarded fold — so this recursion
|
|
102
|
+
* cannot loop unboundedly on its own; nothing here walks the relationship
|
|
103
|
+
* graph unprompted. `READ_INCLUDE_MAX_DEPTH` still bounds how deep a request
|
|
104
|
+
* may reach, fail-closed per ADR-0022: a request naming anything at or past
|
|
105
|
+
* the cap throws `AccessScopeDepthExceededError` rather than silently
|
|
106
|
+
* returning less than what was asked for.
|
|
274
107
|
*/
|
|
275
|
-
export function
|
|
276
|
-
|
|
277
|
-
accessControlledInclude: AccessIncludeResult,
|
|
108
|
+
export async function buildAccessScopedInclude(
|
|
109
|
+
requestedInclude: Record<string, unknown>,
|
|
278
110
|
fieldConfigs: Record<string, FieldConfig>,
|
|
111
|
+
args: {
|
|
112
|
+
session: Session | null
|
|
113
|
+
context: AccessContext
|
|
114
|
+
},
|
|
279
115
|
config: OpenSaasConfig,
|
|
280
116
|
listKey: string,
|
|
281
117
|
depth: number = 0,
|
|
282
|
-
): Record<string, unknown
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
if (accessControlledInclude.kind === 'depth-exceeded') {
|
|
288
|
-
const [firstRelationName] = Object.keys(callerInclude)
|
|
289
|
-
if (firstRelationName !== undefined) {
|
|
290
|
-
throw new AccessScopeDepthExceededError(listKey, firstRelationName, depth)
|
|
291
|
-
}
|
|
292
|
-
return {}
|
|
118
|
+
): Promise<Record<string, unknown>> {
|
|
119
|
+
const requestedKeys = Object.keys(requestedInclude)
|
|
120
|
+
if (depth >= READ_INCLUDE_MAX_DEPTH && requestedKeys.length > 0) {
|
|
121
|
+
throw new AccessScopeDepthExceededError(listKey, requestedKeys[0], depth)
|
|
293
122
|
}
|
|
294
123
|
|
|
295
|
-
const
|
|
296
|
-
const accessInclude = accessControlledInclude.include
|
|
124
|
+
const result: Record<string, unknown> = {}
|
|
297
125
|
|
|
298
|
-
for (const [relationName,
|
|
126
|
+
for (const [relationName, requestedValue] of Object.entries(requestedInclude)) {
|
|
299
127
|
const fieldConfig = fieldConfigs[relationName]
|
|
300
128
|
const isDeclaredRelationship =
|
|
301
129
|
fieldConfig?.type === 'relationship' && 'ref' in fieldConfig && !!fieldConfig.ref
|
|
302
130
|
|
|
303
|
-
// Not a config-declared relationship → access control does not govern it; pass through unchanged.
|
|
304
131
|
if (!isDeclaredRelationship) {
|
|
305
|
-
|
|
132
|
+
result[relationName] = requestedValue
|
|
306
133
|
continue
|
|
307
134
|
}
|
|
308
135
|
|
|
309
|
-
const
|
|
136
|
+
const relatedConfig = getRelatedListConfig(fieldConfig.ref as string, config)
|
|
137
|
+
if (!relatedConfig) continue
|
|
138
|
+
|
|
139
|
+
const queryAccess = relatedConfig.listConfig.access?.operation?.query
|
|
140
|
+
const accessResult = await checkAccess(queryAccess, {
|
|
141
|
+
session: args.session,
|
|
142
|
+
context: args.context,
|
|
143
|
+
})
|
|
310
144
|
|
|
311
|
-
|
|
312
|
-
if (accessEntry === undefined) {
|
|
145
|
+
if (accessResult === false) {
|
|
313
146
|
continue
|
|
314
147
|
}
|
|
315
148
|
|
|
316
|
-
const
|
|
149
|
+
const accessWhere = typeof accessResult === 'object' ? accessResult : undefined
|
|
150
|
+
const requestedEntry = asEntryObject(requestedValue)
|
|
151
|
+
const mergedWhere = andWhere(accessWhere, requestedEntry?.where)
|
|
317
152
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
let mergedNested: Record<string, unknown> | undefined
|
|
325
|
-
if (callerEntry?.include && relatedFields && relatedConfig) {
|
|
326
|
-
// Recurse: scope the caller's nested selection against the nested access
|
|
327
|
-
// result. If that result is 'depth-exceeded', the recursive call itself
|
|
328
|
-
// throws — the caller named something the engine cannot scope.
|
|
329
|
-
mergedNested = mergeIncludeWithAccessControl(
|
|
330
|
-
callerEntry.include,
|
|
331
|
-
accessEntry.nested,
|
|
332
|
-
relatedFields,
|
|
153
|
+
let nestedInclude: Record<string, unknown> | undefined
|
|
154
|
+
if (requestedEntry?.include) {
|
|
155
|
+
nestedInclude = await buildAccessScopedInclude(
|
|
156
|
+
requestedEntry.include,
|
|
157
|
+
relatedConfig.listConfig.fields,
|
|
158
|
+
args,
|
|
333
159
|
config,
|
|
334
160
|
relatedConfig.listName,
|
|
335
161
|
depth + 1,
|
|
336
162
|
)
|
|
337
|
-
} else if (accessEntry.nested.kind === 'scoped') {
|
|
338
|
-
// Caller selected the relation bare (no nested include); keep the
|
|
339
|
-
// access-controlled nested include so deeper relations stay filtered.
|
|
340
|
-
mergedNested = toPrismaInclude(accessEntry.nested)
|
|
341
163
|
}
|
|
342
|
-
// If the caller's entry has no nested include, a 'depth-exceeded' nested
|
|
343
|
-
// result is silent here too — nothing was asked for past this point.
|
|
344
164
|
|
|
345
165
|
const entry: { where?: PrismaFilter; include?: Record<string, unknown>; take?: number } = {}
|
|
346
166
|
if (mergedWhere) entry.where = mergedWhere
|
|
347
|
-
if (
|
|
348
|
-
|
|
349
|
-
// narrows, never widens, so it rides on top of the access `where`/include.
|
|
350
|
-
if (callerEntry?.take !== undefined) entry.take = callerEntry.take
|
|
167
|
+
if (nestedInclude && Object.keys(nestedInclude).length > 0) entry.include = nestedInclude
|
|
168
|
+
if (requestedEntry?.take !== undefined) entry.take = requestedEntry.take
|
|
351
169
|
|
|
352
|
-
|
|
353
|
-
merged[relationName] = Object.keys(entry).length > 0 ? entry : true
|
|
170
|
+
result[relationName] = Object.keys(entry).length > 0 ? entry : true
|
|
354
171
|
}
|
|
355
172
|
|
|
356
|
-
return
|
|
173
|
+
return result
|
|
357
174
|
}
|
|
358
175
|
|
|
359
176
|
/**
|