@thotischner/observability-mcp 1.8.1 → 3.0.1

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 (204) hide show
  1. package/dist/analysis/history.d.ts +70 -0
  2. package/dist/analysis/history.js +170 -0
  3. package/dist/analysis/history.test.d.ts +1 -0
  4. package/dist/analysis/history.test.js +141 -0
  5. package/dist/audit/log.d.ts +9 -0
  6. package/dist/audit/log.js +20 -0
  7. package/dist/audit/redaction-bypass.d.ts +67 -0
  8. package/dist/audit/redaction-bypass.js +64 -0
  9. package/dist/audit/redaction-bypass.test.d.ts +1 -0
  10. package/dist/audit/redaction-bypass.test.js +72 -0
  11. package/dist/audit/sinks/s3.d.ts +61 -0
  12. package/dist/audit/sinks/s3.js +179 -0
  13. package/dist/audit/sinks/s3.test.d.ts +1 -0
  14. package/dist/audit/sinks/s3.test.js +175 -0
  15. package/dist/audit/sinks/types.d.ts +18 -0
  16. package/dist/audit/sinks/types.js +1 -0
  17. package/dist/audit/sinks/webhook.d.ts +45 -0
  18. package/dist/audit/sinks/webhook.js +111 -0
  19. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  20. package/dist/audit/sinks/webhook.test.js +162 -0
  21. package/dist/auth/credentials.d.ts +11 -0
  22. package/dist/auth/credentials.js +27 -0
  23. package/dist/auth/credentials.test.js +21 -1
  24. package/dist/auth/csrf.d.ts +26 -0
  25. package/dist/auth/csrf.js +128 -0
  26. package/dist/auth/csrf.test.d.ts +1 -0
  27. package/dist/auth/csrf.test.js +143 -0
  28. package/dist/auth/local-users.d.ts +6 -0
  29. package/dist/auth/local-users.js +11 -0
  30. package/dist/auth/local-users.test.js +41 -0
  31. package/dist/auth/middleware.d.ts +7 -6
  32. package/dist/auth/oidc/dcr.d.ts +70 -0
  33. package/dist/auth/oidc/dcr.js +160 -0
  34. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  35. package/dist/auth/oidc/dcr.test.js +109 -0
  36. package/dist/auth/oidc/endpoints.js +44 -0
  37. package/dist/auth/oidc/profiles.d.ts +22 -0
  38. package/dist/auth/oidc/profiles.js +95 -0
  39. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  40. package/dist/auth/oidc/profiles.test.js +51 -0
  41. package/dist/auth/oidc/runtime.d.ts +3 -0
  42. package/dist/auth/oidc/runtime.js +16 -3
  43. package/dist/auth/oidc/runtime.test.js +1 -0
  44. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  45. package/dist/auth/policy/batch-dry-run.js +144 -0
  46. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  47. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  48. package/dist/auth/policy/engine.d.ts +20 -4
  49. package/dist/auth/policy/engine.js +16 -2
  50. package/dist/auth/policy/loader.d.ts +11 -1
  51. package/dist/auth/policy/loader.js +37 -0
  52. package/dist/auth/policy/loader.test.d.ts +1 -0
  53. package/dist/auth/policy/loader.test.js +86 -0
  54. package/dist/auth/policy/opa.d.ts +5 -5
  55. package/dist/auth/policy/opa.js +25 -14
  56. package/dist/auth/policy/opa.test.js +48 -0
  57. package/dist/auth/rbac.d.ts +23 -1
  58. package/dist/auth/rbac.js +43 -1
  59. package/dist/auth/rbac.test.js +62 -0
  60. package/dist/cli/index.js +3 -0
  61. package/dist/cli/inspector-config.d.ts +9 -0
  62. package/dist/cli/inspector-config.js +28 -0
  63. package/dist/cli/inspector-config.test.d.ts +1 -0
  64. package/dist/cli/inspector-config.test.js +33 -0
  65. package/dist/cli/lib.d.ts +1 -1
  66. package/dist/cli/lib.js +1 -0
  67. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  68. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  69. package/dist/connectors/interface.d.ts +5 -1
  70. package/dist/connectors/loader.d.ts +8 -0
  71. package/dist/connectors/loader.js +55 -4
  72. package/dist/connectors/loader.test.d.ts +1 -0
  73. package/dist/connectors/loader.test.js +78 -0
  74. package/dist/connectors/manifest-hooks.test.d.ts +1 -0
  75. package/dist/connectors/manifest-hooks.test.js +206 -0
  76. package/dist/connectors/prometheus.test.js +31 -13
  77. package/dist/connectors/registry.d.ts +13 -0
  78. package/dist/connectors/registry.js +30 -0
  79. package/dist/connectors/registry.test.js +56 -2
  80. package/dist/context.d.ts +32 -0
  81. package/dist/context.js +35 -0
  82. package/dist/context.test.d.ts +1 -0
  83. package/dist/context.test.js +58 -0
  84. package/dist/federation/registry.d.ts +54 -0
  85. package/dist/federation/registry.js +122 -0
  86. package/dist/federation/registry.test.d.ts +1 -0
  87. package/dist/federation/registry.test.js +206 -0
  88. package/dist/federation/upstream.d.ts +86 -0
  89. package/dist/federation/upstream.js +162 -0
  90. package/dist/federation/upstream.test.d.ts +1 -0
  91. package/dist/federation/upstream.test.js +118 -0
  92. package/dist/index.js +1435 -126
  93. package/dist/metrics/self.d.ts +1 -0
  94. package/dist/metrics/self.js +8 -0
  95. package/dist/middleware/ssrfGuard.d.ts +15 -0
  96. package/dist/middleware/ssrfGuard.js +103 -0
  97. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  98. package/dist/middleware/ssrfGuard.test.js +81 -0
  99. package/dist/observability/otel.d.ts +20 -0
  100. package/dist/observability/otel.js +118 -0
  101. package/dist/observability/otel.test.d.ts +1 -0
  102. package/dist/observability/otel.test.js +56 -0
  103. package/dist/openapi.js +215 -7
  104. package/dist/openapi.test.js +34 -0
  105. package/dist/policy/redact.js +1 -1
  106. package/dist/postmortem/store.d.ts +34 -0
  107. package/dist/postmortem/store.js +113 -0
  108. package/dist/postmortem/store.test.d.ts +1 -0
  109. package/dist/postmortem/store.test.js +118 -0
  110. package/dist/postmortem/synthesizer.d.ts +83 -0
  111. package/dist/postmortem/synthesizer.js +205 -0
  112. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  113. package/dist/postmortem/synthesizer.test.js +141 -0
  114. package/dist/products/loader.d.ts +31 -3
  115. package/dist/products/loader.js +77 -4
  116. package/dist/products/loader.test.js +90 -1
  117. package/dist/quota/charge.d.ts +28 -0
  118. package/dist/quota/charge.js +30 -0
  119. package/dist/quota/charge.test.d.ts +1 -0
  120. package/dist/quota/charge.test.js +83 -0
  121. package/dist/quota/limiter.d.ts +29 -4
  122. package/dist/quota/limiter.js +64 -8
  123. package/dist/quota/limiter.test.js +86 -0
  124. package/dist/scim/compliance.test.d.ts +1 -0
  125. package/dist/scim/compliance.test.js +169 -0
  126. package/dist/scim/factory.test.d.ts +1 -0
  127. package/dist/scim/factory.test.js +54 -0
  128. package/dist/scim/group-role-map.d.ts +4 -0
  129. package/dist/scim/group-role-map.js +33 -0
  130. package/dist/scim/group-role-map.test.d.ts +1 -0
  131. package/dist/scim/group-role-map.test.js +33 -0
  132. package/dist/scim/patch-ops.test.d.ts +1 -0
  133. package/dist/scim/patch-ops.test.js +100 -0
  134. package/dist/scim/redis-store.d.ts +38 -0
  135. package/dist/scim/redis-store.js +178 -0
  136. package/dist/scim/redis-store.test.d.ts +1 -0
  137. package/dist/scim/redis-store.test.js +138 -0
  138. package/dist/scim/routes.d.ts +40 -0
  139. package/dist/scim/routes.js +395 -0
  140. package/dist/scim/store.d.ts +76 -0
  141. package/dist/scim/store.js +196 -0
  142. package/dist/scim/store.test.d.ts +1 -0
  143. package/dist/scim/store.test.js +121 -0
  144. package/dist/scim/types.d.ts +73 -0
  145. package/dist/scim/types.js +29 -0
  146. package/dist/sdk/hook-wrappers.d.ts +39 -0
  147. package/dist/sdk/hook-wrappers.js +113 -0
  148. package/dist/sdk/hook-wrappers.test.d.ts +1 -0
  149. package/dist/sdk/hook-wrappers.test.js +204 -0
  150. package/dist/sdk/hooks.d.ts +77 -0
  151. package/dist/sdk/hooks.js +72 -0
  152. package/dist/sdk/hooks.test.d.ts +1 -0
  153. package/dist/sdk/hooks.test.js +159 -0
  154. package/dist/sdk/index.d.ts +15 -0
  155. package/dist/sdk/index.js +1 -0
  156. package/dist/sdk/manifest-schema.d.ts +17 -0
  157. package/dist/sdk/manifest-schema.js +21 -0
  158. package/dist/tools/context-seam.test.js +6 -1
  159. package/dist/tools/detect-anomalies.d.ts +12 -1
  160. package/dist/tools/detect-anomalies.js +26 -5
  161. package/dist/tools/generate-postmortem.d.ts +35 -0
  162. package/dist/tools/generate-postmortem.js +191 -0
  163. package/dist/tools/get-anomaly-history.d.ts +35 -0
  164. package/dist/tools/get-anomaly-history.js +126 -0
  165. package/dist/tools/get-service-health.d.ts +1 -1
  166. package/dist/tools/get-service-health.js +4 -3
  167. package/dist/tools/list-services.d.ts +1 -1
  168. package/dist/tools/list-services.js +3 -2
  169. package/dist/tools/list-sources.d.ts +1 -1
  170. package/dist/tools/list-sources.js +6 -2
  171. package/dist/tools/query-logs.d.ts +1 -1
  172. package/dist/tools/query-logs.js +2 -2
  173. package/dist/tools/query-metrics.d.ts +1 -1
  174. package/dist/tools/query-metrics.js +19 -6
  175. package/dist/tools/query-traces.d.ts +47 -0
  176. package/dist/tools/query-traces.js +145 -0
  177. package/dist/tools/query-traces.test.d.ts +1 -0
  178. package/dist/tools/query-traces.test.js +110 -0
  179. package/dist/tools/registry-names.d.ts +35 -0
  180. package/dist/tools/registry-names.js +54 -0
  181. package/dist/tools/registry-names.test.d.ts +1 -0
  182. package/dist/tools/registry-names.test.js +61 -0
  183. package/dist/tools/topology.d.ts +3 -3
  184. package/dist/tools/topology.js +33 -11
  185. package/dist/tools/topology.test.js +45 -0
  186. package/dist/topology/merge.d.ts +22 -0
  187. package/dist/topology/merge.js +178 -0
  188. package/dist/topology/merge.test.d.ts +1 -0
  189. package/dist/topology/merge.test.js +110 -0
  190. package/dist/transport/sessionStore.d.ts +66 -0
  191. package/dist/transport/sessionStore.js +138 -0
  192. package/dist/transport/sessionStore.test.d.ts +1 -0
  193. package/dist/transport/sessionStore.test.js +118 -0
  194. package/dist/transport/transportSessionMap.d.ts +70 -0
  195. package/dist/transport/transportSessionMap.js +128 -0
  196. package/dist/transport/transportSessionMap.test.d.ts +1 -0
  197. package/dist/transport/transportSessionMap.test.js +111 -0
  198. package/dist/transport/websocket.d.ts +35 -0
  199. package/dist/transport/websocket.js +133 -0
  200. package/dist/transport/websocket.test.d.ts +1 -0
  201. package/dist/transport/websocket.test.js +124 -0
  202. package/dist/types.d.ts +51 -0
  203. package/dist/ui/index.html +2529 -145
  204. package/package.json +13 -3
@@ -936,6 +936,206 @@
936
936
  .view-toggle button + button { border-left: 1px solid var(--border-strong); }
937
937
  .view-toggle button.active { background: var(--accent-soft); color: var(--accent); }
938
938
  .view-toggle button:hover { color: var(--text); }
939
+
940
+ /* One helper line under a page H1. */
941
+ .ph-sub { margin: 4px 0 0; font-size: var(--fs-sm); color: var(--text-2, var(--text)); }
942
+
943
+ /* Disclosure (native <details>) — used for the in-card "bind a
944
+ credential" note and the demoted legacy catalog section. */
945
+ .pg-disclosure > summary { cursor: pointer; list-style: none; user-select: none; font-size: 13px; color: var(--text-2, var(--text)); padding: 8px 0; display: flex; align-items: center; gap: 8px; }
946
+ .pg-disclosure > summary::-webkit-details-marker { display: none; }
947
+ .pg-disclosure > summary::before { content: "▸"; font-size: 11px; color: var(--text-3, #8a93a5); transition: transform .12s ease; }
948
+ .pg-disclosure[open] > summary::before { transform: rotate(90deg); }
949
+ .pg-disclosure-body { padding: 4px 0 8px; }
950
+ /* The "bind a credential" note sits inside the primary card. */
951
+ .pg-bind { border-top: 1px solid var(--border); margin-top: 8px; }
952
+ .pg-bind pre { background: var(--surface-2); padding: 8px 10px; border-radius: 4px; font-size: 11px; overflow-x: auto; margin: 6px 0; }
953
+ /* The legacy catalog: a recessed, muted block, demoted by depth. */
954
+ .pg-legacy { border: 1px solid var(--border); border-radius: 6px; margin-top: 18px; padding: 0 14px; background: var(--surface-2); }
955
+ .pg-legacy > summary { color: var(--text-3, #8a93a5); }
956
+ .pg-legacy-note { color: var(--text-3, #8a93a5); margin: 0 0 12px; }
957
+
958
+ /* Products — card grid */
959
+ .pcard-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: var(--sp-3); padding: var(--sp-3); }
960
+ .pcard { position: relative; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); padding: var(--sp-4); padding-left: calc(var(--sp-4) + 6px); display: flex; flex-direction: column; gap: var(--sp-2); transition: border-color .15s ease, box-shadow .15s ease; }
961
+ .pcard:hover { border-color: var(--border-strong); box-shadow: 0 1px 3px rgba(0,0,0,.06); }
962
+ .pcard-rail { position: absolute; left: 0; top: var(--sp-3); bottom: var(--sp-3); width: 4px; border-radius: 0 2px 2px 0; background: var(--accent); }
963
+ .pcard-hd { display: flex; align-items: flex-start; gap: var(--sp-2); }
964
+ .pcard-icon { width: 32px; height: 32px; flex: 0 0 32px; border-radius: var(--radius-sm); background: var(--surface-2); display: flex; align-items: center; justify-content: center; font-size: 18px; overflow: hidden; }
965
+ .pcard-icon img { width: 100%; height: 100%; object-fit: cover; }
966
+ .pcard-title { flex: 1; min-width: 0; }
967
+ .pcard-title h3 { font-size: 14px; font-weight: 600; margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
968
+ .pcard-meta { font-size: 11px; opacity: .7; margin-top: 2px; font-family: 'JetBrains Mono', ui-monospace, monospace; }
969
+ .pcard-status { flex: 0 0 auto; }
970
+ .pcard-desc { font-size: 12px; line-height: 1.45; opacity: .85; min-height: 2.9em; }
971
+ .pcard-tools-label { font-size: 11px; font-weight: 600; opacity: .65; text-transform: uppercase; letter-spacing: .04em; margin-bottom: var(--sp-1); }
972
+ .pcard-tools { display: flex; flex-wrap: wrap; gap: 4px; min-height: 22px; }
973
+ .pcard-tool { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 10.5px; padding: 2px 6px; background: var(--surface-2); border-radius: var(--radius-sm); border: 1px solid var(--border); }
974
+ .pcard-tool-all { font-size: 11px; opacity: .55; font-style: italic; }
975
+ .pcard-footer { display: flex; align-items: center; gap: var(--sp-2); margin-top: var(--sp-2); padding-top: var(--sp-2); border-top: 1px solid var(--border); }
976
+ .pcard-footer .pcard-spacer { flex: 1; }
977
+
978
+ /* Policies — engine banner + sticky dry-run */
979
+ .pol-engine-banner { display: flex; align-items: flex-start; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); border-radius: var(--radius); border: 1px solid var(--border); margin-bottom: var(--sp-3); }
980
+ .pol-engine-banner .pol-eb-dot { flex: 0 0 8px; width: 8px; height: 8px; border-radius: 50%; margin-top: 6px; background: var(--text-2); }
981
+ .pol-engine-banner .pol-eb-body { flex: 1; min-width: 0; }
982
+ .pol-engine-banner .pol-eb-title { font-weight: 600; font-size: 13px; display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: center; }
983
+ .pol-engine-banner .pol-eb-meta { font-size: 11px; opacity: .7; margin-top: 2px; font-family: 'JetBrains Mono', ui-monospace, monospace; }
984
+ .pol-engine-banner .pol-eb-body p { margin: var(--sp-2) 0 0; font-size: 12px; line-height: 1.5; opacity: .9; max-width: 720px; }
985
+ .pol-engine-banner.eng-builtin { background: var(--surface-2); border-color: var(--border); }
986
+ .pol-engine-banner.eng-builtin .pol-eb-dot { background: var(--text-2); }
987
+ .pol-engine-banner.eng-file { background: var(--success-soft); border-color: var(--success); }
988
+ .pol-engine-banner.eng-file .pol-eb-dot { background: var(--success); }
989
+ .pol-engine-banner.eng-opa { background: var(--warning-soft); border-color: var(--warning); }
990
+ .pol-engine-banner.eng-opa .pol-eb-dot { background: var(--warning); }
991
+ .pol-engine-banner .pol-eb-pill { font-size: 11px; padding: 2px 8px; border-radius: 999px; background: var(--surface); border: 1px solid var(--border); font-weight: 500; }
992
+
993
+ /* Policies sub-tabs (Roles / Bindings / Subjects) */
994
+ .pol-subtabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: var(--sp-3); }
995
+ .pol-subtab { background: none; border: 0; padding: var(--sp-3) var(--sp-4); color: var(--text-2); cursor: pointer; font-size: 13px; position: relative; border-bottom: 2px solid transparent; margin-bottom: -1px; }
996
+ .pol-subtab:hover { color: var(--text); }
997
+ .pol-subtab[data-active="true"] { color: var(--text); border-bottom-color: var(--accent); font-weight: 500; }
998
+ .pol-pane[hidden] { display: none; }
999
+
1000
+ /* Roles master/detail */
1001
+ .pol-roles-layout { display: grid; grid-template-columns: 260px 1fr; min-height: 320px; }
1002
+ .pol-role-list { border-right: 1px solid var(--border); max-height: 60vh; overflow-y: auto; }
1003
+ .pol-role-row { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); cursor: pointer; border-bottom: 1px solid var(--border); }
1004
+ .pol-role-row:hover { background: var(--surface-2); }
1005
+ .pol-role-row[data-active="true"] { background: var(--accent-soft); }
1006
+ .pol-role-row .pol-role-name { font-weight: 500; font-size: 13px; }
1007
+ .pol-role-row .pol-role-count { font-size: 11px; opacity: .65; }
1008
+ .pol-role-detail { padding: var(--sp-4); overflow-x: auto; }
1009
+ .pol-role-detail h3 { margin: 0 0 var(--sp-3); font-size: 14px; font-weight: 600; }
1010
+ .pol-matrix { border-collapse: collapse; width: 100%; font-size: 12px; }
1011
+ .pol-matrix th, .pol-matrix td { padding: var(--sp-2); border: 1px solid var(--border); text-align: left; }
1012
+ .pol-matrix thead th { background: var(--surface-2); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; font-weight: 600; opacity: .8; }
1013
+ .pol-matrix tbody th { font-weight: 500; background: var(--surface-2); font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 11.5px; }
1014
+ .pol-matrix td { text-align: center; }
1015
+ .pol-matrix .cell-grant { color: var(--success); font-weight: 600; }
1016
+ .pol-matrix .cell-empty { opacity: .25; }
1017
+ /* Effective-permissions overlay cells — drop the bold/success
1018
+ colour so "via <role>" reads as informational, not as a fresh
1019
+ grant; "denied" cells stay dimmed via cell-empty. */
1020
+ .pol-matrix td[data-effective="allowed"] { color: var(--text-1); font-weight: 400; }
1021
+ .pol-matrix td[data-effective="allowed"][data-via-selected="true"] { color: var(--success); font-weight: 500; }
1022
+ .pol-matrix td[data-effective="denied"] { color: var(--text-3); opacity: .55; font-style: italic; }
1023
+ @media (max-width: 900px) {
1024
+ .pol-roles-layout { grid-template-columns: 1fr; }
1025
+ .pol-role-list { border-right: 0; border-bottom: 1px solid var(--border); max-height: none; }
1026
+ }
1027
+
1028
+ /* Subjects sub-tab — three stacked sections */
1029
+ .pol-subjects-section { padding: var(--sp-3) var(--sp-4); }
1030
+ .pol-subjects-section + .pol-subjects-section { border-top: 1px solid var(--border); }
1031
+ .pol-subjects-section h3 { margin: 0 0 var(--sp-2); font-size: 13px; font-weight: 600; display: flex; align-items: baseline; gap: var(--sp-2); }
1032
+ .pol-subjects-section h3 .pol-subjects-count { font-size: 11px; opacity: .65; font-weight: 400; }
1033
+ .pol-subjects-section h3 .pol-subjects-source { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 10.5px; opacity: .55; margin-left: auto; }
1034
+ .pol-subjects-empty { font-size: 12px; opacity: .65; padding: var(--sp-2) 0; }
1035
+
1036
+ /* Sticky dry-run bar — pinned at the top of the policies page */
1037
+ .pol-probe-bar { position: sticky; top: 0; z-index: 5; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--sp-3); margin-bottom: var(--sp-3); box-shadow: 0 1px 2px rgba(0,0,0,.04); }
1038
+ .pol-probe-grid { display: grid; grid-template-columns: repeat(5, 1fr) auto; gap: var(--sp-3); align-items: end; }
1039
+ .pol-probe-grid .form-group { margin: 0; }
1040
+ .pol-probe-result { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) 0 0; }
1041
+ .pol-probe-result .pol-pv { font-weight: 600; padding: 2px 10px; border-radius: 4px; font-size: 12px; letter-spacing: .04em; text-transform: uppercase; }
1042
+ .pol-probe-result .pol-pv.allow { background: var(--success-soft); color: var(--success); }
1043
+ .pol-probe-result .pol-pv.deny { background: var(--danger-soft); color: var(--danger); }
1044
+ .pol-probe-result code { font-size: 11px; opacity: .8; }
1045
+ @media (max-width: 1000px) {
1046
+ .pol-probe-grid { grid-template-columns: 1fr 1fr; }
1047
+ }
1048
+
1049
+ /* Batch evaluate heat-map (P4) */
1050
+ .pol-batch { display: grid; grid-template-columns: 360px 1fr; gap: var(--sp-4); align-items: start; }
1051
+ .pol-batch .form-row { display: flex; flex-direction: column; gap: var(--sp-1); margin-bottom: var(--sp-3); }
1052
+ .pol-batch textarea { font-family: var(--mono); font-size: 12px; padding: var(--sp-2); border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text); resize: vertical; }
1053
+ .pol-batch select[multiple] { font-family: var(--mono); font-size: 12px; padding: var(--sp-1); border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text); }
1054
+ .pol-batch-result { min-height: 200px; }
1055
+ .pol-heat { border-collapse: collapse; font-size: 11px; }
1056
+ .pol-heat th, .pol-heat td { border: 1px solid var(--border); padding: 4px 8px; text-align: center; vertical-align: middle; }
1057
+ .pol-heat thead th { background: var(--surface-2); position: sticky; top: 0; }
1058
+ .pol-heat .row-head { background: var(--surface-2); font-weight: 600; text-align: left; padding-right: var(--sp-3); white-space: nowrap; }
1059
+ .pol-heat .resource-head { background: var(--surface-2); font-size: 10px; opacity: .8; font-weight: normal; }
1060
+ .pol-heat .cell-allow { background: var(--success-soft); color: var(--success); font-weight: 600; cursor: help; }
1061
+ .pol-heat .cell-deny { background: var(--danger-soft); color: var(--danger); font-weight: 600; cursor: help; }
1062
+ .pol-heat .cell-na { background: transparent; color: var(--text-3); }
1063
+ .pol-batch-dropped { margin-top: var(--sp-2); font-size: 11px; color: var(--warn); }
1064
+ @media (max-width: 1200px) {
1065
+ .pol-batch { grid-template-columns: 1fr; }
1066
+ }
1067
+
1068
+ /* Author-controls are hidden when the active engine is read-only.
1069
+ Anything with data-engine-required="file" needs the file engine. */
1070
+ body[data-policy-engine="opa"] [data-engine-required="file"],
1071
+ body[data-policy-engine="builtin"] [data-engine-required="file"] {
1072
+ opacity: .35; pointer-events: none;
1073
+ }
1074
+
1075
+ /* Products wizard — multi-step modal */
1076
+ .mcp-product-wizard { width: min(720px, 92vw); max-height: 86vh; display: flex; flex-direction: column; }
1077
+ .mcp-product-wizard .modal-body { flex: 1; overflow-y: auto; }
1078
+ .mcp-product-wizard .modal-footer { display: flex; align-items: center; gap: var(--sp-2); }
1079
+ .wiz-stepper { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border); background: var(--surface-2); }
1080
+ .wiz-step-btn { display: flex; align-items: center; gap: var(--sp-2); background: none; border: 0; cursor: pointer; color: var(--text-2); padding: 4px 8px; border-radius: var(--radius-sm); font-size: 12px; }
1081
+ .wiz-step-btn:hover { color: var(--text); background: var(--surface); }
1082
+ .wiz-step-btn .wiz-step-num { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 50%; background: var(--surface); border: 1px solid var(--border); font-size: 11px; font-weight: 600; }
1083
+ .wiz-step-btn[data-active="true"] { color: var(--text); }
1084
+ .wiz-step-btn[data-active="true"] .wiz-step-num { background: var(--accent); border-color: var(--accent); color: #fff; }
1085
+ .wiz-step-btn[data-done="true"] .wiz-step-num { background: var(--success); border-color: var(--success); color: #fff; }
1086
+ .wiz-step-btn[data-done="true"] .wiz-step-num::after { content: "✓"; font-size: 10px; }
1087
+ .wiz-step-btn[data-done="true"] .wiz-step-num > * { display: none; }
1088
+ .wiz-step-line { flex: 1; height: 1px; background: var(--border); min-width: 12px; }
1089
+ .wiz-step-help { padding: 0 0 var(--sp-3); opacity: .85; line-height: 1.5; }
1090
+ .wiz-pane[hidden] { display: none; }
1091
+
1092
+ /* Review pane */
1093
+ .wiz-review-grid { display: grid; grid-template-columns: max-content 1fr; gap: var(--sp-2) var(--sp-3); padding: var(--sp-2) var(--sp-3); background: var(--surface-2); border-radius: var(--radius-sm); border: 1px solid var(--border); }
1094
+ .wiz-review-grid dt { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; font-weight: 600; opacity: .65; padding-top: 2px; }
1095
+ .wiz-review-grid dd { margin: 0; font-size: 13px; word-break: break-word; }
1096
+ .wiz-review-grid dd code { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 12px; }
1097
+ .wiz-review-grid .wiz-review-empty { opacity: .5; font-style: italic; }
1098
+ .wiz-review-grid .wiz-review-swatch { display: inline-block; width: 14px; height: 14px; border-radius: 3px; vertical-align: middle; margin-right: 6px; border: 1px solid var(--border); }
1099
+ .wiz-review-tools { display: flex; flex-wrap: wrap; gap: 4px; }
1100
+ .wiz-review-tools .pcard-tool { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 10.5px; padding: 2px 6px; background: var(--surface); border-radius: var(--radius-sm); border: 1px solid var(--border); }
1101
+
1102
+ /* Wizard Review — agent preview panel */
1103
+ .wiz-agent-preview-h { margin: var(--sp-4) 0 var(--sp-2); font-size: 13px; font-weight: 600; }
1104
+ .wiz-agent-preview { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: var(--sp-3); background: var(--surface-2); }
1105
+ .wiz-agent-banner { padding: var(--sp-2) var(--sp-3); margin-bottom: var(--sp-3); border-radius: var(--radius-sm); background: var(--warning-soft); color: var(--warning); font-size: 12px; }
1106
+ .wiz-agent-group { padding: var(--sp-1) 0; }
1107
+ .wiz-agent-group + .wiz-agent-group { border-top: 1px dashed var(--border); padding-top: var(--sp-2); margin-top: var(--sp-2); }
1108
+ .wiz-agent-tool { padding: 4px 0; }
1109
+ .wiz-agent-tool-name { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 12px; font-weight: 500; }
1110
+ .wiz-agent-tool-summary { font-size: 11px; opacity: .75; line-height: 1.4; margin-top: 1px; }
1111
+ .mcp-agent-preview { width: min(640px, 92vw); }
1112
+ .mcp-agent-preview .modal-header { padding-left: var(--sp-3); }
1113
+
1114
+ /* Products modal — tools picker (multi-select grouped by category) */
1115
+ .tools-picker { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: var(--sp-2); background: var(--surface-2); max-height: 280px; overflow-y: auto; }
1116
+ .tools-picker .tp-group { padding: var(--sp-1) 0; }
1117
+ .tools-picker .tp-group + .tp-group { margin-top: var(--sp-2); border-top: 1px dashed var(--border); padding-top: var(--sp-2); }
1118
+ .tools-picker .tp-cat { font-size: 11px; font-weight: 600; opacity: .65; text-transform: uppercase; letter-spacing: .04em; margin-bottom: var(--sp-1); padding: 0 var(--sp-1); }
1119
+ .tools-picker .tp-item { display: flex; align-items: flex-start; gap: var(--sp-2); padding: var(--sp-1) var(--sp-2); border-radius: var(--radius-sm); cursor: pointer; }
1120
+ .tools-picker .tp-item:hover { background: var(--surface); }
1121
+ .tools-picker .tp-item input { margin-top: 3px; flex: 0 0 auto; }
1122
+ .tools-picker .tp-item .tp-text { flex: 1; min-width: 0; }
1123
+ .tools-picker .tp-item .tp-name { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 12px; font-weight: 500; }
1124
+ .tools-picker .tp-item .tp-summary { font-size: 11px; opacity: .7; line-height: 1.4; margin-top: 1px; }
1125
+ .tools-picker .tp-actions { display: flex; gap: var(--sp-2); margin-bottom: var(--sp-2); }
1126
+ .tools-picker .tp-actions button { font-size: 11px; padding: 2px 8px; }
1127
+ .tools-picker .tools-picker-hint { padding: var(--sp-2); opacity: .7; }
1128
+
1129
+ /* Products — empty state with templates */
1130
+ .pempty { padding: var(--sp-5); text-align: center; }
1131
+ .pempty h3 { margin: 0 0 var(--sp-2); font-size: 16px; }
1132
+ .pempty p { margin: 0 auto var(--sp-4); max-width: 540px; line-height: 1.5; opacity: .8; }
1133
+ .pempty-templates { display: flex; gap: var(--sp-2); justify-content: center; flex-wrap: wrap; }
1134
+ .pempty-tpl { padding: var(--sp-3) var(--sp-4); border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); cursor: pointer; min-width: 140px; text-align: left; transition: border-color .15s ease, background .15s ease; }
1135
+ .pempty-tpl:hover { border-color: var(--accent); background: var(--accent-soft); }
1136
+ .pempty-tpl-title { font-weight: 600; font-size: 13px; margin-bottom: 2px; }
1137
+ .pempty-tpl-desc { font-size: 11px; opacity: .7; }
1138
+
939
1139
  /* Form-first editor (Form / JSON / YAML — OpenShift-style) */
940
1140
  .ed-bar { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-3); flex-wrap: wrap; }
