@opensaas/stack-core 0.39.1 → 0.40.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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +156 -0
- package/CLAUDE.md +51 -0
- package/dist/access/access-filter.d.ts +83 -10
- package/dist/access/access-filter.d.ts.map +1 -1
- package/dist/access/access-filter.js +139 -18
- package/dist/access/access-filter.js.map +1 -1
- package/dist/access/access-filter.test.js +224 -12
- package/dist/access/access-filter.test.js.map +1 -1
- package/dist/access/engine.d.ts +43 -1
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +58 -0
- package/dist/access/engine.js.map +1 -1
- package/dist/access/engine.test.d.ts +2 -0
- package/dist/access/engine.test.d.ts.map +1 -0
- package/dist/access/engine.test.js +56 -0
- package/dist/access/engine.test.js.map +1 -0
- package/dist/access/errors.d.ts +28 -0
- package/dist/access/errors.d.ts.map +1 -1
- package/dist/access/errors.js +38 -2
- package/dist/access/errors.js.map +1 -1
- package/dist/access/field-access.d.ts +12 -0
- package/dist/access/field-access.d.ts.map +1 -1
- package/dist/access/field-access.js +22 -2
- package/dist/access/field-access.js.map +1 -1
- package/dist/access/field-access.test.js +66 -0
- package/dist/access/field-access.test.js.map +1 -1
- package/dist/access/field-visibility.d.ts +2 -1
- package/dist/access/field-visibility.d.ts.map +1 -1
- package/dist/access/field-visibility.js +45 -3
- package/dist/access/field-visibility.js.map +1 -1
- package/dist/access/index.d.ts +5 -2
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +4 -2
- package/dist/access/index.js.map +1 -1
- package/dist/config/types.d.ts +77 -11
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/index.d.ts +19 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +61 -17
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +54 -13
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +3 -2
- package/dist/context/write-pipeline.d.ts.map +1 -1
- package/dist/context/write-pipeline.js +8 -5
- package/dist/context/write-pipeline.js.map +1 -1
- package/dist/fields/index.d.ts +13 -1
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +50 -5
- package/dist/fields/index.js.map +1 -1
- package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
- package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
- package/dist/fields/relationship-foreign-key.test.js +18 -0
- package/dist/fields/relationship-foreign-key.test.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/case-utils.d.ts +15 -0
- package/dist/lib/case-utils.d.ts.map +1 -1
- package/dist/lib/case-utils.js +21 -0
- package/dist/lib/case-utils.js.map +1 -1
- package/dist/lib/case-utils.test.js +23 -1
- package/dist/lib/case-utils.test.js.map +1 -1
- package/dist/lib/prisma-errors.d.ts +38 -0
- package/dist/lib/prisma-errors.d.ts.map +1 -0
- package/dist/lib/prisma-errors.js +53 -0
- package/dist/lib/prisma-errors.js.map +1 -0
- package/dist/mcp/constants.d.ts +23 -0
- package/dist/mcp/constants.d.ts.map +1 -0
- package/dist/mcp/constants.js +23 -0
- package/dist/mcp/constants.js.map +1 -0
- package/dist/mcp/field-schema.d.ts +8 -0
- package/dist/mcp/field-schema.d.ts.map +1 -0
- package/dist/mcp/field-schema.js +69 -0
- package/dist/mcp/field-schema.js.map +1 -0
- package/dist/mcp/handler.d.ts.map +1 -1
- package/dist/mcp/handler.js +76 -90
- package/dist/mcp/handler.js.map +1 -1
- package/dist/mcp/projection.d.ts +62 -0
- package/dist/mcp/projection.d.ts.map +1 -0
- package/dist/mcp/projection.js +390 -0
- package/dist/mcp/projection.js.map +1 -0
- package/dist/utils/password.d.ts +3 -1
- package/dist/utils/password.d.ts.map +1 -1
- package/dist/utils/password.js +4 -2
- package/dist/utils/password.js.map +1 -1
- package/package.json +1 -1
- package/src/access/access-filter.test.ts +278 -12
- package/src/access/access-filter.ts +207 -19
- package/src/access/engine.test.ts +67 -0
- package/src/access/engine.ts +81 -1
- package/src/access/errors.ts +42 -2
- package/src/access/field-access.test.ts +78 -0
- package/src/access/field-access.ts +34 -2
- package/src/access/field-visibility.ts +75 -10
- package/src/access/index.ts +14 -0
- package/src/config/types.ts +77 -11
- package/src/context/index.ts +95 -15
- package/src/context/nested-operations.ts +61 -16
- package/src/context/write-pipeline.ts +8 -4
- package/src/fields/index.ts +55 -5
- package/src/fields/relationship-foreign-key.test.ts +29 -0
- package/src/index.ts +17 -1
- package/src/lib/case-utils.test.ts +32 -0
- package/src/lib/case-utils.ts +26 -0
- package/src/lib/prisma-errors.ts +86 -0
- package/src/mcp/constants.ts +24 -0
- package/src/mcp/field-schema.ts +84 -0
- package/src/mcp/handler.ts +109 -107
- package/src/mcp/projection.ts +497 -0
- package/src/utils/password.ts +5 -3
- package/tests/access-relationships.test.ts +3 -3
- package/tests/access.test.ts +83 -0
- package/tests/context.test.ts +259 -12
- package/tests/field-types.test.ts +113 -0
- package/tests/mcp-fields-projection-access.test.ts +250 -0
- package/tests/mcp-handler.test.ts +695 -4
- package/tests/nested-access-and-hooks.test.ts +65 -0
- package/tests/nested-write-synthetic-relation.test.ts +437 -0
- package/tests/password-types.test.ts +28 -0
- package/tests/password.test.ts +16 -2
- package/tests/prisma-errors.test.ts +141 -0
- package/tests/singleton.test.ts +9 -1
- package/tests/to-one-include-access-filter.test.ts +172 -0
- package/tests/with-session.test.ts +243 -0
- package/tests/write-pipeline.test.ts +29 -0
- 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
|
+
})
|