@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.0.72",
3
+ "version": "0.0.74",
4
4
  "description": "MCP server for Enfyra - manage your Enfyra instance via Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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 **`create_column_rule`** for standard request validation, **`create_field_permission`** for per-field read/create/update rules, **`create_route_permission`** for authenticated route access, and **`create_guard`** for pre/post-auth request gates.',
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
+ }
@@ -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(normalizeRelationForTablePatch);
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(normalizeRelationForTablePatch);
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(normalizeRelationForTablePatch);
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.`);
@@ -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
  [