@nextsparkjs/core 0.1.0-beta.183 → 0.1.0-beta.185

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 (80) hide show
  1. package/dist/components/auth/forms/LoginForm.d.ts.map +1 -1
  2. package/dist/components/auth/forms/LoginForm.js +8 -5
  3. package/dist/hooks/useAuth.d.ts +74 -25
  4. package/dist/hooks/useAuth.d.ts.map +1 -1
  5. package/dist/hooks/useValidationSchemas.d.ts +7 -7
  6. package/dist/lib/api/auth/dual-auth.d.ts +7 -0
  7. package/dist/lib/api/auth/dual-auth.d.ts.map +1 -1
  8. package/dist/lib/api/auth/dual-auth.js +1 -1
  9. package/dist/lib/api/auth.d.ts +20 -3
  10. package/dist/lib/api/auth.d.ts.map +1 -1
  11. package/dist/lib/api/auth.js +37 -19
  12. package/dist/lib/api/entity/generic-handler.d.ts.map +1 -1
  13. package/dist/lib/api/entity/generic-handler.js +19 -22
  14. package/dist/lib/auth.d.ts +1 -3057
  15. package/dist/lib/auth.d.ts.map +1 -1
  16. package/dist/lib/billing/schema.d.ts +1 -1
  17. package/dist/lib/mcp/audit.d.ts +17 -0
  18. package/dist/lib/mcp/audit.d.ts.map +1 -0
  19. package/dist/lib/mcp/audit.js +48 -0
  20. package/dist/lib/mcp/engine.d.ts +28 -0
  21. package/dist/lib/mcp/engine.d.ts.map +1 -0
  22. package/dist/lib/mcp/engine.js +158 -0
  23. package/dist/lib/mcp/errors.d.ts +16 -0
  24. package/dist/lib/mcp/errors.d.ts.map +1 -0
  25. package/dist/lib/mcp/errors.js +66 -0
  26. package/dist/lib/mcp/executor.d.ts +15 -0
  27. package/dist/lib/mcp/executor.d.ts.map +1 -0
  28. package/dist/lib/mcp/executor.js +63 -0
  29. package/dist/lib/mcp/index.d.ts +9 -0
  30. package/dist/lib/mcp/index.d.ts.map +1 -0
  31. package/dist/lib/mcp/index.js +32 -0
  32. package/dist/lib/mcp/schema-builder.d.ts +45 -0
  33. package/dist/lib/mcp/schema-builder.d.ts.map +1 -0
  34. package/dist/lib/mcp/schema-builder.js +212 -0
  35. package/dist/lib/mcp/tool-generator.d.ts +31 -0
  36. package/dist/lib/mcp/tool-generator.d.ts.map +1 -0
  37. package/dist/lib/mcp/tool-generator.js +271 -0
  38. package/dist/lib/mcp/transport.d.ts +31 -0
  39. package/dist/lib/mcp/transport.d.ts.map +1 -0
  40. package/dist/lib/mcp/transport.js +58 -0
  41. package/dist/lib/mcp/types.d.ts +184 -0
  42. package/dist/lib/mcp/types.d.ts.map +1 -0
  43. package/dist/lib/mcp/types.js +11 -0
  44. package/dist/messages/de/auth.json +2 -0
  45. package/dist/messages/de/index.d.ts +2 -0
  46. package/dist/messages/de/index.d.ts.map +1 -1
  47. package/dist/messages/en/auth.json +3 -1
  48. package/dist/messages/en/index.d.ts +2 -0
  49. package/dist/messages/en/index.d.ts.map +1 -1
  50. package/dist/messages/es/auth.json +2 -0
  51. package/dist/messages/es/index.d.ts +2 -0
  52. package/dist/messages/es/index.d.ts.map +1 -1
  53. package/dist/messages/fr/auth.json +2 -0
  54. package/dist/messages/fr/index.d.ts +2 -0
  55. package/dist/messages/fr/index.d.ts.map +1 -1
  56. package/dist/messages/it/auth.json +2 -0
  57. package/dist/messages/it/index.d.ts +2 -0
  58. package/dist/messages/it/index.d.ts.map +1 -1
  59. package/dist/messages/pt/auth.json +2 -0
  60. package/dist/messages/pt/index.d.ts +2 -0
  61. package/dist/messages/pt/index.d.ts.map +1 -1
  62. package/dist/styles/classes.json +1 -1
  63. package/dist/styles/ui.css +1 -1
  64. package/dist/templates/app/api/mcp/route.ts +173 -0
  65. package/dist/templates/app/api/v1/api-keys/route.ts +15 -3
  66. package/dist/templates/app/layout.tsx +3 -0
  67. package/package.json +8 -3
  68. package/scripts/build/registry/__tests__/mcp-registry.test.mjs +64 -0
  69. package/scripts/build/registry/__tests__/scope-registry.test.mjs +88 -0
  70. package/scripts/build/registry/discovery/mcp-overrides.mjs +72 -0
  71. package/scripts/build/registry/discovery/mcp-overrides.test.mjs +86 -0
  72. package/scripts/build/registry/generators/mcp-registry.mjs +61 -0
  73. package/scripts/build/registry/generators/scope-registry.mjs +133 -88
  74. package/scripts/build/registry.mjs +8 -4
  75. package/templates/app/api/mcp/route.ts +173 -0
  76. package/templates/app/api/v1/api-keys/route.ts +15 -3
  77. package/templates/app/layout.tsx +3 -0
  78. package/tests/jest/__mocks__/@nextsparkjs/registries/scope-registry.ts +56 -6
  79. package/dist/lib/auth-client.d.ts +0 -3595
  80. package/dist/lib/auth-client.d.ts.map +0 -1
