@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.
Files changed (165) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +189 -0
  3. package/CLAUDE.md +103 -0
  4. package/dist/access/access-filter.d.ts +157 -14
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +470 -42
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +982 -17
  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 +54 -1
  15. package/dist/access/engine.d.ts.map +1 -1
  16. package/dist/access/engine.js +84 -0
  17. package/dist/access/engine.js.map +1 -1
  18. package/dist/access/engine.test.d.ts +2 -0
  19. package/dist/access/engine.test.d.ts.map +1 -0
  20. package/dist/access/engine.test.js +56 -0
  21. package/dist/access/engine.test.js.map +1 -0
  22. package/dist/access/errors.d.ts +62 -0
  23. package/dist/access/errors.d.ts.map +1 -1
  24. package/dist/access/errors.js +85 -2
  25. package/dist/access/errors.js.map +1 -1
  26. package/dist/access/field-access.d.ts +12 -0
  27. package/dist/access/field-access.d.ts.map +1 -1
  28. package/dist/access/field-access.js +22 -2
  29. package/dist/access/field-access.js.map +1 -1
  30. package/dist/access/field-access.test.js +66 -0
  31. package/dist/access/field-access.test.js.map +1 -1
  32. package/dist/access/field-visibility.d.ts +2 -1
  33. package/dist/access/field-visibility.d.ts.map +1 -1
  34. package/dist/access/field-visibility.js +107 -15
  35. package/dist/access/field-visibility.js.map +1 -1
  36. package/dist/access/index.d.ts +9 -2
  37. package/dist/access/index.d.ts.map +1 -1
  38. package/dist/access/index.js +11 -2
  39. package/dist/access/index.js.map +1 -1
  40. package/dist/access/multi-column-read-write.test.js.map +1 -1
  41. package/dist/access/query-validation.d.ts +31 -0
  42. package/dist/access/query-validation.d.ts.map +1 -1
  43. package/dist/access/query-validation.js +75 -16
  44. package/dist/access/query-validation.js.map +1 -1
  45. package/dist/access/relationship-count.d.ts +41 -3
  46. package/dist/access/relationship-count.d.ts.map +1 -1
  47. package/dist/access/relationship-count.js +44 -7
  48. package/dist/access/relationship-count.js.map +1 -1
  49. package/dist/access/relationship-count.test.js +62 -0
  50. package/dist/access/relationship-count.test.js.map +1 -1
  51. package/dist/access/synthetic-include-read.test.d.ts +2 -0
  52. package/dist/access/synthetic-include-read.test.d.ts.map +1 -0
  53. package/dist/access/synthetic-include-read.test.js +121 -0
  54. package/dist/access/synthetic-include-read.test.js.map +1 -0
  55. package/dist/config/types.d.ts +98 -32
  56. package/dist/config/types.d.ts.map +1 -1
  57. package/dist/context/hook-pipeline.d.ts +2 -2
  58. package/dist/context/hook-pipeline.d.ts.map +1 -1
  59. package/dist/context/hook-pipeline.js.map +1 -1
  60. package/dist/context/index.d.ts +35 -2
  61. package/dist/context/index.d.ts.map +1 -1
  62. package/dist/context/index.js +121 -27
  63. package/dist/context/index.js.map +1 -1
  64. package/dist/context/nested-operations.d.ts +2 -2
  65. package/dist/context/nested-operations.d.ts.map +1 -1
  66. package/dist/context/nested-operations.js +74 -15
  67. package/dist/context/nested-operations.js.map +1 -1
  68. package/dist/context/write-pipeline.d.ts +3 -2
  69. package/dist/context/write-pipeline.d.ts.map +1 -1
  70. package/dist/context/write-pipeline.js +31 -34
  71. package/dist/context/write-pipeline.js.map +1 -1
  72. package/dist/fields/index.d.ts +13 -1
  73. package/dist/fields/index.d.ts.map +1 -1
  74. package/dist/fields/index.js +50 -5
  75. package/dist/fields/index.js.map +1 -1
  76. package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
  77. package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
  78. package/dist/fields/relationship-foreign-key.test.js +18 -0
  79. package/dist/fields/relationship-foreign-key.test.js.map +1 -0
  80. package/dist/hooks/index.d.ts +16 -15
  81. package/dist/hooks/index.d.ts.map +1 -1
  82. package/dist/hooks/index.js.map +1 -1
  83. package/dist/index.d.ts +4 -1
  84. package/dist/index.d.ts.map +1 -1
  85. package/dist/index.js +14 -1
  86. package/dist/index.js.map +1 -1
  87. package/dist/lib/case-utils.d.ts +15 -0
  88. package/dist/lib/case-utils.d.ts.map +1 -1
  89. package/dist/lib/case-utils.js +21 -0
  90. package/dist/lib/case-utils.js.map +1 -1
  91. package/dist/lib/case-utils.test.js +23 -1
  92. package/dist/lib/case-utils.test.js.map +1 -1
  93. package/dist/lib/prisma-errors.d.ts +38 -0
  94. package/dist/lib/prisma-errors.d.ts.map +1 -0
  95. package/dist/lib/prisma-errors.js +53 -0
  96. package/dist/lib/prisma-errors.js.map +1 -0
  97. package/dist/mcp/constants.d.ts +23 -0
  98. package/dist/mcp/constants.d.ts.map +1 -0
  99. package/dist/mcp/constants.js +23 -0
  100. package/dist/mcp/constants.js.map +1 -0
  101. package/dist/mcp/field-schema.d.ts +8 -0
  102. package/dist/mcp/field-schema.d.ts.map +1 -0
  103. package/dist/mcp/field-schema.js +69 -0
  104. package/dist/mcp/field-schema.js.map +1 -0
  105. package/dist/mcp/handler.d.ts.map +1 -1
  106. package/dist/mcp/handler.js +76 -90
  107. package/dist/mcp/handler.js.map +1 -1
  108. package/dist/mcp/projection.d.ts +62 -0
  109. package/dist/mcp/projection.d.ts.map +1 -0
  110. package/dist/mcp/projection.js +390 -0
  111. package/dist/mcp/projection.js.map +1 -0
  112. package/dist/utils/password.d.ts +3 -1
  113. package/dist/utils/password.d.ts.map +1 -1
  114. package/dist/utils/password.js +4 -2
  115. package/dist/utils/password.js.map +1 -1
  116. package/package.json +2 -2
  117. package/src/access/access-filter.test.ts +1528 -18
  118. package/src/access/access-filter.ts +708 -42
  119. package/src/access/denied-relation-visibility.test.ts +173 -0
  120. package/src/access/engine.test.ts +67 -0
  121. package/src/access/engine.ts +109 -1
  122. package/src/access/errors.ts +97 -2
  123. package/src/access/field-access.test.ts +78 -0
  124. package/src/access/field-access.ts +34 -2
  125. package/src/access/field-visibility.ts +171 -25
  126. package/src/access/index.ts +25 -0
  127. package/src/access/multi-column-read-write.test.ts +3 -2
  128. package/src/access/query-validation.ts +136 -9
  129. package/src/access/relationship-count.test.ts +75 -0
  130. package/src/access/relationship-count.ts +53 -10
  131. package/src/access/synthetic-include-read.test.ts +179 -0
  132. package/src/config/types.ts +98 -32
  133. package/src/context/hook-pipeline.ts +4 -2
  134. package/src/context/index.ts +203 -42
  135. package/src/context/nested-operations.ts +96 -27
  136. package/src/context/write-pipeline.ts +60 -36
  137. package/src/fields/index.ts +55 -5
  138. package/src/fields/relationship-foreign-key.test.ts +29 -0
  139. package/src/hooks/index.ts +22 -15
  140. package/src/index.ts +17 -1
  141. package/src/lib/case-utils.test.ts +32 -0
  142. package/src/lib/case-utils.ts +26 -0
  143. package/src/lib/prisma-errors.ts +86 -0
  144. package/src/mcp/constants.ts +24 -0
  145. package/src/mcp/field-schema.ts +84 -0
  146. package/src/mcp/handler.ts +109 -107
  147. package/src/mcp/projection.ts +497 -0
  148. package/src/utils/password.ts +5 -3
  149. package/tests/access-relationships.test.ts +3 -3
  150. package/tests/access.test.ts +83 -0
  151. package/tests/context.test.ts +452 -17
  152. package/tests/field-types.test.ts +113 -0
  153. package/tests/hook-context-secured.test.ts +424 -0
  154. package/tests/mcp-fields-projection-access.test.ts +250 -0
  155. package/tests/mcp-handler.test.ts +695 -4
  156. package/tests/nested-access-and-hooks.test.ts +389 -0
  157. package/tests/nested-write-synthetic-relation.test.ts +437 -0
  158. package/tests/password-types.test.ts +28 -0
  159. package/tests/password.test.ts +16 -2
  160. package/tests/prisma-errors.test.ts +141 -0
  161. package/tests/singleton.test.ts +9 -1
  162. package/tests/to-one-include-access-filter.test.ts +172 -0
  163. package/tests/with-session.test.ts +243 -0
  164. package/tests/write-pipeline.test.ts +29 -0
  165. package/tsconfig.tsbuildinfo +1 -1
