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