@thotischner/observability-mcp 1.8.1 → 3.0.0

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 (169) 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/types.d.ts +18 -0
  12. package/dist/audit/sinks/types.js +1 -0
  13. package/dist/audit/sinks/webhook.d.ts +45 -0
  14. package/dist/audit/sinks/webhook.js +111 -0
  15. package/dist/audit/sinks/webhook.test.d.ts +1 -0
  16. package/dist/audit/sinks/webhook.test.js +162 -0
  17. package/dist/auth/credentials.d.ts +11 -0
  18. package/dist/auth/credentials.js +27 -0
  19. package/dist/auth/credentials.test.js +21 -1
  20. package/dist/auth/csrf.d.ts +26 -0
  21. package/dist/auth/csrf.js +128 -0
  22. package/dist/auth/csrf.test.d.ts +1 -0
  23. package/dist/auth/csrf.test.js +143 -0
  24. package/dist/auth/local-users.d.ts +6 -0
  25. package/dist/auth/local-users.js +11 -0
  26. package/dist/auth/local-users.test.js +41 -0
  27. package/dist/auth/middleware.d.ts +7 -6
  28. package/dist/auth/oidc/dcr.d.ts +70 -0
  29. package/dist/auth/oidc/dcr.js +160 -0
  30. package/dist/auth/oidc/dcr.test.d.ts +1 -0
  31. package/dist/auth/oidc/dcr.test.js +109 -0
  32. package/dist/auth/oidc/endpoints.js +44 -0
  33. package/dist/auth/oidc/profiles.d.ts +22 -0
  34. package/dist/auth/oidc/profiles.js +95 -0
  35. package/dist/auth/oidc/profiles.test.d.ts +1 -0
  36. package/dist/auth/oidc/profiles.test.js +51 -0
  37. package/dist/auth/oidc/runtime.d.ts +3 -0
  38. package/dist/auth/oidc/runtime.js +16 -3
  39. package/dist/auth/oidc/runtime.test.js +1 -0
  40. package/dist/auth/policy/batch-dry-run.d.ts +56 -0
  41. package/dist/auth/policy/batch-dry-run.js +129 -0
  42. package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
  43. package/dist/auth/policy/batch-dry-run.test.js +140 -0
  44. package/dist/auth/policy/engine.d.ts +20 -4
  45. package/dist/auth/policy/engine.js +16 -2
  46. package/dist/auth/policy/loader.d.ts +11 -1
  47. package/dist/auth/policy/loader.js +37 -0
  48. package/dist/auth/policy/loader.test.d.ts +1 -0
  49. package/dist/auth/policy/loader.test.js +86 -0
  50. package/dist/auth/policy/opa.d.ts +5 -5
  51. package/dist/auth/policy/opa.js +25 -14
  52. package/dist/auth/policy/opa.test.js +48 -0
  53. package/dist/auth/rbac.d.ts +23 -1
  54. package/dist/auth/rbac.js +43 -1
  55. package/dist/auth/rbac.test.js +62 -0
  56. package/dist/cli/index.js +3 -0
  57. package/dist/cli/inspector-config.d.ts +9 -0
  58. package/dist/cli/inspector-config.js +28 -0
  59. package/dist/cli/inspector-config.test.d.ts +1 -0
  60. package/dist/cli/inspector-config.test.js +33 -0
  61. package/dist/cli/lib.d.ts +1 -1
  62. package/dist/cli/lib.js +1 -0
  63. package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
  64. package/dist/conformance/mcp-2025-11-25.test.js +206 -0
  65. package/dist/connectors/interface.d.ts +5 -1
  66. package/dist/connectors/loader.js +6 -4
  67. package/dist/connectors/loader.test.d.ts +1 -0
  68. package/dist/connectors/loader.test.js +78 -0
  69. package/dist/connectors/prometheus.test.js +31 -13
  70. package/dist/connectors/registry.d.ts +13 -0
  71. package/dist/connectors/registry.js +30 -0
  72. package/dist/connectors/registry.test.js +56 -2
  73. package/dist/context.d.ts +32 -0
  74. package/dist/context.js +35 -0
  75. package/dist/context.test.d.ts +1 -0
  76. package/dist/context.test.js +58 -0
  77. package/dist/federation/registry.d.ts +32 -0
  78. package/dist/federation/registry.js +77 -0
  79. package/dist/federation/registry.test.d.ts +1 -0
  80. package/dist/federation/registry.test.js +130 -0
  81. package/dist/federation/upstream.d.ts +60 -0
  82. package/dist/federation/upstream.js +114 -0
  83. package/dist/index.js +1188 -120
  84. package/dist/middleware/ssrfGuard.d.ts +15 -0
  85. package/dist/middleware/ssrfGuard.js +103 -0
  86. package/dist/middleware/ssrfGuard.test.d.ts +1 -0
  87. package/dist/middleware/ssrfGuard.test.js +81 -0
  88. package/dist/observability/otel.d.ts +20 -0
  89. package/dist/observability/otel.js +118 -0
  90. package/dist/observability/otel.test.d.ts +1 -0
  91. package/dist/observability/otel.test.js +56 -0
  92. package/dist/openapi.js +215 -7
  93. package/dist/openapi.test.js +34 -0
  94. package/dist/postmortem/synthesizer.d.ts +83 -0
  95. package/dist/postmortem/synthesizer.js +205 -0
  96. package/dist/postmortem/synthesizer.test.d.ts +1 -0
  97. package/dist/postmortem/synthesizer.test.js +141 -0
  98. package/dist/products/loader.d.ts +31 -3
  99. package/dist/products/loader.js +77 -4
  100. package/dist/products/loader.test.js +90 -1
  101. package/dist/quota/charge.d.ts +28 -0
  102. package/dist/quota/charge.js +30 -0
  103. package/dist/quota/charge.test.d.ts +1 -0
  104. package/dist/quota/charge.test.js +83 -0
  105. package/dist/quota/limiter.d.ts +29 -4
  106. package/dist/quota/limiter.js +64 -8
  107. package/dist/quota/limiter.test.js +86 -0
  108. package/dist/scim/group-role-map.d.ts +4 -0
  109. package/dist/scim/group-role-map.js +33 -0
  110. package/dist/scim/group-role-map.test.d.ts +1 -0
  111. package/dist/scim/group-role-map.test.js +33 -0
  112. package/dist/scim/routes.d.ts +15 -0
  113. package/dist/scim/routes.js +249 -0
  114. package/dist/scim/store.d.ts +37 -0
  115. package/dist/scim/store.js +178 -0
  116. package/dist/scim/store.test.d.ts +1 -0
  117. package/dist/scim/store.test.js +121 -0
  118. package/dist/scim/types.d.ts +73 -0
  119. package/dist/scim/types.js +29 -0
  120. package/dist/sdk/hooks.d.ts +77 -0
  121. package/dist/sdk/hooks.js +72 -0
  122. package/dist/sdk/hooks.test.d.ts +1 -0
  123. package/dist/sdk/hooks.test.js +159 -0
  124. package/dist/sdk/index.d.ts +2 -0
  125. package/dist/sdk/index.js +1 -0
  126. package/dist/sdk/manifest-schema.d.ts +17 -0
  127. package/dist/sdk/manifest-schema.js +21 -0
  128. package/dist/tools/context-seam.test.js +6 -1
  129. package/dist/tools/detect-anomalies.d.ts +1 -1
  130. package/dist/tools/detect-anomalies.js +5 -4
  131. package/dist/tools/generate-postmortem.d.ts +35 -0
  132. package/dist/tools/generate-postmortem.js +191 -0
  133. package/dist/tools/get-anomaly-history.d.ts +35 -0
  134. package/dist/tools/get-anomaly-history.js +126 -0
  135. package/dist/tools/get-service-health.d.ts +1 -1
  136. package/dist/tools/get-service-health.js +4 -3
  137. package/dist/tools/list-services.d.ts +1 -1
  138. package/dist/tools/list-services.js +3 -2
  139. package/dist/tools/list-sources.d.ts +1 -1
  140. package/dist/tools/list-sources.js +6 -2
  141. package/dist/tools/query-logs.d.ts +1 -1
  142. package/dist/tools/query-logs.js +2 -2
  143. package/dist/tools/query-metrics.d.ts +1 -1
  144. package/dist/tools/query-metrics.js +19 -6
  145. package/dist/tools/query-traces.d.ts +47 -0
  146. package/dist/tools/query-traces.js +145 -0
  147. package/dist/tools/query-traces.test.d.ts +1 -0
  148. package/dist/tools/query-traces.test.js +110 -0
  149. package/dist/tools/registry-names.d.ts +35 -0
  150. package/dist/tools/registry-names.js +54 -0
  151. package/dist/tools/registry-names.test.d.ts +1 -0
  152. package/dist/tools/registry-names.test.js +61 -0
  153. package/dist/tools/topology.d.ts +3 -3
  154. package/dist/tools/topology.js +10 -6
  155. package/dist/topology/merge.d.ts +22 -0
  156. package/dist/topology/merge.js +178 -0
  157. package/dist/topology/merge.test.d.ts +1 -0
  158. package/dist/topology/merge.test.js +110 -0
  159. package/dist/transport/sessionStore.d.ts +66 -0
  160. package/dist/transport/sessionStore.js +138 -0
  161. package/dist/transport/sessionStore.test.d.ts +1 -0
  162. package/dist/transport/sessionStore.test.js +118 -0
  163. package/dist/transport/websocket.d.ts +35 -0
  164. package/dist/transport/websocket.js +133 -0
  165. package/dist/transport/websocket.test.d.ts +1 -0
  166. package/dist/transport/websocket.test.js +124 -0
  167. package/dist/types.d.ts +51 -0
  168. package/dist/ui/index.html +1729 -100
  169. package/package.json +13 -3
