From 77768ba3ca378870f89ec85fe30b8be897dd293f Mon Sep 17 00:00:00 2001 From: Artur Mukhamadiev Date: Tue, 15 Sep 2026 15:24:17 +0300 Subject: [PATCH] credentials: offer the approved Confluence origin as a fixed choice The origin check is an exact match including the context path, so users had to type "https://collab.lge.com/main" precisely. GET /api/v1/config now returns the approved origins in canonical form (non-secret: they are the only destinations the backend will talk to), and the UI swaps the URL text field for a select listing them, keeping the element id, focus handling and the Test connection flow unchanged. The text field remains the fallback when the fetch fails. Backend validation of the submitted URL is untouched. Mock server serves the endpoint; contract, API and e2e tests cover it. --- README.md | 8 ++-- backend/README.md | 2 +- backend/app.py | 20 +++++++- frontend/README.md | 1 + frontend/css/style.css | 5 ++ frontend/dev/mock-server.js | 7 +++ frontend/index.html | 2 +- frontend/js/api.js | 26 ++++++++++ frontend/js/app.js | 82 ++++++++++++++++++++++++++++---- frontend/tests/contract.test.js | 9 ++++ frontend/tests/e2e_runner.js | 12 +++++ tests/backend/test_config_api.py | 56 ++++++++++++++++++++++ 12 files changed, 215 insertions(+), 15 deletions(-) create mode 100644 tests/backend/test_config_api.py diff --git a/README.md b/README.md index c5e2035..b3829cb 100644 --- a/README.md +++ b/README.md @@ -49,10 +49,10 @@ make run # scripts/run-backend.sh deploy/confluence-web.env ``` The backend binds `127.0.0.1:8000` by default and serves the UI at -`http://127.0.0.1:8000/`. Open it in a browser, click the key icon, enter the -Confluence base URL **including its context path** (for example -`https://collab.lge.com/main`, which must match an approved origin), your -personal access token (PAT), test the connection, then ask a question. On +`http://127.0.0.1:8000/`. Open it in a browser, click the key icon, pick the +Confluence origin (the list comes from `CONFLUENCE_WEB_APPROVED_ORIGINS`, each +entry including its context path, for example `https://collab.lge.com/main`), +enter your personal access token (PAT), test the connection, then ask a question. On instances that allow anonymous REST reads, "Test connection" proves the destination is reachable but cannot prove the PAT is valid; a wrong token then shows up as denied or missing pages. Credentials live only in browser memory and in the backend diff --git a/backend/README.md b/backend/README.md index 1c9efc5..212be89 100644 --- a/backend/README.md +++ b/backend/README.md @@ -27,7 +27,7 @@ backend/ | Variable | Default | Description | |---|---|---| -| `CONFLUENCE_WEB_APPROVED_ORIGINS` | `https://approved.example.com` | Comma-separated list of approved Confluence base origins and context paths. | +| `CONFLUENCE_WEB_APPROVED_ORIGINS` | `https://approved.example.com` | Comma-separated list of approved Confluence base origins and context paths. Exposed in canonical form by `GET /api/v1/config`, which the UI uses to offer the origin as a fixed choice. | | `CONFLUENCE_WEB_CORPORATE_CA_PATH` | `None` | Path to corporate CA bundle for TLS verification if needed. | | `CONFLUENCE_WEB_MODEL_PROVIDER` | `fake` | Model provider adapter (`fake`, `openai`). | | `CONFLUENCE_WEB_MODEL_NAME` | `fake-model` | Model name (e.g. `gpt-4o`). | diff --git a/backend/app.py b/backend/app.py index 74f259d..18e6bd1 100644 --- a/backend/app.py +++ b/backend/app.py @@ -38,7 +38,7 @@ from backend.errors import ( ) from backend.model import FakeModelAdapter, ModelAdapter, OpenAIModelAdapter from backend.runner import QueryRunner -from backend.settings import Settings, validate_confluence_url +from backend.settings import Settings, canonicalize_url, validate_confluence_url logger = logging.getLogger(__name__) @@ -454,6 +454,24 @@ def create_app( get_or_set_session_cookie(request, resp, session_store) return resp + @app.get("/api/v1/config") + async def public_config(): + """Non-secret deployment facts the UI needs before any credentials exist. + + The approved Confluence origins are the destinations this backend will talk + to; the UI offers them as a fixed choice so users cannot mistype the origin or + its context path. Validation of the submitted URL is unchanged. + """ + origins: list[str] = [] + for origin in app_settings.approved_confluence_origins: + try: + canonical = canonicalize_url(origin) + except Exception: + continue + if canonical not in origins: + origins.append(canonical) + return JSONResponse(content={"approved_origins": origins}) + @app.post("/api/v1/queue/join") async def queue_join(req: QueueJoinRequest, request: Request): check_same_origin(request) diff --git a/frontend/README.md b/frontend/README.md index 7029c87..69725bd 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -47,6 +47,7 @@ frontend/ 1. **In-Memory Credentials**: - Confluence Base URL and Personal Access Token (PAT) reside strictly in browser JavaScript memory. + - The URL field is a select filled from `GET /api/v1/config` (the backend's approved origins, canonical form). If that fetch fails, the plain text input stays as the fallback. - Never written to `localStorage`, `sessionStorage`, cookies, query parameters, console logs, or exported files. - A `cw_session` HttpOnly cookie is set by the origin for artifact download ownership. 2. **Content Security Policy (CSP)**: diff --git a/frontend/css/style.css b/frontend/css/style.css index 4660e44..0b4f8be 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -1040,6 +1040,11 @@ body { transition: border-color 0.15s ease, box-shadow 0.15s ease; } +select.modal-input { + background-color: #fff; + cursor: pointer; +} + .modal-input:focus { border-color: var(--color-accent); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); diff --git a/frontend/dev/mock-server.js b/frontend/dev/mock-server.js index 9d89623..7f4b382 100644 --- a/frontend/dev/mock-server.js +++ b/frontend/dev/mock-server.js @@ -837,6 +837,13 @@ export function createMockServer() { } // API Endpoint 1: POST /api/v1/auth/verify + // Non-secret deployment facts: the approved Confluence origins offered as a fixed choice. + if (pathname === '/api/v1/config' && method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify({ approved_origins: ['https://approved.example.com'] })); + return; + } + if (pathname === '/api/v1/auth/verify' && method === 'POST') { let body = ''; req.on('data', (chunk) => { body += chunk; }); diff --git a/frontend/index.html b/frontend/index.html index 9326146..60be21f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -124,7 +124,7 @@ spellcheck="false" required > - Must be a valid HTTP or HTTPS URL (max 8 KiB) + Must be a valid HTTP or HTTPS URL (max 8 KiB)
diff --git a/frontend/js/api.js b/frontend/js/api.js index c134aba..5aa6aa7 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -230,6 +230,32 @@ export async function joinQueue({ signal } = {}) { return await response.json(); } +/** + * Fetches non-secret deployment facts: the approved Confluence origins the backend accepts. + * @param {{ signal?: AbortSignal }} [options] + * @returns {Promise<{ approved_origins: string[] }>} + */ +export async function fetchConfig({ signal } = {}) { + const response = await fetch('/api/v1/config', { + method: 'GET', + credentials: 'same-origin', + cache: 'no-store', + signal + }); + + if (!response.ok) { + throw await parseApiError(response); + } + + const data = await response.json(); + if (!data || !Array.isArray(data.approved_origins) || !data.approved_origins.every((o) => typeof o === 'string' && o.trim())) { + const err = new Error('Invalid config response.'); + err.code = 'invalid_response'; + throw err; + } + return data; +} + /** * Polls the current session's ticket status. Every call refreshes the server-side heartbeat. * @param {{ signal?: AbortSignal }} [options] diff --git a/frontend/js/app.js b/frontend/js/app.js index e772ba9..9828ca8 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -3,7 +3,7 @@ * Credentials remain strictly in browser memory and are never persisted or logged. */ -import { verifyCredentials, submitQuery, downloadArtifact, validateCredentials, joinQueue, queueStatus, leaveQueue } from './api.js'; +import { verifyCredentials, submitQuery, downloadArtifact, validateCredentials, joinQueue, queueStatus, leaveQueue, fetchConfig } from './api.js'; import { MarkdownRenderer, exportToMarkdown, renderWarnings } from './render.js'; import { renderPagesAccessed, renderToolHistory, renderArtifacts } from './history.js'; import { mountThinkingOrb } from './orb.js'; @@ -37,7 +37,7 @@ let viewPrompt, viewLoading, viewResult; let promptInput, submitBtn, promptError; let keyBtn, credIndicator, credStatusSr; let modalBackdrop, modalDialog, modalCloseBtn, credentialsForm; -let credUrlInput, credPatInput, togglePatBtn, modalFeedback; +let credUrlInput, credUrlHint, credPatInput, togglePatBtn, modalFeedback; let btnTestCred, btnSaveCred, btnCancelCred, btnClearCred; let cancelBtn, exitQueueBtn, loadingStatus, backBtn, exportBtn; let outputContent, sectionNav, resultWarnings; @@ -77,6 +77,7 @@ document.addEventListener('DOMContentLoaded', () => { modalCloseBtn = document.getElementById('modal-close-btn'); credentialsForm = document.getElementById('credentials-form'); credUrlInput = document.getElementById('cred-url'); + credUrlHint = document.getElementById('cred-url-hint'); credPatInput = document.getElementById('cred-pat'); togglePatBtn = document.getElementById('toggle-pat-btn'); modalFeedback = document.getElementById('modal-feedback'); @@ -119,6 +120,7 @@ document.addEventListener('DOMContentLoaded', () => { setupLoadingEvents(); setupResultEvents(); updateCredentialIndicator(); + loadApprovedOrigins(); // Best-effort ticket release on tab close/navigation while a ticket may still be held // (queued, reserved, or running). A keepalive DELETE beats nothing; if it does not land, @@ -442,10 +444,7 @@ function setupModalEvents() { }); // Typing in inputs invalidates any in-flight test connection - credUrlInput.addEventListener('input', () => { - abortActiveVerify(); - btnTestCred.disabled = false; - }); + bindUrlFieldEvents(credUrlInput); credPatInput.addEventListener('input', () => { abortActiveVerify(); btnTestCred.disabled = false; @@ -554,10 +553,10 @@ function openCredentialsModal() { modalFeedback.textContent = ''; if (committedCredentials) { - credUrlInput.value = committedCredentials.url; + setUrlFieldValue(committedCredentials.url); credPatInput.value = committedCredentials.pat; } else { - credUrlInput.value = draftCredentials.url || ''; + setUrlFieldValue(draftCredentials.url || ''); credPatInput.value = draftCredentials.pat || ''; } @@ -565,6 +564,73 @@ function openCredentialsModal() { credUrlInput.focus(); } +/** + * Any edit or choice in the URL field invalidates an in-flight test connection. + * @param {HTMLElement} field + */ +function bindUrlFieldEvents(field) { + for (const type of ['input', 'change']) { + field.addEventListener(type, () => { + abortActiveVerify(); + btnTestCred.disabled = false; + }); + } +} + +/** + * Sets the URL field value. A select falls back to its first option when the value is not + * one of the approved origins, so the field never shows an empty choice. + * @param {string} value + */ +function setUrlFieldValue(value) { + credUrlInput.value = value; + if (credUrlInput.tagName === 'SELECT' && credUrlInput.selectedIndex === -1) { + credUrlInput.selectedIndex = 0; + } +} + +/** + * Asks the backend for the approved Confluence origins and, when it answers, turns the URL + * text field into a fixed choice among them. The text field stays as the fallback for a + * backend without the endpoint or a failed fetch, so the modal always works. + */ +async function loadApprovedOrigins() { + let origins; + try { + ({ approved_origins: origins } = await fetchConfig()); + } catch { + return; + } + if (origins.length === 0) return; + applyApprovedOrigins(origins); +} + +/** + * Replaces the URL text input with a select listing the approved origins, keeping the + * element id so labels, focus handling and tests are unchanged. + * @param {string[]} origins + */ +function applyApprovedOrigins(origins) { + const select = document.createElement('select'); + select.id = credUrlInput.id; + select.className = credUrlInput.className; + select.required = true; + for (const origin of origins) { + const option = document.createElement('option'); + option.value = origin; + option.textContent = origin; + select.appendChild(option); + } + const previous = credUrlInput.value.trim(); + credUrlInput.replaceWith(select); + credUrlInput = select; + bindUrlFieldEvents(select); + setUrlFieldValue(previous); + credUrlHint.textContent = origins.length === 1 + ? 'The only Confluence origin this server is approved to reach' + : 'Confluence origins this server is approved to reach'; +} + /** * Closes credentials modal and restores focus to key button. */ diff --git a/frontend/tests/contract.test.js b/frontend/tests/contract.test.js index f305d0a..4b6e569 100644 --- a/frontend/tests/contract.test.js +++ b/frontend/tests/contract.test.js @@ -27,6 +27,15 @@ describe('Mock Server and Wire Contract Tests', () => { }); }); + test('GET /api/v1/config returns the approved origins with no-store', async () => { + const res = await fetch(`${BASE_URL}/api/v1/config`); + assert.equal(res.status, 200); + assert.equal(res.headers.get('cache-control'), 'no-store'); + const data = await res.json(); + assert.deepEqual(Object.keys(data), ['approved_origins']); + assert.deepEqual(data.approved_origins, ['https://approved.example.com']); + }); + test('GET / sets cw_session cookie and serves security headers', async () => { const res = await fetch(`${BASE_URL}/`); assert.equal(res.status, 200); diff --git a/frontend/tests/e2e_runner.js b/frontend/tests/e2e_runner.js index a8cd945..df9de73 100644 --- a/frontend/tests/e2e_runner.js +++ b/frontend/tests/e2e_runner.js @@ -155,6 +155,18 @@ async function runTests() { const focusedId = await cdp.eval('document.activeElement.id'); assert.equal(focusedId, 'cred-url', 'URL input should be focused on open'); + // The URL field is a fixed choice among the server's approved origins (GET /api/v1/config). + const urlField = await cdp.eval(`({ + tag: document.getElementById("cred-url").tagName, + options: Array.from(document.getElementById("cred-url").options || []).map((o) => o.value), + value: document.getElementById("cred-url").value, + hint: document.getElementById("cred-url-hint").textContent + })`); + assert.equal(urlField.tag, 'SELECT', 'URL field must become a select once config loads'); + assert.deepEqual(urlField.options, ['https://approved.example.com']); + assert.equal(urlField.value, 'https://approved.example.com', 'First approved origin must be preselected'); + assert.ok(urlField.hint.includes('approved'), `Hint should explain the fixed choice: ${urlField.hint}`); + // Test Cancel closes modal and restores focus await cdp.eval('document.getElementById("btn-cancel-cred").click()'); const isModalClosed = await cdp.eval('document.getElementById("modal-backdrop").classList.contains("hidden")'); diff --git a/tests/backend/test_config_api.py b/tests/backend/test_config_api.py new file mode 100644 index 0000000..a57ea49 --- /dev/null +++ b/tests/backend/test_config_api.py @@ -0,0 +1,56 @@ +"""API tests for GET /api/v1/config: approved origins offered to the UI as a fixed choice.""" + +from __future__ import annotations + +from pathlib import Path + +import httpx +import pytest +from httpx import ASGITransport + +from backend.app import create_app +from backend.artifacts import ArtifactStore +from backend.containers import FakeContainerManager +from backend.dev.fake_peer import ScriptedContainerPeer +from backend.model import FakeModelAdapter +from backend.settings import Settings + +from tests.backend.conftest import make_test_confluence_client_factory + + +def build_app(tmp_path: Path, origins: list[str]): + settings = Settings(approved_confluence_origins=origins) + return create_app( + settings=settings, + container_manager=FakeContainerManager(lambda: ScriptedContainerPeer(scenario="standard")), + artifact_store=ArtifactStore(tmp_path / "artifacts"), + model_adapter=FakeModelAdapter(), + confluence_client_factory=make_test_confluence_client_factory(), + ) + + +@pytest.mark.asyncio +async def test_config_lists_canonical_approved_origins(tmp_path: Path): + app = build_app(tmp_path, ["https://Collab.Example.com/main/", "https://approved.example.com", "https://approved.example.com/"]) + async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as client: + resp = await client.get("/api/v1/config") + assert resp.status_code == 200 + assert resp.headers.get("Cache-Control") == "no-store" + assert resp.json() == {"approved_origins": ["https://collab.example.com/main", "https://approved.example.com"]} + + +@pytest.mark.asyncio +async def test_config_needs_no_session_or_origin_header(tmp_path: Path): + app = build_app(tmp_path, ["https://approved.example.com"]) + async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as client: + resp = await client.get("/api/v1/config") + assert resp.status_code == 200 + assert "set-cookie" not in resp.headers + + +@pytest.mark.asyncio +async def test_config_rejects_other_methods(tmp_path: Path): + app = build_app(tmp_path, ["https://approved.example.com"]) + async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") as client: + resp = await client.post("/api/v1/config", json={}, headers={"Origin": "http://testserver"}) + assert resp.status_code == 405