@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
|
@@ -6,6 +6,7 @@ import { RESOLVE_CHAIN_MAX_LENGTH } from './depth-limits.js'
|
|
|
6
6
|
import { ResolveOutputCycleError } from './errors.js'
|
|
7
7
|
import type { DeclaredOnlyTree } from './declared-dependencies.js'
|
|
8
8
|
import { emptyDeclaredOnlyTree } from './declared-dependencies.js'
|
|
9
|
+
import type { FieldSelectionScope } from '../query/index.js'
|
|
9
10
|
// NOTE: `context/index.ts` imports `filterReadableFields` from this module
|
|
10
11
|
// (via the `access/index.ts` barrel) — this is an intentional cyclic
|
|
11
12
|
// dependency, the same shape and for the same reason as the one documented in
|
|
@@ -23,6 +24,14 @@ import { buildDbDelegate } from '../context/index.js'
|
|
|
23
24
|
* virtual fields. None of this can move into phase 1: virtual fields are
|
|
24
25
|
* computed in JavaScript and field access can depend on the fetched row.
|
|
25
26
|
*
|
|
27
|
+
* A computed field — any field carrying a `resolveOutput` hook, virtual or
|
|
28
|
+
* not — is produced only where the read is going to return it (ADR-0027). A
|
|
29
|
+
* fragment `query`'s own field selection is the only thing that restricts a
|
|
30
|
+
* level this way; a bare or `include`-based read, and any relation reached
|
|
31
|
+
* purely to satisfy a `needs` declaration, still compute every field, as
|
|
32
|
+
* before. A field the read is not going to return does no work at all —
|
|
33
|
+
* neither its read-access evaluation nor its hook.
|
|
34
|
+
*
|
|
26
35
|
* Phase 1 (pre-query row/relation scoping) lives in `access-filter.ts`. See
|
|
27
36
|
* `docs/adr/0001-access-control-is-a-two-phase-read.md` and the access-control
|
|
28
37
|
* glossary in `CONTEXT.md`.
|
|
@@ -96,8 +105,11 @@ function deriveResolveOutputContext(
|
|
|
96
105
|
* from the result.
|
|
97
106
|
*
|
|
98
107
|
* `accessItem` is the row used to evaluate field access; `hookItem` is the
|
|
99
|
-
* object passed to the hook as `item
|
|
100
|
-
*
|
|
108
|
+
* object passed to the hook as `item`. For a stored field, both are
|
|
109
|
+
* `workingItem` (the row's own stored/fetched columns). For a virtual field,
|
|
110
|
+
* `hookItem` is `computedFieldItem` instead — the same stored columns with
|
|
111
|
+
* every skipped-or-denied key removed — so it never sees another computed
|
|
112
|
+
* field's resolved value (ADR-0027).
|
|
101
113
|
*/
|
|
102
114
|
async function resolveReadableFieldValue(params: {
|
|
103
115
|
fieldConfig: FieldConfig | undefined
|
|
@@ -189,6 +201,13 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
189
201
|
// returned — after resolveOutput has had a chance to read them — so a
|
|
190
202
|
// declared dependency never widens what the caller receives.
|
|
191
203
|
declaredOnly: DeclaredOnlyTree = emptyDeclaredOnlyTree(),
|
|
204
|
+
// The fragment scope this level was reached under (ADR-0027), and the same
|
|
205
|
+
// tree one level down for each nested relation. `undefined` — the default,
|
|
206
|
+
// and what a bare/`include`-based read passes at every level — means
|
|
207
|
+
// unrestricted: every field on the list is computed, unchanged from
|
|
208
|
+
// before ADR-0027. Only a `query` fragment's own field selection ever
|
|
209
|
+
// restricts a level.
|
|
210
|
+
selection?: FieldSelectionScope,
|
|
192
211
|
): Promise<Partial<T>> {
|
|
193
212
|
const filtered: Record<string, unknown> = {}
|
|
194
213
|
|
|
@@ -214,6 +233,15 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
214
233
|
workingItem[fieldName] = assembled
|
|
215
234
|
}
|
|
216
235
|
|
|
236
|
+
// Keys denied by field-level read access during the pass below — as opposed
|
|
237
|
+
// to a key merely skipped by `selection` or held back only for
|
|
238
|
+
// `declaredOnly` stripping. Tracked separately because a denied key must
|
|
239
|
+
// stay invisible to a computed field's hook (below), while a declared-only
|
|
240
|
+
// key must stay VISIBLE to one — that is the entire point of declaring it
|
|
241
|
+
// (ADR-0025) — even though `selection` above skipped adding it to
|
|
242
|
+
// `filtered` because the caller's fragment never asked for it.
|
|
243
|
+
const accessDeniedKeys = new Set<string>()
|
|
244
|
+
|
|
217
245
|
// Process existing fields from the database result
|
|
218
246
|
for (const [fieldName, value] of Object.entries(workingItem)) {
|
|
219
247
|
const fieldConfig = fieldConfigs[fieldName]
|
|
@@ -224,8 +252,19 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
224
252
|
continue
|
|
225
253
|
}
|
|
226
254
|
|
|
255
|
+
// Projection-aware skip (ADR-0027): a fragment read that does not select
|
|
256
|
+
// this field does no work for it at all — no field-level read-access
|
|
257
|
+
// check, no resolveOutput, no recursion into a relation — because the
|
|
258
|
+
// read is never going to return it. `selection` is only ever restricted
|
|
259
|
+
// by a fragment's own field selection; a bare/`include`-based read, and a
|
|
260
|
+
// relation reached only to satisfy another field's `needs`, pass no
|
|
261
|
+
// selection at all and compute every field here, unchanged.
|
|
262
|
+
if (selection?.fields && !selection.fields.has(fieldName)) {
|
|
263
|
+
continue
|
|
264
|
+
}
|
|
265
|
+
|
|
227
266
|
// Handle relationship fields - recursively filter fields within related items
|
|
228
|
-
// Note: Access control filtering is now done at database level via
|
|
267
|
+
// Note: Access control filtering is now done at database level via buildAccessScopedInclude
|
|
229
268
|
// This only handles field-level access (hiding sensitive fields)
|
|
230
269
|
//
|
|
231
270
|
// Deliberately uncapped: the row/relation scoping in access-filter.ts bounds
|
|
@@ -251,6 +290,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
251
290
|
})
|
|
252
291
|
|
|
253
292
|
if (!canRead) {
|
|
293
|
+
accessDeniedKeys.add(fieldName)
|
|
254
294
|
continue
|
|
255
295
|
}
|
|
256
296
|
|
|
@@ -260,6 +300,12 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
260
300
|
// back to an empty tree when this relation isn't declaration-related at
|
|
261
301
|
// all — the common case.
|
|
262
302
|
const nestedDeclaredOnly = declaredOnly.nested[fieldName] ?? emptyDeclaredOnlyTree()
|
|
303
|
+
// This relation's own fragment scope, if the caller's fragment named it
|
|
304
|
+
// with a nested Fragment/RelationSelector. `undefined` (a bare `true`
|
|
305
|
+
// selector, or no `selection` at all) means the nested list computes
|
|
306
|
+
// unrestricted — matching what naming a relation without narrowing it
|
|
307
|
+
// further has always meant.
|
|
308
|
+
const nestedSelection = selection?.nested[fieldName]
|
|
263
309
|
|
|
264
310
|
if (relatedConfig) {
|
|
265
311
|
// For many relationships (arrays) - recursively filter fields in each item
|
|
@@ -275,6 +321,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
275
321
|
depth + 1,
|
|
276
322
|
relatedConfig.listName,
|
|
277
323
|
nestedDeclaredOnly,
|
|
324
|
+
nestedSelection,
|
|
278
325
|
),
|
|
279
326
|
),
|
|
280
327
|
)
|
|
@@ -290,6 +337,7 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
290
337
|
depth + 1,
|
|
291
338
|
relatedConfig.listName,
|
|
292
339
|
nestedDeclaredOnly,
|
|
340
|
+
nestedSelection,
|
|
293
341
|
)
|
|
294
342
|
}
|
|
295
343
|
} else {
|
|
@@ -314,7 +362,36 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
314
362
|
|
|
315
363
|
if (result.readable) {
|
|
316
364
|
filtered[fieldName] = result.value
|
|
365
|
+
} else {
|
|
366
|
+
accessDeniedKeys.add(fieldName)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// The item a virtual field's hook sees: stored columns and fetched
|
|
371
|
+
// relations (from `workingItem`, never a resolved value — no hook's output
|
|
372
|
+
// is ever written back into `workingItem`). A key is visible here if it
|
|
373
|
+
// either survived into `filtered` (selected and allowed) OR exists only to
|
|
374
|
+
// satisfy a `needs` declaration (`declaredOnly` — that IS the point of
|
|
375
|
+
// declaring it: fetched for a hook, never for the caller, ADR-0025).
|
|
376
|
+
// Everything else — field-level denied, or skipped by `selection` and
|
|
377
|
+
// declared by no one — is deleted. A computed field reaches for exactly its
|
|
378
|
+
// own declared dependencies and nothing another field's hook produced
|
|
379
|
+
// (ADR-0027): reaching for a sibling that was denied or skipped-and-
|
|
380
|
+
// undeclared finds nothing there, the same as reaching for one never
|
|
381
|
+
// declared at all, and reaching for a sibling that DID survive finds its
|
|
382
|
+
// raw stored form, never another hook's resolved value — a virtual field
|
|
383
|
+
// computed earlier in declaration order is exactly as invisible as one
|
|
384
|
+
// computed later.
|
|
385
|
+
const computedFieldItem: Record<string, unknown> = { ...workingItem }
|
|
386
|
+
for (const key of Object.keys(workingItem)) {
|
|
387
|
+
if (['id', 'createdAt', 'updatedAt'].includes(key)) continue
|
|
388
|
+
if (accessDeniedKeys.has(key)) {
|
|
389
|
+
delete computedFieldItem[key]
|
|
390
|
+
continue
|
|
317
391
|
}
|
|
392
|
+
if (key in filtered) continue
|
|
393
|
+
if (declaredOnly.keys.has(key)) continue
|
|
394
|
+
delete computedFieldItem[key]
|
|
318
395
|
}
|
|
319
396
|
|
|
320
397
|
// Process virtual fields - compute values from other fields
|
|
@@ -330,22 +407,29 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
330
407
|
continue
|
|
331
408
|
}
|
|
332
409
|
|
|
333
|
-
//
|
|
334
|
-
//
|
|
410
|
+
// Projection-aware skip (ADR-0027): same rule as the stored-field pass
|
|
411
|
+
// above — a fragment that does not select this virtual field does no
|
|
412
|
+
// work for it at all.
|
|
413
|
+
if (selection?.fields && !selection.fields.has(fieldName)) {
|
|
414
|
+
continue
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// A virtual field with no resolveOutput hook can never produce a value
|
|
418
|
+
// on ANY read — there is nothing to compute, so there is nothing to do,
|
|
419
|
+
// including evaluating its read access (ADR-0027 reconciles the
|
|
420
|
+
// access-only evaluation this branch used to preserve: a field that
|
|
421
|
+
// never has output has no side effect worth preserving access for).
|
|
335
422
|
if (!(fieldConfig.hooks?.resolveOutput && listKey)) {
|
|
336
|
-
// Still evaluate read access to preserve any access-fn side effects.
|
|
337
|
-
await checkFieldAccess(fieldConfig.access, 'read', { ...args, item: workingItem })
|
|
338
423
|
continue
|
|
339
424
|
}
|
|
340
425
|
|
|
341
|
-
// Check read access and compute the value via the shared helper.
|
|
342
|
-
// fields see the already-filtered item so they can read sibling fields.
|
|
426
|
+
// Check read access and compute the value via the shared helper.
|
|
343
427
|
const result = await resolveReadableFieldValue({
|
|
344
428
|
fieldConfig,
|
|
345
429
|
fieldName,
|
|
346
430
|
value: undefined, // Virtual fields don't have a database value
|
|
347
431
|
accessItem: workingItem,
|
|
348
|
-
hookItem:
|
|
432
|
+
hookItem: computedFieldItem,
|
|
349
433
|
listKey,
|
|
350
434
|
args,
|
|
351
435
|
config,
|
|
@@ -357,10 +441,11 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
|
357
441
|
}
|
|
358
442
|
|
|
359
443
|
// Strip relations that were fetched ONLY to satisfy a `needs` declaration
|
|
360
|
-
// (ADR-0025), now that every resolveOutput hook at this level
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
444
|
+
// (ADR-0025), now that every resolveOutput hook at this level has had the
|
|
445
|
+
// chance to see them (via `computedFieldItem`, never `filtered` itself — a
|
|
446
|
+
// declared dependency is read from stored columns, not from another
|
|
447
|
+
// field's resolved output). A declared dependency is private plumbing, not
|
|
448
|
+
// an implicit `include`: it never widens what the caller receives.
|
|
364
449
|
for (const key of declaredOnly.keys) {
|
|
365
450
|
delete filtered[key]
|
|
366
451
|
}
|
package/src/access/index.ts
CHANGED
|
@@ -22,13 +22,7 @@ export {
|
|
|
22
22
|
// Canonical field-level access evaluation (shared by read and write paths).
|
|
23
23
|
export { checkFieldAccess, filterWritableFields } from './field-access.js'
|
|
24
24
|
// Phase 1 — Access Filter (pre-query row/relation scoping).
|
|
25
|
-
export {
|
|
26
|
-
buildIncludeWithAccessControl,
|
|
27
|
-
mergeIncludeWithAccessControl,
|
|
28
|
-
stripVirtualFieldsFromInclude,
|
|
29
|
-
toPrismaInclude,
|
|
30
|
-
} from './access-filter.js'
|
|
31
|
-
export type { AccessIncludeResult } from './access-filter.js'
|
|
25
|
+
export { buildAccessScopedInclude, stripVirtualFieldsFromInclude } from './access-filter.js'
|
|
32
26
|
// Phase 2 — Field Visibility (post-query field stripping + resolveOutput).
|
|
33
27
|
export { filterReadableFields } from './field-visibility.js'
|
|
34
28
|
// Declared Dependencies — folding `needs` into an include without widening
|
|
@@ -15,7 +15,7 @@ import { getDbKey } from '../lib/case-utils.js'
|
|
|
15
15
|
* per-row query is issued and the count can never include rows the session
|
|
16
16
|
* cannot read. This module is the single place the related list's
|
|
17
17
|
* operation-level `query` access is folded into that `_count`, mirroring how
|
|
18
|
-
* `
|
|
18
|
+
* `buildAccessScopedInclude` folds it into relation includes.
|
|
19
19
|
*
|
|
20
20
|
* It also resolves the count Filter spec's markers: Prisma cannot compare a
|
|
21
21
|
* relation count in a `where`, so a to-many relationship's Filter spec emits a
|
package/src/context/index.ts
CHANGED
|
@@ -4,8 +4,7 @@ import {
|
|
|
4
4
|
checkAccess,
|
|
5
5
|
mergeFilters,
|
|
6
6
|
filterReadableFields,
|
|
7
|
-
|
|
8
|
-
mergeIncludeWithAccessControl,
|
|
7
|
+
buildAccessScopedInclude,
|
|
9
8
|
stripVirtualFieldsFromInclude,
|
|
10
9
|
foldDeclaredDependencies,
|
|
11
10
|
} from '../access/index.js'
|
|
@@ -13,8 +12,8 @@ import type { DeclaredOnlyTree } from '../access/index.js'
|
|
|
13
12
|
import { ValidationError, DatabaseError } from '../hooks/index.js'
|
|
14
13
|
import { getDbKey } from '../lib/case-utils.js'
|
|
15
14
|
import type { PrismaClientLike } from '../access/types.js'
|
|
16
|
-
import { buildInclude, pickFields, isFragment } from '../query/index.js'
|
|
17
|
-
import type { FieldSelection } from '../query/index.js'
|
|
15
|
+
import { buildInclude, pickFields, isFragment, buildFieldSelectionScope } from '../query/index.js'
|
|
16
|
+
import type { FieldSelection, FieldSelectionScope } from '../query/index.js'
|
|
18
17
|
import { getRelationshipOptions } from '../query/relationship-options.js'
|
|
19
18
|
import {
|
|
20
19
|
runWritePipeline,
|
|
@@ -921,11 +920,19 @@ export function buildDbDelegate<TPrisma extends PrismaClientLike>(
|
|
|
921
920
|
*
|
|
922
921
|
* A fragment's own `include` and a sudo caller's `include` are folded and
|
|
923
922
|
* used as-is, matching their existing (unmerged) treatment. A non-sudo
|
|
924
|
-
* caller include is folded and then
|
|
925
|
-
*
|
|
926
|
-
*
|
|
927
|
-
*
|
|
928
|
-
*
|
|
923
|
+
* caller include is folded and then scoped by `buildAccessScopedInclude`
|
|
924
|
+
* (ADR-0026) — caller-directed, so a relation named nowhere in the folded
|
|
925
|
+
* tree never has its list's `query` access evaluated at all. A bare read
|
|
926
|
+
* stays on the exact ADR-0024 path — `include: undefined`, no related
|
|
927
|
+
* `query` access evaluated — unless folding actually added something, which
|
|
928
|
+
* only happens when a field on this list declares `needs`.
|
|
929
|
+
*
|
|
930
|
+
* Also returns the `FieldSelectionScope` a fragment's own field selection
|
|
931
|
+
* produces (ADR-0027), so the caller can pass it to `filterReadableFields`
|
|
932
|
+
* and make computation itself projection-aware, not only the fold above.
|
|
933
|
+
* `undefined` for every non-fragment path: a caller `include` (sudo or not)
|
|
934
|
+
* and a bare read both mean "compute every field," matching what they
|
|
935
|
+
* already fetch.
|
|
929
936
|
*/
|
|
930
937
|
async function resolveReadInclude(
|
|
931
938
|
callerInclude: Record<string, unknown> | undefined,
|
|
@@ -935,38 +942,43 @@ async function resolveReadInclude(
|
|
|
935
942
|
listConfig: ListConfig<any>,
|
|
936
943
|
context: AccessContext & { _isSudo?: boolean },
|
|
937
944
|
config: OpenSaasConfig,
|
|
938
|
-
): Promise<{
|
|
945
|
+
): Promise<{
|
|
946
|
+
include: Record<string, unknown> | undefined
|
|
947
|
+
declaredOnly: DeclaredOnlyTree
|
|
948
|
+
selection: FieldSelectionScope | undefined
|
|
949
|
+
}> {
|
|
939
950
|
if (fragmentFields !== undefined) {
|
|
940
951
|
const fragmentInclude = buildInclude(fragmentFields) ?? undefined
|
|
941
|
-
|
|
952
|
+
const selection = buildFieldSelectionScope(fragmentFields)
|
|
953
|
+
const folded = foldDeclaredDependencies(
|
|
954
|
+
fragmentInclude,
|
|
955
|
+
listConfig.fields,
|
|
956
|
+
config,
|
|
957
|
+
listName,
|
|
958
|
+
[listName],
|
|
959
|
+
selection,
|
|
960
|
+
)
|
|
961
|
+
return { ...folded, selection }
|
|
942
962
|
}
|
|
943
963
|
|
|
944
964
|
if (context._isSudo) {
|
|
945
|
-
|
|
965
|
+
const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config, listName)
|
|
966
|
+
return { ...folded, selection: undefined }
|
|
946
967
|
}
|
|
947
968
|
|
|
948
|
-
const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config)
|
|
969
|
+
const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config, listName)
|
|
949
970
|
if (!folded.include) {
|
|
950
|
-
return folded
|
|
971
|
+
return { ...folded, selection: undefined }
|
|
951
972
|
}
|
|
952
973
|
|
|
953
|
-
const
|
|
954
|
-
listConfig.fields,
|
|
955
|
-
{ session: context.session, context },
|
|
956
|
-
config,
|
|
957
|
-
0,
|
|
958
|
-
// Seed the cycle guard with the root list so a relationship cycle back
|
|
959
|
-
// to it (self-referential or longer) stops re-descending.
|
|
960
|
-
[listName],
|
|
961
|
-
)
|
|
962
|
-
const include = mergeIncludeWithAccessControl(
|
|
974
|
+
const include = await buildAccessScopedInclude(
|
|
963
975
|
folded.include,
|
|
964
|
-
accessControlledInclude,
|
|
965
976
|
listConfig.fields,
|
|
977
|
+
{ session: context.session, context },
|
|
966
978
|
config,
|
|
967
979
|
listName,
|
|
968
980
|
)
|
|
969
|
-
return { include, declaredOnly: folded.declaredOnly }
|
|
981
|
+
return { include, declaredOnly: folded.declaredOnly, selection: undefined }
|
|
970
982
|
}
|
|
971
983
|
|
|
972
984
|
/**
|
|
@@ -1032,7 +1044,7 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
1032
1044
|
// Resolve `include`, folding any declared dependencies (`needs`,
|
|
1033
1045
|
// ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
|
|
1034
1046
|
// already produces — see `resolveReadInclude`'s doc comment.
|
|
1035
|
-
let { include, declaredOnly } = await resolveReadInclude(
|
|
1047
|
+
let { include, declaredOnly, selection } = await resolveReadInclude(
|
|
1036
1048
|
args.include,
|
|
1037
1049
|
fragment ? fragment._fields : undefined,
|
|
1038
1050
|
listName,
|
|
@@ -1044,8 +1056,10 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
1044
1056
|
// Virtual fields have no database column. Whichever path produced
|
|
1045
1057
|
// `include` (fragment, access-controlled merge, or sudo passthrough), a
|
|
1046
1058
|
// virtual key must never reach Prisma — it would throw "Unknown field"
|
|
1047
|
-
// (#628).
|
|
1048
|
-
// `
|
|
1059
|
+
// (#628). Below, `filterReadableFields` computes a virtual field's value
|
|
1060
|
+
// exactly when `selection` says the read is going to return it (ADR-0027)
|
|
1061
|
+
// — every one of them for a bare/`include`-based read (`selection` is
|
|
1062
|
+
// `undefined`), only the ones a fragment named otherwise.
|
|
1049
1063
|
include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
|
|
1050
1064
|
|
|
1051
1065
|
// Execute query with optimized includes
|
|
@@ -1074,6 +1088,7 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
1074
1088
|
0,
|
|
1075
1089
|
listName,
|
|
1076
1090
|
declaredOnly,
|
|
1091
|
+
selection,
|
|
1077
1092
|
)
|
|
1078
1093
|
|
|
1079
1094
|
// When a fragment is provided, pick only the requested fields from the result
|
|
@@ -1145,7 +1160,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
1145
1160
|
// Resolve `include`, folding any declared dependencies (`needs`,
|
|
1146
1161
|
// ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
|
|
1147
1162
|
// already produces — see `resolveReadInclude`'s doc comment.
|
|
1148
|
-
let { include, declaredOnly } = await resolveReadInclude(
|
|
1163
|
+
let { include, declaredOnly, selection } = await resolveReadInclude(
|
|
1149
1164
|
args?.include,
|
|
1150
1165
|
fragment ? fragment._fields : undefined,
|
|
1151
1166
|
listName,
|
|
@@ -1157,8 +1172,10 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
1157
1172
|
// Virtual fields have no database column. Whichever path produced
|
|
1158
1173
|
// `include` (fragment, access-controlled merge, or sudo passthrough), a
|
|
1159
1174
|
// virtual key must never reach Prisma — it would throw "Unknown field"
|
|
1160
|
-
// (#628).
|
|
1161
|
-
// `
|
|
1175
|
+
// (#628). Below, `filterReadableFields` computes a virtual field's value
|
|
1176
|
+
// exactly when `selection` says the read is going to return it (ADR-0027)
|
|
1177
|
+
// — every one of them for a bare/`include`-based read (`selection` is
|
|
1178
|
+
// `undefined`), only the ones a fragment named otherwise.
|
|
1162
1179
|
include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
|
|
1163
1180
|
|
|
1164
1181
|
// Execute query with optimized includes
|
|
@@ -1188,6 +1205,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
1188
1205
|
0,
|
|
1189
1206
|
listName,
|
|
1190
1207
|
declaredOnly,
|
|
1208
|
+
selection,
|
|
1191
1209
|
),
|
|
1192
1210
|
),
|
|
1193
1211
|
)
|
|
@@ -1461,7 +1479,7 @@ function createGet<TPrisma extends PrismaClientLike>(
|
|
|
1461
1479
|
// Resolve `include`, folding any declared dependencies (`needs`,
|
|
1462
1480
|
// ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
|
|
1463
1481
|
// already produces — see `resolveReadInclude`'s doc comment.
|
|
1464
|
-
let { include, declaredOnly } = await resolveReadInclude(
|
|
1482
|
+
let { include, declaredOnly, selection } = await resolveReadInclude(
|
|
1465
1483
|
args?.include,
|
|
1466
1484
|
fragment ? fragment._fields : undefined,
|
|
1467
1485
|
listName,
|
|
@@ -1493,6 +1511,7 @@ function createGet<TPrisma extends PrismaClientLike>(
|
|
|
1493
1511
|
0,
|
|
1494
1512
|
listName,
|
|
1495
1513
|
declaredOnly,
|
|
1514
|
+
selection,
|
|
1496
1515
|
)
|
|
1497
1516
|
// When a fragment is provided, pick only the requested fields from the result
|
|
1498
1517
|
if (fragment) {
|
package/src/query/index.ts
CHANGED
|
@@ -345,6 +345,59 @@ export function buildInclude(fields: FieldSelection<unknown>): Record<string, un
|
|
|
345
345
|
return hasIncludes ? include : undefined
|
|
346
346
|
}
|
|
347
347
|
|
|
348
|
+
/**
|
|
349
|
+
* A snapshot of which field names a fragment selects at one nesting level,
|
|
350
|
+
* plus the same tree one level down for every relation selected via a nested
|
|
351
|
+
* Fragment/RelationSelector. `fields: undefined` means "unrestricted" — every
|
|
352
|
+
* field at this level is going to be returned, which is what a bare or
|
|
353
|
+
* `include`-based read means for the whole tree (only a `query` fragment ever
|
|
354
|
+
* produces a restricted scope, and only as deep as it names).
|
|
355
|
+
*
|
|
356
|
+
* Used to make computed-field evaluation (`filterReadableFields`) and
|
|
357
|
+
* declared-dependency folding (`foldDeclaredDependencies`) projection-aware
|
|
358
|
+
* (ADR-0027): a field not named by the scope at its level is never computed
|
|
359
|
+
* and its `needs` are never folded, because the read is never going to
|
|
360
|
+
* return it.
|
|
361
|
+
* @internal
|
|
362
|
+
*/
|
|
363
|
+
export type FieldSelectionScope = {
|
|
364
|
+
readonly fields: ReadonlySet<string> | undefined
|
|
365
|
+
readonly nested: Readonly<Record<string, FieldSelectionScope>>
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Build the `FieldSelectionScope` for one fragment's field selection,
|
|
370
|
+
* recursing into nested Fragment/RelationSelector entries the same way
|
|
371
|
+
* `buildInclude` does. A relation named with the bare `true` shorthand (no
|
|
372
|
+
* narrower nested Fragment) gets no entry in `nested`, so a level reached
|
|
373
|
+
* through it is treated as unrestricted — the caller asked for "everything"
|
|
374
|
+
* there and gave no narrower shape to restrict it with.
|
|
375
|
+
* @internal
|
|
376
|
+
*/
|
|
377
|
+
export function buildFieldSelectionScope(fields: FieldSelection<unknown>): FieldSelectionScope {
|
|
378
|
+
const fieldNames = new Set(Object.keys(fields as Record<string, unknown>))
|
|
379
|
+
const nested: Record<string, FieldSelectionScope> = {}
|
|
380
|
+
|
|
381
|
+
for (const [key, value] of Object.entries(fields as Record<string, unknown>)) {
|
|
382
|
+
if (value === null || value === true || typeof value !== 'object') continue
|
|
383
|
+
const val = value as Record<string, unknown>
|
|
384
|
+
|
|
385
|
+
if (isFragment(val)) {
|
|
386
|
+
nested[key] = buildFieldSelectionScope(val._fields as FieldSelection<unknown>)
|
|
387
|
+
continue
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if ('query' in val && isFragment(val.query)) {
|
|
391
|
+
nested[key] = buildFieldSelectionScope(
|
|
392
|
+
(val.query as Fragment<unknown, FieldSelection<unknown>>)
|
|
393
|
+
._fields as FieldSelection<unknown>,
|
|
394
|
+
)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return { fields: fieldNames, nested }
|
|
399
|
+
}
|
|
400
|
+
|
|
348
401
|
/**
|
|
349
402
|
* Recursively pick only the fields requested by a fragment from a raw Prisma
|
|
350
403
|
* result object. This ensures the runtime shape exactly matches the type
|
|
@@ -23,7 +23,7 @@ export interface RelationshipOptionsArgs {
|
|
|
23
23
|
* editor — the read primitive behind the `relationshipOptions` serverAction
|
|
24
24
|
* op. Selects only `id` and the resolved label field (via
|
|
25
25
|
* {@link getLabelFieldName}), so the fragment carries no relation keys and
|
|
26
|
-
* `
|
|
26
|
+
* `buildAccessScopedInclude` never has anything to scope.
|
|
27
27
|
*
|
|
28
28
|
* Operation-level `query` access on `relatedListKey` still applies — a denied
|
|
29
29
|
* list resolves to `[]` (via the underlying access-controlled `findMany`).
|
|
@@ -123,7 +123,7 @@ describe('Relationship Access Control', () => {
|
|
|
123
123
|
expect(result.author?.name).toBe('John Doe')
|
|
124
124
|
})
|
|
125
125
|
|
|
126
|
-
it('should filter out single relationship when access denied (via
|
|
126
|
+
it('should filter out single relationship when access denied (via buildAccessScopedInclude)', async () => {
|
|
127
127
|
const config: OpenSaasConfig = {
|
|
128
128
|
db: {
|
|
129
129
|
provider: 'postgresql',
|
|
@@ -152,16 +152,18 @@ describe('Relationship Access Control', () => {
|
|
|
152
152
|
},
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
// Test that
|
|
156
|
-
const {
|
|
155
|
+
// Test that buildAccessScopedInclude excludes the denied relationship
|
|
156
|
+
const { buildAccessScopedInclude } = await import('../src/access/index.js')
|
|
157
157
|
|
|
158
|
-
const include = await
|
|
158
|
+
const include = await buildAccessScopedInclude(
|
|
159
|
+
{ author: true },
|
|
159
160
|
config.lists.Post.fields,
|
|
160
161
|
{
|
|
161
162
|
session: null,
|
|
162
163
|
context: mockContext,
|
|
163
164
|
},
|
|
164
165
|
config,
|
|
166
|
+
'Post',
|
|
165
167
|
)
|
|
166
168
|
|
|
167
169
|
// When access is denied, the relationship should not be included
|
|
@@ -286,7 +288,7 @@ describe('Relationship Access Control', () => {
|
|
|
286
288
|
expect(result.posts?.[1].title).toBe('Post 2')
|
|
287
289
|
})
|
|
288
290
|
|
|
289
|
-
it('should filter items in many relationships based on query access (via
|
|
291
|
+
it('should filter items in many relationships based on query access (via buildAccessScopedInclude)', async () => {
|
|
290
292
|
const config: OpenSaasConfig = {
|
|
291
293
|
db: {
|
|
292
294
|
provider: 'postgresql',
|
|
@@ -318,19 +320,19 @@ describe('Relationship Access Control', () => {
|
|
|
318
320
|
},
|
|
319
321
|
}
|
|
320
322
|
|
|
321
|
-
// Test that
|
|
322
|
-
const {
|
|
323
|
-
await import('../src/access/index.js')
|
|
323
|
+
// Test that buildAccessScopedInclude creates the right where clause
|
|
324
|
+
const { buildAccessScopedInclude } = await import('../src/access/index.js')
|
|
324
325
|
|
|
325
|
-
const
|
|
326
|
+
const include = await buildAccessScopedInclude(
|
|
327
|
+
{ posts: true },
|
|
326
328
|
config.lists.User.fields,
|
|
327
329
|
{
|
|
328
330
|
session: null,
|
|
329
331
|
context: mockContext,
|
|
330
332
|
},
|
|
331
333
|
config,
|
|
334
|
+
'User',
|
|
332
335
|
)
|
|
333
|
-
const include = toPrismaInclude(result)
|
|
334
336
|
|
|
335
337
|
// Should include posts with a where filter
|
|
336
338
|
expect(include).toBeDefined()
|
|
@@ -452,7 +454,7 @@ describe('Relationship Access Control', () => {
|
|
|
452
454
|
})
|
|
453
455
|
|
|
454
456
|
describe('session-based access for relationships', () => {
|
|
455
|
-
it('should apply session-based access to relationships (via
|
|
457
|
+
it('should apply session-based access to relationships (via buildAccessScopedInclude)', async () => {
|
|
456
458
|
const config: OpenSaasConfig = {
|
|
457
459
|
db: {
|
|
458
460
|
provider: 'postgresql',
|
|
@@ -487,19 +489,19 @@ describe('Relationship Access Control', () => {
|
|
|
487
489
|
},
|
|
488
490
|
}
|
|
489
491
|
|
|
490
|
-
// Test that
|
|
491
|
-
const {
|
|
492
|
-
await import('../src/access/index.js')
|
|
492
|
+
// Test that buildAccessScopedInclude creates session-based where clause
|
|
493
|
+
const { buildAccessScopedInclude } = await import('../src/access/index.js')
|
|
493
494
|
|
|
494
|
-
const
|
|
495
|
+
const include = await buildAccessScopedInclude(
|
|
496
|
+
{ posts: true },
|
|
495
497
|
config.lists.User.fields,
|
|
496
498
|
{
|
|
497
499
|
session: { userId: '1' },
|
|
498
500
|
context: mockContext,
|
|
499
501
|
},
|
|
500
502
|
config,
|
|
503
|
+
'User',
|
|
501
504
|
)
|
|
502
|
-
const include = toPrismaInclude(result)
|
|
503
505
|
|
|
504
506
|
// Should include posts with session-based where filter
|
|
505
507
|
expect(include).toBeDefined()
|