941
1141
  .ed-token { flex: 1; min-width: 200px; }
@@ -1191,6 +1391,48 @@
1191
1391
  stroke: var(--accent); stroke-width: 3;
1192
1392
  filter: drop-shadow(0 0 4px var(--accent-soft));
1193
1393
  }
1394
+
1395
+ /* ===== Playground (Q13) — restrained, monochrome ===== */
1396
+ .pg-seg { display:inline-flex; gap:0; border:1px solid var(--border); border-radius:6px; overflow:hidden; }
1397
+ .pg-seg button { border:none; background:transparent; color:var(--text-2,var(--text)); font:inherit; font-size:12px; padding:3px 11px; cursor:pointer; }
1398
+ .pg-seg button + button { border-left:1px solid var(--border); }
1399
+ .pg-seg button.active { background:var(--surface-3); color:var(--text); font-weight:600; }
1400
+ /* JSON: two tones only — keys at full strength, scalars muted. */
1401
+ .pg-json { white-space:pre-wrap; font-family:var(--mono); font-size:12px; background:var(--bg); padding:12px; border:1px solid var(--border); border-radius:4px; max-height:540px; overflow:auto; margin:0; color:var(--text-3,#8a93a5); }
1402
+ .pg-json .k { color:var(--text); }
1403
+ .pg-json .s { color:var(--text-2,var(--text)); }
1404
+ .pg-json .n, .pg-json .b { color:var(--text-2,var(--text)); }
1405
+ .pg-json .z { color:var(--text-3,#8a93a5); }
1406
+ .pg-tbl-wrap { max-height:540px; overflow:auto; border:1px solid var(--border); border-radius:4px; }
1407
+ table.pg-tbl { border-collapse:collapse; width:100%; font-size:12px; }
1408
+ table.pg-tbl th, table.pg-tbl td { text-align:left; padding:6px 10px; border-bottom:1px solid var(--border); white-space:nowrap; }
1409
+ table.pg-tbl th { position:sticky; top:0; background:var(--surface-2); color:var(--text-2,var(--text)); font-weight:600; font-size:11px; text-transform:uppercase; letter-spacing:.03em; }
1410
+ table.pg-tbl tr:last-child td { border-bottom:none; }
1411
+ table.pg-tbl tr:hover td { background:var(--surface-2); }
1412
+ /* Status: plain text by default; only failure states get a tint. */
1413
+ table.pg-tbl td .pill { font-weight:600; }
1414
+ table.pg-tbl td .pill.down, table.pg-tbl td .pill.error, table.pg-tbl td .pill.crit { color:var(--danger); }
1415
+ table.pg-tbl td .pill.warn, table.pg-tbl td .pill.degraded { color:var(--warning); }
1416
+ .pg-err-banner { border:1px solid var(--danger); color:var(--danger); border-radius:4px; padding:8px 12px; margin-bottom:10px; font-size:13px; }
1417
+
1418
+ /* Tool picker — type-ahead combobox, closed at rest (Carbon-style). */
1419
+ .pg-combo { position:relative; max-width:560px; }
1420
+ .pg-combo input { width:100%; padding-right:30px; }
1421
+ .pg-combo input:focus { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-soft); outline:none; }
1422
+ .pg-combo-chev { position:absolute; right:6px; top:50%; transform:translateY(-50%); background:none; border:none; color:var(--text-3,#8a93a5); cursor:pointer; font-size:11px; padding:4px; line-height:1; }
1423
+ .pg-combo-menu { position:absolute; z-index:30; left:0; right:0; top:calc(100% + 4px); background:var(--surface); border:1px solid var(--border); border-radius:4px; box-shadow:0 6px 18px rgba(0,0,0,0.28); max-height:340px; overflow:auto; }
1424
+ .pg-grp-hdr { font-size:11px; text-transform:uppercase; letter-spacing:0.06em; color:var(--text-3,#8a93a5); padding:8px 14px 4px; pointer-events:none; }
1425
+ .pg-grp-hdr + .pg-grp-hdr { display:none; } /* no empty headers */
1426
+ .pg-opt { padding:7px 14px; cursor:pointer; border-left:2px solid transparent; }
1427
+ .pg-opt:hover, .pg-opt.hl { background:var(--surface-2); }
1428
+ .pg-opt.sel { background:var(--surface-3); border-left-color:var(--accent); }
1429
+ .pg-opt .nm { font-family:var(--mono); font-size:13px; color:var(--text); }
1430
+ .pg-opt .sm { font-size:12px; color:var(--text-3,#8a93a5); margin-top:1px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
1431
+ .pg-opt .src { font-size:10px; text-transform:uppercase; letter-spacing:.04em; color:var(--text-3,#8a93a5); }
1432
+ .pg-sel-sum { font-size:12px; color:var(--text-3,#8a93a5); margin-top:6px; min-height:1em; max-width:560px; }
1433
+ .pg-args-tools { float:right; display:inline-flex; gap:4px; }
1434
+ .pg-args-box { font-family:var(--mono); font-size:12px; line-height:1.5; }
1435
+ .pg-args-err { color:var(--danger); font-size:12px; margin-top:6px; }
1194
1436
  </style>
1195
1437
  </head>
1196
1438
  <body>
@@ -1209,6 +1451,8 @@
1209
1451
  <button class="nav-btn" data-page="services" title="Services" onclick="showPage('services')"><span class="nav-ico">⊞</span><span class="nav-label">Services</span></button>
1210
1452
  <button class="nav-btn" data-page="health" title="Health" onclick="showPage('health')"><span class="nav-ico">✚</span><span class="nav-label">Health</span></button>
1211
1453
  <button class="nav-btn" data-page="topology" title="Topology" onclick="showPage('topology')"><span class="nav-ico">◇</span><span class="nav-label">Topology</span></button>
1454
+ <button class="nav-btn" data-page="postmortems" title="Postmortems" onclick="showPage('postmortems')"><span class="nav-ico">◷</span><span class="nav-label">Postmortems</span></button>
1455
+ <button class="nav-btn" data-page="playground" title="Playground" onclick="showPage('playground')"><span class="nav-ico">⏵</span><span class="nav-label">Playground</span></button>
1212
1456
  </div>
1213
1457
  </div>
1214
1458
  <div class="rail-grp" data-grp="catalog">
@@ -1710,6 +1954,109 @@ curl -X PUT http://localhost:3000/api/enterprise/policy \
1710
1954
  </div>
1711
1955
  </div>
1712
1956
 
1957
+ <!-- ===== Observability: Postmortems (P6 — persisted reports) ===== -->
1958
+ <div class="page" id="page-postmortems">
1959
+ <div class="page-head">
1960
+ <div class="ph-left">
1961
+ <div class="breadcrumb">Console / Observability / <b>Postmortems</b></div>
1962
+ <h1>Postmortems</h1>
1963
+ </div>
1964
+ <div class="ph-actions">
1965
+ <button class="btn btn-primary btn-sm" onclick="pmOpenNew()">+ Generate</button>
1966
+ </div>
1967
+ </div>
1968
+
1969
+ <div class="card">
1970
+ <div class="card-header"><h2>Generated reports
1971
+ <button class="info" aria-label="About postmortems"
1972
+ data-title="Postmortems"
1973
+ data-info="Persisted output of the generate_postmortem MCP tool. Each entry stitches anomaly history + traces + blast-radius + log highlights into one markdown report for a service. RBAC: viewers list, operators regenerate, admins delete."
1974
+ onclick="infoPop(this)">?</button>
1975
+ </h2></div>
1976
+ <div class="content">
1977
+ <div id="pm-list-body"><div class="empty">Loading…</div></div>
1978
+ </div>
1979
+ </div>
1980
+
1981
+ <div class="card" id="pm-detail-card" hidden>
1982
+ <div class="card-header"><h2 id="pm-detail-title">Report</h2>
1983
+ <span style="flex:1"></span>
1984
+ <button class="btn btn-ghost btn-sm" onclick="pmCloseDetail()">Close</button>
1985
+ <button class="btn btn-sm" id="pm-detail-regen" onclick="pmRegenerate()" hidden>Regenerate</button>
1986
+ <button class="btn btn-sm btn-danger" id="pm-detail-delete" onclick="pmDelete()" hidden>Delete</button>
1987
+ </div>
1988
+ <div class="content">
1989
+ <div id="pm-detail-meta" class="muted" style="margin-bottom:8px;font-size:12px"></div>
1990
+ <pre id="pm-detail-md" style="white-space:pre-wrap;font-family:var(--mono);font-size:12px;background:var(--bg);padding:12px;border:1px solid var(--border);border-radius:4px;max-height:540px;overflow:auto"></pre>
1991
+ </div>
1992
+ </div>
1993
+ </div>
1994
+
1995
+ <!-- ===== Playground (Q13 / v3.1) ===== -->
1996
+ <div class="page" id="page-playground">
1997
+ <div class="page-head">
1998
+ <div class="ph-left">
1999
+ <div class="breadcrumb">Console / Observability / <b>Playground</b></div>
2000
+ <h1>Tool Playground</h1>
2001
+ </div>
2002
+ </div>
2003
+
2004
+ <div class="card">
2005
+ <div class="card-header"><h2>Invoke a tool
2006
+ <button class="info" aria-label="About playground"
2007
+ data-title="Playground"
2008
+ data-info="Run any registered MCP tool against the live gateway. Uses your current credential's RBAC + rate-limit + entitlement + audit — identical to the dispatch path an MCP client would hit. Result is the raw CallToolResult."
2009
+ onclick="infoPop(this)">?</button>
2010
+ </h2></div>
2011
+ <div class="content">
2012
+ <!-- hidden field carries the selected tool name -->
2013
+ <input type="hidden" id="pg-tool" value="">
2014
+ <div class="form-row">
2015
+ <label for="pg-combo-input">Tool</label>
2016
+ <div class="pg-combo" id="pg-combo">
2017
+ <input type="text" id="pg-combo-input" class="input" autocomplete="off" spellcheck="false"
2018
+ placeholder="Select or filter tools…" role="combobox" aria-expanded="false" aria-controls="pg-combo-menu"
2019
+ oninput="pgComboFilter(this.value)" onfocus="pgComboOpen()" onkeydown="pgComboKey(event)">
2020
+ <button type="button" class="pg-combo-chev" id="pg-combo-chev" tabindex="-1" aria-label="Toggle tool list" onclick="pgComboToggle()">▾</button>
2021
+ <div class="pg-combo-menu" id="pg-combo-menu" role="listbox" hidden></div>
2022
+ </div>
2023
+ <div id="pg-sel-sum" class="pg-sel-sum"></div>
2024
+ </div>
2025
+
2026
+ <div class="form-row" style="margin-top:20px">
2027
+ <label for="pg-args">Arguments <span class="muted" style="font-weight:normal">(JSON)</span>
2028
+ <span class="pg-args-tools">
2029
+ <button type="button" class="btn btn-ghost btn-sm" onclick="pgFormatArgs()">Format</button>
2030
+ <button type="button" class="btn btn-ghost btn-sm" onclick="document.getElementById('pg-args').value='{}'">Reset</button>
2031
+ </span>
2032
+ </label>
2033
+ <textarea id="pg-args" class="input pg-args-box" rows="8" spellcheck="false">{}</textarea>
2034
+ <div id="pg-args-err" class="pg-args-err" hidden></div>
2035
+ </div>
2036
+ <div class="row" style="gap:8px;margin-top:8px">
2037
+ <button class="btn btn-primary" onclick="pgRun()" id="pg-run-btn" disabled>Invoke</button>
2038
+ <button class="btn btn-ghost" onclick="pgClear()">Clear result</button>
2039
+ </div>
2040
+ </div>
2041
+ </div>
2042
+
2043
+ <div class="card" id="pg-result-card" hidden>
2044
+ <div class="card-header">
2045
+ <h2>Result <span id="pg-result-meta" class="muted" style="font-weight:normal;font-size:12px"></span></h2>
2046
+ <span style="flex:1"></span>
2047
+ <div class="pg-seg" id="pg-view-seg">
2048
+ <button data-view="pretty" class="active" onclick="pgSetView('pretty')">Pretty</button>
2049
+ <button data-view="table" onclick="pgSetView('table')">Table</button>
2050
+ <button data-view="raw" onclick="pgSetView('raw')">Raw</button>
2051
+ </div>
2052
+ <button class="btn btn-ghost btn-sm" style="margin-left:8px" onclick="pgCopy()">Copy</button>
2053
+ </div>
2054
+ <div class="content">
2055
+ <div id="pg-result-body"></div>
2056
+ </div>
2057
+ </div>
2058
+ </div>
2059
+
1713
2060
  <!-- ===== Catalog: Context Products ===== -->
1714
2061
  <div class="page" id="page-products">
1715
2062
  <div class="page-head">
@@ -1718,70 +2065,80 @@ curl -X PUT http://localhost:3000/api/enterprise/policy \
1718
2065
  <h1>Context Products
1719
2066
  <button class="info" aria-label="What is a context product"
1720
2067
  data-title="Context products"
1721
- data-info="A context product is a named, governed bundle of observability context an agent may consume a curated set of sources, services and tools. It is the unit you grant to a principal. The catalog is the collection of all products plus the grants that map principals to them; it composes with access control (a request must satisfy both RBAC and the catalog)."
2068
+ data-info="A context product is a named, governed bundle of MCP tools you expose to an agent or credential. The bundle's tools allow-list filters tools/list at the /mcp transport, so an agent bound to a product sees only that product's tools. Products compose with access control a request must satisfy both RBAC and the product binding."
1722
2069
  onclick="infoPop(this)">?</button>
1723
2070
  </h1>
2071
+ <p class="ph-sub">Curated, governed tool bundles you expose to an agent or credential.</p>
1724
2072
  </div>
1725
- <div class="ph-actions"><button class="btn btn-primary btn-sm" onclick="entEditNew('cat')">+ New product</button><button class="btn btn-sm" id="ent-cat-editbtn" onclick="entEditOpen('cat')">Edit catalog</button></div>
1726
2073
  </div>
1727
- <div class="card" style="background:var(--accent-soft);border-color:transparent">
1728
- <div style="font-size:var(--fs-sm);color:var(--text);line-height:1.6">
1729
- <b>The catalog</b> publishes <b>context products</b> — reusable bundles of sources, services and tools —
1730
- and the <b>grants</b> that decide which principals may consume each product. Browse below as cards or a
1731
- table; an admin can create or change products via the editor or the API example.
1732
- </div>
1733
- </div>
1734
- <!-- MCP Products — governed via /api/products (the new, RBAC-gated
1735
- surface from the MCP Products + RBAC phase). Replaces the
1736
- legacy enterprise-catalog block below for new deployments;
1737
- the legacy block stays so existing /api/enterprise/catalog
1738
- operators see their data until they migrate. -->
2074
+
2075
+ <!-- Primary surface: MCP Products via /api/products (RBAC-gated). -->
1739
2076
  <div class="card" id="mcp-products-card">
1740
2077
  <div class="card-header">
1741
2078
  <h2>MCP Products
1742
2079
  <button class="info" aria-label="About the MCP Products API"
1743
2080
  data-title="MCP Products"
1744
- data-info="Curated tool bundles loaded from OMCP_PRODUCTS_FILE. Each product names an id, display name, optional tool allowlist (filters tools/list at the /mcp transport in a future slice), branding metadata, and a status (published | staging). Non-admins see only their own tenant's published entries; admins see everything plus staging. Edits go through PUT /api/products/{id} with strict body validation."
2081
+ data-info="Curated tool bundles loaded from OMCP_PRODUCTS_FILE. Each product names an id, display name, optional tool allowlist (filters tools/list at the /mcp transport), branding metadata, and a status (published | staging). Non-admins see only their own tenant's published entries; admins see everything plus staging. Edits go through PUT /api/products/{id} with strict body validation."
1745
2082
  onclick="infoPop(this)">?</button>
1746
2083
  </h2>
1747
2084
  <div class="row-inline">
1748
2085
  <span id="mcp-products-scope" class="badge"></span>
2086
+ <span class="view-toggle" id="mcp-products-views">
2087
+ <button id="mcp-pv-cards" onclick="mcpProductsSetView('cards')">Cards</button>
2088
+ <button id="mcp-pv-table" onclick="mcpProductsSetView('table')">Table</button>
2089
+ </span>
1749
2090
  <button class="btn btn-primary btn-sm" data-rbac="products:write" onclick="mcpProductsNew()">+ New product</button>
1750
2091
  </div>
1751
2092
  </div>
1752
2093
  <div id="mcp-products-box"><div class="empty">Loading…</div></div>
2094
+ <details class="pg-disclosure pg-bind">
2095
+ <summary>Bind a credential to a product</summary>
2096
+ <div class="pg-disclosure-body">
2097
+ <p class="t-sm">Bind a credential to a product via <code>OMCP_KEY_PRODUCTS</code>; the agent's next <code>/mcp</code> session then sees only that product's tools:</p>
2098
+ <pre><code>OMCP_API_KEYS="agent:tok_ops,ci:tok_dev"
2099
+ OMCP_KEY_PRODUCTS="agent=ops-bundle;ci=dev-bundle"</code></pre>
2100
+ <p class="t-sm"><a href="https://github.com/ThoTischner/observability-mcp/blob/main/docs/products.md" target="_blank" rel="noreferrer">Full docs →</a></p>
2101
+ </div>
2102
+ </details>
1753
2103
  </div>
1754
2104
 
1755
- <!-- Legacy enterprise-products card (kept for operators that
1756
- still wire OMCP_ENTERPRISE_CATALOG_FILE) -->
1757
- <div class="card">
1758
- <div class="card-header"><h2>Products <span class="t-sm" style="opacity:.7">(legacy / enterprise catalog)</span></h2></div>
1759
- <div id="ent-catalog"><div class="empty">Loading…</div></div>
1760
- <div id="ent-cat-editor" class="hidden" style="margin-top:12px">
1761
- <div class="ed-bar">
1762
- <span class="view-toggle" id="cat-views">
1763
- <button data-v="form" class="active" onclick="catView('form')">Form</button>
1764
- <button data-v="json" onclick="catView('json')">JSON</button>
1765
- <button data-v="yaml" onclick="catView('yaml')">YAML</button>
1766
- </span>
1767
- <input type="password" id="ent-cat-token" class="ed-token" placeholder="Admin API key (Bearer)">
1768
- </div>
1769
- <div class="form-hint" style="margin-bottom:8px">Define products &amp; grants below — no JSON required. JSON / YAML are optional alternative views. Saving needs an admin API key.</div>
1770
- <div id="cat-form"></div>
1771
- <textarea id="ent-cat-json" class="ed-code hidden" rows="16" spellcheck="false"></textarea>
1772
- <div id="ent-cat-msg" style="margin:8px 0;font-size:12px"></div>
1773
- <div style="display:flex;gap:8px;justify-content:flex-end">
1774
- <button class="btn btn-ghost btn-sm" onclick="closeDrawer()">Cancel</button>
1775
- <button class="btn btn-primary btn-sm" onclick="entSaveCat()">Save catalog</button>
2105
+ <!-- Legacy enterprise catalog deprecated, demoted into a
2106
+ collapsed disclosure so it stays reachable for operators
2107
+ still wiring OMCP_ENTERPRISE_CATALOG_FILE without competing
2108
+ with the primary surface above. Open state persists. -->
2109
+ <details class="pg-disclosure pg-legacy" id="ent-legacy" ontoggle="pgLegacyPersist(this)">
2110
+ <summary>Legacy catalog (enterprise)</summary>
2111
+ <div class="pg-disclosure-body">
2112
+ <p class="t-sm pg-legacy-note">Deprecated. Backed by <code>OMCP_ENTERPRISE_CATALOG_FILE</code>. Use <b>MCP Products</b> above for new deployments.</p>
2113
+ <div class="row-inline" style="margin-bottom:12px">
2114
+ <button class="btn btn-sm" onclick="entEditNew('cat')">+ New product</button>
2115
+ <button class="btn btn-sm" id="ent-cat-editbtn" onclick="entEditOpen('cat')">Edit catalog</button>
1776
2116
  </div>
1777
- </div>
1778
- <div class="form-hint" style="margin-top:14px">Use <b>Edit catalog</b> above — no command line needed. The block below is the optional API equivalent for automation/CI.</div>
1779
- <div class="codeblock collapsed">
1780
- <div class="codeblock-hd" onclick="toggleCode(this)">
1781
- <span class="cb-chev">▾</span><span class="cb-title">API · create a context product via curl</span>
1782
- <button class="codeblock-cp" onclick="event.stopPropagation();copyCode(this)">Copy</button>
2117
+ <div id="ent-catalog"><div class="empty">Loading…</div></div>
2118
+ <div id="ent-cat-editor" class="hidden" style="margin-top:12px">
2119
+ <div class="ed-bar">
2120
+ <span class="view-toggle" id="cat-views">
2121
+ <button data-v="form" class="active" onclick="catView('form')">Form</button>
2122
+ <button data-v="json" onclick="catView('json')">JSON</button>
2123
+ <button data-v="yaml" onclick="catView('yaml')">YAML</button>
2124
+ </span>
2125
+ <input type="password" id="ent-cat-token" class="ed-token" placeholder="Admin API key (Bearer)">
2126
+ </div>
2127
+ <div class="form-hint" style="margin-bottom:8px">Define products &amp; grants below — no JSON required. JSON / YAML are optional alternative views. Saving needs an admin API key.</div>
2128
+ <div id="cat-form"></div>
2129
+ <textarea id="ent-cat-json" class="ed-code hidden" rows="16" spellcheck="false"></textarea>
2130
+ <div id="ent-cat-msg" style="margin:8px 0;font-size:12px"></div>
2131
+ <div style="display:flex;gap:8px;justify-content:flex-end">
2132
+ <button class="btn btn-ghost btn-sm" onclick="closeDrawer()">Cancel</button>
2133
+ <button class="btn btn-primary btn-sm" onclick="entSaveCat()">Save catalog</button>
2134
+ </div>
1783
2135
  </div>
1784
- <pre><span class="tok-cmt"># publish a "payments-eu" product and grant it to a principal.</span>
2136
+ <div class="codeblock collapsed" style="margin-top:14px">
2137
+ <div class="codeblock-hd" onclick="toggleCode(this)">
2138
+ <span class="cb-chev">▾</span><span class="cb-title">API · create a context product via curl</span>
2139
+ <button class="codeblock-cp" onclick="event.stopPropagation();copyCode(this)">Copy</button>
2140
+ </div>
2141
+ <pre><span class="tok-cmt"># publish a "payments-eu" product and grant it to a principal.</span>
1785
2142
  <span class="tok-cmt"># needs an admin API key (a principal the current policy grants admin).</span>
1786
2143
  curl -X PUT http://localhost:3000/api/enterprise/catalog \
1787
2144
  -H "Authorization: Bearer &lt;ADMIN_API_KEY&gt;" \
@@ -1796,8 +2153,9 @@ curl -X PUT http://localhost:3000/api/enterprise/catalog \
1796
2153
  },
1797
2154
  "grants": { "alice": ["payments-eu"] }
1798
2155
  }'</pre>
2156
+ </div>
1799
2157
  </div>
1800
- </div>
2158
+ </details>
1801
2159
  </div>
1802
2160
 
1803
2161
  <!-- ===== Governance: Policies (PolicyEngine snapshot + dry-run) ===== -->
@@ -1809,38 +2167,208 @@ curl -X PUT http://localhost:3000/api/enterprise/catalog \
1809
2167
  </div>
1810
2168
  <div class="ph-actions"><span id="pol-engine" class="badge"></span></div>
1811
2169
  </div>
1812
- <div class="card">
1813
- <div class="card-header"><h2>Active policy
1814
- <button class="info" aria-label="About the active policy"
1815
- data-title="Active policy"
1816
- data-info="Read-only view of the RBAC policy this build is running. When OMCP_RBAC_POLICY_FILE is set the file replaces the built-in defaults; the badge above identifies which is active. Use the dry-run panel below to probe specific role/resource/action combinations without writing a test."
1817
- onclick="infoPop(this)">?</button>
1818
- </h2></div>
1819
- <div id="pol-roles" class="content"><div class="empty">Loading…</div></div>
1820
- <div class="form-hint" style="padding: var(--sp-3) var(--sp-4)">
1821
- File source: read-only here. To change, edit <code>OMCP_RBAC_POLICY_FILE</code> on the server and restart.
2170
+
2171
+ <!-- Engine banner — colour + copy distinguishes editable file
2172
+ vs read-only builtin / opa modes at a glance. -->
2173
+ <div id="pol-engine-banner" class="pol-engine-banner eng-builtin" hidden>
2174
+ <div class="pol-eb-dot"></div>
2175
+ <div class="pol-eb-body">
2176
+ <div class="pol-eb-title">
2177
+ <span id="pol-eb-title-text">Engine</span>
2178
+ <span id="pol-eb-kind" class="pol-eb-pill"></span>
2179
+ <span id="pol-eb-tenant-aware" class="pol-eb-pill" hidden>tenant-aware</span>
2180
+ </div>
2181
+ <div class="pol-eb-meta" id="pol-eb-meta"></div>
2182
+ <p id="pol-eb-copy"></p>
1822
2183
  </div>
1823
2184
  </div>
1824
- <div class="card">
1825
- <div class="card-header"><h2>Dry-run probe</h2></div>
1826
- <div class="content" style="display:grid; gap: var(--sp-3); grid-template-columns: 1fr 1fr 1fr auto;">
1827
- <div class="form-group" style="margin:0">
2185
+
2186
+ <!-- Sticky dry-run probe bar — promoted to the top so it's the
2187
+ first affordance, not buried below the snapshot. Works
2188
+ across every engine including OPA (the only way to see what
2189
+ Rego actually grants without writing a unit test). -->
2190
+ <div class="pol-probe-bar">
2191
+ <h3 style="margin:0 0 var(--sp-2);display:flex;align-items:center;gap:var(--sp-2);font-size:13px">
2192
+ <span>Probe a permission</span>
2193
+ <button class="info" aria-label="About dry-run"
2194
+ data-title="Dry-run probe"
2195
+ data-info="Asks the active engine 'would these roles be allowed this resource:action under this tenant?' Works for every engine kind including OPA — the answer reflects the live Rego evaluation."
2196
+ onclick="infoPop(this)">?</button>
2197
+ </h3>
2198
+ <div class="pol-probe-grid">
2199
+ <div class="form-group">
1828
2200
  <label>Roles <span class="form-hint">comma-separated</span></label>
1829
2201
  <input id="pol-dry-roles" placeholder="admin, operator">
1830
2202
  </div>
1831
- <div class="form-group" style="margin:0">
2203
+ <div class="form-group">
1832
2204
  <label>Resource</label>
1833
2205
  <input id="pol-dry-resource" placeholder="sources">
1834
2206
  </div>
1835
- <div class="form-group" style="margin:0">
2207
+ <div class="form-group">
1836
2208
  <label>Action</label>
1837
- <input id="pol-dry-action" placeholder="write">
2209
+ <input id="pol-dry-action" placeholder="read">
2210
+ </div>
2211
+ <div class="form-group">
2212
+ <label>Tenant <span class="form-hint">optional</span></label>
2213
+ <input id="pol-dry-tenant" placeholder="default">
1838
2214
  </div>
1839
- <div class="form-group" style="margin:0; align-self:end">
2215
+ <div class="form-group"><label>&nbsp;</label>
1840
2216
  <button class="btn btn-primary" onclick="polDryRun()">Evaluate</button>
1841
2217
  </div>
1842
2218
  </div>
1843
- <div id="pol-dry-out" style="padding: 0 var(--sp-4) var(--sp-4)"></div>
2219
+ <div id="pol-dry-out"></div>
2220
+ </div>
2221
+
2222
+ <!-- Sub-tab nav — k8s-style separation of Roles (the WHAT) vs
2223
+ Bindings (the WHO) vs Subjects (the principals). Slice F
2224
+ ships Roles; G + H fill in Bindings + Subjects. -->
2225
+ <nav class="pol-subtabs" role="tablist" aria-label="Policies sections">
2226
+ <button class="pol-subtab" role="tab" aria-controls="pol-pane-roles" data-pol-tab="roles" onclick="polSetTab('roles')">Roles</button>
2227
+ <button class="pol-subtab" role="tab" aria-controls="pol-pane-bindings" data-pol-tab="bindings" onclick="polSetTab('bindings')">Bindings</button>
2228
+ <button class="pol-subtab" role="tab" aria-controls="pol-pane-subjects" data-pol-tab="subjects" onclick="polSetTab('subjects')">Subjects</button>
2229
+ <button class="pol-subtab" role="tab" aria-controls="pol-pane-batch" data-pol-tab="batch" onclick="polSetTab('batch')">Batch evaluate</button>
2230
+ </nav>
2231
+
2232
+ <!-- Roles sub-tab — master/detail: role list on the left, the
2233
+ selected role's permission matrix on the right. -->
2234
+ <div class="card pol-pane" id="pol-pane-roles" role="tabpanel">
2235
+ <div class="card-header"><h2>Roles
2236
+ <button class="info" aria-label="About roles"
2237
+ data-title="Roles"
2238
+ data-info="A role is the WHAT — a named bundle of resource:action grants. Bindings (next sub-tab) decide who carries the role. The matrix view shows the granted (✓) vs not-granted (—) cells for the selected role across every resource and action."
2239
+ onclick="infoPop(this)">?</button>
2240
+ <span style="flex:1"></span>
2241
+ <button class="btn btn-primary btn-sm" data-engine-required="file" data-rbac="users:delete" onclick="polRoleAuthorNew()">+ New role</button>
2242
+ </h2></div>
2243
+ <div class="pol-roles-layout">
2244
+ <div class="pol-role-list" id="pol-role-list" role="listbox" aria-label="Roles">
2245
+ <div class="empty">Loading…</div>
2246
+ </div>
2247
+ <div class="pol-role-detail" id="pol-role-detail">
2248
+ <div class="empty">Select a role on the left to see its permission matrix.</div>
2249
+ </div>
2250
+ </div>
2251
+ </div>
2252
+
2253
+ <!-- Bindings sub-tab — subject → roles table with inline edit. -->
2254
+ <div class="card pol-pane" id="pol-pane-bindings" role="tabpanel" hidden>
2255
+ <div class="card-header"><h2>Bindings
2256
+ <button class="info" aria-label="About bindings"
2257
+ data-title="Bindings"
2258
+ data-info="A binding maps a subject (user, API key, OIDC group) to one or more roles. Today only local-user role assignments can be edited at runtime (writes through OMCP_USERS_FILE). API-key roles aren't stored on the credential, and OIDC group → role mappings come from OMCP_OIDC_ROLE_MAP which is read once at boot — those rows show their env source instead of an edit button."
2259
+ onclick="infoPop(this)">?</button>
2260
+ </h2></div>
2261
+ <div id="pol-bindings-body" class="content"><div class="empty">Loading…</div></div>
2262
+ </div>
2263
+
2264
+ <!-- Role-author modal (file-engine only). Surfaces a name +
2265
+ permission-matrix checkbox grid; saves via
2266
+ PUT /api/policy/roles/:name. -->
2267
+ <div class="modal-overlay" id="pol-role-author-modal">
2268
+ <div class="modal" style="width:min(720px, 92vw)">
2269
+ <div class="modal-header">
2270
+ <h3>New role</h3>
2271
+ <button class="btn-icon" onclick="closeModal('pol-role-author-modal')">&times;</button>
2272
+ </div>
2273
+ <div class="modal-body">
2274
+ <div class="form-group">
2275
+ <label for="pol-role-author-name">Role name</label>
2276
+ <input type="text" id="pol-role-author-name" placeholder="e.g. on-call-sre" autocomplete="off">
2277
+ <div class="form-hint">Pattern: <code>[A-Za-z0-9][A-Za-z0-9._-]{0,63}</code>. New roles append to the policy file; existing names overwrite.</div>
2278
+ </div>
2279
+ <div class="form-group">
2280
+ <label>Permissions</label>
2281
+ <div id="pol-role-author-grid" class="content"></div>
2282
+ <div class="form-hint">Tick cells to grant. Empty rows mean the role has no access to that resource.</div>
2283
+ </div>
2284
+ <div id="pol-role-author-error" class="form-hint" role="alert" aria-live="polite" style="color: var(--danger); display: none;"></div>
2285
+ </div>
2286
+ <div class="modal-footer">
2287
+ <button class="btn btn-ghost" onclick="closeModal('pol-role-author-modal')">Cancel</button>
2288
+ <span style="flex:1"></span>
2289
+ <button class="btn btn-primary" onclick="polRoleAuthorSave()">Save role</button>
2290
+ </div>
2291
+ </div>
2292
+ </div>
2293
+
2294
+ <!-- Binding-edit modal — only used for the local-user row. -->
2295
+ <div class="modal-overlay" id="pol-binding-modal">
2296
+ <div class="modal" style="width:min(520px, 92vw)">
2297
+ <div class="modal-header">
2298
+ <h3>Edit binding · <span id="pol-binding-subject"></span></h3>
2299
+ <button class="btn-icon" onclick="closeModal('pol-binding-modal')">&times;</button>
2300
+ </div>
2301
+ <div class="modal-body">
2302
+ <p class="t-sm" style="opacity:.85;margin:0 0 var(--sp-3)">
2303
+ Select the roles to grant <strong id="pol-binding-subject-2"></strong>.
2304
+ Unknown roles are rejected — the catalogue comes from the active engine.
2305
+ </p>
2306
+ <div id="pol-binding-roles" class="content"><div class="empty">Loading…</div></div>
2307
+ <div id="pol-binding-error" class="form-hint" role="alert" aria-live="polite" style="color: var(--danger); display: none;"></div>
2308
+ </div>
2309
+ <div class="modal-footer">
2310
+ <button class="btn btn-ghost" onclick="closeModal('pol-binding-modal')">Cancel</button>
2311
+ <span style="flex:1"></span>
2312
+ <button class="btn btn-primary" onclick="polBindingSave()">Save binding</button>
2313
+ </div>
2314
+ </div>
2315
+ </div>
2316
+
2317
+ <!-- Subjects sub-tab — three sections (Users / API keys /
2318
+ OIDC groups). Read-only this slice. -->
2319
+ <div class="card pol-pane" id="pol-pane-subjects" role="tabpanel" hidden>
2320
+ <div class="card-header"><h2>Subjects
2321
+ <button class="info" aria-label="About subjects"
2322
+ data-title="Subjects"
2323
+ data-info="The principals an OMCP deployment recognises: local users (basic auth via OMCP_USERS_FILE), bearer-token names from OMCP_API_KEYS, and OIDC groups the operator mapped to roles via OMCP_OIDC_ROLE_MAP. Slice H lands binding edits; this view is read-only."
2324
+ onclick="infoPop(this)">?</button>
2325
+ </h2></div>
2326
+ <div id="pol-subjects-body" class="content"><div class="empty">Loading…</div></div>
2327
+ </div>
2328
+
2329
+ <!-- Batch evaluate sub-tab (P4) — wraps POST /api/policy/dry-run-batch
2330
+ into a UI: subjects × resources × actions multi-select,
2331
+ one click renders a green/red heat-map matrix the user
2332
+ can hover for the per-cell deny reason; "Export CSV"
2333
+ re-hits the same endpoint with Accept: text/csv. -->
2334
+ <div class="card pol-pane" id="pol-pane-batch" role="tabpanel" hidden>
2335
+ <div class="card-header"><h2>Batch evaluate
2336
+ <button class="info" aria-label="About batch evaluate"
2337
+ data-title="Batch evaluate"
2338
+ data-info="Probe the active policy engine for every (subject × resource × action) cell at once. Useful before changing a role to see who would lose/gain access. Capped at 100 × 100 × 10 cells."
2339
+ onclick="infoPop(this)">?</button>
2340
+ </h2></div>
2341
+ <div class="content pol-batch">
2342
+ <div class="pol-batch-form">
2343
+ <div class="form-row">
2344
+ <label for="pol-batch-subjects">Subjects (one role per line; format: <code>label=role1,role2</code>; e.g. <code>alice=viewer</code>):</label>
2345
+ <textarea id="pol-batch-subjects" rows="4" placeholder="alice=viewer&#10;bob=operator,viewer&#10;admin@prod=admin"></textarea>
2346
+ </div>
2347
+ <div class="form-row">
2348
+ <label for="pol-batch-resources">Resources (multi-select):</label>
2349
+ <select id="pol-batch-resources" multiple size="6"></select>
2350
+ </div>
2351
+ <div class="form-row">
2352
+ <label for="pol-batch-actions">Actions (multi-select):</label>
2353
+ <select id="pol-batch-actions" multiple size="4"></select>
2354
+ </div>
2355
+ <div class="form-row" style="display:flex;gap:8px;align-items:center">
2356
+ <button class="btn btn-primary" id="pol-batch-eval-btn" onclick="polBatchEvaluate()">Evaluate</button>
2357
+ <button class="btn btn-sm" id="pol-batch-csv-btn" onclick="polBatchExportCsv()" disabled>Export CSV</button>
2358
+ <span id="pol-batch-totals" class="muted" style="margin-left:auto"></span>
2359
+ </div>
2360
+ </div>
2361
+ <div id="pol-batch-result" class="pol-batch-result"><div class="muted">Pick subjects + resources + actions, then click <b>Evaluate</b>.</div></div>
2362
+ </div>
2363
+ </div>
2364
+
2365
+ <!-- Kept for back-compat — the legacy snapshot lives here but
2366
+ the new Roles sub-tab supersedes it. JS hides it once
2367
+ Roles renders successfully so we don't duplicate
2368
+ information. -->
2369
+ <div class="card" id="pol-legacy-snapshot" hidden>
2370
+ <div class="card-header"><h2>Active policy (legacy view)</h2></div>
2371
+ <div id="pol-roles" class="content"></div>
1844
2372
  </div>
1845
2373
  </div>
1846
2374
 
@@ -1938,6 +2466,11 @@ curl -s http://localhost:3000/api/enterprise/status</pre>
1938
2466
  <div class="form-group"><label>CA Certificate Path</label><input type="text" id="src-tls-ca" placeholder="/path/to/ca.pem"><div class="form-hint">Custom CA for self-signed certs (safer than skip verify)</div></div>
1939
2467
  <div class="form-group"><label>Client Certificate Path</label><input type="text" id="src-tls-cert" placeholder="/path/to/client.pem"><div class="form-hint">For mutual TLS (mTLS)</div></div>
1940
2468
  <div class="form-group"><label>Client Key Path</label><input type="text" id="src-tls-key" placeholder="/path/to/client-key.pem"></div>
2469
+ <div class="form-group">
2470
+ <label for="src-tenant">Tenant <span class="t-sm" style="opacity:.6">(optional, admin-only)</span></label>
2471
+ <input type="text" id="src-tenant" placeholder="(blank = global)" autocomplete="off">
2472
+ <div class="form-hint">Tagged source is visible only inside its tenant. Blank = global (every tenant). Non-admins can only see / create within their own tenant.</div>
2473
+ </div>
1941
2474
  <div class="form-group" style="display:flex;align-items:center;gap:10px;"><label style="margin:0">Enabled</label><label class="toggle"><input type="checkbox" id="src-enabled" checked><span class="slider"></span></label></div>
1942
2475
  <div class="test-result" id="test-result"></div>
1943
2476
  </div>
@@ -1978,6 +2511,143 @@ curl -s http://localhost:3000/api/enterprise/status</pre>
1978
2511
  </div>
1979
2512
  </div>
1980
2513
 
2514
+ <!-- MCP Product Modal (Create + Edit) — 4-step wizard.
2515
+ The form fields keep their stable ids so save() and the
2516
+ existing field-by-field tests stay byte-identical; only the
2517
+ structural grouping + stepper navigation are new. -->
2518
+ <div class="modal-overlay" id="mcp-product-modal">
2519
+ <div class="modal mcp-product-wizard">
2520
+ <div class="modal-header">
2521
+ <h3 id="mcp-product-modal-title">New Product</h3>
2522
+ <button class="btn-icon" onclick="closeModal('mcp-product-modal')">&times;</button>
2523
+ </div>
2524
+ <!-- Stepper rail — bullets are clickable so an operator can
2525
+ jump back to any step they've already filled in. -->
2526
+ <div class="wiz-stepper" id="mcp-wiz-stepper" role="tablist" aria-label="Product wizard steps">
2527
+ <button class="wiz-step-btn" data-step="1" role="tab" aria-controls="wiz-pane-1" onclick="mcpWizGoto(1)">
2528
+ <span class="wiz-step-num">1</span><span class="wiz-step-lbl">Identity</span>
2529
+ </button>
2530
+ <span class="wiz-step-line"></span>
2531
+ <button class="wiz-step-btn" data-step="2" role="tab" aria-controls="wiz-pane-2" onclick="mcpWizGoto(2)">
2532
+ <span class="wiz-step-num">2</span><span class="wiz-step-lbl">Tools</span>
2533
+ </button>
2534
+ <span class="wiz-step-line"></span>
2535
+ <button class="wiz-step-btn" data-step="3" role="tab" aria-controls="wiz-pane-3" onclick="mcpWizGoto(3)">
2536
+ <span class="wiz-step-num">3</span><span class="wiz-step-lbl">Scope &amp; branding</span>
2537
+ </button>
2538
+ <span class="wiz-step-line"></span>
2539
+ <button class="wiz-step-btn" data-step="4" role="tab" aria-controls="wiz-pane-4" onclick="mcpWizGoto(4)">
2540
+ <span class="wiz-step-num">4</span><span class="wiz-step-lbl">Review &amp; publish</span>
2541
+ </button>
2542
+ </div>
2543
+ <div class="modal-body">
2544
+ <input type="hidden" id="mcp-product-mode" value="new">
2545
+ <input type="hidden" id="mcp-product-original-id" value="">
2546
+
2547
+ <!-- Step 1: Identity -->
2548
+ <div class="wiz-pane" id="wiz-pane-1" data-step="1" role="tabpanel">
2549
+ <p class="wiz-step-help t-sm">Give the product a stable id (URL-safe, immutable once saved) and a display name agents will see in <code>tools/list</code>.</p>
2550
+ <div class="form-group">
2551
+ <label for="mcp-product-id">Id</label>
2552
+ <input type="text" id="mcp-product-id" placeholder="e.g. ops-bundle" autocomplete="off">
2553
+ <div class="form-hint">Pattern: <code>[A-Za-z0-9][A-Za-z0-9._-]{0,63}</code>. Immutable once saved.</div>
2554
+ </div>
2555
+ <div class="form-group">
2556
+ <label for="mcp-product-name">Display name</label>
2557
+ <input type="text" id="mcp-product-name" placeholder="e.g. Operations Bundle" autocomplete="off">
2558
+ </div>
2559
+ <div class="form-group">
2560
+ <label for="mcp-product-description">Description <span class="t-sm" style="opacity:.6">(optional)</span></label>
2561
+ <input type="text" id="mcp-product-description" placeholder="One-sentence summary" autocomplete="off">
2562
+ </div>
2563
+ </div>
2564
+
2565
+ <!-- Step 2: Tools -->
2566
+ <div class="wiz-pane" id="wiz-pane-2" data-step="2" role="tabpanel" hidden>
2567
+ <p class="wiz-step-help t-sm">Curate the tools an agent bound to this product can call. Leaving everything unchecked = no filter (every registered tool).</p>
2568
+ <div class="form-group">
2569
+ <div id="mcp-product-tools-picker" class="tools-picker">
2570
+ <div class="tools-picker-hint t-sm">Loading…</div>
2571
+ </div>
2572
+ <!-- Hidden textarea kept for back-compat — the picker syncs
2573
+ selections here, and save() still reads from it. -->
2574
+ <textarea id="mcp-product-tools" rows="2" hidden></textarea>
2575
+ </div>
2576
+ </div>
2577
+
2578
+ <!-- Step 3: Scope & branding -->
2579
+ <div class="wiz-pane" id="wiz-pane-3" data-step="3" role="tabpanel" hidden>
2580
+ <p class="wiz-step-help t-sm">Pick the lifecycle stage, the tenant scope, optional version, and the branding metadata that surfaces on the catalogue card.</p>
2581
+ <div class="form-row">
2582
+ <div class="form-group">
2583
+ <label for="mcp-product-status">Status</label>
2584
+ <select id="mcp-product-status">
2585
+ <option value="staging">staging (admin-only)</option>
2586
+ <option value="published">published (visible to agents)</option>
2587
+ </select>
2588
+ </div>
2589
+ <div class="form-group">
2590
+ <label for="mcp-product-tenant">Tenant <span class="t-sm" style="opacity:.6">(admin only)</span></label>
2591
+ <input type="text" id="mcp-product-tenant" placeholder="default" autocomplete="off">
2592
+ <div class="form-hint">Blank = same tenant as the calling user.</div>
2593
+ </div>
2594
+ </div>
2595
+ <div class="form-row">
2596
+ <div class="form-group">
2597
+ <label for="mcp-product-version">Version <span class="t-sm" style="opacity:.6">(optional)</span></label>
2598
+ <input type="text" id="mcp-product-version" placeholder="1.0.0" autocomplete="off">
2599
+ </div>
2600
+ <div class="form-group">
2601
+ <label for="mcp-product-color">Brand colour <span class="t-sm" style="opacity:.6">(optional)</span></label>
2602
+ <input type="text" id="mcp-product-color" placeholder="#3178c6" autocomplete="off">
2603
+ </div>
2604
+ </div>
2605
+ <div class="form-group">
2606
+ <label for="mcp-product-icon">Icon URL <span class="t-sm" style="opacity:.6">(optional)</span></label>
2607
+ <input type="text" id="mcp-product-icon" placeholder="https://example.com/icons/ops.svg" autocomplete="off">
2608
+ </div>
2609
+ </div>
2610
+
2611
+ <!-- Step 4: Review & publish -->
2612
+ <div class="wiz-pane" id="wiz-pane-4" data-step="4" role="tabpanel" hidden>
2613
+ <p class="wiz-step-help t-sm">Confirm everything below, then save. Staging products are admin-only; published products are visible to agents in the selected tenant.</p>
2614
+ <div id="mcp-wiz-review"></div>
2615
+ </div>
2616
+
2617
+ <div id="mcp-product-error" class="form-hint" role="alert" aria-live="polite" style="color: var(--danger); display: none;"></div>
2618
+ </div>
2619
+ <div class="modal-footer">
2620
+ <button class="btn btn-ghost" onclick="closeModal('mcp-product-modal')">Cancel</button>
2621
+ <span style="flex:1"></span>
2622
+ <button class="btn btn-ghost" id="mcp-wiz-back" onclick="mcpWizBack()" hidden>Back</button>
2623
+ <button class="btn btn-primary" id="mcp-wiz-next" onclick="mcpWizNext()">Next</button>
2624
+ <button class="btn btn-primary" id="mcp-wiz-save" onclick="mcpProductSave()" hidden>Save Product</button>
2625
+ </div>
2626
+ </div>
2627
+ </div>
2628
+
2629
+ <!-- Agent preview modal — invoked from per-card "Preview as agent"
2630
+ button. Same /api/products/:id/preview backend the wizard
2631
+ Review pane could call, but the wizard uses the live registry
2632
+ in-form because the draft hasn't been saved yet. -->
2633
+ <div class="modal-overlay" id="mcp-agent-preview-modal">
2634
+ <div class="modal mcp-agent-preview" style="--mcp-agent-accent: var(--accent)">
2635
+ <div class="modal-header" style="border-left: 4px solid var(--mcp-agent-accent)">
2636
+ <h3 id="mcp-agent-preview-title">Agent preview</h3>
2637
+ <button class="btn-icon" onclick="closeModal('mcp-agent-preview-modal')">&times;</button>
2638
+ </div>
2639
+ <div class="modal-body">
2640
+ <div id="mcp-agent-preview-meta" class="t-sm" style="opacity:.7;margin-bottom:var(--sp-3);font-family:'JetBrains Mono', ui-monospace, monospace;"></div>
2641
+ <p class="t-sm" style="opacity:.85">This is the <code>tools/list</code> set a credential bound to this product would receive on its <code>/mcp</code> session.</p>
2642
+ <div id="mcp-agent-preview-list" class="wiz-agent-preview"></div>
2643
+ </div>
2644
+ <div class="modal-footer">
2645
+ <span style="flex:1"></span>
2646
+ <button class="btn btn-primary" onclick="closeModal('mcp-agent-preview-modal')">Close</button>
2647
+ </div>
2648
+ </div>
2649
+ </div>
2650
+
1981
2651
  <div class="toast" id="toast-el"></div>
1982
2652
 
1983
2653
  <div class="drawer-ov" id="drawer-ov" onclick="closeDrawer()"></div>
@@ -2017,48 +2687,1182 @@ function showPage(name) {
2017
2687
  if(name==='access'||name==='products'||name==='audit'||name==='entitlement') loadEnterprise();
2018
2688
  if(name==='products') loadMcpProducts();
2019
2689
  if(name==='policies') loadPolicies();
2690
+ if(name==='postmortems') pmLoadList();
2691
+ if(name==='playground') pgInit();
2692
+ }
2693
+
2694
+ // --- Playground tab (Q13 / v3.1) ---------------------------------------
2695
+ // In-product tool invocation. Picks a tool from /api/tools/registry,
2696
+ // POSTs {tool, args} to /api/playground/invoke, renders the raw
2697
+ // CallToolResult. RBAC + entitlement + audit live on the server side —
2698
+ // the UI just shows whatever comes back.
2699
+
2700
+ let PG_TOOLS = [];
2701
+ let PG_FILTERED = []; // flat list of tool names currently shown in the menu (keyboard nav)
2702
+ let PG_HL = -1; // highlighted index into PG_FILTERED
2703
+ let PG_COMBO_WIRED = false;
2704
+ // Category display order + label. Unknown categories fall into "other"
2705
+ // so nothing silently vanishes.
2706
+ const PG_CAT_ORDER = ['discovery', 'query', 'diagnose', 'topology', 'federated', 'other'];
2707
+ const PG_CAT_LABEL = { discovery:'Discovery', query:'Query', diagnose:'Diagnose', topology:'Topology', federated:'Federated', other:'Other' };
2708
+
2709
+ async function pgInit() {
2710
+ if (PG_TOOLS.length) return; // already loaded
2711
+ try {
2712
+ const res = await fetch('/api/tools/registry');
2713
+ PG_TOOLS = (await res.json()).tools || [];
2714
+ } catch (e) {
2715
+ document.getElementById('pg-sel-sum').textContent = 'Failed to load tool catalogue: ' + (e && e.message ? e.message : String(e));
2716
+ }
2717
+ if (!PG_COMBO_WIRED) {
2718
+ // One document-level outside-click closer.
2719
+ document.addEventListener('click', (ev) => {
2720
+ const combo = document.getElementById('pg-combo');
2721
+ if (combo && !combo.contains(ev.target)) pgComboClose();
2722
+ });
2723
+ PG_COMBO_WIRED = true;
2724
+ }
2725
+ }
2726
+
2727
+ // A tool name with a dot is federated in from an upstream gateway —
2728
+ // the prefix is its source; route those into the "federated" group.
2729
+ function pgEnrich(t) {
2730
+ const dot = t.name.indexOf('.');
2731
+ return {
2732
+ name: t.name,
2733
+ summary: t.summary || '',
2734
+ category: dot > 0 ? 'federated' : (t.category || 'other'),
2735
+ source: dot > 0 ? t.name.slice(0, dot) : null,
2736
+ };
2737
+ }
2738
+
2739
+ function pgComboRender(q) {
2740
+ const menu = document.getElementById('pg-combo-menu');
2741
+ const sel = document.getElementById('pg-tool').value;
2742
+ const norm = (q || '').trim().toLowerCase();
2743
+ const showAll = !norm || norm === sel.toLowerCase();
2744
+ const match = (t) => showAll || t.name.toLowerCase().includes(norm) || t.summary.toLowerCase().includes(norm);
2745
+ const byCat = {};
2746
+ PG_TOOLS.map(pgEnrich).filter(match).forEach((t) => { (byCat[t.category] = byCat[t.category] || []).push(t); });
2747
+ const cats = PG_CAT_ORDER.filter((c) => byCat[c]).concat(Object.keys(byCat).filter((c) => !PG_CAT_ORDER.includes(c)));
2748
+ PG_FILTERED = [];
2749
+ let html = '';
2750
+ cats.forEach((cat) => {
2751
+ html += '<div class="pg-grp-hdr">' + escHtml(PG_CAT_LABEL[cat] || cat) + '</div>';
2752
+ byCat[cat].forEach((t) => {
2753
+ const idx = PG_FILTERED.length;
2754
+ PG_FILTERED.push(t.name);
2755
+ html += '<div class="pg-opt' + (t.name === sel ? ' sel' : '') + '" role="option" data-idx="' + idx + '" data-name="' + escHtml(t.name) + '" onclick="pgComboPick(this.dataset.name)">' +
2756
+ '<div class="nm">' + escHtml(t.name) + (t.source ? ' <span class="src">via ' + escHtml(t.source) + '</span>' : '') + '</div>' +
2757
+ '<div class="sm">' + escHtml(t.summary) + '</div>' +
2758
+ '</div>';
2759
+ });
2760
+ });
2761
+ if (!PG_FILTERED.length) html = '<div class="pg-grp-hdr">No matches</div>';
2762
+ menu.innerHTML = html;
2763
+ PG_HL = -1;
2764
+ }
2765
+
2766
+ function pgComboOpen() {
2767
+ const menu = document.getElementById('pg-combo-menu');
2768
+ pgComboRender(document.getElementById('pg-combo-input').value);
2769
+ menu.hidden = false;
2770
+ document.getElementById('pg-combo-input').setAttribute('aria-expanded', 'true');
2771
+ }
2772
+ function pgComboClose() {
2773
+ const menu = document.getElementById('pg-combo-menu');
2774
+ if (menu) menu.hidden = true;
2775
+ const inp = document.getElementById('pg-combo-input');
2776
+ if (inp) inp.setAttribute('aria-expanded', 'false');
2777
+ }
2778
+ function pgComboToggle() {
2779
+ const menu = document.getElementById('pg-combo-menu');
2780
+ if (menu.hidden) { document.getElementById('pg-combo-input').focus(); pgComboOpen(); }
2781
+ else pgComboClose();
2782
+ }
2783
+ function pgComboFilter(v) { pgComboRender(v); document.getElementById('pg-combo-menu').hidden = false; }
2784
+
2785
+ function pgComboPick(name) {
2786
+ document.getElementById('pg-tool').value = name;
2787
+ document.getElementById('pg-combo-input').value = name;
2788
+ const t = PG_TOOLS.find((x) => x.name === name);
2789
+ document.getElementById('pg-sel-sum').textContent = t ? t.summary : '';
2790
+ document.getElementById('pg-run-btn').disabled = false;
2791
+ pgComboClose();
2792
+ }
2793
+
2794
+ function pgComboKey(e) {
2795
+ const menu = document.getElementById('pg-combo-menu');
2796
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
2797
+ e.preventDefault();
2798
+ if (menu.hidden) { pgComboOpen(); return; }
2799
+ if (!PG_FILTERED.length) return;
2800
+ PG_HL = e.key === 'ArrowDown'
2801
+ ? (PG_HL + 1) % PG_FILTERED.length
2802
+ : (PG_HL - 1 + PG_FILTERED.length) % PG_FILTERED.length;
2803
+ const opts = menu.querySelectorAll('.pg-opt');
2804
+ opts.forEach((o) => o.classList.remove('hl'));
2805
+ const cur = menu.querySelector('.pg-opt[data-idx="' + PG_HL + '"]');
2806
+ if (cur) { cur.classList.add('hl'); cur.scrollIntoView({ block: 'nearest' }); }
2807
+ } else if (e.key === 'Enter') {
2808
+ if (!menu.hidden && PG_HL >= 0 && PG_FILTERED[PG_HL]) { e.preventDefault(); pgComboPick(PG_FILTERED[PG_HL]); }
2809
+ } else if (e.key === 'Escape') {
2810
+ pgComboClose();
2811
+ }
2812
+ }
2813
+
2814
+ function pgFormatArgs() {
2815
+ const el = document.getElementById('pg-args');
2816
+ const err = document.getElementById('pg-args-err');
2817
+ try {
2818
+ el.value = JSON.stringify(JSON.parse(el.value || '{}'), null, 2);
2819
+ err.hidden = true;
2820
+ } catch (e) {
2821
+ err.hidden = false;
2822
+ err.textContent = 'Invalid JSON: ' + (e && e.message ? e.message : e);
2823
+ }
2824
+ }
2825
+
2826
+ // Last invocation state — drives the view toggle without re-running.
2827
+ let PG_LAST = null; // { raw: <full {tool,result} JSON>, data: <unwrapped payload>, error: <string|null> }
2828
+ let PG_VIEW = 'pretty';
2829
+
2830
+ async function pgRun() {
2831
+ const sel = document.getElementById('pg-tool');
2832
+ const argsEl = document.getElementById('pg-args');
2833
+ const btn = document.getElementById('pg-run-btn');
2834
+ const card = document.getElementById('pg-result-card');
2835
+ const body = document.getElementById('pg-result-body');
2836
+ const meta = document.getElementById('pg-result-meta');
2837
+ const tool = sel.value || '';
2838
+ if (!tool) { return; }
2839
+ const argErr = document.getElementById('pg-args-err');
2840
+ let args;
2841
+ try {
2842
+ args = argsEl.value.trim() ? JSON.parse(argsEl.value) : {};
2843
+ if (argErr) argErr.hidden = true;
2844
+ } catch (e) {
2845
+ if (argErr) { argErr.hidden = false; argErr.textContent = 'Arguments are not valid JSON: ' + (e && e.message ? e.message : e); }
2846
+ card.hidden = false;
2847
+ meta.textContent = ' · client-side error';
2848
+ PG_LAST = { raw: null, data: null, error: 'Arguments are not valid JSON: ' + (e && e.message ? e.message : e) };
2849
+ pgRenderResult();
2850
+ return;
2851
+ }
2852
+ btn.disabled = true;
2853
+ meta.textContent = ' · running…';
2854
+ card.hidden = false;
2855
+ body.textContent = '';
2856
+ const t0 = Date.now();
2857
+ try {
2858
+ const res = await fetch('/api/playground/invoke', {
2859
+ method: 'POST',
2860
+ headers: { 'content-type': 'application/json' },
2861
+ body: JSON.stringify({ tool, args }),
2862
+ });
2863
+ const json = await res.json();
2864
+ meta.textContent = ' · HTTP ' + res.status + ' · ' + (Date.now() - t0) + ' ms';
2865
+ PG_LAST = pgUnwrap(json, res.ok);
2866
+ pgRenderResult();
2867
+ } catch (e) {
2868
+ meta.textContent = ' · network error';
2869
+ PG_LAST = { raw: null, data: null, error: (e && e.message) ? e.message : String(e) };
2870
+ pgRenderResult();
2871
+ } finally {
2872
+ btn.disabled = false;
2873
+ }
2874
+ }
2875
+
2876
+ // Pull the meaningful payload out of an MCP CallToolResult. Tools return
2877
+ // { tool, result:{ content:[{type:'text',text}], isError? } }. The text
2878
+ // is usually itself JSON — parse it so we can pretty/table-render it.
2879
+ function pgUnwrap(json, httpOk) {
2880
+ if (!httpOk || (json && json.error)) {
2881
+ return { raw: json, data: null, error: (json && (json.error || json.message)) || ('HTTP error') };
2882
+ }
2883
+ const result = json && json.result;
2884
+ let data = result;
2885
+ let error = (result && result.isError) ? 'Tool reported isError' : null;
2886
+ const content = result && result.content;
2887
+ if (Array.isArray(content)) {
2888
+ const text = content.filter(c => c && c.type === 'text' && typeof c.text === 'string').map(c => c.text).join('\n');
2889
+ if (text) {
2890
+ try { data = JSON.parse(text); }
2891
+ catch (e) { data = text; } // not JSON — show the raw text
2892
+ }
2893
+ }
2894
+ return { raw: json, data, error };
2895
+ }
2896
+
2897
+ function pgSetView(v) {
2898
+ PG_VIEW = v;
2899
+ document.querySelectorAll('#pg-view-seg button').forEach(b => b.classList.toggle('active', b.dataset.view === v));
2900
+ pgRenderResult();
2901
+ }
2902
+
2903
+ function pgRenderResult() {
2904
+ const body = document.getElementById('pg-result-body');
2905
+ if (!PG_LAST) { body.textContent = ''; return; }
2906
+ let html = '';
2907
+ if (PG_LAST.error) html += '<div class="pg-err-banner">⚠ ' + escHtml(PG_LAST.error) + '</div>';
2908
+
2909
+ if (PG_VIEW === 'raw' || PG_LAST.raw == null && PG_LAST.data == null) {
2910
+ html += '<pre class="pg-json">' + pgHighlight(PG_LAST.raw != null ? PG_LAST.raw : (PG_LAST.error || '')) + '</pre>';
2911
+ } else if (PG_VIEW === 'table') {
2912
+ const tbl = pgTryTable(PG_LAST.data);
2913
+ html += tbl || ('<div class="muted" style="font-size:13px;margin-bottom:8px">No tabular shape detected — showing Pretty.</div><pre class="pg-json">' + pgHighlight(PG_LAST.data) + '</pre>');
2914
+ } else { // pretty
2915
+ html += '<pre class="pg-json">' + pgHighlight(PG_LAST.data) + '</pre>';
2916
+ }
2917
+ body.innerHTML = html;
2918
+ }
2919
+
2920
+ // Syntax-highlight a value as pretty JSON. Escapes first (XSS-safe),
2921
+ // then wraps tokens in colour spans.
2922
+ function pgHighlight(value) {
2923
+ let s;
2924
+ if (typeof value === 'string') s = value;
2925
+ else s = JSON.stringify(value, null, 2);
2926
+ if (typeof s !== 'string') s = String(s);
2927
+ s = escHtml(s);
2928
+ // strings (incl. keys), then numbers / bools / null
2929
+ s = s.replace(/&quot;(\\.|[^&]|&(?!quot;))*?&quot;(\s*:)?/g, (m, _g, colon) =>
2930
+ '<span class="' + (colon ? 'k' : 's') + '">' + m.replace(/(\s*:)$/, '') + '</span>' + (colon || ''));
2931
+ s = s.replace(/\b(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\b/g, '<span class="n">$1</span>');
2932
+ s = s.replace(/\b(true|false)\b/g, '<span class="b">$1</span>');
2933
+ s = s.replace(/\bnull\b/g, '<span class="z">null</span>');
2934
+ return s;
2935
+ }
2936
+
2937
+ // Render an array-of-objects (or an object whose first array value is one)
2938
+ // as a table. Returns null when nothing tabular is found.
2939
+ function pgTryTable(data) {
2940
+ let rows = null;
2941
+ if (Array.isArray(data) && data.length && data.every(r => r && typeof r === 'object' && !Array.isArray(r))) {
2942
+ rows = data;
2943
+ } else if (data && typeof data === 'object') {
2944
+ for (const k of Object.keys(data)) {
2945
+ const v = data[k];
2946
+ if (Array.isArray(v) && v.length && v.every(r => r && typeof r === 'object' && !Array.isArray(r))) { rows = v; break; }
2947
+ }
2948
+ }
2949
+ if (!rows) return null;
2950
+ // Union of keys, preserving first-seen order.
2951
+ const cols = [];
2952
+ rows.forEach(r => Object.keys(r).forEach(k => { if (!cols.includes(k)) cols.push(k); }));
2953
+ const head = '<tr>' + cols.map(c => '<th>' + escHtml(c) + '</th>').join('') + '</tr>';
2954
+ const bodyRows = rows.map(r => '<tr>' + cols.map(c => '<td>' + pgCell(r[c]) + '</td>').join('') + '</tr>').join('');
2955
+ return '<div class="pg-tbl-wrap"><table class="pg-tbl"><thead>' + head + '</thead><tbody>' + bodyRows + '</tbody></table></div>';
2956
+ }
2957
+
2958
+ // One table cell. Status-like strings get a coloured pill; objects/arrays
2959
+ // collapse to compact JSON.
2960
+ function pgCell(v) {
2961
+ if (v == null) return '<span class="muted">—</span>';
2962
+ if (typeof v === 'object') return '<span class="muted" style="font-family:var(--mono);font-size:11px">' + escHtml(JSON.stringify(v)) + '</span>';
2963
+ const s = String(v);
2964
+ const cls = { up:'up', healthy:'healthy', ok:'ok', down:'down', error:'error', critical:'crit', crit:'crit', warn:'warn', warning:'warn', degraded:'degraded' }[s.toLowerCase()];
2965
+ if (cls) return '<span class="pill ' + cls + '">' + escHtml(s) + '</span>';
2966
+ return escHtml(s);
2967
+ }
2968
+
2969
+ function pgCopy() {
2970
+ if (!PG_LAST) return;
2971
+ const text = PG_VIEW === 'raw'
2972
+ ? JSON.stringify(PG_LAST.raw, null, 2)
2973
+ : (typeof PG_LAST.data === 'string' ? PG_LAST.data : JSON.stringify(PG_LAST.data, null, 2));
2974
+ navigator.clipboard.writeText(text).then(() => toast('Copied'), () => toast('Copy failed'));
2975
+ }
2976
+
2977
+ function pgClear() {
2978
+ document.getElementById('pg-result-card').hidden = true;
2979
+ document.getElementById('pg-result-body').textContent = '';
2980
+ document.getElementById('pg-result-meta').textContent = '';
2981
+ PG_LAST = null;
2982
+ }
2983
+
2984
+ // --- Postmortems tab (P6) -----------------------------------------------
2985
+ // Mirrors the generate_postmortem MCP tool into the UI: list persisted
2986
+ // entries newest-first, open a detail view rendering the markdown,
2987
+ // regenerate (POST /api/postmortems re-runs the tool with the same
2988
+ // service+window), delete (admin-only — the API gates).
2989
+
2990
+ let PM_LAST_DETAIL = null;
2991
+
2992
+ async function pmLoadList() {
2993
+ const body = document.getElementById('pm-list-body');
2994
+ if (!body) return;
2995
+ body.innerHTML = '<div class="empty">Loading…</div>';
2996
+ try {
2997
+ const r = await fetch('/api/postmortems');
2998
+ if (!r.ok) { body.innerHTML = '<div class="empty">HTTP ' + r.status + '</div>'; return; }
2999
+ const j = await r.json();
3000
+ if (!j.entries || j.entries.length === 0) {
3001
+ body.innerHTML = '<div class="empty">No postmortems yet. Click <b>+ Generate</b> to create one for a service.</div>';
3002
+ return;
3003
+ }
3004
+ let html = '<table class="data-table"><thead><tr><th>When</th><th>Service</th><th>Window</th><th>Synopsis</th><th>By</th><th></th></tr></thead><tbody>';
3005
+ for (const e of j.entries) {
3006
+ html += '<tr>'
3007
+ + '<td>' + escHtml(e.ts) + '</td>'
3008
+ + '<td><code>' + escHtml(e.service) + '</code></td>'
3009
+ + '<td>' + escHtml(e.window) + '</td>'
3010
+ + '<td>' + escHtml((e.synopsis || '').slice(0, 120)) + '</td>'
3011
+ + '<td>' + escHtml(e.createdBy) + '</td>'
3012
+ + '<td><button class="btn btn-sm" onclick="pmOpen(\'' + escHtml(e.id) + '\')">Open</button></td>'
3013
+ + '</tr>';
3014
+ }
3015
+ html += '</tbody></table>';
3016
+ body.innerHTML = html;
3017
+ } catch (e) {
3018
+ body.innerHTML = '<div class="empty">' + escHtml('Load failed: ' + (e?.message || e)) + '</div>';
3019
+ }
3020
+ }
3021
+
3022
+ async function pmOpen(id) {
3023
+ try {
3024
+ const r = await fetch('/api/postmortems/' + encodeURIComponent(id));
3025
+ if (!r.ok) { alert('HTTP ' + r.status); return; }
3026
+ const j = await r.json();
3027
+ PM_LAST_DETAIL = j;
3028
+ const card = document.getElementById('pm-detail-card');
3029
+ const title = document.getElementById('pm-detail-title');
3030
+ const meta = document.getElementById('pm-detail-meta');
3031
+ const md = document.getElementById('pm-detail-md');
3032
+ title.textContent = j.report.service + ' — ' + j.report.window;
3033
+ meta.textContent = j.ts + ' · by ' + j.createdBy + ' · id ' + j.id;
3034
+ md.textContent = j.report.markdown || '';
3035
+ card.hidden = false;
3036
+ document.getElementById('pm-detail-regen').hidden = false;
3037
+ document.getElementById('pm-detail-delete').hidden = false;
3038
+ card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
3039
+ } catch (e) { alert('Open failed: ' + (e?.message || e)); }
3040
+ }
3041
+
3042
+ function pmCloseDetail() {
3043
+ PM_LAST_DETAIL = null;
3044
+ document.getElementById('pm-detail-card').hidden = true;
3045
+ }
3046
+
3047
+ async function pmOpenNew() {
3048
+ const service = prompt('Service name to generate a postmortem for:');
3049
+ if (!service) return;
3050
+ const duration = prompt('Window (e.g. 1h, 6h):', '1h') || '1h';
3051
+ await pmGenerate(service.trim(), duration.trim());
3052
+ }
3053
+
3054
+ async function pmGenerate(service, duration) {
3055
+ try {
3056
+ const r = await fetch('/api/postmortems', {
3057
+ method: 'POST',
3058
+ headers: { 'content-type': 'application/json' },
3059
+ body: JSON.stringify({ service, duration }),
3060
+ });
3061
+ if (!r.ok) {
3062
+ const text = await r.text();
3063
+ alert('Generate failed: HTTP ' + r.status + (text ? '\n' + text : ''));
3064
+ return;
3065
+ }
3066
+ const stored = await r.json();
3067
+ await pmLoadList();
3068
+ await pmOpen(stored.id);
3069
+ } catch (e) { alert('Generate failed: ' + (e?.message || e)); }
3070
+ }
3071
+
3072
+ async function pmRegenerate() {
3073
+ if (!PM_LAST_DETAIL) return;
3074
+ await pmGenerate(PM_LAST_DETAIL.report.service, PM_LAST_DETAIL.report.window);
3075
+ }
3076
+
3077
+ async function pmDelete() {
3078
+ if (!PM_LAST_DETAIL) return;
3079
+ if (!confirm('Delete this postmortem? This cannot be undone.')) return;
3080
+ try {
3081
+ const r = await fetch('/api/postmortems/' + encodeURIComponent(PM_LAST_DETAIL.id), { method: 'DELETE' });
3082
+ if (r.status === 204) { pmCloseDetail(); await pmLoadList(); return; }
3083
+ alert('Delete failed: HTTP ' + r.status);
3084
+ } catch (e) { alert('Delete failed: ' + (e?.message || e)); }
2020
3085
  }
2021
3086
 
2022
3087
  // --- Policies tab (PolicyEngine snapshot + dry-run) ---
2023
- async function loadPolicies() {
3088
+ // Classify the engine kind reported by /api/policy.engine into one of
3089
+ // the three banner styles. The kind is `builtin`, `file:<path>`, or
3090
+ // `opa:<url>`; the prefix is what we key on.
3091
+ function polEngineKind(engineStr) {
3092
+ const s = (engineStr || '').toLowerCase();
3093
+ if (s.startsWith('opa:')) return 'opa';
3094
+ if (s.startsWith('file:') || s.startsWith('file ')) return 'file';
3095
+ return 'builtin';
3096
+ }
3097
+ function polRenderEngineBanner(j) {
3098
+ const banner = document.getElementById('pol-engine-banner');
3099
+ if (!banner) return;
3100
+ const kind = polEngineKind(j.engine);
3101
+ banner.hidden = false;
3102
+ banner.className = 'pol-engine-banner eng-' + kind;
3103
+ // body[data-policy-engine="..."] drives CSS that disables every
3104
+ // [data-engine-required="file"] control when authoring isn't
3105
+ // supported by the active engine.
3106
+ document.body.setAttribute('data-policy-engine', kind);
3107
+ const titleEl = document.getElementById('pol-eb-title-text');
3108
+ const kindEl = document.getElementById('pol-eb-kind');
3109
+ const metaEl = document.getElementById('pol-eb-meta');
3110
+ const copyEl = document.getElementById('pol-eb-copy');
3111
+ const tenantPill = document.getElementById('pol-eb-tenant-aware');
3112
+ if (kindEl) kindEl.textContent = j.engine || 'unknown';
3113
+ if (tenantPill) tenantPill.hidden = !j.tenantAware;
3114
+ if (kind === 'file') {
3115
+ titleEl.textContent = 'Editable here';
3116
+ metaEl.textContent = 'source: ' + (j.engine || '');
3117
+ copyEl.textContent = 'Changes saved via the API are persisted to the file. A server restart re-reads the file from disk; until then the in-memory state is authoritative.';
3118
+ } else if (kind === 'opa') {
3119
+ titleEl.textContent = 'Read-only (OPA)';
3120
+ metaEl.textContent = 'evaluating Rego at ' + (j.engine || '').replace(/^opa:/i, '');
3121
+ copyEl.textContent = 'OPA is the source of truth. Define roles + bindings in Rego, deploy them through OPA’s bundle pipeline; this view reflects what OPA evaluates on every gate decision. Use the probe above to verify a specific verdict.';
3122
+ } else {
3123
+ titleEl.textContent = 'Built-in defaults (read-only)';
3124
+ metaEl.textContent = j.note || 'DEFAULT_POLICY shipped with the build';
3125
+ copyEl.textContent = 'Set OMCP_RBAC_POLICY_FILE to override with a YAML / JSON role catalogue (then the engine flips to file mode and authoring becomes available), or OMCP_OPA_URL to delegate evaluation to OPA.';
3126
+ }
3127
+ // Mirror the kind on the legacy badge in the header.
2024
3128
  const engineEl = document.getElementById('pol-engine');
3129
+ if (engineEl) {
3130
+ engineEl.textContent = 'engine: ' + (j.engine || 'unknown');
3131
+ engineEl.className = 'badge ' + (kind === 'opa' ? 'badge-warn' : kind === 'file' ? 'badge-ok' : '');
3132
+ }
3133
+ }
3134
+ // Policy snapshot — fetched once on page enter, then reused by the
3135
+ // sub-tab renderers (Roles matrix today; Bindings + Subjects in
3136
+ // later slices). Reset on every loadPolicies() call.
3137
+ let POL_SNAPSHOT = null;
3138
+ let POL_SELECTED_ROLE = null;
3139
+
3140
+ function polSetTab(name) {
3141
+ document.querySelectorAll('.pol-subtab').forEach((btn) => {
3142
+ btn.setAttribute('data-active', String(btn.getAttribute('data-pol-tab') === name));
3143
+ btn.setAttribute('aria-selected', String(btn.getAttribute('data-pol-tab') === name));
3144
+ });
3145
+ document.querySelectorAll('.pol-pane').forEach((p) => {
3146
+ p.hidden = p.id !== 'pol-pane-' + name;
3147
+ });
3148
+ // Lazy-load Subjects on first visit — it has its own endpoint
3149
+ // so deferring the fetch keeps the page-enter cost low.
3150
+ if (name === 'subjects') polLoadSubjects();
3151
+ if (name === 'bindings') polLoadBindings();
3152
+ if (name === 'batch') polBatchInit();
3153
+ }
3154
+
3155
+ // --- Batch evaluate sub-tab (P4) ---
3156
+ //
3157
+ // One round-trip to POST /api/policy/dry-run-batch with the
3158
+ // parsed subjects + selected resources + selected actions.
3159
+ // Renders the (subject × resource × action) matrix as a coloured
3160
+ // heat-map. CSV export re-hits the same endpoint with
3161
+ // `Accept: text/csv` so we never have to format CSV in the UI.
3162
+
3163
+ let POL_BATCH_INITED = false;
3164
+ let POL_BATCH_LAST = null;
3165
+
3166
+ function polBatchInit() {
3167
+ if (POL_BATCH_INITED) return;
3168
+ POL_BATCH_INITED = true;
3169
+ // Reuse POL_RESOURCES + POL_ACTIONS — the same constants the Roles
3170
+ // matrix renders from, kept in sync with VALID_RESOURCES /
3171
+ // VALID_ACTIONS server-side via a CI check.
3172
+ const resSel = document.getElementById('pol-batch-resources');
3173
+ const actSel = document.getElementById('pol-batch-actions');
3174
+ if (resSel) {
3175
+ resSel.innerHTML = POL_RESOURCES.map((r) => '<option value="' + escHtml(r) + '" selected>' + escHtml(r) + '</option>').join('');
3176
+ }
3177
+ if (actSel) {
3178
+ actSel.innerHTML = POL_ACTIONS.map((a) => '<option value="' + escHtml(a) + '" selected>' + escHtml(a) + '</option>').join('');
3179
+ }
3180
+ }
3181
+
3182
+ function polBatchParseSubjects(text) {
3183
+ const out = [];
3184
+ for (const line of String(text || '').split('\n')) {
3185
+ const t = line.trim();
3186
+ if (!t) continue;
3187
+ const eq = t.indexOf('=');
3188
+ if (eq < 0) continue;
3189
+ const key = t.slice(0, eq).trim();
3190
+ const roles = t.slice(eq + 1).split(',').map((r) => r.trim()).filter(Boolean);
3191
+ if (!key || roles.length === 0) continue;
3192
+ out.push({ key, roles });
3193
+ }
3194
+ return out;
3195
+ }
3196
+
3197
+ function polBatchBuildRequest() {
3198
+ const subjects = polBatchParseSubjects(document.getElementById('pol-batch-subjects')?.value);
3199
+ const resources = Array.from(document.getElementById('pol-batch-resources')?.selectedOptions || []).map((o) => o.value);
3200
+ const actions = Array.from(document.getElementById('pol-batch-actions')?.selectedOptions || []).map((o) => o.value);
3201
+ return { subjects, resources, actions };
3202
+ }
3203
+
3204
+ async function polBatchEvaluate() {
3205
+ const body = polBatchBuildRequest();
3206
+ const out = document.getElementById('pol-batch-result');
3207
+ const totals = document.getElementById('pol-batch-totals');
3208
+ const csvBtn = document.getElementById('pol-batch-csv-btn');
3209
+ if (body.subjects.length === 0 || body.resources.length === 0 || body.actions.length === 0) {
3210
+ out.innerHTML = '<div class="empty">Need at least one subject, one resource, and one action.</div>';
3211
+ if (csvBtn) csvBtn.disabled = true;
3212
+ if (totals) totals.textContent = '';
3213
+ return;
3214
+ }
3215
+ out.innerHTML = '<div class="muted">Evaluating…</div>';
3216
+ try {
3217
+ const r = await fetch('/api/policy/dry-run-batch', {
3218
+ method: 'POST',
3219
+ headers: { 'content-type': 'application/json' },
3220
+ body: JSON.stringify(body),
3221
+ });
3222
+ if (!r.ok) {
3223
+ out.innerHTML = '<div class="empty">' + escHtml('Evaluate failed: HTTP ' + r.status) + '</div>';
3224
+ if (csvBtn) csvBtn.disabled = true;
3225
+ return;
3226
+ }
3227
+ const j = await r.json();
3228
+ POL_BATCH_LAST = body;
3229
+ polBatchRender(j, body);
3230
+ if (csvBtn) csvBtn.disabled = false;
3231
+ if (totals) totals.textContent = `${j.totals.cells} cells · ${j.totals.allow} allow · ${j.totals.deny} deny`;
3232
+ } catch (e) {
3233
+ out.innerHTML = '<div class="empty">' + escHtml('Evaluate failed: ' + (e?.message || e)) + '</div>';
3234
+ if (csvBtn) csvBtn.disabled = true;
3235
+ }
3236
+ }
3237
+
3238
+ function polBatchRender(result, req) {
3239
+ const out = document.getElementById('pol-batch-result');
3240
+ if (!out) return;
3241
+ // Matrix layout: rows = subjects, columns = (resource, action) pairs.
3242
+ // Grouping resources visually keeps the table compact.
3243
+ const rows = req.subjects.map((s) => s.key);
3244
+ const colGroups = req.resources.map((res) => ({ res, actions: req.actions.slice() }));
3245
+ let html = '<div style="overflow:auto;max-height:520px"><table class="pol-heat">';
3246
+ // Resource header row
3247
+ html += '<thead><tr><th rowspan="2" class="row-head">Subject</th>';
3248
+ for (const g of colGroups) html += '<th class="resource-head" colspan="' + g.actions.length + '">' + escHtml(g.res) + '</th>';
3249
+ html += '</tr><tr>';
3250
+ for (const g of colGroups) for (const a of g.actions) html += '<th>' + escHtml(a) + '</th>';
3251
+ html += '</tr></thead><tbody>';
3252
+ for (const sk of rows) {
3253
+ html += '<tr><td class="row-head">' + escHtml(sk) + '</td>';
3254
+ for (const g of colGroups) {
3255
+ for (const a of g.actions) {
3256
+ const cell = result.matrix?.[sk]?.[g.res]?.[a];
3257
+ if (!cell) {
3258
+ html += '<td class="cell-na" title="not evaluated">·</td>';
3259
+ } else if (cell.allowed) {
3260
+ html += '<td class="cell-allow" title="' + escHtml(cell.reason || 'allow') + '">✓</td>';
3261
+ } else {
3262
+ html += '<td class="cell-deny" title="' + escHtml(cell.reason || 'deny') + '">✗</td>';
3263
+ }
3264
+ }
3265
+ }
3266
+ html += '</tr>';
3267
+ }
3268
+ html += '</tbody></table></div>';
3269
+ if (Array.isArray(result.dropped) && result.dropped.length) {
3270
+ html += '<div class="pol-batch-dropped">Dropped: ' +
3271
+ result.dropped.map((d) => escHtml(d.kind + ' ' + d.value + ' (' + d.reason + ')')).join('; ') + '</div>';
3272
+ }
3273
+ out.innerHTML = html;
3274
+ }
3275
+
3276
+ async function polBatchExportCsv() {
3277
+ if (!POL_BATCH_LAST) return;
3278
+ try {
3279
+ const r = await fetch('/api/policy/dry-run-batch', {
3280
+ method: 'POST',
3281
+ headers: { 'content-type': 'application/json', 'accept': 'text/csv' },
3282
+ body: JSON.stringify(POL_BATCH_LAST),
3283
+ });
3284
+ if (!r.ok) { alert('CSV export failed: HTTP ' + r.status); return; }
3285
+ const csv = await r.text();
3286
+ const blob = new Blob([csv], { type: 'text/csv' });
3287
+ const url = URL.createObjectURL(blob);
3288
+ const a = document.createElement('a');
3289
+ a.href = url;
3290
+ a.download = 'policy-dry-run-' + new Date().toISOString().replace(/[:.]/g, '-') + '.csv';
3291
+ document.body.appendChild(a); a.click(); a.remove();
3292
+ URL.revokeObjectURL(url);
3293
+ } catch (e) {
3294
+ alert('CSV export failed: ' + (e?.message || e));
3295
+ }
3296
+ }
3297
+
3298
+ // Bindings = the (subject → roles) view derived from /api/subjects.
3299
+ // We don't have a separate /api/bindings endpoint because the binding
3300
+ // data IS the subject data today (users carry roles; api-keys don't;
3301
+ // oidc groups map to a single role via the env catalog).
3302
+ let POL_BINDING_EDIT = null; // { kind, name } of the row being edited
3303
+ async function polLoadBindings() {
3304
+ const body = document.getElementById('pol-bindings-body');
3305
+ if (!body) return;
3306
+ // Reuse the subjects payload if already fetched — same data source.
3307
+ if (!POL_SUBJECTS) {
3308
+ try {
3309
+ const r = await fetch('/api/subjects');
3310
+ if (!r.ok) {
3311
+ body.innerHTML = '<div class="empty">Bindings view requires the <code>users:delete</code> permission (admin role).</div>';
3312
+ return;
3313
+ }
3314
+ POL_SUBJECTS = await r.json();
3315
+ } catch (e) {
3316
+ body.innerHTML = '<div class="empty">Subjects unavailable: ' + escHtml(String(e && e.message || e)) + '</div>';
3317
+ return;
3318
+ }
3319
+ }
3320
+ polRenderBindings(POL_SUBJECTS);
3321
+ }
3322
+ function polRenderBindings(s) {
3323
+ const body = document.getElementById('pol-bindings-body');
3324
+ if (!body) return;
3325
+ // Are user edits available? Only when OMCP_USERS_FILE is set
3326
+ // (server returns it under sources.users) — otherwise the PUT
3327
+ // endpoint 409s and the operator just gets a confusing failure.
3328
+ const userEditable = !!(s.sources && s.sources.users);
3329
+ const rows = [];
3330
+ for (const u of (s.users || [])) {
3331
+ const roles = (u.roles || []).map((r) => `<span class="pill">${escHtml(r)}</span>`).join(' ') || '<span class="t-sm" style="opacity:.5">—</span>';
3332
+ const action = userEditable
3333
+ ? `<button class="btn btn-ghost btn-sm" data-bind-edit data-bind-kind="user" data-bind-name="${escHtml(u.username)}">Edit</button>`
3334
+ : '<span class="t-sm" style="opacity:.55" title="Set OMCP_USERS_FILE to enable user-role editing">read-only</span>';
3335
+ rows.push(`<tr>
3336
+ <td><code>${escHtml(u.username)}</code></td>
3337
+ <td><span class="tag">user</span></td>
3338
+ <td>${roles}</td>
3339
+ <td>${escHtml(u.tenant || 'default')}</td>
3340
+ <td style="text-align:right">${action}</td>
3341
+ </tr>`);
3342
+ }
3343
+ for (const k of (s.apiKeys || [])) {
3344
+ rows.push(`<tr>
3345
+ <td><code>${escHtml(k.name)}</code></td>
3346
+ <td><span class="tag">api key</span></td>
3347
+ <td><span class="t-sm" style="opacity:.5" title="Bearer credentials don't carry RBAC roles today — the management-plane RBAC gate uses session principals, not /mcp credentials">—</span></td>
3348
+ <td>${escHtml(k.tenant || 'default')}</td>
3349
+ <td style="text-align:right"><span class="t-sm" style="opacity:.55">via <code>OMCP_API_KEYS</code></span></td>
3350
+ </tr>`);
3351
+ }
3352
+ for (const g of (s.oidcGroups || [])) {
3353
+ rows.push(`<tr>
3354
+ <td><code>${escHtml(g.claim)}</code></td>
3355
+ <td><span class="tag">oidc group</span></td>
3356
+ <td><span class="pill">${escHtml(g.role)}</span></td>
3357
+ <td><span class="t-sm" style="opacity:.55">—</span></td>
3358
+ <td style="text-align:right"><span class="t-sm" style="opacity:.55">via <code>OMCP_OIDC_ROLE_MAP</code></span></td>
3359
+ </tr>`);
3360
+ }
3361
+ if (rows.length === 0) {
3362
+ body.innerHTML = `<div class="pol-subjects-empty" style="padding:var(--sp-4)">
3363
+ No subjects configured. Set one of <code>OMCP_USERS_FILE</code> /
3364
+ <code>OMCP_API_KEYS</code> / <code>OMCP_OIDC_ROLE_MAP</code> to populate this view.
3365
+ </div>`;
3366
+ return;
3367
+ }
3368
+ body.innerHTML = `<table class="data-table" style="width:100%">
3369
+ <thead><tr><th>Subject</th><th>Kind</th><th>Roles</th><th>Tenant</th><th></th></tr></thead>
3370
+ <tbody>${rows.join('')}</tbody>
3371
+ </table>`;
3372
+ // Wire the per-row Edit buttons via delegation so role-name strings
3373
+ // can't escape into onclick context (same defence-in-depth as the
3374
+ // role list in Slice F).
3375
+ if (!body.dataset.delegated) {
3376
+ body.addEventListener('click', (ev) => {
3377
+ const btn = ev.target.closest('[data-bind-edit]');
3378
+ if (!btn) return;
3379
+ const kind = btn.getAttribute('data-bind-kind');
3380
+ const name = btn.getAttribute('data-bind-name');
3381
+ if (kind && name) polBindingEdit(kind, name);
3382
+ });
3383
+ body.dataset.delegated = '1';
3384
+ }
3385
+ }
3386
+ function polBindingEdit(kind, name) {
3387
+ POL_BINDING_EDIT = { kind, name };
3388
+ const subj = document.getElementById('pol-binding-subject');
3389
+ const subj2 = document.getElementById('pol-binding-subject-2');
3390
+ if (subj) subj.textContent = name;
3391
+ if (subj2) subj2.textContent = name;
3392
+ const errEl = document.getElementById('pol-binding-error');
3393
+ if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
3394
+ // Build the checklist from the active engine's role catalogue.
3395
+ const rolesBox = document.getElementById('pol-binding-roles');
3396
+ const knownRoles = (POL_SNAPSHOT && POL_SNAPSHOT.roles) || [];
3397
+ const currentRoles = kind === 'user'
3398
+ ? ((POL_SUBJECTS && POL_SUBJECTS.users.find((u) => u.username === name) || {}).roles || [])
3399
+ : [];
3400
+ if (rolesBox) {
3401
+ if (knownRoles.length === 0) {
3402
+ rolesBox.innerHTML = '<div class="empty">No roles declared in the active engine.</div>';
3403
+ } else {
3404
+ rolesBox.innerHTML = knownRoles.map((r) => {
3405
+ const checked = currentRoles.includes(r) ? 'checked' : '';
3406
+ return `<label class="tp-item" style="display:flex;align-items:center;gap:var(--sp-2);padding:var(--sp-1) 0;cursor:pointer">
3407
+ <input type="checkbox" data-pol-role value="${escHtml(r)}" ${checked}>
3408
+ <span style="font-family:'JetBrains Mono', ui-monospace, monospace;font-size:13px">${escHtml(r)}</span>
3409
+ </label>`;
3410
+ }).join('');
3411
+ }
3412
+ }
3413
+ document.getElementById('pol-binding-modal').classList.add('open');
3414
+ }
3415
+ async function polBindingSave() {
3416
+ if (!POL_BINDING_EDIT) return;
3417
+ const errEl = document.getElementById('pol-binding-error');
3418
+ const setErr = (msg) => { if (errEl) { errEl.textContent = msg; errEl.style.display = ''; } };
3419
+ if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
3420
+ if (POL_BINDING_EDIT.kind !== 'user') {
3421
+ setErr('Only local-user bindings are editable at runtime today.');
3422
+ return;
3423
+ }
3424
+ const checks = Array.from(document.querySelectorAll('#pol-binding-roles input[data-pol-role]:checked'))
3425
+ .map((el) => el.value);
3426
+ try {
3427
+ const r = await fetch('/api/users/' + encodeURIComponent(POL_BINDING_EDIT.name) + '/roles', {
3428
+ method: 'PUT',
3429
+ headers: { 'Content-Type': 'application/json' },
3430
+ body: JSON.stringify({ roles: checks }),
3431
+ });
3432
+ if (!r.ok) {
3433
+ const j = await r.json().catch(() => ({}));
3434
+ setErr(j.error || ('HTTP ' + r.status));
3435
+ return;
3436
+ }
3437
+ closeModal('pol-binding-modal');
3438
+ toast('Roles updated for ' + POL_BINDING_EDIT.name);
3439
+ // Invalidate + refresh the bindings view.
3440
+ POL_SUBJECTS = null;
3441
+ await polLoadBindings();
3442
+ } catch (e) {
3443
+ setErr('Save failed: ' + (e && e.message || e));
3444
+ }
3445
+ }
3446
+
3447
+ // Cache the subjects payload so re-entering the tab doesn't refetch.
3448
+ let POL_SUBJECTS = null;
3449
+ async function polLoadSubjects() {
3450
+ const body = document.getElementById('pol-subjects-body');
3451
+ if (!body) return;
3452
+ if (POL_SUBJECTS) {
3453
+ polRenderSubjects(POL_SUBJECTS);
3454
+ return;
3455
+ }
3456
+ try {
3457
+ const r = await fetch('/api/subjects');
3458
+ if (!r.ok) {
3459
+ body.innerHTML = '<div class="empty">Subjects view requires the <code>users:delete</code> permission (admin role).</div>';
3460
+ return;
3461
+ }
3462
+ POL_SUBJECTS = await r.json();
3463
+ polRenderSubjects(POL_SUBJECTS);
3464
+ } catch (e) {
3465
+ body.innerHTML = '<div class="empty">Subjects unavailable: ' + escHtml(String(e && e.message || e)) + '</div>';
3466
+ }
3467
+ }
3468
+ function polRenderSubjects(j) {
3469
+ const body = document.getElementById('pol-subjects-body');
3470
+ if (!body) return;
3471
+ const sources = j.sources || {};
3472
+ const usersHtml = (j.users || []).map((u) => `
3473
+ <tr>
3474
+ <td><code>${escHtml(u.username)}</code></td>
3475
+ <td>${escHtml(u.name)}</td>
3476
+ <td>${(u.roles || []).map((r) => `<span class="pill">${escHtml(r)}</span>`).join(' ') || '<span class="t-sm" style="opacity:.5">—</span>'}</td>
3477
+ <td>${escHtml(u.tenant || 'default')}</td>
3478
+ </tr>`).join('');
3479
+ const apiKeysHtml = (j.apiKeys || []).map((k) => `
3480
+ <tr>
3481
+ <td><code>${escHtml(k.name)}</code></td>
3482
+ <td>${escHtml(k.tenant || 'default')}</td>
3483
+ <td>${k.productId ? `<code>${escHtml(k.productId)}</code>` : '<span class="t-sm" style="opacity:.5">—</span>'}</td>
3484
+ <td>${k.bypassRedaction ? '<span class="pill" style="background:var(--warning-soft);color:var(--warning)">bypass</span>' : '<span class="t-sm" style="opacity:.5">—</span>'}</td>
3485
+ <td>${k.allowedSources && k.allowedSources.length ? k.allowedSources.map((s) => `<code class="t-sm">${escHtml(s)}</code>`).join(' ') : '<span class="t-sm" style="opacity:.5">(all)</span>'}</td>
3486
+ </tr>`).join('');
3487
+ const groupsHtml = (j.oidcGroups || []).map((g) => `
3488
+ <tr>
3489
+ <td><code>${escHtml(g.claim)}</code></td>
3490
+ <td><span class="pill">${escHtml(g.role)}</span></td>
3491
+ </tr>`).join('');
3492
+ const usersTbl = usersHtml
3493
+ ? `<table class="data-table" style="width:100%"><thead><tr><th>Username</th><th>Name</th><th>Roles</th><th>Tenant</th></tr></thead><tbody>${usersHtml}</tbody></table>`
3494
+ : `<div class="pol-subjects-empty">${sources.users ? 'No users in <code>' + escHtml(sources.users) + '</code>.' : 'No local-user file configured — set <code>OMCP_USERS_FILE</code> to enable basic-mode logins.'}</div>`;
3495
+ const apiKeysTbl = apiKeysHtml
3496
+ ? `<table class="data-table" style="width:100%"><thead><tr><th>Name</th><th>Tenant</th><th>Product</th><th>Bypass</th><th>Sources</th></tr></thead><tbody>${apiKeysHtml}</tbody></table>`
3497
+ : `<div class="pol-subjects-empty">${sources.apiKeys ? 'No credentials in <code>OMCP_API_KEYS</code>.' : 'No API-key credentials configured — set <code>OMCP_API_KEYS</code> to gate the <code>/mcp</code> transport with bearer tokens.'}</div>`;
3498
+ const groupsTbl = groupsHtml
3499
+ ? `<table class="data-table" style="width:100%"><thead><tr><th>Group / claim</th><th>Maps to role</th></tr></thead><tbody>${groupsHtml}</tbody></table>`
3500
+ : `<div class="pol-subjects-empty">${sources.oidcGroups ? 'No mappings in <code>OMCP_OIDC_ROLE_MAP</code>.' : 'No OIDC group mapping configured — set <code>OMCP_OIDC_ROLE_MAP</code> when running under <code>OMCP_AUTH=oidc</code>.'}</div>`;
3501
+ body.innerHTML = `
3502
+ <div class="pol-subjects-section">
3503
+ <h3>Users
3504
+ <span class="pol-subjects-count">${(j.users || []).length}</span>
3505
+ <span class="pol-subjects-source">${sources.users ? escHtml(sources.users) : 'OMCP_USERS_FILE'}</span>
3506
+ </h3>
3507
+ ${usersTbl}
3508
+ </div>
3509
+ <div class="pol-subjects-section">
3510
+ <h3>API keys
3511
+ <span class="pol-subjects-count">${(j.apiKeys || []).length}</span>
3512
+ <span class="pol-subjects-source">OMCP_API_KEYS</span>
3513
+ </h3>
3514
+ ${apiKeysTbl}
3515
+ </div>
3516
+ <div class="pol-subjects-section">
3517
+ <h3>OIDC groups
3518
+ <span class="pol-subjects-count">${(j.oidcGroups || []).length}</span>
3519
+ <span class="pol-subjects-source">OMCP_OIDC_ROLE_MAP</span>
3520
+ </h3>
3521
+ ${groupsTbl}
3522
+ </div>`;
3523
+ }
3524
+
3525
+ // Resources × actions catalogue, kept in sync with VALID_RESOURCES +
3526
+ // VALID_ACTIONS in src/auth/policy/loader.ts. Pinned client-side so
3527
+ // the matrix shows the FULL grid (granted vs not-granted) even when
3528
+ // a role has zero grants for a given resource — the empty cells are
3529
+ // information too. If the server adds a new resource / action,
3530
+ // extend this list; the policy.engine snapshot guards against
3531
+ // silent drift through its declared-roles catalogue.
3532
+ const POL_RESOURCES = ["sources","services","health","topology","settings","connectors","audit","catalog","users","redaction","products"];
3533
+ const POL_ACTIONS = ["read","write","delete","bypass"];
3534
+
3535
+ // Effective-permissions overlay: when a subject is selected the
3536
+ // matrix flips from "what does THIS role grant" to "what can THIS
3537
+ // subject do, and via which role". The overlay is pure client-side
3538
+ // composition over the existing /api/policy + /api/subjects snapshots
3539
+ // — no new endpoint. Null means overlay off (default = role-centric).
3540
+ let POL_EFFECTIVE_SUBJECT = null; // { kind: 'user'|'oidc', id: string, roles: string[] } | null
3541
+
3542
+ function polEffectiveSubjectsList() {
3543
+ // Build the dropdown options from the cached subjects payload.
3544
+ // Users carry an explicit roles[] array. OIDC groups map to a
3545
+ // single role via OMCP_OIDC_ROLE_MAP. API keys don't carry RBAC
3546
+ // roles in the current model, so they're omitted — selecting one
3547
+ // would always show "denied" everywhere, which is misleading.
3548
+ const out = [];
3549
+ if (!POL_SUBJECTS) return out;
3550
+ for (const u of (POL_SUBJECTS.users || [])) {
3551
+ out.push({ kind: 'user', id: u.username, label: u.username + ' (user)', roles: u.roles || [] });
3552
+ }
3553
+ for (const g of (POL_SUBJECTS.oidcGroups || [])) {
3554
+ out.push({ kind: 'oidc', id: g.claim, label: g.claim + ' (oidc group)', roles: [g.role] });
3555
+ }
3556
+ return out;
3557
+ }
3558
+
3559
+ async function polEffectiveEnsureSubjects() {
3560
+ // Lazy-load /api/subjects the first time the operator opens the
3561
+ // selector. Reuse the same cache the Bindings/Subjects tabs use.
3562
+ if (POL_SUBJECTS) return;
3563
+ try {
3564
+ const r = await fetch('/api/subjects');
3565
+ if (!r.ok) return;
3566
+ POL_SUBJECTS = await r.json();
3567
+ } catch (e) {
3568
+ // Silent — the selector will just show "no subjects".
3569
+ }
3570
+ }
3571
+
3572
+ function polEffectiveOnChange(ev) {
3573
+ const val = ev.target.value;
3574
+ if (!val) {
3575
+ POL_EFFECTIVE_SUBJECT = null;
3576
+ polRolesRender();
3577
+ return;
3578
+ }
3579
+ const list = polEffectiveSubjectsList();
3580
+ const found = list.find((s) => (s.kind + ':' + s.id) === val);
3581
+ POL_EFFECTIVE_SUBJECT = found || null;
3582
+ polRolesRender();
3583
+ }
3584
+
3585
+ async function polEffectiveOpen() {
3586
+ // Operator clicked the "Show effective permissions" affordance —
3587
+ // make sure the subjects cache is populated, then re-render so the
3588
+ // selector has options.
3589
+ await polEffectiveEnsureSubjects();
3590
+ polRolesRender();
3591
+ }
3592
+
3593
+ function polRolesRender() {
3594
+ const listEl = document.getElementById('pol-role-list');
3595
+ const detailEl = document.getElementById('pol-role-detail');
3596
+ if (!listEl || !detailEl || !POL_SNAPSHOT) return;
3597
+ const roles = POL_SNAPSHOT.roles || [];
3598
+ if (roles.length === 0) {
3599
+ listEl.innerHTML = '<div class="empty">No roles defined.</div>';
3600
+ detailEl.innerHTML = '<div class="empty">Define a role to see its permission matrix.</div>';
3601
+ return;
3602
+ }
3603
+ if (!POL_SELECTED_ROLE || !roles.includes(POL_SELECTED_ROLE)) {
3604
+ POL_SELECTED_ROLE = roles[0];
3605
+ }
3606
+ // Render the role list with grant counts. Selection is handled
3607
+ // via event delegation on the list container — keeps untrusted
3608
+ // role names (file-loaded policies can use any string) out of the
3609
+ // onclick attribute, where escHtml's HTML-special set doesn't
3610
+ // sanitise JS-string-literal characters like the single quote.
3611
+ listEl.innerHTML = roles.map((role) => {
3612
+ const grants = (POL_SNAPSHOT.policy && POL_SNAPSHOT.policy[role]) || [];
3613
+ const active = role === POL_SELECTED_ROLE;
3614
+ return `<div class="pol-role-row" role="option" data-role="${escHtml(role)}" data-active="${active}">
3615
+ <span class="pol-role-name">${escHtml(role)}</span>
3616
+ <span class="pol-role-count">${grants.length} grant${grants.length === 1 ? '' : 's'}</span>
3617
+ </div>`;
3618
+ }).join('');
3619
+ // Wire delegation once. Idempotent — re-rendering the inner HTML
3620
+ // doesn't lose the listener because it's bound to the outer list
3621
+ // element which persists. Guarded with a marker attribute so a
3622
+ // second loadPolicies call doesn't double-bind.
3623
+ if (!listEl.dataset.delegated) {
3624
+ listEl.addEventListener('click', (ev) => {
3625
+ const row = ev.target.closest('.pol-role-row');
3626
+ if (!row) return;
3627
+ const role = row.getAttribute('data-role');
3628
+ if (role) polSelectRole(role);
3629
+ });
3630
+ listEl.dataset.delegated = '1';
3631
+ }
3632
+ // Render the matrix for the selected role.
3633
+ const grants = (POL_SNAPSHOT.policy && POL_SNAPSHOT.policy[POL_SELECTED_ROLE]) || [];
3634
+ // Build a Set of "resource:action" pairs for O(1) lookup.
3635
+ const granted = new Set();
3636
+ for (const g of grants) granted.add(g.resource + ':' + g.action);
3637
+ const headerCells = POL_ACTIONS.map((a) => `<th>${escHtml(a)}</th>`).join('');
3638
+
3639
+ // Effective-overlay precompute. For each (resource, action) build a
3640
+ // list of roles (from the subject's role bundle) that grant it — so
3641
+ // the cell can show "via <role>" or "via <role> +N". Empty = denied.
3642
+ const effective = POL_EFFECTIVE_SUBJECT
3643
+ ? (() => {
3644
+ const m = new Map();
3645
+ for (const role of (POL_EFFECTIVE_SUBJECT.roles || [])) {
3646
+ const gs = (POL_SNAPSHOT.policy && POL_SNAPSHOT.policy[role]) || [];
3647
+ for (const g of gs) {
3648
+ const k = g.resource + ':' + g.action;
3649
+ if (!m.has(k)) m.set(k, []);
3650
+ m.get(k).push(role);
3651
+ }
3652
+ }
3653
+ return m;
3654
+ })()
3655
+ : null;
3656
+
3657
+ const bodyRows = POL_RESOURCES.map((res) => {
3658
+ const cells = POL_ACTIONS.map((act) => {
3659
+ const key = res + ':' + act;
3660
+ if (effective) {
3661
+ const viaRoles = effective.get(key) || [];
3662
+ const ownGrant = granted.has(key);
3663
+ if (viaRoles.length === 0) {
3664
+ return `<td class="cell-empty" data-grant="false" data-effective="denied" title="denied — subject has no role granting ${escHtml(res)}:${escHtml(act)}">denied</td>`;
3665
+ }
3666
+ const first = viaRoles[0];
3667
+ const extra = viaRoles.length > 1 ? ` <span class="t-sm" style="opacity:.55">+${viaRoles.length - 1}</span>` : '';
3668
+ const ownHint = ownGrant ? ' data-via-selected="true"' : '';
3669
+ // Show all granting roles in the title for an audit trail.
3670
+ const titleRoles = viaRoles.map((r) => r).join(', ');
3671
+ return `<td class="cell-grant" data-grant="true" data-effective="allowed"${ownHint} title="via ${escHtml(titleRoles)}"><span class="t-sm" style="font-family:'JetBrains Mono', ui-monospace, monospace">via ${escHtml(first)}</span>${extra}</td>`;
3672
+ }
3673
+ const has = granted.has(key);
3674
+ return has
3675
+ ? `<td class="cell-grant" data-grant="true" title="${escHtml(res)}:${escHtml(act)} granted">✓</td>`
3676
+ : `<td class="cell-empty" data-grant="false">—</td>`;
3677
+ }).join('');
3678
+ return `<tr><th scope="row"><code>${escHtml(res)}</code></th>${cells}</tr>`;
3679
+ }).join('');
3680
+
3681
+ // Effective-overlay bar — subject selector + clear control. Rendered
3682
+ // inside the detail panel so it survives polRolesRender re-renders
3683
+ // and stays visually attached to the matrix it modifies.
3684
+ const subjOpts = polEffectiveSubjectsList();
3685
+ const selectedKey = POL_EFFECTIVE_SUBJECT ? (POL_EFFECTIVE_SUBJECT.kind + ':' + POL_EFFECTIVE_SUBJECT.id) : '';
3686
+ const optEls = subjOpts.map((s) => {
3687
+ const v = s.kind + ':' + s.id;
3688
+ const sel = v === selectedKey ? ' selected' : '';
3689
+ return `<option value="${escHtml(v)}"${sel}>${escHtml(s.label)}</option>`;
3690
+ }).join('');
3691
+ const subjEmpty = subjOpts.length === 0;
3692
+ const overlayBar = `
3693
+ <div class="pol-effective-bar" style="display:flex;align-items:center;gap:var(--sp-2);margin-bottom:var(--sp-2);flex-wrap:wrap">
3694
+ <label class="t-sm" style="opacity:.7" for="pol-effective-subject">Show effective permissions for</label>
3695
+ <select id="pol-effective-subject" class="input input-sm" style="min-width:240px" onchange="polEffectiveOnChange(event)" onfocus="polEffectiveOpen()" aria-label="Subject for effective-permissions overlay">
3696
+ <option value="">(none — show role grants)</option>
3697
+ ${optEls}
3698
+ </select>
3699
+ ${POL_EFFECTIVE_SUBJECT
3700
+ ? `<button class="btn btn-ghost btn-sm" onclick="polEffectiveOnChange({target:{value:''}})">Clear</button>
3701
+ <span class="t-sm" style="opacity:.65">via roles: ${(POL_EFFECTIVE_SUBJECT.roles || []).map((r) => `<code>${escHtml(r)}</code>`).join(', ') || '<em>none</em>'}</span>`
3702
+ : (subjEmpty ? '<span class="t-sm" style="opacity:.55">No subjects configured — set OMCP_USERS_FILE or OMCP_OIDC_ROLE_MAP to populate.</span>' : '')
3703
+ }
3704
+ </div>`;
3705
+
3706
+ const titleSuffix = POL_EFFECTIVE_SUBJECT
3707
+ ? ` <span class="t-sm" style="opacity:.65;font-weight:400">— effective view for ${escHtml(POL_EFFECTIVE_SUBJECT.id)}</span>`
3708
+ : ` <span class="t-sm" style="opacity:.65;font-weight:400">${grants.length} grant${grants.length === 1 ? '' : 's'}</span>`;
3709
+
3710
+ const legend = POL_EFFECTIVE_SUBJECT
3711
+ ? `<p class="t-sm" style="opacity:.65;margin-top:var(--sp-3)">
3712
+ <em>via &lt;role&gt;</em> = the subject inherits this grant through that role. <em>denied</em> = no role in the subject's bundle grants it. +N = additional roles also grant it; hover the cell for the full list.
3713
+ </p>`
3714
+ : `<p class="t-sm" style="opacity:.65;margin-top:var(--sp-3)">
3715
+ ✓ = the role grants this resource:action combination. — = no grant. The
3716
+ <em>redaction:bypass</em> column is the operator-side gate for the per-call
3717
+ bypass; the credential must also be allow-listed via OMCP_KEY_BYPASS_REDACTION.
3718
+ </p>`;
3719
+
3720
+ detailEl.innerHTML = `
3721
+ <h3>${escHtml(POL_SELECTED_ROLE)}${titleSuffix}</h3>
3722
+ ${overlayBar}
3723
+ <table class="pol-matrix"><thead><tr><th>Resource</th>${headerCells}</tr></thead><tbody>${bodyRows}</tbody></table>
3724
+ ${legend}
3725
+ `;
3726
+ }
3727
+ function polSelectRole(role) {
3728
+ POL_SELECTED_ROLE = role;
3729
+ polRolesRender();
3730
+ }
3731
+
3732
+ function polRoleAuthorNew() {
3733
+ // Author modal — render a permission-matrix as a checkbox grid
3734
+ // (rows = resources × cols = actions) plus a role-name input.
3735
+ const nameEl = document.getElementById('pol-role-author-name');
3736
+ const gridEl = document.getElementById('pol-role-author-grid');
3737
+ const errEl = document.getElementById('pol-role-author-error');
3738
+ if (nameEl) nameEl.value = '';
3739
+ if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
3740
+ if (gridEl) {
3741
+ const headerCells = POL_ACTIONS.map((a) => `<th>${escHtml(a)}</th>`).join('');
3742
+ const bodyRows = POL_RESOURCES.map((res) => {
3743
+ const cells = POL_ACTIONS.map((act) => `<td style="text-align:center">
3744
+ <input type="checkbox" data-pol-resource="${escHtml(res)}" data-pol-action="${escHtml(act)}" aria-label="${escHtml(res)}:${escHtml(act)}">
3745
+ </td>`).join('');
3746
+ return `<tr><th scope="row"><code>${escHtml(res)}</code></th>${cells}</tr>`;
3747
+ }).join('');
3748
+ gridEl.innerHTML = `<table class="pol-matrix"><thead><tr><th>Resource</th>${headerCells}</tr></thead><tbody>${bodyRows}</tbody></table>`;
3749
+ }
3750
+ document.getElementById('pol-role-author-modal').classList.add('open');
3751
+ setTimeout(() => nameEl && nameEl.focus(), 50);
3752
+ }
3753
+ async function polRoleAuthorSave() {
3754
+ const nameEl = document.getElementById('pol-role-author-name');
3755
+ const errEl = document.getElementById('pol-role-author-error');
3756
+ const setErr = (msg) => { if (errEl) { errEl.textContent = msg; errEl.style.display = ''; } };
3757
+ if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
3758
+ const name = (nameEl && nameEl.value || '').trim();
3759
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name)) {
3760
+ setErr('Role name must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}.');
3761
+ nameEl && nameEl.focus();
3762
+ return;
3763
+ }
3764
+ const checks = Array.from(document.querySelectorAll('#pol-role-author-grid input[type=checkbox]:checked'));
3765
+ const perms = checks.map((el) => ({
3766
+ resource: el.getAttribute('data-pol-resource'),
3767
+ action: el.getAttribute('data-pol-action'),
3768
+ })).filter((p) => p.resource && p.action);
3769
+ try {
3770
+ const r = await fetch('/api/policy/roles/' + encodeURIComponent(name), {
3771
+ method: 'PUT',
3772
+ headers: { 'Content-Type': 'application/json' },
3773
+ body: JSON.stringify({ permissions: perms }),
3774
+ });
3775
+ if (!r.ok) {
3776
+ const j = await r.json().catch(() => ({}));
3777
+ setErr(j.error || ('HTTP ' + r.status));
3778
+ return;
3779
+ }
3780
+ closeModal('pol-role-author-modal');
3781
+ toast('Saved role ' + name);
3782
+ // Re-fetch the policy snapshot so the Roles list + matrix
3783
+ // reflect the change without a page reload.
3784
+ POL_SUBJECTS = null;
3785
+ POL_SELECTED_ROLE = name;
3786
+ await loadPolicies();
3787
+ } catch (e) {
3788
+ setErr('Save failed: ' + (e && e.message || e));
3789
+ }
3790
+ }
3791
+
3792
+ async function loadPolicies() {
2025
3793
  const rolesEl = document.getElementById('pol-roles');
2026
3794
  try {
2027
3795
  const r = await fetch('/api/policy');
2028
3796
  if (!r.ok) {
2029
- rolesEl.innerHTML = '<div class="empty">Policy view requires the <code>users:delete</code> permission (admin role).</div>';
2030
- engineEl.textContent = '';
3797
+ if (rolesEl) rolesEl.innerHTML = '';
3798
+ const listEl = document.getElementById('pol-role-list');
3799
+ const detailEl = document.getElementById('pol-role-detail');
3800
+ const msg = '<div class="empty">Policy view requires the <code>users:delete</code> permission (admin role).</div>';
3801
+ if (listEl) listEl.innerHTML = msg;
3802
+ if (detailEl) detailEl.innerHTML = '';
3803
+ const engineEl = document.getElementById('pol-engine');
3804
+ if (engineEl) engineEl.textContent = '';
3805
+ const banner = document.getElementById('pol-engine-banner');
3806
+ if (banner) banner.hidden = true;
3807
+ // Clear the body attribute so a stale value from a prior session
3808
+ // (e.g. logged-out tab refresh) doesn't keep authoring controls
3809
+ // dimmed under the wrong engine assumption.
3810
+ document.body.removeAttribute('data-policy-engine');
2031
3811
  return;
2032
3812
  }
2033
3813
  const j = await r.json();
2034
- engineEl.textContent = 'engine: ' + (j.engine || 'unknown');
2035
- engineEl.className = 'badge ' + (j.engine === 'builtin' ? 'badge-ok' : '');
2036
- const blocks = [];
2037
- for (const role of (j.roles || [])) {
2038
- const grants = (j.policy && j.policy[role]) || [];
2039
- // Group grants by resource for compact rendering.
2040
- const byRes = {};
2041
- for (const g of grants) {
2042
- (byRes[g.resource] = byRes[g.resource] || []).push(g.action);
3814
+ polRenderEngineBanner(j);
3815
+ POL_SNAPSHOT = j;
3816
+ // Invalidate subjects cache on each policy reload so the next
3817
+ // Subjects tab visit fetches fresh data (env vars / users file
3818
+ // may have changed since last visit).
3819
+ POL_SUBJECTS = null;
3820
+ // Stale overlay selection points at a subject that may no longer
3821
+ // exist (subjects cache will be repopulated on next visit)
3822
+ // safest to drop it on every policy reload.
3823
+ POL_EFFECTIVE_SUBJECT = null;
3824
+ // Default sub-tab = Roles.
3825
+ if (!document.querySelector('.pol-subtab[data-active="true"]')) {
3826
+ polSetTab('roles');
3827
+ }
3828
+ polRolesRender();
3829
+ // Warm the subjects cache in the background so the effective-
3830
+ // permissions selector has options the first time an operator
3831
+ // opens it (no fetch-on-focus jank). Failure is silent — the
3832
+ // selector falls back to its empty-state copy.
3833
+ polEffectiveEnsureSubjects().then(() => {
3834
+ if (POL_SUBJECTS && document.getElementById('pol-effective-subject')) polRolesRender();
3835
+ });
3836
+ // Legacy snapshot card kept hidden — the matrix supersedes it.
3837
+ if (rolesEl) {
3838
+ const blocks = [];
3839
+ for (const role of (j.roles || [])) {
3840
+ const grants = (j.policy && j.policy[role]) || [];
3841
+ const byRes = {};
3842
+ for (const g of grants) {
3843
+ (byRes[g.resource] = byRes[g.resource] || []).push(g.action);
3844
+ }
3845
+ const rows = Object.entries(byRes).map(([res, actions]) =>
3846
+ `<tr><td><code>${escHtml(res)}</code></td><td>${actions.map(a => `<span class="pill">${escHtml(a)}</span>`).join(' ')}</td></tr>`
3847
+ ).join('');
3848
+ blocks.push(`
3849
+ <div style="padding: var(--sp-3) var(--sp-4)">
3850
+ <h3 style="margin: 0 0 var(--sp-2); display:flex; gap: var(--sp-2); align-items:baseline;">
3851
+ <span>${escHtml(role)}</span>
3852
+ <span class="t-sm" style="opacity:.7">${grants.length} permission${grants.length===1?'':'s'}</span>
3853
+ </h3>
3854
+ <table class="data-table" style="width:100%"><thead><tr><th>Resource</th><th>Actions</th></tr></thead><tbody>${rows || '<tr><td colspan="2" class="empty">no grants</td></tr>'}</tbody></table>
3855
+ </div>`);
2043
3856
  }
2044
- const rows = Object.entries(byRes).map(([res, actions]) =>
2045
- `<tr><td><code>${escHtml(res)}</code></td><td>${actions.map(a => `<span class="pill">${escHtml(a)}</span>`).join(' ')}</td></tr>`
2046
- ).join('');
2047
- blocks.push(`
2048
- <div style="padding: var(--sp-3) var(--sp-4)">
2049
- <h3 style="margin: 0 0 var(--sp-2); display:flex; gap: var(--sp-2); align-items:baseline;">
2050
- <span>${escHtml(role)}</span>
2051
- <span class="t-sm" style="opacity:.7">${grants.length} permission${grants.length===1?'':'s'}</span>
2052
- </h3>
2053
- <table class="data-table" style="width:100%"><thead><tr><th>Resource</th><th>Actions</th></tr></thead><tbody>${rows || '<tr><td colspan="2" class="empty">no grants</td></tr>'}</tbody></table>
2054
- </div>`);
2055
- }
2056
- rolesEl.innerHTML = blocks.join('') || '<div class="empty">No roles defined.</div>';
2057
- if (j.note) {
2058
- rolesEl.insertAdjacentHTML('beforeend', `<div class="form-hint" style="padding: var(--sp-2) var(--sp-4) var(--sp-3)">${escHtml(j.note)}</div>`);
3857
+ rolesEl.innerHTML = blocks.join('') || '<div class="empty">No roles defined.</div>';
2059
3858
  }
2060
3859
  } catch (e) {
2061
- rolesEl.innerHTML = '<div class="empty">Policy unavailable: ' + escHtml(String(e && e.message || e)) + '</div>';
3860
+ const msg = '<div class="empty">Policy unavailable: ' + escHtml(String(e && e.message || e)) + '</div>';
3861
+ if (rolesEl) rolesEl.innerHTML = msg;
3862
+ const listEl = document.getElementById('pol-role-list');
3863
+ const detailEl = document.getElementById('pol-role-detail');
3864
+ if (listEl) listEl.innerHTML = msg;
3865
+ if (detailEl) detailEl.innerHTML = '';
2062
3866
  }
2063
3867
  }
2064
3868
  async function polDryRun() {
@@ -2066,24 +3870,28 @@ async function polDryRun() {
2066
3870
  const roles = document.getElementById('pol-dry-roles').value.trim();
2067
3871
  const resource = document.getElementById('pol-dry-resource').value.trim();
2068
3872
  const action = document.getElementById('pol-dry-action').value.trim();
3873
+ const tenant = document.getElementById('pol-dry-tenant').value.trim();
2069
3874
  if (!resource || !action) {
2070
3875
  out.innerHTML = '<div class="form-banner show error" style="margin-top: var(--sp-3)">Resource and action are required.</div>';
2071
3876
  return;
2072
3877
  }
2073
3878
  const params = new URLSearchParams({ resource, action });
2074
3879
  if (roles) params.set('roles', roles);
3880
+ if (tenant) params.set('tenant', tenant);
2075
3881
  try {
2076
3882
  const r = await fetch('/api/policy?' + params.toString());
2077
3883
  const j = await r.json();
2078
3884
  const d = j.dryRun || {};
2079
- const cls = d.allowed ? 'badge-ok' : 'badge-err';
2080
3885
  const verdict = d.allowed ? 'allowed' : 'denied';
3886
+ const cls = d.allowed ? 'allow' : 'deny';
3887
+ const tenantTag = d.tenant ? ` <span class="tag t-sm">tenant: ${escHtml(d.tenant)}</span>` : '';
2081
3888
  out.innerHTML = `
2082
- <div style="margin-top: var(--sp-3); display:flex; gap: var(--sp-2); align-items:center;">
2083
- <span class="badge ${cls}">${verdict}</span>
3889
+ <div class="pol-probe-result">
3890
+ <span class="pol-pv ${cls}" data-verdict="${verdict}">${verdict}</span>
2084
3891
  <code>${escHtml(Array.isArray(d.roles) ? d.roles.join(',') : '')} → ${escHtml(resource)}:${escHtml(action)}</code>
3892
+ ${tenantTag}
2085
3893
  </div>
2086
- <div class="form-hint" style="margin-top: var(--sp-2)">${escHtml(d.reason || '')}</div>`;
3894
+ <div class="form-hint" style="margin-top: var(--sp-1)">${escHtml(d.reason || '')}</div>`;
2087
3895
  } catch (e) {
2088
3896
  out.innerHTML = '<div class="form-banner show error" style="margin-top: var(--sp-3)">Probe failed: ' + escHtml(String(e && e.message || e)) + '</div>';
2089
3897
  }
@@ -2112,7 +3920,29 @@ function toggleTheme(){
2112
3920
  const _omcpRawFetch = window.fetch.bind(window);
2113
3921
  let _omcpLoginInflight = null;
2114
3922
  let _omcpLoginResolve = null;
3923
+ // CSRF double-submit: the server issues a non-HttpOnly omcp-csrf
3924
+ // cookie and enforces that mutating /api requests echo it back in
3925
+ // X-CSRF-Token. The browser sends the cookie automatically but never
3926
+ // the header — so this wrapper reads the cookie and injects the
3927
+ // matching header on every state-changing request. Safe methods and
3928
+ // cross-origin requests are left untouched.
3929
+ function _omcpCsrfToken() {
3930
+ const m = document.cookie.match(/(?:^|;\s*)omcp-csrf=([^;]+)/);
3931
+ return m ? decodeURIComponent(m[1]) : null;
3932
+ }
3933
+ function _omcpWithCsrf(input, init) {
3934
+ const method = ((init && init.method) || (typeof input === 'object' && input.method) || 'GET').toUpperCase();
3935
+ if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return init;
3936
+ const token = _omcpCsrfToken();
3937
+ if (!token) return init;
3938
+ const next = Object.assign({}, init);
3939
+ const headers = new Headers((init && init.headers) || (typeof input === 'object' && input.headers) || {});
3940
+ if (!headers.has('X-CSRF-Token')) headers.set('X-CSRF-Token', token);
3941
+ next.headers = headers;
3942
+ return next;
3943
+ }
2115
3944
  window.fetch = async function omcpAuthedFetch(input, init) {
3945
+ init = _omcpWithCsrf(input, init);
2116
3946
  const res = await _omcpRawFetch(input, init);
2117
3947
  if (res.status !== 401) return res;
2118
3948
  let body = null;
@@ -2299,10 +4129,21 @@ async function omcpCheckGovernance() {
2299
4129
  const info = await r.json();
2300
4130
  const g = info && info.governance;
2301
4131
  if (!g) return;
4132
+ const warns = [];
2302
4133
  if (g.authMode === 'basic' && g.authSecretEphemeral) {
4134
+ warns.push('OMCP_SESSION_SECRET is not set — every sign-in will be lost on server restart. Set a stable value in production.');
4135
+ }
4136
+ // P9: plugin signature verification is off — filesystem plugins
4137
+ // load without integrity checks. Production deployments should
4138
+ // turn this back on (VERIFY_PLUGINS=true is the default since
4139
+ // F1, so seeing this banner means an operator opted OUT).
4140
+ if (g.pluginsVerified === false) {
4141
+ warns.push('Plugin signature verification is OFF (VERIFY_PLUGINS=false). Any filesystem plugin can load unchecked. Re-enable for production.');
4142
+ }
4143
+ if (warns.length > 0) {
2303
4144
  const bar = document.getElementById('omcp-warning-bar');
2304
4145
  if (bar) {
2305
- bar.textContent = '⚠ OMCP_SESSION_SECRET is not set — every sign-in will be lost on server restart. Set a stable value in production.';
4146
+ bar.textContent = '⚠ ' + warns.join(' · ');
2306
4147
  bar.style.display = '';
2307
4148
  }
2308
4149
  }
@@ -3073,14 +4914,261 @@ function closeModal(id) { document.getElementById(id).classList.remove('open');
3073
4914
  // --- Data Loading ---
3074
4915
  async function loadSources() { try { sourcesData=await(await fetch('/api/sources')).json(); renderSources(); updateStats(); } catch(e){} }
3075
4916
  // --- MCP Products (new /api/products surface) ---
4917
+ // View mode (cards | table) for the Products catalog. Persisted in
4918
+ // localStorage so an operator's preference survives reloads.
4919
+ function mcpProductsView() {
4920
+ try { return localStorage.getItem('omcp-mcp-products-view') === 'table' ? 'table' : 'cards'; }
4921
+ catch (e) { return 'cards'; }
4922
+ }
4923
+ function mcpProductsSetView(v) {
4924
+ try { localStorage.setItem('omcp-mcp-products-view', v); } catch (e) { /* noop */ }
4925
+ loadMcpProducts();
4926
+ }
4927
+
4928
+ // Legacy-catalog disclosure: persist open/closed so an operator who
4929
+ // actually uses the deprecated catalog isn't re-expanding it every
4930
+ // visit. Default closed (the <details> has no `open` attribute).
4931
+ function pgLegacyPersist(el) {
4932
+ try { localStorage.setItem('omcp-legacy-catalog-open', el.open ? '1' : '0'); } catch (e) { /* noop */ }
4933
+ }
4934
+ function pgLegacyRestore() {
4935
+ const el = document.getElementById('ent-legacy');
4936
+ if (!el) return;
4937
+ try { el.open = localStorage.getItem('omcp-legacy-catalog-open') === '1'; } catch (e) { /* noop */ }
4938
+ }
4939
+
4940
+ // Cache the tool registry — fetched once on first modal open and
4941
+ // reused thereafter. Falsy means not-yet-loaded; an empty array
4942
+ // means the endpoint replied but with no tools (server bug — we
4943
+ // fall back to a textarea-style entry in that case).
4944
+ let MCP_TOOLS_REGISTRY = null;
4945
+ async function mcpLoadToolsRegistry() {
4946
+ if (MCP_TOOLS_REGISTRY) return MCP_TOOLS_REGISTRY;
4947
+ try {
4948
+ const r = await fetch('/api/tools/registry');
4949
+ if (!r.ok) throw new Error('HTTP ' + r.status);
4950
+ const j = await r.json();
4951
+ MCP_TOOLS_REGISTRY = Array.isArray(j.tools) ? j.tools : [];
4952
+ return MCP_TOOLS_REGISTRY;
4953
+ } catch (e) {
4954
+ // Surface but don't break the modal — fall back to []
4955
+ // (operator can still hand-edit via the textarea fallback).
4956
+ MCP_TOOLS_REGISTRY = [];
4957
+ return MCP_TOOLS_REGISTRY;
4958
+ }
4959
+ }
4960
+ function mcpRenderToolsPicker(selected) {
4961
+ const picker = document.getElementById('mcp-product-tools-picker');
4962
+ const textarea = document.getElementById('mcp-product-tools');
4963
+ if (!picker || !textarea) return;
4964
+ const tools = MCP_TOOLS_REGISTRY || [];
4965
+ if (tools.length === 0) {
4966
+ // Fallback: expose the textarea so the operator can still author
4967
+ // by hand. The server-side typo guard catches mistakes either way.
4968
+ textarea.hidden = false;
4969
+ textarea.value = (selected || []).join('\n');
4970
+ picker.innerHTML = '<div class="tools-picker-hint">Tool registry unavailable — type names one per line.</div>';
4971
+ return;
4972
+ }
4973
+ const selSet = new Set(selected || []);
4974
+ // Group by category, ordered. Keep an "(other)" bucket for any
4975
+ // future category we forgot in the CSS.
4976
+ const order = ['discovery', 'query', 'diagnose', 'topology'];
4977
+ const groups = {};
4978
+ for (const t of tools) (groups[t.category] = groups[t.category] || []).push(t);
4979
+ const labels = { discovery: 'Discovery', query: 'Query', diagnose: 'Diagnose', topology: 'Topology' };
4980
+ const html = order.filter((c) => groups[c]).map((cat) => {
4981
+ const items = groups[cat].map((t) => {
4982
+ const checked = selSet.has(t.name) ? 'checked' : '';
4983
+ return `<label class="tp-item">
4984
+ <input type="checkbox" data-tool="${escHtml(t.name)}" ${checked} onchange="mcpToolsPickerSync()">
4985
+ <span class="tp-text">
4986
+ <span class="tp-name">${escHtml(t.name)}</span>
4987
+ <span class="tp-summary">${escHtml(t.summary)}</span>
4988
+ </span>
4989
+ </label>`;
4990
+ }).join('');
4991
+ return `<div class="tp-group">
4992
+ <div class="tp-cat">${escHtml(labels[cat] || cat)}</div>
4993
+ ${items}
4994
+ </div>`;
4995
+ }).join('');
4996
+ picker.innerHTML = `
4997
+ <div class="tp-actions">
4998
+ <button type="button" class="btn btn-ghost" onclick="mcpToolsPickerAll(true)">Select all</button>
4999
+ <button type="button" class="btn btn-ghost" onclick="mcpToolsPickerAll(false)">Clear</button>
5000
+ </div>
5001
+ ${html}
5002
+ `;
5003
+ // Keep the hidden textarea in sync with the initial selection.
5004
+ textarea.value = (selected || []).join('\n');
5005
+ }
5006
+ function mcpToolsPickerSync() {
5007
+ const picker = document.getElementById('mcp-product-tools-picker');
5008
+ const textarea = document.getElementById('mcp-product-tools');
5009
+ if (!picker || !textarea) return;
5010
+ const checked = Array.from(picker.querySelectorAll('input[type=checkbox][data-tool]:checked'))
5011
+ .map((el) => el.getAttribute('data-tool'))
5012
+ .filter(Boolean);
5013
+ textarea.value = checked.join('\n');
5014
+ }
5015
+ function mcpToolsPickerAll(on) {
5016
+ const picker = document.getElementById('mcp-product-tools-picker');
5017
+ if (!picker) return;
5018
+ picker.querySelectorAll('input[type=checkbox][data-tool]').forEach((el) => { el.checked = on; });
5019
+ mcpToolsPickerSync();
5020
+ }
5021
+
5022
+ // Empty-state product templates. Cloning a template prefills the
5023
+ // modal — the operator only has to pick an id + tweak before save.
5024
+ // Templates are pure UI; the server doesn't know about them.
5025
+ const MCP_PRODUCT_TEMPLATES = {
5026
+ ops: {
5027
+ title: 'Ops Bundle',
5028
+ desc: 'Incident-response tools for an on-call SRE agent.',
5029
+ product: {
5030
+ id: '', name: 'Ops Bundle',
5031
+ description: 'Incident-response tools for the on-call SRE agent.',
5032
+ status: 'staging',
5033
+ tools: ['query_logs', 'query_metrics', 'get_service_health', 'detect_anomalies'],
5034
+ },
5035
+ },
5036
+ dev: {
5037
+ title: 'Dev Bundle',
5038
+ desc: 'Discovery + topology tools for a coding agent. Read-only.',
5039
+ product: {
5040
+ id: '', name: 'Dev Bundle',
5041
+ description: 'Discovery + topology tools for a coding agent. Read-only.',
5042
+ status: 'staging',
5043
+ tools: ['list_sources', 'list_services', 'get_topology'],
5044
+ },
5045
+ },
5046
+ compliance: {
5047
+ title: 'Compliance Bundle',
5048
+ desc: 'Logs query only — for an audit agent.',
5049
+ product: {
5050
+ id: '', name: 'Compliance Bundle',
5051
+ description: 'Logs query only — for an audit agent.',
5052
+ status: 'staging',
5053
+ tools: ['query_logs'],
5054
+ },
5055
+ },
5056
+ blank: {
5057
+ title: 'Blank',
5058
+ desc: 'Start from scratch.',
5059
+ product: { id: '', name: '', status: 'staging' },
5060
+ },
5061
+ };
5062
+ function mcpProductTemplate(key) {
5063
+ const t = MCP_PRODUCT_TEMPLATES[key];
5064
+ if (!t) return;
5065
+ mcpProductOpen('new', t.product);
5066
+ }
5067
+
5068
+ function mcpProductCardHtml(p) {
5069
+ const accent = (p.branding && typeof p.branding.color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(p.branding.color))
5070
+ ? p.branding.color : 'var(--accent)';
5071
+ const icon = (p.branding && typeof p.branding.iconUrl === 'string' && /^https?:\/\//.test(p.branding.iconUrl))
5072
+ ? `<img src="${escHtml(p.branding.iconUrl)}" alt="" onerror="this.style.display='none'">`
5073
+ : '◫';
5074
+ const status = p.status === 'staging'
5075
+ ? '<span class="pill" style="background:var(--warning-soft);color:var(--warning)" title="Hidden from non-admin agents">staging</span>'
5076
+ : '<span class="pill" style="background:var(--success-soft);color:var(--success)">published</span>';
5077
+ const meta = [p.id, p.version ? 'v' + p.version : null, 'tenant: ' + (p.tenant || 'default')]
5078
+ .filter(Boolean).map(escHtml).join(' · ');
5079
+ const tools = (p.tools || []);
5080
+ const toolsLabel = tools.length === 0
5081
+ ? '<span class="pcard-tool-all">no filter — every registered tool</span>'
5082
+ : tools.slice(0, 8).map((t) => `<span class="pcard-tool">${escHtml(t)}</span>`).join('') +
5083
+ (tools.length > 8 ? `<span class="pcard-tool">+${tools.length - 8} more</span>` : '');
5084
+ const desc = p.description
5085
+ ? escHtml(p.description)
5086
+ : '<span style="opacity:.5">No description.</span>';
5087
+ const idAttr = encodeURIComponent(p.id);
5088
+ return `<div class="pcard">
5089
+ <div class="pcard-rail" style="background:${accent}"></div>
5090
+ <div class="pcard-hd">
5091
+ <div class="pcard-icon" style="color:${accent}">${icon}</div>
5092
+ <div class="pcard-title">
5093
+ <h3>${escHtml(p.name)}</h3>
5094
+ <div class="pcard-meta">${meta}</div>
5095
+ </div>
5096
+ <div class="pcard-status">${status}</div>
5097
+ </div>
5098
+ <div class="pcard-desc">${desc}</div>
5099
+ <div>
5100
+ <div class="pcard-tools-label">Tools (${tools.length || 'unrestricted'})</div>
5101
+ <div class="pcard-tools">${toolsLabel}</div>
5102
+ </div>
5103
+ <div class="pcard-footer">
5104
+ <button class="btn btn-ghost btn-sm" title="Preview as agent" aria-label="Preview ${escHtml(p.id)} as agent" onclick="mcpProductPreviewAgent('${idAttr}')">Preview as agent</button>
5105
+ <div class="pcard-spacer"></div>
5106
+ <button class="btn-icon" data-rbac="products:write" title="Edit" aria-label="Edit ${escHtml(p.id)}" onclick="mcpProductsEdit('${idAttr}')">&#9998;</button>
5107
+ <button class="btn-icon" data-rbac="products:delete" title="Delete" aria-label="Delete ${escHtml(p.id)}" onclick="mcpProductsDelete('${idAttr}')">&#128465;</button>
5108
+ </div>
5109
+ </div>`;
5110
+ }
5111
+
5112
+ function mcpProductTableHtml(products) {
5113
+ const rows = products.map((p) => {
5114
+ const status = p.status === 'staging'
5115
+ ? '<span class="pill" style="background:var(--warning-soft);color:var(--warning)">staging</span>'
5116
+ : '<span class="pill" style="background:var(--success-soft);color:var(--success)">published</span>';
5117
+ const tools = (p.tools || []).slice(0, 5).map((t) => `<code class="t-sm">${escHtml(t)}</code>`).join(' ');
5118
+ const moreTools = (p.tools || []).length > 5 ? ` <span class="t-sm" style="opacity:.7">+${(p.tools.length - 5)} more</span>` : '';
5119
+ const tenantCell = `<span class="tag">${escHtml(p.tenant || 'default')}</span>`;
5120
+ return `<tr>
5121
+ <td><code>${escHtml(p.id)}</code></td>
5122
+ <td>${escHtml(p.name)}${p.description ? `<div class="t-sm" style="opacity:.7">${escHtml(p.description)}</div>` : ''}</td>
5123
+ <td>${tenantCell}</td>
5124
+ <td>${status}</td>
5125
+ <td>${tools || '<span class="t-sm" style="opacity:.5">all tools</span>'}${moreTools}</td>
5126
+ <td style="text-align:right">
5127
+ <button class="btn-icon" data-rbac="products:write" title="Edit" onclick="mcpProductsEdit('${encodeURIComponent(p.id)}')">&#9998;</button>
5128
+ <button class="btn-icon" data-rbac="products:delete" title="Delete" onclick="mcpProductsDelete('${encodeURIComponent(p.id)}')">&#128465;</button>
5129
+ </td>
5130
+ </tr>`;
5131
+ }).join('');
5132
+ return `<table class="data-table" style="width:100%">
5133
+ <thead><tr><th>id</th><th>Name</th><th>Tenant</th><th>Status</th><th>Tools</th><th></th></tr></thead>
5134
+ <tbody>${rows}</tbody>
5135
+ </table>`;
5136
+ }
5137
+
5138
+ function mcpProductEmptyHtml(configured) {
5139
+ const tpls = ['ops', 'dev', 'compliance', 'blank']
5140
+ .map((k) => {
5141
+ const t = MCP_PRODUCT_TEMPLATES[k];
5142
+ return `<button class="pempty-tpl" data-rbac="products:write" onclick="mcpProductTemplate('${k}')">
5143
+ <div class="pempty-tpl-title">${escHtml(t.title)}</div>
5144
+ <div class="pempty-tpl-desc">${escHtml(t.desc)}</div>
5145
+ </button>`;
5146
+ }).join('');
5147
+ const persistedHint = configured
5148
+ ? 'Changes here persist to <code>OMCP_PRODUCTS_FILE</code>.'
5149
+ : '<code>OMCP_PRODUCTS_FILE</code> is unset — changes live in memory only and won\'t survive a restart.';
5150
+ return `<div class="pempty">
5151
+ <h3>No products yet</h3>
5152
+ <p>A product is a curated tool bundle an agent receives when it connects with a bound credential. Pick a template below or start blank — you can rename and tweak everything before saving.</p>
5153
+ <div class="pempty-templates" data-rbac="products:write">${tpls}</div>
5154
+ <p class="t-sm" style="margin-top:var(--sp-4);opacity:.7">${persistedHint}</p>
5155
+ </div>`;
5156
+ }
5157
+
3076
5158
  async function loadMcpProducts() {
5159
+ pgLegacyRestore();
3077
5160
  const box = document.getElementById('mcp-products-box');
3078
5161
  const scope = document.getElementById('mcp-products-scope');
3079
5162
  if (!box) return;
5163
+ // Sync view-toggle active state.
5164
+ const v = mcpProductsView();
5165
+ const btnC = document.getElementById('mcp-pv-cards');
5166
+ const btnT = document.getElementById('mcp-pv-table');
5167
+ if (btnC) btnC.classList.toggle('active', v === 'cards');
5168
+ if (btnT) btnT.classList.toggle('active', v === 'table');
3080
5169
  try {
3081
5170
  const r = await fetch('/api/products');
3082
5171
  if (!r.ok) {
3083
- // 403 = no products:read; render a friendly hint instead of an empty list
3084
5172
  if (r.status === 403) {
3085
5173
  box.innerHTML = '<div class="empty">Requires <code>products:read</code> permission (granted to viewer / operator / admin by default).</div>';
3086
5174
  } else {
@@ -3094,59 +5182,312 @@ async function loadMcpProducts() {
3094
5182
  scope.textContent = 'scope: ' + s + (j.includesStaging ? ' · staging visible' : '');
3095
5183
  }
3096
5184
  if (!j.products || j.products.length === 0) {
3097
- const hint = j.configured
3098
- ? 'No products yet. Click <b>+ New product</b> or edit <code>OMCP_PRODUCTS_FILE</code> directly.'
3099
- : 'Set <code>OMCP_PRODUCTS_FILE=&lt;path&gt;</code> on the server (or use <b>+ New product</b> to start an in-memory catalog).';
3100
- box.innerHTML = '<div class="empty">' + hint + '</div>';
5185
+ box.innerHTML = mcpProductEmptyHtml(j.configured);
5186
+ omcpApplyRbacToDom();
3101
5187
  return;
3102
5188
  }
3103
- const rows = j.products.map((p) => {
3104
- const status = p.status === 'staging'
3105
- ? '<span class="pill" style="background:var(--warning-soft);color:var(--warning)">staging</span>'
3106
- : '<span class="pill" style="background:var(--success-soft);color:var(--success)">published</span>';
3107
- const tools = (p.tools || []).slice(0, 5).map((t) => `<code class="t-sm">${escHtml(t)}</code>`).join(' ');
3108
- const moreTools = (p.tools || []).length > 5 ? ` <span class="t-sm" style="opacity:.7">+${(p.tools.length - 5)} more</span>` : '';
3109
- const tenantCell = `<span class="tag">${escHtml(p.tenant || 'default')}</span>`;
3110
- return `<tr>
3111
- <td><code>${escHtml(p.id)}</code></td>
3112
- <td>${escHtml(p.name)}${p.description ? `<div class="t-sm" style="opacity:.7">${escHtml(p.description)}</div>` : ''}</td>
3113
- <td>${tenantCell}</td>
3114
- <td>${status}</td>
3115
- <td>${tools || '<span class="t-sm" style="opacity:.5">all tools</span>'}${moreTools}</td>
3116
- <td style="text-align:right">
3117
- <button class="btn-icon" data-rbac="products:write" title="Edit" onclick="mcpProductsEdit('${encodeURIComponent(p.id)}')">&#9998;</button>
3118
- <button class="btn-icon" data-rbac="products:delete" title="Delete" onclick="mcpProductsDelete('${encodeURIComponent(p.id)}')">&#128465;</button>
3119
- </td>
3120
- </tr>`;
3121
- }).join('');
3122
- box.innerHTML = `<table class="data-table" style="width:100%">
3123
- <thead><tr><th>id</th><th>Name</th><th>Tenant</th><th>Status</th><th>Tools</th><th></th></tr></thead>
3124
- <tbody>${rows}</tbody>
3125
- </table>`;
5189
+ if (v === 'table') {
5190
+ box.innerHTML = mcpProductTableHtml(j.products);
5191
+ } else {
5192
+ box.innerHTML = `<div class="pcard-grid">${j.products.map(mcpProductCardHtml).join('')}</div>`;
5193
+ }
3126
5194
  omcpApplyRbacToDom();
3127
5195
  } catch (e) {
3128
5196
  box.innerHTML = '<div class="empty">/api/products unavailable: ' + escHtml(String(e && e.message || e)) + '</div>';
3129
5197
  }
3130
5198
  }
3131
- async function mcpProductsNew() {
3132
- const id = (window.prompt('New product id (lowercase, [a-z0-9._-]):') || '').trim();
3133
- if (!id) return;
3134
- const name = (window.prompt('Display name:') || '').trim();
3135
- if (!name) return;
3136
- await mcpProductsUpsert(id, { id, name, status: 'staging' });
5199
+ // Form-driven Product modal — replaces the old chain of window.prompt
5200
+ // dialogs. Exposes id + name + description + status + tenant + tools
5201
+ // + version + branding in one place, validated client-side before
5202
+ // the server's strict parser sees it.
5203
+ // Wizard navigation state — current step (1..4) for the active
5204
+ // modal session. Reset on every mcpProductOpen call.
5205
+ let MCP_WIZ_STEP = 1;
5206
+ const MCP_WIZ_LAST = 4;
5207
+
5208
+ function mcpWizGoto(step) {
5209
+ if (step < 1 || step > MCP_WIZ_LAST) return;
5210
+ // Validate the current step before allowing forward navigation —
5211
+ // backwards / sidewards navigation always allowed (the operator
5212
+ // may want to revise).
5213
+ if (step > MCP_WIZ_STEP && !mcpWizValidateStep(MCP_WIZ_STEP)) return;
5214
+ MCP_WIZ_STEP = step;
5215
+ mcpWizRender();
5216
+ }
5217
+ function mcpWizNext() {
5218
+ if (MCP_WIZ_STEP < MCP_WIZ_LAST) mcpWizGoto(MCP_WIZ_STEP + 1);
5219
+ }
5220
+ function mcpWizBack() {
5221
+ if (MCP_WIZ_STEP > 1) mcpWizGoto(MCP_WIZ_STEP - 1);
5222
+ }
5223
+ function mcpWizValidateStep(step) {
5224
+ const errEl = document.getElementById('mcp-product-error');
5225
+ errEl.style.display = 'none';
5226
+ errEl.textContent = '';
5227
+ if (step === 1) {
5228
+ const id = document.getElementById('mcp-product-id').value.trim();
5229
+ const name = document.getElementById('mcp-product-name').value.trim();
5230
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(id)) {
5231
+ errEl.textContent = 'Id must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}.';
5232
+ errEl.style.display = '';
5233
+ document.getElementById('mcp-product-id').focus();
5234
+ return false;
5235
+ }
5236
+ if (!name) {
5237
+ errEl.textContent = 'Display name is required.';
5238
+ errEl.style.display = '';
5239
+ document.getElementById('mcp-product-name').focus();
5240
+ return false;
5241
+ }
5242
+ }
5243
+ // Step 2 (tools) + step 3 (scope/branding) have no required fields.
5244
+ return true;
5245
+ }
5246
+ function mcpWizRender() {
5247
+ // Toggle pane visibility.
5248
+ for (let i = 1; i <= MCP_WIZ_LAST; i++) {
5249
+ const pane = document.getElementById('wiz-pane-' + i);
5250
+ if (pane) pane.hidden = i !== MCP_WIZ_STEP;
5251
+ }
5252
+ // Update stepper bullets — active = current, done = all earlier.
5253
+ const stepper = document.getElementById('mcp-wiz-stepper');
5254
+ if (stepper) {
5255
+ stepper.querySelectorAll('.wiz-step-btn').forEach((btn) => {
5256
+ const n = parseInt(btn.getAttribute('data-step') || '0', 10);
5257
+ btn.setAttribute('data-active', String(n === MCP_WIZ_STEP));
5258
+ btn.setAttribute('data-done', String(n < MCP_WIZ_STEP));
5259
+ btn.setAttribute('aria-selected', String(n === MCP_WIZ_STEP));
5260
+ });
5261
+ }
5262
+ // Footer button visibility.
5263
+ const back = document.getElementById('mcp-wiz-back');
5264
+ const next = document.getElementById('mcp-wiz-next');
5265
+ const save = document.getElementById('mcp-wiz-save');
5266
+ if (back) back.hidden = MCP_WIZ_STEP === 1;
5267
+ if (next) next.hidden = MCP_WIZ_STEP === MCP_WIZ_LAST;
5268
+ if (save) save.hidden = MCP_WIZ_STEP !== MCP_WIZ_LAST;
5269
+ // Render the review summary lazily when entering step 4.
5270
+ if (MCP_WIZ_STEP === MCP_WIZ_LAST) mcpWizRenderReview();
5271
+ }
5272
+ function mcpWizRenderReview() {
5273
+ const out = document.getElementById('mcp-wiz-review');
5274
+ if (!out) return;
5275
+ const v = (id) => (document.getElementById(id).value || '').trim();
5276
+ const id = v('mcp-product-id');
5277
+ const name = v('mcp-product-name');
5278
+ const desc = v('mcp-product-description');
5279
+ const status = v('mcp-product-status');
5280
+ const tenant = v('mcp-product-tenant');
5281
+ const version = v('mcp-product-version');
5282
+ const color = v('mcp-product-color');
5283
+ const icon = v('mcp-product-icon');
5284
+ const toolsRaw = (document.getElementById('mcp-product-tools').value || '');
5285
+ const tools = toolsRaw.split(/[\n,]+/).map((s) => s.trim()).filter(Boolean);
5286
+ const empty = (s) => s ? escHtml(s) : '<span class="wiz-review-empty">—</span>';
5287
+ // Mirror mcpProductCardHtml's colour-validation regex so the
5288
+ // inline style attribute can't carry an arbitrary CSS value. Even
5289
+ // though escHtml prevents attribute breakout, restricting the
5290
+ // value to a hex literal makes a CSS-context injection
5291
+ // structurally impossible (defence-in-depth — reviewer-agent flag).
5292
+ const safeColor = /^#[0-9a-fA-F]{3,8}$/.test(color) ? color : null;
5293
+ const colorCell = !color
5294
+ ? '<span class="wiz-review-empty">— (no brand colour)</span>'
5295
+ : safeColor
5296
+ ? `<span class="wiz-review-swatch" style="background:${safeColor}"></span><code>${escHtml(color)}</code>`
5297
+ : `<code>${escHtml(color)}</code> <span class="t-sm" style="opacity:.6">(not a hex value)</span>`;
5298
+ const toolsCell = tools.length === 0
5299
+ ? '<span class="wiz-review-empty">no filter — every registered tool</span>'
5300
+ : `<div class="wiz-review-tools">${tools.map((t) => `<span class="pcard-tool">${escHtml(t)}</span>`).join('')}</div>`;
5301
+ out.innerHTML = `<dl class="wiz-review-grid">
5302
+ <dt>Id</dt><dd><code>${empty(id)}</code></dd>
5303
+ <dt>Name</dt><dd>${empty(name)}</dd>
5304
+ <dt>Description</dt><dd>${empty(desc)}</dd>
5305
+ <dt>Status</dt><dd><span class="pill">${empty(status)}</span></dd>
5306
+ <dt>Tenant</dt><dd>${tenant ? escHtml(tenant) : '<span class="wiz-review-empty">(caller\'s tenant)</span>'}</dd>
5307
+ <dt>Tools</dt><dd>${toolsCell}</dd>
5308
+ <dt>Version</dt><dd>${empty(version)}</dd>
5309
+ <dt>Colour</dt><dd>${colorCell}</dd>
5310
+ <dt>Icon URL</dt><dd>${icon ? `<code>${escHtml(icon)}</code>` : '<span class="wiz-review-empty">—</span>'}</dd>
5311
+ </dl>
5312
+ <h4 class="wiz-agent-preview-h">Agent preview <span class="t-sm" style="opacity:.6;font-weight:400">— what the bound agent will see in <code>tools/list</code></span></h4>
5313
+ <div id="mcp-wiz-agent-preview" class="wiz-agent-preview"><div class="t-sm" style="opacity:.7">${tools.length === 0
5314
+ ? 'No filter set. Agent would see every registered tool — load the live registry to confirm.'
5315
+ : `Filter active. Agent would see ${tools.length} tool${tools.length===1?'':'s'} from the allow-list.`}</div></div>`;
5316
+ // Resolve the preview from the live tool registry — same source the
5317
+ // server uses to filter /mcp tools/list. We don't hit
5318
+ // /api/products/:id/preview here because the wizard's form-state
5319
+ // hasn't been saved yet (the id may not exist on the server). The
5320
+ // resolution mirrors allowsTool's semantics exactly.
5321
+ mcpLoadToolsRegistry().then(() => {
5322
+ const previewEl = document.getElementById('mcp-wiz-agent-preview');
5323
+ if (!previewEl) return;
5324
+ const allow = new Set(tools);
5325
+ const filtered = (MCP_TOOLS_REGISTRY || []).filter((t) => tools.length === 0 || allow.has(t.name));
5326
+ previewEl.innerHTML = mcpAgentPreviewHtml(filtered, tools.length === 0);
5327
+ });
5328
+ }
5329
+
5330
+ // Shared renderer for the agent-preview list — used by the wizard
5331
+ // Review pane and by the per-card "Preview as agent" action.
5332
+ function mcpAgentPreviewHtml(tools, unrestricted) {
5333
+ if (!tools || tools.length === 0) {
5334
+ return '<div class="t-sm" style="opacity:.7">No tools available.</div>';
5335
+ }
5336
+ const order = ['discovery', 'query', 'diagnose', 'topology'];
5337
+ const groups = {};
5338
+ for (const t of tools) (groups[t.category] = groups[t.category] || []).push(t);
5339
+ const labels = { discovery: 'Discovery', query: 'Query', diagnose: 'Diagnose', topology: 'Topology' };
5340
+ const banner = unrestricted
5341
+ ? '<div class="wiz-agent-banner" data-unrestricted="true">Unrestricted — the bound agent sees every registered tool below.</div>'
5342
+ : '';
5343
+ const html = order.filter((c) => groups[c]).map((cat) => {
5344
+ const items = groups[cat].map((t) => `
5345
+ <div class="wiz-agent-tool">
5346
+ <div class="wiz-agent-tool-name">${escHtml(t.name)}</div>
5347
+ <div class="wiz-agent-tool-summary">${escHtml(t.summary)}</div>
5348
+ </div>`).join('');
5349
+ return `<div class="wiz-agent-group">
5350
+ <div class="tp-cat">${escHtml(labels[cat] || cat)}</div>
5351
+ ${items}
5352
+ </div>`;
5353
+ }).join('');
5354
+ return banner + html;
3137
5355
  }
5356
+
5357
+ // "Preview as agent" — called from the per-card button. Hits the
5358
+ // authoritative server endpoint so the operator sees what the bound
5359
+ // agent actually receives in production, not a client-side guess.
5360
+ async function mcpProductPreviewAgent(idEnc) {
5361
+ const id = decodeURIComponent(idEnc);
5362
+ let body;
5363
+ try {
5364
+ const r = await fetch('/api/products/' + encodeURIComponent(id) + '/preview');
5365
+ if (!r.ok) { toast('Preview unavailable: HTTP ' + r.status); return; }
5366
+ body = await r.json();
5367
+ } catch (e) { toast('Preview failed: ' + (e && e.message || e)); return; }
5368
+ const accent = (body.product && body.product.branding && /^#[0-9a-fA-F]{3,8}$/.test(body.product.branding.color || ''))
5369
+ ? body.product.branding.color : 'var(--accent)';
5370
+ const dlg = document.getElementById('mcp-agent-preview-modal');
5371
+ const title = document.getElementById('mcp-agent-preview-title');
5372
+ const meta = document.getElementById('mcp-agent-preview-meta');
5373
+ const list = document.getElementById('mcp-agent-preview-list');
5374
+ if (!dlg || !title || !meta || !list) return;
5375
+ title.textContent = body.product.name || body.product.id;
5376
+ meta.innerHTML = `<code>${escHtml(body.product.id)}</code>${body.product.version ? ' · v' + escHtml(body.product.version) : ''} · tenant: ${escHtml(body.product.tenant || 'default')} · status: ${escHtml(body.product.status || 'published')}`;
5377
+ list.innerHTML = mcpAgentPreviewHtml(body.tools || [], !!body.unrestricted);
5378
+ // Apply branding to the modal's left rail for a quick at-a-glance
5379
+ // identity confirmation that the right product was probed.
5380
+ dlg.style.setProperty('--mcp-agent-accent', accent);
5381
+ dlg.classList.add('open');
5382
+ }
5383
+
5384
+ function mcpProductOpen(mode, current) {
5385
+ const idEl = document.getElementById('mcp-product-id');
5386
+ const nameEl = document.getElementById('mcp-product-name');
5387
+ const descEl = document.getElementById('mcp-product-description');
5388
+ const statusEl = document.getElementById('mcp-product-status');
5389
+ const tenantEl = document.getElementById('mcp-product-tenant');
5390
+ const toolsEl = document.getElementById('mcp-product-tools');
5391
+ const verEl = document.getElementById('mcp-product-version');
5392
+ const colorEl = document.getElementById('mcp-product-color');
5393
+ const iconEl = document.getElementById('mcp-product-icon');
5394
+ const errEl = document.getElementById('mcp-product-error');
5395
+ const titleEl = document.getElementById('mcp-product-modal-title');
5396
+ document.getElementById('mcp-product-mode').value = mode;
5397
+ document.getElementById('mcp-product-original-id').value = (current && current.id) || '';
5398
+ errEl.style.display = 'none';
5399
+ errEl.textContent = '';
5400
+ if (mode === 'edit' && current) {
5401
+ titleEl.textContent = 'Edit Product · ' + current.id;
5402
+ idEl.value = current.id;
5403
+ idEl.disabled = true;
5404
+ nameEl.value = current.name || '';
5405
+ descEl.value = current.description || '';
5406
+ statusEl.value = current.status || 'published';
5407
+ tenantEl.value = current.tenant || '';
5408
+ toolsEl.value = (current.tools || []).join('\n');
5409
+ verEl.value = current.version || '';
5410
+ colorEl.value = (current.branding && current.branding.color) || '';
5411
+ iconEl.value = (current.branding && current.branding.iconUrl) || '';
5412
+ } else {
5413
+ // 'new' mode. When the caller hands us a partial `current`
5414
+ // (the empty-state templates do this), prefill every field
5415
+ // they supplied — the operator only has to pick an id + tweak.
5416
+ // Falsy `current` zeroes everything (legacy "fresh modal" path).
5417
+ const c = current || {};
5418
+ titleEl.textContent = 'New Product';
5419
+ idEl.value = c.id || '';
5420
+ idEl.disabled = false;
5421
+ nameEl.value = c.name || '';
5422
+ descEl.value = c.description || '';
5423
+ statusEl.value = c.status || 'staging';
5424
+ tenantEl.value = c.tenant || '';
5425
+ toolsEl.value = (c.tools || []).join('\n');
5426
+ verEl.value = c.version || '';
5427
+ colorEl.value = (c.branding && c.branding.color) || '';
5428
+ iconEl.value = (c.branding && c.branding.iconUrl) || '';
5429
+ }
5430
+ document.getElementById('mcp-product-modal').classList.add('open');
5431
+ // Reset wizard state to step 1 on every open. Edit mode could
5432
+ // start on step 4 (Review), but starting at step 1 keeps the UX
5433
+ // uniform — an editor stepping through their own config catches
5434
+ // surprises.
5435
+ MCP_WIZ_STEP = 1;
5436
+ mcpWizRender();
5437
+ // Build the tools picker from the registry — fetched once on the
5438
+ // first modal open and cached client-side. Selected = whatever was
5439
+ // already in the (potentially template-prefilled) textarea.
5440
+ mcpLoadToolsRegistry().then(() => {
5441
+ const seeded = (toolsEl.value || '').split(/[\n,]+/).map((s) => s.trim()).filter(Boolean);
5442
+ mcpRenderToolsPicker(seeded);
5443
+ });
5444
+ // When opening from a template the id is the one thing the operator
5445
+ // hasn't picked yet; in every other case (edit, blank new) the name
5446
+ // is the most natural focus target.
5447
+ const focusEl = (mode === 'edit') ? nameEl
5448
+ : (current && (current.name || (current.tools && current.tools.length))) ? idEl
5449
+ : idEl;
5450
+ setTimeout(() => focusEl.focus(), 50);
5451
+ }
5452
+ async function mcpProductsNew() { mcpProductOpen('new'); }
3138
5453
  async function mcpProductsEdit(idEnc) {
3139
5454
  const id = decodeURIComponent(idEnc);
3140
5455
  const r = await fetch('/api/products/' + encodeURIComponent(id));
3141
5456
  if (!r.ok) { toast('Could not fetch ' + id); return; }
3142
5457
  const current = await r.json();
3143
- const newName = window.prompt('Display name', current.name);
3144
- if (newName === null) return;
3145
- const status = window.prompt('Status (published | staging)', current.status || 'published');
3146
- if (status === null) return;
3147
- await mcpProductsUpsert(id, { ...current, name: newName, status });
3148
- }
3149
- async function mcpProductsUpsert(id, body) {
5458
+ mcpProductOpen('edit', current);
5459
+ }
5460
+ async function mcpProductSave() {
5461
+ const mode = document.getElementById('mcp-product-mode').value;
5462
+ const id = document.getElementById('mcp-product-id').value.trim();
5463
+ const name = document.getElementById('mcp-product-name').value.trim();
5464
+ const errEl = document.getElementById('mcp-product-error');
5465
+ function fail(msg) { errEl.textContent = msg; errEl.style.display = ''; }
5466
+ // Mirror the server-side ID_RE so the user sees the rule before the
5467
+ // round-trip — server is still the source of truth for rejection.
5468
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(id)) {
5469
+ fail('Id must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}.');
5470
+ return;
5471
+ }
5472
+ if (!name) { fail('Display name is required.'); return; }
5473
+ const body = { id, name, status: document.getElementById('mcp-product-status').value };
5474
+ const desc = document.getElementById('mcp-product-description').value.trim();
5475
+ if (desc) body.description = desc;
5476
+ const tenant = document.getElementById('mcp-product-tenant').value.trim();
5477
+ if (tenant) body.tenant = tenant;
5478
+ // Split on newlines or commas so paste-from-a-list works either way.
5479
+ const toolsRaw = document.getElementById('mcp-product-tools').value || '';
5480
+ const tools = toolsRaw.split(/[\n,]+/).map((s) => s.trim()).filter(Boolean);
5481
+ if (tools.length > 0) body.tools = tools;
5482
+ const version = document.getElementById('mcp-product-version').value.trim();
5483
+ if (version) body.version = version;
5484
+ const color = document.getElementById('mcp-product-color').value.trim();
5485
+ const icon = document.getElementById('mcp-product-icon').value.trim();
5486
+ if (color || icon) {
5487
+ body.branding = {};
5488
+ if (icon) body.branding.iconUrl = icon;
5489
+ if (color) body.branding.color = color;
5490
+ }
3150
5491
  try {
3151
5492
  const r = await fetch('/api/products/' + encodeURIComponent(id), {
3152
5493
  method: 'PUT',
@@ -3155,12 +5496,13 @@ async function mcpProductsUpsert(id, body) {
3155
5496
  });
3156
5497
  if (!r.ok) {
3157
5498
  const j = await r.json().catch(() => ({}));
3158
- toast('Save failed: ' + (j.error || r.status));
5499
+ fail(j.error || ('Save failed: HTTP ' + r.status));
3159
5500
  return;
3160
5501
  }
3161
- toast('Saved ' + id);
5502
+ closeModal('mcp-product-modal');
5503
+ toast((mode === 'edit' ? 'Updated ' : 'Created ') + id);
3162
5504
  await loadMcpProducts();
3163
- } catch (e) { toast('Save failed: ' + (e && e.message || e)); }
5505
+ } catch (e) { fail('Save failed: ' + (e && e.message || e)); }
3164
5506
  }
3165
5507
  async function mcpProductsDelete(idEnc) {
3166
5508
  const id = decodeURIComponent(idEnc);
@@ -3378,7 +5720,10 @@ function sortIndClass(key){
3378
5720
 
3379
5721
  function srcRow(s){
3380
5722
  const sc=!s.enabled?'dot-disabled':s.status==='up'?'dot-up':'dot-down';
3381
- return `<div class="source-row" data-src="${esc(s.name)}"><div class="dot ${sc}"></div><div class="source-info"><div class="name">${esc(s.name)}<span class="tag tag-type">${esc(s.type)}</span>${s.signalType?`<span class="tag tag-${s.signalType}">${s.signalType}</span>`:''}</div><div class="url">${esc(s.url)}</div></div>${s.latencyMs?`<span class="tag-latency">${s.latencyMs}ms</span>`:''}<div class="source-actions" onclick="event.stopPropagation()"><label class="toggle" data-rbac="sources:write"><input type="checkbox" ${s.enabled?'checked':''} onchange="toggleSource('${esc(s.name)}')"><span class="slider"></span></label><button class="btn-icon" data-rbac="sources:write" title="Edit source" aria-label="Edit source" onclick="openEditModal('${esc(s.name)}')">&#9998;</button><button class="btn-icon" data-rbac="sources:delete" title="Delete source" aria-label="Delete source" onclick="openDeleteConfirm('source','${esc(s.name)}')">&#128465;</button></div></div>`;
5723
+ // Show a tenant tag only when set single-tenant deployments
5724
+ // stay visually identical to the pre-tenant rows.
5725
+ const tenantTag = s.tenant ? `<span class="tag" title="Tenant ${esc(s.tenant)}">${esc(s.tenant)}</span>` : '';
5726
+ return `<div class="source-row" data-src="${esc(s.name)}"><div class="dot ${sc}"></div><div class="source-info"><div class="name">${esc(s.name)}<span class="tag tag-type">${esc(s.type)}</span>${s.signalType?`<span class="tag tag-${s.signalType}">${s.signalType}</span>`:''}${tenantTag}</div><div class="url">${esc(s.url)}</div></div>${s.latencyMs?`<span class="tag-latency">${s.latencyMs}ms</span>`:''}<div class="source-actions" onclick="event.stopPropagation()"><label class="toggle" data-rbac="sources:write"><input type="checkbox" ${s.enabled?'checked':''} onchange="toggleSource('${esc(s.name)}')"><span class="slider"></span></label><button class="btn-icon" data-rbac="sources:write" title="Edit source" aria-label="Edit source" onclick="openEditModal('${esc(s.name)}')">&#9998;</button><button class="btn-icon" data-rbac="sources:delete" title="Delete source" aria-label="Delete source" onclick="openDeleteConfirm('source','${esc(s.name)}')">&#128465;</button></div></div>`;
3382
5727
  }
3383
5728
  function renderSources() {
3384
5729
  const emptyDash = richEmpty({
@@ -3407,13 +5752,22 @@ function renderSources() {
3407
5752
  const filterBar=`<div class="list-filter">
3408
5753
  <input id="src-filter" type="search" placeholder="Filter sources by name, type or URL…" value="${esc(srcFilter)}" oninput="setSrcFilter(this.value)" aria-label="Filter sources">
3409
5754
  <span class="count">${visible.length} of ${sourcesData.length}</span>
3410
- <span class="view-toggle" style="margin-left:auto"><button class="${view==='list'?'active':''}" onclick="setSrcView('list')">List</button><button class="${view==='table'?'active':''}" onclick="setSrcView('table')">Table</button></span>
5755
+ <span class="view-toggle" id="src-views" style="margin-left:auto"><button id="src-view-list" class="${view==='list'?'active':''}" onclick="setSrcView('list')">List</button><button id="src-view-table" class="${view==='table'?'active':''}" onclick="setSrcView('table')">Table</button></span>
3411
5756
  </div>`;
3412
5757
  let body;
3413
5758
  if(view==='table'){
3414
5759
  const h = (key, label) => `<th class="sortable ${sortIndClass(key)}" data-sort-key="${key}" onclick="setSrcSort('${key}')">${label}<span class="sort-ind"></span></th>`;
3415
- body=`<table class="dtable"><thead><tr>${h('name','Name')}${h('type','Type')}${h('signal','Signal')}${h('url','URL')}${h('status','Status')}${h('latency','Latency')}</tr></thead><tbody>`+
3416
- visible.map(s=>`<tr data-src="${esc(s.name)}"><td class="mono">${esc(s.name)}</td><td>${esc(s.type)}</td><td>${s.signalType?esc(s.signalType):''}</td><td class="mono t-muted">${esc(s.url)}</td><td>${!s.enabled?'disabled':s.status==='up'?'<span class="pill-yes">up</span>':'<span class="pill-no">down</span>'}</td><td class="mono">${s.latencyMs?s.latencyMs+'ms':'—'}</td></tr>`).join('')+
5760
+ // Show the tenant column only when at least one source is
5761
+ // taggedsingle-tenant deployments stay uncluttered.
5762
+ const anyTenant = visible.some(s => s.tenant);
5763
+ const tenantHead = anyTenant ? h('tenant','Tenant') : '';
5764
+ body=`<table class="dtable"><thead><tr>${h('name','Name')}${h('type','Type')}${h('signal','Signal')}${tenantHead}${h('url','URL')}${h('status','Status')}${h('latency','Latency')}</tr></thead><tbody>`+
5765
+ visible.map(s=>{
5766
+ const tenantCell = anyTenant
5767
+ ? `<td>${s.tenant ? `<span class="badge">${esc(s.tenant)}</span>` : '<span class="t-muted">global</span>'}</td>`
5768
+ : '';
5769
+ return `<tr data-src="${esc(s.name)}"><td class="mono">${esc(s.name)}</td><td>${esc(s.type)}</td><td>${s.signalType?esc(s.signalType):'—'}</td>${tenantCell}<td class="mono t-muted">${esc(s.url)}</td><td>${!s.enabled?'disabled':s.status==='up'?'<span class="pill-yes">up</span>':'<span class="pill-no">down</span>'}</td><td class="mono">${s.latencyMs?s.latencyMs+'ms':'—'}</td></tr>`;
5770
+ }).join('')+
3417
5771
  `</tbody></table>`;
3418
5772
  } else {
3419
5773
  body = visible.length === 0
@@ -3603,14 +5957,37 @@ function setAuthInForm(auth) {
3603
5957
  if(auth.type==='bearer') { document.getElementById('src-auth-token').value=auth.token||''; }
3604
5958
  toggleAuthFields();
3605
5959
  }
5960
+ // Admins (users:delete) can pick any tenant or leave blank (global);
5961
+ // non-admins are silently scoped to their own tenant by the server.
5962
+ // Pre-fill + disable for non-admins so the UX matches the server
5963
+ // posture — they SEE the constraint instead of typing into a field
5964
+ // that the server will then quietly rewrite.
5965
+ function _omcpApplyTenantFieldMode() {
5966
+ const inp = document.getElementById('src-tenant');
5967
+ if (!inp) return;
5968
+ const isAdmin = (typeof omcpCan === 'function') && omcpCan('users', 'delete');
5969
+ const sess = window.__omcpMe;
5970
+ const isAnonymous = !sess || sess.mode === 'anonymous';
5971
+ if (isAdmin || isAnonymous) {
5972
+ inp.disabled = false;
5973
+ inp.placeholder = '(blank = global)';
5974
+ } else {
5975
+ const own = (sess && sess.user && sess.user.tenant) || 'default';
5976
+ inp.value = own;
5977
+ inp.disabled = true;
5978
+ inp.placeholder = own;
5979
+ }
5980
+ }
3606
5981
  function openAddModal() {
3607
5982
  document.getElementById('modal-title').textContent='Add Source'; document.getElementById('modal-mode').value='add';
3608
5983
  document.getElementById('modal-original-name').value=''; document.getElementById('src-name').value='';
3609
5984
  document.getElementById('src-url').value=''; document.getElementById('src-enabled').checked=true;
5985
+ document.getElementById('src-tenant').value='';
3610
5986
  resetTlsFields();
3611
5987
  document.getElementById('src-name').disabled=false; resetAuthFields(); hideTestResult();
3612
5988
  setFieldError('src-name','src-name-err',null); setFieldError('src-url','src-url-err',null); clearSrcBanner();
3613
5989
  const sel=document.getElementById('src-type'); sel.innerHTML=supportedTypes.map(t=>`<option value="${t}">${t}</option>`).join('');
5990
+ _omcpApplyTenantFieldMode();
3614
5991
  document.getElementById('source-modal').classList.add('open');
3615
5992
  }
3616
5993
  function openEditModal(name) {
@@ -3619,8 +5996,10 @@ function openEditModal(name) {
3619
5996
  document.getElementById('modal-original-name').value=name; document.getElementById('src-name').value=s.name;
3620
5997
  document.getElementById('src-name').disabled=true; document.getElementById('src-url').value=s.url;
3621
5998
  document.getElementById('src-enabled').checked=s.enabled; setTlsInForm(s.tls); setAuthInForm(s.auth); hideTestResult();
5999
+ document.getElementById('src-tenant').value = s.tenant || '';
3622
6000
  setFieldError('src-name','src-name-err',null); setFieldError('src-url','src-url-err',null); clearSrcBanner();
3623
6001
  const sel=document.getElementById('src-type'); sel.innerHTML=supportedTypes.map(t=>`<option value="${t}" ${t===s.type?'selected':''}>${t}</option>`).join('');
6002
+ _omcpApplyTenantFieldMode();
3624
6003
  document.getElementById('source-modal').classList.add('open');
3625
6004
  }
3626
6005
  // --- Source modal form validation + banner ------------------------------
@@ -3680,6 +6059,8 @@ async function saveSource() {
3680
6059
  if (!okName || !okUrl) { showSrcBanner('error', 'Fix the highlighted fields and try again.'); return; }
3681
6060
  const mode=document.getElementById('modal-mode').value;
3682
6061
  const src={name:document.getElementById('src-name').value.trim(),type:document.getElementById('src-type').value,url:document.getElementById('src-url').value.trim(),enabled:document.getElementById('src-enabled').checked,auth:getAuthFromForm(),tls:getTlsFromForm()};
6062
+ const tenant = document.getElementById('src-tenant').value.trim();
6063
+ if (tenant) src.tenant = tenant;
3683
6064
  const btn=document.getElementById('save-btn'); btn.disabled=true; btn.textContent='Saving...';
3684
6065
  try {
3685
6066
  let res; if(mode==='add') res=await fetch('/api/sources',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(src)});
@@ -4334,7 +6715,10 @@ function renderTopologyGraph(){
4334
6715
  const r = idx.byId.get(id);
4335
6716
  const base = topoKindColor(r ? r.kind : 'scope');
4336
6717
  // soft alpha + slightly lighter
4337
- return base.replace(/^#/, '#') + '22'; // append alpha (works because we use hex)
6718
+ // `base` already starts with `#` from topoKindColor; just append
6719
+ // a 1-byte alpha to get an 8-digit hex CSS colour. The dead
6720
+ // `.replace(/^#/, '#')` step from an earlier refactor is gone.
6721
+ return base + '22';
4338
6722
  };
4339
6723
  const scopeBands = [];
4340
6724
  for (const scopeId of pureScope){