diff --git a/.gitignore b/.gitignore index eca270c9..47932757 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,10 @@ __pycache__/ *.egg-info/ dist/ .env +config/tenants.yaml +config/tenants.yaml.tmp +config/playground_tenants.yaml +config/playground_tenants.yaml.tmp .DS_Store **/.DS_Store .coverage @@ -46,3 +50,7 @@ launch.json # log mcp_debug.log .cursor/ + +# nw-mcp-builder +nw-mcp-builder/fixtures/ +nw-mcp-builder/out/ diff --git a/docs/configuration.md b/docs/configuration.md index 11033b8d..d8d07ed0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -65,6 +65,17 @@ copy sample.env .env | `NW_JWT_ISSUER` | Expected JWT `iss` claim when any `*_JWT_SECRET` is set | _(required with JWT secret)_ | | `NW_SMTP_ALLOWED_HOSTS` | Optional comma-separated SMTP relay hostnames permitted for `smtp.send_email` (recommended for production) | _(unset = env relay only)_ | +### Multi-tenancy + +| Variable | Description | Default | +|----------|-------------|---------| +| `NW_MULTITENANCY_ENABLED` | When `true`, resolve tenant from header / `NW_TENANT_ID` / JWT and require a tenant (missing → error). When `false`, always `__default__`. | `false` | +| `NW_TENANT_ID` | **MCP stdio only** — pins the process to one tenant. Do not set on multi-tenant streamable-http (use `X-Tenant-ID` instead). Required when multitenancy is enabled for stdio. | _(unset)_ | +| `NW_TENANT_ID_HEADER` | HTTP/gRPC header name for tenant id (case-insensitive) | `X-Tenant-ID` | +| `NW_TENANTS_PATH` | Path to the YAML file that persists runtime named configs + tenant secret overlays (`config/tenants.yaml` by default; gitignored) | `config/tenants.yaml` | + +Named-tenant secrets use `NW_{TENANT}_{CONNECTOR}_{CONFIG}_{KEY}` (one credential vault per named config). MCP transport details: [mcp-servers.md](mcp-servers.md#multi-tenancy-mcp). + --- ## Configuration File (`config/connectors.yaml`) diff --git a/playground/README.md b/playground/README.md index 6cbbf49d..6f7e1758 100644 --- a/playground/README.md +++ b/playground/README.md @@ -94,21 +94,49 @@ The Agentic Workflow panel displays the active transport as a pill: Set the mode before starting the REST API: ```powershell -# Buffered stdio mode +# Playground agent: in-process MCP (recommended for local MT testing) +$env:MODE="API" $env:NW_MCP_TRANSPORT="stdio" +# Leave TOOLHIVE_MCP_URL empty unless ToolHive / a separate MCP server is running uv run node-wire ``` ```powershell -# Streamable HTTP mode +# Streamable HTTP UI + remote MCP proxy (requires something listening on the URL) +$env:MODE="API" $env:NW_MCP_TRANSPORT="streamable-http" -$env:NW_MCP_HOST="127.0.0.1" -$env:NW_MCP_PORT="8081" -$env:NW_MCP_PATH="/mcp" +$env:TOOLHIVE_MCP_URL="http://127.0.0.1:8081/mcp" +# In another terminal: start MCP — see docs/mcp.md +uv run node-wire +``` + +After changing `NW_MCP_TRANSPORT` / `TOOLHIVE_MCP_URL`, restart the backend and hard refresh the browser so the latest `app.js` and transport status are loaded. + +**Modes (do not confuse):** + +| Goal | What to run | +|------|-------------| +| Playground + connector scenarios + Agent (tenant-aware) | `MODE=API` → `http://127.0.0.1:8000/playground/` | +| Standalone MCP tools (Inspector / Claude) | `python -m agents.mcp_entrypoint` (`NW_MCP_TRANSPORT=stdio` or `streamable-http` on `:8081`) — see [docs/mcp.md](../docs/mcp.md) | +| Full binding as MCP process | `MODE=MCP` with same transport vars | + +If `TOOLHIVE_MCP_URL` points at `:8081` but nothing is listening, Agent chat fails with `All connection attempts failed`. Clear the URL or start an MCP server; by default the playground falls back to **in-process** MCP when the proxy cannot list tools. + +#### Multitenancy (`NW_MULTITENANCY_ENABLED`) + +Defaults to off (legacy single-tenant). When enabled: + +```powershell +$env:NW_MULTITENANCY_ENABLED="true" uv run node-wire ``` -After changing `NW_MCP_TRANSPORT`, restart the backend and hard refresh the browser so the latest `app.js` and transport status are loaded. +- **Tenant ID required**: connector/scenario calls without `X-Tenant-ID` return **400**. Explicit `__default__` is allowed. +- **Header**: Tenant dropdown (existing tenants) and Config dropdown appear when multitenancy is on. Header **Add config** is hidden; use **Add config** on each System Connector page. +- **Config dropdown**: lists configs for the **active connector** only under the selected tenant. Switching connectors clears a name that does not exist for the new connector. +- **Agentic Workflow**: sends the same `X-Tenant-ID` (and optional `config_name` query). Local agent MCP runs **in-process** against the playground factory. Streamable-HTTP / ToolHive proxy URLs receive `X-Tenant-ID` on each MCP HTTP request. (Agent Add-config UI is unchanged / out of scope for this flow.) +- **Per-connector Add config**: On a connector page, **Add config** opens a modal for that connector only (tenant free-text for new tenants, config name, default flag, and varying credentials). Each named config has its **own** credential vault (`NW_{TENANT}_{CONNECTOR}_{CONFIG}_{KEY}`). Shared host env (e.g. `EPIC_FHIR_BASE_URL`, `EPIC_TOKEN_URL`) is copied into that config’s secret overlay when omitted. Persist file: gitignored `config/tenants.yaml` (holds secrets — do not commit). +- **Tenant in logs**: When multitenancy is on, server INFO lines include `tenant_id` / `config_name` for Agent chat, connector scenarios, config mutations, REST connector calls, and MCP tool resolution. The playground Technical Audit panel also prints Tenant/Config for those actions. #### Testing the MCP server with Inspector diff --git a/playground/app.js b/playground/app.js index b2c49525..12ea5a19 100644 --- a/playground/app.js +++ b/playground/app.js @@ -329,6 +329,7 @@ document.addEventListener('DOMContentLoaded', () => { Back to Workspace `; } + syncMultitenancyUi(); log('Switched to AI Agent mode (MCP + LLM)', 'system'); } else if (view === 'connector-apps-menu') { rootSelectionView.classList.add('hidden'); @@ -345,6 +346,7 @@ document.addEventListener('DOMContentLoaded', () => { Back to Workspace `; } + syncMultitenancyUi(); log('Opened Connector Apps menu', 'system'); } else if (view === 'ext-patient-viewer') { document.getElementById('connector-apps-selection-view').classList.add('hidden'); @@ -365,6 +367,7 @@ document.addEventListener('DOMContentLoaded', () => { `; } setMode('ext-patient-viewer'); + syncMultitenancyUi(); } else { rootSelectionView.classList.add('hidden'); layoutMain.classList.remove('hidden'); @@ -384,6 +387,7 @@ document.addEventListener('DOMContentLoaded', () => { Back to Workspace `; } + syncMultitenancyUi(); log('Switched to Connectors view', 'system'); } }); @@ -618,6 +622,10 @@ document.addEventListener('DOMContentLoaded', () => { if (slackPanel) slackPanel.classList.add('hidden'); if (extViewerPanel) extViewerPanel.classList.add('hidden'); + if (typeof syncMultitenancyUi === 'function') { + syncMultitenancyUi(); + } + if (mode === 'ehr') { ehrPanel.classList.remove('hidden'); @@ -724,6 +732,8 @@ document.addEventListener('DOMContentLoaded', () => { if (backSelectionBtn) backSelectionBtn.classList.add('hidden'); if (backToConnectorsBtn) backToConnectorsBtn.classList.remove('hidden'); setMode(mode); + refreshConfigDropdown(mode); + syncMultitenancyUi(); }); }); @@ -738,6 +748,7 @@ document.addEventListener('DOMContentLoaded', () => { connectorStatus.textContent = 'Connectors Ready'; tagline.textContent = 'Enterprise Integration Suite'; document.documentElement.style.setProperty('--brand-accent', '#2563eb'); + syncMultitenancyUi(); log('Returned to Connectors list', 'system'); } }); @@ -779,6 +790,582 @@ document.addEventListener('DOMContentLoaded', () => { gdriveActionSelect.addEventListener('change', scheduleGdriveDriveActionSyncFromUser); } + // --- Multitenancy feature flag (fetched once on page load) --- + let _multitenancyEnabled = false; + + function syncMultitenancyUi() { + const headerTenancy = document.getElementById('header-tenancy'); + if (headerTenancy) { + headerTenancy.classList.toggle('hidden', !_multitenancyEnabled); + } + const addConfigBtn = document.getElementById('add-config-btn'); + if (addConfigBtn) { + // Simplified: always hide header Add config (Agent work later). + addConfigBtn.classList.add('hidden'); + addConfigBtn.hidden = true; + } + const connectorAddBtn = document.getElementById('connector-add-config-btn'); + if (connectorAddBtn) { + const onConnector = + !!_modeToConnectorId[currentMode] && + playgroundView && + !playgroundView.classList.contains('hidden'); + connectorAddBtn.classList.toggle('hidden', !_multitenancyEnabled || !onConnector); + } + if (!_multitenancyEnabled) { + closeConfigAdminModal(); + } + } + + async function openConfigAdminModal() { + const modal = document.getElementById('config-admin-modal'); + if (!modal) return; + const connectorId = currentConnectorId(); + if (!connectorId) { + log('Open a connector page before adding config.', 'error'); + return; + } + const tip = document.getElementById('config-admin-connector-tip'); + if (tip) tip.textContent = connectorId; + + const tenantId = getPlaygroundTenantId(); + const configName = getPlaygroundConfigName(); + if (_cfgTenantInput) { + _cfgTenantInput.value = tenantId || ''; + } + + modal.classList.remove('hidden'); + setConfigAdminError(''); + await refreshModalConfigSelect(tenantId, connectorId, configName); + await applyModalConfigSelection(connectorId); + refreshConfigDropdown(currentMode); + } + + function closeConfigAdminModal() { + const modal = document.getElementById('config-admin-modal'); + if (modal) modal.classList.add('hidden'); + setConfigAdminError(''); + } + + function syncCfgNameGroupVisibility() { + const select = document.getElementById('cfg-select'); + const nameGroup = document.getElementById('cfg-name-group'); + const nameEl = document.getElementById('cfg-name'); + if (!select || !nameGroup || !nameEl) return; + const isNew = !select.value; + nameGroup.classList.toggle('hidden', !isNew); + if (!isNew) { + nameEl.value = select.value; + nameEl.readOnly = true; + } else { + nameEl.readOnly = false; + if (!nameEl.value.trim()) nameEl.value = 'test'; + } + } + + async function listConfigsForTenantConnector(tenantId, connectorId) { + if (!tenantId || !connectorId) return []; + const headers = { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId }; + const res = await fetch( + `/v1/connectors/${encodeURIComponent(connectorId)}/configs`, + { headers } + ); + if (!res.ok) return []; + const data = await res.json(); + return Array.isArray(data.configs) + ? data.configs + : Array.isArray(data) + ? data + : []; + } + + async function refreshModalConfigSelect(tenantId, connectorId, preferredName) { + const select = document.getElementById('cfg-select'); + if (!select) return; + const prev = preferredName != null ? preferredName : select.value; + select.innerHTML = ''; + if (!tenantId || !connectorId) { + syncCfgNameGroupVisibility(); + return; + } + try { + const configs = await listConfigsForTenantConnector(tenantId, connectorId); + configs.forEach((cfg) => { + const opt = document.createElement('option'); + opt.value = cfg.name || ''; + opt.textContent = (cfg.name || '') + (cfg.default ? ' (default)' : ''); + select.appendChild(opt); + }); + if (prev && [...select.options].some((o) => o.value === prev)) { + select.value = prev; + } else if (prev && configs.length === 0) { + // Deleted / unknown name: stay on new, keep typed name. + select.value = ''; + const nameEl = document.getElementById('cfg-name'); + if (nameEl && prev) nameEl.value = prev; + } else { + select.value = ''; + } + } catch (_) { + // ignore + } + syncCfgNameGroupVisibility(); + } + + async function applyModalConfigSelection(connectorId) { + const select = document.getElementById('cfg-select'); + const nameEl = document.getElementById('cfg-name'); + const defaultEl = document.getElementById('cfg-default'); + const tenantId = modalTenantId(); + const selected = select && select.value ? select.value : ''; + syncCfgNameGroupVisibility(); + + let existingKeys = []; + if (defaultEl) defaultEl.value = 'true'; + + if (tenantId && selected && connectorId) { + if (nameEl) nameEl.value = selected; + try { + const headers = { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId }; + const cfgRes = await fetch( + `/v1/connectors/${encodeURIComponent(connectorId)}/configs/${encodeURIComponent(selected)}`, + { headers } + ); + if (cfgRes.ok) { + const doc = await cfgRes.json(); + if (defaultEl && typeof doc.default === 'boolean') { + defaultEl.value = doc.default ? 'true' : 'false'; + } + const secRes = await fetch( + `/v1/connectors/${encodeURIComponent(connectorId)}/secrets?config_name=${encodeURIComponent(selected)}`, + { headers } + ); + if (secRes.ok) { + const sec = await secRes.json(); + existingKeys = Array.isArray(sec.keys) ? sec.keys : []; + } + } + } catch (_) { + // Prefill is best-effort. + } + } + renderSecretFields(connectorId || currentConnectorId(), existingKeys); + } + + function modalSelectedConfigName() { + const select = document.getElementById('cfg-select'); + if (select && select.value) return select.value.trim(); + return (document.getElementById('cfg-name')?.value || 'test').trim(); + } + + async function loadFeatureFlags() { + try { + const res = await fetch('/playground/feature-flags'); + if (res.ok) { + const flags = await res.json(); + _multitenancyEnabled = !!flags.multitenancy_enabled; + } + } catch (_) { + // If endpoint is unreachable, keep default (disabled). + } + syncMultitenancyUi(); + if (_multitenancyEnabled) { + await refreshTenantDropdown(); + await refreshConfigDropdown(currentMode); + } + } + + // Header Tenant is a select of existing tenants; modal Tenant ID is free-text for new ones. + const _tenantInput = document.getElementById('playground-tenant-id'); + const _cfgTenantInput = document.getElementById('cfg-tenant-id'); + let _tenantSyncLock = false; + + function syncTenantInputs(source) { + if (_tenantSyncLock) return; + _tenantSyncLock = true; + try { + const value = source && source.value != null ? String(source.value).trim() : ''; + if (_cfgTenantInput && source !== _cfgTenantInput) { + // Do not overwrite free-text while user types a new tenant in the modal. + if (source === _tenantInput) _cfgTenantInput.value = value; + } + if (_tenantInput && source === _cfgTenantInput && value) { + ensureTenantOption(value, true); + } + } finally { + _tenantSyncLock = false; + } + } + + function ensureTenantOption(tenantId, selectIt) { + if (!_tenantInput || !tenantId) return; + let found = false; + for (const opt of _tenantInput.options) { + if (opt.value === tenantId) { + found = true; + break; + } + } + if (!found) { + const opt = document.createElement('option'); + opt.value = tenantId; + opt.textContent = tenantId; + _tenantInput.appendChild(opt); + } + if (selectIt) _tenantInput.value = tenantId; + } + + async function refreshTenantDropdown() { + if (!_multitenancyEnabled || !_tenantInput) return; + const prev = _tenantInput.value; + try { + const res = await fetch('/v1/tenants'); + if (!res.ok) return; + const data = await res.json(); + const tenants = Array.isArray(data.tenants) ? data.tenants : []; + _tenantInput.innerHTML = ''; + tenants.forEach((tid) => { + const opt = document.createElement('option'); + opt.value = tid; + opt.textContent = tid; + _tenantInput.appendChild(opt); + }); + if (prev && tenants.includes(prev)) { + _tenantInput.value = prev; + } else if (prev) { + ensureTenantOption(prev, true); + } + } catch (_) { + // ignore + } + } + + let _tenantDebounce = null; + function onTenantChanged() { + clearTimeout(_tenantDebounce); + _tenantDebounce = setTimeout(() => refreshConfigDropdown(currentMode), 200); + if (_cfgTenantInput && _tenantInput) { + syncTenantInputs(_tenantInput); + } + } + if (_tenantInput) { + _tenantInput.addEventListener('change', onTenantChanged); + } + if (_cfgTenantInput) { + _cfgTenantInput.addEventListener('input', () => syncTenantInputs(_cfgTenantInput)); + let _cfgTenantDebounce = null; + _cfgTenantInput.addEventListener('change', () => { + clearTimeout(_cfgTenantDebounce); + _cfgTenantDebounce = setTimeout(async () => { + const connectorId = currentConnectorId(); + const tenantId = modalTenantId(); + await refreshModalConfigSelect(tenantId, connectorId, ''); + await applyModalConfigSelection(connectorId); + }, 150); + }); + _cfgTenantInput.addEventListener('blur', async () => { + const connectorId = currentConnectorId(); + const tenantId = modalTenantId(); + await refreshModalConfigSelect(tenantId, connectorId, document.getElementById('cfg-select')?.value || ''); + await applyModalConfigSelection(connectorId); + }); + } + + document.getElementById('cfg-select')?.addEventListener('change', async () => { + await applyModalConfigSelection(currentConnectorId()); + }); + + // Map playground mode names to connector_id strings used by the config API. + const _modeToConnectorId = { + ehr: 'fhir_epic', + cerner: 'fhir_cerner', + gdrive: 'google_drive', + itops: 'http_generic', + slack: 'slack', + stripe: 'stripe', + salesforce: 'salesforce', + }; + + function currentConnectorId() { + return _modeToConnectorId[currentMode] || ''; + } + + // Self-contained auth/config templates matching config/connectors.yaml (secret refs only). + const _connectorCreateDocs = { + http_generic: { + config: {}, + auth: {}, + }, + google_drive: { + config: {}, + auth: { + provider: 'service_account', + sa_json_secret: 'GOOGLE_DRIVE_SA_JSON', + scopes: ['https://www.googleapis.com/auth/drive'], + }, + }, + fhir_epic: { + config: { + base_url: 'https://fhir.epic.sandbox/api/FHIR/R4', + }, + auth: { + provider: 'oauth2', + grant_method: 'private_key_jwt', + token_url_secret: 'EPIC_TOKEN_URL', + client_id_secret: 'EPIC_CLIENT_ID', + private_key_secret: 'EPIC_PRIVATE_KEY', + kid_secret: 'EPIC_KID', + algorithm: 'RS384', + }, + }, + fhir_cerner: { + config: { + base_url: 'https://fhir-ehr-code.cerner.com/r4/your-tenant-id', + }, + auth: { + provider: 'oauth2', + grant_method: 'private_key_jwt', + token_url_secret: 'CERNER_TOKEN_URL', + client_id_secret: 'CERNER_CLIENT_ID', + private_key_secret: 'CERNER_PRIVATE_KEY', + kid_secret: 'CERNER_KID', + algorithm: 'RS384', + scopes_secret: 'CERNER_SCOPES', + scopes: [ + 'system/Patient.read', + 'system/Encounter.read', + 'system/DocumentReference.read', + 'system/DocumentReference.write', + ], + }, + }, + slack: { + config: {}, + auth: { + provider: 'static_token', + secret_key: 'SLACK_BOT_TOKEN', + }, + }, + stripe: { + config: {}, + auth: { + provider: 'static_token', + secret_key: 'stripe_api_key', + header_name: 'Authorization', + prefix: '', + }, + }, + salesforce: { + config: {}, + auth: { + provider: 'oauth2', + grant_method: 'refresh_token', + token_url_secret: 'SALESFORCE_TOKEN_URL', + client_id_secret: 'SALESFORCE_CLIENT_ID', + client_secret_secret: 'SALESFORCE_CLIENT_SECRET', + refresh_token_secret: 'SALESFORCE_REFRESH_TOKEN', + }, + }, + }; + + // Varying-only secret fields shown in Add config (shared env auto-copied server-side). + // Format / required secret validation is server-side only (tenant_store). + const _connectorSecretFields = { + google_drive: [ + { key: 'GOOGLE_DRIVE_SA_JSON', label: 'Service account JSON', type: 'textarea', required: true }, + ], + fhir_epic: [ + { key: 'EPIC_CLIENT_ID', label: 'Client ID', type: 'text', required: true }, + { key: 'EPIC_PRIVATE_KEY', label: 'Private key (PEM)', type: 'textarea', required: true }, + { key: 'EPIC_KID', label: 'Key ID (kid)', type: 'text', required: true }, + ], + fhir_cerner: [ + { key: 'CERNER_CLIENT_ID', label: 'Client ID', type: 'text', required: true }, + { key: 'CERNER_PRIVATE_KEY', label: 'Private key (PEM)', type: 'textarea', required: true }, + { key: 'CERNER_KID', label: 'Key ID (kid)', type: 'text', required: true }, + { key: 'CERNER_SCOPES', label: 'Scopes (space-separated)', type: 'text', required: false }, + ], + slack: [ + { key: 'SLACK_BOT_TOKEN', label: 'Bot token', type: 'text', required: true }, + ], + stripe: [ + { key: 'stripe_api_key', label: 'API key', type: 'text', required: true }, + ], + salesforce: [ + { key: 'SALESFORCE_CLIENT_ID', label: 'Client ID', type: 'text', required: true }, + { key: 'SALESFORCE_CLIENT_SECRET', label: 'Client secret', type: 'text', required: true }, + { key: 'SALESFORCE_REFRESH_TOKEN', label: 'Refresh token', type: 'text', required: true }, + ], + http_generic: [], + }; + + function setConfigAdminError(message) { + const el = document.getElementById('cfg-error'); + if (!el) return; + if (message) { + el.textContent = message; + el.classList.remove('hidden'); + } else { + el.textContent = ''; + el.classList.add('hidden'); + } + } + + function renderSecretFields(connectorId, existingKeys = []) { + const host = document.getElementById('cfg-secret-fields'); + if (!host) return; + host.innerHTML = ''; + const fields = _connectorSecretFields[connectorId] || []; + const existing = new Set(existingKeys || []); + if (!fields.length) { + host.innerHTML = '
No credential fields for this connector.
'; + return; + } + fields.forEach((f) => { + const wrap = document.createElement('div'); + wrap.className = 'field-group'; + const alreadySet = existing.has(f.key); + const label = document.createElement('label'); + label.setAttribute('for', `cfg-secret-${f.key}`); + label.textContent = + f.label + (f.required && !alreadySet ? ' *' : '') + (alreadySet ? ' (saved)' : ''); + let input; + if (f.type === 'textarea') { + input = document.createElement('textarea'); + input.rows = 5; + } else { + input = document.createElement('input'); + input.type = 'text'; + } + input.id = `cfg-secret-${f.key}`; + input.dataset.secretKey = f.key; + input.dataset.alreadySet = alreadySet ? '1' : '0'; + input.autocomplete = 'off'; + input.value = ''; + if (alreadySet) { + input.placeholder = '(already set — leave blank to keep)'; + } else if (f.required) { + input.placeholder = 'Required'; + } + wrap.appendChild(label); + wrap.appendChild(input); + host.appendChild(wrap); + }); + if ([...existing].some((k) => fields.some((f) => f.key === k))) { + const hint = document.createElement('p'); + hint.className = 'field-help'; + hint.textContent = + 'Blank credential fields keep the previously saved value. Only type a field to change it.'; + host.appendChild(hint); + } + } + + function collectSecretFields() { + const host = document.getElementById('cfg-secret-fields'); + const secrets = {}; + if (!host) return secrets; + host.querySelectorAll('[data-secret-key]').forEach((el) => { + const key = el.dataset.secretKey; + const val = (el.value || '').trim(); + if (key && val) secrets[key] = val; + }); + return secrets; + } + + async function refreshConfigDropdown(modeOrConnectorId) { + if (!_multitenancyEnabled) return; + const tenantId = getPlaygroundTenantId(); + const select = document.getElementById('playground-config-name'); + if (!select) return; + const previous = select.value; + select.innerHTML = ''; + if (!tenantId) return; + + const connectorId = + _modeToConnectorId[modeOrConnectorId] || + (Object.values(_modeToConnectorId).includes(modeOrConnectorId) + ? modeOrConnectorId + : currentConnectorId()); + if (!connectorId) return; + + const headers = { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId }; + try { + const res = await fetch( + `/v1/connectors/${encodeURIComponent(connectorId)}/configs`, + { headers } + ); + if (!res.ok) return; + const data = await res.json(); + const configs = Array.isArray(data.configs) + ? data.configs + : Array.isArray(data) + ? data + : []; + let matched = false; + configs.forEach((cfg) => { + const opt = document.createElement('option'); + opt.value = cfg.name || ''; + opt.textContent = cfg.name + (cfg.default ? ' (default)' : ''); + select.appendChild(opt); + if (previous && opt.value === previous) matched = true; + }); + if (previous && matched) { + select.value = previous; + } else if (previous && !matched) { + // Clear stale name from another connector (e.g. Drive → Epic). + select.value = ''; + } else { + const def = configs.find((c) => c.default); + if (def) select.value = def.name || ''; + } + } catch (_) { + // ignore + } + } + + function getPlaygroundTenantId() { + if (!_multitenancyEnabled) return ''; + const el = document.getElementById('playground-tenant-id'); + return (el && el.value ? el.value : '').trim(); + } + + function getPlaygroundConfigName() { + if (!_multitenancyEnabled) return ''; + const el = document.getElementById('playground-config-name'); + return (el && el.value ? el.value : '').trim(); + } + + loadFeatureFlags(); + + function logTenancyContext(actionLabel) { + if (!_multitenancyEnabled) return; + const tenantId = getPlaygroundTenantId(); + const configName = getPlaygroundConfigName(); + const label = actionLabel ? `${actionLabel} — ` : ''; + log( + `${label}Tenant: ${tenantId || '(missing)'} | Config: ${configName || '(default)'}`, + 'system' + ); + } + + function playgroundRequestHeaders(extra = {}) { + const headers = { 'Content-Type': 'application/json', ...extra }; + if (!_multitenancyEnabled) return headers; + const tenantId = getPlaygroundTenantId(); + if (tenantId) { + headers['X-Tenant-ID'] = tenantId; + } + return headers; + } + + function withTenancyQuery(endpoint) { + if (!_multitenancyEnabled) return endpoint; + const configName = getPlaygroundConfigName(); + if (!configName) return endpoint; + const sep = endpoint.includes('?') ? '&' : '?'; + return `${endpoint}${sep}config_name=${encodeURIComponent(configName)}`; + } + async function handleSubmission(payload, endpoint, btn, btnLbl, spinner, resetText, pipelineLabelOverride = null) { resetUI(pipelineLabelOverride); @@ -787,22 +1374,36 @@ document.addEventListener('DOMContentLoaded', () => { btnLbl.textContent = 'Orchestrating...'; log(`Initiating intelligent ${currentMode.toUpperCase()} orchestration...`, 'system'); + logTenancyContext(currentMode.toUpperCase()); + const configName = getPlaygroundConfigName(); + if (configName) { + payload = { ...payload, config_name: configName }; + } + const firstNode = nodes.find((n) => n && !n.classList.contains('hidden')); if (firstNode) firstNode.classList.add('active'); try { - const response = await fetch(endpoint, { + const response = await fetch(withTenancyQuery(endpoint), { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: playgroundRequestHeaders(), body: JSON.stringify(payload) }); - if (!response.ok) throw new Error(`Server returned ${response.status}`); + const data = await response.json().catch(() => ({})); - const data = await response.json(); - traceDisplay.textContent = data.trace_id.toUpperCase(); + if (!response.ok) { + const detail = data.detail || data.message || `Server returned ${response.status}`; + let msg = typeof detail === 'string' ? detail : JSON.stringify(detail); + if (response.status === 403 && /No connector configuration/i.test(msg)) { + msg = `${msg} Open Add config on this connector page.`; + } + throw new Error(msg); + } - for (let i = 0; i < data.steps.length; i++) { + traceDisplay.textContent = (data.trace_id || '').toUpperCase() || '—'; + + for (let i = 0; i < (data.steps || []).length; i++) { const step = data.steps[i]; const node = nodes[i]; if (!node) continue; @@ -1250,6 +1851,135 @@ document.addEventListener('DOMContentLoaded', () => { } }); + // --- Per-connector runtime config CRUD --- + const addConfigBtn = document.getElementById('add-config-btn'); + const connectorAddConfigBtn = document.getElementById('connector-add-config-btn'); + + function modalTenantId() { + const fromModal = (_cfgTenantInput && _cfgTenantInput.value ? _cfgTenantInput.value : '').trim(); + return fromModal || getPlaygroundTenantId(); + } + + async function cfgFetchFor(connectorId, method, path = '', body = null, tenantOverride = null) { + const tenantId = (tenantOverride || getPlaygroundTenantId() || '').trim(); + if (!tenantId) { + throw new Error('Set Tenant ID before managing configs (e.g. acme).'); + } + const opts = { + method, + headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId }, + }; + if (body != null) { + opts.body = JSON.stringify(body); + } + const response = await fetch( + `/v1/connectors/${encodeURIComponent(connectorId)}/configs${path}`, + opts + ); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + const detail = data.detail || `HTTP ${response.status}`; + throw new Error(typeof detail === 'string' ? detail : JSON.stringify(detail)); + } + return data; + } + + if (addConfigBtn) { + addConfigBtn.addEventListener('click', () => { + openConfigAdminModal(); + }); + } + if (connectorAddConfigBtn) { + connectorAddConfigBtn.addEventListener('click', () => { + openConfigAdminModal(); + }); + } + + document.getElementById('config-admin-close')?.addEventListener('click', () => { + closeConfigAdminModal(); + }); + + document.getElementById('config-admin-modal')?.addEventListener('click', (ev) => { + if (ev.target && ev.target.hasAttribute('data-config-admin-dismiss')) { + closeConfigAdminModal(); + } + }); + + document.addEventListener('keydown', (ev) => { + if (ev.key !== 'Escape') return; + const modal = document.getElementById('config-admin-modal'); + if (modal && !modal.classList.contains('hidden')) { + closeConfigAdminModal(); + } + }); + + document.getElementById('cfg-create')?.addEventListener('click', async () => { + setConfigAdminError(''); + try { + const connectorId = currentConnectorId(); + if (!connectorId) throw new Error('Open a connector page first.'); + const tenantId = modalTenantId(); + if (!tenantId) throw new Error('Tenant ID is required'); + const name = modalSelectedConfigName(); + if (!name) throw new Error('Config name is required'); + const isDefault = document.getElementById('cfg-default')?.value === 'true'; + const secrets = collectSecretFields(); + const tmpl = _connectorCreateDocs[connectorId] || { config: {}, auth: {} }; + const body = { + name, + default: isDefault, + config: tmpl.config || {}, + auth: tmpl.auth || {}, + secrets, + }; + logTenancyContext(`Config SAVE ${connectorId}`); + await cfgFetchFor(connectorId, 'POST', '', body, tenantId); + ensureTenantOption(tenantId, true); + await refreshTenantDropdown(); + ensureTenantOption(tenantId, true); + await refreshConfigDropdown(currentMode); + const headerCfg = document.getElementById('playground-config-name'); + if (headerCfg) headerCfg.value = name; + await refreshModalConfigSelect(tenantId, connectorId, name); + await applyModalConfigSelection(connectorId); + log(`Saved config '${name}' for ${connectorId} / tenant '${tenantId}'`, 'success'); + } catch (err) { + setConfigAdminError(err.message); + log(`Config save failed: ${err.message}`, 'error'); + } + }); + + document.getElementById('cfg-delete')?.addEventListener('click', async () => { + setConfigAdminError(''); + try { + const connectorId = currentConnectorId(); + if (!connectorId) throw new Error('Open a connector page first.'); + const tenantId = modalTenantId(); + const name = modalSelectedConfigName(); + if (!name) throw new Error('Config name is required'); + const newDefault = name === 'default' ? '' : 'default'; + const q = newDefault ? `?new_default=${encodeURIComponent(newDefault)}` : ''; + await cfgFetchFor( + connectorId, + 'DELETE', + `/${encodeURIComponent(name)}${q}`, + null, + tenantId + ); + log(`Deleted config '${name}' for ${connectorId} / tenant '${tenantId}'`, 'success'); + const configSelect = document.getElementById('playground-config-name'); + if (configSelect && configSelect.value === name) { + configSelect.value = ''; + } + await refreshConfigDropdown(currentMode); + await refreshModalConfigSelect(tenantId, connectorId, ''); + await applyModalConfigSelection(connectorId); + } catch (err) { + setConfigAdminError(err.message); + log(`Config delete failed: ${err.message}`, 'error'); + } + }); + if (slackActionSelect) { slackActionSelect.addEventListener('change', () => { const action = slackActionSelect.value; @@ -1557,9 +2287,13 @@ document.addEventListener('DOMContentLoaded', () => { } startRunningTimer(); - const response = await fetch('/scenarios/agent-chat-stream', { + if (_multitenancyEnabled && !getPlaygroundTenantId()) { + throw new Error('Tenant ID is required when multitenancy is enabled'); + } + logTenancyContext('Agent chat (stream)'); + const response = await fetch(withTenancyQuery('/scenarios/agent-chat-stream'), { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: playgroundRequestHeaders(), body: JSON.stringify({ message: message, history: agentConversationHistory.slice(0, -1) @@ -1637,9 +2371,13 @@ document.addEventListener('DOMContentLoaded', () => { return; } - const response = await fetch('/scenarios/agent-chat', { + if (_multitenancyEnabled && !getPlaygroundTenantId()) { + throw new Error('Tenant ID is required when multitenancy is enabled'); + } + logTenancyContext('Agent chat'); + const response = await fetch(withTenancyQuery('/scenarios/agent-chat'), { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: playgroundRequestHeaders(), body: JSON.stringify({ message: message, history: agentConversationHistory.slice(0, -1) // Exclude current message (already in payload) diff --git a/playground/index.html b/playground/index.html index 0882d126..6bf17cf4 100644 --- a/playground/index.html +++ b/playground/index.html @@ -54,6 +54,23 @@