@opensaas/stack-core 0.38.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 (204) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +123 -0
  3. package/CLAUDE.md +21 -0
  4. package/dist/access/access-filter.d.ts +46 -1
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +113 -4
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/declared-dependencies.d.ts.map +1 -1
  9. package/dist/access/declared-dependencies.js +4 -9
  10. package/dist/access/declared-dependencies.js.map +1 -1
  11. package/dist/access/engine.d.ts +0 -20
  12. package/dist/access/engine.d.ts.map +1 -1
  13. package/dist/access/engine.js +0 -25
  14. package/dist/access/engine.js.map +1 -1
  15. package/dist/access/errors.d.ts +49 -0
  16. package/dist/access/errors.d.ts.map +1 -1
  17. package/dist/access/errors.js +75 -0
  18. package/dist/access/errors.js.map +1 -1
  19. package/dist/access/field-access.d.ts +39 -1
  20. package/dist/access/field-access.d.ts.map +1 -1
  21. package/dist/access/field-access.js +108 -42
  22. package/dist/access/field-access.js.map +1 -1
  23. package/dist/access/field-access.test.js +229 -2
  24. package/dist/access/field-access.test.js.map +1 -1
  25. package/dist/access/field-transforms.d.ts +0 -27
  26. package/dist/access/field-transforms.d.ts.map +1 -1
  27. package/dist/access/field-visibility.d.ts +0 -4
  28. package/dist/access/field-visibility.d.ts.map +1 -1
  29. package/dist/access/field-visibility.js +13 -39
  30. package/dist/access/field-visibility.js.map +1 -1
  31. package/dist/access/index.d.ts +6 -2
  32. package/dist/access/index.d.ts.map +1 -1
  33. package/dist/access/index.js +12 -2
  34. package/dist/access/index.js.map +1 -1
  35. package/dist/access/query-validation.d.ts +121 -0
  36. package/dist/access/query-validation.d.ts.map +1 -0
  37. package/dist/access/query-validation.js +292 -0
  38. package/dist/access/query-validation.js.map +1 -0
  39. package/dist/access/relationship-count.d.ts.map +1 -1
  40. package/dist/access/relationship-count.js +2 -10
  41. package/dist/access/relationship-count.js.map +1 -1
  42. package/dist/access/relationship-label-filter.d.ts +23 -23
  43. package/dist/access/relationship-label-filter.d.ts.map +1 -1
  44. package/dist/access/relationship-label-filter.js +5 -76
  45. package/dist/access/relationship-label-filter.js.map +1 -1
  46. package/dist/access/relationship-label-filter.test.js +19 -56
  47. package/dist/access/relationship-label-filter.test.js.map +1 -1
  48. package/dist/access/transaction-registry.d.ts +45 -0
  49. package/dist/access/transaction-registry.d.ts.map +1 -0
  50. package/dist/access/transaction-registry.js +37 -0
  51. package/dist/access/transaction-registry.js.map +1 -0
  52. package/dist/access/types.d.ts +55 -59
  53. package/dist/access/types.d.ts.map +1 -1
  54. package/dist/config/index.d.ts +9 -7
  55. package/dist/config/index.d.ts.map +1 -1
  56. package/dist/config/index.js +10 -18
  57. package/dist/config/index.js.map +1 -1
  58. package/dist/config/label.d.ts +6 -13
  59. package/dist/config/label.d.ts.map +1 -1
  60. package/dist/config/label.js +6 -13
  61. package/dist/config/label.js.map +1 -1
  62. package/dist/config/nav-count.d.ts +11 -19
  63. package/dist/config/nav-count.d.ts.map +1 -1
  64. package/dist/config/nav-count.js +11 -19
  65. package/dist/config/nav-count.js.map +1 -1
  66. package/dist/config/plugin-engine.d.ts +0 -10
  67. package/dist/config/plugin-engine.d.ts.map +1 -1
  68. package/dist/config/plugin-engine.js +1 -37
  69. package/dist/config/plugin-engine.js.map +1 -1
  70. package/dist/config/types.d.ts +94 -109
  71. package/dist/config/types.d.ts.map +1 -1
  72. package/dist/context/apply-defaults.d.ts +8 -31
  73. package/dist/context/apply-defaults.d.ts.map +1 -1
  74. package/dist/context/apply-defaults.js +12 -41
  75. package/dist/context/apply-defaults.js.map +1 -1
  76. package/dist/context/hook-pipeline.d.ts +4 -29
  77. package/dist/context/hook-pipeline.d.ts.map +1 -1
  78. package/dist/context/hook-pipeline.js +8 -41
  79. package/dist/context/hook-pipeline.js.map +1 -1
  80. package/dist/context/index.d.ts +4 -15
  81. package/dist/context/index.d.ts.map +1 -1
  82. package/dist/context/index.js +190 -193
  83. package/dist/context/index.js.map +1 -1
  84. package/dist/context/nested-operations.d.ts +7 -26
  85. package/dist/context/nested-operations.d.ts.map +1 -1
  86. package/dist/context/nested-operations.js +73 -171
  87. package/dist/context/nested-operations.js.map +1 -1
  88. package/dist/context/transaction-boundary.d.ts +36 -47
  89. package/dist/context/transaction-boundary.d.ts.map +1 -1
  90. package/dist/context/transaction-boundary.js +92 -79
  91. package/dist/context/transaction-boundary.js.map +1 -1
  92. package/dist/context/write-pipeline.d.ts +25 -64
  93. package/dist/context/write-pipeline.d.ts.map +1 -1
  94. package/dist/context/write-pipeline.js +81 -111
  95. package/dist/context/write-pipeline.js.map +1 -1
  96. package/dist/fields/format-prisma-default.d.ts.map +1 -1
  97. package/dist/fields/format-prisma-default.js +4 -7
  98. package/dist/fields/format-prisma-default.js.map +1 -1
  99. package/dist/fields/index.d.ts +17 -2
  100. package/dist/fields/index.d.ts.map +1 -1
  101. package/dist/fields/index.js +136 -147
  102. package/dist/fields/index.js.map +1 -1
  103. package/dist/filter/collect.d.ts +26 -3
  104. package/dist/filter/collect.d.ts.map +1 -1
  105. package/dist/filter/collect.js +30 -12
  106. package/dist/filter/collect.js.map +1 -1
  107. package/dist/filter/filter.test.js +98 -15
  108. package/dist/filter/filter.test.js.map +1 -1
  109. package/dist/filter/index.d.ts +1 -0
  110. package/dist/filter/index.d.ts.map +1 -1
  111. package/dist/filter/index.js.map +1 -1
  112. package/dist/filter/map.d.ts.map +1 -1
  113. package/dist/filter/map.js +0 -5
  114. package/dist/filter/map.js.map +1 -1
  115. package/dist/filter/parse.d.ts.map +1 -1
  116. package/dist/filter/parse.js +2 -9
  117. package/dist/filter/parse.js.map +1 -1
  118. package/dist/filter/serialize.d.ts.map +1 -1
  119. package/dist/filter/serialize.js +2 -4
  120. package/dist/filter/serialize.js.map +1 -1
  121. package/dist/hooks/index.d.ts +4 -42
  122. package/dist/hooks/index.d.ts.map +1 -1
  123. package/dist/hooks/index.js +5 -59
  124. package/dist/hooks/index.js.map +1 -1
  125. package/dist/index.d.ts +3 -1
  126. package/dist/index.d.ts.map +1 -1
  127. package/dist/index.js +20 -4
  128. package/dist/index.js.map +1 -1
  129. package/dist/internal.d.ts +1 -0
  130. package/dist/internal.d.ts.map +1 -1
  131. package/dist/internal.js +4 -0
  132. package/dist/internal.js.map +1 -1
  133. package/dist/lib/case-utils.d.ts +0 -32
  134. package/dist/lib/case-utils.d.ts.map +1 -1
  135. package/dist/lib/case-utils.js +0 -32
  136. package/dist/lib/case-utils.js.map +1 -1
  137. package/dist/mcp/handler.d.ts +0 -4
  138. package/dist/mcp/handler.d.ts.map +1 -1
  139. package/dist/mcp/handler.js +12 -69
  140. package/dist/mcp/handler.js.map +1 -1
  141. package/dist/query/index.d.ts +26 -227
  142. package/dist/query/index.d.ts.map +1 -1
  143. package/dist/query/index.js +14 -117
  144. package/dist/query/index.js.map +1 -1
  145. package/dist/utils/password.d.ts +0 -68
  146. package/dist/utils/password.d.ts.map +1 -1
  147. package/dist/utils/password.js +7 -76
  148. package/dist/utils/password.js.map +1 -1
  149. package/dist/validation/field-config.d.ts.map +1 -1
  150. package/dist/validation/field-config.js +0 -6
  151. package/dist/validation/field-config.js.map +1 -1
  152. package/dist/validation/schema.d.ts +0 -7
  153. package/dist/validation/schema.d.ts.map +1 -1
  154. package/dist/validation/schema.js +0 -10
  155. package/dist/validation/schema.js.map +1 -1
  156. package/package.json +1 -1
  157. package/src/access/access-filter.ts +152 -5
  158. package/src/access/declared-dependencies.ts +4 -9
  159. package/src/access/engine.ts +0 -25
  160. package/src/access/errors.ts +81 -0
  161. package/src/access/field-access.test.ts +312 -2
  162. package/src/access/field-access.ts +118 -42
  163. package/src/access/field-transforms.ts +8 -39
  164. package/src/access/field-visibility.ts +16 -47
  165. package/src/access/index.ts +20 -2
  166. package/src/access/query-validation.ts +359 -0
  167. package/src/access/relationship-count.ts +2 -10
  168. package/src/access/relationship-label-filter.test.ts +19 -68
  169. package/src/access/relationship-label-filter.ts +27 -104
  170. package/src/access/transaction-registry.ts +58 -0
  171. package/src/access/types.ts +83 -86
  172. package/src/config/index.ts +10 -22
  173. package/src/config/label.ts +6 -13
  174. package/src/config/nav-count.ts +11 -19
  175. package/src/config/plugin-engine.ts +1 -37
  176. package/src/config/types.ts +95 -110
  177. package/src/context/apply-defaults.ts +12 -44
  178. package/src/context/hook-pipeline.ts +12 -67
  179. package/src/context/index.ts +263 -247
  180. package/src/context/nested-operations.ts +94 -226
  181. package/src/context/transaction-boundary.ts +113 -109
  182. package/src/context/write-pipeline.ts +99 -155
  183. package/src/fields/format-prisma-default.ts +4 -7
  184. package/src/fields/index.ts +155 -147
  185. package/src/filter/collect.ts +48 -12
  186. package/src/filter/filter.test.ts +153 -15
  187. package/src/filter/index.ts +1 -0
  188. package/src/filter/map.ts +0 -5
  189. package/src/filter/parse.ts +2 -9
  190. package/src/filter/serialize.ts +2 -4
  191. package/src/hooks/index.ts +5 -59
  192. package/src/index.ts +23 -4
  193. package/src/internal.ts +5 -0
  194. package/src/lib/case-utils.ts +0 -32
  195. package/src/mcp/handler.ts +13 -71
  196. package/src/query/index.ts +29 -235
  197. package/src/utils/password.ts +7 -76
  198. package/src/validation/field-config.ts +0 -6
  199. package/src/validation/schema.ts +0 -10
  200. package/tests/context.test.ts +700 -1
  201. package/tests/field-types.test.ts +222 -0
  202. package/tests/mcp-handler.test.ts +34 -0
  203. package/tests/transaction-boundary-outermost.test.ts +530 -0
  204. package/tsconfig.tsbuildinfo +1 -1
