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.
334 lines
9.8 KiB
JavaScript
334 lines
9.8 KiB
JavaScript
/**
|
|
* API client boundary for same-origin backend communication.
|
|
* Strictly uses relative URLs (/api/v1/...) with no mock toggles or hardcoded origins.
|
|
*/
|
|
|
|
const MAX_PROMPT_BYTES = 16 * 1024 * 1024; // 16 MiB
|
|
const MAX_FIELD_BYTES = 8 * 1024; // 8 KiB for Confluence URL / PAT
|
|
|
|
/**
|
|
* Returns the UTF-8 byte length of a string.
|
|
* @param {string} str
|
|
* @returns {number}
|
|
*/
|
|
export function getUtf8ByteLength(str) {
|
|
if (typeof str !== 'string') return 0;
|
|
return new TextEncoder().encode(str).length;
|
|
}
|
|
|
|
/**
|
|
* Validates Confluence URL and PAT strings according to contract limits.
|
|
* @param {{ url: string, pat: string }} credentials
|
|
* @throws {Error} If credentials fail validation
|
|
*/
|
|
export function validateCredentials({ url, pat }) {
|
|
if (!url || typeof url !== 'string' || !url.trim()) {
|
|
const err = new Error('Confluence URL is required.');
|
|
err.code = 'invalid_input';
|
|
throw err;
|
|
}
|
|
if (!pat || typeof pat !== 'string' || !pat.trim()) {
|
|
const err = new Error('Personal Access Token (PAT) is required.');
|
|
err.code = 'invalid_input';
|
|
throw err;
|
|
}
|
|
|
|
const urlBytes = getUtf8ByteLength(url);
|
|
if (urlBytes > MAX_FIELD_BYTES) {
|
|
const err = new Error('Confluence URL exceeds the 8 KiB limit.');
|
|
err.code = 'invalid_input';
|
|
throw err;
|
|
}
|
|
|
|
const patBytes = getUtf8ByteLength(pat);
|
|
if (patBytes > MAX_FIELD_BYTES) {
|
|
const err = new Error('Personal Access Token exceeds the 8 KiB limit.');
|
|
err.code = 'invalid_input';
|
|
throw err;
|
|
}
|
|
|
|
try {
|
|
const parsed = new URL(url);
|
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
throw new Error();
|
|
}
|
|
} catch {
|
|
const err = new Error('Confluence URL must be a valid HTTP or HTTPS URL.');
|
|
err.code = 'invalid_input';
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parses and normalizes API error responses.
|
|
* @param {Response} response
|
|
* @param {{ notFoundCode?: string }} [opts] Overrides the fallback code used for a bodyless/non-JSON 404
|
|
* (queue endpoints use `ticket_not_found`; artifacts use `artifact_not_found`).
|
|
* @returns {Promise<Error>}
|
|
*/
|
|
async function parseApiError(response, { notFoundCode = 'artifact_not_found' } = {}) {
|
|
let code = 'execution_failed';
|
|
let message = `Request failed with status ${response.status}`;
|
|
|
|
let rawText = '';
|
|
try {
|
|
rawText = await response.text();
|
|
if (rawText) {
|
|
const data = JSON.parse(rawText);
|
|
if (data && data.error) {
|
|
if (typeof data.error.code === 'string') {
|
|
code = data.error.code;
|
|
}
|
|
if (typeof data.error.message === 'string') {
|
|
message = data.error.message;
|
|
}
|
|
const err = new Error(message);
|
|
err.code = code;
|
|
err.status = response.status;
|
|
return err;
|
|
}
|
|
}
|
|
} catch {
|
|
// Non-JSON body fallback
|
|
}
|
|
|
|
// Non-JSON or empty error body fallback based on HTTP status
|
|
if (response.status === 400) {
|
|
code = 'invalid_input';
|
|
} else if (response.status === 403) {
|
|
// Differentiate origin / destination denials from PAT failures per CONTRACTS §2
|
|
const lower = (rawText || response.statusText || '').toLowerCase();
|
|
if (lower.includes('origin')) {
|
|
code = 'origin_denied';
|
|
message = 'Request forbidden: origin denied.';
|
|
} else if (lower.includes('destination') || lower.includes('host')) {
|
|
code = 'destination_denied';
|
|
message = 'Request forbidden: destination URL not permitted.';
|
|
} else if (lower.includes('auth') || lower.includes('pat') || lower.includes('token') || lower.includes('credential')) {
|
|
code = 'confluence_auth_failed';
|
|
message = 'Confluence authentication failed.';
|
|
} else {
|
|
// Ingress / reverse proxy 403 rejection
|
|
code = 'origin_denied';
|
|
message = 'Request forbidden by server policy.';
|
|
}
|
|
} else if (response.status === 404) {
|
|
code = notFoundCode;
|
|
} else if (response.status === 409) {
|
|
code = 'busy';
|
|
} else if (response.status === 413) {
|
|
code = 'request_too_large';
|
|
} else if (response.status === 502) {
|
|
code = 'upstream_failed';
|
|
} else if (response.status === 503) {
|
|
code = 'queue_full';
|
|
} else if (response.status === 504) {
|
|
code = 'query_timeout';
|
|
}
|
|
|
|
const err = new Error(message);
|
|
err.code = code;
|
|
err.status = response.status;
|
|
return err;
|
|
}
|
|
|
|
/**
|
|
* Verifies credentials against the backend.
|
|
* @param {{ url: string, pat: string }} credentials
|
|
* @param {{ signal?: AbortSignal }} [options]
|
|
* @returns {Promise<{ valid: boolean }>}
|
|
*/
|
|
export async function verifyCredentials(credentials, { signal } = {}) {
|
|
validateCredentials(credentials);
|
|
|
|
const response = await fetch('/api/v1/auth/verify', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
url: credentials.url.trim(),
|
|
pat: credentials.pat.trim()
|
|
}),
|
|
signal
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw await parseApiError(response);
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
/**
|
|
* Submits a research prompt with credentials.
|
|
* @param {{ prompt: string, credentials: { url: string, pat: string } }} params
|
|
* @param {{ signal?: AbortSignal }} [options]
|
|
* @returns {Promise<object>} Query result object
|
|
*/
|
|
export async function submitQuery({ prompt, credentials }, { signal } = {}) {
|
|
if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
|
|
const err = new Error('Prompt cannot be empty.');
|
|
err.code = 'invalid_input';
|
|
throw err;
|
|
}
|
|
|
|
const promptBytes = getUtf8ByteLength(prompt);
|
|
if (promptBytes > MAX_PROMPT_BYTES) {
|
|
const err = new Error(`Prompt exceeds the 16 MiB UTF-8 limit (${promptBytes} bytes).`);
|
|
err.code = 'invalid_input';
|
|
throw err;
|
|
}
|
|
|
|
validateCredentials(credentials);
|
|
|
|
const response = await fetch('/api/v1/query', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
prompt: prompt, // Preserve original untrimmed text inside payload while verifying trimmed was non-empty
|
|
credentials: {
|
|
url: credentials.url.trim(),
|
|
pat: credentials.pat.trim()
|
|
}
|
|
}),
|
|
signal
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw await parseApiError(response);
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
/**
|
|
* Joins the admission queue for the current session. Idempotent: if the session already
|
|
* holds a ticket, the same ticket is returned unchanged. Carries no credentials.
|
|
* @param {{ signal?: AbortSignal }} [options]
|
|
* @returns {Promise<{ ticket_id: string, status: 'ready'|'queued', position: number, ahead: number,
|
|
* eta_seconds: number|null, reservation_expires_in_seconds: number|null, runner: 'reserved'|'running' }>}
|
|
*/
|
|
export async function joinQueue({ signal } = {}) {
|
|
const response = await fetch('/api/v1/queue/join', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
credentials: 'same-origin',
|
|
cache: 'no-store',
|
|
body: JSON.stringify({}),
|
|
signal
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw await parseApiError(response, { notFoundCode: 'ticket_not_found' });
|
|
}
|
|
|
|
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]
|
|
* @returns {Promise<object>} Same shape as {@link joinQueue}.
|
|
*/
|
|
export async function queueStatus({ signal } = {}) {
|
|
const response = await fetch('/api/v1/queue/status', {
|
|
method: 'GET',
|
|
credentials: 'same-origin',
|
|
cache: 'no-store',
|
|
signal
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw await parseApiError(response, { notFoundCode: 'ticket_not_found' });
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
/**
|
|
* Drops the current session's ticket, if any. Always succeeds (204) whether or not a ticket
|
|
* existed. Used both for the explicit "Exit queue" action and a best-effort call on `pagehide`.
|
|
* @param {{ keepalive?: boolean, signal?: AbortSignal }} [options]
|
|
* @returns {Promise<void>}
|
|
*/
|
|
export async function leaveQueue({ keepalive = false, signal } = {}) {
|
|
const response = await fetch('/api/v1/queue/ticket', {
|
|
method: 'DELETE',
|
|
credentials: 'same-origin',
|
|
cache: 'no-store',
|
|
keepalive,
|
|
signal
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw await parseApiError(response, { notFoundCode: 'ticket_not_found' });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Downloads an artifact by its backend-issued ID.
|
|
* @param {string} artifactId
|
|
* @param {{ signal?: AbortSignal }} [options]
|
|
* @returns {Promise<{ blob: Blob, filename: string }>}
|
|
*/
|
|
export async function downloadArtifact(artifactId, { signal } = {}) {
|
|
if (!artifactId || typeof artifactId !== 'string') {
|
|
const err = new Error('Invalid artifact ID.');
|
|
err.code = 'invalid_input';
|
|
throw err;
|
|
}
|
|
|
|
const response = await fetch(`/api/v1/artifacts/${encodeURIComponent(artifactId)}`, {
|
|
method: 'GET',
|
|
signal
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw await parseApiError(response);
|
|
}
|
|
|
|
// Extract filename from Content-Disposition if present
|
|
let filename = 'download';
|
|
const disposition = response.headers.get('Content-Disposition');
|
|
if (disposition) {
|
|
const match = disposition.match(/filename=["']?([^"';]+)["']?/i);
|
|
if (match && match[1]) {
|
|
filename = match[1].trim();
|
|
}
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
return { blob, filename };
|
|
}
|