@@ -3,112 +3,82 @@
3
3
  *
4
4
  * Generates scope-registry.ts
5
5
  *
6
+ * IMPORTANT: unlike most registry generators, the role→scope computation does
7
+ * NOT happen here at generation time. It happens at IMPORT TIME inside the
8
+ * emitted TypeScript, by statically importing the sibling generated
9
+ * `entity-registry.ts` (for which entities are API-exposed, via
10
+ * `access.api`) and `permissions-registry.ts` (for which team roles can
11
+ * perform which action on each entity, via `PERMISSIONS_BY_ROLE`).
12
+ *
13
+ * Why: this generator previously computed scopes from the raw `entities`
14
+ * discovery array (filesystem-scan metadata only — `{name, exportName,
15
+ * configPath, ...}`), which never carried `.api`/`.features`/`.fields`
16
+ * properties. Those properties don't exist on that shape, nor on the real
17
+ * `EntityConfig` type, so the computation silently produced `base: []` and a
18
+ * hardcoded, wrong `roles` object every time. Reading the REAL entity config
19
+ * (`access.api`, `slug`, `fields`) and the REAL per-role permission matrix
20
+ * requires resolving imports, which can only happen once the generated files
21
+ * are imported by app code — not synchronously inside this generation
22
+ * script. Doing the computation at import time also guarantees scope
23
+ * minting and scope enforcement can never diverge again: both read the same
24
+ * `PERMISSIONS_BY_ROLE` source of truth `checkPermission()` uses at request
25
+ * time.
26
+ *
6
27
  * @module core/scripts/build/registry/generators/scope-registry
7
28
  */
8
29
 
30
+ // Flag-based scopes and restriction rules are orthogonal to team roles/entity
31
+ // permissions (they're driven by user flags like `beta_tester`/`vip`, not by
32
+ // what a role can do to which entity) — kept as static literals, unrelated to
33
+ // the role/entity computation this generator used to get wrong.
34
+ const FLAG_SCOPES = {
35
+ 'beta_tester': ['beta:features'],
36
+ 'vip': ['vip:features', 'advanced:export'],
37
+ 'early_adopter': ['early:features'],
38
+ 'power_user': ['advanced:features', 'bulk:operations']
39
+ }
40
+
41
+ const SCOPE_RESTRICTIONS = {
42
+ 'restricted': {
43
+ remove: ['delete', 'admin']
44
+ },
45
+ 'limited_access': {
46
+ allow_only: ['read', 'tasks:write']
47
+ }
48
+ }
49
+
9
50
  /**
10
51
  * Generate the scope registry file
11
52
  *
12
- * @param {Array} entities - Discovered entities
53
+ * @param {Array} entities - Discovered entities (unused for scope computation
54
+ * today — kept in the signature for call-site consistency with the other
55
+ * generators; the real per-entity data used below comes from the generated
56
+ * `entity-registry.ts` this file imports, not from this parameter)
13
57
  * @param {object} config - Configuration object from getConfig()
14
58
  * @returns {string} Generated TypeScript content
15
59
  */
