diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index cf922bfa02d..3e2978ff6f7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -121,6 +121,8 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.workflow_run.head_sha }} + # The release fence verifies ancestry and artifacts at prior commits. + fetch-depth: 0 - name: Recheck tested SHA is still current main env: diff --git a/server/public/chat.html b/server/public/chat.html index 99730b2daa9..3c00f184403 100644 --- a/server/public/chat.html +++ b/server/public/chat.html @@ -5242,10 +5242,87 @@

Ask Addie

}); panel.append(title, detail, retryButton); + appendChatRequestReference(panel, options.requestId); contentDiv.appendChild(panel); appendCertificationStatus(contentDiv, options.certification); } + function validChatRequestId(value) { + return typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value) + ? value.toLowerCase() : null; + } + + function appendChatRequestReference(container, requestId) { + const id = validChatRequestId(requestId); + if (!id) return; + const reference = document.createElement('div'); + reference.className = 'chat-request-reference'; + reference.textContent = `Support reference: ${id}`; + container.appendChild(reference); + } + + function chatRequestError(payload, status, requestId, streaming = false) { + const data = payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {}; + const nested = data.error && typeof data.error === 'object' && !Array.isArray(data.error) ? data.error : {}; + const code = typeof data.code === 'string' ? data.code + : typeof data.error === 'string' ? data.error + : typeof nested.code === 'string' ? nested.code : ''; + const publicErrors = new Map([ + ['Invalid session', 'Your session has expired. Sign in again, then retry your message.'], + ['An unambiguous organization selection is required', 'Your organization selection could not be verified. Choose your organization again or sign in again, then retry your message.'], + ['CSRF validation failed', 'Your security token could not be verified. Refresh this page, then retry your message.'], + ['Authentication service temporarily unavailable', 'Sign-in is temporarily unavailable. Please try again shortly.'], + ['Authorization service temporarily unavailable', 'Your access could not be verified right now. Please try again shortly.'], + ['Conversation not found', 'This conversation is unavailable for your current session. Sign in again or start a new chat.'], + ['checkpoint_persistence_failed', 'An action completed, but its result could not be safely saved. Review the action before trying again.'], + ]); + let message = publicErrors.get(code) || publicErrors.get(data.reason); + // `message` is the API's public prose field. Never show arbitrary error + // objects, provider errors/reasons, HTML responses or 5xx diagnostics. + const suppliedMessage = typeof data.message === 'string' ? data.message : nested.message; + if (!message && status >= 400 && status < 500 && typeof suppliedMessage === 'string' + && suppliedMessage.trim().length > 0 && suppliedMessage.length <= 400 + && !/[<>\r\n\u0000-\u001f]/.test(suppliedMessage) + && !/\b(?:stack|exception|postgres|bearer)\b|https?:\/\/|\bat \w+\(/i.test(suppliedMessage)) { + message = suppliedMessage.trim(); + } + if (!message) { + message = status === 401 ? 'Sign in again, then retry your message.' + : status === 403 ? 'This request is not allowed for your current session. Check your account and organization, then try again.' + : status === 429 ? 'You have reached the message limit. Please try again later.' + : streaming ? 'The reply was interrupted before completion.' + : 'Addie could not complete this request. Please try again.'; + } + const error = new Error(message); + if (status) error.httpStatus = status; + error.serverCode = /^[a-z][a-z0-9_]{0,63}$/.test(code) ? code : ''; + error.requestId = validChatRequestId(data.request_id) || validChatRequestId(requestId); + if (typeof data.recoverable === 'boolean') error.recoverable = data.recoverable; + if (code === 'turn_in_progress') error.recoverable = true; + error.certification = data.certification || null; + return error; + } + + function showChatRequestError(error) { + // Keep the support reference available after the transient toast would + // have disappeared. Server text is always rendered as text, never HTML. + const messageDiv = addMessage('', 'assistant'); + const panel = document.createElement('div'); + panel.className = 'chat-request-error'; + panel.setAttribute('role', 'alert'); + const detail = document.createElement('div'); + detail.textContent = error.message; + panel.appendChild(detail); + if (error.httpStatus === 401) { + const link = document.createElement('a'); + link.href = '/auth/login?return_to=/chat'; + link.textContent = 'Sign in again'; + panel.appendChild(link); + } + appendChatRequestReference(panel, error.requestId); + messageDiv.querySelector('.message-content').appendChild(panel); + } + // Send message with streaming async function sendMessage(sendOptions = {}) { const retrying = sendOptions.retry === true; @@ -5294,39 +5371,38 @@

Ask Addie

let pendingSiSession = null; let pendingSiAgents = null; let pendingModelInfo = null; + // A transport attempt gets its own correlation ID; clientRequestId + // remains the stable logical turn ID used by safe continuation. + let requestId = crypto.randomUUID(); try { + const selectedOrganizationId = getSelectedOrganizationId(); const response = await authFetch('/api/addie/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json', + 'X-Request-ID': requestId, }, body: JSON.stringify({ message, conversation_id: conversationId, message_source: messageSource, model_preference: selectedModel, - organization_id: getSelectedOrganizationId(), + ...(typeof selectedOrganizationId === 'string' && selectedOrganizationId.trim() + ? { organization_id: selectedOrganizationId.trim() } : {}), attachments: buildAttachmentPayload(attachmentsToSend), client_request_id: clientRequestId, retry: retrying, }), }); + requestId = validChatRequestId(response.headers.get('X-Request-ID')) || requestId; if (!response.ok) { - // Surface server error messages (e.g., rate limit "daily limit reached") - let errorMsg = 'Failed to send message'; - let errorCode = ''; + let errorData = null; try { - const errorData = await response.json(); - if (errorData.message) errorMsg = errorData.message; - if (errorData.error) errorCode = errorData.error; + errorData = await response.json(); } catch (e) { /* ignore parse errors */ } - const httpError = new Error(errorMsg); - httpError.httpStatus = response.status; - httpError.serverCode = errorCode; - httpError.recoverable = errorCode === 'turn_in_progress'; - throw httpError; + throw chatRequestError(errorData, response.status, requestId); } // Show remaining message count for anonymous users (when running low) @@ -5406,13 +5482,12 @@

Ask Addie

pendingSiAgents = data.si_agents; } } else if (currentEventType === 'stream_error') { - streamFailure = data.reason || 'Reply interrupted'; - const streamError = new Error(streamFailure); - streamError.recoverable = data.recoverable === true; - streamError.certification = data.certification || null; + const streamError = chatRequestError(data, 0, requestId, true); + streamFailure = streamError.message; + streamError.recoverable = streamError.recoverable === true; throw streamError; } else if (currentEventType === 'error') { - throw new Error(data.error || 'Unknown error'); + throw chatRequestError(data, 0, requestId, true); } // tool_start and tool_end events are informational, could show status currentEventType = ''; @@ -5503,9 +5578,10 @@