@@ -936,6 +936,180 @@
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
+ /* Products — leitfaden card */
941
+ .leitfaden-chev { display: inline-block; transition: transform .15s ease; font-size: .85em; opacity: .7; }
942
+ #mcp-products-leitfaden.collapsed .leitfaden-chev { transform: rotate(-90deg); }
943
+ #mcp-products-leitfaden.collapsed #mcp-leitfaden-body { display: none; }
944
+ .leitfaden-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-4); padding: 0 var(--sp-4) var(--sp-4); }
945
+ .leitfaden-grid h3 { font-size: 13px; font-weight: 600; margin: 0 0 var(--sp-2); }
946
+ .leitfaden-grid p { margin: 0 0 var(--sp-2); line-height: 1.5; }
947
+ .leitfaden-grid pre { background: var(--surface-2); padding: var(--sp-2); border-radius: var(--radius-sm); font-size: 11px; overflow-x: auto; }
948
+ .leitfaden-grid code { font-size: .92em; }
949
+ @media (max-width: 900px) { .leitfaden-grid { grid-template-columns: 1fr; } }
950
+
951
+ /* Products — card grid */
952
+ .pcard-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: var(--sp-3); padding: var(--sp-3); }
953
+ .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; }
954
+ .pcard:hover { border-color: var(--border-strong); box-shadow: 0 1px 3px rgba(0,0,0,.06); }
955
+ .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); }
956
+ .pcard-hd { display: flex; align-items: flex-start; gap: var(--sp-2); }
957
+ .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; }
958
+ .pcard-icon img { width: 100%; height: 100%; object-fit: cover; }
959
+ .pcard-title { flex: 1; min-width: 0; }
960
+ .pcard-title h3 { font-size: 14px; font-weight: 600; margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
961
+ .pcard-meta { font-size: 11px; opacity: .7; margin-top: 2px; font-family: 'JetBrains Mono', ui-monospace, monospace; }
962
+ .pcard-status { flex: 0 0 auto; }
963
+ .pcard-desc { font-size: 12px; line-height: 1.45; opacity: .85; min-height: 2.9em; }
964
+ .pcard-tools-label { font-size: 11px; font-weight: 600; opacity: .65; text-transform: uppercase; letter-spacing: .04em; margin-bottom: var(--sp-1); }
965
+ .pcard-tools { display: flex; flex-wrap: wrap; gap: 4px; min-height: 22px; }
966
+ .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); }
967
+ .pcard-tool-all { font-size: 11px; opacity: .55; font-style: italic; }
968
+ .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); }
969
+ .pcard-footer .pcard-spacer { flex: 1; }
970
+
971
+ /* Policies — engine banner + sticky dry-run */
972
+ .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); }
973
+ .pol-engine-banner .pol-eb-dot { flex: 0 0 8px; width: 8px; height: 8px; border-radius: 50%; margin-top: 6px; background: var(--text-2); }
974
+ .pol-engine-banner .pol-eb-body { flex: 1; min-width: 0; }
975
+ .pol-engine-banner .pol-eb-title { font-weight: 600; font-size: 13px; display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: center; }
976
+ .pol-engine-banner .pol-eb-meta { font-size: 11px; opacity: .7; margin-top: 2px; font-family: 'JetBrains Mono', ui-monospace, monospace; }
977
+ .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; }
978
+ .pol-engine-banner.eng-builtin { background: var(--surface-2); border-color: var(--border); }
979
+ .pol-engine-banner.eng-builtin .pol-eb-dot { background: var(--text-2); }
980
+ .pol-engine-banner.eng-file { background: var(--success-soft); border-color: var(--success); }
981
+ .pol-engine-banner.eng-file .pol-eb-dot { background: var(--success); }
982
+ .pol-engine-banner.eng-opa { background: var(--warning-soft); border-color: var(--warning); }
983
+ .pol-engine-banner.eng-opa .pol-eb-dot { background: var(--warning); }
984
+ .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; }
985
+
986
+ /* Policies sub-tabs (Roles / Bindings / Subjects) */
987
+ .pol-subtabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: var(--sp-3); }
988
+ .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; }
989
+ .pol-subtab:hover { color: var(--text); }
990
+ .pol-subtab[data-active="true"] { color: var(--text); border-bottom-color: var(--accent); font-weight: 500; }
991
+ .pol-pane[hidden] { display: none; }
992
+
993
+ /* Roles master/detail */
994
+ .pol-roles-layout { display: grid; grid-template-columns: 260px 1fr; min-height: 320px; }
995
+ .pol-role-list { border-right: 1px solid var(--border); max-height: 60vh; overflow-y: auto; }
996
+ .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); }
997
+ .pol-role-row:hover { background: var(--surface-2); }
998
+ .pol-role-row[data-active="true"] { background: var(--accent-soft); }
999
+ .pol-role-row .pol-role-name { font-weight: 500; font-size: 13px; }
1000
+ .pol-role-row .pol-role-count { font-size: 11px; opacity: .65; }
1001
+ .pol-role-detail { padding: var(--sp-4); overflow-x: auto; }
1002
+ .pol-role-detail h3 { margin: 0 0 var(--sp-3); font-size: 14px; font-weight: 600; }
1003
+ .pol-matrix { border-collapse: collapse; width: 100%; font-size: 12px; }
1004
+ .pol-matrix th, .pol-matrix td { padding: var(--sp-2); border: 1px solid var(--border); text-align: left; }
1005
+ .pol-matrix thead th { background: var(--surface-2); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; font-weight: 600; opacity: .8; }
1006
+ .pol-matrix tbody th { font-weight: 500; background: var(--surface-2); font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 11.5px; }
1007
+ .pol-matrix td { text-align: center; }
1008
+ .pol-matrix .cell-grant { color: var(--success); font-weight: 600; }
1009
+ .pol-matrix .cell-empty { opacity: .25; }
1010
+ /* Effective-permissions overlay cells — drop the bold/success
1011
+ colour so "via <role>" reads as informational, not as a fresh
1012
+ grant; "denied" cells stay dimmed via cell-empty. */
1013
+ .pol-matrix td[data-effective="allowed"] { color: var(--text-1); font-weight: 400; }
1014
+ .pol-matrix td[data-effective="allowed"][data-via-selected="true"] { color: var(--success); font-weight: 500; }
1015
+ .pol-matrix td[data-effective="denied"] { color: var(--text-3); opacity: .55; font-style: italic; }
1016
+ @media (max-width: 900px) {
1017
+ .pol-roles-layout { grid-template-columns: 1fr; }
1018
+ .pol-role-list { border-right: 0; border-bottom: 1px solid var(--border); max-height: none; }
1019
+ }
1020
+
1021
+ /* Subjects sub-tab — three stacked sections */
1022
+ .pol-subjects-section { padding: var(--sp-3) var(--sp-4); }
1023
+ .pol-subjects-section + .pol-subjects-section { border-top: 1px solid var(--border); }
1024
+ .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); }
1025
+ .pol-subjects-section h3 .pol-subjects-count { font-size: 11px; opacity: .65; font-weight: 400; }
1026
+ .pol-subjects-section h3 .pol-subjects-source { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 10.5px; opacity: .55; margin-left: auto; }
1027
+ .pol-subjects-empty { font-size: 12px; opacity: .65; padding: var(--sp-2) 0; }
1028
+
1029
+ /* Sticky dry-run bar — pinned at the top of the policies page */
1030
+ .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); }
1031
+ .pol-probe-grid { display: grid; grid-template-columns: repeat(5, 1fr) auto; gap: var(--sp-3); align-items: end; }
1032
+ .pol-probe-grid .form-group { margin: 0; }
1033
+ .pol-probe-result { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) 0 0; }
1034
+ .pol-probe-result .pol-pv { font-weight: 600; padding: 2px 10px; border-radius: 4px; font-size: 12px; letter-spacing: .04em; text-transform: uppercase; }
1035
+ .pol-probe-result .pol-pv.allow { background: var(--success-soft); color: var(--success); }
1036
+ .pol-probe-result .pol-pv.deny { background: var(--danger-soft); color: var(--danger); }
1037
+ .pol-probe-result code { font-size: 11px; opacity: .8; }
1038
+ @media (max-width: 1000px) {
1039
+ .pol-probe-grid { grid-template-columns: 1fr 1fr; }
1040
+ }
1041
+
1042
+ /* Author-controls are hidden when the active engine is read-only.
1043
+ Anything with data-engine-required="file" needs the file engine. */
1044
+ body[data-policy-engine="opa"] [data-engine-required="file"],
1045
+ body[data-policy-engine="builtin"] [data-engine-required="file"] {
1046
+ opacity: .35; pointer-events: none;
1047
+ }
1048
+
1049
+ /* Products wizard — multi-step modal */
1050
+ .mcp-product-wizard { width: min(720px, 92vw); max-height: 86vh; display: flex; flex-direction: column; }
1051
+ .mcp-product-wizard .modal-body { flex: 1; overflow-y: auto; }
1052
+ .mcp-product-wizard .modal-footer { display: flex; align-items: center; gap: var(--sp-2); }
1053
+ .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); }
1054
+ .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; }
1055
+ .wiz-step-btn:hover { color: var(--text); background: var(--surface); }
1056
+ .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; }
1057
+ .wiz-step-btn[data-active="true"] { color: var(--text); }
1058
+ .wiz-step-btn[data-active="true"] .wiz-step-num { background: var(--accent); border-color: var(--accent); color: #fff; }
1059
+ .wiz-step-btn[data-done="true"] .wiz-step-num { background: var(--success); border-color: var(--success); color: #fff; }
1060
+ .wiz-step-btn[data-done="true"] .wiz-step-num::after { content: "✓"; font-size: 10px; }
1061
+ .wiz-step-btn[data-done="true"] .wiz-step-num > * { display: none; }
1062
+ .wiz-step-line { flex: 1; height: 1px; background: var(--border); min-width: 12px; }
1063
+ .wiz-step-help { padding: 0 0 var(--sp-3); opacity: .85; line-height: 1.5; }
1064
+ .wiz-pane[hidden] { display: none; }
1065
+
1066
+ /* Review pane */
1067
+ .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); }
1068
+ .wiz-review-grid dt { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; font-weight: 600; opacity: .65; padding-top: 2px; }
1069
+ .wiz-review-grid dd { margin: 0; font-size: 13px; word-break: break-word; }
1070
+ .wiz-review-grid dd code { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 12px; }
1071
+ .wiz-review-grid .wiz-review-empty { opacity: .5; font-style: italic; }
1072
+ .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); }
1073
+ .wiz-review-tools { display: flex; flex-wrap: wrap; gap: 4px; }
1074
+ .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); }
1075
+
1076
+ /* Wizard Review — agent preview panel */
1077
+ .wiz-agent-preview-h { margin: var(--sp-4) 0 var(--sp-2); font-size: 13px; font-weight: 600; }
1078
+ .wiz-agent-preview { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: var(--sp-3); background: var(--surface-2); }
1079
+ .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; }
1080
+ .wiz-agent-group { padding: var(--sp-1) 0; }
1081
+ .wiz-agent-group + .wiz-agent-group { border-top: 1px dashed var(--border); padding-top: var(--sp-2); margin-top: var(--sp-2); }
1082
+ .wiz-agent-tool { padding: 4px 0; }
1083
+ .wiz-agent-tool-name { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 12px; font-weight: 500; }
1084
+ .wiz-agent-tool-summary { font-size: 11px; opacity: .75; line-height: 1.4; margin-top: 1px; }
1085
+ .mcp-agent-preview { width: min(640px, 92vw); }
1086
+ .mcp-agent-preview .modal-header { padding-left: var(--sp-3); }
1087
+
1088
+ /* Products modal — tools picker (multi-select grouped by category) */
1089
+ .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; }
1090
+ .tools-picker .tp-group { padding: var(--sp-1) 0; }
1091
+ .tools-picker .tp-group + .tp-group { margin-top: var(--sp-2); border-top: 1px dashed var(--border); padding-top: var(--sp-2); }
1092
+ .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); }
1093
+ .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; }
1094
+ .tools-picker .tp-item:hover { background: var(--surface); }
1095
+ .tools-picker .tp-item input { margin-top: 3px; flex: 0 0 auto; }
1096
+ .tools-picker .tp-item .tp-text { flex: 1; min-width: 0; }
1097
+ .tools-picker .tp-item .tp-name { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 12px; font-weight: 500; }
1098
+ .tools-picker .tp-item .tp-summary { font-size: 11px; opacity: .7; line-height: 1.4; margin-top: 1px; }
1099
+ .tools-picker .tp-actions { display: flex; gap: var(--sp-2); margin-bottom: var(--sp-2); }
1100
+ .tools-picker .tp-actions button { font-size: 11px; padding: 2px 8px; }
1101
+ .tools-picker .tools-picker-hint { padding: var(--sp-2); opacity: .7; }
1102
+
1103
+ /* Products — empty state with templates */
1104
+ .pempty { padding: var(--sp-5); text-align: center; }
1105
+ .pempty h3 { margin: 0 0 var(--sp-2); font-size: 16px; }
1106
+ .pempty p { margin: 0 auto var(--sp-4); max-width: 540px; line-height: 1.5; opacity: .8; }
1107
+ .pempty-templates { display: flex; gap: var(--sp-2); justify-content: center; flex-wrap: wrap; }
1108
+ .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; }
1109
+ .pempty-tpl:hover { border-color: var(--accent); background: var(--accent-soft); }
1110
+ .pempty-tpl-title { font-weight: 600; font-size: 13px; margin-bottom: 2px; }
1111
+ .pempty-tpl-desc { font-size: 11px; opacity: .7; }
1112
+
939
1113
  /* Form-first editor (Form / JSON / YAML — OpenShift-style) */
940
1114
  .ed-bar { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-3); flex-wrap: wrap; }
941
1115
  .ed-token { flex: 1; min-width: 200px; }
@@ -1736,16 +1910,51 @@ curl -X PUT http://localhost:3000/api/enterprise/policy \
1736
1910
  legacy enterprise-catalog block below for new deployments;
1737
1911
  the legacy block stays so existing /api/enterprise/catalog
1738
1912
  operators see their data until they migrate. -->
1913
+ <!-- Inline leitfaden — collapsed by default once the operator
1914
+ dismisses it; re-opens by clicking the chevron. -->
1915
+ <div class="card" id="mcp-products-leitfaden">
1916
+ <div class="card-header" style="cursor:pointer" onclick="mcpLeitfadenToggle()">
1917
+ <h2 style="display:flex;align-items:center;gap:.5rem">
1918
+ <span class="leitfaden-chev" id="mcp-leitfaden-chev">▾</span>
1919
+ About Products
1920
+ </h2>
1921
+ <span class="t-sm" style="opacity:.7">click to collapse</span>
1922
+ </div>
1923
+ <div id="mcp-leitfaden-body">
1924
+ <div class="leitfaden-grid">
1925
+ <div>
1926
+ <h3>What is a product?</h3>
1927
+ <p class="t-sm">A curated, named bundle of MCP tools you expose to one agent. The bundle's <code>tools</code> allow-list filters <code>tools/list</code> at the <code>/mcp</code> transport, so an agent bound to <em>Ops Bundle</em> sees only the operations tools and not the developer tools.</p>
1928
+ </div>
1929
+ <div>
1930
+ <h3>When do I need one?</h3>
1931
+ <p class="t-sm">Whenever more than one agent connects. Each team / use-case gets its own product — SRE on-call vs coding assistant vs compliance auditor. Without a product the credential sees every registered tool, which is fine for a single-operator demo but not for a production multi-agent deployment.</p>
1932
+ </div>
1933
+ <div>
1934
+ <h3>How do agents pick one up?</h3>
1935
+ <p class="t-sm">Bind a credential to a product via <code>OMCP_KEY_PRODUCTS</code>:</p>
1936
+ <pre style="margin:.25rem 0"><code>OMCP_API_KEYS="agent:tok_ops,ci:tok_dev"
1937
+ OMCP_KEY_PRODUCTS="agent=ops-bundle;ci=dev-bundle"</code></pre>
1938
+ <p class="t-sm">The next <code>/mcp</code> session of <code>agent</code> sees only the tools in <code>ops-bundle</code>. <a href="https://github.com/ThoTischner/observability-mcp/blob/main/docs/products.md" target="_blank" rel="noreferrer">Full docs →</a></p>
1939
+ </div>
1940
+ </div>
1941
+ </div>
1942
+ </div>
1943
+
1739
1944
  <div class="card" id="mcp-products-card">
1740
1945
  <div class="card-header">
1741
1946
  <h2>MCP Products
1742
1947
  <button class="info" aria-label="About the MCP Products API"
1743
1948
  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."
1949
+ 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
1950
  onclick="infoPop(this)">?</button>
1746
1951
  </h2>
1747
1952
  <div class="row-inline">
1748
1953
  <span id="mcp-products-scope" class="badge"></span>
1954
+ <span class="view-toggle" id="mcp-products-views">
1955
+ <button id="mcp-pv-cards" onclick="mcpProductsSetView('cards')">Cards</button>
1956
+ <button id="mcp-pv-table" onclick="mcpProductsSetView('table')">Table</button>
1957
+ </span>
1749
1958
  <button class="btn btn-primary btn-sm" data-rbac="products:write" onclick="mcpProductsNew()">+ New product</button>
1750
1959
  </div>
1751
1960
  </div>
@@ -1809,38 +2018,171 @@ curl -X PUT http://localhost:3000/api/enterprise/catalog \
1809
2018
  </div>
1810
2019
  <div class="ph-actions"><span id="pol-engine" class="badge"></span></div>
1811
2020
  </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.
2021
+
2022
+ <!-- Engine banner — colour + copy distinguishes editable file
2023
+ vs read-only builtin / opa modes at a glance. -->
2024
+ <div id="pol-engine-banner" class="pol-engine-banner eng-builtin" hidden>
2025
+ <div class="pol-eb-dot"></div>
2026
+ <div class="pol-eb-body">
2027
+ <div class="pol-eb-title">
2028
+ <span id="pol-eb-title-text">Engine</span>
2029
+ <span id="pol-eb-kind" class="pol-eb-pill"></span>
2030
+ <span id="pol-eb-tenant-aware" class="pol-eb-pill" hidden>tenant-aware</span>
2031
+ </div>
2032
+ <div class="pol-eb-meta" id="pol-eb-meta"></div>
2033
+ <p id="pol-eb-copy"></p>
1822
2034
  </div>
1823
2035
  </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">
2036
+
2037
+ <!-- Sticky dry-run probe bar — promoted to the top so it's the
2038
+ first affordance, not buried below the snapshot. Works
2039
+ across every engine including OPA (the only way to see what
2040
+ Rego actually grants without writing a unit test). -->
2041
+ <div class="pol-probe-bar">
2042
+ <h3 style="margin:0 0 var(--sp-2);display:flex;align-items:center;gap:var(--sp-2);font-size:13px">
2043
+ <span>Probe a permission</span>
2044
+ <button class="info" aria-label="About dry-run"
2045
+ data-title="Dry-run probe"
2046
+ 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."
2047
+ onclick="infoPop(this)">?</button>
2048
+ </h3>
2049
+ <div class="pol-probe-grid">
2050
+ <div class="form-group">
1828
2051
  <label>Roles <span class="form-hint">comma-separated</span></label>
1829
2052
  <input id="pol-dry-roles" placeholder="admin, operator">
1830
2053
  </div>
1831
- <div class="form-group" style="margin:0">
2054
+ <div class="form-group">
1832
2055
  <label>Resource</label>
1833
2056
  <input id="pol-dry-resource" placeholder="sources">
1834
2057
  </div>
1835
- <div class="form-group" style="margin:0">
2058
+ <div class="form-group">
1836
2059
  <label>Action</label>
1837
- <input id="pol-dry-action" placeholder="write">
2060
+ <input id="pol-dry-action" placeholder="read">
1838
2061
  </div>
1839
- <div class="form-group" style="margin:0; align-self:end">
2062
+ <div class="form-group">
2063
+ <label>Tenant <span class="form-hint">optional</span></label>
2064
+ <input id="pol-dry-tenant" placeholder="default">
2065
+ </div>
2066
+ <div class="form-group"><label>&nbsp;</label>
1840
2067
  <button class="btn btn-primary" onclick="polDryRun()">Evaluate</button>
1841
2068
  </div>
1842
2069
  </div>
