@opensaas/stack-core 0.37.0 → 0.39.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 (226) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +191 -0
  3. package/CLAUDE.md +42 -3
  4. package/dist/access/access-filter.d.ts +70 -113
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +183 -210
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +148 -188
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/declared-dependencies.d.ts +66 -26
  11. package/dist/access/declared-dependencies.d.ts.map +1 -1
  12. package/dist/access/declared-dependencies.js +62 -17
  13. package/dist/access/declared-dependencies.js.map +1 -1
  14. package/dist/access/declared-dependencies.test.d.ts +2 -0
  15. package/dist/access/declared-dependencies.test.d.ts.map +1 -0
  16. package/dist/access/declared-dependencies.test.js +226 -0
  17. package/dist/access/declared-dependencies.test.js.map +1 -0
  18. package/dist/access/depth-limits.d.ts +8 -7
  19. package/dist/access/depth-limits.d.ts.map +1 -1
  20. package/dist/access/depth-limits.js +8 -7
  21. package/dist/access/depth-limits.js.map +1 -1
  22. package/dist/access/engine.d.ts +0 -20
  23. package/dist/access/engine.d.ts.map +1 -1
  24. package/dist/access/engine.js +0 -25
  25. package/dist/access/engine.js.map +1 -1
  26. package/dist/access/errors.d.ts +61 -8
  27. package/dist/access/errors.d.ts.map +1 -1
  28. package/dist/access/errors.js +91 -12
  29. package/dist/access/errors.js.map +1 -1
  30. package/dist/access/field-access.d.ts +39 -1
  31. package/dist/access/field-access.d.ts.map +1 -1
  32. package/dist/access/field-access.js +108 -42
  33. package/dist/access/field-access.js.map +1 -1
  34. package/dist/access/field-access.test.js +229 -2
  35. package/dist/access/field-access.test.js.map +1 -1
  36. package/dist/access/field-transforms.d.ts +0 -27
  37. package/dist/access/field-transforms.d.ts.map +1 -1
  38. package/dist/access/field-visibility.d.ts +2 -5
  39. package/dist/access/field-visibility.d.ts.map +1 -1
  40. package/dist/access/field-visibility.js +89 -41
  41. package/dist/access/field-visibility.js.map +1 -1
  42. package/dist/access/index.d.ts +6 -3
  43. package/dist/access/index.d.ts.map +1 -1
  44. package/dist/access/index.js +12 -2
  45. package/dist/access/index.js.map +1 -1
  46. package/dist/access/query-validation.d.ts +121 -0
  47. package/dist/access/query-validation.d.ts.map +1 -0
  48. package/dist/access/query-validation.js +292 -0
  49. package/dist/access/query-validation.js.map +1 -0
  50. package/dist/access/relationship-count.d.ts +1 -1
  51. package/dist/access/relationship-count.d.ts.map +1 -1
  52. package/dist/access/relationship-count.js +2 -10
  53. package/dist/access/relationship-count.js.map +1 -1
  54. package/dist/access/relationship-label-filter.d.ts +23 -23
  55. package/dist/access/relationship-label-filter.d.ts.map +1 -1
  56. package/dist/access/relationship-label-filter.js +5 -76
  57. package/dist/access/relationship-label-filter.js.map +1 -1
  58. package/dist/access/relationship-label-filter.test.js +19 -56
  59. package/dist/access/relationship-label-filter.test.js.map +1 -1
  60. package/dist/access/transaction-registry.d.ts +45 -0
  61. package/dist/access/transaction-registry.d.ts.map +1 -0
  62. package/dist/access/transaction-registry.js +37 -0
  63. package/dist/access/transaction-registry.js.map +1 -0
  64. package/dist/access/types.d.ts +55 -59
  65. package/dist/access/types.d.ts.map +1 -1
  66. package/dist/config/index.d.ts +9 -7
  67. package/dist/config/index.d.ts.map +1 -1
  68. package/dist/config/index.js +10 -18
  69. package/dist/config/index.js.map +1 -1
  70. package/dist/config/label.d.ts +6 -13
  71. package/dist/config/label.d.ts.map +1 -1
  72. package/dist/config/label.js +6 -13
  73. package/dist/config/label.js.map +1 -1
  74. package/dist/config/nav-count.d.ts +11 -19
  75. package/dist/config/nav-count.d.ts.map +1 -1
  76. package/dist/config/nav-count.js +11 -19
  77. package/dist/config/nav-count.js.map +1 -1
  78. package/dist/config/plugin-engine.d.ts +0 -10
  79. package/dist/config/plugin-engine.d.ts.map +1 -1
  80. package/dist/config/plugin-engine.js +1 -37
  81. package/dist/config/plugin-engine.js.map +1 -1
  82. package/dist/config/types.d.ts +94 -109
  83. package/dist/config/types.d.ts.map +1 -1
  84. package/dist/context/apply-defaults.d.ts +8 -31
  85. package/dist/context/apply-defaults.d.ts.map +1 -1
  86. package/dist/context/apply-defaults.js +12 -41
  87. package/dist/context/apply-defaults.js.map +1 -1
  88. package/dist/context/hook-pipeline.d.ts +4 -29
  89. package/dist/context/hook-pipeline.d.ts.map +1 -1
  90. package/dist/context/hook-pipeline.js +8 -41
  91. package/dist/context/hook-pipeline.js.map +1 -1
  92. package/dist/context/index.d.ts +4 -15
  93. package/dist/context/index.d.ts.map +1 -1
  94. package/dist/context/index.js +223 -215
  95. package/dist/context/index.js.map +1 -1
  96. package/dist/context/nested-operations.d.ts +7 -26
  97. package/dist/context/nested-operations.d.ts.map +1 -1
  98. package/dist/context/nested-operations.js +73 -171
  99. package/dist/context/nested-operations.js.map +1 -1
  100. package/dist/context/transaction-boundary.d.ts +36 -47
  101. package/dist/context/transaction-boundary.d.ts.map +1 -1
  102. package/dist/context/transaction-boundary.js +92 -79
  103. package/dist/context/transaction-boundary.js.map +1 -1
  104. package/dist/context/write-pipeline.d.ts +25 -64
  105. package/dist/context/write-pipeline.d.ts.map +1 -1
  106. package/dist/context/write-pipeline.js +81 -111
  107. package/dist/context/write-pipeline.js.map +1 -1
  108. package/dist/fields/format-prisma-default.d.ts.map +1 -1
  109. package/dist/fields/format-prisma-default.js +4 -7
  110. package/dist/fields/format-prisma-default.js.map +1 -1
  111. package/dist/fields/index.d.ts +17 -2
  112. package/dist/fields/index.d.ts.map +1 -1
  113. package/dist/fields/index.js +136 -147
  114. package/dist/fields/index.js.map +1 -1
  115. package/dist/filter/collect.d.ts +26 -3
  116. package/dist/filter/collect.d.ts.map +1 -1
  117. package/dist/filter/collect.js +30 -12
  118. package/dist/filter/collect.js.map +1 -1
  119. package/dist/filter/filter.test.js +98 -15
  120. package/dist/filter/filter.test.js.map +1 -1
  121. package/dist/filter/index.d.ts +1 -0
  122. package/dist/filter/index.d.ts.map +1 -1
  123. package/dist/filter/index.js.map +1 -1
  124. package/dist/filter/map.d.ts.map +1 -1
  125. package/dist/filter/map.js +0 -5
  126. package/dist/filter/map.js.map +1 -1
  127. package/dist/filter/parse.d.ts.map +1 -1
  128. package/dist/filter/parse.js +2 -9
  129. package/dist/filter/parse.js.map +1 -1
  130. package/dist/filter/serialize.d.ts.map +1 -1
  131. package/dist/filter/serialize.js +2 -4
  132. package/dist/filter/serialize.js.map +1 -1
  133. package/dist/hooks/index.d.ts +4 -42
  134. package/dist/hooks/index.d.ts.map +1 -1
  135. package/dist/hooks/index.js +5 -59
  136. package/dist/hooks/index.js.map +1 -1
  137. package/dist/index.d.ts +3 -1
  138. package/dist/index.d.ts.map +1 -1
  139. package/dist/index.js +20 -4
  140. package/dist/index.js.map +1 -1
  141. package/dist/internal.d.ts +1 -0
  142. package/dist/internal.d.ts.map +1 -1
  143. package/dist/internal.js +4 -0
  144. package/dist/internal.js.map +1 -1
  145. package/dist/lib/case-utils.d.ts +0 -32
  146. package/dist/lib/case-utils.d.ts.map +1 -1
  147. package/dist/lib/case-utils.js +0 -32
  148. package/dist/lib/case-utils.js.map +1 -1
  149. package/dist/mcp/handler.d.ts +0 -4
  150. package/dist/mcp/handler.d.ts.map +1 -1
  151. package/dist/mcp/handler.js +12 -69
  152. package/dist/mcp/handler.js.map +1 -1
  153. package/dist/query/index.d.ts +55 -227
  154. package/dist/query/index.d.ts.map +1 -1
  155. package/dist/query/index.js +41 -117
  156. package/dist/query/index.js.map +1 -1
  157. package/dist/query/relationship-options.d.ts +1 -1
  158. package/dist/query/relationship-options.js +1 -1
  159. package/dist/utils/password.d.ts +0 -68
  160. package/dist/utils/password.d.ts.map +1 -1
  161. package/dist/utils/password.js +7 -76
  162. package/dist/utils/password.js.map +1 -1
  163. package/dist/validation/field-config.d.ts.map +1 -1
  164. package/dist/validation/field-config.js +0 -6
  165. package/dist/validation/field-config.js.map +1 -1
  166. package/dist/validation/schema.d.ts +0 -7
  167. package/dist/validation/schema.d.ts.map +1 -1
  168. package/dist/validation/schema.js +0 -10
  169. package/dist/validation/schema.js.map +1 -1
  170. package/package.json +1 -1
  171. package/src/access/access-filter.test.ts +205 -275
  172. package/src/access/access-filter.ts +236 -272
  173. package/src/access/declared-dependencies.test.ts +277 -0
  174. package/src/access/declared-dependencies.ts +117 -37
  175. package/src/access/depth-limits.ts +8 -7
  176. package/src/access/engine.ts +0 -25
  177. package/src/access/errors.ts +97 -12
  178. package/src/access/field-access.test.ts +312 -2
  179. package/src/access/field-access.ts +118 -42
  180. package/src/access/field-transforms.ts +8 -39
  181. package/src/access/field-visibility.ts +99 -45
  182. package/src/access/index.ts +17 -5
  183. package/src/access/query-validation.ts +359 -0
  184. package/src/access/relationship-count.ts +3 -11
  185. package/src/access/relationship-label-filter.test.ts +19 -68
  186. package/src/access/relationship-label-filter.ts +27 -104
  187. package/src/access/transaction-registry.ts +58 -0
  188. package/src/access/types.ts +83 -86
  189. package/src/config/index.ts +10 -22
  190. package/src/config/label.ts +6 -13
  191. package/src/config/nav-count.ts +11 -19
  192. package/src/config/plugin-engine.ts +1 -37
  193. package/src/config/types.ts +95 -110
  194. package/src/context/apply-defaults.ts +12 -44
  195. package/src/context/hook-pipeline.ts +12 -67
  196. package/src/context/index.ts +311 -276
  197. package/src/context/nested-operations.ts +94 -226
  198. package/src/context/transaction-boundary.ts +113 -109
  199. package/src/context/write-pipeline.ts +99 -155
  200. package/src/fields/format-prisma-default.ts +4 -7
  201. package/src/fields/index.ts +155 -147
  202. package/src/filter/collect.ts +48 -12
  203. package/src/filter/filter.test.ts +153 -15
  204. package/src/filter/index.ts +1 -0
  205. package/src/filter/map.ts +0 -5
  206. package/src/filter/parse.ts +2 -9
  207. package/src/filter/serialize.ts +2 -4
  208. package/src/hooks/index.ts +5 -59
  209. package/src/index.ts +23 -4
  210. package/src/internal.ts +5 -0
  211. package/src/lib/case-utils.ts +0 -32
  212. package/src/mcp/handler.ts +13 -71
  213. package/src/query/index.ts +82 -235
  214. package/src/query/relationship-options.ts +1 -1
  215. package/src/utils/password.ts +7 -76
  216. package/src/validation/field-config.ts +0 -6
  217. package/src/validation/schema.ts +0 -10
  218. package/tests/access-relationships.test.ts +18 -16
  219. package/tests/computed-field-selective-evaluation.test.ts +418 -0
  220. package/tests/context.test.ts +727 -1
  221. package/tests/field-types.test.ts +222 -0
  222. package/tests/mcp-handler.test.ts +34 -0
  223. package/tests/needs-declared-dependencies.test.ts +7 -4
  224. package/tests/resolve-chain.test.ts +11 -11
  225. package/tests/transaction-boundary-outermost.test.ts +530 -0
  226. package/tsconfig.tsbuildinfo +1 -1
