@opensaas/stack-core 0.39.2 → 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.
Files changed (130) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +150 -0
  3. package/CLAUDE.md +51 -0
  4. package/dist/access/access-filter.d.ts +83 -10
  5. package/dist/access/access-filter.d.ts.map +1 -1
  6. package/dist/access/access-filter.js +139 -18
  7. package/dist/access/access-filter.js.map +1 -1
  8. package/dist/access/access-filter.test.js +224 -12
  9. package/dist/access/access-filter.test.js.map +1 -1
  10. package/dist/access/engine.d.ts +43 -1
  11. package/dist/access/engine.d.ts.map +1 -1
  12. package/dist/access/engine.js +58 -0
  13. package/dist/access/engine.js.map +1 -1
  14. package/dist/access/engine.test.d.ts +2 -0
  15. package/dist/access/engine.test.d.ts.map +1 -0
  16. package/dist/access/engine.test.js +56 -0
  17. package/dist/access/engine.test.js.map +1 -0
  18. package/dist/access/errors.d.ts +28 -0
  19. package/dist/access/errors.d.ts.map +1 -1
  20. package/dist/access/errors.js +38 -2
  21. package/dist/access/errors.js.map +1 -1
  22. package/dist/access/field-access.d.ts +12 -0
  23. package/dist/access/field-access.d.ts.map +1 -1
  24. package/dist/access/field-access.js +22 -2
  25. package/dist/access/field-access.js.map +1 -1
  26. package/dist/access/field-access.test.js +66 -0
  27. package/dist/access/field-access.test.js.map +1 -1
  28. package/dist/access/field-visibility.d.ts +2 -1
  29. package/dist/access/field-visibility.d.ts.map +1 -1
  30. package/dist/access/field-visibility.js +45 -3
  31. package/dist/access/field-visibility.js.map +1 -1
  32. package/dist/access/index.d.ts +5 -2
  33. package/dist/access/index.d.ts.map +1 -1
  34. package/dist/access/index.js +4 -2
  35. package/dist/access/index.js.map +1 -1
  36. package/dist/config/types.d.ts +76 -10
  37. package/dist/config/types.d.ts.map +1 -1
  38. package/dist/context/index.d.ts +19 -1
  39. package/dist/context/index.d.ts.map +1 -1
  40. package/dist/context/index.js +61 -17
  41. package/dist/context/index.js.map +1 -1
  42. package/dist/context/nested-operations.d.ts.map +1 -1
  43. package/dist/context/nested-operations.js +54 -13
  44. package/dist/context/nested-operations.js.map +1 -1
  45. package/dist/context/write-pipeline.d.ts +3 -2
  46. package/dist/context/write-pipeline.d.ts.map +1 -1
  47. package/dist/context/write-pipeline.js +8 -5
  48. package/dist/context/write-pipeline.js.map +1 -1
  49. package/dist/fields/index.d.ts +13 -1
  50. package/dist/fields/index.d.ts.map +1 -1
  51. package/dist/fields/index.js +50 -5
  52. package/dist/fields/index.js.map +1 -1
  53. package/dist/fields/relationship-foreign-key.test.d.ts +2 -0
  54. package/dist/fields/relationship-foreign-key.test.d.ts.map +1 -0
  55. package/dist/fields/relationship-foreign-key.test.js +18 -0
  56. package/dist/fields/relationship-foreign-key.test.js.map +1 -0
  57. package/dist/index.d.ts +4 -1
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +14 -1
  60. package/dist/index.js.map +1 -1
  61. package/dist/lib/case-utils.d.ts +15 -0
  62. package/dist/lib/case-utils.d.ts.map +1 -1
  63. package/dist/lib/case-utils.js +21 -0
  64. package/dist/lib/case-utils.js.map +1 -1
  65. package/dist/lib/case-utils.test.js +23 -1
  66. package/dist/lib/case-utils.test.js.map +1 -1
  67. package/dist/lib/prisma-errors.d.ts +38 -0
  68. package/dist/lib/prisma-errors.d.ts.map +1 -0
  69. package/dist/lib/prisma-errors.js +53 -0
  70. package/dist/lib/prisma-errors.js.map +1 -0
  71. package/dist/mcp/constants.d.ts +23 -0
  72. package/dist/mcp/constants.d.ts.map +1 -0
  73. package/dist/mcp/constants.js +23 -0
  74. package/dist/mcp/constants.js.map +1 -0
  75. package/dist/mcp/field-schema.d.ts +8 -0
  76. package/dist/mcp/field-schema.d.ts.map +1 -0
  77. package/dist/mcp/field-schema.js +69 -0
  78. package/dist/mcp/field-schema.js.map +1 -0
  79. package/dist/mcp/handler.d.ts.map +1 -1
  80. package/dist/mcp/handler.js +76 -90
  81. package/dist/mcp/handler.js.map +1 -1
  82. package/dist/mcp/projection.d.ts +62 -0
  83. package/dist/mcp/projection.d.ts.map +1 -0
  84. package/dist/mcp/projection.js +390 -0
  85. package/dist/mcp/projection.js.map +1 -0
  86. package/dist/utils/password.d.ts +3 -1
  87. package/dist/utils/password.d.ts.map +1 -1
  88. package/dist/utils/password.js +4 -2
  89. package/dist/utils/password.js.map +1 -1
  90. package/package.json +1 -1
  91. package/src/access/access-filter.test.ts +278 -12
  92. package/src/access/access-filter.ts +207 -19
  93. package/src/access/engine.test.ts +67 -0
  94. package/src/access/engine.ts +81 -1
  95. package/src/access/errors.ts +42 -2
  96. package/src/access/field-access.test.ts +78 -0
  97. package/src/access/field-access.ts +34 -2
  98. package/src/access/field-visibility.ts +75 -10
  99. package/src/access/index.ts +14 -0
  100. package/src/config/types.ts +76 -10
  101. package/src/context/index.ts +95 -15
  102. package/src/context/nested-operations.ts +61 -16
  103. package/src/context/write-pipeline.ts +8 -4
  104. package/src/fields/index.ts +55 -5
  105. package/src/fields/relationship-foreign-key.test.ts +29 -0
  106. package/src/index.ts +17 -1
  107. package/src/lib/case-utils.test.ts +32 -0
  108. package/src/lib/case-utils.ts +26 -0
  109. package/src/lib/prisma-errors.ts +86 -0
  110. package/src/mcp/constants.ts +24 -0
  111. package/src/mcp/field-schema.ts +84 -0
  112. package/src/mcp/handler.ts +109 -107
  113. package/src/mcp/projection.ts +497 -0
  114. package/src/utils/password.ts +5 -3
  115. package/tests/access-relationships.test.ts +3 -3
  116. package/tests/access.test.ts +83 -0
  117. package/tests/context.test.ts +259 -12
  118. package/tests/field-types.test.ts +113 -0
  119. package/tests/mcp-fields-projection-access.test.ts +250 -0
  120. package/tests/mcp-handler.test.ts +695 -4
  121. package/tests/nested-access-and-hooks.test.ts +65 -0
  122. package/tests/nested-write-synthetic-relation.test.ts +437 -0
  123. package/tests/password-types.test.ts +28 -0
  124. package/tests/password.test.ts +16 -2
  125. package/tests/prisma-errors.test.ts +141 -0
  126. package/tests/singleton.test.ts +9 -1
  127. package/tests/to-one-include-access-filter.test.ts +172 -0
  128. package/tests/with-session.test.ts +243 -0
  129. package/tests/write-pipeline.test.ts +29 -0
  130. package/tsconfig.tsbuildinfo +1 -1
