@opensaas/stack-core 0.39.2 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +189 -0
  3. package/CLAUDE.md +103 -0
  4. package/dist/access/access-filter.d.ts +157 -14
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +470 -42
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +982 -17
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/denied-relation-visibility.test.d.ts +2 -0
  11. package/dist/access/denied-relation-visibility.test.d.ts.map +1 -0
  12. package/dist/access/denied-relation-visibility.test.js +114 -0
  13. package/dist/access/denied-relation-visibility.test.js.map +1 -0
  14. package/dist/access/engine.d.ts +54 -1
  15. package/dist/access/engine.d.ts.map +1 -1
  16. package/dist/access/engine.js +84 -0
  17. package/dist/access/engine.js.map +1 -1
  18. package/dist/access/engine.test.d.ts +2 -0
  19. package/dist/access/engine.test.d.ts.map +1 -0
  20. package/dist/access/engine.test.js +56 -0
  21. package/dist/access/engine.test.js.map +1 -0
  22. package/dist/access/errors.d.ts +62 -0
  23. package/dist/access/errors.d.ts.map +1 -1
  24. package/dist/access/errors.js +85 -2
  25. package/dist/access/errors.js.map +1 -1
  26. package/dist/access/field-access.d.ts +12 -0
  27. package/dist/access/field-access.d.ts.map +1 -1
  28. package/dist/access/field-access.js +22 -2
  29. package/dist/access/field-access.js.map +1 -1
  30. package/dist/access/field-access.test.js +66 -0
  31. package/dist/access/field-access.test.js.map +1 -1
  32. package/dist/access/field-visibility.d.ts +2 -1
  33. package/dist/access/field-visibility.d.ts.map +1 -1
  34. package/dist/access/field-visibility.js +107 -15
  35. package/dist/access/field-visibility.js.map +1 -1
  36. package/dist/access/index.d.ts +9 -2
  37. package/dist/access/index.d.ts.map +1 -1
  38. package/dist/access/index.js +11 -2
  39. package/dist/access/index.js.map +1 -1
  40. package/dist/access/multi-column-read-write.test.js.map +1 -1
  41. package/dist/access/query-validation.d.ts +31 -0
  42. package/dist/access/query-validation.d.ts.map +1 -1
  43. package/dist/access/query-validation.js +75 -16
  44. package/dist/access/query-validation.js.map +1 -1
  45. package/dist/access/relationship-count.d.ts +41 -3
  46. package/dist/access/relationship-count.d.ts.map +1 -1
  47. package/dist/access/relationship-count.js +44 -7
  48. package/dist/access/relationship-count.js.map +1 -1
  49. package/dist/access/relationship-count.test.js +62 -0
  50. package/dist/access/relationship-count.test.js.map +1 -1
  51. package/dist/access/synthetic-include-read.test.d.ts +2 -0
  52. package/dist/access/synthetic-include-read.test.d.ts.map +1 -0
  53. package/dist/access/synthetic-include-read.test.js +121 -0
  54. package/dist/access/synthetic-include-read.test.js.map +1 -0
  55. package/dist/config/types.d.ts +98 -32
  56. package/dist/config/types.d.ts.map +1 -1
  57. package/dist/context/hook-pipeline.d.ts +2 -2
  58. package/dist/context/hook-pipeline.d.ts.map +1 -1
  59. package/dist/context/hook-pipeline.js.map +1 -1
  60. package/dist/context/index.d.ts +35 -2
  61. package/dist/context/index.d.ts.map +1 -1
  62. package/dist/context/index.js +121 -27
  63. package/dist/context/index.js.map +1 -1
  64. package/dist/context/nested-operations.d.ts +2 -2
  65. package/dist/context/nested-operations.d.ts.map +1 -1
  66. package/dist/context/nested-operations.js +74 -15
  67. package/dist/context/nested-operations.js.map +1 -1
  68. package/dist/context/write-pipeline.d.ts +3 -2
  69. package/dist/context/write-pipeline.d.ts.map +1 -1
  70. package/dist/context/write-pipeline.js +31 -34
  71. package/dist/context/write-pipeline.js.map +1 -1
  72. package/dist/fields/index.d.ts +13 -1
  73. package/dist/fields/index.d.ts.map +1 -1
  74. package/dist/fields/index.js +50 -5
  75. package/dist/fields/index.js.map +1 -1
  76. package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
  77. package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
  78. package/dist/fields/relationship-foreign-key.test.js +18 -0
  79. package/dist/fields/relationship-foreign-key.test.js.map +1 -0
  80. package/dist/hooks/index.d.ts +16 -15
  81. package/dist/hooks/index.d.ts.map +1 -1
  82. package/dist/hooks/index.js.map +1 -1
  83. package/dist/index.d.ts +4 -1
  84. package/dist/index.d.ts.map +1 -1
  85. package/dist/index.js +14 -1
  86. package/dist/index.js.map +1 -1
  87. package/dist/lib/case-utils.d.ts +15 -0
  88. package/dist/lib/case-utils.d.ts.map +1 -1
  89. package/dist/lib/case-utils.js +21 -0
  90. package/dist/lib/case-utils.js.map +1 -1
  91. package/dist/lib/case-utils.test.js +23 -1
  92. package/dist/lib/case-utils.test.js.map +1 -1
  93. package/dist/lib/prisma-errors.d.ts +38 -0
  94. package/dist/lib/prisma-errors.d.ts.map +1 -0
  95. package/dist/lib/prisma-errors.js +53 -0
  96. package/dist/lib/prisma-errors.js.map +1 -0
  97. package/dist/mcp/constants.d.ts +23 -0
  98. package/dist/mcp/constants.d.ts.map +1 -0
  99. package/dist/mcp/constants.js +23 -0
  100. package/dist/mcp/constants.js.map +1 -0
  101. package/dist/mcp/field-schema.d.ts +8 -0
  102. package/dist/mcp/field-schema.d.ts.map +1 -0
  103. package/dist/mcp/field-schema.js +69 -0
  104. package/dist/mcp/field-schema.js.map +1 -0
  105. package/dist/mcp/handler.d.ts.map +1 -1
  106. package/dist/mcp/handler.js +76 -90
  107. package/dist/mcp/handler.js.map +1 -1
  108. package/dist/mcp/projection.d.ts +62 -0
  109. package/dist/mcp/projection.d.ts.map +1 -0
  110. package/dist/mcp/projection.js +390 -0
  111. package/dist/mcp/projection.js.map +1 -0
  112. package/dist/utils/password.d.ts +3 -1
  113. package/dist/utils/password.d.ts.map +1 -1
  114. package/dist/utils/password.js +4 -2
  115. package/dist/utils/password.js.map +1 -1
  116. package/package.json +2 -2
  117. package/src/access/access-filter.test.ts +1528 -18
  118. package/src/access/access-filter.ts +708 -42
  119. package/src/access/denied-relation-visibility.test.ts +173 -0
  120. package/src/access/engine.test.ts +67 -0
  121. package/src/access/engine.ts +109 -1
  122. package/src/access/errors.ts +97 -2
  123. package/src/access/field-access.test.ts +78 -0
  124. package/src/access/field-access.ts +34 -2
  125. package/src/access/field-visibility.ts +171 -25
  126. package/src/access/index.ts +25 -0
  127. package/src/access/multi-column-read-write.test.ts +3 -2
  128. package/src/access/query-validation.ts +136 -9
  129. package/src/access/relationship-count.test.ts +75 -0
  130. package/src/access/relationship-count.ts +53 -10
  131. package/src/access/synthetic-include-read.test.ts +179 -0
  132. package/src/config/types.ts +98 -32
  133. package/src/context/hook-pipeline.ts +4 -2
  134. package/src/context/index.ts +203 -42
  135. package/src/context/nested-operations.ts +96 -27
  136. package/src/context/write-pipeline.ts +60 -36
  137. package/src/fields/index.ts +55 -5
  138. package/src/fields/relationship-foreign-key.test.ts +29 -0
  139. package/src/hooks/index.ts +22 -15
  140. package/src/index.ts +17 -1
  141. package/src/lib/case-utils.test.ts +32 -0
  142. package/src/lib/case-utils.ts +26 -0
  143. package/src/lib/prisma-errors.ts +86 -0
  144. package/src/mcp/constants.ts +24 -0
  145. package/src/mcp/field-schema.ts +84 -0
  146. package/src/mcp/handler.ts +109 -107
  147. package/src/mcp/projection.ts +497 -0
  148. package/src/utils/password.ts +5 -3
  149. package/tests/access-relationships.test.ts +3 -3
  150. package/tests/access.test.ts +83 -0
  151. package/tests/context.test.ts +452 -17
  152. package/tests/field-types.test.ts +113 -0
  153. package/tests/hook-context-secured.test.ts +424 -0
  154. package/tests/mcp-fields-projection-access.test.ts +250 -0
  155. package/tests/mcp-handler.test.ts +695 -4
  156. package/tests/nested-access-and-hooks.test.ts +389 -0
  157. package/tests/nested-write-synthetic-relation.test.ts +437 -0
  158. package/tests/password-types.test.ts +28 -0
  159. package/tests/password.test.ts +16 -2
  160. package/tests/prisma-errors.test.ts +141 -0
  161. package/tests/singleton.test.ts +9 -1
  162. package/tests/to-one-include-access-filter.test.ts +172 -0
  163. package/tests/with-session.test.ts +243 -0
  164. package/tests/write-pipeline.test.ts +29 -0
  165. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,250 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { createMcpHandlers } from '../src/mcp/handler.js'
