Artur Mukhamadiev a9908a533f frontend: web UI track handoff (contract revision 1)
Static frontend with vendored marked/DOMPurify, bounded Markdown
pipeline, same-origin mock server with scenario selection, unit,
contract and CDP end-to-end tests under frontend/**.
2026-09-14 21:57:54 +03:00

238 lines
6.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
* @returns {Promise<Error>}
*/
async function parseApiError(response) {
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 = 'artifact_not_found';
} 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 === 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();
}
/**
* 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 };
}