@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,2285 @@
1
+ export const EXAMPLE_REASONING_GUIDE = [
2
+ 'Examples are reasoning anchors, not templates to copy blindly. Preserve the platform contract, then adapt table names, route paths, relation names, fields, UI labels, and lifecycle triggers to the live app.',
3
+ 'First identify the invariant being demonstrated: security boundary, query shape, shell registry contract, schema relation direction, runtime lifecycle, or browser proxy pattern.',
4
+ 'Then identify what is illustrative: chat/order/report/cloud paths, sample field names, icons, labels, menu order, and specific notification kinds.',
5
+ 'When a note says do not, treat it as a contract or safety boundary unless live metadata proves a different supported contract. When a note says for example, map the idea to the current domain instead of copying the literal names.',
6
+ 'Before applying an example, inspect live metadata/routes/features and choose the closest supported tool. Use the smallest example that proves the decision, then compose with other examples only when the task truly needs multiple contracts.',
7
+ ];
8
+ export const EXAMPLE_CATEGORIES = {
9
+ 'ssr-app-auth': {
10
+ title: 'SSR app auth, OAuth, refresh, and proxy setup',
11
+ useWhen: 'Use when building Nuxt, Next, or another browser app that should rely on Enfyra cookies through an app-origin proxy; adapt the framework-specific wrapper while preserving the same-origin proxy and cookie boundary.',
12
+ examples: [
13
+ {
14
+ name: 'Nuxt routeRules for REST and Socket.IO',
15
+ code: `export default defineNuxtConfig({
16
+ routeRules: {
17
+ "/enfyra/**": {
18
+ proxy: {
19
+ to: \`\${process.env.ENFYRA_API_URL}/**\`,
20
+ fetchOptions: { redirect: "manual" }
21
+ }
22
+ },
23
+ "/socket.io/**": {
24
+ proxy: \`\${process.env.ENFYRA_APP_URL}/ws/socket.io/**\`
25
+ }
26
+ }
27
+ })`,
28
+ notes: [
29
+ 'Browser code calls /enfyra/login, /enfyra/me, /enfyra/logout, and /enfyra/<table>.',
30
+ 'Keep redirects manual so OAuth set-cookie redirects reach the browser.',
31
+ 'Do not add custom token cookies when the proxy is enough.',
32
+ ],
33
+ },
34
+ {
35
+ name: 'Next rewrites for REST and Socket.IO',
36
+ code: `const nextConfig = {
37
+ async rewrites() {
38
+ return [
39
+ {
40
+ source: "/enfyra/:path*",
41
+ destination: \`\${process.env.ENFYRA_API_URL}/:path*\`
42
+ },
43
+ {
44
+ source: "/socket.io/",
45
+ destination: \`\${process.env.ENFYRA_APP_URL}/ws/socket.io/\`
46
+ }
47
+ ]
48
+ }
49
+ }
50
+
51
+ export default nextConfig`,
52
+ notes: [
53
+ 'Use rewrites for browser traffic.',
54
+ 'If you add Next middleware/proxy for auth gating, server-side checks may call the Enfyra API origin directly while forwarding the incoming Cookie header.',
55
+ ],
56
+ },
57
+ {
58
+ name: 'Angular dev proxy for REST and Socket.IO',
59
+ code: `// src/proxy.conf.json
60
+ {
61
+ "/enfyra/**": {
62
+ "target": "https://demo.enfyra.io/api",
63
+ "secure": true,
64
+ "changeOrigin": true,
65
+ "pathRewrite": {
66
+ "^/enfyra": ""
67
+ }
68
+ },
69
+ "/socket.io/**": {
70
+ "target": "https://demo.enfyra.io/api/ws",
71
+ "secure": true,
72
+ "changeOrigin": true,
73
+ "ws": true
74
+ }
75
+ }
76
+
77
+ // angular.json
78
+ {
79
+ "projects": {
80
+ "app": {
81
+ "architect": {
82
+ "serve": {
83
+ "options": {
84
+ "proxyConfig": "src/proxy.conf.json"
85
+ }
86
+ }
87
+ }
88
+ }
89
+ }
90
+ }`,
91
+ notes: [
92
+ 'Browser code still calls /enfyra/login, /enfyra/me, /enfyra/logout, and /enfyra/<table>.',
93
+ 'The /enfyra proxy strips the prefix before forwarding to the Enfyra API origin.',
94
+ 'The /socket.io proxy forwards to the Enfyra app bridge /ws/socket.io while keeping the browser transport path as /socket.io.',
95
+ 'Restart ng serve after changing proxy.conf.json.',
96
+ ],
97
+ },
98
+ {
99
+ name: 'Password login and current user fetch',
100
+ code: `await fetch("/enfyra/login", {
101
+ method: "POST",
102
+ credentials: "include",
103
+ headers: { "Content-Type": "application/json" },
104
+ body: JSON.stringify({ email, password, remember: true })
105
+ })
106
+
107
+ const me = await fetch("/enfyra/me", {
108
+ credentials: "include"
109
+ }).then((res) => res.ok ? res.json() : null)`,
110
+ notes: [
111
+ 'Use /login, not /auth/login, for app/browser cookie login.',
112
+ 'Do not read or store JWTs in browser JavaScript in proxy-cookie mode.',
113
+ ],
114
+ },
115
+ {
116
+ name: 'Nuxt client plugin for authenticated realtime',
117
+ code: `// composables/useRealtime.ts
118
+ import { io, type Socket } from "socket.io-client"
119
+ import { readonly, ref, shallowRef } from "vue"
120
+
121
+ const socket = shallowRef<Socket | null>(null)
122
+ const isConnected = ref(false)
123
+
124
+ export function useRealtime() {
125
+ function connect() {
126
+ if (import.meta.server) return null
127
+ if (socket.value) return socket.value
128
+
129
+ const nextSocket = io("/chat", {
130
+ path: "/socket.io",
131
+ withCredentials: true,
132
+ reconnection: true,
133
+ reconnectionAttempts: Infinity,
134
+ reconnectionDelay: 2000,
135
+ reconnectionDelayMax: 30000
136
+ })
137
+
138
+ nextSocket.on("connect", () => {
139
+ isConnected.value = true
140
+ })
141
+ nextSocket.on("disconnect", () => {
142
+ isConnected.value = false
143
+ })
144
+
145
+ socket.value = nextSocket
146
+ return nextSocket
147
+ }
148
+
149
+ function disconnect() {
150
+ if (!socket.value) return
151
+ socket.value.disconnect()
152
+ socket.value = null
153
+ isConnected.value = false
154
+ }
155
+
156
+ function onMessage(handler) {
157
+ const activeSocket = socket.value ?? connect()
158
+ if (!activeSocket) return () => {}
159
+ activeSocket.on("chat:message", handler)
160
+ return () => activeSocket.off("chat:message", handler)
161
+ }
162
+
163
+ return { socket, isConnected: readonly(isConnected), connect, disconnect, onMessage }
164
+ }
165
+
166
+ // plugins/realtime.client.ts
167
+ import { watch } from "vue"
168
+
169
+ export default defineNuxtPlugin(() => {
170
+ const { me } = useAuth()
171
+ const realtime = useRealtime()
172
+
173
+ watch(
174
+ me,
175
+ user => {
176
+ if (user) realtime.connect()
177
+ else realtime.disconnect()
178
+ },
179
+ { immediate: true }
180
+ )
181
+ })
182
+
183
+ // pages/chat.vue
184
+ const realtime = useRealtime()
185
+ let stopRealtime = () => {}
186
+
187
+ onMounted(() => {
188
+ stopRealtime = realtime.onMessage(event => {
189
+ // Update local UI state, then debounce REST refresh if full state is needed.
190
+ })
191
+ })
192
+
193
+ onUnmounted(() => {
194
+ stopRealtime()
195
+ })`,
196
+ notes: [
197
+ 'Create the socket once in a client-only plugin after auth has resolved; pages should not own the initial connection lifecycle.',
198
+ 'Use the websocket namespace path from live metadata, such as /chat, and keep the transport path as /socket.io.',
199
+ 'Proxy /socket.io/** to the Enfyra app bridge /ws/socket.io/** so cookies are same-origin.',
200
+ 'Route components add event listeners and remove them on unmount; they can optimistically update local state and debounce REST refreshes.',
201
+ 'Disconnect the singleton socket when the current user/session clears.',
202
+ ],
203
+ },
204
+ {
205
+ name: 'Angular HttpClient auth service and route guard',
206
+ code: `// app.config.ts
207
+ import { ApplicationConfig, inject } from "@angular/core"
208
+ import { provideRouter, CanActivateFn, Router } from "@angular/router"
209
+ import { HttpInterceptorFn, provideHttpClient, withInterceptors } from "@angular/common/http"
210
+ import { catchError, map, of } from "rxjs"
211
+
212
+ import { routes } from "./app.routes"
213
+ import { EnfyraAuthService } from "./enfyra-auth.service"
214
+
215
+ export const enfyraCredentialsInterceptor: HttpInterceptorFn = (req, next) => {
216
+ if (!req.url.startsWith("/enfyra/")) return next(req)
217
+ return next(req.clone({ withCredentials: true }))
218
+ }
219
+
220
+ export const requireUserGuard: CanActivateFn = () => {
221
+ const auth = inject(EnfyraAuthService)
222
+ const router = inject(Router)
223
+
224
+ return auth.loadMe().pipe(
225
+ map(user => user ? true : router.createUrlTree(["/login"])),
226
+ catchError(() => of(router.createUrlTree(["/login"])))
227
+ )
228
+ }
229
+
230
+ export const appConfig: ApplicationConfig = {
231
+ providers: [
232
+ provideHttpClient(withInterceptors([enfyraCredentialsInterceptor])),
233
+ provideRouter(routes)
234
+ ]
235
+ }
236
+
237
+ // enfyra-auth.service.ts
238
+ import { Injectable, signal } from "@angular/core"
239
+ import { HttpClient } from "@angular/common/http"
240
+ import { Observable, tap } from "rxjs"
241
+
242
+ type EnfyraUser = { id: string | number; email?: string }
243
+
244
+ @Injectable({ providedIn: "root" })
245
+ export class EnfyraAuthService {
246
+ readonly user = signal<EnfyraUser | null>(null)
247
+
248
+ constructor(private readonly http: HttpClient) {}
249
+
250
+ login(email: string, password: string): Observable<unknown> {
251
+ return this.http.post("/enfyra/login", { email, password, remember: true }).pipe(
252
+ tap(() => this.loadMe().subscribe())
253
+ )
254
+ }
255
+
256
+ loadMe(): Observable<EnfyraUser | null> {
257
+ return this.http.get<EnfyraUser | null>("/enfyra/me").pipe(
258
+ tap(user => this.user.set(user))
259
+ )
260
+ }
261
+
262
+ logout(): Observable<unknown> {
263
+ return this.http.post("/enfyra/logout", {}).pipe(
264
+ tap(() => this.user.set(null))
265
+ )
266
+ }
267
+
268
+ startGoogleOAuth(returnPath = "/") {
269
+ const redirect = new URL(returnPath, window.location.origin)
270
+ const url = new URL("/enfyra/auth/google", window.location.origin)
271
+ url.searchParams.set("redirect", redirect.toString())
272
+ url.searchParams.set("cookieBridgePrefix", "/enfyra")
273
+ window.location.href = url.toString()
274
+ }
275
+ }`,
276
+ notes: [
277
+ 'Use HttpClient with a credentials interceptor for /enfyra/* calls so cookies are sent consistently.',
278
+ 'The guard is only for user experience; Enfyra route permissions and server-side owner checks remain authoritative.',
279
+ 'Keep the current user in an Angular service or store; do not read JWTs from cookies or URLs.',
280
+ 'OAuth starts at the app proxy path and returns through the cookie bridge before the Angular route loads /enfyra/me.',
281
+ ],
282
+ },
283
+ {
284
+ name: 'Next client provider for authenticated realtime',
285
+ code: `"use client"
286
+
287
+ // app/realtime-provider.tsx
288
+ import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react"
289
+ import { io, type Socket } from "socket.io-client"
290
+
291
+ type RealtimeContextValue = {
292
+ socket: Socket | null
293
+ isConnected: boolean
294
+ }
295
+
296
+ const RealtimeContext = createContext<RealtimeContextValue>({
297
+ socket: null,
298
+ isConnected: false
299
+ })
300
+
301
+ export function RealtimeProvider({
302
+ user,
303
+ children
304
+ }: {
305
+ user: { id: string | number } | null
306
+ children: React.ReactNode
307
+ }) {
308
+ const socketRef = useRef<Socket | null>(null)
309
+ const [isConnected, setConnected] = useState(false)
310
+
311
+ useEffect(() => {
312
+ if (!user) {
313
+ socketRef.current?.disconnect()
314
+ socketRef.current = null
315
+ setConnected(false)
316
+ return
317
+ }
318
+
319
+ if (socketRef.current) return
320
+
321
+ const socket = io("/chat", {
322
+ path: "/socket.io",
323
+ withCredentials: true,
324
+ reconnection: true,
325
+ reconnectionAttempts: Infinity,
326
+ reconnectionDelay: 2000,
327
+ reconnectionDelayMax: 30000
328
+ })
329
+
330
+ socket.on("connect", () => setConnected(true))
331
+ socket.on("disconnect", () => setConnected(false))
332
+ socketRef.current = socket
333
+
334
+ return () => {
335
+ socket.off("connect")
336
+ socket.off("disconnect")
337
+ socket.disconnect()
338
+ socketRef.current = null
339
+ setConnected(false)
340
+ }
341
+ }, [user])
342
+
343
+ const value = useMemo(
344
+ () => ({ socket: socketRef.current, isConnected }),
345
+ [isConnected]
346
+ )
347
+
348
+ return <RealtimeContext.Provider value={value}>{children}</RealtimeContext.Provider>
349
+ }
350
+
351
+ export function useRealtime() {
352
+ return useContext(RealtimeContext)
353
+ }
354
+
355
+ // app/chat/page.tsx
356
+ // const { socket } = useRealtime()
357
+ // useEffect(() => {
358
+ // if (!socket) return
359
+ // const onMessage = event => {
360
+ // // Update local UI state, then debounce REST refresh if full state is needed.
361
+ // }
362
+ // socket.on("chat:message", onMessage)
363
+ // return () => socket.off("chat:message", onMessage)
364
+ // }, [socket])`,
365
+ notes: [
366
+ 'Create the Socket.IO client once in a top-level client provider after the current user is known.',
367
+ 'Use the websocket namespace path from live metadata, such as /chat, and keep the transport path as /socket.io.',
368
+ 'Proxy /socket.io through Next rewrites to the Enfyra app bridge /ws/socket.io so cookies remain same-origin.',
369
+ 'Pages/components should only subscribe/unsubscribe listeners; they should not create independent socket connections.',
370
+ 'Disconnect the singleton socket when the current user/session clears.',
371
+ ],
372
+ },
373
+ {
374
+ name: 'Angular singleton Socket.IO realtime service',
375
+ code: `// enfyra-realtime.service.ts
376
+ import { Injectable, computed, effect, signal } from "@angular/core"
377
+ import { io, Socket } from "socket.io-client"
378
+
379
+ import { EnfyraAuthService } from "./enfyra-auth.service"
380
+
381
+ @Injectable({ providedIn: "root" })
382
+ export class EnfyraRealtimeService {
383
+ private socket: Socket | null = null
384
+ private readonly connected = signal(false)
385
+ readonly isConnected = computed(() => this.connected())
386
+
387
+ constructor(private readonly auth: EnfyraAuthService) {
388
+ effect(() => {
389
+ const user = this.auth.user()
390
+ if (user) this.connect()
391
+ else this.disconnect()
392
+ })
393
+ }
394
+
395
+ connect() {
396
+ if (this.socket) return this.socket
397
+
398
+ this.socket = io("/chat", {
399
+ path: "/socket.io",
400
+ withCredentials: true,
401
+ reconnection: true,
402
+ reconnectionAttempts: Infinity,
403
+ reconnectionDelay: 2000,
404
+ reconnectionDelayMax: 30000
405
+ })
406
+
407
+ this.socket.on("connect", () => this.connected.set(true))
408
+ this.socket.on("disconnect", () => this.connected.set(false))
409
+ return this.socket
410
+ }
411
+
412
+ disconnect() {
413
+ this.socket?.disconnect()
414
+ this.socket = null
415
+ this.connected.set(false)
416
+ }
417
+
418
+ onMessage(handler: (event: unknown) => void) {
419
+ const activeSocket = this.connect()
420
+ activeSocket.on("chat:message", handler)
421
+ return () => activeSocket.off("chat:message", handler)
422
+ }
423
+ }`,
424
+ notes: [
425
+ 'Create one app-level Socket.IO connection after auth is known.',
426
+ 'Use the websocket namespace path from live metadata, such as /chat, and keep the transport path as /socket.io.',
427
+ 'Components subscribe with onMessage and call the returned cleanup function in ngOnDestroy.',
428
+ 'Do not create a new socket per routed component.',
429
+ ],
430
+ },
431
+ {
432
+ name: 'OAuth provider setup values',
433
+ code: `// Enfyra OAuth config row, stored in enfyra_oauth_config.
434
+ {
435
+ "provider": "google",
436
+ "clientId": "<google-client-id>",
437
+ "clientSecret": "<google-client-secret>",
438
+ "redirectUri": "http://localhost:3000/api/auth/google/callback",
439
+ "isEnabled": true
440
+ }
441
+
442
+ // Google Cloud Console -> Authorized redirect URIs:
443
+ // http://localhost:3000/api/auth/google/callback`,
444
+ notes: [
445
+ 'redirectUri is the Enfyra callback URL: {ENFYRA_API_URL}/auth/google/callback.',
446
+ 'The provider console callback URL and enfyra_oauth_config.redirectUri must match exactly.',
447
+ 'This callback URL is not the app return page; the app return page is sent as the redirect query when starting OAuth.',
448
+ 'Use appCallbackUrl only for manual-token apps that intentionally read token query parameters.',
449
+ ],
450
+ },
451
+ {
452
+ name: 'Google OAuth button',
453
+ code: `const redirect = new URL("/chat", window.location.origin)
454
+ const url = new URL("/enfyra/auth/google", window.location.origin)
455
+ url.searchParams.set("redirect", redirect.toString())
456
+ url.searchParams.set("cookieBridgePrefix", "/enfyra")
457
+ window.location.href = url.toString()`,
458
+ notes: [
459
+ 'redirect must be absolute and must include the app origin.',
460
+ 'cookieBridgePrefix is the app proxy prefix that forwards to Enfyra API routes.',
461
+ 'Enfyra redirects through {redirect.origin}{cookieBridgePrefix}/auth/set-cookies before returning to redirect.',
462
+ 'After returning, call /enfyra/me to load the authenticated user; do not parse tokens from the URL in proxy-cookie mode.',
463
+ ],
464
+ },
465
+ ],
466
+ },
467
+ 'oauth-setup': {
468
+ title: 'OAuth provider setup',
469
+ useWhen: 'Use when configuring Google or another OAuth provider for an Enfyra-backed app.',
470
+ examples: [
471
+ {
472
+ name: 'Google OAuth setup workflow',
473
+ code: `// 1. Ask for the public app/admin URL, not the API URL.
474
+ // Example input from the user:
475
+ const appUrl = "https://demo.enfyra.io"
476
+
477
+ // 2. Derive the Enfyra API base and provider callback.
478
+ const apiBase = appUrl.replace(/\\/$/, "") + "/api"
479
+ const googleCallbackUrl = apiBase + "/auth/google/callback"
480
+
481
+ // 3. Tell the user to paste this exact value into Google Cloud Console:
482
+ // APIs & Services -> Credentials -> OAuth 2.0 Client -> Authorized redirect URIs
483
+ // https://demo.enfyra.io/api/auth/google/callback
484
+
485
+ // 4. After the user provides Google client id/secret, save Enfyra config:
486
+ create_record({
487
+ tableName: "enfyra_oauth_config",
488
+ body: JSON.stringify({
489
+ provider: "google",
490
+ clientId: "<google-client-id>",
491
+ clientSecret: "<google-client-secret>",
492
+ redirectUri: googleCallbackUrl,
493
+ isEnabled: true
494
+ })
495
+ })`,
496
+ notes: [
497
+ 'Ask for the app/admin URL such as https://demo.enfyra.io; derive the API base by appending /api.',
498
+ 'The provider callback is {appUrl}/api/auth/{provider}/callback and must exactly match the Authorized redirect URI in Google Cloud Console.',
499
+ 'Do not ask the user to choose or type the callback URL manually once the app URL is known; compute it and show the exact value to paste.',
500
+ 'The OAuth callback is the Enfyra provider callback, not the final app page.',
501
+ 'When starting OAuth from a browser app, use the same-origin proxy route with redirect and cookieBridgePrefix as shown in ssr-app-auth examples.',
502
+ ],
503
+ },
504
+ {
505
+ name: 'Browser OAuth start URL after setup',
506
+ code: `const returnUrl = new URL("/dashboard", window.location.origin)
507
+ const oauthUrl = new URL("/enfyra/auth/google", window.location.origin)
508
+ oauthUrl.searchParams.set("redirect", returnUrl.toString())
509
+ oauthUrl.searchParams.set("cookieBridgePrefix", "/enfyra")
510
+ window.location.href = oauthUrl.toString()`,
511
+ notes: [
512
+ 'This is the browser start URL through the app proxy; it is different from the Google Authorized redirect URI.',
513
+ 'After Enfyra finishes the Google callback, it bridges cookies through /enfyra/auth/set-cookies and returns to the absolute redirect URL.',
514
+ 'After return, call /enfyra/me to load the user.',
515
+ ],
516
+ },
517
+ {
518
+ name: 'Update an existing Google OAuth config',
519
+ code: `const existing = await query_table({
520
+ tableName: "enfyra_oauth_config",
521
+ filter: JSON.stringify({ provider: { _eq: "google" } }),
522
+ fields: ["id", "provider", "redirectUri", "isEnabled"],
523
+ limit: 1
524
+ })
525
+
526
+ // If a row exists, update it instead of creating a duplicate.
527
+ update_record({
528
+ tableName: "enfyra_oauth_config",
529
+ id: "<existing-config-id>",
530
+ body: JSON.stringify({
531
+ clientId: "<google-client-id>",
532
+ clientSecret: "<google-client-secret>",
533
+ redirectUri: "https://demo.enfyra.io/api/auth/google/callback",
534
+ isEnabled: true
535
+ })
536
+ })`,
537
+ notes: [
538
+ 'Inspect first so setup is idempotent.',
539
+ 'Use the current system table name enfyra_oauth_config.',
540
+ 'Never expose the client secret back in app code or documentation.',
541
+ ],
542
+ },
543
+ ],
544
+ },
545
+ 'schema-relations': {
546
+ title: 'Tables, columns, relations, cascade, and indexes',
547
+ useWhen: 'Use when creating or changing persisted data models.',
548
+ examples: [
549
+ {
550
+ name: 'Create a chat conversation table',
551
+ code: `create_table({
552
+ name: "chat_conversation",
553
+ globalRulesAckKey: "<globalRulesAckKey from get_enfyra_required_knowledge>",
554
+ columns: JSON.stringify([
555
+ { name: "kind", type: "varchar", isNullable: false, defaultValue: "dm" },
556
+ { name: "title", type: "varchar", isNullable: true },
557
+ { name: "description", type: "text", isNullable: true }
558
+ ])
559
+ })`,
560
+ notes: [
561
+ 'Chat is the illustrative domain here. For another domain, keep the same modeling question: what is the parent entity, what is stored on the parent, and what belongs on child rows?',
562
+ 'create_table creates the default route for /chat_conversation.',
563
+ 'Keep the latest message as a relation named lastMessage after chat_message exists; do not duplicate last message text/date columns.',
564
+ 'Do not create tables just to get custom paths; use create_route for that.',
565
+ ],
566
+ },
567
+ {
568
+ name: 'Create relations directly to enfyra_user',
569
+ code: `create_table({
570
+ name: "chat_message",
571
+ globalRulesAckKey: "<globalRulesAckKey from get_enfyra_required_knowledge>",
572
+ columns: JSON.stringify([
573
+ { name: "text", type: "text", isNullable: false },
574
+ { name: "persistStatus", type: "varchar", defaultValue: "persisted" }
575
+ ]),
576
+ relations: JSON.stringify([
577
+ {
578
+ propertyName: "conversation",
579
+ type: "many-to-one",
580
+ targetTable: { id: "<chat_conversation_id>" },
581
+ isNullable: false,
582
+ onDelete: "CASCADE"
583
+ },
584
+ {
585
+ propertyName: "sender",
586
+ type: "many-to-one",
587
+ targetTable: { id: "<enfyra_user_id>" },
588
+ isNullable: false,
589
+ onDelete: "CASCADE"
590
+ }
591
+ ]),
592
+ indexes: JSON.stringify([
593
+ ["conversation", "createdAt"]
594
+ ])
595
+ })`,
596
+ notes: [
597
+ 'The relation names conversation and sender are examples of domain language; choose relation property names that match the entity model users reason about.',
598
+ 'Use enfyra_user as the user table.',
599
+ 'Use table ids for targetTable when already known; MCP can also resolve exact table names such as "enfyra_user" before schema mutation.',
600
+ 'Do not add inverse relations on enfyra_user unless a concrete user-to-record response, UI, or deep query will use it.',
601
+ 'Do not add inverse relations just because a parent table exists. If messages are read by querying chat_message with conversation/member filters, conversation.messages and user.messages are not needed.',
602
+ 'createdAt, updatedAt, and custom date/datetime/timestamp fields already get auto-generated single-field indexes; add only compound indexes needed by hot filters.',
603
+ 'Do not provide physical FK column names; Enfyra derives them.',
604
+ ],
605
+ },
606
+ {
607
+ name: 'Add an inverse only for a planned parent child-list query',
608
+ code: `create_relation({
609
+ sourceTableId: "chat_message",
610
+ targetTableId: "chat_conversation",
611
+ globalRulesAckKey: "<globalRulesAckKey from get_enfyra_required_knowledge>",
612
+ propertyName: "conversation",
613
+ inversePropertyName: "messages",
614
+ type: "many-to-one",
615
+ isNullable: false,
616
+ onDelete: "CASCADE"
617
+ })`,
618
+ notes: [
619
+ 'Use inversePropertyName only when the parent table will actually expose, deep-load, count, or sort by that child collection.',
620
+ 'For example, conversation.messages is justified if a conversation detail response loads the latest message page with deep.messages limit/sort, or if a list sorts by _max(messages.createdAt).',
621
+ 'Translate this to the current domain by asking whether the parent screen needs a child collection or aggregate; if not, keep the relation one-directional.',
622
+ 'If the app only filters chat_message by conversation.id, omit inversePropertyName and keep the schema one-directional.',
623
+ 'Before creating an inverse, inspect existing relations and state why the reverse traversal is needed.',
624
+ ],
625
+ },
626
+ {
627
+ name: 'Add chat_conversation.lastMessage after chat_message exists',
628
+ code: `update_table({
629
+ tableId: "<chat_conversation_id>",
630
+ globalRulesAckKey: "<globalRulesAckKey from get_enfyra_required_knowledge>",
631
+ relations: JSON.stringify([
632
+ {
633
+ propertyName: "createdBy",
634
+ type: "many-to-one",
635
+ targetTable: { id: "<enfyra_user_id>" },
636
+ isNullable: true,
637
+ onDelete: "CASCADE"
638
+ },
639
+ {
640
+ propertyName: "lastMessage",
641
+ type: "many-to-one",
642
+ targetTable: { id: "<chat_message_id>" },
643
+ isNullable: true,
644
+ onDelete: "SET NULL"
645
+ }
646
+ ])
647
+ })`,
648
+ notes: [
649
+ 'Use relation fields such as lastMessage.id,lastMessage.text,lastMessage.createdAt when loading conversation lists.',
650
+ 'When deleting the current last message, a post-hook should set lastMessage to the newest remaining message or null.',
651
+ ],
652
+ },
653
+ {
654
+ name: 'Unread/read table with unique and indexes',
655
+ code: `create_table({
656
+ name: "chat_message_read",
657
+ globalRulesAckKey: "<globalRulesAckKey from get_enfyra_required_knowledge>",
658
+ columns: JSON.stringify([
659
+ { name: "isRead", type: "boolean", defaultValue: false },
660
+ { name: "readAt", type: "datetime", isNullable: true }
661
+ ]),
662
+ relations: JSON.stringify([
663
+ { propertyName: "message", type: "many-to-one", targetTable: { id: "<chat_message_id>" }, onDelete: "CASCADE" },
664
+ { propertyName: "conversation", type: "many-to-one", targetTable: { id: "<chat_conversation_id>" }, onDelete: "CASCADE" },
665
+ { propertyName: "member", type: "many-to-one", targetTable: { id: "<enfyra_user_id>" }, onDelete: "CASCADE" }
666
+ ]),
667
+ uniques: JSON.stringify([["message", "member"]]),
668
+ indexes: JSON.stringify([
669
+ ["conversation", "isRead"]
670
+ ])
671
+ })`,
672
+ notes: [
673
+ 'Unread is per user and per message; do not put global read state on conversation.',
674
+ 'message and member appear in the unique constraint, so they must not be added to indexes; unique fields are already indexed by the unique constraint.',
675
+ 'readAt is a datetime field and gets its own auto index; explicit indexes should cover only non-unique hot lookup fields.',
676
+ 'For chat-list UX, default to a boolean unread dot instead of exact counts.',
677
+ ],
678
+ },
679
+ {
680
+ name: 'Add server-owned user verification fields',
681
+ code: `create_column({
682
+ tableId: "<enfyra_user_table_id>",
683
+ name: "emailVerifiedAt",
684
+ globalRulesAckKey: "<globalRulesAckKey from get_enfyra_required_knowledge>",
685
+ type: "datetime",
686
+ isNullable: true,
687
+ isPublished: true,
688
+ description: "When the user's email address was verified."
689
+ })
690
+
691
+ create_column({
692
+ tableId: "<enfyra_user_table_id>",
693
+ name: "emailVerificationStatus",
694
+ globalRulesAckKey: "<globalRulesAckKey from get_enfyra_required_knowledge>",
695
+ type: "varchar",
696
+ isNullable: false,
697
+ defaultValue: "pending",
698
+ isPublished: true,
699
+ description: "Email verification state controlled by server hooks."
700
+ })
701
+
702
+ create_column({
703
+ tableId: "<integration_secret_table_id>",
704
+ name: "value",
705
+ globalRulesAckKey: "<globalRulesAckKey from get_enfyra_required_knowledge>",
706
+ type: "text",
707
+ isNullable: false,
708
+ isPublished: false,
709
+ isEncrypted: true,
710
+ description: "Encrypted secret value."
711
+ })`,
712
+ notes: [
713
+ 'Run schema-changing calls sequentially. Do not parallelize create_column calls.',
714
+ 'create_column fetches enfyra_table and patches only real persisted columns with id/_id; generated metadata projections such as createdAt, updatedAt, or relation FK display fields are skipped.',
715
+ 'Use isEncrypted=true for encryption at rest. Add isUpdatable=false separately only when the field should be immutable.',
716
+ 'Use hooks or field permissions to prevent clients from updating server-owned fields.',
717
+ ],
718
+ },
719
+ {
720
+ name: 'Patch table schema from metadata only',
721
+ code: `// Safe schema patch process used by create_column/update_column/delete_column:
722
+ // 1. Read GET /metadata and find the target table.
723
+ // 2. Keep only persisted column rows with id/_id.
724
+ // 3. Add, change, or remove the intended column.
725
+ // 4. PATCH /enfyra_table/:id with the full preserved columns array.
726
+ // 5. If the backend returns requiredConfirmHash, resend with ?schemaConfirmHash=<hash>.
727
+ // 6. Re-read metadata and verify unrelated column ids still exist.
728
+
729
+ create_column({
730
+ tableId: "<table_id>",
731
+ name: "api_secret",
732
+ globalRulesAckKey: "<globalRulesAckKey from get_enfyra_required_knowledge>",
733
+ type: "text",
734
+ isPublished: false,
735
+ isEncrypted: true
736
+ })`,
737
+ notes: [
738
+ 'Do not rebuild schema cascade payloads from enfyra_table?fields=columns.*; nested fields can be truncated or relation-derived.',
739
+ 'Generated projections such as createdAt, updatedAt, and relation FK display fields without id/_id are not valid enfyra_column rows.',
740
+ 'Never delete or omit unrelated persisted columns when adding one field.',
741
+ 'Run schema-changing calls sequentially; migration locks are backend-owned.',
742
+ ],
743
+ },
744
+ ],
745
+ },
746
+ 'queries-deep': {
747
+ title: 'REST queries, filters, meta counts, and deep relation fetches',
748
+ useWhen: 'Use when fetching records, filtering by relations, loading nested relation data in the same request, or counting efficiently.',
749
+ examples: [
750
+ {
751
+ name: 'Minimal MCP query then explicit detail query',
752
+ code: `query_table({
753
+ tableName: "enfyra_user",
754
+ fields: ["id", "email"],
755
+ filter: "{\\"email\\":{\\"_contains\\":\\"@example.com\\"}}",
756
+ limit: 10
757
+ })`,
758
+ notes: [
759
+ 'Always pass fields when you need more than ids; query_table without fields intentionally returns only the primary key.',
760
+ 'Use inspect_table first when you do not know valid column names or relation propertyName values.',
761
+ 'Use count_records when only the count is needed.',
762
+ 'When the user asks for all matching rows, pass all: true instead of choosing an arbitrary page size such as 30 or 50.',
763
+ ],
764
+ },
765
+ {
766
+ name: 'List current user conversations through RLS',
767
+ code: `query_table({
768
+ tableName: "chat_conversation",
769
+ fields: ["id", "kind", "title", "lastMessage.id", "lastMessage.text", "lastMessage.createdAt"],
770
+ all: true
771
+ })`,
772
+ notes: [
773
+ 'Use a conversation read pre-hook/RLS boundary so the route only returns conversations visible to @USER.',
774
+ 'lastMessage is a relation to chat_message; do not duplicate preview fields on chat_conversation.',
775
+ 'all: true tells MCP to send REST limit=0 and load all matching conversation rows.',
776
+ 'This is a small bounded user inbox example. For larger inventories, prefer pagination even when RLS scopes the records.',
777
+ 'Do not fetch messages for every conversation on initial list load; load messages after selecting a conversation.',
778
+ ],
779
+ },
780
+ {
781
+ name: 'Fetch one record by id',
782
+ code: `find_one_record({
783
+ tableName: "post",
784
+ id: "123",
785
+ fields: ["id", "title", "createdAt"]
786
+ })
787
+
788
+ // REST equivalent after inspecting metadata primary key:
789
+ GET /enfyra/post?filter={"<primaryKeyFromMetadata>":{"_eq":123}}&limit=1`,
790
+ notes: [
791
+ 'There is no dynamic GET /<table>/<id> route.',
792
+ 'Prefer MCP find_one_record because it resolves the primary key from live metadata.',
793
+ 'If writing raw REST, inspect metadata first and use the real primary key field; do not assume id on every backend.',
794
+ ],
795
+ },
796
+ {
797
+ name: 'Count without loading all rows',
798
+ code: `query_table({
799
+ tableName: "chat_message_read",
800
+ fields: ["id"],
801
+ limit: 1,
802
+ meta: "filterCount",
803
+ filter: JSON.stringify({
804
+ member: { id: { _eq: "<currentUserId>" } },
805
+ isRead: { _eq: false }
806
+ })
807
+ })`,
808
+ notes: [
809
+ 'Use meta=totalCount with no filter and meta=filterCount with a filter.',
810
+ 'MCP count_records wraps this pattern for simple counts.',
811
+ 'Do not fetch all rows only to count them.',
812
+ ],
813
+ },
814
+ {
815
+ name: 'Relation fields without deep',
816
+ code: `query_table({
817
+ tableName: "order",
818
+ fields: [
819
+ "id",
820
+ "total",
821
+ "customer.id",
822
+ "customer.email",
823
+ "customer.displayName"
824
+ ],
825
+ limit: 20
826
+ })`,
827
+ notes: [
828
+ 'Use fields with dotted relation paths when you only need scalar fields from related records.',
829
+ 'This is enough for simple many-to-one or one-to-one relation display such as owner.email, customer.name, or lastMessage.text.',
830
+ 'Treat order/customer as placeholders; the transferable idea is "show parent rows with a few scalar relation fields".',
831
+ 'Do not add deep when fields alone can express the relation data you need.',
832
+ ],
833
+ },
834
+ {
835
+ name: 'Exclude large generated fields',
836
+ code: `query_table({
837
+ tableName: "enfyra_route_handler",
838
+ fields: ["-compiledCode"],
839
+ limit: 20
840
+ })
841
+
842
+ query_table({
843
+ tableName: "post",
844
+ fields: ["id", "-author.avatar"],
845
+ deep: JSON.stringify({
846
+ comments: {
847
+ fields: "-compiledCode,-author.avatar",
848
+ limit: 10,
849
+ deep: {
850
+ author: { fields: "-avatar" }
851
+ }
852
+ }
853
+ })
854
+ })`,
855
+ notes: [
856
+ 'Use fields=-compiledCode when reading script-backed records; sourceCode is the editable contract and compiledCode is generated by the server.',
857
+ 'Any -field token switches that fields scope to exclude mode, so fields=id,-compiledCode returns all readable fields except compiledCode.',
858
+ 'Dotted exclusions and deep relation fields use the same exclude-mode rule.',
859
+ 'Excluded fields and relations must exist in metadata; typos should fail instead of silently returning large or sensitive fields.',
860
+ ],
861
+ },
862
+ {
863
+ name: 'Deep relation query options',
864
+ code: `query_table({
865
+ tableName: "order",
866
+ fields: ["id", "total"],
867
+ deep: JSON.stringify({
868
+ items: {
869
+ fields: "id,quantity,product",
870
+ sort: "-createdAt",
871
+ limit: 20,
872
+ deep: {
873
+ product: { fields: "id,name,price" }
874
+ }
875
+ }
876
+ })
877
+ })`,
878
+ notes: [
879
+ 'Use deep when relation loading needs query options such as filter, sort, limit, page, or nested deep.',
880
+ 'Deep is mainly useful for controlled child collections or nested relation fetches, not for basic related-field display.',
881
+ 'Do not use deep just to filter by a relation id; use a normal relation filter instead.',
882
+ 'Do not use deep for counts; use count_records or meta=filterCount/totalCount.',
883
+ 'Do not deep-load large child collections without an explicit limit/page. For heavy screens, fetch the parent list first, then load the selected child collection separately with pagination.',
884
+ 'Use query_table deep for normal MCP reads; use test_rest_endpoint only when you need a custom raw URL or route behavior test.',
885
+ 'deep keys must be relation property names.',
886
+ 'Allowed deep options are fields, filter, sort, limit, page, and deep.',
887
+ 'Do not invent deep keys like members unless members is a relation on that table.',
888
+ ],
889
+ },
890
+ {
891
+ name: 'Sort parent rows by child relation aggregates',
892
+ code: `query_table({
893
+ tableName: "cloud_support_tickets",
894
+ fields: [
895
+ "id",
896
+ "subject",
897
+ "status",
898
+ "project.id",
899
+ "project.name"
900
+ ],
901
+ sort: "-_max(messages.createdAt),-createdAt",
902
+ limit: 25,
903
+ deep: JSON.stringify({
904
+ messages: {
905
+ fields: "id,authorKind,body,createdAt",
906
+ sort: "-createdAt",
907
+ limit: 3
908
+ }
909
+ })
910
+ })
911
+
912
+ // Other parent aggregate sorts:
913
+ // sort=-_count(messages)
914
+ // sort=_min(messages.createdAt)`,
915
+ notes: [
916
+ 'Use _max(relation.field) for latest-child ordering, _min(relation.field) for earliest-child ordering, and _count(relation) for child-count ordering.',
917
+ 'Aggregate sort helpers only work on direct one-to-many or many-to-many list relations.',
918
+ 'Support tickets and messages are illustrative. Apply this when a parent list must be ordered by child recency or child volume.',
919
+ 'The aggregate field must be a real published, non-encrypted scalar field on the related table for user-facing APIs.',
920
+ 'Do not use _max, _min, or _count on private relations or unpublished fields unless the endpoint intentionally exposes that fact.',
921
+ 'Do not use raw sort=-messages.createdAt for parent ordering; it is ambiguous and rejected.',
922
+ 'deep.messages.sort only orders the loaded message rows inside each ticket, so keep parent sort and child pagination as separate concerns.',
923
+ ],
924
+ },
925
+ {
926
+ name: 'Encrypted fields are not lookup fields',
927
+ code: `// Bad: api_token is isEncrypted=true, so filter/sort cannot use it.
928
+ GET /enfyra/integrations?filter={"api_token":{"_eq":"plaintext-token"}}
929
+
930
+ // Good: store a separate non-secret lookup hash if lookup is needed.
931
+ create_column({
932
+ tableId: "<integrations_table_id>",
933
+ name: "api_token_lookup_sha256",
934
+ globalRulesAckKey: "<globalRulesAckKey from get_enfyra_required_knowledge>",
935
+ type: "varchar",
936
+ isNullable: false,
937
+ isPublished: false
938
+ })
939
+
940
+ // In the create/update handler or pre-hook, hash plaintext before it is encrypted.
941
+ if (@BODY.api_token) {
942
+ @BODY.api_token_lookup_sha256 = @HELPERS.$crypto.sha256(@BODY.api_token)
943
+ }
944
+
945
+ // Lookup by the hash, never by the encrypted field.
946
+ const lookup = @HELPERS.$crypto.sha256(@BODY.api_token)
947
+ const found = await #integrations.find({
948
+ filter: { api_token_lookup_sha256: { _eq: lookup } },
949
+ limit: 1
950
+ })`,
951
+ notes: [
952
+ 'isEncrypted values are encrypted at rest and decrypted after select.',
953
+ 'Do not filter, sort, or deep-filter by encrypted fields.',
954
+ 'Use a separate deterministic non-secret hash/lookup column when the product needs secret-derived lookup.',
955
+ 'Do not ask clients to submit enc:v1: ciphertext.',
956
+ ],
957
+ },
958
+ ],
959
+ },
960
+ 'handlers-hooks': {
961
+ title: 'Custom handlers, pre-hooks, post-hooks, and script macros',
962
+ useWhen: 'Use when writing Enfyra dynamic JavaScript for REST behavior.',
963
+ examples: [
964
+ {
965
+ name: 'Create a route handler with current script fields',
966
+ code: `create_handler({
967
+ routeId: "<route_id>",
968
+ method: "POST",
969
+ scriptLanguage: "javascript",
970
+ globalRulesAckKey: "<globalRulesAckKey from get_enfyra_required_knowledge>",
971
+ knowledgeAckKey: "<dynamicCodeAckKey from get_enfyra_required_knowledge>",
972
+ sourceCode: \`const email = @BODY.email
973
+ if (!email) @THROW400("Email is required")
974
+
975
+ return { ok: true, email }\`
976
+ })`,
977
+ notes: [
978
+ 'Use sourceCode, not logic. The server generates compiledCode.',
979
+ 'Call get_enfyra_required_knowledge before saving dynamic code, pass globalRulesAckKey as globalRulesAckKey, and pass dynamicCodeAckKey as knowledgeAckKey.',
980
+ 'Use method for one handler, or methods only when the same sourceCode should be saved for multiple methods.',
981
+ 'Do not pass name to enfyra_route_handler; one handler is identified by route + method.',
982
+ ],
983
+ },
984
+ {
985
+ name: 'Custom register handler',
986
+ code: `const email = @BODY.email
987
+ const password = @BODY.password
988
+
989
+ if (!email || !password) @THROW400("Email and password are required")
990
+
991
+ const existing = await #enfyra_user.find({
992
+ filter: { email: { _eq: email } },
993
+ limit: 1
994
+ })
995
+ if (existing.data[0]) @THROW409("Email is already registered")
996
+
997
+ const result = await #enfyra_user.create({
998
+ data: {
999
+ email,
1000
+ password: await @HELPERS.$bcrypt.hash(password)
1001
+ }
1002
+ })
1003
+
1004
+ return result.data?.[0] ?? null`,
1005
+ notes: [
1006
+ 'create/update return { data: [...] }, not a bare row.',
1007
+ 'Use @THROW helpers for HTTP errors.',
1008
+ 'Prefer macros over raw $ctx when a macro exists.',
1009
+ ],
1010
+ },
1011
+ {
1012
+ name: 'Pre-hook RLS filter merge',
1013
+ code: `const incoming = @QUERY.filter || {}
1014
+ const scope = {
1015
+ memberships: {
1016
+ member: { id: { _eq: @USER.id } }
1017
+ }
1018
+ }
1019
+
1020
+ @QUERY.filter = Object.keys(incoming).length
1021
+ ? { _and: [incoming, scope] }
1022
+ : scope`,
1023
+ notes: [
1024
+ '@QUERY.filter is initialized as an object for REST pre-hooks.',
1025
+ 'Mutate @QUERY.filter before canonical CRUD runs.',
1026
+ 'Do not override @QUERY.fields, @QUERY.deep, @QUERY.sort, @QUERY.limit, @QUERY.page, @QUERY.meta, @QUERY.aggregate, or debugMode in RLS; keep projection and pagination client-owned.',
1027
+ ],
1028
+ },
1029
+ {
1030
+ name: 'Encrypted field table definition',
1031
+ code: `create_table({
1032
+ name: "integrations",
1033
+ globalRulesAckKey: "<globalRulesAckKey from get_enfyra_required_knowledge>",
1034
+ columns: JSON.stringify([
1035
+ { name: "name", type: "varchar", isNullable: false },
1036
+ {
1037
+ name: "api_token",
1038
+ type: "varchar",
1039
+ isNullable: false,
1040
+ isPublished: false,
1041
+ isEncrypted: true
1042
+ }
1043
+ ])
1044
+ })`,
1045
+ notes: [
1046
+ 'Use isEncrypted=true for values that must be encrypted at rest.',
1047
+ 'Scripts and REST callers read and write plaintext values; Enfyra encrypts on write and decrypts after select.',
1048
+ 'Set isPublished=false for secret fields that should not be exposed by default.',
1049
+ 'isEncrypted does not imply immutability; add isUpdatable=false separately only when the value must not change.',
1050
+ 'Do not generate manual $encrypt hooks or accept caller-supplied enc:v1: ciphertext for normal app data.',
1051
+ 'Encrypted fields cannot be filtered or sorted.',
1052
+ ],
1053
+ },
1054
+ {
1055
+ name: 'Pre-hook strips protected body fields silently',
1056
+ code: `create_pre_hook({
1057
+ routeId: "<enfyra_user_patch_route_id>",
1058
+ name: "strip_email_verification_fields",
1059
+ methods: ["PATCH"],
1060
+ priority: -10,
1061
+ globalRulesAckKey: "<globalRulesAckKey from get_enfyra_required_knowledge>",
1062
+ knowledgeAckKey: "<dynamicCodeAckKey from get_enfyra_required_knowledge>",
1063
+ code: \`delete @BODY.emailVerifiedAt
1064
+ delete @BODY.emailVerificationStatus
1065
+ delete @BODY.emailVerificationSentAt\`
1066
+ })`,
1067
+ notes: [
1068
+ 'Use this pattern when clients may send protected user fields through /me or enfyra_user PATCH.',
1069
+ 'Strip fields instead of throwing when the product wants a permissive client contract with server-owned fields.',
1070
+ 'Use native macros such as @BODY instead of raw $ctx when a macro exists.',
1071
+ ],
1072
+ },
1073
+ {
1074
+ name: 'Post-hook response shaping',
1075
+ code: `create_post_hook({
1076
+ routeId: "<route_id>",
1077
+ name: "shape_display_title",
1078
+ methods: ["GET"],
1079
+ priority: 0,
1080
+ globalRulesAckKey: "<globalRulesAckKey from get_enfyra_required_knowledge>",
1081
+ knowledgeAckKey: "<dynamicCodeAckKey from get_enfyra_required_knowledge>",
1082
+ code: \`if (@ERROR) {
1083
+ @LOGS("Request failed", @ERROR.message)
1084
+ return
1085
+ }
1086
+
1087
+ const row = Array.isArray(@DATA?.data) ? @DATA.data[0] : @DATA
1088
+ if (row) {
1089
+ row.displayTitle = row.title || row.email || String(row.id)
1090
+ }
1091
+
1092
+ return @DATA\`
1093
+ })`,
1094
+ notes: [
1095
+ 'MCP create_post_hook accepts code as the tool argument, then persists sourceCode/scriptLanguage to Enfyra.',
1096
+ 'Post-hooks run after success and error paths.',
1097
+ 'Return non-undefined only when replacing the response body.',
1098
+ ],
1099
+ },
1100
+ ],
1101
+ },
1102
+ 'permissions-rls': {
1103
+ title: 'Route permissions, guards, field permissions, column rules, and RLS',
1104
+ useWhen: 'Use when securing routes or shaping what fields a user can read/write.',
1105
+ examples: [
1106
+ {
1107
+ name: 'Audit and grant authenticated route access',
1108
+ code: `audit_route_access({
1109
+ path: "/orders",
1110
+ roleName: "user",
1111
+ methods: ["GET", "POST"]
1112
+ })
1113
+
1114
+ ensure_route_access({
1115
+ path: "/orders",
1116
+ roleName: "user",
1117
+ methods: ["GET", "POST"],
1118
+ description: "Authenticated users can list and create their own orders."
1119
+ })`,
1120
+ notes: [
1121
+ 'Start with the security boundary: choose public/private methods, role or user route access, owner/tenant scope, and field exposure before writing handler or UI logic.',
1122
+ 'Use route permissions for authenticated access. The tool resolves role and method ids, validates the route available methods, merges existing methods, and reloads routes.',
1123
+ 'Handlers or pre-hooks must still enforce owner or tenant scope; route permission only lets the request pass RoleGuard.',
1124
+ 'Use publicMethods only for anonymous public access.',
1125
+ ],
1126
+ },
1127
+ {
1128
+ name: 'Make a read-only route public',
1129
+ code: `public_route_methods({
1130
+ path: "/articles",
1131
+ methods: ["GET"]
1132
+ })`,
1133
+ notes: [
1134
+ 'Use public_route_methods instead of raw enfyra_route updates; the tool resolves method ids and validates that GET is available.',
1135
+ 'publicMethods controls anonymous route access. Route permissions are not for public access.',
1136
+ 'Route permissions apply when the method is not public.',
1137
+ ],
1138
+ },
1139
+ {
1140
+ name: 'Rate limit anonymous requests by IP',
1141
+ code: `ensure_guard({
1142
+ name: "Public signup IP rate limit",
1143
+ path: "/newsletter_signup",
1144
+ methods: ["POST"],
1145
+ position: "pre_auth",
1146
+ isEnabled: true,
1147
+ description: "Limit anonymous signup attempts by client IP.",
1148
+ rules: JSON.stringify([
1149
+ {
1150
+ type: "rate_limit_by_ip",
1151
+ config: { maxRequests: 10, perSeconds: 60 },
1152
+ description: "10 signup attempts per minute per IP"
1153
+ }
1154
+ ])
1155
+ })
1156
+
1157
+ inspect_route({ path: "/newsletter_signup" })
1158
+
1159
+ test_rest_endpoint({
1160
+ method: "POST",
1161
+ path: "/newsletter_signup",
1162
+ body: { email: "test@example.com" }
1163
+ })`,
1164
+ notes: [
1165
+ 'Use pre_auth for anonymous/public route protection because no user is available yet.',
1166
+ 'Rate-limit configs use maxRequests and perSeconds.',
1167
+ 'Inspect and test after creation so the final behavior is verified through the actual REST route.',
1168
+ ],
1169
+ },
1170
+ {
1171
+ name: 'Rate limit authenticated users',
1172
+ code: `ensure_guard({
1173
+ name: "Project create per-user limit",
1174
+ path: "/projects",
1175
+ methods: ["POST"],
1176
+ position: "post_auth",
1177
+ isEnabled: true,
1178
+ description: "Authenticated users can create at most 3 projects per hour.",
1179
+ rules: JSON.stringify([
1180
+ {
1181
+ type: "rate_limit_by_user",
1182
+ config: { maxRequests: 3, perSeconds: 3600 }
1183
+ }
1184
+ ])
1185
+ })`,
1186
+ notes: [
1187
+ 'Use post_auth for rate_limit_by_user because the server only has user id after auth and RoleGuard.',
1188
+ 'This does not grant access; users still need route permissions or a public method to reach the route.',
1189
+ 'Do not put rate_limit_by_user on pre_auth guards; the server drops that rule from pre-auth trees.',
1190
+ ],
1191
+ },
1192
+ {
1193
+ name: 'Restrict an admin-only route to office IPs',
1194
+ code: `ensure_guard({
1195
+ name: "Admin reports office allowlist",
1196
+ path: "/admin/reports",
1197
+ methods: ["GET", "POST"],
1198
+ position: "pre_auth",
1199
+ isEnabled: false,
1200
+ description: "Only office network IPs can reach admin reports.",
1201
+ rules: JSON.stringify([
1202
+ {
1203
+ type: "ip_whitelist",
1204
+ config: { ips: ["203.0.113.10", "198.51.100.0/24"] }
1205
+ }
1206
+ ])
1207
+ })`,
1208
+ notes: [
1209
+ 'Create risky allowlists disabled first, then inspect the saved guard before enabling it.',
1210
+ 'IP list configs use ips; exact IPv4 addresses and IPv4 CIDR ranges are supported.',
1211
+ 'An allowlist is an additional gate, not a replacement for route permissions.',
1212
+ ],
1213
+ },
1214
+ {
1215
+ name: 'Column rule for email format',
1216
+ code: `ensure_column_rule({
1217
+ tableName: "enfyra_user",
1218
+ columnName: "email",
1219
+ ruleType: "format",
1220
+ value: JSON.stringify({ v: "email" }),
1221
+ message: "Please enter a valid email address"
1222
+ })`,
1223
+ notes: [
1224
+ 'Column rules validate canonical POST/PATCH body payloads.',
1225
+ 'The rule value payload uses the { v: ... } shape; do not pass ruleConfig.',
1226
+ 'Use column rules before writing custom validation code when the rule is simple.',
1227
+ ],
1228
+ },
1229
+ {
1230
+ name: 'Field permission condition',
1231
+ code: `ensure_field_permission({
1232
+ tableName: "project",
1233
+ columnName: "internal_notes",
1234
+ action: "read",
1235
+ effect: "allow",
1236
+ roleName: "user",
1237
+ condition: JSON.stringify({
1238
+ owner: { id: { _eq: "@USER.id" } }
1239
+ })
1240
+ })`,
1241
+ notes: [
1242
+ 'Field permissions are for field-level access.',
1243
+ 'Use route/pre-hook filters for row-level access.',
1244
+ ],
1245
+ },
1246
+ {
1247
+ name: 'Admin menu and extension permission gates',
1248
+ code: `<template>
1249
+ <section class="space-y-4">
1250
+ <PermissionGate :condition="canReadReports">
1251
+ <template #default>
1252
+ <div class="flex items-center justify-between gap-3">
1253
+ <h2 class="text-lg font-semibold">Reports</h2>
1254
+
1255
+ <PermissionGate :condition="canCreateReport">
1256
+ <UButton icon="i-lucide-plus" label="Create report" @click="openCreate = true" />
1257
+ </PermissionGate>
1258
+ </div>
1259
+
1260
+ <div v-for="report in reports" :key="report.id" class="rounded-lg border p-4">
1261
+ <NuxtLink :to="\`/reports/\${report.id}\`" class="font-medium">
1262
+ {{ report.title }}
1263
+ </NuxtLink>
1264
+
1265
+ <div class="mt-3 flex gap-2">
1266
+ <PermissionGate :condition="canUpdateReport">
1267
+ <UButton icon="i-lucide-pencil" variant="outline" label="Edit" @click="openEdit(report)" />
1268
+ </PermissionGate>
1269
+
1270
+ <PermissionGate :condition="canDeleteReport">
1271
+ <UButton icon="i-lucide-trash-2" color="error" variant="outline" label="Delete" @click="openDelete(report)" />
1272
+ </PermissionGate>
1273
+ </div>
1274
+ </div>
1275
+ </template>
1276
+
1277
+ <template #fallback>
1278
+ <EmptyState title="No access" description="You do not have permission to view reports." />
1279
+ </template>
1280
+ </PermissionGate>
1281
+ </section>
1282
+ </template>
1283
+
1284
+ <script setup>
1285
+ const { checkPermissionCondition } = usePermissions()
1286
+
1287
+ const canReadReports = computed(() => checkPermissionCondition({
1288
+ or: [
1289
+ { route: '/reports', methods: ['GET'] },
1290
+ { route: '/report', methods: ['GET'] }
1291
+ ]
1292
+ }))
1293
+
1294
+ const canCreateReport = computed(() => checkPermissionCondition({
1295
+ or: [{ route: '/report', methods: ['POST'] }]
1296
+ }))
1297
+
1298
+ const canUpdateReport = computed(() => checkPermissionCondition({
1299
+ or: [{ route: '/report', methods: ['PATCH'] }]
1300
+ }))
1301
+
1302
+ const canDeleteReport = computed(() => checkPermissionCondition({
1303
+ or: [{ route: '/report', methods: ['DELETE'] }]
1304
+ }))
1305
+ </script>`,
1306
+ notes: [
1307
+ 'This is menu/extension visibility, not row-level RLS.',
1308
+ 'Set enfyra_menu.permission on every sensitive admin menu. Example for /reports: { or: [{ route: "/reports", methods: ["GET"] }, { route: "/report", methods: ["GET"] }] }.',
1309
+ 'Admin pages are sensitive. Use permission gates by default, not as an optional polish step.',
1310
+ 'Menus should only be visible when the user has at least GET permission for the page route or backing data route.',
1311
+ 'Inside the extension, gate each action by its own route/method: GET for page visibility, POST for create/flow-trigger buttons, PATCH for normal record edits, DELETE for native delete routes.',
1312
+ 'Server route permissions remain mandatory; UI gates are for clear operator UX and least-privilege surfaces.',
1313
+ ],
1314
+ },
1315
+ ],
1316
+ },
1317
+ websocket: {
1318
+ title: 'Socket.IO gateways, events, rooms, and browser connection',
1319
+ useWhen: 'Use when creating realtime features.',
1320
+ examples: [
1321
+ {
1322
+ name: 'Browser client connection through app bridge',
1323
+ code: `import { io } from "socket.io-client"
1324
+
1325
+ const socket = io("/chat", {
1326
+ path: "/socket.io",
1327
+ withCredentials: true,
1328
+ transports: ["polling", "websocket"]
1329
+ })`,
1330
+ notes: [
1331
+ '/chat is the Socket.IO namespace.',
1332
+ '/socket.io is the app-origin transport path proxied to Enfyra app /ws/socket.io.',
1333
+ 'Do not connect browser code directly to the hidden backend.',
1334
+ ],
1335
+ },
1336
+ {
1337
+ name: 'Connection script joins user presence room',
1338
+ code: `if (!@USER?.id) {
1339
+ @SOCKET.disconnect()
1340
+ return
1341
+ }
1342
+
1343
+ @SOCKET.join(\`user_\${@USER.id}\`)
1344
+ @SOCKET.reply("chat:ready", { userId: @USER.id })`,
1345
+ notes: [
1346
+ 'Authenticated Enfyra sockets already load @USER.',
1347
+ 'Enfyra also joins user_<userId> for emitToUser delivery after connection succeeds.',
1348
+ ],
1349
+ },
1350
+ {
1351
+ name: 'Chat join event',
1352
+ code: `const conversationId = @BODY.conversationId
1353
+ if (!conversationId) @THROW400("conversationId is required")
1354
+
1355
+ const membership = await @REPOS.secure.chat_conversation_member.find({
1356
+ filter: {
1357
+ conversation: { id: { _eq: conversationId } },
1358
+ member: { id: { _eq: @USER.id } }
1359
+ },
1360
+ limit: 1
1361
+ })
1362
+
1363
+ if (!membership.data[0]) @THROW403("Not a conversation member")
1364
+
1365
+ @SOCKET.join(\`conversation:\${conversationId}\`)
1366
+ @SOCKET.reply("chat:joined", { conversationId })`,
1367
+ notes: [
1368
+ 'Join conversation rooms, not member-id rooms.',
1369
+ 'conversationId is a request/room identifier; DB filters still use the relation property conversation.',
1370
+ 'Check membership server-side; do not trust the client.',
1371
+ 'Use @REPOS.secure.<table> for explicit table access in user-facing websocket scripts.',
1372
+ ],
1373
+ },
1374
+ {
1375
+ name: 'Chat message event with room broadcast and persistence',
1376
+ code: `const { conversationId, text, clientId } = @BODY
1377
+ if (!conversationId || !text) @THROW400("conversationId and text are required")
1378
+
1379
+ const membership = await @REPOS.secure.chat_conversation_member.find({
1380
+ filter: {
1381
+ conversation: { id: { _eq: conversationId } },
1382
+ member: { id: { _eq: @USER.id } }
1383
+ },
1384
+ limit: 1
1385
+ })
1386
+ if (!membership.data[0]) @THROW403("Not a conversation member")
1387
+
1388
+ const created = await @REPOS.secure.chat_message.create({
1389
+ data: {
1390
+ conversation: { id: conversationId },
1391
+ sender: { id: @USER.id },
1392
+ text,
1393
+ persistStatus: "persisted"
1394
+ }
1395
+ })
1396
+
1397
+ const message = created.data?.[0] ?? null
1398
+ if (message?.id) {
1399
+ await @REPOS.secure.chat_conversation.update({
1400
+ id: conversationId,
1401
+ data: { lastMessage: { id: message.id }, updatedAt: message.createdAt || new Date().toISOString() }
1402
+ })
1403
+ }
1404
+ @SOCKET.emitToCurrentRoom(\`conversation:\${conversationId}\`, "chat:message", {
1405
+ clientId,
1406
+ message
1407
+ })
1408
+
1409
+ return { ok: true, message }`,
1410
+ notes: [
1411
+ 'Do not ask the client for senderId. The sender relation is derived from @USER.id.',
1412
+ 'conversationId is accepted only as the room/business identifier; persistence uses relation properties conversation and sender, not physical FK fields.',
1413
+ 'Event scripts should explicitly emit replies/broadcasts.',
1414
+ 'Use @REPOS.secure.<table> for explicit table access in user-facing websocket scripts; trusted @REPOS.<table> is only for internal/admin logic that will sanitize output.',
1415
+ ],
1416
+ },
1417
+ ],
1418
+ },
1419
+ flows: {
1420
+ title: 'Flows and step scripts',
1421
+ useWhen: 'Use when automating background work or chaining steps.',
1422
+ examples: [
1423
+ {
1424
+ name: 'Manual flow trigger from a post-hook',
1425
+ code: `if (!@ERROR && @DATA?.data?.[0]) {
1426
+ await @TRIGGER("send-welcome-email", {
1427
+ userId: @DATA.data[0].id,
1428
+ email: @DATA.data[0].email
1429
+ })
1430
+ }`,
1431
+ notes: [
1432
+ 'Use flows for workflow semantics, retries, and history.',
1433
+ 'Do not use a flow just to persist a normal chat message.',
1434
+ ],
1435
+ },
1436
+ {
1437
+ name: 'Flow condition step',
1438
+ code: `const order = @FLOW_PAYLOAD.order
1439
+ return order && order.total > 1000`,
1440
+ notes: [
1441
+ 'Condition steps use JavaScript truthy/falsy.',
1442
+ 'Children run according to branch true/false.',
1443
+ ],
1444
+ },
1445
+ {
1446
+ name: 'Split a provisioning workflow into focused steps',
1447
+ code: `[
1448
+ { "key": "load_project", "stepOrder": 10, "type": "query" },
1449
+ { "key": "reserve_capacity", "stepOrder": 20, "type": "script" },
1450
+ { "key": "create_database_user", "stepOrder": 30, "type": "http" },
1451
+ { "key": "apply_database_guardrails", "stepOrder": 40, "type": "script" },
1452
+ { "key": "start_container", "stepOrder": 50, "type": "http" },
1453
+ { "key": "check_health", "stepOrder": 60, "type": "http" },
1454
+ { "key": "finalize_project", "stepOrder": 70, "type": "update" },
1455
+ { "key": "write_audit_log", "stepOrder": 80, "type": "log" }
1456
+ ]`,
1457
+ notes: [
1458
+ 'Prefer the fixed-type flow step tool that matches each operation before falling back to script.',
1459
+ 'Use choose_flow_step_tool when the right step type is unclear.',
1460
+ 'Each step should return only ids, booleans, status keys, or small counters that later steps need.',
1461
+ 'When refactoring an existing flow, add or extract adjacent focused enfyra_flow_step rows instead of making an oversized sourceCode block longer.',
1462
+ ],
1463
+ },
1464
+ {
1465
+ name: 'Flow query step config',
1466
+ code: `{
1467
+ "table": "enfyra_user",
1468
+ "filter": { "email": { "_contains": "@example.com" } },
1469
+ "limit": 50
1470
+ }`,
1471
+ notes: [
1472
+ 'Step configs are JSON; script steps use code strings.',
1473
+ 'Use public-safe URLs for HTTP steps.',
1474
+ ],
1475
+ },
1476
+ {
1477
+ name: 'Flow create/update/delete step configs',
1478
+ code: `// ensure_create_flow_step
1479
+ { "table": "todo", "data": { "title": "Review", "status": "open" } }
1480
+
1481
+ // ensure_update_flow_step
1482
+ { "table": "todo", "id": "@FLOW_PAYLOAD.todoId", "data": { "status": "done" } }
1483
+
1484
+ // ensure_delete_flow_step
1485
+ { "table": "todo", "id": "@FLOW_PAYLOAD.todoId" }`,
1486
+ notes: [
1487
+ 'Use fixed CRUD flow step tools for single-record writes.',
1488
+ 'Use script only when a step must coordinate multiple records, compute complex data, or call packages.',
1489
+ ],
1490
+ },
1491
+ ],
1492
+ },
1493
+ files: {
1494
+ title: 'Files, folders, upload metadata, and assets',
1495
+ useWhen: 'Use when handling uploads or returning uploaded files.',
1496
+ examples: [
1497
+ {
1498
+ name: 'Upload a file from browser',
1499
+ code: `const form = new FormData()
1500
+ form.append("file", file)
1501
+ form.append("folder", folderId)
1502
+ form.append("title", "Invoice")
1503
+
1504
+ const uploaded = await fetch("/enfyra/files/upload", {
1505
+ method: "POST",
1506
+ credentials: "include",
1507
+ body: form
1508
+ }).then((res) => res.json())`,
1509
+ notes: [
1510
+ 'Do not set Content-Type manually for FormData.',
1511
+ 'Use file routes/helpers instead of writing binary data into normal tables.',
1512
+ ],
1513
+ },
1514
+ {
1515
+ name: 'Use uploaded file in handler',
1516
+ code: `const file = @UPLOADED_FILE
1517
+ if (!file) @THROW400("File is required")
1518
+
1519
+ const saved = await @STORAGE.$upload({
1520
+ file,
1521
+ storageConfig: @BODY.storageConfig,
1522
+ folder: @BODY.folder,
1523
+ title: @BODY.title,
1524
+ description: @BODY.description
1525
+ })
1526
+
1527
+ return saved`,
1528
+ notes: [
1529
+ 'Use file-specific context only in upload-capable routes.',
1530
+ 'For request uploads, pass file: @UPLOADED_FILE to @STORAGE.$upload/@STORAGE.$update so Enfyra streams from the temp file path.',
1531
+ 'Use @STORAGE.$registerFile when an external process already uploaded the object and the script only needs to create the enfyra_file record.',
1532
+ 'Do not read @UPLOADED_FILE.path into a Buffer and do not generate examples using @UPLOADED_FILE.buffer.',
1533
+ 'Use buffer only for small generated or transformed files, such as image thumbnails.',
1534
+ ],
1535
+ },
1536
+ ],
1537
+ },
1538
+ extensions: {
1539
+ title: 'Dynamic app extensions and menus',
1540
+ useWhen: 'Use when adding custom Enfyra admin UI pages, widgets, global shell integrations, menu entries, account-panel rows, or shell attention signals.',
1541
+ examples: [
1542
+ {
1543
+ name: 'Create or update HTTP method colors',
1544
+ code: `list_methods()
1545
+
1546
+ create_method({
1547
+ method: "PUT",
1548
+ buttonColor: "#e0e7ff",
1549
+ textColor: "#4338ca"
1550
+ })
1551
+
1552
+ update_method({
1553
+ method: "PATCH",
1554
+ buttonColor: "#fef3c7",
1555
+ textColor: "#b45309"
1556
+ })`,
1557
+ notes: [
1558
+ 'Use dedicated method tools instead of generic CRUD on enfyra_method.',
1559
+ 'The backend stores the method label in enfyra_method.name; do not send or filter a `method` field on `enfyra_method`.',
1560
+ 'buttonColor is the badge background and textColor is the badge text color.',
1561
+ 'The Enfyra admin UI is /settings/methods.',
1562
+ 'delete_method is preview-first and should only be used for unused custom methods.',
1563
+ ],
1564
+ },
1565
+ {
1566
+ name: 'Create menu then extension',
1567
+ code: `ensure_menu({
1568
+ label: "Reports",
1569
+ type: "Menu",
1570
+ path: "/reports",
1571
+ icon: "lucide:bar-chart-3",
1572
+ order: 20,
1573
+ isEnabled: true,
1574
+ globalRulesAckKey: "<globalRulesAckKey from get_enfyra_required_knowledge>",
1575
+ permission: JSON.stringify({
1576
+ or: [
1577
+ { route: "/reports", methods: ["GET"] },
1578
+ { route: "/report", methods: ["GET"] }
1579
+ ]
1580
+ })
1581
+ })
1582
+
1583
+ // Read the created menu id from the tool response, then:
1584
+ ensure_page_extension({
1585
+ name: "ReportsPage",
1586
+ description: "Reports dashboard",
1587
+ menuId: "<created-menu-id>",
1588
+ code: "<template><section class=\\"min-h-full w-full space-y-4\\"><div class=\\"grid gap-4 md:grid-cols-2 xl:grid-cols-3\\"><article class=\\"eapp-surface-card p-4\\"><div class=\\"flex items-start justify-between gap-3\\"><div><p class=\\"text-sm font-medium eapp-text-tertiary\\">Total</p><p class=\\"mt-2 text-2xl font-semibold eapp-text-primary\\">0</p></div><span class=\\"eapp-primary-soft eapp-icon-tile\\"><span class=\\"eapp-primary-text\\">◆</span></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><article class=\\"eapp-primary-surface eapp-radius-panel border p-4\\"><p class=\\"text-sm font-semibold eapp-text-primary\\">Selected report</p><p class=\\"mt-1 text-sm eapp-text-tertiary\\">Only selected/current identity blocks use identity surface.</p></article></div></section></template><script setup>const { registerPageHeader } = usePageHeaderRegistry(); const { register: registerHeaderActions } = useHeaderActionRegistry(); registerPageHeader({ title: 'Reports', description: 'Operational report overview.', leadingIcon: 'lucide:bar-chart-3', gradient: 'none', variant: 'minimal' }); registerHeaderActions([{ id: 'refresh-reports', label: 'Refresh', icon: 'lucide:refresh-cw', color: 'neutral', variant: 'outline', onClick: () => {}, order: 80 }])</script>",
1589
+ isEnabled: true,
1590
+ globalRulesAckKey: "<globalRulesAckKey from get_enfyra_required_knowledge>",
1591
+ extensionKnowledgeAckKey: "<extensionAckKey from get_enfyra_required_knowledge>"
1592
+ })`,
1593
+ notes: [
1594
+ 'Reports is an illustrative page. Keep the shell/page contracts, but choose the real route, menu label, icon, permissions, and body layout from the operator workflow.',
1595
+ 'Menu provides navigation; extension provides content.',
1596
+ 'Use enfyra_menu.label, not title.',
1597
+ 'Sensitive admin menus should include a permission condition at creation time.',
1598
+ 'For page extensions, create the menu first with ensure_menu and pass its id to ensure_page_extension.',
1599
+ 'When editing an existing extension by id or name, use update_extension_code so local guards plus /enfyra_extension/preview and the save happen in one atomic call. Do not spend a second LLM step on validate_extension_code followed by update_record unless the user requested validation-only output.',
1600
+ 'Call get_extension_theme_contract before writing or reviewing page/widget/global extension UI; that tool is the authority for theme, color, layout, modal, drawer, and shell registry details.',
1601
+ 'Call get_enfyra_required_knowledge before saving extension code, pass globalRulesAckKey as globalRulesAckKey, and pass extensionAckKey as extensionKnowledgeAckKey.',
1602
+ 'Page extensions must register the app-shell PageHeader with usePageHeaderRegistry instead of rendering a custom top header.',
1603
+ 'Put page-level actions in useHeaderActionRegistry or useSubHeaderActionRegistry, destructure register first, then call it with one action or an array.',
1604
+ 'Page extensions should be full-bleed and responsive from the first version; the extension root is already inside the Enfyra admin page main.',
1605
+ 'Render ordinary metrics and lists in the body, not PageHeader.stats, unless the user explicitly wants a compact overview header.',
1606
+ 'Use app theme tokens and Nuxt UI semantic colors by intent; do not hard-code concrete palettes or redefine the app palette inside extension code.',
1607
+ 'Use app-owned primitives such as UTabs, CommonModal, CommonDrawer, Widget, useMenuNotificationRegistry, and useAccountPanelRegistry when the workflow matches them.',
1608
+ 'Keep list selection local and fetch detail rows only; do not refetch the whole list after a row click unless the list data changed.',
1609
+ 'Page extension paths are admin app UI routes. Do not verify them with test_rest_endpoint against ENFYRA_API_URL unless inspect_route shows an API route with the same path.',
1610
+ 'After saving, open Enfyra admin tabs should update through the server/Enfyra admin UI realtime reload contract; do not tell the user to refresh unless that contract is proven broken.',
1611
+ ],
1612
+ },
1613
+ {
1614
+ name: 'Compose page extensions from widgets',
1615
+ code: `// Create reusable/bulky sections as widget extension records first.
1616
+ const reportStatusWidgetCode = \`
1617
+ <template>
1618
+ <section class="eapp-surface-card p-4">
1619
+ <div class="flex items-start justify-between gap-3">
1620
+ <div>
1621
+ <p class="text-sm font-medium eapp-text-tertiary">Total reports</p>
1622
+ <p class="mt-2 text-2xl font-semibold eapp-text-primary">{{ total }}</p>
1623
+ <p class="mt-1 text-xs eapp-text-tertiary">{{ latestLabel }}</p>
1624
+ </div>
1625
+ <UButton type="button" color="neutral" variant="outline" @click.stop.prevent="emit('refresh')">Refresh</UButton>
1626
+ </div>
1627
+ <div class="mt-3 h-1.5 overflow-hidden eapp-radius-pill eapp-surface-muted">
1628
+ <div class="eapp-primary-solid h-full" :style="{ width: progressWidth }"></div>
1629
+ </div>
1630
+ <UButton v-if="hasLatest" type="button" class="mt-3" color="primary" variant="solid" @click.stop.prevent="openLatest">Open latest</UButton>
1631
+ </section>
1632
+ </template>
1633
+
1634
+ <script setup>
1635
+ const props = defineProps({
1636
+ total: { type: Number, default: 0 },
1637
+ rows: { type: Array, default: () => [] },
1638
+ openDetails: { type: Function, default: null }
1639
+ })
1640
+ const emit = defineEmits(['refresh'])
1641
+ const hasLatest = computed(() => props.rows.length > 0)
1642
+ const latestLabel = computed(() => hasLatest.value ? 'Latest: ' + (props.rows[0]?.title || props.rows[0]?.id || 'Untitled') : 'No reports yet')
1643
+ const progressWidth = computed(() => hasLatest.value ? '100%' : '0%')
1644
+ function openLatest() {
1645
+ if (typeof props.openDetails === 'function' && props.rows[0]) props.openDetails(props.rows[0])
1646
+ }
1647
+ </script>
1648
+ \`
1649
+
1650
+ ensure_widget_extension({
1651
+ name: "ReportStatusWidget",
1652
+ description: "Report status summary cards",
1653
+ code: reportStatusWidgetCode,
1654
+ isEnabled: true,
1655
+ globalRulesAckKey: "<globalRulesAckKey from get_enfyra_required_knowledge>",
1656
+ extensionKnowledgeAckKey: "<extensionAckKey from get_enfyra_required_knowledge>"
1657
+ })
1658
+
1659
+ // Read the created widget record id, then embed it from the page extension.
1660
+ ensure_page_extension({
1661
+ name: "ReportsPage",
1662
+ menuId: "<reports-menu-id>",
1663
+ code: "<template><section class=\\"min-h-full w-full space-y-4\\"><Widget :id=\\"<report-status-widget-id>\\" :total=\\"totalReports\\" :rows=\\"reportRows\\" :open-details=\\"openReportDetails\\" @refresh=\\"refresh\\" /><Widget :id=\\"<report-table-widget-id>\\" :rows=\\"reportRows\\" @refresh=\\"refresh\\" /></section></template><script setup>const { registerPageHeader } = usePageHeaderRegistry(); registerPageHeader({ title: 'Reports', description: 'Operational report overview.', leadingIcon: 'lucide:bar-chart-3', gradient: 'none', variant: 'minimal' }); const totalReports = ref(0); const reportRows = ref([]); function refresh() {} function openReportDetails(row) { navigateTo('/data/report?filter=' + encodeURIComponent(JSON.stringify({ id: { _eq: row.id } }))) }</script>",
1664
+ isEnabled: true,
1665
+ globalRulesAckKey: "<globalRulesAckKey from get_enfyra_required_knowledge>",
1666
+ extensionKnowledgeAckKey: "<extensionAckKey from get_enfyra_required_knowledge>"
1667
+ })`,
1668
+ notes: [
1669
+ 'This shows composition mechanics. Replace reports/status/table with domain sections that are independently reusable or complex enough to deserve widgets.',
1670
+ 'Use widgets for bulky or reusable sections such as operation panels, timelines, tables, sidebars, and status cards.',
1671
+ 'Embed widgets by their numeric enfyra_extension id, not by extensionId/name.',
1672
+ 'Props and listeners pass through the Widget wrapper. Widget defineProps values update reactively when the parent refs/computed values change.',
1673
+ 'Use kebab-case in the parent template for camelCase widget props, for example :open-details maps to openDetails.',
1674
+ 'Do not mutate widget props. Use computed for derived display state, and use watch only when mirroring a prop into local editable draft state.',
1675
+ 'Prefer defineEmits for child-to-parent requests such as refresh. Use callback props only for parent-owned modal/drawer openers or imperative navigation.',
1676
+ 'Keep PermissionGate and type="button" plus @click.stop.prevent inside action widgets; server permissions still enforce the real boundary.',
1677
+ 'the Enfyra admin UI batch-fetches widget metadata requested in the same tick and caches loaded widgets, so render Widget components directly instead of manually fetching widget code.',
1678
+ ],
1679
+ },
1680
+ {
1681
+ name: 'Create a global shell extension for app-wide notifications',
1682
+ code: `const notificationBellCode = \`
1683
+ <template></template>
1684
+
1685
+ <script setup>
1686
+ const unread = ref(0)
1687
+ const expanded = ref(false)
1688
+
1689
+ const notificationDescription = computed(() => {
1690
+ if (unread.value > 0) return unread.value === 1 ? '1 unread' : unread.value + ' unread'
1691
+ return 'All caught up'
1692
+ })
1693
+ const notificationBadge = computed(() => unread.value > 0 ? (unread.value > 99 ? '99+' : unread.value) : null)
1694
+ const notificationIcon = computed(() => unread.value > 0 ? 'lucide:bell-ring' : 'lucide:bell')
1695
+
1696
+ const NotificationList = defineComponent({
1697
+ name: 'NotificationList',
1698
+ setup() {
1699
+ return () => h('div', { class: 'p-2 text-sm' }, [
1700
+ h('button', {
1701
+ type: 'button',
1702
+ class: 'flex w-full items-center justify-between rounded px-2 py-2 text-left eapp-surface-hover',
1703
+ onClick: () => navigateTo('/notifications'),
1704
+ }, [
1705
+ h('span', 'Open notification center'),
1706
+ h(resolveComponent('UIcon'), { name: 'lucide:arrow-right', class: 'h-4 w-4' }),
1707
+ ]),
1708
+ ])
1709
+ },
1710
+ })
1711
+
1712
+ const { register } = useAccountPanelRegistry()
1713
+ register({
1714
+ id: 'notifications',
1715
+ order: 20,
1716
+ label: 'Notifications',
1717
+ icon: notificationIcon,
1718
+ description: notificationDescription,
1719
+ count: notificationBadge,
1720
+ badgeColor: 'error',
1721
+ expanded,
1722
+ onToggle: () => {
1723
+ expanded.value = !expanded.value
1724
+ },
1725
+ contentComponent: NotificationList,
1726
+ })
1727
+
1728
+ const { register: registerMenuNotification, unregister: unregisterMenuNotification } = useMenuNotificationRegistry()
1729
+ watchEffect(() => {
1730
+ if (notificationBadge.value) {
1731
+ registerMenuNotification({
1732
+ id: 'notifications-menu-unread',
1733
+ target: { path: '/notifications' },
1734
+ value: notificationBadge.value,
1735
+ color: 'error',
1736
+ title: notificationDescription.value,
1737
+ })
1738
+ } else {
1739
+ unregisterMenuNotification('notifications-menu-unread')
1740
+ }
1741
+ })
1742
+
1743
+ const { adminSocket } = useAdminSocket()
1744
+ const handleNotification = (payload) => {
1745
+ if (payload?.unread != null) unread.value = payload.unread
1746
+ }
1747
+ adminSocket.on('notification:summary', handleNotification)
1748
+ onUnmounted(() => {
1749
+ adminSocket.off('notification:summary', handleNotification)
1750
+ unregisterMenuNotification('notifications-menu-unread')
1751
+ })
1752
+ </script>
1753
+ \`
1754
+
1755
+ ensure_global_extension({
1756
+ name: "NotificationBellGlobal",
1757
+ description: "Registers the app-wide notification bell in the account panel",
1758
+ code: notificationBellCode,
1759
+ isEnabled: true,
1760
+ globalRulesAckKey: "<globalRulesAckKey from get_enfyra_required_knowledge>",
1761
+ extensionKnowledgeAckKey: "<extensionAckKey from get_enfyra_required_knowledge>"
1762
+ })`,
1763
+ notes: [
1764
+ 'Global extensions are mounted invisibly by Enfyra admin UI during layout init; do not create a menu and do not embed them with Widget.',
1765
+ 'Use them for shell-level registrations, realtime listeners, notification counters, account panel rows, and background refresh bridges.',
1766
+ 'The notification center is only one possible shell integration. The transferable shape is invisible global extension -> shell registry -> cleanup on unmount.',
1767
+ 'Use useMenuNotificationRegistry for sidebar menu counts/dots when notification state should be visible in the menu as well as the notification center.',
1768
+ 'Choose value only when the signal source already owns an exact count. Omit value for a dot when realtime only proves that something new exists.',
1769
+ 'Do not fetch the destination domain list just to decorate a menu. A mail page fetches mail; a support page fetches tickets; the shell should use notification or summary signals.',
1770
+ 'Keep the global extension template empty or hidden; visible UI should be registered into an existing shell registry or component slot.',
1771
+ 'For account-panel UI, register data-driven row fields so Enfyra admin UI owns icon size, row spacing, badge placement, hover state, and expanded chrome.',
1772
+ 'Use contentComponent only for expanded inner content; use raw component only as an escape hatch when the row cannot fit the shell contract.',
1773
+ 'Destructure registry functions and register stable ids so reloads replace the same shell item predictably.',
1774
+ 'Remove socket or DOM listeners in onUnmounted; The Enfyra admin UI unmounts old global components when extension cache reloads or the extension is disabled.',
1775
+ ],
1776
+ },
1777
+ {
1778
+ name: 'Signal menu attention without polling destination lists',
1779
+ code: `const signalBridgeCode = \`
1780
+ <template></template>
1781
+
1782
+ <script setup>
1783
+ const attentionRows = ref([])
1784
+ const notificationSignal = ref(false)
1785
+ const route = useRoute()
1786
+
1787
+ const notificationApi = useApi('/cloud_admin_notifications', {
1788
+ query: {
1789
+ filter: JSON.stringify({ readAt: { _is_null: true } }),
1790
+ fields: 'id,kind,targetPath,readAt',
1791
+ sort: '-createdAt,-id',
1792
+ limit: 10,
1793
+ },
1794
+ })
1795
+
1796
+ const hasNewEmail = computed(() =>
1797
+ attentionRows.value.some((row) => row.kind === 'email_inbound' && !row.readAt)
1798
+ )
1799
+ const hasNewSupport = computed(() =>
1800
+ attentionRows.value.some((row) => row.kind === 'support' && !row.readAt)
1801
+ )
1802
+ const accountBadge = computed(() => notificationSignal.value ? 'New' : null)
1803
+ const accountDescription = computed(() => notificationSignal.value ? 'New admin attention' : 'All caught up')
1804
+
1805
+ function syncFromNotificationRows() {
1806
+ const value = notificationApi.data?.value
1807
+ const rows = Array.isArray(value?.data)
1808
+ ? value.data
1809
+ : Array.isArray(value?.data?.data)
1810
+ ? value.data.data
1811
+ : []
1812
+ attentionRows.value = rows
1813
+ notificationSignal.value = rows.some((row) => !row.readAt)
1814
+ }
1815
+
1816
+ async function refreshNotificationSignals() {
1817
+ await notificationApi.execute()
1818
+ syncFromNotificationRows()
1819
+ }
1820
+
1821
+ const { register: registerAccountPanel } = useAccountPanelRegistry()
1822
+ registerAccountPanel({
1823
+ id: 'admin-attention',
1824
+ order: 20,
1825
+ label: 'Notifications',
1826
+ icon: computed(() => notificationSignal.value ? 'lucide:bell-ring' : 'lucide:bell'),
1827
+ description: accountDescription,
1828
+ count: accountBadge,
1829
+ badgeColor: 'info',
1830
+ onClick: () => navigateTo('/data/cloud_admin_notifications'),
1831
+ })
1832
+
1833
+ const { register: registerMenuNotification, unregister: unregisterMenuNotification } = useMenuNotificationRegistry()
1834
+ watchEffect(() => {
1835
+ if (hasNewEmail.value) {
1836
+ registerMenuNotification({
1837
+ id: 'attention-email',
1838
+ target: { path: '/email/messages' },
1839
+ color: 'info',
1840
+ title: 'New inbound email',
1841
+ })
1842
+ } else {
1843
+ unregisterMenuNotification('attention-email')
1844
+ }
1845
+
1846
+ if (hasNewSupport.value) {
1847
+ registerMenuNotification({
1848
+ id: 'attention-support',
1849
+ target: { path: '/cloud/support' },
1850
+ color: 'info',
1851
+ title: 'New support activity',
1852
+ })
1853
+ } else {
1854
+ unregisterMenuNotification('attention-support')
1855
+ }
1856
+ })
1857
+
1858
+ watch(() => route.path, (path) => {
1859
+ if (path.startsWith('/email/messages')) {
1860
+ attentionRows.value = attentionRows.value.filter((row) => row.kind !== 'email_inbound')
1861
+ }
1862
+ if (path.startsWith('/cloud/support')) {
1863
+ attentionRows.value = attentionRows.value.filter((row) => row.kind !== 'support')
1864
+ }
1865
+ notificationSignal.value = attentionRows.value.some((row) => !row.readAt)
1866
+ })
1867
+
1868
+ const { adminSocket } = useAdminSocket()
1869
+ function handleAdminNotification(payload) {
1870
+ refreshNotificationSignals()
1871
+ if (payload?.kind === 'email_inbound') {
1872
+ registerMenuNotification({ id: 'attention-email', target: { path: '/email/messages' }, color: 'info', title: 'New inbound email' })
1873
+ }
1874
+ if (payload?.kind === 'support') {
1875
+ registerMenuNotification({ id: 'attention-support', target: { path: '/cloud/support' }, color: 'info', title: 'New support activity' })
1876
+ }
1877
+ }
1878
+
1879
+ function getAdminSocket() {
1880
+ return adminSocket && adminSocket.value !== undefined ? adminSocket.value : adminSocket
1881
+ }
1882
+
1883
+ function bindAdminSocket(socket) {
1884
+ if (socket && typeof socket.on === 'function') {
1885
+ socket.on('admin:notification-created', handleAdminNotification)
1886
+ }
1887
+ }
1888
+
1889
+ function unbindAdminSocket(socket) {
1890
+ if (socket && typeof socket.off === 'function') {
1891
+ socket.off('admin:notification-created', handleAdminNotification)
1892
+ }
1893
+ }
1894
+
1895
+ if (adminSocket && adminSocket.value !== undefined) {
1896
+ watch(adminSocket, (nextSocket, previousSocket) => {
1897
+ unbindAdminSocket(previousSocket)
1898
+ bindAdminSocket(nextSocket)
1899
+ })
1900
+ }
1901
+
1902
+ onMounted(() => {
1903
+ refreshNotificationSignals()
1904
+ bindAdminSocket(getAdminSocket())
1905
+ })
1906
+ onUnmounted(() => {
1907
+ unbindAdminSocket(getAdminSocket())
1908
+ unregisterMenuNotification('attention-email')
1909
+ unregisterMenuNotification('attention-support')
1910
+ })
1911
+ </script>
1912
+ \`
1913
+
1914
+ ensure_global_extension({
1915
+ name: "AdminAttentionSignalBridge",
1916
+ description: "Routes notification signals into account-panel and sidebar menu attention markers without polling destination lists",
1917
+ code: signalBridgeCode,
1918
+ isEnabled: true,
1919
+ globalRulesAckKey: "<globalRulesAckKey from get_enfyra_required_knowledge>",
1920
+ extensionKnowledgeAckKey: "<extensionAckKey from get_enfyra_required_knowledge>"
1921
+ })`,
1922
+ notes: [
1923
+ 'Use this reasoning pattern when the shell should show attention but the destination page owns the expensive or domain-specific list fetch.',
1924
+ 'This example fetches only the notification source of truth, not the email, support, order, or job tables. Substitute your own notification or summary endpoint when available.',
1925
+ 'Omitting value on registerMenuNotification renders a dot. That is the right promise when the shell knows "new work exists" but not an exact count.',
1926
+ 'If a backend summary event already includes an exact unread count, use value for a count chip. If the event only says one record changed, use a dot and let the page fetch details.',
1927
+ 'Map notification kinds to menu targets by product meaning, not by copying these paths. For example, approval_required could target /reviews, failed_job could target /operations/jobs, and quota_warning could target /billing.',
1928
+ 'Generalize the lifecycle: seed from a bounded signal source, react to realtime events, clear local attention when the user reaches the owning page, and avoid duplicating that page\'s data fetch.',
1929
+ 'Clear local dot signals when the user enters the destination route or when the notification center marks the underlying notification as read.',
1930
+ ],
1931
+ },
1932
+ {
1933
+ name: 'Register a data-driven account-panel item',
1934
+ code: `<script setup>
1935
+ const unread = ref(3)
1936
+ const expanded = ref(false)
1937
+
1938
+ const label = 'Notifications'
1939
+ const icon = computed(() => unread.value > 0 ? 'lucide:bell-ring' : 'lucide:bell')
1940
+ const count = computed(() => unread.value > 0 ? String(unread.value) : null)
1941
+ const description = computed(() => unread.value > 0 ? 'Needs review' : 'All caught up')
1942
+
1943
+ const NotificationPanelContent = defineComponent({
1944
+ name: 'NotificationPanelContent',
1945
+ setup() {
1946
+ return () => h('div', { class: 'px-2 py-1 text-xs eapp-text-tertiary' }, 'Recent unread notifications can render here.')
1947
+ },
1948
+ })
1949
+
1950
+ const { register } = useAccountPanelRegistry()
1951
+ register({
1952
+ id: 'notifications',
1953
+ order: 20,
1954
+ label,
1955
+ icon,
1956
+ description,
1957
+ count,
1958
+ badgeColor: 'error',
1959
+ expanded,
1960
+ onToggle: () => {
1961
+ expanded.value = !expanded.value
1962
+ },
1963
+ contentComponent: NotificationPanelContent,
1964
+ })
1965
+ </script>`,
1966
+ notes: [
1967
+ 'Prefer this contract for shell/account-panel items: data fields for the row, optional contentComponent for the expanded body.',
1968
+ 'Notifications is illustrative. Account-panel rows can represent any account-scoped attention or shortcut, such as approvals, billing, deployments, or personal tasks.',
1969
+ 'Use count for the primary visible badge value. badge remains supported as a legacy alias, but count is what the account trigger aggregates.',
1970
+ 'Do not draw a custom full row with page-scale cards, hero headings, large whitespace, or nested buttons unless the shell contract cannot express the UI.',
1971
+ 'Let the Enfyra admin UI handle the row button, icon container, label, microcopy, badge, chevron, hover state, spacing, and expanded wrapper.',
1972
+ 'Keep contentComponent compact; it is rendered inside account-panel chrome and should not create another large card around itself.',
1973
+ 'Register the component from a `type="global"` extension, not from a page extension, when it must appear everywhere.',
1974
+ ],
1975
+ },
1976
+ {
1977
+ name: 'Page header and action button variants',
1978
+ code: `<script setup>
1979
+ const { registerPageHeader } = usePageHeaderRegistry()
1980
+ const { register: registerHeaderActions } = useHeaderActionRegistry()
1981
+
1982
+ registerPageHeader({
1983
+ title: 'Report detail',
1984
+ description: 'Review status, schedule, and delivery history.',
1985
+ leadingIcon: 'lucide:file-text',
1986
+ gradient: 'none',
1987
+ variant: 'minimal'
1988
+ })
1989
+
1990
+ registerHeaderActions([
1991
+ {
1992
+ id: 'back-to-reports',
1993
+ label: 'Reports',
1994
+ icon: 'lucide:arrow-left',
1995
+ color: 'neutral',
1996
+ variant: 'ghost',
1997
+ order: 0,
1998
+ onClick: () => navigateTo('/reports')
1999
+ },
2000
+ {
2001
+ id: 'send-test-report',
2002
+ label: 'Send test',
2003
+ icon: 'lucide:send',
2004
+ color: 'neutral',
2005
+ variant: 'outline',
2006
+ order: 1,
2007
+ permission: { or: [{ route: '/reports/send-test', methods: ['POST'] }] },
2008
+ onClick: sendTest
2009
+ },
2010
+ {
2011
+ id: 'refresh-report',
2012
+ label: 'Refresh',
2013
+ icon: 'lucide:refresh-cw',
2014
+ color: 'neutral',
2015
+ variant: 'outline',
2016
+ order: 2,
2017
+ onClick: refresh
2018
+ }
2019
+ ])
2020
+ </script>`,
2021
+ notes: [
2022
+ 'Use PageHeader for the title strip; do not render a duplicate header inside extension body.',
2023
+ 'The exact actions are illustrative. Choose action prominence from user intent: navigation, secondary utility, primary mutation, or destructive confirmation.',
2024
+ 'Use gradient: "none" for generated operational pages; hardcoded named gradients are decorative and should be explicit user intent.',
2025
+ 'Back/navigation actions should be neutral ghost so they read as navigation, not a primary operation.',
2026
+ 'Visible secondary operations should be neutral outline; soft is only for low-emphasis chrome actions.',
2027
+ 'The main page mutation action should be primary solid; refresh is neutral outline unless refresh is the actual primary workflow.',
2028
+ 'Do not choose soft only because it looks acceptable in dark mode; light mode must remain clear too.',
2029
+ ],
2030
+ },
2031
+ {
2032
+ name: 'Debug menu or extension changes that do not appear in open Enfyra admin tabs',
2033
+ code: `// Server side: enfyra_menu and enfyra_extension are runtime UI definitions.
2034
+ // They must participate in partial reload, just like metadata/routes.
2035
+ // Expected server contract:
2036
+ // - cache orchestrator maps enfyra_menu -> menu reload
2037
+ // - cache orchestrator maps enfyra_extension -> extension reload
2038
+ // - successful writes emit $system:reload to the admin Socket.IO namespace
2039
+
2040
+ // Enfyra admin UI side expected listener behavior:
2041
+ // if reload target is metadata/menu:
2042
+ // await fetch menus
2043
+ // rebuild menu registry with reset: true
2044
+ // invalidate dynamic extension cache too, because route-to-extension mapping may change
2045
+ // if reload target is extension or menu:
2046
+ // clear dynamic extension component/meta cache
2047
+ // reload enabled type="global" shell extensions
2048
+
2049
+ // Verification pattern:
2050
+ // 1. Save the menu or extension record.
2051
+ // 2. Watch the open Enfyra admin UI tab for the $system:reload event.
2052
+ // 3. Confirm sidebar/menu registry or extension component cache changed.
2053
+ // 4. Only use manual reload endpoints or browser refresh after the natural event path is proven stale.`,
2054
+ notes: [
2055
+ 'Do not treat menu and extension writes as plain CRUD when debugging live admin UI.',
2056
+ 'Check both halves: Enfyra Server emits the reload event, and Enfyra admin UI consumes it.',
2057
+ 'Menu reload should also invalidate extension cache because menu records attach page extensions to routes.',
2058
+ 'Manual reload is a fallback, not the default fix.',
2059
+ ],
2060
+ },
2061
+ {
2062
+ name: 'Plan an admin dashboard as multiple pages',
2063
+ code: `// Illustrative menu shape for an operations surface:
2064
+ ensure_menu({
2065
+ type: "Dropdown Menu",
2066
+ label: "Operations",
2067
+ path: "/operations",
2068
+ icon: "lucide:layout-dashboard",
2069
+ order: 2,
2070
+ isEnabled: true,
2071
+ permission: JSON.stringify({
2072
+ or: [
2073
+ { route: "/operations/jobs", methods: ["GET"] },
2074
+ { route: "/enfyra_flow_execution", methods: ["GET"] }
2075
+ ]
2076
+ })
2077
+ })
2078
+
2079
+ // Child page extensions should be focused:
2080
+ // /dashboard compact summary/routing hub: KPIs, current signal, attention queue, navigation cards
2081
+ // /operations/jobs background jobs, current step, meaning, next action
2082
+ // /operations/orders order/payment status and drill-downs
2083
+ // /operations/reports report configuration and delivery history
2084
+ // /operations/settings system readiness and configuration
2085
+ // Use UTabs inside large pages instead of placing every section in one dashboard.
2086
+ // For admin record management, link to /data/<table>, e.g. /data/report, not public website paths.`,
2087
+ notes: [
2088
+ 'Design the menu/page split before generating dashboard code.',
2089
+ 'Operations/jobs/orders/reports/settings are examples of separating mental models. Replace them with the real domains users navigate between.',
2090
+ 'Permission-gate sensitive parent dropdown menus too, using any child page route or backing route that represents read access.',
2091
+ 'Keep /dashboard as a summary and distribution page, not a detailed operations table.',
2092
+ 'Use focused pages for operational domains.',
2093
+ 'Each page extension must use usePageHeaderRegistry for the app-shell title strip and should not render a duplicate top header in the body.',
2094
+ 'PageHeader.stats is reserved for deliberate overview headers; operational KPIs belong in body cards/tables.',
2095
+ 'Operational history pages should not show raw event rows as the primary UI; group by entity/run and translate step keys into operator-facing labels.',
2096
+ 'Operational lists should use pagination plus search/filter controls; do not rely on arbitrary fixed limits such as limit=50.',
2097
+ 'UTabs is available in the Enfyra admin UI extension runtime for page-level sections.',
2098
+ 'Admin links for editing or inspecting records should point to /data/<table> routes.',
2099
+ ],
2100
+ },
2101
+ {
2102
+ name: 'Extension fetches Enfyra data',
2103
+ code: `<script setup>
2104
+ const { data, pending, execute: fetchOrders } = useApi('/order', {
2105
+ query: {
2106
+ limit: 10,
2107
+ sort: '-createdAt'
2108
+ }
2109
+ })
2110
+
2111
+ onMounted(() => fetchOrders())
2112
+ </script>
2113
+
2114
+ <template>
2115
+ <UButton :loading="pending" @click="fetchOrders">Refresh</UButton>
2116
+ <pre>{{ data }}</pre>
2117
+ </template>`,
2118
+ notes: [
2119
+ 'Use app-provided composables in extensions.',
2120
+ 'useApi does not auto-run; call execute() on mounted or through an action.',
2121
+ 'The /order path is illustrative; inspect routes and fetch the smallest data shape the extension needs.',
2122
+ 'Keep extension UI focused; move backend logic into handlers/hooks when needed.',
2123
+ ],
2124
+ },
2125
+ {
2126
+ name: 'Managed modal and drawer footer actions',
2127
+ code: `<template>
2128
+ <CommonModal
2129
+ v-model:open="open"
2130
+ :cancel-action="{ label: 'Cancel', onClick: () => (open = false) }"
2131
+ :primary-action="{ label: 'Update version', loading: saving, disabled: !canSubmit, onClick: submit }"
2132
+ >
2133
+ <template #header>
2134
+ <h3 class="text-lg font-semibold">Update version</h3>
2135
+ </template>
2136
+
2137
+ <template #body>
2138
+ <UInput v-model="version" />
2139
+ <UButton
2140
+ type="button"
2141
+ icon="i-lucide-refresh-cw"
2142
+ label="Check version"
2143
+ @click.stop.prevent="checkVersion"
2144
+ />
2145
+ </template>
2146
+ </CommonModal>
2147
+ </template>`,
2148
+ notes: [
2149
+ 'For action-only footers, use CommonModal/CommonDrawer footer props: cancelAction, primaryAction, dangerAction, leadingActions, and footerHint.',
2150
+ 'cancelAction defaults to neutral outline. Use dangerAction for irreversible destructive work and tone: "primary" for Keep editing in discard dialogs.',
2151
+ 'Every trigger/body action button inside CommonModal, CommonDrawer, or UModal should use type="button" unless it intentionally submits a form.',
2152
+ 'Use @click.stop.prevent on body action buttons so clicks do not bubble to row/page triggers.',
2153
+ 'Open modal/drawer shells immediately, then load content inside them; do not close and reopen after an API call.',
2154
+ 'Keep destructive final actions disabled until all confirmation inputs are valid.',
2155
+ ],
2156
+ },
2157
+ {
2158
+ name: 'Extension can use modern browser APIs',
2159
+ code: `<script setup lang="ts">
2160
+ const statuses = ['active', 'ready']
2161
+ const ok = statuses.includes('active')
2162
+ const requiredTerms = new Set(['terms', 'privacy'])
2163
+ const loaded = await Promise.all([Promise.resolve(1), Promise.resolve(2)])
2164
+ const label = String('pending_payment').replace(/_/g, ' ')
2165
+ const date = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }).format(new Date())
2166
+ console.log(ok, requiredTerms.has('terms'), loaded, label, date)
2167
+ </script>`,
2168
+ notes: [
2169
+ 'Do not rewrite extension code to ES5 when tooling rejects modern APIs.',
2170
+ 'If diagnostics complain about these APIs, fix Enfyra admin extension TypeScript lib/runtime contract.',
2171
+ ],
2172
+ },
2173
+ {
2174
+ name: 'Install and use an app package in an extension',
2175
+ code: `install_package({
2176
+ name: "dayjs",
2177
+ type: "App",
2178
+ globalRulesAckKey: "<globalRulesAckKey from get_enfyra_required_knowledge>"
2179
+ })
2180
+
2181
+ // Then in extension code:
2182
+ <script setup>
2183
+ const formatted = ref('')
2184
+
2185
+ onMounted(async () => {
2186
+ const pkgs = await getPackages(['dayjs'])
2187
+ const dayjs = pkgs.dayjs
2188
+ formatted.value = dayjs().format('YYYY-MM-DD')
2189
+ })
2190
+ </script>
2191
+
2192
+ <template>
2193
+ <span>{{ formatted }}</span>
2194
+ </template>`,
2195
+ notes: [
2196
+ 'Install browser-side extension dependencies as type: "App".',
2197
+ 'Do not use static import statements in enfyra_extension.code.',
2198
+ 'Load app packages with getPackages([...]) inside the extension runtime.',
2199
+ 'Use onMounted or an explicit action for package loading when the UI can render a loading state.',
2200
+ ],
2201
+ },
2202
+ {
2203
+ name: 'Dashboard aggregate stats with a time range',
2204
+ code: `<script setup>
2205
+ const range = ref('7d')
2206
+ const now = () => new Date()
2207
+ const rangeStart = computed(() => {
2208
+ const d = now()
2209
+ if (range.value === '24h') d.setHours(d.getHours() - 24)
2210
+ else if (range.value === '30d') d.setDate(d.getDate() - 30)
2211
+ else d.setDate(d.getDate() - 7)
2212
+ return d.toISOString()
2213
+ })
2214
+
2215
+ const flowStats = useApi('/enfyra_flow_execution', {
2216
+ query: computed(() => ({
2217
+ fields: 'id',
2218
+ limit: 1,
2219
+ meta: 'filterCount',
2220
+ filter: { startedAt: { _gte: rangeStart.value } },
2221
+ aggregate: {
2222
+ id: { count: true },
2223
+ status: { count: { _eq: 'failed' } }
2224
+ }
2225
+ }))
2226
+ })
2227
+
2228
+ const orderStats = useApi('/order', {
2229
+ query: computed(() => ({
2230
+ fields: 'id',
2231
+ limit: 1,
2232
+ meta: 'filterCount',
2233
+ filter: { createdAt: { _gte: rangeStart.value } },
2234
+ aggregate: {
2235
+ id: { count: true },
2236
+ status: { count: { _eq: 'applied' } },
2237
+ amount_usd: { sum: true }
2238
+ }
2239
+ }))
2240
+ })
2241
+
2242
+ watch(range, () => Promise.all([flowStats.execute(), orderStats.execute()]))
2243
+ onMounted(() => Promise.all([flowStats.execute(), orderStats.execute()]))
2244
+ </script>`,
2245
+ notes: [
2246
+ 'Aggregate keys must be real fields or relations.',
2247
+ 'Read results from response.meta.aggregate.',
2248
+ 'Use top-level filter for time windows and cross-field conditions.',
2249
+ 'The flow/order pair is illustrative. Choose aggregates that answer the page question, such as failed work, pending approvals, unread support, quota pressure, or revenue.',
2250
+ 'Only aggregate fields and relations that the dashboard is allowed to expose; aggregate values can reveal hidden data even when rows omit that field.',
2251
+ 'sum/avg require numeric fields; amount_usd must be a real float/numeric SQL column, not metadata-only float over a varchar physical column.',
2252
+ ],
2253
+ },
2254
+ ],
2255
+ },
2256
+ };
2257
+ export function listExampleCategories() {
2258
+ return Object.entries(EXAMPLE_CATEGORIES).map(([key, value]) => ({
2259
+ key,
2260
+ title: value.title,
2261
+ useWhen: value.useWhen,
2262
+ }));
2263
+ }
2264
+ export function getExamples(category) {
2265
+ if (!category) {
2266
+ return {
2267
+ reasoningGuide: EXAMPLE_REASONING_GUIDE,
2268
+ categories: listExampleCategories(),
2269
+ hint: 'Call get_enfyra_examples with one category key to retrieve concrete examples for that area.',
2270
+ };
2271
+ }
2272
+ const entry = EXAMPLE_CATEGORIES[category];
2273
+ if (!entry) {
2274
+ return {
2275
+ error: `Unknown example category "${category}"`,
2276
+ categories: listExampleCategories(),
2277
+ };
2278
+ }
2279
+ return {
2280
+ category,
2281
+ reasoningGuide: EXAMPLE_REASONING_GUIDE,
2282
+ ...entry,
2283
+ };
2284
+ }
2285
+ //# sourceMappingURL=mcp-examples.js.map