Ask Addie

} catch (error) { // Network EOFs do not carry an SSE error event, but once the server // assigned a conversation the same client request can be continued. - if (!error.recoverable && !error.httpStatus && conversationId && clientRequestId) { + if (typeof error.recoverable !== 'boolean' && !error.httpStatus && conversationId && clientRequestId) { error.recoverable = true; } + error.requestId = validChatRequestId(error.requestId) || requestId; if (error.recoverable) { console.warn('Addie reply interrupted; offering a safe continuation:', error.message); } else { @@ -5522,9 +5598,13 @@

Ask Addie

clientRequestId, modelPreference: selectedModel, certification: error.certification, + requestId: error.requestId, }); } else { - showError(error.message || 'Failed to send message. Please try again.'); + // Network/parser exceptions may contain browser or transport internals. + const visibleError = error.serverCode !== undefined ? error + : chatRequestError(null, 0, error.requestId); + showChatRequestError(visibleError); } // Clear loading state on error diff --git a/server/src/addie/config-version.ts b/server/src/addie/config-version.ts index 5820c9b80a1..9f804a3d27a 100644 --- a/server/src/addie/config-version.ts +++ b/server/src/addie/config-version.ts @@ -30,7 +30,7 @@ import { loadRules, loadResponseStyle } from './rules/index.js'; * Format: YYYY.MM.N where N is incremented for multiple changes in a month * Example: 2025.01.1, 2025.01.2, 2025.02.1 */ -export const CODE_VERSION = '2026.09.67'; +export const CODE_VERSION = '2026.09.68'; // Types export interface ConfigVersion { diff --git a/server/src/addie/generated/tool-surface-inventory.generated.json b/server/src/addie/generated/tool-surface-inventory.generated.json index c5a9ce77548..f45a9bb806f 100644 --- a/server/src/addie/generated/tool-surface-inventory.generated.json +++ b/server/src/addie/generated/tool-surface-inventory.generated.json @@ -71,7 +71,7 @@ "server/src/addie/prompt-assembly.ts": "6c002ae5a52cdd26e13f424f6e00757dff163b3b273a1b4ab069fdb86f4f375f", "server/src/addie/register-baseline-tools.ts": "52893917fa19fa8904e1667857a7c4bfc2dc90bd3341e60d1ff45a40400d17aa", "server/src/addie/bolt-app.ts": "60b068f6f2e417d14ff9be5f263b5cc0a971199e83614acae1843c186ef53f22", - "server/src/routes/addie-chat.ts": "0d2936a0ac58455dfa8e9528e291dbb0bab1f3389e2c5ad99d7ccc4143135303", + "server/src/routes/addie-chat.ts": "c3c949c3e9ff1d8ff88f95acd0e8021bda0f1e1350fdc149c502e06807f1f7fa", "server/src/routes/tavus.ts": "2026b89e14588371f6efd9d756c2fb161d6fdeb1c6937ef9cdb1652022711ae6", "server/src/addie/email-conversation-handler.ts": "d4f5090d6f71937fff4ff8774ff516ca814be4bd7b23566b366fa037dcc642f3", "server/src/mcp/chat-tool.ts": "a55a9412eb57ce2560b3cdbf2a63c1c1e9fb71361ff1690fc70d5b37b8158258", diff --git a/server/src/http.ts b/server/src/http.ts index 5a4fd4b7ab3..bf44b11c815 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -4,6 +4,7 @@ import cookieParser from "cookie-parser"; import DOMPurify from "isomorphic-dompurify"; import { Marked } from "marked"; import { csrfProtection } from "./middleware/csrf.js"; +import { chatRequestCorrelation } from "./middleware/chat-request-correlation.js"; import { slowResponseTracker } from "./middleware/slow-response.js"; import { requestMetrics } from "./middleware/request-metrics.js"; import escapeHtml from "escape-html"; @@ -1336,6 +1337,9 @@ export class HTTPServer { // Capture request duration metrics for all API calls this.app.use(requestMetrics); + // Include parser, CSRF and authentication rejections in chat support traces. + this.app.use('/api/addie/chat', chatRequestCorrelation); + // Use JSON parser for all routes EXCEPT those that need raw body for signature verification // Limit increased to 10MB to support base64-encoded logo uploads in member profiles this.app.use((req, res, next) => { diff --git a/server/src/middleware/chat-request-correlation.ts b/server/src/middleware/chat-request-correlation.ts new file mode 100644 index 00000000000..f6b41ca9410 --- /dev/null +++ b/server/src/middleware/chat-request-correlation.ts @@ -0,0 +1,69 @@ +import { randomUUID } from 'node:crypto'; +import type { Request, Response, NextFunction } from 'express'; +import { validate as validateUuid } from 'uuid'; +import { createLogger } from '../logger.js'; + +const logger = createLogger('addie-chat-request'); +const errorCodes = new Map([ + ['Invalid session', 'invalid_session'], + ['An unambiguous organization selection is required', 'organization_selection_conflict'], + ['CSRF validation failed', 'csrf_validation_failed'], + ['Authentication service temporarily unavailable', 'authentication_unavailable'], + ['Authorization service temporarily unavailable', 'authorization_unavailable'], + ['Conversation not found', 'conversation_not_found'], + ['turn_in_progress', 'turn_in_progress'], + ['Malformed request body', 'malformed_request'], +]); + +/** Transport correlation only; never use this caller-supplied ID as authority or idempotency. */ +export function chatRequestCorrelation(req: Request, res: Response, next: NextFunction): void { + // Also installed on the router for standalone hosts. The HTTP entry point + // installs it before parsing/CSRF/auth so those rejections are covered too. + if (res.locals.chatRequestId) return next(); + const supplied = req.headers['x-request-id']; + const requestId = typeof supplied === 'string' && supplied.length === 36 && validateUuid(supplied) + ? supplied.toLowerCase() + : randomUUID(); + res.locals.chatRequestId = requestId; + res.setHeader('X-Request-ID', requestId); + const startedAt = Date.now(); + // Log a fixed route family, not URL query strings, identities, cookies or bodies. + let errorCode: string | undefined; + const json = res.json.bind(res); + res.json = (body: unknown) => { + if (res.statusCode >= 400 && body && typeof body === 'object' && !Array.isArray(body)) { + const payload = body as Record; + errorCode = typeof payload.error === 'string' ? errorCodes.get(payload.error) : undefined; + return json({ ...payload, request_id: requestId }); + } + return json(body); + }; + res.on('finish', () => { + if (res.statusCode >= 400) { + logger.warn({ + event: 'addie_chat_request_failed', request_id: requestId, + route: '/api/addie/chat', method: req.method, status: res.statusCode, + error_code: errorCode, duration_ms: Date.now() - startedAt, + }, 'Addie chat HTTP request failed'); + } + }); + res.on('close', () => { + if (!res.writableFinished) { + logger.warn({ + event: 'addie_chat_request_interrupted', request_id: requestId, + route: '/api/addie/chat', method: req.method, duration_ms: Date.now() - startedAt, + }, 'Addie chat connection closed before completion'); + } + }); + next(); +} + +export function correlateChatStreamError(res: Response, data: unknown): Record { + const payload = data && typeof data === 'object' && !Array.isArray(data) + ? data as Record : {}; + logger.warn({ + event: 'addie_chat_stream_failed', request_id: res.locals.chatRequestId, + recoverable: payload.recoverable === true, + }, 'Addie chat stream failed'); + return { ...payload, request_id: res.locals.chatRequestId }; +} diff --git a/server/src/routes/addie-chat.ts b/server/src/routes/addie-chat.ts index 66d5507b155..8ba729a23c9 100644 --- a/server/src/routes/addie-chat.ts +++ b/server/src/routes/addie-chat.ts @@ -16,6 +16,7 @@ import cors from "cors"; import { createLogger } from "../logger.js"; import { CachedPostgresStore } from "../middleware/pg-rate-limit-store.js"; import { optionalAuth } from "../middleware/auth.js"; +import { chatRequestCorrelation, correlateChatStreamError } from '../middleware/chat-request-correlation.js'; import { serveHtmlWithConfig } from "../utils/html-config.js"; import { AddieClaudeClient, type AddieResponse, type RequestTools } from "../addie/claude-client.js"; import { @@ -1117,8 +1118,8 @@ const chatCorsOptions: cors.CorsOptions = { ], credentials: true, methods: ['GET', 'POST', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization'], - exposedHeaders: ['X-Conversation-Id', 'RateLimit-Limit', 'RateLimit-Remaining'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'], + exposedHeaders: ['X-Conversation-Id', 'RateLimit-Limit', 'RateLimit-Remaining', 'X-Request-ID'], }; export function createAddieChatRouter(options?: { @@ -1142,6 +1143,7 @@ export function createAddieChatRouter(options?: { // Enable CORS for all API routes (for native app support) apiRouter.use(cors(chatCorsOptions)); + apiRouter.use(chatRequestCorrelation); // Initialize client after server starts (deferred to avoid blocking startup with sync I/O) if (!injectedChatClient) { @@ -1507,7 +1509,7 @@ export function createAddieChatRouter(options?: { logger.warn({ error }, "Addie Chat: Conversation exceeded context limit"); errorMessage = "This conversation is too long for me to process. Please start a new conversation and I'll be happy to help!"; } else { - logger.error({ error }, "Addie Chat: Error processing message"); + logger.error({ error, request_id: res.locals.chatRequestId }, "Addie Chat: Error processing message"); errorMessage = isRetriesExhaustedError(error) ? `${error.reason}. Please try again in a moment.` : "I'm sorry, I encountered an error. Please try again."; @@ -1613,7 +1615,7 @@ export function createAddieChatRouter(options?: { message: ATTACHMENT_VALIDATION_CLIENT_MESSAGE, }); } - logger.error({ err: error }, "Addie Chat: Error handling message"); + logger.error({ err: error, request_id: res.locals.chatRequestId }, "Addie Chat: Error handling message"); res.status(500).json({ error: "Internal server error", message: "Unable to process message", @@ -1666,6 +1668,9 @@ export function createAddieChatRouter(options?: { // Helper to send SSE events (checks if connection is still open) const sendEvent = (event: string, data: unknown) => { + if (event === 'error' || event === 'stream_error') { + data = correlateChatStreamError(res, data); + } if (connectionClosed) return; try { res.write(`event: ${event}\n`); @@ -2554,7 +2559,7 @@ export function createAddieChatRouter(options?: { message: error.statusCode === 403 ? 'Sign in to choose a model.' : 'This model selection is unavailable for the request.', }); } - logger.error({ err: error }, "Addie Chat Stream: Error handling message"); + logger.error({ err: error, request_id: res.locals.chatRequestId }, "Addie Chat Stream: Error handling message"); let interruptedMessageId: string | undefined; if (claimedTurn) { try { diff --git a/server/tests/unit/addie-chat-object-authorization.test.ts b/server/tests/unit/addie-chat-object-authorization.test.ts index 06664ba575a..d4e19324a8c 100644 --- a/server/tests/unit/addie-chat-object-authorization.test.ts +++ b/server/tests/unit/addie-chat-object-authorization.test.ts @@ -792,6 +792,8 @@ describe('Addie chat conversation object authorization', () => { }); expect(response.status).toBe(200); expect(response.text).toContain('event: stream_error'); + expect(response.headers['x-request-id']).toMatch(/^[0-9a-f-]{36}$/); + expect(response.text).toContain(`"request_id":"${response.headers['x-request-id']}"`); expect(markDelivery).toHaveBeenCalledWith('interrupted', 'message_assistant'); } finally { prepare.mockRestore(); diff --git a/server/tests/unit/addie-chat-org-selection.test.ts b/server/tests/unit/addie-chat-org-selection.test.ts index 54fa3b5dc6c..fb8d62c9f9c 100644 --- a/server/tests/unit/addie-chat-org-selection.test.ts +++ b/server/tests/unit/addie-chat-org-selection.test.ts @@ -451,7 +451,9 @@ describe('mounted Addie web-thread ownership', () => { expect(response.body).toEqual({ error: 'Internal server error', message: 'Unable to process message', + request_id: response.headers['x-request-id'], }); + expect(response.body.request_id).toMatch(/^[0-9a-f-]{36}$/); expect(router.route).toHaveBeenCalledOnce(); expect(chatClient.processMessage).not.toHaveBeenCalled(); expect(chatClient.processMessageStream).not.toHaveBeenCalled(); diff --git a/server/tests/unit/addie-web-org-selection.test.ts b/server/tests/unit/addie-web-org-selection.test.ts index 9276c9d10ff..8a64bcf2733 100644 --- a/server/tests/unit/addie-web-org-selection.test.ts +++ b/server/tests/unit/addie-web-org-selection.test.ts @@ -17,7 +17,6 @@ describe('Addie web organization selection client', () => { expect(chatHtml).toContain('authFetch(`/api/me/addie-home?format=html${orgParam}`)'); }); - it('forwards the selected organization in chat stream requests', () => { - expect(chatHtml).toContain('organization_id: getSelectedOrganizationId(),'); - }); + // Stream request forwarding and null/empty omission are exercised through + // the browser in chat-request-errors.test.ts. }); diff --git a/server/tests/unit/authorization-epoch-session.test.ts b/server/tests/unit/authorization-epoch-session.test.ts index 4b359a0ba65..8ead8c1ed51 100644 --- a/server/tests/unit/authorization-epoch-session.test.ts +++ b/server/tests/unit/authorization-epoch-session.test.ts @@ -48,6 +48,7 @@ vi.mock('../../src/db/session-refresh-db.js', () => ({ import { AuthorizationSnapshotUnavailableError } from '../../src/db/user-authorization-snapshot-db.js'; import { invalidateBanCache, optionalAuth, requireAuth, stopAuthTimers } from '../../src/middleware/auth.js'; import { csrfProtection } from '../../src/middleware/csrf.js'; +import { chatRequestCorrelation } from '../../src/middleware/chat-request-correlation.js'; const AUTHENTICATED_ID = 'user_epoch_authenticated'; const PROVIDER_USER = { @@ -55,6 +56,28 @@ const PROVIDER_USER = { emailVerified: true, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', }; let sequence = 0; + +it.each([ + [{ message: 'Hello' }, 200], + [{ message: 'Hello', organization_id: null }, 403], + [{ message: 'Hello', organization_id: 'org_other' }, 403], + [{ message: 'Hello', organization_id: 'org_pinnacle' }, 200], +])('preserves exact-credential chat authorization for body %j', async (body, status) => { + mocks.authenticate.mockResolvedValue({ authenticated: true, user: { ...PROVIDER_USER }, accessToken: 'access-token', organizationId: 'org_pinnacle' }); + const app = express(); + app.use(chatRequestCorrelation); + app.use(express.json()); + app.use((req, _res, next) => { req.cookies = { 'wos-session': `chat-org-${++sequence}` }; next(); }); + app.post('/api/addie/chat/stream', optionalAuth, (_req, res) => res.json({ ok: true })); + const response = await supertest(app).post('/api/addie/chat/stream').send(body); + expect(response.status).toBe(status); + if (status === 403) { + expect(response.body).toMatchObject({ error: 'An unambiguous organization selection is required', request_id: response.headers['x-request-id'] }); + expect(mocks.loadAuthorizationSnapshot).not.toHaveBeenCalled(); + } else { + expect(mocks.loadAuthorizationSnapshot).toHaveBeenCalledWith(AUTHENTICATED_ID, 'org_pinnacle'); + } +}); function snapshot(overrides: Partial = {}): AuthorizationSnapshot { return { authenticatedUserId: AUTHENTICATED_ID, canonicalUserId: AUTHENTICATED_ID, diff --git a/server/tests/unit/chat-request-correlation.test.ts b/server/tests/unit/chat-request-correlation.test.ts new file mode 100644 index 00000000000..a90b8f5defc --- /dev/null +++ b/server/tests/unit/chat-request-correlation.test.ts @@ -0,0 +1,91 @@ +import express from 'express'; +import cookieParser from 'cookie-parser'; +import request from 'supertest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { chatRequestCorrelation, correlateChatStreamError } from '../../src/middleware/chat-request-correlation.js'; +import { csrfProtection } from '../../src/middleware/csrf.js'; + +const logs = vi.hoisted(() => ({ warn: vi.fn() })); +vi.mock('../../src/logger.js', () => ({ createLogger: () => logs })); +const id = 'a94c88c0-f379-4a1d-826d-4d9b251838e0'; +const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +beforeEach(() => vi.clearAllMocks()); + +function app(csrf = false) { + const app = express(); + app.use('/api/addie/chat', chatRequestCorrelation); + app.use(express.json()); + app.use(cookieParser()); + if (csrf) app.use(csrfProtection); + const router = express.Router(); + router.use(chatRequestCorrelation); + router.post('/stream', (_req, res) => res.status(403).json({ error: 'An unambiguous organization selection is required' })); + router.post('/sse', (_req, res) => { + res.type('text/event-stream'); + res.end(`event: stream_error\ndata: ${JSON.stringify(correlateChatStreamError(res, { error: 'Internal server error', recoverable: true }))}\n\n`); + }); + app.use('/api/addie/chat', router); + app.use((_err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(400).json({ error: 'Malformed request body' }); + }); + return app; +} + +describe('chat transport request correlation', () => { + it('preserves a validated UUID across header, JSON and one structured failure log', async () => { + const response = await request(app()).post('/api/addie/chat/stream?secret=never-log') + .set('X-Request-ID', id.toUpperCase()).send({ message: 'private text' }); + expect(response.status).toBe(403); + expect(response.headers['x-request-id']).toBe(id); + expect(response.body.request_id).toBe(id); + expect(logs.warn).toHaveBeenCalledTimes(1); + expect(logs.warn).toHaveBeenCalledWith(expect.objectContaining({ + request_id: id, status: 403, event: 'addie_chat_request_failed', error_code: 'organization_selection_conflict', + }), expect.any(String)); + expect(JSON.stringify(logs.warn.mock.calls)).not.toMatch(/never-log|private text/); + }); + + it.each([undefined, 'sam@example.test', 'x'.repeat(500), `${id},${id}`, 'not-a-uuid'])('generates an ID for missing/unsafe input %s', async (supplied) => { + const call = request(app()).post('/api/addie/chat/stream'); + if (supplied !== undefined) call.set('X-Request-ID', supplied); + const response = await call.send({}); + expect(response.headers['x-request-id']).toMatch(uuid); + expect(response.body.request_id).toBe(response.headers['x-request-id']); + expect(response.body.request_id).not.toBe(supplied); + if (supplied) expect(JSON.stringify(logs.warn.mock.calls)).not.toContain(supplied); + }); + + it('correlates early CSRF rejection and preserves the fresh-token retry contract', async () => { + const response = await request(app(true)).post('/api/addie/chat/stream').set('X-Request-ID', id).send({}); + expect(response.status).toBe(403); + expect(response.headers['x-csrf-retry']).toBe('true'); + expect(response.body).toMatchObject({ error: 'CSRF validation failed', request_id: id }); + expect(response.body.token).toMatch(/^[0-9a-f]{64}$/); + expect(logs.warn).toHaveBeenCalledWith(expect.objectContaining({ request_id: id, status: 403 }), expect.any(String)); + expect(JSON.stringify(logs.warn.mock.calls)).not.toContain(response.body.token); + }); + + it('correlates malformed JSON before the chat router runs', async () => { + const response = await request(app()).post('/api/addie/chat/stream').set('Content-Type', 'application/json').send('{'); + expect(response.status).toBe(400); + expect(response.body.request_id).toBe(response.headers['x-request-id']); + }); + + it('correlates SSE errors without logging provider details or changing recoverability', async () => { + const response = await request(app()).post('/api/addie/chat/sse').set('X-Request-ID', id).send({}); + const data = JSON.parse(response.text.split('data: ')[1]); + expect(data).toMatchObject({ request_id: id, recoverable: true }); + expect(response.headers['x-request-id']).toBe(id); + expect(logs.warn).toHaveBeenCalledWith(expect.objectContaining({ request_id: id, event: 'addie_chat_stream_failed' }), expect.any(String)); + expect(JSON.stringify(logs.warn.mock.calls)).not.toContain('Internal server error'); + }); + + it('mounts correlation ahead of parser and CSRF in the actual HTTP host', () => { + const source = readFileSync('server/src/http.ts', 'utf8'); + const correlation = source.indexOf("this.app.use('/api/addie/chat', chatRequestCorrelation)"); + expect(correlation).toBeGreaterThan(-1); + expect(correlation).toBeLessThan(source.indexOf('express.json({')); + expect(correlation).toBeLessThan(source.indexOf('this.app.use(csrfProtection)')); + }); +}); diff --git a/server/tests/unit/chat-request-errors.test.ts b/server/tests/unit/chat-request-errors.test.ts new file mode 100644 index 00000000000..205e111f41d --- /dev/null +++ b/server/tests/unit/chat-request-errors.test.ts @@ -0,0 +1,141 @@ +import { readFileSync } from 'node:fs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { marked } from 'marked'; +import createDOMPurify from 'dompurify'; + +const html = readFileSync('server/public/chat.html', 'utf8'); +const models = readFileSync('server/public/chat-model-selection.js', 'utf8'); +const csrf = readFileSync('server/public/csrf.js', 'utf8'); +const id = 'a94c88c0-f379-4a1d-826d-4d9b251838e0'; +let dom: JSDOM; +afterEach(() => { dom?.window.close(); vi.restoreAllMocks(); }); +const json = (body: unknown, status = 200, headers = {}) => new Response(JSON.stringify(body), { status, headers }); +const sse = (event: string, body: unknown) => new Response( + `event: meta\ndata: {"conversation_id":"test-thread"}\n\nevent: ${event}\ndata: ${JSON.stringify(body)}\n\n`, + { headers: { 'Content-Type': 'text/event-stream', 'X-Request-ID': id } }, +); + +async function openChat(reply: (attempt: number) => Response, organization?: string, csrfRetry = false) { + const requests: Array<{ body: Record; headers: Headers }> = []; + dom = new JSDOM(html, { + url: 'https://example.test/chat', runScripts: 'dangerously', pretendToBeVisual: true, + beforeParse(window) { + window.eval(models); + window.marked = marked; + window.DOMPurify = createDOMPurify(window); + window.TextDecoder = TextDecoder; + window.Headers = Headers; + window.matchMedia = (() => ({ matches: false, addEventListener() {} })) as any; + if (organization !== undefined) window.localStorage.setItem('selectedOrgId', organization); + window.fetch = (async (url: string, options?: RequestInit) => { + if (url === '/api/me') return json({ user: { id: 'member' } }); + if (url === '/api/addie/chat/status') return json({ ready: true, model_selection: { enabled: false } }); + if (url.startsWith('/api/me/addie-home')) return json({ html: '', css: '' }); + if (url === '/api/addie/chat/threads') return json({ conversations: [] }); + if (url === '/api/si/sessions/user') return json({ sessions: [] }); + if (url === '/api/addie/chat/stream') { + requests.push({ body: JSON.parse(String(options?.body)), headers: new Headers(options?.headers) }); + return reply(requests.length); + } + return json({}, 404); + }) as typeof fetch; + if (csrfRetry) window.eval(csrf); + }, + }); + const window = dom.window; + const input = window.document.getElementById('chatInput') as HTMLTextAreaElement; + await vi.waitFor(() => expect(input.disabled).toBe(false)); + input.value = 'Help me understand AdCP'; + input.dispatchEvent(new window.Event('input')); + await vi.waitFor(() => expect((window.document.getElementById('sendButton') as HTMLButtonElement).disabled).toBe(false)); + window.document.getElementById('sendButton')!.click(); + await vi.waitFor(() => expect(requests.length).toBeGreaterThan(0)); + return { document: window.document, requests }; +} + +describe('chat requests and actionable failure UI', () => { + it.each([undefined, '', ' '])('omits an absent/empty organization selector (%s)', async (org) => { + const { requests } = await openChat(() => json({}, 403), org); + expect(requests[0].body).not.toHaveProperty('organization_id'); + expect(requests[0].headers.get('X-Request-ID')).toMatch(/^[0-9a-f-]{36}$/); + expect(requests[0].body.client_request_id).not.toBe(requests[0].headers.get('X-Request-ID')); + }); + + it('retains a selected organization without inferring another one', async () => { + const { requests } = await openChat(() => json({}, 403), 'org_selected'); + expect(requests[0].body.organization_id).toBe('org_selected'); + }); + + it.each([ + [401, { error: 'Invalid session' }, 'Your session has expired'], + [403, { error: 'An unambiguous organization selection is required' }, 'Choose your organization again'], + [403, { error: 'CSRF validation failed' }, 'Refresh this page'], + [403, { message: 'Sign in to choose a model.' }, 'Sign in to choose a model.'], + [401, { error: { message: 'Your session expired. Please sign in again.' } }, 'Your session expired. Please sign in again.'], + ])('renders HTTP %s safely with a persistent support reference', async (status, body, text) => { + const { document } = await openChat(() => json(body, status as number, { 'X-Request-ID': id })); + await vi.waitFor(() => expect(document.querySelector('.chat-request-error')?.textContent).toContain(text)); + expect(document.querySelector('.chat-request-reference')?.textContent).toContain(id); + if (status === 401) expect(document.querySelector('.chat-request-error a')?.getAttribute('href')).toBe('/auth/login?return_to=/chat'); + }); + + it.each([ + () => json({ error: { stack: 'private stack', message: 'postgres://secret' }, message: 'private stack' }, 500), + () => json({ message: '', error: { stack: 'private stack' } }, 403), + () => new Response('private stack', { status: 502 }), + () => json(null, 401), + () => json(['private stack'], 403), + ])('does not render internal errors, HTML, or malformed error shapes', async (reply) => { + const { document } = await openChat(reply); + await vi.waitFor(() => expect(document.querySelector('.chat-request-error')).not.toBeNull()); + expect(document.querySelector('.chat-request-error')?.textContent).not.toMatch(/private stack|postgres|\[object Object\]/); + expect(document.querySelector('.chat-request-error script')).toBeNull(); + }); + + it('uses a valid body correlation ID and rejects unsafe IDs', async () => { + const { document } = await openChat(() => json({ error: 'Invalid session', request_id: id }, 401)); + await vi.waitFor(() => expect(document.querySelector('.chat-request-reference')?.textContent).toContain(id)); + }); + + it('replaces invalid server correlation IDs with the safe client attempt ID', async () => { + const { document, requests } = await openChat(() => json({ request_id: '' }, 403, { 'X-Request-ID': 'sam@example.test' })); + await vi.waitFor(() => expect(document.querySelector('.chat-request-reference')?.textContent).toContain(requests[0].headers.get('X-Request-ID'))); + expect(document.querySelector('.chat-request-reference')?.textContent).not.toMatch(/script|sam@/); + }); + + it('preserves recoverable SSE continuation and correlates each transport attempt', async () => { + const { document, requests } = await openChat(() => sse('stream_error', { error: { message: 'private stack' }, recoverable: true })); + await vi.waitFor(() => expect(document.querySelector('.reply-recovery__action')).not.toBeNull()); + expect(document.querySelector('.reply-recovery')?.textContent).not.toContain('private stack'); + expect(document.querySelector('.reply-recovery .chat-request-reference')?.textContent).toContain(id); + (document.querySelector('.reply-recovery__action') as HTMLButtonElement).click(); + await vi.waitFor(() => expect(requests).toHaveLength(2)); + expect(requests[1].body).toMatchObject({ retry: true, client_request_id: requests[0].body.client_request_id }); + expect(requests[1].headers.get('X-Request-ID')).not.toBe(requests[0].headers.get('X-Request-ID')); + }); + + it('does not promote an explicitly nonrecoverable stream error into a retry', async () => { + const { document } = await openChat(() => sse('stream_error', { reason: 'checkpoint_persistence_failed', recoverable: false })); + await vi.waitFor(() => expect(document.querySelector('.chat-request-error')?.textContent).toContain('Review the action before trying again')); + expect(document.querySelector('.reply-recovery__action')).toBeNull(); + }); + + it('keeps network EOFs recoverable after a conversation was assigned', async () => { + const { document } = await openChat(() => sse('text', { text: 'Partial reply' })); + await vi.waitFor(() => expect(document.querySelector('.reply-recovery__action')).not.toBeNull()); + expect(document.querySelector('.chat-request-reference')?.textContent).toContain(id); + }); + + it('retries a CSRF cookie-expiry 403 once using its fresh token and preserves correlation', async () => { + const token = 'a'.repeat(64); + const { document, requests } = await openChat((attempt) => attempt === 1 + ? json({ error: 'CSRF validation failed', token }, 403, { 'X-CSRF-Retry': 'true' }) + : json({ error: 'An unambiguous organization selection is required' }, 403, { 'X-Request-ID': id }), undefined, true); + await vi.waitFor(() => expect(document.querySelector('.chat-request-error')?.textContent).toContain('Choose your organization again')); + expect(requests).toHaveLength(2); + expect(requests[1].headers.get('X-CSRF-Token')).toBe(token); + expect(requests[1].headers.get('X-Request-ID')).toBe(requests[0].headers.get('X-Request-ID')); + expect(requests[1].body).toEqual(requests[0].body); + }); +}); diff --git a/tests/release-publication-ordering.test.cjs b/tests/release-publication-ordering.test.cjs index e7cac555a9b..cd116dc30a0 100644 --- a/tests/release-publication-ordering.test.cjs +++ b/tests/release-publication-ordering.test.cjs @@ -647,6 +647,7 @@ test("main advancing during GitHub staging leaves a draft and publishes no R2 ob }); test("workflow wiring binds tested SHA, approval, exact recovery, and publication order", () => { const ds = deploy.jobs.deploy.steps; + assert.equal(ds.find((s) => s.name === "Checkout").with["fetch-depth"], 0); assert.ok( ds.findIndex( (s) => s.name === "Require committed version to have a complete release",