From e688ccbc5d40a95ce5811a7a8cf45906b8ca65cd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 06:49:05 +0000 Subject: [PATCH 01/12] Use OpenAI SDK for Responses API streaming Replace the hand-rolled fetch + SSE parsing in the OpenAI adapter with the official openai package. client.responses.create({ stream: true }) returns a typed async-iterable of events, eliminating the manual TextDecoder/buffer and extractSseJson machinery. Behavior, exports, and the tool-call loop are unchanged. --- backend/package-lock.json | 23 ++++ backend/package.json | 1 + backend/src/lib/llm/openai.ts | 225 ++++++++++------------------------ 3 files changed, 86 insertions(+), 163 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index effa2adef..42b28e294 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "mike-backend", "version": "1.0.0", + "license": "AGPL-3.0-only", "dependencies": { "@anthropic-ai/sdk": "^0.90.0", "@aws-sdk/client-s3": "^3.787.0", @@ -25,6 +26,7 @@ "libreoffice-convert": "^1.6.0", "mammoth": "^1.9.0", "multer": "^1.4.5-lts.2", + "openai": "^6.39.1", "pdfjs-dist": "^4.10.38", "resend": "^4.5.1" }, @@ -4172,6 +4174,27 @@ "node": ">= 0.8" } }, + "node_modules/openai": { + "version": "6.39.1", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.39.1.tgz", + "integrity": "sha512-z3dO9fEWOXBzlXynVb/xZ/tujzUjFWQWn3C0n0mw6Vo0zJTbEkaN4b2cLWjhJ6haJQx8LlREoafHRl+Gu/Hl+A==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/option": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", diff --git a/backend/package.json b/backend/package.json index 8451ab8b7..c9304f205 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,6 +25,7 @@ "libreoffice-convert": "^1.6.0", "mammoth": "^1.9.0", "multer": "^1.4.5-lts.2", + "openai": "^6.39.1", "pdfjs-dist": "^4.10.38", "resend": "^4.5.1" }, diff --git a/backend/src/lib/llm/openai.ts b/backend/src/lib/llm/openai.ts index de07b5c96..8f2254545 100644 --- a/backend/src/lib/llm/openai.ts +++ b/backend/src/lib/llm/openai.ts @@ -1,3 +1,4 @@ +import OpenAI from "openai"; import type { LlmMessage, NormalizedToolCall, @@ -7,34 +8,14 @@ import type { StreamChatResult, } from "./types"; -const OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses"; const MAX_OUTPUT_TOKENS = 16384; +// The Responses API input is either a chat-style message or a tool-call +// result. We only ever feed it these two shapes. type ResponseInputItem = | { role: "user" | "assistant"; content: string } | { type: "function_call_output"; call_id: string; output: string }; -type ResponseFunctionTool = { - type: "function"; - name: string; - description?: string; - parameters: Record; -}; - -type ResponseFunctionCallItem = { - type: "function_call"; - call_id?: string; - name?: string; - arguments?: string; -}; - -type ResponseStreamEvent = { - type?: string; - delta?: string; - response?: { id?: string; output_text?: string }; - item?: ResponseFunctionCallItem; -}; - function apiKey(override?: string | null): string { const key = override?.trim() || process.env.OPENAI_API_KEY?.trim() || ""; if (!key) { @@ -45,12 +26,19 @@ function apiKey(override?: string | null): string { return key; } -function toResponseTools(tools: OpenAIToolSchema[]): ResponseFunctionTool[] { +function client(override?: string | null): OpenAI { + return new OpenAI({ apiKey: apiKey(override) }); +} + +function toResponseTools( + tools: OpenAIToolSchema[], +): OpenAI.Responses.Tool[] { return tools.map((tool) => ({ type: "function", name: tool.function.name, description: tool.function.description, parameters: tool.function.parameters, + strict: false, })); } @@ -61,32 +49,11 @@ function toResponseInput(messages: LlmMessage[]): ResponseInputItem[] { })); } -function extractSseJson(buffer: string): { events: unknown[]; rest: string } { - const events: unknown[] = []; - const chunks = buffer.split(/\n\n/); - const rest = chunks.pop() ?? ""; - - for (const chunk of chunks) { - const dataLines = chunk - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.startsWith("data:")) - .map((line) => line.slice(5).trim()); - - for (const data of dataLines) { - if (!data || data === "[DONE]") continue; - try { - events.push(JSON.parse(data)); - } catch { - // Incomplete events stay buffered until the next read. - } - } - } - - return { events, rest }; -} - -function parseFunctionCall(item: ResponseFunctionCallItem): NormalizedToolCall { +function parseFunctionCall(item: { + call_id?: string; + name?: string; + arguments?: string; +}): NormalizedToolCall { let input: Record = {}; try { const parsed = JSON.parse(item.arguments || "{}"); @@ -104,49 +71,6 @@ function parseFunctionCall(item: ResponseFunctionCallItem): NormalizedToolCall { }; } -async function createResponse(params: { - model: string; - input: ResponseInputItem[]; - instructions?: string; - tools?: ResponseFunctionTool[]; - stream?: boolean; - maxTokens?: number; - previousResponseId?: string; - reasoningSummary?: boolean; - apiKey: string; -}): Promise { - const response = await fetch(OPENAI_RESPONSES_URL, { - method: "POST", - headers: { - Authorization: `Bearer ${params.apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - model: params.model, - instructions: params.instructions || undefined, - input: params.input, - tools: params.tools?.length ? params.tools : undefined, - stream: params.stream, - max_output_tokens: params.maxTokens ?? MAX_OUTPUT_TOKENS, - previous_response_id: params.previousResponseId, - reasoning: params.reasoningSummary - ? { summary: "auto" } - : undefined, - }), - }); - - if (!response.ok) { - const text = await response.text().catch(() => ""); - const err = new Error( - `OpenAI request failed (${response.status}): ${text || response.statusText}`, - ); - (err as { status?: number }).status = response.status; - throw err; - } - - return response; -} - export async function streamOpenAI( params: StreamChatParams, ): Promise { @@ -160,86 +84,76 @@ export async function streamOpenAI( enableThinking, } = params; const maxIter = params.maxIterations ?? 10; - const key = apiKey(apiKeys?.openai); + const openai = client(apiKeys?.openai); const responseTools = toResponseTools(tools); - let input = toResponseInput(params.messages); + const hasTools = responseTools.length > 0; + + let input: ResponseInputItem[] = toResponseInput(params.messages); let previousResponseId: string | undefined; let fullText = ""; - const hasTools = responseTools.length > 0; for (let iter = 0; iter < maxIter; iter++) { - const response = await createResponse({ + // The SDK returns a typed async iterable of SSE events — no manual + // fetch/TextDecoder/buffer parsing required. Conversation state is + // carried server-side via `previous_response_id`, so after the first + // turn we only send fresh input (tool outputs) and let the prior + // context (including instructions) persist. + const stream = await openai.responses.create({ model, instructions: iter === 0 ? systemPrompt : undefined, - input, - tools: responseTools, + input: input as OpenAI.Responses.ResponseInput, + tools: responseTools.length ? responseTools : undefined, stream: true, - previousResponseId, - reasoningSummary: !!enableThinking, - apiKey: key, + max_output_tokens: MAX_OUTPUT_TOKENS, + previous_response_id: previousResponseId, + reasoning: enableThinking ? { summary: "auto" } : undefined, }); - if (!response.body) throw new Error("OpenAI response had no body"); - const reader = response.body.getReader(); - const decoder = new TextDecoder(); const toolCalls: NormalizedToolCall[] = []; const startedToolCallIds = new Set(); - let buffer = ""; let pendingText = ""; let sawReasoning = false; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const extracted = extractSseJson(buffer); - buffer = extracted.rest; - - for (const event of extracted.events as ResponseStreamEvent[]) { - if (event.response?.id) { + for await (const event of stream) { + switch (event.type) { + case "response.created": previousResponseId = event.response.id; - } + break; - if ( - event.type === "response.reasoning_summary_text.delta" && - typeof event.delta === "string" - ) { + case "response.reasoning_summary_text.delta": sawReasoning = true; callbacks.onReasoningDelta?.(event.delta); - } + break; - if ( - event.type === "response.output_text.delta" && - typeof event.delta === "string" - ) { + case "response.output_text.delta": + // When tools are in play we buffer text and only flush it + // once we know the model isn't going to call a tool — a + // tool-calling turn shouldn't surface partial prose. if (hasTools) { pendingText += event.delta; } else { fullText += event.delta; callbacks.onContentDelta?.(event.delta); } - } - - if ( - event.type === "response.output_item.added" && - event.item?.type === "function_call" - ) { - const call = parseFunctionCall(event.item); - startedToolCallIds.add(call.id); - callbacks.onToolCallStart?.(call); - } + break; - if ( - event.type === "response.output_item.done" && - event.item?.type === "function_call" - ) { - const call = parseFunctionCall(event.item); - if (!startedToolCallIds.has(call.id)) { + case "response.output_item.added": + if (event.item.type === "function_call") { + const call = parseFunctionCall(event.item); + startedToolCallIds.add(call.id); callbacks.onToolCallStart?.(call); } - toolCalls.push(call); - } + break; + + case "response.output_item.done": + if (event.item.type === "function_call") { + const call = parseFunctionCall(event.item); + if (!startedToolCallIds.has(call.id)) { + callbacks.onToolCallStart?.(call); + } + toolCalls.push(call); + } + break; } } @@ -271,29 +185,14 @@ export async function completeOpenAIText(params: { maxTokens?: number; apiKeys?: { openai?: string | null }; }): Promise { - const response = await createResponse({ + const openai = client(params.apiKeys?.openai); + const response = await openai.responses.create({ model: params.model, instructions: params.systemPrompt, - input: [{ role: "user", content: params.user }], - maxTokens: params.maxTokens ?? 512, - apiKey: apiKey(params.apiKeys?.openai), + input: params.user, + max_output_tokens: params.maxTokens ?? 512, }); - const json = (await response.json()) as { - output_text?: string; - output?: { - content?: { type?: string; text?: string }[]; - }[]; - }; - - if (typeof json.output_text === "string") return json.output_text; - - return ( - json.output - ?.flatMap((item) => item.content ?? []) - .filter((content) => content.type === "output_text") - .map((content) => content.text ?? "") - .join("") ?? "" - ); + return response.output_text ?? ""; } export type { NormalizedToolResult }; From 9dfb6fd565121a0acecc346eeffe227ff4b90000 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 22:11:51 +0000 Subject: [PATCH 02/12] Extract shared streaming loop into a provider-agnostic driver The Claude, OpenAI, and Gemini adapters each re-implemented the same agentic streaming loop (iterate to maxIterations, stream a turn, accumulate fullText, run tools, feed results back). That triplication let the loop logic drift between providers. Introduce backend/src/lib/llm/driver.ts owning the loop, break conditions, the runTools call, and the single fullText accumulation. Each provider becomes a thin session factory (create{Claude,OpenAI, Gemini}Session) that owns its SDK call, event parsing, follow-up message state, and all callback firing. Public stream* signatures are unchanged, so callers stay untouched. Behavior is preserved: per-provider callback ordering, the OpenAI pre-tool preamble drop, Claude's stop_reason hard-stop, Gemini's verbatim thoughtSignature replay, and OpenAI instructions-on-iter-0. Chat history persistence (route-level, fed by callbacks + fullText) is unaffected. Typecheck passes. --- backend/src/lib/llm/claude.ts | 51 ++++++++++++++++++--------- backend/src/lib/llm/driver.ts | 66 +++++++++++++++++++++++++++++++++++ backend/src/lib/llm/gemini.ts | 52 ++++++++++++++++++--------- backend/src/lib/llm/openai.ts | 53 +++++++++++++++++++--------- 4 files changed, 172 insertions(+), 50 deletions(-) create mode 100644 backend/src/lib/llm/driver.ts diff --git a/backend/src/lib/llm/claude.ts b/backend/src/lib/llm/claude.ts index 9f86b1679..551a5aa4a 100644 --- a/backend/src/lib/llm/claude.ts +++ b/backend/src/lib/llm/claude.ts @@ -7,6 +7,12 @@ import type { NormalizedToolResult, } from "./types"; import { toClaudeTools } from "./tools"; +import { + runStreamingLoop, + type ProviderSession, + type TurnContext, + type TurnResult, +} from "./driver"; type ContentBlock = | { type: "text"; text: string } @@ -41,26 +47,24 @@ function toNativeMessages( return messages.map((m) => ({ role: m.role, content: m.content })); } -export async function streamClaude( - params: StreamChatParams, -): Promise { +function createClaudeSession(params: StreamChatParams): ProviderSession { const { model, systemPrompt, tools = [], - callbacks = {}, - runTools, apiKeys, enableThinking, } = params; - const maxIter = params.maxIterations ?? 10; const anthropic = client(apiKeys?.claude); const claudeTools = toClaudeTools(tools); const messages: NativeMessage[] = toNativeMessages(params.messages); - let fullText = ""; + // Holds the previous turn's assistant content blocks, which Claude requires + // verbatim on the follow-up turn that carries tool_result blocks. + let lastAssistantBlocks: ContentBlock[] = []; - for (let iter = 0; iter < maxIter; iter++) { + async function runTurn(ctx: TurnContext): Promise { + const { callbacks } = ctx; const stream = anthropic.messages.stream({ model, system: systemPrompt, @@ -94,17 +98,21 @@ export async function streamClaude( } const final = await stream.finalMessage(); + // Claude fires onReasoningBlockEnd before walking blocks for + // onToolCallStart — preserving the provider's callback ordering. if (sawThinking) callbacks.onReasoningBlockEnd?.(); const stopReason = final.stop_reason; const assistantBlocks = final.content as ContentBlock[]; + lastAssistantBlocks = assistantBlocks; // Extract text content and tool_use calls from the final assistant // message so we can accumulate text and drive the tool-call loop. + let turnText = ""; const toolCalls: NormalizedToolCall[] = []; for (const block of assistantBlocks) { if (block.type === "text") { const txt = (block as { text: string }).text; - if (typeof txt === "string") fullText += txt; + if (typeof txt === "string") turnText += txt; } else if (block.type === "tool_use") { const tu = block as { id: string; @@ -121,16 +129,21 @@ export async function streamClaude( } } - if (stopReason !== "tool_use" || !toolCalls.length || !runTools) { - break; - } - - const results = await runTools(toolCalls); + return { + toolCalls, + textForFullText: turnText, + stop: stopReason !== "tool_use", + }; + } + function recordToolResults( + _calls: NormalizedToolCall[], + results: NormalizedToolResult[], + ): void { // Record the assistant turn (preserving the original content blocks, // which Claude requires on the follow-up) and the user turn that // carries the tool_result blocks. - messages.push({ role: "assistant", content: assistantBlocks }); + messages.push({ role: "assistant", content: lastAssistantBlocks }); messages.push({ role: "user", content: results.map((r) => ({ @@ -141,7 +154,13 @@ export async function streamClaude( }); } - return { fullText }; + return { runTurn, recordToolResults }; +} + +export async function streamClaude( + params: StreamChatParams, +): Promise { + return runStreamingLoop(params, createClaudeSession); } export async function completeClaudeText(params: { diff --git a/backend/src/lib/llm/driver.ts b/backend/src/lib/llm/driver.ts new file mode 100644 index 000000000..af43cd831 --- /dev/null +++ b/backend/src/lib/llm/driver.ts @@ -0,0 +1,66 @@ +// Shared agentic streaming loop for all LLM providers. +// +// Each provider (Claude, OpenAI, Gemini) re-implemented the same skeleton: +// iterate up to `maxIterations`, stream one turn (emitting content/reasoning +// deltas and tool-call starts), accumulate `fullText`, collect tool calls and — +// if any — run them and feed the results back into provider-specific message +// state for the next turn. That triplication let the loop logic drift between +// providers, so the skeleton lives here once and each provider supplies only a +// thin "session" that owns its SDK call, event parsing, follow-up message +// state, and callback firing. + +import type { + NormalizedToolCall, + NormalizedToolResult, + StreamCallbacks, + StreamChatParams, + StreamChatResult, +} from "./types"; + +export type TurnContext = { + iter: number; + callbacks: StreamCallbacks; +}; + +export type TurnResult = { + // Tool calls discovered this turn. The session is responsible for having + // already emitted each via `onToolCallStart` (ordering is provider-specific + // and observable, so the driver never fires callbacks itself). + toolCalls: NormalizedToolCall[]; + // Text the driver should append to `fullText` for this turn. + textForFullText: string; + // Provider hard-stop (e.g. Claude `stop_reason !== "tool_use"`): ends the + // loop even if stray tool calls are present. + stop?: boolean; +}; + +export type ProviderSession = { + runTurn(ctx: TurnContext): Promise; + recordToolResults( + calls: NormalizedToolCall[], + results: NormalizedToolResult[], + ): void; +}; + +export type SessionFactory = (params: StreamChatParams) => ProviderSession; + +export async function runStreamingLoop( + params: StreamChatParams, + createSession: SessionFactory, +): Promise { + const maxIter = params.maxIterations ?? 10; + const callbacks = params.callbacks ?? {}; + const { runTools } = params; + const session = createSession(params); + let fullText = ""; + + for (let iter = 0; iter < maxIter; iter++) { + const turn = await session.runTurn({ iter, callbacks }); + fullText += turn.textForFullText; + if (turn.stop || !turn.toolCalls.length || !runTools) break; + const results = await runTools(turn.toolCalls); + session.recordToolResults(turn.toolCalls, results); + } + + return { fullText }; +} diff --git a/backend/src/lib/llm/gemini.ts b/backend/src/lib/llm/gemini.ts index e40fc6031..739a6b10f 100644 --- a/backend/src/lib/llm/gemini.ts +++ b/backend/src/lib/llm/gemini.ts @@ -3,8 +3,15 @@ import type { StreamChatParams, StreamChatResult, NormalizedToolCall, + NormalizedToolResult, } from "./types"; import { toGeminiTools } from "./tools"; +import { + runStreamingLoop, + type ProviderSession, + type TurnContext, + type TurnResult, +} from "./driver"; type GeminiPart = { text?: string; @@ -49,18 +56,19 @@ function toNativeContents(messages: StreamChatParams["messages"]): GeminiContent })); } -export async function streamGemini( - params: StreamChatParams, -): Promise { - const { model, systemPrompt, tools = [], callbacks = {}, runTools, apiKeys, enableThinking } = params; - const maxIter = params.maxIterations ?? 10; +function createGeminiSession(params: StreamChatParams): ProviderSession { + const { model, systemPrompt, tools = [], apiKeys, enableThinking } = params; const ai = client(apiKeys?.gemini); const functionDeclarations = toGeminiTools(tools); const contents: GeminiContent[] = toNativeContents(params.messages); - let fullText = ""; + // Stashed from the latest turn so recordToolResults can rebuild the model + // turn (text + functionCall parts) before appending the tool responses. + let lastTextParts: string[] = []; + let lastCallParts: GeminiPart[] = []; - for (let iter = 0; iter < maxIter; iter++) { + async function runTurn(ctx: TurnContext): Promise { + const { callbacks } = ctx; const stream = await ai.models.generateContentStream({ model, contents: contents as never, @@ -115,27 +123,31 @@ export async function streamGemini( } } + // Gemini fires onToolCallStart mid-stream (above) and onReasoningBlockEnd + // after the stream — preserving the provider's callback ordering. if (sawThinking) callbacks.onReasoningBlockEnd?.(); - fullText += textParts.join(""); + lastTextParts = textParts; + lastCallParts = callParts; - if (!toolCalls.length || !runTools) { - break; - } - - const results = await runTools(toolCalls); + return { toolCalls, textForFullText: textParts.join("") }; + } + function recordToolResults( + calls: NormalizedToolCall[], + results: NormalizedToolResult[], + ): void { // Append the model's turn (text + functionCall parts, in that order) // and the matching functionResponse turn. const modelParts: GeminiPart[] = []; - if (textParts.length) modelParts.push({ text: textParts.join("") }); - for (const cp of callParts) modelParts.push(cp); + if (lastTextParts.length) modelParts.push({ text: lastTextParts.join("") }); + for (const cp of lastCallParts) modelParts.push(cp); contents.push({ role: "model", parts: modelParts }); contents.push({ role: "user", parts: results.map((r) => { - const match = toolCalls.find((c) => c.id === r.tool_use_id); + const match = calls.find((c) => c.id === r.tool_use_id); return { functionResponse: { ...(r.tool_use_id && !r.tool_use_id.startsWith(match?.name ?? "") @@ -149,7 +161,13 @@ export async function streamGemini( }); } - return { fullText }; + return { runTurn, recordToolResults }; +} + +export async function streamGemini( + params: StreamChatParams, +): Promise { + return runStreamingLoop(params, createGeminiSession); } export async function completeGeminiText(params: { diff --git a/backend/src/lib/llm/openai.ts b/backend/src/lib/llm/openai.ts index 8f2254545..1c9cd67bc 100644 --- a/backend/src/lib/llm/openai.ts +++ b/backend/src/lib/llm/openai.ts @@ -7,6 +7,12 @@ import type { StreamChatParams, StreamChatResult, } from "./types"; +import { + runStreamingLoop, + type ProviderSession, + type TurnContext, + type TurnResult, +} from "./driver"; const MAX_OUTPUT_TOKENS = 16384; @@ -71,28 +77,26 @@ function parseFunctionCall(item: { }; } -export async function streamOpenAI( - params: StreamChatParams, -): Promise { +function createOpenAISession(params: StreamChatParams): ProviderSession { const { model, systemPrompt, tools = [], - callbacks = {}, runTools, apiKeys, enableThinking, } = params; - const maxIter = params.maxIterations ?? 10; const openai = client(apiKeys?.openai); const responseTools = toResponseTools(tools); const hasTools = responseTools.length > 0; + // Conversation state is carried server-side via `previous_response_id`; + // after the first turn `input` only carries fresh tool outputs. let input: ResponseInputItem[] = toResponseInput(params.messages); let previousResponseId: string | undefined; - let fullText = ""; - for (let iter = 0; iter < maxIter; iter++) { + async function runTurn(ctx: TurnContext): Promise { + const { callbacks } = ctx; // The SDK returns a typed async iterable of SSE events — no manual // fetch/TextDecoder/buffer parsing required. Conversation state is // carried server-side via `previous_response_id`, so after the first @@ -100,7 +104,7 @@ export async function streamOpenAI( // context (including instructions) persist. const stream = await openai.responses.create({ model, - instructions: iter === 0 ? systemPrompt : undefined, + instructions: ctx.iter === 0 ? systemPrompt : undefined, input: input as OpenAI.Responses.ResponseInput, tools: responseTools.length ? responseTools : undefined, stream: true, @@ -111,6 +115,7 @@ export async function streamOpenAI( const toolCalls: NormalizedToolCall[] = []; const startedToolCallIds = new Set(); + let turnText = ""; let pendingText = ""; let sawReasoning = false; @@ -132,7 +137,7 @@ export async function streamOpenAI( if (hasTools) { pendingText += event.delta; } else { - fullText += event.delta; + turnText += event.delta; callbacks.onContentDelta?.(event.delta); } break; @@ -157,17 +162,25 @@ export async function streamOpenAI( } } + // OpenAI fires onToolCallStart mid-stream (above) and onReasoningBlockEnd + // after the stream — preserving the provider's callback ordering. if (sawReasoning) callbacks.onReasoningBlockEnd?.(); - if (!toolCalls.length || !runTools) { - if (pendingText) { - fullText += pendingText; - callbacks.onContentDelta?.(pendingText); - } - break; + // Buffered preamble text is surfaced only when this turn isn't going to + // run tools; in a tool-using turn it is dropped (neither streamed nor + // counted in fullText). Mirrors the driver's break condition. + if ((!toolCalls.length || !runTools) && pendingText) { + turnText += pendingText; + callbacks.onContentDelta?.(pendingText); } - const results = await runTools(toolCalls); + return { toolCalls, textForFullText: turnText }; + } + + function recordToolResults( + _calls: NormalizedToolCall[], + results: NormalizedToolResult[], + ): void { input = results.map((result) => ({ type: "function_call_output", call_id: result.tool_use_id, @@ -175,7 +188,13 @@ export async function streamOpenAI( })); } - return { fullText }; + return { runTurn, recordToolResults }; +} + +export async function streamOpenAI( + params: StreamChatParams, +): Promise { + return runStreamingLoop(params, createOpenAISession); } export async function completeOpenAIText(params: { From 0ad1b344e658183a378407d137ede43d77bc09ac Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 31 May 2026 22:35:06 +0000 Subject: [PATCH 03/12] Add native web-search tool to all three LLM adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable each provider's built-in, server-executed web search so the main chat can browse the internet: - Claude: native web_search_20250305 server tool - OpenAI Responses: web_search built-in tool - Gemini: googleSearch grounding tool These run server-side (the provider performs the search and folds results into its answer), so they bypass the runTools function-call loop. A new enableWebSearch flag on StreamChatParams gates them; only the interactive main chat (runLLMStream) opts in, leaving tabular review and bulk extraction untouched. Surface searches to the UI via a new onWebSearch callback that each adapter fires when a search starts — Claude on the server_tool_use contentBlock, OpenAI on the web_search_call output item, Gemini on groundingMetadata.webSearchQueries. runLLMStream streams a web_search event and persists it to the assistant message (unlike the transient tool_call_start), so reloaded chats still show "Searched the web for …". Added the web_search variant to the backend and frontend AssistantEvent unions, a stream handler, and a WebSearchBlock renderer. Backend and frontend typecheck clean. --- backend/src/lib/chatTools.ts | 16 +++++++ backend/src/lib/llm/claude.ts | 24 ++++++++++- backend/src/lib/llm/gemini.ts | 30 ++++++++++--- backend/src/lib/llm/openai.ts | 20 ++++++++- backend/src/lib/llm/types.ts | 16 +++++++ .../components/assistant/AssistantMessage.tsx | 43 +++++++++++++++++++ frontend/src/app/components/shared/types.ts | 1 + frontend/src/app/hooks/useAssistantChat.ts | 11 +++++ 8 files changed, 153 insertions(+), 8 deletions(-) diff --git a/backend/src/lib/chatTools.ts b/backend/src/lib/chatTools.ts index 6d85c6aaa..4c11d5b19 100644 --- a/backend/src/lib/chatTools.ts +++ b/backend/src/lib/chatTools.ts @@ -2698,6 +2698,7 @@ type AssistantEvent = }[]; } | { type: "workflow_applied"; workflow_id: string; title: string } + | { type: "web_search"; query?: string } | { type: "doc_edited"; filename: string; @@ -2840,6 +2841,7 @@ export async function runLLMStream(params: { maxIterations: 10, apiKeys, enableThinking: true, + enableWebSearch: true, callbacks: { onContentDelta: (delta) => { iterText += delta; @@ -2874,6 +2876,20 @@ export async function runLLMStream(params: { })}\n\n`, ); }, + // Native web search runs server-side inside the provider turn. + // Flush any preceding prose so the indicator lands in order, then + // both stream it and persist it (unlike tool_call_start, which is + // a transient placeholder) so reloaded chats still show the search. + onWebSearch: (query) => { + flushText(); + events.push({ type: "web_search", query }); + write( + `data: ${JSON.stringify({ + type: "web_search", + query, + })}\n\n`, + ); + }, }, runTools: async (calls) => { // Emit any text the model produced before this tool turn so the diff --git a/backend/src/lib/llm/claude.ts b/backend/src/lib/llm/claude.ts index 551a5aa4a..f46b972b7 100644 --- a/backend/src/lib/llm/claude.ts +++ b/backend/src/lib/llm/claude.ts @@ -54,9 +54,20 @@ function createClaudeSession(params: StreamChatParams): ProviderSession { tools = [], apiKeys, enableThinking, + enableWebSearch, } = params; const anthropic = client(apiKeys?.claude); - const claudeTools = toClaudeTools(tools); + // Combine caller-supplied function tools with Anthropic's native, + // server-executed web_search tool. The latter never surfaces as a + // `tool_use` block (it arrives as `server_tool_use` and is run by + // Anthropic), so it sits alongside the function tools without touching the + // tool-call loop. + const claudeTools = [ + ...toClaudeTools(tools), + ...(enableWebSearch + ? [{ type: "web_search_20250305", name: "web_search" }] + : []), + ]; const messages: NativeMessage[] = toNativeMessages(params.messages); // Holds the previous turn's assistant content blocks, which Claude requires @@ -90,6 +101,17 @@ function createClaudeSession(params: StreamChatParams): ProviderSession { stream.on("text", (delta) => { callbacks.onContentDelta?.(delta); }); + // `contentBlock` fires when a block is fully assembled. A native + // web_search arrives as a completed `server_tool_use` block (with the + // query in its input) before Anthropic runs the search and streams the + // answer text — so firing here keeps the indicator ahead of the prose. + stream.on("contentBlock", (block) => { + const b = block as { type?: string; name?: string; input?: unknown }; + if (b.type === "server_tool_use" && b.name === "web_search") { + const query = (b.input as { query?: string } | undefined)?.query; + callbacks.onWebSearch?.(query); + } + }); if (enableThinking) { stream.on("thinking", (delta) => { sawThinking = true; diff --git a/backend/src/lib/llm/gemini.ts b/backend/src/lib/llm/gemini.ts index 739a6b10f..16ae21106 100644 --- a/backend/src/lib/llm/gemini.ts +++ b/backend/src/lib/llm/gemini.ts @@ -57,9 +57,14 @@ function toNativeContents(messages: StreamChatParams["messages"]): GeminiContent } function createGeminiSession(params: StreamChatParams): ProviderSession { - const { model, systemPrompt, tools = [], apiKeys, enableThinking } = params; + const { model, systemPrompt, tools = [], apiKeys, enableThinking, enableWebSearch } = params; const ai = client(apiKeys?.gemini); const functionDeclarations = toGeminiTools(tools); + // Combine function declarations with the native googleSearch grounding + // tool. Gemini 2.0+ models accept both in the same request. + const geminiTools: unknown[] = []; + if (functionDeclarations.length) geminiTools.push({ functionDeclarations }); + if (enableWebSearch) geminiTools.push({ googleSearch: {} }); const contents: GeminiContent[] = toNativeContents(params.messages); // Stashed from the latest turn so recordToolResults can rebuild the model @@ -74,8 +79,8 @@ function createGeminiSession(params: StreamChatParams): ProviderSession { contents: contents as never, config: { systemInstruction: systemPrompt, - tools: functionDeclarations.length - ? [{ functionDeclarations } as never] + tools: geminiTools.length + ? (geminiTools as never) : undefined, // When enabled, ask Gemini to surface thought summaries. // When disabled, explicitly zero the thinking budget so the @@ -91,12 +96,25 @@ function createGeminiSession(params: StreamChatParams): ProviderSession { const textParts: string[] = []; const callParts: GeminiPart[] = []; const toolCalls: NormalizedToolCall[] = []; + // googleSearch grounding reports its queries via groundingMetadata, + // which can repeat across chunks — dedupe so each search fires once. + const seenQueries = new Set(); let sawThinking = false; for await (const chunk of stream) { - const parts = - (chunk as { candidates?: { content?: { parts?: GeminiPart[] } }[] }) - .candidates?.[0]?.content?.parts ?? []; + const candidate = (chunk as { + candidates?: { + content?: { parts?: GeminiPart[] }; + groundingMetadata?: { webSearchQueries?: string[] }; + }[]; + }).candidates?.[0]; + for (const query of candidate?.groundingMetadata?.webSearchQueries ?? []) { + if (!seenQueries.has(query)) { + seenQueries.add(query); + callbacks.onWebSearch?.(query); + } + } + const parts = candidate?.content?.parts ?? []; for (const part of parts) { if (part.text) { diff --git a/backend/src/lib/llm/openai.ts b/backend/src/lib/llm/openai.ts index 1c9cd67bc..04648957f 100644 --- a/backend/src/lib/llm/openai.ts +++ b/backend/src/lib/llm/openai.ts @@ -85,10 +85,21 @@ function createOpenAISession(params: StreamChatParams): ProviderSession { runTools, apiKeys, enableThinking, + enableWebSearch, } = params; const openai = client(apiKeys?.openai); const responseTools = toResponseTools(tools); + // `hasTools` (which drives the pre-tool preamble buffering) keys off + // *function* tools only — the native web_search tool is server-executed + // and doesn't produce a function-call preamble, so it's appended + // separately and left out of that gate. const hasTools = responseTools.length > 0; + const requestTools: OpenAI.Responses.Tool[] = [ + ...responseTools, + ...(enableWebSearch + ? [{ type: "web_search" } as OpenAI.Responses.Tool] + : []), + ]; // Conversation state is carried server-side via `previous_response_id`; // after the first turn `input` only carries fresh tool outputs. @@ -106,7 +117,7 @@ function createOpenAISession(params: StreamChatParams): ProviderSession { model, instructions: ctx.iter === 0 ? systemPrompt : undefined, input: input as OpenAI.Responses.ResponseInput, - tools: responseTools.length ? responseTools : undefined, + tools: requestTools.length ? requestTools : undefined, stream: true, max_output_tokens: MAX_OUTPUT_TOKENS, previous_response_id: previousResponseId, @@ -147,6 +158,13 @@ function createOpenAISession(params: StreamChatParams): ProviderSession { const call = parseFunctionCall(event.item); startedToolCallIds.add(call.id); callbacks.onToolCallStart?.(call); + } else if (event.item.type === "web_search_call") { + // Native web search runs server-side; surface it as a + // search indicator rather than a tool call. + const action = ( + event.item as { action?: { query?: string } } + ).action; + callbacks.onWebSearch?.(action?.query); } break; diff --git a/backend/src/lib/llm/types.ts b/backend/src/lib/llm/types.ts index a8409d80e..6cedda1a8 100644 --- a/backend/src/lib/llm/types.ts +++ b/backend/src/lib/llm/types.ts @@ -34,6 +34,14 @@ export type StreamCallbacks = { onReasoningBlockEnd?: () => void; onContentDelta?: (text: string) => void; onToolCallStart?: (call: NormalizedToolCall) => void; + /** + * Fired when the provider's native web-search tool issues a search. These + * searches run server-side (the provider executes them itself and folds + * the results into its response), so they never reach `runTools`; this + * callback exists purely so consumers can surface a "searching the web" + * indicator. `query` is the search string when the provider exposes it. + */ + onWebSearch?: (query?: string) => void; }; export type UserApiKeys = { @@ -58,6 +66,14 @@ export type StreamChatParams = { * one-shot completions should leave this off to save tokens and latency. */ enableThinking?: boolean; + /** + * Enable the provider's native web-search tool (Anthropic `web_search`, + * OpenAI Responses `web_search`, Gemini `googleSearch` grounding). The + * provider runs searches server-side and incorporates results into its + * answer; matching `onWebSearch` callbacks fire as searches happen. Off by + * default — only interactive chat surfaces should opt in. + */ + enableWebSearch?: boolean; }; export type StreamChatResult = { diff --git a/frontend/src/app/components/assistant/AssistantMessage.tsx b/frontend/src/app/components/assistant/AssistantMessage.tsx index f33dfb046..a6a3695f9 100644 --- a/frontend/src/app/components/assistant/AssistantMessage.tsx +++ b/frontend/src/app/components/assistant/AssistantMessage.tsx @@ -517,6 +517,39 @@ function DocFindBlock({ ); } +function WebSearchBlock({ + query, + isStreaming, + showConnector, +}: { + query?: string; + isStreaming?: boolean; + showConnector?: boolean; +}) { + const label = isStreaming ? "Searching the web" : "Searched the web"; + return ( +
+ {showConnector && ( +
+ )} + {isStreaming ? ( +
+ ) : ( +
+ )} +
+ {label} + {query ? ( + + {" "}for “{query}” + + ) : null} + {isStreaming && "..."} +
+
+ ); +} + function DocCreatedBlock({ filename, showConnector, @@ -1348,6 +1381,16 @@ export function AssistantMessage({ /> ); } + if (event.type === "web_search") { + return ( + + ); + } return null; }; diff --git a/frontend/src/app/components/shared/types.ts b/frontend/src/app/components/shared/types.ts index d7bac4ecb..f1f6ddcde 100644 --- a/frontend/src/app/components/shared/types.ts +++ b/frontend/src/app/components/shared/types.ts @@ -128,6 +128,7 @@ export type AssistantEvent = isStreaming?: boolean; } | { type: "workflow_applied"; workflow_id: string; title: string } + | { type: "web_search"; query?: string; isStreaming?: boolean } | { type: "doc_edited"; filename: string; diff --git a/frontend/src/app/hooks/useAssistantChat.ts b/frontend/src/app/hooks/useAssistantChat.ts index 1a28ac1c3..8382a2529 100644 --- a/frontend/src/app/hooks/useAssistantChat.ts +++ b/frontend/src/app/hooks/useAssistantChat.ts @@ -558,6 +558,17 @@ export function useAssistantChat({ continue; } + if (data.type === "web_search") { + // Native, server-side web search performed by the + // model. Persisted (unlike tool_call_start), so it + // stays visible in reloaded chats. + pushEvent({ + type: "web_search", + query: data.query as string | undefined, + }); + continue; + } + if (data.type === "workflow_applied") { pushEvent({ type: "workflow_applied", From a101800d50445c441310a33c56ebf32be7c38ed0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 06:53:55 +0000 Subject: [PATCH 04/12] test(backend): add Vitest route integration tests for all 8 routers Introduce a supertest-based integration suite covering user, chat, projectChat, projects, documents, tabular, workflows and downloads routes (77 tests). Supabase, auth, storage and the LLM/chat tooling are mocked; streaming SSE endpoints and the signed-download token round-trip are exercised end to end. Refactor src/index.ts to export createApp() and only bind a port when run directly, and skip rate limiters under NODE_ENV=test so the suite is deterministic. Add vitest + supertest dev deps and test scripts. --- backend/package-lock.json | 1599 ++++++++++++++++++++++- backend/package.json | 9 +- backend/src/index.ts | 122 +- backend/test/helpers/supabaseMock.ts | 91 ++ backend/test/routes/chat.test.ts | 194 +++ backend/test/routes/documents.test.ts | 146 +++ backend/test/routes/downloads.test.ts | 101 ++ backend/test/routes/health.test.ts | 11 + backend/test/routes/projectChat.test.ts | 103 ++ backend/test/routes/projects.test.ts | 153 +++ backend/test/routes/tabular.test.ts | 150 +++ backend/test/routes/user.test.ts | 220 ++++ backend/test/routes/workflows.test.ts | 139 ++ backend/test/setup.ts | 12 + backend/vitest.config.ts | 16 + 15 files changed, 2974 insertions(+), 92 deletions(-) create mode 100644 backend/test/helpers/supabaseMock.ts create mode 100644 backend/test/routes/chat.test.ts create mode 100644 backend/test/routes/documents.test.ts create mode 100644 backend/test/routes/downloads.test.ts create mode 100644 backend/test/routes/health.test.ts create mode 100644 backend/test/routes/projectChat.test.ts create mode 100644 backend/test/routes/projects.test.ts create mode 100644 backend/test/routes/tabular.test.ts create mode 100644 backend/test/routes/user.test.ts create mode 100644 backend/test/routes/workflows.test.ts create mode 100644 backend/test/setup.ts create mode 100644 backend/vitest.config.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 42b28e294..5fbb70d41 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -35,9 +35,12 @@ "@types/express": "^4.17.21", "@types/multer": "^1.4.12", "@types/node": "^22.14.1", + "@types/supertest": "^7.2.0", "prettier": "^3.8.1", + "supertest": "^7.2.2", "tsx": "^4.19.3", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "vitest": "^4.1.7" } }, "node_modules/@anthropic-ai/sdk": { @@ -974,6 +977,40 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -1439,6 +1476,13 @@ } } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/canvas": { "version": "0.1.97", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz", @@ -1689,6 +1733,38 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodable/entities": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", @@ -1701,6 +1777,26 @@ ], "license": "MIT" }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -1783,6 +1879,270 @@ "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@selderee/plugin-htmlparser2": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", @@ -2515,6 +2875,13 @@ "node": ">=18.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@supabase/auth-js": { "version": "2.102.1", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.102.1.tgz", @@ -2601,10 +2968,21 @@ "node": ">=20.0.0" } }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", "dev": true, "license": "MIT", "dependencies": { @@ -2612,6 +2990,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -2622,6 +3011,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -2632,6 +3028,20 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/express": { "version": "4.17.25", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", @@ -2665,6 +3075,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -2744,6 +3161,30 @@ "@types/node": "*" } }, + "node_modules/@types/superagent": { + "version": "8.1.10", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.10.tgz", + "integrity": "sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -2753,6 +3194,119 @@ "@types/node": "*" } }, + "node_modules/@vitest/expect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.12", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", @@ -2805,12 +3359,36 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -2937,6 +3515,39 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-stream": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", @@ -2973,6 +3584,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -2988,6 +3606,13 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -3038,6 +3663,16 @@ "node": ">=0.10.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3057,6 +3692,27 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dingbat-to-unicode": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", @@ -3239,6 +3895,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -3251,6 +3914,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -3299,6 +3978,16 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -3308,6 +3997,16 @@ "node": ">= 0.6" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -3390,6 +4089,13 @@ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "license": "Apache-2.0" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-xml-builder": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", @@ -3426,6 +4132,24 @@ "fxparser": "src/cli/cli.js" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -3467,6 +4191,23 @@ "node": ">= 0.8" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -3479,6 +4220,24 @@ "node": ">=12.20.0" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3649,6 +4408,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hash.js": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", @@ -3914,38 +4689,309 @@ "immediate": "~3.0.5" } }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, - "node_modules/lop": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", - "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", - "license": "BSD-2-Clause", - "dependencies": { - "duck": "^0.1.12", - "option": "~0.2.1", - "underscore": "^1.13.1" - } - }, - "node_modules/mammoth": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz", - "integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==", - "license": "BSD-2-Clause", + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", "dependencies": { - "@xmldom/xmldom": "^0.8.6", - "argparse": "~1.0.3", - "base64-js": "^1.5.1", - "bluebird": "~3.4.0", - "dingbat-to-unicode": "^1.0.1", - "jszip": "^3.7.1", - "lop": "^0.4.2", - "path-is-absolute": "^1.0.0", - "underscore": "^1.13.1", + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lop": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", + "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", + "license": "BSD-2-Clause", + "dependencies": { + "duck": "^0.1.12", + "option": "~0.2.1", + "underscore": "^1.13.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mammoth": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz", + "integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==", + "license": "BSD-2-Clause", + "dependencies": { + "@xmldom/xmldom": "^0.8.6", + "argparse": "~1.0.3", + "base64-js": "^1.5.1", + "bluebird": "~3.4.0", + "dingbat-to-unicode": "^1.0.1", + "jszip": "^3.7.1", + "lop": "^0.4.2", + "path-is-absolute": "^1.0.0", + "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": { @@ -4162,6 +5208,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -4174,6 +5231,16 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/openai": { "version": "6.39.1", "resolved": "https://registry.npmjs.org/openai/-/openai-6.39.1.tgz", @@ -4272,6 +5339,13 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pdfjs-dist": { "version": "4.10.38", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.10.38.tgz", @@ -4293,6 +5367,74 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/prettier": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", @@ -4474,6 +5616,40 @@ "node": ">= 4" } }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -4657,12 +5833,36 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -4672,6 +5872,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -4707,6 +5914,134 @@ ], "license": "MIT" }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -4835,6 +6170,174 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.0.15", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.15.tgz", + "integrity": "sha512-qpgllRxrLqwsMAGRdLhsEr9bepaOQk1rxH1xT2coBXLaEB/bfkqQj1j7RMxwMfnYrvO1ZnFMiwX+wBVgnsyn0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -4844,6 +6347,30 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", diff --git a/backend/package.json b/backend/package.json index c9304f205..25e18d25c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,7 +5,9 @@ "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", - "start": "node dist/index.js" + "start": "node dist/index.js", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@anthropic-ai/sdk": "^0.90.0", @@ -34,9 +36,12 @@ "@types/express": "^4.17.21", "@types/multer": "^1.4.12", "@types/node": "^22.14.1", + "@types/supertest": "^7.2.0", "prettier": "^3.8.1", + "supertest": "^7.2.2", "tsx": "^4.19.3", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "vitest": "^4.1.7" }, "license": "AGPL-3.0-only" } diff --git a/backend/src/index.ts b/backend/src/index.ts index 07b3b8490..813ea16d2 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -12,9 +12,9 @@ import { workflowsRouter } from "./routes/workflows"; import { userRouter } from "./routes/user"; import { downloadsRouter } from "./routes/downloads"; -const app = express(); const PORT = process.env.PORT ?? 3001; const isProduction = process.env.NODE_ENV === "production"; +const isTest = process.env.NODE_ENV === "test"; function envInt(name: string, fallback: number): number { const raw = process.env[name]; @@ -71,56 +71,70 @@ const uploadLimiter = makeLimiter({ message: "Too many upload requests. Please try again later.", }); -app.disable("x-powered-by"); -app.set("trust proxy", envInt("TRUST_PROXY_HOPS", 1)); - -app.use( - helmet({ - contentSecurityPolicy: false, - crossOriginEmbedderPolicy: false, - hsts: isProduction - ? { - maxAge: 15552000, - includeSubDomains: true, - } - : false, - referrerPolicy: { policy: "no-referrer" }, - }), -); - -app.use( - cors({ - origin: process.env.FRONTEND_URL ?? "http://localhost:3000", - credentials: true, - }), -); - -app.use(generalLimiter); - -app.use(express.json({ limit: "50mb" })); - -app.post("/chat", chatLimiter); -app.post("/projects/:projectId/chat", chatLimiter); -app.post("/tabular-review/:reviewId/chat", chatLimiter); -app.post("/tabular-review/:reviewId/generate", chatLimiter); -app.post("/chat/create", chatCreateLimiter); -app.post("/chat/:chatId/generate-title", chatCreateLimiter); -app.post("/single-documents", uploadLimiter); -app.post("/single-documents/:documentId/versions", uploadLimiter); -app.post("/projects/:projectId/documents", uploadLimiter); - -app.use("/chat", chatRouter); -app.use("/projects", projectsRouter); -app.use("/projects/:projectId/chat", projectChatRouter); -app.use("/single-documents", documentsRouter); -app.use("/tabular-review", tabularRouter); -app.use("/workflows", workflowsRouter); -app.use("/user", userRouter); -app.use("/users", userRouter); -app.use("/download", downloadsRouter); - -app.get("/health", (_req, res) => res.json({ ok: true })); - -app.listen(PORT, () => { - console.log(`Mike backend running on port ${PORT}`); -}); +export function createApp() { + const app = express(); + + app.disable("x-powered-by"); + app.set("trust proxy", envInt("TRUST_PROXY_HOPS", 1)); + + app.use( + helmet({ + contentSecurityPolicy: false, + crossOriginEmbedderPolicy: false, + hsts: isProduction + ? { + maxAge: 15552000, + includeSubDomains: true, + } + : false, + referrerPolicy: { policy: "no-referrer" }, + }), + ); + + app.use( + cors({ + origin: process.env.FRONTEND_URL ?? "http://localhost:3000", + credentials: true, + }), + ); + + // Rate limiters interfere with deterministic tests; skip them under test. + if (!isTest) { + app.use(generalLimiter); + } + + app.use(express.json({ limit: "50mb" })); + + if (!isTest) { + app.post("/chat", chatLimiter); + app.post("/projects/:projectId/chat", chatLimiter); + app.post("/tabular-review/:reviewId/chat", chatLimiter); + app.post("/tabular-review/:reviewId/generate", chatLimiter); + app.post("/chat/create", chatCreateLimiter); + app.post("/chat/:chatId/generate-title", chatCreateLimiter); + app.post("/single-documents", uploadLimiter); + app.post("/single-documents/:documentId/versions", uploadLimiter); + app.post("/projects/:projectId/documents", uploadLimiter); + } + + app.use("/chat", chatRouter); + app.use("/projects", projectsRouter); + app.use("/projects/:projectId/chat", projectChatRouter); + app.use("/single-documents", documentsRouter); + app.use("/tabular-review", tabularRouter); + app.use("/workflows", workflowsRouter); + app.use("/user", userRouter); + app.use("/users", userRouter); + app.use("/download", downloadsRouter); + + app.get("/health", (_req, res) => res.json({ ok: true })); + + return app; +} + +// Only bind a port when run directly (not when imported by tests). +if (require.main === module) { + createApp().listen(PORT, () => { + console.log(`Mike backend running on port ${PORT}`); + }); +} diff --git a/backend/test/helpers/supabaseMock.ts b/backend/test/helpers/supabaseMock.ts new file mode 100644 index 000000000..701c090f5 --- /dev/null +++ b/backend/test/helpers/supabaseMock.ts @@ -0,0 +1,91 @@ +import { vi } from "vitest"; + +export type SupabaseResult = { data: unknown; error: unknown }; + +export interface SupabaseMockControl { + /** The object returned by the mocked `createServerSupabase()`. */ + db: Record; + /** Queue one `{ data, error }` to be returned by the next awaited query. */ + queue: (result: SupabaseResult) => SupabaseMockControl; + /** Queue several results, consumed in order. */ + queueMany: (results: SupabaseResult[]) => SupabaseMockControl; + /** Result returned once the queue is exhausted (defaults to `{data:null,error:null}`). */ + setDefault: (result: SupabaseResult) => void; + /** Mock for `db.auth.admin.deleteUser`. */ + authDeleteUser: ReturnType; + /** Table names passed to every `from()` call, in order. */ + fromCalls: string[]; + /** Every chain method invoked, in order, for assertions. */ + calls: Array<{ table: string; method: string; args: unknown[] }>; +} + +// The Supabase JS client exposes a fluent, chainable query builder whose calls +// (`from().select().eq().maybeSingle()`) only resolve when awaited. We emulate +// that with a builder where every chain/terminal method returns the same +// thenable object, and awaiting it dequeues the next configured result. Route +// handlers issue their DB calls sequentially, so a simple FIFO queue lines up +// results with the order the handler asks for them. +const CHAIN_METHODS = [ + "select", "insert", "update", "upsert", "delete", "eq", "neq", "gt", "gte", + "lt", "lte", "in", "is", "or", "and", "not", "contains", "containedBy", + "filter", "match", "like", "ilike", "order", "limit", "range", "onConflict", + "returns", "overlaps", "textSearch", "throwOnError", +]; +const TERMINAL_METHODS = ["single", "maybeSingle", "csv", "geojson"]; + +export function createSupabaseMock(): SupabaseMockControl { + const results: SupabaseResult[] = []; + let defaultResult: SupabaseResult = { data: null, error: null }; + const fromCalls: string[] = []; + const calls: SupabaseMockControl["calls"] = []; + + const next = (): SupabaseResult => + results.length ? results.shift()! : defaultResult; + + function makeBuilder(table: string): Record { + const builder: Record = { + then: (onF: ((v: SupabaseResult) => unknown) | null, onR?: ((e: unknown) => unknown) | null) => + Promise.resolve(next()).then(onF, onR), + catch: (onR: ((e: unknown) => unknown) | null) => + Promise.resolve(next()).catch(onR), + finally: (onF: (() => void) | null) => + Promise.resolve(next()).finally(onF ?? undefined), + }; + for (const m of [...CHAIN_METHODS, ...TERMINAL_METHODS]) { + builder[m] = (...args: unknown[]) => { + calls.push({ table, method: m, args }); + return builder; + }; + } + return builder; + } + + const authDeleteUser = vi.fn(async () => ({ data: { user: null }, error: null })); + + const db: Record = { + from: (table: string) => { + fromCalls.push(table); + return makeBuilder(table); + }, + auth: { admin: { deleteUser: authDeleteUser } }, + }; + + const control: SupabaseMockControl = { + db, + queue(result) { + results.push(result); + return control; + }, + queueMany(items) { + results.push(...items); + return control; + }, + setDefault(result) { + defaultResult = result; + }, + authDeleteUser, + fromCalls, + calls, + }; + return control; +} diff --git a/backend/test/routes/chat.test.ts b/backend/test/routes/chat.test.ts new file mode 100644 index 000000000..7cdfc8d50 --- /dev/null +++ b/backend/test/routes/chat.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import request from "supertest"; +import { + createSupabaseMock, + type SupabaseMockControl, +} from "../helpers/supabaseMock"; + +const auth = vi.hoisted(() => ({ + userId: "user-1" as string | null, + userEmail: "user@example.com", +})); +const sb = vi.hoisted(() => ({ current: null as unknown })); + +vi.mock("../../src/lib/supabase", () => ({ + createServerSupabase: () => sb.current, +})); +vi.mock("../../src/middleware/auth", () => ({ + requireAuth: (req: unknown, res: any, next: () => void) => { + if (!auth.userId) + return void res.status(401).json({ detail: "unauthorized" }); + res.locals.userId = auth.userId; + res.locals.userEmail = auth.userEmail; + res.locals.token = "test-token"; + next(); + }, +})); +vi.mock("../../src/lib/chatTools", () => ({ + buildDocContext: vi.fn(async () => ({ docIndex: {}, docStore: new Map() })), + buildProjectDocContext: vi.fn(async () => ({ + docIndex: {}, + docStore: new Map(), + folderPaths: new Map(), + })), + buildMessages: vi.fn(() => []), + enrichWithPriorEvents: vi.fn(async (m: unknown) => m), + buildWorkflowStore: vi.fn(async () => new Map()), + extractAnnotations: vi.fn(() => []), + runLLMStream: vi.fn(async ({ write }: { write: (s: string) => void }) => { + write(`data: ${JSON.stringify({ type: "text", text: "hi" })}\n\n`); + return { fullText: "hi", events: [{ type: "text", text: "hi" }] }; + }), + PROJECT_EXTRA_TOOLS: [], +})); +vi.mock("../../src/lib/llm", () => ({ + completeText: vi.fn(async () => "Generated Title"), + resolveModel: (id: string, fb: string) => id || fb, + DEFAULT_TABULAR_MODEL: "gemini-3-flash-preview", + streamChatWithTools: vi.fn(), +})); +vi.mock("../../src/lib/userSettings", () => ({ + getUserModelSettings: vi.fn(async () => ({ title_model: "m", api_keys: {} })), + getUserApiKeys: vi.fn(async () => ({})), +})); + +import { createApp } from "../../src/index"; + +let app: ReturnType; +let mock: SupabaseMockControl; + +beforeEach(() => { + auth.userId = "user-1"; + mock = createSupabaseMock(); + sb.current = mock.db; + app = createApp(); +}); + +describe("auth gate", () => { + it("returns 401 without auth", async () => { + auth.userId = null; + expect((await request(app).get("/chat")).status).toBe(401); + }); +}); + +describe("GET /chat", () => { + it("lists accessible chats", async () => { + mock.queueMany([ + { data: [{ id: "p1" }], error: null }, + { data: [{ id: "c1", title: "Hi" }], error: null }, + ]); + const res = await request(app).get("/chat"); + expect(res.status).toBe(200); + expect(res.body).toEqual([{ id: "c1", title: "Hi" }]); + }); + + it("returns 500 when the projects lookup fails", async () => { + mock.queue({ data: null, error: { message: "boom" } }); + const res = await request(app).get("/chat"); + expect(res.status).toBe(500); + expect(res.body).toEqual({ detail: "boom" }); + }); +}); + +describe("POST /chat/create", () => { + it("creates a chat and returns its id", async () => { + mock.queue({ data: { id: "new-chat" }, error: null }); + const res = await request(app).post("/chat/create").send({}); + expect(res.status).toBe(200); + expect(res.body).toEqual({ id: "new-chat" }); + }); + + it("rejects an empty-string project_id with 400", async () => { + const res = await request(app) + .post("/chat/create") + .send({ project_id: "" }); + expect(res.status).toBe(400); + }); +}); + +describe("GET /chat/:chatId", () => { + it("returns 404 when the chat is not accessible", async () => { + mock.queue({ data: null, error: null }); + expect((await request(app).get("/chat/c1")).status).toBe(404); + }); + + it("returns the chat with hydrated messages", async () => { + mock.queueMany([ + { + data: { id: "c1", user_id: "user-1", title: "t", project_id: null }, + error: null, + }, + { data: [{ id: "m1", content: "hello", annotations: null }], error: null }, + ]); + const res = await request(app).get("/chat/c1"); + expect(res.status).toBe(200); + expect(res.body.chat.id).toBe("c1"); + expect(res.body.messages).toHaveLength(1); + }); +}); + +describe("PATCH /chat/:chatId", () => { + it("rejects an empty title with 400", async () => { + const res = await request(app).patch("/chat/c1").send({ title: " " }); + expect(res.status).toBe(400); + }); + + it("renames a chat", async () => { + mock.queue({ data: { id: "c1", title: "New" }, error: null }); + const res = await request(app).patch("/chat/c1").send({ title: "New" }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ id: "c1", title: "New" }); + }); + + it("returns 404 when the chat is missing", async () => { + mock.queue({ data: null, error: null }); + const res = await request(app).patch("/chat/c1").send({ title: "New" }); + expect(res.status).toBe(404); + }); +}); + +describe("DELETE /chat/:chatId", () => { + it("deletes and returns 204", async () => { + const res = await request(app).delete("/chat/c1"); + expect(res.status).toBe(204); + }); +}); + +describe("POST /chat/:chatId/generate-title", () => { + it("rejects a missing message with 400", async () => { + const res = await request(app) + .post("/chat/c1/generate-title") + .send({}); + expect(res.status).toBe(400); + }); + + it("generates and persists a title", async () => { + mock.queue({ + data: { id: "c1", user_id: "user-1", project_id: null, title: null }, + error: null, + }); + const res = await request(app) + .post("/chat/c1/generate-title") + .send({ message: "What are the indemnity terms?" }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ title: "Generated Title" }); + }); +}); + +describe("POST /chat (streaming)", () => { + it("rejects a missing messages array with 400", async () => { + const res = await request(app).post("/chat").send({}); + expect(res.status).toBe(400); + }); + + it("streams an SSE response for a new chat", async () => { + mock.queue({ data: { id: "chat-1", title: null }, error: null }); + const res = await request(app) + .post("/chat") + .send({ messages: [{ role: "user", content: "hi" }] }); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/text\/event-stream/); + expect(res.text).toContain('"type":"chat_id"'); + expect(res.text).toContain('"text":"hi"'); + }); +}); diff --git a/backend/test/routes/documents.test.ts b/backend/test/routes/documents.test.ts new file mode 100644 index 000000000..c00566655 --- /dev/null +++ b/backend/test/routes/documents.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import request from "supertest"; +import { + createSupabaseMock, + type SupabaseMockControl, +} from "../helpers/supabaseMock"; + +const auth = vi.hoisted(() => ({ + userId: "user-1" as string | null, + userEmail: "user@example.com", +})); +const sb = vi.hoisted(() => ({ current: null as unknown })); + +vi.mock("../../src/lib/supabase", () => ({ + createServerSupabase: () => sb.current, +})); +vi.mock("../../src/middleware/auth", () => ({ + requireAuth: (req: unknown, res: any, next: () => void) => { + if (!auth.userId) + return void res.status(401).json({ detail: "unauthorized" }); + res.locals.userId = auth.userId; + res.locals.userEmail = auth.userEmail; + res.locals.token = "test-token"; + next(); + }, +})); +vi.mock("../../src/lib/storage", () => ({ + uploadFile: vi.fn(async () => {}), + downloadFile: vi.fn(async () => new TextEncoder().encode("bytes").buffer), + deleteFile: vi.fn(async () => {}), + getSignedUrl: vi.fn(async () => "https://signed.example/x"), + buildContentDisposition: (type: string, filename: string) => + `${type}; filename="${filename}"`, + storageKey: (...p: string[]) => `key/${p.join("/")}`, + versionStorageKey: (...p: string[]) => `vkey/${p.join("/")}`, +})); +vi.mock("../../src/lib/convert", () => ({ + docxToPdf: vi.fn(async () => Buffer.from("pdf-bytes")), + convertedPdfKey: (userId: string, docId: string) => `pdf/${userId}/${docId}`, +})); +vi.mock("../../src/lib/documentVersions", () => ({ + attachLatestVersionNumbers: vi.fn(async () => {}), + attachActiveVersionPaths: vi.fn(async () => {}), + loadActiveVersion: vi.fn(async () => null), +})); + +import { createApp } from "../../src/index"; + +let app: ReturnType; +let mock: SupabaseMockControl; + +beforeEach(() => { + auth.userId = "user-1"; + mock = createSupabaseMock(); + sb.current = mock.db; + app = createApp(); +}); + +describe("auth gate", () => { + it("returns 401 without auth", async () => { + auth.userId = null; + expect((await request(app).get("/single-documents")).status).toBe(401); + }); +}); + +describe("GET /single-documents", () => { + it("lists the user's standalone documents", async () => { + mock.queue({ data: [], error: null }); + const res = await request(app).get("/single-documents"); + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); + + it("returns 500 on query error", async () => { + mock.queue({ data: null, error: { message: "boom" } }); + const res = await request(app).get("/single-documents"); + expect(res.status).toBe(500); + }); +}); + +describe("DELETE /single-documents/:documentId", () => { + it("returns 404 when the document is missing", async () => { + mock.queue({ data: null, error: null }); + expect((await request(app).delete("/single-documents/d1")).status).toBe( + 404, + ); + }); + + it("deletes the document and its version bytes", async () => { + mock.queueMany([ + { data: { id: "d1" }, error: null }, // doc lookup + { + data: [{ storage_path: "a", pdf_storage_path: "b" }], + error: null, + }, // versions + // documents.delete -> default + ]); + const res = await request(app).delete("/single-documents/d1"); + expect(res.status).toBe(204); + }); +}); + +describe("GET /single-documents/:documentId/display", () => { + it("returns 404 when the document is not found", async () => { + mock.queue({ data: null, error: null }); + const res = await request(app).get("/single-documents/d1/display"); + expect(res.status).toBe(404); + }); +}); + +describe("POST /single-documents (upload)", () => { + it("rejects a request with no file", async () => { + const res = await request(app).post("/single-documents"); + expect(res.status).toBe(400); + expect(res.body).toEqual({ detail: "file is required" }); + }); + + it("rejects an unsupported file type", async () => { + const res = await request(app) + .post("/single-documents") + .attach("file", Buffer.from("hello"), { + filename: "notes.txt", + contentType: "text/plain", + }); + expect(res.status).toBe(400); + expect(res.body.detail).toMatch(/Unsupported file type/); + }); + + it("uploads a docx and returns the created document", async () => { + mock.queueMany([ + { data: { id: "d1" }, error: null }, // insert documents + { data: { id: "v1" }, error: null }, // insert document_versions + { data: null, error: null }, // update documents + { data: { id: "d1", filename: "contract.docx" }, error: null }, // re-select + ]); + const res = await request(app) + .post("/single-documents") + .attach("file", Buffer.from("PKdocxbytes"), { + filename: "contract.docx", + contentType: + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }); + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ id: "d1", filename: "contract.docx" }); + }); +}); diff --git a/backend/test/routes/downloads.test.ts b/backend/test/routes/downloads.test.ts new file mode 100644 index 000000000..7192ea529 --- /dev/null +++ b/backend/test/routes/downloads.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import request from "supertest"; +import { + createSupabaseMock, + type SupabaseMockControl, +} from "../helpers/supabaseMock"; + +const auth = vi.hoisted(() => ({ + userId: "user-1" as string | null, + userEmail: "user@example.com", +})); +const sb = vi.hoisted(() => ({ current: null as unknown })); + +vi.mock("../../src/lib/supabase", () => ({ + createServerSupabase: () => sb.current, +})); +vi.mock("../../src/middleware/auth", () => ({ + requireAuth: (req: unknown, res: any, next: () => void) => { + if (!auth.userId) + return void res.status(401).json({ detail: "unauthorized" }); + res.locals.userId = auth.userId; + res.locals.userEmail = auth.userEmail; + res.locals.token = "test-token"; + next(); + }, +})); +// Only the network-touching storage helper is mocked; buildContentDisposition +// keeps a faithful-enough implementation so the header assertion is meaningful. +vi.mock("../../src/lib/storage", () => ({ + downloadFile: vi.fn(async () => new TextEncoder().encode("PKfilebytes").buffer), + buildContentDisposition: (type: string, filename: string) => + `${type}; filename="${filename}"`, +})); + +import { createApp } from "../../src/index"; +// Real signer/verifier — exercises the token round-trip against the test secret. +import { signDownload } from "../../src/lib/downloadTokens"; +import { downloadFile } from "../../src/lib/storage"; + +let app: ReturnType; +let mock: SupabaseMockControl; + +beforeEach(() => { + auth.userId = "user-1"; + mock = createSupabaseMock(); + sb.current = mock.db; + app = createApp(); +}); + +describe("GET /download/:token", () => { + it("returns 401 without auth", async () => { + auth.userId = null; + const token = signDownload("docs/file.docx", "file.docx"); + expect((await request(app).get(`/download/${token}`)).status).toBe(401); + }); + + it("returns 404 for an invalid token", async () => { + const res = await request(app).get("/download/not-a-valid-token"); + expect(res.status).toBe(404); + expect(res.body).toEqual({ detail: "Invalid link" }); + }); + + it("returns 404 when the caller lacks access", async () => { + mock.queueMany([ + { data: { id: "v1", document_id: "d1" }, error: null }, + // Document owned by someone else, no project -> ensureDocAccess fails. + { data: { id: "d1", user_id: "other", project_id: null }, error: null }, + ]); + const token = signDownload("docs/file.docx", "file.docx"); + const res = await request(app).get(`/download/${token}`); + expect(res.status).toBe(404); + }); + + it("streams the file with the right content type for the owner", async () => { + mock.queueMany([ + { data: { id: "v1", document_id: "d1" }, error: null }, + { data: { id: "d1", user_id: "user-1", project_id: null }, error: null }, + ]); + const token = signDownload("docs/file.docx", "file.docx"); + const res = await request(app).get(`/download/${token}`); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toBe( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ); + expect(res.headers["content-disposition"]).toBe( + 'attachment; filename="file.docx"', + ); + expect(downloadFile).toHaveBeenCalledWith("docs/file.docx"); + }); + + it("maps .pdf to the pdf content type", async () => { + mock.queueMany([ + { data: { id: "v1", document_id: "d1" }, error: null }, + { data: { id: "d1", user_id: "user-1", project_id: null }, error: null }, + ]); + const token = signDownload("docs/file.pdf", "file.pdf"); + const res = await request(app).get(`/download/${token}`); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toBe("application/pdf"); + }); +}); diff --git a/backend/test/routes/health.test.ts b/backend/test/routes/health.test.ts new file mode 100644 index 000000000..0004f50d8 --- /dev/null +++ b/backend/test/routes/health.test.ts @@ -0,0 +1,11 @@ +import { describe, it, expect } from "vitest"; +import request from "supertest"; +import { createApp } from "../../src/index"; + +describe("GET /health", () => { + it("returns ok without auth", async () => { + const res = await request(createApp()).get("/health"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + }); +}); diff --git a/backend/test/routes/projectChat.test.ts b/backend/test/routes/projectChat.test.ts new file mode 100644 index 000000000..7ef45c0ac --- /dev/null +++ b/backend/test/routes/projectChat.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import request from "supertest"; +import { + createSupabaseMock, + type SupabaseMockControl, +} from "../helpers/supabaseMock"; + +const auth = vi.hoisted(() => ({ + userId: "user-1" as string | null, + userEmail: "user@example.com", +})); +const sb = vi.hoisted(() => ({ current: null as unknown })); + +vi.mock("../../src/lib/supabase", () => ({ + createServerSupabase: () => sb.current, +})); +vi.mock("../../src/middleware/auth", () => ({ + requireAuth: (req: unknown, res: any, next: () => void) => { + if (!auth.userId) + return void res.status(401).json({ detail: "unauthorized" }); + res.locals.userId = auth.userId; + res.locals.userEmail = auth.userEmail; + res.locals.token = "test-token"; + next(); + }, +})); +vi.mock("../../src/lib/chatTools", () => ({ + buildDocContext: vi.fn(async () => ({ docIndex: {}, docStore: new Map() })), + buildProjectDocContext: vi.fn(async () => ({ + docIndex: {}, + docStore: new Map(), + folderPaths: new Map(), + })), + buildMessages: vi.fn(() => []), + enrichWithPriorEvents: vi.fn(async (m: unknown) => m), + buildWorkflowStore: vi.fn(async () => new Map()), + extractAnnotations: vi.fn(() => []), + runLLMStream: vi.fn(async ({ write }: { write: (s: string) => void }) => { + write(`data: ${JSON.stringify({ type: "text", text: "yo" })}\n\n`); + return { fullText: "yo", events: [{ type: "text", text: "yo" }] }; + }), + PROJECT_EXTRA_TOOLS: [], +})); +vi.mock("../../src/lib/userSettings", () => ({ + getUserModelSettings: vi.fn(async () => ({ title_model: "m", api_keys: {} })), + getUserApiKeys: vi.fn(async () => ({})), +})); + +import { createApp } from "../../src/index"; +import { runLLMStream } from "../../src/lib/chatTools"; + +let app: ReturnType; +let mock: SupabaseMockControl; + +beforeEach(() => { + auth.userId = "user-1"; + mock = createSupabaseMock(); + sb.current = mock.db; + app = createApp(); +}); + +describe("POST /projects/:projectId/chat", () => { + it("returns 401 without auth", async () => { + auth.userId = null; + const res = await request(app) + .post("/projects/p1/chat") + .send({ messages: [{ role: "user", content: "hi" }] }); + expect(res.status).toBe(401); + }); + + it("returns 404 when the project is not accessible", async () => { + mock.queue({ data: null, error: null }); // checkProjectAccess -> no project + const res = await request(app) + .post("/projects/p1/chat") + .send({ messages: [{ role: "user", content: "hi" }] }); + expect(res.status).toBe(404); + expect(res.body).toEqual({ detail: "Project not found" }); + }); + + it("streams an SSE response and runs the LLM stream", async () => { + mock.queueMany([ + // checkProjectAccess -> owned project + { + data: { id: "p1", user_id: "user-1", shared_with: null }, + error: null, + }, + // insert new chat + { data: { id: "chat-1", title: null }, error: null }, + ]); + const res = await request(app) + .post("/projects/p1/chat") + .send({ messages: [{ role: "user", content: "hi" }] }); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/text\/event-stream/); + expect(res.text).toContain('"type":"chat_id"'); + expect(res.text).toContain('"text":"yo"'); + expect(runLLMStream).toHaveBeenCalledOnce(); + const arg = vi.mocked(runLLMStream).mock.calls[0][0] as { + projectId: string; + }; + expect(arg.projectId).toBe("p1"); + }); +}); diff --git a/backend/test/routes/projects.test.ts b/backend/test/routes/projects.test.ts new file mode 100644 index 000000000..25e6d2d17 --- /dev/null +++ b/backend/test/routes/projects.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import request from "supertest"; +import { + createSupabaseMock, + type SupabaseMockControl, +} from "../helpers/supabaseMock"; + +const auth = vi.hoisted(() => ({ + userId: "user-1" as string | null, + userEmail: "user@example.com", +})); +const sb = vi.hoisted(() => ({ current: null as unknown })); + +vi.mock("../../src/lib/supabase", () => ({ + createServerSupabase: () => sb.current, +})); +vi.mock("../../src/middleware/auth", () => ({ + requireAuth: (req: unknown, res: any, next: () => void) => { + if (!auth.userId) + return void res.status(401).json({ detail: "unauthorized" }); + res.locals.userId = auth.userId; + res.locals.userEmail = auth.userEmail; + res.locals.token = "test-token"; + next(); + }, +})); + +import { createApp } from "../../src/index"; + +let app: ReturnType; +let mock: SupabaseMockControl; + +beforeEach(() => { + auth.userId = "user-1"; + auth.userEmail = "user@example.com"; + mock = createSupabaseMock(); + sb.current = mock.db; + app = createApp(); +}); + +describe("auth gate", () => { + it("returns 401 without auth", async () => { + auth.userId = null; + expect((await request(app).get("/projects")).status).toBe(401); + }); +}); + +describe("GET /projects", () => { + it("returns owned projects with computed counts", async () => { + mock.queueMany([ + { + data: [ + { id: "p1", user_id: "user-1", name: "P", created_at: "2024-01-01" }, + ], + error: null, + }, + { data: [], error: null }, // shared projects + // remaining count queries fall through to the default {data:null} -> 0 + ]); + const res = await request(app).get("/projects"); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0]).toMatchObject({ + id: "p1", + is_owner: true, + document_count: 0, + chat_count: 0, + review_count: 0, + }); + }); + + it("returns 500 when the owned-projects query fails", async () => { + mock.queue({ data: null, error: { message: "boom" } }); + const res = await request(app).get("/projects"); + expect(res.status).toBe(500); + expect(res.body).toEqual({ detail: "boom" }); + }); +}); + +describe("POST /projects", () => { + it("rejects a missing name with 400", async () => { + const res = await request(app).post("/projects").send({ name: " " }); + expect(res.status).toBe(400); + }); + + it("rejects sharing with yourself", async () => { + const res = await request(app) + .post("/projects") + .send({ name: "P", shared_with: ["user@example.com"] }); + expect(res.status).toBe(400); + expect(res.body.detail).toMatch(/cannot share a project with yourself/i); + }); + + it("creates a project", async () => { + mock.queue({ + data: { id: "p1", name: "P", user_id: "user-1", shared_with: [] }, + error: null, + }); + const res = await request(app) + .post("/projects") + .send({ name: "P", shared_with: ["friend@example.com"] }); + expect(res.status).toBe(201); + expect(res.body).toMatchObject({ id: "p1", documents: [] }); + const insert = mock.calls.find((c) => c.method === "insert"); + expect(insert?.args[0]).toMatchObject({ + name: "P", + shared_with: ["friend@example.com"], + }); + }); +}); + +describe("DELETE /projects/:projectId", () => { + it("deletes and returns 204", async () => { + expect((await request(app).delete("/projects/p1")).status).toBe(204); + }); + + it("returns 500 on delete error", async () => { + mock.queue({ data: null, error: { message: "nope" } }); + const res = await request(app).delete("/projects/p1"); + expect(res.status).toBe(500); + }); +}); + +describe("POST /projects/:projectId/folders", () => { + it("rejects a missing name with 400", async () => { + const res = await request(app) + .post("/projects/p1/folders") + .send({ name: "" }); + expect(res.status).toBe(400); + }); + + it("returns 404 when the project is not accessible", async () => { + mock.queue({ data: null, error: null }); // checkProjectAccess + const res = await request(app) + .post("/projects/p1/folders") + .send({ name: "Contracts" }); + expect(res.status).toBe(404); + }); + + it("creates a folder", async () => { + mock.queueMany([ + // checkProjectAccess -> owned project + { data: { id: "p1", user_id: "user-1", shared_with: null }, error: null }, + // insert folder + { data: { id: "f1", name: "Contracts" }, error: null }, + ]); + const res = await request(app) + .post("/projects/p1/folders") + .send({ name: "Contracts" }); + expect(res.status).toBe(201); + expect(res.body).toEqual({ id: "f1", name: "Contracts" }); + }); +}); diff --git a/backend/test/routes/tabular.test.ts b/backend/test/routes/tabular.test.ts new file mode 100644 index 000000000..3d14df2ae --- /dev/null +++ b/backend/test/routes/tabular.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import request from "supertest"; +import { + createSupabaseMock, + type SupabaseMockControl, +} from "../helpers/supabaseMock"; + +const auth = vi.hoisted(() => ({ + userId: "user-1" as string | null, + userEmail: "user@example.com", +})); +const sb = vi.hoisted(() => ({ current: null as unknown })); + +vi.mock("../../src/lib/supabase", () => ({ + createServerSupabase: () => sb.current, +})); +vi.mock("../../src/middleware/auth", () => ({ + requireAuth: (req: unknown, res: any, next: () => void) => { + if (!auth.userId) + return void res.status(401).json({ detail: "unauthorized" }); + res.locals.userId = auth.userId; + res.locals.userEmail = auth.userEmail; + res.locals.token = "test-token"; + next(); + }, +})); +vi.mock("../../src/lib/chatTools", () => ({ + runLLMStream: vi.fn(async ({ write }: { write: (s: string) => void }) => { + write(`data: ${JSON.stringify({ type: "text", text: "t" })}\n\n`); + return { fullText: "t", events: [{ type: "text", text: "t" }] }; + }), + TABULAR_TOOLS: [], +})); +vi.mock("../../src/lib/llm", () => ({ + completeText: vi.fn(async () => "Title"), + streamChatWithTools: vi.fn(), + providerForModel: (model: string) => + model.startsWith("claude") + ? "claude" + : model.startsWith("gpt") + ? "openai" + : "gemini", + resolveModel: (id: string, fb: string) => id || fb, + DEFAULT_TABULAR_MODEL: "gemini-3-flash-preview", +})); +vi.mock("../../src/lib/userSettings", () => ({ + getUserModelSettings: vi.fn(async () => ({ + tabular_model: "gemini-3-flash-preview", + api_keys: {}, + })), + getUserApiKeys: vi.fn(async () => ({})), +})); + +import { createApp } from "../../src/index"; + +let app: ReturnType; +let mock: SupabaseMockControl; + +beforeEach(() => { + auth.userId = "user-1"; + mock = createSupabaseMock(); + sb.current = mock.db; + app = createApp(); +}); + +describe("auth gate", () => { + it("returns 401 without auth", async () => { + auth.userId = null; + expect((await request(app).get("/tabular-review")).status).toBe(401); + }); +}); + +describe("GET /tabular-review", () => { + it("returns an empty list when the user has no reviews", async () => { + // listAccessibleProjectIds (own, shared), then own/sharedDirect reviews. + mock.queueMany([ + { data: [], error: null }, + { data: [], error: null }, + { data: [], error: null }, + { data: [], error: null }, + ]); + const res = await request(app).get("/tabular-review"); + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); + + it("returns 500 when the own-reviews query fails", async () => { + mock.queueMany([ + { data: [], error: null }, + { data: [], error: null }, + { data: null, error: { message: "boom" } }, + ]); + const res = await request(app).get("/tabular-review"); + expect(res.status).toBe(500); + }); +}); + +describe("POST /tabular-review", () => { + it("creates a review", async () => { + mock.queue({ data: { id: "r1", title: "T" }, error: null }); + const res = await request(app) + .post("/tabular-review") + .send({ title: "T", columns_config: [] }); + expect(res.status).toBe(201); + expect(res.body).toEqual({ id: "r1", title: "T" }); + }); +}); + +describe("DELETE /tabular-review/:reviewId", () => { + it("deletes and returns 204", async () => { + expect((await request(app).delete("/tabular-review/r1")).status).toBe(204); + }); +}); + +describe("POST /tabular-review/:reviewId/chat", () => { + it("rejects when there is no user message", async () => { + const res = await request(app) + .post("/tabular-review/r1/chat") + .send({ messages: [{ role: "assistant", content: "hi" }] }); + expect(res.status).toBe(400); + }); + + it("returns 404 when the review is missing", async () => { + mock.queue({ data: null, error: null }); + const res = await request(app) + .post("/tabular-review/r1/chat") + .send({ messages: [{ role: "user", content: "summarize" }] }); + expect(res.status).toBe(404); + }); + + it("returns 422 when the model's API key is missing", async () => { + mock.queueMany([ + // review lookup (owned) + { + data: { id: "r1", user_id: "user-1", columns_config: [] }, + error: null, + }, + // cells lookup + { data: [], error: null }, + ]); + const res = await request(app) + .post("/tabular-review/r1/chat") + .send({ messages: [{ role: "user", content: "summarize" }] }); + expect(res.status).toBe(422); + expect(res.body).toMatchObject({ + code: "missing_api_key", + provider: "gemini", + }); + }); +}); diff --git a/backend/test/routes/user.test.ts b/backend/test/routes/user.test.ts new file mode 100644 index 000000000..08fd86539 --- /dev/null +++ b/backend/test/routes/user.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import request from "supertest"; +import { + createSupabaseMock, + type SupabaseMockControl, +} from "../helpers/supabaseMock"; + +// --- Mocked auth + Supabase + userApiKeys ------------------------------------ +const auth = vi.hoisted(() => ({ + userId: "user-1" as string | null, + userEmail: "user@example.com", +})); +const sb = vi.hoisted(() => ({ current: null as unknown })); + +vi.mock("../../src/lib/supabase", () => ({ + createServerSupabase: () => sb.current, +})); + +vi.mock("../../src/middleware/auth", () => ({ + requireAuth: (req: unknown, res: any, next: () => void) => { + if (!auth.userId) { + res + .status(401) + .json({ detail: "Missing or invalid Authorization header" }); + return; + } + res.locals.userId = auth.userId; + res.locals.userEmail = auth.userEmail; + res.locals.token = "test-token"; + next(); + }, +})); + +vi.mock("../../src/lib/userApiKeys", () => ({ + getUserApiKeyStatus: vi.fn(async () => ({ + claude: false, + gemini: false, + openai: false, + })), + hasEnvApiKey: vi.fn(() => false), + normalizeApiKeyProvider: vi.fn((v: string) => + ["claude", "gemini", "openai"].includes(v) ? v : null, + ), + saveUserApiKey: vi.fn(async () => {}), +})); + +import { createApp } from "../../src/index"; +import { + getUserApiKeyStatus, + hasEnvApiKey, + normalizeApiKeyProvider, + saveUserApiKey, +} from "../../src/lib/userApiKeys"; + +const profileRow = { + display_name: "Ada", + organisation: "Analytical Engines", + message_credits_used: 5, + // Far-future reset date avoids the credit-reset branch (which would issue + // an extra UPDATE...SELECT round-trip). + credits_reset_date: "2999-01-01T00:00:00.000Z", + tier: "Pro", + tabular_model: "gemini-3-flash-preview", +}; + +let app: ReturnType; +let mock: SupabaseMockControl; + +beforeEach(() => { + auth.userId = "user-1"; + auth.userEmail = "user@example.com"; + mock = createSupabaseMock(); + sb.current = mock.db; + vi.mocked(getUserApiKeyStatus).mockResolvedValue({ + claude: false, + gemini: false, + openai: false, + }); + vi.mocked(hasEnvApiKey).mockReturnValue(false); + vi.mocked(normalizeApiKeyProvider).mockImplementation((v: string) => + ["claude", "gemini", "openai"].includes(v) + ? (v as "claude" | "gemini" | "openai") + : null, + ); + vi.mocked(saveUserApiKey).mockResolvedValue(undefined); + app = createApp(); +}); + +describe("auth gate", () => { + it("rejects unauthenticated requests with 401", async () => { + auth.userId = null; + const res = await request(app).get("/user/profile"); + expect(res.status).toBe(401); + }); +}); + +describe("POST /user/profile", () => { + it("ensures a profile row and returns ok", async () => { + const res = await request(app).post("/user/profile"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + expect(mock.fromCalls).toContain("user_profiles"); + }); + + it("returns 500 when the upsert fails", async () => { + mock.queue({ data: null, error: { message: "db down" } }); + const res = await request(app).post("/user/profile"); + expect(res.status).toBe(500); + expect(res.body).toEqual({ detail: "db down" }); + }); +}); + +describe("GET /user/profile", () => { + it("serializes the profile and attaches api-key status", async () => { + mock.queue({ data: profileRow, error: null }); + const res = await request(app).get("/user/profile"); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + displayName: "Ada", + organisation: "Analytical Engines", + messageCreditsUsed: 5, + tier: "Pro", + tabularModel: "gemini-3-flash-preview", + apiKeyStatus: { claude: false, gemini: false, openai: false }, + }); + }); + + it("returns 500 when the profile load fails", async () => { + mock.queue({ data: null, error: { message: "read error" } }); + const res = await request(app).get("/user/profile"); + expect(res.status).toBe(500); + expect(res.body).toEqual({ detail: "read error" }); + }); +}); + +describe("PATCH /user/profile", () => { + it("validates and persists allowed fields", async () => { + // ensureProfileRow upsert, update, then loadProfile select. + mock.queueMany([ + { data: null, error: null }, + { data: null, error: null }, + { data: profileRow, error: null }, + ]); + const res = await request(app) + .patch("/user/profile") + .send({ displayName: "Grace", organisation: "Navy" }); + expect(res.status).toBe(200); + expect(res.body.displayName).toBe("Ada"); + const update = mock.calls.find((c) => c.method === "update"); + expect(update?.args[0]).toMatchObject({ + display_name: "Grace", + organisation: "Navy", + }); + }); + + it("rejects unsupported fields with 400", async () => { + const res = await request(app) + .patch("/user/profile") + .send({ isAdmin: true }); + expect(res.status).toBe(400); + expect(res.body.detail).toMatch(/Unsupported profile field/); + }); + + it("rejects an unknown tabularModel with 400", async () => { + const res = await request(app) + .patch("/user/profile") + .send({ tabularModel: "not-a-real-model" }); + expect(res.status).toBe(400); + expect(res.body.detail).toMatch(/Unsupported tabularModel/); + }); +}); + +describe("PUT /user/api-keys/:provider", () => { + it("returns 400 for an unsupported provider", async () => { + const res = await request(app) + .put("/user/api-keys/bogus") + .send({ api_key: "x" }); + expect(res.status).toBe(400); + expect(res.body).toEqual({ detail: "Unsupported provider" }); + }); + + it("returns 409 when the provider is configured by the environment", async () => { + vi.mocked(hasEnvApiKey).mockReturnValue(true); + const res = await request(app) + .put("/user/api-keys/openai") + .send({ api_key: "sk-test" }); + expect(res.status).toBe(409); + }); + + it("saves a user-supplied key and returns the updated status", async () => { + const res = await request(app) + .put("/user/api-keys/claude") + .send({ api_key: "sk-test" }); + expect(res.status).toBe(200); + expect(saveUserApiKey).toHaveBeenCalledWith( + "user-1", + "claude", + "sk-test", + expect.anything(), + ); + }); +}); + +describe("DELETE /user/account", () => { + it("deletes the auth user and returns 204", async () => { + const res = await request(app).delete("/user/account"); + expect(res.status).toBe(204); + expect(mock.authDeleteUser).toHaveBeenCalledWith("user-1"); + }); + + it("returns 500 when deletion fails", async () => { + mock.authDeleteUser.mockResolvedValueOnce({ + data: { user: null }, + error: { message: "cannot delete" }, + } as never); + const res = await request(app).delete("/user/account"); + expect(res.status).toBe(500); + expect(res.body).toEqual({ detail: "cannot delete" }); + }); +}); diff --git a/backend/test/routes/workflows.test.ts b/backend/test/routes/workflows.test.ts new file mode 100644 index 000000000..1a3eec8d1 --- /dev/null +++ b/backend/test/routes/workflows.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import request from "supertest"; +import { + createSupabaseMock, + type SupabaseMockControl, +} from "../helpers/supabaseMock"; + +const auth = vi.hoisted(() => ({ + userId: "user-1" as string | null, + userEmail: "user@example.com", +})); +const sb = vi.hoisted(() => ({ current: null as unknown })); + +vi.mock("../../src/lib/supabase", () => ({ + createServerSupabase: () => sb.current, +})); +vi.mock("../../src/middleware/auth", () => ({ + requireAuth: (req: unknown, res: any, next: () => void) => { + if (!auth.userId) + return void res.status(401).json({ detail: "unauthorized" }); + res.locals.userId = auth.userId; + res.locals.userEmail = auth.userEmail; + res.locals.token = "test-token"; + next(); + }, +})); + +import { createApp } from "../../src/index"; + +let app: ReturnType; +let mock: SupabaseMockControl; + +beforeEach(() => { + auth.userId = "user-1"; + mock = createSupabaseMock(); + sb.current = mock.db; + app = createApp(); +}); + +describe("auth gate", () => { + it("returns 401 without auth", async () => { + auth.userId = null; + expect((await request(app).get("/workflows")).status).toBe(401); + }); +}); + +describe("GET /workflows", () => { + it("returns own workflows flagged as owned", async () => { + mock.queueMany([ + { + data: [{ id: "w1", user_id: "user-1", is_system: false, title: "X" }], + error: null, + }, + { data: [], error: null }, // workflow_shares + ]); + const res = await request(app).get("/workflows"); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0]).toMatchObject({ + id: "w1", + allow_edit: true, + is_owner: true, + }); + }); + + it("returns 500 when the own-workflows query fails", async () => { + mock.queue({ data: null, error: { message: "boom" } }); + expect((await request(app).get("/workflows")).status).toBe(500); + }); +}); + +describe("POST /workflows", () => { + it("rejects a missing title with 400", async () => { + const res = await request(app) + .post("/workflows") + .send({ type: "assistant" }); + expect(res.status).toBe(400); + }); + + it("rejects an invalid type with 400", async () => { + const res = await request(app) + .post("/workflows") + .send({ title: "T", type: "bogus" }); + expect(res.status).toBe(400); + }); + + it("creates a workflow", async () => { + mock.queue({ data: { id: "w1", title: "T" }, error: null }); + const res = await request(app) + .post("/workflows") + .send({ title: "T", type: "assistant" }); + expect(res.status).toBe(201); + expect(res.body).toEqual({ id: "w1", title: "T" }); + }); +}); + +describe("DELETE /workflows/:workflowId", () => { + it("deletes and returns 204", async () => { + expect((await request(app).delete("/workflows/w1")).status).toBe(204); + }); +}); + +describe("GET /workflows/:workflowId", () => { + it("returns 404 when not accessible", async () => { + mock.queue({ data: null, error: null }); + expect((await request(app).get("/workflows/w1")).status).toBe(404); + }); + + it("returns the workflow for its owner", async () => { + mock.queue({ + data: { id: "w1", user_id: "user-1", is_system: false, title: "X" }, + error: null, + }); + const res = await request(app).get("/workflows/w1"); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ id: "w1", is_owner: true }); + }); +}); + +describe("hidden workflows", () => { + it("lists hidden workflow ids", async () => { + mock.queue({ data: [{ workflow_id: "w9" }], error: null }); + const res = await request(app).get("/workflows/hidden"); + expect(res.status).toBe(200); + expect(res.body).toEqual(["w9"]); + }); + + it("rejects hiding without a workflow_id", async () => { + const res = await request(app).post("/workflows/hidden").send({}); + expect(res.status).toBe(400); + }); + + it("hides a workflow", async () => { + const res = await request(app) + .post("/workflows/hidden") + .send({ workflow_id: "w1" }); + expect(res.status).toBe(204); + }); +}); diff --git a/backend/test/setup.ts b/backend/test/setup.ts new file mode 100644 index 000000000..f2d134749 --- /dev/null +++ b/backend/test/setup.ts @@ -0,0 +1,12 @@ +// Global test bootstrap. Runs before every test file. +// +// Several modules read these at import time (createServerSupabase throws when +// they are absent, the auth middleware short-circuits to 500, downloadTokens +// throws without a signing secret). We use throwaway values because Supabase, +// storage and the LLM clients are all mocked in the route suites — nothing in +// the tests makes a real network call. +process.env.NODE_ENV = "test"; +process.env.SUPABASE_URL ??= "http://localhost:54321"; +process.env.SUPABASE_SECRET_KEY ??= "test-service-role-key"; +process.env.DOWNLOAD_SIGNING_SECRET ??= "test-download-signing-secret"; +process.env.FRONTEND_URL ??= "http://localhost:3000"; diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts new file mode 100644 index 000000000..2c3354033 --- /dev/null +++ b/backend/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + setupFiles: ["./test/setup.ts"], + include: ["test/**/*.test.ts"], + // Each test file gets a fresh module registry so vi.mock state and the + // per-test Supabase mock never leak between route suites. + isolate: true, + // Clear call history between tests but keep mock implementations defined + // in vi.mock factories (restoreMocks would wipe those). + clearMocks: true, + }, +}); From 30f579354187e4c342513ce14f70bae5ae0eb698 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 23:29:00 +0000 Subject: [PATCH 05/12] feat(backend): per-user practice profile injected into the assistant prompt Add a free-text practice profile to user_profiles (firm positions, house style, escalation rules) that is injected into the assistant system prompt for the chat and project-chat routes, so ported legal workflows can rely on the user's configured playbook instead of assuming defaults. Mirrors the per-team CLAUDE.md profiles used by the claude-for-legal skill set. - schema.sql + migrations/001_practice_profile.sql add user_profiles.practice_profile - getUserPracticeProfile loader and formatPracticeProfile prompt-block helper - GET/PATCH /user/profile expose and validate the field (20k char cap) - tests for serialization, validation, the formatter, and the loader --- backend/migrations/001_practice_profile.sql | 9 ++++ backend/schema.sql | 1 + backend/src/lib/chatTools.ts | 19 ++++++++ backend/src/lib/userSettings.ts | 20 +++++++++ backend/src/routes/chat.ts | 14 +++++- backend/src/routes/projectChat.ts | 12 +++++- backend/src/routes/user.ts | 40 ++++++++++++++--- backend/test/lib/practiceProfile.test.ts | 48 +++++++++++++++++++++ backend/test/routes/chat.test.ts | 2 + backend/test/routes/projectChat.test.ts | 2 + backend/test/routes/user.test.ts | 34 +++++++++++++++ 11 files changed, 191 insertions(+), 10 deletions(-) create mode 100644 backend/migrations/001_practice_profile.sql create mode 100644 backend/test/lib/practiceProfile.test.ts diff --git a/backend/migrations/001_practice_profile.sql b/backend/migrations/001_practice_profile.sql new file mode 100644 index 000000000..7138bf9df --- /dev/null +++ b/backend/migrations/001_practice_profile.sql @@ -0,0 +1,9 @@ +-- Per-user practice profile. +-- +-- A free-text "playbook" the user maintains (firm positions, house style, +-- escalation matrix, preferred governing law, etc.). It is injected into the +-- assistant system prompt so ported legal workflows can rely on the user's +-- configured positions instead of assuming defaults. Mirrors the per-team +-- CLAUDE.md practice profiles used by the claude-for-legal skill set. +alter table public.user_profiles + add column if not exists practice_profile text; diff --git a/backend/schema.sql b/backend/schema.sql index b6a4e934a..df0a09f10 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -18,6 +18,7 @@ create table if not exists public.user_profiles ( message_credits_used integer not null default 0, credits_reset_date timestamptz not null default (now() + interval '30 days'), tabular_model text not null default 'gemini-3-flash-preview', + practice_profile text, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); diff --git a/backend/src/lib/chatTools.ts b/backend/src/lib/chatTools.ts index 4c11d5b19..ef7ef922f 100644 --- a/backend/src/lib/chatTools.ts +++ b/backend/src/lib/chatTools.ts @@ -632,6 +632,25 @@ export async function enrichWithPriorEvents( return enriched; } +/** + * Wrap the user's practice profile in a clearly-delimited system-prompt block. + * Returns an empty string when no profile is set, so callers can splice the + * result into `systemPromptExtra` unconditionally. + */ +export function formatPracticeProfile(profile?: string | null): string { + if (!profile || !profile.trim()) return ""; + return ( + "USER PRACTICE PROFILE:\n" + + "The user has configured the practice profile below. Treat it as authoritative " + + "for their firm's positions, house style, escalation rules, preferred governing " + + "law, and review preferences. When a workflow or task references a playbook, firm " + + "position, escalation matrix, or house style, use the values from this profile. If " + + "a value the task needs is not present here, ask the user rather than assuming a " + + "default.\n\n" + + profile.trim() + ); +} + export function buildMessages( messages: ChatMessage[], docAvailability: { diff --git a/backend/src/lib/userSettings.ts b/backend/src/lib/userSettings.ts index bfbeb0fd5..92cad3b2f 100644 --- a/backend/src/lib/userSettings.ts +++ b/backend/src/lib/userSettings.ts @@ -51,3 +51,23 @@ export async function getUserApiKeys( const client = db ?? createServerSupabase(); return getStoredUserApiKeys(userId, client); } + +/** + * The user's free-text practice profile (firm positions, house style, + * escalation rules, preferred governing law, …). Injected into the assistant + * system prompt so workflows can rely on the user's configured positions + * instead of assuming defaults. Returns null when unset. + */ +export async function getUserPracticeProfile( + userId: string, + db?: ReturnType, +): Promise { + const client = db ?? createServerSupabase(); + const { data } = await client + .from("user_profiles") + .select("practice_profile") + .eq("user_id", userId) + .maybeSingle(); + const profile = (data?.practice_profile as string | null) ?? null; + return profile && profile.trim() ? profile : null; +} diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index 9a39e0a9b..5ecbfe642 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -7,11 +7,16 @@ import { enrichWithPriorEvents, buildWorkflowStore, extractAnnotations, + formatPracticeProfile, runLLMStream, type ChatMessage, } from "../lib/chatTools"; import { completeText } from "../lib/llm"; -import { getUserApiKeys, getUserModelSettings } from "../lib/userSettings"; +import { + getUserApiKeys, + getUserModelSettings, + getUserPracticeProfile, +} from "../lib/userSettings"; import { checkProjectAccess } from "../lib/access"; export const chatRouter = Router(); @@ -538,7 +543,12 @@ chatRouter.post("/", requireAuth, async (req, res) => { db, docIndex, ); - const apiMessages = buildMessages(enrichedMessages, docAvailability); + const practiceProfile = await getUserPracticeProfile(userId, db); + const apiMessages = buildMessages( + enrichedMessages, + docAvailability, + formatPracticeProfile(practiceProfile), + ); const workflowStore = await buildWorkflowStore(userId, userEmail, db); diff --git a/backend/src/routes/projectChat.ts b/backend/src/routes/projectChat.ts index 5e2996152..97672dea8 100644 --- a/backend/src/routes/projectChat.ts +++ b/backend/src/routes/projectChat.ts @@ -7,11 +7,12 @@ import { buildWorkflowStore, enrichWithPriorEvents, extractAnnotations, + formatPracticeProfile, runLLMStream, PROJECT_EXTRA_TOOLS, type ChatMessage, } from "../lib/chatTools"; -import { getUserApiKeys } from "../lib/userSettings"; +import { getUserApiKeys, getUserPracticeProfile } from "../lib/userSettings"; import { checkProjectAccess } from "../lib/access"; const PROJECT_SYSTEM_PROMPT_EXTRA = `PROJECT CONTEXT: @@ -136,10 +137,17 @@ projectChatRouter.post("/", requireAuth, async (req, res) => { systemPromptExtra += `\n\nUSER-ATTACHED DOCUMENTS FOR THIS TURN:\nThe user has attached the following document(s) directly to their latest message. Treat these as the primary focus of the request unless their message clearly says otherwise.\n${lines.join("\n")}`; } + const practiceProfile = await getUserPracticeProfile(userId, db); + const combinedSystemExtra = [ + systemPromptExtra, + formatPracticeProfile(practiceProfile), + ] + .filter(Boolean) + .join("\n\n"); const apiMessages = buildMessages( messagesForLLM, docAvailability, - systemPromptExtra, + combinedSystemExtra, ); const workflowStore = await buildWorkflowStore(userId, userEmail, db); diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts index 0df2021d6..87a6da495 100644 --- a/backend/src/routes/user.ts +++ b/backend/src/routes/user.ts @@ -14,6 +14,14 @@ export const userRouter = Router(); const MONTHLY_CREDIT_LIMIT = 999999; +// Single source of truth for the profile columns we select, so the three +// load paths below can't drift apart. +const PROFILE_COLUMNS = + "display_name, organisation, message_credits_used, credits_reset_date, tier, tabular_model, practice_profile"; + +// Cap the practice profile so a runaway paste can't blow up the system prompt. +const MAX_PRACTICE_PROFILE_CHARS = 20000; + type UserProfileRow = { display_name: string | null; organisation: string | null; @@ -21,6 +29,7 @@ type UserProfileRow = { credits_reset_date: string; tier: string; tabular_model: string; + practice_profile: string | null; }; function serializeProfile( @@ -36,6 +45,7 @@ function serializeProfile( creditsRemaining: Math.max(MONTHLY_CREDIT_LIMIT - creditsUsed, 0), tier: row.tier || "Free", tabularModel: resolveModel(row.tabular_model, DEFAULT_TABULAR_MODEL), + practiceProfile: row.practice_profile ?? null, ...(apiKeyStatus ? { apiKeyStatus } : {}), }; } @@ -47,6 +57,7 @@ function validateProfilePayload(body: unknown): display_name?: string | null; organisation?: string | null; tabular_model?: string; + practice_profile?: string | null; updated_at: string; }; } @@ -60,6 +71,7 @@ function validateProfilePayload(body: unknown): "displayName", "organisation", "tabularModel", + "practiceProfile", ]); const invalidField = Object.keys(raw).find((key) => !allowedFields.has(key)); if (invalidField) { @@ -70,6 +82,7 @@ function validateProfilePayload(body: unknown): display_name?: string | null; organisation?: string | null; tabular_model?: string; + practice_profile?: string | null; updated_at: string; } = { updated_at: new Date().toISOString() }; @@ -98,6 +111,25 @@ function validateProfilePayload(body: unknown): update.tabular_model = resolved; } + if ("practiceProfile" in raw) { + if (raw.practiceProfile !== null && typeof raw.practiceProfile !== "string") { + return { + ok: false, + detail: "practiceProfile must be a string or null", + }; + } + if ( + typeof raw.practiceProfile === "string" && + raw.practiceProfile.length > MAX_PRACTICE_PROFILE_CHARS + ) { + return { + ok: false, + detail: `practiceProfile must be ${MAX_PRACTICE_PROFILE_CHARS} characters or fewer`, + }; + } + update.practice_profile = raw.practiceProfile?.trim() || null; + } + return { ok: true, update }; } @@ -121,9 +153,7 @@ async function loadProfile( ) { let { data, error } = await db .from("user_profiles") - .select( - "display_name, organisation, message_credits_used, credits_reset_date, tier, tabular_model", - ) + .select(PROFILE_COLUMNS) .eq("user_id", userId) .maybeSingle(); @@ -138,9 +168,7 @@ async function loadProfile( const created = await db .from("user_profiles") - .select( - "display_name, organisation, message_credits_used, credits_reset_date, tier, tabular_model", - ) + .select(PROFILE_COLUMNS) .eq("user_id", userId) .single(); if (created.error) return { data: null, error: created.error }; diff --git a/backend/test/lib/practiceProfile.test.ts b/backend/test/lib/practiceProfile.test.ts new file mode 100644 index 000000000..3694168ad --- /dev/null +++ b/backend/test/lib/practiceProfile.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi } from "vitest"; +import { formatPracticeProfile } from "../../src/lib/chatTools"; + +describe("formatPracticeProfile", () => { + it("returns an empty string when there is no profile", () => { + expect(formatPracticeProfile(null)).toBe(""); + expect(formatPracticeProfile(undefined)).toBe(""); + expect(formatPracticeProfile(" ")).toBe(""); + }); + + it("wraps the profile in a labelled, authoritative system block", () => { + const out = formatPracticeProfile("We cap liability at 12 months of fees."); + expect(out).toContain("USER PRACTICE PROFILE:"); + expect(out).toContain("authoritative"); + // The verbatim profile text is preserved. + expect(out).toContain("We cap liability at 12 months of fees."); + // Tells the model to ask rather than invent missing values. + expect(out).toMatch(/ask the user/i); + }); +}); + +describe("getUserPracticeProfile", () => { + it("returns the trimmed profile, or null when blank/absent", async () => { + // Import lazily so we can stub createServerSupabase per-case. + const mod = await import("../../src/lib/userSettings"); + const make = (practice_profile: unknown) => + ({ + from: () => ({ + select: () => ({ + eq: () => ({ + maybeSingle: async () => ({ + data: practice_profile === undefined + ? null + : { practice_profile }, + error: null, + }), + }), + }), + }), + }) as never; + + expect(await mod.getUserPracticeProfile("u", make(" keep me "))).toBe( + " keep me ", + ); + expect(await mod.getUserPracticeProfile("u", make(" "))).toBeNull(); + expect(await mod.getUserPracticeProfile("u", make(undefined))).toBeNull(); + }); +}); diff --git a/backend/test/routes/chat.test.ts b/backend/test/routes/chat.test.ts index 7cdfc8d50..f5339c1fb 100644 --- a/backend/test/routes/chat.test.ts +++ b/backend/test/routes/chat.test.ts @@ -35,6 +35,7 @@ vi.mock("../../src/lib/chatTools", () => ({ enrichWithPriorEvents: vi.fn(async (m: unknown) => m), buildWorkflowStore: vi.fn(async () => new Map()), extractAnnotations: vi.fn(() => []), + formatPracticeProfile: vi.fn(() => ""), runLLMStream: vi.fn(async ({ write }: { write: (s: string) => void }) => { write(`data: ${JSON.stringify({ type: "text", text: "hi" })}\n\n`); return { fullText: "hi", events: [{ type: "text", text: "hi" }] }; @@ -50,6 +51,7 @@ vi.mock("../../src/lib/llm", () => ({ vi.mock("../../src/lib/userSettings", () => ({ getUserModelSettings: vi.fn(async () => ({ title_model: "m", api_keys: {} })), getUserApiKeys: vi.fn(async () => ({})), + getUserPracticeProfile: vi.fn(async () => null), })); import { createApp } from "../../src/index"; diff --git a/backend/test/routes/projectChat.test.ts b/backend/test/routes/projectChat.test.ts index 7ef45c0ac..398ab03e6 100644 --- a/backend/test/routes/projectChat.test.ts +++ b/backend/test/routes/projectChat.test.ts @@ -35,6 +35,7 @@ vi.mock("../../src/lib/chatTools", () => ({ enrichWithPriorEvents: vi.fn(async (m: unknown) => m), buildWorkflowStore: vi.fn(async () => new Map()), extractAnnotations: vi.fn(() => []), + formatPracticeProfile: vi.fn(() => ""), runLLMStream: vi.fn(async ({ write }: { write: (s: string) => void }) => { write(`data: ${JSON.stringify({ type: "text", text: "yo" })}\n\n`); return { fullText: "yo", events: [{ type: "text", text: "yo" }] }; @@ -44,6 +45,7 @@ vi.mock("../../src/lib/chatTools", () => ({ vi.mock("../../src/lib/userSettings", () => ({ getUserModelSettings: vi.fn(async () => ({ title_model: "m", api_keys: {} })), getUserApiKeys: vi.fn(async () => ({})), + getUserPracticeProfile: vi.fn(async () => null), })); import { createApp } from "../../src/index"; diff --git a/backend/test/routes/user.test.ts b/backend/test/routes/user.test.ts index 08fd86539..c6e866582 100644 --- a/backend/test/routes/user.test.ts +++ b/backend/test/routes/user.test.ts @@ -61,6 +61,7 @@ const profileRow = { credits_reset_date: "2999-01-01T00:00:00.000Z", tier: "Pro", tabular_model: "gemini-3-flash-preview", + practice_profile: "Our firm prefers English law and a 12-month liability cap.", }; let app: ReturnType; @@ -121,6 +122,8 @@ describe("GET /user/profile", () => { messageCreditsUsed: 5, tier: "Pro", tabularModel: "gemini-3-flash-preview", + practiceProfile: + "Our firm prefers English law and a 12-month liability cap.", apiKeyStatus: { claude: false, gemini: false, openai: false }, }); }); @@ -168,6 +171,37 @@ describe("PATCH /user/profile", () => { expect(res.status).toBe(400); expect(res.body.detail).toMatch(/Unsupported tabularModel/); }); + + it("persists the practice profile", async () => { + mock.queueMany([ + { data: null, error: null }, // ensureProfileRow + { data: null, error: null }, // update + { data: profileRow, error: null }, // loadProfile + ]); + const res = await request(app) + .patch("/user/profile") + .send({ practiceProfile: "We escalate any uncapped indemnity to the GC." }); + expect(res.status).toBe(200); + const update = mock.calls.find((c) => c.method === "update"); + expect(update?.args[0]).toMatchObject({ + practice_profile: "We escalate any uncapped indemnity to the GC.", + }); + }); + + it("rejects a non-string practice profile with 400", async () => { + const res = await request(app) + .patch("/user/profile") + .send({ practiceProfile: 42 }); + expect(res.status).toBe(400); + }); + + it("rejects an oversized practice profile with 400", async () => { + const res = await request(app) + .patch("/user/profile") + .send({ practiceProfile: "x".repeat(20001) }); + expect(res.status).toBe(400); + expect(res.body.detail).toMatch(/characters or fewer/); + }); }); describe("PUT /user/api-keys/:provider", () => { From f4e192c34c58aa77ace39a18f20c5b6c629d9ddd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 23:31:29 +0000 Subject: [PATCH 06/12] feat(frontend): practice profile editor in Account settings Add a Practice Profile textarea to the account page (with a 20k char counter and save state) wired through UserProfileContext.updatePracticeProfile to PATCH /user/profile. Extend the UserProfile API/context types with the practiceProfile field. --- frontend/src/app/(pages)/account/page.tsx | 76 +++++++++++++++++++- frontend/src/app/lib/mikeApi.ts | 2 + frontend/src/contexts/UserProfileContext.tsx | 24 +++++++ 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/(pages)/account/page.tsx b/frontend/src/app/(pages)/account/page.tsx index 1c18aa4d5..d9795a7a6 100644 --- a/frontend/src/app/(pages)/account/page.tsx +++ b/frontend/src/app/(pages)/account/page.tsx @@ -12,13 +12,21 @@ import { deleteAccount } from "@/app/lib/mikeApi"; export default function AccountPage() { const router = useRouter(); const { user, signOut } = useAuth(); - const { profile, updateDisplayName, updateOrganisation } = useUserProfile(); + const { + profile, + updateDisplayName, + updateOrganisation, + updatePracticeProfile, + } = useUserProfile(); const [displayName, setDisplayName] = useState(""); const [isSavingName, setIsSavingName] = useState(false); const [saved, setSaved] = useState(false); const [organisation, setOrganisation] = useState(""); const [isSavingOrg, setIsSavingOrg] = useState(false); const [orgSaved, setOrgSaved] = useState(false); + const [practiceProfile, setPracticeProfile] = useState(""); + const [isSavingPractice, setIsSavingPractice] = useState(false); + const [practiceSaved, setPracticeSaved] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(false); const [isDeleting, setIsDeleting] = useState(false); @@ -29,6 +37,7 @@ export default function AccountPage() { if (profile?.organisation) { setOrganisation(profile.organisation); } + setPracticeProfile(profile?.practiceProfile ?? ""); }, [profile]); const handleLogout = async () => { @@ -75,6 +84,19 @@ export default function AccountPage() { } }; + const handleSavePracticeProfile = async () => { + setIsSavingPractice(true); + const success = await updatePracticeProfile(practiceProfile); + setIsSavingPractice(false); + + if (success) { + setPracticeSaved(true); + setTimeout(() => setPracticeSaved(false), 2000); + } else { + alert("Failed to update practice profile. Please try again."); + } + }; + if (!user) return null; return ( @@ -163,6 +185,58 @@ export default function AccountPage() {
+ {/* Practice Profile */} +
+
+

+ Practice Profile +

+
+

+ A plain-English playbook the assistant reads on every chat: + your firm's positions, house style, escalation rules, + preferred governing law, and review preferences. Workflows + use these instead of assuming defaults; anything you leave + out, the assistant will ask about. +

+