1843
- <div id="pol-dry-out" style="padding: 0 var(--sp-4) var(--sp-4)"></div>
2070
+ <div id="pol-dry-out"></div>
2071
+ </div>
2072
+
2073
+ <!-- Sub-tab nav — k8s-style separation of Roles (the WHAT) vs
2074
+ Bindings (the WHO) vs Subjects (the principals). Slice F
2075
+ ships Roles; G + H fill in Bindings + Subjects. -->
2076
+ <nav class="pol-subtabs" role="tablist" aria-label="Policies sections">
2077
+ <button class="pol-subtab" role="tab" aria-controls="pol-pane-roles" data-pol-tab="roles" onclick="polSetTab('roles')">Roles</button>
2078
+ <button class="pol-subtab" role="tab" aria-controls="pol-pane-bindings" data-pol-tab="bindings" onclick="polSetTab('bindings')">Bindings</button>
2079
+ <button class="pol-subtab" role="tab" aria-controls="pol-pane-subjects" data-pol-tab="subjects" onclick="polSetTab('subjects')">Subjects</button>
2080
+ </nav>
2081
+
2082
+ <!-- Roles sub-tab — master/detail: role list on the left, the
2083
+ selected role's permission matrix on the right. -->
2084
+ <div class="card pol-pane" id="pol-pane-roles" role="tabpanel">
2085
+ <div class="card-header"><h2>Roles
2086
+ <button class="info" aria-label="About roles"
2087
+ data-title="Roles"
2088
+ 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."
2089
+ onclick="infoPop(this)">?</button>
2090
+ <span style="flex:1"></span>
2091
+ <button class="btn btn-primary btn-sm" data-engine-required="file" data-rbac="users:delete" onclick="polRoleAuthorNew()">+ New role</button>
2092
+ </h2></div>
2093
+ <div class="pol-roles-layout">
2094
+ <div class="pol-role-list" id="pol-role-list" role="listbox" aria-label="Roles">
2095
+ <div class="empty">Loading…</div>
2096
+ </div>
2097
+ <div class="pol-role-detail" id="pol-role-detail">
2098
+ <div class="empty">Select a role on the left to see its permission matrix.</div>
2099
+ </div>
2100
+ </div>
2101
+ </div>
2102
+
2103
+ <!-- Bindings sub-tab — subject → roles table with inline edit. -->
2104
+ <div class="card pol-pane" id="pol-pane-bindings" role="tabpanel" hidden>
2105
+ <div class="card-header"><h2>Bindings
2106
+ <button class="info" aria-label="About bindings"
2107
+ data-title="Bindings"
2108
+ 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."
2109
+ onclick="infoPop(this)">?</button>
2110
+ </h2></div>
2111
+ <div id="pol-bindings-body" class="content"><div class="empty">Loading…</div></div>
2112
+ </div>
2113
+
2114
+ <!-- Role-author modal (file-engine only). Surfaces a name +
2115
+ permission-matrix checkbox grid; saves via
2116
+ PUT /api/policy/roles/:name. -->
2117
+ <div class="modal-overlay" id="pol-role-author-modal">
2118
+ <div class="modal" style="width:min(720px, 92vw)">
2119
+ <div class="modal-header">
2120
+ <h3>New role</h3>
2121
+ <button class="btn-icon" onclick="closeModal('pol-role-author-modal')">&times;</button>
2122
+ </div>
2123
+ <div class="modal-body">
2124
+ <div class="form-group">
2125
+ <label for="pol-role-author-name">Role name</label>
2126
+ <input type="text" id="pol-role-author-name" placeholder="e.g. on-call-sre" autocomplete="off">
2127
+ <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>
2128
+ </div>
2129
+ <div class="form-group">
2130
+ <label>Permissions</label>
2131
+ <div id="pol-role-author-grid" class="content"></div>
2132
+ <div class="form-hint">Tick cells to grant. Empty rows mean the role has no access to that resource.</div>
2133
+ </div>
2134
+ <div id="pol-role-author-error" class="form-hint" role="alert" aria-live="polite" style="color: var(--danger); display: none;"></div>
2135
+ </div>
2136
+ <div class="modal-footer">
2137
+ <button class="btn btn-ghost" onclick="closeModal('pol-role-author-modal')">Cancel</button>
2138
+ <span style="flex:1"></span>
2139
+ <button class="btn btn-primary" onclick="polRoleAuthorSave()">Save role</button>
2140
+ </div>
2141
+ </div>
2142
+ </div>
2143
+
2144
+ <!-- Binding-edit modal — only used for the local-user row. -->
2145
+ <div class="modal-overlay" id="pol-binding-modal">
2146
+ <div class="modal" style="width:min(520px, 92vw)">
2147
+ <div class="modal-header">
2148
+ <h3>Edit binding · <span id="pol-binding-subject"></span></h3>
2149
+ <button class="btn-icon" onclick="closeModal('pol-binding-modal')">&times;</button>
2150
+ </div>
2151
+ <div class="modal-body">
2152
+ <p class="t-sm" style="opacity:.85;margin:0 0 var(--sp-3)">
2153
+ Select the roles to grant <strong id="pol-binding-subject-2"></strong>.
2154
+ Unknown roles are rejected — the catalogue comes from the active engine.
2155
+ </p>
2156
+ <div id="pol-binding-roles" class="content"><div class="empty">Loading…</div></div>
2157
+ <div id="pol-binding-error" class="form-hint" role="alert" aria-live="polite" style="color: var(--danger); display: none;"></div>
2158
+ </div>
2159
+ <div class="modal-footer">
2160
+ <button class="btn btn-ghost" onclick="closeModal('pol-binding-modal')">Cancel</button>
2161
+ <span style="flex:1"></span>
2162
+ <button class="btn btn-primary" onclick="polBindingSave()">Save binding</button>
2163
+ </div>
2164
+ </div>
2165
+ </div>
2166
+
2167
+ <!-- Subjects sub-tab — three sections (Users / API keys /
2168
+ OIDC groups). Read-only this slice. -->
2169
+ <div class="card pol-pane" id="pol-pane-subjects" role="tabpanel" hidden>
2170
+ <div class="card-header"><h2>Subjects
2171
+ <button class="info" aria-label="About subjects"
2172
+ data-title="Subjects"
2173
+ 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."
2174
+ onclick="infoPop(this)">?</button>
2175
+ </h2></div>
2176
+ <div id="pol-subjects-body" class="content"><div class="empty">Loading…</div></div>
2177
+ </div>
2178
+
2179
+ <!-- Kept for back-compat — the legacy snapshot lives here but
2180
+ the new Roles sub-tab supersedes it. JS hides it once
2181
+ Roles renders successfully so we don't duplicate
2182
+ information. -->
2183
+ <div class="card" id="pol-legacy-snapshot" hidden>
2184
+ <div class="card-header"><h2>Active policy (legacy view)</h2></div>
2185
+ <div id="pol-roles" class="content"></div>
1844
2186
  </div>
1845
2187
  </div>
1846
2188
 
@@ -1938,6 +2280,11 @@ curl -s http://localhost:3000/api/enterprise/status</pre>
1938
2280
  <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
2281
  <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
2282
  <div class="form-group"><label>Client Key Path</label><input type="text" id="src-tls-key" placeholder="/path/to/client-key.pem"></div>
2283
+ <div class="form-group">
2284
+ <label for="src-tenant">Tenant <span class="t-sm" style="opacity:.6">(optional, admin-only)</span></label>
2285
+ <input type="text" id="src-tenant" placeholder="(blank = global)" autocomplete="off">
2286
+ <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>
2287
+ </div>
1941
2288
  <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
2289
  <div class="test-result" id="test-result"></div>
1943
2290
  </div>
@@ -1978,6 +2325,143 @@ curl -s http://localhost:3000/api/enterprise/status</pre>
1978
2325
  </div>
1979
2326
  </div>
1980
2327
 
2328
+ <!-- MCP Product Modal (Create + Edit) — 4-step wizard.
2329
+ The form fields keep their stable ids so save() and the
2330
+ existing field-by-field tests stay byte-identical; only the
2331
+ structural grouping + stepper navigation are new. -->
2332
+ <div class="modal-overlay" id="mcp-product-modal">
2333
+ <div class="modal mcp-product-wizard">
2334
+ <div class="modal-header">
2335
+ <h3 id="mcp-product-modal-title">New Product</h3>
2336
+ <button class="btn-icon" onclick="closeModal('mcp-product-modal')">&times;</button>
2337
+ </div>
2338
+ <!-- Stepper rail — bullets are clickable so an operator can
2339
+ jump back to any step they've already filled in. -->
2340
+ <div class="wiz-stepper" id="mcp-wiz-stepper" role="tablist" aria-label="Product wizard steps">
2341
+ <button class="wiz-step-btn" data-step="1" role="tab" aria-controls="wiz-pane-1" onclick="mcpWizGoto(1)">
2342
+ <span class="wiz-step-num">1</span><span class="wiz-step-lbl">Identity</span>
2343
+ </button>
2344
+ <span class="wiz-step-line"></span>
2345
+ <button class="wiz-step-btn" data-step="2" role="tab" aria-controls="wiz-pane-2" onclick="mcpWizGoto(2)">
2346
+ <span class="wiz-step-num">2</span><span class="wiz-step-lbl">Tools</span>
2347
+ </button>
2348
+ <span class="wiz-step-line"></span>
2349
+ <button class="wiz-step-btn" data-step="3" role="tab" aria-controls="wiz-pane-3" onclick="mcpWizGoto(3)">
2350
+ <span class="wiz-step-num">3</span><span class="wiz-step-lbl">Scope &amp; branding</span>
2351
+ </button>
2352
+ <span class="wiz-step-line"></span>
2353
+ <button class="wiz-step-btn" data-step="4" role="tab" aria-controls="wiz-pane-4" onclick="mcpWizGoto(4)">
2354
+ <span class="wiz-step-num">4</span><span class="wiz-step-lbl">Review &amp; publish</span>
2355
+ </button>
2356
+ </div>
2357
+ <div class="modal-body">
2358
+ <input type="hidden" id="mcp-product-mode" value="new">
2359
+ <input type="hidden" id="mcp-product-original-id" value="">
2360
+
2361
+ <!-- Step 1: Identity -->
2362
+ <div class="wiz-pane" id="wiz-pane-1" data-step="1" role="tabpanel">
2363
+ <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>
2364
+ <div class="form-group">
2365
+ <label for="mcp-product-id">Id</label>
2366
+ <input type="text" id="mcp-product-id" placeholder="e.g. ops-bundle" autocomplete="off">
2367
+ <div class="form-hint">Pattern: <code>[A-Za-z0-9][A-Za-z0-9._-]{0,63}</code>. Immutable once saved.</div>
2368
+ </div>
2369
+ <div class="form-group">
2370
+ <label for="mcp-product-name">Display name</label>
2371
+ <input type="text" id="mcp-product-name" placeholder="e.g. Operations Bundle" autocomplete="off">
2372
+ </div>
2373
+ <div class="form-group">
2374
+ <label for="mcp-product-description">Description <span class="t-sm" style="opacity:.6">(optional)</span></label>
2375
+ <input type="text" id="mcp-product-description" placeholder="One-sentence summary" autocomplete="off">
2376
+ </div>
2377
+ </div>
2378
+
2379
+ <!-- Step 2: Tools -->
2380
+ <div class="wiz-pane" id="wiz-pane-2" data-step="2" role="tabpanel" hidden>
2381
+ <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>
2382
+ <div class="form-group">
2383
+ <div id="mcp-product-tools-picker" class="tools-picker">
2384
+ <div class="tools-picker-hint t-sm">Loading…</div>
2385
+ </div>
2386
+ <!-- Hidden textarea kept for back-compat — the picker syncs
2387
+ selections here, and save() still reads from it. -->
2388
+ <textarea id="mcp-product-tools" rows="2" hidden></textarea>
2389
+ </div>
2390
+ </div>
2391
+
2392
+ <!-- Step 3: Scope & branding -->
2393
+ <div class="wiz-pane" id="wiz-pane-3" data-step="3" role="tabpanel" hidden>
2394
+ <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>
2395
+ <div class="form-row">
2396
+ <div class="form-group">
2397
+ <label for="mcp-product-status">Status</label>
2398
+ <select id="mcp-product-status">
2399
+ <option value="staging">staging (admin-only)</option>
2400
+ <option value="published">published (visible to agents)</option>
2401
+ </select>
2402
+ </div>
2403
+ <div class="form-group">
2404
+ <label for="mcp-product-tenant">Tenant <span class="t-sm" style="opacity:.6">(admin only)</span></label>
2405
+ <input type="text" id="mcp-product-tenant" placeholder="default" autocomplete="off">
2406
+ <div class="form-hint">Blank = same tenant as the calling user.</div>
2407
+ </div>
2408
+ </div>
2409
+ <div class="form-row">
2410
+ <div class="form-group">
2411
+ <label for="mcp-product-version">Version <span class="t-sm" style="opacity:.6">(optional)</span></label>
2412
+ <input type="text" id="mcp-product-version" placeholder="1.0.0" autocomplete="off">
2413
+ </div>
2414
+ <div class="form-group">
2415
+ <label for="mcp-product-color">Brand colour <span class="t-sm" style="opacity:.6">(optional)</span></label>
2416
+ <input type="text" id="mcp-product-color" placeholder="#3178c6" autocomplete="off">
2417
+ </div>
2418
+ </div>
2419
+ <div class="form-group">
2420
+ <label for="mcp-product-icon">Icon URL <span class="t-sm" style="opacity:.6">(optional)</span></label>
2421
+ <input type="text" id="mcp-product-icon" placeholder="https://example.com/icons/ops.svg" autocomplete="off">
2422
+ </div>
2423
+ </div>
2424
+
2425
+ <!-- Step 4: Review & publish -->
2426
+ <div class="wiz-pane" id="wiz-pane-4" data-step="4" role="tabpanel" hidden>
2427
+ <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>
2428
+ <div id="mcp-wiz-review"></div>
2429
+ </div>
2430
+
2431
+ <div id="mcp-product-error" class="form-hint" role="alert" aria-live="polite" style="color: var(--danger); display: none;"></div>
2432
+ </div>
2433
+ <div class="modal-footer">
2434
+ <button class="btn btn-ghost" onclick="closeModal('mcp-product-modal')">Cancel</button>
2435
+ <span style="flex:1"></span>
2436
+ <button class="btn btn-ghost" id="mcp-wiz-back" onclick="mcpWizBack()" hidden>Back</button>
2437
+ <button class="btn btn-primary" id="mcp-wiz-next" onclick="mcpWizNext()">Next</button>
2438
+ <button class="btn btn-primary" id="mcp-wiz-save" onclick="mcpProductSave()" hidden>Save Product</button>
2439
+ </div>
2440
+ </div>
2441
+ </div>
2442
+
2443
+ <!-- Agent preview modal — invoked from per-card "Preview as agent"
2444
+ button. Same /api/products/:id/preview backend the wizard
2445
+ Review pane could call, but the wizard uses the live registry
2446
+ in-form because the draft hasn't been saved yet. -->
2447
+ <div class="modal-overlay" id="mcp-agent-preview-modal">
2448
+ <div class="modal mcp-agent-preview" style="--mcp-agent-accent: var(--accent)">
2449
+ <div class="modal-header" style="border-left: 4px solid var(--mcp-agent-accent)">
2450
+ <h3 id="mcp-agent-preview-title">Agent preview</h3>
2451
+ <button class="btn-icon" onclick="closeModal('mcp-agent-preview-modal')">&times;</button>
2452
+ </div>
2453
+ <div class="modal-body">
2454
+ <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>
2455
+ <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>
2456
+ <div id="mcp-agent-preview-list" class="wiz-agent-preview"></div>
2457
+ </div>
2458
+ <div class="modal-footer">
2459
+ <span style="flex:1"></span>
2460
+ <button class="btn btn-primary" onclick="closeModal('mcp-agent-preview-modal')">Close</button>
2461
+ </div>
2462
+ </div>
2463
+ </div>
2464
+
1981
2465
  <div class="toast" id="toast-el"></div>
1982
2466
 
1983
2467
  <div class="drawer-ov" id="drawer-ov" onclick="closeDrawer()"></div>
@@ -2020,45 +2504,640 @@ function showPage(name) {
2020
2504
  }
2021
2505
 
2022
2506
  // --- Policies tab (PolicyEngine snapshot + dry-run) ---