@@ -1,9 +1,48 @@
1
1
  import type { Session, AccessContext } from './types.js'
2
- import type { FieldAccess } from './types.js'
2
+ import type { FieldAccess, FieldAccessControl } from './types.js'
3
3
  // `ValidationError` is referenced only inside function bodies (call-time), never
4
4
  // at module-evaluation time, so the field-access ⇄ hooks import cycle is safe
5
5
  // under ESM live bindings.
6
6
  import { ValidationError } from '../hooks/index.js'
7
+ import { InvalidFieldAccessResultError } from './errors.js'
8
+
9
+ /**
10
+ * Marks a throw caused by touching {@link createPoisonedItem}'s `item`, as
11
+ * opposed to some other error a field rule legitimately raises. Not exported
12
+ * — callers only ever see its effect (a `false` from
13
+ * `isFieldReadableForPredicate`), never the class itself.
14
+ */
15
+ class PredicateTimeItemAccessError extends Error {}
16
+
17
+ /**
18
+ * An `item` that throws {@link PredicateTimeItemAccessError} on ANY attempt to
19
+ * read a property off it — including via optional chaining (`item?.x`),
20
+ * since the Proxy itself is a truthy object and optional chaining still
21
+ * performs the property read once its base is non-nullish. Used by
22
+ * `isFieldReadableForPredicate` to detect a read rule that depends on the
23
+ * fetched row: there is no row yet at predicate-evaluation time (see that
24
+ * function's doc), so any rule that reaches into `item` at all cannot be
25
+ * answered here.
26
+ */
27
+ function createPoisonedItem(): Record<string, unknown> {
28
+ return new Proxy(
29
+ {},
30
+ {
31
+ get(_target, prop) {
32
+ throw new PredicateTimeItemAccessError(String(prop))
33
+ },
34
+ has(_target, prop) {
35
+ throw new PredicateTimeItemAccessError(String(prop))
36
+ },
37
+ ownKeys() {
38
+ throw new PredicateTimeItemAccessError('ownKeys')
39
+ },
40
+ getOwnPropertyDescriptor(_target, prop) {
41
+ throw new PredicateTimeItemAccessError(String(prop))
42
+ },
43
+ },
44
+ ) as Record<string, unknown>
45
+ }
7
46
 
