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/**.
238 lines
6.8 KiB
JavaScript
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 };
|
|
}
|