16
60
  export function generateScopeRegistry(entities, config) {
17
- // Generate base scopes from entities
18
- const baseScopes = []
19
- const entityPaths = []
20
-
21
- for (const entity of entities) {
22
- if (entity.api && entity.features?.enabled) {
23
- const entityName = entity.name
24
- baseScopes.push(`${entityName}:read`)
25
-
26
- // Add write scope if entity supports creation
27
- if (entity.api.endpoints && entity.api.endpoints.create) {
28
- baseScopes.push(`${entityName}:write`)
29
- }
30
-
31
- // Add delete scope for entities that support deletion
32
- if (entity.api.endpoints && entity.api.endpoints.delete) {
33
- baseScopes.push(`${entityName}:delete`)
34
- }
35
-
36
- // Collect entity paths for routing
37
- if (entity.api.apiPath) {
38
- entityPaths.push(entity.api.apiPath)
39
- }
40
- }
41
- }
42
-
43
- // Define role-based scopes
44
- const roleScopes = {
45
- 'superadmin': ['admin:users', 'tasks:delete', 'products:write', 'products:delete', ...baseScopes.filter(s => s.includes(':delete'))],
46
- 'admin': ['admin:users', 'tasks:delete', 'products:write', 'products:delete', ...baseScopes.filter(s => s.includes(':delete'))],
47
- 'manager': baseScopes.filter(s => s.includes(':write') || s.includes(':read')),
48
- 'member': baseScopes.filter(s => s.includes(':read') || s === 'tasks:write')
49
- }
50
-
51
- // Define flag-based scopes
52
- const flagScopes = {
53
- 'beta_tester': ['beta:features'],
54
- 'vip': ['vip:features', 'advanced:export'],
55
- 'early_adopter': ['early:features'],
56
- 'power_user': ['advanced:features', 'bulk:operations']
57
- }
58
-
59
- // Define allowed filters based on entities
60
- const allowedFilters = ['status', 'role', 'completed', 'userId']
61
-
62
- // Add entity-specific filters
63
- for (const entity of entities) {
64
- if (entity.api && entity.features?.enabled) {
65
- allowedFilters.push(`${entity.name}Id`)
66
-
67
- // Add common entity filters
68
- if (entity.fields) {
69
- const fields = Object.keys(entity.fields)
70
- if (fields.includes('status')) allowedFilters.push(`${entity.name}Status`)
71
- if (fields.includes('type')) allowedFilters.push(`${entity.name}Type`)
72
- if (fields.includes('category')) allowedFilters.push(`${entity.name}Category`)
73
- }
74
- }
75
- }
76
-
77
- const scopeConfig = {
78
- base: [...new Set(baseScopes)],
79
- roles: roleScopes,
80
- flags: flagScopes,
81
- restrictions: {
82
- 'restricted': {
83
- remove: ['delete', 'admin']
84
- },
85
- 'limited_access': {
86
- allow_only: ['read', 'tasks:write']
87
- }
88
- }
89
- }
90
-
91
- const apiConfig = {
92
- filters: {
93
- allowed: [...new Set(allowedFilters)]
94
- },
95
- entityPaths: [...new Set(entityPaths)]
96
- }
97
-
98
61
  return `/**
99
62
  * Auto-generated API Scope Registry
100
63
  *
101
64
  * Generated at: ${new Date().toISOString()}
102
- * Base scopes: ${scopeConfig.base.length}
103
- * Role configurations: ${Object.keys(scopeConfig.roles).length}
104
- * Flag configurations: ${Object.keys(scopeConfig.flags).length}
105
- * Allowed filters: ${apiConfig.filters.allowed.length}
65
+ *
66
+ * Role→scope mapping is computed below at import time from
67
+ * \`entity-registry.ts\` (which entities are API-exposed) and
68
+ * \`permissions-registry.ts\` (which team roles can do what to each entity),
69
+ * so scope MINTING (this file) and scope ENFORCEMENT
70
+ * (\`checkPermission\`/\`hasRequiredScope\`) can never drift apart.
106
71
  *
107
72
  * This file replaces hardcoded scope configurations in helpers.ts
108
73
  *
109
74
  * DO NOT EDIT - This file is auto-generated by scripts/build-registry.mjs
110
75
  */
111
76
 
77
+ import { ENTITY_REGISTRY } from './entity-registry'
78
+ import { AVAILABLE_ROLES, PERMISSIONS_BY_ROLE } from './permissions-registry'
79
+ import type { Permission } from '@/core/lib/permissions/types'
80
+ import type { EntityConfig } from '@/core/lib/entities/types'
81
+
112
82
  export interface ScopeConfig {
113
83
  base: string[]
114
84
  roles: Record<string, string[]>
@@ -123,9 +93,84 @@ export interface ApiConfig {
123
93
  entityPaths: string[]
124
94
  }
125
95
 
126
- export const SCOPE_CONFIG: ScopeConfig = ${JSON.stringify(scopeConfig, null, 2)}
96
+ interface ScopeApiEntity {
97
+ slug: string
98
+ apiPath: string
99
+ fieldNames: string[]
100
+ }
127
101
 
128
- export const API_CONFIG: ApiConfig = ${JSON.stringify(apiConfig, null, 2)}
102
+ // Entities exposed via the external API (access.api === true). ENTITY_REGISTRY
103
+ // entries can be a full EntityConfig or a ChildEntityDefinition (which has no
104
+ // \`access\`/\`slug\` at all) — guard defensively rather than assume every
105
+ // registry entry is a root entity.
106
+ const SCOPE_API_ENTITIES: ScopeApiEntity[] = Object.values(ENTITY_REGISTRY)
107
+ .map((entry) => {
108
+ const cfg = entry.config as Partial<EntityConfig>
109
+ if (!cfg?.access?.api || !cfg.slug) return null
110
+ return {
111
+ slug: cfg.slug,
112
+ apiPath: cfg.access.basePath || \`/api/v1/\${cfg.slug}\`,
113
+ fieldNames: Array.isArray(cfg.fields) ? cfg.fields.map((field) => field.name) : []
114
+ }
115
+ })
116
+ .filter((entity): entity is ScopeApiEntity => entity !== null)
117
+
118
+ // For each API-exposed entity, grant \`<slug>:read/write/delete\` to any team
119
+ // role whose PERMISSIONS_BY_ROLE set includes the corresponding
120
+ // \`<slug>.<action>\` permission — the SAME set checkAuthPermission() consults
121
+ // at request time. Superadmin is a GLOBAL role (never in AVAILABLE_ROLES) and
122
+ // is intentionally NOT represented here; it's handled as an explicit bypass
123
+ // in validateScopesForUser().
124
+ function computeRoleScopes(): Record<string, string[]> {
125
+ const roleScopes: Record<string, string[]> = {}
126
+ for (const role of AVAILABLE_ROLES) {
127
+ const perms = PERMISSIONS_BY_ROLE[role]
128
+ const scopes: string[] = []
129
+ for (const { slug } of SCOPE_API_ENTITIES) {
130
+ if (perms?.has(\`\${slug}.list\` as Permission) || perms?.has(\`\${slug}.read\` as Permission)) {
131
+ scopes.push(\`\${slug}:read\`)
132
+ }
133
+ if (perms?.has(\`\${slug}.create\` as Permission) || perms?.has(\`\${slug}.update\` as Permission)) {
134
+ scopes.push(\`\${slug}:write\`)
135
+ }
136
+ if (perms?.has(\`\${slug}.delete\` as Permission)) {
137
+ scopes.push(\`\${slug}:delete\`)
138
+ }
139
+ }
140
+ roleScopes[role] = [...new Set(scopes)]
141
+ }
142
+ return roleScopes
143
+ }
144
+
145
+ function computeAllowedFilters(): string[] {
146
+ const allowedFilters = new Set<string>(['status', 'role', 'completed', 'userId'])
147
+ for (const { slug, fieldNames } of SCOPE_API_ENTITIES) {
148
+ allowedFilters.add(\`\${slug}Id\`)
149
+ if (fieldNames.includes('status')) allowedFilters.add(\`\${slug}Status\`)
150
+ if (fieldNames.includes('type')) allowedFilters.add(\`\${slug}Type\`)
151
+ if (fieldNames.includes('category')) allowedFilters.add(\`\${slug}Category\`)
152
+ }
153
+ return [...allowedFilters]
154
+ }
155
+
156
+ export const SCOPE_CONFIG: ScopeConfig = {
157
+ // Nothing is granted irrespective of role — every entity scope flows
158
+ // through computeRoleScopes() above, sourced from the real permission
159
+ // matrix. Kept as an empty array (rather than removed) so a future,
160
+ // deliberate "these scopes belong to every authenticated key" need has a
161
+ // place to live without re-deriving this decision.
162
+ base: [],
163
+ roles: computeRoleScopes(),
164
+ flags: ${JSON.stringify(FLAG_SCOPES, null, 2).split('\n').join('\n ')},
165
+ restrictions: ${JSON.stringify(SCOPE_RESTRICTIONS, null, 2).split('\n').join('\n ')}
166
+ }
167
+
168
+ export const API_CONFIG: ApiConfig = {
169
+ filters: {
170
+ allowed: computeAllowedFilters()
171
+ },
172
+ entityPaths: SCOPE_API_ENTITIES.map((entity) => entity.apiPath)
173
+ }
129
174
 
130
175
  // ==================== Service Layer ====================
131
176
  // Query functions have been moved to: @nextsparkjs/core/lib/services/scope.service
@@ -55,6 +55,7 @@ import { discoverEmails } from './registry/discovery/emails.mjs'
55
55
  import { discoverBlocks } from './registry/discovery/blocks.mjs'
56
56
  import { discoverCoreRoutes } from './registry/discovery/core-routes.mjs'
57
57
  import { discoverApiPresets } from './registry/discovery/api-presets.mjs'
58
+ import { discoverMcpOverrides } from './registry/discovery/mcp-overrides.mjs'
58
59
  import { validateEntityConfigurations } from './registry/validation/entity-validator.mjs'
59
60
  import { generatePluginRegistry, generatePluginRegistryClient } from './registry/generators/plugin-registry.mjs'
60
61
  import { generateEntityRegistry, generateEntityRegistryClient } from './registry/generators/entity-registry.mjs'
@@ -76,6 +77,7 @@ import { generatePermissionsRegistry } from './registry/generators/permissions-r
76
77
  import { generateScheduledActionsRegistry } from './registry/generators/scheduled-actions-registry.mjs'
77
78
  import { generateDocsRegistry } from './registry/generators/docs-registry.mjs'
78
79
  import { generateApiPresetsRegistry, generateApiDocsRegistry } from './registry/generators/api-presets-registry.mjs'
80
+ import { generateMcpRegistry } from './registry/generators/mcp-registry.mjs'
79
81
  import {
80
82
  generateMissingPages,
81
83
  displayTreeStructure,
@@ -89,7 +91,7 @@ import { syncAppGlobalsCss } from './theme.mjs'
89
91
 
90
92
  // ==================== Registry File Generation ====================
91
93
 
92
- async function generateRegistryFiles(CONFIG, plugins, entities, themes, templates, middlewares, blocks, permissionsConfig, coreRoutes, apiPresetsData, emails) {
94
+ async function generateRegistryFiles(CONFIG, plugins, entities, themes, templates, middlewares, blocks, permissionsConfig, coreRoutes, apiPresetsData, emails, mcpOverridesData) {
93
95
  log('Generating registry files...', 'build')
94
96
 
95
97
  try {
@@ -122,6 +124,7 @@ async function generateRegistryFiles(CONFIG, plugins, entities, themes, template
122
124
  { name: 'docs-registry.ts', content: generateDocsRegistry() },
123
125
  { name: 'api-presets-registry.ts', content: generateApiPresetsRegistry(apiPresetsData, CONFIG) },
124
126
  { name: 'api-docs-registry.ts', content: generateApiDocsRegistry(apiPresetsData, CONFIG) },
127
+ { name: 'mcp-registry.ts', content: generateMcpRegistry(mcpOverridesData, CONFIG) },
125
128
  { name: 'index.ts', content: generateUnifiedRegistry(plugins, entities, themes, templates, middlewares, CONFIG) }
126
129
  ]
127
130
 
@@ -191,7 +194,7 @@ export async function buildRegistries(projectRoot = null) {
191
194
  await discoverParentChildRelations(CONFIG)
192
195
 
193
196
  // Discover all content types in parallel (pass CONFIG to each)
194
- const [plugins, coreEntities, themes, templates, middlewares, blocks, permissionsConfig, coreRoutes, apiPresetsData, emails] = await Promise.all([
197
+ const [plugins, coreEntities, themes, templates, middlewares, blocks, permissionsConfig, coreRoutes, apiPresetsData, emails, mcpOverridesData] = await Promise.all([
195
198
  discoverPlugins(CONFIG),
196
199
  discoverCoreEntities(CONFIG),
197
200
  discoverThemes(CONFIG),
@@ -201,7 +204,8 @@ export async function buildRegistries(projectRoot = null) {
201
204
  discoverPermissionsConfig(CONFIG),
202
205
  discoverCoreRoutes(CONFIG),
203
206
  discoverApiPresets(CONFIG),
204
- discoverEmails(CONFIG)
207
+ discoverEmails(CONFIG),
208
+ discoverMcpOverrides(CONFIG)
205
209
  ])
206
210
 
207
211
  // Aggregate all entities with proper priority: plugin < core < theme
@@ -260,7 +264,7 @@ export async function buildRegistries(projectRoot = null) {
260
264
 
261
265
  // Hoist plugin dependencies to root workspace for proper resolution
262
266
  // Generate all registry files (use aggregated entities for entity registry + blocks)
263
- await generateRegistryFiles(CONFIG, plugins, allEntities, themes, templates, middlewares, blocks, permissionsConfig, coreRoutes, apiPresetsData, emails)
267
+ await generateRegistryFiles(CONFIG, plugins, allEntities, themes, templates, middlewares, blocks, permissionsConfig, coreRoutes, apiPresetsData, emails, mcpOverridesData)
264
268
 
265
269
  // Generate missing pages for templates that don't have core app pages
266
270
  await generateMissingPages(templates, CONFIG)
@@ -0,0 +1,173 @@
1
+ /**
2
+ * MCP endpoint — POST /api/mcp (Streamable HTTP, stateless JSON mode).
3
+ *
4
+ * Auth: API key ONLY (Authorization: Bearer sk_... or x-api-key). Session
5
+ * cookies are rejected — this endpoint is for MCP clients, not browsers.
6
+ * authenticateRequest runs ONCE here; the resolved identity, team context and
7
+ * api_key id travel to every tool via ToolExecutionContext.
8
+ *
9
+ * Tools and overrides are entirely registry-driven: every access.api-enabled
10
+ * entity in ENTITY_REGISTRY becomes a set of tools automatically, and any
11
+ * `entities/<slug>/mcp.ts` a theme defines is picked up via the generated
12
+ * MCP_OVERRIDES registry — no per-app hand-wiring required.
13
+ *
14
+ * Security posture:
15
+ * - kill-switch: MCP_ENABLED (dev: on unless 'false'; production: OFF unless 'true')
16
+ * - Origin validated when present (DNS-rebinding protection); no CORS reflection
17
+ * - rate limit: tier 'api' — with the in-process executor this is the ONLY
18
+ * rate limit on the whole MCP surface (deploy prerequisite: Redis)
19
+ * - team-role permissions + scopes + ownership/field guards come from the
20
+ * real generic entity handlers the executor calls in-process (#94/#95)
21
+ * - every tool call is audited to api_audit_log
22
+ */
23
+
24
+ import { NextRequest, NextResponse } from 'next/server'
25
+ import {
26
+ authenticateRequest,
27
+ resolveTeamContext,
28
+ } from '@nextsparkjs/core/lib/api/auth/dual-auth'
29
+ import { validateApiKey } from '@nextsparkjs/core/lib/api/auth'
30
+ import { withRateLimitTier } from '@nextsparkjs/core/lib/api/rate-limit'
31
+ import { setEntityRegistry } from '@nextsparkjs/core/lib/entities/queries'
32
+ import { createMcpEngine } from '@nextsparkjs/core/lib/mcp'
33
+ import type { ToolExecutionContext } from '@nextsparkjs/core/lib/mcp'
34
+ import type { EntityConfig } from '@nextsparkjs/core/lib/entities/types'
35
+ // Import registries directly - webpack resolves @nextsparkjs/registries alias at compile time
36
+ import { ENTITY_REGISTRY, ENTITY_METADATA } from '@nextsparkjs/registries/entity-registry'
37
+ import { MCP_OVERRIDES } from '@nextsparkjs/registries/mcp-registry'
38
+ import { API_PRESETS_REGISTRY } from '@nextsparkjs/registries/api-presets-registry'
39
+
40
+ // Initialize registry at module load time (before any handler runs)
41
+ setEntityRegistry(ENTITY_REGISTRY, ENTITY_METADATA)
42
+
43
+ // Registry entries are wrappers ({ name, config, tableName, ... }) — the
44
+ // engine consumes the EntityConfig map.
45
+ const entityConfigs = Object.fromEntries(
46
+ Object.entries(ENTITY_REGISTRY as Record<string, { config: EntityConfig }>).map(
47
+ ([slug, entry]) => [slug, entry.config]
48
+ )
49
+ )
50
+
51
+ // Tool descriptions are seeded from api/presets.ts (summary → entity
52
+ // description, presets → few-shot examples) — the same registry the API
53
+ // Explorer already uses, keyed by endpoint instead of slug here.
54
+ const presetsBySlug = Object.fromEntries(
55
+ Object.entries(API_PRESETS_REGISTRY.endpoints ?? {}).map(([endpoint, data]) => [
56
+ endpoint.replace(/^\/api\/v1\//, ''),
57
+ { summary: data.summary, presets: data.presets },
58
+ ])
59
+ )
60
+
61
+ const toolPrefix = (process.env.MCP_TOOL_PREFIX || process.env.NEXT_PUBLIC_ACTIVE_THEME || 'app')
62
+ .toLowerCase()
63
+ .replace(/[^a-z0-9]+/g, '_')
64
+ .replace(/^_+|_+$/g, '')
65
+
66
+ const engine = createMcpEngine(entityConfigs, {
67
+ toolPrefix,
68
+ overrides: MCP_OVERRIDES,
69
+ presets: presetsBySlug,
70
+ })
71
+
72
+ const API_KEY_HELP =
73
+ 'This endpoint requires an API key: generate one in Settings → API Keys and configure it in your MCP client as the header "Authorization: Bearer sk_...".'
74
+
75
+ function isMcpEnabled(): boolean {
76
+ const flag = process.env.MCP_ENABLED
77
+ if (process.env.NODE_ENV === 'production') return flag === 'true'
78
+ return flag !== 'false'
79
+ }
80
+
81
+ function isOriginAllowed(request: NextRequest): boolean {
82
+ const origin = request.headers.get('origin')
83
+ if (!origin) return true // non-browser MCP clients don't send Origin
84
+ const allowed = new Set(
85
+ [request.nextUrl.origin, process.env.NEXT_PUBLIC_APP_URL, process.env.BETTER_AUTH_URL].filter(
86
+ Boolean
87
+ )
88
+ )
89
+ return allowed.has(origin)
90
+ }
91
+
92
+ function jsonRpcError(
93
+ status: number,
94
+ code: number,
95
+ message: string,
96
+ id: string | number | null = null
97
+ ): NextResponse {
98
+ return NextResponse.json({ jsonrpc: '2.0', error: { code, message }, id }, { status })
99
+ }
100
+
101
+ async function handleMcpPost(request: NextRequest): Promise<NextResponse> {
102
+ if (!isMcpEnabled()) {
103
+ return NextResponse.json({ error: 'Not found' }, { status: 404 })
104
+ }
105
+ if (!isOriginAllowed(request)) {
106
+ return jsonRpcError(403, -32000, 'Origin not allowed')
107
+ }
108
+
109
+ const authResult = await authenticateRequest(request)
110
+ if (!authResult.success || authResult.type !== 'api-key' || !authResult.user) {
111
+ return jsonRpcError(401, -32001, API_KEY_HELP)
112
+ }
113
+
114
+ // Same cached lookup the auth already did — needed for the audit trail.
115
+ const apiKeyAuth = await validateApiKey(request)
116
+
117
+ const teamResult = await resolveTeamContext(request, authResult)
118
+ if (teamResult instanceof Response) {
119
+ const status = teamResult.status
120
+ return jsonRpcError(
121
+ status,
122
+ -32002,
123
+ status === 403
124
+ ? 'The API key user is not a member of the team specified in x-team-id.'
125
+ : 'Could not resolve the user team. Send the x-team-id header or assign a default team.'
126
+ )
127
+ }
128
+
129
+ let body: unknown
130
+ try {
131
+ body = await request.json()
132
+ } catch {
133
+ return jsonRpcError(400, -32700, 'Parse error: body must be JSON-RPC')
134
+ }
135
+
136
+ const ctx: ToolExecutionContext = {
137
+ userId: authResult.user.id,
138
+ userRole: authResult.user.role,
139
+ teamId: teamResult,
140
+ authHeader:
141
+ request.headers.get('authorization') ?? `Bearer ${request.headers.get('x-api-key') ?? ''}`,
142
+ apiKeyId: apiKeyAuth?.keyId ?? null,
143
+ scopes: authResult.scopes ?? [],
144
+ }
145
+
146
+ try {
147
+ const result = await engine.handleJsonRpc(body, ctx)
148
+ if (result === null) {
149
+ // Notification(s) only — accepted, nothing to return.
150
+ return new NextResponse(null, { status: 202 })
151
+ }
152
+ return NextResponse.json(result, { status: 200 })
153
+ } catch (error) {
154
+ console.error('[mcp] request failed:', error)
155
+ return jsonRpcError(500, -32603, 'Internal error processing MCP request')
156
+ }
157
+ }
158
+
159
+ export const POST = withRateLimitTier(handleMcpPost, 'api')
160
+
161
+ export async function GET(): Promise<NextResponse> {
162
+ return NextResponse.json(
163
+ { error: 'Method not allowed. MCP requests must be POST (stateless JSON mode, no SSE).' },
164
+ { status: 405, headers: { Allow: 'POST' } }
165
+ )
166
+ }
167
+
168
+ export async function DELETE(): Promise<NextResponse> {
169
+ return NextResponse.json(
170
+ { error: 'Method not allowed. This server is stateless: there is no session to delete.' },
171
+ { status: 405, headers: { Allow: 'POST' } }
172
+ )
173
+ }
@@ -7,7 +7,7 @@ import {
7
7
  handleCorsPreflightRequest,
8
8
  addCorsHeaders
9
9
  } from '@nextsparkjs/core/lib/api/helpers';
10
- import { authenticateRequest, hasRequiredScope } from '@nextsparkjs/core/lib/api/auth/dual-auth';
10
+ import { authenticateRequest, hasRequiredScope, resolveTeamContext } from '@nextsparkjs/core/lib/api/auth/dual-auth';
11
11
  import { ApiKeyManager, API_SCOPES, API_KEY_LIMITS } from '@nextsparkjs/core/lib/api/keys';
12
12
  import { validateScopesForUser } from '@nextsparkjs/core/lib/api/auth';
13
13
  import { withRateLimitTier } from '@nextsparkjs/core/lib/api/rate-limit';
@@ -177,8 +177,20 @@ export const POST = withRateLimitTier(withApiLogging(async (req: NextRequest): P
177
177
  return addCorsHeaders(response);
178
178
  }
179
179
 
180
- // Validar que el usuario pueda crear API keys con estos scopes
181
- const userScopeValidation = await validateScopesForUser(authResult.user!.id, validatedData.scopes);
180
+ // Validar que el usuario pueda crear API keys con estos scopes.
181
+ // Scope minting is TEAM-role based (see validateScopesForUser) — resolve
182
+ // the team this key is being minted for, unless the caller is a global
183
+ // superadmin, which validateScopesForUser bypasses regardless of team.
184
+ let teamId: string | null = null;
185
+ if (authResult.user!.role !== 'superadmin') {
186
+ const teamResult = await resolveTeamContext(req, authResult);
187
+ if (teamResult instanceof NextResponse) {
188
+ return teamResult;
189
+ }
190
+ teamId = teamResult;
191
+ }
192
+
193
+ const userScopeValidation = await validateScopesForUser(authResult.user!.id, teamId, validatedData.scopes);
182
194
  if (!userScopeValidation.valid) {
183
195
  const response = createApiError(
184
196
  'You do not have permission to create API keys with these scopes',
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { Metadata } from "next"
8
+ import { Suspense } from "react"
8
9
  import { Geist, Geist_Mono } from "next/font/google"
9
10
  import { NextIntlClientProvider } from 'next-intl'
10
11
  import { getMessages } from 'next-intl/server'
@@ -13,6 +14,7 @@ import "./globals.css"
13
14
  import { getBillingResourceHints } from "@nextsparkjs/core/lib/billing/gateways/factory"
14
15
  import { ThemeProvider as NextThemeProvider } from "@nextsparkjs/core/providers/theme-provider"
15
16
  import { ThemeProvider as CustomThemeProvider } from "@nextsparkjs/core/lib/theme/ThemeProvider"
17
+ import { Toaster } from "@nextsparkjs/core/components/ui/sonner"
16
18
  import { getUserLocale } from '@nextsparkjs/core/lib/locale'
17
19
  import { TranslationContextManager } from "@nextsparkjs/core/providers/TranslationContextManager"
18
20
  import { PluginService } from '@nextsparkjs/core/lib/services'
@@ -85,6 +87,7 @@ export default async function RootLayout({
85
87
  <CustomThemeProvider>
86
88
  <TranslationContextManager />
87
89
  <main>{children}</main>
90
+ <Suspense><Toaster position="bottom-left" /></Suspense>
88
91
  </CustomThemeProvider>
89
92
  </NextThemeProvider>
90
93
  </NextIntlClientProvider>
@@ -1,15 +1,65 @@
1
1
  /**
2
2
  * Mock Scope Registry for Jest tests
3
+ *
4
+ * Shape MUST match the real generated `scope-registry.ts`
5
+ * (`ScopeConfig`/`ApiConfig`, see
6
+ * packages/core/scripts/build/registry/generators/scope-registry.mjs) — the
7
+ * previous version of this mock only exported an empty `SCOPE_REGISTRY` with
8
+ * unrelated `{name, description}`/`{basePath, version}` interfaces, which
9
+ * meant `scope.service.test.ts` failed on every assertion the moment it was
10
+ * force-run and had to be excluded from the suite.
11
+ *
12
+ * The role names below (superadmin/admin/manager/member) are an arbitrary
13
+ * fixture for exercising ScopeService's lookup/immutability/edge-case
14
+ * behavior — they are NOT meant to represent real `AVAILABLE_ROLES` (which
15
+ * are `owner/admin/member/viewer` + theme customs). ScopeService itself is
16
+ * agnostic to what role names exist; it's a pure Record lookup.
3
17
  */
4
18
 
5
- export const SCOPE_REGISTRY: Record<string, any> = {}
6
-
7
19
  export interface ScopeConfig {
8
- name: string
9
- description: string
20
+ base: string[]
21
+ roles: Record<string, string[]>
22
+ flags: Record<string, string[]>
23
+ restrictions: Record<string, { remove?: string[]; allow_only?: string[] }>
10
24
  }
11
25
 
12
26
  export interface ApiConfig {
13
- basePath: string
14
- version: string
27
+ filters: {
28
+ allowed: string[]
29
+ }
30
+ entityPaths: string[]
31
+ }
32
+
33
+ export const SCOPE_CONFIG: ScopeConfig = {
34
+ base: ['tasks:read'],
35
+ roles: {
36
+ superadmin: ['admin:users', 'admin:api-keys', 'tasks:delete'],
37
+ admin: ['admin:users', 'tasks:read', 'tasks:write', 'tasks:delete'],
38
+ manager: ['tasks:read', 'tasks:write'],
39
+ member: ['tasks:read'],
40
+ },
41
+ flags: {
42
+ beta_tester: ['beta:features'],
43
+ vip: ['vip:features', 'advanced:export'],
44
+ early_adopter: ['early:features'],
45
+ power_user: ['advanced:features', 'bulk:operations'],
46
+ },
47
+ restrictions: {
48
+ restricted: {
49
+ remove: ['delete', 'admin'],
50
+ },
51
+ limited_access: {
52
+ allow_only: ['read', 'tasks:write'],
53
+ },
54
+ },
15
55
  }
56
+
57
+ export const API_CONFIG: ApiConfig = {
58
+ filters: {
59
+ allowed: ['status', 'role', 'completed', 'userId'],
60
+ },
61
+ entityPaths: ['/api/v1/tasks'],
62
+ }
63
+
64
+ // Kept for any legacy import still expecting SCOPE_REGISTRY to exist.
65
+ export const SCOPE_REGISTRY: Record<string, unknown> = {}