@enfyra/mcp-server 0.1.13 → 0.1.14

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 (68) hide show
  1. package/README.md +14 -1
  2. package/dist/index.d.ts +5 -0
  3. package/{src/index.mjs → dist/index.js} +8 -10
  4. package/dist/index.js.map +1 -0
  5. package/dist/lib/auth.d.ts +34 -0
  6. package/dist/lib/auth.js +161 -0
  7. package/dist/lib/auth.js.map +1 -0
  8. package/dist/lib/config-local.d.ts +1 -0
  9. package/dist/lib/config-local.js +719 -0
  10. package/dist/lib/config-local.js.map +1 -0
  11. package/dist/lib/fetch.d.ts +28 -0
  12. package/dist/lib/fetch.js +106 -0
  13. package/dist/lib/fetch.js.map +1 -0
  14. package/dist/lib/mcp-examples.d.ts +99 -0
  15. package/dist/lib/mcp-examples.js +2285 -0
  16. package/dist/lib/mcp-examples.js.map +1 -0
  17. package/dist/lib/mcp-instructions.d.ts +11 -0
  18. package/dist/lib/mcp-instructions.js +78 -0
  19. package/dist/lib/mcp-instructions.js.map +1 -0
  20. package/dist/lib/mutation-guards.d.ts +33 -0
  21. package/dist/lib/mutation-guards.js +106 -0
  22. package/dist/lib/mutation-guards.js.map +1 -0
  23. package/dist/lib/platform-operation-tools.d.ts +12 -0
  24. package/dist/lib/platform-operation-tools.js +2304 -0
  25. package/dist/lib/platform-operation-tools.js.map +1 -0
  26. package/dist/lib/required-knowledge.d.ts +32 -0
  27. package/dist/lib/required-knowledge.js +181 -0
  28. package/dist/lib/required-knowledge.js.map +1 -0
  29. package/dist/lib/response-format.d.ts +7 -0
  30. package/dist/lib/response-format.js +179 -0
  31. package/dist/lib/response-format.js.map +1 -0
  32. package/dist/lib/route-guards.d.ts +1 -0
  33. package/dist/lib/route-guards.js +19 -0
  34. package/dist/lib/route-guards.js.map +1 -0
  35. package/dist/lib/route-permission-tools.d.ts +91 -0
  36. package/dist/lib/route-permission-tools.js +151 -0
  37. package/dist/lib/route-permission-tools.js.map +1 -0
  38. package/dist/lib/source-artifacts.d.ts +27 -0
  39. package/dist/lib/source-artifacts.js +82 -0
  40. package/dist/lib/source-artifacts.js.map +1 -0
  41. package/dist/lib/table-tools.d.ts +62 -0
  42. package/dist/lib/table-tools.js +774 -0
  43. package/dist/lib/table-tools.js.map +1 -0
  44. package/dist/lib/tool-routing.d.ts +297 -0
  45. package/dist/lib/tool-routing.js +585 -0
  46. package/dist/lib/tool-routing.js.map +1 -0
  47. package/dist/lib/types.d.ts +17 -0
  48. package/dist/lib/types.js +2 -0
  49. package/dist/lib/types.js.map +1 -0
  50. package/dist/mcp-server-entry.d.ts +4 -0
  51. package/dist/mcp-server-entry.js +2785 -0
  52. package/dist/mcp-server-entry.js.map +1 -0
  53. package/package.json +16 -9
  54. package/src/lib/auth.js +0 -179
  55. package/src/lib/config-local.mjs +0 -718
  56. package/src/lib/fetch.js +0 -111
  57. package/src/lib/mcp-examples.js +0 -2289
  58. package/src/lib/mcp-instructions.js +0 -80
  59. package/src/lib/mutation-guards.js +0 -118
  60. package/src/lib/platform-operation-tools.js +0 -2616
  61. package/src/lib/required-knowledge.js +0 -188
  62. package/src/lib/response-format.js +0 -187
  63. package/src/lib/route-guards.js +0 -24
  64. package/src/lib/route-permission-tools.js +0 -160
  65. package/src/lib/source-artifacts.js +0 -82
  66. package/src/lib/table-tools.js +0 -907
  67. package/src/lib/tool-routing.js +0 -589
  68. package/src/mcp-server-entry.mjs +0 -3177
