@opensaas/stack-core 0.40.0 → 0.42.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 +65 -0
- package/CLAUDE.md +52 -0
- package/dist/access/access-filter.d.ts +88 -18
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +343 -36
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +759 -6
- 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 +11 -0
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +26 -0
- package/dist/access/engine.js.map +1 -1
- package/dist/access/errors.d.ts +34 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +47 -0
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-visibility.d.ts +2 -2
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +73 -23
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +7 -3
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +9 -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 +70 -58
- 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 +16 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +76 -26
- 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 +21 -3
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +23 -29
- package/dist/context/write-pipeline.js.map +1 -1
- 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/package.json +2 -2
- package/src/access/access-filter.test.ts +1403 -159
- package/src/access/access-filter.ts +525 -47
- package/src/access/denied-relation-visibility.test.ts +173 -0
- package/src/access/engine.ts +28 -0
- package/src/access/errors.ts +55 -0
- package/src/access/field-visibility.ts +105 -24
- package/src/access/index.ts +11 -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 +76 -50
- package/src/context/hook-pipeline.ts +4 -2
- package/src/context/index.ts +120 -39
- package/src/context/nested-operations.ts +35 -11
- package/src/context/write-pipeline.ts +52 -32
- package/src/hooks/index.ts +22 -15
- package/tests/context.test.ts +193 -5
- package/tests/hook-context-secured.test.ts +424 -0
- package/tests/nested-access-and-hooks.test.ts +324 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/context/index.ts
CHANGED
|
@@ -12,8 +12,13 @@ import {
|
|
|
12
12
|
validateQueryFieldReadAccess,
|
|
13
13
|
resolveToOneAccessVisibility,
|
|
14
14
|
emptyToOneAccessFilterTree,
|
|
15
|
+
emptyCountAccessDenialTree,
|
|
16
|
+
} from '../access/index.js'
|
|
17
|
+
import type {
|
|
18
|
+
DeclaredOnlyTree,
|
|
19
|
+
ToOneAccessFilterTree,
|
|
20
|
+
CountAccessDenialTree,
|
|
15
21
|
} from '../access/index.js'
|
|
16
|
-
import type { DeclaredOnlyTree, ToOneAccessFilterTree } from '../access/index.js'
|
|
17
22
|
import { ValidationError, DatabaseError } from '../hooks/index.js'
|
|
18
23
|
import { getDbKey } from '../lib/case-utils.js'
|
|
19
24
|
import { uniqueConstraintOf } from '../lib/prisma-errors.js'
|
|
@@ -359,6 +364,15 @@ export interface StackContext<TPrisma extends PrismaClientLike = PrismaClientLik
|
|
|
359
364
|
*/
|
|
360
365
|
withSession: (session: Session | null) => StackContext<TPrisma>
|
|
361
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
|
|
362
376
|
}
|
|
363
377
|
|
|
364
378
|
/**
|
|
@@ -406,6 +420,11 @@ export function getContext<
|
|
|
406
420
|
// owner's callback body, carry the deferral registry so writes reached
|
|
407
421
|
// through this context join it instead of firing afterTransaction eagerly.
|
|
408
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 }[],
|
|
409
428
|
): StackContext<TPrisma> {
|
|
410
429
|
// Broad type to allow dynamic model access; populated by populateDbDelegate below.
|
|
411
430
|
const db: Record<string, unknown> = {}
|
|
@@ -440,7 +459,7 @@ export function getContext<
|
|
|
440
459
|
// client, otherwise start empty and populate via plugin runtimes below.
|
|
441
460
|
plugins: _sharedPlugins ?? {},
|
|
442
461
|
_isSudo,
|
|
443
|
-
_resolveOutputChain: [],
|
|
462
|
+
_resolveOutputChain: _resolveOutputChain ?? [],
|
|
444
463
|
_transactionOwner,
|
|
445
464
|
}
|
|
446
465
|
|
|
@@ -791,6 +810,10 @@ export function getContext<
|
|
|
791
810
|
// ADR-0028: a sudo write issued from inside an owned transaction (e.g.
|
|
792
811
|
// `tx.sudo().db.x.create()`) must still defer to that owner.
|
|
793
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,
|
|
794
817
|
)
|
|
795
818
|
}
|
|
796
819
|
|
|
@@ -807,6 +830,8 @@ export function getContext<
|
|
|
807
830
|
// ADR-0028: a write issued from inside an owned transaction (e.g.
|
|
808
831
|
// `tx.withSession(s).db.x.create()`) must still defer to that owner.
|
|
809
832
|
context._transactionOwner,
|
|
833
|
+
// #1176: see the identical comment in `sudo()` above.
|
|
834
|
+
context._resolveOutputChain,
|
|
810
835
|
)
|
|
811
836
|
}
|
|
812
837
|
|
|
@@ -847,6 +872,7 @@ export function getContext<
|
|
|
847
872
|
_isSudo,
|
|
848
873
|
context.plugins,
|
|
849
874
|
registry,
|
|
875
|
+
context._resolveOutputChain,
|
|
850
876
|
),
|
|
851
877
|
)
|
|
852
878
|
: (client.$transaction(
|
|
@@ -860,6 +886,7 @@ export function getContext<
|
|
|
860
886
|
_isSudo,
|
|
861
887
|
context.plugins,
|
|
862
888
|
registry,
|
|
889
|
+
context._resolveOutputChain,
|
|
863
890
|
),
|
|
864
891
|
),
|
|
865
892
|
options,
|
|
@@ -879,6 +906,12 @@ export function getContext<
|
|
|
879
906
|
withSession,
|
|
880
907
|
transaction,
|
|
881
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,
|
|
882
915
|
}
|
|
883
916
|
return returned
|
|
884
917
|
}
|
|
@@ -955,11 +988,12 @@ export function buildDbDelegate<TPrisma extends PrismaClientLike>(
|
|
|
955
988
|
* caller include / bare (ADR-0024) — while folding declared dependencies
|
|
956
989
|
* (`needs`, ADR-0025) into whichever of those the read is already using.
|
|
957
990
|
*
|
|
958
|
-
* A fragment's own `include` and a sudo caller's `include` are
|
|
959
|
-
*
|
|
960
|
-
* caller
|
|
961
|
-
*
|
|
962
|
-
*
|
|
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
|
|
963
997
|
* stays on the exact ADR-0024 path — `include: undefined`, no related
|
|
964
998
|
* `query` access evaluated — unless folding actually added something, which
|
|
965
999
|
* only happens when a field on this list declares `needs`.
|
|
@@ -973,9 +1007,10 @@ export function buildDbDelegate<TPrisma extends PrismaClientLike>(
|
|
|
973
1007
|
*
|
|
974
1008
|
* Also returns `toOneAccessFilters` — the to-one relations `buildAccessScopedInclude`
|
|
975
1009
|
* flagged as needing a post-query existence check rather than a Prisma-side
|
|
976
|
-
* `where` (issue #974). Only
|
|
977
|
-
* non-empty tree:
|
|
978
|
-
* access at all.
|
|
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.
|
|
979
1014
|
*/
|
|
980
1015
|
async function resolveReadInclude(
|
|
981
1016
|
callerInclude: Record<string, unknown> | undefined,
|
|
@@ -990,6 +1025,7 @@ async function resolveReadInclude(
|
|
|
990
1025
|
declaredOnly: DeclaredOnlyTree
|
|
991
1026
|
selection: FieldSelectionScope | undefined
|
|
992
1027
|
toOneAccessFilters: ToOneAccessFilterTree
|
|
1028
|
+
countDenials: CountAccessDenialTree
|
|
993
1029
|
}> {
|
|
994
1030
|
if (fragmentFields !== undefined) {
|
|
995
1031
|
const fragmentInclude = buildInclude(fragmentFields) ?? undefined
|
|
@@ -1002,27 +1038,66 @@ async function resolveReadInclude(
|
|
|
1002
1038
|
[listName],
|
|
1003
1039
|
selection,
|
|
1004
1040
|
)
|
|
1005
|
-
|
|
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
|
+
}
|
|
1006
1065
|
}
|
|
1007
1066
|
|
|
1008
1067
|
if (context._isSudo) {
|
|
1009
1068
|
const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config, listName)
|
|
1010
|
-
return {
|
|
1069
|
+
return {
|
|
1070
|
+
...folded,
|
|
1071
|
+
selection: undefined,
|
|
1072
|
+
toOneAccessFilters: emptyToOneAccessFilterTree(),
|
|
1073
|
+
countDenials: emptyCountAccessDenialTree(),
|
|
1074
|
+
}
|
|
1011
1075
|
}
|
|
1012
1076
|
|
|
1013
1077
|
const folded = foldDeclaredDependencies(callerInclude, listConfig.fields, config, listName)
|
|
1014
1078
|
if (!folded.include) {
|
|
1015
|
-
return {
|
|
1079
|
+
return {
|
|
1080
|
+
...folded,
|
|
1081
|
+
selection: undefined,
|
|
1082
|
+
toOneAccessFilters: emptyToOneAccessFilterTree(),
|
|
1083
|
+
countDenials: emptyCountAccessDenialTree(),
|
|
1084
|
+
}
|
|
1016
1085
|
}
|
|
1017
1086
|
|
|
1018
|
-
const { include, toOneAccessFilters } = await buildAccessScopedInclude(
|
|
1087
|
+
const { include, toOneAccessFilters, countDenials } = await buildAccessScopedInclude(
|
|
1019
1088
|
folded.include,
|
|
1020
1089
|
listConfig.fields,
|
|
1021
1090
|
{ session: context.session, context },
|
|
1022
1091
|
config,
|
|
1023
1092
|
listName,
|
|
1024
1093
|
)
|
|
1025
|
-
return {
|
|
1094
|
+
return {
|
|
1095
|
+
include,
|
|
1096
|
+
declaredOnly: folded.declaredOnly,
|
|
1097
|
+
selection: undefined,
|
|
1098
|
+
toOneAccessFilters,
|
|
1099
|
+
countDenials,
|
|
1100
|
+
}
|
|
1026
1101
|
}
|
|
1027
1102
|
|
|
1028
1103
|
function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
@@ -1079,14 +1154,15 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
1079
1154
|
// Resolve `include`, folding any declared dependencies (`needs`,
|
|
1080
1155
|
// ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
|
|
1081
1156
|
// already produces — see `resolveReadInclude`'s doc comment.
|
|
1082
|
-
let { include, declaredOnly, selection, toOneAccessFilters } =
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
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
|
+
)
|
|
1090
1166
|
|
|
1091
1167
|
// Virtual fields have no database column. Whichever path produced
|
|
1092
1168
|
// `include` (fragment, access-controlled merge, or sudo passthrough), a
|
|
@@ -1131,6 +1207,7 @@ function createFindUnique<TPrisma extends PrismaClientLike>(
|
|
|
1131
1207
|
declaredOnly,
|
|
1132
1208
|
selection,
|
|
1133
1209
|
toOneVisibility,
|
|
1210
|
+
countDenials,
|
|
1134
1211
|
)
|
|
1135
1212
|
|
|
1136
1213
|
if (fragment) {
|
|
@@ -1240,14 +1317,15 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
1240
1317
|
// Resolve `include`, folding any declared dependencies (`needs`,
|
|
1241
1318
|
// ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
|
|
1242
1319
|
// already produces — see `resolveReadInclude`'s doc comment.
|
|
1243
|
-
let { include, declaredOnly, selection, toOneAccessFilters } =
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
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
|
+
)
|
|
1251
1329
|
|
|
1252
1330
|
// Strips virtual keys from `include` before the Prisma call — see the
|
|
1253
1331
|
// `createFindUnique` comment above for why (#628, ADR-0027).
|
|
@@ -1289,6 +1367,7 @@ function createFindMany<TPrisma extends PrismaClientLike>(
|
|
|
1289
1367
|
declaredOnly,
|
|
1290
1368
|
selection,
|
|
1291
1369
|
toOneVisibility,
|
|
1370
|
+
countDenials,
|
|
1292
1371
|
),
|
|
1293
1372
|
),
|
|
1294
1373
|
)
|
|
@@ -1573,14 +1652,15 @@ function createGet<TPrisma extends PrismaClientLike>(
|
|
|
1573
1652
|
// Resolve `include`, folding any declared dependencies (`needs`,
|
|
1574
1653
|
// ADR-0025) in alongside whatever the fragment/caller/sudo/bare path
|
|
1575
1654
|
// already produces — see `resolveReadInclude`'s doc comment.
|
|
1576
|
-
let { include, declaredOnly, selection, toOneAccessFilters } =
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
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
|
+
)
|
|
1584
1664
|
|
|
1585
1665
|
// Virtual fields have no database column and must never reach Prisma (#628).
|
|
1586
1666
|
include = stripVirtualFieldsFromInclude(include, listConfig.fields, config)
|
|
@@ -1611,6 +1691,7 @@ function createGet<TPrisma extends PrismaClientLike>(
|
|
|
1611
1691
|
declaredOnly,
|
|
1612
1692
|
selection,
|
|
1613
1693
|
toOneVisibility,
|
|
1694
|
+
countDenials,
|
|
1614
1695
|
)
|
|
1615
1696
|
if (fragment) {
|
|
1616
1697
|
return pickFields(filtered, fragment._fields)
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { OpenSaasConfig, ListConfig, FieldConfig } from '../config/types.js'
|
|
2
|
-
import type {
|
|
2
|
+
import type { FieldAccess, PrismaFilter } from '../access/types.js'
|
|
3
|
+
import type { StackContext } from './index.js'
|
|
3
4
|
import {
|
|
4
5
|
checkAccess,
|
|
5
6
|
checkCreateAccess,
|
|
6
7
|
filterWritableFields,
|
|
7
8
|
getRelatedListConfig,
|
|
9
|
+
mergeFilters,
|
|
8
10
|
resolveSyntheticReverseRelation,
|
|
9
11
|
} from '../access/index.js'
|
|
10
12
|
import { checkFieldAccess } from '../access/field-access.js'
|
|
@@ -199,7 +201,7 @@ async function processNestedCreate(
|
|
|
199
201
|
relatedListName: string,
|
|
200
202
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
201
203
|
relatedListConfig: ListConfig<any>,
|
|
202
|
-
context:
|
|
204
|
+
context: StackContext,
|
|
203
205
|
config: OpenSaasConfig,
|
|
204
206
|
prisma: unknown,
|
|
205
207
|
afterTasks: AfterTask[],
|
|
@@ -391,7 +393,7 @@ async function verifyConnectReachable(
|
|
|
391
393
|
relatedListName: string,
|
|
392
394
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
393
395
|
relatedListConfig: ListConfig<any>,
|
|
394
|
-
context:
|
|
396
|
+
context: StackContext,
|
|
395
397
|
prisma: unknown,
|
|
396
398
|
owningFieldAccess: FieldAccess | undefined,
|
|
397
399
|
enclosingOperation: 'create' | 'update',
|
|
@@ -456,7 +458,7 @@ async function processNestedConnect(
|
|
|
456
458
|
relatedListName: string,
|
|
457
459
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
458
460
|
relatedListConfig: ListConfig<any>,
|
|
459
|
-
context:
|
|
461
|
+
context: StackContext,
|
|
460
462
|
prisma: unknown,
|
|
461
463
|
owningFieldAccess: FieldAccess | undefined,
|
|
462
464
|
enclosingOperation: 'create' | 'update',
|
|
@@ -484,6 +486,28 @@ async function processNestedConnect(
|
|
|
484
486
|
return connections
|
|
485
487
|
}
|
|
486
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
|
+
|
|
487
511
|
/**
|
|
488
512
|
* Process nested update operations.
|
|
489
513
|
*
|
|
@@ -497,7 +521,7 @@ async function processNestedUpdate(
|
|
|
497
521
|
relatedListName: string,
|
|
498
522
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
499
523
|
relatedListConfig: ListConfig<any>,
|
|
500
|
-
context:
|
|
524
|
+
context: StackContext,
|
|
501
525
|
config: OpenSaasConfig,
|
|
502
526
|
prisma: unknown,
|
|
503
527
|
afterTasks: AfterTask[],
|
|
@@ -529,7 +553,7 @@ async function processNestedUpdate(
|
|
|
529
553
|
context,
|
|
530
554
|
})
|
|
531
555
|
|
|
532
|
-
if (
|
|
556
|
+
if (!(await isExistingTargetAccessible(model, where, accessResult))) {
|
|
533
557
|
throw new Error('Access denied: Cannot update related item')
|
|
534
558
|
}
|
|
535
559
|
}
|
|
@@ -693,7 +717,7 @@ async function processNestedDelete(
|
|
|
693
717
|
relatedListName: string,
|
|
694
718
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
695
719
|
relatedListConfig: ListConfig<any>,
|
|
696
|
-
context:
|
|
720
|
+
context: StackContext,
|
|
697
721
|
prisma: unknown,
|
|
698
722
|
afterTasks: AfterTask[],
|
|
699
723
|
): Promise<Record<string, unknown> | Array<Record<string, unknown>> | boolean> {
|
|
@@ -726,7 +750,7 @@ async function processNestedDelete(
|
|
|
726
750
|
context,
|
|
727
751
|
})
|
|
728
752
|
|
|
729
|
-
if (
|
|
753
|
+
if (!(await isExistingTargetAccessible(model, where, accessResult))) {
|
|
730
754
|
throw new Error('Access denied: Cannot delete related item')
|
|
731
755
|
}
|
|
732
756
|
}
|
|
@@ -801,7 +825,7 @@ async function processNestedConnectOrCreate(
|
|
|
801
825
|
relatedListName: string,
|
|
802
826
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
803
827
|
relatedListConfig: ListConfig<any>,
|
|
804
|
-
context:
|
|
828
|
+
context: StackContext,
|
|
805
829
|
config: OpenSaasConfig,
|
|
806
830
|
prisma: unknown,
|
|
807
831
|
afterTasks: AfterTask[],
|
|
@@ -922,7 +946,7 @@ interface NestedOpHandlerArgs {
|
|
|
922
946
|
enclosingItem: Record<string, unknown> | undefined
|
|
923
947
|
/** The enclosing write's input data, passed to the owning-field gate for the same reason as `enclosingItem`. */
|
|
924
948
|
enclosingInputData: Record<string, unknown> | undefined
|
|
925
|
-
context:
|
|
949
|
+
context: StackContext
|
|
926
950
|
config: OpenSaasConfig
|
|
927
951
|
/** Prisma client used for dynamic model access during access checks. */
|
|
928
952
|
prisma: unknown
|
|
@@ -1190,7 +1214,7 @@ export async function processNestedOperations(
|
|
|
1190
1214
|
data: Record<string, unknown>,
|
|
1191
1215
|
fieldConfigs: Record<string, FieldConfig>,
|
|
1192
1216
|
config: OpenSaasConfig,
|
|
1193
|
-
context:
|
|
1217
|
+
context: StackContext & { prisma: unknown },
|
|
1194
1218
|
operation: 'create' | 'update',
|
|
1195
1219
|
parentListName: string,
|
|
1196
1220
|
parentOriginalItem: Record<string, unknown> | undefined,
|
|
@@ -23,10 +23,11 @@ import { enumerateInvolvedLists, runWithTransactionBoundary } from './transactio
|
|
|
23
23
|
import { TransactionRegistry } from '../access/transaction-registry.js'
|
|
24
24
|
import { getDbKey } from '../lib/case-utils.js'
|
|
25
25
|
// NOTE: `index.ts` imports from this module too — this is an intentional cyclic
|
|
26
|
-
// dependency. It is safe because `
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
import {
|
|
26
|
+
// dependency. It is safe because `getContext` is only INVOKED at write time
|
|
27
|
+
// (never during module evaluation), so by the time it runs the export is fully
|
|
28
|
+
// initialised.
|
|
29
|
+
import { getContext } from './index.js'
|
|
30
|
+
import type { StackContext } from './index.js'
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* Write Pipeline — runs the canonical, secured write sequence for one
|
|
@@ -166,6 +167,22 @@ export interface WritePipelineArgs<TPrisma extends PrismaClientLike> {
|
|
|
166
167
|
preResolvedTarget?: TargetResolution
|
|
167
168
|
}
|
|
168
169
|
|
|
170
|
+
/**
|
|
171
|
+
* {@link WritePipelineArgs} narrowed to the in-transaction phase: `context` is
|
|
172
|
+
* the full {@link StackContext} `bindContextToTransaction` rebuilds (issue
|
|
173
|
+
* #1176), not the plain {@link AccessContext} the pre-transaction gate and the
|
|
174
|
+
* transaction-boundary hooks use. `StackContext` is a structural superset of
|
|
175
|
+
* `AccessContext` (it carries `_resolveOutputChain`/`_transactionOwner` too),
|
|
176
|
+
* so nothing downstream of {@link runWriteInTransaction} that only needs
|
|
177
|
+
* `AccessContext` requires any change.
|
|
178
|
+
*/
|
|
179
|
+
type WriteInTransactionArgs<TPrisma extends PrismaClientLike> = Omit<
|
|
180
|
+
WritePipelineArgs<TPrisma>,
|
|
181
|
+
'context'
|
|
182
|
+
> & {
|
|
183
|
+
context: StackContext<TPrisma>
|
|
184
|
+
}
|
|
185
|
+
|
|
169
186
|
/**
|
|
170
187
|
* Run the canonical secured write sequence once. Phase order matches the
|
|
171
188
|
* "Write Pipeline" glossary entry in CONTEXT.md.
|
|
@@ -242,41 +259,44 @@ export async function runWritePipeline<TPrisma extends PrismaClientLike>(
|
|
|
242
259
|
}
|
|
243
260
|
|
|
244
261
|
/**
|
|
245
|
-
* Build
|
|
246
|
-
* client `tx`, so a `context.db` write a hook
|
|
247
|
-
* back with — this write's transaction
|
|
262
|
+
* Build the full {@link StackContext} a hook's `context` is (issue #1176):
|
|
263
|
+
* bound to the transaction client `tx`, so a `context.db` write a hook
|
|
264
|
+
* performs runs inside — and rolls back with — this write's transaction
|
|
265
|
+
* (ADR-0010), and carrying `sudo()`/`withSession()`/`transaction()` so a hook
|
|
266
|
+
* can reach an elevated or substituted read/write that stays on the SAME
|
|
267
|
+
* transaction client rather than escaping to the base one.
|
|
248
268
|
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
* `_resolveOutputChain`
|
|
254
|
-
* keeps that hook's chain
|
|
269
|
+
* Goes through the same {@link getContext} factory `context.transaction()`
|
|
270
|
+
* already rebuilds through (ADR-0012), rather than hand-assembling a plain
|
|
271
|
+
* object literal — reusing the request context's `session`, `storage`,
|
|
272
|
+
* `_isSudo`, `plugins` (as `_sharedPlugins`, so plugin `runtime()` is NOT
|
|
273
|
+
* re-executed on the rebind), and `_resolveOutputChain` (so a write issued
|
|
274
|
+
* from inside a `resolveOutput` hook keeps that hook's cycle-guard chain,
|
|
275
|
+
* ADR-0023) as-is.
|
|
255
276
|
*
|
|
256
277
|
* `transactionOwner` (ADR-0028) is carried onto the rebuilt context so a hook's
|
|
257
|
-
* own `context.db` write
|
|
258
|
-
*
|
|
278
|
+
* own `context.db` write — and any write reached through its `sudo()`/
|
|
279
|
+
* `withSession()` — defers its transaction-boundary bracket to that owner
|
|
280
|
+
* instead of firing eagerly; `context.transaction()` called from a hook joins
|
|
281
|
+
* this same owner rather than opening a nested transaction, for the same
|
|
282
|
+
* reason.
|
|
259
283
|
*/
|
|
260
284
|
function bindContextToTransaction<TPrisma extends PrismaClientLike>(
|
|
261
285
|
args: WritePipelineArgs<TPrisma>,
|
|
262
286
|
tx: TPrisma,
|
|
263
287
|
transactionOwner: TransactionRegistry | undefined,
|
|
264
|
-
):
|
|
288
|
+
): StackContext<TPrisma> {
|
|
265
289
|
const { context, config } = args
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
// Rebuild `db` against `tx`, referencing `txContext` itself so hooks reached
|
|
277
|
-
// through it see the transactional context.
|
|
278
|
-
txContext.db = buildDbDelegate(config, tx, txContext)
|
|
279
|
-
return txContext
|
|
290
|
+
return getContext(
|
|
291
|
+
config,
|
|
292
|
+
tx,
|
|
293
|
+
context.session,
|
|
294
|
+
context.storage,
|
|
295
|
+
context._isSudo,
|
|
296
|
+
context.plugins,
|
|
297
|
+
transactionOwner,
|
|
298
|
+
context._resolveOutputChain,
|
|
299
|
+
)
|
|
280
300
|
}
|
|
281
301
|
|
|
282
302
|
/**
|
|
@@ -286,7 +306,7 @@ function bindContextToTransaction<TPrisma extends PrismaClientLike>(
|
|
|
286
306
|
* `runInTransaction` and rolls the transaction back.
|
|
287
307
|
*/
|
|
288
308
|
async function runWriteInTransaction<TPrisma extends PrismaClientLike>(
|
|
289
|
-
args:
|
|
309
|
+
args: WriteInTransactionArgs<TPrisma>,
|
|
290
310
|
): Promise<Record<string, unknown> | null> {
|
|
291
311
|
const { listName, listConfig, prisma: tx, context, config, inputData, strategy } = args
|
|
292
312
|
const { operation } = strategy
|
|
@@ -485,7 +505,7 @@ async function runDeletePath(args: {
|
|
|
485
505
|
listName: string
|
|
486
506
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
|
|
487
507
|
listConfig: ListConfig<any>
|
|
488
|
-
context:
|
|
508
|
+
context: StackContext
|
|
489
509
|
originalItem: Record<string, unknown> | undefined
|
|
490
510
|
model: PrismaModel
|
|
491
511
|
strategy: WriteStrategy
|