@@ -1,44 +1,12 @@
1
1
  import bcrypt from 'bcryptjs';
2
- /**
3
- * Default bcrypt cost factor (rounds)
4
- * Higher values = more secure but slower
5
- * 10 is a good balance for production use
6
- */
2
+ // Bcrypt cost factor: higher is slower but more resistant to brute force.
7
3
  const DEFAULT_COST_FACTOR = 10;
8
- /**
9
- * Hash a plain text password using bcrypt
10
- *
11
- * @param plainPassword - The plain text password to hash
12
- * @param costFactor - The bcrypt cost factor (default: 10)
13
- * @returns Promise resolving to the hashed password
14
- *
15
- * @example
16
- * ```typescript
17
- * const hashed = await hashPassword('mypassword')
18
- * // Returns: $2a$10$...
19
- * ```
20
- */
21
4
  export async function hashPassword(plainPassword, costFactor = DEFAULT_COST_FACTOR) {
22
5
  if (typeof plainPassword !== 'string' || plainPassword.length === 0) {
23
6
  throw new Error('Password must be a non-empty string');
24
7
  }
25
8
  return bcrypt.hash(plainPassword, costFactor);
26
9
  }
27
- /**
28
- * Compare a plain text password with a hashed password
29
- *
30
- * @param plainPassword - The plain text password to compare
31
- * @param hashedPassword - The hashed password to compare against
32
- * @returns Promise resolving to true if passwords match, false otherwise
33
- *
34
- * @example
35
- * ```typescript
36
- * const isValid = await comparePassword('mypassword', hashedPassword)
37
- * if (isValid) {
38
- * // Password is correct
39
- * }
40
- * ```
41
- */
42
10
  export async function comparePassword(plainPassword, hashedPassword) {
43
11
  if (typeof plainPassword !== 'string' || typeof hashedPassword !== 'string') {
44
12
  return false;
@@ -50,36 +18,19 @@ export async function comparePassword(plainPassword, hashedPassword) {
50
18
  return await bcrypt.compare(plainPassword, hashedPassword);
51
19
  }
52
20
  catch (error) {
53
- // Invalid hash format or other bcrypt error
21
+ // Fail closed on a malformed hash or other bcrypt error, rather than
22
+ // letting it propagate as an authentication-bypassing exception.
54
23
  console.error('Password comparison failed:', error);
55
24
  return false;
56
25
  }
57
26
  }
58
- /**
59
- * Check if a string appears to be a bcrypt hash
60
- * Bcrypt hashes follow the format: $2a$10$...
61
- *
62
- * @param value - The string to check
63
- * @returns True if the string looks like a bcrypt hash
64
- */
65
27
  export function isHashedPassword(value) {
66
28
  if (typeof value !== 'string')
67
29
  return false;
68
- // Bcrypt hashes start with $2a$, $2b$, or $2y$ followed by cost factor
69
- // and are typically 60 characters long
70
30
  return /^\$2[aby]\$\d{2}\$.{53}$/.test(value);
71
31
  }
72
- /**
73
- * HashedPassword class wraps a bcrypt hash and provides a compare method
74
- * This allows password field values to be used as strings while also
75
- * providing a convenient compare() method for authentication
76
- *
77
- * @example
78
- * ```typescript
79
- * const user = await context.db.user.findUnique({ where: { id: '1' } })
80
- * const isValid = await user.password.compare('plaintextPassword')
81
- * ```
82
- */
32
+ // Wraps a bcrypt hash so a password field's value can still be used as a
33
+ // plain string while also exposing compare() for authentication.
83
34
  export class HashedPassword {
84
35
  hash;
85
36
  constructor(hash) {
@@ -88,43 +39,23 @@ export class HashedPassword {
88
39
  throw new Error('HashedPassword requires a non-empty hash string');
89
40
  }
90
41
  }
91
- /**
92
- * Compare a plain text password with this hashed password
93
- *
94
- * @param plainPassword - The plain text password to compare
95
- * @returns Promise resolving to true if passwords match
96
- */
97
42
  async compare(plainPassword) {
98
43
  return comparePassword(plainPassword, this.hash);
99
44
  }
100
- /**
101
- * Get the underlying hash string
102
- * This allows the HashedPassword to be used anywhere a string is expected
103
- */
104
45
  toString() {
105
46
  return this.hash;
106
47
  }
107
- /**
108
- * Get the underlying hash when used in string contexts
109
- * This allows the HashedPassword to be coerced to a string automatically
110
- */
48
+ // Implicit coercion (template literals, string concatenation) resolves here.
111
49
  [Symbol.toPrimitive](hint) {
112
50
  if (hint === 'string' || hint === 'default') {
113
51
  return this.hash;
114
52
  }
115
53
  return this.hash;
116
54
  }
117
- /**
118
- * Return the hash for JSON serialization
119
- * This ensures the hash is properly serialized when converting to JSON
120
- */
55
+ // JSON.stringify calls this when present, so the hash serializes as a plain string.
121
56
  toJSON() {
122
57
  return this.hash;
123
58
  }
124
- /**
125
- * Get the underlying hash value
126
- * This allows accessing the raw hash string
127
- */
128
59
  valueOf() {
129
60
  return this.hash;
130
61
  }
@@ -1 +1 @@
1
- {"version":3,"file":"password.js","sourceRoot":"","sources":["../../src/utils/password.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,UAAU,CAAA;AAE7B;;;;GAIG;AACH,MAAM,mBAAmB,GAAG,EAAE,CAAA;AAE9B;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,aAAqB,EACrB,UAAU,GAAW,mBAAmB;IAExC,IAAI,OAAO,aAAa,KAAK,QAAQ,IAAI,aAAa,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACpE,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAA;IACxD,CAAC;IAED,OAAO,MAAM,CAAC,IAAI,CAAC,aAAa,EAAE,UAAU,CAAC,CAAA;AAC/C,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,aAAqB,EACrB,cAAsB;IAEtB,IAAI,OAAO,aAAa,KAAK,QAAQ,IAAI,OAAO,cAAc,KAAK,QAAQ,EAAE,CAAC;QAC5E,OAAO,KAAK,CAAA;IACd,CAAC;IAED,IAAI,aAAa,CAAC,MAAM,KAAK,CAAC,IAAI,cAAc,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC9D,OAAO,KAAK,CAAA;IACd,CAAC;IAED,IAAI,CAAC;QACH,OAAO,MAAM,MAAM,CAAC,OAAO,CAAC,aAAa,EAAE,cAAc,CAAC,CAAA;IAC5D,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,4CAA4C;QAC5C,OAAO,CAAC,KAAK,CAAC,6BAA6B,EAAE,KAAK,CAAC,CAAA;QACnD,OAAO,KAAK,CAAA;IACd,CAAC;AACH,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAa;IAC5C,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAA;IAE3C,uEAAuE;IACvE,uCAAuC;IACvC,OAAO,0BAA0B,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;AAC/C,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,OAAO,cAAc;IACI,IAAI;IAAjC,YAA6B,IAAY;oBAAZ,IAAI;QAC/B,IAAI,CAAC,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ,EAAE,CAAC;YACtC,MAAM,IAAI,KAAK,CAAC,iDAAiD,CAAC,CAAA;QACpE,CAAC;IACH,CAAC;IAED;;;;;OAKG;IACH,KAAK,CAAC,OAAO,CAAC,aAAqB;QACjC,OAAO,eAAe,CAAC,aAAa,EAAE,IAAI,CAAC,IAAI,CAAC,CAAA;IAClD,CAAC;IAED;;;OAGG;IACH,QAAQ;QACN,OAAO,IAAI,CAAC,IAAI,CAAA;IAClB,CAAC;IAED;;;OAGG;IACH,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,IAAY;QAC/B,IAAI,IAAI,KAAK,QAAQ,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;YAC5C,OAAO,IAAI,CAAC,IAAI,CAAA;QAClB,CAAC;QACD,OAAO,IAAI,CAAC,IAAI,CAAA;IAClB,CAAC;IAED;;;OAGG;IACH,MAAM;QACJ,OAAO,IAAI,CAAC,IAAI,CAAA;IAClB,CAAC;IAED;;;OAGG;IACH,OAAO;QACL,OAAO,IAAI,CAAC,IAAI,CAAA;IAClB,CAAC;CACF"}
1
+ {"version":3,"file":"password.js","sourceRoot":"","sources":["../../src/utils/password.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,UAAU,CAAA;AAE7B,0EAA0E;AAC1E,MAAM,mBAAmB,GAAG,EAAE,CAAA;AAE9B,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,aAAqB,EACrB,UAAU,GAAW,mBAAmB;IAExC,IAAI,OAAO,aAAa,KAAK,QAAQ,IAAI,aAAa,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACpE,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAA;IACxD,CAAC;IAED,OAAO,MAAM,CAAC,IAAI,CAAC,aAAa,EAAE,UAAU,CAAC,CAAA;AAC/C,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,aAAqB,EACrB,cAAsB;IAEtB,IAAI,OAAO,aAAa,KAAK,QAAQ,IAAI,OAAO,cAAc,KAAK,QAAQ,EAAE,CAAC;QAC5E,OAAO,KAAK,CAAA;IACd,CAAC;IAED,IAAI,aAAa,CAAC,MAAM,KAAK,CAAC,IAAI,cAAc,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC9D,OAAO,KAAK,CAAA;IACd,CAAC;IAED,IAAI,CAAC;QACH,OAAO,MAAM,MAAM,CAAC,OAAO,CAAC,aAAa,EAAE,cAAc,CAAC,CAAA;IAC5D,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,qEAAqE;QACrE,iEAAiE;QACjE,OAAO,CAAC,KAAK,CAAC,6BAA6B,EAAE,KAAK,CAAC,CAAA;QACnD,OAAO,KAAK,CAAA;IACd,CAAC;AACH,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,KAAa;IAC5C,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAA;IAE3C,OAAO,0BAA0B,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;AAC/C,CAAC;AAED,yEAAyE;AACzE,iEAAiE;AACjE,MAAM,OAAO,cAAc;IACI,IAAI;IAAjC,YAA6B,IAAY;oBAAZ,IAAI;QAC/B,IAAI,CAAC,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ,EAAE,CAAC;YACtC,MAAM,IAAI,KAAK,CAAC,iDAAiD,CAAC,CAAA;QACpE,CAAC;IACH,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,aAAqB;QACjC,OAAO,eAAe,CAAC,aAAa,EAAE,IAAI,CAAC,IAAI,CAAC,CAAA;IAClD,CAAC;IAED,QAAQ;QACN,OAAO,IAAI,CAAC,IAAI,CAAA;IAClB,CAAC;IAED,6EAA6E;IAC7E,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,IAAY;QAC/B,IAAI,IAAI,KAAK,QAAQ,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;YAC5C,OAAO,IAAI,CAAC,IAAI,CAAA;QAClB,CAAC;QACD,OAAO,IAAI,CAAC,IAAI,CAAA;IAClB,CAAC;IAED,oFAAoF;IACpF,MAAM;QACJ,OAAO,IAAI,CAAC,IAAI,CAAA;IAClB,CAAC;IAED,OAAO;QACL,OAAO,IAAI,CAAC,IAAI,CAAA;IAClB,CAAC;CACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"field-config.d.ts","sourceRoot":"","sources":["../../src/validation/field-config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AAErE;;;;;;;;;GASG;AACH,MAAM,WAAW,0BAA0B;IACzC,0FAA0F;IAC1F,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,qDAAqD;IACrD,QAAQ,EAAE,MAAM,CAAA;IAChB,8EAA8E;IAC9E,SAAS,EAAE,MAAM,CAAA;IACjB,2CAA2C;IAC3C,aAAa,EAAE,eAAe,GAAG,mBAAmB,GAAG,cAAc,GAAG,mBAAmB,CAAA;IAC3F,mFAAmF;IACnF,OAAO,EAAE,MAAM,CAAA;CAChB;AAwCD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,WAAW,EAClB,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,MAAM,GACf,0BAA0B,EAAE,CAmC9B;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,cAAc,GAAG,0BAA0B,EAAE,CAWzF"}
1
+ {"version":3,"file":"field-config.d.ts","sourceRoot":"","sources":["../../src/validation/field-config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AAErE;;;;;;;;;GASG;AACH,MAAM,WAAW,0BAA0B;IACzC,0FAA0F;IAC1F,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,qDAAqD;IACrD,QAAQ,EAAE,MAAM,CAAA;IAChB,8EAA8E;IAC9E,SAAS,EAAE,MAAM,CAAA;IACjB,2CAA2C;IAC3C,aAAa,EAAE,eAAe,GAAG,mBAAmB,GAAG,cAAc,GAAG,mBAAmB,CAAA;IAC3F,mFAAmF;IACnF,OAAO,EAAE,MAAM,CAAA;CAChB;AAqCD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,WAAW,EAClB,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,MAAM,GACf,0BAA0B,EAAE,CAgC9B;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,cAAc,GAAG,0BAA0B,EAAE,CAWzF"}
@@ -17,9 +17,6 @@ function hasFieldMethod(field, method) {
17
17
  const value = Reflect.get(field, method);
18
18
  return typeof value === 'function';
19
19
  }
20
- /**
21
- * Build the canonical error message for a missing contract method.
22
- */
23
20
  function buildMessage(fieldType, method, fieldKey, listKey) {
24
21
  const location = listKey ? `Field "${listKey}.${fieldKey}"` : `Field "${fieldKey}"`;
25
22
  return (`${location} (type "${fieldType}") is not self-contained: it does not implement ` +
@@ -61,17 +58,14 @@ export function validateFieldConfig(field, fieldKey, listKey) {
61
58
  }
62
59
  };
63
60
  if (field.type === 'relationship') {
64
- // Relationships render through the relationship path only.
65
61
  requireMethod('getPrismaRelation');
66
62
  return errors;
67
63
  }
68
64
  if (field.virtual === true || field.type === 'virtual') {
69
- // Virtual fields are not persisted, so getPrismaType is intentionally absent.
70
65
  requireMethod('getTypeScriptType');
71
66
  requireMethod('getZodSchema');
72
67
  return errors;
73
68
  }
74
- // Stored scalar fields must implement the full generation contract.
75
69
  requireMethod('getPrismaType');
76
70
  requireMethod('getTypeScriptType');
77
71
  requireMethod('getZodSchema');
@@ -1 +1 @@
1
- {"version":3,"file":"field-config.js","sourceRoot":"","sources":["../../src/validation/field-config.ts"],"names":[],"mappings":"AAyBA;;;GAGG;AACH,SAAS,iBAAiB,CAAC,KAAkB;IAC3C,OAAO,OAAO,KAAK,CAAC,IAAI,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAA;AACzF,CAAC;AAED;;;;;;;GAOG;AACH,SAAS,cAAc,CAAC,KAAkB,EAAE,MAAc;IACxD,MAAM,KAAK,GAAY,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,MAAM,CAAC,CAAA;IACjD,OAAO,OAAO,KAAK,KAAK,UAAU,CAAA;AACpC,CAAC;AAED;;GAEG;AACH,SAAS,YAAY,CACnB,SAAiB,EACjB,MAAmD,EACnD,QAAgB,EAChB,OAAgB;IAEhB,MAAM,QAAQ,GAAG,OAAO,CAAC,CAAC,CAAC,UAAU,OAAO,IAAI,QAAQ,GAAG,CAAC,CAAC,CAAC,UAAU,QAAQ,GAAG,CAAA;IACnF,OAAO,CACL,GAAG,QAAQ,WAAW,SAAS,kDAAkD;QACjF,GAAG,MAAM,mEAAmE;QAC5E,8DAA8D,CAC/D,CAAA;AACH,CAAC;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,mBAAmB,CACjC,KAAkB,EAClB,QAAgB,EAChB,OAAgB;IAEhB,MAAM,MAAM,GAAiC,EAAE,CAAA;IAC/C,MAAM,SAAS,GAAG,iBAAiB,CAAC,KAAK,CAAC,CAAA;IAE1C,MAAM,aAAa,GAAG,CAAC,MAAmD,EAAQ,EAAE;QAClF,IAAI,CAAC,cAAc,CAAC,KAAK,EAAE,MAAM,CAAC,EAAE,CAAC;YACnC,MAAM,CAAC,IAAI,CAAC;gBACV,OAAO;gBACP,QAAQ;gBACR,SAAS;gBACT,aAAa,EAAE,MAAM;gBACrB,OAAO,EAAE,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,CAAC;aAC5D,CAAC,CAAA;QACJ,CAAC;IACH,CAAC,CAAA;IAED,IAAI,KAAK,CAAC,IAAI,KAAK,cAAc,EAAE,CAAC;QAClC,2DAA2D;QAC3D,aAAa,CAAC,mBAAmB,CAAC,CAAA;QAClC,OAAO,MAAM,CAAA;IACf,CAAC;IAED,IAAI,KAAK,CAAC,OAAO,KAAK,IAAI,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;QACvD,8EAA8E;QAC9E,aAAa,CAAC,mBAAmB,CAAC,CAAA;QAClC,aAAa,CAAC,cAAc,CAAC,CAAA;QAC7B,OAAO,MAAM,CAAA;IACf,CAAC;IAED,oEAAoE;IACpE,aAAa,CAAC,eAAe,CAAC,CAAA;IAC9B,aAAa,CAAC,mBAAmB,CAAC,CAAA;IAClC,aAAa,CAAC,cAAc,CAAC,CAAA;IAE7B,OAAO,MAAM,CAAA;AACf,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,oBAAoB,CAAC,MAAsB;IACzD,MAAM,MAAM,GAAiC,EAAE,CAAA;IAE/C,KAAK,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;QACjE,IAAI,CAAC,UAAU,EAAE,MAAM;YAAE,SAAQ;QACjC,KAAK,MAAM,CAAC,QAAQ,EAAE,WAAW,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,UAAU,CAAC,MAAM,CAAC,EAAE,CAAC;YACxE,MAAM,CAAC,IAAI,CAAC,GAAG,mBAAmB,CAAC,WAAW,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAA;QACrE,CAAC;IACH,CAAC;IAED,OAAO,MAAM,CAAA;AACf,CAAC"}
1
+ {"version":3,"file":"field-config.js","sourceRoot":"","sources":["../../src/validation/field-config.ts"],"names":[],"mappings":"AAyBA;;;GAGG;AACH,SAAS,iBAAiB,CAAC,KAAkB;IAC3C,OAAO,OAAO,KAAK,CAAC,IAAI,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAA;AACzF,CAAC;AAED;;;;;;;GAOG;AACH,SAAS,cAAc,CAAC,KAAkB,EAAE,MAAc;IACxD,MAAM,KAAK,GAAY,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,MAAM,CAAC,CAAA;IACjD,OAAO,OAAO,KAAK,KAAK,UAAU,CAAA;AACpC,CAAC;AAED,SAAS,YAAY,CACnB,SAAiB,EACjB,MAAmD,EACnD,QAAgB,EAChB,OAAgB;IAEhB,MAAM,QAAQ,GAAG,OAAO,CAAC,CAAC,CAAC,UAAU,OAAO,IAAI,QAAQ,GAAG,CAAC,CAAC,CAAC,UAAU,QAAQ,GAAG,CAAA;IACnF,OAAO,CACL,GAAG,QAAQ,WAAW,SAAS,kDAAkD;QACjF,GAAG,MAAM,mEAAmE;QAC5E,8DAA8D,CAC/D,CAAA;AACH,CAAC;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,mBAAmB,CACjC,KAAkB,EAClB,QAAgB,EAChB,OAAgB;IAEhB,MAAM,MAAM,GAAiC,EAAE,CAAA;IAC/C,MAAM,SAAS,GAAG,iBAAiB,CAAC,KAAK,CAAC,CAAA;IAE1C,MAAM,aAAa,GAAG,CAAC,MAAmD,EAAQ,EAAE;QAClF,IAAI,CAAC,cAAc,CAAC,KAAK,EAAE,MAAM,CAAC,EAAE,CAAC;YACnC,MAAM,CAAC,IAAI,CAAC;gBACV,OAAO;gBACP,QAAQ;gBACR,SAAS;gBACT,aAAa,EAAE,MAAM;gBACrB,OAAO,EAAE,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,CAAC;aAC5D,CAAC,CAAA;QACJ,CAAC;IACH,CAAC,CAAA;IAED,IAAI,KAAK,CAAC,IAAI,KAAK,cAAc,EAAE,CAAC;QAClC,aAAa,CAAC,mBAAmB,CAAC,CAAA;QAClC,OAAO,MAAM,CAAA;IACf,CAAC;IAED,IAAI,KAAK,CAAC,OAAO,KAAK,IAAI,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;QACvD,aAAa,CAAC,mBAAmB,CAAC,CAAA;QAClC,aAAa,CAAC,cAAc,CAAC,CAAA;QAC7B,OAAO,MAAM,CAAA;IACf,CAAC;IAED,aAAa,CAAC,eAAe,CAAC,CAAA;IAC9B,aAAa,CAAC,mBAAmB,CAAC,CAAA;IAClC,aAAa,CAAC,cAAc,CAAC,CAAA;IAE7B,OAAO,MAAM,CAAA;AACf,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,oBAAoB,CAAC,MAAsB;IACzD,MAAM,MAAM,GAAiC,EAAE,CAAA;IAE/C,KAAK,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;QACjE,IAAI,CAAC,UAAU,EAAE,MAAM;YAAE,SAAQ;QACjC,KAAK,MAAM,CAAC,QAAQ,EAAE,WAAW,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,UAAU,CAAC,MAAM,CAAC,EAAE,CAAC;YACxE,MAAM,CAAC,IAAI,CAAC,GAAG,mBAAmB,CAAC,WAAW,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC,CAAA;QACrE,CAAC;IACH,CAAC;IAED,OAAO,MAAM,CAAA;AACf,CAAC"}
@@ -1,13 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import type { FieldConfig } from '../config/types.js';
3
- /**
4
- * Generate Zod schema from field configurations
5
- */
6
3
  export declare function generateZodSchema(fieldConfigs: Record<string, FieldConfig>, operation?: 'create' | 'update'): z.ZodObject;
7
- /**
8
- * Validate data against field configurations using Zod
9
- * Returns structured errors by field
10
- */
11
4
  export declare function validateWithZod(data: Record<string, unknown>, fieldConfigs: Record<string, FieldConfig>, operation?: 'create' | 'update'): {
12
5
  success: true;
13
6
  } | {
@@ -1 +1 @@
1
- {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/validation/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AACvB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAErD;;GAEG;AACH,wBAAgB,iBAAiB,CAC/B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,EACzC,SAAS,GAAE,QAAQ,GAAG,QAAmB,GACxC,CAAC,CAAC,SAAS,CAwBb;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,EACzC,SAAS,GAAE,QAAQ,GAAG,QAAmB,GACxC;IAAE,OAAO,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,CAiBxE"}
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/validation/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AACvB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAErD,wBAAgB,iBAAiB,CAC/B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,EACzC,SAAS,GAAE,QAAQ,GAAG,QAAmB,GACxC,CAAC,CAAC,SAAS,CAsBb;AAED,wBAAgB,eAAe,CAC7B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,EACzC,SAAS,GAAE,QAAQ,GAAG,QAAmB,GACxC;IAAE,OAAO,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,CAgBxE"}
@@ -1,18 +1,13 @@
1
1
  import { z } from 'zod';
2
- /**
3
- * Generate Zod schema from field configurations
4
- */
5
2
  export function generateZodSchema(fieldConfigs, operation = 'create') {
6
3
  const shape = {};
7
4
  for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
8
- // Skip system fields, relationships, and virtual fields
9
5
  // Virtual fields don't accept input - they only compute output
10
6
  if (['id', 'createdAt', 'updatedAt'].includes(fieldName) ||
11
7
  fieldConfig.type === 'relationship' ||
12
8
  fieldConfig.virtual) {
13
9
  continue;
14
10
  }
15
- // Use the field's schema generator
16
11
  if (fieldConfig.getZodSchema) {
17
12
  shape[fieldName] = fieldConfig.getZodSchema(fieldName, operation);
18
13
  }
@@ -23,17 +18,12 @@ export function generateZodSchema(fieldConfigs, operation = 'create') {
23
18
  }
24
19
  return z.object(shape);
25
20
  }
26
- /**
27
- * Validate data against field configurations using Zod
28
- * Returns structured errors by field
29
- */
30
21
  export function validateWithZod(data, fieldConfigs, operation = 'create') {
31
22
  const schema = generateZodSchema(fieldConfigs, operation);
32
23
  const result = schema.safeParse(data);
33
24
  if (result.success) {
34
25
  return { success: true };
35
26
  }
36
- // Convert Zod errors to field-specific error messages
37
27
  const errors = {};
38
28
  for (const issue of result.error.issues) {
39
29
  const fieldPath = issue.path.join('.');
@@ -1 +1 @@
1
- {"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/validation/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAGvB;;GAEG;AACH,MAAM,UAAU,iBAAiB,CAC/B,YAAyC,EACzC,SAAS,GAAwB,QAAQ;IAEzC,MAAM,KAAK,GAAiC,EAAE,CAAA;IAE9C,KAAK,MAAM,CAAC,SAAS,EAAE,WAAW,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,YAAY,CAAC,EAAE,CAAC;QACpE,wDAAwD;QACxD,+DAA+D;QAC/D,IACE,CAAC,IAAI,EAAE,WAAW,EAAE,WAAW,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC;YACpD,WAAW,CAAC,IAAI,KAAK,cAAc;YACnC,WAAW,CAAC,OAAO,EACnB,CAAC;YACD,SAAQ;QACV,CAAC;QAED,mCAAmC;QACnC,IAAI,WAAW,CAAC,YAAY,EAAE,CAAC;YAC7B,KAAK,CAAC,SAAS,CAAC,GAAG,WAAW,CAAC,YAAY,CAAC,SAAS,EAAE,SAAS,CAAC,CAAA;QACnE,CAAC;aAAM,CAAC;YACN,4DAA4D;YAC5D,KAAK,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE,CAAA;QACvC,CAAC;IACH,CAAC;IAED,OAAO,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AACxB,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,eAAe,CAC7B,IAA6B,EAC7B,YAAyC,EACzC,SAAS,GAAwB,QAAQ;IAEzC,MAAM,MAAM,GAAG,iBAAiB,CAAC,YAAY,EAAE,SAAS,CAAC,CAAA;IAEzD,MAAM,MAAM,GAAG,MAAM,CAAC,SAAS,CAAC,IAAI,CAAC,CAAA;IAErC,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;QACnB,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;IAC1B,CAAC;IAED,sDAAsD;IACtD,MAAM,MAAM,GAA2B,EAAE,CAAA;IACzC,KAAK,MAAM,KAAK,IAAI,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC;QACxC,MAAM,SAAS,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;QACtC,MAAM,CAAC,SAAS,CAAC,GAAG,KAAK,CAAC,OAAO,CAAA;IACnC,CAAC;IAED,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,CAAA;AACnC,CAAC"}
1
+ {"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/validation/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAGvB,MAAM,UAAU,iBAAiB,CAC/B,YAAyC,EACzC,SAAS,GAAwB,QAAQ;IAEzC,MAAM,KAAK,GAAiC,EAAE,CAAA;IAE9C,KAAK,MAAM,CAAC,SAAS,EAAE,WAAW,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,YAAY,CAAC,EAAE,CAAC;QACpE,+DAA+D;QAC/D,IACE,CAAC,IAAI,EAAE,WAAW,EAAE,WAAW,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC;YACpD,WAAW,CAAC,IAAI,KAAK,cAAc;YACnC,WAAW,CAAC,OAAO,EACnB,CAAC;YACD,SAAQ;QACV,CAAC;QAED,IAAI,WAAW,CAAC,YAAY,EAAE,CAAC;YAC7B,KAAK,CAAC,SAAS,CAAC,GAAG,WAAW,CAAC,YAAY,CAAC,SAAS,EAAE,SAAS,CAAC,CAAA;QACnE,CAAC;aAAM,CAAC;YACN,4DAA4D;YAC5D,KAAK,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC,QAAQ,EAAE,CAAA;QACvC,CAAC;IACH,CAAC;IAED,OAAO,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AACxB,CAAC;AAED,MAAM,UAAU,eAAe,CAC7B,IAA6B,EAC7B,YAAyC,EACzC,SAAS,GAAwB,QAAQ;IAEzC,MAAM,MAAM,GAAG,iBAAiB,CAAC,YAAY,EAAE,SAAS,CAAC,CAAA;IAEzD,MAAM,MAAM,GAAG,MAAM,CAAC,SAAS,CAAC,IAAI,CAAC,CAAA;IAErC,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;QACnB,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;IAC1B,CAAC;IAED,MAAM,MAAM,GAA2B,EAAE,CAAA;IACzC,KAAK,MAAM,KAAK,IAAI,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC;QACxC,MAAM,SAAS,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;QACtC,MAAM,CAAC,SAAS,CAAC,GAAG,KAAK,CAAC,OAAO,CAAA;IACnC,CAAC;IAED,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,CAAA;AACnC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opensaas/stack-core",
3
- "version": "0.38.0",
3
+ "version": "0.39.0",
4
4
  "description": "Core stack for OpenSaas - schema definition, access control, and runtime utilities",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,8 +1,14 @@
1
1
  import type { Session, AccessContext, PrismaFilter } from './types.js'
2
- import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
2
+ import type { OpenSaasConfig, FieldConfig, ListConfig } from '../config/types.js'
3
3
  import { checkAccess, getRelatedListConfig } from './engine.js'
4
4
  import { READ_INCLUDE_MAX_DEPTH } from './depth-limits.js'
5
- import { AccessScopeDepthExceededError } from './errors.js'
5
+ import { AccessScopeDepthExceededError, RelationFilterAccessDeniedError } from './errors.js'
6
+ import {
7
+ LOGICAL_OPERATORS,
8
+ RELATION_QUANTIFIERS,
9
+ resolveQueryField,
10
+ walkWhereReadAccess,
11
+ } from './query-validation.js'
6
12
 
7
13
  /**
8
14
  * Access Filter — phase 1 of the two-phase read (pre-query).
@@ -63,8 +69,7 @@ function asEntryObject(value: unknown): IncludeEntryObject | null {
63
69
  *
64
70
  * The access filter is authoritative: the caller's filter may only NARROW the
65
71
  * result further, never widen past what access permits. We therefore wrap both
66
- * in a Prisma `AND` so neither can override the other. If only one side is
67
- * present, it is returned as-is; if neither is present, the result is undefined.
72
+ * in a Prisma `AND` so neither can override the other.
68
73
  */
69
74
  function andWhere(
70
75
  accessWhere: PrismaFilter | undefined,
@@ -173,6 +178,149 @@ export async function buildAccessScopedInclude(
173
178
  return result
174
179
  }
175
180
 
181
+ /**
182
+ * Scope every relation filter (`some`/`every`/`none`/`is`/`isNot`) nested in a
183
+ * caller's `where` by the related list's own `query` access — the `where`
184
+ * counterpart to `buildAccessScopedInclude` above, closing #916 (the
185
+ * "unclosed half of ADR-0022"). `include` and `where` are fundamentally
186
+ * different requests (which relations come back, vs. which parent rows
187
+ * match), so this is a distinct function, but it shares every primitive that
188
+ * matters: `checkAccess`/`getRelatedListConfig` (the same access-evaluation
189
+ * calls `buildAccessScopedInclude` makes), `andWhere` (the same AND-fold), and
190
+ * `resolveQueryField`/`LOGICAL_OPERATORS`/`RELATION_QUANTIFIERS`/
191
+ * `walkWhereReadAccess` (the same shape-recognition and field-read check
192
+ * `query-validation.ts` already uses for #912/#915) — there is no second,
193
+ * parallel implementation of any of those decisions.
194
+ *
195
+ * For each relationship key found (at any depth — the walk recurses through
196
+ * `AND`/`OR`/`NOT` and through every hop of a chain):
197
+ * - Not a declared relationship, or the key #912 already rejected (this walk
198
+ * runs strictly after that check) → passed through unchanged.
199
+ * - The related list's `query` access denies it (`=== false`) → THROWS
200
+ * `RelationFilterAccessDeniedError`. Unlike `buildAccessScopedInclude`'s
201
+ * silent drop, this is a loud failure: a `where` predicate has no neutral
202
+ * "not requested" outcome the way a missing `include` key does, so a
203
+ * silently-empty match would itself be a distinguishable signal (ADR-0022).
204
+ * - Otherwise → the access filter (if any) is AND-combined into the relation
205
+ * quantifier's nested clause (never replacing the caller's own condition,
206
+ * mirroring `andWhere`'s include-side contract), keys inside that nested
207
+ * clause are checked against the RELATED list's field-level `read` access
208
+ * (`walkWhereReadAccess`, closing #915's stated gap for this path), and the
209
+ * walk recurses into the related list's own fields for a further hop.
210
+ *
211
+ * One quantifier is deliberately conservative rather than exactly precise:
212
+ * folding the access filter into `every`'s nested clause with a plain AND
213
+ * makes `every` require every related row to be BOTH access-visible AND
214
+ * matching, not "every access-visible row matches" (the latter needs a
215
+ * `NOT`/`OR` transform this does not attempt). The conservative version can
216
+ * only reject a query that the precise version would allow — it never
217
+ * widens what a caller can learn — so it is the safe direction to ship; a
218
+ * caller can observe that an inaccessible related row exists (an `every`
219
+ * that "should" pass instead fails), but never that row's field values,
220
+ * which is the property this ticket exists to close.
221
+ */
222
+ export async function buildAccessScopedWhere(
223
+ where: unknown,
224
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ListConfig must accept any TypeInfo
225
+ listConfig: ListConfig<any>,
226
+ listName: string,
227
+ config: OpenSaasConfig,
228
+ args: {
229
+ session: Session | null
230
+ context: AccessContext
231
+ },
232
+ ): Promise<unknown> {
233
+ if (where === null || typeof where !== 'object') return where
234
+
235
+ if (Array.isArray(where)) {
236
+ return Promise.all(
237
+ where.map((entry) => buildAccessScopedWhere(entry, listConfig, listName, config, args)),
238
+ )
239
+ }
240
+
241
+ const result: Record<string, unknown> = {}
242
+
243
+ for (const [key, value] of Object.entries(where as Record<string, unknown>)) {
244
+ if (LOGICAL_OPERATORS.has(key)) {
245
+ result[key] = await buildAccessScopedWhere(value, listConfig, listName, config, args)
246
+ continue
247
+ }
248
+
249
+ const resolved = resolveQueryField(key, listConfig.fields)
250
+ if (
251
+ !resolved ||
252
+ !resolved.isRelationship ||
253
+ value === null ||
254
+ typeof value !== 'object' ||
255
+ Array.isArray(value)
256
+ ) {
257
+ result[key] = value
258
+ continue
259
+ }
260
+
261
+ const related = getRelatedListConfig(resolved.fieldConfig.ref, config)
262
+ if (!related) {
263
+ result[key] = value
264
+ continue
265
+ }
266
+
267
+ const queryAccess = related.listConfig.access?.operation?.query
268
+ const accessResult = await checkAccess(queryAccess, {
269
+ session: args.session,
270
+ context: args.context,
271
+ })
272
+
273
+ if (accessResult === false) {
274
+ throw new RelationFilterAccessDeniedError(listName, key, related.listName)
275
+ }
276
+
277
+ const accessWhere = typeof accessResult === 'object' ? accessResult : undefined
278
+ const relationEntries = Object.entries(value as Record<string, unknown>)
279
+ const hasQuantifier = relationEntries.some(([k]) => RELATION_QUANTIFIERS.has(k))
280
+
281
+ if (hasQuantifier) {
282
+ const nestedEntry: Record<string, unknown> = {}
283
+ for (const [quantifier, quantifierValue] of relationEntries) {
284
+ if (!RELATION_QUANTIFIERS.has(quantifier)) {
285
+ nestedEntry[quantifier] = quantifierValue
286
+ continue
287
+ }
288
+ await walkWhereReadAccess(quantifierValue, related.listConfig, related.listName, args)
289
+ const scopedNested = await buildAccessScopedWhere(
290
+ quantifierValue,
291
+ related.listConfig,
292
+ related.listName,
293
+ config,
294
+ args,
295
+ )
296
+ nestedEntry[quantifier] = accessWhere
297
+ ? andWhere(accessWhere, scopedNested as PrismaFilter | undefined)
298
+ : scopedNested
299
+ }
300
+ result[key] = nestedEntry
301
+ } else {
302
+ // Prisma's direct-nesting to-one form: the whole value IS the nested
303
+ // WHERE clause, with no `is` wrapper. Only wrap it in `is` when there is
304
+ // an access filter to fold in — an unwrapped value that needs no fold
305
+ // (fully-allowed related list) is returned exactly as the caller wrote
306
+ // it, so an already-permitted query is not perturbed by this pass.
307
+ await walkWhereReadAccess(value, related.listConfig, related.listName, args)
308
+ const scopedNested = await buildAccessScopedWhere(
309
+ value,
310
+ related.listConfig,
311
+ related.listName,
312
+ config,
313
+ args,
314
+ )
315
+ result[key] = accessWhere
316
+ ? { is: andWhere(accessWhere, scopedNested as PrismaFilter | undefined) }
317
+ : scopedNested
318
+ }
319
+ }
320
+
321
+ return result
322
+ }
323
+
176
324
  /**
177
325
  * Remove keys that correspond to `virtual` fields from a Prisma `include`
178
326
  * object, recursing into nested relationship includes using the related
@@ -201,7 +349,6 @@ export function stripVirtualFieldsFromInclude(
201
349
  for (const [key, value] of Object.entries(include)) {
202
350
  const fieldConfig = fieldConfigs[key]
203
351
 
204
- // Virtual fields have no database column — drop them from the include.
205
352
  if (fieldConfig?.virtual) continue
206
353
 
207
354
  const isDeclaredRelationship =
@@ -180,12 +180,8 @@ export function foldDeclaredDependencies(
180
180
  const relatedConfig = getRelatedListConfig(fieldConfig.ref, config)
181
181
  if (!relatedConfig) continue
182
182
  // Defensive cycle guard (see module doc comment) — a DECLARATION-ADDED
183
- // edge into a list already on this path stops here rather than recursing
184
- // without bound. The value at `key` is left exactly as-is. The guard is
185
- // deliberately not applied to an edge the request itself named: that
186
- // recursion is bounded by the request's own finite literal and cannot
187
- // loop, so stopping it would silently drop the folds beneath a request
188
- // that merely revisits a list (e.g. `Post → author → posts`).
183
+ // edge into a list already on this path stops here; the value at `key`
184
+ // is left exactly as-is.
189
185
  if (declaredOnly.keys.has(key) && visitedLists.includes(relatedConfig.listName)) continue
190
186
 
191
187
  // A branch added purely by the fold has no fragment scope of its own —
@@ -213,9 +209,8 @@ export function foldDeclaredDependencies(
213
209
  }
214
210
  }
215
211
 
216
- // A whole branch added purely by the fold above is stripped wholesale by
217
- // field-visibility regardless of what's nested inside it, so it needs no
218
- // individual nested-key tracking of its own.
212
+ // Declaration-added branches need no nested tracking of their own (see
213
+ // module doc comment) only caller/fragment-named branches do.
219
214
  if (!declaredOnly.keys.has(key) && !isDeclaredOnlyTreeEmpty(nested.declaredOnly)) {
220
215
  declaredOnly.nested[key] = nested.declaredOnly
221
216
  }
@@ -16,28 +16,14 @@ import type { OpenSaasConfig, ListConfig } from '../config/types.js'
16
16
  * and the access-control glossary in `CONTEXT.md`.
17
17
  */
18
18
 
19
- /**
20
- * Check if access control result is a boolean
21
- */
22
19
  export function isBoolean(value: unknown): value is boolean {
23
20
  return typeof value === 'boolean'
24
21
  }
25
22
 
26
- /**
27
- * Check if access control result is a Prisma filter
28
- */
29
23
  export function isPrismaFilter(value: unknown): value is PrismaFilter {
30
24
  return typeof value === 'object' && value !== null && !Array.isArray(value)
31
25
  }
32
26
 
33
- /**
34
- * Parse a relationship ref and get the related list configuration
35
- * Relationship refs are in the format "ListName.fieldName"
36
- *
37
- * @param relationshipRef - The ref string (e.g., "Post.author")
38
- * @param config - The OpenSaas configuration
39
- * @returns The related list name and config, or null if not found
40
- */
41
27
  export function getRelatedListConfig(
42
28
  relationshipRef: string,
43
29
  config: OpenSaasConfig,
@@ -59,9 +45,6 @@ export function getRelatedListConfig(
59
45
  return { listName, listConfig }
60
46
  }
61
47
 
62
- /**
63
- * Execute an access control function
64
- */
65
48
  export async function checkAccess<T = Record<string, unknown>>(
66
49
  accessControl: AccessControl<T> | undefined,
67
50
  args: {
@@ -75,35 +58,27 @@ export async function checkAccess<T = Record<string, unknown>>(
75
58
  return false
76
59
  }
77
60
 
78
- // Execute the access control function
79
61
  const result = await accessControl(args)
80
62
 
81
63
  return result
82
64
  }
83
65
 
84
- /**
85
- * Merge user filter with access control filter
86
- */
87
66
  export function mergeFilters(
88
67
  userFilter: PrismaFilter | undefined,
89
68
  accessFilter: boolean | PrismaFilter,
90
69
  ): PrismaFilter | null {
91
- // If access is denied, return null
92
70
  if (accessFilter === false) {
93
71
  return null
94
72
  }
95
73
 
96
- // If access is fully granted, use user filter
97
74
  if (accessFilter === true) {
98
75
  return userFilter || {}
99
76
  }
100
77
 
101
- // Merge access filter with user filter
102
78
  if (!userFilter) {
103
79
  return accessFilter
104
80
  }
105
81
 
106
- // Combine filters with AND
107
82
  return {
108
83
  AND: [accessFilter, userFilter],
109
84
  }
@@ -64,3 +64,84 @@ export class ResolveOutputCycleError extends Error {
64
64
  this.chain = chain
65
65
  }
66
66
  }
67
+
68
+ /**
69
+ * Thrown when a relation filter in a caller's `where` (`some`/`every`/`none`/
70
+ * `is`/`isNot`) names a relationship whose related list denies operation-level
71
+ * `query` access outright (`=== false`). Deliberately a loud failure, not a
72
+ * silently narrowed `{ id: { in: [] } }` or a pass-through: ADR-0022 requires
73
+ * an engine that cannot compute a scope to deny, never pass through, and here
74
+ * the engine CAN compute the scope (denied) — passing that through as a
75
+ * silently-empty match would itself be a distinguishable signal for a caller
76
+ * probing which relations they may filter on. `include`'s equivalent case
77
+ * (`buildAccessScopedInclude`) drops the relation silently instead, because a
78
+ * `null`/missing key in a response is indistinguishable from "not requested";
79
+ * a `where` predicate has no such neutral outcome, so a relation filter on a
80
+ * fully denied relation is refused instead. `sudo` bypasses this check
81
+ * entirely, matching every other access-control escape hatch. See #916 and
82
+ * `docs/adr/0022-access-control-fails-closed-when-it-cannot-scope.md`.
83
+ */
84
+ export class RelationFilterAccessDeniedError extends Error {
85
+ public listKey: string
86
+ public fieldKey: string
87
+ public relatedListKey: string
88
+
89
+ constructor(listKey: string, fieldKey: string, relatedListKey: string) {
90
+ super(
91
+ `Cannot filter "${listKey}.${fieldKey}" — the related list "${relatedListKey}" denies ` +
92
+ `query access to this session, so this relation filter cannot be scoped. Denial is loud ` +
93
+ `rather than silently narrowed to match nothing (ADR-0022). Use sudo to bypass.`,
94
+ )
95
+ this.name = 'RelationFilterAccessDeniedError'
96
+ this.listKey = listKey
97
+ this.fieldKey = fieldKey
98
+ this.relatedListKey = relatedListKey
99
+ }
100
+ }
101
+
102
+ function describeFieldAccessResult(result: unknown): string {
103
+ if (result === null) return 'null'
104
+ if (result === undefined) return 'undefined'
105
+ if (typeof result === 'object') return 'an object (e.g. a Prisma filter)'
106
+ return `a ${typeof result}`
107
+ }
108
+
109
+ /**
110
+ * Thrown when a field-level access control function returns anything other
111
+ * than a strict `boolean`. `FieldAccessControl` is typed to return `boolean`
112
+ * only: field access is a single per-field visibility decision, not a row
113
+ * filter, and a denied field is removed rather than used to scope rows (see
114
+ * the "Field-level access" glossary entry in `CONTEXT.md`, ADR-0001, and
115
+ * ADR-0030).
116
+ *
117
+ * A rule that type-checks can never reach this — the only way here is a
118
+ * caller that bypasses the type (an untyped JS config, or a value forced past
119
+ * the checker), most notably a Prisma filter, which is the shape
120
+ * operation-level `AccessControl` accepts but `FieldAccessControl` does not.
121
+ * Before #913 this fell through to an unconditional `return true`, silently
122
+ * granting the field blanket access; it now fails loudly and closed instead,
123
+ * for `read`, `create`, and `update` alike.
124
+ *
125
+ * Deliberately does not expose the offending result as a public field: unlike
126
+ * `AccessScopeDepthExceededError`/`ResolveOutputCycleError`'s fields, there is
127
+ * no concretely-typed shape to give it (the whole problem is that it isn't
128
+ * the `boolean` the caller's rule promised), and the root CLAUDE.md forbids
129
+ * exposing `unknown`/`any` as part of a package's external API. A description
130
+ * of what was returned instead is folded into the message text.
131
+ */
132
+ export class InvalidFieldAccessResultError extends Error {
133
+ public operation: 'read' | 'create' | 'update'
134
+
135
+ constructor(operation: 'read' | 'create' | 'update', result: unknown) {
136
+ super(
137
+ `Field-level access control for operation "${operation}" returned ` +
138
+ `${describeFieldAccessResult(result)}, not a boolean. Field access is a per-field ` +
139
+ `visibility decision — it must return true or false, and (unlike operation-level access) ` +
140
+ `cannot scope which rows are affected. If you meant to restrict access based on the row or ` +
141
+ `the write payload, evaluate the condition yourself and return a boolean, e.g. ` +
142
+ `\`({ item, session }) => item?.ownerId === session?.userId\`.`,
143
+ )
144
+ this.name = 'InvalidFieldAccessResultError'
145
+ this.operation = operation
146
+ }
147
+ }