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",