@nextsparkjs/core 0.1.0-beta.184 → 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.
- package/dist/hooks/useAuth.d.ts +74 -25
- package/dist/hooks/useAuth.d.ts.map +1 -1
- package/dist/hooks/useValidationSchemas.d.ts +7 -7
- package/dist/lib/api/auth/dual-auth.d.ts +7 -0
- package/dist/lib/api/auth/dual-auth.d.ts.map +1 -1
- package/dist/lib/api/auth/dual-auth.js +1 -1
- package/dist/lib/api/auth.d.ts +20 -3
- package/dist/lib/api/auth.d.ts.map +1 -1
- package/dist/lib/api/auth.js +37 -19
- package/dist/lib/api/entity/generic-handler.d.ts.map +1 -1
- package/dist/lib/api/entity/generic-handler.js +19 -22
- package/dist/lib/auth.d.ts +1 -3057
- package/dist/lib/auth.d.ts.map +1 -1
- package/dist/lib/billing/schema.d.ts +1 -1
- package/dist/lib/mcp/audit.d.ts +17 -0
- package/dist/lib/mcp/audit.d.ts.map +1 -0
- package/dist/lib/mcp/audit.js +48 -0
- package/dist/lib/mcp/engine.d.ts +28 -0
- package/dist/lib/mcp/engine.d.ts.map +1 -0
- package/dist/lib/mcp/engine.js +158 -0
- package/dist/lib/mcp/errors.d.ts +16 -0
- package/dist/lib/mcp/errors.d.ts.map +1 -0
- package/dist/lib/mcp/errors.js +66 -0
- package/dist/lib/mcp/executor.d.ts +15 -0
- package/dist/lib/mcp/executor.d.ts.map +1 -0
- package/dist/lib/mcp/executor.js +63 -0
- package/dist/lib/mcp/index.d.ts +9 -0
- package/dist/lib/mcp/index.d.ts.map +1 -0
- package/dist/lib/mcp/index.js +32 -0
- package/dist/lib/mcp/schema-builder.d.ts +45 -0
- package/dist/lib/mcp/schema-builder.d.ts.map +1 -0
- package/dist/lib/mcp/schema-builder.js +212 -0
- package/dist/lib/mcp/tool-generator.d.ts +31 -0
- package/dist/lib/mcp/tool-generator.d.ts.map +1 -0
- package/dist/lib/mcp/tool-generator.js +271 -0
- package/dist/lib/mcp/transport.d.ts +31 -0
- package/dist/lib/mcp/transport.d.ts.map +1 -0
- package/dist/lib/mcp/transport.js +58 -0
- package/dist/lib/mcp/types.d.ts +184 -0
- package/dist/lib/mcp/types.d.ts.map +1 -0
- package/dist/lib/mcp/types.js +11 -0
- package/dist/styles/classes.json +1 -1
- package/dist/styles/ui.css +1 -1
- package/dist/templates/app/api/mcp/route.ts +173 -0
- package/dist/templates/app/api/v1/api-keys/route.ts +15 -3
- package/dist/templates/app/layout.tsx +3 -0
- package/package.json +8 -3
- package/scripts/build/registry/__tests__/mcp-registry.test.mjs +64 -0
- package/scripts/build/registry/__tests__/scope-registry.test.mjs +88 -0
- package/scripts/build/registry/discovery/mcp-overrides.mjs +72 -0
- package/scripts/build/registry/discovery/mcp-overrides.test.mjs +86 -0
- package/scripts/build/registry/generators/mcp-registry.mjs +61 -0
- package/scripts/build/registry/generators/scope-registry.mjs +133 -88
- package/scripts/build/registry.mjs +8 -4
- package/templates/app/api/mcp/route.ts +173 -0
- package/templates/app/api/v1/api-keys/route.ts +15 -3
- package/templates/app/layout.tsx +3 -0
- package/tests/jest/__mocks__/@nextsparkjs/registries/scope-registry.ts +56 -6
- package/dist/lib/auth-client.d.ts +0 -3595
- 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
|
-
*
|
|
103
|
-
* Role
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
-
|
|
96
|
+
interface ScopeApiEntity {
|
|
97
|
+
slug: string
|
|
98
|
+
apiPath: string
|
|
99
|
+
fieldNames: string[]
|
|
100
|
+
}
|
|
127
101
|
|
|
128
|
-
|
|
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
|
-
|
|
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',
|
package/templates/app/layout.tsx
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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> = {}
|