3
+ import { getContext as buildContext } from '../src/context/index.js'
4
+ import { config, list } from '../src/config/index.js'
5
+ import { text, relationship, virtual, checkbox } from '../src/fields/index.js'
6
+ import type { McpSessionProvider } from '../src/mcp/types.js'
7
+
8
+ /**
9
+ * Coverage for issue #851 / ADR-0033: the `query` tool's `fields` projection
10
+ * exercised through the REAL access-control pipeline (`getContext`), not the
11
+ * lightweight mocked-`context.db` harness `mcp-handler.test.ts` uses. This is
12
+ * what actually proves a nested to-many `where` gets AND-combined with the
13
+ * related list's own access filter, a to-one relation failing that filter is
14
+ * nulled by the real existence check, field-level access still drops values
15
+ * regardless of the projection, and a selected computed field's declared
16
+ * `needs` are folded in by the ordinary read pipeline — none of which the
17
+ * mocked-`context.db` harness can exercise, since it bypasses the engine.
18
+ */
19
+
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ function createMockPrisma(): any {
22
+ const model = () => ({
23
+ findMany: vi.fn(),
24
+ findFirst: vi.fn(),
25
+ create: vi.fn(),
26
+ update: vi.fn(),
27
+ delete: vi.fn(),
28
+ })
29
+ return { user: model(), comment: model(), post: model() }
30
+ }
31
+
32
+ async function buildTestConfig() {
33
+ return config({
34
+ db: { provider: 'postgresql', url: 'postgresql://localhost:5432/test' },
35
+ mcp: { enabled: true },
36
+ lists: {
37
+ User: list({
38
+ fields: { name: text() },
39
+ // Only "u1" is queryable — a FILTER, not a boolean, so a to-one
40
+ // relation pointing at a different user gets nulled post-query.
41
+ access: { operation: { query: () => ({ id: { equals: 'u1' } }) } },
42
+ }),
43
+ Comment: list({
44
+ fields: {
45
+ text: text(),
46
+ approved: checkbox(),
47
+ post: relationship({ ref: 'Post.comments' }),
48
+ },
49
+ // Only approved comments are queryable.
50
+ access: { operation: { query: () => ({ approved: { equals: true } }) } },
51
+ }),
52
+ Post: list({
53
+ fields: {
54
+ title: text(),
55
+ author: relationship({ ref: 'User' }),
56
+ comments: relationship({ ref: 'Comment.post', many: true }),
57
+ // Field-level read denied on the RELATIONSHIP FIELD itself —
58
+ // distinct from a related list's operation-level access.
59
+ restrictedComments: relationship({
60
+ ref: 'Comment',
61
+ many: true,
62
+ access: { read: () => false },
63
+ }),
64
+ // Field-level read access that depends on the relation's OWN
65
+ // fetched value — proves the count mechanism doesn't corrupt that
66
+ // value with a synthetic empty array to make the check cheaper.
67
+ commentsVisibleIfEmpty: relationship({
68
+ ref: 'Comment',
69
+ many: true,
70
+ access: {
71
+ read: ({ item }) => {
72
+ const value = (item as { commentsVisibleIfEmpty?: unknown[] })
73
+ .commentsVisibleIfEmpty
74
+ return Array.isArray(value) ? value.length === 0 : true
75
+ },
76
+ },
77
+ }),
78
+ secret: text({ access: { read: () => false } }),
79
+ commentCount: virtual({
80
+ type: 'number',
81
+ needs: ['comments'],
82
+ hooks: {
83
+ resolveOutput: ({ item }) => {
84
+ const typed = item as { comments?: unknown[] }
85
+ return (typed.comments ?? []).length
86
+ },
87
+ },
88
+ }),
89
+ },
90
+ access: { operation: { query: () => true } },
91
+ }),
92
+ },
93
+ })
94
+ }
95
+
96
+ const mockGetSession: McpSessionProvider = vi.fn(async () => ({ userId: 'me' }))
97
+
98
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
99
+ async function callPostQuery(testConfig: any, mockPrisma: any, args: Record<string, unknown>) {
100
+ const handlers = createMcpHandlers({
101
+ config: testConfig,
102
+ getSession: mockGetSession,
103
+ getContext: async (session) => buildContext(testConfig, mockPrisma, session ?? null),
104
+ })
105
+
106
+ const response = await handlers.POST(
107
+ new Request('http://localhost/api/mcp', {
108
+ method: 'POST',
109
+ headers: { 'Content-Type': 'application/json' },
110
+ body: JSON.stringify({
111
+ jsonrpc: '2.0',
112
+ id: 1,
113
+ method: 'tools/call',
114
+ params: { name: 'list_post_query', arguments: args },
115
+ }),
116
+ }),
117
+ )
118
+ return response.json()
119
+ }
120
+
121
+ describe('MCP `fields` projection against the real access-control pipeline (#851)', () => {
122
+ it('nulls a to-one relation whose related row fails the related list access filter', async () => {
123
+ const testConfig = await buildTestConfig()
124
+ const mockPrisma = createMockPrisma()
125
+ mockPrisma.post.findMany.mockResolvedValue([
126
+ { id: 'p1', title: 'Hi', author: { id: 'u2', name: 'Bob' } },
127
+ ])
128
+ // The batched existence check queries the RAW prisma client for ids
129
+ // matching the access filter — "u2" doesn't, so it comes back empty.
130
+ mockPrisma.user.findMany.mockResolvedValue([])
131
+
132
+ const data = await callPostQuery(testConfig, mockPrisma, {
133
+ fields: { title: true, author: { fields: { name: true } } },
134
+ })
135
+
136
+ const result = JSON.parse(data.result.content[0].text)
137
+ expect(result.items).toEqual([{ id: 'p1', title: 'Hi', author: null }])
138
+ })
139
+
140
+ it('AND-combines a nested to-many where with the related list’s own access filter', async () => {
141
+ const testConfig = await buildTestConfig()
142
+ const mockPrisma = createMockPrisma()
143
+ mockPrisma.post.findMany.mockResolvedValue([
144
+ { id: 'p1', title: 'Hi', comments: [{ id: 'c1', text: 'nice' }] },
145
+ ])
146
+
147
+ await callPostQuery(testConfig, mockPrisma, {
148
+ fields: { comments: { fields: { text: true }, where: { text: { contains: 'nice' } } } },
149
+ })
150
+
151
+ expect(mockPrisma.post.findMany).toHaveBeenCalledWith(
152
+ expect.objectContaining({
153
+ include: {
154
+ comments: {
155
+ where: { AND: [{ approved: { equals: true } }, { text: { contains: 'nice' } }] },
156
+ take: 5,
157
+ },
158
+ },
159
+ }),
160
+ )
161
+ })
162
+
163
+ it('still drops a field-level-denied field even when named in fields', async () => {
164
+ const testConfig = await buildTestConfig()
165
+ const mockPrisma = createMockPrisma()
166
+ mockPrisma.post.findMany.mockResolvedValue([{ id: 'p1', title: 'Hi', secret: 'shh' }])
167
+
168
+ const data = await callPostQuery(testConfig, mockPrisma, {
169
+ fields: { title: true, secret: true },
170
+ })
171
+
172
+ const result = JSON.parse(data.result.content[0].text)
173
+ expect(result.items).toEqual([{ id: 'p1', title: 'Hi' }])
174
+ })
175
+
176
+ it('running a selected computed field folds its declared `needs`, and the count reflects the access-scoped rows', async () => {
177
+ const testConfig = await buildTestConfig()
178
+ const mockPrisma = createMockPrisma()
179
+ // The engine fetches `comments` unscoped by our projection (folded via
180
+ // `needs`) and access-scopes it exactly like any other named relation —
181
+ // only the approved one should count.
182
+ mockPrisma.post.findMany.mockResolvedValue([
183
+ { id: 'p1', title: 'Hi', comments: [{ id: 'c1', approved: true }] },
184
+ ])
185
+
186
+ const data = await callPostQuery(testConfig, mockPrisma, {
187
+ fields: { commentCount: true },
188
+ })
189
+
190
+ const result = JSON.parse(data.result.content[0].text)
191
+ expect(result.items).toEqual([{ id: 'p1', commentCount: 1 }])
192
+
193
+ // `needs` folded `comments` into the include even though the caller's
194
+ // own `fields` never named it.
195
+ const callArgs = mockPrisma.post.findMany.mock.calls[0][0]
196
+ expect(callArgs.include.comments).toBeDefined()
197
+ })
198
+
199
+ it('suppresses a relation count denied by the relationship field’s own access, through the real field-visibility pass', async () => {
200
+ const testConfig = await buildTestConfig()
201
+ const mockPrisma = createMockPrisma()
202
+ // The real pipeline's `filterReadableFields` runs before `projectMcpResult`
203
+ // ever sees this row: it evaluates `restrictedComments`'s field-level
204
+ // `access.read` against the TRUE raw row below and strips the key
205
+ // entirely (denied), so `_count.restrictedComments` survives in the raw
206
+ // Prisma row but the relation key itself does not.
207
+ mockPrisma.post.findMany.mockResolvedValue([
208
+ { id: 'p1', title: 'Hi', restrictedComments: [], _count: { restrictedComments: 4 } },
209
+ ])
210
+
211
+ const data = await callPostQuery(testConfig, mockPrisma, {
212
+ fields: { title: true, restrictedComments: { count: true } },
213
+ })
214
+
215
+ const result = JSON.parse(data.result.content[0].text)
216
+ expect(result.items).toEqual([{ id: 'p1', title: 'Hi' }])
217
+ })
218
+
219
+ it('evaluates a value-dependent field-level access rule against the relation’s TRUE fetched rows, not a synthetic empty placeholder', async () => {
220
+ const testConfig = await buildTestConfig()
221
+ const mockPrisma = createMockPrisma()
222
+ // `commentsVisibleIfEmpty`'s own access rule denies read when the
223
+ // relation actually has rows. A `take: 0` "cheap" fetch would make
224
+ // field-visibility see an empty array regardless of the truth and
225
+ // wrongly GRANT access here — asserting the count is suppressed proves
226
+ // the real (non-empty) rows reached the access check.
227
+ mockPrisma.post.findMany.mockResolvedValue([
228
+ {
229
+ id: 'p1',
230
+ title: 'Hi',
231
+ commentsVisibleIfEmpty: [{ id: 'c1', text: 'nice' }],
232
+ _count: { commentsVisibleIfEmpty: 1 },
233
+ },
234
+ ])
235
+
236
+ const data = await callPostQuery(testConfig, mockPrisma, {
237
+ fields: { title: true, commentsVisibleIfEmpty: { count: true } },
238
+ })
239
+
240
+ const result = JSON.parse(data.result.content[0].text)
241
+ expect(result.items).toEqual([{ id: 'p1', title: 'Hi' }])
242
+
243
+ // Real rows were fetched (access-scoped by Comment's own operation-level
244
+ // query access, folded in the same way any other named relation is) —
245
+ // never a synthetic `take: 0` placeholder.
246
+ const callArgs = mockPrisma.post.findMany.mock.calls[0][0]
247
+ expect(callArgs.include.commentsVisibleIfEmpty).toMatchObject({ take: 5 })
248
+ expect(callArgs.include.commentsVisibleIfEmpty.take).not.toBe(0)
249
+ })
250
+ })