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.
This commit is contained in:
Artur Mukhamadiev 2026-09-15 15:24:17 +03:00
parent 1d7b867986
commit 77768ba3ca
12 changed files with 215 additions and 15 deletions

View File

@ -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 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 `http://127.0.0.1:8000/`. Open it in a browser, click the key icon, pick the
Confluence base URL **including its context path** (for example Confluence origin (the list comes from `CONFLUENCE_WEB_APPROVED_ORIGINS`, each
`https://collab.lge.com/main`, which must match an approved origin), your entry including its context path, for example `https://collab.lge.com/main`),
personal access token (PAT), test the connection, then ask a question. On enter your personal access token (PAT), test the connection, then ask a question. On
instances that allow anonymous REST reads, "Test connection" proves the instances that allow anonymous REST reads, "Test connection" proves the
destination is reachable but cannot prove the PAT is valid; a wrong token then 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 shows up as denied or missing pages. Credentials live only in browser memory and in the backend

View File

@ -27,7 +27,7 @@ backend/
| Variable | Default | Description | | 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_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_PROVIDER` | `fake` | Model provider adapter (`fake`, `openai`). |
| `CONFLUENCE_WEB_MODEL_NAME` | `fake-model` | Model name (e.g. `gpt-4o`). | | `CONFLUENCE_WEB_MODEL_NAME` | `fake-model` | Model name (e.g. `gpt-4o`). |

View File

@ -38,7 +38,7 @@ from backend.errors import (
) )
from backend.model import FakeModelAdapter, ModelAdapter, OpenAIModelAdapter from backend.model import FakeModelAdapter, ModelAdapter, OpenAIModelAdapter
from backend.runner import QueryRunner 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__) logger = logging.getLogger(__name__)
@ -454,6 +454,24 @@ def create_app(
get_or_set_session_cookie(request, resp, session_store) get_or_set_session_cookie(request, resp, session_store)
return resp 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") @app.post("/api/v1/queue/join")
async def queue_join(req: QueueJoinRequest, request: Request): async def queue_join(req: QueueJoinRequest, request: Request):
check_same_origin(request) check_same_origin(request)

View File

@ -47,6 +47,7 @@ frontend/
1. **In-Memory Credentials**: 1. **In-Memory Credentials**:
- Confluence Base URL and Personal Access Token (PAT) reside strictly in browser JavaScript memory. - 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. - 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. - A `cw_session` HttpOnly cookie is set by the origin for artifact download ownership.
2. **Content Security Policy (CSP)**: 2. **Content Security Policy (CSP)**:

View File

@ -1040,6 +1040,11 @@ body {
transition: border-color 0.15s ease, box-shadow 0.15s ease; transition: border-color 0.15s ease, box-shadow 0.15s ease;
} }
select.modal-input {
background-color: #fff;
cursor: pointer;
}
.modal-input:focus { .modal-input:focus {
border-color: var(--color-accent); border-color: var(--color-accent);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);

View File

@ -837,6 +837,13 @@ export function createMockServer() {
} }
// API Endpoint 1: POST /api/v1/auth/verify // 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') { if (pathname === '/api/v1/auth/verify' && method === 'POST') {
let body = ''; let body = '';
req.on('data', (chunk) => { body += chunk; }); req.on('data', (chunk) => { body += chunk; });

View File

@ -124,7 +124,7 @@
spellcheck="false" spellcheck="false"
required required
> >
<span class="field-hint">Must be a valid HTTP or HTTPS URL (max 8 KiB)</span> <span id="cred-url-hint" class="field-hint">Must be a valid HTTP or HTTPS URL (max 8 KiB)</span>
</div> </div>
<div class="form-group"> <div class="form-group">

View File

@ -230,6 +230,32 @@ export async function joinQueue({ signal } = {}) {
return await response.json(); 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. * Polls the current session's ticket status. Every call refreshes the server-side heartbeat.
* @param {{ signal?: AbortSignal }} [options] * @param {{ signal?: AbortSignal }} [options]

View File

@ -3,7 +3,7 @@
* Credentials remain strictly in browser memory and are never persisted or logged. * 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 { MarkdownRenderer, exportToMarkdown, renderWarnings } from './render.js';
import { renderPagesAccessed, renderToolHistory, renderArtifacts } from './history.js'; import { renderPagesAccessed, renderToolHistory, renderArtifacts } from './history.js';
import { mountThinkingOrb } from './orb.js'; import { mountThinkingOrb } from './orb.js';
@ -37,7 +37,7 @@ let viewPrompt, viewLoading, viewResult;
let promptInput, submitBtn, promptError; let promptInput, submitBtn, promptError;
let keyBtn, credIndicator, credStatusSr; let keyBtn, credIndicator, credStatusSr;
let modalBackdrop, modalDialog, modalCloseBtn, credentialsForm; let modalBackdrop, modalDialog, modalCloseBtn, credentialsForm;
let credUrlInput, credPatInput, togglePatBtn, modalFeedback; let credUrlInput, credUrlHint, credPatInput, togglePatBtn, modalFeedback;
let btnTestCred, btnSaveCred, btnCancelCred, btnClearCred; let btnTestCred, btnSaveCred, btnCancelCred, btnClearCred;
let cancelBtn, exitQueueBtn, loadingStatus, backBtn, exportBtn; let cancelBtn, exitQueueBtn, loadingStatus, backBtn, exportBtn;
let outputContent, sectionNav, resultWarnings; let outputContent, sectionNav, resultWarnings;
@ -77,6 +77,7 @@ document.addEventListener('DOMContentLoaded', () => {
modalCloseBtn = document.getElementById('modal-close-btn'); modalCloseBtn = document.getElementById('modal-close-btn');
credentialsForm = document.getElementById('credentials-form'); credentialsForm = document.getElementById('credentials-form');
credUrlInput = document.getElementById('cred-url'); credUrlInput = document.getElementById('cred-url');
credUrlHint = document.getElementById('cred-url-hint');
credPatInput = document.getElementById('cred-pat'); credPatInput = document.getElementById('cred-pat');
togglePatBtn = document.getElementById('toggle-pat-btn'); togglePatBtn = document.getElementById('toggle-pat-btn');
modalFeedback = document.getElementById('modal-feedback'); modalFeedback = document.getElementById('modal-feedback');
@ -119,6 +120,7 @@ document.addEventListener('DOMContentLoaded', () => {
setupLoadingEvents(); setupLoadingEvents();
setupResultEvents(); setupResultEvents();
updateCredentialIndicator(); updateCredentialIndicator();
loadApprovedOrigins();
// Best-effort ticket release on tab close/navigation while a ticket may still be held // 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, // (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 // Typing in inputs invalidates any in-flight test connection
credUrlInput.addEventListener('input', () => { bindUrlFieldEvents(credUrlInput);
abortActiveVerify();
btnTestCred.disabled = false;
});
credPatInput.addEventListener('input', () => { credPatInput.addEventListener('input', () => {
abortActiveVerify(); abortActiveVerify();
btnTestCred.disabled = false; btnTestCred.disabled = false;
@ -554,10 +553,10 @@ function openCredentialsModal() {
modalFeedback.textContent = ''; modalFeedback.textContent = '';
if (committedCredentials) { if (committedCredentials) {
credUrlInput.value = committedCredentials.url; setUrlFieldValue(committedCredentials.url);
credPatInput.value = committedCredentials.pat; credPatInput.value = committedCredentials.pat;
} else { } else {
credUrlInput.value = draftCredentials.url || ''; setUrlFieldValue(draftCredentials.url || '');
credPatInput.value = draftCredentials.pat || ''; credPatInput.value = draftCredentials.pat || '';
} }
@ -565,6 +564,73 @@ function openCredentialsModal() {
credUrlInput.focus(); 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. * Closes credentials modal and restores focus to key button.
*/ */

View File

@ -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 () => { test('GET / sets cw_session cookie and serves security headers', async () => {
const res = await fetch(`${BASE_URL}/`); const res = await fetch(`${BASE_URL}/`);
assert.equal(res.status, 200); assert.equal(res.status, 200);

View File

@ -155,6 +155,18 @@ async function runTests() {
const focusedId = await cdp.eval('document.activeElement.id'); const focusedId = await cdp.eval('document.activeElement.id');
assert.equal(focusedId, 'cred-url', 'URL input should be focused on open'); 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 // Test Cancel closes modal and restores focus
await cdp.eval('document.getElementById("btn-cancel-cred").click()'); await cdp.eval('document.getElementById("btn-cancel-cred").click()');
const isModalClosed = await cdp.eval('document.getElementById("modal-backdrop").classList.contains("hidden")'); const isModalClosed = await cdp.eval('document.getElementById("modal-backdrop").classList.contains("hidden")');

View File

@ -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