/** * 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} */ 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} 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 }; }