2023
- async function loadPolicies() {
2507
+ // Classify the engine kind reported by /api/policy.engine into one of
2508
+ // the three banner styles. The kind is `builtin`, `file:<path>`, or
2509
+ // `opa:<url>`; the prefix is what we key on.
2510
+ function polEngineKind(engineStr) {
2511
+ const s = (engineStr || '').toLowerCase();
2512
+ if (s.startsWith('opa:')) return 'opa';
2513
+ if (s.startsWith('file:') || s.startsWith('file ')) return 'file';
2514
+ return 'builtin';
2515
+ }
2516
+ function polRenderEngineBanner(j) {
2517
+ const banner = document.getElementById('pol-engine-banner');
2518
+ if (!banner) return;
2519
+ const kind = polEngineKind(j.engine);
2520
+ banner.hidden = false;
2521
+ banner.className = 'pol-engine-banner eng-' + kind;
2522
+ // body[data-policy-engine="..."] drives CSS that disables every
2523
+ // [data-engine-required="file"] control when authoring isn't
2524
+ // supported by the active engine.
2525
+ document.body.setAttribute('data-policy-engine', kind);
2526
+ const titleEl = document.getElementById('pol-eb-title-text');
2527
+ const kindEl = document.getElementById('pol-eb-kind');
2528
+ const metaEl = document.getElementById('pol-eb-meta');
2529
+ const copyEl = document.getElementById('pol-eb-copy');
2530
+ const tenantPill = document.getElementById('pol-eb-tenant-aware');
2531
+ if (kindEl) kindEl.textContent = j.engine || 'unknown';
2532
+ if (tenantPill) tenantPill.hidden = !j.tenantAware;
2533
+ if (kind === 'file') {
2534
+ titleEl.textContent = 'Editable here';
2535
+ metaEl.textContent = 'source: ' + (j.engine || '');
2536
+ 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.';
2537
+ } else if (kind === 'opa') {
2538
+ titleEl.textContent = 'Read-only (OPA)';
2539
+ metaEl.textContent = 'evaluating Rego at ' + (j.engine || '').replace(/^opa:/i, '');
2540
+ 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.';
2541
+ } else {
2542
+ titleEl.textContent = 'Built-in defaults (read-only)';
2543
+ metaEl.textContent = j.note || 'DEFAULT_POLICY shipped with the build';
2544
+ 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.';
2545
+ }
2546
+ // Mirror the kind on the legacy badge in the header.
2024
2547
  const engineEl = document.getElementById('pol-engine');
2548
+ if (engineEl) {
2549
+ engineEl.textContent = 'engine: ' + (j.engine || 'unknown');
2550
+ engineEl.className = 'badge ' + (kind === 'opa' ? 'badge-warn' : kind === 'file' ? 'badge-ok' : '');
2551
+ }
2552
+ }
2553
+ // Policy snapshot — fetched once on page enter, then reused by the
2554
+ // sub-tab renderers (Roles matrix today; Bindings + Subjects in
2555
+ // later slices). Reset on every loadPolicies() call.
2556
+ let POL_SNAPSHOT = null;
2557
+ let POL_SELECTED_ROLE = null;
2558
+
2559
+ function polSetTab(name) {
2560
+ document.querySelectorAll('.pol-subtab').forEach((btn) => {
2561
+ btn.setAttribute('data-active', String(btn.getAttribute('data-pol-tab') === name));
2562
+ btn.setAttribute('aria-selected', String(btn.getAttribute('data-pol-tab') === name));
2563
+ });
2564
+ document.querySelectorAll('.pol-pane').forEach((p) => {
2565
+ p.hidden = p.id !== 'pol-pane-' + name;
2566
+ });
2567
+ // Lazy-load Subjects on first visit — it has its own endpoint
2568
+ // so deferring the fetch keeps the page-enter cost low.
2569
+ if (name === 'subjects') polLoadSubjects();
2570
+ if (name === 'bindings') polLoadBindings();
2571
+ }
2572
+
2573
+ // Bindings = the (subject → roles) view derived from /api/subjects.
2574
+ // We don't have a separate /api/bindings endpoint because the binding
2575
+ // data IS the subject data today (users carry roles; api-keys don't;
2576
+ // oidc groups map to a single role via the env catalog).
2577
+ let POL_BINDING_EDIT = null; // { kind, name } of the row being edited
2578
+ async function polLoadBindings() {
2579
+ const body = document.getElementById('pol-bindings-body');
2580
+ if (!body) return;
2581
+ // Reuse the subjects payload if already fetched — same data source.
2582
+ if (!POL_SUBJECTS) {
2583
+ try {
2584
+ const r = await fetch('/api/subjects');
2585
+ if (!r.ok) {
2586
+ body.innerHTML = '<div class="empty">Bindings view requires the <code>users:delete</code> permission (admin role).</div>';
2587
+ return;
2588
+ }
2589
+ POL_SUBJECTS = await r.json();
2590
+ } catch (e) {
2591
+ body.innerHTML = '<div class="empty">Subjects unavailable: ' + escHtml(String(e && e.message || e)) + '</div>';
2592
+ return;
2593
+ }
2594
+ }
2595
+ polRenderBindings(POL_SUBJECTS);
2596
+ }
2597
+ function polRenderBindings(s) {
2598
+ const body = document.getElementById('pol-bindings-body');
2599
+ if (!body) return;
2600
+ // Are user edits available? Only when OMCP_USERS_FILE is set
2601
+ // (server returns it under sources.users) — otherwise the PUT
2602
+ // endpoint 409s and the operator just gets a confusing failure.
2603
+ const userEditable = !!(s.sources && s.sources.users);
2604
+ const rows = [];
2605
+ for (const u of (s.users || [])) {
2606
+ const roles = (u.roles || []).map((r) => `<span class="pill">${escHtml(r)}</span>`).join(' ') || '<span class="t-sm" style="opacity:.5">—</span>';
2607
+ const action = userEditable
2608
+ ? `<button class="btn btn-ghost btn-sm" data-bind-edit data-bind-kind="user" data-bind-name="${escHtml(u.username)}">Edit</button>`
2609
+ : '<span class="t-sm" style="opacity:.55" title="Set OMCP_USERS_FILE to enable user-role editing">read-only</span>';
2610
+ rows.push(`<tr>
2611
+ <td><code>${escHtml(u.username)}</code></td>
2612
+ <td><span class="tag">user</span></td>
2613
+ <td>${roles}</td>
2614
+ <td>${escHtml(u.tenant || 'default')}</td>
2615
+ <td style="text-align:right">${action}</td>
2616
+ </tr>`);
2617
+ }
2618
+ for (const k of (s.apiKeys || [])) {
2619
+ rows.push(`<tr>
2620
+ <td><code>${escHtml(k.name)}</code></td>
2621
+ <td><span class="tag">api key</span></td>
2622
+ <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>
2623
+ <td>${escHtml(k.tenant || 'default')}</td>
2624
+ <td style="text-align:right"><span class="t-sm" style="opacity:.55">via <code>OMCP_API_KEYS</code></span></td>
2625
+ </tr>`);
2626
+ }
2627
+ for (const g of (s.oidcGroups || [])) {
2628
+ rows.push(`<tr>
2629
+ <td><code>${escHtml(g.claim)}</code></td>
2630
+ <td><span class="tag">oidc group</span></td>
2631
+ <td><span class="pill">${escHtml(g.role)}</span></td>
2632
+ <td><span class="t-sm" style="opacity:.55">—</span></td>
2633
+ <td style="text-align:right"><span class="t-sm" style="opacity:.55">via <code>OMCP_OIDC_ROLE_MAP</code></span></td>
2634
+ </tr>`);
2635
+ }
2636
+ if (rows.length === 0) {
2637
+ body.innerHTML = `<div class="pol-subjects-empty" style="padding:var(--sp-4)">
2638
+ No subjects configured. Set one of <code>OMCP_USERS_FILE</code> /
2639
+ <code>OMCP_API_KEYS</code> / <code>OMCP_OIDC_ROLE_MAP</code> to populate this view.
2640
+ </div>`;
2641
+ return;
2642
+ }
2643
+ body.innerHTML = `<table class="data-table" style="width:100%">
2644
+ <thead><tr><th>Subject</th><th>Kind</th><th>Roles</th><th>Tenant</th><th></th></tr></thead>
2645
+ <tbody>${rows.join('')}</tbody>
2646
+ </table>`;
2647
+ // Wire the per-row Edit buttons via delegation so role-name strings
2648
+ // can't escape into onclick context (same defence-in-depth as the
2649
+ // role list in Slice F).
2650
+ if (!body.dataset.delegated) {
2651
+ body.addEventListener('click', (ev) => {
2652
+ const btn = ev.target.closest('[data-bind-edit]');
2653
+ if (!btn) return;
2654
+ const kind = btn.getAttribute('data-bind-kind');
2655
+ const name = btn.getAttribute('data-bind-name');
2656
+ if (kind && name) polBindingEdit(kind, name);
2657
+ });
2658
+ body.dataset.delegated = '1';
2659
+ }
2660
+ }
2661
+ function polBindingEdit(kind, name) {
2662
+ POL_BINDING_EDIT = { kind, name };
2663
+ const subj = document.getElementById('pol-binding-subject');
2664
+ const subj2 = document.getElementById('pol-binding-subject-2');
2665
+ if (subj) subj.textContent = name;
2666
+ if (subj2) subj2.textContent = name;
2667
+ const errEl = document.getElementById('pol-binding-error');
2668
+ if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
2669
+ // Build the checklist from the active engine's role catalogue.
2670
+ const rolesBox = document.getElementById('pol-binding-roles');
2671
+ const knownRoles = (POL_SNAPSHOT && POL_SNAPSHOT.roles) || [];
2672
+ const currentRoles = kind === 'user'
2673
+ ? ((POL_SUBJECTS && POL_SUBJECTS.users.find((u) => u.username === name) || {}).roles || [])
2674
+ : [];
2675
+ if (rolesBox) {
2676
+ if (knownRoles.length === 0) {
2677
+ rolesBox.innerHTML = '<div class="empty">No roles declared in the active engine.</div>';
2678
+ } else {
2679
+ rolesBox.innerHTML = knownRoles.map((r) => {
2680
+ const checked = currentRoles.includes(r) ? 'checked' : '';
2681
+ return `<label class="tp-item" style="display:flex;align-items:center;gap:var(--sp-2);padding:var(--sp-1) 0;cursor:pointer">
2682
+ <input type="checkbox" data-pol-role value="${escHtml(r)}" ${checked}>
2683
+ <span style="font-family:'JetBrains Mono', ui-monospace, monospace;font-size:13px">${escHtml(r)}</span>
2684
+ </label>`;
2685
+ }).join('');
2686
+ }
2687
+ }
2688
+ document.getElementById('pol-binding-modal').classList.add('open');
2689
+ }
2690
+ async function polBindingSave() {
2691
+ if (!POL_BINDING_EDIT) return;
2692
+ const errEl = document.getElementById('pol-binding-error');
2693
+ const setErr = (msg) => { if (errEl) { errEl.textContent = msg; errEl.style.display = ''; } };
2694
+ if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
2695
+ if (POL_BINDING_EDIT.kind !== 'user') {
2696
+ setErr('Only local-user bindings are editable at runtime today.');
2697
+ return;
2698
+ }
2699
+ const checks = Array.from(document.querySelectorAll('#pol-binding-roles input[data-pol-role]:checked'))
2700
+ .map((el) => el.value);
2701
+ try {
2702
+ const r = await fetch('/api/users/' + encodeURIComponent(POL_BINDING_EDIT.name) + '/roles', {
2703
+ method: 'PUT',
2704
+ headers: { 'Content-Type': 'application/json' },
2705
+ body: JSON.stringify({ roles: checks }),
2706
+ });
2707
+ if (!r.ok) {
2708
+ const j = await r.json().catch(() => ({}));
2709
+ setErr(j.error || ('HTTP ' + r.status));
2710
+ return;
2711
+ }
2712
+ closeModal('pol-binding-modal');
2713
+ toast('Roles updated for ' + POL_BINDING_EDIT.name);
2714
+ // Invalidate + refresh the bindings view.
2715
+ POL_SUBJECTS = null;
2716
+ await polLoadBindings();
2717
+ } catch (e) {
2718
+ setErr('Save failed: ' + (e && e.message || e));
2719
+ }
2720
+ }
2721
+
2722
+ // Cache the subjects payload so re-entering the tab doesn't refetch.
2723
+ let POL_SUBJECTS = null;
2724
+ async function polLoadSubjects() {
2725
+ const body = document.getElementById('pol-subjects-body');
2726
+ if (!body) return;
2727
+ if (POL_SUBJECTS) {
2728
+ polRenderSubjects(POL_SUBJECTS);
2729
+ return;
2730
+ }
2731
+ try {
2732
+ const r = await fetch('/api/subjects');
2733
+ if (!r.ok) {
2734
+ body.innerHTML = '<div class="empty">Subjects view requires the <code>users:delete</code> permission (admin role).</div>';
2735
+ return;
2736
+ }
2737
+ POL_SUBJECTS = await r.json();
2738
+ polRenderSubjects(POL_SUBJECTS);
2739
+ } catch (e) {
2740
+ body.innerHTML = '<div class="empty">Subjects unavailable: ' + escHtml(String(e && e.message || e)) + '</div>';
2741
+ }
2742
+ }
2743
+ function polRenderSubjects(j) {
2744
+ const body = document.getElementById('pol-subjects-body');
2745
+ if (!body) return;
2746
+ const sources = j.sources || {};
2747
+ const usersHtml = (j.users || []).map((u) => `
2748
+ <tr>
2749
+ <td><code>${escHtml(u.username)}</code></td>
2750
+ <td>${escHtml(u.name)}</td>
2751
+ <td>${(u.roles || []).map((r) => `<span class="pill">${escHtml(r)}</span>`).join(' ') || '<span class="t-sm" style="opacity:.5">—</span>'}</td>
2752
+ <td>${escHtml(u.tenant || 'default')}</td>
2753
+ </tr>`).join('');
2754
+ const apiKeysHtml = (j.apiKeys || []).map((k) => `
2755
+ <tr>
2756
+ <td><code>${escHtml(k.name)}</code></td>
2757
+ <td>${escHtml(k.tenant || 'default')}</td>
2758
+ <td>${k.productId ? `<code>${escHtml(k.productId)}</code>` : '<span class="t-sm" style="opacity:.5">—</span>'}</td>
2759
+ <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>
2760
+ <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>
2761
+ </tr>`).join('');
2762
+ const groupsHtml = (j.oidcGroups || []).map((g) => `
2763
+ <tr>
2764
+ <td><code>${escHtml(g.claim)}</code></td>
2765
+ <td><span class="pill">${escHtml(g.role)}</span></td>
2766
+ </tr>`).join('');
2767
+ const usersTbl = usersHtml
2768
+ ? `<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>`
2769
+ : `<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>`;
2770
+ const apiKeysTbl = apiKeysHtml
2771
+ ? `<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>`
2772
+ : `<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>`;
2773
+ const groupsTbl = groupsHtml
2774
+ ? `<table class="data-table" style="width:100%"><thead><tr><th>Group / claim</th><th>Maps to role</th></tr></thead><tbody>${groupsHtml}</tbody></table>`
2775
+ : `<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>`;
2776
+ body.innerHTML = `
2777
+ <div class="pol-subjects-section">
2778
+ <h3>Users
2779
+ <span class="pol-subjects-count">${(j.users || []).length}</span>
2780
+ <span class="pol-subjects-source">${sources.users ? escHtml(sources.users) : 'OMCP_USERS_FILE'}</span>
2781
+ </h3>
2782
+ ${usersTbl}
2783
+ </div>
2784
+ <div class="pol-subjects-section">
2785
+ <h3>API keys
2786
+ <span class="pol-subjects-count">${(j.apiKeys || []).length}</span>
2787
+ <span class="pol-subjects-source">OMCP_API_KEYS</span>
2788
+ </h3>
2789
+ ${apiKeysTbl}
2790
+ </div>
2791
+ <div class="pol-subjects-section">
2792
+ <h3>OIDC groups
2793
+ <span class="pol-subjects-count">${(j.oidcGroups || []).length}</span>
2794
+ <span class="pol-subjects-source">OMCP_OIDC_ROLE_MAP</span>
2795
+ </h3>
2796
+ ${groupsTbl}
2797
+ </div>`;
2798
+ }
2799
+
2800
+ // Resources × actions catalogue, kept in sync with VALID_RESOURCES +
2801
+ // VALID_ACTIONS in src/auth/policy/loader.ts. Pinned client-side so
2802
+ // the matrix shows the FULL grid (granted vs not-granted) even when
2803
+ // a role has zero grants for a given resource — the empty cells are
2804
+ // information too. If the server adds a new resource / action,
2805
+ // extend this list; the policy.engine snapshot guards against
2806
+ // silent drift through its declared-roles catalogue.
2807
+ const POL_RESOURCES = ["sources","services","health","topology","settings","connectors","audit","catalog","users","redaction","products"];
2808
+ const POL_ACTIONS = ["read","write","delete","bypass"];
2809
+
2810
+ // Effective-permissions overlay: when a subject is selected the
2811
+ // matrix flips from "what does THIS role grant" to "what can THIS
2812
+ // subject do, and via which role". The overlay is pure client-side
2813
+ // composition over the existing /api/policy + /api/subjects snapshots
2814
+ // — no new endpoint. Null means overlay off (default = role-centric).
2815
+ let POL_EFFECTIVE_SUBJECT = null; // { kind: 'user'|'oidc', id: string, roles: string[] } | null
2816
+
2817
+ function polEffectiveSubjectsList() {
2818
+ // Build the dropdown options from the cached subjects payload.
2819
+ // Users carry an explicit roles[] array. OIDC groups map to a
2820
+ // single role via OMCP_OIDC_ROLE_MAP. API keys don't carry RBAC
2821
+ // roles in the current model, so they're omitted — selecting one
2822
+ // would always show "denied" everywhere, which is misleading.
2823
+ const out = [];
2824
+ if (!POL_SUBJECTS) return out;
2825
+ for (const u of (POL_SUBJECTS.users || [])) {
2826
+ out.push({ kind: 'user', id: u.username, label: u.username + ' (user)', roles: u.roles || [] });
2827
+ }
2828
+ for (const g of (POL_SUBJECTS.oidcGroups || [])) {
2829
+ out.push({ kind: 'oidc', id: g.claim, label: g.claim + ' (oidc group)', roles: [g.role] });
2830
+ }
2831
+ return out;
2832
+ }
2833
+
2834
+ async function polEffectiveEnsureSubjects() {
2835
+ // Lazy-load /api/subjects the first time the operator opens the
2836
+ // selector. Reuse the same cache the Bindings/Subjects tabs use.
2837
+ if (POL_SUBJECTS) return;
2838
+ try {
2839
+ const r = await fetch('/api/subjects');
2840
+ if (!r.ok) return;
2841
+ POL_SUBJECTS = await r.json();
2842
+ } catch (e) {
2843
+ // Silent — the selector will just show "no subjects".
2844
+ }
2845
+ }
2846
+
2847
+ function polEffectiveOnChange(ev) {
2848
+ const val = ev.target.value;
2849
+ if (!val) {
2850
+ POL_EFFECTIVE_SUBJECT = null;
2851
+ polRolesRender();
2852
+ return;
2853
+ }
2854
+ const list = polEffectiveSubjectsList();
2855
+ const found = list.find((s) => (s.kind + ':' + s.id) === val);
2856
+ POL_EFFECTIVE_SUBJECT = found || null;
2857
+ polRolesRender();
2858
+ }
2859
+
2860
+ async function polEffectiveOpen() {
2861
+ // Operator clicked the "Show effective permissions" affordance —
2862
+ // make sure the subjects cache is populated, then re-render so the
2863
+ // selector has options.
2864
+ await polEffectiveEnsureSubjects();
2865
+ polRolesRender();
2866
+ }
2867
+
2868
+ function polRolesRender() {
2869
+ const listEl = document.getElementById('pol-role-list');
2870
+ const detailEl = document.getElementById('pol-role-detail');
2871
+ if (!listEl || !detailEl || !POL_SNAPSHOT) return;
2872
+ const roles = POL_SNAPSHOT.roles || [];
2873
+ if (roles.length === 0) {
2874
+ listEl.innerHTML = '<div class="empty">No roles defined.</div>';
2875
+ detailEl.innerHTML = '<div class="empty">Define a role to see its permission matrix.</div>';
2876
+ return;
2877
+ }
2878
+ if (!POL_SELECTED_ROLE || !roles.includes(POL_SELECTED_ROLE)) {
2879
+ POL_SELECTED_ROLE = roles[0];
2880
+ }
2881
+ // Render the role list with grant counts. Selection is handled
2882
+ // via event delegation on the list container — keeps untrusted
2883
+ // role names (file-loaded policies can use any string) out of the
2884
+ // onclick attribute, where escHtml's HTML-special set doesn't
2885
+ // sanitise JS-string-literal characters like the single quote.
2886
+ listEl.innerHTML = roles.map((role) => {
2887
+ const grants = (POL_SNAPSHOT.policy && POL_SNAPSHOT.policy[role]) || [];
2888
+ const active = role === POL_SELECTED_ROLE;
2889
+ return `<div class="pol-role-row" role="option" data-role="${escHtml(role)}" data-active="${active}">
2890
+ <span class="pol-role-name">${escHtml(role)}</span>
2891
+ <span class="pol-role-count">${grants.length} grant${grants.length === 1 ? '' : 's'}</span>
2892
+ </div>`;
2893
+ }).join('');
2894
+ // Wire delegation once. Idempotent — re-rendering the inner HTML
2895
+ // doesn't lose the listener because it's bound to the outer list
2896
+ // element which persists. Guarded with a marker attribute so a
2897
+ // second loadPolicies call doesn't double-bind.
2898
+ if (!listEl.dataset.delegated) {
2899
+ listEl.addEventListener('click', (ev) => {
2900
+ const row = ev.target.closest('.pol-role-row');
2901
+ if (!row) return;
2902
+ const role = row.getAttribute('data-role');
2903
+ if (role) polSelectRole(role);
2904
+ });
2905
+ listEl.dataset.delegated = '1';
2906
+ }
2907
+ // Render the matrix for the selected role.
2908
+ const grants = (POL_SNAPSHOT.policy && POL_SNAPSHOT.policy[POL_SELECTED_ROLE]) || [];
2909
+ // Build a Set of "resource:action" pairs for O(1) lookup.
2910
+ const granted = new Set();
2911
+ for (const g of grants) granted.add(g.resource + ':' + g.action);
2912
+ const headerCells = POL_ACTIONS.map((a) => `<th>${escHtml(a)}</th>`).join('');
2913
+
2914
+ // Effective-overlay precompute. For each (resource, action) build a
2915
+ // list of roles (from the subject's role bundle) that grant it — so
2916
+ // the cell can show "via <role>" or "via <role> +N". Empty = denied.
2917
+ const effective = POL_EFFECTIVE_SUBJECT
2918
+ ? (() => {
2919
+ const m = new Map();
2920
+ for (const role of (POL_EFFECTIVE_SUBJECT.roles || [])) {
2921
+ const gs = (POL_SNAPSHOT.policy && POL_SNAPSHOT.policy[role]) || [];
2922
+ for (const g of gs) {
2923
+ const k = g.resource + ':' + g.action;
2924
+ if (!m.has(k)) m.set(k, []);
2925
+ m.get(k).push(role);
2926
+ }
2927
+ }
2928
+ return m;
2929
+ })()
2930
+ : null;
2931
+
2932
+ const bodyRows = POL_RESOURCES.map((res) => {
2933
+ const cells = POL_ACTIONS.map((act) => {
2934
+ const key = res + ':' + act;
2935
+ if (effective) {
2936
+ const viaRoles = effective.get(key) || [];
2937
+ const ownGrant = granted.has(key);
2938
+ if (viaRoles.length === 0) {
2939
+ return `<td class="cell-empty" data-grant="false" data-effective="denied" title="denied — subject has no role granting ${escHtml(res)}:${escHtml(act)}">denied</td>`;
2940
+ }
2941
+ const first = viaRoles[0];
2942
+ const extra = viaRoles.length > 1 ? ` <span class="t-sm" style="opacity:.55">+${viaRoles.length - 1}</span>` : '';
2943
+ const ownHint = ownGrant ? ' data-via-selected="true"' : '';
2944
+ // Show all granting roles in the title for an audit trail.
2945
+ const titleRoles = viaRoles.map((r) => r).join(', ');
2946
+ 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>`;
2947
+ }
2948
+ const has = granted.has(key);
2949
+ return has
2950
+ ? `<td class="cell-grant" data-grant="true" title="${escHtml(res)}:${escHtml(act)} granted">✓</td>`
2951
+ : `<td class="cell-empty" data-grant="false">—</td>`;
2952
+ }).join('');
2953
+ return `<tr><th scope="row"><code>${escHtml(res)}</code></th>${cells}</tr>`;
2954
+ }).join('');
2955
+
2956
+ // Effective-overlay bar — subject selector + clear control. Rendered
2957
+ // inside the detail panel so it survives polRolesRender re-renders
2958
+ // and stays visually attached to the matrix it modifies.
2959
+ const subjOpts = polEffectiveSubjectsList();
2960
+ const selectedKey = POL_EFFECTIVE_SUBJECT ? (POL_EFFECTIVE_SUBJECT.kind + ':' + POL_EFFECTIVE_SUBJECT.id) : '';
2961
+ const optEls = subjOpts.map((s) => {
2962
+ const v = s.kind + ':' + s.id;
2963
+ const sel = v === selectedKey ? ' selected' : '';
2964
+ return `<option value="${escHtml(v)}"${sel}>${escHtml(s.label)}</option>`;
2965
+ }).join('');
2966
+ const subjEmpty = subjOpts.length === 0;
2967
+ const overlayBar = `
2968
+ <div class="pol-effective-bar" style="display:flex;align-items:center;gap:var(--sp-2);margin-bottom:var(--sp-2);flex-wrap:wrap">
2969
+ <label class="t-sm" style="opacity:.7" for="pol-effective-subject">Show effective permissions for</label>
2970
+ <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">
2971
+ <option value="">(none — show role grants)</option>
2972
+ ${optEls}
2973
+ </select>
2974
+ ${POL_EFFECTIVE_SUBJECT
2975
+ ? `<button class="btn btn-ghost btn-sm" onclick="polEffectiveOnChange({target:{value:''}})">Clear</button>
2976
+ <span class="t-sm" style="opacity:.65">via roles: ${(POL_EFFECTIVE_SUBJECT.roles || []).map((r) => `<code>${escHtml(r)}</code>`).join(', ') || '<em>none</em>'}</span>`
2977
+ : (subjEmpty ? '<span class="t-sm" style="opacity:.55">No subjects configured — set OMCP_USERS_FILE or OMCP_OIDC_ROLE_MAP to populate.</span>' : '')
2978
+ }
2979
+ </div>`;
2980
+
2981
+ const titleSuffix = POL_EFFECTIVE_SUBJECT
2982
+ ? ` <span class="t-sm" style="opacity:.65;font-weight:400">— effective view for ${escHtml(POL_EFFECTIVE_SUBJECT.id)}</span>`
2983
+ : ` <span class="t-sm" style="opacity:.65;font-weight:400">${grants.length} grant${grants.length === 1 ? '' : 's'}</span>`;
2984
+
2985
+ const legend = POL_EFFECTIVE_SUBJECT
2986
+ ? `<p class="t-sm" style="opacity:.65;margin-top:var(--sp-3)">
2987
+ <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.
2988
+ </p>`
2989
+ : `<p class="t-sm" style="opacity:.65;margin-top:var(--sp-3)">
2990
+ ✓ = the role grants this resource:action combination. — = no grant. The
2991
+ <em>redaction:bypass</em> column is the operator-side gate for the per-call
2992
+ bypass; the credential must also be allow-listed via OMCP_KEY_BYPASS_REDACTION.
2993
+ </p>`;
2994
+
2995
+ detailEl.innerHTML = `
2996
+ <h3>${escHtml(POL_SELECTED_ROLE)}${titleSuffix}</h3>
2997
+ ${overlayBar}
2998
+ <table class="pol-matrix"><thead><tr><th>Resource</th>${headerCells}</tr></thead><tbody>${bodyRows}</tbody></table>
2999
+ ${legend}
3000
+ `;
3001
+ }
3002
+ function polSelectRole(role) {
3003
+ POL_SELECTED_ROLE = role;
3004
+ polRolesRender();
3005
+ }
3006
+
3007
+ function polRoleAuthorNew() {
3008
+ // Author modal — render a permission-matrix as a checkbox grid
3009
+ // (rows = resources × cols = actions) plus a role-name input.
3010
+ const nameEl = document.getElementById('pol-role-author-name');
3011
+ const gridEl = document.getElementById('pol-role-author-grid');
3012
+ const errEl = document.getElementById('pol-role-author-error');
3013
+ if (nameEl) nameEl.value = '';
3014
+ if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
3015
+ if (gridEl) {
3016
+ const headerCells = POL_ACTIONS.map((a) => `<th>${escHtml(a)}</th>`).join('');
3017
+ const bodyRows = POL_RESOURCES.map((res) => {
3018
+ const cells = POL_ACTIONS.map((act) => `<td style="text-align:center">
3019
+ <input type="checkbox" data-pol-resource="${escHtml(res)}" data-pol-action="${escHtml(act)}" aria-label="${escHtml(res)}:${escHtml(act)}">
3020
+ </td>`).join('');
3021
+ return `<tr><th scope="row"><code>${escHtml(res)}</code></th>${cells}</tr>`;
3022
+ }).join('');
3023
+ gridEl.innerHTML = `<table class="pol-matrix"><thead><tr><th>Resource</th>${headerCells}</tr></thead><tbody>${bodyRows}</tbody></table>`;
3024
+ }
3025
+ document.getElementById('pol-role-author-modal').classList.add('open');
3026
+ setTimeout(() => nameEl && nameEl.focus(), 50);
3027
+ }
3028
+ async function polRoleAuthorSave() {
3029
+ const nameEl = document.getElementById('pol-role-author-name');
3030
+ const errEl = document.getElementById('pol-role-author-error');
3031
+ const setErr = (msg) => { if (errEl) { errEl.textContent = msg; errEl.style.display = ''; } };
3032
+ if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
3033
+ const name = (nameEl && nameEl.value || '').trim();
3034
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name)) {
3035
+ setErr('Role name must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}.');
3036
+ nameEl && nameEl.focus();
3037
+ return;
3038
+ }
3039
+ const checks = Array.from(document.querySelectorAll('#pol-role-author-grid input[type=checkbox]:checked'));
3040
+ const perms = checks.map((el) => ({
3041
+ resource: el.getAttribute('data-pol-resource'),
3042
+ action: el.getAttribute('data-pol-action'),
3043
+ })).filter((p) => p.resource && p.action);
3044
+ try {
3045
+ const r = await fetch('/api/policy/roles/' + encodeURIComponent(name), {
3046
+ method: 'PUT',
3047
+ headers: { 'Content-Type': 'application/json' },
3048
+ body: JSON.stringify({ permissions: perms }),
3049
+ });
3050
+ if (!r.ok) {
3051
+ const j = await r.json().catch(() => ({}));
3052
+ setErr(j.error || ('HTTP ' + r.status));
3053
+ return;
3054
+ }
3055
+ closeModal('pol-role-author-modal');
3056
+ toast('Saved role ' + name);
3057
+ // Re-fetch the policy snapshot so the Roles list + matrix
3058
+ // reflect the change without a page reload.
3059
+ POL_SUBJECTS = null;
3060
+ POL_SELECTED_ROLE = name;
3061
+ await loadPolicies();
3062
+ } catch (e) {
3063
+ setErr('Save failed: ' + (e && e.message || e));
3064
+ }
3065
+ }
3066
+
3067
+ async function loadPolicies() {
2025
3068
  const rolesEl = document.getElementById('pol-roles');
2026
3069
  try {
2027
3070
  const r = await fetch('/api/policy');
2028
3071
  if (!r.ok) {
2029
- rolesEl.innerHTML = '<div class="empty">Policy view requires the <code>users:delete</code> permission (admin role).</div>';
2030
- engineEl.textContent = '';
3072
+ if (rolesEl) rolesEl.innerHTML = '';
3073
+ const listEl = document.getElementById('pol-role-list');
3074
+ const detailEl = document.getElementById('pol-role-detail');
3075
+ const msg = '<div class="empty">Policy view requires the <code>users:delete</code> permission (admin role).</div>';
3076
+ if (listEl) listEl.innerHTML = msg;
3077
+ if (detailEl) detailEl.innerHTML = '';
3078
+ const engineEl = document.getElementById('pol-engine');
3079
+ if (engineEl) engineEl.textContent = '';
3080
+ const banner = document.getElementById('pol-engine-banner');
3081
+ if (banner) banner.hidden = true;
3082
+ // Clear the body attribute so a stale value from a prior session
3083
+ // (e.g. logged-out tab refresh) doesn't keep authoring controls
3084
+ // dimmed under the wrong engine assumption.
3085
+ document.body.removeAttribute('data-policy-engine');
2031
3086
  return;
2032
3087
  }
2033
3088
  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);
3089
+ polRenderEngineBanner(j);
3090
+ POL_SNAPSHOT = j;
3091
+ // Invalidate subjects cache on each policy reload so the next
3092
+ // Subjects tab visit fetches fresh data (env vars / users file
3093
+ // may have changed since last visit).
3094
+ POL_SUBJECTS = null;
3095
+ // Stale overlay selection points at a subject that may no longer
3096
+ // exist (subjects cache will be repopulated on next visit)
3097
+ // safest to drop it on every policy reload.
3098
+ POL_EFFECTIVE_SUBJECT = null;
3099
+ // Default sub-tab = Roles.
3100
+ if (!document.querySelector('.pol-subtab[data-active="true"]')) {
3101
+ polSetTab('roles');
3102
+ }
3103
+ polRolesRender();
3104
+ // Warm the subjects cache in the background so the effective-
3105
+ // permissions selector has options the first time an operator
3106
+ // opens it (no fetch-on-focus jank). Failure is silent — the
3107
+ // selector falls back to its empty-state copy.
3108
+ polEffectiveEnsureSubjects().then(() => {
3109
+ if (POL_SUBJECTS && document.getElementById('pol-effective-subject')) polRolesRender();
3110
+ });
3111
+ // Legacy snapshot card kept hidden — the matrix supersedes it.
3112
+ if (rolesEl) {
3113
+ const blocks = [];
3114
+ for (const role of (j.roles || [])) {
3115
+ const grants = (j.policy && j.policy[role]) || [];
3116
+ const byRes = {};
3117
+ for (const g of grants) {
3118
+ (byRes[g.resource] = byRes[g.resource] || []).push(g.action);
3119
+ }
3120
+ const rows = Object.entries(byRes).map(([res, actions]) =>
3121
+ `<tr><td><code>${escHtml(res)}</code></td><td>${actions.map(a => `<span class="pill">${escHtml(a)}</span>`).join(' ')}</td></tr>`
3122
+ ).join('');
3123
+ blocks.push(`
3124
+ <div style="padding: var(--sp-3) var(--sp-4)">
3125
+ <h3 style="margin: 0 0 var(--sp-2); display:flex; gap: var(--sp-2); align-items:baseline;">
3126
+ <span>${escHtml(role)}</span>
3127
+ <span class="t-sm" style="opacity:.7">${grants.length} permission${grants.length===1?'':'s'}</span>
3128
+ </h3>
3129
+ <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>
3130
+ </div>`);
2043
3131
  }
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>`);
3132
+ rolesEl.innerHTML = blocks.join('') || '<div class="empty">No roles defined.</div>';
2059
3133
  }
2060
3134
  } catch (e) {
2061
- rolesEl.innerHTML = '<div class="empty">Policy unavailable: ' + escHtml(String(e && e.message || e)) + '</div>';
3135
+ const msg = '<div class="empty">Policy unavailable: ' + escHtml(String(e && e.message || e)) + '</div>';
3136
+ if (rolesEl) rolesEl.innerHTML = msg;
3137
+ const listEl = document.getElementById('pol-role-list');
3138
+ const detailEl = document.getElementById('pol-role-detail');
3139
+ if (listEl) listEl.innerHTML = msg;
3140
+ if (detailEl) detailEl.innerHTML = '';
2062
3141
  }
2063
3142
  }
2064
3143
  async function polDryRun() {
@@ -2066,24 +3145,28 @@ async function polDryRun() {
2066
3145
  const roles = document.getElementById('pol-dry-roles').value.trim();
2067
3146
  const resource = document.getElementById('pol-dry-resource').value.trim();
2068
3147
  const action = document.getElementById('pol-dry-action').value.trim();
3148
+ const tenant = document.getElementById('pol-dry-tenant').value.trim();
2069
3149
  if (!resource || !action) {
2070
3150
  out.innerHTML = '<div class="form-banner show error" style="margin-top: var(--sp-3)">Resource and action are required.</div>';
2071
3151
  return;
2072
3152
  }
2073
3153
  const params = new URLSearchParams({ resource, action });
2074
3154
  if (roles) params.set('roles', roles);
3155
+ if (tenant) params.set('tenant', tenant);
2075
3156
  try {
2076
3157
  const r = await fetch('/api/policy?' + params.toString());
2077
3158
  const j = await r.json();
2078
3159
  const d = j.dryRun || {};
2079
- const cls = d.allowed ? 'badge-ok' : 'badge-err';
2080
3160
  const verdict = d.allowed ? 'allowed' : 'denied';
3161
+ const cls = d.allowed ? 'allow' : 'deny';
3162
+ const tenantTag = d.tenant ? ` <span class="tag t-sm">tenant: ${escHtml(d.tenant)}</span>` : '';
2081
3163
  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>
3164
+ <div class="pol-probe-result">
3165
+ <span class="pol-pv ${cls}" data-verdict="${verdict}">${verdict}</span>
2084
3166
  <code>${escHtml(Array.isArray(d.roles) ? d.roles.join(',') : '')} → ${escHtml(resource)}:${escHtml(action)}</code>
3167
+ ${tenantTag}
2085
3168
  </div>
2086
- <div class="form-hint" style="margin-top: var(--sp-2)">${escHtml(d.reason || '')}</div>`;
3169
+ <div class="form-hint" style="margin-top: var(--sp-1)">${escHtml(d.reason || '')}</div>`;
2087
3170
  } catch (e) {
2088
3171
  out.innerHTML = '<div class="form-banner show error" style="margin-top: var(--sp-3)">Probe failed: ' + escHtml(String(e && e.message || e)) + '</div>';
2089
3172
  }
@@ -3073,14 +4156,267 @@ function closeModal(id) { document.getElementById(id).classList.remove('open');
3073
4156
  // --- Data Loading ---
3074
4157
  async function loadSources() { try { sourcesData=await(await fetch('/api/sources')).json(); renderSources(); updateStats(); } catch(e){} }
3075
4158
  // --- MCP Products (new /api/products surface) ---
4159
+ // View mode (cards | table) for the Products catalog. Persisted in
4160
+ // localStorage so an operator's preference survives reloads.
4161
+ function mcpProductsView() {
4162
+ try { return localStorage.getItem('omcp-mcp-products-view') === 'table' ? 'table' : 'cards'; }
4163
+ catch (e) { return 'cards'; }
4164
+ }
4165
+ function mcpProductsSetView(v) {
4166
+ try { localStorage.setItem('omcp-mcp-products-view', v); } catch (e) { /* noop */ }
4167
+ loadMcpProducts();
4168
+ }
4169
+
4170
+ // Leitfaden collapse state. Default expanded on first visit; once
4171
+ // the user collapses it the choice sticks. Idempotent — safe to
4172
+ // call before the DOM is ready (no-ops if the card isn't present).
4173
+ function mcpLeitfadenSync() {
4174
+ const card = document.getElementById('mcp-products-leitfaden');
4175
+ if (!card) return;
4176
+ let collapsed = false;
4177
+ try { collapsed = localStorage.getItem('omcp-mcp-leitfaden-collapsed') === '1'; } catch (e) { /* noop */ }
4178
+ card.classList.toggle('collapsed', collapsed);
4179
+ }
4180
+ function mcpLeitfadenToggle() {
4181
+ const card = document.getElementById('mcp-products-leitfaden');
4182
+ if (!card) return;
4183
+ const nextCollapsed = !card.classList.contains('collapsed');
4184
+ card.classList.toggle('collapsed', nextCollapsed);
4185
+ try { localStorage.setItem('omcp-mcp-leitfaden-collapsed', nextCollapsed ? '1' : '0'); } catch (e) { /* noop */ }
4186
+ }
4187
+
4188
+ // Cache the tool registry — fetched once on first modal open and
4189
+ // reused thereafter. Falsy means not-yet-loaded; an empty array
4190
+ // means the endpoint replied but with no tools (server bug — we
4191
+ // fall back to a textarea-style entry in that case).
4192
+ let MCP_TOOLS_REGISTRY = null;
4193
+ async function mcpLoadToolsRegistry() {
4194
+ if (MCP_TOOLS_REGISTRY) return MCP_TOOLS_REGISTRY;
4195
+ try {
4196
+ const r = await fetch('/api/tools/registry');
4197
+ if (!r.ok) throw new Error('HTTP ' + r.status);
4198
+ const j = await r.json();
4199
+ MCP_TOOLS_REGISTRY = Array.isArray(j.tools) ? j.tools : [];
4200
+ return MCP_TOOLS_REGISTRY;
4201
+ } catch (e) {
4202
+ // Surface but don't break the modal — fall back to []
4203
+ // (operator can still hand-edit via the textarea fallback).
4204
+ MCP_TOOLS_REGISTRY = [];
4205
+ return MCP_TOOLS_REGISTRY;
4206
+ }
4207
+ }
4208
+ function mcpRenderToolsPicker(selected) {
4209
+ const picker = document.getElementById('mcp-product-tools-picker');
4210
+ const textarea = document.getElementById('mcp-product-tools');
4211
+ if (!picker || !textarea) return;
4212
+ const tools = MCP_TOOLS_REGISTRY || [];
4213
+ if (tools.length === 0) {
4214
+ // Fallback: expose the textarea so the operator can still author
4215
+ // by hand. The server-side typo guard catches mistakes either way.
4216
+ textarea.hidden = false;
4217
+ textarea.value = (selected || []).join('\n');
4218
+ picker.innerHTML = '<div class="tools-picker-hint">Tool registry unavailable — type names one per line.</div>';
4219
+ return;
4220
+ }
4221
+ const selSet = new Set(selected || []);
4222
+ // Group by category, ordered. Keep an "(other)" bucket for any
4223
+ // future category we forgot in the CSS.
4224
+ const order = ['discovery', 'query', 'diagnose', 'topology'];
4225
+ const groups = {};
4226
+ for (const t of tools) (groups[t.category] = groups[t.category] || []).push(t);
4227
+ const labels = { discovery: 'Discovery', query: 'Query', diagnose: 'Diagnose', topology: 'Topology' };
4228
+ const html = order.filter((c) => groups[c]).map((cat) => {
4229
+ const items = groups[cat].map((t) => {
4230
+ const checked = selSet.has(t.name) ? 'checked' : '';
4231
+ return `<label class="tp-item">
4232
+ <input type="checkbox" data-tool="${escHtml(t.name)}" ${checked} onchange="mcpToolsPickerSync()">
4233
+ <span class="tp-text">
4234
+ <span class="tp-name">${escHtml(t.name)}</span>
4235
+ <span class="tp-summary">${escHtml(t.summary)}</span>
4236
+ </span>
4237
+ </label>`;
4238
+ }).join('');
4239
+ return `<div class="tp-group">
4240
+ <div class="tp-cat">${escHtml(labels[cat] || cat)}</div>
4241
+ ${items}
4242
+ </div>`;
4243
+ }).join('');
4244
+ picker.innerHTML = `
4245
+ <div class="tp-actions">
4246
+ <button type="button" class="btn btn-ghost" onclick="mcpToolsPickerAll(true)">Select all</button>
4247
+ <button type="button" class="btn btn-ghost" onclick="mcpToolsPickerAll(false)">Clear</button>
4248
+ </div>
4249
+ ${html}
4250
+ `;
4251
+ // Keep the hidden textarea in sync with the initial selection.
4252
+ textarea.value = (selected || []).join('\n');
4253
+ }
4254
+ function mcpToolsPickerSync() {
4255
+ const picker = document.getElementById('mcp-product-tools-picker');
4256
+ const textarea = document.getElementById('mcp-product-tools');
4257
+ if (!picker || !textarea) return;
4258
+ const checked = Array.from(picker.querySelectorAll('input[type=checkbox][data-tool]:checked'))
4259
+ .map((el) => el.getAttribute('data-tool'))
4260
+ .filter(Boolean);
4261
+ textarea.value = checked.join('\n');
4262
+ }
4263
+ function mcpToolsPickerAll(on) {
4264
+ const picker = document.getElementById('mcp-product-tools-picker');
4265
+ if (!picker) return;
4266
+ picker.querySelectorAll('input[type=checkbox][data-tool]').forEach((el) => { el.checked = on; });
4267
+ mcpToolsPickerSync();
4268
+ }
4269
+
4270
+ // Empty-state product templates. Cloning a template prefills the
4271
+ // modal — the operator only has to pick an id + tweak before save.
4272
+ // Templates are pure UI; the server doesn't know about them.
4273
+ const MCP_PRODUCT_TEMPLATES = {
4274
+ ops: {
4275
+ title: 'Ops Bundle',
4276
+ desc: 'Incident-response tools for an on-call SRE agent.',
4277
+ product: {
4278
+ id: '', name: 'Ops Bundle',
4279
+ description: 'Incident-response tools for the on-call SRE agent.',
4280
+ status: 'staging',
4281
+ tools: ['query_logs', 'query_metrics', 'get_service_health', 'detect_anomalies'],
4282
+ },
4283
+ },
4284
+ dev: {
4285
+ title: 'Dev Bundle',
4286
+ desc: 'Discovery + topology tools for a coding agent. Read-only.',
4287
+ product: {
4288
+ id: '', name: 'Dev Bundle',
4289
+ description: 'Discovery + topology tools for a coding agent. Read-only.',
4290
+ status: 'staging',
4291
+ tools: ['list_sources', 'list_services', 'get_topology'],
4292
+ },
4293
+ },
4294
+ compliance: {
4295
+ title: 'Compliance Bundle',
4296
+ desc: 'Logs query only — for an audit agent.',
4297
+ product: {
4298
+ id: '', name: 'Compliance Bundle',
4299
+ description: 'Logs query only — for an audit agent.',
4300
+ status: 'staging',
4301
+ tools: ['query_logs'],
4302
+ },
4303
+ },
4304
+ blank: {
4305
+ title: 'Blank',
4306
+ desc: 'Start from scratch.',
4307
+ product: { id: '', name: '', status: 'staging' },
4308
+ },
4309
+ };
4310
+ function mcpProductTemplate(key) {
4311
+ const t = MCP_PRODUCT_TEMPLATES[key];
4312
+ if (!t) return;
4313
+ mcpProductOpen('new', t.product);
4314
+ }
4315
+
4316
+ function mcpProductCardHtml(p) {
4317
+ const accent = (p.branding && typeof p.branding.color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(p.branding.color))
4318
+ ? p.branding.color : 'var(--accent)';
4319
+ const icon = (p.branding && typeof p.branding.iconUrl === 'string' && /^https?:\/\//.test(p.branding.iconUrl))
4320
+ ? `<img src="${escHtml(p.branding.iconUrl)}" alt="" onerror="this.style.display='none'">`
4321
+ : '◫';
4322
+ const status = p.status === 'staging'
4323
+ ? '<span class="pill" style="background:var(--warning-soft);color:var(--warning)" title="Hidden from non-admin agents">staging</span>'
4324
+ : '<span class="pill" style="background:var(--success-soft);color:var(--success)">published</span>';
4325
+ const meta = [p.id, p.version ? 'v' + p.version : null, 'tenant: ' + (p.tenant || 'default')]
4326
+ .filter(Boolean).map(escHtml).join(' · ');
4327
+ const tools = (p.tools || []);
4328
+ const toolsLabel = tools.length === 0
4329
+ ? '<span class="pcard-tool-all">no filter — every registered tool</span>'
4330
+ : tools.slice(0, 8).map((t) => `<span class="pcard-tool">${escHtml(t)}</span>`).join('') +
4331
+ (tools.length > 8 ? `<span class="pcard-tool">+${tools.length - 8} more</span>` : '');
4332
+ const desc = p.description
4333
+ ? escHtml(p.description)
4334
+ : '<span style="opacity:.5">No description.</span>';
4335
+ const idAttr = encodeURIComponent(p.id);
4336
+ return `<div class="pcard">
4337
+ <div class="pcard-rail" style="background:${accent}"></div>
4338
+ <div class="pcard-hd">
4339
+ <div class="pcard-icon" style="color:${accent}">${icon}</div>
4340
+ <div class="pcard-title">
4341
+ <h3>${escHtml(p.name)}</h3>
4342
+ <div class="pcard-meta">${meta}</div>
4343
+ </div>
4344
+ <div class="pcard-status">${status}</div>
4345
+ </div>
4346
+ <div class="pcard-desc">${desc}</div>
4347
+ <div>
4348
+ <div class="pcard-tools-label">Tools (${tools.length || 'unrestricted'})</div>
4349
+ <div class="pcard-tools">${toolsLabel}</div>
4350
+ </div>
4351
+ <div class="pcard-footer">
4352
+ <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>
4353
+ <div class="pcard-spacer"></div>
4354
+ <button class="btn-icon" data-rbac="products:write" title="Edit" aria-label="Edit ${escHtml(p.id)}" onclick="mcpProductsEdit('${idAttr}')">&#9998;</button>
4355
+ <button class="btn-icon" data-rbac="products:delete" title="Delete" aria-label="Delete ${escHtml(p.id)}" onclick="mcpProductsDelete('${idAttr}')">&#128465;</button>
4356
+ </div>
4357
+ </div>`;
4358
+ }
4359
+
4360
+ function mcpProductTableHtml(products) {
4361
+ const rows = products.map((p) => {
4362
+ const status = p.status === 'staging'
4363
+ ? '<span class="pill" style="background:var(--warning-soft);color:var(--warning)">staging</span>'
4364
+ : '<span class="pill" style="background:var(--success-soft);color:var(--success)">published</span>';
4365
+ const tools = (p.tools || []).slice(0, 5).map((t) => `<code class="t-sm">${escHtml(t)}</code>`).join(' ');
4366
+ const moreTools = (p.tools || []).length > 5 ? ` <span class="t-sm" style="opacity:.7">+${(p.tools.length - 5)} more</span>` : '';
4367
+ const tenantCell = `<span class="tag">${escHtml(p.tenant || 'default')}</span>`;
4368
+ return `<tr>
4369
+ <td><code>${escHtml(p.id)}</code></td>
4370
+ <td>${escHtml(p.name)}${p.description ? `<div class="t-sm" style="opacity:.7">${escHtml(p.description)}</div>` : ''}</td>
4371
+ <td>${tenantCell}</td>
4372
+ <td>${status}</td>
4373
+ <td>${tools || '<span class="t-sm" style="opacity:.5">all tools</span>'}${moreTools}</td>
4374
+ <td style="text-align:right">
4375
+ <button class="btn-icon" data-rbac="products:write" title="Edit" onclick="mcpProductsEdit('${encodeURIComponent(p.id)}')">&#9998;</button>
4376
+ <button class="btn-icon" data-rbac="products:delete" title="Delete" onclick="mcpProductsDelete('${encodeURIComponent(p.id)}')">&#128465;</button>
4377
+ </td>
4378
+ </tr>`;
4379
+ }).join('');
4380
+ return `<table class="data-table" style="width:100%">
4381
+ <thead><tr><th>id</th><th>Name</th><th>Tenant</th><th>Status</th><th>Tools</th><th></th></tr></thead>
4382
+ <tbody>${rows}</tbody>
4383
+ </table>`;
4384
+ }
4385
+
4386
+ function mcpProductEmptyHtml(configured) {
4387
+ const tpls = ['ops', 'dev', 'compliance', 'blank']
4388
+ .map((k) => {
4389
+ const t = MCP_PRODUCT_TEMPLATES[k];
4390
+ return `<button class="pempty-tpl" data-rbac="products:write" onclick="mcpProductTemplate('${k}')">
4391
+ <div class="pempty-tpl-title">${escHtml(t.title)}</div>
4392
+ <div class="pempty-tpl-desc">${escHtml(t.desc)}</div>
4393
+ </button>`;
4394
+ }).join('');
4395
+ const persistedHint = configured
4396
+ ? 'Changes here persist to <code>OMCP_PRODUCTS_FILE</code>.'
4397
+ : '<code>OMCP_PRODUCTS_FILE</code> is unset — changes live in memory only and won\'t survive a restart.';
4398
+ return `<div class="pempty">
4399
+ <h3>No products yet</h3>
4400
+ <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>
4401
+ <div class="pempty-templates" data-rbac="products:write">${tpls}</div>
4402
+ <p class="t-sm" style="margin-top:var(--sp-4);opacity:.7">${persistedHint}</p>
4403
+ </div>`;
4404
+ }
4405
+
3076
4406
  async function loadMcpProducts() {
4407
+ mcpLeitfadenSync();
3077
4408
  const box = document.getElementById('mcp-products-box');
3078
4409
  const scope = document.getElementById('mcp-products-scope');
3079
4410
  if (!box) return;
4411
+ // Sync view-toggle active state.
4412
+ const v = mcpProductsView();
4413
+ const btnC = document.getElementById('mcp-pv-cards');
4414
+ const btnT = document.getElementById('mcp-pv-table');
4415
+ if (btnC) btnC.classList.toggle('active', v === 'cards');
4416
+ if (btnT) btnT.classList.toggle('active', v === 'table');
3080
4417
  try {
3081
4418
  const r = await fetch('/api/products');
3082
4419
  if (!r.ok) {
3083
- // 403 = no products:read; render a friendly hint instead of an empty list
3084
4420
  if (r.status === 403) {
3085
4421
  box.innerHTML = '<div class="empty">Requires <code>products:read</code> permission (granted to viewer / operator / admin by default).</div>';
3086
4422
  } else {
@@ -3094,59 +4430,312 @@ async function loadMcpProducts() {
3094
4430
  scope.textContent = 'scope: ' + s + (j.includesStaging ? ' · staging visible' : '');
3095
4431
  }
3096
4432
  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>';
4433
+ box.innerHTML = mcpProductEmptyHtml(j.configured);
4434
+ omcpApplyRbacToDom();
3101
4435
  return;
3102
4436
  }
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>`;
4437
+ if (v === 'table') {
4438
+ box.innerHTML = mcpProductTableHtml(j.products);
4439
+ } else {
4440
+ box.innerHTML = `<div class="pcard-grid">${j.products.map(mcpProductCardHtml).join('')}</div>`;
4441
+ }
3126
4442
  omcpApplyRbacToDom();
3127
4443
  } catch (e) {
3128
4444
  box.innerHTML = '<div class="empty">/api/products unavailable: ' + escHtml(String(e && e.message || e)) + '</div>';
3129
4445
  }
3130
4446
  }
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' });
4447
+ // Form-driven Product modal — replaces the old chain of window.prompt
4448
+ // dialogs. Exposes id + name + description + status + tenant + tools
4449
+ // + version + branding in one place, validated client-side before
4450
+ // the server's strict parser sees it.
4451
+ // Wizard navigation state — current step (1..4) for the active
4452
+ // modal session. Reset on every mcpProductOpen call.
4453
+ let MCP_WIZ_STEP = 1;
4454
+ const MCP_WIZ_LAST = 4;
4455
+
4456
+ function mcpWizGoto(step) {
4457
+ if (step < 1 || step > MCP_WIZ_LAST) return;
4458
+ // Validate the current step before allowing forward navigation —
4459
+ // backwards / sidewards navigation always allowed (the operator
4460
+ // may want to revise).
4461
+ if (step > MCP_WIZ_STEP && !mcpWizValidateStep(MCP_WIZ_STEP)) return;
4462
+ MCP_WIZ_STEP = step;
4463
+ mcpWizRender();
4464
+ }
4465
+ function mcpWizNext() {
4466
+ if (MCP_WIZ_STEP < MCP_WIZ_LAST) mcpWizGoto(MCP_WIZ_STEP + 1);
4467
+ }
4468
+ function mcpWizBack() {
4469
+ if (MCP_WIZ_STEP > 1) mcpWizGoto(MCP_WIZ_STEP - 1);
4470
+ }
4471
+ function mcpWizValidateStep(step) {
4472
+ const errEl = document.getElementById('mcp-product-error');
4473
+ errEl.style.display = 'none';
4474
+ errEl.textContent = '';
4475
+ if (step === 1) {
4476
+ const id = document.getElementById('mcp-product-id').value.trim();
4477
+ const name = document.getElementById('mcp-product-name').value.trim();
4478
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(id)) {
4479
+ errEl.textContent = 'Id must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}.';
4480
+ errEl.style.display = '';
4481
+ document.getElementById('mcp-product-id').focus();
4482
+ return false;
4483
+ }
4484
+ if (!name) {
4485
+ errEl.textContent = 'Display name is required.';
4486
+ errEl.style.display = '';
4487
+ document.getElementById('mcp-product-name').focus();
4488
+ return false;
4489
+ }
4490
+ }
4491
+ // Step 2 (tools) + step 3 (scope/branding) have no required fields.
4492
+ return true;
4493
+ }
4494
+ function mcpWizRender() {
4495
+ // Toggle pane visibility.
4496
+ for (let i = 1; i <= MCP_WIZ_LAST; i++) {
4497
+ const pane = document.getElementById('wiz-pane-' + i);
4498
+ if (pane) pane.hidden = i !== MCP_WIZ_STEP;
4499
+ }
4500
+ // Update stepper bullets — active = current, done = all earlier.
4501
+ const stepper = document.getElementById('mcp-wiz-stepper');
4502
+ if (stepper) {
4503
+ stepper.querySelectorAll('.wiz-step-btn').forEach((btn) => {
4504
+ const n = parseInt(btn.getAttribute('data-step') || '0', 10);
4505
+ btn.setAttribute('data-active', String(n === MCP_WIZ_STEP));
4506
+ btn.setAttribute('data-done', String(n < MCP_WIZ_STEP));
4507
+ btn.setAttribute('aria-selected', String(n === MCP_WIZ_STEP));
4508
+ });
4509
+ }
4510
+ // Footer button visibility.
4511
+ const back = document.getElementById('mcp-wiz-back');
4512
+ const next = document.getElementById('mcp-wiz-next');
4513
+ const save = document.getElementById('mcp-wiz-save');
4514
+ if (back) back.hidden = MCP_WIZ_STEP === 1;
4515
+ if (next) next.hidden = MCP_WIZ_STEP === MCP_WIZ_LAST;
4516
+ if (save) save.hidden = MCP_WIZ_STEP !== MCP_WIZ_LAST;
4517
+ // Render the review summary lazily when entering step 4.
4518
+ if (MCP_WIZ_STEP === MCP_WIZ_LAST) mcpWizRenderReview();
4519
+ }
4520
+ function mcpWizRenderReview() {
4521
+ const out = document.getElementById('mcp-wiz-review');
4522
+ if (!out) return;
4523
+ const v = (id) => (document.getElementById(id).value || '').trim();
4524
+ const id = v('mcp-product-id');
4525
+ const name = v('mcp-product-name');
4526
+ const desc = v('mcp-product-description');
4527
+ const status = v('mcp-product-status');
4528
+ const tenant = v('mcp-product-tenant');
4529
+ const version = v('mcp-product-version');
4530
+ const color = v('mcp-product-color');
4531
+ const icon = v('mcp-product-icon');
4532
+ const toolsRaw = (document.getElementById('mcp-product-tools').value || '');
4533
+ const tools = toolsRaw.split(/[\n,]+/).map((s) => s.trim()).filter(Boolean);
4534
+ const empty = (s) => s ? escHtml(s) : '<span class="wiz-review-empty">—</span>';
4535
+ // Mirror mcpProductCardHtml's colour-validation regex so the
4536
+ // inline style attribute can't carry an arbitrary CSS value. Even
4537
+ // though escHtml prevents attribute breakout, restricting the
4538
+ // value to a hex literal makes a CSS-context injection
4539
+ // structurally impossible (defence-in-depth — reviewer-agent flag).
4540
+ const safeColor = /^#[0-9a-fA-F]{3,8}$/.test(color) ? color : null;
4541
+ const colorCell = !color
4542
+ ? '<span class="wiz-review-empty">— (no brand colour)</span>'
4543
+ : safeColor
4544
+ ? `<span class="wiz-review-swatch" style="background:${safeColor}"></span><code>${escHtml(color)}</code>`
4545
+ : `<code>${escHtml(color)}</code> <span class="t-sm" style="opacity:.6">(not a hex value)</span>`;
4546
+ const toolsCell = tools.length === 0
4547
+ ? '<span class="wiz-review-empty">no filter — every registered tool</span>'
4548
+ : `<div class="wiz-review-tools">${tools.map((t) => `<span class="pcard-tool">${escHtml(t)}</span>`).join('')}</div>`;
4549
+ out.innerHTML = `<dl class="wiz-review-grid">
4550
+ <dt>Id</dt><dd><code>${empty(id)}</code></dd>
4551
+ <dt>Name</dt><dd>${empty(name)}</dd>
4552
+ <dt>Description</dt><dd>${empty(desc)}</dd>
4553
+ <dt>Status</dt><dd><span class="pill">${empty(status)}</span></dd>
4554
+ <dt>Tenant</dt><dd>${tenant ? escHtml(tenant) : '<span class="wiz-review-empty">(caller\'s tenant)</span>'}</dd>
4555
+ <dt>Tools</dt><dd>${toolsCell}</dd>
4556
+ <dt>Version</dt><dd>${empty(version)}</dd>
4557
+ <dt>Colour</dt><dd>${colorCell}</dd>
4558
+ <dt>Icon URL</dt><dd>${icon ? `<code>${escHtml(icon)}</code>` : '<span class="wiz-review-empty">—</span>'}</dd>
4559
+ </dl>
4560
+ <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>
4561
+ <div id="mcp-wiz-agent-preview" class="wiz-agent-preview"><div class="t-sm" style="opacity:.7">${tools.length === 0
4562
+ ? 'No filter set. Agent would see every registered tool — load the live registry to confirm.'
4563
+ : `Filter active. Agent would see ${tools.length} tool${tools.length===1?'':'s'} from the allow-list.`}</div></div>`;
4564
+ // Resolve the preview from the live tool registry — same source the
4565
+ // server uses to filter /mcp tools/list. We don't hit
4566
+ // /api/products/:id/preview here because the wizard's form-state
4567
+ // hasn't been saved yet (the id may not exist on the server). The
4568
+ // resolution mirrors allowsTool's semantics exactly.
4569
+ mcpLoadToolsRegistry().then(() => {
4570
+ const previewEl = document.getElementById('mcp-wiz-agent-preview');
4571
+ if (!previewEl) return;
4572
+ const allow = new Set(tools);
4573
+ const filtered = (MCP_TOOLS_REGISTRY || []).filter((t) => tools.length === 0 || allow.has(t.name));
4574
+ previewEl.innerHTML = mcpAgentPreviewHtml(filtered, tools.length === 0);
4575
+ });
4576
+ }
4577
+
4578
+ // Shared renderer for the agent-preview list — used by the wizard
4579
+ // Review pane and by the per-card "Preview as agent" action.
4580
+ function mcpAgentPreviewHtml(tools, unrestricted) {
4581
+ if (!tools || tools.length === 0) {
4582
+ return '<div class="t-sm" style="opacity:.7">No tools available.</div>';
4583
+ }
4584
+ const order = ['discovery', 'query', 'diagnose', 'topology'];
4585
+ const groups = {};
4586
+ for (const t of tools) (groups[t.category] = groups[t.category] || []).push(t);
4587
+ const labels = { discovery: 'Discovery', query: 'Query', diagnose: 'Diagnose', topology: 'Topology' };
4588
+ const banner = unrestricted
4589
+ ? '<div class="wiz-agent-banner" data-unrestricted="true">Unrestricted — the bound agent sees every registered tool below.</div>'
4590
+ : '';
4591
+ const html = order.filter((c) => groups[c]).map((cat) => {
4592
+ const items = groups[cat].map((t) => `
4593
+ <div class="wiz-agent-tool">
4594
+ <div class="wiz-agent-tool-name">${escHtml(t.name)}</div>
4595
+ <div class="wiz-agent-tool-summary">${escHtml(t.summary)}</div>
4596
+ </div>`).join('');
4597
+ return `<div class="wiz-agent-group">
4598
+ <div class="tp-cat">${escHtml(labels[cat] || cat)}</div>
4599
+ ${items}
4600
+ </div>`;
4601
+ }).join('');
4602
+ return banner + html;
3137
4603
  }
4604
+
4605
+ // "Preview as agent" — called from the per-card button. Hits the
4606
+ // authoritative server endpoint so the operator sees what the bound
4607
+ // agent actually receives in production, not a client-side guess.
4608
+ async function mcpProductPreviewAgent(idEnc) {
4609
+ const id = decodeURIComponent(idEnc);
4610
+ let body;
4611
+ try {
4612
+ const r = await fetch('/api/products/' + encodeURIComponent(id) + '/preview');
4613
+ if (!r.ok) { toast('Preview unavailable: HTTP ' + r.status); return; }
4614
+ body = await r.json();
4615
+ } catch (e) { toast('Preview failed: ' + (e && e.message || e)); return; }
4616
+ const accent = (body.product && body.product.branding && /^#[0-9a-fA-F]{3,8}$/.test(body.product.branding.color || ''))
4617
+ ? body.product.branding.color : 'var(--accent)';
4618
+ const dlg = document.getElementById('mcp-agent-preview-modal');
4619
+ const title = document.getElementById('mcp-agent-preview-title');
4620
+ const meta = document.getElementById('mcp-agent-preview-meta');
4621
+ const list = document.getElementById('mcp-agent-preview-list');
4622
+ if (!dlg || !title || !meta || !list) return;
4623
+ title.textContent = body.product.name || body.product.id;
4624
+ 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')}`;
4625
+ list.innerHTML = mcpAgentPreviewHtml(body.tools || [], !!body.unrestricted);
4626
+ // Apply branding to the modal's left rail for a quick at-a-glance
4627
+ // identity confirmation that the right product was probed.
4628
+ dlg.style.setProperty('--mcp-agent-accent', accent);
4629
+ dlg.classList.add('open');
4630
+ }
4631
+
4632
+ function mcpProductOpen(mode, current) {
4633
+ const idEl = document.getElementById('mcp-product-id');
4634
+ const nameEl = document.getElementById('mcp-product-name');
4635
+ const descEl = document.getElementById('mcp-product-description');
4636
+ const statusEl = document.getElementById('mcp-product-status');
4637
+ const tenantEl = document.getElementById('mcp-product-tenant');
4638
+ const toolsEl = document.getElementById('mcp-product-tools');
4639
+ const verEl = document.getElementById('mcp-product-version');
4640
+ const colorEl = document.getElementById('mcp-product-color');
4641
+ const iconEl = document.getElementById('mcp-product-icon');
4642
+ const errEl = document.getElementById('mcp-product-error');
4643
+ const titleEl = document.getElementById('mcp-product-modal-title');
4644
+ document.getElementById('mcp-product-mode').value = mode;
4645
+ document.getElementById('mcp-product-original-id').value = (current && current.id) || '';
4646
+ errEl.style.display = 'none';
4647
+ errEl.textContent = '';
4648
+ if (mode === 'edit' && current) {
4649
+ titleEl.textContent = 'Edit Product · ' + current.id;
4650
+ idEl.value = current.id;
4651
+ idEl.disabled = true;
4652
+ nameEl.value = current.name || '';
4653
+ descEl.value = current.description || '';
4654
+ statusEl.value = current.status || 'published';
4655
+ tenantEl.value = current.tenant || '';
4656
+ toolsEl.value = (current.tools || []).join('\n');
4657
+ verEl.value = current.version || '';
4658
+ colorEl.value = (current.branding && current.branding.color) || '';
4659
+ iconEl.value = (current.branding && current.branding.iconUrl) || '';
4660
+ } else {
4661
+ // 'new' mode. When the caller hands us a partial `current`
4662
+ // (the empty-state templates do this), prefill every field
4663
+ // they supplied — the operator only has to pick an id + tweak.
4664
+ // Falsy `current` zeroes everything (legacy "fresh modal" path).
4665
+ const c = current || {};
4666
+ titleEl.textContent = 'New Product';
4667
+ idEl.value = c.id || '';
4668
+ idEl.disabled = false;
4669
+ nameEl.value = c.name || '';
4670
+ descEl.value = c.description || '';
4671
+ statusEl.value = c.status || 'staging';
4672
+ tenantEl.value = c.tenant || '';
4673
+ toolsEl.value = (c.tools || []).join('\n');
4674
+ verEl.value = c.version || '';
4675
+ colorEl.value = (c.branding && c.branding.color) || '';
4676
+ iconEl.value = (c.branding && c.branding.iconUrl) || '';
4677
+ }
4678
+ document.getElementById('mcp-product-modal').classList.add('open');
4679
+ // Reset wizard state to step 1 on every open. Edit mode could
4680
+ // start on step 4 (Review), but starting at step 1 keeps the UX
4681
+ // uniform — an editor stepping through their own config catches
4682
+ // surprises.
4683
+ MCP_WIZ_STEP = 1;
4684
+ mcpWizRender();
4685
+ // Build the tools picker from the registry — fetched once on the
4686
+ // first modal open and cached client-side. Selected = whatever was
4687
+ // already in the (potentially template-prefilled) textarea.
4688
+ mcpLoadToolsRegistry().then(() => {
4689
+ const seeded = (toolsEl.value || '').split(/[\n,]+/).map((s) => s.trim()).filter(Boolean);
4690
+ mcpRenderToolsPicker(seeded);
4691
+ });
4692
+ // When opening from a template the id is the one thing the operator
4693
+ // hasn't picked yet; in every other case (edit, blank new) the name
4694
+ // is the most natural focus target.
4695
+ const focusEl = (mode === 'edit') ? nameEl
4696
+ : (current && (current.name || (current.tools && current.tools.length))) ? idEl
4697
+ : idEl;
4698
+ setTimeout(() => focusEl.focus(), 50);
4699
+ }
4700
+ async function mcpProductsNew() { mcpProductOpen('new'); }
3138
4701
  async function mcpProductsEdit(idEnc) {
3139
4702
  const id = decodeURIComponent(idEnc);
3140
4703
  const r = await fetch('/api/products/' + encodeURIComponent(id));
3141
4704
  if (!r.ok) { toast('Could not fetch ' + id); return; }
3142
4705
  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) {
4706
+ mcpProductOpen('edit', current);
4707
+ }
4708
+ async function mcpProductSave() {
4709
+ const mode = document.getElementById('mcp-product-mode').value;
4710
+ const id = document.getElementById('mcp-product-id').value.trim();
4711
+ const name = document.getElementById('mcp-product-name').value.trim();
4712
+ const errEl = document.getElementById('mcp-product-error');
4713
+ function fail(msg) { errEl.textContent = msg; errEl.style.display = ''; }
4714
+ // Mirror the server-side ID_RE so the user sees the rule before the
4715
+ // round-trip — server is still the source of truth for rejection.
4716
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(id)) {
4717
+ fail('Id must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}.');
4718
+ return;
4719
+ }
4720
+ if (!name) { fail('Display name is required.'); return; }
4721
+ const body = { id, name, status: document.getElementById('mcp-product-status').value };
4722
+ const desc = document.getElementById('mcp-product-description').value.trim();
4723
+ if (desc) body.description = desc;
4724
+ const tenant = document.getElementById('mcp-product-tenant').value.trim();
4725
+ if (tenant) body.tenant = tenant;
4726
+ // Split on newlines or commas so paste-from-a-list works either way.
4727
+ const toolsRaw = document.getElementById('mcp-product-tools').value || '';
4728
+ const tools = toolsRaw.split(/[\n,]+/).map((s) => s.trim()).filter(Boolean);
4729
+ if (tools.length > 0) body.tools = tools;
4730
+ const version = document.getElementById('mcp-product-version').value.trim();
4731
+ if (version) body.version = version;
4732
+ const color = document.getElementById('mcp-product-color').value.trim();
4733
+ const icon = document.getElementById('mcp-product-icon').value.trim();
4734
+ if (color || icon) {
4735
+ body.branding = {};
4736
+ if (icon) body.branding.iconUrl = icon;
4737
+ if (color) body.branding.color = color;
4738
+ }
3150
4739
  try {
3151
4740
  const r = await fetch('/api/products/' + encodeURIComponent(id), {
3152
4741
  method: 'PUT',
@@ -3155,12 +4744,13 @@ async function mcpProductsUpsert(id, body) {
3155
4744
  });
3156
4745
  if (!r.ok) {
3157
4746
  const j = await r.json().catch(() => ({}));
3158
- toast('Save failed: ' + (j.error || r.status));
4747
+ fail(j.error || ('Save failed: HTTP ' + r.status));
3159
4748
  return;
3160
4749
  }
3161
- toast('Saved ' + id);
4750
+ closeModal('mcp-product-modal');
4751
+ toast((mode === 'edit' ? 'Updated ' : 'Created ') + id);
3162
4752
  await loadMcpProducts();
3163
- } catch (e) { toast('Save failed: ' + (e && e.message || e)); }
4753
+ } catch (e) { fail('Save failed: ' + (e && e.message || e)); }
3164
4754
  }
3165
4755
  async function mcpProductsDelete(idEnc) {
3166
4756
  const id = decodeURIComponent(idEnc);
@@ -3378,7 +4968,10 @@ function sortIndClass(key){
3378
4968
 
3379
4969
  function srcRow(s){
3380
4970
  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>`;
4971
+ // Show a tenant tag only when set single-tenant deployments
4972
+ // stay visually identical to the pre-tenant rows.
4973
+ const tenantTag = s.tenant ? `<span class="tag" title="Tenant ${esc(s.tenant)}">${esc(s.tenant)}</span>` : '';
4974
+ 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
4975
  }
3383
4976
  function renderSources() {
3384
4977
  const emptyDash = richEmpty({
@@ -3407,13 +5000,22 @@ function renderSources() {
3407
5000
  const filterBar=`<div class="list-filter">
3408
5001
  <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
5002
  <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>
5003
+ <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
5004
  </div>`;
3412
5005
  let body;
3413
5006
  if(view==='table'){
3414
5007
  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('')+
5008
+ // Show the tenant column only when at least one source is
5009
+ // taggedsingle-tenant deployments stay uncluttered.
5010
+ const anyTenant = visible.some(s => s.tenant);
5011
+ const tenantHead = anyTenant ? h('tenant','Tenant') : '';
5012
+ 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>`+
5013
+ visible.map(s=>{
5014
+ const tenantCell = anyTenant
5015
+ ? `<td>${s.tenant ? `<span class="badge">${esc(s.tenant)}</span>` : '<span class="t-muted">global</span>'}</td>`
5016
+ : '';
5017
+ 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>`;
5018
+ }).join('')+
3417
5019
  `</tbody></table>`;
3418
5020
  } else {
3419
5021
  body = visible.length === 0
@@ -3603,14 +5205,37 @@ function setAuthInForm(auth) {
3603
5205
  if(auth.type==='bearer') { document.getElementById('src-auth-token').value=auth.token||''; }
3604
5206
  toggleAuthFields();
3605
5207
  }
5208
+ // Admins (users:delete) can pick any tenant or leave blank (global);
5209
+ // non-admins are silently scoped to their own tenant by the server.
5210
+ // Pre-fill + disable for non-admins so the UX matches the server
5211
+ // posture — they SEE the constraint instead of typing into a field
5212
+ // that the server will then quietly rewrite.
5213
+ function _omcpApplyTenantFieldMode() {
5214
+ const inp = document.getElementById('src-tenant');
5215
+ if (!inp) return;
5216
+ const isAdmin = (typeof omcpCan === 'function') && omcpCan('users', 'delete');
5217
+ const sess = window.__omcpMe;
5218
+ const isAnonymous = !sess || sess.mode === 'anonymous';
5219
+ if (isAdmin || isAnonymous) {
5220
+ inp.disabled = false;
5221
+ inp.placeholder = '(blank = global)';
5222
+ } else {
5223
+ const own = (sess && sess.user && sess.user.tenant) || 'default';
5224
+ inp.value = own;
5225
+ inp.disabled = true;
5226
+ inp.placeholder = own;
5227
+ }
5228
+ }
3606
5229
  function openAddModal() {
3607
5230
  document.getElementById('modal-title').textContent='Add Source'; document.getElementById('modal-mode').value='add';
3608
5231
  document.getElementById('modal-original-name').value=''; document.getElementById('src-name').value='';
3609
5232
  document.getElementById('src-url').value=''; document.getElementById('src-enabled').checked=true;
5233
+ document.getElementById('src-tenant').value='';
3610
5234
  resetTlsFields();
3611
5235
  document.getElementById('src-name').disabled=false; resetAuthFields(); hideTestResult();
3612
5236
  setFieldError('src-name','src-name-err',null); setFieldError('src-url','src-url-err',null); clearSrcBanner();
3613
5237
  const sel=document.getElementById('src-type'); sel.innerHTML=supportedTypes.map(t=>`<option value="${t}">${t}</option>`).join('');
5238
+ _omcpApplyTenantFieldMode();
3614
5239
  document.getElementById('source-modal').classList.add('open');
3615
5240
  }
3616
5241
  function openEditModal(name) {
@@ -3619,8 +5244,10 @@ function openEditModal(name) {
3619
5244
  document.getElementById('modal-original-name').value=name; document.getElementById('src-name').value=s.name;
3620
5245
  document.getElementById('src-name').disabled=true; document.getElementById('src-url').value=s.url;
3621
5246
  document.getElementById('src-enabled').checked=s.enabled; setTlsInForm(s.tls); setAuthInForm(s.auth); hideTestResult();
5247
+ document.getElementById('src-tenant').value = s.tenant || '';
3622
5248
  setFieldError('src-name','src-name-err',null); setFieldError('src-url','src-url-err',null); clearSrcBanner();
3623
5249
  const sel=document.getElementById('src-type'); sel.innerHTML=supportedTypes.map(t=>`<option value="${t}" ${t===s.type?'selected':''}>${t}</option>`).join('');
5250
+ _omcpApplyTenantFieldMode();
3624
5251
  document.getElementById('source-modal').classList.add('open');
3625
5252
  }
3626
5253
  // --- Source modal form validation + banner ------------------------------
@@ -3680,6 +5307,8 @@ async function saveSource() {
3680
5307
  if (!okName || !okUrl) { showSrcBanner('error', 'Fix the highlighted fields and try again.'); return; }
3681
5308
  const mode=document.getElementById('modal-mode').value;
3682
5309
  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()};
5310
+ const tenant = document.getElementById('src-tenant').value.trim();
5311
+ if (tenant) src.tenant = tenant;
3683
5312
  const btn=document.getElementById('save-btn'); btn.disabled=true; btn.textContent='Saving...';
3684
5313
  try {
3685
5314
  let res; if(mode==='add') res=await fetch('/api/sources',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(src)});