@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.
Files changed (86) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +65 -0
  3. package/CLAUDE.md +52 -0
  4. package/dist/access/access-filter.d.ts +88 -18
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +343 -36
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +759 -6
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/denied-relation-visibility.test.d.ts +2 -0
  11. package/dist/access/denied-relation-visibility.test.d.ts.map +1 -0
  12. package/dist/access/denied-relation-visibility.test.js +114 -0
  13. package/dist/access/denied-relation-visibility.test.js.map +1 -0
  14. package/dist/access/engine.d.ts +11 -0
  15. package/dist/access/engine.d.ts.map +1 -1
  16. package/dist/access/engine.js +26 -0
  17. package/dist/access/engine.js.map +1 -1
  18. package/dist/access/errors.d.ts +34 -0
  19. package/dist/access/errors.d.ts.map +1 -1
  20. package/dist/access/errors.js +47 -0
  21. package/dist/access/errors.js.map +1 -1
  22. package/dist/access/field-visibility.d.ts +2 -2
  23. package/dist/access/field-visibility.d.ts.map +1 -1
  24. package/dist/access/field-visibility.js +73 -23
  25. package/dist/access/field-visibility.js.map +1 -1
  26. package/dist/access/index.d.ts +7 -3
  27. package/dist/access/index.d.ts.map +1 -1
  28. package/dist/access/index.js +9 -2
  29. package/dist/access/index.js.map +1 -1
  30. package/dist/access/multi-column-read-write.test.js.map +1 -1
  31. package/dist/access/query-validation.d.ts +31 -0
  32. package/dist/access/query-validation.d.ts.map +1 -1
  33. package/dist/access/query-validation.js +75 -16
  34. package/dist/access/query-validation.js.map +1 -1
  35. package/dist/access/relationship-count.d.ts +41 -3
  36. package/dist/access/relationship-count.d.ts.map +1 -1
  37. package/dist/access/relationship-count.js +44 -7
  38. package/dist/access/relationship-count.js.map +1 -1
  39. package/dist/access/relationship-count.test.js +62 -0
  40. package/dist/access/relationship-count.test.js.map +1 -1
  41. package/dist/access/synthetic-include-read.test.d.ts +2 -0
  42. package/dist/access/synthetic-include-read.test.d.ts.map +1 -0
  43. package/dist/access/synthetic-include-read.test.js +121 -0
  44. package/dist/access/synthetic-include-read.test.js.map +1 -0
  45. package/dist/config/types.d.ts +70 -58
  46. package/dist/config/types.d.ts.map +1 -1
  47. package/dist/context/hook-pipeline.d.ts +2 -2
  48. package/dist/context/hook-pipeline.d.ts.map +1 -1
  49. package/dist/context/hook-pipeline.js.map +1 -1
  50. package/dist/context/index.d.ts +16 -1
  51. package/dist/context/index.d.ts.map +1 -1
  52. package/dist/context/index.js +76 -26
  53. package/dist/context/index.js.map +1 -1
  54. package/dist/context/nested-operations.d.ts +2 -2
  55. package/dist/context/nested-operations.d.ts.map +1 -1
  56. package/dist/context/nested-operations.js +21 -3
  57. package/dist/context/nested-operations.js.map +1 -1
  58. package/dist/context/write-pipeline.d.ts.map +1 -1
  59. package/dist/context/write-pipeline.js +23 -29
  60. package/dist/context/write-pipeline.js.map +1 -1
  61. package/dist/hooks/index.d.ts +16 -15
  62. package/dist/hooks/index.d.ts.map +1 -1
  63. package/dist/hooks/index.js.map +1 -1
  64. package/package.json +2 -2
  65. package/src/access/access-filter.test.ts +1403 -159
  66. package/src/access/access-filter.ts +525 -47
  67. package/src/access/denied-relation-visibility.test.ts +173 -0
  68. package/src/access/engine.ts +28 -0
  69. package/src/access/errors.ts +55 -0
  70. package/src/access/field-visibility.ts +105 -24
  71. package/src/access/index.ts +11 -0
  72. package/src/access/multi-column-read-write.test.ts +3 -2
  73. package/src/access/query-validation.ts +136 -9
  74. package/src/access/relationship-count.test.ts +75 -0
  75. package/src/access/relationship-count.ts +53 -10
  76. package/src/access/synthetic-include-read.test.ts +179 -0
  77. package/src/config/types.ts +76 -50
  78. package/src/context/hook-pipeline.ts +4 -2
  79. package/src/context/index.ts +120 -39
  80. package/src/context/nested-operations.ts +35 -11
  81. package/src/context/write-pipeline.ts +52 -32
  82. package/src/hooks/index.ts +22 -15
  83. package/tests/context.test.ts +193 -5
  84. package/tests/hook-context-secured.test.ts +424 -0
  85. package/tests/nested-access-and-hooks.test.ts +324 -0
  86. package/tsconfig.tsbuildinfo +1 -1