@@ -0,0 +1,2304 @@
1
+ import { z } from 'zod';
2
+ import { fetchAPI } from './fetch.js';
3
+ import { validateScriptSourceIfPresent } from './mutation-guards.js';
4
+ import { assertDynamicCodeKnowledgeAck, assertDynamicCodeKnowledgeAckIf, assertExtensionKnowledgeAck, assertGlobalRulesAck, dynamicCodeKnowledgeAckParam, extensionKnowledgeAckParam, globalRulesAckParam, } from './required-knowledge.js';
5
+ const AUTO_INJECTED_EXTENSION_COMPONENT_TAGS = [
6
+ 'CommonDrawer',
7
+ 'CommonModal',
8
+ 'EmptyState',
9
+ 'FormEditor',
10
+ 'FormEditorLazy',
11
+ 'NuxtLink',
12
+ 'PermissionGate',
13
+ 'UBadge',
14
+ 'UButton',
15
+ 'UCheckbox',
16
+ 'UDropdownMenu',
17
+ 'UForm',
18
+ 'UFormField',
19
+ 'UIcon',
20
+ 'UInput',
21
+ 'UModal',
22
+ 'USelect',
23
+ 'USelectMenu',
24
+ 'USkeleton',
25
+ 'USwitch',
26
+ 'UTabs',
27
+ 'UTextarea',
28
+ 'UTooltip',
29
+ 'Widget',
30
+ ];
31
+ const AUTO_INJECTED_EXTENSION_COMPONENT_BY_LOWERCASE = new Map(AUTO_INJECTED_EXTENSION_COMPONENT_TAGS.map((tag) => [tag.toLowerCase(), tag]));
32
+ function unwrapData(result) {
33
+ return Array.isArray(result?.data) ? result.data : [];
34
+ }
35
+ function getId(record) {
36
+ return record?.id ?? record?._id ?? null;
37
+ }
38
+ function refId(value) {
39
+ return typeof value === 'object' && value !== null ? getId(value) : value;
40
+ }
41
+ function sameId(a, b) {
42
+ if (a === null || a === undefined || b === null || b === undefined)
43
+ return false;
44
+ return String(a) === String(b);
45
+ }
46
+ function firstDataRecord(result) {
47
+ return Array.isArray(result?.data) ? result.data[0] : result;
48
+ }
49
+ function normalizeRestPath(path) {
50
+ if (!path)
51
+ return '/';
52
+ if (/^https?:\/\//i.test(path)) {
53
+ throw new Error('Only Enfyra API paths are allowed, not full external URLs.');
54
+ }
55
+ return path.startsWith('/') ? path : `/${path}`;
56
+ }
57
+ function normalizeMethodName(method) {
58
+ const value = String(method || '').trim().toUpperCase();
59
+ if (!/^[A-Z][A-Z0-9_]*$/.test(value)) {
60
+ throw new Error(`Invalid method "${method}". Method names must start with A-Z and contain only A-Z, 0-9, or underscore.`);
61
+ }
62
+ return value;
63
+ }
64
+ function methodNamesFromRecords(records, methodIdNameMap) {
65
+ return (records || [])
66
+ .map((method) => method?.name || methodIdNameMap[String(getId(method))] || null)
67
+ .filter(Boolean)
68
+ .map(normalizeMethodName);
69
+ }
70
+ function uniqueMethodNames(names) {
71
+ return Array.from(new Set((names || []).map((name) => normalizeMethodName(name))));
72
+ }
73
+ function resolveMethodRefs(methodMap, names) {
74
+ return uniqueMethodNames(names).map((name) => {
75
+ const id = methodMap[name];
76
+ if (!id)
77
+ throw new Error(`Unknown method "${name}". Valid methods: ${Object.keys(methodMap).sort().join(', ')}`);
78
+ return { id };
79
+ });
80
+ }
81
+ function mergeMethods(existing, requested, mode) {
82
+ const existingNames = uniqueMethodNames(existing);
83
+ const requestedNames = uniqueMethodNames(requested);
84
+ if (mode === 'replace')
85
+ return requestedNames;
86
+ if (mode === 'remove')
87
+ return existingNames.filter((method) => !requestedNames.includes(method));
88
+ return uniqueMethodNames([...existingNames, ...requestedNames]);
89
+ }
90
+ async function fetchAll(apiUrl, path) {
91
+ return unwrapData(await fetchAPI(apiUrl, path));
92
+ }
93
+ async function getMethodContext(apiUrl) {
94
+ const methods = await fetchAll(apiUrl, '/enfyra_method?limit=0&fields=id,_id,name');
95
+ const methodMap = {};
96
+ const methodIdNameMap = {};
97
+ for (const method of methods) {
98
+ if (!method?.name)
99
+ continue;
100
+ const name = normalizeMethodName(method.name);
101
+ const id = getId(method);
102
+ methodMap[name] = id;
103
+ methodIdNameMap[String(id)] = name;
104
+ }
105
+ return { methods, methodMap, methodIdNameMap };
106
+ }
107
+ async function reloadRoutes(apiUrl) {
108
+ try {
109
+ const result = await fetchAPI(apiUrl, '/admin/reload/routes', { method: 'POST' });
110
+ return { attempted: true, succeeded: true, result };
111
+ }
112
+ catch (error) {
113
+ return { attempted: true, succeeded: false, error: error?.message || String(error) };
114
+ }
115
+ }
116
+ async function resolveRoute(apiUrl, { path, routeId }) {
117
+ if (!path && !routeId)
118
+ throw new Error('Provide path or routeId.');
119
+ if (path && routeId)
120
+ throw new Error('Provide path or routeId, not both.');
121
+ const routes = await fetchAll(apiUrl, '/enfyra_route?limit=1000&fields=id,_id,path,isEnabled,availableMethods.*,publicMethods.*,mainTable.name');
122
+ const normalizedPath = path ? normalizeRestPath(path) : null;
123
+ const route = routes.find((item) => (routeId ? sameId(getId(item), routeId) : item.path === normalizedPath));
124
+ if (!route)
125
+ throw new Error(`Route not found: ${routeId || normalizedPath}`);
126
+ return { route, routes, path: route.path };
127
+ }
128
+ async function updateRouteMethods(apiUrl, { path, routeId, methods, mode, isEnabled, globalRulesAckKey }) {
129
+ assertGlobalRulesAck(globalRulesAckKey);
130
+ const [{ route }, { methodMap, methodIdNameMap }] = await Promise.all([
131
+ resolveRoute(apiUrl, { path, routeId }),
132
+ getMethodContext(apiUrl),
133
+ ]);
134
+ const existingAvailable = methodNamesFromRecords(route.availableMethods, methodIdNameMap);
135
+ const existingPublic = methodNamesFromRecords(route.publicMethods, methodIdNameMap);
136
+ const finalAvailable = mergeMethods(existingAvailable, methods, mode);
137
+ const finalPublic = existingPublic.filter((method) => finalAvailable.includes(method));
138
+ const body = {
139
+ availableMethods: resolveMethodRefs(methodMap, finalAvailable),
140
+ publicMethods: resolveMethodRefs(methodMap, finalPublic),
141
+ };
142
+ if (isEnabled !== undefined)
143
+ body.isEnabled = isEnabled;
144
+ const result = await fetchAPI(apiUrl, `/enfyra_route/${encodeURIComponent(String(getId(route)))}`, {
145
+ method: 'PATCH',
146
+ body: JSON.stringify(body),
147
+ });
148
+ const routeReload = await reloadRoutes(apiUrl);
149
+ return {
150
+ action: 'route_methods_updated',
151
+ route: { id: getId(route), path: route.path },
152
+ before: { availableMethods: existingAvailable, publicMethods: existingPublic },
153
+ after: { availableMethods: finalAvailable, publicMethods: finalPublic },
154
+ result,
155
+ routeReload,
156
+ };
157
+ }
158
+ async function updateRoutePublicMethods(apiUrl, { path, routeId, methods, mode, globalRulesAckKey }) {
159
+ assertGlobalRulesAck(globalRulesAckKey);
160
+ const [{ route }, { methodMap, methodIdNameMap }] = await Promise.all([
161
+ resolveRoute(apiUrl, { path, routeId }),
162
+ getMethodContext(apiUrl),
163
+ ]);
164
+ const availableMethods = methodNamesFromRecords(route.availableMethods, methodIdNameMap);
165
+ const existingPublic = methodNamesFromRecords(route.publicMethods, methodIdNameMap);
166
+ const requestedMethods = uniqueMethodNames(methods);
167
+ const unavailable = requestedMethods.filter((method) => !availableMethods.includes(method));
168
+ if (unavailable.length > 0) {
169
+ throw new Error(`Cannot make unavailable route method(s) public: ${unavailable.join(', ')}. First call add_route_methods to add them to availableMethods.`);
170
+ }
171
+ const finalPublic = mergeMethods(existingPublic, requestedMethods, mode);
172
+ const result = await fetchAPI(apiUrl, `/enfyra_route/${encodeURIComponent(String(getId(route)))}`, {
173
+ method: 'PATCH',
174
+ body: JSON.stringify({ publicMethods: resolveMethodRefs(methodMap, finalPublic) }),
175
+ });
176
+ const routeReload = await reloadRoutes(apiUrl);
177
+ return {
178
+ action: 'route_public_methods_updated',
179
+ route: { id: getId(route), path: route.path },
180
+ availableMethods,
181
+ publicMethodsBefore: existingPublic,
182
+ publicMethodsAfter: finalPublic,
183
+ publicAccess: finalPublic.length > 0 ? 'Methods listed in publicMethods bypass auth/RoleGuard.' : 'No public methods remain on this route.',
184
+ result,
185
+ routeReload,
186
+ };
187
+ }
188
+ async function setRouteEnabled(apiUrl, { path, routeId, isEnabled, globalRulesAckKey }) {
189
+ assertGlobalRulesAck(globalRulesAckKey);
190
+ const { route } = await resolveRoute(apiUrl, { path, routeId });
191
+ const before = route?.isEnabled !== false;
192
+ if (before === isEnabled) {
193
+ return {
194
+ action: isEnabled ? 'route_already_enabled' : 'route_already_disabled',
195
+ route: { id: getId(route), path: route.path },
196
+ before: { isEnabled: before },
197
+ after: { isEnabled },
198
+ runtimeBehavior: isEnabled ? 'Enabled routes are registered at runtime.' : 'Disabled routes are not registered at runtime and return 404.',
199
+ routeReload: { attempted: false, succeeded: true, reason: 'No route lifecycle change was needed.' },
200
+ };
201
+ }
202
+ const result = await fetchAPI(apiUrl, `/enfyra_route/${encodeURIComponent(String(getId(route)))}`, {
203
+ method: 'PATCH',
204
+ body: JSON.stringify({ isEnabled }),
205
+ });
206
+ const routeReload = await reloadRoutes(apiUrl);
207
+ return {
208
+ action: isEnabled ? 'route_enabled' : 'route_disabled',
209
+ route: { id: getId(route), path: route.path },
210
+ before: { isEnabled: before },
211
+ after: { isEnabled },
212
+ runtimeBehavior: isEnabled ? 'The route should now be registered at runtime.' : 'The route should now return 404 because disabled routes are not registered at runtime.',
213
+ result,
214
+ routeReload,
215
+ };
216
+ }
217
+ async function fetchRouteDependencies(apiUrl, routeId) {
218
+ const routeFilter = filterQuery({ route: { id: { _eq: routeId } } });
219
+ const routeIdFilter = filterQuery({ routeId: { _eq: routeId } });
220
+ const [handlers, permissions, preHooks, postHooks, guards] = await Promise.all([
221
+ fetchAll(apiUrl, `/enfyra_route_handler?filter=${routeIdFilter}&fields=id,_id,routeId,method.name&limit=0`),
222
+ fetchAll(apiUrl, `/enfyra_route_permission?filter=${routeFilter}&fields=id,_id,route.id,role.name,isEnabled&limit=0`),
223
+ fetchAll(apiUrl, `/enfyra_pre_hook?filter=${routeFilter}&fields=id,_id,route.id,name,isEnabled&limit=0`),
224
+ fetchAll(apiUrl, `/enfyra_post_hook?filter=${routeFilter}&fields=id,_id,route.id,name,isEnabled&limit=0`),
225
+ fetchAll(apiUrl, `/enfyra_guard?filter=${routeFilter}&fields=id,_id,route.id,name,isEnabled&limit=0`),
226
+ ]);
227
+ return { handlers, permissions, preHooks, postHooks, guards };
228
+ }
229
+ function summarizeRouteDependencies(dependencies) {
230
+ return {
231
+ handlers: dependencies.handlers.map((item) => ({ id: getId(item), method: item?.method?.name || null })),
232
+ permissions: dependencies.permissions.map((item) => ({ id: getId(item), role: item?.role?.name || null, isEnabled: item?.isEnabled !== false })),
233
+ preHooks: dependencies.preHooks.map((item) => ({ id: getId(item), name: item?.name || null, isEnabled: item?.isEnabled !== false })),
234
+ postHooks: dependencies.postHooks.map((item) => ({ id: getId(item), name: item?.name || null, isEnabled: item?.isEnabled !== false })),
235
+ guards: dependencies.guards.map((item) => ({ id: getId(item), name: item?.name || null, isEnabled: item?.isEnabled !== false })),
236
+ };
237
+ }
238
+ async function deleteRows(apiUrl, tableName, rows) {
239
+ const deleted = [];
240
+ for (const row of rows) {
241
+ const id = getId(row);
242
+ if (id === null || id === undefined)
243
+ continue;
244
+ await fetchAPI(apiUrl, `/${tableName}/${encodeURIComponent(String(id))}`, { method: 'DELETE' });
245
+ deleted.push(id);
246
+ }
247
+ return deleted;
248
+ }
249
+ async function deleteRoute(apiUrl, { path, routeId, expectedPath, confirm, globalRulesAckKey }) {
250
+ const { route } = await resolveRoute(apiUrl, { path, routeId });
251
+ if (expectedPath && route.path !== normalizeRestPath(expectedPath)) {
252
+ throw new Error(`Route path mismatch: resolved ${route.path}, expected ${normalizeRestPath(expectedPath)}.`);
253
+ }
254
+ const dependencies = await fetchRouteDependencies(apiUrl, getId(route));
255
+ const dependencySummary = summarizeRouteDependencies(dependencies);
256
+ const preview = {
257
+ route: { id: getId(route), path: route.path, isEnabled: route?.isEnabled !== false },
258
+ dependencies: dependencySummary,
259
+ };
260
+ if (!confirm) {
261
+ return {
262
+ action: 'delete_route_preview',
263
+ ...preview,
264
+ next: 'Call delete_route again with confirm=true and expectedPath set to this route path to delete the route and related handlers/hooks/permissions/guards.',
265
+ };
266
+ }
267
+ assertGlobalRulesAck(globalRulesAckKey);
268
+ await deleteRows(apiUrl, 'enfyra_route_handler', dependencies.handlers);
269
+ await deleteRows(apiUrl, 'enfyra_pre_hook', dependencies.preHooks);
270
+ await deleteRows(apiUrl, 'enfyra_post_hook', dependencies.postHooks);
271
+ await deleteRows(apiUrl, 'enfyra_guard', dependencies.guards);
272
+ await deleteRows(apiUrl, 'enfyra_route_permission', dependencies.permissions);
273
+ const result = await fetchAPI(apiUrl, `/enfyra_route/${encodeURIComponent(String(getId(route)))}`, { method: 'DELETE' });
274
+ const routeReload = await reloadRoutes(apiUrl);
275
+ return {
276
+ action: 'route_deleted',
277
+ ...preview,
278
+ deleted: {
279
+ handlers: dependencies.handlers.map(getId).filter((id) => id !== null && id !== undefined),
280
+ permissions: dependencies.permissions.map(getId).filter((id) => id !== null && id !== undefined),
281
+ preHooks: dependencies.preHooks.map(getId).filter((id) => id !== null && id !== undefined),
282
+ postHooks: dependencies.postHooks.map(getId).filter((id) => id !== null && id !== undefined),
283
+ guards: dependencies.guards.map(getId).filter((id) => id !== null && id !== undefined),
284
+ route: getId(route),
285
+ },
286
+ result,
287
+ routeReload,
288
+ };
289
+ }
290
+ async function findHandler(apiUrl, routeId, methodId) {
291
+ const filter = encodeURIComponent(JSON.stringify({
292
+ route: { id: { _eq: routeId } },
293
+ method: { id: { _eq: methodId } },
294
+ }));
295
+ const result = await fetchAPI(apiUrl, `/enfyra_route_handler?filter=${filter}&limit=1&fields=id,_id,route.id,method.id,method.name,sourceCode,scriptLanguage,timeout`);
296
+ return unwrapData(result)[0] || null;
297
+ }
298
+ function jsonText(payload) {
299
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
300
+ }
301
+ function getExtensionThemeContract() {
302
+ return {
303
+ action: 'extension_theme_contract',
304
+ useBefore: [
305
+ 'Call this before writing or reviewing Enfyra admin page, widget, or global extension UI.',
306
+ 'Then call validate_extension_code or an ensure_*_extension tool before saving.',
307
+ ],
308
+ layout: [
309
+ 'The extension is already mounted inside the Enfyra app shell. Do not add a duplicate page header, centered page wrapper, or root-level page padding.',
310
+ 'Page extensions should be full-bleed, responsive, and split large operations into focused pages or UTabs.',
311
+ 'Use usePageHeaderRegistry for the shell title and useHeaderActionRegistry/useSubHeaderActionRegistry for page actions.',
312
+ 'Use useMenuNotificationRegistry from global extensions to register sidebar menu notification counts or dots. Register stable ids, target menus by id/path/route, use value for counts, omit value for a dot, and choose color from primary/success/warning/error/info/neutral.',
313
+ 'For shell menu notifications, first decide the signal source. Use a count only when the source already owns an exact count, such as a notification summary endpoint or bounded unread-notification query. Use a dot when a realtime event only proves that something new exists. Do not poll a domain list such as messages, tickets, orders, or jobs solely to decorate the menu; the destination page owns domain fetching.',
314
+ 'Use useAccountPanelRegistry for account panel rows. AccountPanelItem supports count as the preferred numeric/text badge value, badge as a legacy alias, and badgeColor primary/neutral/info/error/warning/success.',
315
+ 'For detail/form workflows that should stay left-aligned with empty space on the right, wrap the body in eapp-page-constrained; use eapp-page-constrained-wide only when the workflow genuinely needs more width.',
316
+ 'Card/list grids inside the default shell must account for the 280px desktop sidebar. Do not switch general card grids to three columns at lg; use md:grid-cols-2 xl:grid-cols-3 unless a local container proves three columns have enough width.',
317
+ ],
318
+ theme: [
319
+ 'Use eApp theme class tokens, not hardcoded light/dark colors and not raw CSS variables inside extension templates. The app owns the CSS variable implementation; generated extensions should choose class tokens by intent.',
320
+ 'Primary color is runtime-configurable through the app color picker and must affect extension identity UI. For Nuxt UI components, choose color="primary" by semantic intent and let the app map it through the primary contract; do not choose a concrete palette. For custom extension UI, first choose whether the element is neutral surface, runtime-primary identity, or status. Regular panels, KPI cards, list rows, and large content blocks should use eapp-surface-card, eapp-surface-muted, eapp-surface-flat, eapp-surface-hover, eapp-divide-y, and eapp-text-* classes. Entity identity, selected/current state, active progress, primary tiles, primary icons, and primary CTA fills should use eapp-primary-surface, eapp-primary-soft, eapp-primary-subtle, eapp-primary-solid, eapp-primary-text, eapp-primary-border, or eapp-primary-ring so the color picker controls them.',
321
+ 'Use eapp-primary-surface only for larger entity/feature blocks, selected/current cards, tiles, or cards that should read like normal app cards with a very subtle active-primary tint; it supplies selected identity color but does not replace card chrome, so keep normal border/radius classes such as border plus eapp-radius-panel on the element. It is not a saturated selected-state fill and must not be applied broadly to every KPI/list wrapper. Add eapp-primary-surface-hover when that block is clickable. Use eapp-primary-soft for compact selected entity chips, pills, square icon tiles using eapp-icon-tile, and identity callouts; add eapp-primary-soft-hover when compact surfaces are clickable; use eapp-primary-subtle for a slightly stronger selected fill; use eapp-primary-solid only for primary identity fills; use eapp-primary-text for identity icons or inline text. eapp-identity-* remains an alias for the same runtime-primary intent, but eapp-primary-* is preferred in new extension code.',
322
+ 'Nuxt UI secondary is still a valid semantic color when the product intentionally wants a secondary action or state. Do not use color="secondary", from-secondary-*, bg-secondary-*, text-secondary-*, or cyan/purple/green palette utilities merely to approximate an entity accent; use eapp-primary-* and let the app decide the color.',
323
+ 'The app runs on Tailwind v4. Short Tailwind color utilities are the canonical way to apply contract colors and ARE allowed: bg-primary, text-primary, border-primary, ring-primary, bg-success, text-error, etc., including opacity modifiers (bg-primary/10, ring-success/20) which v4 resolves via color-mix. They are generated from the token-backed config (primary -> --md-primary runtime, success/error/warning/info -> --st-* status, secondary -> --md-tertiary) so they follow the color picker and dark theme. Do NOT use raw CSS-variable utilities (text-[var(--*)], bg-[var(--*)], border-[var(--*)]), hardcoded hex, inline style colors, or concrete palette substitution (color="violet", from-cyan-*, text-violet-*, bg-green-*, bg-emerald-*, text-green-*, dark:bg-zinc-950). For intent surfaces with no Tailwind equivalent (selected identity block, soft/solid/subtle surface, divider, radius, modal chrome) use the eapp-* classes below; the app owns how they map to the active color picker value.',
324
+ 'Use UButton color="primary" only for the single main action for the current scope. Refresh, back, navigation, filters, and secondary actions should be neutral variants unless they are the main mutation.',
325
+ 'PageHeader gradient must be "none" for generated operational extensions unless the user explicitly asks for a decorative page accent. Do not hardcode cyan, violet, purple, blue, or green PageHeader gradients to force color variety.',
326
+ 'Do not inject global CSS, create theme guards, redefine the app palette, or solve one extension by overriding the whole app shell.',
327
+ 'For panels/cards, prefer eapp-surface-card, eapp-surface-hover, eapp-surface-muted, eapp-surface-flat, and eapp-divide-y. Use eapp-text-primary, eapp-text-secondary, eapp-text-tertiary, or eapp-text-quaternary for copy.',
328
+ 'Never use Nuxt UI neutral semantic classes such as bg-default, bg-muted, border-default, divide-default, text-muted, text-dimmed, or hardcoded dark palettes such as dark:bg-zinc-950, bg-slate-*, text-gray-*, border-black, or black.',
329
+ 'Never use bare border/divide-y for panels or rows: pair borders with eapp-divider or use eapp-divide-y for row separators.',
330
+ 'Use radius tokens or mapped rounded utilities consistently: --radius-card for cards, --radius-panel for nested panels, --radius-control for buttons/inputs, --radius-subcontrol for compact inner controls, and --radius-pill for pills.',
331
+ 'Status colors must remain readable in both themes and must stay scoped to badges, small icons, or short status text. Use UBadge/UAlert semantic colors or eapp-status-success-soft/text/border, eapp-status-warning-soft/text/border, eapp-status-danger-soft/text/border, eapp-status-info-soft/text/border, and eapp-status-neutral-soft/text/border. Do not read --badge-* variables directly from extension templates. Do not color large panels, alert-like success blocks, KPI cards, list containers, or reconciliation/attention blocks green/yellow/red because the status is good/warning/error; use neutral app surfaces for the block and place a small status badge/icon inside.',
332
+ 'Keep dark and light contrast comparable. Do not make dark mode more neon or lower-contrast than light mode; prefer muted soft backgrounds with clear text and visible borders.',
333
+ ],
334
+ decisionCases: [
335
+ {
336
+ intent: 'Normal accent, decorative icon, feature icon, non-state tile, active tab fill, progress fill, selected segment, primary metric accent, or primary action.',
337
+ colorContract: 'Use runtime primary so the app color picker controls the color.',
338
+ use: 'UButton/UBadge color="primary", eapp-primary-soft, eapp-primary-subtle, eapp-primary-solid, eapp-primary-text, or eapp-primary-surface when the whole block is selected/current identity.',
339
+ avoid: 'Do not pick cyan, purple, green, amber, secondary, or concrete Tailwind palettes just because they look good.',
340
+ },
341
+ {
342
+ intent: 'True semantic state: error, danger, destructive, warning, pending attention, success, healthy, running, failed, info, or notice.',
343
+ colorContract: 'Use the matching state/status color because the color communicates meaning, not brand identity.',
344
+ use: 'UAlert/UBadge color="error|warning|success|info|neutral", or eapp-status-success|warning|danger|info|neutral soft/text/border classes for custom compact status chips/icons.',
345
+ avoid: 'Do not force semantic state UI to primary; an error must stay error, a warning must stay warning, success can stay success when it is a badge/icon/short status.',
346
+ },
347
+ {
348
+ intent: 'Large ordinary surface: KPI card, list container, table panel, detail panel, summary block, empty state panel, reconciliation block, or attention block.',
349
+ colorContract: 'Use neutral app surfaces first; large surfaces should not become state-colored or arbitrary accent-colored.',
350
+ use: 'eapp-surface-card, eapp-surface-hover, eapp-surface-muted, eapp-surface-flat, eapp-divide-y, and eapp-text-* classes.',
351
+ avoid: 'Do not color large blocks green/yellow/red because their content says healthy/warning/error; place a status badge/icon inside the neutral block instead.',
352
+ },
353
+ {
354
+ intent: 'Selected/current entity or user-selected option where the whole block is the active identity.',
355
+ colorContract: 'Use runtime primary identity surface, but subtly.',
356
+ use: 'eapp-primary-surface plus optional eapp-primary-surface-hover; eapp-primary-soft/text for the icon or chip inside.',
357
+ avoid: 'Do not use eapp-primary-surface for every card in a grid/list or as a broad page background.',
358
+ },
359
+ ],
360
+ components: [
361
+ 'Use Nuxt UI/eApp components for normal controls: UButton, UInput, UTextarea, USelectMenu/USelect, USwitch, UCheckbox, UTabs, UBadge, UModal, and CommonDrawer when available.',
362
+ 'Use auto-injected components directly in the template with PascalCase names. Do not call resolveComponent() to manually resolve Nuxt UI/eApp components inside extension SFCs; it can compile but render unresolved lowercase DOM tags such as <ubutton>.',
363
+ 'Buttons should have stable geometry: hover may change color, border, or shadow but must not move the button or resize its content. Disabled buttons keep disabled cursor/visual state.',
364
+ 'Inputs and textareas should not add hover movement or decorative hover states; focus, invalid, disabled, and loading states must be explicit.',
365
+ 'Dynamic extensions resolve UModal to the app CommonModal. Do not pass ui.content: "eapp-surface-card" or "surface-card" to UModal/CommonModal; modal content uses the app modal surface and caller ui.content should only append z-index, width, or max-width classes.',
366
+ 'CommonModal and CommonDrawer own action-only footers through cancelAction, primaryAction, dangerAction, leadingActions, and footerHint. Pass footer button intent through those props instead of custom footer slots. cancelAction defaults to neutral outline; use dangerAction for irreversible destructive work and tone: "primary" for Keep editing in discard dialogs.',
367
+ 'Use custom #footer content only when the footer contains real custom layout or non-button content. Every modal/drawer button should use type="button" unless it intentionally submits a form.',
368
+ 'Use CommonDrawer for side-panel editing. Open drawers immediately on user action and render loading/error/content inside the drawer instead of waiting for fetch before opening.',
369
+ 'Use UTabs for page sections and large grouped forms instead of custom tab bars; the app-level Nuxt UI override owns active and inactive indicators, focus rings, spacing, and theme contrast.',
370
+ 'Use UBadge or token-backed badge spans for status. Keep badges legible in both themes with tokenized background, text, and border.',
371
+ 'Use shell registries for shell badges: useAccountPanelRegistry for the account panel and useMenuNotificationRegistry for sidebar menus. Do not draw detached fixed-position badges over the app shell.',
372
+ ],
373
+ loadingAndLists: [
374
+ 'For first load of card/list pages, render calm skeleton cards with a slow pulse. Use USkeleton or shared loading components so the app-owned skeleton theme controls contrast and accent matching. For subsequent pagination/filter refreshes, keep the card shells mounted and skeletonize card content until the new list is ready.',
375
+ 'Keep pagination inside the same transition/loading branch as the list. Do not show pagination before the list content has left loading.',
376
+ 'Use bounded pagination for operational lists. Do not replace pagination with arbitrary fixed caps such as 30 or 50.',
377
+ 'Empty states should use an app-matched card surface with compact icon tile, title, and description; do not use huge blank white panels or naked UEmpty chrome on page surfaces.',
378
+ ],
379
+ interaction: [
380
+ 'Every mutating button needs pending/disabled state, success/error feedback, and must close or update its modal when the operation completes.',
381
+ 'Do not refetch broad lists after selecting one row. Keep local selection state and fetch only the detail or mutation result needed.',
382
+ 'Customer-facing toasts must describe the operation. Do not surface raw job ids, flow ids, or worker ids.',
383
+ ],
384
+ security: [
385
+ 'Decide route permission, owner scope, and field exposure before writing UI or backend logic.',
386
+ 'UI checks are only guidance; handlers/hooks must independently enforce owner/root-admin authorization.',
387
+ 'Use the most specific business route or MCP tool. Do not write directly to raw tables when a domain route exists.',
388
+ ],
389
+ patternExamples: [
390
+ {
391
+ useWhen: 'Ordinary KPI, metric, or summary card where the whole card is not selected/current identity.',
392
+ use: 'Neutral card surface; put runtime-primary only on a small identity icon tile, progress fill, or main CTA inside the card.',
393
+ snippet: '<article class="eapp-surface-card p-4"><div class="flex items-start justify-between gap-3"><div><p class="text-sm eapp-text-tertiary">Metric</p><p class="mt-2 text-2xl font-semibold eapp-text-primary">{{ value }}</p></div><span class="eapp-primary-soft eapp-icon-tile"><UIcon name="lucide:square-stack" class="size-5 eapp-primary-text" /></span></div></article>',
394
+ },
395
+ {
396
+ useWhen: 'Selected/current entity, active plan, chosen package, or the single block that represents the active identity.',
397
+ use: 'eapp-primary-surface for the selected/current block, with eapp-primary-soft/text for compact icon parts.',
398
+ snippet: '<article class="eapp-primary-surface eapp-primary-surface-hover eapp-radius-panel border p-4"><div class="flex items-center gap-3"><span class="eapp-primary-soft eapp-icon-tile"><UIcon name="lucide:box" class="size-5 eapp-primary-text" /></span><div><p class="font-semibold eapp-text-primary">{{ name }}</p><p class="text-sm eapp-text-tertiary">Currently selected</p></div></div></article>',
399
+ },
400
+ {
401
+ useWhen: 'Progress, active tab indicator, selected segment fill, or primary visual meter.',
402
+ use: 'Neutral track plus eapp-primary-solid fill so the app color picker controls the fill.',
403
+ snippet: '<div class="h-1.5 overflow-hidden eapp-radius-pill eapp-surface-muted"><div class="eapp-primary-solid h-full" :style="{ width: progressWidth }"></div></div>',
404
+ },
405
+ {
406
+ useWhen: 'Success, warning, error, info, healthy, running, failed, pending, or attention status.',
407
+ use: 'UBadge/status badge tokens and optionally a small icon only. Keep large alert/panel/card backgrounds neutral unless the whole block is an identity block.',
408
+ snippet: '<section class="eapp-surface-card p-4"><div class="flex items-center justify-between gap-3"><p class="font-semibold eapp-text-primary">Reconciliation</p><UBadge color="success" variant="soft">Healthy</UBadge></div><p class="mt-1 text-sm eapp-text-tertiary">Latest report found no mismatches.</p></section>',
409
+ },
410
+ {
411
+ useWhen: 'List rows, table-like records, history rows, and secondary navigation rows.',
412
+ use: 'Neutral row surface, tokenized dividers, hover surface-muted, with small status/identity chips inside.',
413
+ snippet: '<div class="eapp-surface-card eapp-divide-y"><button class="flex w-full items-center justify-between px-4 py-3 text-left eapp-surface-hover"><span class="text-sm font-medium eapp-text-primary">{{ row.name }}</span><UBadge color="neutral" variant="soft">{{ row.state }}</UBadge></button></div>',
414
+ },
415
+ {
416
+ useWhen: 'Primary action for the current scope, such as create/save/apply/open-current.',
417
+ use: 'UButton color="primary" variant="solid"; secondary actions stay neutral.',
418
+ snippet: '<div class="flex justify-end gap-2"><UButton color="neutral" variant="outline">Cancel</UButton><UButton color="primary" variant="solid" icon="lucide:save">Save</UButton></div>',
419
+ },
420
+ ],
421
+ compactExample: '<template><section class="min-h-full w-full space-y-4"><article class="eapp-surface-card p-4"><div class="flex items-start justify-between gap-3"><div><p class="text-sm eapp-text-tertiary">Neutral KPI</p><p class="mt-2 text-2xl font-semibold eapp-text-primary">24</p></div><span class="eapp-primary-soft eapp-icon-tile"><UIcon name="lucide:square-stack" class="size-5 eapp-primary-text" /></span></div><div class="mt-3 h-1.5 overflow-hidden eapp-radius-pill eapp-surface-muted"><div class="eapp-primary-solid h-full w-1/2"></div></div></article><section class="eapp-surface-card p-4"><div class="flex items-center justify-between gap-3"><p class="font-semibold eapp-text-primary">Status block stays neutral</p><UBadge color="success" variant="soft">Healthy</UBadge></div></section></section></template>',
422
+ shellNotificationContract: {
423
+ menu: 'useMenuNotificationRegistry().register({ id, target: { id?, path?, route? }, value?, color?, title?, order? }). value renders a count/chip; omitting value renders a dot. Parent menus sum numeric child values.',
424
+ accountPanel: 'useAccountPanelRegistry().register({ id, label, description, icon, count?, badge?, badgeColor?, expanded?, onToggle?, contentComponent? }). count is preferred over badge and the account trigger sums numeric visible item counts, capped at 99+.',
425
+ lifecycle: 'Register from global extensions for app-wide notification state; stable ids replace previous registrations and component-owned registrations are removed on unmount.',
426
+ reasoning: 'Counts and dots are different promises. A count says the shell knows an exact or bounded number from an appropriate notification/summary source. A dot says the shell only knows that new attention exists. Avoid fetching the destination domain list just to make a menu badge more precise.',
427
+ },
428
+ contractAuthority: [
429
+ 'This is the authoritative Enfyra theme & color contract. Source of truth: documents/app/theme-color-contract.md. The app owns color through app/utils/primary-colors.ts (Material You seed-to-role generation), app/assets/css/theme.css (semantic variables and Nuxt UI ramps), app/assets/css/main.css (extension-safe semantic utilities), and app/app.config.ts (Nuxt UI component mapping). Pages and extensions only CONSUME classes/Nuxt UI props; they never define colors.',
430
+ 'Every color flows from two base layers: --md-* (Material You, runtime primary picker) and --st-* (status). Runtime primary roles are generated with SchemeTonalSpot. Success/warning/info stay fixed status quarts; error follows the generated Material error role through the single --danger-* lane. All Nuxt UI semantic colors (primary/secondary/success/warning/error/info/neutral) are re-pointed to these, so Nuxt UI is used per its docs but colors are decided by Enfyra. This applies to the shell, system pages, and compiled dynamic extensions.',
431
+ 'Call get_theme_class_reference for the full class->variable->Nuxt UI table when you need the exact class name or variable.',
432
+ ],
433
+ classReference: {
434
+ surfaces: ['eapp-surface-card (default card; --card-bg/--card-border)', 'eapp-surface-muted (recessed/track; --surface-muted)', 'eapp-surface-flat (flush; --surface-default)', 'eapp-surface-hover (clickable row hover)'],
435
+ text: ['eapp-text-primary', 'eapp-text-secondary', 'eapp-text-tertiary', 'eapp-text-quaternary'],
436
+ primaryIdentity: ['eapp-primary-solid (solid fill/meter)', 'eapp-primary-text (inline/icon)', 'eapp-primary-soft + -hover (compact chip/tile)', 'eapp-primary-subtle (stronger selected fill)', 'eapp-primary-surface + -hover (large selected identity block)', 'eapp-primary-border', 'eapp-primary-ring'],
437
+ status: ['eapp-status-success|warning|danger|info|neutral -soft/-text/-border (badges/small icons/short text only)'],
438
+ radius: ['eapp-radius-card', 'eapp-radius-panel', 'eapp-radius-control', 'eapp-radius-subcontrol', 'eapp-radius-pill'],
439
+ dividers: ['eapp-divider', 'eapp-divide-y'],
440
+ modal: ['eapp-modal-surface (modal content chrome; never surface-card)'],
441
+ nuxtUiMapping: 'primary=--md-primary(runtime), secondary=--md-tertiary(runtime), success=--st-success, warning=--st-warning, error=--st-error, info=--st-info, neutral=neutral surfaces',
442
+ },
443
+ };
444
+ }
445
+ function getThemeClassReference() {
446
+ return {
447
+ action: 'theme_class_reference',
448
+ authority: 'Authoritative Enfyra theme & color contract. Source of truth: documents/app/theme-color-contract.md. App owns color via theme.css + main.css + app.config.ts only; pages/extensions consume classes and Nuxt UI props.',
449
+ baseLayers: {
450
+ material: '--md-* (runtime primary picker, HCT/Material You). Drives identity/brand. Never read directly in templates.',
451
+ status: '--st-success/--st-warning/--st-error/--st-info. Fixed semantic palette. Never read directly in templates.',
452
+ },
453
+ nuxtUiColors: {
454
+ primary: 'runtime --md-primary (main brand action/identity). NEVER substitute a concrete palette.',
455
+ secondary: 'runtime --md-tertiary (intentional secondary accent only).',
456
+ success: '--st-success (healthy/success).',
457
+ warning: '--st-warning (pending/attention).',
458
+ error: 'single --danger-* lane from Material error roles (destructive/error). Ghost danger text uses --danger-on-surface; danger fills use --danger-surface.',
459
+ info: '--st-info (informational).',
460
+ neutral: 'neutral surfaces (secondary chrome, non-actions).',
461
+ },
462
+ classes: [
463
+ { group: 'Surfaces (large ordinary - keep neutral)', classes: 'eapp-surface-card, eapp-surface-muted, eapp-surface-flat, eapp-surface-hover' },
464
+ { group: 'Text', classes: 'eapp-text-primary, eapp-text-secondary, eapp-text-tertiary, eapp-text-quaternary' },
465
+ { group: 'Runtime primary identity', classes: 'eapp-primary-solid, eapp-primary-text, eapp-primary-soft(+hover), eapp-primary-subtle, eapp-primary-surface(+hover), eapp-primary-border, eapp-primary-ring' },
466
+ { group: 'Status (badges/small icons/short text only)', classes: 'eapp-status-{success|warning|danger|info|neutral}-{soft|text|border}' },
467
+ { group: 'Radius', classes: 'eapp-radius-card, eapp-radius-panel, eapp-radius-control, eapp-radius-subcontrol, eapp-radius-pill' },
468
+ { group: 'Icon tile geometry', classes: 'eapp-icon-tile, eapp-icon-tile-sm, eapp-icon-tile-lg' },
469
+ { group: 'Dividers', classes: 'eapp-divider, eapp-divide-y' },
470
+ { group: 'Modal', classes: 'eapp-modal-surface (never surface-card as modal ui.content)' },
471
+ ],
472
+ forbidden: [
473
+ 'Raw CSS variables in templates: text-[var(--*)], bg-[var(--*)], border-[var(--*)].',
474
+ 'Tailwind palette accents: from-cyan-*, text-violet-*, bg-green-*, bg-emerald-*, text-gray-*, bg-slate-*, dark:bg-zinc-950.',
475
+ 'Concrete palette substitution (color="violet"/"cyan"/..., from-cyan-*, text-violet-*, bg-green-*, bg-emerald-*, dark:bg-zinc-950).',
476
+ 'Hardcoded hex colors or inline style="color:#..." for theme-driven surfaces.',
477
+ 'Reading --md-* / --st-* / --badge-* base variables directly from extension templates.',
478
+ ],
479
+ allowedShortUtilities: [
480
+ 'Tailwind v4 short utilities ARE canonical and preferred: bg-primary, text-primary, border-primary, ring-primary, bg-success, text-error, bg-warning, text-info, bg-secondary.',
481
+ 'Opacity modifiers work natively via v4 color-mix: bg-primary/10, ring-primary/20, text-primary/70, bg-success/15.',
482
+ 'Use eapp-* classes only for intent surfaces with no Tailwind equivalent (eapp-primary-surface/solid/soft/subtle, eapp-surface-card/muted/flat/hover, eapp-divider/divide-y, eapp-radius-*, eapp-modal-surface).',
483
+ ],
484
+ chooseByIntent: [
485
+ 'Normal accent / active tab / progress / primary CTA -> primary (eapp-primary-* or color="primary").',
486
+ 'True semantic state -> status (eapp-status-* or color="success|warning|error|info").',
487
+ 'Large ordinary surface -> eapp-surface-*; put a small status badge inside.',
488
+ 'Whole block is active identity -> eapp-primary-surface (+hover), subtle only.',
489
+ ],
490
+ };
491
+ }
492
+ function parseJsonObjectArg(name, value, fallback = {}) {
493
+ if (value === undefined || value === null || value === '')
494
+ return fallback;
495
+ const parsed = typeof value === 'string' ? JSON.parse(value) : value;
496
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
497
+ throw new Error(`${name} must be a JSON object.`);
498
+ }
499
+ return parsed;
500
+ }
501
+ function parseJsonArrayArg(name, value, fallback = []) {
502
+ if (value === undefined || value === null || value === '')
503
+ return fallback;
504
+ const parsed = typeof value === 'string' ? JSON.parse(value) : value;
505
+ if (!Array.isArray(parsed)) {
506
+ throw new Error(`${name} must be a JSON array.`);
507
+ }
508
+ return parsed;
509
+ }
510
+ function filterQuery(filter) {
511
+ return encodeURIComponent(JSON.stringify(filter));
512
+ }
513
+ async function reloadBestEffort(apiUrl, path) {
514
+ try {
515
+ const result = await fetchAPI(apiUrl, path, { method: 'POST' });
516
+ return { attempted: true, succeeded: true, result };
517
+ }
518
+ catch (error) {
519
+ return { attempted: true, succeeded: false, error: error?.message || String(error) };
520
+ }
521
+ }
522
+ function naturalPartialReload(reason) {
523
+ return { attempted: false, succeeded: true, reason };
524
+ }
525
+ async function validateDynamicScript(apiUrl, sourceCode, scriptLanguage = 'javascript') {
526
+ const result = await fetchAPI(apiUrl, '/admin/script/validate', {
527
+ method: 'POST',
528
+ body: JSON.stringify({ sourceCode, scriptLanguage }),
529
+ });
530
+ if (result?.valid === false || result?.success === false) {
531
+ throw new Error(result?.error?.message || 'Dynamic script validation failed.');
532
+ }
533
+ return {
534
+ valid: true,
535
+ scriptLanguage,
536
+ compiledLength: typeof result?.data?.compiledCode === 'string' ? result.data.compiledCode.length : undefined,
537
+ };
538
+ }
539
+ function readTemplateBlocks(code) {
540
+ const blocks = [];
541
+ const lower = String(code || '').toLowerCase();
542
+ let index = 0;
543
+ while (index < lower.length) {
544
+ const openStart = lower.indexOf('<template', index);
545
+ if (openStart === -1)
546
+ break;
547
+ const boundary = lower[openStart + '<template'.length];
548
+ if (boundary && !/\s|>/.test(boundary)) {
549
+ index = openStart + 1;
550
+ continue;
551
+ }
552
+ const openEnd = lower.indexOf('>', openStart + '<template'.length);
553
+ if (openEnd === -1)
554
+ break;
555
+ const closeStart = lower.indexOf('</template', openEnd + 1);
556
+ if (closeStart === -1)
557
+ break;
558
+ blocks.push(String(code).slice(openEnd + 1, closeStart));
559
+ index = closeStart + '</template'.length;
560
+ }
561
+ return blocks;
562
+ }
563
+ function readTemplateTagName(template, start) {
564
+ const next = template[start + 1];
565
+ if (!next || next === '!' || next === '?')
566
+ return null;
567
+ let index = start + (next === '/' ? 2 : 1);
568
+ while (/\s/.test(template[index] || ''))
569
+ index += 1;
570
+ const nameStart = index;
571
+ while (/[\w.-]/.test(template[index] || ''))
572
+ index += 1;
573
+ return index > nameStart ? template.slice(nameStart, index) : null;
574
+ }
575
+ export function validateExtensionCodeLocally(code) {
576
+ if (/\bresolveComponent\s*\(/.test(String(code || ''))) {
577
+ throw new Error('Invalid extension component resolution: do not call resolveComponent() in Enfyra extensions. Use auto-injected components such as <UButton> directly in the template so the app/compiler resolves them correctly.');
578
+ }
579
+ const violations = [];
580
+ for (const template of readTemplateBlocks(code)) {
581
+ let index = 0;
582
+ while (index < template.length) {
583
+ const tagStart = template.indexOf('<', index);
584
+ if (tagStart === -1)
585
+ break;
586
+ const tagName = readTemplateTagName(template, tagStart);
587
+ if (tagName && tagName === tagName.toLowerCase() && !tagName.includes('-')) {
588
+ const expected = AUTO_INJECTED_EXTENSION_COMPONENT_BY_LOWERCASE.get(tagName);
589
+ if (expected)
590
+ violations.push({ tag: tagName, expected });
591
+ }
592
+ index = tagStart + 1;
593
+ }
594
+ }
595
+ if (violations.length) {
596
+ const first = violations[0];
597
+ throw new Error(`Invalid extension component casing: use <${first.expected}> instead of <${first.tag}>. Enfyra/Nuxt UI auto-injected components must keep PascalCase in extension templates; lowercase tags render as unresolved DOM elements.`);
598
+ }
599
+ return { componentCasing: 'passed' };
600
+ }
601
+ export async function validateExtensionCode(apiUrl, code, name) {
602
+ const localChecks = validateExtensionCodeLocally(code);
603
+ const result = await fetchAPI(apiUrl, '/enfyra_extension/preview', {
604
+ method: 'POST',
605
+ body: JSON.stringify({ code, name }),
606
+ });
607
+ if (result?.success === false) {
608
+ throw new Error(result?.error?.message || 'Extension validation failed.');
609
+ }
610
+ return {
611
+ valid: true,
612
+ localChecks,
613
+ extensionId: result?.extensionId || name || null,
614
+ compiledLength: typeof result?.compiledCode === 'string' ? result.compiledCode.length : undefined,
615
+ };
616
+ }
617
+ async function updateExtensionCode(apiUrl, { id, name, code, description, isEnabled, version, globalRulesAckKey, extensionKnowledgeAckKey, }) {
618
+ assertGlobalRulesAck(globalRulesAckKey);
619
+ assertExtensionKnowledgeAck(extensionKnowledgeAckKey);
620
+ if (!id && !name)
621
+ throw new Error('Provide id or name to update an existing extension.');
622
+ const existing = id
623
+ ? await findRecord(apiUrl, 'enfyra_extension', { id: { _eq: id } }, 'id,_id,name,type,menu.id')
624
+ : await findRecord(apiUrl, 'enfyra_extension', { name: { _eq: name } }, 'id,_id,name,type,menu.id');
625
+ if (!existing)
626
+ throw new Error(`Extension not found: ${id || name}`);
627
+ const extensionId = getId(existing);
628
+ const validation = await validateExtensionCode(apiUrl, code, name || existing.name || extensionId);
629
+ const body = {
630
+ code,
631
+ ...(description !== undefined ? { description } : {}),
632
+ ...(isEnabled !== undefined ? { isEnabled } : {}),
633
+ ...(version !== undefined ? { version } : {}),
634
+ };
635
+ const result = await fetchAPI(apiUrl, `/enfyra_extension/${encodeURIComponent(String(extensionId))}`, {
636
+ method: 'PATCH',
637
+ body: JSON.stringify(body),
638
+ });
639
+ return {
640
+ action: 'extension_code_updated',
641
+ id: extensionId,
642
+ name: existing.name || name || null,
643
+ type: existing.type || null,
644
+ result,
645
+ validation,
646
+ };
647
+ }
648
+ function normalizeMetadataTables(metadata) {
649
+ const tables = metadata?.data?.tables || metadata?.tables || metadata?.data || [];
650
+ return Array.isArray(tables) ? tables : Object.values(tables || {});
651
+ }
652
+ async function getMetadataTables(apiUrl) {
653
+ return normalizeMetadataTables(await fetchAPI(apiUrl, '/metadata'));
654
+ }
655
+ function resolveTable(tables, tableName) {
656
+ const table = tables.find((item) => item?.name === tableName || item?.alias === tableName || sameId(getId(item), tableName));
657
+ if (!table)
658
+ throw new Error(`Table not found: ${tableName}`);
659
+ return table;
660
+ }
661
+ function resolveColumn(table, columnName) {
662
+ const column = (table.columns || []).find((item) => item?.name === columnName || sameId(getId(item), columnName));
663
+ if (!column)
664
+ throw new Error(`Column not found: ${table.name}.${columnName}`);
665
+ return column;
666
+ }
667
+ function resolveRelation(table, relationName) {
668
+ const relation = (table.relations || []).find((item) => item?.propertyName === relationName || item?.name === relationName || sameId(getId(item), relationName));
669
+ if (!relation)
670
+ throw new Error(`Relation not found: ${table.name}.${relationName}`);
671
+ return relation;
672
+ }
673
+ async function findRecord(apiUrl, tableName, filter, fields = '*') {
674
+ const result = await fetchAPI(apiUrl, `/${tableName}?filter=${filterQuery(filter)}&limit=1&fields=${encodeURIComponent(fields)}`);
675
+ return unwrapData(result)[0] || null;
676
+ }
677
+ async function fetchRecords(apiUrl, tableName, filter, fields = '*', limit = 1000) {
678
+ const result = await fetchAPI(apiUrl, `/${tableName}?filter=${filterQuery(filter)}&limit=${limit}&fields=${encodeURIComponent(fields)}`);
679
+ return unwrapData(result);
680
+ }
681
+ async function createOrPatch(apiUrl, tableName, existing, body) {
682
+ if (existing) {
683
+ const result = await fetchAPI(apiUrl, `/${tableName}/${encodeURIComponent(String(getId(existing)))}`, {
684
+ method: 'PATCH',
685
+ body: JSON.stringify(body),
686
+ });
687
+ return { action: 'updated', result, id: getId(firstDataRecord(result)) || getId(existing) };
688
+ }
689
+ const result = await fetchAPI(apiUrl, `/${tableName}`, {
690
+ method: 'POST',
691
+ body: JSON.stringify(body),
692
+ });
693
+ return { action: 'created', result, id: getId(firstDataRecord(result)) };
694
+ }
695
+ async function resolveRole(apiUrl, { roleId, roleName }) {
696
+ if (roleId && roleName)
697
+ throw new Error('Provide roleId or roleName, not both.');
698
+ if (!roleId && !roleName)
699
+ return null;
700
+ if (roleId)
701
+ return { id: roleId, name: null };
702
+ const role = await findRecord(apiUrl, 'enfyra_role', { name: { _eq: roleName } }, 'id,_id,name');
703
+ if (!role)
704
+ throw new Error(`Role not found: ${roleName}`);
705
+ return { id: getId(role), name: role.name };
706
+ }
707
+ function assertOneScope({ roleId, roleName, allowedUserIds }) {
708
+ if (!roleId && !roleName && (!allowedUserIds || allowedUserIds.length === 0)) {
709
+ throw new Error('Provide roleId, roleName, or allowedUserIds.');
710
+ }
711
+ }
712
+ function normalizeFlowStepBody(step, flowId) {
713
+ const body = {
714
+ key: step.key,
715
+ type: step.type,
716
+ stepOrder: step.order ?? 0,
717
+ config: step.config ?? {},
718
+ timeout: step.timeout,
719
+ isEnabled: step.isEnabled ?? true,
720
+ flow: { id: flowId },
721
+ };
722
+ if (step.sourceCode !== undefined)
723
+ body.sourceCode = step.sourceCode;
724
+ if (step.scriptLanguage !== undefined)
725
+ body.scriptLanguage = step.scriptLanguage;
726
+ return Object.fromEntries(Object.entries(body).filter(([, value]) => value !== undefined));
727
+ }
728
+ async function ensureMenu(apiUrl, { label, path, icon, type = 'Menu', order = 0, permission, description, isEnabled = true, globalRulesAckKey, }) {
729
+ assertGlobalRulesAck(globalRulesAckKey);
730
+ const normalizedPath = path ? normalizeRestPath(path) : undefined;
731
+ const existing = normalizedPath
732
+ ? await findRecord(apiUrl, 'enfyra_menu', { path: { _eq: normalizedPath } }, 'id,_id,path,label')
733
+ : await findRecord(apiUrl, 'enfyra_menu', { label: { _eq: label } }, 'id,_id,path,label');
734
+ const operation = await createOrPatch(apiUrl, 'enfyra_menu', existing, {
735
+ label,
736
+ ...(normalizedPath ? { path: normalizedPath } : {}),
737
+ icon,
738
+ type,
739
+ order,
740
+ permission: parseJsonObjectArg('permission', permission, undefined),
741
+ description,
742
+ isEnabled,
743
+ });
744
+ return {
745
+ id: operation.id || getId(existing),
746
+ path: normalizedPath || existing?.path || null,
747
+ label,
748
+ action: operation.action,
749
+ operation,
750
+ };
751
+ }
752
+ async function reorderMenus(apiUrl, { updates, globalRulesAckKey }) {
753
+ assertGlobalRulesAck(globalRulesAckKey);
754
+ const seen = new Set();
755
+ const normalizedUpdates = updates.map((item, index) => {
756
+ const id = item?.id;
757
+ if (id === null || id === undefined || String(id).trim() === '') {
758
+ throw new Error(`updates[${index}].id is required.`);
759
+ }
760
+ const key = String(id);
761
+ if (seen.has(key))
762
+ throw new Error(`Duplicate menu id in reorder payload: ${key}`);
763
+ seen.add(key);
764
+ const order = Number(item.order);
765
+ if (!Number.isInteger(order) || order < 0) {
766
+ throw new Error(`updates[${index}].order must be a non-negative integer.`);
767
+ }
768
+ const parent = item.parent === undefined || item.parent === null || String(item.parent).trim() === ''
769
+ ? null
770
+ : item.parent;
771
+ return { id, order, parent };
772
+ });
773
+ const result = await fetchAPI(apiUrl, '/admin/menu/reorder', {
774
+ method: 'POST',
775
+ body: JSON.stringify({ updates: normalizedUpdates }),
776
+ });
777
+ return {
778
+ action: 'menus_reordered',
779
+ updates: normalizedUpdates,
780
+ result,
781
+ reload: {
782
+ attempted: false,
783
+ succeeded: true,
784
+ reason: '/admin/menu/reorder persists order/parent updates and emits enfyra_menu cache invalidation.',
785
+ },
786
+ };
787
+ }
788
+ async function ensureExtension(apiUrl, { name, type, code, menuId, description, isEnabled = true, version = '1.0.0', globalRulesAckKey, extensionKnowledgeAckKey, }) {
789
+ assertGlobalRulesAck(globalRulesAckKey);
790
+ assertExtensionKnowledgeAck(extensionKnowledgeAckKey);
791
+ if (type === 'page' && !menuId) {
792
+ throw new Error('menuId is required for page extensions. Use ensure_menu first, then ensure_page_extension.');
793
+ }
794
+ if (type !== 'page' && menuId) {
795
+ throw new Error('menuId is only valid for page extensions.');
796
+ }
797
+ const validation = await validateExtensionCode(apiUrl, code, name);
798
+ const existing = await findRecord(apiUrl, 'enfyra_extension', { name: { _eq: name } }, 'id,_id,name,menu.id,type');
799
+ const operation = await createOrPatch(apiUrl, 'enfyra_extension', existing, {
800
+ name,
801
+ type,
802
+ code,
803
+ ...(menuId ? { menu: { id: menuId } } : {}),
804
+ description,
805
+ isEnabled,
806
+ version,
807
+ });
808
+ return {
809
+ id: operation.id || getId(existing),
810
+ name,
811
+ type,
812
+ action: operation.action,
813
+ operation,
814
+ validation,
815
+ };
816
+ }
817
+ async function ensureFlow(apiUrl, { name, triggerType = 'manual', triggerConfig, timeout, maxExecutions = 100, isEnabled = true, description, globalRulesAckKey, }) {
818
+ assertGlobalRulesAck(globalRulesAckKey);
819
+ const existing = await findRecord(apiUrl, 'enfyra_flow', { name: { _eq: name } }, 'id,_id,name');
820
+ const operation = await createOrPatch(apiUrl, 'enfyra_flow', existing, {
821
+ name,
822
+ triggerType,
823
+ triggerConfig: parseJsonObjectArg('triggerConfig', triggerConfig, {}),
824
+ timeout,
825
+ maxExecutions,
826
+ isEnabled,
827
+ description,
828
+ });
829
+ const reload = naturalPartialReload('Flow metadata writes trigger the server partial reload contract; there is no dedicated flow reload endpoint.');
830
+ return { action: 'flow_ensured', flow: { id: operation.id, name }, operation, reload };
831
+ }
832
+ async function ensureFlowStep(apiUrl, { flowName, flowId, key, type, order, config, sourceCode, scriptLanguage, timeout, isEnabled, globalRulesAckKey, knowledgeAckKey, }) {
833
+ assertGlobalRulesAck(globalRulesAckKey);
834
+ if (!flowName && !flowId)
835
+ throw new Error('Provide flowName or flowId.');
836
+ if (flowName && flowId)
837
+ throw new Error('Provide flowName or flowId, not both.');
838
+ const flow = flowId
839
+ ? await findRecord(apiUrl, 'enfyra_flow', { id: { _eq: flowId } }, 'id,_id,name')
840
+ : await findRecord(apiUrl, 'enfyra_flow', { name: { _eq: flowName } }, 'id,_id,name');
841
+ if (!flow)
842
+ throw new Error(`Flow not found: ${flowId || flowName}`);
843
+ const parsedConfig = parseJsonObjectArg('config', config, {});
844
+ assertDynamicCodeKnowledgeAckIf(Boolean(sourceCode && ['script', 'condition'].includes(type)), knowledgeAckKey);
845
+ const validation = sourceCode && ['script', 'condition'].includes(type)
846
+ ? await validateDynamicScript(apiUrl, sourceCode, scriptLanguage)
847
+ : { validated: false, reason: 'no script validation required' };
848
+ const existing = await findRecord(apiUrl, 'enfyra_flow_step', {
849
+ flow: { id: { _eq: getId(flow) } },
850
+ key: { _eq: key },
851
+ }, 'id,_id,key,flow.id');
852
+ const operation = await createOrPatch(apiUrl, 'enfyra_flow_step', existing, normalizeFlowStepBody({
853
+ key,
854
+ type,
855
+ order,
856
+ config: parsedConfig,
857
+ sourceCode,
858
+ scriptLanguage,
859
+ timeout,
860
+ isEnabled,
861
+ }, getId(flow)));
862
+ const reload = naturalPartialReload('Flow step writes trigger the server partial reload contract; there is no dedicated flow reload endpoint.');
863
+ return { action: 'flow_step_ensured', flow: { id: getId(flow), name: flow.name }, step: { id: operation.id, key, type }, validation, operation, reload };
864
+ }
865
+ const FLOW_STEP_TOOL_GUIDANCE = [
866
+ {
867
+ tool: 'ensure_query_flow_step',
868
+ type: 'query',
869
+ when: 'Read/list records from one table without custom branching or transformation.',
870
+ config: { table: 'table_name', filter: {}, fields: 'id,name', limit: 20, sort: '-createdAt' },
871
+ },
872
+ {
873
+ tool: 'ensure_create_flow_step',
874
+ type: 'create',
875
+ when: 'Create one record in one table from static config or previous flow values.',
876
+ config: { table: 'table_name', data: { field: 'value' } },
877
+ },
878
+ {
879
+ tool: 'ensure_update_flow_step',
880
+ type: 'update',
881
+ when: 'Update one known record by id.',
882
+ config: { table: 'table_name', id: '@FLOW_PAYLOAD.id', data: { field: 'value' } },
883
+ },
884
+ {
885
+ tool: 'ensure_delete_flow_step',
886
+ type: 'delete',
887
+ when: 'Delete one known record by id.',
888
+ config: { table: 'table_name', id: '@FLOW_PAYLOAD.id' },
889
+ },
890
+ {
891
+ tool: 'ensure_http_flow_step',
892
+ type: 'http',
893
+ when: 'Call an external HTTP API.',
894
+ config: { url: 'https://example.com/api', method: 'POST', headers: {}, body: {}, timeout: 10000 },
895
+ },
896
+ {
897
+ tool: 'ensure_condition_flow_step',
898
+ type: 'condition',
899
+ when: 'Branch into true/false child steps based on JavaScript truthiness.',
900
+ sourceCode: 'return Boolean(@FLOW_PAYLOAD.enabled)',
901
+ },
902
+ {
903
+ tool: 'ensure_sleep_flow_step',
904
+ type: 'sleep',
905
+ when: 'Wait for a short bounded delay.',
906
+ config: { ms: 1000 },
907
+ },
908
+ {
909
+ tool: 'ensure_trigger_flow_step',
910
+ type: 'trigger_flow',
911
+ when: 'Trigger another flow as a child/orchestration step.',
912
+ config: { flowName: 'child-flow', payload: {} },
913
+ },
914
+ {
915
+ tool: 'ensure_log_flow_step',
916
+ type: 'log',
917
+ when: 'Record a small execution note for diagnostics.',
918
+ config: { message: 'Reached step_name' },
919
+ },
920
+ {
921
+ tool: 'ensure_script_flow_step',
922
+ type: 'script',
923
+ when: 'Use only when logic needs loops, multiple tables, crypto, package calls, non-trivial transforms, or runtime checks not covered by the atomic step tools.',
924
+ sourceCode: 'return { ok: true }',
925
+ },
926
+ ];
927
+ function chooseFlowStepTool(intent) {
928
+ const text = String(intent || '').toLowerCase();
929
+ const hasAny = (patterns) => patterns.some((pattern) => pattern.test(text));
930
+ if (hasAny([/\bif\b/, /\belse\b/, /\bbranch\b/, /\bcondition\b/, /\bwhen\b/, /\bcheck\b/, /nếu/, /điều kiện/]))
931
+ return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'condition');
932
+ if (hasAny([/\bhttp\b/, /\bapi\b/, /\bwebhook\b/, /\bfetch\b/, /\brequest\b/, /\bpost\b/, /\bget\b/, /\bcall\b/, /gọi api/]))
933
+ return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'http');
934
+ if (hasAny([/\bsleep\b/, /\bwait\b/, /\bdelay\b/, /\bpause\b/, /chờ/, /đợi/]))
935
+ return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'sleep');
936
+ if (hasAny([/\btrigger\b/, /\bchild flow\b/, /\banother flow\b/, /\bsubflow\b/, /flow khác/]))
937
+ return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'trigger_flow');
938
+ if (hasAny([/\bdelete\b/, /\bremove\b/, /\bdestroy\b/, /xóa/, /xoá/]))
939
+ return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'delete');
940
+ if (hasAny([/\bupdate\b/, /\bpatch\b/, /\bset\b/, /\bmark\b/, /\bchange\b/, /cập nhật/, /đánh dấu/]))
941
+ return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'update');
942
+ if (hasAny([/\bcreate\b/, /\binsert\b/, /\badd\b/, /\bstore\b/, /\bsave\b/, /tạo/, /thêm/, /lưu/]))
943
+ return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'create');
944
+ if (hasAny([/\blog\b/, /\bdebug\b/, /\btrace\b/, /ghi log/]))
945
+ return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'log');
946
+ if (hasAny([/\bquery\b/, /\bfind\b/, /\blist\b/, /\bread\b/, /\bload\b/, /\bcount\b/, /\bsearch\b/, /đọc/, /tìm/, /liệt kê/]))
947
+ return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'query');
948
+ return FLOW_STEP_TOOL_GUIDANCE.find((item) => item.type === 'script');
949
+ }
950
+ function normalizeEndpointAccess(anonymousAccess, makePublic) {
951
+ if (makePublic !== undefined)
952
+ return makePublic ? 'public' : 'private';
953
+ return anonymousAccess || 'private';
954
+ }
955
+ function sourceMatches(existingHandler, sourceCode, scriptLanguage, timeout) {
956
+ if (!existingHandler)
957
+ return false;
958
+ if (String(existingHandler.sourceCode ?? '') !== String(sourceCode ?? ''))
959
+ return false;
960
+ if (scriptLanguage && String(existingHandler.scriptLanguage || 'javascript') !== String(scriptLanguage))
961
+ return false;
962
+ if (timeout !== undefined && Number(existingHandler.timeout) !== Number(timeout))
963
+ return false;
964
+ return true;
965
+ }
966
+ function extensionMatches(existingExtension, opts, menuId) {
967
+ if (!existingExtension)
968
+ return false;
969
+ if (String(existingExtension.type || '') !== String(opts.type || 'page'))
970
+ return false;
971
+ if (String(existingExtension.code ?? '') !== String(opts.code ?? ''))
972
+ return false;
973
+ if (opts.description !== undefined && String(existingExtension.description || '') !== String(opts.description || ''))
974
+ return false;
975
+ if (opts.isEnabled !== undefined && Boolean(existingExtension.isEnabled) !== Boolean(opts.isEnabled))
976
+ return false;
977
+ if (opts.version !== undefined && String(existingExtension.version || '') !== String(opts.version))
978
+ return false;
979
+ if ((opts.type || 'page') === 'page' && menuId && String(refId(existingExtension.menu)) !== String(menuId))
980
+ return false;
981
+ if ((opts.type || 'page') !== 'page' && refId(existingExtension.menu))
982
+ return false;
983
+ return true;
984
+ }
985
+ function step(status, id, title, detail = {}) {
986
+ return { id, title, status, ...detail };
987
+ }
988
+ async function resolveApiEndpointWorkflowState(apiUrl, opts) {
989
+ const normalizedPath = normalizeRestPath(opts.path);
990
+ const methodName = normalizeMethodName(opts.method);
991
+ const access = normalizeEndpointAccess(opts.anonymousAccess, opts.public);
992
+ const { methodMap, methodIdNameMap } = await getMethodContext(apiUrl);
993
+ const methodId = methodMap[methodName];
994
+ if (!methodId)
995
+ throw new Error(`Unknown method "${methodName}". Valid methods: ${Object.keys(methodMap).sort().join(', ')}`);
996
+ const [routes, scriptValidation] = await Promise.all([
997
+ fetchAll(apiUrl, '/enfyra_route?limit=1000&fields=id,_id,path,isEnabled,description,availableMethods.*,publicMethods.*,mainTable.name'),
998
+ validateScriptSourceIfPresent(fetchAPI, apiUrl, 'enfyra_route_handler', {
999
+ sourceCode: opts.sourceCode,
1000
+ scriptLanguage: opts.scriptLanguage || 'javascript',
1001
+ }),
1002
+ ]);
1003
+ const route = routes.find((item) => item.path === normalizedPath) || null;
1004
+ const routeId = getId(route);
1005
+ const availableMethods = methodNamesFromRecords(route?.availableMethods || [], methodIdNameMap);
1006
+ const publicMethods = methodNamesFromRecords(route?.publicMethods || [], methodIdNameMap);
1007
+ const methodAvailable = availableMethods.includes(methodName);
1008
+ const routeNeedsUpdate = !!route && (route.isEnabled === false
1009
+ || !methodAvailable
1010
+ || (access === 'public' && !publicMethods.includes(methodName))
1011
+ || (access === 'private' && publicMethods.includes(methodName))
1012
+ || (opts.description !== undefined && route.description !== opts.description));
1013
+ const handler = route ? await findHandler(apiUrl, routeId, methodId) : null;
1014
+ const handlerMatches = sourceMatches(handler, opts.sourceCode, opts.scriptLanguage || 'javascript', opts.timeout);
1015
+ const handlerNeedsOverwrite = !!handler && !handlerMatches;
1016
+ let permission = null;
1017
+ let role = null;
1018
+ let permissionMethods = [];
1019
+ let permissionMissingMethods = [];
1020
+ if (opts.roleName || opts.roleId || opts.allowedUserIds?.length) {
1021
+ if (access === 'public') {
1022
+ permissionMissingMethods = [];
1023
+ }
1024
+ else if (!route) {
1025
+ permissionMissingMethods = [methodName];
1026
+ }
1027
+ else {
1028
+ const permissions = await fetchRecords(apiUrl, 'enfyra_route_permission', {
1029
+ route: { id: { _eq: routeId } },
1030
+ }, 'id,_id,route.id,role.id,role.name,allowedUsers.id,methods.*', 1000);
1031
+ role = await resolveRole(apiUrl, { roleId: opts.roleId, roleName: opts.roleName });
1032
+ const allowedUserIds = (opts.allowedUserIds || []).map(String).sort();
1033
+ permission = permissions.find((candidate) => {
1034
+ const candidateRoleId = refId(candidate.role);
1035
+ const candidateUserIds = (candidate.allowedUsers || []).map((item) => String(refId(item))).sort();
1036
+ if (role && String(candidateRoleId) !== String(role.id))
1037
+ return false;
1038
+ if (!role && candidateRoleId !== null && candidateRoleId !== undefined)
1039
+ return false;
1040
+ return allowedUserIds.length === candidateUserIds.length
1041
+ && allowedUserIds.every((value, index) => value === candidateUserIds[index]);
1042
+ }) || null;
1043
+ permissionMethods = methodNamesFromRecords(permission?.methods || [], methodIdNameMap);
1044
+ permissionMissingMethods = permissionMethods.includes(methodName) ? [] : [methodName];
1045
+ }
1046
+ }
1047
+ const smokeTestRequested = opts.smokeTestQuery !== undefined || opts.smokeTestBody !== undefined;
1048
+ const steps = [
1049
+ route
1050
+ ? step(routeNeedsUpdate ? 'pending' : 'completed', 'sync_route', 'Ensure route method and public access', {
1051
+ routeId,
1052
+ availableMethods,
1053
+ publicMethods,
1054
+ desiredAccess: access,
1055
+ })
1056
+ : step('pending', 'create_route', 'Create custom route', {
1057
+ desiredAccess: access,
1058
+ }),
1059
+ handler
1060
+ ? step(handlerNeedsOverwrite ? (opts.overwrite ? 'pending' : 'blocked') : 'completed', 'save_handler', 'Create or update route handler', {
1061
+ handlerId: getId(handler),
1062
+ reason: handlerNeedsOverwrite && !opts.overwrite ? 'Existing handler differs. Re-run with overwrite=true to update it.' : undefined,
1063
+ })
1064
+ : step(route && methodAvailable ? 'pending' : 'waiting', 'save_handler', 'Create route handler', {
1065
+ reason: !route ? 'Route must exist first.' : methodAvailable ? undefined : 'Route method must be available first.',
1066
+ }),
1067
+ ];
1068
+ if (opts.roleName || opts.roleId || opts.allowedUserIds?.length) {
1069
+ steps.push(access === 'public'
1070
+ ? step('skipped', 'ensure_route_access', 'Ensure authenticated route access', {
1071
+ reason: 'Method is public, so route permission is not required for anonymous access.',
1072
+ })
1073
+ : step(permissionMissingMethods.length ? (route ? 'pending' : 'waiting') : 'completed', 'ensure_route_access', 'Ensure authenticated route access', {
1074
+ permissionId: getId(permission),
1075
+ role,
1076
+ allowedUserIds: opts.allowedUserIds || [],
1077
+ methods: permissionMethods,
1078
+ missingMethods: permissionMissingMethods,
1079
+ }));
1080
+ }
1081
+ if (smokeTestRequested) {
1082
+ const blockers = steps.filter((item) => ['pending', 'waiting', 'blocked'].includes(item.status));
1083
+ steps.push(step(blockers.length ? 'waiting' : 'pending', 'smoke_test', 'Smoke-test the endpoint', {
1084
+ reason: blockers.length ? 'Endpoint must be ready before smoke test.' : undefined,
1085
+ }));
1086
+ }
1087
+ const firstRunnable = steps.find((item) => item.status === 'pending') || null;
1088
+ const blocked = steps.find((item) => item.status === 'blocked') || null;
1089
+ const nextSteps = blocked
1090
+ ? [{ tool: 'api_endpoint_workflow', input: { path: normalizedPath, method: methodName, overwrite: true }, reason: blocked.reason }]
1091
+ : firstRunnable
1092
+ ? [{
1093
+ tool: 'api_endpoint_workflow',
1094
+ input: { path: normalizedPath, method: methodName, apply: true },
1095
+ stepId: firstRunnable.id,
1096
+ requiresKnowledgeAck: firstRunnable.id === 'save_handler' ? 'dynamicCodeAckKey from get_enfyra_required_knowledge' : undefined,
1097
+ }]
1098
+ : [];
1099
+ return {
1100
+ endpoint: {
1101
+ path: normalizedPath,
1102
+ method: methodName,
1103
+ anonymousAccess: access,
1104
+ routeId,
1105
+ handlerId: getId(handler),
1106
+ },
1107
+ methodId,
1108
+ methodMap,
1109
+ methodIdNameMap,
1110
+ route,
1111
+ handler,
1112
+ role,
1113
+ scriptValidation,
1114
+ steps,
1115
+ firstRunnable,
1116
+ blocked,
1117
+ nextSteps,
1118
+ };
1119
+ }
1120
+ async function applyApiEndpointWorkflowStep(apiUrl, state, opts, stepId) {
1121
+ const selectedStep = stepId
1122
+ ? state.steps.find((item) => item.id === stepId)
1123
+ : state.firstRunnable;
1124
+ if (!selectedStep)
1125
+ return { action: 'noop', reason: 'No runnable step remains.' };
1126
+ if (selectedStep.status !== 'pending') {
1127
+ throw new Error(`Step "${selectedStep.id}" is ${selectedStep.status}, not pending.`);
1128
+ }
1129
+ const endpoint = state.endpoint;
1130
+ if (selectedStep.id === 'create_route') {
1131
+ const result = await fetchAPI(apiUrl, '/enfyra_route', {
1132
+ method: 'POST',
1133
+ body: JSON.stringify({
1134
+ path: endpoint.path,
1135
+ description: opts.description,
1136
+ isEnabled: true,
1137
+ availableMethods: [{ id: state.methodId }],
1138
+ publicMethods: endpoint.anonymousAccess === 'public' ? [{ id: state.methodId }] : [],
1139
+ }),
1140
+ });
1141
+ return { action: 'route_created', result, routeReload: await reloadRoutes(apiUrl) };
1142
+ }
1143
+ if (selectedStep.id === 'sync_route') {
1144
+ const availableMethods = methodNamesFromRecords(state.route.availableMethods, state.methodIdNameMap);
1145
+ const publicMethods = methodNamesFromRecords(state.route.publicMethods, state.methodIdNameMap);
1146
+ const finalAvailable = uniqueMethodNames([...availableMethods, endpoint.method]);
1147
+ const finalPublic = endpoint.anonymousAccess === 'public'
1148
+ ? uniqueMethodNames([...publicMethods, endpoint.method])
1149
+ : publicMethods.filter((method) => method !== endpoint.method);
1150
+ const result = await fetchAPI(apiUrl, `/enfyra_route/${encodeURIComponent(String(endpoint.routeId))}`, {
1151
+ method: 'PATCH',
1152
+ body: JSON.stringify({
1153
+ isEnabled: true,
1154
+ availableMethods: resolveMethodRefs(state.methodMap, finalAvailable),
1155
+ publicMethods: resolveMethodRefs(state.methodMap, finalPublic),
1156
+ ...(opts.description !== undefined ? { description: opts.description } : {}),
1157
+ }),
1158
+ });
1159
+ return { action: 'route_synced', result, routeReload: await reloadRoutes(apiUrl) };
1160
+ }
1161
+ if (selectedStep.id === 'save_handler') {
1162
+ assertDynamicCodeKnowledgeAck(opts.knowledgeAckKey);
1163
+ if (!endpoint.routeId)
1164
+ throw new Error('Route must exist before saving handler.');
1165
+ const body = {
1166
+ sourceCode: opts.sourceCode,
1167
+ scriptLanguage: opts.scriptLanguage || 'javascript',
1168
+ ...(opts.timeout !== undefined ? { timeout: opts.timeout } : {}),
1169
+ };
1170
+ if (state.handler) {
1171
+ const result = await fetchAPI(apiUrl, `/enfyra_route_handler/${encodeURIComponent(String(getId(state.handler)))}`, {
1172
+ method: 'PATCH',
1173
+ body: JSON.stringify(body),
1174
+ });
1175
+ return { action: 'handler_updated', result, routeReload: await reloadRoutes(apiUrl) };
1176
+ }
1177
+ const result = await fetchAPI(apiUrl, '/enfyra_route_handler', {
1178
+ method: 'POST',
1179
+ body: JSON.stringify({
1180
+ route: { id: endpoint.routeId },
1181
+ method: { id: state.methodId },
1182
+ ...body,
1183
+ }),
1184
+ });
1185
+ return { action: 'handler_created', result, routeReload: await reloadRoutes(apiUrl) };
1186
+ }
1187
+ if (selectedStep.id === 'ensure_route_access') {
1188
+ assertOneScope(opts);
1189
+ const role = state.role || await resolveRole(apiUrl, { roleId: opts.roleId, roleName: opts.roleName });
1190
+ const existing = state.steps.find((item) => item.id === 'ensure_route_access')?.permissionId
1191
+ ? await findRecord(apiUrl, 'enfyra_route_permission', { id: { _eq: state.steps.find((item) => item.id === 'ensure_route_access').permissionId } }, 'id,_id,methods.*')
1192
+ : null;
1193
+ const existingMethods = methodNamesFromRecords(existing?.methods || [], state.methodIdNameMap);
1194
+ const finalMethods = uniqueMethodNames([...existingMethods, endpoint.method]);
1195
+ const body = {
1196
+ isEnabled: true,
1197
+ description: opts.routePermissionDescription,
1198
+ methods: resolveMethodRefs(state.methodMap, finalMethods),
1199
+ ...(role ? { role: { id: role.id } } : {}),
1200
+ ...(opts.allowedUserIds?.length ? { allowedUsers: opts.allowedUserIds.map((id) => ({ id })) } : {}),
1201
+ };
1202
+ const result = existing
1203
+ ? await fetchAPI(apiUrl, `/enfyra_route_permission/${encodeURIComponent(String(getId(existing)))}`, {
1204
+ method: 'PATCH',
1205
+ body: JSON.stringify(body),
1206
+ })
1207
+ : await fetchAPI(apiUrl, '/enfyra_route_permission', {
1208
+ method: 'POST',
1209
+ body: JSON.stringify({
1210
+ route: { id: endpoint.routeId },
1211
+ ...body,
1212
+ }),
1213
+ });
1214
+ return { action: existing ? 'route_access_updated' : 'route_access_created', result, routeReload: await reloadRoutes(apiUrl) };
1215
+ }
1216
+ if (selectedStep.id === 'smoke_test') {
1217
+ const query = parseJsonObjectArg('smokeTestQuery', opts.smokeTestQuery, {});
1218
+ const queryParams = new URLSearchParams();
1219
+ for (const [key, value] of Object.entries(query)) {
1220
+ if (value !== undefined && value !== null)
1221
+ queryParams.set(key, String(value));
1222
+ }
1223
+ const body = opts.smokeTestBody === undefined ? undefined : parseJsonObjectArg('smokeTestBody', opts.smokeTestBody, {});
1224
+ const smokePath = `${endpoint.path}${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
1225
+ const result = await fetchAPI(apiUrl, smokePath, {
1226
+ method: endpoint.method,
1227
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
1228
+ });
1229
+ return { action: 'smoke_test_passed', result };
1230
+ }
1231
+ throw new Error(`Unsupported workflow step: ${selectedStep.id}`);
1232
+ }
1233
+ async function runApiEndpointWorkflow(apiUrl, opts) {
1234
+ let state = await resolveApiEndpointWorkflowState(apiUrl, opts);
1235
+ const operations = [];
1236
+ let completedEphemeralStepId = null;
1237
+ if (opts.apply || opts.applyAll) {
1238
+ assertGlobalRulesAck(opts.globalRulesAckKey);
1239
+ if (opts.applyAll && state.steps.some((item) => item.id === 'save_handler' && ['pending', 'waiting'].includes(item.status))) {
1240
+ assertDynamicCodeKnowledgeAck(opts.knowledgeAckKey);
1241
+ }
1242
+ const maxSteps = opts.applyAll ? 10 : 1;
1243
+ for (let i = 0; i < maxSteps; i += 1) {
1244
+ if (state.blocked || !state.firstRunnable)
1245
+ break;
1246
+ const operation = await applyApiEndpointWorkflowStep(apiUrl, state, opts, opts.stepId);
1247
+ operations.push(operation);
1248
+ if (state.firstRunnable.id === 'smoke_test') {
1249
+ completedEphemeralStepId = 'smoke_test';
1250
+ break;
1251
+ }
1252
+ if (!opts.applyAll)
1253
+ break;
1254
+ state = await resolveApiEndpointWorkflowState(apiUrl, opts);
1255
+ }
1256
+ }
1257
+ const latestState = operations.length ? await resolveApiEndpointWorkflowState(apiUrl, opts) : state;
1258
+ const latestSteps = completedEphemeralStepId
1259
+ ? latestState.steps.map((item) => (item.id === completedEphemeralStepId
1260
+ ? { ...item, status: 'completed', result: 'passed' }
1261
+ : item))
1262
+ : latestState.steps;
1263
+ const nextSteps = completedEphemeralStepId
1264
+ ? latestState.nextSteps.filter((item) => item.stepId !== completedEphemeralStepId)
1265
+ : latestState.nextSteps;
1266
+ return {
1267
+ action: operations.length ? 'api_endpoint_workflow_advanced' : 'api_endpoint_workflow_planned',
1268
+ endpoint: latestState.endpoint,
1269
+ scriptValidation: latestState.scriptValidation,
1270
+ steps: latestSteps,
1271
+ operations,
1272
+ complete: latestSteps.every((item) => ['completed', 'skipped'].includes(item.status)),
1273
+ nextSteps,
1274
+ cleanupHints: latestState.endpoint.routeId
1275
+ ? [
1276
+ `Use delete_route({ routeId: ${JSON.stringify(latestState.endpoint.routeId)}, confirm: false }) to preview route-owned handlers, hooks, guards, and permissions before cleanup.`,
1277
+ `Then call delete_route({ routeId: ${JSON.stringify(latestState.endpoint.routeId)}, expectedPath: ${JSON.stringify(latestState.endpoint.path)}, confirm: true }) when the route contract is no longer needed.`,
1278
+ ]
1279
+ : [],
1280
+ };
1281
+ }
1282
+ async function resolveExtensionWorkflowState(apiUrl, opts) {
1283
+ const type = opts.type || 'page';
1284
+ if (type === 'page' && opts.menuId && (opts.menuLabel || opts.menuPath)) {
1285
+ throw new Error('Provide menuId or menuLabel/menuPath for page extension workflow, not both.');
1286
+ }
1287
+ if (type !== 'page' && (opts.menuId || opts.menuLabel || opts.menuPath)) {
1288
+ throw new Error('Menu fields are only valid for page extensions.');
1289
+ }
1290
+ const validation = await validateExtensionCode(apiUrl, opts.code, opts.name);
1291
+ const existingExtension = await findRecord(apiUrl, 'enfyra_extension', { name: { _eq: opts.name } }, 'id,_id,name,type,menu.id,description,isEnabled,version,code');
1292
+ let menu = null;
1293
+ if (type === 'page' && opts.menuId) {
1294
+ menu = await findRecord(apiUrl, 'enfyra_menu', { id: { _eq: opts.menuId } }, 'id,_id,label,path,type,order,isEnabled');
1295
+ if (!menu)
1296
+ throw new Error(`Menu not found: ${opts.menuId}`);
1297
+ }
1298
+ else if (type === 'page' && (opts.menuPath || opts.menuLabel)) {
1299
+ const normalizedPath = opts.menuPath ? normalizeRestPath(opts.menuPath) : undefined;
1300
+ menu = normalizedPath
1301
+ ? await findRecord(apiUrl, 'enfyra_menu', { path: { _eq: normalizedPath } }, 'id,_id,label,path,type,order,isEnabled')
1302
+ : await findRecord(apiUrl, 'enfyra_menu', { label: { _eq: opts.menuLabel } }, 'id,_id,label,path,type,order,isEnabled');
1303
+ }
1304
+ const menuId = opts.menuId || getId(menu);
1305
+ const steps = [];
1306
+ steps.push(step('completed', 'validate_extension', 'Validate extension code', { validation }));
1307
+ if (type === 'page') {
1308
+ if (menuId) {
1309
+ const menuNeedsUpdate = Boolean(menu && ((opts.menuLabel !== undefined && menu.label !== opts.menuLabel)
1310
+ || (opts.menuPath !== undefined && menu.path !== normalizeRestPath(opts.menuPath))
1311
+ || (opts.menuType !== undefined && menu.type !== opts.menuType)
1312
+ || (opts.menuOrder !== undefined && Number(menu.order || 0) !== Number(opts.menuOrder))
1313
+ || (opts.menuIsEnabled !== undefined && Boolean(menu.isEnabled) !== Boolean(opts.menuIsEnabled))));
1314
+ steps.push(step(menuNeedsUpdate ? 'pending' : 'completed', 'ensure_menu', 'Ensure page menu', {
1315
+ menuId,
1316
+ menu: menu ? { id: getId(menu), label: menu.label, path: menu.path } : { id: menuId },
1317
+ }));
1318
+ }
1319
+ else if (opts.menuLabel) {
1320
+ steps.push(step('pending', 'ensure_menu', 'Create page menu', {
1321
+ reason: 'No existing menu matched; ensure_menu will create it.',
1322
+ }));
1323
+ }
1324
+ else {
1325
+ steps.push(step('blocked', 'ensure_menu', 'Create or select page menu', {
1326
+ reason: 'Page extensions require menuId or menuLabel. Provide menuId for an existing menu or menuLabel/menuPath to create/update one.',
1327
+ }));
1328
+ }
1329
+ }
1330
+ const effectiveMenuId = type === 'page' ? menuId : undefined;
1331
+ const saveStatus = steps.some((item) => ['blocked', 'waiting'].includes(item.status))
1332
+ ? 'waiting'
1333
+ : extensionMatches(existingExtension, { ...opts, type }, effectiveMenuId)
1334
+ ? 'completed'
1335
+ : 'pending';
1336
+ steps.push(step(saveStatus, 'save_extension', `Ensure ${type} extension`, {
1337
+ extensionId: getId(existingExtension),
1338
+ currentType: existingExtension?.type || null,
1339
+ desiredType: type,
1340
+ menuId: effectiveMenuId || null,
1341
+ reason: saveStatus === 'waiting' ? 'Menu must exist before saving page extension.' : undefined,
1342
+ }));
1343
+ const firstRunnable = steps.find((item) => item.status === 'pending') || null;
1344
+ const blocked = steps.find((item) => item.status === 'blocked') || null;
1345
+ return {
1346
+ extension: {
1347
+ name: opts.name,
1348
+ type,
1349
+ id: getId(existingExtension),
1350
+ menuId: effectiveMenuId || null,
1351
+ },
1352
+ validation,
1353
+ existingExtension: existingExtension ? {
1354
+ id: getId(existingExtension),
1355
+ name: existingExtension.name,
1356
+ type: existingExtension.type,
1357
+ menuId: refId(existingExtension.menu) || null,
1358
+ } : null,
1359
+ menu: menu ? { id: getId(menu), label: menu.label, path: menu.path } : null,
1360
+ steps,
1361
+ firstRunnable,
1362
+ blocked,
1363
+ nextSteps: blocked
1364
+ ? [{ tool: 'extension_workflow', input: { name: opts.name, type }, reason: blocked.reason }]
1365
+ : firstRunnable
1366
+ ? [{
1367
+ tool: 'extension_workflow',
1368
+ input: { name: opts.name, type, apply: true, stepId: firstRunnable.id },
1369
+ stepId: firstRunnable.id,
1370
+ requiresKnowledgeAck: 'globalRulesAckKey and extensionAckKey from get_enfyra_required_knowledge',
1371
+ }]
1372
+ : [],
1373
+ };
1374
+ }
1375
+ async function applyExtensionWorkflowStep(apiUrl, state, opts, stepId) {
1376
+ const selectedStep = stepId
1377
+ ? state.steps.find((item) => item.id === stepId)
1378
+ : state.firstRunnable;
1379
+ if (!selectedStep)
1380
+ return { action: 'noop', reason: 'No runnable step remains.' };
1381
+ if (selectedStep.status !== 'pending') {
1382
+ throw new Error(`Step "${selectedStep.id}" is ${selectedStep.status}, not pending.`);
1383
+ }
1384
+ const type = opts.type || 'page';
1385
+ if (selectedStep.id === 'ensure_menu') {
1386
+ if (type !== 'page')
1387
+ throw new Error('ensure_menu step is only valid for page extensions.');
1388
+ if (!opts.menuLabel && !opts.menuId)
1389
+ throw new Error('menuLabel or menuId is required for ensure_menu.');
1390
+ return {
1391
+ action: 'menu_ensured',
1392
+ menu: await ensureMenu(apiUrl, {
1393
+ label: opts.menuLabel || state.menu?.label || opts.name,
1394
+ path: opts.menuPath || state.menu?.path,
1395
+ icon: opts.menuIcon,
1396
+ type: opts.menuType,
1397
+ order: opts.menuOrder,
1398
+ permission: opts.menuPermission,
1399
+ description: opts.menuDescription,
1400
+ isEnabled: opts.menuIsEnabled,
1401
+ globalRulesAckKey: opts.globalRulesAckKey,
1402
+ }),
1403
+ };
1404
+ }
1405
+ if (selectedStep.id === 'save_extension') {
1406
+ let menuId = opts.menuId || state.extension.menuId;
1407
+ if (type === 'page' && !menuId) {
1408
+ const freshState = await resolveExtensionWorkflowState(apiUrl, opts);
1409
+ menuId = freshState.extension.menuId;
1410
+ }
1411
+ if (type === 'page' && !menuId)
1412
+ throw new Error('Page extension menu is missing. Apply ensure_menu first.');
1413
+ return {
1414
+ action: `${type}_extension_ensured`,
1415
+ extension: await ensureExtension(apiUrl, {
1416
+ name: opts.name,
1417
+ type,
1418
+ code: opts.code,
1419
+ menuId,
1420
+ description: opts.description,
1421
+ isEnabled: opts.isEnabled,
1422
+ version: opts.version,
1423
+ globalRulesAckKey: opts.globalRulesAckKey,
1424
+ extensionKnowledgeAckKey: opts.extensionKnowledgeAckKey,
1425
+ }),
1426
+ };
1427
+ }
1428
+ throw new Error(`Unsupported extension workflow step: ${selectedStep.id}`);
1429
+ }
1430
+ async function runExtensionWorkflow(apiUrl, opts) {
1431
+ let state = await resolveExtensionWorkflowState(apiUrl, opts);
1432
+ const operations = [];
1433
+ if (opts.apply || opts.applyAll) {
1434
+ assertGlobalRulesAck(opts.globalRulesAckKey);
1435
+ assertExtensionKnowledgeAck(opts.extensionKnowledgeAckKey);
1436
+ const maxSteps = opts.applyAll ? 5 : 1;
1437
+ for (let i = 0; i < maxSteps; i += 1) {
1438
+ if (state.blocked || !state.firstRunnable)
1439
+ break;
1440
+ operations.push(await applyExtensionWorkflowStep(apiUrl, state, opts, opts.stepId));
1441
+ if (!opts.applyAll)
1442
+ break;
1443
+ state = await resolveExtensionWorkflowState(apiUrl, opts);
1444
+ }
1445
+ }
1446
+ const latestState = operations.length ? await resolveExtensionWorkflowState(apiUrl, opts) : state;
1447
+ return {
1448
+ action: operations.length ? 'extension_workflow_advanced' : 'extension_workflow_planned',
1449
+ extension: latestState.extension,
1450
+ validation: latestState.validation,
1451
+ menu: latestState.menu,
1452
+ existingExtension: latestState.existingExtension,
1453
+ steps: latestState.steps,
1454
+ operations,
1455
+ complete: latestState.steps.every((item) => ['completed', 'skipped'].includes(item.status)),
1456
+ nextSteps: latestState.nextSteps,
1457
+ guidance: [
1458
+ 'Call get_extension_theme_contract before generating or reviewing extension UI.',
1459
+ 'For menu/account-panel notifications, use counts only when the signal source already owns an exact count; otherwise use a dot/chip for new attention.',
1460
+ 'Do not fetch destination domain lists solely to decorate the shell; destination pages own domain fetching after click.',
1461
+ ],
1462
+ };
1463
+ }
1464
+ export function registerPlatformOperationTools(server, ENFYRA_API_URL) {
1465
+ server.tool('validate_dynamic_script', [
1466
+ 'Validate Enfyra dynamic script code before saving it to any script-backed metadata record.',
1467
+ 'Use this before create/update of handlers, hooks, flow steps, websocket scripts, GraphQL scripts, or bootstrap scripts when the user is iterating on code.',
1468
+ 'This calls the same server compiler contract used by Enfyra, but does not save anything.',
1469
+ ].join(' '), {
1470
+ sourceCode: z.string().describe('Raw dynamic script sourceCode.'),
1471
+ scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language to validate.'),
1472
+ }, async ({ sourceCode, scriptLanguage }) => jsonText({
1473
+ action: 'dynamic_script_validated',
1474
+ validation: await validateDynamicScript(ENFYRA_API_URL, sourceCode, scriptLanguage),
1475
+ }));
1476
+ server.tool('validate_extension_code', [
1477
+ 'Validate Enfyra admin extension code before saving it to enfyra_extension.',
1478
+ 'Use this only when the user explicitly wants a validation-only check. For normal edits, use update_extension_code or ensure_*_extension so successful validation saves in the same tool call.',
1479
+ 'This calls /enfyra_extension/preview and does not save anything.',
1480
+ 'Call get_extension_theme_contract first when generating or reviewing UI.',
1481
+ ].join(' '), {
1482
+ code: z.string().describe('Vue SFC or compiled extension bundle code.'),
1483
+ name: z.string().optional().describe('Optional extension name/id used by the preview compiler.'),
1484
+ }, async ({ code, name }) => jsonText({
1485
+ action: 'extension_code_validated',
1486
+ validation: await validateExtensionCode(ENFYRA_API_URL, code, name),
1487
+ }));
1488
+ server.tool('update_extension_code', [
1489
+ 'Business operation: update an existing Enfyra admin extension code by id or name.',
1490
+ 'It runs local extension guards and /enfyra_extension/preview first, then saves the code in the same call only when validation succeeds.',
1491
+ 'Use this instead of validate_extension_code followed by update_record when editing an existing page/widget/global extension.',
1492
+ 'Call get_extension_theme_contract first when generating or reviewing UI.',
1493
+ ].join(' '), {
1494
+ id: z.union([z.string(), z.number()]).optional().describe('Existing extension id. Provide id or name.'),
1495
+ name: z.string().optional().describe('Existing extension unique name. Provide id or name.'),
1496
+ code: z.string().describe('Vue SFC extension code.'),
1497
+ description: z.string().optional().describe('Optional replacement extension description. Omit to preserve.'),
1498
+ isEnabled: z.boolean().optional().describe('Optional enabled state. Omit to preserve.'),
1499
+ version: z.string().optional().describe('Optional extension version. Omit to preserve.'),
1500
+ globalRulesAckKey: globalRulesAckParam(z),
1501
+ extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
1502
+ }, async (input) => jsonText(await updateExtensionCode(ENFYRA_API_URL, input)));
1503
+ server.tool('get_extension_theme_contract', 'Return the concise Enfyra admin extension UI/theme/security contract. Call before writing or reviewing extension UI.', {}, async () => jsonText(getExtensionThemeContract()));
1504
+ server.tool('get_theme_class_reference', [
1505
+ 'Return the authoritative Enfyra theme & color class reference: class -> CSS variable -> Nuxt UI semantic color -> intent.',
1506
+ 'Call this whenever you need the exact eapp-* class name or the Nuxt UI color mapping for shell, system page, or dynamic extension UI.',
1507
+ 'Source of truth: documents/app/theme-color-contract.md.',
1508
+ ].join(' '), {}, async () => jsonText(getThemeClassReference()));
1509
+ server.tool('extension_workflow', [
1510
+ 'Step-by-step workflow for creating or updating Enfyra admin page, global, or widget extensions.',
1511
+ 'Use this when an LLM is building extension UI, menu shell notifications, account panel entries, or page/menu wiring and should follow live nextSteps instead of guessing raw enfyra_extension mutations.',
1512
+ 'With apply=false it validates code, reads live menu/extension state, and returns pending steps.',
1513
+ 'With apply=true it applies exactly the next pending step. With applyAll=true it advances all currently safe pending steps.',
1514
+ 'Call get_extension_theme_contract before generating or reviewing UI.',
1515
+ ].join(' '), {
1516
+ name: z.string().describe('Extension unique name.'),
1517
+ type: z.enum(['page', 'global', 'widget']).optional().default('page').describe('Extension type. Page extensions need a menu. Global extensions are for shell-wide registration.'),
1518
+ code: z.string().describe('Vue SFC extension code.'),
1519
+ menuId: z.union([z.string(), z.number()]).optional().describe('Existing menu id for a page extension. Provide this or menuLabel/menuPath.'),
1520
+ menuLabel: z.string().optional().describe('Menu label to create or update for a page extension when menuId is not provided.'),
1521
+ menuPath: z.string().optional().describe('Admin app route path for the page menu, e.g. /cloud/support.'),
1522
+ menuIcon: z.string().optional().describe('Optional menu icon name.'),
1523
+ menuType: z.enum(['Menu', 'Dropdown Menu']).optional().describe('Menu type. Omit to preserve an existing menu value or use the platform default for a new menu.'),
1524
+ menuOrder: z.number().optional().describe('Menu display order. Omit to preserve an existing menu value or use the platform default for a new menu.'),
1525
+ menuPermission: z.string().optional().describe('Optional menu permission JSON object.'),
1526
+ menuDescription: z.string().optional().describe('Optional menu admin note.'),
1527
+ menuIsEnabled: z.boolean().optional().describe('Enable the menu. Omit to preserve an existing menu value or use the platform default for a new menu.'),
1528
+ description: z.string().optional().describe('Extension description.'),
1529
+ isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
1530
+ version: z.string().optional().default('1.0.0').describe('Extension version.'),
1531
+ apply: z.boolean().optional().default(false).describe('false returns plan only; true applies exactly the next pending step.'),
1532
+ applyAll: z.boolean().optional().default(false).describe('true applies all safe pending steps in order. Prefer apply=true for production changes.'),
1533
+ stepId: z.string().optional().describe('Optional pending step id to apply. Omit to apply the next pending step.'),
1534
+ globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when apply/applyAll mutates metadata. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
1535
+ extensionKnowledgeAckKey: extensionKnowledgeAckParam(z).optional().describe('Required when apply/applyAll saves extension code. Use extensionAckKey from get_enfyra_required_knowledge.'),
1536
+ }, async (input) => jsonText(await runExtensionWorkflow(ENFYRA_API_URL, input)));
1537
+ server.tool('set_table_graphql', 'Business operation: enable or disable GraphQL for one table through enfyra_graphql, then reload GraphQL. REST route methods do not control GraphQL.', {
1538
+ tableName: z.string().describe('Table name, alias, or id.'),
1539
+ isEnabled: z.boolean().describe('Desired GraphQL enabled state for the table.'),
1540
+ globalRulesAckKey: globalRulesAckParam(z),
1541
+ }, async ({ tableName, isEnabled, globalRulesAckKey }) => {
1542
+ assertGlobalRulesAck(globalRulesAckKey);
1543
+ const table = resolveTable(await getMetadataTables(ENFYRA_API_URL), tableName);
1544
+ const existing = await findRecord(ENFYRA_API_URL, 'enfyra_graphql', { table: { id: { _eq: getId(table) } } }, 'id,_id,table.id,isEnabled');
1545
+ const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_graphql', existing, {
1546
+ table: { id: getId(table) },
1547
+ isEnabled,
1548
+ });
1549
+ const graphqlReload = await reloadBestEffort(ENFYRA_API_URL, '/admin/reload/graphql');
1550
+ return jsonText({
1551
+ action: 'table_graphql_set',
1552
+ table: { id: getId(table), name: table.name },
1553
+ graphql: { id: operation.id, isEnabled },
1554
+ operation,
1555
+ graphqlReload,
1556
+ });
1557
+ });
1558
+ server.tool('add_route_methods', 'Business operation: add HTTP methods to an existing route.', {
1559
+ path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
1560
+ routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
1561
+ methods: z.array(z.string()).min(1).describe('HTTP method names to add.'),
1562
+ isEnabled: z.boolean().optional().describe('Optionally enable/disable the route in the same safe patch.'),
1563
+ globalRulesAckKey: globalRulesAckParam(z),
1564
+ }, async ({ path, routeId, methods, isEnabled, globalRulesAckKey }) => jsonText(await updateRouteMethods(ENFYRA_API_URL, {
1565
+ path,
1566
+ routeId,
1567
+ methods,
1568
+ mode: 'merge',
1569
+ isEnabled,
1570
+ globalRulesAckKey,
1571
+ })));
1572
+ server.tool('replace_route_methods', 'Business operation: replace an existing route availableMethods list exactly.', {
1573
+ path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
1574
+ routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
1575
+ methods: z.array(z.string()).min(1).describe('Exact HTTP method names for availableMethods.'),
1576
+ isEnabled: z.boolean().optional().describe('Optionally enable/disable the route in the same safe patch.'),
1577
+ globalRulesAckKey: globalRulesAckParam(z),
1578
+ }, async ({ path, routeId, methods, isEnabled, globalRulesAckKey }) => jsonText(await updateRouteMethods(ENFYRA_API_URL, {
1579
+ path,
1580
+ routeId,
1581
+ methods,
1582
+ mode: 'replace',
1583
+ isEnabled,
1584
+ globalRulesAckKey,
1585
+ })));
1586
+ server.tool('remove_route_methods', 'Business operation: remove HTTP methods from an existing route.', {
1587
+ path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
1588
+ routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
1589
+ methods: z.array(z.string()).min(1).describe('HTTP method names to remove.'),
1590
+ isEnabled: z.boolean().optional().describe('Optionally enable/disable the route in the same safe patch.'),
1591
+ globalRulesAckKey: globalRulesAckParam(z),
1592
+ }, async ({ path, routeId, methods, isEnabled, globalRulesAckKey }) => jsonText(await updateRouteMethods(ENFYRA_API_URL, {
1593
+ path,
1594
+ routeId,
1595
+ methods,
1596
+ mode: 'remove',
1597
+ isEnabled,
1598
+ globalRulesAckKey,
1599
+ })));
1600
+ server.tool('enable_route', 'Business operation: enable an existing route. Enabled routes are registered at runtime; disabled routes return 404.', {
1601
+ path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
1602
+ routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
1603
+ globalRulesAckKey: globalRulesAckParam(z),
1604
+ }, async ({ path, routeId, globalRulesAckKey }) => jsonText(await setRouteEnabled(ENFYRA_API_URL, {
1605
+ path,
1606
+ routeId,
1607
+ isEnabled: true,
1608
+ globalRulesAckKey,
1609
+ })));
1610
+ server.tool('disable_route', 'Business operation: disable an existing route without deleting metadata. Disabled routes are not registered at runtime and return 404.', {
1611
+ path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
1612
+ routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
1613
+ globalRulesAckKey: globalRulesAckParam(z),
1614
+ }, async ({ path, routeId, globalRulesAckKey }) => jsonText(await setRouteEnabled(ENFYRA_API_URL, {
1615
+ path,
1616
+ routeId,
1617
+ isEnabled: false,
1618
+ globalRulesAckKey,
1619
+ })));
1620
+ server.tool('delete_route', 'Business operation: preview-first delete for a route and its route-owned handlers, hooks, guards, and permissions. Use only when a route contract is retired.', {
1621
+ path: z.string().optional().describe('Route path, e.g. /old-endpoint. Use either path or routeId.'),
1622
+ routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
1623
+ expectedPath: z.string().optional().describe('Optional safety check. When confirm=true, pass the path returned by the preview.'),
1624
+ confirm: z.boolean().optional().default(false).describe('false returns a dependency preview only; true deletes the route and related route-owned records.'),
1625
+ globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when confirm=true. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
1626
+ }, async (input) => jsonText(await deleteRoute(ENFYRA_API_URL, input)));
1627
+ server.tool('public_route_methods', 'Business operation: make existing route methods public/anonymous.', {
1628
+ path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
1629
+ routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
1630
+ methods: z.array(z.string()).min(1).describe('HTTP method names to make public. They must already be available on the route.'),
1631
+ globalRulesAckKey: globalRulesAckParam(z),
1632
+ }, async ({ path, routeId, methods, globalRulesAckKey }) => jsonText(await updateRoutePublicMethods(ENFYRA_API_URL, {
1633
+ path,
1634
+ routeId,
1635
+ methods,
1636
+ mode: 'merge',
1637
+ globalRulesAckKey,
1638
+ })));
1639
+ server.tool('private_route_methods', 'Business operation: make specific public route methods private again.', {
1640
+ path: z.string().optional().describe('Route path, e.g. /sum. Use either path or routeId.'),
1641
+ routeId: z.union([z.string(), z.number()]).optional().describe('Route id. Use either path or routeId.'),
1642
+ methods: z.array(z.string()).min(1).describe('HTTP method names to remove from publicMethods.'),
1643
+ globalRulesAckKey: globalRulesAckParam(z),
1644
+ }, async ({ path, routeId, methods, globalRulesAckKey }) => jsonText(await updateRoutePublicMethods(ENFYRA_API_URL, {
1645
+ path,
1646
+ routeId,
1647
+ methods,
1648
+ mode: 'remove',
1649
+ globalRulesAckKey,
1650
+ })));
1651
+ server.tool('api_endpoint_workflow', [
1652
+ 'Step-by-step workflow for creating or updating a custom REST endpoint.',
1653
+ 'Use this when an LLM is building or changing endpoint behavior and should follow live nextSteps instead of guessing raw metadata mutations.',
1654
+ 'With apply=false it validates sourceCode, reads live route/handler/access state, and returns pending steps.',
1655
+ 'With apply=true it applies only the next pending step, then returns a fresh plan. With applyAll=true it advances all currently safe pending steps.',
1656
+ ].join(' '), {
1657
+ path: z.string().describe('Custom route path, e.g. /sum. Must not be a full URL.'),
1658
+ method: z.string().describe('HTTP method for the handler, e.g. GET or POST.'),
1659
+ sourceCode: z.string().describe('Handler sourceCode. Use macros such as @QUERY, @BODY, @THROW400, @REPOS, @USER. Do not send compiledCode.'),
1660
+ scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
1661
+ anonymousAccess: z.enum(['public', 'private']).optional().default('private').describe('public adds the method to publicMethods; private removes this method from publicMethods.'),
1662
+ public: z.boolean().optional().describe('Compatibility alias for anonymousAccess. true means public, false means private.'),
1663
+ roleId: z.union([z.string(), z.number()]).optional().describe('Optional role id for authenticated route permission.'),
1664
+ roleName: z.string().optional().describe('Optional role name for authenticated route permission, e.g. user.'),
1665
+ allowedUserIds: z.array(z.union([z.string(), z.number()])).optional().describe('Optional user id scope for authenticated route permission.'),
1666
+ routePermissionDescription: z.string().optional().describe('Optional admin note for created/updated route permission.'),
1667
+ description: z.string().optional().describe('Route description.'),
1668
+ timeout: z.number().int().positive().optional().describe('Optional handler timeout in ms.'),
1669
+ overwrite: z.boolean().optional().default(false).describe('Required to update an existing handler whose sourceCode/scriptLanguage/timeout differs.'),
1670
+ smokeTestQuery: z.string().optional().describe('Optional query JSON object for a smoke test, e.g. {"a":"1","b":"2"}.'),
1671
+ smokeTestBody: z.string().optional().describe('Optional body JSON object for a smoke test.'),
1672
+ apply: z.boolean().optional().default(false).describe('false returns plan only; true applies exactly the next pending step.'),
1673
+ applyAll: z.boolean().optional().default(false).describe('true applies all safe pending steps in order. Prefer apply=true for production changes.'),
1674
+ stepId: z.string().optional().describe('Optional pending step id to apply. Omit to apply the next pending step.'),
1675
+ globalRulesAckKey: globalRulesAckParam(z).optional().describe('Required when apply/applyAll mutates metadata. Use globalRulesAckKey from get_enfyra_required_knowledge.'),
1676
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required when apply/applyAll reaches the save_handler step. Use dynamicCodeAckKey from get_enfyra_required_knowledge.'),
1677
+ }, async (input) => jsonText(await runApiEndpointWorkflow(ENFYRA_API_URL, input)));
1678
+ server.tool('create_api_endpoint', [
1679
+ 'Business operation: create or update a custom REST endpoint with a handler in one safe operation.',
1680
+ 'Prefer api_endpoint_workflow when route access, role/user permissions, overwrite decisions, or multi-step planning matter.',
1681
+ 'Use this one-shot helper only when the endpoint contract is already clear and no authenticated route-permission step is needed in the same operation, such as a simple public webhook or private admin-only utility that will be granted separately.',
1682
+ 'It creates the route without mainTableId, ensures the method is available, validates sourceCode, creates or overwrites the route handler, optionally makes the method public, reloads routes, and can smoke-test the endpoint.',
1683
+ 'Use table/schema tools separately when the user needs persisted data. This tool is for custom behavior endpoints.',
1684
+ ].join(' '), {
1685
+ path: z.string().describe('Custom route path, e.g. /sum. Must not be a full URL.'),
1686
+ method: z.string().describe('HTTP method for the handler, e.g. GET or POST.'),
1687
+ sourceCode: z.string().describe('Handler sourceCode. Use macros such as @QUERY, @BODY, @THROW400, @REPOS, @USER. Do not send compiledCode.'),
1688
+ scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
1689
+ public: z.boolean().optional().default(false).describe('When true, the method is added to publicMethods for anonymous access.'),
1690
+ description: z.string().optional().describe('Route description.'),
1691
+ timeout: z.number().int().positive().optional().describe('Optional handler timeout in ms.'),
1692
+ overwrite: z.boolean().optional().default(false).describe('If a handler already exists for route+method, false fails; true updates its sourceCode.'),
1693
+ smokeTestQuery: z.string().optional().describe('Optional query JSON object for a smoke test after save, e.g. {"a":"1","b":"2"}.'),
1694
+ smokeTestBody: z.string().optional().describe('Optional body JSON object for a smoke test after save.'),
1695
+ globalRulesAckKey: globalRulesAckParam(z),
1696
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
1697
+ }, async ({ path, method, sourceCode, scriptLanguage, public: makePublic, description, timeout, overwrite, smokeTestQuery, smokeTestBody, globalRulesAckKey, knowledgeAckKey }) => {
1698
+ assertGlobalRulesAck(globalRulesAckKey);
1699
+ assertDynamicCodeKnowledgeAck(knowledgeAckKey);
1700
+ const normalizedPath = normalizeRestPath(path);
1701
+ const methodName = normalizeMethodName(method);
1702
+ const { methodMap, methodIdNameMap } = await getMethodContext(ENFYRA_API_URL);
1703
+ const methodId = methodMap[methodName];
1704
+ if (!methodId)
1705
+ throw new Error(`Unknown method "${methodName}". Valid methods: ${Object.keys(methodMap).sort().join(', ')}`);
1706
+ const routes = await fetchAll(ENFYRA_API_URL, '/enfyra_route?limit=1000&fields=id,_id,path,isEnabled,availableMethods.*,publicMethods.*,mainTable.name');
1707
+ let route = routes.find((item) => item.path === normalizedPath);
1708
+ let routeAction = 'existing';
1709
+ if (!route) {
1710
+ const createRouteResult = await fetchAPI(ENFYRA_API_URL, '/enfyra_route', {
1711
+ method: 'POST',
1712
+ body: JSON.stringify({
1713
+ path: normalizedPath,
1714
+ description,
1715
+ isEnabled: true,
1716
+ availableMethods: [{ id: methodId }],
1717
+ publicMethods: makePublic ? [{ id: methodId }] : [],
1718
+ }),
1719
+ });
1720
+ route = firstDataRecord(createRouteResult);
1721
+ routeAction = 'created';
1722
+ }
1723
+ else {
1724
+ const availableMethods = methodNamesFromRecords(route.availableMethods, methodIdNameMap);
1725
+ const publicMethods = methodNamesFromRecords(route.publicMethods, methodIdNameMap);
1726
+ const finalAvailable = uniqueMethodNames([...availableMethods, methodName]);
1727
+ const finalPublic = makePublic ? uniqueMethodNames([...publicMethods, methodName]) : publicMethods;
1728
+ const patchRouteResult = await fetchAPI(ENFYRA_API_URL, `/enfyra_route/${encodeURIComponent(String(getId(route)))}`, {
1729
+ method: 'PATCH',
1730
+ body: JSON.stringify({
1731
+ availableMethods: resolveMethodRefs(methodMap, finalAvailable),
1732
+ publicMethods: resolveMethodRefs(methodMap, finalPublic.filter((item) => finalAvailable.includes(item))),
1733
+ ...(description !== undefined ? { description } : {}),
1734
+ }),
1735
+ });
1736
+ route = firstDataRecord(patchRouteResult) || route;
1737
+ routeAction = 'updated';
1738
+ }
1739
+ const routeId = getId(route);
1740
+ const scriptValidation = await validateScriptSourceIfPresent(fetchAPI, ENFYRA_API_URL, 'enfyra_route_handler', {
1741
+ sourceCode,
1742
+ scriptLanguage,
1743
+ });
1744
+ const existingHandler = await findHandler(ENFYRA_API_URL, routeId, methodId);
1745
+ let handlerResult;
1746
+ let handlerAction;
1747
+ if (existingHandler) {
1748
+ if (!overwrite) {
1749
+ throw new Error(`Handler already exists for ${methodName} ${normalizedPath} with id ${getId(existingHandler)}. Re-run with overwrite=true to update it.`);
1750
+ }
1751
+ handlerAction = 'updated';
1752
+ const body = { sourceCode, scriptLanguage };
1753
+ if (timeout !== undefined)
1754
+ body.timeout = timeout;
1755
+ handlerResult = await fetchAPI(ENFYRA_API_URL, `/enfyra_route_handler/${encodeURIComponent(String(getId(existingHandler)))}`, {
1756
+ method: 'PATCH',
1757
+ body: JSON.stringify(body),
1758
+ });
1759
+ }
1760
+ else {
1761
+ handlerAction = 'created';
1762
+ const body = {
1763
+ route: { id: routeId },
1764
+ method: { id: methodId },
1765
+ sourceCode,
1766
+ scriptLanguage,
1767
+ };
1768
+ if (timeout !== undefined)
1769
+ body.timeout = timeout;
1770
+ handlerResult = await fetchAPI(ENFYRA_API_URL, '/enfyra_route_handler', {
1771
+ method: 'POST',
1772
+ body: JSON.stringify(body),
1773
+ });
1774
+ }
1775
+ const routeReload = await reloadRoutes(ENFYRA_API_URL);
1776
+ let smokeTest = null;
1777
+ if (smokeTestQuery !== undefined || smokeTestBody !== undefined) {
1778
+ const query = smokeTestQuery ? JSON.parse(smokeTestQuery) : {};
1779
+ if (!query || typeof query !== 'object' || Array.isArray(query))
1780
+ throw new Error('smokeTestQuery must be a JSON object.');
1781
+ const queryParams = new URLSearchParams();
1782
+ for (const [key, value] of Object.entries(query)) {
1783
+ if (value !== undefined && value !== null)
1784
+ queryParams.set(key, String(value));
1785
+ }
1786
+ const body = smokeTestBody ? JSON.parse(smokeTestBody) : undefined;
1787
+ const smokePath = `${normalizedPath}${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
1788
+ smokeTest = await fetchAPI(ENFYRA_API_URL, smokePath, {
1789
+ method: methodName,
1790
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
1791
+ });
1792
+ }
1793
+ const savedHandler = firstDataRecord(handlerResult);
1794
+ return {
1795
+ content: [{
1796
+ type: 'text',
1797
+ text: JSON.stringify({
1798
+ action: 'api_endpoint_ready',
1799
+ endpoint: {
1800
+ path: normalizedPath,
1801
+ method: methodName,
1802
+ public: makePublic,
1803
+ routeId,
1804
+ handlerId: getId(savedHandler) || getId(existingHandler),
1805
+ },
1806
+ routeAction,
1807
+ handlerAction,
1808
+ scriptValidation,
1809
+ routeReload,
1810
+ smokeTest,
1811
+ usage: {
1812
+ restPath: `${ENFYRA_API_URL.replace(/\/$/, '')}${normalizedPath}`,
1813
+ auth: makePublic ? 'anonymous allowed for this method' : 'Bearer auth and route access are required unless another guard bypass applies',
1814
+ },
1815
+ }, null, 2),
1816
+ }],
1817
+ };
1818
+ });
1819
+ server.tool('ensure_column_rule', 'Business operation: create or update a column validation rule. It resolves table/column ids and avoids duplicate rules for the same column+ruleType.', {
1820
+ tableName: z.string().describe('Table name, alias, or id.'),
1821
+ columnName: z.string().describe('Column name or id.'),
1822
+ ruleType: z.enum(['min', 'max', 'minLength', 'maxLength', 'pattern', 'format', 'minItems', 'maxItems', 'custom']).describe('Validation rule type.'),
1823
+ value: z.string().optional().describe('Rule config JSON object, usually {"v": ...}.'),
1824
+ message: z.string().optional().describe('Custom validation error message.'),
1825
+ description: z.string().optional().describe('Admin note.'),
1826
+ isEnabled: z.boolean().optional().default(true).describe('Enable the rule.'),
1827
+ globalRulesAckKey: globalRulesAckParam(z),
1828
+ }, async ({ tableName, columnName, ruleType, value, message, description, isEnabled, globalRulesAckKey }) => {
1829
+ assertGlobalRulesAck(globalRulesAckKey);
1830
+ const table = resolveTable(await getMetadataTables(ENFYRA_API_URL), tableName);
1831
+ const column = resolveColumn(table, columnName);
1832
+ const existing = await findRecord(ENFYRA_API_URL, 'enfyra_column_rule', {
1833
+ column: { id: { _eq: getId(column) } },
1834
+ ruleType: { _eq: ruleType },
1835
+ }, 'id,_id,column.id,ruleType');
1836
+ const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_column_rule', existing, {
1837
+ column: { id: getId(column) },
1838
+ ruleType,
1839
+ value: parseJsonObjectArg('value', value, null),
1840
+ message,
1841
+ description,
1842
+ isEnabled,
1843
+ });
1844
+ return jsonText({
1845
+ action: 'column_rule_ensured',
1846
+ table: { id: getId(table), name: table.name },
1847
+ column: { id: getId(column), name: column.name },
1848
+ ruleType,
1849
+ operation,
1850
+ });
1851
+ });
1852
+ server.tool('ensure_field_permission', 'Business operation: create or update one field permission. It resolves table field ids, enforces exactly one column/relation target, and enforces a role/user scope.', {
1853
+ tableName: z.string().describe('Table name, alias, or id.'),
1854
+ columnName: z.string().optional().describe('Column name/id to protect. Use exactly one of columnName or relationName.'),
1855
+ relationName: z.string().optional().describe('Relation propertyName/id to protect. Use exactly one of columnName or relationName.'),
1856
+ action: z.enum(['read', 'create', 'update']).optional().default('read').describe('Field action.'),
1857
+ effect: z.enum(['allow', 'deny']).optional().default('allow').describe('Permission effect.'),
1858
+ roleId: z.union([z.string(), z.number()]).optional().describe('Role id scope.'),
1859
+ roleName: z.string().optional().describe('Role name scope.'),
1860
+ allowedUserIds: z.array(z.union([z.string(), z.number()])).optional().describe('Direct user id scope.'),
1861
+ condition: z.string().optional().describe('Condition JSON object using field permission DSL.'),
1862
+ description: z.string().optional().describe('Admin note.'),
1863
+ isEnabled: z.boolean().optional().default(true).describe('Enable the permission.'),
1864
+ globalRulesAckKey: globalRulesAckParam(z),
1865
+ }, async ({ tableName, columnName, relationName, action, effect, roleId, roleName, allowedUserIds, condition, description, isEnabled, globalRulesAckKey }) => {
1866
+ assertGlobalRulesAck(globalRulesAckKey);
1867
+ if (!!columnName === !!relationName)
1868
+ throw new Error('Provide exactly one of columnName or relationName.');
1869
+ assertOneScope({ roleId, roleName, allowedUserIds });
1870
+ const [tables, role] = await Promise.all([
1871
+ getMetadataTables(ENFYRA_API_URL),
1872
+ resolveRole(ENFYRA_API_URL, { roleId, roleName }),
1873
+ ]);
1874
+ const table = resolveTable(tables, tableName);
1875
+ const field = columnName ? resolveColumn(table, columnName) : resolveRelation(table, relationName);
1876
+ const filter = {
1877
+ action: { _eq: action },
1878
+ effect: { _eq: effect },
1879
+ ...(columnName ? { column: { id: { _eq: getId(field) } } } : { relation: { id: { _eq: getId(field) } } }),
1880
+ ...(role ? { role: { id: { _eq: role.id } } } : {}),
1881
+ };
1882
+ const existing = role
1883
+ ? await findRecord(ENFYRA_API_URL, 'enfyra_field_permission', filter, 'id,_id,column.id,relation.id,role.id,action,effect')
1884
+ : null;
1885
+ const body = {
1886
+ action,
1887
+ effect,
1888
+ isEnabled,
1889
+ description,
1890
+ condition: parseJsonObjectArg('condition', condition, null),
1891
+ ...(columnName ? { column: { id: getId(field) } } : { relation: { id: getId(field) } }),
1892
+ ...(role ? { role: { id: role.id } } : {}),
1893
+ ...(allowedUserIds?.length ? { allowedUsers: allowedUserIds.map((id) => ({ id })) } : {}),
1894
+ };
1895
+ const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_field_permission', existing, body);
1896
+ const reload = await reloadBestEffort(ENFYRA_API_URL, '/admin/reload/metadata');
1897
+ return jsonText({
1898
+ action: 'field_permission_ensured',
1899
+ table: { id: getId(table), name: table.name },
1900
+ field: { id: getId(field), name: columnName ? field.name : field.propertyName, kind: columnName ? 'column' : 'relation' },
1901
+ scope: { role, allowedUserIds: allowedUserIds || [] },
1902
+ operation,
1903
+ reload,
1904
+ });
1905
+ });
1906
+ server.tool('ensure_guard', 'Business operation: create or update a request guard and optional guard rules. It resolves route/method ids and prevents pre_auth user-based rules.', {
1907
+ name: z.string().describe('Guard name. Existing guard with this name is updated unless guardId is provided.'),
1908
+ guardId: z.union([z.string(), z.number()]).optional().describe('Optional existing guard id.'),
1909
+ position: z.enum(['pre_auth', 'post_auth']).optional().default('pre_auth').describe('Guard position.'),
1910
+ routeId: z.union([z.string(), z.number()]).optional().describe('Optional route id.'),
1911
+ path: z.string().optional().describe('Optional route path.'),
1912
+ methods: z.array(z.string()).optional().describe('HTTP method names.'),
1913
+ combinator: z.enum(['and', 'or']).optional().default('and').describe('Rule combinator.'),
1914
+ priority: z.number().optional().default(0).describe('Lower runs earlier.'),
1915
+ isGlobal: z.boolean().optional().default(false).describe('Apply globally.'),
1916
+ isEnabled: z.boolean().optional().default(false).describe('Enable guard. Defaults false to avoid lockout.'),
1917
+ description: z.string().optional().describe('Admin note.'),
1918
+ rules: z.string().optional().describe('Rules JSON array: [{type, config, priority, isEnabled, description, userIds}].'),
1919
+ rulesMode: z.enum(['append', 'replace', 'none']).optional().default('append').describe('append creates rules, replace disables existing rules first, none leaves rules unchanged.'),
1920
+ globalRulesAckKey: globalRulesAckParam(z),
1921
+ }, async ({ name, guardId, position, routeId, path, methods, combinator, priority, isGlobal, isEnabled, description, rules, rulesMode, globalRulesAckKey }) => {
1922
+ assertGlobalRulesAck(globalRulesAckKey);
1923
+ if (path && routeId)
1924
+ throw new Error('Provide path or routeId, not both.');
1925
+ const ruleInputs = parseJsonArrayArg('rules', rules, []);
1926
+ if (position === 'pre_auth') {
1927
+ const invalid = ruleInputs.filter((rule) => rule.type === 'rate_limit_by_user' || (Array.isArray(rule.userIds) && rule.userIds.length));
1928
+ if (invalid.length)
1929
+ throw new Error('pre_auth guards cannot use user-based rules or userIds. Use post_auth.');
1930
+ }
1931
+ let route = null;
1932
+ if (!isGlobal && (routeId || path)) {
1933
+ route = (await resolveRoute(ENFYRA_API_URL, { path, routeId })).route;
1934
+ }
1935
+ const { methodMap } = await getMethodContext(ENFYRA_API_URL);
1936
+ const existing = guardId
1937
+ ? await findRecord(ENFYRA_API_URL, 'enfyra_guard', { id: { _eq: guardId } }, 'id,_id,name')
1938
+ : await findRecord(ENFYRA_API_URL, 'enfyra_guard', { name: { _eq: name } }, 'id,_id,name');
1939
+ const guardBody = {
1940
+ name,
1941
+ position,
1942
+ combinator,
1943
+ priority,
1944
+ isGlobal,
1945
+ isEnabled,
1946
+ description,
1947
+ ...(route ? { route: { id: getId(route) } } : {}),
1948
+ ...(methods?.length ? { methods: resolveMethodRefs(methodMap, methods) } : {}),
1949
+ };
1950
+ const guardOperation = await createOrPatch(ENFYRA_API_URL, 'enfyra_guard', existing, guardBody);
1951
+ const resolvedGuardId = guardOperation.id || getId(existing);
1952
+ const existingRules = rulesMode === 'replace'
1953
+ ? await fetchRecords(ENFYRA_API_URL, 'enfyra_guard_rule', { guard: { id: { _eq: resolvedGuardId } } }, 'id,_id,isEnabled')
1954
+ : [];
1955
+ const disabledRules = [];
1956
+ for (const rule of existingRules) {
1957
+ disabledRules.push(await fetchAPI(ENFYRA_API_URL, `/enfyra_guard_rule/${encodeURIComponent(String(getId(rule)))}`, {
1958
+ method: 'PATCH',
1959
+ body: JSON.stringify({ isEnabled: false }),
1960
+ }));
1961
+ }
1962
+ const createdRules = [];
1963
+ if (rulesMode !== 'none') {
1964
+ for (const rule of ruleInputs) {
1965
+ createdRules.push(await fetchAPI(ENFYRA_API_URL, '/enfyra_guard_rule', {
1966
+ method: 'POST',
1967
+ body: JSON.stringify({
1968
+ type: rule.type,
1969
+ config: rule.config,
1970
+ priority: rule.priority ?? 0,
1971
+ isEnabled: rule.isEnabled ?? true,
1972
+ description: rule.description,
1973
+ guard: { id: resolvedGuardId },
1974
+ ...(Array.isArray(rule.userIds) && rule.userIds.length ? { users: rule.userIds.map((id) => ({ id })) } : {}),
1975
+ }),
1976
+ }));
1977
+ }
1978
+ }
1979
+ const reload = await reloadBestEffort(ENFYRA_API_URL, '/admin/reload/guards');
1980
+ return jsonText({
1981
+ action: 'guard_ensured',
1982
+ guard: { id: resolvedGuardId, name, route: route ? route.path : null, isGlobal },
1983
+ guardOperation,
1984
+ disabledRuleCount: disabledRules.length,
1985
+ createdRuleCount: createdRules.length,
1986
+ reload,
1987
+ });
1988
+ });
1989
+ server.tool('ensure_websocket_gateway', 'Business operation: create or update an Enfyra Socket.IO gateway. Connection handler sourceCode is validated before save.', {
1990
+ path: z.string().describe('Gateway namespace/path, e.g. /chat.'),
1991
+ sourceCode: z.string().optional().describe('Optional connection handler dynamic script sourceCode.'),
1992
+ scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language for connection handler.'),
1993
+ isEnabled: z.boolean().optional().default(true).describe('Enable gateway.'),
1994
+ description: z.string().optional().describe('Admin note.'),
1995
+ globalRulesAckKey: globalRulesAckParam(z),
1996
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z).optional().describe('Required when sourceCode is provided. Use dynamicCodeAckKey from get_enfyra_required_knowledge.'),
1997
+ }, async ({ path, sourceCode, scriptLanguage, isEnabled, description, globalRulesAckKey, knowledgeAckKey }) => {
1998
+ assertGlobalRulesAck(globalRulesAckKey);
1999
+ assertDynamicCodeKnowledgeAckIf(sourceCode !== undefined, knowledgeAckKey);
2000
+ const normalizedPath = normalizeRestPath(path);
2001
+ const validation = sourceCode === undefined
2002
+ ? { validated: false, reason: 'no sourceCode' }
2003
+ : await validateDynamicScript(ENFYRA_API_URL, sourceCode, scriptLanguage);
2004
+ const existing = await findRecord(ENFYRA_API_URL, 'enfyra_websocket', { path: { _eq: normalizedPath } }, 'id,_id,path');
2005
+ const body = {
2006
+ path: normalizedPath,
2007
+ isEnabled,
2008
+ description,
2009
+ ...(sourceCode !== undefined ? { sourceCode, scriptLanguage } : {}),
2010
+ };
2011
+ const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_websocket', existing, body);
2012
+ const reload = naturalPartialReload('Websocket metadata writes trigger the server partial reload contract; there is no dedicated websocket reload endpoint.');
2013
+ return jsonText({ action: 'websocket_gateway_ensured', gateway: { id: operation.id, path: normalizedPath }, validation, operation, reload });
2014
+ });
2015
+ server.tool('ensure_websocket_event', 'Business operation: create or update one websocket event handler. It resolves gateway path/id and validates sourceCode before save.', {
2016
+ gatewayPath: z.string().optional().describe('Gateway path, e.g. /chat. Use gatewayPath or gatewayId.'),
2017
+ gatewayId: z.union([z.string(), z.number()]).optional().describe('Gateway id. Use gatewayPath or gatewayId.'),
2018
+ eventName: z.string().describe('Socket event name.'),
2019
+ sourceCode: z.string().describe('Event handler dynamic script sourceCode.'),
2020
+ scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
2021
+ isEnabled: z.boolean().optional().default(true).describe('Enable event.'),
2022
+ description: z.string().optional().describe('Admin note.'),
2023
+ globalRulesAckKey: globalRulesAckParam(z),
2024
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
2025
+ }, async ({ gatewayPath, gatewayId, eventName, sourceCode, scriptLanguage, isEnabled, description, globalRulesAckKey, knowledgeAckKey }) => {
2026
+ assertGlobalRulesAck(globalRulesAckKey);
2027
+ assertDynamicCodeKnowledgeAck(knowledgeAckKey);
2028
+ if (!gatewayPath && !gatewayId)
2029
+ throw new Error('Provide gatewayPath or gatewayId.');
2030
+ if (gatewayPath && gatewayId)
2031
+ throw new Error('Provide gatewayPath or gatewayId, not both.');
2032
+ const gateway = gatewayId
2033
+ ? await findRecord(ENFYRA_API_URL, 'enfyra_websocket', { id: { _eq: gatewayId } }, 'id,_id,path')
2034
+ : await findRecord(ENFYRA_API_URL, 'enfyra_websocket', { path: { _eq: normalizeRestPath(gatewayPath) } }, 'id,_id,path');
2035
+ if (!gateway)
2036
+ throw new Error(`Websocket gateway not found: ${gatewayId || gatewayPath}`);
2037
+ const validation = await validateDynamicScript(ENFYRA_API_URL, sourceCode, scriptLanguage);
2038
+ const existing = await findRecord(ENFYRA_API_URL, 'enfyra_websocket_event', {
2039
+ gateway: { id: { _eq: getId(gateway) } },
2040
+ eventName: { _eq: eventName },
2041
+ }, 'id,_id,eventName,gateway.id');
2042
+ const operation = await createOrPatch(ENFYRA_API_URL, 'enfyra_websocket_event', existing, {
2043
+ gateway: { id: getId(gateway) },
2044
+ eventName,
2045
+ sourceCode,
2046
+ scriptLanguage,
2047
+ isEnabled,
2048
+ description,
2049
+ });
2050
+ const reload = naturalPartialReload('Websocket event writes trigger the server partial reload contract; there is no dedicated websocket reload endpoint.');
2051
+ return jsonText({ action: 'websocket_event_ensured', gateway: { id: getId(gateway), path: gateway.path }, eventName, validation, operation, reload });
2052
+ });
2053
+ server.tool('ensure_manual_flow', 'Business operation: create or update a manually triggered Enfyra flow. Use this when the flow is run by API, admin action, another flow, or hook.', {
2054
+ name: z.string().describe('Flow name. Existing flow with this name is updated.'),
2055
+ timeout: z.number().int().positive().optional().describe('Flow timeout in ms.'),
2056
+ maxExecutions: z.number().int().positive().optional().default(100).describe('Execution history cap.'),
2057
+ isEnabled: z.boolean().optional().default(true).describe('Enable flow.'),
2058
+ description: z.string().optional().describe('Admin note.'),
2059
+ globalRulesAckKey: globalRulesAckParam(z),
2060
+ }, async ({ name, timeout, maxExecutions, isEnabled, description, globalRulesAckKey }) => jsonText(await ensureFlow(ENFYRA_API_URL, {
2061
+ name,
2062
+ triggerType: 'manual',
2063
+ triggerConfig: {},
2064
+ timeout,
2065
+ maxExecutions,
2066
+ isEnabled,
2067
+ description,
2068
+ globalRulesAckKey,
2069
+ })));
2070
+ server.tool('ensure_scheduled_flow', 'Business operation: create or update a scheduled Enfyra flow. Use this only for cron/time-based flows.', {
2071
+ name: z.string().describe('Flow name. Existing flow with this name is updated.'),
2072
+ triggerConfig: z.string().describe('Schedule config JSON object.'),
2073
+ timeout: z.number().int().positive().optional().describe('Flow timeout in ms.'),
2074
+ maxExecutions: z.number().int().positive().optional().default(100).describe('Execution history cap.'),
2075
+ isEnabled: z.boolean().optional().default(true).describe('Enable flow.'),
2076
+ description: z.string().optional().describe('Admin note.'),
2077
+ globalRulesAckKey: globalRulesAckParam(z),
2078
+ }, async ({ name, triggerConfig, timeout, maxExecutions, isEnabled, description, globalRulesAckKey }) => jsonText(await ensureFlow(ENFYRA_API_URL, {
2079
+ name,
2080
+ triggerType: 'schedule',
2081
+ triggerConfig,
2082
+ timeout,
2083
+ maxExecutions,
2084
+ isEnabled,
2085
+ description,
2086
+ globalRulesAckKey,
2087
+ })));
2088
+ server.tool('choose_flow_step_tool', 'Dry-run helper: choose the most specific Enfyra flow step tool for one intended step before mutating flow metadata.', {
2089
+ intent: z.string().describe('Plain-language description of what this one flow step should do.'),
2090
+ }, async ({ intent }) => {
2091
+ const recommendation = chooseFlowStepTool(intent);
2092
+ return jsonText({
2093
+ action: 'flow_step_tool_recommended',
2094
+ intent,
2095
+ recommendation,
2096
+ availableStepTools: FLOW_STEP_TOOL_GUIDANCE,
2097
+ nextSteps: [
2098
+ `Call ${recommendation.tool} with a stable key and order.`,
2099
+ 'Use ensure_script_flow_step only when the atomic tools cannot express the behavior.',
2100
+ 'After saving script or condition steps, use test_flow_step before relying on the flow.',
2101
+ ],
2102
+ });
2103
+ });
2104
+ server.tool('ensure_script_flow_step', 'Business operation: create or update one script flow step. Use this for JavaScript/TypeScript flow logic instead of choosing type=script manually.', {
2105
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
2106
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
2107
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
2108
+ sourceCode: z.string().describe('Script sourceCode.'),
2109
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
2110
+ config: z.string().optional().describe('Step config JSON object.'),
2111
+ scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
2112
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
2113
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
2114
+ globalRulesAckKey: globalRulesAckParam(z),
2115
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
2116
+ }, async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
2117
+ ...input,
2118
+ type: 'script',
2119
+ })));
2120
+ server.tool('ensure_condition_flow_step', 'Business operation: create or update one condition flow step. Use this for dynamic conditional branching instead of choosing type=condition manually.', {
2121
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
2122
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
2123
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
2124
+ sourceCode: z.string().describe('Condition sourceCode.'),
2125
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
2126
+ config: z.string().optional().describe('Step config JSON object.'),
2127
+ scriptLanguage: z.enum(['javascript', 'typescript']).optional().default('javascript').describe('Script language.'),
2128
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
2129
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
2130
+ globalRulesAckKey: globalRulesAckParam(z),
2131
+ knowledgeAckKey: dynamicCodeKnowledgeAckParam(z),
2132
+ }, async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
2133
+ ...input,
2134
+ type: 'condition',
2135
+ })));
2136
+ server.tool('ensure_query_flow_step', 'Business operation: create or update one query flow step. Use this for repository/query-style flow steps instead of choosing type=query manually.', {
2137
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
2138
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
2139
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
2140
+ config: z.string().describe('Step config JSON object.'),
2141
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
2142
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
2143
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
2144
+ globalRulesAckKey: globalRulesAckParam(z),
2145
+ }, async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
2146
+ ...input,
2147
+ type: 'query',
2148
+ })));
2149
+ server.tool('ensure_http_flow_step', 'Business operation: create or update one HTTP flow step. Use this for outbound HTTP calls instead of choosing type=http manually.', {
2150
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
2151
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
2152
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
2153
+ config: z.string().describe('Step config JSON object.'),
2154
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
2155
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
2156
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
2157
+ globalRulesAckKey: globalRulesAckParam(z),
2158
+ }, async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
2159
+ ...input,
2160
+ type: 'http',
2161
+ })));
2162
+ server.tool('ensure_create_flow_step', 'Business operation: create or update one create-record flow step. Use this for a single table insert instead of writing script code.', {
2163
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
2164
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
2165
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
2166
+ config: z.string().describe('Step config JSON object: { "table": "...", "data": { ... } }.'),
2167
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
2168
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
2169
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
2170
+ globalRulesAckKey: globalRulesAckParam(z),
2171
+ }, async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
2172
+ ...input,
2173
+ type: 'create',
2174
+ })));
2175
+ server.tool('ensure_update_flow_step', 'Business operation: create or update one update-record flow step. Use this for a single table update by id instead of writing script code.', {
2176
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
2177
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
2178
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
2179
+ config: z.string().describe('Step config JSON object: { "table": "...", "id": "...", "data": { ... } }.'),
2180
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
2181
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
2182
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
2183
+ globalRulesAckKey: globalRulesAckParam(z),
2184
+ }, async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
2185
+ ...input,
2186
+ type: 'update',
2187
+ })));
2188
+ server.tool('ensure_delete_flow_step', 'Business operation: create or update one delete-record flow step. Use this for a single table delete by id instead of writing script code.', {
2189
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
2190
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
2191
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
2192
+ config: z.string().describe('Step config JSON object: { "table": "...", "id": "..." }.'),
2193
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
2194
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
2195
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
2196
+ globalRulesAckKey: globalRulesAckParam(z),
2197
+ }, async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
2198
+ ...input,
2199
+ type: 'delete',
2200
+ })));
2201
+ server.tool('ensure_sleep_flow_step', 'Business operation: create or update one sleep/wait flow step. Use this for delays instead of choosing type=sleep manually.', {
2202
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
2203
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
2204
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
2205
+ config: z.string().describe('Step config JSON object.'),
2206
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
2207
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
2208
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
2209
+ globalRulesAckKey: globalRulesAckParam(z),
2210
+ }, async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
2211
+ ...input,
2212
+ type: 'sleep',
2213
+ })));
2214
+ server.tool('ensure_log_flow_step', 'Business operation: create or update one log flow step. Use this for lightweight execution diagnostics instead of script code.', {
2215
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
2216
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
2217
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
2218
+ config: z.string().describe('Step config JSON object: { "message": "..." }.'),
2219
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
2220
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
2221
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
2222
+ globalRulesAckKey: globalRulesAckParam(z),
2223
+ }, async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
2224
+ ...input,
2225
+ type: 'log',
2226
+ })));
2227
+ server.tool('ensure_trigger_flow_step', 'Business operation: create or update one child-flow trigger step. Use this for flow-to-flow orchestration instead of choosing type=trigger_flow manually.', {
2228
+ flowName: z.string().optional().describe('Flow name. Use flowName or flowId.'),
2229
+ flowId: z.union([z.string(), z.number()]).optional().describe('Flow id. Use flowName or flowId.'),
2230
+ key: z.string().describe('Stable step key. Existing step with flow+key is updated.'),
2231
+ config: z.string().describe('Step config JSON object.'),
2232
+ order: z.number().optional().default(0).describe('Step order. Saved as enfyra_flow_step.stepOrder.'),
2233
+ timeout: z.number().int().positive().optional().describe('Step timeout in ms.'),
2234
+ isEnabled: z.boolean().optional().default(true).describe('Enable step.'),
2235
+ globalRulesAckKey: globalRulesAckParam(z),
2236
+ }, async (input) => jsonText(await ensureFlowStep(ENFYRA_API_URL, {
2237
+ ...input,
2238
+ type: 'trigger_flow',
2239
+ })));
2240
+ server.tool('ensure_menu', 'Business operation: create or update one admin menu item. Use this instead of raw enfyra_menu CRUD.', {
2241
+ label: z.string().describe('Menu label.'),
2242
+ path: z.string().optional().describe('Admin app route path for leaf menu items, e.g. /reports.'),
2243
+ icon: z.string().optional().describe('Menu icon name.'),
2244
+ type: z.enum(['Menu', 'Dropdown Menu']).optional().default('Menu').describe('Menu type.'),
2245
+ order: z.number().optional().default(0).describe('Display order.'),
2246
+ permission: z.string().optional().describe('Menu permission JSON object.'),
2247
+ description: z.string().optional().describe('Admin note.'),
2248
+ isEnabled: z.boolean().optional().default(true).describe('Enable menu.'),
2249
+ globalRulesAckKey: globalRulesAckParam(z),
2250
+ }, async (input) => jsonText({
2251
+ action: 'menu_ensured',
2252
+ menu: await ensureMenu(ENFYRA_API_URL, input),
2253
+ }));
2254
+ server.tool('reorder_menus', [
2255
+ 'Business operation: reorder Enfyra admin menus and optionally move menus under a new parent.',
2256
+ 'Uses the server /admin/menu/reorder route introduced in Enfyra 2.2.6 instead of PATCHing each enfyra_menu record.',
2257
+ 'The server validates duplicate ids, non-negative integer order, dropdown-only parents, /data child restrictions, system menu parent locks, cycle prevention, persistence, and menu cache invalidation.',
2258
+ ].join(' '), {
2259
+ updates: z.array(z.object({
2260
+ id: z.union([z.string(), z.number()]).describe('Menu id to reorder.'),
2261
+ order: z.number().int().nonnegative().describe('Sibling order index. Must be a non-negative integer.'),
2262
+ parent: z.union([z.string(), z.number(), z.null()]).optional().describe('New parent menu id, or null for a root menu. Parent must be a Dropdown Menu.'),
2263
+ })).min(1).describe('Menu order/parent updates, usually the changed siblings from drag-and-drop.'),
2264
+ globalRulesAckKey: globalRulesAckParam(z),
2265
+ }, async (input) => jsonText(await reorderMenus(ENFYRA_API_URL, input)));
2266
+ server.tool('ensure_page_extension', 'Business operation: create or update one page extension attached to an existing menu. Validates extension code before save. Call get_extension_theme_contract first for UI work.', {
2267
+ name: z.string().describe('Extension unique name.'),
2268
+ code: z.string().describe('Vue SFC extension code.'),
2269
+ menuId: z.union([z.string(), z.number()]).describe('Existing menu id for this page extension.'),
2270
+ description: z.string().optional().describe('Extension description.'),
2271
+ isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
2272
+ version: z.string().optional().default('1.0.0').describe('Extension version.'),
2273
+ globalRulesAckKey: globalRulesAckParam(z),
2274
+ extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
2275
+ }, async (input) => jsonText({
2276
+ action: 'page_extension_ensured',
2277
+ extension: await ensureExtension(ENFYRA_API_URL, { ...input, type: 'page' }),
2278
+ }));
2279
+ server.tool('ensure_global_extension', 'Business operation: create or update one global shell extension. Validates extension code before save and rejects menu coupling. Call get_extension_theme_contract first for UI work.', {
2280
+ name: z.string().describe('Extension unique name.'),
2281
+ code: z.string().describe('Vue SFC extension code.'),
2282
+ description: z.string().optional().describe('Extension description.'),
2283
+ isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
2284
+ version: z.string().optional().default('1.0.0').describe('Extension version.'),
2285
+ globalRulesAckKey: globalRulesAckParam(z),
2286
+ extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
2287
+ }, async (input) => jsonText({
2288
+ action: 'global_extension_ensured',
2289
+ extension: await ensureExtension(ENFYRA_API_URL, { ...input, type: 'global' }),
2290
+ }));
2291
+ server.tool('ensure_widget_extension', 'Business operation: create or update one widget extension. Validates extension code before save and rejects menu coupling. Call get_extension_theme_contract first for UI work.', {
2292
+ name: z.string().describe('Extension unique name.'),
2293
+ code: z.string().describe('Vue SFC extension code.'),
2294
+ description: z.string().optional().describe('Extension description.'),
2295
+ isEnabled: z.boolean().optional().default(true).describe('Enable extension.'),
2296
+ version: z.string().optional().default('1.0.0').describe('Extension version.'),
2297
+ globalRulesAckKey: globalRulesAckParam(z),
2298
+ extensionKnowledgeAckKey: extensionKnowledgeAckParam(z),
2299
+ }, async (input) => jsonText({
2300
+ action: 'widget_extension_ensured',
2301
+ extension: await ensureExtension(ENFYRA_API_URL, { ...input, type: 'widget' }),
2302
+ }));
2303
+ }
2304
+ //# sourceMappingURL=platform-operation-tools.js.map