Submit joins the admission queue first and sends the PAT only once the reservation is held. Queued state shows the shaping orb, ordinal position with estimate, and an exit button whose label changes with the wait; running switches the orb to solving. Rejoin once on a lost reservation or ticket, best-effort leave on pagehide. Mock server gains the queue endpoints and five scenarios; contract, unit and e2e tests cover them.
1076 lines
38 KiB
JavaScript
1076 lines
38 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
|
|
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}`);
|
|
});
|
|
}
|