@enfyra/mcp-server 0.0.72 → 0.0.74
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/README.md +3 -1
- package/package.json +1 -1
- package/src/lib/mcp-examples.js +20 -0
- package/src/lib/mcp-instructions.js +2 -1
- package/src/lib/route-permission-tools.js +160 -0
- package/src/lib/table-tools.js +23 -3
- package/src/mcp-server-entry.mjs +184 -0
package/README.md
CHANGED
|
@@ -221,10 +221,12 @@ When an LLM builds a Nuxt, Next, or other SSR frontend for Enfyra, follow the sa
|
|
|
221
221
|
|
|
222
222
|
## Tools (summary)
|
|
223
223
|
|
|
224
|
-
Metadata, examples, query/CRUD, method management, route/handler/hook, tables/columns, reload cache, logs, user/roles, login, menu/extension, `get_enfyra_api_context`. For full tool list and behavior, see the app after enabling MCP or the source in `src/mcp-server-entry.mjs`.
|
|
224
|
+
Metadata, examples, query/CRUD, method management, route access audit/grant, route/handler/hook, tables/columns, reload cache, logs, user/roles, login, menu/extension, `get_enfyra_api_context`. For full tool list and behavior, see the app after enabling MCP or the source in `src/mcp-server-entry.mjs`.
|
|
225
225
|
|
|
226
226
|
Use `get_enfyra_examples` when asking an LLM to generate concrete Enfyra implementation patterns. It returns categorized examples for SSR app auth/OAuth/proxy setup, schema/relations, queries/deep, handlers/hooks, permissions/RLS, websocket, flows, files, and extensions.
|
|
227
227
|
|
|
228
|
+
For authenticated route access, use `audit_route_access` before changing permissions and `ensure_route_access` to grant access by `path` plus `roleName`/`roleId` or `allowedUserIds`. `ensure_route_access` resolves route, role, and method ids, validates that requested methods are available on the route, merges existing methods by default, and reloads routes.
|
|
229
|
+
|
|
228
230
|
## Security
|
|
229
231
|
|
|
230
232
|
API calls use JWT (MCP auto-refreshes). Permissions are enforced by Enfyra.
|
package/package.json
CHANGED
package/src/lib/mcp-examples.js
CHANGED
|
@@ -679,6 +679,26 @@ return @DATA\`
|
|
|
679
679
|
title: 'Route permissions, guards, field permissions, column rules, and RLS',
|
|
680
680
|
useWhen: 'Use when securing routes or shaping what fields a user can read/write.',
|
|
681
681
|
examples: [
|
|
682
|
+
{
|
|
683
|
+
name: 'Audit and grant authenticated route access',
|
|
684
|
+
code: `audit_route_access({
|
|
685
|
+
path: "/orders",
|
|
686
|
+
roleName: "user",
|
|
687
|
+
methods: ["GET", "POST"]
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
ensure_route_access({
|
|
691
|
+
path: "/orders",
|
|
692
|
+
roleName: "user",
|
|
693
|
+
methods: ["GET", "POST"],
|
|
694
|
+
description: "Authenticated users can list and create their own orders."
|
|
695
|
+
})`,
|
|
696
|
+
notes: [
|
|
697
|
+
'Use route permissions for authenticated access. The tool resolves role and method ids, validates the route available methods, merges existing methods, and reloads routes.',
|
|
698
|
+
'Handlers or pre-hooks must still enforce owner or tenant scope; route permission only lets the request pass RoleGuard.',
|
|
699
|
+
'Use publishedMethods only for anonymous public access.',
|
|
700
|
+
],
|
|
701
|
+
},
|
|
682
702
|
{
|
|
683
703
|
name: 'Publish read-only route',
|
|
684
704
|
code: `update_record({
|
|
@@ -72,7 +72,8 @@ export function buildMcpServerInstructions(apiBaseUrl) {
|
|
|
72
72
|
'',
|
|
73
73
|
'### Routes vs tables (custom endpoints, handlers, hooks)',
|
|
74
74
|
'- REST-first workflow for any feature: **`inspect_feature`** to locate candidates → **`inspect_table`** for table/field/relation/rule context → **`inspect_route`** for handlers/hooks/guards/permissions → **`test_rest_endpoint`** to verify the actual HTTP behavior.',
|
|
75
|
-
'- Use **`
|
|
75
|
+
'- Use **`audit_route_access`** before debugging route 403s or granting access. Use **`ensure_route_access`** for authenticated role/user route access because it resolves route, role, method ids, validates route.availableMethods, merges existing permissions, and reloads routes. Use low-level **`create_route_permission`** only when you intentionally need a new raw permission row.',
|
|
76
|
+
'- Use **`create_column_rule`** for standard request validation, **`create_field_permission`** for per-field read/create/update rules, and **`create_guard`** for pre/post-auth request gates.',
|
|
76
77
|
'- Prefer these REST inspection/operator tools over raw `query_table` on system tables when changing route behavior. They resolve ids, methods, route paths, code previews, and cache reloads for the model.',
|
|
77
78
|
'- If the user asks for a **new route**, **URL path**, **custom API endpoint**, **handler**, **pre-hook**, **post-hook**, or to **test** that kind of logic: use MCP **`create_route`** and **omit `mainTableId`**. `mainTable` is only a marker for canonical table routes like `/orders`; custom paths such as `/orders/stats`, `/reports/summary`, `/auth/login`, or `/me` must not set it.',
|
|
78
79
|
'- **Wrong pattern:** calling **`create_table`** just to get an HTTP path, then overriding handlers on the **default** auto route `/{table_name}`. That adds unnecessary schema and breaks the usual CRUD surface for that table.',
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
export function normalizeMethodName(method) {
|
|
2
|
+
return String(method || '').trim().toUpperCase();
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function normalizeMethodNames(methods) {
|
|
6
|
+
return [...new Set((methods || []).map(normalizeMethodName).filter(Boolean))];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getRecordId(record) {
|
|
10
|
+
return record?.id ?? record?._id ?? null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getReferenceId(value) {
|
|
14
|
+
return typeof value === 'object' && value !== null ? getRecordId(value) : value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function sameRecordId(a, b) {
|
|
18
|
+
if (a === null || a === undefined || b === null || b === undefined) return false;
|
|
19
|
+
return String(a) === String(b);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveRoleByNameOrId(roles, { roleId, roleName } = {}) {
|
|
23
|
+
if (roleId && roleName) {
|
|
24
|
+
throw new Error('Provide roleId or roleName, not both.');
|
|
25
|
+
}
|
|
26
|
+
if (!roleId && !roleName) return null;
|
|
27
|
+
const normalizedRoleName = roleName ? String(roleName).trim().toLowerCase() : null;
|
|
28
|
+
const role = roles.find((item) => (
|
|
29
|
+
roleId
|
|
30
|
+
? sameRecordId(getRecordId(item), roleId)
|
|
31
|
+
: String(item?.name || '').trim().toLowerCase() === normalizedRoleName
|
|
32
|
+
));
|
|
33
|
+
if (!role) throw new Error(`Role not found: ${roleId || roleName}`);
|
|
34
|
+
return role;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function methodNamesFromRecords(methods, methodIdNameMap = {}) {
|
|
38
|
+
return normalizeMethodNames((methods || []).map((method) => (
|
|
39
|
+
method?.name || method?.method || methodIdNameMap[String(getRecordId(method))] || method
|
|
40
|
+
)));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function routeAvailableMethodNames(route, methodIdNameMap = {}) {
|
|
44
|
+
return methodNamesFromRecords(route?.availableMethods || [], methodIdNameMap);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function routePublishedMethodNames(route, methodIdNameMap = {}) {
|
|
48
|
+
return methodNamesFromRecords(route?.publishedMethods || [], methodIdNameMap);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function permissionMethodNames(permission, methodIdNameMap = {}) {
|
|
52
|
+
return methodNamesFromRecords(permission?.methods || [], methodIdNameMap);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function validateMethodsForRoute(route, methods, methodMap, methodIdNameMap = {}) {
|
|
56
|
+
const normalizedMethods = normalizeMethodNames(methods);
|
|
57
|
+
const knownMethods = new Set(Object.keys(methodMap || {}).map(normalizeMethodName));
|
|
58
|
+
const unknown = normalizedMethods.filter((method) => !knownMethods.has(method));
|
|
59
|
+
if (unknown.length) {
|
|
60
|
+
throw new Error(`Unknown method_definition.name values: ${unknown.join(', ')}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const availableMethods = routeAvailableMethodNames(route, methodIdNameMap);
|
|
64
|
+
if (availableMethods.length) {
|
|
65
|
+
const unavailable = normalizedMethods.filter((method) => !availableMethods.includes(method));
|
|
66
|
+
if (unavailable.length) {
|
|
67
|
+
throw new Error(`Route ${route?.path} does not list methods as available: ${unavailable.join(', ')}. Available: ${availableMethods.join(', ')}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return normalizedMethods;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function sortedIdStrings(values) {
|
|
75
|
+
return [...new Set((values || []).map((value) => String(getReferenceId(value))).filter(Boolean))].sort();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function sameIdSet(a, b) {
|
|
79
|
+
const left = sortedIdStrings(a);
|
|
80
|
+
const right = sortedIdStrings(b);
|
|
81
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function routePermissionMatchesScope(permission, { roleId, allowedUserIds } = {}) {
|
|
85
|
+
const expectedUsers = sortedIdStrings(allowedUserIds);
|
|
86
|
+
const permissionUsers = sortedIdStrings(permission?.allowedUsers || []);
|
|
87
|
+
const permissionRoleId = getReferenceId(permission?.role);
|
|
88
|
+
|
|
89
|
+
if (roleId && !sameRecordId(permissionRoleId, roleId)) return false;
|
|
90
|
+
if (!roleId && permissionRoleId !== null && permissionRoleId !== undefined) return false;
|
|
91
|
+
return sameIdSet(permissionUsers, expectedUsers);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function findRoutePermission(routePermissions, routeId, scope) {
|
|
95
|
+
return (routePermissions || []).find((permission) => (
|
|
96
|
+
sameRecordId(getReferenceId(permission?.route), routeId)
|
|
97
|
+
&& routePermissionMatchesScope(permission, scope)
|
|
98
|
+
)) || null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function mergeMethodNames(existingMethods, requestedMethods, mode = 'merge') {
|
|
102
|
+
const requested = normalizeMethodNames(requestedMethods);
|
|
103
|
+
if (mode === 'replace') return requested;
|
|
104
|
+
return normalizeMethodNames([...normalizeMethodNames(existingMethods), ...requested]);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function summarizeRoutePermission(permission, methodIdNameMap = {}) {
|
|
108
|
+
return {
|
|
109
|
+
id: getRecordId(permission),
|
|
110
|
+
isEnabled: permission?.isEnabled !== false,
|
|
111
|
+
description: permission?.description || null,
|
|
112
|
+
route: permission?.route?.path || permission?.route || null,
|
|
113
|
+
role: permission?.role ? {
|
|
114
|
+
id: getReferenceId(permission.role),
|
|
115
|
+
name: permission.role.name || null,
|
|
116
|
+
} : null,
|
|
117
|
+
allowedUsers: (permission?.allowedUsers || []).map((user) => ({
|
|
118
|
+
id: getReferenceId(user),
|
|
119
|
+
email: user?.email || null,
|
|
120
|
+
})),
|
|
121
|
+
methods: permissionMethodNames(permission, methodIdNameMap),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function summarizeRouteAccess(route, routePermissions, methodIdNameMap = {}, expected = {}) {
|
|
126
|
+
const routeId = getRecordId(route);
|
|
127
|
+
const permissions = (routePermissions || [])
|
|
128
|
+
.filter((permission) => sameRecordId(getReferenceId(permission?.route), routeId))
|
|
129
|
+
.map((permission) => summarizeRoutePermission(permission, methodIdNameMap));
|
|
130
|
+
|
|
131
|
+
const expectedMethods = normalizeMethodNames(expected.methods || []);
|
|
132
|
+
const expectedRoleId = expected.roleId ? String(expected.roleId) : null;
|
|
133
|
+
const expectedAllowedUsers = sortedIdStrings(expected.allowedUserIds || []);
|
|
134
|
+
const hasExpectedScope = !!(expectedRoleId || expected.roleRequired || expected.allowedUserIds !== undefined);
|
|
135
|
+
const matchingPermissions = permissions.filter((permission) => {
|
|
136
|
+
if (!hasExpectedScope) return true;
|
|
137
|
+
if (expectedRoleId && String(permission.role?.id) !== expectedRoleId) return false;
|
|
138
|
+
if (!expectedRoleId && expected.roleRequired && permission.role) return false;
|
|
139
|
+
if (!sameIdSet(permission.allowedUsers.map((user) => user.id), expectedAllowedUsers)) return false;
|
|
140
|
+
return true;
|
|
141
|
+
});
|
|
142
|
+
const grantedMethods = normalizeMethodNames(matchingPermissions.flatMap((permission) => (
|
|
143
|
+
permission.isEnabled ? permission.methods : []
|
|
144
|
+
)));
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
id: routeId,
|
|
148
|
+
path: route?.path,
|
|
149
|
+
isEnabled: route?.isEnabled !== false,
|
|
150
|
+
availableMethods: routeAvailableMethodNames(route, methodIdNameMap),
|
|
151
|
+
publishedMethods: routePublishedMethodNames(route, methodIdNameMap),
|
|
152
|
+
skipRoleGuardMethods: methodNamesFromRecords(route?.skipRoleGuardMethods || [], methodIdNameMap),
|
|
153
|
+
permissions,
|
|
154
|
+
expected: expectedMethods.length ? {
|
|
155
|
+
methods: expectedMethods,
|
|
156
|
+
grantedMethods,
|
|
157
|
+
missingMethods: expectedMethods.filter((method) => !grantedMethods.includes(method)),
|
|
158
|
+
} : null,
|
|
159
|
+
};
|
|
160
|
+
}
|
package/src/lib/table-tools.js
CHANGED
|
@@ -16,8 +16,11 @@ const FORBIDDEN_RELATION_KEYS = [
|
|
|
16
16
|
'fkCol',
|
|
17
17
|
'fkColumn',
|
|
18
18
|
'foreignKeyColumn',
|
|
19
|
+
'referencedColumn',
|
|
20
|
+
'constraintName',
|
|
19
21
|
'sourceColumn',
|
|
20
22
|
'targetColumn',
|
|
23
|
+
'junctionTableName',
|
|
21
24
|
'junctionSourceColumn',
|
|
22
25
|
'junctionTargetColumn',
|
|
23
26
|
];
|
|
@@ -144,6 +147,23 @@ export function normalizeRelationForTablePatch(relation) {
|
|
|
144
147
|
return normalized;
|
|
145
148
|
}
|
|
146
149
|
|
|
150
|
+
export function sanitizeExistingRelationForTablePatch(relation) {
|
|
151
|
+
const {
|
|
152
|
+
fkCol,
|
|
153
|
+
fkColumn,
|
|
154
|
+
foreignKeyColumn,
|
|
155
|
+
referencedColumn,
|
|
156
|
+
constraintName,
|
|
157
|
+
sourceColumn,
|
|
158
|
+
targetColumn,
|
|
159
|
+
junctionTableName,
|
|
160
|
+
junctionSourceColumn,
|
|
161
|
+
junctionTargetColumn,
|
|
162
|
+
...rest
|
|
163
|
+
} = relation;
|
|
164
|
+
return normalizeRelationForTablePatch(rest);
|
|
165
|
+
}
|
|
166
|
+
|
|
147
167
|
export function resolveRelationTargetsFromMetadata(metadata, relations) {
|
|
148
168
|
return relations.map((relation) => {
|
|
149
169
|
const targetTable = relation.targetTable;
|
|
@@ -210,7 +230,7 @@ async function verifyRelationCascade(ENFYRA_API_URL, tableId, beforeIds, {
|
|
|
210
230
|
propertyName,
|
|
211
231
|
}) {
|
|
212
232
|
const tableData = await fetchTableWithDetails(ENFYRA_API_URL, tableId);
|
|
213
|
-
const afterRelations = (tableData.relations || []).map(
|
|
233
|
+
const afterRelations = (tableData.relations || []).map(sanitizeExistingRelationForTablePatch);
|
|
214
234
|
const afterIds = afterRelations.map((relation) => String(getId(relation))).filter((id) => id !== 'null');
|
|
215
235
|
const excludedIds = action === 'delete' ? [relationId] : [];
|
|
216
236
|
const missingIds = getMissingIds(beforeIds, afterIds, excludedIds);
|
|
@@ -293,7 +313,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
293
313
|
if (!tableData) {
|
|
294
314
|
return { content: [{ type: 'text', text: `Error: Table with ID ${sourceTableId} not found.` }] };
|
|
295
315
|
}
|
|
296
|
-
const existingRelations = (tableData.relations || []).map(
|
|
316
|
+
const existingRelations = (tableData.relations || []).map(sanitizeExistingRelationForTablePatch);
|
|
297
317
|
const beforeIds = existingRelations.map((relation) => String(getId(relation))).filter((id) => id !== 'null');
|
|
298
318
|
const newRelation = { targetTable: targetTableId, type, propertyName };
|
|
299
319
|
if (inversePropertyName !== undefined) newRelation.inversePropertyName = inversePropertyName || null;
|
|
@@ -361,7 +381,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
|
|
|
361
381
|
return { content: [{ type: 'text', text: `Error: Table with ID ${tableId} not found.` }] };
|
|
362
382
|
}
|
|
363
383
|
|
|
364
|
-
const existingRelations = (tableData.relations || []).map(
|
|
384
|
+
const existingRelations = (tableData.relations || []).map(sanitizeExistingRelationForTablePatch);
|
|
365
385
|
const beforeIds = existingRelations.map((relation) => String(getId(relation))).filter((id) => id !== 'null');
|
|
366
386
|
if (!beforeIds.includes(String(relationId))) {
|
|
367
387
|
throw new Error(`Relation ${relationId} was not found on table ${tableId}; refusing schema cascade patch.`);
|
package/src/mcp-server-entry.mjs
CHANGED
|
@@ -21,6 +21,17 @@ import { getExamples, listExampleCategories } from './lib/mcp-examples.js';
|
|
|
21
21
|
import { registerTableTools } from './lib/table-tools.js';
|
|
22
22
|
import { prepareRecordMutation, validateScriptSourceIfPresent } from './lib/mutation-guards.js';
|
|
23
23
|
import { validateMainTableRoutePath } from './lib/route-guards.js';
|
|
24
|
+
import {
|
|
25
|
+
findRoutePermission,
|
|
26
|
+
mergeMethodNames,
|
|
27
|
+
normalizeMethodNames,
|
|
28
|
+
resolveRoleByNameOrId,
|
|
29
|
+
routeAvailableMethodNames,
|
|
30
|
+
routePublishedMethodNames,
|
|
31
|
+
summarizeRouteAccess,
|
|
32
|
+
summarizeRoutePermission,
|
|
33
|
+
validateMethodsForRoute,
|
|
34
|
+
} from './lib/route-permission-tools.js';
|
|
24
35
|
|
|
25
36
|
// Initialize auth module
|
|
26
37
|
initAuth(ENFYRA_API_URL, ENFYRA_API_TOKEN);
|
|
@@ -2092,6 +2103,179 @@ server.tool(
|
|
|
2092
2103
|
},
|
|
2093
2104
|
);
|
|
2094
2105
|
|
|
2106
|
+
server.tool(
|
|
2107
|
+
'audit_route_access',
|
|
2108
|
+
[
|
|
2109
|
+
'Audit route access for one or more routes.',
|
|
2110
|
+
'Use this before granting access or debugging 403s. It reports available methods, public published methods, skipRoleGuard methods, route permissions, and optional missing methods for one role/user scope.',
|
|
2111
|
+
].join(' '),
|
|
2112
|
+
{
|
|
2113
|
+
path: z.string().optional().describe('Exact route path, e.g. /orders'),
|
|
2114
|
+
routeId: z.union([z.string(), z.number()]).optional().describe('Exact route id'),
|
|
2115
|
+
search: z.string().optional().describe('Optional route path search when path/routeId is not provided'),
|
|
2116
|
+
roleId: z.union([z.string(), z.number()]).optional().describe('Expected role id to check'),
|
|
2117
|
+
roleName: z.string().optional().describe('Expected role name to resolve, e.g. user'),
|
|
2118
|
+
allowedUserIds: z.array(z.union([z.string(), z.number()])).optional().describe('Expected direct/specific user ids to check'),
|
|
2119
|
+
methods: z.array(z.string()).optional().describe('Methods expected to be allowed for this scope'),
|
|
2120
|
+
limit: z.number().int().positive().max(100).optional().default(25).describe('Maximum routes returned for search mode'),
|
|
2121
|
+
},
|
|
2122
|
+
async ({ path, routeId, search, roleId, roleName, allowedUserIds, methods, limit }) => {
|
|
2123
|
+
if ([path, routeId, search].filter((value) => value !== undefined && value !== null && value !== '').length > 1) {
|
|
2124
|
+
throw new Error('Use only one of path, routeId, or search.');
|
|
2125
|
+
}
|
|
2126
|
+
if (roleId && roleName) throw new Error('Provide roleId or roleName, not both.');
|
|
2127
|
+
|
|
2128
|
+
const [routes, routePermissions, roles, methodIdNameMap] = await Promise.all([
|
|
2129
|
+
fetchAll('/route_definition?limit=1000'),
|
|
2130
|
+
fetchAll('/route_permission_definition?limit=1000'),
|
|
2131
|
+
fetchAll('/role_definition?limit=1000'),
|
|
2132
|
+
getMethodIdNameMap(),
|
|
2133
|
+
]);
|
|
2134
|
+
|
|
2135
|
+
const role = resolveRoleByNameOrId(roles, { roleId, roleName });
|
|
2136
|
+
const normalizedPath = path ? normalizeRestPath(path) : null;
|
|
2137
|
+
const query = search ? String(search).toLowerCase() : null;
|
|
2138
|
+
const matchedRoutes = routes.filter((route) => {
|
|
2139
|
+
if (routeId) return sameId(getId(route), routeId);
|
|
2140
|
+
if (normalizedPath) return route.path === normalizedPath;
|
|
2141
|
+
if (query) return String(route.path || '').toLowerCase().includes(query);
|
|
2142
|
+
return true;
|
|
2143
|
+
}).slice(0, limit);
|
|
2144
|
+
|
|
2145
|
+
const expectedMethods = normalizeMethodNames(methods || []);
|
|
2146
|
+
const payload = {
|
|
2147
|
+
guidance: {
|
|
2148
|
+
publicAccess: 'publishedMethods bypass RoleGuard and do not require route_permission_definition.',
|
|
2149
|
+
authenticatedAccess: 'For non-public methods, eApp PermissionGate and backend RoleGuard both expect enabled route_permission_definition rows with matching route + HTTP method.',
|
|
2150
|
+
directUserAccess: 'allowedRoutePermissions on /me represent direct user-scoped route permissions; role.routePermissions represent role-scoped permissions.',
|
|
2151
|
+
},
|
|
2152
|
+
expectedScope: {
|
|
2153
|
+
role: role ? { id: getId(role), name: role.name } : null,
|
|
2154
|
+
allowedUserIds: allowedUserIds || [],
|
|
2155
|
+
methods: expectedMethods,
|
|
2156
|
+
},
|
|
2157
|
+
returnedRouteCount: matchedRoutes.length,
|
|
2158
|
+
routes: matchedRoutes.map((route) => summarizeRouteAccess(route, routePermissions, methodIdNameMap, {
|
|
2159
|
+
roleId: role ? getId(role) : roleId,
|
|
2160
|
+
roleRequired: !!(role || roleId || roleName),
|
|
2161
|
+
allowedUserIds,
|
|
2162
|
+
methods: expectedMethods,
|
|
2163
|
+
})),
|
|
2164
|
+
};
|
|
2165
|
+
|
|
2166
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
2167
|
+
},
|
|
2168
|
+
);
|
|
2169
|
+
|
|
2170
|
+
server.tool(
|
|
2171
|
+
'ensure_route_access',
|
|
2172
|
+
[
|
|
2173
|
+
'Create or update authenticated route access for one role/user scope.',
|
|
2174
|
+
'Use this instead of raw route_permission_definition CRUD when fixing 403s. It resolves roleName/route/method ids, validates route.availableMethods, merges existing permission methods by default, and reloads routes.',
|
|
2175
|
+
].join(' '),
|
|
2176
|
+
{
|
|
2177
|
+
path: z.string().optional().describe('Route path, e.g. /orders'),
|
|
2178
|
+
routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
|
|
2179
|
+
methods: z.array(z.string()).describe('HTTP method names to allow, e.g. ["GET", "POST"].'),
|
|
2180
|
+
roleId: z.union([z.string(), z.number()]).optional().describe('Role id scope'),
|
|
2181
|
+
roleName: z.string().optional().describe('Role name scope, e.g. user. Prefer this when an LLM does not know role ids.'),
|
|
2182
|
+
allowedUserIds: z.array(z.union([z.string(), z.number()])).optional().describe('Specific user ids scope. Omit for role-wide access.'),
|
|
2183
|
+
mode: z.enum(['merge', 'replace']).optional().default('merge').describe('merge adds methods to an existing permission; replace overwrites methods on the matched permission.'),
|
|
2184
|
+
description: z.string().optional().describe('Admin note'),
|
|
2185
|
+
isEnabled: z.boolean().optional().default(true).describe('Enable the permission'),
|
|
2186
|
+
},
|
|
2187
|
+
async ({ path, routeId, methods, roleId, roleName, allowedUserIds, mode, description, isEnabled }) => {
|
|
2188
|
+
if (!path && !routeId) throw new Error('Provide path or routeId.');
|
|
2189
|
+
if (path && routeId) throw new Error('Provide path or routeId, not both.');
|
|
2190
|
+
if (roleId && roleName) throw new Error('Provide roleId or roleName, not both.');
|
|
2191
|
+
if (!roleId && !roleName && (!allowedUserIds || allowedUserIds.length === 0)) {
|
|
2192
|
+
throw new Error('Provide roleId, roleName, or allowedUserIds.');
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
const [routes, routePermissions, roles, methodMap, methodIdNameMap] = await Promise.all([
|
|
2196
|
+
fetchAll('/route_definition?limit=1000'),
|
|
2197
|
+
fetchAll('/route_permission_definition?limit=1000'),
|
|
2198
|
+
fetchAll('/role_definition?limit=1000'),
|
|
2199
|
+
getMethodMap(),
|
|
2200
|
+
getMethodIdNameMap(),
|
|
2201
|
+
]);
|
|
2202
|
+
const route = routes.find((item) => (
|
|
2203
|
+
routeId ? sameId(getId(item), routeId) : item.path === normalizeRestPath(path)
|
|
2204
|
+
));
|
|
2205
|
+
if (!route) throw new Error(`Route not found: ${routeId || path}`);
|
|
2206
|
+
|
|
2207
|
+
const role = resolveRoleByNameOrId(roles, { roleId, roleName });
|
|
2208
|
+
const scope = {
|
|
2209
|
+
roleId: role ? getId(role) : roleId,
|
|
2210
|
+
allowedUserIds: allowedUserIds || [],
|
|
2211
|
+
};
|
|
2212
|
+
const requestedMethods = validateMethodsForRoute(route, methods, methodMap, methodIdNameMap);
|
|
2213
|
+
const existing = findRoutePermission(routePermissions, getId(route), scope);
|
|
2214
|
+
const existingMethods = existing ? summarizeRoutePermission(existing, methodIdNameMap).methods : [];
|
|
2215
|
+
const finalMethods = mergeMethodNames(existingMethods, requestedMethods, mode);
|
|
2216
|
+
const methodRefs = resolveMethodIds(methodMap, finalMethods);
|
|
2217
|
+
const publishedMethods = routePublishedMethodNames(route, methodIdNameMap);
|
|
2218
|
+
const alreadyPublic = requestedMethods.filter((method) => publishedMethods.includes(method));
|
|
2219
|
+
|
|
2220
|
+
let result;
|
|
2221
|
+
let action;
|
|
2222
|
+
if (existing) {
|
|
2223
|
+
action = 'updated';
|
|
2224
|
+
const patchBody = {
|
|
2225
|
+
isEnabled,
|
|
2226
|
+
methods: methodRefs,
|
|
2227
|
+
...(description !== undefined ? { description } : {}),
|
|
2228
|
+
};
|
|
2229
|
+
result = await fetchAPI(ENFYRA_API_URL, `/route_permission_definition/${encodeURIComponent(String(getId(existing)))}`, {
|
|
2230
|
+
method: 'PATCH',
|
|
2231
|
+
body: JSON.stringify(patchBody),
|
|
2232
|
+
});
|
|
2233
|
+
} else {
|
|
2234
|
+
action = 'created';
|
|
2235
|
+
const createBody = {
|
|
2236
|
+
isEnabled,
|
|
2237
|
+
description,
|
|
2238
|
+
route: { id: getId(route) },
|
|
2239
|
+
methods: methodRefs,
|
|
2240
|
+
...(scope.roleId ? { role: { id: scope.roleId } } : {}),
|
|
2241
|
+
...(scope.allowedUserIds.length ? { allowedUsers: scope.allowedUserIds.map((id) => ({ id })) } : {}),
|
|
2242
|
+
};
|
|
2243
|
+
result = await fetchAPI(ENFYRA_API_URL, '/route_permission_definition', {
|
|
2244
|
+
method: 'POST',
|
|
2245
|
+
body: JSON.stringify(createBody),
|
|
2246
|
+
});
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
const routeReload = await reloadRoutesResult();
|
|
2250
|
+
const saved = firstDataRecord(result);
|
|
2251
|
+
const payload = {
|
|
2252
|
+
action,
|
|
2253
|
+
kind: 'route_access',
|
|
2254
|
+
route: {
|
|
2255
|
+
id: getId(route),
|
|
2256
|
+
path: route.path,
|
|
2257
|
+
availableMethods: routeAvailableMethodNames(route, methodIdNameMap),
|
|
2258
|
+
publishedMethods,
|
|
2259
|
+
},
|
|
2260
|
+
scope: {
|
|
2261
|
+
role: role ? { id: getId(role), name: role.name } : null,
|
|
2262
|
+
allowedUserIds: scope.allowedUserIds,
|
|
2263
|
+
},
|
|
2264
|
+
permission: {
|
|
2265
|
+
id: getId(saved) || getId(existing),
|
|
2266
|
+
methods: finalMethods,
|
|
2267
|
+
alreadyPublic,
|
|
2268
|
+
isEnabled,
|
|
2269
|
+
},
|
|
2270
|
+
result,
|
|
2271
|
+
routeReload,
|
|
2272
|
+
auditHint: `Call audit_route_access({ path: "${route.path}", ${role ? `roleName: "${role.name}", ` : ''}methods: ${JSON.stringify(requestedMethods)} }) to verify.`,
|
|
2273
|
+
};
|
|
2274
|
+
|
|
2275
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
2276
|
+
},
|
|
2277
|
+
);
|
|
2278
|
+
|
|
2095
2279
|
server.tool(
|
|
2096
2280
|
'create_guard',
|
|
2097
2281
|
[
|