@enfyra/mcp-server 0.0.58 → 0.0.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -186,7 +186,7 @@ Use this block in any host-specific `mcp.json` / `mcpServers` merge (adjust env
186
186
 
187
187
  Schema and script tools include safety guards for LLM callers: generic record mutations validate request fields against live metadata, script-backed records must validate `sourceCode` before save through `/admin/script/validate` and fail closed if validation is unavailable, relation metadata rejects physical FK/junction inputs, custom routes reject `mainTableId` unless the path is the canonical table route, schema tools serialize table/column/relation changes, and destructive deletes require `confirm=true` after returning a preview.
188
188
 
189
- For route contracts that intentionally keep workflow fields out of request bodies, generic `create_record`, `update_record`, and `delete_record` accept optional `queryParams` as a JSON object string. For example, Cloud admin project creation can keep `expired_at=YYYY-MM-DD` in the URL query while `validateBody` remains enabled for the table body.
189
+ For route contracts that intentionally keep workflow fields out of request bodies, generic `create_record`, `update_record`, and `delete_record` accept optional `queryParams` as a JSON object string. For example, a renewal workflow can keep `expires_at=YYYY-MM-DD` in the URL query while `validateBody` remains enabled for the table body.
190
190
 
191
191
  ### `ENFYRA_API_URL` — use the app proxy
192
192
 
@@ -200,7 +200,7 @@ The Enfyra backend is private infrastructure. MCP, browser code, SSR routes, Gra
200
200
 
201
201
  ### SSR app auth pattern
202
202
 
203
- When an LLM builds a Nuxt, Next, or other SSR frontend for Enfyra, follow the Enfyra Cloud pattern:
203
+ When an LLM builds a Nuxt, Next, or other SSR frontend for Enfyra, follow the same-origin proxy pattern:
204
204
 
205
205
  - Browser code calls a same-origin proxy such as `{{ appOrigin }}/enfyra/**`, never the raw Enfyra backend URL.
206
206
  - Nuxt can proxy it with `routeRules: { "/enfyra/**": { proxy: { to: `${API_URL}/**`, fetchOptions: { redirect: "manual" } } } }`. Keep redirects manual so OAuth set-cookie redirects reach the browser as real HTTP redirects with `Set-Cookie`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enfyra/mcp-server",
3
- "version": "0.0.58",
3
+ "version": "0.0.59",
4
4
  "description": "MCP server for Enfyra - manage your Enfyra instance via Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -206,13 +206,13 @@ create_column({
206
206
  })
207
207
 
208
208
  create_column({
209
- tableId: "<project_env_table_id>",
209
+ tableId: "<integration_secret_table_id>",
210
210
  name: "value",
211
211
  type: "text",
212
212
  isNullable: false,
213
213
  isPublished: false,
214
214
  isEncrypted: true,
215
- description: "Encrypted environment value."
215
+ description: "Encrypted secret value."
216
216
  })`,
217
217
  notes: [
218
218
  'Run schema-changing calls sequentially. Do not parallelize create_column calls.',
@@ -221,6 +221,30 @@ create_column({
221
221
  'Use hooks or field permissions to prevent clients from updating server-owned fields.',
222
222
  ],
223
223
  },
224
+ {
225
+ name: 'Patch table schema from metadata only',
226
+ code: `// Safe schema patch process used by create_column/update_column/delete_column:
227
+ // 1. Read GET /metadata and find the target table.
228
+ // 2. Keep only persisted column rows with id/_id.
229
+ // 3. Add, change, or remove the intended column.
230
+ // 4. PATCH /table_definition/:id with the full preserved columns array.
231
+ // 5. If the backend returns requiredConfirmHash, resend with ?schemaConfirmHash=<hash>.
232
+ // 6. Re-read metadata and verify unrelated column ids still exist.
233
+
234
+ create_column({
235
+ tableId: "<table_id>",
236
+ name: "api_secret",
237
+ type: "text",
238
+ isPublished: false,
239
+ isEncrypted: true
240
+ })`,
241
+ notes: [
242
+ 'Do not rebuild schema cascade payloads from table_definition?fields=columns.*; nested fields can be truncated or relation-derived.',
243
+ 'Generated projections such as createdAt, updatedAt, and relation FK display fields without id/_id are not valid column_definition rows.',
244
+ 'Never delete or omit unrelated persisted columns when adding one field.',
245
+ 'Run schema-changing calls sequentially; migration locks are backend-owned.',
246
+ ],
247
+ },
224
248
  ],
225
249
  },
226
250
  'queries-deep': {
@@ -253,10 +277,18 @@ create_column({
253
277
  },
254
278
  {
255
279
  name: 'Fetch one record by id',
256
- code: `GET /enfyra/post?filter={"id":{"_eq":123}}&limit=1`,
280
+ code: `find_one_record({
281
+ tableName: "post",
282
+ id: "123",
283
+ fields: ["id", "title", "createdAt"]
284
+ })
285
+
286
+ // REST equivalent after inspecting metadata primary key:
287
+ GET /enfyra/post?filter={"<primaryKeyFromMetadata>":{"_eq":123}}&limit=1`,
257
288
  notes: [
258
289
  'There is no dynamic GET /<table>/<id> route.',
259
- 'Use filter + limit=1 or MCP find_one_record.',
290
+ 'Prefer MCP find_one_record because it resolves the primary key from live metadata.',
291
+ 'If writing raw REST, inspect metadata first and use the real primary key field; do not assume id on every backend.',
260
292
  ],
261
293
  },
262
294
  {
@@ -288,6 +320,38 @@ create_column({
288
320
  'Do not invent deep keys like members unless members is a relation on that table.',
289
321
  ],
290
322
  },
323
+ {
324
+ name: 'Encrypted fields are not lookup fields',
325
+ code: `// Bad: api_token is isEncrypted=true, so filter/sort cannot use it.
326
+ GET /enfyra/integrations?filter={"api_token":{"_eq":"plaintext-token"}}
327
+
328
+ // Good: store a separate non-secret lookup hash if lookup is needed.
329
+ create_column({
330
+ tableId: "<integrations_table_id>",
331
+ name: "api_token_lookup_sha256",
332
+ type: "varchar",
333
+ isNullable: false,
334
+ isPublished: false
335
+ })
336
+
337
+ // In the create/update handler or pre-hook, hash plaintext before it is encrypted.
338
+ if (@BODY.api_token) {
339
+ @BODY.api_token_lookup_sha256 = @HELPERS.$crypto.sha256(@BODY.api_token)
340
+ }
341
+
342
+ // Lookup by the hash, never by the encrypted field.
343
+ const lookup = @HELPERS.$crypto.sha256(@BODY.api_token)
344
+ const found = await #integrations.find({
345
+ filter: { api_token_lookup_sha256: { _eq: lookup } },
346
+ limit: 1
347
+ })`,
348
+ notes: [
349
+ 'isEncrypted values are encrypted at rest and decrypted after select.',
350
+ 'Do not filter, sort, or deep-filter by encrypted fields.',
351
+ 'Use a separate deterministic non-secret hash/lookup column when the product needs secret-derived lookup.',
352
+ 'Do not ask clients to submit enc:v1: ciphertext.',
353
+ ],
354
+ },
291
355
  ],
292
356
  },
293
357
  'handlers-hooks': {
@@ -356,23 +420,27 @@ const scope = {
356
420
  ],
357
421
  },
358
422
  {
359
- name: 'Pre-hook encrypted field normalization',
360
- code: `create_pre_hook({
361
- routeId: "<route_id>",
362
- name: "encrypt_api_token",
363
- methods: ["POST", "PATCH"],
364
- priority: 0,
365
- code: \`const value = @BODY.api_token_encrypted
366
- if (value && value.slice(0, 7) !== "enc:v1:") {
367
- @BODY.api_token_encrypted = @HELPERS.$encrypt.encrypt(value)
368
- }\`
423
+ name: 'Encrypted field table definition',
424
+ code: `create_table({
425
+ name: "integrations",
426
+ columns: JSON.stringify([
427
+ { name: "name", type: "varchar", isNullable: false },
428
+ {
429
+ name: "api_token",
430
+ type: "varchar",
431
+ isNullable: false,
432
+ isPublished: false,
433
+ isEncrypted: true
434
+ }
435
+ ])
369
436
  })`,
370
437
  notes: [
371
- 'MCP create_pre_hook accepts code as the tool argument, then persists it to Enfyra as sourceCode with scriptLanguage.',
372
- 'Do not call raw create_record with a code field for pre_hook_definition or post_hook_definition; backend CRUD rejects code.',
373
- 'Use Enfyra pre-hooks for request-body normalization before canonical CRUD persists the record.',
374
- 'Do not implement encrypted field normalization as a Knex/database hook.',
375
- 'Use isEncrypted columns for database encryption and $helpers.$crypto.generateSshKeyPair for SSH key generation; do not use $helpers.$ssh or $helpers.$secrets.',
438
+ 'Use isEncrypted=true for values that must be encrypted at rest.',
439
+ 'Scripts and REST callers read and write plaintext values; Enfyra encrypts on write and decrypts after select.',
440
+ 'Set isPublished=false for secret fields that should not be exposed by default.',
441
+ 'isEncrypted does not imply immutability; add isUpdatable=false separately only when the value must not change.',
442
+ 'Do not generate manual $encrypt hooks or accept caller-supplied enc:v1: ciphertext for normal app data.',
443
+ 'Encrypted fields cannot be filtered or sorted.',
376
444
  ],
377
445
  },
378
446
  {
@@ -471,35 +539,35 @@ return @DATA\`
471
539
  name: 'Admin menu and extension permission gates',
472
540
  code: `<template>
473
541
  <section class="space-y-4">
474
- <PermissionGate :condition="canReadCloudProjects">
542
+ <PermissionGate :condition="canReadReports">
475
543
  <template #default>
476
544
  <div class="flex items-center justify-between gap-3">
477
- <h2 class="text-lg font-semibold">Cloud projects</h2>
545
+ <h2 class="text-lg font-semibold">Reports</h2>
478
546
 
479
- <PermissionGate :condition="canCreateCloudProject">
480
- <UButton icon="i-lucide-plus" label="Create project" @click="openCreate = true" />
547
+ <PermissionGate :condition="canCreateReport">
548
+ <UButton icon="i-lucide-plus" label="Create report" @click="openCreate = true" />
481
549
  </PermissionGate>
482
550
  </div>
483
551
 
484
- <div v-for="project in projects" :key="project.id" class="rounded-lg border p-4">
485
- <NuxtLink :to="\`/cloud/projects/\${project.id}\`" class="font-medium">
486
- {{ project.name }}
552
+ <div v-for="report in reports" :key="report.id" class="rounded-lg border p-4">
553
+ <NuxtLink :to="\`/reports/\${report.id}\`" class="font-medium">
554
+ {{ report.title }}
487
555
  </NuxtLink>
488
556
 
489
557
  <div class="mt-3 flex gap-2">
490
- <PermissionGate :condition="canUpdateCloudProject">
491
- <UButton icon="i-lucide-pause" variant="soft" label="Disable" @click="openDisable(project)" />
558
+ <PermissionGate :condition="canUpdateReport">
559
+ <UButton icon="i-lucide-pencil" variant="outline" label="Edit" @click="openEdit(report)" />
492
560
  </PermissionGate>
493
561
 
494
- <PermissionGate :condition="canDeleteCloudProject">
495
- <UButton icon="i-lucide-trash-2" color="error" variant="soft" label="Delete" @click="openDelete(project)" />
562
+ <PermissionGate :condition="canDeleteReport">
563
+ <UButton icon="i-lucide-trash-2" color="error" variant="outline" label="Delete" @click="openDelete(report)" />
496
564
  </PermissionGate>
497
565
  </div>
498
566
  </div>
499
567
  </template>
500
568
 
501
569
  <template #fallback>
502
- <EmptyState title="No access" description="You do not have permission to view Cloud projects." />
570
+ <EmptyState title="No access" description="You do not have permission to view reports." />
503
571
  </template>
504
572
  </PermissionGate>
505
573
  </section>
@@ -508,28 +576,28 @@ return @DATA\`
508
576
  <script setup>
509
577
  const { checkPermissionCondition } = usePermissions()
510
578
 
511
- const canReadCloudProjects = computed(() => checkPermissionCondition({
579
+ const canReadReports = computed(() => checkPermissionCondition({
512
580
  or: [
513
- { route: '/cloud/projects', methods: ['GET'] },
514
- { route: '/cloud_projects', methods: ['GET'] }
581
+ { route: '/reports', methods: ['GET'] },
582
+ { route: '/report_definition', methods: ['GET'] }
515
583
  ]
516
584
  }))
517
585
 
518
- const canCreateCloudProject = computed(() => checkPermissionCondition({
519
- or: [{ route: '/cloud_projects', methods: ['POST'] }]
586
+ const canCreateReport = computed(() => checkPermissionCondition({
587
+ or: [{ route: '/report_definition', methods: ['POST'] }]
520
588
  }))
521
589
 
522
- const canUpdateCloudProject = computed(() => checkPermissionCondition({
523
- or: [{ route: '/cloud_projects', methods: ['PATCH'] }]
590
+ const canUpdateReport = computed(() => checkPermissionCondition({
591
+ or: [{ route: '/report_definition', methods: ['PATCH'] }]
524
592
  }))
525
593
 
526
- const canDeleteCloudProject = computed(() => checkPermissionCondition({
527
- or: [{ route: '/cloud_projects', methods: ['DELETE'] }]
594
+ const canDeleteReport = computed(() => checkPermissionCondition({
595
+ or: [{ route: '/report_definition', methods: ['DELETE'] }]
528
596
  }))
529
597
  </script>`,
530
598
  notes: [
531
599
  'This is menu/extension visibility, not row-level RLS.',
532
- 'Set menu_definition.permission on every sensitive admin menu. Example for /cloud/hosts: { or: [{ route: "/cloud/admin/hosts", methods: ["GET"] }, { route: "/cloud_servers", methods: ["GET"] }] }.',
600
+ 'Set menu_definition.permission on every sensitive admin menu. Example for /reports: { or: [{ route: "/reports", methods: ["GET"] }, { route: "/report_definition", methods: ["GET"] }] }.',
533
601
  'Admin pages are sensitive. Use permission gates by default, not as an optional polish step.',
534
602
  'Menus should only be visible when the user has at least GET permission for the page route or backing data route.',
535
603
  '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.',
@@ -725,7 +793,13 @@ return {
725
793
  path: "/reports",
726
794
  icon: "lucide:bar-chart-3",
727
795
  order: 20,
728
- isEnabled: true
796
+ isEnabled: true,
797
+ permission: JSON.stringify({
798
+ or: [
799
+ { route: "/reports", methods: ["GET"] },
800
+ { route: "/report_definition", methods: ["GET"] }
801
+ ]
802
+ })
729
803
  })
730
804
 
731
805
  // Read the created menu id from the tool response, then:
@@ -734,12 +808,13 @@ create_extension({
734
808
  name: "ReportsPage",
735
809
  description: "Reports dashboard",
736
810
  menuId: "<created-menu-id>",
737
- code: "<template><section class=\\"min-h-full w-full space-y-4\\"><div class=\\"grid gap-4 md:grid-cols-3\\"><UCard><p class=\\"text-sm text-muted\\">Total</p><p class=\\"mt-2 text-2xl font-semibold\\">0</p></UCard></div></section></template><script setup>const { registerPageHeader } = usePageHeaderRegistry(); registerPageHeader({ title: 'Reports', description: 'Operational report overview.', leadingIcon: 'lucide:bar-chart-3', gradient: 'cyan', variant: 'minimal' }); useHeaderActionRegistry({ id: 'refresh-reports', label: 'Refresh', icon: 'lucide:refresh-cw', onClick: () => {}, order: 0 })</script>",
811
+ code: "<template><section class=\\"min-h-full w-full space-y-4\\"><div class=\\"grid gap-4 md:grid-cols-3\\"><UCard><p class=\\"text-sm text-muted\\">Total</p><p class=\\"mt-2 text-2xl font-semibold\\">0</p></UCard></div></section></template><script setup>const { registerPageHeader } = usePageHeaderRegistry(); registerPageHeader({ title: 'Reports', description: 'Operational report overview.', leadingIcon: 'lucide:bar-chart-3', gradient: 'cyan', variant: 'minimal' }); useHeaderActionRegistry([{ id: 'refresh-reports', label: 'Refresh', icon: 'lucide:refresh-cw', onClick: () => {}, order: 0 }])</script>",
738
812
  isEnabled: true
739
813
  })`,
740
814
  notes: [
741
815
  'Menu provides navigation; extension provides content.',
742
816
  'Use menu_definition.label, not title.',
817
+ 'Sensitive admin menus should include a permission condition at creation time.',
743
818
  'For page extensions, create the menu first and pass menuId to create_extension.',
744
819
  'Page extensions must register the app-shell PageHeader with usePageHeaderRegistry instead of rendering a custom top header.',
745
820
  'Use variant: "minimal" for operational pages unless a larger header is intentionally needed.',
@@ -756,35 +831,35 @@ create_extension({
756
831
  const { registerPageHeader } = usePageHeaderRegistry()
757
832
 
758
833
  registerPageHeader({
759
- title: 'Host detail',
760
- description: 'Provider state, capacity, projects, and reconciliation status.',
761
- leadingIcon: 'lucide:server',
834
+ title: 'Report detail',
835
+ description: 'Review status, schedule, and delivery history.',
836
+ leadingIcon: 'lucide:file-text',
762
837
  gradient: 'cyan',
763
838
  variant: 'minimal'
764
839
  })
765
840
 
766
841
  useHeaderActionRegistry([
767
842
  {
768
- id: 'back-to-hosts',
769
- label: 'Hosts',
843
+ id: 'back-to-reports',
844
+ label: 'Reports',
770
845
  icon: 'lucide:arrow-left',
771
846
  color: 'neutral',
772
847
  variant: 'ghost',
773
848
  order: 0,
774
- onClick: () => navigateTo('/cloud/hosts')
849
+ onClick: () => navigateTo('/reports')
775
850
  },
776
851
  {
777
- id: 'run-host-check',
778
- label: 'Run check',
779
- icon: 'lucide:scan-search',
852
+ id: 'send-test-report',
853
+ label: 'Send test',
854
+ icon: 'lucide:send',
780
855
  color: 'neutral',
781
856
  variant: 'outline',
782
857
  order: 1,
783
- permission: { or: [{ route: '/cloud/admin/hosts/reconcile', methods: ['POST'] }] },
784
- onClick: runCheck
858
+ permission: { or: [{ route: '/reports/send-test', methods: ['POST'] }] },
859
+ onClick: sendTest
785
860
  },
786
861
  {
787
- id: 'refresh-host',
862
+ id: 'refresh-report',
788
863
  label: 'Refresh',
789
864
  icon: 'lucide:refresh-cw',
790
865
  color: 'primary',
@@ -832,33 +907,39 @@ useHeaderActionRegistry([
832
907
  ],
833
908
  },
834
909
  {
835
- name: 'Plan a Cloud admin dashboard as multiple pages',
910
+ name: 'Plan an admin dashboard as multiple pages',
836
911
  code: `// Recommended menu shape for an operations surface:
837
912
  create_menu({
838
913
  type: "Dropdown Menu",
839
- label: "Cloud",
840
- path: "/cloud",
841
- icon: "lucide:cloud",
914
+ label: "Operations",
915
+ path: "/operations",
916
+ icon: "lucide:layout-dashboard",
842
917
  order: 2,
843
- isEnabled: true
918
+ isEnabled: true,
919
+ permission: JSON.stringify({
920
+ or: [
921
+ { route: "/operations/jobs", methods: ["GET"] },
922
+ { route: "/flow_execution_definition", methods: ["GET"] }
923
+ ]
924
+ })
844
925
  })
845
926
 
846
927
  // Child page extensions should be focused:
847
928
  // /dashboard compact summary/routing hub: KPIs, current signal, attention queue, navigation cards
848
- // /cloud/projects project status and drill-downs
849
- // /cloud/provisioning project/run grouped provisioning status, current step, meaning, next action
850
- // /cloud/billing orders/subscriptions/refunds
851
- // /cloud/infrastructure hosts/capacity/plans/system credential readiness
852
- // /cloud/readiness legal/Paddle/landing launch checklist
929
+ // /operations/jobs background jobs, current step, meaning, next action
930
+ // /operations/orders order/payment status and drill-downs
931
+ // /operations/reports report configuration and delivery history
932
+ // /operations/settings system readiness and configuration
853
933
  // Use UTabs inside large pages instead of placing every section in one dashboard.
854
- // For admin record management, link to /data/<table>, e.g. /data/landing_terms, not public landing paths.`,
934
+ // For admin record management, link to /data/<table>, e.g. /data/report_definition, not public website paths.`,
855
935
  notes: [
856
936
  'Design the menu/page split before generating dashboard code.',
937
+ 'Permission-gate sensitive parent dropdown menus too, using any child page route or backing route that represents read access.',
857
938
  'Keep /dashboard as a summary and distribution page, not a detailed operations table.',
858
939
  'Use focused pages for operational domains.',
859
940
  'Each page extension must use usePageHeaderRegistry for the app-shell title strip and should not render a duplicate top header in the body.',
860
941
  'PageHeader.stats is reserved for deliberate overview headers; operational KPIs belong in body cards/tables.',
861
- 'Provisioning pages should not show raw history rows as the primary UI; group by project/run and translate step keys into operator-facing labels.',
942
+ '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.',
862
943
  'Operational lists should use pagination plus search/filter controls; do not rely on arbitrary fixed limits such as limit=50.',
863
944
  'UTabs is available in eApp extension runtime for page-level sections.',
864
945
  'Admin links for editing or inspecting records should point to /data/<table> routes.',
@@ -887,22 +968,93 @@ onMounted(() => fetchOrders())
887
968
  'Keep extension UI focused; move backend logic into handlers/hooks when needed.',
888
969
  ],
889
970
  },
971
+ {
972
+ name: 'Modal and drawer buttons do not submit accidentally',
973
+ code: `<template>
974
+ <CommonModal v-model:open="open">
975
+ <template #header>
976
+ <h3 class="text-lg font-semibold">Update version</h3>
977
+ </template>
978
+
979
+ <template #body>
980
+ <UInput v-model="version" />
981
+ <UButton
982
+ type="button"
983
+ icon="i-lucide-refresh-cw"
984
+ label="Check version"
985
+ @click.stop.prevent="checkVersion"
986
+ />
987
+ </template>
988
+
989
+ <template #footer>
990
+ <UButton
991
+ type="button"
992
+ color="neutral"
993
+ variant="ghost"
994
+ label="Cancel"
995
+ @click.stop.prevent="open = false"
996
+ />
997
+ <UButton
998
+ type="button"
999
+ color="primary"
1000
+ label="Update version"
1001
+ :disabled="!canSubmit"
1002
+ @click.stop.prevent="submit"
1003
+ />
1004
+ </template>
1005
+ </CommonModal>
1006
+ </template>`,
1007
+ notes: [
1008
+ 'Every trigger/footer/action button inside CommonModal, CommonDrawer, or UModal should use type="button" unless it intentionally submits a form.',
1009
+ 'Use @click.stop.prevent on modal/drawer action buttons so clicks do not bubble to row/page triggers.',
1010
+ 'Open modal/drawer shells immediately, then load content inside them; do not close and reopen after an API call.',
1011
+ 'Keep destructive final actions disabled until all confirmation inputs are valid.',
1012
+ ],
1013
+ },
890
1014
  {
891
1015
  name: 'Extension can use modern browser APIs',
892
1016
  code: `<script setup lang="ts">
893
1017
  const statuses = ['active', 'ready']
894
1018
  const ok = statuses.includes('active')
895
- const requiredTerms = new Set(['cloud-terms', 'privacy-policy', 'refund-policy'])
1019
+ const requiredTerms = new Set(['terms', 'privacy'])
896
1020
  const loaded = await Promise.all([Promise.resolve(1), Promise.resolve(2)])
897
1021
  const label = String('pending_payment').replace(/_/g, ' ')
898
1022
  const date = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }).format(new Date())
899
- console.log(ok, requiredTerms.has('cloud-terms'), loaded, label, date)
1023
+ console.log(ok, requiredTerms.has('terms'), loaded, label, date)
900
1024
  </script>`,
901
1025
  notes: [
902
1026
  'Do not rewrite extension code to ES5 when tooling rejects modern APIs.',
903
1027
  'If diagnostics complain about these APIs, fix eApp extension TypeScript lib/runtime contract.',
904
1028
  ],
905
1029
  },
1030
+ {
1031
+ name: 'Install and use an app package in an extension',
1032
+ code: `install_package({
1033
+ name: "dayjs",
1034
+ type: "App"
1035
+ })
1036
+
1037
+ // Then in extension code:
1038
+ <script setup>
1039
+ const formatted = ref('')
1040
+
1041
+ onMounted(async () => {
1042
+ const pkgs = await getPackages(['dayjs'])
1043
+ const dayjs = pkgs.dayjs
1044
+ formatted.value = dayjs().format('YYYY-MM-DD')
1045
+ })
1046
+ </script>
1047
+
1048
+ <template>
1049
+ <span>{{ formatted }}</span>
1050
+ </template>`,
1051
+ notes: [
1052
+ 'Install browser-side extension dependencies as type: "App".',
1053
+ 'Do not use static import statements in extension_definition.code.',
1054
+ 'Load app packages with getPackages([...]) inside the extension runtime.',
1055
+ 'Use onMounted or an explicit action for package loading when the UI can render a loading state.',
1056
+ ],
1057
+ },
906
1058
  {
907
1059
  name: 'Dashboard aggregate stats with a time range',
908
1060
  code: `<script setup>
@@ -929,7 +1081,7 @@ const flowStats = useApi('/flow_execution_definition', {
929
1081
  }))
930
1082
  })
931
1083
 
932
- const orderStats = useApi('/cloud_payment_orders', {
1084
+ const orderStats = useApi('/order_definition', {
933
1085
  query: computed(() => ({
934
1086
  fields: 'id',
935
1087
  limit: 1,
@@ -17,7 +17,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
17
17
  const base = String(apiBaseUrl || '').replace(/\/$/, '');
18
18
  const { graphqlHttpUrl, graphqlSchemaUrl } = buildGraphqlUrls(apiBaseUrl);
19
19
  const getList = `${base}/<table_name>`;
20
- const getOneById = `${base}/<table_name>?filter={"id":{"_eq":"<id>"}}&limit=1`;
20
+ const getOneById = `${base}/<table_name>?filter={"<primaryKeyFromMetadata>":{"_eq":"<id>"}}&limit=1`;
21
21
  const patchOne = `${base}/<table_name>/<id>`;
22
22
  const delOne = `${base}/<table_name>/<id>`;
23
23
  const examplePost = `${base}/post`;
@@ -59,20 +59,20 @@ export function buildMcpServerInstructions(apiBaseUrl) {
59
59
  '',
60
60
  '### When the user asks how to connect a Nuxt/Next/SSR app to Enfyra',
61
61
  '- This is guidance for the assistant to answer users and generate app code. It is not a separate MCP tool workflow.',
62
- '- Follow the Enfyra Cloud app pattern by default: expose one same-origin proxy prefix such as **`/enfyra/**`** and route it to the hidden Enfyra API base, e.g. Nuxt `routeRules: { "/enfyra/**": { proxy: { to: `${API_URL}/**`, fetchOptions: { redirect: "manual" } } } }`. Browser/generated app code calls `/enfyra/...`, not the raw Enfyra backend URL. Keep redirects manual so OAuth set-cookie redirects reach the browser with their `Set-Cookie` headers.',
63
- '- Do **not** generate custom login/logout/me server routes that manually set `accessToken`, `refreshToken`, or `expTime` cookies when a Cloud-style proxy is enough. Let the proxied Enfyra API own its auth response and cookies.',
64
- '- Password login in generated Cloud-style Nuxt code is **`POST /enfyra/login`**, not `/enfyra/auth/login` and not a custom SSR `/api/login` wrapper.',
62
+ '- Follow the app-origin proxy pattern by default: expose one same-origin proxy prefix such as **`/enfyra/**`** and route it to the hidden Enfyra API base, e.g. Nuxt `routeRules: { "/enfyra/**": { proxy: { to: `${API_URL}/**`, fetchOptions: { redirect: "manual" } } } }`. Browser/generated app code calls `/enfyra/...`, not the raw Enfyra backend URL. Keep redirects manual so OAuth set-cookie redirects reach the browser with their `Set-Cookie` headers.',
63
+ '- Do **not** generate custom login/logout/me server routes that manually set `accessToken`, `refreshToken`, or `expTime` cookies when a same-origin proxy is enough. Let the proxied Enfyra API own its auth response and cookies.',
64
+ '- Password login in generated Nuxt code is **`POST /enfyra/login`**, not `/enfyra/auth/login` and not a custom SSR `/api/login` wrapper.',
65
65
  '- Fetch the current user with **`GET /enfyra/me`** and logout with **`POST /enfyra/logout`**. Browser fetches stay same-origin and credentials/cookies flow through the proxy. Do not read JWTs in browser JavaScript for this mode.',
66
66
  '- OAuth starts on the same proxy prefix, e.g. **`GET /enfyra/auth/{provider}?redirect=<absoluteReturnUrl>&cookieBridgePrefix=/enfyra`**. `redirect` must be an absolute `http(s)` URL with the app origin. `cookieBridgePrefix` is the third app proxy prefix that forwards to the Enfyra API; Enfyra normalizes it, so `enfyra`, `/enfyra`, and `/enfyra/` all mean `/enfyra`. Use token-query callback handling only when the app intentionally manages tokens itself.',
67
67
  '- Socket.IO uses the app bridge too. Browser clients should connect to the gateway namespace with the Socket.IO transport path on the app origin, e.g. `io("/chat", { path: "/socket.io", withCredentials: true })`, while Nuxt proxies `/socket.io/**` to the Enfyra app bridge `/ws/socket.io/**`. Do not connect browser code directly to the hidden backend Socket.IO endpoint.',
68
- '- If a project explicitly standardizes on `/api/**` instead of `/enfyra/**`, keep the same Cloud-style behavior under that prefix: proxy to the Enfyra API and avoid generated cookie-management routes unless the user asks for a custom auth boundary.',
68
+ '- If a project explicitly standardizes on `/api/**` instead of `/enfyra/**`, keep the same proxy behavior under that prefix: proxy to the Enfyra API and avoid generated cookie-management routes unless the user asks for a custom auth boundary.',
69
69
  '- If you are explaining MCP\'s own internal authentication, that is separate: this MCP server exchanges `ENFYRA_API_TOKEN` against `{ENFYRA_API_URL}/auth/token/exchange` before authenticated tool calls. The raw `efy_pat_*` token is never a Bearer token. For normal app work, `ENFYRA_API_URL` must still be the app proxy base such as `{{ nuxtApp }}/api`.',
70
70
  '',
71
71
  '### Routes vs tables (custom endpoints, handlers, hooks)',
72
72
  '- REST-first workflow for any feature: **`inspect_feature`** to locate candidates → **`inspect_table`** for table/field/relation/rule context → **`inspect_route`** for handlers/hooks/guards/permissions → **`test_rest_endpoint`** to verify the actual HTTP behavior.',
73
73
  '- Use **`create_column_rule`** for standard request validation, **`create_field_permission`** for per-field read/create/update rules, **`create_route_permission`** for authenticated route access, and **`create_guard`** for pre/post-auth request gates.',
74
74
  '- Prefer these REST inspection/operator tools over raw `query_table` on system tables when changing route behavior. They resolve ids, methods, route paths, code previews, and cache reloads for the model.',
75
- '- If the user asks for a **new route**, **URL path**, **custom API endpoint**, **handler**, **pre-hook**, **post-hook**, or to **test** that kind of logic: use MCP **`create_route`** and **omit `mainTableId`**. `mainTable` is only a marker for canonical table routes like `/orders`; custom paths such as `/orders/stats`, `/cloud/admin/hosts`, `/auth/login`, or `/me` must not set it.',
75
+ '- If the user asks for a **new route**, **URL path**, **custom API endpoint**, **handler**, **pre-hook**, **post-hook**, or to **test** that kind of logic: use MCP **`create_route`** and **omit `mainTableId`**. `mainTable` is only a marker for canonical table routes like `/orders`; custom paths such as `/orders/stats`, `/reports/summary`, `/auth/login`, or `/me` must not set it.',
76
76
  '- **Wrong pattern:** calling **`create_table`** just to get an HTTP path, then overriding handlers on the **default** auto route `/{table_name}`. That adds unnecessary schema and breaks the usual CRUD surface for that table.',
77
77
  '- **`create_table`** is only when the user needs **new persisted data** (new entity + columns). It is **not** the right tool when the goal is only a new path or custom script.',
78
78
  '- **Right pattern:** **`create_route`** without `mainTableId` → optional **`create_handler`** / **`create_pre_hook`** / **`create_post_hook`** on **that route’s id** (from **`get_all_routes`** after create). Handler/hook code must query explicit repos such as `$ctx.$repos.orders`; do not rely on `$repos.main` for custom routes.',
@@ -129,7 +129,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
129
129
  '- Each route has **publishedMethods** (which HTTP verbs are “public”) and **routePermissions** (roles/users for protected access).',
130
130
  '- If the **current request method** is listed in **publishedMethods** for that route, the server allows the call **without** a Bearer token (`RoleGuard`).',
131
131
  '- Otherwise the client must send an **Authorization** header with **Bearer** JWT from login. Then the user must satisfy **routePermissions** (unless root admin).',
132
- '- Cloud owner-scoped GET handlers must keep normal users filtered to their own `cloud_projects.owner`, but root admin operational views must bypass that owner filter with `@USER.isRootAdmin`. Apply this consistently to `cloud_projects`, `cloud_subscriptions`, `cloud_payment_orders`, `cloud_provisioning_history`, and `/cloud/projects` so admin data routes match admin summary APIs.',
132
+ '- Owner-scoped GET handlers must preserve caller filters and merge the owner scope for normal users, while root admin operational views may bypass that owner scope with `@USER.isRootAdmin` when the product explicitly needs global admin visibility.',
133
133
  '- MCP tools that use `fetchAPI` authenticate with the configured `ENFYRA_API_TOKEN`. Explain to users that **direct HTTP** calls need a Bearer token unless the route/method is published.',
134
134
  '',
135
135
  '### Post-hooks (REST)',
@@ -137,15 +137,14 @@ export function buildMcpServerInstructions(apiBaseUrl) {
137
137
  '- You may **mutate** `@DATA` / `$ctx.$data` in place, or **return** a value: a non-`undefined` return replaces `$ctx.$data` as the response body.',
138
138
  '',
139
139
  '### Dynamic script syntax preference',
140
- '- When writing server-side Enfyra scripts, prefer template macros over raw `$ctx` access: use `@BODY`, `@QUERY`, `@PARAMS`, `@USER`, `@REPOS`, `@HELPERS`, `@SOCKET`, `@TRIGGER`, `@DATA`, `@ERROR`, and `@THROW400`–`@THROW503`.',
140
+ '- When writing server-side Enfyra scripts, prefer template macros over raw `$ctx` access: use `@BODY`, `@QUERY`, `@PARAMS`, `@USER`, `@REPOS`, `@HELPERS`, `@SOCKET`, `@TRIGGER`, `@DATA`, `@ERROR`, and `@THROW400`–`@THROW503`. `$ctx.$env` currently has no macro; access it directly when needed.',
141
141
  '- Use Enfyra native throw helpers for intentional errors: `@THROW400("message")`, `@THROW403()`, `@THROW404("resource", id)`, or `$ctx.$throw[400]("message")`. Do not generate `throw new Error(...)` for user/domain errors in handlers, hooks, flows, websocket events, OAuth scripts, or admin-generated scripts.',
142
- '- For regular app data that must be encrypted at rest, create the column with `isEncrypted=true`; Enfyra database-query hooks will encrypt on insert/update and decrypt after select. `isEncrypted` does not imply immutability; use `isUpdatable=false` separately only when the field itself must be immutable. Do not filter or sort on encrypted fields. Legacy Cloud control-plane fields named `*_encrypted` may still use route pre-hooks until migrated.',
142
+ '- For regular app data that must be encrypted at rest, create the column with `isEncrypted=true`; Enfyra database-query hooks will encrypt on insert/update and decrypt after select. `isEncrypted` does not imply immutability; use `isUpdatable=false` separately only when the field itself must be immutable. Do not filter or sort on encrypted fields. Do not generate new route pre-hooks for manual encryption.',
143
143
  '- Enfyra scripts use `$helpers.$crypto` for bounded crypto helpers such as `randomUUID()`, `randomBytes(size, encoding)`, `sha256(value, encoding)`, `hmacSha256(value, secret, encoding)`, and `generateSshKeyPair(comment)`. Do not generate legacy `$helpers.$ssh` or `$helpers.$secrets` usage.',
144
+ '- `$ctx.$env` exposes only a sanitized process env snapshot. Current OSS deny keys are exact matches: `DB_URI`, `DB_REPLICA_URIS`, `REDIS_URI`, `SECRET_KEY`, and `ADMIN_PASSWORD`. Do not read secrets from `$ctx.$env`; model app secrets as unpublished `isEncrypted=true` fields instead.',
144
145
  '- Script-backed records use one shared persistence contract: `sourceCode` is the editable source, `scriptLanguage` controls compilation, and `compiledCode` is generated by the server from `sourceCode`. Do not hand-edit or send stale `compiledCode` from generated tools; save `sourceCode`/`scriptLanguage` through `PATCH /<script_table>/<id>` and let the server persist generated `compiledCode` internally. Public metadata may mark `compiledCode` non-updatable, but the server engine must still preserve the generated value after normalization.',
145
146
  '- For route handlers specifically, the field is also `sourceCode`. Older names such as `logic` are wrong for current Enfyra REST CRUD and will be rejected. Use MCP `create_handler` so it writes `sourceCode` and resolves method ids correctly.',
146
147
  '- MCP `create_pre_hook` and `create_post_hook` accept a user-facing `code` argument but persist it as `sourceCode` with `scriptLanguage`. Do not call raw `create_record` with a `code` field for hook tables; backend request validation rejects `code` on REST CRUD.',
147
- '- Enfyra Cloud host provisioning with PgBouncer must preserve tenant database isolation. PgBouncer should use per-tenant `DATABASE_URLS` entries with each tenant `db_user`, `db_password`, and `db_name`, and PostgreSQL 16/SCRAM hosts need `AUTH_TYPE=plain`. Do not route all tenants through PostgreSQL `postgres` just to make PgBouncer connect; that bypasses tenant DB permissions.',
148
- '- Enfyra Cloud Docker health checks must compare exact healthy states. `true healthy` passes, `true starting` keeps waiting, and `true unhealthy` fails. Do not use broad substring logic where `unhealthy` accidentally counts as healthy.',
149
148
  '- Before saving generated script code, validate it with `POST /admin/script/validate` when available. It compiles with the server kernel and parses the executable async body without running side effects. Enfyra App `FormCodeEditor` also exposes a `Validate` action for this endpoint; use it before save/run when editing through the UI. If unavailable, use `run_admin_test`/`test_flow_step` as the closest validation path before saving.',
150
149
  '- Do not coerce dynamic script values with `String(...)`, `Number(...)`, or `Boolean(...)`. Enfyra payloads, user ids, record ids, and relation ids should keep their runtime type; validate required values and pass them through directly.',
151
150
  '- Use raw `$ctx` only when there is no template macro for the field or helper you need.',
@@ -180,14 +179,14 @@ export function buildMcpServerInstructions(apiBaseUrl) {
180
179
  '- Enfyra exposes OAuth callback at **`{ENFYRA_API_URL}/auth/{provider}/callback`**. Example when `ENFYRA_API_URL` is `http://localhost:3000/api`: **Google** callback URL is **`http://localhost:3000/api/auth/google/callback`** — i.e. `{ENFYRA_API_URL}/auth/google/callback` (same pattern for Facebook/GitHub: `.../auth/facebook/callback`, `.../auth/github/callback`).',
181
180
  '- **Google Cloud Console** → OAuth client → **Authorized redirect URIs**: register **exactly** that URL (scheme + host + path, no typo, no extra slash).',
182
181
  '- **Enfyra** (`oauth_config_definition` / OAuth settings): field **`redirectUri`** must be the **same string** as in Google Console — byte-for-byte. If they differ, Google or the server will reject the flow.',
183
- '- **Cloud-style proxy mode:** generated Nuxt/Next apps should start OAuth through the same-origin proxy, e.g. `/enfyra/auth/google?redirect=<absoluteReturnUrl>&cookieBridgePrefix=/enfyra`, and let Enfyra handle the auth response. Do not generate a set-cookie route unless the user explicitly chooses a custom SSR auth boundary.',
182
+ '- **Same-origin proxy mode:** generated Nuxt/Next apps should start OAuth through the same-origin proxy, e.g. `/enfyra/auth/google?redirect=<absoluteReturnUrl>&cookieBridgePrefix=/enfyra`, and let Enfyra handle the auth response. Do not generate a set-cookie route unless the user explicitly chooses a custom SSR auth boundary.',
184
183
  '- **Manual token mode only:** `appCallbackUrl` is the frontend URL where Enfyra redirects after OAuth with `accessToken`, `refreshToken`, etc. in query. Use this only when the app intentionally manages tokens itself; it is not the preferred Nuxt/Next SSR pattern.',
185
184
  '',
186
185
  '**Server flow (for answering users or designing FE):**',
187
- '1. **Start login (redirect user in browser):** Cloud-style apps use `GET /enfyra/auth/{provider}?redirect=<URL_ENCODED_ABSOLUTE_RETURN_URL>&cookieBridgePrefix=/enfyra` from the app origin; direct/manual apps may use `GET {base}/auth/{provider}?redirect=<URL_ENCODED>`. `redirect` is required and is where to send the user after the whole flow.',
186
+ '1. **Start login (redirect user in browser):** proxy-mode apps use `GET /enfyra/auth/{provider}?redirect=<URL_ENCODED_ABSOLUTE_RETURN_URL>&cookieBridgePrefix=/enfyra` from the app origin; direct/manual apps may use `GET {base}/auth/{provider}?redirect=<URL_ENCODED>`. `redirect` is required and is where to send the user after the whole flow.',
188
187
  '2. Server **302** to Google/Facebook/GitHub authorization page.',
189
188
  '3. Provider calls back: `GET {base}/auth/{provider}/callback?code=...&state=...` (server exchanges code, creates/links user, issues JWT).',
190
- '4. In Cloud-style proxy mode, Enfyra redirects to `{redirect.origin}{cookieBridgePrefix}/auth/set-cookies?...&redirect=<originalRedirect>`. That request goes through the third app proxy to Enfyra, Enfyra returns `Set-Cookie`, then the browser is redirected to the original `redirect`. In manual token mode, backend redirects to `appCallbackUrl` with token query params. On failure, redirect includes `?error=...`.',
189
+ '4. In same-origin proxy mode, Enfyra redirects to `{redirect.origin}{cookieBridgePrefix}/auth/set-cookies?...&redirect=<originalRedirect>`. That request goes through the app proxy to Enfyra, Enfyra returns `Set-Cookie`, then the browser is redirected to the original `redirect`. In manual token mode, backend redirects to `appCallbackUrl` with token query params. On failure, redirect includes `?error=...`.',
191
190
  '',
192
191
  '**Frontend build checklist:**',
193
192
  '- Nuxt/Next generated apps: implement a same-origin API proxy such as `/enfyra/**` to the Enfyra API. Browser code never stores JWTs.',
@@ -294,37 +293,31 @@ export function buildMcpServerInstructions(apiBaseUrl) {
294
293
  '- **CRITICAL:** MUST call `create_record` or `update_record` on `extension_definition` — outputting Vue code in chat does NOT save it. User will NOT see it.',
295
294
  '- **Code format:** Vue SFC only. Structure: `<template>...</template>` + `<script setup>...</script>`. Server auto-compiles; if compile fails, fix and retry.',
296
295
  '- **NO import statements.** All APIs are injected globally (see full list below).',
297
- '- **Design first for dashboards:** before creating/updating a dashboard extension, define the menu/page split, time range controls, tabs, and drill-down links. Keep `/dashboard` as a compact summary and routing hub only: KPIs, current signal, attention queue, and navigation cards. Put detailed lists/tables/timelines into focused pages such as projects, provisioning, billing, infrastructure, and readiness.',
298
- '- **Operational page modeling:** model admin operational pages around the operator mental entity, not raw database/event rows. For provisioning, group history by project/run, translate internal step keys into readable labels, show current step, operator meaning, and next action, and keep raw history as a secondary `/data` shortcut.',
299
- '- **Operational list data loading:** do not use arbitrary fixed limits such as `limit=50` as the whole data strategy for admin pages. Use pagination, expose result count when the API supports `meta=filterCount`, and add search/filter controls for natural lookup keys such as project id, name, and subdomain.',
296
+ '- **Design first for dashboards:** before creating/updating a dashboard extension, define the menu/page split, time range controls, tabs, and drill-down links. Keep `/dashboard` as a compact summary and routing hub only: KPIs, current signal, attention queue, and navigation cards. Put detailed lists/tables/timelines into focused pages such as jobs, orders, reports, integrations, and settings.',
297
+ '- **Operational page modeling:** model admin operational pages around the operator mental entity, not raw database/event rows. For long-running jobs, group history by entity/run, translate internal step keys into readable labels, show current step, operator meaning, and next action, and keep raw history as a secondary `/data` shortcut.',
298
+ '- **Operational list data loading:** do not use arbitrary fixed limits such as `limit=50` as the whole data strategy for admin pages. Use pagination, expose result count when the API supports `meta=filterCount`, and add search/filter controls for natural lookup keys such as id, name, slug, status, email, or external reference.',
300
299
  '- **ESV aggregate contract:** aggregate query must be an object keyed by a real field or relation, for example `aggregate: { id: { count: true }, status: { count: { _eq: "failed" } }, amount: { sum: true } }`. Results are returned in `response.meta.aggregate`. Time windows and cross-field conditions belong in top-level `filter`, not inside a field aggregate condition. Field aggregate conditions only support operators on that same field; relation aggregates use `countRecords`.',
301
300
  '- **Aggregate numeric rule:** `sum` and `avg` require a numeric field in ESV. Do not aggregate money stored as varchar/text. Use a numeric money field such as `amount_usd` with type `float`, `amount_cents`, or `amount` for revenue stats, or build a dedicated stats route that normalizes legacy values explicitly. If metadata says `float` but SQL aggregate still fails with `sum(character varying)`, the Enfyra Server physical schema is stale or missing the SQL float DDL mapping and must be redeployed/healed before relying on aggregate.',
302
301
  '- **Partial reload default:** ESV/ASV automatically triggers partial reloads for metadata, routes, menus, extensions, flows, handlers, and related caches after successful writes. Do not reflexively call `/admin/reload`, `/admin/reload/metadata`, or `/admin/reload/routes` after each change. Verify naturally first; use manual reload only when verification shows stale behavior, a reload event failed, or a concrete error indicates the partial reload did not apply.',
303
302
  '- **Menu/extension realtime reload contract:** `menu_definition` and `extension_definition` writes are runtime UI changes, not plain CRUD. The server cache orchestrator must emit `$system:reload` through the admin Socket.IO channel with identifiers that eApp handles; eApp must refetch menus/rebuild the menu registry for menu reloads and invalidate dynamic extension caches for extension reloads. Menu reloads can change route-to-extension mapping, so they should also invalidate extension cache. If an open admin tab does not reflect menu/extension changes, debug this two-sided reload contract before telling the user to refresh.',
304
- '- **Dashboard stats:** time range buttons must change the query filter and reload stats. Cloud dashboard should summarize flow execution errors from `flow_execution_definition` and billing stats from order/subscription records; successful/no-error flow runs do not need a standalone provisioning menu.',
303
+ '- **Dashboard stats:** time range buttons must change the query filter and reload stats. Dashboards should summarize actionable errors and high-level activity; successful/no-error background runs usually do not need a standalone page unless there is a real workflow to manage.',
305
304
  '- **Page layout default:** page extensions should render full-bleed inside the app shell by default. The extension root is already inside the eApp page `<main>`, so do not add root-level page padding such as `p-4 sm:p-6 xl:p-8`; use spacing between internal sections only. Do not wrap the entire page in a centered card/container unless explicitly requested. Use responsive grids/stacks from the first version so the page works on desktop, tablet, and mobile.',
306
305
  '- **PageHeader is mandatory for page extensions:** eApp already renders `CommonPageHeader` from `usePageHeaderRegistry()` in the app shell. Page extensions must call `const { registerPageHeader } = usePageHeaderRegistry()` and register app-level context such as `{ title, description, leadingIcon, gradient, variant }` instead of rendering their own top `<header>` inside extension content. Use `variant: "minimal"` for operational/admin detail pages unless the page intentionally needs a larger title strip.',
307
- '- **Do not misuse PageHeader stats:** `PageHeader.stats` renders prominent stat cards inside the shell header. Do not put normal operational KPIs, host capacity, billing totals, or detail metrics there by default; keep those as body cards/tables where the operator can scan them with the page content. Only use PageHeader stats for a deliberately compact overview page where the stats are truly header-level context.',
308
- '- **Page actions belong in registries:** Move page-level buttons into `useHeaderActionRegistry` or `useSubHeaderActionRegistry`; keep the extension body for operational content only. Sensitive registry actions must include a `permission` condition, for example `{ id: "create", label: "Create project", permission: { and: [{ route: "/cloud_projects", methods: ["POST"] }] }, onClick }`.',
306
+ '- **Do not misuse PageHeader stats:** `PageHeader.stats` renders prominent stat cards inside the shell header. Do not put normal operational KPIs, capacity totals, billing totals, or detail metrics there by default; keep those as body cards/tables where the operator can scan them with the page content. Only use PageHeader stats for a deliberately compact overview page where the stats are truly header-level context.',
307
+ '- **Page actions belong in registries:** Move page-level buttons into `useHeaderActionRegistry` or `useSubHeaderActionRegistry`; keep the extension body for operational content only. Sensitive registry actions must include a `permission` condition, for example `{ id: "create", label: "Create report", permission: { and: [{ route: "/report_definition", methods: ["POST"] }] }, onClick }`.',
309
308
  '- **Header action button variants:** choose the button variant by intent. Use `color: "primary", variant: "solid"` for the main page action. Use `color: "neutral", variant: "ghost"` for back/navigation actions and `color: "neutral", variant: "outline"` for visible secondary actions. `variant: "soft"` is only for low-emphasis secondary/chrome actions; do not use soft for critical or primary header actions just because it looks acceptable in dark mode.',
310
309
  '- **Extension navigation:** prefer `NuxtLink` or Nuxt UI components with `:to` for visible navigation links and drill-down cards/buttons. Use `navigateTo(...)` only for imperative navigation after submit, confirm, mutation, or another side effect.',
311
- '- **Extension runtime scope:** eApp exposes Vue APIs and injected Nuxt/Enfyra composables both to script global scope and Vue app `globalProperties`. Template expressions may call injected helpers directly, for example after a save handler can call `navigateTo("/data/cloud_projects")`, because Vue compiles template helpers to `_ctx.*`.',
310
+ '- **Extension runtime scope:** eApp exposes Vue APIs and injected Nuxt/Enfyra composables both to script global scope and Vue app `globalProperties`. Template expressions may call injected helpers directly, for example after a save handler can call `navigateTo("/data/report_definition")`, because Vue compiles template helpers to `_ctx.*`.',
312
311
  '- **Extension CSS affects shell utility ordering:** dynamic extension CSS is injected after the app shell CSS. Shell/page-header code must not put conflicting plain Tailwind utilities on the same element, such as `flex-col` plus `flex-row`, `items-start` plus `items-center`, or `text-left` plus `text-center`. Choose one mutually exclusive class per state; otherwise extension CSS can change which utility wins and shift shell layout.',
313
- '- **Admin record links:** when an admin extension links to backend records for management or inspection, point to eApp data routes such as `/data/landing_terms`, `/data/cloud_projects`, or `/data/cloud_payment_orders`. Do not use public landing-page paths from record fields such as `/cloud-terms` unless the explicit intent is previewing the public website.',
314
- '- **Admin menu visibility is permission-driven, not RLS:** Cloud/admin menu entries are sensitive and must set `menu_definition.permission` so they are visible only to users who have at least GET permission for the backing route or table. Permission conditions use HTTP `methods`, not CRUD `actions`. Do not show a Cloud menu merely because an extension exists or because the path is hardcoded. Example: `/cloud/hosts` menu should require `{ or: [{ route: "/cloud/admin/hosts", methods: ["GET"] }, { route: "/cloud_servers", methods: ["GET"] }] }`.',
312
+ '- **Admin record links:** when an admin extension links to backend records for management or inspection, point to eApp data routes such as `/data/report_definition` or `/data/order_definition`. Do not use public website paths from record fields unless the explicit intent is previewing the public website.',
313
+ '- **Admin menu visibility is permission-driven, not RLS:** admin menu entries are sensitive and must set `menu_definition.permission` so they are visible only to users who have at least GET permission for the backing route or table. Permission conditions use HTTP `methods`, not CRUD `actions`. Do not show an admin menu merely because an extension exists or because the path is hardcoded. Example: `/reports` menu can require `{ or: [{ route: "/reports", methods: ["GET"] }, { route: "/report_definition", methods: ["GET"] }] }`.',
315
314
  '- **PermissionGate is mandatory inside admin extensions:** every sensitive action button, form, mutation, destructive workflow, and data shortcut must be wrapped in `PermissionGate` or guarded with `usePermissions()` before rendering/enabling. Default gates: list/detail visibility needs `methods: ["GET"]`; create and custom flow-trigger routes usually need `methods: ["POST"]`; native record edits need `methods: ["PATCH"]`; native delete routes need `methods: ["DELETE"]`. Root admin still passes through normal permission helpers, but extension code must not rely on root-only assumptions.',
316
315
  '- **Extension permission UX:** if the current user can read a page but cannot perform an action, hide the action by default. If hiding would confuse the workflow, render a disabled state with a short reason. Never let the button render active and depend only on the server rejection; server permissions are the final boundary, not the UI contract.',
317
- '- **Cloud project admin operations:** use canonical `cloud_projects` table routes as the single source of truth. Admin manual create uses `POST /cloud_projects` with schema-safe body fields such as `owner: { id }`, `plan: { id }`, and `status: "creating"`; expiry is passed as query `expired_at=YYYY-MM-DD`, not body `expiredAt`. The UI searches/selects `user_definition` by email and selects a plan by card; do not ask the operator to type raw user ids, plan ids, project names, subdomains, tenant admin emails, or passwords when the handler can derive them server-side. Project detail is the place for destructive lifecycle actions. Disable uses `PATCH /cloud_projects/:id` with body `{ status: "disabled" }` and `confirm_tenant_id`/`confirm_hash` in query params. Enable uses `PATCH /cloud_projects/:id` with body `{ status: "running" }`. Renew uses the canonical renew flow/query contract and writes `cloud_project_renewals`; do not add expiry columns to `cloud_projects`. Delete uses `DELETE /cloud_projects/:id` with typed `confirm_tenant_id`, returned `requiredConfirmHash`, and the matching hash before triggering `cloud-delete-project`. Do not create separate one-off `/cloud/admin/projects/*` action routes for create/disable/enable/delete when the canonical table route can own the workflow.',
318
- '- **Cloud admin terminology:** in Cloud admin UI, call physical tenant workloads "projects" everywhere. Do not label creation or details as "instance" unless the user explicitly asks for that word.',
319
- '- **Cloud project create UI:** manual Cloud project creation should use `CommonDrawer`, not a wide modal. Let the operator search/select `user_definition` by email and select a plan with cards; do not expose duplicate free-text `user id` or `plan id` inputs when selectors exist. Prefer sending only the selected owner id and plan id in the schema-safe body; pass expiry as query `expired_at=YYYY-MM-DD` when the canonical handler requires it and let the handler derive customer email, project name, subdomain, and password. Expiry selection should use quick presets plus a manual calendar (`UCalendar` when available, loaded through `install_package`/`getPackages` if an app package is needed).',
320
- '- **Cloud host settings and creation UI:** host settings store only provider selection codes Enfyra controls, currently location and server type. Do not expose or save provider-derived RAM, disk, vCPU, or cost values by hand. Query the provider catalog route, show real package/location cards, support load-more/search when the list is long, and snapshot provider facts onto `cloud_servers` only during host creation.',
321
316
  '- **Flow schedule UI:** schedule trigger editors must keep the server contract as `triggerConfig.cron` and `triggerConfig.timezone`, but the UI should not be a bare cron field plus giant timezone dropdown. Provide common cadence presets, readable current-schedule summary, searchable access to all IANA timezones, suggested timezone shortcuts, and a custom cron escape hatch so operators can configure recurring checks without remembering cron syntax.',
322
- '- **Admin operation UI:** use eApp `CommonModal` for compact create, disable, delete, and multi-field confirmation workflows. Use `CommonDrawer` for longer setup workflows such as Cloud host settings, host creation, project creation, and provider/package selection. Open the modal/drawer immediately on click, then render loading/error/content inside it; do not wait for async fetches to finish before showing the shell.',
323
- '- **FormEditor is preferred for table-record forms:** when an extension creates or edits a concrete table record such as `cloud_host_settings`, `cloud_servers`, or `cloud_projects`, use `FormEditor`/`FormEditorLazy` inside the modal/page when the form is a direct table edit. Customize layout with `sections`, `includes`, and `field-map`; reserve custom inputs only for workflow-specific fields that are not table columns.',
317
+ '- **Admin operation UI:** use eApp `CommonModal` for compact create, disable, delete, and multi-field confirmation workflows. Use `CommonDrawer` for longer setup workflows such as multi-step create/edit forms and provider/package selection. Open the modal/drawer immediately on click, then render loading/error/content inside it; do not wait for async fetches to finish before showing the shell.',
318
+ '- **FormEditor is preferred for table-record forms:** when an extension creates or edits a concrete table record such as `report_definition`, `order_definition`, or another table-backed entity, use `FormEditor`/`FormEditorLazy` inside the modal/page when the form is a direct table edit. Customize layout with `sections`, `includes`, and `field-map`; reserve custom inputs only for workflow-specific fields that are not table columns.',
324
319
  '- **Modal form layout:** inside `CommonModal`, stack each form control vertically with label text above a full-width input/control. Use a small grid/space stack such as `grid gap-4`, `p.text-sm.font-medium`, then `UInput class="mt-2 w-full"`. Do not place modal labels and inputs side by side unless the user explicitly asks for a dense horizontal form.',
325
- '- **Confirmation modal flow:** destructive/admin confirmation modals must read top-to-bottom as the operator workflow. For server-hash confirmations, render: tenant/id input first, then a full-width `Request hash` button, then a disabled hash input that is auto-filled by the server response, then the final destructive action in the footer. The final action stays disabled until the typed id matches and the server hash has been requested. Do not ask operators to manually type or edit the hash.',
326
- '- **Cloud host deletion:** do not delete `cloud_servers` directly from an extension or custom route. Root admin host deletion must be detail-only from `/cloud/hosts/:id`, call `POST /cloud/admin/hosts/delete`, require typed `confirm_host_id`, block when any project is running or attached, return a server-generated `requiredConfirmHash` for an empty host, and require the hash back via `confirmHash` query before triggering `cloud-delete-host`.',
327
- '- **Cloud host reconciliation is host-rooted:** use `cloud_reconciliation_reports` as the persisted audit source for host/control-plane/runtime mismatch checks, and use `cloud_servers.projects` as the canonical inverse list of physical projects attached to a host. Do not model reconciliation from the project side or make projects manage themselves; the host owns capacity and runtime scan responsibility. `cloud-reconcile-hosts` is a scheduled flow using `triggerConfig.cron`/`timezone`; `POST /cloud/admin/hosts/reconcile` should only trigger that flow for a host and return job metadata. Host list/detail pages should consume `/cloud/admin/hosts`, which includes the latest `reconciliation_report`, instead of querying raw report rows separately.',
320
+ '- **Confirmation modal flow:** destructive/admin confirmation modals must read top-to-bottom as the operator workflow. For server-hash confirmations, render: id input first, then a full-width `Request hash` button, then a disabled hash input that is auto-filled by the server response, then the final destructive action in the footer. The final action stays disabled until the typed id matches and the server hash has been requested. Do not ask operators to manually type or edit the hash.',
328
321
  '- **Do not downgrade extension code to ES5 to appease tooling.** eApp extension runtime should support normal browser/runtime APIs such as `Array.includes`, `Set`, `Promise.all`, `String.replace`, `Intl.DateTimeFormat`, and `Intl.NumberFormat`. If diagnostics reject these, fix eApp extension checker/runtime contract instead of rewriting generated extension code around the limitation. Vue extension diagnostics should only TypeScript-check `<script setup lang="ts">`; plain `<script setup>` extension code is JavaScript and must not emit TypeScript diagnostics into the form.',
329
322
  '',
330
323
  '#### Injected Vue API functions:',
@@ -378,7 +371,7 @@ export function buildMcpServerInstructions(apiBaseUrl) {
378
371
  '',
379
372
  '#### Extension types:',
380
373
  '- **FormEditor field-map:** Customize fields via `:field-map`. Options: `label`, `description`, `hideLabel`, `hideDescription`, `component`, `componentProps`, `type`, `disabled`, `placeholder`, `permission`, `excludedOptions`/`includedOptions`, `fieldProps` (e.g. grid `class: \'md:col-span-2\'` when `layout=\'grid\'`), `booleanWrapperClass`, `fieldWrapperClass`. Optional `:sections` — array of `{ id, title?, hideHeading?, headingClass?, class?, rootClass?, fields: string[] }`; field order follows `fields`; unlisted columns render after. Custom input component: `modelValue` / `update:modelValue`.',
381
- '- **type "page":** Full-page extension. Requires `menu: { id }` — create menu first (`create_menu` or `create_record` on `menu_definition`), find by path/label, then create extension with `menu: { id: menuId }`. `menu_definition` uses **label** not name — filter by `label` or `path`.',
374
+ '- **type "page":** Full-page extension. Requires `menu: { id }` — create menu first (`create_menu` or `create_record` on `menu_definition`), find by path/label, then create extension with `menu: { id: menuId }`. `menu_definition` uses **label** not name — filter by `label` or `path`. Sensitive/admin menus should pass a `permission` JSON object string to `create_menu` so visibility is permission-gated from creation.',
382
375
  '- **type "widget":** Widget extension. No menu required. Embed via `<Widget :id="extensionId" />` in other extensions or pages.',
383
376
  '- **Existing pages:** if a menu already has a page extension, update that `extension_definition` record instead of creating a duplicate menu/extension. For example `/dashboard` is menu-driven and may already have an extension attached.',
384
377
  '',
@@ -377,7 +377,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
377
377
 
378
378
  const columnCreateSchema = {
379
379
  tableId: z.string().describe('Table definition ID (from get_all_tables or create_table).'),
380
- name: z.string().describe('Column name (e.g., "title", "payment_confirm_secret_encrypted"). Lowercase with underscores.'),
380
+ name: z.string().describe('Column name (e.g., "title", "webhook_secret"). Lowercase with underscores.'),
381
381
  type: z.string().describe('Column type: varchar, int, text, boolean, datetime, json, decimal, timestamp, uuid, bigint, float, longtext, richtext, simple-json, code, enum, array-select, date.'),
382
382
  isNullable: z.boolean().optional().default(true).describe('Set to false if column cannot be null.'),
383
383
  isUnique: z.boolean().optional().default(false).describe('Set to true for unique constraint.'),
@@ -444,7 +444,7 @@ export function registerTableTools(server, ENFYRA_API_URL) {
444
444
  'Schema operations (create/update/delete table, add column) must run one at a time — migration locks DB; parallel calls will fail.',
445
445
  'Enfyra auto-creates a default REST route at path `/<table_name>` (same segment as `name`, not alias).',
446
446
  'REST surface for that route (matches server route engine): 4 HTTP operations — GET `/<table>` (list/filter), POST `/<table>` (create), PATCH `/<table>/:id` (update), DELETE `/<table>/:id` (delete).',
447
- 'There is NO `GET /<table>/:id`. To fetch one row by id, use GET `/<table>?filter={"id":{"_eq":"<id>"}}&limit=1` or tool query_table / find_one_record.',
447
+ 'There is NO `GET /<table>/:id`. To fetch one row by id, use find_one_record or inspect metadata first and call GET `/<table>?filter={"<primaryKeyFromMetadata>":{"_eq":"<id>"}}&limit=1`.',
448
448
  'Set `isSingleRecord: true` directly in create_table for settings/config tables that should keep only one record.',
449
449
  `Full URLs: ${apiBase}/<table_name> (example table post: ${apiBase}/post).`,
450
450
  'GraphQL is enabled separately per table through `gql_definition` or `update_table` with `graphqlEnabled`; it is not controlled by route availableMethods.',
@@ -705,14 +705,15 @@ server.tool(
705
705
  },
706
706
  throws: '@THROW400 through @THROW503 and @THROW map to $ctx.$throw helpers.',
707
707
  helpers: {
708
- crypto: '$ctx.$helpers.$crypto exposes bounded runtime crypto helpers: randomUUID(), randomBytes(size, encoding), sha256(value, encoding), hmacSha256(value, secret, encoding), and generateSshKeyPair(comment). Use generateSshKeyPair for Cloud host SSH keys. Do not use legacy $ctx.$helpers.$ssh.',
708
+ crypto: '$ctx.$helpers.$crypto exposes bounded runtime crypto helpers: randomUUID(), randomBytes(size, encoding), sha256(value, encoding), hmacSha256(value, secret, encoding), and generateSshKeyPair(comment). Use generateSshKeyPair for SSH key material. Do not use legacy $ctx.$helpers.$ssh.',
709
709
  },
710
+ env: '$ctx.$env exposes a sanitized process env snapshot with exact sensitive keys removed: DB_URI, DB_REPLICA_URIS, REDIS_URI, SECRET_KEY, and ADMIN_PASSWORD. Store app secrets in unpublished isEncrypted fields instead of reading them from $env.',
710
711
  },
711
712
  contexts: {
712
713
  preHook: {
713
714
  runs: 'Before handler.',
714
715
  data: ['@BODY', '@QUERY', '@PARAMS', '@USER', '@REPOS', '@CACHE', '@HELPERS', '@THROW*', '@SOCKET emit helpers'],
715
- queryContract: '@QUERY.filter is initialized as an object. When adding RLS or tenant filters in pre-hooks, merge directly with _and; do not add defensive type checks around @QUERY.filter.',
716
+ queryContract: '@QUERY.filter is initialized as an object. When adding RLS/scope filters in pre-hooks, merge directly with _and; do not add defensive type checks around @QUERY.filter.',
716
717
  rlsPattern: 'For relation-scoped reads, mutate @QUERY.filter instead of returning data. Example: const incomingFilter = @QUERY.filter; const scope = { memberships: { member: { id: { _eq: @USER.id } } } }; @QUERY.filter = Object.keys(incomingFilter).length ? { _and: [incomingFilter, scope] } : scope;',
717
718
  returnBehavior: 'Returning a non-undefined value skips handler and becomes response data.',
718
719
  },
@@ -799,7 +800,7 @@ server.tool(
799
800
  examples: {
800
801
  listOrCreate: `${base}/<table_name>`,
801
802
  updateOrDelete: `${base}/<table_name>/<id>`,
802
- oneRowById: `${base}/<table_name>?filter={"id":{"_eq":"<id>"}}&limit=1`,
803
+ oneRowById: `${base}/<table_name>?filter={"<primaryKeyFromMetadata>":{"_eq":"<id>"}}&limit=1`,
803
804
  },
804
805
  auth: {
805
806
  publishedMethods: 'If the HTTP method is published for that route, no Bearer required; else Bearer JWT and routePermissions apply.',
@@ -1458,7 +1459,7 @@ server.tool(
1458
1459
  'create_route',
1459
1460
  [
1460
1461
  '**Use this when the user wants a new REST API route or path** — not `create_table`. Custom routes must omit `mainTableId`.',
1461
- '`mainTableId` is only a marker for canonical table routes such as `/orders`; do not set it for `/orders/stats`, `/cloud/admin/hosts`, `/auth/login`, or any custom path.',
1462
+ '`mainTableId` is only a marker for canonical table routes such as `/orders`; do not set it for `/orders/stats`, `/reports/summary`, `/auth/login`, or any custom path.',
1462
1463
  'Do NOT create a new table_definition only to expose an endpoint; create a route without `mainTableId`, then have the handler/hook query explicit repos such as `$ctx.$repos.orders`.',
1463
1464
  'availableMethods = which REST verbs the route responds to. publishedMethods = which REST verbs are public (no auth). GraphQL is enabled separately through gql_definition/update_table graphqlEnabled.',
1464
1465
  'After creation the tool auto-reloads routes. Then create handlers for specific methods via create_handler on this route id.',
@@ -2094,17 +2095,24 @@ server.tool(
2094
2095
  // MENU & EXTENSION TOOLS
2095
2096
  // ============================================================================
2096
2097
 
2097
- server.tool('create_menu', 'Create a menu item in the navigation', {
2098
+ server.tool('create_menu', 'Create a menu item in the navigation. Use permission JSON for sensitive menu visibility; successful writes should trigger the app menu reload contract.', {
2098
2099
  label: z.string().describe('Menu label'),
2099
2100
  type: z.enum(['Menu', 'Dropdown Menu']).default('Menu').describe('Menu type: "Menu" for leaf items, "Dropdown Menu" for items with children'),
2100
2101
  icon: z.string().optional().describe('Lucide icon name'),
2101
- path: z.string().optional().describe('Route path for type=route'),
2102
- externalUrl: z.string().optional().describe('External URL for type=link'),
2102
+ path: z.string().optional().describe('App route path for a clickable menu item, e.g. "/reports".'),
2103
+ externalUrl: z.string().optional().describe('External URL for a menu item when the backend supports external links.'),
2103
2104
  order: z.number().optional().default(0).describe('Display order'),
2104
2105
  isEnabled: z.boolean().optional().default(true).describe('Enable menu'),
2105
2106
  description: z.string().optional().describe('Menu description'),
2107
+ permission: z.string().optional().describe('Optional menu visibility permission JSON object string, e.g. {"or":[{"route":"/reports","methods":["GET"]}]}'),
2106
2108
  }, async (data) => {
2107
2109
  const body = { ...data };
2110
+ if (body.permission !== undefined) {
2111
+ body.permission = parseJsonArg(body.permission);
2112
+ if (!body.permission || typeof body.permission !== 'object' || Array.isArray(body.permission)) {
2113
+ throw new Error('permission must be a JSON object string.');
2114
+ }
2115
+ }
2108
2116
  if (body.path && !body.path.startsWith('/')) {
2109
2117
  body.path = '/' + body.path;
2110
2118
  }