@@ -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 folded and
959
- * used as-is, matching their existing (unmerged) treatment. A non-sudo
960
- * caller include is folded and then scoped by `buildAccessScopedInclude`
961
- * (ADR-0026) caller-directed, so a relation named nowhere in the folded
962
- * tree never has its list's `query` access evaluated at all. A bare read
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 the non-sudo caller-include path can produce a
977
- * non-empty tree: it's the only path that evaluates a related list's `query`
978
- * access at all. The fragment and sudo paths always return an empty tree.
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
- return { ...folded, selection, toOneAccessFilters: emptyToOneAccessFilterTree() }
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 { ...folded, selection: undefined, toOneAccessFilters: emptyToOneAccessFilterTree() }
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 { ...folded, selection: undefined, toOneAccessFilters: emptyToOneAccessFilterTree() }
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 { include, declaredOnly: folded.declaredOnly, selection: undefined, toOneAccessFilters }
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 } = await resolveReadInclude(
1083
- args.include,
1084
- fragment ? fragment._fields : undefined,
1085
- listName,
1086
- listConfig,
1087
- context,
1088
- config,
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 } = await resolveReadInclude(
1244
- args?.include,
1245
- fragment ? fragment._fields : undefined,
1246
- listName,
1247
- listConfig,
1248
- context,
1249
- config,
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 } = await resolveReadInclude(
1577
- args?.include,
1578
- fragment ? fragment._fields : undefined,
1579
- listName,
1580
- listConfig,
1581
- context,
1582
- config,
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 { AccessContext, FieldAccess } from '../access/types.js'
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: AccessContext,
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: AccessContext,
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: AccessContext,
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: AccessContext,
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 (accessResult === false) {
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: AccessContext,
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 (accessResult === false) {
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: AccessContext,
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: AccessContext
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: AccessContext & { prisma: unknown },
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 `buildDbDelegate` is only INVOKED at write
27
- // time (never during module evaluation), so by the time it runs the export is
28
- // fully initialised.
29
- import { buildDbDelegate } from './index.js'
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 an {@link AccessContext} whose `db`/`prisma` target the transaction
246
- * client `tx`, so a `context.db` write a hook performs runs inside — and rolls
247
- * back with — this write's transaction (ADR-0010).
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
- * The access-controlled `db` delegates capture their Prisma client at
250
- * construction, so swapping `context.prisma` alone would not rebind `db` — we
251
- * rebuild the delegates against `tx` via {@link buildDbDelegate}, reusing the
252
- * request context's `session`, `storage`, `plugins`, `_isSudo`, and
253
- * `_resolveOutputChain` as-is (so a write from inside a `resolveOutput` hook
254
- * keeps that hook's chain). Plugin runtimes are NOT re-executed.
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 defers its transaction-boundary bracket to that owner
258
- * instead of firing eagerly.
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
- ): AccessContext<TPrisma> {
288
+ ): StackContext<TPrisma> {
265
289
  const { context, config } = args
266
- const txContext: AccessContext<TPrisma> = {
267
- session: context.session,
268
- prisma: tx,
269
- db: context.db,
270
- storage: context.storage,
271
- plugins: context.plugins,
272
- _isSudo: context._isSudo,
273
- _resolveOutputChain: context._resolveOutputChain,
274
- _transactionOwner: transactionOwner,
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: WritePipelineArgs<TPrisma>,
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: AccessContext
508
+ context: StackContext
489
509
  originalItem: Record<string, unknown> | undefined
490
510
  model: PrismaModel
491
511
  strategy: WriteStrategy