8
47
  /**
9
48
  * Shared field-level access evaluation.
@@ -24,6 +63,10 @@ import { ValidationError } from '../hooks/index.js'
24
63
  * (read/update/delete), and on the `inputData` being written (create/update),
25
64
  * so all of those are accepted. Do not introduce a parallel evaluator with a
26
65
  * narrower signature.
66
+ *
67
+ * Returns a strict boolean, never a filter: a rule that returns anything else
68
+ * throws `InvalidFieldAccessResultError` rather than defaulting to allow (see
69
+ * that error's doc, ADR-0001, and ADR-0030).
27
70
  */
28
71
  export async function checkFieldAccess(
29
72
  fieldAccess: FieldAccess | undefined,
@@ -35,18 +78,25 @@ export async function checkFieldAccess(
35
78
  inputData?: Record<string, unknown>
36
79
  },
37
80
  ): Promise<boolean> {
38
- // Skip access check in sudo mode
39
81
  if (args.context._isSudo) {
40
82
  return true
41
83
  }
42
84
 
43
85
  if (!fieldAccess) {
44
- return true // No field access means allow
86
+ return true
45
87
  }
46
88
 
47
- const accessControl = fieldAccess[operation]
89
+ // `FieldAccess['read']` is narrower than `FieldAccess['create'/'update']` (it
90
+ // only accepts the single `operation: 'read'` call shape, see `types.ts`),
91
+ // so indexing by a not-yet-narrowed `operation` union produces a callable
92
+ // whose effective parameter type collapses to an intersection TypeScript
93
+ // can't satisfy generically here. Widen back to the general
94
+ // `FieldAccessControl` — the same shape this function has always built and
95
+ // passed below — since by construction the args object always matches
96
+ // whichever operation is actually requested.
97
+ const accessControl = fieldAccess[operation] as FieldAccessControl | undefined
48
98
  if (!accessControl) {
49
- return true // No specific access control means allow
99
+ return true
50
100
  }
51
101
 
52
102
  const result = await accessControl({
@@ -57,51 +107,79 @@ export async function checkFieldAccess(
57
107
  operation,
58
108
  } as Parameters<typeof accessControl>[0])
59
109
 
60
- // If result is false, deny access
61
110
  if (result === false) {
62
111
  return false
63
112
  }
64
113
 
65
- // If result is true, allow access
66
114
  if (result === true) {
67
115
  return true
68
116
  }
69
117
 
70
- // Default to allowing access if we can't determine
71
- return true
118
+ // `FieldAccessControl` is typed to return `boolean` only field access is a
119
+ // per-field visibility decision, not a row filter (ADR-0001, ADR-0030). A
120
+ // well-typed rule can never reach this line; getting here means a caller
121
+ // bypassed the type (most notably by returning a Prisma filter, the shape
122
+ // operation-level `AccessControl` accepts but this does not). Fail loudly
123
+ // and closed — this operation applies identically whether or not `item` is
124
+ // available, so `create` (which has no `item` to evaluate a filter against)
125
+ // needs no separate answer: neither operation ever honours a non-boolean.
126
+ throw new InvalidFieldAccessResultError(operation, result)
72
127
  }
73
128
 
74
129
  /**
75
- * Simple filter matching for field-level access
76
- * Checks if an item matches a Prisma-like filter object
130
+ * Whether a field's `read` access allows it to be NAMED in a `where`/`orderBy`
131
+ * predicate, evaluated BEFORE the query runs (#915).
132
+ *
133
+ * Field-level `read` access has always been a post-query check (Field
134
+ * Visibility, `field-visibility.ts`): it strips a denied key from a row that
135
+ * has already been fetched. That leaves the predicate itself unconstrained —
136
+ * a caller can still filter or sort by a field whose value they could never
137
+ * read, and recover it (or its relative order) by probing. This function is
138
+ * the pre-query counterpart, called from `query-validation.ts`'s `where`/
139
+ * `orderBy` walk for every key that resolves to a declared field.
140
+ *
141
+ * It delegates to the same evaluator Field Visibility uses (`checkFieldAccess`),
142
+ * per this module's canonical-evaluator rule above. The one difference is the
143
+ * `item` it hands the rule: there is no fetched row
144
+ * yet, so a rule that depends on one (the shape `FieldAccess['read']`
145
+ * documents as the norm, e.g. `item?.ownerId === session?.userId`) cannot be
146
+ * answered here. Rather than skip the check for such a rule — which would
147
+ * reopen exactly the hole this closes for the fields most likely to be
148
+ * sensitive — it is handed a poisoned `item` that throws on any property
149
+ * read, and that throw is caught and resolved to `false`: a row-dependent
150
+ * `read` rule always denies at predicate time, documented and deliberate
151
+ * (see docs/adr/0031). A rule that never touches `item` (checking only
152
+ * `session`, the common case for a field meant to be filterable/sortable at
153
+ * all) evaluates normally and returns its real answer.
154
+ *
155
+ * A rule that returns a non-boolean is a distinct, louder failure (#913,
156
+ * ADR-0030) — `InvalidFieldAccessResultError` — and is deliberately NOT
157
+ * folded into the `false` here; it propagates so the config bug it signals is
158
+ * never mistaken for an ordinary field-level denial.
77
159
  */
78
- function matchesFilter(item: Record<string, unknown>, filter: Record<string, unknown>): boolean {
79
- for (const [key, condition] of Object.entries(filter)) {
80
- if (typeof condition === 'object' && condition !== null) {
81
- // Handle nested conditions like { equals: value }
82
- if ('equals' in condition) {
83
- if (item[key] !== condition.equals) {
84
- return false
85
- }
86
- } else if ('not' in condition) {
87
- if (item[key] === condition.not) {
88
- return false
89
- }
90
- }
91
- // Add more condition types as needed
92
- } else {
93
- // Direct equality check
94
- if (item[key] !== condition) {
95
- return false
96
- }
97
- }
160
+ export async function isFieldReadableForPredicate(
161
+ fieldAccess: FieldAccess | undefined,
162
+ args: {
163
+ session: Session | null
164
+ context: AccessContext & { _isSudo?: boolean }
165
+ },
166
+ ): Promise<boolean> {
167
+ try {
168
+ return await checkFieldAccess(fieldAccess, 'read', {
169
+ session: args.session,
170
+ context: args.context,
171
+ item: createPoisonedItem(),
172
+ })
173
+ } catch (err) {
174
+ // Only the poisoned-item signal means "row-dependent, deny". Anything
175
+ // else including `InvalidFieldAccessResultError` and a genuine bug in
176
+ // the rule itself — propagates unchanged rather than being silently
177
+ // folded into an ordinary denial (found in review of #925).
178
+ if (err instanceof PredicateTimeItemAccessError) return false
179
+ throw err
98
180
  }
99
- return true
100
181
  }
101
182
 
102
- /**
103
- * Filter fields from input data based on write access (create/update)
104
- */
105
183
  export async function filterWritableFields<T extends Record<string, unknown>>(
106
184
  data: T,
107
185
  fieldConfigs: Record<
@@ -122,8 +200,7 @@ export async function filterWritableFields<T extends Record<string, unknown>>(
122
200
  ): Promise<Partial<T>> {
123
201
  const filtered: Record<string, unknown> = {}
124
202
 
125
- // Build a set of foreign key field names to exclude
126
- // Foreign keys should not be in the data when using Prisma's relation syntax
203
+ // Foreign keys must not appear in `data` when using Prisma's relation syntax.
127
204
  const foreignKeyFields = new Set<string>()
128
205
  // Map each raw per-part column name contributed by a multi-column field
129
206
  // (e.g. storage image()/file() in Keystone-parity mode) back to its OWNING
@@ -162,19 +239,18 @@ export async function filterWritableFields<T extends Record<string, unknown>>(
162
239
  for (const [fieldName, value] of Object.entries(data)) {
163
240
  const fieldConfig = fieldConfigs[fieldName]
164
241
 
165
- // Skip system fields
166
242
  if (['id', 'createdAt', 'updatedAt'].includes(fieldName)) {
167
243
  continue
168
244
  }
169
245
 
170
- // Skip virtual fields - they don't store in database
171
- // Virtual fields with resolveInput hooks handle side effects separately
246
+ // Virtual fields don't store in the database — skipped here, but their
247
+ // resolveInput hooks still run as a separate side-effect step.
172
248
  if (fieldConfig && 'virtual' in fieldConfig && fieldConfig.virtual) {
173
249
  continue
174
250
  }
175
251
 
176
- // Skip foreign key fields (e.g., authorId) when their corresponding relationship field exists
177
- // This prevents conflicts when using Prisma's relation syntax (e.g., author: { connect: { id } })
252
+ // Prevents conflicts with Prisma's relation syntax (e.g.,
253
+ // `author: { connect: { id } }`).
178
254
  if (foreignKeyFields.has(fieldName)) {
179
255
  continue
180
256
  }
@@ -1,29 +1,20 @@
1
1
  import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
2
2
  import type { HashedPassword } from '../utils/password.js'
3
3
 
4
- /**
5
- * Extract the return type of a field's afterOperation hook
6
- * If the field has an afterOperation hook, infer its return type
7
- * Otherwise, use the original type
8
- */
9
4
  export type InferFieldReadType<TField extends FieldConfig, TOriginal> = TField extends {
10
- // Generic `any` is required here for TypeScript's conditional type inference to work correctly
11
- // This allows us to infer the exact return type `R` from hooks of any signature
5
+ // Uses `any` here so TypeScript's conditional type inference can pick up the
6
+ // exact return type `R` from a hook of any signature.
12
7
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
13
8
  hooks?: { afterOperation?: (...args: any[]) => infer R }
14
9
  }
15
10
  ? R extends never
16
- ? TOriginal // No hook defined
17
- : R // Hook return type
18
- : TOriginal // No hooks at all
11
+ ? TOriginal
12
+ : R
13
+ : TOriginal
19
14
 
20
- /**
21
- * Transform a Prisma model's field types based on OpenSaas field configs
22
- * This applies afterOperation hook transformations to field types
23
- */
24
15
  export type TransformModelFields<
25
- // Generic constraint requires `any` to allow indexing by string keys from Prisma models
26
- // This is necessary for mapped types to work with Prisma's generated model types
16
+ // Uses `any` so this constraint accepts Prisma's generated model types, which are
17
+ // indexed by string keys.
27
18
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
19
  TModel extends Record<string, any>,
29
20
  TFields extends Record<string, FieldConfig>,
@@ -33,34 +24,22 @@ export type TransformModelFields<
33
24
  : TModel[K]
34
25
  }
35
26
 
36
- /**
37
- * Get the field configs for a specific list from the OpenSaas config
38
- */
39
27
  export type GetListFields<
40
28
  TConfig extends OpenSaasConfig,
41
29
  TListKey extends keyof TConfig['lists'],
42
30
  > = TConfig['lists'][TListKey]['fields']
43
31
 
44
- /**
45
- * Transform a Prisma model result based on OpenSaas config
46
- * Applies field hooks transformations
47
- */
48
32
  export type TransformResult<
49
33
  TConfig extends OpenSaasConfig,
50
34
  TListKey extends keyof TConfig['lists'],
51
35
  TResult,
52
36
  > =
53
- // Generic constraint requires `any` to check if TResult is an object type that can be transformed
54
- // This pattern is standard in TypeScript for conditional types on object shapes
37
+ // Uses `any` to check whether TResult is an object type that can be transformed.
55
38
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
56
39
  TResult extends Record<string, any>
57
40
  ? TransformModelFields<TResult, GetListFields<TConfig, TListKey>>
58
41
  : TResult
59
42
 
60
- /**
61
- * Transform a Prisma operation's return type
62
- * Handles single results, arrays, and null cases
63
- */
64
43
  export type TransformOperationResult<
65
44
  TConfig extends OpenSaasConfig,
66
45
  TListKey extends keyof TConfig['lists'],
@@ -76,20 +55,10 @@ export type TransformOperationResult<
76
55
  >
77
56
  : never
78
57
 
79
- /**
80
- * Known field type mappings for afterOperation hooks
81
- * These provide concrete type hints for common field transformations
82
- */
83
58
  export interface FieldTypeTransforms {
84
59
  password: HashedPassword
85
- // Future field types can be added here
86
- // richText: TiptapContent
87
- // json: JSONValue
88
60
  }
89
61
 
90
- /**
91
- * Helper to infer field type based on field config type
92
- */
93
62
  export type InferFieldTypeTransform<TField extends FieldConfig> = TField extends {
94
63
  type: infer TType
95
64
  }
@@ -6,6 +6,7 @@ import { RESOLVE_CHAIN_MAX_LENGTH } from './depth-limits.js'
6
6
  import { ResolveOutputCycleError } from './errors.js'
7
7
  import type { DeclaredOnlyTree } from './declared-dependencies.js'
8
8
  import { emptyDeclaredOnlyTree } from './declared-dependencies.js'
9
+ import type { FieldSelectionScope } from '../query/index.js'
9
10
  // NOTE: `context/index.ts` imports `filterReadableFields` from this module
10
11
  // (via the `access/index.ts` barrel) — this is an intentional cyclic
11
12
  // dependency, the same shape and for the same reason as the one documented in
@@ -23,16 +24,20 @@ import { buildDbDelegate } from '../context/index.js'
23
24
  * virtual fields. None of this can move into phase 1: virtual fields are
24
25
  * computed in JavaScript and field access can depend on the fetched row.
25
26
  *
27
+ * A computed field — any field carrying a `resolveOutput` hook, virtual or
28
+ * not — is produced only where the read is going to return it (ADR-0027). A
29
+ * fragment `query`'s own field selection is the only thing that restricts a
30
+ * level this way; a bare or `include`-based read, and any relation reached
31
+ * purely to satisfy a `needs` declaration, still compute every field, as
32
+ * before. A field the read is not going to return does no work at all —
33
+ * neither its read-access evaluation nor its hook. The projection-aware skip
34
+ * below shows this rule at each of the two places it applies.
35
+ *
26
36
  * Phase 1 (pre-query row/relation scoping) lives in `access-filter.ts`. See
27
37
  * `docs/adr/0001-access-control-is-a-two-phase-read.md` and the access-control
28
38
  * glossary in `CONTEXT.md`.
29
39
  */
30
40
 
31
- /**
32
- * Runtime type for resolveOutput hooks
33
- * Used when we need to call hooks generically without knowing the specific field type
34
- * Supports both sync and async implementations
35
- */
36
41
  type ResolveOutputHookRuntime = (args: {
37
42
  operation: 'query'
38
43
  value: unknown
@@ -96,8 +101,11 @@ function deriveResolveOutputContext(
96
101
  * from the result.
97
102
  *
98
103
  * `accessItem` is the row used to evaluate field access; `hookItem` is the
99
- * object passed to the hook as `item` (these differ for virtual fields, which
100
- * see the already-filtered output so they can read sibling fields).
104
+ * object passed to the hook as `item`. For a stored field, both are
105
+ * `workingItem` (the row's own stored/fetched columns). For a virtual field,
106
+ * `hookItem` is `computedFieldItem` instead — the same stored columns with
107
+ * every skipped-or-denied key removed — so it never sees another computed
108
+ * field's resolved value (ADR-0027).
101
109
  */
102
110
  async function resolveReadableFieldValue(params: {
103
111
  fieldConfig: FieldConfig | undefined
@@ -111,7 +119,6 @@ async function resolveReadableFieldValue(params: {
111
119
  }): Promise<{ readable: false } | { readable: true; value: unknown }> {
112
120
  const { fieldConfig, fieldName, value, accessItem, hookItem, listKey, args, config } = params
113
121
 
114
- // Check field access (checkFieldAccess already handles sudo mode)
115
122
  const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
116
123
  ...args,
117
124
  item: accessItem,
@@ -121,10 +128,9 @@ async function resolveReadableFieldValue(params: {
121
128
  return { readable: false }
122
129
  }
123
130
 
124
- // Apply resolveOutput hook if present
125
131
  if (fieldConfig?.hooks?.resolveOutput && listKey) {
126
- // Cast to runtime type for generic execution
127
- // At runtime, the hook will receive the correct value type for the field
132
+ // The hook is erased to this runtime shape here; at the actual call it
133
+ // receives the value typed for its own field.
128
134
  const hook = fieldConfig.hooks.resolveOutput as unknown as ResolveOutputHookRuntime
129
135
  const link = { listKey, fieldKey: fieldName }
130
136
  const chain = args.context._resolveOutputChain
@@ -152,7 +158,6 @@ async function resolveReadableFieldValue(params: {
152
158
  return { readable: false }
153
159
  }
154
160
 
155
- // Use Promise.resolve() to handle both sync and async hooks
156
161
  const resolved = await Promise.resolve(
157
162
  hook({
158
163
  value,
@@ -169,10 +174,6 @@ async function resolveReadableFieldValue(params: {
169
174
  return { readable: true, value }
170
175
  }
171
176
 
172
- /**
173
- * Filter fields from an object based on read access
174
- * Recursively applies access control to nested relationships
175
- */
176
177
  export async function filterReadableFields<T extends Record<string, unknown>>(
177
178
  item: T,
178
179
  fieldConfigs: Record<string, FieldConfig>,
@@ -189,6 +190,9 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
189
190
  // returned — after resolveOutput has had a chance to read them — so a
190
191
  // declared dependency never widens what the caller receives.
191
192
  declaredOnly: DeclaredOnlyTree = emptyDeclaredOnlyTree(),
193
+ // The fragment scope this level was reached under (ADR-0027, see module doc
194
+ // above), and the same tree one level down for each nested relation.
195
+ selection?: FieldSelectionScope,
192
196
  ): Promise<Partial<T>> {
193
197
  const filtered: Record<string, unknown> = {}
194
198
 
@@ -214,19 +218,35 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
214
218
  workingItem[fieldName] = assembled
215
219
  }
216
220
 
221
+ // Keys denied by field-level read access during the pass below — as opposed
222
+ // to a key merely skipped by `selection` or held back only for
223
+ // `declaredOnly` stripping. Tracked separately because a denied key must
224
+ // stay invisible to a computed field's hook (below), while a declared-only
225
+ // key must stay VISIBLE to one — that is the entire point of declaring it
226
+ // (ADR-0025) — even though `selection` above skipped adding it to
227
+ // `filtered` because the caller's fragment never asked for it.
228
+ const accessDeniedKeys = new Set<string>()
229
+
217
230
  // Process existing fields from the database result
218
231
  for (const [fieldName, value] of Object.entries(workingItem)) {
219
232
  const fieldConfig = fieldConfigs[fieldName]
220
233
 
221
- // Always include id, createdAt, updatedAt
222
234
  if (['id', 'createdAt', 'updatedAt'].includes(fieldName)) {
223
235
  filtered[fieldName] = value
224
236
  continue
225
237
  }
226
238
 
227
- // Handle relationship fields - recursively filter fields within related items
228
- // Note: Access control filtering is now done at database level via buildIncludeWithAccessControl
229
- // This only handles field-level access (hiding sensitive fields)
239
+ // Projection-aware skip (ADR-0027, see module doc above): a field this
240
+ // level's fragment did not select gets no read-access check, no
241
+ // resolveOutput, and no recursion into a relation — none of that work
242
+ // happens for a value the read isn't going to return.
243
+ if (selection?.fields && !selection.fields.has(fieldName)) {
244
+ continue
245
+ }
246
+
247
+ // Row/relation-level access is already scoped at the DB level via
248
+ // buildAccessScopedInclude; this only handles field-level access (hiding
249
+ // sensitive fields).
230
250
  //
231
251
  // Deliberately uncapped: the row/relation scoping in access-filter.ts bounds
232
252
  // what gets FETCHED (a caller include past its depth cap is now a denial,
@@ -244,13 +264,13 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
244
264
  value !== null &&
245
265
  value !== undefined
246
266
  ) {
247
- // Gate the relationship on read access before recursing.
248
267
  const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
249
268
  ...args,
250
269
  item: workingItem,
251
270
  })
252
271
 
253
272
  if (!canRead) {
273
+ accessDeniedKeys.add(fieldName)
254
274
  continue
255
275
  }
256
276
 
@@ -260,10 +280,14 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
260
280
  // back to an empty tree when this relation isn't declaration-related at
261
281
  // all — the common case.
262
282
  const nestedDeclaredOnly = declaredOnly.nested[fieldName] ?? emptyDeclaredOnlyTree()
283
+ // This relation's own fragment scope, if the caller's fragment named it
284
+ // with a nested Fragment/RelationSelector. `undefined` (a bare `true`
285
+ // selector, or no `selection` at all) means the nested list computes
286
+ // unrestricted — matching what naming a relation without narrowing it
287
+ // further has always meant.
288
+ const nestedSelection = selection?.nested[fieldName]
263
289
 
264
290
  if (relatedConfig) {
265
- // For many relationships (arrays) - recursively filter fields in each item
266
- // The recursive call already handles applying resolveOutput hooks
267
291
  if (Array.isArray(value)) {
268
292
  filtered[fieldName] = await Promise.all(
269
293
  value.map((relatedItem) =>
@@ -275,13 +299,11 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
275
299
  depth + 1,
276
300
  relatedConfig.listName,
277
301
  nestedDeclaredOnly,
302
+ nestedSelection,
278
303
  ),
279
304
  ),
280
305
  )
281
- }
282
- // For single relationships (objects) - recursively filter fields
283
- // The recursive call already handles applying resolveOutput hooks
284
- else if (typeof value === 'object') {
306
+ } else if (typeof value === 'object') {
285
307
  filtered[fieldName] = await filterReadableFields(
286
308
  value as Record<string, unknown>,
287
309
  relatedConfig.listConfig.fields,
@@ -290,17 +312,17 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
290
312
  depth + 1,
291
313
  relatedConfig.listName,
292
314
  nestedDeclaredOnly,
315
+ nestedSelection,
293
316
  )
294
317
  }
295
318
  } else {
296
- // Related config not found, include the value as-is
297
319
  filtered[fieldName] = value
298
320
  }
299
321
  continue
300
322
  }
301
323
 
302
- // Non-relationship field (or relationship without an includable value):
303
- // check read access and apply resolveOutput via the shared helper.
324
+ // Non-relationship field, or a relationship field whose value is not
325
+ // includable (null/undefined) falls through to the shared helper.
304
326
  const result = await resolveReadableFieldValue({
305
327
  fieldConfig,
306
328
  fieldName,
@@ -314,38 +336,69 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
314
336
 
315
337
  if (result.readable) {
316
338
  filtered[fieldName] = result.value
339
+ } else {
340
+ accessDeniedKeys.add(fieldName)
341
+ }
342
+ }
343
+
344
+ // The item a virtual field's hook sees: stored columns and fetched
345
+ // relations (from `workingItem`, never a resolved value — no hook's output
346
+ // is ever written back into `workingItem`). A key is visible here if it
347
+ // either survived into `filtered` (selected and allowed) OR exists only to
348
+ // satisfy a `needs` declaration (`declaredOnly` — that IS the point of
349
+ // declaring it: fetched for a hook, never for the caller, ADR-0025).
350
+ // Everything else — field-level denied, or skipped by `selection` and
351
+ // declared by no one — is deleted. A computed field reaches for exactly its
352
+ // own declared dependencies and nothing another field's hook produced
353
+ // (ADR-0027): reaching for a sibling that was denied or skipped-and-
354
+ // undeclared finds nothing there, the same as reaching for one never
355
+ // declared at all, and reaching for a sibling that DID survive finds its
356
+ // raw stored form, never another hook's resolved value — a virtual field
357
+ // computed earlier in declaration order is exactly as invisible as one
358
+ // computed later.
359
+ const computedFieldItem: Record<string, unknown> = { ...workingItem }
360
+ for (const key of Object.keys(workingItem)) {
361
+ if (['id', 'createdAt', 'updatedAt'].includes(key)) continue
362
+ if (accessDeniedKeys.has(key)) {
363
+ delete computedFieldItem[key]
364
+ continue
317
365
  }
366
+ if (key in filtered) continue
367
+ if (declaredOnly.keys.has(key)) continue
368
+ delete computedFieldItem[key]
318
369
  }
319
370
 
320
- // Process virtual fields - compute values from other fields
321
- // Virtual fields don't exist in the database result, so we need to compute them separately
322
371
  for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
323
- // Skip if already processed (from database result)
324
372
  if (fieldName in filtered) {
325
373
  continue
326
374
  }
327
375
 
328
- // Only process virtual fields
329
376
  if (!fieldConfig.virtual) {
330
377
  continue
331
378
  }
332
379
 
333
- // Virtual fields must have a resolveOutput hook to compute their value;
334
- // without one there is nothing to add to the result.
380
+ // Projection-aware skip (ADR-0027): same rule as the stored-field pass
381
+ // above a fragment that does not select this virtual field does no
382
+ // work for it at all.
383
+ if (selection?.fields && !selection.fields.has(fieldName)) {
384
+ continue
385
+ }
386
+
387
+ // A virtual field with no resolveOutput hook can never produce a value
388
+ // on ANY read — there is nothing to compute, so there is nothing to do,
389
+ // including evaluating its read access (ADR-0027 reconciles the
390
+ // access-only evaluation this branch used to preserve: a field that
391
+ // never has output has no side effect worth preserving access for).
335
392
  if (!(fieldConfig.hooks?.resolveOutput && listKey)) {
336
- // Still evaluate read access to preserve any access-fn side effects.
337
- await checkFieldAccess(fieldConfig.access, 'read', { ...args, item: workingItem })
338
393
  continue
339
394
  }
340
395
 
341
- // Check read access and compute the value via the shared helper. Virtual
342
- // fields see the already-filtered item so they can read sibling fields.
343
396
  const result = await resolveReadableFieldValue({
344
397
  fieldConfig,
345
398
  fieldName,
346
399
  value: undefined, // Virtual fields don't have a database value
347
400
  accessItem: workingItem,
348
- hookItem: filtered,
401
+ hookItem: computedFieldItem,
349
402
  listKey,
350
403
  args,
351
404
  config,
@@ -357,10 +410,11 @@ export async function filterReadableFields<T extends Record<string, unknown>>(
357
410
  }
358
411
 
359
412
  // Strip relations that were fetched ONLY to satisfy a `needs` declaration
360
- // (ADR-0025), now that every resolveOutput hook at this level including
361
- // virtual fields, which read the assembled `filtered` object above has
362
- // had the chance to see them. A declared dependency is private plumbing,
363
- // not an implicit `include`: it never widens what the caller receives.
413
+ // (ADR-0025), now that every resolveOutput hook at this level has had the
414
+ // chance to see them (via `computedFieldItem`, never `filtered` itselfa
415
+ // declared dependency is read from stored columns, not from another
416
+ // field's resolved output). A declared dependency is private plumbing, not
417
+ // an implicit `include`: it never widens what the caller receives.
364
418
  for (const key of declaredOnly.keys) {
365
419
  delete filtered[key]
366
420
  }
@@ -20,15 +20,23 @@ export {
20
20
  getRelatedListConfig,
21
21
  } from './engine.js'
22
22
  // Canonical field-level access evaluation (shared by read and write paths).
23
- export { checkFieldAccess, filterWritableFields } from './field-access.js'
23
+ export {
24
+ checkFieldAccess,
25
+ filterWritableFields,
26
+ isFieldReadableForPredicate,
27
+ } from './field-access.js'
28
+ // Read-path key validation — the `findMany`/`count` counterpart to the write
29
+ // path's #564 undeclared-key reject.
30
+ export { validateQueryKeys } from './query-validation.js'
31
+ // Read-path field-level access on `where`/`orderBy` keys — a field the
32
+ // session cannot read cannot be named in a predicate either (#915).
33
+ export { validateQueryFieldReadAccess } from './query-validation.js'
24
34
  // Phase 1 — Access Filter (pre-query row/relation scoping).
25
35
  export {
26
- buildIncludeWithAccessControl,
27
- mergeIncludeWithAccessControl,
36
+ buildAccessScopedInclude,
37
+ buildAccessScopedWhere,
28
38
  stripVirtualFieldsFromInclude,
29
- toPrismaInclude,
30
39
  } from './access-filter.js'
31
- export type { AccessIncludeResult } from './access-filter.js'
32
40
  // Phase 2 — Field Visibility (post-query field stripping + resolveOutput).
33
41
  export { filterReadableFields } from './field-visibility.js'
34
42
  // Declared Dependencies — folding `needs` into an include without widening
@@ -43,3 +51,7 @@ export type { DeclaredOnlyTree } from './declared-dependencies.js'
43
51
  export { AccessScopeDepthExceededError } from './errors.js'
44
52
  // Thrown when a resolveOutput hook's own resolve chain cycles back into itself.
45
53
  export { ResolveOutputCycleError } from './errors.js'
54
+ // Thrown when a field-level access control function returns a non-boolean result.
55
+ export { InvalidFieldAccessResultError } from './errors.js'
56
+ // Thrown when a relation filter's related list denies query access outright (#916).
57
+ export { RelationFilterAccessDeniedError } from './errors.js'