confluence_web/frontend/dev/mock-server.js
Artur Mukhamadiev 77768ba3ca credentials: offer the approved Confluence origin as a fixed choice
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.
2026-09-15 15:24:17 +03:00

1083 lines
39 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',
'queued',
'queued_no_estimate',
'queue_full',
'reservation_lost',
'ticket_lost'
];
// Admission queue scenarios (docs/QUEUE_SPECIFICATION.md §10) that drive a canned join/status
// dance instead of the immediate "ready" every other scenario answers with.
const QUEUE_SCENARIOS = new Set(['queued', 'queued_no_estimate', 'queue_full', 'reservation_lost', 'ticket_lost']);
const QUEUE_RESERVATION_SECONDS = 45;
// Per-session queue simulation state, keyed by cw_session cookie value. Reset whenever the
// scenario active for that session changes, so switching scenarios mid-flow never leaks stale
// poll/join counters into the new scenario.
const queueSessions = new Map();
/**
* Returns (creating or resetting as needed) the per-session queue simulation record.
* @param {string} sessionId
* @param {string} scenario
*/
function getQueueRecord(sessionId, scenario) {
let rec = queueSessions.get(sessionId);
if (!rec || rec.scenario !== scenario) {
rec = { scenario, ticketId: null, joinCount: 0, pollCount: 0, queryAttempt: 0 };
queueSessions.set(sessionId, rec);
}
return rec;
}
/**
* Generates an opaque ticket id in the shape used by the real backend (CONTRACTS §1).
*/
function makeTicketId() {
return `q_${crypto.randomBytes(6).toString('hex')}`;
}
/**
* 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);
let sessionId = cookies.cw_session;
if (!sessionId) {
sessionId = crypto.randomBytes(16).toString('hex');
res.setHeader('Set-Cookie', `cw_session=${sessionId}; 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;
}
}
// Admission Queue Endpoint: POST /api/v1/queue/join
if (pathname === '/api/v1/queue/join' && method === 'POST') {
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
let data;
try {
data = body ? JSON.parse(body) : {};
} 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;
}
if (data && typeof data === 'object' && ('credentials' in data || 'pat' in data)) {
res.writeHead(400, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify({ error: { code: 'invalid_input', message: 'Queue endpoints do not accept credentials' } }));
return;
}
const scenario = getScenarioForRequest(req, urlObj);
if (scenario === 'queue_full') {
res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify({ error: { code: 'queue_full', message: 'The queue is full, please try later.' } }));
return;
}
if (!QUEUE_SCENARIOS.has(scenario)) {
// Every other scenario answers join with an immediate reservation (spec §10).
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify({
ticket_id: makeTicketId(),
status: 'ready',
position: 0,
ahead: 0,
eta_seconds: null,
reservation_expires_in_seconds: QUEUE_RESERVATION_SECONDS,
runner: 'reserved'
}));
return;
}
const rec = getQueueRecord(sessionId, scenario);
rec.joinCount += 1;
if (scenario === 'reservation_lost') {
rec.ticketId = rec.ticketId || makeTicketId();
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify({
ticket_id: rec.ticketId,
status: 'ready',
position: 0,
ahead: 0,
eta_seconds: null,
reservation_expires_in_seconds: QUEUE_RESERVATION_SECONDS,
runner: 'reserved'
}));
return;
}
if (scenario === 'ticket_lost') {
rec.ticketId = makeTicketId();
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
if (rec.joinCount === 1) {
rec.pollCount = 0;
res.end(JSON.stringify({
ticket_id: rec.ticketId,
status: 'queued',
position: 2,
ahead: 1,
eta_seconds: 60,
reservation_expires_in_seconds: null,
runner: 'running'
}));
} else {
// The automatic rejoin after the ticket was lost (spec §10, §7.1 step 6).
res.end(JSON.stringify({
ticket_id: rec.ticketId,
status: 'ready',
position: 0,
ahead: 0,
eta_seconds: null,
reservation_expires_in_seconds: QUEUE_RESERVATION_SECONDS,
runner: 'reserved'
}));
}
return;
}
// 'queued' / 'queued_no_estimate': idempotent join, position decrements only on
// status polls (see GET /api/v1/queue/status below).
const noEstimate = scenario === 'queued_no_estimate';
rec.ticketId = rec.ticketId || makeTicketId();
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify({
ticket_id: rec.ticketId,
status: 'queued',
position: 3,
ahead: 2,
eta_seconds: noEstimate ? null : 150,
reservation_expires_in_seconds: null,
runner: 'running'
}));
});
return;
}
// Admission Queue Endpoint: GET /api/v1/queue/status
if (pathname === '/api/v1/queue/status' && method === 'GET') {
const scenario = getScenarioForRequest(req, urlObj);
if (!QUEUE_SCENARIOS.has(scenario) || scenario === 'queue_full' || scenario === 'reservation_lost') {
// These scenarios never leave a session polling: non-queue scenarios resolve at join,
// queue_full never creates a ticket, and reservation_lost resolves at join too.
res.writeHead(404, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify({ error: { code: 'ticket_not_found', message: 'No ticket for this session.' } }));
return;
}
const rec = queueSessions.get(sessionId);
if (!rec || rec.scenario !== scenario || !rec.ticketId) {
res.writeHead(404, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify({ error: { code: 'ticket_not_found', message: 'No ticket for this session.' } }));
return;
}
rec.pollCount += 1;
// Build the response body first so exactly one writeHead/end pair is ever sent,
// regardless of which branch (200 queued/ready vs. 404 lost) fires.
let statusCode = 200;
let payload;
if (scenario === 'ticket_lost') {
if (rec.pollCount === 1) {
payload = {
ticket_id: rec.ticketId,
status: 'queued',
position: 1,
ahead: 0,
eta_seconds: 30,
reservation_expires_in_seconds: null,
runner: 'running'
};
} else {
// Second poll: the ticket has expired server-side (spec §10).
rec.ticketId = null;
statusCode = 404;
payload = { error: { code: 'ticket_not_found', message: 'Ticket expired.' } };
}
} else {
// 'queued' / 'queued_no_estimate': decrement position on each poll; ready on the third.
const noEstimate = scenario === 'queued_no_estimate';
if (rec.pollCount === 1) {
payload = {
ticket_id: rec.ticketId,
status: 'queued',
position: 2,
ahead: 1,
eta_seconds: noEstimate ? null : 95,
reservation_expires_in_seconds: null,
runner: 'running'
};
} else if (rec.pollCount === 2) {
payload = {
ticket_id: rec.ticketId,
status: 'queued',
position: 1,
ahead: 0,
eta_seconds: noEstimate ? null : 40,
reservation_expires_in_seconds: null,
runner: 'running'
};
} else {
payload = {
ticket_id: rec.ticketId,
status: 'ready',
position: 0,
ahead: 0,
eta_seconds: null,
reservation_expires_in_seconds: QUEUE_RESERVATION_SECONDS,
runner: 'reserved'
};
}
}
res.writeHead(statusCode, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(payload));
return;
}
// Admission Queue Endpoint: DELETE /api/v1/queue/ticket
if (pathname === '/api/v1/queue/ticket' && method === 'DELETE') {
const rec = queueSessions.get(sessionId);
if (rec) {
rec.ticketId = null;
}
res.writeHead(204, { 'Cache-Control': 'no-store' });
res.end();
return;
}
// API Endpoint 1: POST /api/v1/auth/verify
// Non-secret deployment facts: the approved Confluence origins offered as a fixed choice.
if (pathname === '/api/v1/config' && method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify({ approved_origins: ['https://approved.example.com'] }));
return;
}
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);
// Admission queue scenario: the first query after "ready" loses the reservation
// (simulating network trouble); the client's automatic rejoin-then-retry succeeds
// on the second attempt (spec §10).
if (scenario === 'reservation_lost') {
const rec = getQueueRecord(sessionId, scenario);
rec.queryAttempt += 1;
if (rec.queryAttempt === 1) {
res.writeHead(409, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify({ error: { code: 'busy', message: 'Reservation lost; rejoin the queue.' } }));
return;
}
}
// 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}`);
});
}