@@ -1,8 +1,18 @@
1
1
  import * as z from 'zod'
2
- import type { OpenSaasConfig, FieldConfig, McpCustomTool } from '../config/types.js'
2
+ import type { OpenSaasConfig, McpCustomTool } from '../config/types.js'
3
3
  import type { AccessContext } from '../access/types.js'
4
+ import { checkAccess } from '../access/engine.js'
5
+ import { AccessScopeDepthExceededError, RelationFilterAccessDeniedError } from '../access/errors.js'
6
+ import { ValidationError } from '../hooks/index.js'
4
7
  import { getDbKey } from '../lib/case-utils.js'
5
8
  import type { McpSession, McpSessionProvider } from './types.js'
9
+ import { generateFieldSchemas } from './field-schema.js'
10
+ import {
11
+ McpProjectionRefusedError,
12
+ generateFieldsProjectionSchema,
13
+ projectMcpResult,
14
+ resolveFieldsProjection,
15
+ } from './projection.js'
6
16
 
7
17
  /**
8
18
  * Context session type accepted by the generated getContext factory.
@@ -119,7 +129,8 @@ export function createMcpHandlers(options: {
119
129
  }
120
130
 
121
131
  if (body.method === 'tools/list') {
122
- return handleToolsList(config, body.id)
132
+ const context = await getContext(toContextSession(session))
133
+ return await handleToolsList(config, context, body.id)
123
134
  }
124
135
 
125
136
  if (body.method === 'tools/call') {
@@ -194,91 +205,24 @@ function handleInitialize(_params?: any, id?: number | string): Response {
194
205
  )
195
206
  }
196
207
 
197
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Field configs have varying structures
198
- function fieldToJsonSchema(fieldName: string, fieldConfig: any): Record<string, unknown> {
199
- const baseSchema: Record<string, unknown> = {}
200
-
201
- switch (fieldConfig.type) {
202
- case 'text':
203
- case 'password':
204
- baseSchema.type = 'string'
205
- if (fieldConfig.validation?.length) {
206
- if (fieldConfig.validation.length.min)
207
- baseSchema.minLength = fieldConfig.validation.length.min
208
- if (fieldConfig.validation.length.max)
209
- baseSchema.maxLength = fieldConfig.validation.length.max
210
- }
211
- break
212
- case 'integer':
213
- baseSchema.type = 'number'
214
- if (fieldConfig.validation?.min !== undefined) baseSchema.minimum = fieldConfig.validation.min
215
- if (fieldConfig.validation?.max !== undefined) baseSchema.maximum = fieldConfig.validation.max
216
- break
217
- case 'checkbox':
218
- baseSchema.type = 'boolean'
219
- break
220
- case 'timestamp':
221
- baseSchema.type = 'string'
222
- baseSchema.format = 'date-time'
223
- break
224
- case 'select':
225
- baseSchema.type = 'string'
226
- if (fieldConfig.options) {
227
- baseSchema.enum = fieldConfig.options.map((opt: { value: string }) => opt.value)
228
- }
229
- break
230
- case 'relationship':
231
- baseSchema.type = 'object'
232
- baseSchema.properties = {
233
- connect: {
234
- type: 'object',
235
- properties: {
236
- id: { type: 'string' },
237
- },
238
- },
239
- }
240
- break
241
- default:
242
- baseSchema.type = 'string'
243
- }
244
-
245
- return baseSchema
246
- }
247
-
248
- function generateFieldSchemas(
249
- fields: Record<string, FieldConfig>,
250
- operation: 'create' | 'update',
251
- ): {
252
- properties: Record<string, unknown>
253
- required: string[]
254
- } {
255
- const properties: Record<string, unknown> = {}
256
- const required: string[] = []
257
-
258
- for (const [fieldName, fieldConfig] of Object.entries(fields)) {
259
- if (['id', 'createdAt', 'updatedAt'].includes(fieldName)) continue
260
-
261
- properties[fieldName] = fieldToJsonSchema(fieldName, fieldConfig)
262
-
263
- if (
264
- operation === 'create' &&
265
- 'validation' in fieldConfig &&
266
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Validation property varies by field type
267
- (fieldConfig.validation as any)?.isRequired
268
- ) {
269
- required.push(fieldName)
270
- }
271
- }
272
-
273
- return { properties, required }
274
- }
275
-
276
- function handleToolsList(config: OpenSaasConfig, id?: number | string): Response {
208
+ async function handleToolsList(
209
+ config: OpenSaasConfig,
210
+ context: AccessContext,
211
+ id?: number | string,
212
+ ): Promise<Response> {
277
213
  const tools: McpTool[] = []
278
214
 
279
215
  for (const [listKey, listConfig] of Object.entries(config.lists)) {
280
216
  if (listConfig.mcp?.enabled === false) continue
281
217
 
218
+ // A session denied operation-level `query` outright sees none of this
219
+ // list's tools, nor any relation entry elsewhere pointing at it as a
220
+ // target (`relatedListIfVisible` in projection.ts applies the identical
221
+ // check there) — ADR-0033.
222
+ const queryAccess = listConfig.access?.operation?.query
223
+ const accessResult = await checkAccess(queryAccess, { session: context.session, context })
224
+ if (accessResult === false) continue
225
+
282
226
  const dbKey = getDbKey(listKey)
283
227
  const defaultTools = config.mcp?.defaultTools || {
284
228
  read: true,
@@ -295,6 +239,12 @@ function handleToolsList(config: OpenSaasConfig, id?: number | string): Response
295
239
  }
296
240
 
297
241
  if (enabledTools.read) {
242
+ const fieldsSchema = await generateFieldsProjectionSchema(
243
+ listConfig,
244
+ config,
245
+ context.session,
246
+ context,
247
+ )
298
248
  tools.push({
299
249
  name: `list_${dbKey}_query`,
300
250
  description: `Query ${listKey} records with optional filters`,
@@ -305,6 +255,7 @@ function handleToolsList(config: OpenSaasConfig, id?: number | string): Response
305
255
  take: { type: 'number', description: 'Number of records to return (max 100)' },
306
256
  skip: { type: 'number', description: 'Number of records to skip' },
307
257
  orderBy: { type: 'object', description: 'Sort order' },
258
+ fields: fieldsSchema,
308
259
  },
309
260
  },
310
261
  })
@@ -462,27 +413,60 @@ async function handleCrudTool(
462
413
  let result: any
463
414
 
464
415
  switch (operation) {
465
- case 'query':
416
+ case 'query': {
417
+ let projection: Awaited<ReturnType<typeof resolveFieldsProjection>> | undefined
418
+ if (args.fields !== undefined) {
419
+ const listEntry = Object.entries(config.lists).find(
420
+ ([listKey]) => getDbKey(listKey) === dbKey,
421
+ )
422
+ if (!listEntry) {
423
+ return createErrorResponse(`Unknown list for tool: ${dbKey}`, id)
424
+ }
425
+ const [listKey, listConfig] = listEntry
426
+ try {
427
+ projection = await resolveFieldsProjection(
428
+ args.fields,
429
+ listKey,
430
+ listConfig,
431
+ config,
432
+ context.session,
433
+ context,
434
+ )
435
+ } catch (error) {
436
+ if (error instanceof McpProjectionRefusedError || error instanceof ValidationError) {
437
+ return createErrorResultResponse(error.message, id)
438
+ }
439
+ throw error
440
+ }
441
+ }
442
+
466
443
  result = await context.db[dbKey].findMany({
467
444
  where: args.where,
468
445
  take: Math.min(args.take || 10, 100),
469
446
  skip: args.skip,
470
447
  orderBy: args.orderBy,
448
+ ...(projection?.include ? { include: projection.include } : {}),
471
449
  })
450
+
451
+ const items = projection
452
+ ? result.map((item: Record<string, unknown>) => projectMcpResult(item, projection))
453
+ : result
454
+
472
455
  return createSuccessResponse(
473
456
  {
474
- items: result,
475
- count: result.length,
457
+ items,
458
+ count: items.length,
476
459
  },
477
460
  id,
478
461
  )
462
+ }
479
463
 
480
464
  case 'create':
481
465
  result = await context.db[dbKey].create({
482
466
  data: args.data,
483
467
  })
484
468
  if (!result) {
485
- return createErrorResponse(
469
+ return createErrorResultResponse(
486
470
  'Failed to create record. Access denied or validation failed.',
487
471
  id,
488
472
  )
@@ -495,7 +479,7 @@ async function handleCrudTool(
495
479
  data: args.data,
496
480
  })
497
481
  if (!result) {
498
- return createErrorResponse(
482
+ return createErrorResultResponse(
499
483
  'Failed to update record. Access denied or record not found.',
500
484
  id,
501
485
  )
@@ -507,7 +491,7 @@ async function handleCrudTool(
507
491
  where: args.where,
508
492
  })
509
493
  if (!result) {
510
- return createErrorResponse(
494
+ return createErrorResultResponse(
511
495
  'Failed to delete record. Access denied or record not found.',
512
496
  id,
513
497
  )
@@ -518,7 +502,13 @@ async function handleCrudTool(
518
502
  return createErrorResponse(`Unknown operation: ${operation}`, id)
519
503
  }
520
504
  } catch (error) {
521
- return createErrorResponse(
505
+ if (
506
+ error instanceof AccessScopeDepthExceededError ||
507
+ error instanceof RelationFilterAccessDeniedError
508
+ ) {
509
+ return createErrorResultResponse(error.message, id)
510
+ }
511
+ return createErrorResultResponse(
522
512
  'Operation failed: ' + (error instanceof Error ? error.message : 'Unknown error'),
523
513
  id,
524
514
  )
@@ -550,20 +540,7 @@ async function handleCustomTool(
550
540
  if (isZodSchema(customTool.inputSchema)) {
551
541
  const parsed = customTool.inputSchema.safeParse(args)
552
542
  if (!parsed.success) {
553
- return new Response(
554
- JSON.stringify({
555
- jsonrpc: '2.0',
556
- id: id ?? null,
557
- error: {
558
- code: -32602,
559
- message: `Invalid params: ${parsed.error.message}`,
560
- },
561
- }),
562
- {
563
- status: 400,
564
- headers: { 'Content-Type': 'application/json' },
565
- },
566
- )
543
+ return createErrorResultResponse(`Invalid params: ${parsed.error.message}`, id)
567
544
  }
568
545
  input = parsed.data
569
546
  }
@@ -578,7 +555,7 @@ async function handleCustomTool(
578
555
 
579
556
  return createSuccessResponse(result, id)
580
557
  } catch (error) {
581
- return createErrorResponse(
558
+ return createErrorResultResponse(
582
559
  'Custom tool execution failed: ' + (error instanceof Error ? error.message : 'Unknown error'),
583
560
  id,
584
561
  )
@@ -597,15 +574,39 @@ function mcpJsonReplacer(_key: string, value: unknown): unknown {
597
574
  return typeof value === 'bigint' ? value.toString() : value
598
575
  }
599
576
 
577
+ function toToolContent(data: unknown): { type: 'text'; text: string }[] {
578
+ const text = typeof data === 'string' ? data : JSON.stringify(data, mcpJsonReplacer, 2)
579
+ return [{ type: 'text', text }]
580
+ }
581
+
600
582
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Response data structure is flexible per MCP protocol
601
583
  function createSuccessResponse(data: any, id?: number | string): Response {
602
584
  return new Response(
603
585
  JSON.stringify({
604
586
  jsonrpc: '2.0',
605
587
  id: id ?? null,
606
- result: {
607
- content: [{ type: 'text', text: JSON.stringify(data, mcpJsonReplacer, 2) }],
608
- },
588
+ result: { content: toToolContent(data) },
589
+ }),
590
+ {
591
+ headers: { 'Content-Type': 'application/json' },
592
+ },
593
+ )
594
+ }
595
+
596
+ /**
597
+ * A dispatched tool that failed — access denial, a thrown engine/database
598
+ * error, or custom-tool input validation. This is a successful JSON-RPC
599
+ * response (HTTP 200, no `error` member): the model that called the tool
600
+ * needs to see the failure in `result` to have any chance of correcting its
601
+ * request, whereas a JSON-RPC `error` object is a protocol-level failure a
602
+ * host may never surface back to it.
603
+ */
604
+ function createErrorResultResponse(message: string, id?: number | string): Response {
605
+ return new Response(
606
+ JSON.stringify({
607
+ jsonrpc: '2.0',
608
+ id: id ?? null,
609
+ result: { content: toToolContent(message), isError: true },
609
610
  }),
610
611
  {
611
612
  headers: { 'Content-Type': 'application/json' },
@@ -613,6 +614,7 @@ function createSuccessResponse(data: any, id?: number | string): Response {
613
614
  )
614
615
  }
615
616
 
617
+ /** Genuine JSON-RPC protocol failures — unknown method, malformed request, unknown tool name. */
616
618
  function createErrorResponse(message: string, id?: number | string): Response {
617
619
  return new Response(
618
620
  JSON.stringify({