@@ -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.meta?.target
234
+ const target = uniqueConstraintOf(prismaError)?.fields
219
235
  const fieldErrors: Record<string, string> = {}
220
236
 
221
- if (target && Array.isArray(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] = `This ${label.toLowerCase()} is already in use`
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((f) => f.charAt(0).toUpperCase() + f.slice(1)).join(', ')
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), and `transaction()` (interactive, hook-firing
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 folded and
913
- * used as-is, matching their existing (unmerged) treatment. A non-sudo
914
- * caller include is folded and then scoped by `buildAccessScopedInclude`
915
- * (ADR-0026) caller-directed, so a relation named nowhere in the folded
916
- * 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
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
- return { ...folded, selection }
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 { ...folded, selection: undefined }
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 { ...folded, selection: undefined }
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 { include, declaredOnly: folded.declaredOnly, selection: undefined }
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 } = await resolveReadInclude(
1030
- args.include,
1031
- fragment ? fragment._fields : undefined,
1032
- listName,
1033
- listConfig,
1034
- context,
1035
- config,
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 } = await resolveReadInclude(
1182
- args?.include,
1183
- fragment ? fragment._fields : undefined,
1184
- listName,
1185
- listConfig,
1186
- context,
1187
- config,
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 } = await resolveReadInclude(
1505
- args?.include,
1506
- fragment ? fragment._fields : undefined,
1507
- listName,
1508
- listConfig,
1509
- context,
1510
- config,
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 { AccessContext, FieldAccess } from '../access/types.js'
3
- import { checkAccess, filterWritableFields, getRelatedListConfig } from '../access/index.js'
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: AccessContext,
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 accessResult = await checkAccess(createAccess, {
216
+ const allowed = await checkCreateAccess(relatedListName, createAccess, {
209
217
  session: context.session,
210
218
  context,
211
219
  })
212
220
 
213
- if (accessResult === false) {
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: AccessContext,
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: AccessContext,
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: AccessContext,
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 (accessResult === false) {
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: AccessContext,
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 (accessResult === false) {
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: AccessContext,
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: AccessContext
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: AccessContext & { prisma: unknown },
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
- const fieldConfig = fieldConfigs[fieldName]
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 relationshipField = fieldConfig as { type: 'relationship'; ref: string }
1205
- const relatedConfig = getRelatedListConfig(relationshipField.ref, config)
1206
- if (!relatedConfig) {
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>,