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