@opensaas/stack-core 0.39.2 → 0.41.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 +189 -0
- package/CLAUDE.md +103 -0
- package/dist/access/access-filter.d.ts +157 -14
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +470 -42
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +982 -17
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/denied-relation-visibility.test.d.ts +2 -0
- package/dist/access/denied-relation-visibility.test.d.ts.map +1 -0
- package/dist/access/denied-relation-visibility.test.js +114 -0
- package/dist/access/denied-relation-visibility.test.js.map +1 -0
- package/dist/access/engine.d.ts +54 -1
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +84 -0
- package/dist/access/engine.js.map +1 -1
- package/dist/access/engine.test.d.ts +2 -0
- package/dist/access/engine.test.d.ts.map +1 -0
- package/dist/access/engine.test.js +56 -0
- package/dist/access/engine.test.js.map +1 -0
- package/dist/access/errors.d.ts +62 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +85 -2
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-access.d.ts +12 -0
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +22 -2
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +66 -0
- package/dist/access/field-access.test.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 +107 -15
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +9 -2
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +11 -2
- package/dist/access/index.js.map +1 -1
- package/dist/access/multi-column-read-write.test.js.map +1 -1
- package/dist/access/query-validation.d.ts +31 -0
- package/dist/access/query-validation.d.ts.map +1 -1
- package/dist/access/query-validation.js +75 -16
- package/dist/access/query-validation.js.map +1 -1
- package/dist/access/relationship-count.d.ts +41 -3
- package/dist/access/relationship-count.d.ts.map +1 -1
- package/dist/access/relationship-count.js +44 -7
- package/dist/access/relationship-count.js.map +1 -1
- package/dist/access/relationship-count.test.js +62 -0
- package/dist/access/relationship-count.test.js.map +1 -1
- package/dist/access/synthetic-include-read.test.d.ts +2 -0
- package/dist/access/synthetic-include-read.test.d.ts.map +1 -0
- package/dist/access/synthetic-include-read.test.js +121 -0
- package/dist/access/synthetic-include-read.test.js.map +1 -0
- package/dist/config/types.d.ts +98 -32
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/hook-pipeline.d.ts +2 -2
- package/dist/context/hook-pipeline.d.ts.map +1 -1
- package/dist/context/hook-pipeline.js.map +1 -1
- package/dist/context/index.d.ts +35 -2
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +121 -27
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts +2 -2
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +74 -15
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +3 -2
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +31 -34
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/index.d.ts +13 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +50 -5
- package/dist/fields/index.js.map +1 -1
- package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
- package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
- package/dist/fields/relationship-foreign-key.test.js +18 -0
- package/dist/fields/relationship-foreign-key.test.js.map +1 -0
- package/dist/hooks/index.d.ts +16 -15
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/case-utils.d.ts +15 -0
- package/dist/lib/case-utils.d.ts.map +1 -1
- package/dist/lib/case-utils.js +21 -0
- package/dist/lib/case-utils.js.map +1 -1
- package/dist/lib/case-utils.test.js +23 -1
- package/dist/lib/case-utils.test.js.map +1 -1
- package/dist/lib/prisma-errors.d.ts +38 -0
- package/dist/lib/prisma-errors.d.ts.map +1 -0
- package/dist/lib/prisma-errors.js +53 -0
- package/dist/lib/prisma-errors.js.map +1 -0
- package/dist/mcp/constants.d.ts +23 -0
- package/dist/mcp/constants.d.ts.map +1 -0
- package/dist/mcp/constants.js +23 -0
- package/dist/mcp/constants.js.map +1 -0
- package/dist/mcp/field-schema.d.ts +8 -0
- package/dist/mcp/field-schema.d.ts.map +1 -0
- package/dist/mcp/field-schema.js +69 -0
- package/dist/mcp/field-schema.js.map +1 -0
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +76 -90
- package/dist/mcp/handler.js.map +1 -1
- package/dist/mcp/projection.d.ts +62 -0
- package/dist/mcp/projection.d.ts.map +1 -0
- package/dist/mcp/projection.js +390 -0
- package/dist/mcp/projection.js.map +1 -0
- package/dist/utils/password.d.ts +3 -1
- package/dist/utils/password.d.ts.map +1 -1
- package/dist/utils/password.js +4 -2
- package/dist/utils/password.js.map +1 -1
- package/package.json +2 -2
- package/src/access/access-filter.test.ts +1528 -18
- package/src/access/access-filter.ts +708 -42
- package/src/access/denied-relation-visibility.test.ts +173 -0
- package/src/access/engine.test.ts +67 -0
- package/src/access/engine.ts +109 -1
- package/src/access/errors.ts +97 -2
- package/src/access/field-access.test.ts +78 -0
- package/src/access/field-access.ts +34 -2
- package/src/access/field-visibility.ts +171 -25
- package/src/access/index.ts +25 -0
- package/src/access/multi-column-read-write.test.ts +3 -2
- package/src/access/query-validation.ts +136 -9
- package/src/access/relationship-count.test.ts +75 -0
- package/src/access/relationship-count.ts +53 -10
- package/src/access/synthetic-include-read.test.ts +179 -0
- package/src/config/types.ts +98 -32
- package/src/context/hook-pipeline.ts +4 -2
- package/src/context/index.ts +203 -42
- package/src/context/nested-operations.ts +96 -27
- package/src/context/write-pipeline.ts +60 -36
- package/src/fields/index.ts +55 -5
- package/src/fields/relationship-foreign-key.test.ts +29 -0
- package/src/hooks/index.ts +22 -15
- package/src/index.ts +17 -1
- package/src/lib/case-utils.test.ts +32 -0
- package/src/lib/case-utils.ts +26 -0
- package/src/lib/prisma-errors.ts +86 -0
- package/src/mcp/constants.ts +24 -0
- package/src/mcp/field-schema.ts +84 -0
- package/src/mcp/handler.ts +109 -107
- package/src/mcp/projection.ts +497 -0
- package/src/utils/password.ts +5 -3
- package/tests/access-relationships.test.ts +3 -3
- package/tests/access.test.ts +83 -0
- package/tests/context.test.ts +452 -17
- package/tests/field-types.test.ts +113 -0
- package/tests/hook-context-secured.test.ts +424 -0
- package/tests/mcp-fields-projection-access.test.ts +250 -0
- package/tests/mcp-handler.test.ts +695 -4
- package/tests/nested-access-and-hooks.test.ts +389 -0
- package/tests/nested-write-synthetic-relation.test.ts +437 -0
- package/tests/password-types.test.ts +28 -0
- package/tests/password.test.ts +16 -2
- package/tests/prisma-errors.test.ts +141 -0
- package/tests/singleton.test.ts +9 -1
- package/tests/to-one-include-access-filter.test.ts +172 -0
- package/tests/with-session.test.ts +243 -0
- package/tests/write-pipeline.test.ts +29 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/context/index.ts
CHANGED
|
@@ -10,10 +10,18 @@ import {
|
|
|
10
10
|
foldDeclaredDependencies,
|
|
11
11
|
validateQueryKeys,
|
|
12
12
|
validateQueryFieldReadAccess,
|
|
13
|
+
resolveToOneAccessVisibility,
|
|
14
|
+
emptyToOneAccessFilterTree,
|
|
15
|
+
emptyCountAccessDenialTree,
|
|
16
|
+
} from '../access/index.js'
|
|
17
|
+
import type {
|
|
18
|
+
DeclaredOnlyTree,
|
|
19
|
+
ToOneAccessFilterTree,
|
|
20
|
+
CountAccessDenialTree,
|
|
13
21
|
} from '../access/index.js'
|
|
14
|
-
import type { DeclaredOnlyTree } from '../access/index.js'
|
|
15
22
|
import { ValidationError, DatabaseError } from '../hooks/index.js'
|
|
16
23
|
import { getDbKey } from '../lib/case-utils.js'
|
|
24
|
+
import { uniqueConstraintOf } from '../lib/prisma-errors.js'
|
|
17
25
|
import type { PrismaClientLike } from '../access/types.js'
|
|
18
26
|
import { buildInclude, pickFields, isFragment, buildFieldSelectionScope } from '../query/index.js'
|
|
19
27
|
import type { FieldSelection, FieldSelectionScope } from '../query/index.js'
|
|
@@ -202,6 +210,14 @@ function getDefaultData(listConfig: ListConfig<any>): Record<string, unknown> {
|
|
|
202
210
|
return data
|
|
203
211
|
}
|
|
204
212
|
|
|
213
|
+
// A camelCase field name (e.g. a relationship's `tenantId` foreign key) needs
|
|
214
|
+
// its word boundary split before title-casing, or it reads as one run-together
|
|
215
|
+
// word ("Tenantid") in a user-facing unique-constraint message.
|
|
216
|
+
function humanizeFieldName(fieldName: string): string {
|
|
217
|
+
const spaced = fieldName.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
218
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1)
|
|
219
|
+
}
|
|
220
|
+
|
|
205
221
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
206
222
|
function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
|
|
207
223
|
if (
|
|
@@ -215,22 +231,22 @@ function parsePrismaError(error: unknown, listConfig: ListConfig<any>): Error {
|
|
|
215
231
|
|
|
216
232
|
// P2002 is Prisma's unique constraint violation code.
|
|
217
233
|
if (prismaError.code === 'P2002') {
|
|
218
|
-
const target = prismaError
|
|
234
|
+
const target = uniqueConstraintOf(prismaError)?.fields
|
|
219
235
|
const fieldErrors: Record<string, string> = {}
|
|
220
236
|
|
|
221
|
-
if (target &&
|
|
237
|
+
if (target && target.length > 0) {
|
|
222
238
|
for (const fieldName of target) {
|
|
223
239
|
const fieldConfig = listConfig.fields[fieldName]
|
|
224
|
-
const label = fieldName.charAt(0).toUpperCase() + fieldName.slice(1)
|
|
225
240
|
|
|
226
241
|
if (fieldConfig) {
|
|
227
|
-
fieldErrors[fieldName] =
|
|
242
|
+
fieldErrors[fieldName] =
|
|
243
|
+
`This ${humanizeFieldName(fieldName).toLowerCase()} is already in use`
|
|
228
244
|
} else {
|
|
229
245
|
fieldErrors[fieldName] = `This value is already in use`
|
|
230
246
|
}
|
|
231
247
|
}
|
|
232
248
|
|
|
233
|
-
const fieldLabels = target.map(
|
|
249
|
+
const fieldLabels = target.map(humanizeFieldName).join(', ')
|
|
234
250
|
return new DatabaseError(
|
|
235
251
|
`${fieldLabels} must be unique. The value you entered is already in use.`,
|
|
236
252
|
fieldErrors,
|
|
@@ -293,7 +309,8 @@ interface TransactionCapable<TPrisma> {
|
|
|
293
309
|
*
|
|
294
310
|
* Exposes the secured `db` delegate plus the session, raw `prisma`, storage,
|
|
295
311
|
* plugin services, the generic `serverAction` handler, `sudo()` (bypasses access
|
|
296
|
-
* control but still runs hooks),
|
|
312
|
+
* control but still runs hooks), `withSession()` (substitutes the session but
|
|
313
|
+
* still runs access control), and `transaction()` (interactive, hook-firing
|
|
297
314
|
* transaction). All access-checked operations run their list/field hooks.
|
|
298
315
|
*/
|
|
299
316
|
export interface StackContext<TPrisma extends PrismaClientLike = PrismaClientLike> {
|
|
@@ -329,7 +346,33 @@ export interface StackContext<TPrisma extends PrismaClientLike = PrismaClientLik
|
|
|
329
346
|
options?: TransactionOptions,
|
|
330
347
|
) => Promise<T>
|
|
331
348
|
sudo: () => StackContext<TPrisma>
|
|
349
|
+
/**
|
|
350
|
+
* Derive a context identical to this one except for its session, reusing
|
|
351
|
+
* the already-resolved config and this context's own client (including a
|
|
352
|
+
* transaction client — a call inside `context.transaction()` stays in that
|
|
353
|
+
* transaction) and storage.
|
|
354
|
+
*
|
|
355
|
+
* **This is not an authorisation.** It substitutes who hooks and access
|
|
356
|
+
* control see; it does not change what they decide. The derived context
|
|
357
|
+
* can do exactly what any context built with `session` directly could
|
|
358
|
+
* do — access rules still evaluate against the new session. The caller is
|
|
359
|
+
* responsible for deciding who may invoke this.
|
|
360
|
+
*
|
|
361
|
+
* Orthogonal to `sudo()`: `withSession(s)` preserves the receiver's sudo
|
|
362
|
+
* state (elevated stays elevated), so `context.withSession(s).sudo()` and
|
|
363
|
+
* `context.sudo().withSession(s)` are equivalent.
|
|
364
|
+
*/
|
|
365
|
+
withSession: (session: Session | null) => StackContext<TPrisma>
|
|
332
366
|
_isSudo: boolean
|
|
367
|
+
/**
|
|
368
|
+
* @internal Present so a hook-bound `StackContext` (issue #1176) satisfies
|
|
369
|
+
* {@link AccessContext} and can be threaded through the same internal
|
|
370
|
+
* write-pipeline/access plumbing that a plain `AccessContext` is — see
|
|
371
|
+
* `_resolveOutputChain` on `AccessContext` for what this tracks.
|
|
372
|
+
*/
|
|
373
|
+
_resolveOutputChain: readonly { listKey: string; fieldKey: string }[]
|
|
374
|
+
/** @internal See `_transactionOwner` on `AccessContext`. */
|
|
375
|
+
_transactionOwner?: TransactionRegistry
|
|
333
376
|
}
|
|
334
377
|
|
|
335
378
|
/**
|
|
@@ -377,6 +420,11 @@ export function getContext<
|
|
|
377
420
|
// owner's callback body, carry the deferral registry so writes reached
|
|
378
421
|
// through this context join it instead of firing afterTransaction eagerly.
|
|
379
422
|
_transactionOwner?: TransactionRegistry,
|
|
423
|
+
// Internal (ADR-0023, issue #1176): when rebuilding the context for a
|
|
424
|
+
// hook-bound write (or a derived `sudo()`/`withSession()` of one), carry the
|
|
425
|
+
// resolve chain forward so a write issued from inside a `resolveOutput` hook
|
|
426
|
+
// keeps that hook's cycle-guard chain instead of resetting to empty.
|
|
427
|
+
_resolveOutputChain?: readonly { listKey: string; fieldKey: string }[],
|
|
380
428
|
): StackContext<TPrisma> {
|
|
381
429
|
// Broad type to allow dynamic model access; populated by populateDbDelegate below.
|
|
382
430
|
const db: Record<string, unknown> = {}
|
|
@@ -411,7 +459,7 @@ export function getContext<
|
|
|
411
459
|
// client, otherwise start empty and populate via plugin runtimes below.
|
|
412
460
|
plugins: _sharedPlugins ?? {},
|
|
413
461
|
_isSudo,
|
|
414
|
-
_resolveOutputChain: [],
|
|
462
|
+
_resolveOutputChain: _resolveOutputChain ?? [],
|
|
415
463
|
_transactionOwner,
|
|
416
464
|
}
|
|
417
465
|
|
|
@@ -762,6 +810,28 @@ export function getContext<
|
|
|
762
810
|
// ADR-0028: a sudo write issued from inside an owned transaction (e.g.
|
|
763
811
|
// `tx.sudo().db.x.create()`) must still defer to that owner.
|
|
764
812
|
context._transactionOwner,
|
|
813
|
+
// #1176: carry the resolve chain forward so a `context.sudo()` called
|
|
814
|
+
// from inside a `resolveOutput` hook keeps that hook's cycle-guard
|
|
815
|
+
// chain (ADR-0023) rather than resetting to empty.
|
|
816
|
+
context._resolveOutputChain,
|
|
817
|
+
)
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// Substitutes the session; access control and hooks still run against it
|
|
821
|
+
// (orthogonal to `sudo`, so the receiver's sudo state is preserved).
|
|
822
|
+
function withSession(newSession: Session | null): StackContext<TPrisma> {
|
|
823
|
+
return getContext(
|
|
824
|
+
config,
|
|
825
|
+
prisma,
|
|
826
|
+
newSession,
|
|
827
|
+
context.storage,
|
|
828
|
+
_isSudo,
|
|
829
|
+
undefined,
|
|
830
|
+
// ADR-0028: a write issued from inside an owned transaction (e.g.
|
|
831
|
+
// `tx.withSession(s).db.x.create()`) must still defer to that owner.
|
|
832
|
+
context._transactionOwner,
|
|
833
|
+
// #1176: see the identical comment in `sudo()` above.
|
|
834
|
+
context._resolveOutputChain,
|
|
765
835
|
)
|
|
766
836
|
}
|
|
767
837
|
|
|
@@ -802,6 +872,7 @@ export function getContext<
|
|
|
802
872
|
_isSudo,
|
|
803
873
|
context.plugins,
|
|
804
874
|
registry,
|
|
875
|
+
context._resolveOutputChain,
|
|
805
876
|
),
|
|
806
877
|
)
|
|
807
878
|
: (client.$transaction(
|
|
@@ -815,6 +886,7 @@ export function getContext<
|
|
|
815
886
|
_isSudo,
|
|
816
887
|
context.plugins,
|
|
817
888
|
registry,
|
|
889
|
+
context._resolveOutputChain,
|
|
818
890
|
),
|
|
819
891
|
),
|
|
820
892
|
options,
|
|
@@ -831,8 +903,15 @@ export function getContext<
|
|
|
831
903
|
plugins: context.plugins,
|
|
832
904
|
serverAction,
|
|
833
905
|
sudo,
|
|
906
|
+
withSession,
|
|
834
907
|
transaction,
|
|
835
908
|
_isSudo,
|
|
909
|
+
// #1176: carried so a `StackContext` structurally satisfies `AccessContext`
|
|
910
|
+
// and can be handed, unchanged, to the internal write-pipeline/hook
|
|
911
|
+
// plumbing that a plain `AccessContext` was built for — see
|
|
912
|
+
// `bindContextToTransaction` in `write-pipeline.ts`.
|
|
913
|
+
_resolveOutputChain: context._resolveOutputChain,
|
|
914
|
+
_transactionOwner: context._transactionOwner,
|
|
836
915
|
}
|
|
837
916
|
return returned
|
|
838
917
|
}
|
|
@@ -909,11 +988,12 @@ export function buildDbDelegate<TPrisma extends PrismaClientLike>(
|
|
|
909
988
|
* caller include / bare (ADR-0024) — while folding declared dependencies
|
|
910
989
|
* (`needs`, ADR-0025) into whichever of those the read is already using.
|
|
911
990
|
*
|
|
912
|
-
* A fragment's own `include` and a sudo caller's `include` are
|
|
913
|
-
*
|
|
914
|
-
* caller
|
|
915
|
-
*
|
|
916
|
-
*
|
|
991
|
+
* A non-sudo fragment's own `include` and a non-sudo caller's `include` are
|
|
992
|
+
* both folded and then scoped by `buildAccessScopedInclude` (ADR-0026) —
|
|
993
|
+
* caller-directed, so a relation named nowhere in the folded tree never has
|
|
994
|
+
* its list's `query` access evaluated at all (issue #1088: a fragment read
|
|
995
|
+
* used to skip this walk entirely). A sudo caller's `include` (fragment or
|
|
996
|
+
* not) is folded and used as-is, unscoped — sudo is unaffected. A bare read
|
|
917
997
|
* stays on the exact ADR-0024 path — `include: undefined`, no related
|
|
918
998
|
* `query` access evaluated — unless folding actually added something, which
|
|
919
999
|
* only happens when a field on this list declares `needs`.
|
|
@@ -924,6 +1004,13 @@ export function buildDbDelegate<TPrisma extends PrismaClientLike>(
|
|
|
924
1004
|
* `undefined` for every non-fragment path: a caller `include` (sudo or not)
|
|
925
1005
|
* and a bare read both mean "compute every field," matching what they
|
|
926
1006
|
* already fetch.
|
|
1007
|
+
*
|
|
1008
|
+
* Also returns `toOneAccessFilters` — the to-one relations `buildAccessScopedInclude`
|
|
1009
|
+
* flagged as needing a post-query existence check rather than a Prisma-side
|
|
1010
|
+
* `where` (issue #974). Only a non-sudo fragment or caller-include read can
|
|
1011
|
+
* produce a non-empty tree: those are the only paths that evaluate a related
|
|
1012
|
+
* list's `query` access at all. A sudo read and a bare read always return an
|
|
1013
|
+
* empty tree.
|
|
927
1014
|
*/
|
|
928
1015
|
async function resolveReadInclude(
|
|
929
1016
|
callerInclude: Record<string, unknown> | undefined,
|
|
@@ -937,6 +1024,8 @@ async function resolveReadInclude(
|
|
|
937
1024
|
include: Record<string, unknown> | undefined
|
|
938
1025
|
declaredOnly: DeclaredOnlyTree
|
|
939
1026
|
selection: FieldSelectionScope | undefined
|
|
1027
|
+
toOneAccessFilters: ToOneAccessFilterTree
|
|
1028
|
+
countDenials: CountAccessDenialTree
|
|
940
1029
|
}> {
|
|
941
1030
|
if (fragmentFields !== undefined) {
|
|
942
1031
|
const fragmentInclude = buildInclude(fragmentFields) ?? undefined
|
|
@@ -949,27 +1038,66 @@ async function resolveReadInclude(
|
|
|
949
1038
|
[listName],
|
|
950
1039
|
selection,
|
|
951
1040
|
)
|
|
952
|
-
|
|
1041
|
+
|
|
1042
|
+
if (context._isSudo || !folded.include) {
|
|
1043
|
+
return {
|
|
1044
|
+
...folded,
|
|
1045
|
+
selection,
|
|
1046
|
+
toOneAccessFilters: emptyToOneAccessFilterTree(),
|
|
1047
|
+
countDenials: emptyCountAccessDenialTree(),
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const { include, toOneAccessFilters, countDenials } = await buildAccessScopedInclude(
|
|
1052
|
+
folded.include,
|
|
1053
|
+
listConfig.fields,
|
|
1054
|
+
{ session: context.session, context },
|
|
1055
|
+
config,
|
|
1056
|
+
listName,
|
|
1057
|
+
)
|
|
1058
|
+
return {
|
|
1059
|
+
include,
|
|
1060
|
+
declaredOnly: folded.declaredOnly,
|
|
1061
|
+
selection,
|
|
1062
|
+
toOneAccessFilters,
|
|
1063
|
+
countDenials,
|
|
1064
|
+
}
|
|
953
1065
|
}
|
|
954
1066
|
|
|
955
1067
|
if (context._isSudo) {
|
|
956
1068
|
const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config, listName)
|
|
957
|
-
return {
|
|
1069
|
+
return {
|
|
1070
|
+
...folded,
|
|
1071
|
+
selection: undefined,
|
|
1072
|
+
toOneAccessFilters: emptyToOneAccessFilterTree(),
|
|
1073
|
+
countDenials: emptyCountAccessDenialTree(),
|
|
1074
|
+
}
|
|
958
1075
|
}
|
|
959
1076
|
|
|
960
1077
|
const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config, listName)
|
|
961
1078
|
if (!folded.include) {
|
|
962
|
-
return {
|
|
1079
|
+
return {
|
|
1080
|
+
...folded,
|
|
1081
|
+
selection: undefined,
|
|
1082
|
+
toOneAccessFilters: emptyToOneAccessFilterTree(),
|
|
1083
|
+
countDenials: emptyCountAccessDenialTree(),
|
|
1084
|
+
}
|
|
963
1085
|
}
|
|
964
1086
|
|
|
965
|
-
const include = await buildAccessScopedInclude(
|
|
1087
|
+
const { include, toOneAccessFilters, countDenials } = await buildAccessScopedInclude(
|
|
966
1088
|
folded.include,
|
|
967
1089
|
listConfig.fields,
|
|
968
1090
|
{ session: context.session, context },
|
|
969
1091
|
config,
|
|
970
1092
|
listName,
|
|
971
1093
|
)
|
|
972
|
-
return {
|
|
1094
|
+
return {
|
|
1095
|
+
include,
|
|
1096
|
+
declaredOnly: folded.declaredOnly,
|
|
1097
|
+
selection: undefined,
|
|
1098
|
+
toOneAccessFilters,
|
|
1099
|
+
countDenials,
|
|
1100
|
+
}
|
|
973
1101
|
}
|
|
974
1102
|
|
|
975
1103
|
function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
@@ -1026,14 +1154,15 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
1026
1154
|
// Resolve `include`, folding any declared dependencies (`needs`,
|
|
1027
1155
|
// ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
|
|
1028
1156
|
// already produces — see `resolveReadInclude`'s doc comment.
|
|
1029
|
-
let { include, declaredOnly, selection } =
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1157
|
+
let { include, declaredOnly, selection, toOneAccessFilters, countDenials } =
|
|
1158
|
+
await resolveReadInclude(
|
|
1159
|
+
args.include,
|
|
1160
|
+
fragment ? fragment._fields : undefined,
|
|
1161
|
+
listName,
|
|
1162
|
+
listConfig,
|
|
1163
|
+
context,
|
|
1164
|
+
config,
|
|
1165
|
+
)
|
|
1037
1166
|
|
|
1038
1167
|
// Virtual fields have no database column. Whichever path produced
|
|
1039
1168
|
// `include` (fragment, access-controlled merge, or sudo passthrough), a
|
|
@@ -1056,6 +1185,14 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
1056
1185
|
return null
|
|
1057
1186
|
}
|
|
1058
1187
|
|
|
1188
|
+
// Resolve which of the to-one relations flagged by `toOneAccessFilters`
|
|
1189
|
+
// actually survive their related list's `query` access (issue #974) —
|
|
1190
|
+
// one batched existence check per relation, before field visibility runs.
|
|
1191
|
+
const toOneVisibility = await resolveToOneAccessVisibility([item], toOneAccessFilters, {
|
|
1192
|
+
session: context.session,
|
|
1193
|
+
context,
|
|
1194
|
+
})
|
|
1195
|
+
|
|
1059
1196
|
// Pass sudo flag through context to skip field-level access checks
|
|
1060
1197
|
const filtered = await filterReadableFields(
|
|
1061
1198
|
item,
|
|
@@ -1069,6 +1206,8 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
1069
1206
|
listName,
|
|
1070
1207
|
declaredOnly,
|
|
1071
1208
|
selection,
|
|
1209
|
+
toOneVisibility,
|
|
1210
|
+
countDenials,
|
|
1072
1211
|
)
|
|
1073
1212
|
|
|
1074
1213
|
if (fragment) {
|
|
@@ -1178,14 +1317,15 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
1178
1317
|
// Resolve `include`, folding any declared dependencies (`needs`,
|
|
1179
1318
|
// ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
|
|
1180
1319
|
// already produces — see `resolveReadInclude`'s doc comment.
|
|
1181
|
-
let { include, declaredOnly, selection } =
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1320
|
+
let { include, declaredOnly, selection, toOneAccessFilters, countDenials } =
|
|
1321
|
+
await resolveReadInclude(
|
|
1322
|
+
args?.include,
|
|
1323
|
+
fragment ? fragment._fields : undefined,
|
|
1324
|
+
listName,
|
|
1325
|
+
listConfig,
|
|
1326
|
+
context,
|
|
1327
|
+
config,
|
|
1328
|
+
)
|
|
1189
1329
|
|
|
1190
1330
|
// Strips virtual keys from `include` before the Prisma call — see the
|
|
1191
1331
|
// `createFindUnique` comment above for why (#628, ADR-0027).
|
|
@@ -1202,6 +1342,15 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
1202
1342
|
include,
|
|
1203
1343
|
})
|
|
1204
1344
|
|
|
1345
|
+
// Resolve which of the to-one relations flagged by `toOneAccessFilters`
|
|
1346
|
+
// actually survive their related list's `query` access (issue #974) —
|
|
1347
|
+
// ONE batched existence check per relation across every row in `items`,
|
|
1348
|
+
// before field visibility runs on any of them.
|
|
1349
|
+
const toOneVisibility = await resolveToOneAccessVisibility(items, toOneAccessFilters, {
|
|
1350
|
+
session: context.session,
|
|
1351
|
+
context,
|
|
1352
|
+
})
|
|
1353
|
+
|
|
1205
1354
|
// Pass sudo flag through context to skip field-level access checks
|
|
1206
1355
|
const filtered = await Promise.all(
|
|
1207
1356
|
items.map((item: Record<string, unknown>) =>
|
|
@@ -1217,6 +1366,8 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
1217
1366
|
listName,
|
|
1218
1367
|
declaredOnly,
|
|
1219
1368
|
selection,
|
|
1369
|
+
toOneVisibility,
|
|
1370
|
+
countDenials,
|
|
1220
1371
|
),
|
|
1221
1372
|
),
|
|
1222
1373
|
)
|
|
@@ -1501,14 +1652,15 @@ function createGet<TPrisma extends PrismaClientLike>(
|
|
|
1501
1652
|
// Resolve `include`, folding any declared dependencies (`needs`,
|
|
1502
1653
|
// ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
|
|
1503
1654
|
// already produces — see `resolveReadInclude`'s doc comment.
|
|
1504
|
-
let { include, declaredOnly, selection } =
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1655
|
+
let { include, declaredOnly, selection, toOneAccessFilters, countDenials } =
|
|
1656
|
+
await resolveReadInclude(
|
|
1657
|
+
args?.include,
|
|
1658
|
+
fragment ? fragment._fields : undefined,
|
|
1659
|
+
listName,
|
|
1660
|
+
listConfig,
|
|
1661
|
+
context,
|
|
1662
|
+
config,
|
|
1663
|
+
)
|
|
1512
1664
|
|
|
1513
1665
|
// Virtual fields have no database column and must never reach Prisma (#628).
|
|
1514
1666
|
include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
|
|
@@ -1519,6 +1671,13 @@ function createGet<TPrisma extends PrismaClientLike>(
|
|
|
1519
1671
|
})
|
|
1520
1672
|
|
|
1521
1673
|
if (item) {
|
|
1674
|
+
// Resolve which of the to-one relations flagged by `toOneAccessFilters`
|
|
1675
|
+
// actually survive their related list's `query` access (issue #974).
|
|
1676
|
+
const toOneVisibility = await resolveToOneAccessVisibility([item], toOneAccessFilters, {
|
|
1677
|
+
session: context.session,
|
|
1678
|
+
context,
|
|
1679
|
+
})
|
|
1680
|
+
|
|
1522
1681
|
const filtered = await filterReadableFields(
|
|
1523
1682
|
item,
|
|
1524
1683
|
listConfig.fields,
|
|
@@ -1531,6 +1690,8 @@ function createGet<TPrisma extends PrismaClientLike>(
|
|
|
1531
1690
|
listName,
|
|
1532
1691
|
declaredOnly,
|
|
1533
1692
|
selection,
|
|
1693
|
+
toOneVisibility,
|
|
1694
|
+
countDenials,
|
|
1534
1695
|
)
|
|
1535
1696
|
if (fragment) {
|
|
1536
1697
|
return pickFields(filtered, fragment._fields)
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import type { OpenSaasConfig, ListConfig, FieldConfig } from '../config/types.js'
|
|
2
|
-
import type {
|
|
3
|
-
import {
|
|
2
|
+
import type { FieldAccess, PrismaFilter } from '../access/types.js'
|
|
3
|
+
import type { StackContext } from './index.js'
|
|
4
|
+
import {
|
|
5
|
+
checkAccess,
|
|
6
|
+
checkCreateAccess,
|
|
7
|
+
filterWritableFields,
|
|
8
|
+
getRelatedListConfig,
|
|
9
|
+
mergeFilters,
|
|
10
|
+
resolveSyntheticReverseRelation,
|
|
11
|
+
} from '../access/index.js'
|
|
4
12
|
import { checkFieldAccess } from '../access/field-access.js'
|
|
5
13
|
import {
|
|
6
14
|
executeResolveInput,
|
|
@@ -193,7 +201,7 @@ async function processNestedCreate(
|
|
|
193
201
|
relatedListName: string,
|
|
194
202
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
195
203
|
relatedListConfig: ListConfig<any>,
|
|
196
|
-
context:
|
|
204
|
+
context: StackContext,
|
|
197
205
|
config: OpenSaasConfig,
|
|
198
206
|
prisma: unknown,
|
|
199
207
|
afterTasks: AfterTask[],
|
|
@@ -205,12 +213,12 @@ async function processNestedCreate(
|
|
|
205
213
|
itemsArray.map(async (item, index) => {
|
|
206
214
|
if (!context._isSudo) {
|
|
207
215
|
const createAccess = relatedListConfig.access?.operation?.create
|
|
208
|
-
const
|
|
216
|
+
const allowed = await checkCreateAccess(relatedListName, createAccess, {
|
|
209
217
|
session: context.session,
|
|
210
218
|
context,
|
|
211
219
|
})
|
|
212
220
|
|
|
213
|
-
if (
|
|
221
|
+
if (!allowed) {
|
|
214
222
|
throw new Error('Access denied: Cannot create related item')
|
|
215
223
|
}
|
|
216
224
|
}
|
|
@@ -279,6 +287,8 @@ async function processNestedCreate(
|
|
|
279
287
|
session: context.session,
|
|
280
288
|
context,
|
|
281
289
|
inputData: item,
|
|
290
|
+
listName: relatedListName,
|
|
291
|
+
config,
|
|
282
292
|
},
|
|
283
293
|
)
|
|
284
294
|
|
|
@@ -383,7 +393,7 @@ async function verifyConnectReachable(
|
|
|
383
393
|
relatedListName: string,
|
|
384
394
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
385
395
|
relatedListConfig: ListConfig<any>,
|
|
386
|
-
context:
|
|
396
|
+
context: StackContext,
|
|
387
397
|
prisma: unknown,
|
|
388
398
|
owningFieldAccess: FieldAccess | undefined,
|
|
389
399
|
enclosingOperation: 'create' | 'update',
|
|
@@ -448,7 +458,7 @@ async function processNestedConnect(
|
|
|
448
458
|
relatedListName: string,
|
|
449
459
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
450
460
|
relatedListConfig: ListConfig<any>,
|
|
451
|
-
context:
|
|
461
|
+
context: StackContext,
|
|
452
462
|
prisma: unknown,
|
|
453
463
|
owningFieldAccess: FieldAccess | undefined,
|
|
454
464
|
enclosingOperation: 'create' | 'update',
|
|
@@ -476,6 +486,28 @@ async function processNestedConnect(
|
|
|
476
486
|
return connections
|
|
477
487
|
}
|
|
478
488
|
|
|
489
|
+
/**
|
|
490
|
+
* Re-check a nested update/delete access result against the target row,
|
|
491
|
+
* mirroring the Write Pipeline's `resolveExistingTarget` (#1081): `false`
|
|
492
|
+
* denies, `true` allows outright (the row's existence is already established
|
|
493
|
+
* by the caller's own `findUnique`), and a returned PrismaFilter must
|
|
494
|
+
* additionally match the row, re-checked in the DATABASE via
|
|
495
|
+
* `findFirst(mergeFilters(where, accessResult))` rather than in memory — the
|
|
496
|
+
* same requirement `connect`'s reachability check already applies (#578), so
|
|
497
|
+
* nested-relation predicates and boolean combinators are honoured correctly.
|
|
498
|
+
*/
|
|
499
|
+
async function isExistingTargetAccessible(
|
|
500
|
+
model: { findFirst: (args: { where: Record<string, unknown> }) => Promise<unknown> },
|
|
501
|
+
where: Record<string, unknown>,
|
|
502
|
+
accessResult: boolean | PrismaFilter,
|
|
503
|
+
): Promise<boolean> {
|
|
504
|
+
if (accessResult === false) return false
|
|
505
|
+
if (accessResult === true) return true
|
|
506
|
+
|
|
507
|
+
const matchesFilter = await model.findFirst({ where: mergeFilters(where, accessResult) ?? {} })
|
|
508
|
+
return matchesFilter !== null
|
|
509
|
+
}
|
|
510
|
+
|
|
479
511
|
/**
|
|
480
512
|
* Process nested update operations.
|
|
481
513
|
*
|
|
@@ -489,7 +521,7 @@ async function processNestedUpdate(
|
|
|
489
521
|
relatedListName: string,
|
|
490
522
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
491
523
|
relatedListConfig: ListConfig<any>,
|
|
492
|
-
context:
|
|
524
|
+
context: StackContext,
|
|
493
525
|
config: OpenSaasConfig,
|
|
494
526
|
prisma: unknown,
|
|
495
527
|
afterTasks: AfterTask[],
|
|
@@ -521,7 +553,7 @@ async function processNestedUpdate(
|
|
|
521
553
|
context,
|
|
522
554
|
})
|
|
523
555
|
|
|
524
|
-
if (
|
|
556
|
+
if (!(await isExistingTargetAccessible(model, where, accessResult))) {
|
|
525
557
|
throw new Error('Access denied: Cannot update related item')
|
|
526
558
|
}
|
|
527
559
|
}
|
|
@@ -590,6 +622,8 @@ async function processNestedUpdate(
|
|
|
590
622
|
item: originalItem,
|
|
591
623
|
context,
|
|
592
624
|
inputData: updateData,
|
|
625
|
+
listName: relatedListName,
|
|
626
|
+
config,
|
|
593
627
|
},
|
|
594
628
|
)
|
|
595
629
|
|
|
@@ -683,7 +717,7 @@ async function processNestedDelete(
|
|
|
683
717
|
relatedListName: string,
|
|
684
718
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
685
719
|
relatedListConfig: ListConfig<any>,
|
|
686
|
-
context:
|
|
720
|
+
context: StackContext,
|
|
687
721
|
prisma: unknown,
|
|
688
722
|
afterTasks: AfterTask[],
|
|
689
723
|
): Promise<Record<string, unknown> | Array<Record<string, unknown>> | boolean> {
|
|
@@ -716,7 +750,7 @@ async function processNestedDelete(
|
|
|
716
750
|
context,
|
|
717
751
|
})
|
|
718
752
|
|
|
719
|
-
if (
|
|
753
|
+
if (!(await isExistingTargetAccessible(model, where, accessResult))) {
|
|
720
754
|
throw new Error('Access denied: Cannot delete related item')
|
|
721
755
|
}
|
|
722
756
|
}
|
|
@@ -791,7 +825,7 @@ async function processNestedConnectOrCreate(
|
|
|
791
825
|
relatedListName: string,
|
|
792
826
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
793
827
|
relatedListConfig: ListConfig<any>,
|
|
794
|
-
context:
|
|
828
|
+
context: StackContext,
|
|
795
829
|
config: OpenSaasConfig,
|
|
796
830
|
prisma: unknown,
|
|
797
831
|
afterTasks: AfterTask[],
|
|
@@ -912,7 +946,7 @@ interface NestedOpHandlerArgs {
|
|
|
912
946
|
enclosingItem: Record<string, unknown> | undefined
|
|
913
947
|
/** The enclosing write's input data, passed to the owning-field gate for the same reason as `enclosingItem`. */
|
|
914
948
|
enclosingInputData: Record<string, unknown> | undefined
|
|
915
|
-
context:
|
|
949
|
+
context: StackContext
|
|
916
950
|
config: OpenSaasConfig
|
|
917
951
|
/** Prisma client used for dynamic model access during access checks. */
|
|
918
952
|
prisma: unknown
|
|
@@ -1180,7 +1214,7 @@ export async function processNestedOperations(
|
|
|
1180
1214
|
data: Record<string, unknown>,
|
|
1181
1215
|
fieldConfigs: Record<string, FieldConfig>,
|
|
1182
1216
|
config: OpenSaasConfig,
|
|
1183
|
-
context:
|
|
1217
|
+
context: StackContext & { prisma: unknown },
|
|
1184
1218
|
operation: 'create' | 'update',
|
|
1185
1219
|
parentListName: string,
|
|
1186
1220
|
parentOriginalItem: Record<string, unknown> | undefined,
|
|
@@ -1194,27 +1228,62 @@ export async function processNestedOperations(
|
|
|
1194
1228
|
const processed: Record<string, unknown> = {}
|
|
1195
1229
|
|
|
1196
1230
|
for (const [fieldName, value] of Object.entries(data)) {
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
if (!isRelationshipField(fieldConfig) || value === null || value === undefined) {
|
|
1231
|
+
if (value === null || value === undefined) {
|
|
1200
1232
|
processed[fieldName] = value
|
|
1201
1233
|
continue
|
|
1202
1234
|
}
|
|
1203
1235
|
|
|
1204
|
-
const
|
|
1205
|
-
|
|
1206
|
-
|
|
1236
|
+
const fieldConfig = fieldConfigs[fieldName]
|
|
1237
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
1238
|
+
let relatedListConfig: ListConfig<any>
|
|
1239
|
+
let resolvedListName: string
|
|
1240
|
+
let owningFieldAccess: FieldAccess | undefined
|
|
1241
|
+
|
|
1242
|
+
if (isRelationshipField(fieldConfig)) {
|
|
1243
|
+
const relationshipField = fieldConfig as { type: 'relationship'; ref: string }
|
|
1244
|
+
const relatedConfig = getRelatedListConfig(relationshipField.ref, config)
|
|
1245
|
+
if (!relatedConfig) {
|
|
1246
|
+
processed[fieldName] = value
|
|
1247
|
+
continue
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
const { listName: relatedListName, listConfig } = relatedConfig
|
|
1251
|
+
resolvedListName = relatedListName || findListName(listConfig, config)
|
|
1252
|
+
relatedListConfig = listConfig
|
|
1253
|
+
// The owning relationship field's field-level access, for the #588 gate
|
|
1254
|
+
// in verifyConnectReachable.
|
|
1255
|
+
owningFieldAccess = fieldConfig.access
|
|
1256
|
+
} else if (!fieldConfig) {
|
|
1257
|
+
// Not a field the parent's config declares — check whether it names the
|
|
1258
|
+
// synthetic back-relation a list-only `ref` generates on this list
|
|
1259
|
+
// (`from_<List>_<field>`, #978). Resolved, it is processed exactly like a
|
|
1260
|
+
// nested write through the declared field that owns it; unresolved, it
|
|
1261
|
+
// is left untouched. This is NOT solely a sudo path: a multi-column
|
|
1262
|
+
// field's raw per-part columns (e.g. `m_url`/`m_size`, #789) are ALSO
|
|
1263
|
+
// undeclared from this list's `fieldConfigs` perspective and reach here
|
|
1264
|
+
// on every write, sudo or not — `filterWritableFields` already
|
|
1265
|
+
// recognises and gates those via its own `splitColumnOwners` map before
|
|
1266
|
+
// its undeclared-key branch, and this function has no such map to tell
|
|
1267
|
+
// them apart from a genuinely-unrecognised key, so it must not throw
|
|
1268
|
+
// here. A genuinely unrecognised key under sudo is refused instead by
|
|
1269
|
+
// `filterWritableFields`'s own resolution attempt, one level up.
|
|
1270
|
+
const synthetic = resolveSyntheticReverseRelation(fieldName, parentListName, config)
|
|
1271
|
+
if (!synthetic) {
|
|
1272
|
+
processed[fieldName] = value
|
|
1273
|
+
continue
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
resolvedListName = synthetic.sourceListName
|
|
1277
|
+
relatedListConfig = synthetic.sourceListConfig
|
|
1278
|
+
// There is no parent-side field here (unlike the declared-relationship
|
|
1279
|
+
// branch above) — the FK-owning field genuinely IS the one the source
|
|
1280
|
+
// list declares, so its access is the owning-field gate.
|
|
1281
|
+
owningFieldAccess = synthetic.sourceFieldConfig.access
|
|
1282
|
+
} else {
|
|
1207
1283
|
processed[fieldName] = value
|
|
1208
1284
|
continue
|
|
1209
1285
|
}
|
|
1210
1286
|
|
|
1211
|
-
const { listName: relatedListName, listConfig: relatedListConfig } = relatedConfig
|
|
1212
|
-
const resolvedListName = relatedListName || findListName(relatedListConfig, config)
|
|
1213
|
-
|
|
1214
|
-
// The owning relationship field's field-level access, for the #588 gate
|
|
1215
|
-
// in verifyConnectReachable.
|
|
1216
|
-
const owningFieldAccess = fieldConfig.access
|
|
1217
|
-
|
|
1218
1287
|
processed[fieldName] = await processFieldNestedOps(
|
|
1219
1288
|
fieldName,
|
|
1220
1289
|
value as Record<string, unknown>,
|