The 180 s query cap was enforced independently by the backend clamp, the agent limits, and the container supervisor. All three now follow CONFLUENCE_WEB_MAX_DEADLINE_SECONDS (default 900, allowed 60-3600): the backend passes it into the container at launch, the supervisor reads it and forwards it to the bridge, and both fall back to 900 s on invalid input. The query timeout must not exceed it (startup fails otherwise). The supervisor keeps a separate 180 s guard for a container that never receives a start frame. Add assets/book.svg as the tab icon: the backend serves assets/ and the CSP allows same-origin images (the sanitizer still never emits <img>).
824 lines
29 KiB
JavaScript
824 lines
29 KiB
JavaScript
/**
|
|
* Same-origin Mock HTTP Server for the Confluence Research Web UI.
|
|
* Implements exact HTTP contracts, security headers, cookie sessions, and deterministic scenarios.
|
|
* Strictly binds loopback (default 5173). Zero external dependencies.
|
|
*/
|
|
|
|
import http from 'node:http';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import crypto from 'node:crypto';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const FRONTEND_ROOT = path.resolve(__dirname, '..');
|
|
|
|
const DEFAULT_PORT = 5173;
|
|
const HOST = '127.0.0.1';
|
|
|
|
// Active state
|
|
let currentGlobalScenario = 'normal';
|
|
let isQueryBusy = false;
|
|
|
|
// Predefined available scenarios
|
|
export const SCENARIOS = [
|
|
'normal',
|
|
'empty_search',
|
|
'no_artifacts',
|
|
'repeated_cached_view',
|
|
'failed_tool',
|
|
'warning_truncated_history',
|
|
'403_verify',
|
|
'409_busy',
|
|
'504_timeout',
|
|
'delayed_cancellation',
|
|
'unknown_expired_download',
|
|
'malicious_content',
|
|
'large_output'
|
|
];
|
|
|
|
/**
|
|
* Standard Security Headers
|
|
*/
|
|
const SECURITY_HEADERS = {
|
|
'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; media-src 'none'; font-src 'self'; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
'Referrer-Policy': 'no-referrer',
|
|
'X-Content-Type-Options': 'nosniff'
|
|
};
|
|
|
|
const MIME_TYPES = {
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.js': 'text/javascript; charset=utf-8',
|
|
'.mjs': 'text/javascript; charset=utf-8',
|
|
'.json': 'application/json; charset=utf-8',
|
|
'.svg': 'image/svg+xml',
|
|
'.md': 'text/markdown; charset=utf-8',
|
|
'.map': 'application/json'
|
|
};
|
|
|
|
/**
|
|
* Parses cookies from request.
|
|
*/
|
|
function parseCookies(cookieHeader) {
|
|
const list = {};
|
|
if (!cookieHeader) return list;
|
|
cookieHeader.split(';').forEach((cookie) => {
|
|
const parts = cookie.split('=');
|
|
if (parts.length >= 2) {
|
|
list[parts[0].trim()] = decodeURIComponent(parts.slice(1).join('=').trim());
|
|
}
|
|
});
|
|
return list;
|
|
}
|
|
|
|
/**
|
|
* Determines the active scenario for a request.
|
|
*/
|
|
function getScenarioForRequest(req, urlObj) {
|
|
// 1. Query parameter
|
|
const qScenario = urlObj.searchParams.get('scenario');
|
|
if (qScenario && SCENARIOS.includes(qScenario)) return qScenario;
|
|
|
|
// 2. Custom header
|
|
const hScenario = req.headers['x-mock-scenario'];
|
|
if (hScenario && SCENARIOS.includes(hScenario)) return hScenario;
|
|
|
|
// 3. Cookie
|
|
const cookies = parseCookies(req.headers.cookie);
|
|
if (cookies.mock_scenario && SCENARIOS.includes(cookies.mock_scenario)) {
|
|
return cookies.mock_scenario;
|
|
}
|
|
|
|
// 4. Global fallback
|
|
return currentGlobalScenario;
|
|
}
|
|
|
|
/**
|
|
* Generates fixture data for the requested scenario.
|
|
*/
|
|
function buildScenarioResponse(scenario, prompt) {
|
|
const now = new Date();
|
|
const nowIso = now.toISOString();
|
|
const expiresAtIso = new Date(now.getTime() + 15 * 60 * 1000).toISOString();
|
|
|
|
switch (scenario) {
|
|
case 'empty_search':
|
|
return {
|
|
session_id: 'mock-session-empty',
|
|
markdown: '# Research Results\n\nNo Confluence documentation matched your query.',
|
|
pages_accessed: [],
|
|
tool_history: [
|
|
{
|
|
tool_call_id: 'b_search_01',
|
|
tool: 'confluence_search',
|
|
parameters: { query: prompt || 'empty query', limit: 10 },
|
|
parameters_truncated: false,
|
|
started_at: nowIso,
|
|
completed_at: nowIso,
|
|
status: 'success',
|
|
cache_hit: false,
|
|
result: {
|
|
pages: [],
|
|
pagination: { offset: 0, limit: 10, has_more: false }
|
|
},
|
|
error: null,
|
|
result_truncated: false
|
|
}
|
|
],
|
|
artifacts: [],
|
|
warnings: [],
|
|
duration_seconds: 1.1
|
|
};
|
|
|
|
case 'no_artifacts':
|
|
return {
|
|
session_id: 'mock-session-no-art',
|
|
markdown: '# Research Summary\n\nInformation gathered from [Deployment Guide](https://approved.example.com/pages/viewpage.action?pageId=847291).\n\nNo artifacts produced.',
|
|
pages_accessed: [
|
|
{
|
|
page_id: '847291',
|
|
title: 'Deployment Guide',
|
|
space: 'OPS',
|
|
url: 'https://approved.example.com/pages/viewpage.action?pageId=847291',
|
|
accessed_at: nowIso
|
|
}
|
|
],
|
|
tool_history: [
|
|
{
|
|
tool_call_id: 'b_view_01',
|
|
tool: 'confluence_view',
|
|
parameters: { page_id: '847291' },
|
|
parameters_truncated: false,
|
|
started_at: nowIso,
|
|
completed_at: nowIso,
|
|
status: 'success',
|
|
cache_hit: false,
|
|
result: {
|
|
page_id: '847291',
|
|
title: 'Deployment Guide',
|
|
space: 'OPS',
|
|
url: 'https://approved.example.com/pages/viewpage.action?pageId=847291',
|
|
markdown: 'Deploy service X.',
|
|
truncated: false
|
|
},
|
|
error: null,
|
|
result_truncated: false
|
|
}
|
|
],
|
|
artifacts: [],
|
|
warnings: [],
|
|
duration_seconds: 2.3
|
|
};
|
|
|
|
case 'repeated_cached_view':
|
|
return {
|
|
session_id: 'mock-session-cached',
|
|
markdown: '# Deployment Summary\n\nReferenced [Deployment Guide](https://approved.example.com/pages/viewpage.action?pageId=847291) across multiple steps.',
|
|
pages_accessed: [
|
|
{
|
|
page_id: '847291',
|
|
title: 'Deployment Guide',
|
|
space: 'OPS',
|
|
url: 'https://approved.example.com/pages/viewpage.action?pageId=847291',
|
|
accessed_at: nowIso
|
|
}
|
|
],
|
|
tool_history: [
|
|
{
|
|
tool_call_id: 'b_call_01',
|
|
tool: 'confluence_view',
|
|
parameters: { page_id: '847291' },
|
|
parameters_truncated: false,
|
|
started_at: nowIso,
|
|
completed_at: nowIso,
|
|
status: 'success',
|
|
cache_hit: false,
|
|
result: {
|
|
page_id: '847291',
|
|
title: 'Deployment Guide',
|
|
space: 'OPS',
|
|
url: 'https://approved.example.com/pages/viewpage.action?pageId=847291',
|
|
markdown: 'Initial view.',
|
|
truncated: false
|
|
},
|
|
error: null,
|
|
result_truncated: false
|
|
},
|
|
{
|
|
tool_call_id: 'b_call_02',
|
|
tool: 'confluence_view',
|
|
parameters: { page_id: '847291' },
|
|
parameters_truncated: false,
|
|
started_at: nowIso,
|
|
completed_at: nowIso,
|
|
status: 'success',
|
|
cache_hit: true,
|
|
result: {
|
|
page_id: '847291',
|
|
title: 'Deployment Guide',
|
|
space: 'OPS',
|
|
url: 'https://approved.example.com/pages/viewpage.action?pageId=847291',
|
|
markdown: 'Initial view (from cache).',
|
|
truncated: false
|
|
},
|
|
error: null,
|
|
result_truncated: false
|
|
}
|
|
],
|
|
artifacts: [],
|
|
warnings: [],
|
|
duration_seconds: 1.8
|
|
};
|
|
|
|
case 'failed_tool':
|
|
return {
|
|
session_id: 'mock-session-failed-tool',
|
|
markdown: '# Partial Summary\n\nSearch succeeded, but page 999999 could not be accessed due to an upstream error.',
|
|
pages_accessed: [],
|
|
tool_history: [
|
|
{
|
|
tool_call_id: 'b_search_01',
|
|
tool: 'confluence_search',
|
|
parameters: { query: 'archived docs', limit: 5 },
|
|
parameters_truncated: false,
|
|
started_at: nowIso,
|
|
completed_at: nowIso,
|
|
status: 'success',
|
|
cache_hit: false,
|
|
result: {
|
|
pages: [{ page_id: '999999', title: 'Archived Page', space: 'ARCH', url: 'https://approved.example.com/pages/viewpage.action?pageId=999999', snippet: 'Missing page' }],
|
|
pagination: { offset: 0, limit: 5, has_more: false }
|
|
},
|
|
error: null,
|
|
result_truncated: false
|
|
},
|
|
{
|
|
tool_call_id: 'b_view_02',
|
|
tool: 'confluence_view',
|
|
parameters: { page_id: '999999' },
|
|
parameters_truncated: false,
|
|
started_at: nowIso,
|
|
completed_at: nowIso,
|
|
status: 'error',
|
|
cache_hit: false,
|
|
result: null,
|
|
error: {
|
|
code: 'page_not_found',
|
|
message: 'Confluence page 999999 was not found or has been deleted.'
|
|
},
|
|
result_truncated: false
|
|
}
|
|
],
|
|
artifacts: [],
|
|
warnings: [
|
|
{ code: 'page_not_found', message: 'Page 999999 access failed', tool_call_id: 'b_view_02' }
|
|
],
|
|
duration_seconds: 2.7
|
|
};
|
|
|
|
case 'warning_truncated_history':
|
|
return {
|
|
session_id: 'mock-session-warn-trunc',
|
|
markdown: '# Bounded Results\n\nExtensive data retrieved with truncated history logs.',
|
|
pages_accessed: [
|
|
{
|
|
page_id: '12345',
|
|
title: 'Large Architecture Document',
|
|
space: 'ARCH',
|
|
url: 'https://approved.example.com/pages/viewpage.action?pageId=12345',
|
|
accessed_at: nowIso
|
|
}
|
|
],
|
|
tool_history: [
|
|
{
|
|
tool_call_id: 'b_call_trunc_01',
|
|
tool: 'confluence_view',
|
|
parameters: { page_id: '12345', details: 'A'.repeat(500) },
|
|
parameters_truncated: true,
|
|
started_at: nowIso,
|
|
completed_at: nowIso,
|
|
status: 'success',
|
|
cache_hit: false,
|
|
result: {
|
|
page_id: '12345',
|
|
summary: 'Summary preserved while large raw content was truncated.'
|
|
},
|
|
error: null,
|
|
result_truncated: true
|
|
}
|
|
],
|
|
artifacts: [],
|
|
warnings: [
|
|
{
|
|
code: 'history_truncated',
|
|
message: 'History result budget exceeded; 1 entry truncated.',
|
|
tool_call_id: 'b_call_trunc_01'
|
|
},
|
|
{
|
|
code: 'unknown_custom_warning',
|
|
message: 'Custom backend warning code test.'
|
|
}
|
|
],
|
|
duration_seconds: 4.1
|
|
};
|
|
|
|
case 'malicious_content':
|
|
return {
|
|
session_id: 'mock-session-malicious',
|
|
markdown: [
|
|
'# Malicious Input Test',
|
|
'',
|
|
'Attempting XSS and unsafe content:',
|
|
'<script>alert("xss-script")</script>',
|
|
'<img src="https://attacker.example.com/beacon.png" onerror="alert(\'xss-img\')">',
|
|
'<style>body { display: none !important; }</style>',
|
|
'<iframe src="https://attacker.example.com"></iframe>',
|
|
'<form action="https://attacker.example.com"><input type="submit" value="Phish"></form>',
|
|
'',
|
|
'Harmful links:',
|
|
'- [JavaScript link](javascript:alert("xss-link"))',
|
|
'- [Data URI link](data:text/html,<script>alert(1)</script>)',
|
|
'- [Safe citation link](https://approved.example.com/safe/page)',
|
|
'',
|
|
'```html',
|
|
'<script>console.log("safe inside code fence");</script>',
|
|
'```'
|
|
].join('\n'),
|
|
pages_accessed: [
|
|
{
|
|
page_id: '777',
|
|
title: '<script>alert("xss-title")</script> Safe Title',
|
|
space: 'SEC',
|
|
url: 'https://approved.example.com/page/777',
|
|
accessed_at: nowIso
|
|
}
|
|
],
|
|
tool_history: [
|
|
{
|
|
tool_call_id: 'b_xss_01',
|
|
tool: 'confluence_view',
|
|
parameters: { page_id: '<script>alert("xss-param")</script>' },
|
|
parameters_truncated: false,
|
|
started_at: nowIso,
|
|
completed_at: nowIso,
|
|
status: 'success',
|
|
cache_hit: false,
|
|
result: {
|
|
raw_payload: '<img src=x onerror=alert(1)>',
|
|
nested: { malicious: '<script>evil()</script>' }
|
|
},
|
|
error: null,
|
|
result_truncated: false
|
|
}
|
|
],
|
|
artifacts: [],
|
|
warnings: [],
|
|
duration_seconds: 1.0
|
|
};
|
|
|
|
case 'large_output': {
|
|
// Dynamically generate multi-megabyte markdown with sections, tables, and code blocks
|
|
const sectionCount = 40;
|
|
const mdParts = ['# Large Document Benchmark\n\nGenerated large output to test bounded section rendering and responsive UI.\n'];
|
|
for (let i = 1; i <= sectionCount; i++) {
|
|
mdParts.push(`\n## Section ${i}: Architectural Components\n`);
|
|
mdParts.push(`This is paragraph content for section ${i} detailing deployment topologies, container security, and protocol isolation.\n`);
|
|
mdParts.push('```bash\n# Simulated shell commands\necho "Running container isolation check for section ' + i + '"\nfind /work -type f -ls\n```\n');
|
|
mdParts.push('| Component | Status | Metric |\n|---|---|---|\n| Bridge | Active | 100% |\n| Storage | Bounded | 50 MiB |\n| Latency | Nominal | 12ms |\n');
|
|
// Repeat text to bulk up section size (~50 KiB per section)
|
|
mdParts.push('Confluence documentation analysis paragraph '.repeat(200) + '\n');
|
|
}
|
|
|
|
return {
|
|
session_id: 'mock-session-large',
|
|
markdown: mdParts.join('\n'),
|
|
pages_accessed: [
|
|
{
|
|
page_id: '99991',
|
|
title: 'Enterprise Architecture Overview',
|
|
space: 'ARCH',
|
|
url: 'https://approved.example.com/pages/viewpage.action?pageId=99991',
|
|
accessed_at: nowIso
|
|
}
|
|
],
|
|
tool_history: [
|
|
{
|
|
tool_call_id: 'b_large_01',
|
|
tool: 'confluence_view',
|
|
parameters: { page_id: '99991' },
|
|
parameters_truncated: false,
|
|
started_at: nowIso,
|
|
completed_at: nowIso,
|
|
status: 'success',
|
|
cache_hit: false,
|
|
result: {
|
|
page_id: '99991',
|
|
title: 'Enterprise Architecture Overview',
|
|
space: 'ARCH',
|
|
status: 'ok'
|
|
},
|
|
error: null,
|
|
result_truncated: false
|
|
}
|
|
],
|
|
artifacts: [
|
|
{
|
|
id: 'art-large-export',
|
|
name: 'full_architecture.md',
|
|
size_bytes: 32,
|
|
expires_at: expiresAtIso
|
|
}
|
|
],
|
|
warnings: [],
|
|
duration_seconds: 5.4
|
|
};
|
|
}
|
|
|
|
case 'normal':
|
|
default:
|
|
// Shared example scenario from CONTRACTS.md Section 7
|
|
return {
|
|
session_id: 'a0f2b3c4-1234-5678-9abc-def012345678',
|
|
markdown: '# Deployment Guide for Service X\n\nTo deploy **Service X**, follow the steps outlined in the [Deployment Guide](https://approved.example.com/pages/viewpage.action?pageId=847291).\n\n### Key Steps:\n1. Verify container runtime prerequisites.\n2. Review the checklist exported to `checklist.md`.\n3. Execute staged rollout.\n',
|
|
pages_accessed: [
|
|
{
|
|
page_id: '847291',
|
|
title: 'Deployment Guide',
|
|
space: 'OPS',
|
|
url: 'https://approved.example.com/pages/viewpage.action?pageId=847291',
|
|
accessed_at: nowIso
|
|
}
|
|
],
|
|
tool_history: [
|
|
{
|
|
tool_call_id: 'b_call_01',
|
|
tool: 'confluence_search',
|
|
parameters: { query: 'deploy service X', limit: 10 },
|
|
parameters_truncated: false,
|
|
started_at: nowIso,
|
|
completed_at: nowIso,
|
|
status: 'success',
|
|
cache_hit: false,
|
|
result: {
|
|
pages: [
|
|
{
|
|
page_id: '847291',
|
|
title: 'Deployment Guide',
|
|
space: 'OPS',
|
|
url: 'https://approved.example.com/pages/viewpage.action?pageId=847291',
|
|
snippet: 'Deployment steps for service X'
|
|
}
|
|
],
|
|
pagination: { offset: 0, limit: 10, has_more: false }
|
|
},
|
|
error: null,
|
|
result_truncated: false
|
|
},
|
|
{
|
|
tool_call_id: 'b_call_02',
|
|
tool: 'confluence_view',
|
|
parameters: { page_id: '847291' },
|
|
parameters_truncated: false,
|
|
started_at: nowIso,
|
|
completed_at: nowIso,
|
|
status: 'success',
|
|
cache_hit: false,
|
|
result: {
|
|
page_id: '847291',
|
|
title: 'Deployment Guide',
|
|
space: 'OPS',
|
|
url: 'https://approved.example.com/pages/viewpage.action?pageId=847291',
|
|
markdown: 'Deploy service X using the release checklist.',
|
|
truncated: false
|
|
},
|
|
error: null,
|
|
result_truncated: false
|
|
}
|
|
],
|
|
artifacts: [
|
|
{
|
|
id: 'art-checklist-01',
|
|
name: 'checklist.md',
|
|
size_bytes: 32,
|
|
expires_at: expiresAtIso
|
|
}
|
|
],
|
|
warnings: [],
|
|
duration_seconds: 3.2
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates that credentials in mock mode are synthetic/dummy credentials.
|
|
*/
|
|
function validateMockCredentials(url, pat) {
|
|
if (!url || !pat) return false;
|
|
// Prevent common production token patterns
|
|
if (pat.startsWith('ghp_') || pat.startsWith('glpat-') || pat.startsWith('xoxb-')) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Creates the HTTP server.
|
|
*/
|
|
export function createMockServer() {
|
|
const server = http.createServer(async (req, res) => {
|
|
const urlObj = new URL(req.url, `http://${req.headers.host || `${HOST}:${DEFAULT_PORT}`}`);
|
|
const pathname = urlObj.pathname;
|
|
const method = req.method.toUpperCase();
|
|
|
|
// Attach security headers to all responses
|
|
Object.entries(SECURITY_HEADERS).forEach(([key, val]) => {
|
|
res.setHeader(key, val);
|
|
});
|
|
|
|
// Session cookie: cw_session
|
|
const cookies = parseCookies(req.headers.cookie);
|
|
if (!cookies.cw_session) {
|
|
const newSession = crypto.randomBytes(16).toString('hex');
|
|
res.setHeader('Set-Cookie', `cw_session=${newSession}; Path=/; HttpOnly; SameSite=Strict`);
|
|
}
|
|
|
|
// Origin check for mutation requests
|
|
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
|
|
const origin = req.headers.origin;
|
|
if (origin) {
|
|
try {
|
|
const origUrl = new URL(origin);
|
|
if (!['127.0.0.1', 'localhost'].includes(origUrl.hostname)) {
|
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: { code: 'origin_denied', message: 'Cross-origin request denied.' } }));
|
|
return;
|
|
}
|
|
} catch {
|
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: { code: 'origin_denied', message: 'Malformed origin.' } }));
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Dev Scenario API (development tooling outside user flow)
|
|
if (pathname === '/dev/scenario') {
|
|
if (method === 'GET') {
|
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
res.end(JSON.stringify({ scenario: currentGlobalScenario, available: SCENARIOS }));
|
|
return;
|
|
}
|
|
if (method === 'POST') {
|
|
let body = '';
|
|
req.on('data', (chunk) => { body += chunk; });
|
|
req.on('end', () => {
|
|
try {
|
|
const data = JSON.parse(body);
|
|
if (data.scenario && SCENARIOS.includes(data.scenario)) {
|
|
currentGlobalScenario = data.scenario;
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
'Set-Cookie': `mock_scenario=${data.scenario}; Path=/; SameSite=Strict`
|
|
});
|
|
res.end(JSON.stringify({ ok: true, scenario: currentGlobalScenario }));
|
|
} else {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: { code: 'invalid_input', message: 'Unknown scenario' } }));
|
|
}
|
|
} catch {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: { code: 'invalid_input', message: 'Invalid JSON' } }));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
// API Endpoint 1: POST /api/v1/auth/verify
|
|
if (pathname === '/api/v1/auth/verify' && method === 'POST') {
|
|
let body = '';
|
|
req.on('data', (chunk) => { body += chunk; });
|
|
req.on('end', () => {
|
|
try {
|
|
const data = JSON.parse(body);
|
|
if (!data.url || !data.pat) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
res.end(JSON.stringify({ error: { code: 'invalid_input', message: 'Missing URL or PAT' } }));
|
|
return;
|
|
}
|
|
|
|
if (!validateMockCredentials(data.url, data.pat)) {
|
|
res.writeHead(403, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
res.end(JSON.stringify({ error: { code: 'confluence_auth_failed', message: 'Invalid mock credentials' } }));
|
|
return;
|
|
}
|
|
|
|
const scenario = getScenarioForRequest(req, urlObj);
|
|
if (scenario === '403_verify') {
|
|
res.writeHead(403, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
res.end(JSON.stringify({ error: { code: 'confluence_auth_failed', message: 'Confluence authentication failed: invalid PAT.' } }));
|
|
return;
|
|
}
|
|
|
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
res.end(JSON.stringify({ valid: true }));
|
|
} catch {
|
|
res.writeHead(400, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
res.end(JSON.stringify({ error: { code: 'invalid_input', message: 'Invalid JSON body' } }));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
// API Endpoint 2: POST /api/v1/query
|
|
if (pathname === '/api/v1/query' && method === 'POST') {
|
|
let body = '';
|
|
req.on('data', (chunk) => { body += chunk; });
|
|
req.on('end', async () => {
|
|
try {
|
|
const data = JSON.parse(body);
|
|
if (!data.prompt || !data.credentials || !data.credentials.url || !data.credentials.pat) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
res.end(JSON.stringify({ error: { code: 'invalid_input', message: 'Missing required query parameters' } }));
|
|
return;
|
|
}
|
|
|
|
if (!validateMockCredentials(data.credentials.url, data.credentials.pat)) {
|
|
res.writeHead(403, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
res.end(JSON.stringify({ error: { code: 'confluence_auth_failed', message: 'Invalid mock credentials' } }));
|
|
return;
|
|
}
|
|
|
|
const scenario = getScenarioForRequest(req, urlObj);
|
|
|
|
// 409 Busy check
|
|
if (scenario === '409_busy' || isQueryBusy) {
|
|
res.writeHead(409, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
res.end(JSON.stringify({ error: { code: 'busy', message: 'A query is currently executing or prior cleanup is in progress' } }));
|
|
return;
|
|
}
|
|
|
|
// 504 Timeout check
|
|
if (scenario === '504_timeout') {
|
|
res.writeHead(504, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
res.end(JSON.stringify({ error: { code: 'query_timeout', message: 'The query deadline of 180 seconds was exceeded' } }));
|
|
return;
|
|
}
|
|
|
|
// Delayed cancellation test scenario
|
|
if (scenario === 'delayed_cancellation') {
|
|
isQueryBusy = true;
|
|
let aborted = false;
|
|
|
|
const cancelTimeout = setTimeout(() => {
|
|
isQueryBusy = false;
|
|
if (!aborted && !res.writableEnded) {
|
|
const responseData = buildScenarioResponse('normal', data.prompt);
|
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
res.end(JSON.stringify(responseData));
|
|
}
|
|
}, 30000); // 30s delay
|
|
|
|
req.on('close', () => {
|
|
if (!res.writableEnded) {
|
|
aborted = true;
|
|
clearTimeout(cancelTimeout);
|
|
// Simulate backend cleanup budget
|
|
setTimeout(() => {
|
|
isQueryBusy = false;
|
|
}, 400);
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Normal response
|
|
const responseData = buildScenarioResponse(scenario, data.prompt);
|
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
res.end(JSON.stringify(responseData));
|
|
} catch {
|
|
res.writeHead(400, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
res.end(JSON.stringify({ error: { code: 'invalid_input', message: 'Malformed JSON query body' } }));
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
// API Endpoint 3: GET /api/v1/artifacts/{id}
|
|
if (pathname.startsWith('/api/v1/artifacts/') && method === 'GET') {
|
|
const scenario = getScenarioForRequest(req, urlObj);
|
|
const artifactId = pathname.slice('/api/v1/artifacts/'.length);
|
|
|
|
if (scenario === 'unknown_expired_download' || artifactId === 'expired' || artifactId === 'non-existent') {
|
|
res.writeHead(404, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
res.end(JSON.stringify({ error: { code: 'artifact_not_found', message: 'Artifact has expired or does not exist.' } }));
|
|
return;
|
|
}
|
|
|
|
// Exact 32 bytes from CONTRACTS.md Section 7
|
|
const artifactContent = '# Checklist\n\n- Deploy service X\n';
|
|
const buffer = Buffer.from(artifactContent, 'utf-8');
|
|
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/octet-stream',
|
|
'Content-Length': buffer.length,
|
|
'Content-Disposition': 'attachment; filename="checklist.md"',
|
|
'Cache-Control': 'no-store'
|
|
});
|
|
res.end(buffer);
|
|
return;
|
|
}
|
|
|
|
// Static File Serving
|
|
if (method === 'GET' || method === 'HEAD') {
|
|
let relativePath = pathname === '/' ? 'index.html' : pathname.slice(1);
|
|
const safePath = path.normalize(relativePath).replace(/^(\.\.[/\\])+/, '');
|
|
const filePath = path.join(FRONTEND_ROOT, safePath);
|
|
|
|
// Security check: ensure path is within FRONTEND_ROOT
|
|
if (!filePath.startsWith(FRONTEND_ROOT)) {
|
|
res.writeHead(403);
|
|
res.end('Forbidden');
|
|
return;
|
|
}
|
|
|
|
fs.stat(filePath, (err, stats) => {
|
|
if (err || !stats.isFile()) {
|
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
res.end('Not Found');
|
|
return;
|
|
}
|
|
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
|
|
// For index.html in dev mode, inject a dev scenario selector bar
|
|
if (ext === '.html') {
|
|
fs.readFile(filePath, 'utf8', (readErr, htmlContent) => {
|
|
if (readErr) {
|
|
res.writeHead(500);
|
|
res.end('Server Error');
|
|
return;
|
|
}
|
|
|
|
const activeScenario = getScenarioForRequest(req, urlObj);
|
|
|
|
// Dev scenario selector toolbar (external CSS and JS to satisfy CSP script-src 'self' style-src 'self')
|
|
const devBar = `
|
|
<!-- Mock Server Dev Toolbar (Dev Only) -->
|
|
<link rel="stylesheet" href="/dev/scenario-toolbar.css">
|
|
<aside id="dev-scenario-bar">
|
|
<span id="dev-scenario-label">Mock Scenario:</span>
|
|
<select id="dev-scenario-select">
|
|
${SCENARIOS.map((s) => `<option value="${s}" ${s === activeScenario ? 'selected' : ''}>${s}</option>`).join('')}
|
|
</select>
|
|
</aside>
|
|
<script type="module" src="/dev/scenario-toolbar.js"></script>
|
|
`;
|
|
const modifiedHtml = htmlContent.replace('</body>', `${devBar}</body>`);
|
|
res.writeHead(200, {
|
|
'Content-Type': contentType,
|
|
'Content-Length': Buffer.byteLength(modifiedHtml)
|
|
});
|
|
res.end(modifiedHtml);
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Other static files
|
|
res.writeHead(200, {
|
|
'Content-Type': contentType,
|
|
'Content-Length': stats.size
|
|
});
|
|
if (method === 'HEAD') {
|
|
res.end();
|
|
return;
|
|
}
|
|
const stream = fs.createReadStream(filePath);
|
|
stream.pipe(res);
|
|
});
|
|
return;
|
|
}
|
|
|
|
res.writeHead(405, { 'Content-Type': 'text/plain' });
|
|
res.end('Method Not Allowed');
|
|
});
|
|
|
|
return server;
|
|
}
|
|
|
|
// If run directly from CLI
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
const portArgIdx = process.argv.indexOf('--port');
|
|
const port = portArgIdx !== -1 ? parseInt(process.argv[portArgIdx + 1], 10) : (parseInt(process.env.PORT, 10) || DEFAULT_PORT);
|
|
|
|
const server = createMockServer();
|
|
server.listen(port, HOST, () => {
|
|
console.log(`Mock server running at http://${HOST}:${port}/`);
|
|
console.log(`Current default scenario: ${currentGlobalScenario}`);
|
|
});
|
|
}
|