From 575b41d41ccaa9fdc9c1bd0830b2fae8aa902ba7 Mon Sep 17 00:00:00 2001 From: Metbcy Date: Sun, 3 May 2026 00:12:44 +0000 Subject: [PATCH 01/26] fix(security): fail fast when download HMAC secret is missing Resolves the issue where getSecret() silently fell back to the literal string "dev-secret" when neither DOWNLOAD_SIGNING_SECRET nor SUPABASE_SECRET_KEY was set. Because the codebase is public, that fallback let anyone forge valid /download/:token signatures against a mis-configured deployment. - Throw at first call instead of returning the hardcoded string, with a message pointing the operator at \`openssl rand -hex 32\`. - Document DOWNLOAD_SIGNING_SECRET in backend/.env.example so deployers following the README know to set it (and that it should be distinct from SUPABASE_SECRET_KEY). Closes upstream issue #7. Cherry-picked from upstream PR #21: fix(security): fail fast when download HMAC secret is missing Author: @Metbcy Source: https://github.com/willchen96/mike/pull/21 (cherry picked from commit eb4414092e3970bc274f760b86687f48755e622f) --- backend/.env.example | 5 +++++ backend/src/lib/downloadTokens.ts | 13 +++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 1db370a9..2b163985 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,5 +1,10 @@ PORT=3001 FRONTEND_URL=http://localhost:3000 + +# HMAC key used to sign /download/:token URLs. Required at startup. +# Generate with: openssl rand -hex 32 +# Use a dedicated secret distinct from SUPABASE_SECRET_KEY. +DOWNLOAD_SIGNING_SECRET=replace-with-a-random-32-byte-hex-string SUPABASE_URL=https://your-project.supabase.co SUPABASE_SECRET_KEY=your-supabase-service-role-key diff --git a/backend/src/lib/downloadTokens.ts b/backend/src/lib/downloadTokens.ts index 0fe27cbc..de2240af 100644 --- a/backend/src/lib/downloadTokens.ts +++ b/backend/src/lib/downloadTokens.ts @@ -10,11 +10,16 @@ import crypto from "crypto"; */ function getSecret(): string { - return ( + const secret = process.env.DOWNLOAD_SIGNING_SECRET ?? - process.env.SUPABASE_SECRET_KEY ?? - "dev-secret" - ); + process.env.SUPABASE_SECRET_KEY; + if (!secret) { + throw new Error( + "DOWNLOAD_SIGNING_SECRET (or SUPABASE_SECRET_KEY as a fallback) must be set. " + + "Generate a strong random value (e.g. `openssl rand -hex 32`) and set it in the environment.", + ); + } + return secret; } function b64urlEncode(buf: Buffer): string { From e57320402a78713d31420895f8af7c685194c8de Mon Sep 17 00:00:00 2001 From: Eli Fayerman Date: Mon, 4 May 2026 12:40:19 -0400 Subject: [PATCH 02/26] fix(security): remove persisted raw Claude stream log backend/src/lib/llm/claude.ts unconditionally appended every Anthropic stream event to claude-raw-stream.log on disk. That writes full conversation content (user prompts + model output) to a file in the backend's working directory, surviving restarts and untracked by any retention policy. Replace with an env-gated console.debug (DEBUG_LLM_STREAM=true) so debugging is opt-in and never persists. Cherry-picked from upstream PR #29: fix(security): remove persisted Claude raw stream log Author: @fayerman-source Source: https://github.com/willchen96/mike/pull/29 (cherry picked from commit 95cf296f3782d26a48ae898e20ccfd9d09508637) --- backend/src/lib/llm/claude.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/backend/src/lib/llm/claude.ts b/backend/src/lib/llm/claude.ts index 9ed625eb..ee62de82 100644 --- a/backend/src/lib/llm/claude.ts +++ b/backend/src/lib/llm/claude.ts @@ -1,7 +1,5 @@ import Anthropic from "@anthropic-ai/sdk"; import type { Tool } from "@anthropic-ai/sdk/resources/messages/messages"; -import * as fs from "fs"; -import * as path from "path"; import type { StreamChatParams, StreamChatResult, @@ -10,10 +8,7 @@ import type { } from "./types"; import { toClaudeTools } from "./tools"; -const RAW_STREAM_LOG_PATH = path.resolve( - process.cwd(), - "claude-raw-stream.log", -); +const DEBUG_LLM_STREAM = process.env.DEBUG_LLM_STREAM === "true"; type ContentBlock = | { type: "text"; text: string } @@ -81,9 +76,9 @@ export async function streamClaude( let sawThinking = false; stream.on("streamEvent", (event) => { - const line = JSON.stringify(event); - console.log("[claude raw stream]", line); - fs.appendFile(RAW_STREAM_LOG_PATH, line + "\n", () => {}); + if (DEBUG_LLM_STREAM) { + console.debug("[claude raw stream]", JSON.stringify(event)); + } }); stream.on("text", (delta) => { From 6c488bd21da0895b7ba51f5e3ac559a43c595b34 Mon Sep 17 00:00:00 2001 From: Eli Fayerman Date: Mon, 4 May 2026 11:59:04 -0400 Subject: [PATCH 03/26] fix(projects): validate folder ownership before folder mutations Several folder-mutation paths in projects.ts accepted folder IDs without first proving the folder belonged to the project being mutated: - moving folders accepted any parent folder ID - moving documents accepted any target folder ID - deleting a folder didn't verify it belonged to the project, and the follow-on document cleanup wasn't scoped to the project either Cross-project folder references are inside Mike's basic trust boundary, so each path now validates the folder against the current project before mutating. Cherry-picked from upstream PR #28: fix(projects): validate folder ownership before folder mutations Author: @fayerman-source Source: https://github.com/willchen96/mike/pull/28 (cherry picked from commit 7062a300397b6307d19b85159d7a02e548460b9d) --- backend/src/routes/projects.ts | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index 61fdd83e..a5ad4697 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -526,11 +526,14 @@ projectsRouter.patch("/:projectId/folders/:folderId", requireAuth, async (req, r if ("parent_folder_id" in body) { // Cycle check: walk up the tree from the proposed parent to ensure folderId is not an ancestor if (body.parent_folder_id) { + const parent = await loadProjectFolder(db, projectId, body.parent_folder_id); + if (!parent) return void res.status(404).json({ detail: "Parent folder not found" }); + let cur: string | null = body.parent_folder_id; while (cur) { if (cur === folderId) return void res.status(400).json({ detail: "Cannot move a folder into itself or a descendant" }); - const { data: p }: { data: { parent_folder_id: string | null } | null } = - await db.from("project_subfolders").select("parent_folder_id").eq("id", cur).single(); + const p = await loadProjectFolder(db, projectId, cur); + if (!p) return void res.status(404).json({ detail: "Parent folder not found" }); cur = p?.parent_folder_id ?? null; } } @@ -555,8 +558,11 @@ projectsRouter.delete("/:projectId/folders/:folderId", requireAuth, async (req, const access = await checkProjectAccess(projectId, userId, userEmail, db); if (!access.ok) return void res.status(404).json({ detail: "Project not found" }); + const folder = await loadProjectFolder(db, projectId, folderId); + if (!folder) return void res.status(404).json({ detail: "Folder not found" }); + // Move direct documents to root before cascade-deleting subfolders - await db.from("documents").update({ folder_id: null }).eq("folder_id", folderId); + await db.from("documents").update({ folder_id: null }).eq("folder_id", folderId).eq("project_id", projectId); const { error } = await db.from("project_subfolders") .delete().eq("id", folderId).eq("project_id", projectId); @@ -575,6 +581,11 @@ projectsRouter.patch("/:projectId/documents/:documentId/folder", requireAuth, as const access = await checkProjectAccess(projectId, userId, userEmail, db); if (!access.ok) return void res.status(404).json({ detail: "Project not found" }); + if (folder_id) { + const folder = await loadProjectFolder(db, projectId, folder_id); + if (!folder) return void res.status(404).json({ detail: "Folder not found" }); + } + const { data, error } = await db.from("documents") .update({ folder_id: folder_id ?? null, updated_at: new Date().toISOString() }) .eq("id", documentId).eq("project_id", projectId) @@ -583,6 +594,20 @@ projectsRouter.patch("/:projectId/documents/:documentId/folder", requireAuth, as res.json(data); }); +async function loadProjectFolder( + db: ReturnType, + projectId: string, + folderId: string, +): Promise<{ id: string; parent_folder_id: string | null } | null> { + const { data } = await db + .from("project_subfolders") + .select("id, parent_folder_id") + .eq("id", folderId) + .eq("project_id", projectId) + .maybeSingle(); + return (data as { id: string; parent_folder_id: string | null } | null) ?? null; +} + export async function handleDocumentUpload( req: import("express").Request, res: import("express").Response, From 8a05fe2e77fc9698bf2ff3cbca9e35aeac8e4945 Mon Sep 17 00:00:00 2001 From: ryanmcdonough Date: Thu, 30 Apr 2026 09:32:03 +0100 Subject: [PATCH 04/26] fix(chat): require project access on chat creation endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /chat/create accepted any project_id from an authenticated user without verifying the caller could actually access the project, letting authenticated users create chats under arbitrary existing project IDs (app-layer project spoofing). The route now reads userEmail from the auth context and, when project_id is provided, calls checkProjectAccess() before the insert. Access denied → 404 with no row written; access allowed (owner or shared) → unchanged behaviour. Cherry-picked from upstream PR #2: Enhance chat creation endpoint to check project access using user email Author: @ryanmcdonough Source: https://github.com/willchen96/mike/pull/2 (cherry picked from commit 69c283eef885e601af1cef811924141affdaec64) --- backend/src/routes/chat.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index b56c2936..ab9d00bd 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -52,8 +52,16 @@ chatRouter.get("/", requireAuth, async (req, res) => { // POST /chat/create chatRouter.post("/create", requireAuth, async (req, res) => { const userId = res.locals.userId as string; + const userEmail = res.locals.userEmail as string | undefined; const projectId: string | null = req.body.project_id ?? null; const db = createServerSupabase(); + + if (projectId) { + const access = await checkProjectAccess(projectId, userId, userEmail, db); + if (!access.ok) + return void res.status(404).json({ detail: "Project not found" }); + } + const { data, error } = await db .from("chats") .insert({ user_id: userId, project_id: projectId ?? undefined }) From 1cdbd3303295a15c972f388433db9d1c0e12f706 Mon Sep 17 00:00:00 2001 From: Charles Becker Date: Fri, 1 May 2026 14:03:10 -0500 Subject: [PATCH 05/26] feat: Add OpenRouter as third LLM provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds OpenRouter alongside Claude and Gemini, providing access to GPT, Claude (via OpenRouter routing), and Grok models through a single unified API key. Backend gets a new openrouter.ts streaming implementation using the OpenAI-compatible interface; frontend gets the new provider in ModelToggle and the API-key input in Account → Models. Includes migration 001_add_openrouter_api_key.sql. Replaces the previously-vestigial @openrouter/sdk dependency with a fetch-based backend implementation following the same pattern as claude.ts and gemini.ts. Cherry-picked from upstream PR #11: feat: Add OpenRouter as third LLM provider Author: @becker-charles Source: https://github.com/willchen96/mike/pull/11 (cherry picked from commit bb05dd792456a1b2219e377d03b03151a4478657) --- backend/migrations/000_one_shot_schema.sql | 1 + .../migrations/001_add_openrouter_api_key.sql | 5 + backend/src/lib/llm/index.ts | 3 + backend/src/lib/llm/models.ts | 15 + backend/src/lib/llm/openrouter.ts | 272 ++++++++++++++++++ backend/src/lib/llm/types.ts | 3 +- backend/src/lib/userSettings.ts | 6 +- .../src/app/(pages)/account/models/page.tsx | 13 +- .../app/components/assistant/ChatInput.tsx | 1 + .../app/components/assistant/ModelToggle.tsx | 164 +++++------ .../app/components/tabular/TRChatPanel.tsx | 3 +- frontend/src/app/lib/modelAvailability.ts | 34 ++- frontend/src/contexts/UserProfileContext.tsx | 24 +- 13 files changed, 423 insertions(+), 121 deletions(-) create mode 100644 backend/migrations/001_add_openrouter_api_key.sql create mode 100644 backend/src/lib/llm/openrouter.ts diff --git a/backend/migrations/000_one_shot_schema.sql b/backend/migrations/000_one_shot_schema.sql index 80d563af..07d0d0b6 100644 --- a/backend/migrations/000_one_shot_schema.sql +++ b/backend/migrations/000_one_shot_schema.sql @@ -20,6 +20,7 @@ create table if not exists public.user_profiles ( tabular_model text not null default 'gemini-3-flash-preview', claude_api_key text, gemini_api_key text, + openrouter_api_key text, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); diff --git a/backend/migrations/001_add_openrouter_api_key.sql b/backend/migrations/001_add_openrouter_api_key.sql new file mode 100644 index 00000000..dcc33ef5 --- /dev/null +++ b/backend/migrations/001_add_openrouter_api_key.sql @@ -0,0 +1,5 @@ +-- Add OpenRouter API key column to user_profiles +-- Run this migration in your Supabase SQL Editor + +alter table public.user_profiles + add column if not exists openrouter_api_key text; diff --git a/backend/src/lib/llm/index.ts b/backend/src/lib/llm/index.ts index 518ddc01..06e1bee6 100644 --- a/backend/src/lib/llm/index.ts +++ b/backend/src/lib/llm/index.ts @@ -1,5 +1,6 @@ import { streamClaude, completeClaudeText } from "./claude"; import { streamGemini, completeGeminiText } from "./gemini"; +import { streamOpenRouter, completeOpenRouterText } from "./openrouter"; import { providerForModel } from "./models"; import type { StreamChatParams, StreamChatResult, UserApiKeys } from "./types"; @@ -11,6 +12,7 @@ export async function streamChatWithTools( ): Promise { const provider = providerForModel(params.model); if (provider === "claude") return streamClaude(params); + if (provider === "openrouter") return streamOpenRouter(params); return streamGemini(params); } @@ -23,5 +25,6 @@ export async function completeText(params: { }): Promise { const provider = providerForModel(params.model); if (provider === "claude") return completeClaudeText(params); + if (provider === "openrouter") return completeOpenRouterText(params); return completeGeminiText(params); } diff --git a/backend/src/lib/llm/models.ts b/backend/src/lib/llm/models.ts index 52314007..0afb5a74 100644 --- a/backend/src/lib/llm/models.ts +++ b/backend/src/lib/llm/models.ts @@ -10,14 +10,25 @@ export const GEMINI_MAIN_MODELS = [ "gemini-3-flash-preview", ] as const; +// OpenRouter main-chat tier +export const OPENROUTER_MAIN_MODELS = [ + "openrouter/openai/gpt-5.3-chat", + "openrouter/anthropic/claude-sonnet-4.6", + "openrouter/anthropic/claude-opus-4.7", + "openrouter/x-ai/grok-4.3", + "openrouter/openai/gpt-4o-mini", +] as const; + // Mid-tier (used for tabular review) — user picks one in account settings. export const CLAUDE_MID_MODELS = ["claude-sonnet-4-6"] as const; export const GEMINI_MID_MODELS = ["gemini-3-flash-preview"] as const; +export const OPENROUTER_MID_MODELS = ["openrouter/openai/gpt-4o-mini"] as const; // Low-tier (used for title generation, lightweight extractions) — user picks // one in account settings. export const CLAUDE_LOW_MODELS = ["claude-haiku-4-5"] as const; export const GEMINI_LOW_MODELS = ["gemini-3.1-flash-lite-preview"] as const; +export const OPENROUTER_LOW_MODELS = ["openrouter/openai/gpt-4o-mini"] as const; export const DEFAULT_MAIN_MODEL = "gemini-3-flash-preview"; export const DEFAULT_TITLE_MODEL = "gemini-3.1-flash-lite-preview"; @@ -26,10 +37,13 @@ export const DEFAULT_TABULAR_MODEL = "gemini-3-flash-preview"; const ALL_MODELS = new Set([ ...CLAUDE_MAIN_MODELS, ...GEMINI_MAIN_MODELS, + ...OPENROUTER_MAIN_MODELS, ...CLAUDE_MID_MODELS, ...GEMINI_MID_MODELS, + ...OPENROUTER_MID_MODELS, ...CLAUDE_LOW_MODELS, ...GEMINI_LOW_MODELS, + ...OPENROUTER_LOW_MODELS, ]); // --------------------------------------------------------------------------- @@ -37,6 +51,7 @@ const ALL_MODELS = new Set([ // --------------------------------------------------------------------------- export function providerForModel(model: string): Provider { + if (model.startsWith("openrouter/")) return "openrouter"; if (model.startsWith("claude")) return "claude"; if (model.startsWith("gemini")) return "gemini"; throw new Error(`Unknown model id: ${model}`); diff --git a/backend/src/lib/llm/openrouter.ts b/backend/src/lib/llm/openrouter.ts new file mode 100644 index 00000000..16f1e2d5 --- /dev/null +++ b/backend/src/lib/llm/openrouter.ts @@ -0,0 +1,272 @@ +import type { + StreamChatParams, + StreamChatResult, + NormalizedToolCall, +} from "./types"; + +const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"; +const MAX_TOKENS = 16384; + +type OpenRouterMessage = { + role: "system" | "user" | "assistant" | "tool"; + content: string | null; + tool_calls?: { + id: string; + type: "function"; + function: { name: string; arguments: string }; + }[]; + tool_call_id?: string; +}; + +type OpenRouterChoice = { + delta?: { + content?: string | null; + tool_calls?: { + index: number; + id?: string; + type?: "function"; + function?: { name?: string; arguments?: string }; + }[]; + }; + finish_reason?: string | null; +}; + +type OpenRouterStreamChunk = { + choices: OpenRouterChoice[]; +}; + +function getApiKey(override?: string | null): string { + return override?.trim() || process.env.OPENROUTER_API_KEY || ""; +} + +/** + * Strip the "openrouter/" prefix from model IDs. + * e.g., "openrouter/openai/gpt-4o" -> "openai/gpt-4o" + */ +function toOpenRouterModelId(model: string): string { + return model.startsWith("openrouter/") ? model.slice("openrouter/".length) : model; +} + +function toOpenRouterMessages( + systemPrompt: string, + messages: StreamChatParams["messages"], +): OpenRouterMessage[] { + const result: OpenRouterMessage[] = []; + if (systemPrompt) { + result.push({ role: "system", content: systemPrompt }); + } + for (const m of messages) { + result.push({ role: m.role, content: m.content }); + } + return result; +} + +export async function streamOpenRouter( + params: StreamChatParams, +): Promise { + const { + model, + systemPrompt, + tools = [], + callbacks = {}, + runTools, + apiKeys, + } = params; + const maxIter = params.maxIterations ?? 10; + const apiKey = getApiKey(apiKeys?.openrouter); + const openRouterModel = toOpenRouterModelId(model); + + const messages: OpenRouterMessage[] = toOpenRouterMessages(systemPrompt, params.messages); + let fullText = ""; + + for (let iter = 0; iter < maxIter; iter++) { + const body: Record = { + model: openRouterModel, + messages, + max_tokens: MAX_TOKENS, + stream: true, + }; + + if (tools.length > 0) { + body.tools = tools; + body.tool_choice = "auto"; + } + + const response = await fetch(OPENROUTER_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + "HTTP-Referer": process.env.APP_URL || "http://localhost:3000", + "X-Title": "Mike", + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`OpenRouter API error: ${response.status} - ${errorText}`); + } + + if (!response.body) { + throw new Error("OpenRouter response body is null"); + } + + // Parse SSE stream + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + // Per-iteration accumulators + const textParts: string[] = []; + const toolCalls: Map = new Map(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed === "data: [DONE]") continue; + if (!trimmed.startsWith("data: ")) continue; + + const jsonStr = trimmed.slice(6); + let chunk: OpenRouterStreamChunk; + try { + chunk = JSON.parse(jsonStr); + } catch { + continue; + } + + console.log("[openrouter stream chunk]", JSON.stringify(chunk, null, 2)); + + const choice = chunk.choices?.[0]; + if (!choice?.delta) continue; + + // Handle text content + if (choice.delta.content) { + textParts.push(choice.delta.content); + callbacks.onContentDelta?.(choice.delta.content); + } + + // Handle tool calls + if (choice.delta.tool_calls) { + for (const tc of choice.delta.tool_calls) { + const existing = toolCalls.get(tc.index); + if (existing) { + // Accumulate function arguments + if (tc.function?.arguments) { + existing.arguments += tc.function.arguments; + } + } else { + // New tool call + toolCalls.set(tc.index, { + id: tc.id || `tool-${tc.index}`, + name: tc.function?.name || "", + arguments: tc.function?.arguments || "", + }); + } + } + } + } + } + + fullText += textParts.join(""); + + // Convert accumulated tool calls to normalized format + const normalizedCalls: NormalizedToolCall[] = []; + for (const [, tc] of toolCalls) { + if (!tc.name) continue; + let input: Record = {}; + try { + input = JSON.parse(tc.arguments || "{}"); + } catch { + input = {}; + } + const call: NormalizedToolCall = { + id: tc.id, + name: tc.name, + input, + }; + callbacks.onToolCallStart?.(call); + normalizedCalls.push(call); + } + + // If no tool calls or no runTools handler, we're done + if (!normalizedCalls.length || !runTools) { + break; + } + + // Execute tools and continue the loop + const results = await runTools(normalizedCalls); + + // Add assistant message with tool calls + messages.push({ + role: "assistant", + content: textParts.join("") || null, + tool_calls: normalizedCalls.map((c) => ({ + id: c.id, + type: "function" as const, + function: { + name: c.name, + arguments: JSON.stringify(c.input), + }, + })), + }); + + // Add tool results + for (const r of results) { + messages.push({ + role: "tool", + tool_call_id: r.tool_use_id, + content: r.content, + }); + } + } + + return { fullText }; +} + +export async function completeOpenRouterText(params: { + model: string; + systemPrompt?: string; + user: string; + maxTokens?: number; + apiKeys?: { openrouter?: string | null }; +}): Promise { + const apiKey = getApiKey(params.apiKeys?.openrouter); + const openRouterModel = toOpenRouterModelId(params.model); + + const messages: OpenRouterMessage[] = []; + if (params.systemPrompt) { + messages.push({ role: "system", content: params.systemPrompt }); + } + messages.push({ role: "user", content: params.user }); + + const response = await fetch(OPENROUTER_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + "HTTP-Referer": process.env.APP_URL || "http://localhost:3000", + "X-Title": "Mike", + }, + body: JSON.stringify({ + model: openRouterModel, + messages, + max_tokens: params.maxTokens ?? 512, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`OpenRouter API error: ${response.status} - ${errorText}`); + } + + const data = await response.json(); + return data.choices?.[0]?.message?.content ?? ""; +} diff --git a/backend/src/lib/llm/types.ts b/backend/src/lib/llm/types.ts index 8cc411a7..4858972b 100644 --- a/backend/src/lib/llm/types.ts +++ b/backend/src/lib/llm/types.ts @@ -2,7 +2,7 @@ // Callers always speak OpenAI-style tools + { role, content } messages; each // provider translates internally. -export type Provider = "claude" | "gemini"; +export type Provider = "claude" | "gemini" | "openrouter"; export type OpenAIToolSchema = { type: "function"; @@ -39,6 +39,7 @@ export type StreamCallbacks = { export type UserApiKeys = { claude?: string | null; gemini?: string | null; + openrouter?: string | null; }; export type StreamChatParams = { diff --git a/backend/src/lib/userSettings.ts b/backend/src/lib/userSettings.ts index c798b636..201da3d4 100644 --- a/backend/src/lib/userSettings.ts +++ b/backend/src/lib/userSettings.ts @@ -29,13 +29,14 @@ export async function getUserModelSettings( const client = db ?? createServerSupabase(); const { data } = await client .from("user_profiles") - .select("tabular_model, claude_api_key, gemini_api_key") + .select("tabular_model, claude_api_key, gemini_api_key, openrouter_api_key") .eq("user_id", userId) .single(); const api_keys: UserApiKeys = { claude: data?.claude_api_key ?? null, gemini: data?.gemini_api_key ?? null, + openrouter: data?.openrouter_api_key ?? null, }; return { @@ -52,11 +53,12 @@ export async function getUserApiKeys( const client = db ?? createServerSupabase(); const { data } = await client .from("user_profiles") - .select("claude_api_key, gemini_api_key") + .select("claude_api_key, gemini_api_key, openrouter_api_key") .eq("user_id", userId) .single(); return { claude: data?.claude_api_key ?? null, gemini: data?.gemini_api_key ?? null, + openrouter: data?.openrouter_api_key ?? null, }; } diff --git a/frontend/src/app/(pages)/account/models/page.tsx b/frontend/src/app/(pages)/account/models/page.tsx index cf3720ea..f10619fc 100644 --- a/frontend/src/app/(pages)/account/models/page.tsx +++ b/frontend/src/app/(pages)/account/models/page.tsx @@ -44,6 +44,7 @@ export default function ModelsAndApiKeysPage() { apiKeys={{ claudeApiKey: profile?.claudeApiKey ?? null, geminiApiKey: profile?.geminiApiKey ?? null, + openrouterApiKey: profile?.openrouterApiKey ?? null, }} onChange={(id) => updateModelPreference("tabularModel", id) @@ -87,6 +88,14 @@ export default function ModelsAndApiKeysPage() { updateApiKey("gemini", value.trim() || null) } /> + + updateApiKey("openrouter", value.trim() || null) + } + /> @@ -100,12 +109,12 @@ function TabularModelDropdown({ }: { value: string; onChange: (id: string) => void; - apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null }; + apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null; openrouterApiKey: string | null }; }) { const [isOpen, setIsOpen] = useState(false); const selected = MODELS.find((m) => m.id === value); const selectedAvailable = isModelAvailable(value, apiKeys); - const groups: ("Anthropic" | "Google")[] = ["Anthropic", "Google"]; + const groups: ("Anthropic" | "Google" | "OpenRouter")[] = ["Anthropic", "Google", "OpenRouter"]; return ( diff --git a/frontend/src/app/components/assistant/ChatInput.tsx b/frontend/src/app/components/assistant/ChatInput.tsx index 7f56192b..df0a38f4 100644 --- a/frontend/src/app/components/assistant/ChatInput.tsx +++ b/frontend/src/app/components/assistant/ChatInput.tsx @@ -70,6 +70,7 @@ export const ChatInput = forwardRef(function ChatInput( const apiKeys = { claudeApiKey: profile?.claudeApiKey ?? null, geminiApiKey: profile?.geminiApiKey ?? null, + openrouterApiKey: profile?.openrouterApiKey ?? null, }; const textareaRef = useRef(null); const [docSelectorOpen, setDocSelectorOpen] = useState(false); diff --git a/frontend/src/app/components/assistant/ModelToggle.tsx b/frontend/src/app/components/assistant/ModelToggle.tsx index cc10d518..f9ac42da 100644 --- a/frontend/src/app/components/assistant/ModelToggle.tsx +++ b/frontend/src/app/components/assistant/ModelToggle.tsx @@ -1,115 +1,85 @@ -"use client"; +'use client'; -import { useState } from "react"; -import { ChevronDown, Check, AlertCircle } from "lucide-react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { isModelAvailable } from "@/app/lib/modelAvailability"; +import { useState } from 'react'; +import { ChevronDown, Check, AlertCircle } from 'lucide-react'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { isModelAvailable } from '@/app/lib/modelAvailability'; export interface ModelOption { - id: string; - label: string; - group: "Anthropic" | "Google"; + id: string; + label: string; + group: 'Anthropic' | 'Google' | 'OpenRouter'; } export const MODELS: ModelOption[] = [ - { id: "claude-opus-4-7", label: "Claude Opus 4.7", group: "Anthropic" }, - { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", group: "Anthropic" }, - { id: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro", group: "Google" }, - { id: "gemini-3-flash-preview", label: "Gemini 3 Flash", group: "Google" }, + { id: 'claude-opus-4-7', label: 'Claude Opus 4.7', group: 'Anthropic' }, + { id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', group: 'Anthropic' }, + { id: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro', group: 'Google' }, + { id: 'gemini-3-flash-preview', label: 'Gemini 3 Flash', group: 'Google' }, + { id: 'openrouter/openai/gpt-5.3-chat', label: 'GPT-5.3', group: 'OpenRouter' }, + { id: 'openrouter/openai/gpt-4o-mini', label: 'GPT-4o Mini', group: 'OpenRouter' }, + { id: 'openrouter/anthropic/claude-sonnet-4.6', label: 'Claude Sonnet 4.6', group: 'OpenRouter' }, + { id: 'openrouter/anthropic/claude-opus-4.7', label: 'Claude Opus 4.7', group: 'OpenRouter' }, + { id: 'openrouter/x-ai/grok-4.3', label: 'Grok 4.3', group: 'OpenRouter' } ]; -export const DEFAULT_MODEL_ID = "gemini-3-flash-preview"; +export const DEFAULT_MODEL_ID = 'gemini-3-flash-preview'; export const ALLOWED_MODEL_IDS = new Set(MODELS.map((m) => m.id)); -const GROUP_ORDER: ModelOption["group"][] = ["Anthropic", "Google"]; +const GROUP_ORDER: ModelOption['group'][] = ['Anthropic', 'Google', 'OpenRouter']; interface Props { - value: string; - onChange: (id: string) => void; - apiKeys?: { - claudeApiKey: string | null; - geminiApiKey: string | null; - }; + value: string; + onChange: (id: string) => void; + apiKeys?: { + claudeApiKey: string | null; + geminiApiKey: string | null; + openrouterApiKey: string | null; + }; } export function ModelToggle({ value, onChange, apiKeys }: Props) { - const [isOpen, setIsOpen] = useState(false); - const selected = MODELS.find((m) => m.id === value); - const selectedLabel = selected?.label ?? "Model"; - const selectedAvailable = apiKeys - ? isModelAvailable(value, apiKeys) - : true; + const [isOpen, setIsOpen] = useState(false); + const selected = MODELS.find((m) => m.id === value); + const selectedLabel = selected?.label ?? 'Model'; + const selectedAvailable = apiKeys ? isModelAvailable(value, apiKeys) : true; - return ( - - - - - - {GROUP_ORDER.map((group, gi) => { - const items = MODELS.filter((m) => m.group === group); - if (items.length === 0) return null; - return ( -
- {gi > 0 && } - - {group} - - {items.map((m) => { - const available = apiKeys - ? isModelAvailable(m.id, apiKeys) - : true; - return ( - onChange(m.id)} - > - - {m.label} - - {!available && ( - - )} - {m.id === value && available && ( - - )} - - ); - })} -
- ); - })} -
-
- ); + return ( + + + + + + {GROUP_ORDER.map((group, gi) => { + const items = MODELS.filter((m) => m.group === group); + if (items.length === 0) return null; + return ( +
+ {gi > 0 && } + {group} + {items.map((m) => { + const available = apiKeys ? isModelAvailable(m.id, apiKeys) : true; + return ( + onChange(m.id)}> + {m.label} + {!available && } + {m.id === value && available && } + + ); + })} +
+ ); + })} +
+
+ ); } diff --git a/frontend/src/app/components/tabular/TRChatPanel.tsx b/frontend/src/app/components/tabular/TRChatPanel.tsx index 3522df3a..fa4e4755 100644 --- a/frontend/src/app/components/tabular/TRChatPanel.tsx +++ b/frontend/src/app/components/tabular/TRChatPanel.tsx @@ -453,7 +453,7 @@ function TRChatInput({ onCancel: () => void; model: string; onModelChange: (id: string) => void; - apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null }; + apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null; openrouterApiKey: string | null }; }) { const [value, setValue] = useState(""); const textareaRef = useRef(null); @@ -610,6 +610,7 @@ export function TRChatPanel({ const apiKeys = { claudeApiKey: profile?.claudeApiKey ?? null, geminiApiKey: profile?.geminiApiKey ?? null, + openrouterApiKey: profile?.openrouterApiKey ?? null, }; const currentModel = profile?.tabularModel ?? "gemini-3-flash-preview"; const [apiKeyModalProvider, setApiKeyModalProvider] = diff --git a/frontend/src/app/lib/modelAvailability.ts b/frontend/src/app/lib/modelAvailability.ts index 933a8c2d..285d8fb5 100644 --- a/frontend/src/app/lib/modelAvailability.ts +++ b/frontend/src/app/lib/modelAvailability.ts @@ -1,39 +1,49 @@ import { MODELS, type ModelOption } from "../components/assistant/ModelToggle"; -export type ModelProvider = "claude" | "gemini"; +export type ModelProvider = "claude" | "gemini" | "openrouter"; export function getModelProvider(modelId: string): ModelProvider | null { const model = MODELS.find((m) => m.id === modelId); if (!model) return null; - return model.group === "Anthropic" ? "claude" : "gemini"; + if (model.group === "Anthropic") return "claude"; + if (model.group === "Google") return "gemini"; + if (model.group === "OpenRouter") return "openrouter"; + return null; } export function isModelAvailable( modelId: string, - apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null }, + apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null; openrouterApiKey?: string | null }, ): boolean { const provider = getModelProvider(modelId); if (!provider) return false; - return provider === "claude" - ? !!apiKeys.claudeApiKey?.trim() - : !!apiKeys.geminiApiKey?.trim(); + if (provider === "claude") return !!apiKeys.claudeApiKey?.trim(); + if (provider === "gemini") return !!apiKeys.geminiApiKey?.trim(); + if (provider === "openrouter") return !!apiKeys.openrouterApiKey?.trim(); + return false; } export function isProviderAvailable( provider: ModelProvider, - apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null }, + apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null; openrouterApiKey?: string | null }, ): boolean { - return provider === "claude" - ? !!apiKeys.claudeApiKey?.trim() - : !!apiKeys.geminiApiKey?.trim(); + if (provider === "claude") return !!apiKeys.claudeApiKey?.trim(); + if (provider === "gemini") return !!apiKeys.geminiApiKey?.trim(); + if (provider === "openrouter") return !!apiKeys.openrouterApiKey?.trim(); + return false; } export function providerLabel(provider: ModelProvider): string { - return provider === "claude" ? "Anthropic (Claude)" : "Google (Gemini)"; + if (provider === "claude") return "Anthropic (Claude)"; + if (provider === "gemini") return "Google (Gemini)"; + if (provider === "openrouter") return "OpenRouter"; + return ""; } export function modelGroupToProvider( group: ModelOption["group"], ): ModelProvider { - return group === "Anthropic" ? "claude" : "gemini"; + if (group === "Anthropic") return "claude"; + if (group === "Google") return "gemini"; + return "openrouter"; } diff --git a/frontend/src/contexts/UserProfileContext.tsx b/frontend/src/contexts/UserProfileContext.tsx index 12061076..c9bc2a60 100644 --- a/frontend/src/contexts/UserProfileContext.tsx +++ b/frontend/src/contexts/UserProfileContext.tsx @@ -21,6 +21,7 @@ interface UserProfile { tabularModel: string; claudeApiKey: string | null; geminiApiKey: string | null; + openrouterApiKey: string | null; } interface UserProfileContextType { @@ -33,7 +34,7 @@ interface UserProfileContextType { value: string, ) => Promise; updateApiKey: ( - provider: "claude" | "gemini", + provider: "claude" | "gemini" | "openrouter", value: string | null, ) => Promise; reloadProfile: () => Promise; @@ -77,6 +78,7 @@ export function UserProfileProvider({ children }: { children: ReactNode }) { tabularModel: "gemini-3-flash-preview", claudeApiKey: null, geminiApiKey: null, + openrouterApiKey: null, }); return; } @@ -111,6 +113,7 @@ export function UserProfileProvider({ children }: { children: ReactNode }) { data.tabular_model || "gemini-3-flash-preview", claudeApiKey: data.claude_api_key ?? null, geminiApiKey: data.gemini_api_key ?? null, + openrouterApiKey: data.openrouter_api_key ?? null, }); // 2. Update database in background if needed @@ -148,6 +151,7 @@ export function UserProfileProvider({ children }: { children: ReactNode }) { tabularModel: "gemini-3-flash-preview", claudeApiKey: null, geminiApiKey: null, + openrouterApiKey: null, }); } finally { setLoading(false); @@ -245,14 +249,22 @@ export function UserProfileProvider({ children }: { children: ReactNode }) { const updateApiKey = useCallback( async ( - provider: "claude" | "gemini", + provider: "claude" | "gemini" | "openrouter", value: string | null, ): Promise => { if (!user) return false; - const dbField = - provider === "claude" ? "claude_api_key" : "gemini_api_key"; - const stateField = - provider === "claude" ? "claudeApiKey" : "geminiApiKey"; + const dbFieldMap: Record = { + claude: "claude_api_key", + gemini: "gemini_api_key", + openrouter: "openrouter_api_key", + }; + const stateFieldMap: Record = { + claude: "claudeApiKey", + gemini: "geminiApiKey", + openrouter: "openrouterApiKey", + }; + const dbField = dbFieldMap[provider]; + const stateField = stateFieldMap[provider]; const normalized = value?.trim() ? value.trim() : null; try { const { error } = await supabase From f4d8045a6822824c3389cadfd40548105ebf7831 Mon Sep 17 00:00:00 2001 From: Lef Date: Fri, 8 May 2026 11:38:23 +0200 Subject: [PATCH 06/26] chore(self-host): apply incremental migrations after the one-shot schema PR #11 (OpenRouter, commit 1cdbd33) introduced backend/migrations/ 001_add_openrouter_api_key.sql for existing deployments. Our init-db.sh only applied 000_one_shot_schema.sql, so a fresh stack got the column (it was added to the one-shot file too) but a docker compose down+up against an existing volume would skip it. Loop over all 0NN_*.sql files alphabetically after applying the one-shot. Each is idempotent (ADD COLUMN IF NOT EXISTS / CREATE OR REPLACE / etc.) so re-running on every container start is safe. --- docker/init-db.sh | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docker/init-db.sh b/docker/init-db.sh index 9ebff94c..d0db3a1e 100755 --- a/docker/init-db.sh +++ b/docker/init-db.sh @@ -27,4 +27,13 @@ done echo "init-db: applying /migrations/000_one_shot_schema.sql" psql -v ON_ERROR_STOP=1 -f /migrations/000_one_shot_schema.sql +# Apply incremental migrations (00[1-9]_*.sql) in order. Each is +# idempotent (CREATE OR REPLACE / ADD COLUMN IF NOT EXISTS / etc.) +# so re-running on every boot is safe. +for migration in /migrations/0[1-9][0-9]_*.sql /migrations/00[1-9]_*.sql; do + [ -f "$migration" ] || continue + echo "init-db: applying $migration" + psql -v ON_ERROR_STOP=1 -f "$migration" +done + echo "init-db: complete" From 284890db4b11184a5eff3542bc9140cc67c769aa Mon Sep 17 00:00:00 2001 From: Zacharie Laik Date: Fri, 8 May 2026 11:39:55 +0200 Subject: [PATCH 07/26] feat(mcp): add user-configurable Connectors with OAuth 2.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an MCP (Model Context Protocol) Connectors feature: users can configure external MCP servers — URL + headers, with optional OAuth 2.1 sign-in — and Mike will list/run their tools as part of the chat tool surface. Backend additions: - backend/src/lib/mcp/{client,oauth,servers,types}.ts — MCP client, OAuth flow handler, server registry, and shared types - backend/src/routes/mcpServers.ts + mcpOauth.ts — CRUD for user-owned MCP servers and the /mcp-oauth/* callback endpoints - backend/src/lib/chatTools.ts — dispatches tool calls to the configured MCP servers when the chat asks for them - New migrations 002_user_mcp_servers.sql and 003_user_mcp_servers_oauth.sql (renumbered locally — PR #11's 001_add_openrouter_api_key.sql already occupied 001) - New BACKEND_PUBLIC_URL env var for the OAuth callback URL Frontend additions: - /account/mcp settings tab for managing connectors - McpToggleButton in the chat composer to enable/disable per-message - Tool-call rendering in AssistantMessage showing MCP-server origin Cherry-picked from upstream PR #32: feat(mcp): add Connectors — URL+headers and OAuth 2.1 Author: @ZachLaik Source: https://github.com/willchen96/mike/pull/32 Squashed from upstream commits: 277339f6ae9f291c35ce35d99533c22591dde36d feat(mcp): add user-configurable MCP servers (URL + custom headers) fad06acac44b41eb36ba7b7c48e0e212e3c48d9c feat(mcp): rename to Connectors, prettier tool calls, observability 52749e6e67d1190959fb5072e92000247f368dfd feat(mcp): OAuth 2.1 sign-in for connectors Notes: - Migration files renamed from 001_*/002_* to 002_*/003_* to avoid collision with PR #11's 001_add_openrouter_api_key.sql. - backend/.env.example merge: kept both PR #21's DOWNLOAD_SIGNING_SECRET (already in our tree from cherry-pick) and this PR's BACKEND_PUBLIC_URL. --- backend/.env.example | 4 + backend/migrations/000_one_shot_schema.sql | 50 ++ backend/migrations/002_user_mcp_servers.sql | 49 ++ .../migrations/003_user_mcp_servers_oauth.sql | 16 + backend/package-lock.json | 657 +++++++++++++++ backend/package.json | 1 + backend/src/index.ts | 4 + backend/src/lib/chatTools.ts | 110 ++- backend/src/lib/mcp/client.ts | 126 +++ backend/src/lib/mcp/oauth.ts | 288 +++++++ backend/src/lib/mcp/servers.ts | 157 ++++ backend/src/lib/mcp/types.ts | 38 + backend/src/routes/chat.ts | 7 + backend/src/routes/mcpOauth.ts | 115 +++ backend/src/routes/mcpServers.ts | 332 ++++++++ backend/src/routes/projectChat.ts | 7 + frontend/src/app/(pages)/account/layout.tsx | 1 + frontend/src/app/(pages)/account/mcp/page.tsx | 761 ++++++++++++++++++ .../components/assistant/AssistantMessage.tsx | 102 ++- .../app/components/assistant/ChatInput.tsx | 2 + .../components/assistant/McpToggleButton.tsx | 173 ++++ frontend/src/app/components/shared/types.ts | 13 + frontend/src/app/hooks/useAssistantChat.ts | 16 + frontend/src/app/lib/mikeApi.ts | 78 ++ 24 files changed, 3101 insertions(+), 6 deletions(-) create mode 100644 backend/migrations/002_user_mcp_servers.sql create mode 100644 backend/migrations/003_user_mcp_servers_oauth.sql create mode 100644 backend/src/lib/mcp/client.ts create mode 100644 backend/src/lib/mcp/oauth.ts create mode 100644 backend/src/lib/mcp/servers.ts create mode 100644 backend/src/lib/mcp/types.ts create mode 100644 backend/src/routes/mcpOauth.ts create mode 100644 backend/src/routes/mcpServers.ts create mode 100644 frontend/src/app/(pages)/account/mcp/page.tsx create mode 100644 frontend/src/app/components/assistant/McpToggleButton.tsx diff --git a/backend/.env.example b/backend/.env.example index 2b163985..b85fa56a 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -5,6 +5,10 @@ FRONTEND_URL=http://localhost:3000 # Generate with: openssl rand -hex 32 # Use a dedicated secret distinct from SUPABASE_SECRET_KEY. DOWNLOAD_SIGNING_SECRET=replace-with-a-random-32-byte-hex-string + +# Externally-reachable backend URL. Used to build the OAuth callback URL for +# MCP connectors. Defaults to http://localhost:${PORT} when unset. +BACKEND_PUBLIC_URL=http://localhost:3001 SUPABASE_URL=https://your-project.supabase.co SUPABASE_SECRET_KEY=your-supabase-service-role-key diff --git a/backend/migrations/000_one_shot_schema.sql b/backend/migrations/000_one_shot_schema.sql index 07d0d0b6..fed448f6 100644 --- a/backend/migrations/000_one_shot_schema.sql +++ b/backend/migrations/000_one_shot_schema.sql @@ -273,6 +273,56 @@ begin end; $$; +-- --------------------------------------------------------------------------- +-- User MCP servers +-- --------------------------------------------------------------------------- + +create table if not exists public.user_mcp_servers ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + slug text not null, + name text not null, + url text not null, + headers jsonb not null default '{}'::jsonb, + enabled boolean not null default true, + last_error text, + auth_type text not null default 'headers' + check (auth_type in ('headers', 'oauth')), + oauth_metadata jsonb, + oauth_tokens jsonb, + oauth_code_verifier text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint user_mcp_servers_slug_format + check (slug ~ '^[a-z0-9_-]{1,24}$'), + unique (user_id, slug) +); + +create index if not exists idx_user_mcp_servers_user + on public.user_mcp_servers(user_id, enabled); + +alter table public.user_mcp_servers enable row level security; + +drop policy if exists "Users can view their own MCP servers" on public.user_mcp_servers; +create policy "Users can view their own MCP servers" + on public.user_mcp_servers for select + using (auth.uid() = user_id); + +drop policy if exists "Users can insert their own MCP servers" on public.user_mcp_servers; +create policy "Users can insert their own MCP servers" + on public.user_mcp_servers for insert + with check (auth.uid() = user_id); + +drop policy if exists "Users can update their own MCP servers" on public.user_mcp_servers; +create policy "Users can update their own MCP servers" + on public.user_mcp_servers for update + using (auth.uid() = user_id); + +drop policy if exists "Users can delete their own MCP servers" on public.user_mcp_servers; +create policy "Users can delete their own MCP servers" + on public.user_mcp_servers for delete + using (auth.uid() = user_id); + -- --------------------------------------------------------------------------- -- Tabular reviews -- --------------------------------------------------------------------------- diff --git a/backend/migrations/002_user_mcp_servers.sql b/backend/migrations/002_user_mcp_servers.sql new file mode 100644 index 00000000..bd592257 --- /dev/null +++ b/backend/migrations/002_user_mcp_servers.sql @@ -0,0 +1,49 @@ +-- User-configurable MCP (Model Context Protocol) servers. +-- Each row points the chat backend at a Streamable-HTTP MCP endpoint that +-- exposes additional tools. Tools discovered from these endpoints are merged +-- into the per-request tool list and routed under the `mcp____` prefix. +-- +-- Sensitive header values (e.g. Authorization tokens) live in the `headers` +-- jsonb column. Access is gated by Postgres RLS — owner-only — matching the +-- precedent set by user_profiles.claude_api_key / gemini_api_key. + +create table if not exists public.user_mcp_servers ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + slug text not null, + name text not null, + url text not null, + headers jsonb not null default '{}'::jsonb, + enabled boolean not null default true, + last_error text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint user_mcp_servers_slug_format + check (slug ~ '^[a-z0-9_-]{1,24}$'), + unique (user_id, slug) +); + +create index if not exists idx_user_mcp_servers_user + on public.user_mcp_servers(user_id, enabled); + +alter table public.user_mcp_servers enable row level security; + +drop policy if exists "Users can view their own MCP servers" on public.user_mcp_servers; +create policy "Users can view their own MCP servers" + on public.user_mcp_servers for select + using (auth.uid() = user_id); + +drop policy if exists "Users can insert their own MCP servers" on public.user_mcp_servers; +create policy "Users can insert their own MCP servers" + on public.user_mcp_servers for insert + with check (auth.uid() = user_id); + +drop policy if exists "Users can update their own MCP servers" on public.user_mcp_servers; +create policy "Users can update their own MCP servers" + on public.user_mcp_servers for update + using (auth.uid() = user_id); + +drop policy if exists "Users can delete their own MCP servers" on public.user_mcp_servers; +create policy "Users can delete their own MCP servers" + on public.user_mcp_servers for delete + using (auth.uid() = user_id); diff --git a/backend/migrations/003_user_mcp_servers_oauth.sql b/backend/migrations/003_user_mcp_servers_oauth.sql new file mode 100644 index 00000000..b53b75da --- /dev/null +++ b/backend/migrations/003_user_mcp_servers_oauth.sql @@ -0,0 +1,16 @@ +-- OAuth 2.1 support for user-configured MCP connectors. +-- Adds auth-mode toggle + storage for the discovered authorization-server +-- metadata, the dynamically-registered client info, the access/refresh +-- tokens, and the (transient) PKCE verifier between /oauth/start and +-- /oauth/callback. +-- +-- Tokens are stored at-rest in jsonb (RLS owner-only). Per-row encryption +-- is intentionally deferred to a separate hardening PR — this matches the +-- existing precedent for user_profiles.{claude,gemini}_api_key. + +alter table public.user_mcp_servers + add column if not exists auth_type text not null default 'headers' + check (auth_type in ('headers', 'oauth')), + add column if not exists oauth_metadata jsonb, + add column if not exists oauth_tokens jsonb, + add column if not exists oauth_code_verifier text; diff --git a/backend/package-lock.json b/backend/package-lock.json index 86f82382..90b08c42 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,6 +13,7 @@ "@aws-sdk/client-s3": "^3.787.0", "@aws-sdk/s3-request-presigner": "^3.787.0", "@google/genai": "^1.50.1", + "@modelcontextprotocol/sdk": "^1.29.0", "@supabase/supabase-js": "^2.49.4", "cors": "^2.8.5", "docx": "^9.5.0", @@ -1436,6 +1437,358 @@ } } }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/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==", + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@napi-rs/canvas": { "version": "0.1.97", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz", @@ -2781,6 +3134,45 @@ "node": ">= 14" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv/node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", @@ -3008,6 +3400,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -3305,6 +3711,27 @@ "node": ">= 0.6" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -3351,6 +3778,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz", + "integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -3369,6 +3814,22 @@ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "license": "Apache-2.0" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fast-xml-builder": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", @@ -3650,6 +4111,15 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.16", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.16.tgz", + "integrity": "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-to-text": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", @@ -3774,6 +4244,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3783,12 +4262,33 @@ "node": ">= 0.10" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -3811,6 +4311,18 @@ "node": ">=16" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -4135,6 +4647,15 @@ "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==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/option": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", @@ -4206,6 +4727,15 @@ "node": ">=0.10.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", @@ -4233,6 +4763,15 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/prettier": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", @@ -4383,6 +4922,15 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resend": { "version": "4.8.0", "resolved": "https://registry.npmjs.org/resend/-/resend-4.8.0.tgz", @@ -4414,6 +4962,55 @@ "node": ">= 4" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/router/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==", + "license": "MIT" + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -4525,6 +5122,27 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -4784,6 +5402,27 @@ "node": ">= 8" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "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==", + "license": "ISC" + }, "node_modules/ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", @@ -4840,6 +5479,24 @@ "engines": { "node": ">=0.4" } + }, + "node_modules/zod": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz", + "integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/backend/package.json b/backend/package.json index 50dfb585..354a389b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,6 +13,7 @@ "@aws-sdk/client-s3": "^3.787.0", "@aws-sdk/s3-request-presigner": "^3.787.0", "@google/genai": "^1.50.1", + "@modelcontextprotocol/sdk": "^1.29.0", "@supabase/supabase-js": "^2.49.4", "cors": "^2.8.5", "docx": "^9.5.0", diff --git a/backend/src/index.ts b/backend/src/index.ts index 0e99fffb..4f4d599a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -9,6 +9,8 @@ import { tabularRouter } from "./routes/tabular"; import { workflowsRouter } from "./routes/workflows"; import { userRouter } from "./routes/user"; import { downloadsRouter } from "./routes/downloads"; +import { mcpServersRouter } from "./routes/mcpServers"; +import { mcpOauthRouter } from "./routes/mcpOauth"; const app = express(); const PORT = process.env.PORT ?? 3001; @@ -31,6 +33,8 @@ app.use("/workflows", workflowsRouter); app.use("/user", userRouter); app.use("/users", userRouter); app.use("/download", downloadsRouter); +app.use("/user/mcp-servers", mcpServersRouter); +app.use("/mcp/oauth", mcpOauthRouter); app.get("/health", (_req, res) => res.json({ ok: true })); diff --git a/backend/src/lib/chatTools.ts b/backend/src/lib/chatTools.ts index c3ab2439..e1db1977 100644 --- a/backend/src/lib/chatTools.ts +++ b/backend/src/lib/chatTools.ts @@ -21,6 +21,8 @@ import { type LlmMessage, type OpenAIToolSchema, } from "./llm"; +import { findMcpServerForTool } from "./mcp/servers"; +import type { LoadedMcpServer } from "./mcp/types"; const STANDARD_FONT_DATA_URL = (() => { try { @@ -1484,6 +1486,32 @@ export type DocReplicatedResult = { }[]; }; +/** + * One MCP tool call worth of observability — surfaced to the chat UI so the + * user can see what was sent and what came back. `args` and `output` are + * already capped in size before this event is emitted/persisted. + */ +export type McpToolResultEvent = { + type: "mcp_tool_result"; + server: string; + tool: string; + ok: boolean; + args: string; + output: string; +}; + +/** + * Cap previewed args/output to keep `chat_messages.content` from bloating. + * The model still receives the full untruncated tool output — this only + * affects what is shown to and persisted for the user. + */ +const MCP_PREVIEW_MAX = 4096; + +function truncateForPreview(s: string): string { + if (s.length <= MCP_PREVIEW_MAX) return s; + return s.slice(0, MCP_PREVIEW_MAX) + "\n…(truncated)"; +} + export async function runToolCalls( toolCalls: ToolCall[], docStore: DocStore, @@ -1495,6 +1523,7 @@ export async function runToolCalls( docIndex?: DocIndex, turnEditState?: TurnEditState, projectId?: string | null, + mcpServers?: LoadedMcpServer[], ): Promise<{ toolResults: unknown[]; docsRead: { filename: string; document_id?: string }[]; @@ -1503,6 +1532,7 @@ export async function runToolCalls( docsReplicated: DocReplicatedResult[]; workflowsApplied: { workflow_id: string; title: string }[]; docsEdited: DocEditedResult[]; + mcpResults: McpToolResultEvent[]; }> { const toolResults: unknown[] = []; const docsRead: { filename: string; document_id?: string }[] = []; @@ -1515,6 +1545,7 @@ export async function runToolCalls( const docsReplicated: DocReplicatedResult[] = []; const workflowsApplied: { workflow_id: string; title: string }[] = []; const docsEdited: DocEditedResult[] = []; + const mcpResults: McpToolResultEvent[] = []; for (const tc of toolCalls) { let args: Record = {}; @@ -1524,6 +1555,46 @@ export async function runToolCalls( /* ignore */ } + if (tc.function.name.startsWith("mcp__") && mcpServers?.length) { + const match = findMcpServerForTool(tc.function.name, mcpServers); + if (!match) { + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content: `MCP tool '${tc.function.name}' not available (server may have been removed mid-request).`, + }); + continue; + } + const { server, originalName } = match; + write( + `data: ${JSON.stringify({ + type: "mcp_tool_call", + server: server.row.name, + tool: originalName, + })}\n\n`, + ); + const content = await server.client.callTool(originalName, args); + // The model gets the untruncated content; the user-facing preview + // is capped to keep chat_messages.content from bloating. + const ok = !content.startsWith(`MCP tool '${originalName}' `); + const preview: McpToolResultEvent = { + type: "mcp_tool_result", + server: server.row.name, + tool: originalName, + ok, + args: truncateForPreview(JSON.stringify(args)), + output: truncateForPreview(content), + }; + write(`data: ${JSON.stringify(preview)}\n\n`); + mcpResults.push(preview); + toolResults.push({ + role: "tool", + tool_call_id: tc.id, + content, + }); + continue; + } + if (tc.function.name === "read_document") { const rawDocId = args.doc_id as string; const docId = @@ -2206,6 +2277,7 @@ export async function runToolCalls( docsReplicated, workflowsApplied, docsEdited, + mcpResults, }; } @@ -2291,7 +2363,8 @@ type AssistantEvent = download_url: string; annotations: EditAnnotation[]; } - | { type: "content"; text: string }; + | { type: "content"; text: string } + | McpToolResultEvent; export async function runLLMStream(params: { apiMessages: unknown[]; @@ -2312,11 +2385,22 @@ export async function runLLMStream(params: { * generated docs still get persisted, but as standalone documents. */ projectId?: string | null; + /** + * MCP servers loaded for this user (already connected, with tool lists + * fetched). Their tools are merged into the per-request tool set under + * the `mcp____` prefix. Leave undefined when no MCP support is + * wired in or the user has none configured. + */ + mcpServers?: LoadedMcpServer[]; }): Promise<{ fullText: string; events: AssistantEvent[] }> { - const { apiMessages, docStore, docIndex, userId, db, write, extraTools, workflowStore, tabularStore, buildCitations, model, apiKeys, projectId } = params; - const activeTools = extraTools?.length - ? [...TOOLS, ...WORKFLOW_TOOLS, ...extraTools] - : [...TOOLS, ...WORKFLOW_TOOLS]; + const { apiMessages, docStore, docIndex, userId, db, write, extraTools, workflowStore, tabularStore, buildCitations, model, apiKeys, projectId, mcpServers } = params; + const mcpTools = (mcpServers ?? []).flatMap((s) => s.tools); + const activeTools = [ + ...TOOLS, + ...WORKFLOW_TOOLS, + ...(extraTools ?? []), + ...mcpTools, + ]; // Extract system prompt; pass remaining turns to the adapter as // plain user/assistant messages. @@ -2444,10 +2528,21 @@ export async function runLLMStream(params: { // and the first tool-specific event. onToolCallStart: (call) => { flushText(); + // For MCP tools, emit a friendly display name (server + tool) + // alongside the raw prefixed name. The UI renders display_name + // when present so users don't see `mcp____`. + let display_name: string | undefined; + if (call.name.startsWith("mcp__") && mcpServers?.length) { + const match = findMcpServerForTool(call.name, mcpServers); + if (match) { + display_name = `${match.server.row.name} · ${match.originalName}`; + } + } write( `data: ${JSON.stringify({ type: "tool_call_start", name: call.name, + ...(display_name ? { display_name } : {}), })}\n\n`, ); }, @@ -2472,6 +2567,7 @@ export async function runLLMStream(params: { docsReplicated, workflowsApplied, docsEdited, + mcpResults, } = await runToolCalls( toolCalls, docStore, @@ -2483,6 +2579,7 @@ export async function runLLMStream(params: { docIndex, turnEditState, projectId, + mcpServers, ); for (const r of docsRead) { events.push({ @@ -2535,6 +2632,9 @@ export async function runLLMStream(params: { annotations: e.annotations, }); } + for (const r of mcpResults) { + events.push(r); + } // Index alignment would break if any tool branch skips its // push (unhandled tool name, disabled store, guard failure). diff --git a/backend/src/lib/mcp/client.ts b/backend/src/lib/mcp/client.ts new file mode 100644 index 00000000..897d606d --- /dev/null +++ b/backend/src/lib/mcp/client.ts @@ -0,0 +1,126 @@ +// Thin wrapper around the MCP TypeScript SDK's Streamable-HTTP client. +// +// Mike opens one client per (user, MCP server) per chat request. Connections +// are short-lived: we initialize, list tools, run any tools the model calls, +// then close in a `finally` on the request handler. There is no connection +// pool — each chat request pays an `initialize` round-trip per enabled +// server. This keeps the design stateless and avoids needing a worker. + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; + +const CONNECT_TIMEOUT_MS = 10_000; +const CALL_TIMEOUT_MS = 60_000; + +export class McpHttpClient { + private client: Client | null = null; + private transport: StreamableHTTPClientTransport | null = null; + + constructor( + private readonly url: string, + private readonly headers: Record, + private readonly authProvider?: OAuthClientProvider, + ) {} + + async connect(): Promise { + this.transport = new StreamableHTTPClientTransport(new URL(this.url), { + requestInit: { + headers: this.headers, + }, + ...(this.authProvider ? { authProvider: this.authProvider } : {}), + }); + this.client = new Client( + { name: "mike", version: "1.0.0" }, + { capabilities: {} }, + ); + await withTimeout( + this.client.connect(this.transport), + CONNECT_TIMEOUT_MS, + "MCP connect", + ); + } + + async listTools(): Promise { + if (!this.client) throw new Error("MCP client not connected"); + const result = await withTimeout( + this.client.listTools(), + CONNECT_TIMEOUT_MS, + "MCP listTools", + ); + return result.tools as Tool[]; + } + + /** + * Calls a tool and returns its text content joined by blank lines. + * Errors (transport failures, MCP `isError`) are turned into a text + * response so the model can surface them rather than crashing the chat. + */ + async callTool( + name: string, + args: Record, + ): Promise { + if (!this.client) return "MCP client not connected"; + try { + const result = await withTimeout( + this.client.callTool({ name, arguments: args }), + CALL_TIMEOUT_MS, + `MCP callTool(${name})`, + ); + const blocks = (result.content ?? []) as Array<{ + type?: string; + text?: string; + }>; + const text = blocks + .filter((b) => b?.type === "text" && typeof b.text === "string") + .map((b) => b.text) + .join("\n\n"); + if (result.isError) { + return `MCP tool '${name}' returned error: ${text || "(no detail)"}`; + } + return text || "(tool returned no text content)"; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return `MCP tool '${name}' failed: ${msg}`; + } + } + + async close(): Promise { + try { + await this.client?.close(); + } catch { + /* ignore */ + } + try { + await this.transport?.close(); + } catch { + /* ignore */ + } + this.client = null; + this.transport = null; + } +} + +function withTimeout( + p: Promise, + ms: number, + label: string, +): Promise { + return new Promise((resolve, reject) => { + const t = setTimeout( + () => reject(new Error(`${label} timed out after ${ms}ms`)), + ms, + ); + p.then( + (v) => { + clearTimeout(t); + resolve(v); + }, + (e) => { + clearTimeout(t); + reject(e); + }, + ); + }); +} diff --git a/backend/src/lib/mcp/oauth.ts b/backend/src/lib/mcp/oauth.ts new file mode 100644 index 00000000..61e41a31 --- /dev/null +++ b/backend/src/lib/mcp/oauth.ts @@ -0,0 +1,288 @@ +// OAuth 2.1 client glue for MCP connectors. +// +// The MCP SDK does almost all of the heavy lifting via its `auth()` helper — +// RFC 9728 discovery, dynamic client registration (RFC 7591), PKCE (S256), +// authorization-code exchange, and token refresh. We only have to plug in an +// `OAuthClientProvider` whose getters/setters read and write the row's +// oauth_* columns, plus a thin HMAC-signed state token so the callback can +// look the row up without a server-side session. + +import crypto from "crypto"; +import type { + OAuthClientInformationFull, + OAuthClientInformationMixed, + OAuthClientMetadata, + OAuthTokens, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { createServerSupabase } from "../supabase"; + +const STATE_TTL_SECONDS = 5 * 60; // 5 minutes +const CLIENT_NAME = "Mike"; +const CLIENT_URI = "https://github.com/willchen96/mike"; + +function backendPublicUrl(): string { + const url = process.env.BACKEND_PUBLIC_URL?.trim(); + if (url) return url.replace(/\/+$/, ""); + const port = process.env.PORT ?? "3001"; + return `http://localhost:${port}`; +} + +export function oauthCallbackUrl(): string { + return `${backendPublicUrl()}/mcp/oauth/callback`; +} + +// --------------------------------------------------------------------------- +// State token (CSRF + flow continuation across the popup hop). +// HMAC-signed, no DB round-trip; encodes { user_id, server_id, exp }. +// Reuses DOWNLOAD_SIGNING_SECRET — the same secret already gates download +// tokens and would already have to be rotated on compromise. +// --------------------------------------------------------------------------- + +function getSecret(): string { + return ( + process.env.DOWNLOAD_SIGNING_SECRET ?? + process.env.SUPABASE_SECRET_KEY ?? + "dev-secret" + ); +} + +function b64url(buf: Buffer): string { + return buf + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +function b64urlDecode(s: string): Buffer { + let t = s.replace(/-/g, "+").replace(/_/g, "/"); + while (t.length % 4) t += "="; + return Buffer.from(t, "base64"); +} + +export function signOAuthState(payload: { + user_id: string; + server_id: string; +}): string { + const body = { + ...payload, + exp: Math.floor(Date.now() / 1000) + STATE_TTL_SECONDS, + }; + const enc = b64url(Buffer.from(JSON.stringify(body), "utf8")); + const sig = crypto.createHmac("sha256", getSecret()).update(enc).digest(); + return `${enc}.${b64url(sig)}`; +} + +export function verifyOAuthState( + token: string, +): { user_id: string; server_id: string } | null { + const parts = token.split("."); + if (parts.length !== 2) return null; + const [enc, sigEnc] = parts; + const expected = crypto + .createHmac("sha256", getSecret()) + .update(enc) + .digest(); + const expectedEnc = b64url(expected); + if (sigEnc.length !== expectedEnc.length) return null; + if ( + !crypto.timingSafeEqual(Buffer.from(sigEnc), Buffer.from(expectedEnc)) + ) { + return null; + } + try { + const body = JSON.parse(b64urlDecode(enc).toString("utf8")) as { + user_id: string; + server_id: string; + exp: number; + }; + if (!body.user_id || !body.server_id) return null; + if (Math.floor(Date.now() / 1000) > body.exp) return null; + return { user_id: body.user_id, server_id: body.server_id }; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// OAuthClientProvider implementation backed by user_mcp_servers row. +// +// Two modes: +// - "initiate": from POST /oauth/start. The SDK's auth() will discover, DCR +// if needed, generate PKCE, and call redirectToAuthorization() with the +// authorize URL. We capture that URL into `lastAuthorizeUrl` and the +// route returns it to the frontend popup. +// - "use": from a chat request. If the SDK needs a fresh authorization +// (refresh failed or never happened), redirectToAuthorization() throws +// so the caller can mark the row reauth_required and surface to the UI. +// --------------------------------------------------------------------------- + +export type OAuthProviderMode = "initiate" | "use"; + +export class DbOAuthProvider implements OAuthClientProvider { + private metadataCache: Record | null = null; + private tokensCache: OAuthTokens | null = null; + private codeVerifierCache: string | null = null; + private mode: OAuthProviderMode; + private signedState: string; + + /** Set by redirectToAuthorization() in `initiate` mode. */ + public lastAuthorizeUrl: URL | null = null; + + constructor( + private readonly db: ReturnType, + private readonly serverId: string, + private readonly userId: string, + mode: OAuthProviderMode, + ) { + this.mode = mode; + this.signedState = signOAuthState({ + user_id: userId, + server_id: serverId, + }); + } + + get redirectUrl(): string { + return oauthCallbackUrl(); + } + + get clientMetadata(): OAuthClientMetadata { + return { + client_name: CLIENT_NAME, + client_uri: CLIENT_URI, + redirect_uris: [oauthCallbackUrl()], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + // Public client — no client secret stored, PKCE-protected. + token_endpoint_auth_method: "none", + }; + } + + state(): string { + return this.signedState; + } + + async clientInformation(): Promise< + OAuthClientInformationMixed | undefined + > { + await this.loadMetadata(); + const ci = (this.metadataCache as { client?: OAuthClientInformationFull } | null) + ?.client; + return ci ?? undefined; + } + + async saveClientInformation( + info: OAuthClientInformationMixed, + ): Promise { + await this.loadMetadata(); + const next = { ...(this.metadataCache ?? {}), client: info }; + this.metadataCache = next; + await this.db + .from("user_mcp_servers") + .update({ oauth_metadata: next }) + .eq("id", this.serverId); + } + + async tokens(): Promise { + if (this.tokensCache) return this.tokensCache; + const { data } = await this.db + .from("user_mcp_servers") + .select("oauth_tokens") + .eq("id", this.serverId) + .single(); + const t = (data?.oauth_tokens ?? null) as OAuthTokens | null; + this.tokensCache = t; + return t ?? undefined; + } + + async saveTokens(tokens: OAuthTokens): Promise { + this.tokensCache = tokens; + // The PKCE verifier is one-shot; no point persisting after token + // exchange. Clearing also keeps the row tidy on subsequent refreshes. + this.codeVerifierCache = null; + await this.db + .from("user_mcp_servers") + .update({ + oauth_tokens: tokens, + oauth_code_verifier: null, + last_error: null, + }) + .eq("id", this.serverId); + } + + async redirectToAuthorization(authorizationUrl: URL): Promise { + if (this.mode === "initiate") { + this.lastAuthorizeUrl = authorizationUrl; + return; + } + // In "use" mode (mid-chat), we have nowhere to redirect the user; the + // caller will catch this and mark the row reauth_required so the UI + // prompts the user to re-sign in from settings. + throw new ReauthRequiredError( + `Connector requires re-sign-in (would redirect to ${authorizationUrl.origin})`, + ); + } + + async saveCodeVerifier(codeVerifier: string): Promise { + this.codeVerifierCache = codeVerifier; + await this.db + .from("user_mcp_servers") + .update({ oauth_code_verifier: codeVerifier }) + .eq("id", this.serverId); + } + + async codeVerifier(): Promise { + if (this.codeVerifierCache) return this.codeVerifierCache; + const { data } = await this.db + .from("user_mcp_servers") + .select("oauth_code_verifier") + .eq("id", this.serverId) + .single(); + if (!data?.oauth_code_verifier) { + throw new Error("Missing PKCE verifier — start the flow again"); + } + this.codeVerifierCache = data.oauth_code_verifier; + return data.oauth_code_verifier; + } + + async invalidateCredentials( + scope: "all" | "client" | "tokens" | "verifier" | "discovery", + ): Promise { + const update: Record = {}; + if (scope === "all" || scope === "tokens") + update.oauth_tokens = null; + if (scope === "all" || scope === "client" || scope === "discovery") + update.oauth_metadata = null; + if (scope === "all" || scope === "verifier") + update.oauth_code_verifier = null; + if (Object.keys(update).length === 0) return; + await this.db + .from("user_mcp_servers") + .update(update) + .eq("id", this.serverId); + if (update.oauth_tokens === null) this.tokensCache = null; + if (update.oauth_metadata === null) this.metadataCache = null; + if (update.oauth_code_verifier === null) this.codeVerifierCache = null; + } + + private async loadMetadata(): Promise { + if (this.metadataCache !== null) return; + const { data } = await this.db + .from("user_mcp_servers") + .select("oauth_metadata") + .eq("id", this.serverId) + .single(); + this.metadataCache = (data?.oauth_metadata ?? {}) as Record< + string, + unknown + >; + } +} + +export class ReauthRequiredError extends Error { + constructor(message?: string) { + super(message ?? "Re-authorization required"); + this.name = "ReauthRequiredError"; + } +} diff --git a/backend/src/lib/mcp/servers.ts b/backend/src/lib/mcp/servers.ts new file mode 100644 index 00000000..5b36a0d4 --- /dev/null +++ b/backend/src/lib/mcp/servers.ts @@ -0,0 +1,157 @@ +// Per-request MCP server loader. +// +// Called once at the top of each chat request. Reads the user's enabled MCP +// servers from Postgres, opens a Streamable-HTTP client to each in parallel, +// fetches its tool list, and converts each tool to the OpenAI-style schema +// Mike's LLM adapter speaks. Tool names are prefixed with `mcp____` so +// the dispatcher in chatTools can route calls back to the right server. + +import { createHash } from "crypto"; +import type { OpenAIToolSchema } from "../llm/types"; +import type { createServerSupabase } from "../supabase"; +import { McpHttpClient } from "./client"; +import { DbOAuthProvider, ReauthRequiredError } from "./oauth"; +import type { LoadedMcpServer, McpServerRow } from "./types"; + +const TOOL_NAME_MAX = 64; +const TOOL_PREFIX = "mcp__"; + +export async function loadEnabledMcpServersForUser( + userId: string, + db: ReturnType, +): Promise { + const { data, error } = await db + .from("user_mcp_servers") + .select("*") + .eq("user_id", userId) + .eq("enabled", true); + if (error || !data || data.length === 0) return []; + + const rows = data as McpServerRow[]; + const results = await Promise.allSettled( + rows.map((row) => loadOne(row, userId, db)), + ); + + const out: LoadedMcpServer[] = []; + for (let i = 0; i < results.length; i++) { + const r = results[i]; + const row = rows[i]; + if (r.status === "fulfilled" && r.value) { + out.push(r.value); + // Clear stale error on success. + if (row.last_error) { + await db + .from("user_mcp_servers") + .update({ last_error: null }) + .eq("id", row.id); + } + } else { + const reason = r.status === "rejected" ? r.reason : "unknown error"; + const isReauth = reason instanceof ReauthRequiredError; + const err = + reason instanceof Error ? reason.message : String(reason); + console.warn( + `[mcp] failed to load server ${row.slug} (${row.url}): ${err}`, + ); + await db + .from("user_mcp_servers") + .update({ + last_error: isReauth + ? "reauth_required" + : err.slice(0, 1000), + }) + .eq("id", row.id); + } + } + return out; +} + +async function loadOne( + row: McpServerRow, + userId: string, + db: ReturnType, +): Promise { + let authProvider: DbOAuthProvider | undefined; + if (row.auth_type === "oauth") { + // No tokens yet → don't even try to connect; the UI will surface a + // "Sign in" affordance and the user kicks off /oauth/start. + if (!row.oauth_tokens) { + throw new ReauthRequiredError( + "Connector not yet authorized — sign in from settings", + ); + } + authProvider = new DbOAuthProvider(db, row.id, userId, "use"); + } + const client = new McpHttpClient( + row.url, + row.headers ?? {}, + authProvider, + ); + await client.connect(); + const mcpTools = await client.listTools(); + + const tools: OpenAIToolSchema[] = []; + const toolNameMap = new Map(); + for (const t of mcpTools) { + const prefixed = prefixedToolName(row.slug, t.name); + toolNameMap.set(prefixed, t.name); + tools.push({ + type: "function", + function: { + name: prefixed, + description: `[${row.name}] ${t.description ?? ""}`.trim(), + parameters: (t.inputSchema as Record) ?? { + type: "object", + properties: {}, + }, + }, + }); + } + + return { + row, + tools, + toolNameMap, + client: { + callTool: (name, args) => client.callTool(name, args), + close: () => client.close(), + }, + }; +} + +/** + * `mcp____`, capped at 64 chars (Anthropic's limit). + * If the natural name is too long, the toolName tail is replaced with a + * 12-hex-char hash so the prefix stays intact and the dispatcher can route. + */ +export function prefixedToolName(slug: string, toolName: string): string { + const natural = `${TOOL_PREFIX}${slug}__${toolName}`; + if (natural.length <= TOOL_NAME_MAX) return natural; + const hash = createHash("sha256") + .update(toolName) + .digest("hex") + .slice(0, 12); + const head = `${TOOL_PREFIX}${slug}__`; + const room = TOOL_NAME_MAX - head.length - 1 /* underscore */ - hash.length; + const truncated = toolName.slice(0, Math.max(0, room)); + return `${head}${truncated}_${hash}`.slice(0, TOOL_NAME_MAX); +} + +export async function closeMcpServers(servers: LoadedMcpServer[]): Promise { + await Promise.allSettled(servers.map((s) => s.client.close())); +} + +/** + * Look up which loaded server owns a prefixed tool name. Used by the chat + * tool dispatcher. + */ +export function findMcpServerForTool( + prefixedName: string, + servers: LoadedMcpServer[], +): { server: LoadedMcpServer; originalName: string } | null { + for (const s of servers) { + const original = s.toolNameMap.get(prefixedName); + if (original) return { server: s, originalName: original }; + } + return null; +} diff --git a/backend/src/lib/mcp/types.ts b/backend/src/lib/mcp/types.ts new file mode 100644 index 00000000..c347ddfa --- /dev/null +++ b/backend/src/lib/mcp/types.ts @@ -0,0 +1,38 @@ +import type { OpenAIToolSchema } from "../llm/types"; + +export type McpServerRow = { + id: string; + user_id: string; + slug: string; + name: string; + url: string; + headers: Record; + enabled: boolean; + last_error: string | null; + auth_type: "headers" | "oauth"; + oauth_metadata: Record | null; + oauth_tokens: Record | null; + oauth_code_verifier: string | null; +}; + +/** + * One MCP server, opened for the duration of a single chat request. + * + * `tools` are already prefixed (`mcp____`) and ready to merge + * into the per-request tool list. The original tool name is preserved in + * `toolNameMap` so the dispatcher can call back into the MCP server with the + * unprefixed name. + */ +export type LoadedMcpServer = { + row: McpServerRow; + tools: OpenAIToolSchema[]; + /** prefixed tool name → original MCP tool name */ + toolNameMap: Map; + client: { + callTool: ( + toolName: string, + args: Record, + ) => Promise; + close: () => Promise; + }; +}; diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index ab9d00bd..b576fb84 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -13,6 +13,10 @@ import { import { completeText } from "../lib/llm"; import { getUserApiKeys, getUserModelSettings } from "../lib/userSettings"; import { checkProjectAccess } from "../lib/access"; +import { + closeMcpServers, + loadEnabledMcpServersForUser, +} from "../lib/mcp/servers"; export const chatRouter = Router(); @@ -443,6 +447,7 @@ chatRouter.post("/", requireAuth, async (req, res) => { const write = (line: string) => res.write(line); const apiKeys = await getUserApiKeys(userId, db); + const mcpServers = await loadEnabledMcpServersForUser(userId, db); try { write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`); @@ -458,6 +463,7 @@ chatRouter.post("/", requireAuth, async (req, res) => { model, apiKeys, projectId: project_id ?? null, + mcpServers, }); console.log("[chat/stream] LLM stream finished", { @@ -490,6 +496,7 @@ chatRouter.post("/", requireAuth, async (req, res) => { /* ignore */ } } finally { + await closeMcpServers(mcpServers); res.end(); } }); diff --git a/backend/src/routes/mcpOauth.ts b/backend/src/routes/mcpOauth.ts new file mode 100644 index 00000000..cec60239 --- /dev/null +++ b/backend/src/routes/mcpOauth.ts @@ -0,0 +1,115 @@ +// Unauthenticated OAuth callback for MCP connectors. +// +// The user is bounced here from the connector's authorization server with +// `?code=...&state=...` after consenting in the popup. We don't have an +// auth header here (different origin / popup context), so the route is +// public — the HMAC-signed `state` token carries the user_id + server_id +// we need to find the row. + +import { Router } from "express"; +import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; +import { createServerSupabase } from "../lib/supabase"; +import { DbOAuthProvider, verifyOAuthState } from "../lib/mcp/oauth"; + +export const mcpOauthRouter = Router(); + +const RESULT_HTML = (success: boolean, message?: string) => ` + + + + ${success ? "Connector connected" : "Connector failed"} + + + +

${ + success ? "✓ Connector connected" : "✗ Connection failed" + }

+

${ + success + ? "You can close this window and return to Mike." + : (message ?? "Something went wrong. Close this window and try again.") + }

+ + +`; + +mcpOauthRouter.get("/callback", async (req, res) => { + const code = (req.query.code as string | undefined)?.trim(); + const state = (req.query.state as string | undefined)?.trim(); + const error = (req.query.error as string | undefined)?.trim(); + + if (error) { + return void res + .status(400) + .type("html") + .send(RESULT_HTML(false, `Authorization server returned: ${error}`)); + } + if (!code || !state) { + return void res + .status(400) + .type("html") + .send(RESULT_HTML(false, "Missing code or state.")); + } + + const decoded = verifyOAuthState(state); + if (!decoded) { + return void res + .status(400) + .type("html") + .send(RESULT_HTML(false, "Invalid or expired state — restart sign-in.")); + } + + const db = createServerSupabase(); + const { data: row, error: fetchErr } = await db + .from("user_mcp_servers") + .select("id, user_id, url, auth_type") + .eq("id", decoded.server_id) + .eq("user_id", decoded.user_id) + .single(); + if (fetchErr || !row) { + return void res + .status(404) + .type("html") + .send(RESULT_HTML(false, "Connector not found.")); + } + if (row.auth_type !== "oauth") { + return void res + .status(400) + .type("html") + .send(RESULT_HTML(false, "Connector is not configured for OAuth.")); + } + + const provider = new DbOAuthProvider(db, row.id, row.user_id, "initiate"); + try { + const result = await auth(provider, { + serverUrl: row.url, + authorizationCode: code, + }); + if (result !== "AUTHORIZED") { + throw new Error(`Token exchange returned ${result}`); + } + // saveTokens() ran inside auth() and cleared last_error. + return void res.type("html").send(RESULT_HTML(true)); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await db + .from("user_mcp_servers") + .update({ last_error: message.slice(0, 1000) }) + .eq("id", row.id); + return void res + .status(500) + .type("html") + .send(RESULT_HTML(false, message)); + } +}); diff --git a/backend/src/routes/mcpServers.ts b/backend/src/routes/mcpServers.ts new file mode 100644 index 00000000..6ab9fdbb --- /dev/null +++ b/backend/src/routes/mcpServers.ts @@ -0,0 +1,332 @@ +// CRUD for user-configurable MCP (Model Context Protocol) servers. +// +// Mounted at `/user/mcp-servers`. The backend uses Supabase's service role +// (bypassing RLS), so every handler MUST filter by `user_id = userId`. + +import { Router } from "express"; +import { auth as runOAuth } from "@modelcontextprotocol/sdk/client/auth.js"; +import { requireAuth } from "../middleware/auth"; +import { createServerSupabase } from "../lib/supabase"; +import { McpHttpClient } from "../lib/mcp/client"; +import { DbOAuthProvider } from "../lib/mcp/oauth"; + +export const mcpServersRouter = Router(); + +const SLUG_RE = /^[a-z0-9_-]{1,24}$/; +const NAME_MAX = 80; +const URL_MAX = 500; +const HEADER_NAME_RE = /^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/; +const MAX_HEADERS = 20; +const MAX_HEADER_VALUE_LEN = 4096; + +type Body = { + name?: unknown; + slug?: unknown; + url?: unknown; + headers?: unknown; + enabled?: unknown; + auth_type?: unknown; +}; + +function deriveSlug(name: string): string { + const base = name + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^[-_]+|[-_]+$/g, "") + .slice(0, 24); + return base || "mcp"; +} + +function validateUrl(raw: string): { ok: true } | { ok: false; error: string } { + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return { ok: false, error: "url is not a valid URL" }; + } + if (parsed.protocol === "https:") return { ok: true }; + if ( + parsed.protocol === "http:" && + (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") + ) { + return { ok: true }; + } + return { ok: false, error: "url must use https (or http://localhost)" }; +} + +function validateHeaders( + raw: unknown, +): { ok: true; value: Record } | { ok: false; error: string } { + if (raw === undefined || raw === null) return { ok: true, value: {} }; + if (typeof raw !== "object" || Array.isArray(raw)) { + return { ok: false, error: "headers must be an object of string→string" }; + } + const entries = Object.entries(raw as Record); + if (entries.length > MAX_HEADERS) { + return { ok: false, error: `headers may not have more than ${MAX_HEADERS} entries` }; + } + const out: Record = {}; + for (const [k, v] of entries) { + if (!HEADER_NAME_RE.test(k)) { + return { ok: false, error: `invalid header name: ${k}` }; + } + if (typeof v !== "string" || v.length > MAX_HEADER_VALUE_LEN) { + return { ok: false, error: `header '${k}' value must be a string of ≤${MAX_HEADER_VALUE_LEN} chars` }; + } + out[k] = v; + } + return { ok: true, value: out }; +} + +function publicShape>(row: T) { + const { + headers, + oauth_metadata: _md, + oauth_tokens: tokens, + oauth_code_verifier: _cv, + ...rest + } = row as T & { + headers?: Record; + oauth_metadata?: unknown; + oauth_tokens?: unknown; + oauth_code_verifier?: unknown; + }; + return { + ...rest, + header_keys: headers ? Object.keys(headers) : [], + // Boolean only — never round-trip the actual access token to the + // browser, even to the row's owner. + oauth_authorized: !!tokens, + }; +} + +// GET /user/mcp-servers — list (header values redacted, only keys returned) +mcpServersRouter.get("/", requireAuth, async (_req, res) => { + const userId = res.locals.userId as string; + const db = createServerSupabase(); + const { data, error } = await db + .from("user_mcp_servers") + .select("id, slug, name, url, headers, enabled, last_error, auth_type, oauth_tokens, created_at, updated_at") + .eq("user_id", userId) + .order("created_at", { ascending: true }); + if (error) return void res.status(500).json({ detail: error.message }); + res.json((data ?? []).map(publicShape)); +}); + +// POST /user/mcp-servers — create +mcpServersRouter.post("/", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const body = (req.body ?? {}) as Body; + + const name = typeof body.name === "string" ? body.name.trim() : ""; + if (!name || name.length > NAME_MAX) { + return void res.status(400).json({ detail: `name is required (≤${NAME_MAX} chars)` }); + } + const url = typeof body.url === "string" ? body.url.trim() : ""; + if (!url || url.length > URL_MAX) { + return void res.status(400).json({ detail: `url is required (≤${URL_MAX} chars)` }); + } + const urlOk = validateUrl(url); + if (!urlOk.ok) return void res.status(400).json({ detail: urlOk.error }); + + let slug = typeof body.slug === "string" && body.slug.trim() + ? body.slug.trim().toLowerCase() + : deriveSlug(name); + if (!SLUG_RE.test(slug)) { + return void res.status(400).json({ detail: "slug must match /^[a-z0-9_-]{1,24}$/" }); + } + + const headersOk = validateHeaders(body.headers); + if (!headersOk.ok) return void res.status(400).json({ detail: headersOk.error }); + + const auth_type = + body.auth_type === "oauth" ? "oauth" : "headers"; + + const enabled = body.enabled === false ? false : true; + + const db = createServerSupabase(); + const { data, error } = await db + .from("user_mcp_servers") + .insert({ + user_id: userId, + slug, + name, + url, + headers: auth_type === "oauth" ? {} : headersOk.value, + enabled, + auth_type, + }) + .select("id, slug, name, url, headers, enabled, last_error, auth_type, oauth_tokens, created_at, updated_at") + .single(); + if (error) { + const status = error.code === "23505" ? 409 : 500; + return void res.status(status).json({ detail: error.message }); + } + res.json(publicShape(data)); +}); + +// PATCH /user/mcp-servers/:id — update name/url/headers/enabled +mcpServersRouter.patch("/:id", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const { id } = req.params; + const body = (req.body ?? {}) as Body; + const update: Record = { updated_at: new Date().toISOString() }; + + if (body.name !== undefined) { + const name = typeof body.name === "string" ? body.name.trim() : ""; + if (!name || name.length > NAME_MAX) { + return void res.status(400).json({ detail: `name must be 1–${NAME_MAX} chars` }); + } + update.name = name; + } + if (body.url !== undefined) { + const url = typeof body.url === "string" ? body.url.trim() : ""; + if (!url) return void res.status(400).json({ detail: "url is required" }); + const urlOk = validateUrl(url); + if (!urlOk.ok) return void res.status(400).json({ detail: urlOk.error }); + update.url = url; + } + if (body.headers !== undefined) { + const headersOk = validateHeaders(body.headers); + if (!headersOk.ok) return void res.status(400).json({ detail: headersOk.error }); + update.headers = headersOk.value; + } + if (body.enabled !== undefined) { + update.enabled = body.enabled === true; + } + + const db = createServerSupabase(); + const { data, error } = await db + .from("user_mcp_servers") + .update(update) + .eq("id", id) + .eq("user_id", userId) + .select("id, slug, name, url, headers, enabled, last_error, auth_type, oauth_tokens, created_at, updated_at") + .single(); + if (error || !data) { + return void res.status(404).json({ detail: error?.message ?? "Not found" }); + } + res.json(publicShape(data)); +}); + +// DELETE /user/mcp-servers/:id +mcpServersRouter.delete("/:id", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const { id } = req.params; + const db = createServerSupabase(); + const { error } = await db + .from("user_mcp_servers") + .delete() + .eq("id", id) + .eq("user_id", userId); + if (error) return void res.status(500).json({ detail: error.message }); + res.status(204).send(); +}); + +// POST /user/mcp-servers/:id/test — connect + list_tools, return summary +mcpServersRouter.post("/:id/test", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const { id } = req.params; + const db = createServerSupabase(); + const { data: row, error } = await db + .from("user_mcp_servers") + .select("url, headers, auth_type, oauth_tokens") + .eq("id", id) + .eq("user_id", userId) + .single(); + if (error || !row) { + return void res.status(404).json({ detail: "Not found" }); + } + + if (row.auth_type === "oauth" && !row.oauth_tokens) { + return void res.status(200).json({ + ok: false, + error: "Connector is configured for OAuth but not yet signed in.", + }); + } + + const provider = + row.auth_type === "oauth" + ? new DbOAuthProvider(db, id, userId, "use") + : undefined; + const client = new McpHttpClient( + row.url, + (row.headers ?? {}) as Record, + provider, + ); + try { + await client.connect(); + const tools = await client.listTools(); + await db + .from("user_mcp_servers") + .update({ last_error: null }) + .eq("id", id); + res.json({ + ok: true, + tool_count: tools.length, + tools: tools.map((t) => ({ name: t.name, description: t.description ?? "" })), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await db + .from("user_mcp_servers") + .update({ last_error: message.slice(0, 1000) }) + .eq("id", id); + res.status(200).json({ ok: false, error: message }); + } finally { + await client.close(); + } +}); + +// POST /user/mcp-servers/:id/oauth/start — discover + DCR + build authorize URL +// +// Returns { authorize_url } so the frontend can open it in a popup. The user +// completes consent at the connector's auth server and is redirected back to +// /mcp/oauth/callback (mounted under mcpOauthRouter), which exchanges the +// code and stores tokens. +mcpServersRouter.post("/:id/oauth/start", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const { id } = req.params; + const db = createServerSupabase(); + const { data: row, error } = await db + .from("user_mcp_servers") + .select("id, user_id, url, auth_type") + .eq("id", id) + .eq("user_id", userId) + .single(); + if (error || !row) return void res.status(404).json({ detail: "Not found" }); + if (row.auth_type !== "oauth") { + return void res + .status(400) + .json({ detail: "Connector is not configured for OAuth" }); + } + + const provider = new DbOAuthProvider(db, row.id, userId, "initiate"); + try { + const result = await runOAuth(provider, { serverUrl: row.url }); + if (result === "AUTHORIZED") { + // Already valid (e.g. row had a usable refresh token). Nothing + // for the user to do. + return void res.json({ + authorize_url: null, + already_authorized: true, + }); + } + if (!provider.lastAuthorizeUrl) { + throw new Error("Auth flow returned REDIRECT but no URL"); + } + await db + .from("user_mcp_servers") + .update({ last_error: null }) + .eq("id", id); + res.json({ authorize_url: provider.lastAuthorizeUrl.toString() }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await db + .from("user_mcp_servers") + .update({ last_error: message.slice(0, 1000) }) + .eq("id", id); + res.status(500).json({ detail: message }); + } +}); diff --git a/backend/src/routes/projectChat.ts b/backend/src/routes/projectChat.ts index 5e299615..35129a67 100644 --- a/backend/src/routes/projectChat.ts +++ b/backend/src/routes/projectChat.ts @@ -13,6 +13,10 @@ import { } from "../lib/chatTools"; import { getUserApiKeys } from "../lib/userSettings"; import { checkProjectAccess } from "../lib/access"; +import { + closeMcpServers, + loadEnabledMcpServersForUser, +} from "../lib/mcp/servers"; const PROJECT_SYSTEM_PROMPT_EXTRA = `PROJECT CONTEXT: You are operating within a project folder that contains a collection of legal documents the user has organised for a single matter. The user's questions will usually refer to one or more documents in this project — your job is to find the relevant files to work on. Use list_documents to see what is available and fetch_documents / read_document to pull in any documents you need before answering. @@ -153,6 +157,7 @@ projectChatRouter.post("/", requireAuth, async (req, res) => { const write = (line: string) => res.write(line); const apiKeys = await getUserApiKeys(userId, db); + const mcpServers = await loadEnabledMcpServersForUser(userId, db); try { write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`); @@ -169,6 +174,7 @@ projectChatRouter.post("/", requireAuth, async (req, res) => { model, apiKeys, projectId, + mcpServers, }); const annotations = extractAnnotations(fullText, docIndex, events); @@ -196,6 +202,7 @@ projectChatRouter.post("/", requireAuth, async (req, res) => { /* ignore */ } } finally { + await closeMcpServers(mcpServers); res.end(); } }); diff --git a/frontend/src/app/(pages)/account/layout.tsx b/frontend/src/app/(pages)/account/layout.tsx index 543638c1..475fa81d 100644 --- a/frontend/src/app/(pages)/account/layout.tsx +++ b/frontend/src/app/(pages)/account/layout.tsx @@ -14,6 +14,7 @@ interface TabDef { const TABS: TabDef[] = [ { id: "general", label: "General", href: "/account" }, { id: "models", label: "Models & API Keys", href: "/account/models" }, + { id: "mcp", label: "Connectors", href: "/account/mcp" }, ]; export default function AccountLayout({ diff --git a/frontend/src/app/(pages)/account/mcp/page.tsx b/frontend/src/app/(pages)/account/mcp/page.tsx new file mode 100644 index 00000000..13d3ef60 --- /dev/null +++ b/frontend/src/app/(pages)/account/mcp/page.tsx @@ -0,0 +1,761 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + AlertCircle, + Check, + ChevronUp, + Loader2, + Plus, + Trash2, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + createMcpServer, + deleteMcpServer, + listMcpServers, + startMcpOauth, + testMcpServer, + updateMcpServer, + type McpServer, + type McpServerTestResult, +} from "@/app/lib/mikeApi"; + +type DraftHeader = { key: string; value: string }; + +type Draft = { + name: string; + url: string; + headers: DraftHeader[]; + auth_type: "headers" | "oauth"; +}; + +const EMPTY_DRAFT: Draft = { + name: "", + url: "", + headers: [{ key: "", value: "" }], + auth_type: "headers", +}; + +export default function McpServersPage() { + const [servers, setServers] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + + const [showAdd, setShowAdd] = useState(false); + const [draft, setDraft] = useState(EMPTY_DRAFT); + const [saving, setSaving] = useState(false); + const [addError, setAddError] = useState(null); + + const [testing, setTesting] = useState>({}); + const [testResults, setTestResults] = useState< + Record + >({}); + + const reload = useCallback(async () => { + setLoadError(null); + try { + const list = await listMcpServers(); + setServers(list); + } catch (err) { + setLoadError(err instanceof Error ? err.message : "Failed to load"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + reload(); + }, [reload]); + + const handleAdd = async () => { + setAddError(null); + const name = draft.name.trim(); + const url = draft.url.trim(); + if (!name || !url) { + setAddError("Name and URL are required."); + return; + } + const headers: Record = {}; + for (const h of draft.headers) { + const k = h.key.trim(); + if (!k) continue; + headers[k] = h.value; + } + setSaving(true); + try { + const created = await createMcpServer({ + name, + url, + headers: draft.auth_type === "oauth" ? {} : headers, + auth_type: draft.auth_type, + }); + setDraft(EMPTY_DRAFT); + setShowAdd(false); + await reload(); + if (draft.auth_type === "oauth") { + // Discovery + sign-in needs user interaction. Kick the popup + // immediately so it feels like one continuous flow. + void launchOAuth(created.id); + } else { + // Auto-discover tools so the user sees the tool list right + // away without an extra Test click. + void runAutoTest(created.id); + } + } catch (err) { + setAddError(err instanceof Error ? err.message : "Failed to save"); + } finally { + setSaving(false); + } + }; + + const launchOAuth = async (id: string) => { + try { + const { authorize_url, already_authorized } = await startMcpOauth(id); + if (already_authorized) { + await reload(); + void runAutoTest(id); + return; + } + if (!authorize_url) { + alert("Authorization server did not return a URL."); + return; + } + const popup = window.open( + authorize_url, + "mcp_oauth", + "width=600,height=720,menubar=no,toolbar=no,location=no", + ); + if (!popup) { + alert( + "Couldn't open the sign-in window — check your popup blocker.", + ); + return; + } + // Poll the row until tokens are saved, or until the popup closes + // unfinished. Stop after 5 minutes regardless. + const deadline = Date.now() + 5 * 60 * 1000; + const interval = setInterval(async () => { + try { + const list = await listMcpServers(); + const row = list.find((s) => s.id === id); + if (row?.oauth_authorized) { + clearInterval(interval); + try { + popup.close(); + } catch { + /* ignore */ + } + setServers(list); + void runAutoTest(id); + return; + } + } catch { + /* ignore transient errors */ + } + if (popup.closed || Date.now() > deadline) { + clearInterval(interval); + await reload(); + } + }, 1500); + } catch (err) { + alert(err instanceof Error ? err.message : "Sign-in failed"); + } + }; + + const runAutoTest = async (id: string) => { + setTesting((s) => ({ ...s, [id]: true })); + try { + const result = await testMcpServer(id); + setTestResults((r) => ({ ...r, [id]: result })); + } catch (err) { + setTestResults((r) => ({ + ...r, + [id]: { + ok: false, + error: err instanceof Error ? err.message : String(err), + }, + })); + } finally { + setTesting((s) => ({ ...s, [id]: false })); + reload(); + } + }; + + const handleToggleEnabled = async (server: McpServer) => { + const wasDisabled = !server.enabled; + try { + await updateMcpServer(server.id, { enabled: !server.enabled }); + await reload(); + // Auto-test when going disabled → enabled so the user sees + // immediately if the server still works after re-enabling. + if (wasDisabled) void runAutoTest(server.id); + } catch (err) { + alert(err instanceof Error ? err.message : "Failed to update"); + } + }; + + const handleDelete = async (server: McpServer) => { + if (!confirm(`Remove connector "${server.name}"?`)) return; + try { + await deleteMcpServer(server.id); + await reload(); + } catch (err) { + alert(err instanceof Error ? err.message : "Failed to delete"); + } + }; + + const handleTest = async (server: McpServer) => { + setTesting((s) => ({ ...s, [server.id]: true })); + try { + const result = await testMcpServer(server.id); + setTestResults((r) => ({ ...r, [server.id]: result })); + } catch (err) { + setTestResults((r) => ({ + ...r, + [server.id]: { + ok: false, + error: err instanceof Error ? err.message : String(err), + }, + })); + } finally { + setTesting((s) => ({ ...s, [server.id]: false })); + // Reload so last_error reflects the test outcome. + reload(); + } + }; + + return ( +
+
+
+

+ Connectors +

+ +
+

+ Connectors plug external tools into Mike via the{" "} + + Model Context Protocol + {" "} + (MCP) — legal-data sources, web research, internal + company APIs, and so on. Tools discovered from each + connector become available to the chat assistant under + the{" "} + + mcp__<slug>__<tool> + {" "} + name. +

+
+ + {/* Trust trade-off warning. Surfaced once at the top so users + don't paste URLs and tokens for servers they haven't vetted. */} +
+ +
+

+ Only add connectors you trust +

+

+ A connector’s operator can see anything Mike + sends in tool calls — your prompts, document + excerpts, and the tool’s own response. Custom + headers (including{" "} + + Authorization + {" "} + tokens) are sent on every request. +

+
+
+ + {showAdd && ( + + )} + + {loading ? ( +
+ Loading + servers… +
+ ) : loadError ? ( +
+ + {loadError} +
+ ) : servers.length === 0 ? ( +
+ No connectors configured yet. +
+ ) : ( +
+ {servers.map((s) => ( + handleToggleEnabled(s)} + onDelete={() => handleDelete(s)} + onTest={() => handleTest(s)} + onSignIn={() => launchOAuth(s.id)} + /> + ))} +
+ )} +
+ ); +} + +function AddForm({ + draft, + setDraft, + onSave, + saving, + error, +}: { + draft: Draft; + setDraft: (d: Draft) => void; + onSave: () => void; + saving: boolean; + error: string | null; +}) { + const updateHeader = (idx: number, patch: Partial) => { + const headers = draft.headers.map((h, i) => + i === idx ? { ...h, ...patch } : h, + ); + setDraft({ ...draft, headers }); + }; + const addHeaderRow = () => + setDraft({ + ...draft, + headers: [...draft.headers, { key: "", value: "" }], + }); + const removeHeaderRow = (idx: number) => + setDraft({ + ...draft, + headers: draft.headers.filter((_, i) => i !== idx), + }); + + return ( +
+
+ + + setDraft({ ...draft, name: e.target.value }) + } + /> +
+
+ + + setDraft({ ...draft, url: e.target.value }) + } + /> +
+
+ +
+ + +
+
+ {draft.auth_type === "headers" && ( +
+ +

+ Sent on every request. Common usage:{" "} + + Authorization + {" "} + →{" "} + + Bearer <token> + + . +

+
+ {draft.headers.map((h, idx) => ( +
+ + updateHeader(idx, { key: e.target.value }) + } + className="flex-1" + /> + + updateHeader(idx, { + value: e.target.value, + }) + } + className="flex-1" + type="password" + /> + +
+ ))} + +
+
+ )} + {error && ( +
+ + {error} +
+ )} +
+ +
+
+ ); +} + +/** + * Sanitize a user-supplied server name for safe rendering. Strips Bearer + * prefixes and obvious secret-looking tokens that users sometimes paste into + * the Name field by mistake — the chat surface uses this label, so we don't + * want secrets leaking onto screens / screenshots. + */ +function safeServerName(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return "Untitled connector"; + const looksLikeSecret = + /\b(?:Bearer|Basic|sk-[A-Za-z0-9_-]{8,}|sb_secret_|AIza[A-Za-z0-9_-]{20,})\b/i.test( + trimmed, + ); + if (looksLikeSecret) { + // Best-effort cleanup: strip the secret-shaped substring. + const cleaned = trimmed + .replace( + /\s*\(?(Bearer|Basic)\s+[A-Za-z0-9._~+/\-]+=*\)?/gi, + "", + ) + .replace(/sk-[A-Za-z0-9_-]{8,}/g, "") + .replace(/sb_secret_[A-Za-z0-9_-]+/g, "") + .replace(/AIza[A-Za-z0-9_-]{20,}/g, "") + .replace(/\s{2,}/g, " ") + .trim(); + return cleaned || "Untitled connector"; + } + return trimmed; +} + +function ServerCard({ + server, + testing, + testResult, + onToggle, + onDelete, + onTest, + onSignIn, +}: { + server: McpServer; + testing: boolean; + testResult?: McpServerTestResult; + onToggle: () => void; + onDelete: () => void; + onTest: () => void; + onSignIn: () => void; +}) { + const [showDetails, setShowDetails] = useState(false); + const displayName = safeServerName(server.name); + const nameWasSanitized = displayName !== server.name.trim(); + const needsSignIn = + server.auth_type === "oauth" && !server.oauth_authorized; + + return ( +
+ {/* Header */} +
+
+
+

+ {displayName} +

+ {server.auth_type === "oauth" && ( + + {server.oauth_authorized + ? "OAuth · signed in" + : "OAuth · sign-in required"} + + )} + {server.enabled ? ( + + + Enabled + + ) : ( + + + Disabled + + )} + {server.last_error && server.last_error !== "reauth_required" && ( + + + Error + + )} +
+ {nameWasSanitized && ( +

+ Name contained what looks like a secret — + displayed redacted. Edit the server to fix. +

+ )} + + {server.url} + + {server.header_keys.length > 0 && ( +
+ Headers: + {server.header_keys.map((k) => ( + + {k} + + ))} +
+ )} +
+
+ {needsSignIn ? ( + + ) : ( + + )} + + +
+
+ + {/* Errors / status footer */} + {testResult && !testResult.ok && ( +
+ + + {testResult.error ?? "Unknown error"} + +
+ )} + {server.last_error && !testResult && ( +
+ + {server.last_error} +
+ )} + + {/* Tool list */} + {testResult?.ok && testResult.tools && testResult.tools.length > 0 && ( +
+ + {showDetails && ( +
    + {testResult.tools.map((t) => ( + + ))} +
+ )} +
+ )} +
+ ); +} + +function ToolListItem({ + name, + description, +}: { + name: string; + description: string; +}) { + const [expanded, setExpanded] = useState(false); + const trimmed = description.trim(); + const isLong = trimmed.length > 160; + const shown = expanded || !isLong ? trimmed : trimmed.slice(0, 160) + "…"; + return ( +
  • +
    + + {name} + + {isLong && ( + + )} +
    + {trimmed && ( +

    {shown}

    + )} +
  • + ); +} + diff --git a/frontend/src/app/components/assistant/AssistantMessage.tsx b/frontend/src/app/components/assistant/AssistantMessage.tsx index 48b0425b..930a6fe1 100644 --- a/frontend/src/app/components/assistant/AssistantMessage.tsx +++ b/frontend/src/app/components/assistant/AssistantMessage.tsx @@ -421,6 +421,89 @@ function ReasoningBlock({ ); } +function McpToolResultBlock({ + server, + tool, + ok, + args, + output, + showConnector, +}: { + server: string; + tool: string; + ok: boolean; + args: string; + output: string; + showConnector?: boolean; +}) { + const [expanded, setExpanded] = useState(false); + const prettyArgs = (() => { + try { + const parsed = JSON.parse(args); + return JSON.stringify(parsed, null, 2); + } catch { + return args; + } + })(); + const outputPreview = output.split("\n").slice(0, 1).join("\n"); + const outputClamped = + outputPreview.length > 160 + ? outputPreview.slice(0, 160) + "…" + : outputPreview; + return ( +
    + {showConnector && ( +
    + )} +
    +
    + +
    + {expanded && ( +
    +
    +
    + Arguments +
    +
    +                            {prettyArgs || "(none)"}
    +                        
    +
    +
    +
    + Output +
    +
    +                            {output || "(empty)"}
    +                        
    +
    +
    + )} +
    + ); +} + function DocReadBlock({ filename, onClick, @@ -1239,7 +1322,11 @@ export function AssistantMessage({
    Running - {event.name ? `${event.name}...` : "tool..."} + {event.display_name + ? `${event.display_name}...` + : event.name + ? `${event.name}...` + : "tool..."}
    ); @@ -1336,6 +1423,19 @@ export function AssistantMessage({ /> ); } + if (event.type === "mcp_tool_result") { + return ( + + ); + } return null; }; diff --git a/frontend/src/app/components/assistant/ChatInput.tsx b/frontend/src/app/components/assistant/ChatInput.tsx index df0a38f4..cfe8a11d 100644 --- a/frontend/src/app/components/assistant/ChatInput.tsx +++ b/frontend/src/app/components/assistant/ChatInput.tsx @@ -21,6 +21,7 @@ import { AddDocButton } from "./AddDocButton"; import { AddDocumentsModal } from "../shared/AddDocumentsModal"; import { AssistantWorkflowModal } from "./AssistantWorkflowModal"; import { ApiKeyMissingModal } from "../shared/ApiKeyMissingModal"; +import { McpToggleButton } from "./McpToggleButton"; import { ModelToggle } from "./ModelToggle"; import { useSelectedModel } from "@/app/hooks/useSelectedModel"; import { useUserProfile } from "@/contexts/UserProfileContext"; @@ -272,6 +273,7 @@ export const ChatInput = forwardRef(function ChatInput( )} +
    diff --git a/frontend/src/app/components/assistant/McpToggleButton.tsx b/frontend/src/app/components/assistant/McpToggleButton.tsx new file mode 100644 index 00000000..53d09b84 --- /dev/null +++ b/frontend/src/app/components/assistant/McpToggleButton.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { AlertCircle, Plug, Plus } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + listMcpServers, + updateMcpServer, + type McpServer, +} from "@/app/lib/mikeApi"; + +/** + * Sit next to "Documents" / "Workflows" in the chat input. Opens a popover + * where the user toggles each of their configured MCP servers on/off. The + * toggle flips `enabled` on the row, which the chat backend honors at the + * start of the next request. + */ +export function McpToggleButton() { + const [servers, setServers] = useState(null); + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState>({}); + + const reload = useCallback(async () => { + try { + const list = await listMcpServers(); + setServers(list); + } catch { + setServers([]); + } + }, []); + + // Refresh when the menu opens so toggles always reflect current state + // (the user may have edited servers in the settings page). + useEffect(() => { + if (open) reload(); + else if (servers === null) reload(); + }, [open, reload, servers]); + + const handleToggle = async (server: McpServer) => { + setBusy((s) => ({ ...s, [server.id]: true })); + // Optimistic flip. + setServers((prev) => + prev + ? prev.map((s) => + s.id === server.id ? { ...s, enabled: !s.enabled } : s, + ) + : prev, + ); + try { + await updateMcpServer(server.id, { enabled: !server.enabled }); + } catch { + // Revert on error. + await reload(); + } finally { + setBusy((s) => ({ ...s, [server.id]: false })); + } + }; + + // Hide the button entirely when the user has no servers configured — + // surface only emerges when there's something to toggle. + if (servers !== null && servers.length === 0) return null; + + const enabledCount = servers?.filter((s) => s.enabled).length ?? 0; + const totalCount = servers?.length ?? 0; + + return ( + + + + + + + Connectors + + + {servers?.map((s) => ( + handleToggle(s)} + /> + ))} + + + + Manage connectors + + + + ); +} + +function McpRow({ + server, + busy, + onToggle, +}: { + server: McpServer; + busy: boolean; + onToggle: () => void; +}) { + const safeName = + server.name.trim().length > 0 ? server.name.trim() : "Untitled"; + return ( + + ); +} + +function ToggleSwitch({ on }: { on: boolean }) { + return ( + + + + ); +} diff --git a/frontend/src/app/components/shared/types.ts b/frontend/src/app/components/shared/types.ts index 2fa4d6dc..475ca4f6 100644 --- a/frontend/src/app/components/shared/types.ts +++ b/frontend/src/app/components/shared/types.ts @@ -85,6 +85,8 @@ export type AssistantEvent = | { type: "tool_call_start"; name: string; + /** Friendly label (e.g. "Legal Data Hunter · search") for MCP tools. */ + display_name?: string; isStreaming?: boolean; } | { type: "thinking"; isStreaming?: boolean } @@ -112,6 +114,17 @@ export type AssistantEvent = isStreaming?: boolean; } | { type: "doc_download"; filename: string; download_url: string } + | { + type: "mcp_tool_result"; + server: string; + tool: string; + ok: boolean; + /** JSON-stringified args (capped server-side). */ + args: string; + /** Tool output text (capped server-side). */ + output: string; + isStreaming?: boolean; + } | { type: "doc_replicated"; /** Source document filename. */ diff --git a/frontend/src/app/hooks/useAssistantChat.ts b/frontend/src/app/hooks/useAssistantChat.ts index fa82ef40..0e04ecf7 100644 --- a/frontend/src/app/hooks/useAssistantChat.ts +++ b/frontend/src/app/hooks/useAssistantChat.ts @@ -534,6 +534,9 @@ export function useAssistantChat({ pushEvent({ type: "tool_call_start", name: (data.name as string) ?? "", + display_name: + (data.display_name as string | undefined) ?? + undefined, isStreaming: true, }); continue; @@ -756,6 +759,19 @@ export function useAssistantChat({ continue; } + if (data.type === "mcp_tool_result") { + pushEvent({ + type: "mcp_tool_result", + server: (data.server as string) ?? "", + tool: (data.tool as string) ?? "", + ok: data.ok !== false, + args: (data.args as string) ?? "", + output: (data.output as string) ?? "", + }); + pushThinkingPlaceholder(); + continue; + } + if (data.type === "citations") { // End-of-stream signal — scrub any lingering // placeholders so they don't persist into the diff --git a/frontend/src/app/lib/mikeApi.ts b/frontend/src/app/lib/mikeApi.ts index 2d2a7417..abda6082 100644 --- a/frontend/src/app/lib/mikeApi.ts +++ b/frontend/src/app/lib/mikeApi.ts @@ -815,3 +815,81 @@ export async function deleteWorkflowShare( method: "DELETE", }); } + +// --------------------------------------------------------------------------- +// MCP servers +// --------------------------------------------------------------------------- + +export interface McpServer { + id: string; + slug: string; + name: string; + url: string; + header_keys: string[]; + enabled: boolean; + last_error: string | null; + auth_type: "headers" | "oauth"; + oauth_authorized: boolean; + created_at: string; + updated_at: string; +} + +export interface McpServerTestResult { + ok: boolean; + tool_count?: number; + tools?: { name: string; description: string }[]; + error?: string; +} + +export async function listMcpServers(): Promise { + return apiRequest("/user/mcp-servers"); +} + +export async function createMcpServer(payload: { + name: string; + url: string; + slug?: string; + headers?: Record; + enabled?: boolean; + auth_type?: "headers" | "oauth"; +}): Promise { + return apiRequest("/user/mcp-servers", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +} + +export async function startMcpOauth( + id: string, +): Promise<{ authorize_url: string | null; already_authorized?: boolean }> { + return apiRequest(`/user/mcp-servers/${id}/oauth/start`, { + method: "POST", + }); +} + +export async function updateMcpServer( + id: string, + payload: { + name?: string; + url?: string; + headers?: Record; + enabled?: boolean; + }, +): Promise { + return apiRequest(`/user/mcp-servers/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +} + +export async function deleteMcpServer(id: string): Promise { + await apiRequest(`/user/mcp-servers/${id}`, { method: "DELETE" }); +} + +export async function testMcpServer(id: string): Promise { + return apiRequest(`/user/mcp-servers/${id}/test`, { + method: "POST", + }); +} From 11da4b99185f3f17bb149ea5bea96d5848c4f845 Mon Sep 17 00:00:00 2001 From: Lef Date: Fri, 8 May 2026 11:40:26 +0200 Subject: [PATCH 08/26] chore(self-host): wire OPENROUTER_API_KEY, BACKEND_PUBLIC_URL, DOWNLOAD_SIGNING_SECRET into compose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three env vars introduced by recent upstream cherry-picks need to flow through to the mike-backend container in our self-host stack: - OPENROUTER_API_KEY (PR #11) — read by lib/llm/openrouter.ts as the fallback when the user hasn't set a per-user key in account settings; matches the existing pattern for ANTHROPIC and GEMINI. - BACKEND_PUBLIC_URL (PR #32) — used to build the OAuth callback URL for MCP connectors. Defaults to http://${MIKE_HOST}:${MIKE_PORT}/backend which works for laptop traffic; OAuth flows from third-party MCP servers require this URL to actually be reachable from the public internet. - DOWNLOAD_SIGNING_SECRET (PR #21) — required at backend startup; the previous silent fallback to SUPABASE_SECRET_KEY is preserved as the compose default so existing deployments don't break, but the spec encourages using a dedicated secret. Also adds OPENROUTER_API_KEY and BACKEND_PUBLIC_URL to .env.example with comments. --- .env.example | 8 ++++++++ docker-compose.yml | 3 +++ 2 files changed, 11 insertions(+) diff --git a/.env.example b/.env.example index e3d29d43..e88af1b3 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,14 @@ GOTRUE_DISABLE_SIGNUP=false # --- LLM providers (set at least one) ---------------------------------------- ANTHROPIC_API_KEY= GEMINI_API_KEY= +OPENROUTER_API_KEY= + +# --- MCP Connectors (optional) ----------------------------------------------- +# Externally-reachable backend URL used by MCP OAuth 2.1 callbacks. The +# default works for laptop use; OAuth-based MCP servers will only complete +# the callback if this URL is reachable from the third-party MCP server +# (i.e. you've exposed Mike to the public internet over TLS). +BACKEND_PUBLIC_URL=http://localhost:80/backend # --- Garage ------------------------------------------------------------------ GARAGE_RPC_SECRET= # set by generate-secrets.sh diff --git a/docker-compose.yml b/docker-compose.yml index e195dfa9..8fc43c3f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -136,6 +136,9 @@ services: SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} GEMINI_API_KEY: ${GEMINI_API_KEY:-} + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} + DOWNLOAD_SIGNING_SECRET: ${DOWNLOAD_SIGNING_SECRET:-${SUPABASE_SECRET_KEY}} + BACKEND_PUBLIC_URL: ${BACKEND_PUBLIC_URL:-http://${MIKE_HOST}:${MIKE_PORT}/backend} R2_ENDPOINT_URL: http://garage:3900 R2_BUCKET_NAME: ${R2_BUCKET_NAME} volumes: From 4f988f827bf7249ae4791550ecce4b1345fcb06c Mon Sep 17 00:00:00 2001 From: ryanmcdonough Date: Fri, 1 May 2026 19:47:45 +0100 Subject: [PATCH 09/26] fix(security): add RLS policies to projects, chats, and chat_messages Until now only user_profiles had row-level security policies; the projects, chats, and chat_messages tables had RLS disabled, meaning the anon/authenticated PostgREST roles could read/write any row. Mike's backend uses the service-role key (which bypasses RLS) so the absence wasn't visible in the API, but anyone using the publishable anon key directly against /rest/v1/* could exfiltrate or mutate arbitrary rows. Adds policies for: - projects: SELECT for owner OR email member of shared_with; INSERT/UPDATE/DELETE owner-only - chats: SELECT for owner OR member of the chat's project; INSERT requires either user-owned (no project) or project access; UPDATE/DELETE owner-only - chat_messages: SELECT/INSERT scoped by access to the parent chat (which itself flows through chats' policies) Closes upstream issue #12. Cherry-picked from upstream PR #13: Fix: #12 - RLS Author: @ryanmcdonough Source: https://github.com/willchen96/mike/pull/13 (cherry picked from commit 1e73b7aa4c729e929e1e8fed51f34a7ffe239955) --- backend/migrations/000_one_shot_schema.sql | 151 +++++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/backend/migrations/000_one_shot_schema.sql b/backend/migrations/000_one_shot_schema.sql index fed448f6..8b8f0f03 100644 --- a/backend/migrations/000_one_shot_schema.sql +++ b/backend/migrations/000_one_shot_schema.sql @@ -83,6 +83,36 @@ create index if not exists idx_projects_user create index if not exists projects_shared_with_idx on public.projects using gin (shared_with); +alter table public.projects enable row level security; + +drop policy if exists projects_select_owner_or_shared on public.projects; +create policy projects_select_owner_or_shared + on public.projects for select + using ( + user_id = auth.uid()::text + or exists ( + select 1 + from jsonb_array_elements_text(coalesce(shared_with, '[]'::jsonb)) as member(email) + where lower(member.email) = lower(coalesce(auth.jwt()->>'email', '')) + ) + ); + +drop policy if exists projects_insert_owner_only on public.projects; +create policy projects_insert_owner_only + on public.projects for insert + with check (user_id = auth.uid()::text); + +drop policy if exists projects_update_owner_only on public.projects; +create policy projects_update_owner_only + on public.projects for update + using (user_id = auth.uid()::text) + with check (user_id = auth.uid()::text); + +drop policy if exists projects_delete_owner_only on public.projects; +create policy projects_delete_owner_only + on public.projects for delete + using (user_id = auth.uid()::text); + create table if not exists public.project_subfolders ( id uuid primary key default gen_random_uuid(), project_id uuid not null references public.projects(id) on delete cascade, @@ -243,6 +273,65 @@ create index if not exists idx_chats_user create index if not exists idx_chats_project on public.chats(project_id); +alter table public.chats enable row level security; + +drop policy if exists chats_select_owner_or_project_member on public.chats; +create policy chats_select_owner_or_project_member + on public.chats for select + using ( + user_id = auth.uid()::text + or ( + project_id is not null + and exists ( + select 1 + from public.projects p + where p.id = chats.project_id + and ( + p.user_id = auth.uid()::text + or exists ( + select 1 + from jsonb_array_elements_text(coalesce(p.shared_with, '[]'::jsonb)) as member(email) + where lower(member.email) = lower(coalesce(auth.jwt()->>'email', '')) + ) + ) + ) + ) + ); + +drop policy if exists chats_insert_user_and_project_access on public.chats; +create policy chats_insert_user_and_project_access + on public.chats for insert + with check ( + user_id = auth.uid()::text + and ( + project_id is null + or exists ( + select 1 + from public.projects p + where p.id = chats.project_id + and ( + p.user_id = auth.uid()::text + or exists ( + select 1 + from jsonb_array_elements_text(coalesce(p.shared_with, '[]'::jsonb)) as member(email) + where lower(member.email) = lower(coalesce(auth.jwt()->>'email', '')) + ) + ) + ) + ) + ); + +drop policy if exists chats_update_owner_only on public.chats; +create policy chats_update_owner_only + on public.chats for update + using (user_id = auth.uid()::text) + with check (user_id = auth.uid()::text); + +drop policy if exists chats_delete_owner_only on public.chats; +create policy chats_delete_owner_only + on public.chats for delete + using (user_id = auth.uid()::text); + create table if not exists public.chat_messages ( id uuid primary key default gen_random_uuid(), chat_id uuid not null references public.chats(id) on delete cascade, @@ -256,6 +345,68 @@ create table if not exists public.chat_messages ( create index if not exists idx_chat_messages_chat on public.chat_messages(chat_id); +alter table public.chat_messages enable row level security; + +drop policy if exists chat_messages_select_by_chat_access on public.chat_messages; +create policy chat_messages_select_by_chat_access + on public.chat_messages for select + using ( + exists ( + select 1 + from public.chats c + where c.id = chat_messages.chat_id + and ( + c.user_id = auth.uid()::text + or ( + c.project_id is not null + and exists ( + select 1 + from public.projects p + where p.id = c.project_id + and ( + p.user_id = auth.uid()::text + or exists ( + select 1 + from jsonb_array_elements_text(coalesce(p.shared_with, '[]'::jsonb)) as member(email) + where lower(member.email) = lower(coalesce(auth.jwt()->>'email', '')) + ) + ) + ) + ) + ) + ) + ); + +drop policy if exists chat_messages_insert_by_chat_access on public.chat_messages; +create policy chat_messages_insert_by_chat_access + on public.chat_messages for insert + with check ( + exists ( + select 1 + from public.chats c + where c.id = chat_messages.chat_id + and ( + c.user_id = auth.uid()::text + or ( + c.project_id is not null + and exists ( + select 1 + from public.projects p + where p.id = c.project_id + and ( + p.user_id = auth.uid()::text + or exists ( + select 1 + from jsonb_array_elements_text(coalesce(p.shared_with, '[]'::jsonb)) as member(email) + where lower(member.email) = lower(coalesce(auth.jwt()->>'email', '')) + ) + ) + ) + ) + ) + ) + ); + do $$ begin if not exists ( From 997956612ee8c3d56d48c8cc12702c443e51ac12 Mon Sep 17 00:00:00 2001 From: kveton Date: Thu, 7 May 2026 12:22:45 -0700 Subject: [PATCH 10/26] fix(security): harden data access, document uploads, and secret handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major security pass over Mike's data-access surface: Backend - Revoke all anon/authenticated direct Supabase table access. Every read/write now flows through service-role backend endpoints. - Encrypt user-stored LLM API keys at rest. Adds USER_API_KEYS_- ENCRYPTION_KEY env var and a new lib/apiKeys.ts module that encrypts on write and decrypts on read with opportunistic upgrade for legacy plaintext rows. - Validate uploaded file *bytes* (not just extensions) — backend now refuses files whose magic bytes don't match the declared mime type, closing the trivial extension-spoof upload bypass. - Tighten download-token signing — require an explicit DOWNLOAD_SIGNING_SECRET (no SUPABASE_SECRET_KEY fallback). - New /user/profile endpoint exposing has__api_key booleans rather than raw key strings. - Test suite under backend/test/ covering apiKeys, access, downloadTokens, and upload. Frontend - Remove frontend/src/lib/{storage.ts,supabase-server.ts} entirely — there is no longer a frontend-side S3 client or Supabase server client. All data access goes through the backend. - UserProfileContext, signup, account/models, account/mcp, ChatInput, TRChatPanel, ModelToggle, etc. all rewritten to use the backend /user/profile endpoint and the "configured" sentinel pattern (frontend never sees raw API keys). - Migration 001_security_lockdown.sql revokes anon/authenticated privileges on every public table and grants service_role only. Cherry-picked from upstream PR #42: Harden data access, document uploads, and secret handling Author: @kveton Source: https://github.com/willchen96/mike/pull/42 (cherry picked from commit 5605a086263297417e34c3b8cc47f7011226b495) Notes on conflict resolution against earlier cherry-picks on this branch: - PR #21 (downloadTokens fail-fast): kept PR #21's longer error message pointing at openssl rand -hex 32; took PR #42's removal of the SUPABASE_SECRET_KEY fallback. - PR #29 (claude.ts stream log fix): re-applied the env-gated DEBUG_LLM_STREAM debug listener on top of PR #42's claude.ts. - PR #2 (chat creation access check): preserved the checkProjectAccess guard on POST /chat/create. - PR #11 (OpenRouter): extended PR #42's userSettings.ts encrypt/ decrypt logic to also handle openrouter_api_key; added has_openrouter_api_key to the /user/profile shape; restored OpenRouter rows in account/models, ChatInput, TRChatPanel, ModelToggle and UserProfileContext using the same "configured" sentinel pattern PR #42 introduced for Claude/Gemini. - PR #28 (folder ownership): no semantic conflict; PR #42 auto-merged. - PR #32 (MCP Connectors): preserved chatTools.ts MCP dispatch and closeMcpServers finally-hook in chat.ts on top of PR #42's effectiveProjectId rework. - backend/.env.example: deduped DOWNLOAD_SIGNING_SECRET, added USER_API_KEYS_ENCRYPTION_KEY. - backend/package-lock.json: regenerated via npm install --package-lock-only --legacy-peer-deps. --- backend/.env.example | 4 + backend/migrations/000_one_shot_schema.sql | 46 +- backend/migrations/001_security_lockdown.sql | 61 + backend/package-lock.json | 1545 +++++++++-------- backend/package.json | 12 +- backend/src/lib/access.ts | 20 +- backend/src/lib/apiKeys.ts | 78 + backend/src/lib/chatTools.ts | 88 - backend/src/lib/downloadTokens.ts | 10 +- backend/src/lib/llm/gemini.ts | 1 - backend/src/lib/upload.ts | 79 + backend/src/lib/userSettings.ts | 59 +- backend/src/routes/chat.ts | 75 +- backend/src/routes/documents.ts | 115 +- backend/src/routes/projects.ts | 32 +- backend/src/routes/tabular.ts | 266 ++- backend/src/routes/user.ts | 130 +- backend/test/access.test.ts | 121 ++ backend/test/apiKeys.test.ts | 44 + backend/test/downloadTokens.test.ts | 38 + backend/test/upload.test.ts | 69 + frontend/.env.local.example | 1 - frontend/package-lock.json | 1525 ++++++++-------- frontend/package.json | 18 +- .../src/app/(pages)/account/models/page.tsx | 68 +- .../components/assistant/AssistantMessage.tsx | 32 +- .../app/components/assistant/ChatInput.tsx | 6 +- .../src/app/components/assistant/EditCard.tsx | 20 - .../src/app/components/shared/DocxView.tsx | 7 - .../app/components/tabular/TRChatPanel.tsx | 28 +- .../components/tabular/TREditColumnMenu.tsx | 2 - .../app/components/tabular/TRSidePanel.tsx | 6 +- .../components/tabular/TabularReviewView.tsx | 4 +- frontend/src/app/hooks/useFetchDocxBytes.ts | 36 +- frontend/src/app/signup/page.tsx | 36 +- frontend/src/contexts/AuthContext.tsx | 4 +- frontend/src/contexts/UserProfileContext.tsx | 362 ++-- frontend/src/lib/auth.ts | 4 - frontend/src/lib/storage.ts | 132 -- frontend/src/lib/supabase-server.ts | 38 - 40 files changed, 2854 insertions(+), 2368 deletions(-) create mode 100644 backend/migrations/001_security_lockdown.sql create mode 100644 backend/src/lib/apiKeys.ts create mode 100644 backend/test/access.test.ts create mode 100644 backend/test/apiKeys.test.ts create mode 100644 backend/test/downloadTokens.test.ts create mode 100644 backend/test/upload.test.ts delete mode 100644 frontend/src/lib/storage.ts delete mode 100644 frontend/src/lib/supabase-server.ts diff --git a/backend/.env.example b/backend/.env.example index b85fa56a..7cef3071 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -12,6 +12,10 @@ BACKEND_PUBLIC_URL=http://localhost:3001 SUPABASE_URL=https://your-project.supabase.co SUPABASE_SECRET_KEY=your-supabase-service-role-key +# Symmetric key used to encrypt user-supplied LLM API keys at rest. +# Required at startup. Generate with: openssl rand -hex 32 +USER_API_KEYS_ENCRYPTION_KEY=replace-with-a-random-32-byte-hex-string + R2_ENDPOINT_URL=https://your-account-id.r2.cloudflarestorage.com R2_ACCESS_KEY_ID=your-r2-access-key R2_SECRET_ACCESS_KEY=your-r2-secret-key diff --git a/backend/migrations/000_one_shot_schema.sql b/backend/migrations/000_one_shot_schema.sql index 8b8f0f03..a2e37a22 100644 --- a/backend/migrations/000_one_shot_schema.sql +++ b/backend/migrations/000_one_shot_schema.sql @@ -31,14 +31,7 @@ create index if not exists idx_user_profiles_user alter table public.user_profiles enable row level security; drop policy if exists "Users can view their own profile" on public.user_profiles; -create policy "Users can view their own profile" - on public.user_profiles for select - using (auth.uid() = user_id); - drop policy if exists "Users can update their own profile" on public.user_profiles; -create policy "Users can update their own profile" - on public.user_profiles for update - using (auth.uid() = user_id); create or replace function public.handle_new_user() returns trigger @@ -540,3 +533,42 @@ create table if not exists public.tabular_review_chat_messages ( create index if not exists tabular_review_chat_messages_chat_idx on public.tabular_review_chat_messages(chat_id, created_at); + +-- --------------------------------------------------------------------------- +-- Security posture +-- --------------------------------------------------------------------------- +-- App data is accessed through backend service-role routes. Keep RLS enabled +-- without direct anon/authenticated policies so browser clients cannot read or +-- write raw tables such as user profiles, document metadata, or API keys. + +alter table public.user_profiles enable row level security; +alter table public.projects enable row level security; +alter table public.project_subfolders enable row level security; +alter table public.documents enable row level security; +alter table public.document_versions enable row level security; +alter table public.document_edits enable row level security; +alter table public.workflows enable row level security; +alter table public.hidden_workflows enable row level security; +alter table public.workflow_shares enable row level security; +alter table public.chats enable row level security; +alter table public.chat_messages enable row level security; +alter table public.tabular_reviews enable row level security; +alter table public.tabular_cells enable row level security; +alter table public.tabular_review_chats enable row level security; +alter table public.tabular_review_chat_messages enable row level security; + +revoke all on public.user_profiles from anon, authenticated; +revoke all on public.projects from anon, authenticated; +revoke all on public.project_subfolders from anon, authenticated; +revoke all on public.documents from anon, authenticated; +revoke all on public.document_versions from anon, authenticated; +revoke all on public.document_edits from anon, authenticated; +revoke all on public.workflows from anon, authenticated; +revoke all on public.hidden_workflows from anon, authenticated; +revoke all on public.workflow_shares from anon, authenticated; +revoke all on public.chats from anon, authenticated; +revoke all on public.chat_messages from anon, authenticated; +revoke all on public.tabular_reviews from anon, authenticated; +revoke all on public.tabular_cells from anon, authenticated; +revoke all on public.tabular_review_chats from anon, authenticated; +revoke all on public.tabular_review_chat_messages from anon, authenticated; diff --git a/backend/migrations/001_security_lockdown.sql b/backend/migrations/001_security_lockdown.sql new file mode 100644 index 00000000..aa584502 --- /dev/null +++ b/backend/migrations/001_security_lockdown.sql @@ -0,0 +1,61 @@ +-- Lock app data behind backend service-role APIs and clean up tabular cells +-- that point at documents outside their review authorization boundary. + +delete from public.tabular_cells c +where not exists ( + select 1 + from public.documents d + where d.id = c.document_id + ) + or exists ( + select 1 + from public.tabular_reviews r + left join public.documents d on d.id = c.document_id + where r.id = c.review_id + and ( + d.id is null + or ( + r.project_id is not null + and d.project_id is distinct from r.project_id + ) + or ( + r.project_id is null + and d.user_id is distinct from r.user_id + ) + ) + ); + +alter table public.user_profiles enable row level security; +alter table public.projects enable row level security; +alter table public.project_subfolders enable row level security; +alter table public.documents enable row level security; +alter table public.document_versions enable row level security; +alter table public.document_edits enable row level security; +alter table public.workflows enable row level security; +alter table public.hidden_workflows enable row level security; +alter table public.workflow_shares enable row level security; +alter table public.chats enable row level security; +alter table public.chat_messages enable row level security; +alter table public.tabular_reviews enable row level security; +alter table public.tabular_cells enable row level security; +alter table public.tabular_review_chats enable row level security; +alter table public.tabular_review_chat_messages enable row level security; + +drop policy if exists "Users can view their own profile" on public.user_profiles; +drop policy if exists "Users can update their own profile" on public.user_profiles; + +revoke all on public.user_profiles from anon, authenticated; +revoke all on public.projects from anon, authenticated; +revoke all on public.project_subfolders from anon, authenticated; +revoke all on public.documents from anon, authenticated; +revoke all on public.document_versions from anon, authenticated; +revoke all on public.document_edits from anon, authenticated; +revoke all on public.workflows from anon, authenticated; +revoke all on public.hidden_workflows from anon, authenticated; +revoke all on public.workflow_shares from anon, authenticated; +revoke all on public.chats from anon, authenticated; +revoke all on public.chat_messages from anon, authenticated; +revoke all on public.tabular_reviews from anon, authenticated; +revoke all on public.tabular_cells from anon, authenticated; +revoke all on public.tabular_review_chats from anon, authenticated; +revoke all on public.tabular_review_chat_messages from anon, authenticated; diff --git a/backend/package-lock.json b/backend/package-lock.json index 90b08c42..c6f9ed0d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,9 +9,9 @@ "version": "1.0.0", "license": "AGPL-3.0-only", "dependencies": { - "@anthropic-ai/sdk": "^0.90.0", - "@aws-sdk/client-s3": "^3.787.0", - "@aws-sdk/s3-request-presigner": "^3.787.0", + "@anthropic-ai/sdk": "^0.95.1", + "@aws-sdk/client-s3": "^3.1044.0", + "@aws-sdk/s3-request-presigner": "^3.1044.0", "@google/genai": "^1.50.1", "@modelcontextprotocol/sdk": "^1.29.0", "@supabase/supabase-js": "^2.49.4", @@ -20,7 +20,7 @@ "dotenv": "^17.4.1", "express": "^4.21.2", "fast-diff": "^1.3.0", - "fast-xml-parser": "^5.7.1", + "fast-xml-parser": "^5.7.3", "jszip": "^3.10.1", "libreoffice-convert": "^1.6.0", "mammoth": "^1.9.0", @@ -39,12 +39,13 @@ } }, "node_modules/@anthropic-ai/sdk": { - "version": "0.90.0", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.90.0.tgz", - "integrity": "sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg==", + "version": "0.95.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.95.1.tgz", + "integrity": "sha512-OO9AF7hmAoU492c/mD7Q2cPqI2WNAj7rAPHlawgBeUgpwiboLRiDs+grsErGWeHHP9ZRWfzq2OVrODTt8aITVg==", "license": "MIT", "dependencies": { - "json-schema-to-ts": "^3.1.1" + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" }, "bin": { "anthropic-ai-sdk": "bin/cli" @@ -261,65 +262,65 @@ } }, "node_modules/@aws-sdk/client-s3": { - "version": "3.1026.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1026.0.tgz", - "integrity": "sha512-tMP+s641FLSXdJazvYvuf38F7suWWv+wagTvShykPTffuFpBj5J9f7Rw0eKsauBcsjPSntiwBz9Gm0Tlh+cKfQ==", + "version": "3.1045.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1045.0.tgz", + "integrity": "sha512-fsuO3Y6t+3Ro9Bsg41DKj4Sfy53CGSrhnMldNplWmG8Tx0UbYk+YDa4RD1hVlJpERw4JBmPkl0+J9qlxMh1pcA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/credential-provider-node": "^3.972.30", - "@aws-sdk/middleware-bucket-endpoint": "^3.972.9", - "@aws-sdk/middleware-expect-continue": "^3.972.9", - "@aws-sdk/middleware-flexible-checksums": "^3.974.7", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-location-constraint": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-sdk-s3": "^3.972.28", - "@aws-sdk/middleware-ssec": "^3.972.9", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/signature-v4-multi-region": "^3.996.16", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/eventstream-serde-browser": "^4.2.13", - "@smithy/eventstream-serde-config-resolver": "^4.3.13", - "@smithy/eventstream-serde-node": "^4.2.13", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-blob-browser": "^4.2.14", - "@smithy/hash-node": "^4.2.13", - "@smithy/hash-stream-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/md5-js": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/credential-provider-node": "^3.972.39", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.10", + "@aws-sdk/middleware-expect-continue": "^3.972.10", + "@aws-sdk/middleware-flexible-checksums": "^3.974.16", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-location-constraint": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-sdk-s3": "^3.972.37", + "@aws-sdk/middleware-ssec": "^3.972.10", + "@aws-sdk/middleware-user-agent": "^3.972.38", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/signature-v4-multi-region": "^3.996.25", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.24", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/eventstream-serde-browser": "^4.2.14", + "@smithy/eventstream-serde-config-resolver": "^4.3.14", + "@smithy/eventstream-serde-node": "^4.2.14", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-blob-browser": "^4.2.15", + "@smithy/hash-node": "^4.2.14", + "@smithy/hash-stream-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/md5-js": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.7", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", - "@smithy/util-stream": "^4.5.22", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", + "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", - "@smithy/util-waiter": "^4.2.15", + "@smithy/util-waiter": "^4.3.0", "tslib": "^2.6.2" }, "engines": { @@ -327,22 +328,23 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.973.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.27.tgz", - "integrity": "sha512-CUZ5m8hwMCH6OYI4Li/WgMfIEx10Q2PLI9Y3XOUTPGZJ53aZ0007jCv+X/ywsaERyKPdw5MRZWk877roQksQ4A==", + "version": "3.974.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.8.tgz", + "integrity": "sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/xml-builder": "^3.972.17", - "@smithy/core": "^3.23.14", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/property-provider": "^4.2.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/signature-v4": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.22", + "@smithy/core": "^3.23.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", - "@smithy/util-middleware": "^4.2.13", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -351,12 +353,12 @@ } }, "node_modules/@aws-sdk/crc64-nvme": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.6.tgz", - "integrity": "sha512-NMbiqKdruhwwgI6nzBVe2jWMkXjaoQz2YOs3rFX+2F3gGyrJDkDPwMpV/RsTFeq2vAQ055wZNtOXFK4NYSkM8g==", + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.7.tgz", + "integrity": "sha512-QUagVVBbC8gODCF6e1aV0mE2TXWB9Opz4k8EJFdNrujUVQm5R4AjJa1mpOqzwOuROBzqJU9zawzig7M96L8Ejg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -364,15 +366,15 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.25", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.25.tgz", - "integrity": "sha512-6QfI0wv4jpG5CrdO/AO0JfZ2ux+tKwJPrUwmvxXF50vI5KIypKVGNF6b4vlkYEnKumDTI1NX2zUBi8JoU5QU3A==", + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.34.tgz", + "integrity": "sha512-XT0jtf8Fw9JE6ppsQeoNnZRiG+jqRixMT1v1ZR17G60UvVdsQmTG8nbEyHuEPfMxDXEhfdARaM/XiEhca4lGHQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -380,20 +382,20 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.27.tgz", - "integrity": "sha512-3V3Usj9Gs93h865DqN4M2NWJhC5kXU9BvZskfN3+69omuYlE3TZxOEcVQtBGLOloJB7BVfJKXVLqeNhOzHqSlQ==", + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.36.tgz", + "integrity": "sha512-DPoGWfy7J7RKxvbf5kOKIGQkD2ek3dbKgzKIGrnLuvZBz5myU+Im/H6pmc14QcnFbqHMqxvtWSgRDSJW3qXLQg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/types": "^3.973.7", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/property-provider": "^4.2.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/util-stream": "^4.5.22", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/types": "^3.973.8", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/util-stream": "^4.5.25", "tslib": "^2.6.2" }, "engines": { @@ -401,24 +403,24 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.29.tgz", - "integrity": "sha512-SiBuAnXecCbT/OpAf3vqyI/AVE3mTaYr9ShXLybxZiPLBiPCCOIWSGAtYYGQWMRvobBTiqOewaB+wcgMMZI2Aw==", + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.38.tgz", + "integrity": "sha512-oDzUBu2MGJFgoar05sPMCwSrhw44ASyccrHzj66vO69OZqi7I6hZZxXfuPLC8OCzW7C+sU+bI73XHij41yekgQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/credential-provider-env": "^3.972.25", - "@aws-sdk/credential-provider-http": "^3.972.27", - "@aws-sdk/credential-provider-login": "^3.972.29", - "@aws-sdk/credential-provider-process": "^3.972.25", - "@aws-sdk/credential-provider-sso": "^3.972.29", - "@aws-sdk/credential-provider-web-identity": "^3.972.29", - "@aws-sdk/nested-clients": "^3.996.19", - "@aws-sdk/types": "^3.973.7", - "@smithy/credential-provider-imds": "^4.2.13", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/credential-provider-env": "^3.972.34", + "@aws-sdk/credential-provider-http": "^3.972.36", + "@aws-sdk/credential-provider-login": "^3.972.38", + "@aws-sdk/credential-provider-process": "^3.972.34", + "@aws-sdk/credential-provider-sso": "^3.972.38", + "@aws-sdk/credential-provider-web-identity": "^3.972.38", + "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/types": "^3.973.8", + "@smithy/credential-provider-imds": "^4.2.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -426,18 +428,18 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.29.tgz", - "integrity": "sha512-OGOslTbOlxXexKMqhxCEbBQbUIfuhGxU5UXw3Fm56ypXHvrXH4aTt/xb5Y884LOoteP1QST1lVZzHfcTnWhiPQ==", + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.38.tgz", + "integrity": "sha512-g1NosS8qe4OF++G2UFCM5ovSkgipC7YYor5KCWatG0UoMSO5YFj9C8muePlyVmOBV/WTI16Jo3/s1NUo/o1Bww==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/nested-clients": "^3.996.19", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -445,22 +447,22 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.30", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.30.tgz", - "integrity": "sha512-FMnAnWxc8PG+ZrZ2OBKzY4luCUJhe9CG0B9YwYr4pzrYGLXBS2rl+UoUvjGbAwiptxRL6hyA3lFn03Bv1TLqTw==", + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.39.tgz", + "integrity": "sha512-HEswDQyxUtadoZ/bJsPPENHg7R0Lzym5LuMksJeHvqhCOpP+rtkDLKI4/ZChH4w3cf5kG8n6bZuI8PzajoiqMg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.25", - "@aws-sdk/credential-provider-http": "^3.972.27", - "@aws-sdk/credential-provider-ini": "^3.972.29", - "@aws-sdk/credential-provider-process": "^3.972.25", - "@aws-sdk/credential-provider-sso": "^3.972.29", - "@aws-sdk/credential-provider-web-identity": "^3.972.29", - "@aws-sdk/types": "^3.973.7", - "@smithy/credential-provider-imds": "^4.2.13", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/credential-provider-env": "^3.972.34", + "@aws-sdk/credential-provider-http": "^3.972.36", + "@aws-sdk/credential-provider-ini": "^3.972.38", + "@aws-sdk/credential-provider-process": "^3.972.34", + "@aws-sdk/credential-provider-sso": "^3.972.38", + "@aws-sdk/credential-provider-web-identity": "^3.972.38", + "@aws-sdk/types": "^3.973.8", + "@smithy/credential-provider-imds": "^4.2.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -468,16 +470,16 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.25", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.25.tgz", - "integrity": "sha512-HR7ynNRdNhNsdVCOCegy1HsfsRzozCOPtD3RzzT1JouuaHobWyRfJzCBue/3jP7gECHt+kQyZUvwg/cYLWurNQ==", + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.34.tgz", + "integrity": "sha512-T3IFs4EVmVi1dVN5RciFnklCANSzvrQd/VuHY9ThHSQmYkTogjcGkoJEr+oNUPQZnso52183088NqysMPji1/Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -485,18 +487,18 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.29.tgz", - "integrity": "sha512-HWv4SEq3jZDYPlwryZVef97+U8CxxRos5mK8sgGO1dQaFZpV5giZLzqGE5hkDmh2csYcBO2uf5XHjPTpZcJlig==", + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.38.tgz", + "integrity": "sha512-5ZxG+t0+3Q3QPh8KEjX6syskhgNf7I0MN7oGioTf6Lm1NTjfP7sIcYGNsthXC2qR8vcD3edNZwCr2ovfSSWuRA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/nested-clients": "^3.996.19", - "@aws-sdk/token-providers": "3.1026.0", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/token-providers": "3.1041.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -504,17 +506,17 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.29.tgz", - "integrity": "sha512-PdMBza1WEKEUPFEmMGCfnU2RYCz9MskU2e8JxjyUOsMKku7j9YaDKvbDi2dzC0ihFoM6ods2SbhfAAro+Gwlew==", + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.38.tgz", + "integrity": "sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/nested-clients": "^3.996.19", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -522,16 +524,16 @@ } }, "node_modules/@aws-sdk/middleware-bucket-endpoint": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.9.tgz", - "integrity": "sha512-COToYKgquDyligbcAep7ygs48RK+mwe/IYprq4+TSrVFzNOYmzWvHf6werpnKV5VYpRiwdn+Wa5ZXkPqLVwcTg==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.10.tgz", + "integrity": "sha512-Vbc2frZH7wXlMNd+ZZSXUEs/l1Sv8Jj4zUnIfwrYF5lwaLdXHZ9xx4U3rjUcaye3HRhFVc+E5DbBxpRAbB16BA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", + "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-arn-parser": "^3.972.3", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, @@ -540,14 +542,14 @@ } }, "node_modules/@aws-sdk/middleware-expect-continue": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.9.tgz", - "integrity": "sha512-V/FNCjFxnh4VGu+HdSiW4Yg5GELihA1MIDSAdsEPvuayXBVmr0Jaa6jdLAZLH38KYXl/vVjri9DQJWnTAujHEA==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.10.tgz", + "integrity": "sha512-2Yn0f1Qiq/DjxYR3wfI3LokXnjOhFM7Ssn4LTdFDIxRMCE6I32MAsVnhPX1cUZsuVA9tiZtwwhlSLAtFGxAZlQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -555,23 +557,23 @@ } }, "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.974.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.7.tgz", - "integrity": "sha512-uU4/ch2CLHB8Phu1oTKnnQ4e8Ujqi49zEnQYBhWYT53zfFvtJCdGsaOoypBr8Fm/pmCBssRmGoIQ4sixgdLP9w==", + "version": "3.974.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.16.tgz", + "integrity": "sha512-6ru8doI0/XzszqLIPXf0E/V7HhAw1Pu94010XCKYtBUfD0LxF0BuOzrUf8OQGR6j2o6wgKTHUniOmndQycHwCA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/crc64-nvme": "^3.972.6", - "@aws-sdk/types": "^3.973.7", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/crc64-nvme": "^3.972.7", + "@aws-sdk/types": "^3.973.8", "@smithy/is-array-buffer": "^4.2.2", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-stream": "^4.5.22", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -580,14 +582,14 @@ } }, "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.9.tgz", - "integrity": "sha512-je5vRdNw4SkuTnmRbFZLdye4sQ0faLt8kwka5wnnSU30q1mHO4X+idGEJOOE+Tn1ME7Oryn05xxkDvIb3UaLaQ==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.10.tgz", + "integrity": "sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -595,13 +597,13 @@ } }, "node_modules/@aws-sdk/middleware-location-constraint": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.9.tgz", - "integrity": "sha512-TyfOi2XNdOZpNKeTJwRUsVAGa+14nkyMb2VVGG+eDgcWG/ed6+NUo72N3hT6QJioxym80NSinErD+LBRF0Ir1w==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.10.tgz", + "integrity": "sha512-rI3NZvJcEvjoD0+0PI0iUAwlPw2IlSlhyvgBK/3WkKJQE/YiKFedd9dMN2lVacdNxPNhxL/jzQaKQdrGtQagjQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -609,13 +611,13 @@ } }, "node_modules/@aws-sdk/middleware-logger": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.9.tgz", - "integrity": "sha512-HsVgDrruhqI28RkaXALm8grJ7Agc1wF6Et0xh6pom8NdO2VdO/SD9U/tPwUjewwK/pVoka+EShBxyCvgsPCtog==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.10.tgz", + "integrity": "sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -623,15 +625,15 @@ } }, "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.10.tgz", - "integrity": "sha512-RVQQbq5orQ/GHUnXvqEOj2HHPBJm+mM+ySwZKS5UaLBwra5ugRtiH09PLUoOZRl7a1YzaOzXSuGbn9iD5j60WQ==", + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.11.tgz", + "integrity": "sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", + "@aws-sdk/types": "^3.973.8", "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -639,23 +641,23 @@ } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.28", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.28.tgz", - "integrity": "sha512-qJHcJQH9UNPUrnPlRtCozKjtqAaypQ5IgQxTNoPsVYIQeuwNIA8Rwt3NvGij1vCDYDfCmZaPLpnJEHlZXeFqmg==", + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.37.tgz", + "integrity": "sha512-Km7M+i8DrLArVzrid1gfxeGhYHBd3uxvE77g0s5a52zPSVosxzQBnJ0gwWb6NIp/DOk8gsBMhi7V+cpJG0ndTA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/types": "^3.973.7", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-arn-parser": "^3.972.3", - "@smithy/core": "^3.23.14", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/signature-v4": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", + "@smithy/core": "^3.23.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-stream": "^4.5.22", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -664,13 +666,13 @@ } }, "node_modules/@aws-sdk/middleware-ssec": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.9.tgz", - "integrity": "sha512-wSA2BR7L0CyBNDJeSrleIIzC+DzL93YNTdfU0KPGLiocK6YsRv1nPAzPF+BFSdcs0Qa5ku5Kcf4KvQcWwKGenQ==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.10.tgz", + "integrity": "sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -678,18 +680,18 @@ } }, "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.29.tgz", - "integrity": "sha512-f/sIRzuTfEjg6NsbMYvye2VsmnQoNgntntleQyx5uGacUYzszbfIlO3GcI6G6daWUmTm0IDZc11qMHWwF0o0mQ==", + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.38.tgz", + "integrity": "sha512-iz+B29TXcAZsJpwB+AwG/TTGA5l/VnmMZ2UxtiySOZjI6gCdmviXPwdgzcmuazMy16rXoPY4mYCGe7zdNKfx5A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@smithy/core": "^3.23.14", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", - "@smithy/util-retry": "^4.3.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@smithy/core": "^3.23.17", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-retry": "^4.3.6", "tslib": "^2.6.2" }, "engines": { @@ -697,47 +699,48 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.996.19", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.19.tgz", - "integrity": "sha512-uFkmCDXvmQYLanlYdOFS0+MQWkrj9wPMt/ZCc/0J0fjPim6F5jBVBmEomvGY/j77ILW6GTPwN22Jc174Mhkw6Q==", + "version": "3.997.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.6.tgz", + "integrity": "sha512-WBDnqatJl+kGObpfmfSxqnXeYTu3Me8wx8WCtvoxX3pfWrrTv8I4WTMSSs7PZqcRcVh8WeUKMgGFjMG+52SR1w==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-user-agent": "^3.972.38", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/signature-v4-multi-region": "^3.996.25", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.24", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.7", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -746,15 +749,15 @@ } }, "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.11.tgz", - "integrity": "sha512-6Q8B1dcx6BBqUTY1Mc/eROKA0FImEEY5VPSd6AGPEUf0ErjExz4snVqa9kNJSoVDV1rKaNf3qrWojgcKW+SdDg==", + "version": "3.972.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.13.tgz", + "integrity": "sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/config-resolver": "^4.4.14", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/config-resolver": "^4.4.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -762,18 +765,18 @@ } }, "node_modules/@aws-sdk/s3-request-presigner": { - "version": "3.1026.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1026.0.tgz", - "integrity": "sha512-PBVt/zb4YsJMcyB/HbGmID4RP00dTkdQGkNQiw1i6oXQ/U8hnPEI8+IvTKR4+5YEQ8Cq4QmtIV0mzv070L+oOg==", + "version": "3.1045.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1045.0.tgz", + "integrity": "sha512-VDRF8GIuUPX+K4DUYrvcODj/h54LOmdJ7DhpLQ0wrYrdxzIiJEpi0n9jZ1bbjT2UxhwTbOorse5EGo+gnOK2aA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/signature-v4-multi-region": "^3.996.16", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-format-url": "^3.972.9", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", + "@aws-sdk/signature-v4-multi-region": "^3.996.25", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-format-url": "^3.972.10", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -781,16 +784,16 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.16.tgz", - "integrity": "sha512-EMdXYB4r/k5RWq86fugjRhid5JA+Z6MpS7n4sij4u5/C+STrkvuf9aFu41rJA9MjUzxCLzv8U2XL8cH2GSRYpQ==", + "version": "3.996.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.25.tgz", + "integrity": "sha512-+CMIt3e1VzlklAECmG+DtP1sV8iKq25FuA0OKpnJ4KA0kxUtd7CgClY7/RU6VzJBQwbN4EJ9Ue6plvqx1qGadw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-sdk-s3": "^3.972.28", - "@aws-sdk/types": "^3.973.7", - "@smithy/protocol-http": "^5.3.13", - "@smithy/signature-v4": "^5.3.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/middleware-sdk-s3": "^3.972.37", + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -798,17 +801,17 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1026.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1026.0.tgz", - "integrity": "sha512-Ieq/HiRrbEtrYP387Nes0XlR7H1pJiJOZKv+QyQzMYpvTiDs0VKy2ZB3E2Zf+aFovWmeE7lRE4lXyF7dYM6GgA==", + "version": "3.1041.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1041.0.tgz", + "integrity": "sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/nested-clients": "^3.996.19", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -816,12 +819,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.7.tgz", - "integrity": "sha512-reXRwoJ6CfChoqAsBszUYajAF8Z2LRE+CRcKocvFSMpIiLOtYU3aJ9trmn6VVPAzbbY5LXF+FfmUslbXk1SYFg==", + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -841,15 +844,15 @@ } }, "node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.6.tgz", - "integrity": "sha512-2nUQ+2ih7CShuKHpGSIYvvAIOHy52dOZguYG36zptBukhw6iFwcvGfG0tes0oZFWQqEWvgZe9HLWaNlvXGdOrg==", + "version": "3.996.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.8.tgz", + "integrity": "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", - "@smithy/util-endpoints": "^3.3.4", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-endpoints": "^3.4.2", "tslib": "^2.6.2" }, "engines": { @@ -857,14 +860,14 @@ } }, "node_modules/@aws-sdk/util-format-url": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.9.tgz", - "integrity": "sha512-fNJXHrs0ZT7Wx0KGIqKv7zLxlDXt2vqjx9z6oKUQFmpE5o4xxnSryvVHfHpIifYHWKz94hFccIldJ0YSZjlCBw==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.10.tgz", + "integrity": "sha512-DEKiHNJVtNxdyTeQspzY+15Po/kHm6sF0Cs4HV9Q2+lplB63+DrvdeiSoOSdWEWAoO2RcY1veoXVDz2tWxWCgQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/querystring-builder": "^4.2.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/querystring-builder": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -884,27 +887,27 @@ } }, "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.9.tgz", - "integrity": "sha512-sn/LMzTbGjYqCCF24390WxPd6hkpoSptiUn5DzVp4cD71yqw+yGEGm1YCxyEoPXyc8qciM8UzLJcZBFslxo5Uw==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.10.tgz", + "integrity": "sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.15.tgz", - "integrity": "sha512-fYn3s9PtKdgQkczGZCFMgkNEe8aq1JCVbnRqjqN9RSVW43xn2RV9xdcZ3z01a48Jpkuh/xCmBKJxdLOo4Ozg7w==", + "version": "3.973.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.24.tgz", + "integrity": "sha512-ZWwlkjcIp7cEL8ZfTpTAPNkwx25p7xol0xlKoWVVf22+nsjwmLcHYtTPjIV1cSpmB/b6DaK4cb1fSkvCXHgRdw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/types": "^3.973.7", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/middleware-user-agent": "^3.972.38", + "@aws-sdk/types": "^3.973.8", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, @@ -921,13 +924,14 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.17", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.17.tgz", - "integrity": "sha512-Ra7hjqAZf1OXRRMueB13qex7mFJRDK/pgCvdSFemXBT8KCGnQDPoKzHY1SjN+TjJVmnpSF14W5tJ1vDamFu+Gg==", + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.22.tgz", + "integrity": "sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", - "fast-xml-parser": "5.5.8", + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.2", "tslib": "^2.6.2" }, "engines": { @@ -935,9 +939,9 @@ } }, "node_modules/@aws-sdk/xml-builder/node_modules/fast-xml-parser": { - "version": "5.5.8", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", - "integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz", + "integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==", "funding": [ { "type": "github", @@ -946,9 +950,10 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.1.4", - "path-expression-matcher": "^1.2.0", - "strnum": "^2.2.0" + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.5", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" @@ -1415,9 +1420,10 @@ } }, "node_modules/@google/genai": { - "version": "1.50.1", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.50.1.tgz", - "integrity": "sha512-YbkX7H9+1Pt8wOt7DDREy8XSoiL6fRDzZQRyaVBarFf8MR3zHGqVdvM4cLbDXqPhxqvegZShgfxb8kw9C7YhAQ==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "google-auth-library": "^10.3.0", @@ -1715,21 +1721,6 @@ "node": ">= 0.6" } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/@modelcontextprotocol/sdk/node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -1790,9 +1781,9 @@ } }, "node_modules/@napi-rs/canvas": { - "version": "0.1.97", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz", - "integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==", + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz", + "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==", "license": "MIT", "optional": true, "workspaces": [ @@ -1806,23 +1797,23 @@ "url": "https://github.com/sponsors/Brooooooklyn" }, "optionalDependencies": { - "@napi-rs/canvas-android-arm64": "0.1.97", - "@napi-rs/canvas-darwin-arm64": "0.1.97", - "@napi-rs/canvas-darwin-x64": "0.1.97", - "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97", - "@napi-rs/canvas-linux-arm64-gnu": "0.1.97", - "@napi-rs/canvas-linux-arm64-musl": "0.1.97", - "@napi-rs/canvas-linux-riscv64-gnu": "0.1.97", - "@napi-rs/canvas-linux-x64-gnu": "0.1.97", - "@napi-rs/canvas-linux-x64-musl": "0.1.97", - "@napi-rs/canvas-win32-arm64-msvc": "0.1.97", - "@napi-rs/canvas-win32-x64-msvc": "0.1.97" + "@napi-rs/canvas-android-arm64": "0.1.100", + "@napi-rs/canvas-darwin-arm64": "0.1.100", + "@napi-rs/canvas-darwin-x64": "0.1.100", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", + "@napi-rs/canvas-linux-arm64-musl": "0.1.100", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-musl": "0.1.100", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", + "@napi-rs/canvas-win32-x64-msvc": "0.1.100" } }, "node_modules/@napi-rs/canvas-android-arm64": { - "version": "0.1.97", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz", - "integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==", + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz", + "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==", "cpu": [ "arm64" ], @@ -1840,9 +1831,9 @@ } }, "node_modules/@napi-rs/canvas-darwin-arm64": { - "version": "0.1.97", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz", - "integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==", + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz", + "integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==", "cpu": [ "arm64" ], @@ -1860,9 +1851,9 @@ } }, "node_modules/@napi-rs/canvas-darwin-x64": { - "version": "0.1.97", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz", - "integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==", + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz", + "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==", "cpu": [ "x64" ], @@ -1880,9 +1871,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { - "version": "0.1.97", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz", - "integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==", + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz", + "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==", "cpu": [ "arm" ], @@ -1900,9 +1891,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm64-gnu": { - "version": "0.1.97", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz", - "integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==", + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz", + "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==", "cpu": [ "arm64" ], @@ -1920,9 +1911,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm64-musl": { - "version": "0.1.97", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz", - "integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==", + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz", + "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==", "cpu": [ "arm64" ], @@ -1940,9 +1931,9 @@ } }, "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { - "version": "0.1.97", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz", - "integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==", + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz", + "integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==", "cpu": [ "riscv64" ], @@ -1960,9 +1951,9 @@ } }, "node_modules/@napi-rs/canvas-linux-x64-gnu": { - "version": "0.1.97", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz", - "integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==", + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz", + "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==", "cpu": [ "x64" ], @@ -1980,9 +1971,9 @@ } }, "node_modules/@napi-rs/canvas-linux-x64-musl": { - "version": "0.1.97", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz", - "integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==", + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz", + "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==", "cpu": [ "x64" ], @@ -2000,9 +1991,9 @@ } }, "node_modules/@napi-rs/canvas-win32-arm64-msvc": { - "version": "0.1.97", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz", - "integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==", + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz", + "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==", "cpu": [ "arm64" ], @@ -2020,9 +2011,9 @@ } }, "node_modules/@napi-rs/canvas-win32-x64-msvc": { - "version": "0.1.97", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz", - "integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==", + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz", + "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==", "cpu": [ "x64" ], @@ -2064,9 +2055,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { @@ -2092,9 +2083,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", + "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { @@ -2110,9 +2101,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "license": "BSD-3-Clause" }, "node_modules/@react-email/render": { @@ -2172,16 +2163,16 @@ } }, "node_modules/@smithy/config-resolver": { - "version": "4.4.14", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.14.tgz", - "integrity": "sha512-N55f8mPEccpzKetUagdvmAy8oohf0J5cuj9jLI1TaSceRlq0pJsIZepY3kmAXAhyxqXPV6hDerDQhqQPKWgAoQ==", + "version": "4.4.17", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.17.tgz", + "integrity": "sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.13", - "@smithy/types": "^4.14.0", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", "tslib": "^2.6.2" }, "engines": { @@ -2189,18 +2180,18 @@ } }, "node_modules/@smithy/core": { - "version": "3.23.14", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.14.tgz", - "integrity": "sha512-vJ0IhpZxZAkFYOegMKSrxw7ujhhT2pass/1UEcZ4kfl5srTAqtPU5I7MdYQoreVas3204ykCiNhY1o7Xlz6Yyg==", + "version": "3.23.17", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.17.tgz", + "integrity": "sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-stream": "^4.5.22", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" @@ -2210,15 +2201,15 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.13.tgz", - "integrity": "sha512-wboCPijzf6RJKLOvnjDAiBxGSmSnGXj35o5ZAWKDaHa/cvQ5U3ZJ13D4tMCE8JG4dxVAZFy/P0x/V9CwwdfULQ==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.14.tgz", + "integrity": "sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.13", - "@smithy/property-provider": "^4.2.13", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", "tslib": "^2.6.2" }, "engines": { @@ -2226,13 +2217,13 @@ } }, "node_modules/@smithy/eventstream-codec": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.13.tgz", - "integrity": "sha512-vYahwBAtRaAcFbOmE9aLr12z7RiHYDSLcnogSdxfm7kKfsNa3wH+NU5r7vTeB5rKvLsWyPjVX8iH94brP7umiQ==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.14.tgz", + "integrity": "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" }, @@ -2241,13 +2232,13 @@ } }, "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.13.tgz", - "integrity": "sha512-wwybfcOX0tLqCcBP378TIU9IqrDuZq/tDV48LlZNydMpCnqnYr+hWBAYbRE+rFFf/p7IkDJySM3bgiMKP2ihPg==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.14.tgz", + "integrity": "sha512-8IelTCtTctWRbb+0Dcy+C0aICh1qa0qWXqgjcXDmMuCvPJRnv26hiDZoAau2ILOniki65mCPKqOQs/BaWvO4CQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.13", - "@smithy/types": "^4.14.0", + "@smithy/eventstream-serde-universal": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2255,12 +2246,12 @@ } }, "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.3.13", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.13.tgz", - "integrity": "sha512-ied1lO559PtAsMJzg2TKRlctLnEi1PfkNeMMpdwXDImk1zV9uvS/Oxoy/vcy9uv1GKZAjDAB5xT6ziE9fzm5wA==", + "version": "4.3.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.14.tgz", + "integrity": "sha512-sqHiHpYRYo3FJlaIxD1J8PhbcmJAm7IuM16mVnwSkCToD7g00IBZzKuiLNMGmftULmEUX6/UAz8/NN5uMP8bVA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2268,13 +2259,13 @@ } }, "node_modules/@smithy/eventstream-serde-node": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.13.tgz", - "integrity": "sha512-hFyK+ORJrxAN3RYoaD6+gsGDQjeix8HOEkosoajvXYZ4VeqonM3G4jd9IIRm/sWGXUKmudkY9KdYjzosUqdM8A==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.14.tgz", + "integrity": "sha512-Ht/8BuGlKfFTy0H3+8eEu0vdpwGztCnaLLXtpXNdQqiR7Hj4vFScU3T436vRAjATglOIPjJXronY+1WxxNLSiw==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.13", - "@smithy/types": "^4.14.0", + "@smithy/eventstream-serde-universal": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2282,13 +2273,13 @@ } }, "node_modules/@smithy/eventstream-serde-universal": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.13.tgz", - "integrity": "sha512-kRrq4EKLGeOxhC2CBEhRNcu1KSzNJzYY7RK3S7CxMPgB5dRrv55WqQOtRwQxQLC04xqORFLUgnDlc6xrNUULaA==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.14.tgz", + "integrity": "sha512-lWyt4T2XQZUZgK3tQ3Wn0w3XBvZsK/vjTuJl6bXbnGZBHH0ZUSONTYiK9TgjTTzU54xQr3DRFwpjmhp0oLm3gg==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-codec": "^4.2.13", - "@smithy/types": "^4.14.0", + "@smithy/eventstream-codec": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2296,14 +2287,14 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.16", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.16.tgz", - "integrity": "sha512-nYDRUIvNd4mFmuXraRWt6w5UsZTNqtj4hXJA/iiOD4tuseIdLP9Lq38teH/SZTcIFCa2f+27o7hYpIsWktJKEQ==", + "version": "5.3.17", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.17.tgz", + "integrity": "sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.13", - "@smithy/querystring-builder": "^4.2.13", - "@smithy/types": "^4.14.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/querystring-builder": "^4.2.14", + "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" }, @@ -2312,14 +2303,14 @@ } }, "node_modules/@smithy/hash-blob-browser": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.14.tgz", - "integrity": "sha512-rtQ5es8r/5v4rav7q5QTsfx9CtCyzrz/g7ZZZBH2xtMmd6G/KQrLOWfSHTvFOUPlVy59RQvxeBYJaLRoybMEyA==", + "version": "4.2.15", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.15.tgz", + "integrity": "sha512-0PJ4Al3fg2nM4qKrAIxyNcApgqHAXcBkN8FeizOz69z0rb26uZ6lMESYtxegaTlXB5Hj84JfwMPavMrwDMjucA==", "license": "Apache-2.0", "dependencies": { "@smithy/chunked-blob-reader": "^5.2.2", "@smithy/chunked-blob-reader-native": "^4.2.3", - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2327,12 +2318,12 @@ } }, "node_modules/@smithy/hash-node": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.13.tgz", - "integrity": "sha512-4/oy9h0jjmY80a2gOIo75iLl8TOPhmtx4E2Hz+PfMjvx/vLtGY4TMU/35WRyH2JHPfT5CVB38u4JRow7gnmzJA==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.14.tgz", + "integrity": "sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" @@ -2342,12 +2333,12 @@ } }, "node_modules/@smithy/hash-stream-node": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.13.tgz", - "integrity": "sha512-WdQ7HwUjINXETeh6dqUeob1UHIYx8kAn9PSp1HhM2WWegiZBYVy2WXIs1lB07SZLan/udys9SBnQGt9MQbDpdg==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.14.tgz", + "integrity": "sha512-tw4GANWkZPb6+BdD4Fgucqzey2+r73Z/GRo9zklsCdwrnxxumUV83ZIaBDdudV4Ylazw3EPTiJZhpX42105ruQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -2356,12 +2347,12 @@ } }, "node_modules/@smithy/invalid-dependency": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.13.tgz", - "integrity": "sha512-jvC0RB/8BLj2SMIkY0Npl425IdnxZJxInpZJbu563zIRnVjpDMXevU3VMCRSabaLB0kf/eFIOusdGstrLJ8IDg==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.14.tgz", + "integrity": "sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2381,12 +2372,12 @@ } }, "node_modules/@smithy/md5-js": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.13.tgz", - "integrity": "sha512-cNm7I9NXolFxtS20ojROddOEpSAeI1Obq6pd1Kj5HtHws3s9Fkk8DdHDfQSs5KuxCewZuVK6UqrJnfJmiMzDuQ==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.14.tgz", + "integrity": "sha512-V2v0vx+h0iUSNG1Alt+GNBMSLGCrl9iVsdd+Ap67HPM9PN479x12V8LkuMoKImNZxn3MXeuyUjls+/7ZACZghA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -2395,13 +2386,13 @@ } }, "node_modules/@smithy/middleware-content-length": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.13.tgz", - "integrity": "sha512-IPMLm/LE4AZwu6qiE8Rr8vJsWhs9AtOdySRXrOM7xnvclp77Tyh7hMs/FRrMf26kgIe67vFJXXOSmVxS7oKeig==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.14.tgz", + "integrity": "sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2409,18 +2400,18 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.29", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.29.tgz", - "integrity": "sha512-R9Q/58U+qBiSARGWbAbFLczECg/RmysRksX6Q8BaQEpt75I7LI6WGDZnjuC9GXSGKljEbA7N118LhGaMbfrTXw==", + "version": "4.4.32", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.32.tgz", + "integrity": "sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.14", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", - "@smithy/util-middleware": "^4.2.13", + "@smithy/core": "^3.23.17", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-middleware": "^4.2.14", "tslib": "^2.6.2" }, "engines": { @@ -2428,19 +2419,19 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.0.tgz", - "integrity": "sha512-/NzISn4grj/BRFVua/xnQwF+7fakYZgimpw2dfmlPgcqecBMKxpB9g5mLYRrmBD5OrPoODokw4Vi1hrSR4zRyw==", + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.7.tgz", + "integrity": "sha512-bRt6ZImqVSeTk39Nm81K20ObIiAZ3WefY7G6+iz/0tZjs4dgRRjvRX2sgsH+zi6iDCRR/aQvQofLKxxz4rPBZg==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.14", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/service-error-classification": "^4.2.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@smithy/core": "^3.23.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/service-error-classification": "^4.3.1", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, @@ -2449,14 +2440,14 @@ } }, "node_modules/@smithy/middleware-serde": { - "version": "4.2.17", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.17.tgz", - "integrity": "sha512-0T2mcaM6v9W1xku86Dk0bEW7aEseG6KenFkPK98XNw0ZhOqOiD1MrMsdnQw9QsL3/Oa85T53iSMlm0SZdSuIEQ==", + "version": "4.2.20", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.20.tgz", + "integrity": "sha512-Lx9JMO9vArPtiChE3wbEZ5akMIDQpWQtlu90lhACQmNOXcGXRbaDywMHDzuDZ2OkZzP+9wQfZi3YJT9F67zTQQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.14", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", + "@smithy/core": "^3.23.17", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2464,12 +2455,12 @@ } }, "node_modules/@smithy/middleware-stack": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.13.tgz", - "integrity": "sha512-g72jN/sGDLyTanrCLH9fhg3oysO3f7tQa6eWWsMyn2BiYNCgjF24n4/I9wff/5XidFvjj9ilipAoQrurTUrLvw==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.14.tgz", + "integrity": "sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2477,14 +2468,14 @@ } }, "node_modules/@smithy/node-config-provider": { - "version": "4.3.13", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.13.tgz", - "integrity": "sha512-iGxQ04DsKXLckbgnX4ipElrOTk+IHgTyu0q0WssZfYhDm9CQWHmu6cOeI5wmWRxpXbBDhIIfXMWz5tPEtcVqbw==", + "version": "4.3.14", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.14.tgz", + "integrity": "sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2492,14 +2483,14 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.2.tgz", - "integrity": "sha512-/oD7u8M0oj2ZTFw7GkuuHWpIxtWdLlnyNkbrWcyVYhd5RJNDuczdkb0wfnQICyNFrVPlr8YHOhamjNy3zidhmA==", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.6.1.tgz", + "integrity": "sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.13", - "@smithy/querystring-builder": "^4.2.13", - "@smithy/types": "^4.14.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/querystring-builder": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2507,12 +2498,12 @@ } }, "node_modules/@smithy/property-provider": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.13.tgz", - "integrity": "sha512-bGzUCthxRmezuxkbu9wD33wWg9KX3hJpCXpQ93vVkPrHn9ZW6KNNdY5xAUWNuRCwQ+VyboFuWirG1lZhhkcyRQ==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.14.tgz", + "integrity": "sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2520,12 +2511,12 @@ } }, "node_modules/@smithy/protocol-http": { - "version": "5.3.13", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.13.tgz", - "integrity": "sha512-+HsmuJUF4u8POo6s8/a2Yb/AQ5t/YgLovCuHF9oxbocqv+SZ6gd8lC2duBFiCA/vFHoHQhoq7QjqJqZC6xOxxg==", + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.14.tgz", + "integrity": "sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2533,12 +2524,12 @@ } }, "node_modules/@smithy/querystring-builder": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.13.tgz", - "integrity": "sha512-tG4aOYFCZdPMjbgfhnIQ322H//ojujldp1SrHPHpBSb3NqgUp3dwiUGRJzie87hS1DYwWGqDuPaowoDF+rYCbQ==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.14.tgz", + "integrity": "sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" }, @@ -2547,12 +2538,12 @@ } }, "node_modules/@smithy/querystring-parser": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.13.tgz", - "integrity": "sha512-hqW3Q4P+CDzUyQ87GrboGMeD7XYNMOF+CuTwu936UQRB/zeYn3jys8C3w+wMkDfY7CyyyVwZQ5cNFoG0x1pYmA==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.14.tgz", + "integrity": "sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2560,24 +2551,24 @@ } }, "node_modules/@smithy/service-error-classification": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.13.tgz", - "integrity": "sha512-a0s8XZMfOC/qpqq7RCPvJlk93rWFrElH6O++8WJKz0FqnA4Y7fkNi/0mnGgSH1C4x6MFsuBA8VKu4zxFrMe5Vw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.3.1.tgz", + "integrity": "sha512-aUQuDGh760ts/8MU+APjIZhlLPKhIIfqyzZaJikLEIMrdxFvxuLYD0WxWzaYWpmLbQlXDe9p7EWM3HsBe0K6Gw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0" + "@smithy/types": "^4.14.1" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.8", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.8.tgz", - "integrity": "sha512-VZCZx2bZasxdqxVgEAhREvDSlkatTPnkdWy1+Kiy8w7kYPBosW0V5IeDwzDUMvWBt56zpK658rx1cOBFOYaPaw==", + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.9.tgz", + "integrity": "sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2585,16 +2576,16 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.3.13", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.13.tgz", - "integrity": "sha512-YpYSyM0vMDwKbHD/JA7bVOF6kToVRpa+FM5ateEVRpsTNu564g1muBlkTubXhSKKYXInhpADF46FPyrZcTLpXg==", + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.14.tgz", + "integrity": "sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==", "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^4.2.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-middleware": "^4.2.13", + "@smithy/util-middleware": "^4.2.14", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" @@ -2604,17 +2595,17 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.12.9", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.9.tgz", - "integrity": "sha512-ovaLEcTU5olSeHcRXcxV6viaKtpkHZumn6Ps0yn7dRf2rRSfy794vpjOtrWDO0d1auDSvAqxO+lyhERSXQ03EQ==", + "version": "4.12.13", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.13.tgz", + "integrity": "sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.14", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", - "@smithy/util-stream": "^4.5.22", + "@smithy/core": "^3.23.17", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-stream": "^4.5.25", "tslib": "^2.6.2" }, "engines": { @@ -2622,9 +2613,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.0.tgz", - "integrity": "sha512-OWgntFLW88kx2qvf/c/67Vno1yuXm/f9M7QFAtVkkO29IJXGBIg0ycEaBTH0kvCtwmvZxRujrgP5a86RvsXJAQ==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz", + "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2634,13 +2625,13 @@ } }, "node_modules/@smithy/url-parser": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.13.tgz", - "integrity": "sha512-2G03yoboIRZlZze2+PT4GZEjgwQsJjUgn6iTsvxA02bVceHR6vp4Cuk7TUnPFWKF+ffNUk3kj4COwkENS2K3vw==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.14.tgz", + "integrity": "sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.2.13", - "@smithy/types": "^4.14.0", + "@smithy/querystring-parser": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2711,14 +2702,14 @@ } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.45", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.45.tgz", - "integrity": "sha512-ag9sWc6/nWZAuK3Wm9KlFJUnRkXLrXn33RFjIAmCTFThqLHY+7wCst10BGq56FxslsDrjhSie46c8OULS+BiIw==", + "version": "4.3.49", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.49.tgz", + "integrity": "sha512-a5bNrdiONYB/qE2BuKegvUMd/+ZDwdg4vsNuuSzYE8qs2EYAdK9CynL+Rzn29PbPiUqoz/cbpRbcLzD5lEevHw==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", + "@smithy/property-provider": "^4.2.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2726,17 +2717,17 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.49", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.49.tgz", - "integrity": "sha512-jlN6vHwE8gY5AfiFBavtD3QtCX2f7lM3BKkz7nFKSNfFR5nXLXLg6sqXTJEEyDwtxbztIDBQCfjsGVXlIru2lQ==", + "version": "4.2.54", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.54.tgz", + "integrity": "sha512-g1cvrJvOnzeJgEdf7AE4luI7gp6L8weE0y9a9wQUSGtjb8QRHDbCJYuE4Sy0SD9N8RrnNPFsPltAz/OSoBR9Zw==", "license": "Apache-2.0", "dependencies": { - "@smithy/config-resolver": "^4.4.14", - "@smithy/credential-provider-imds": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/property-provider": "^4.2.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", + "@smithy/config-resolver": "^4.4.17", + "@smithy/credential-provider-imds": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2744,13 +2735,13 @@ } }, "node_modules/@smithy/util-endpoints": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.4.tgz", - "integrity": "sha512-BKoR/ubPp9KNKFxPpg1J28N1+bgu8NGAtJblBP7yHy8yQPBWhIAv9+l92SlQLpolGm71CVO+btB60gTgzT0wog==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.4.2.tgz", + "integrity": "sha512-a55Tr+3OKld4TTtnT+RhKOQHyPxm3j/xL4OR83WBUhLJaKDS9dnJ7arRMOp3t31dcLhApwG9bgvrRXBHlLdIkg==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.13", - "@smithy/types": "^4.14.0", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2770,12 +2761,12 @@ } }, "node_modules/@smithy/util-middleware": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.13.tgz", - "integrity": "sha512-GTooyrlmRTqvUen4eK7/K1p6kryF7bnDfq6XsAbIsf2mo51B/utaH+XThY6dKgNCWzMAaH/+OLmqaBuLhLWRow==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.14.tgz", + "integrity": "sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2783,13 +2774,13 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.0.tgz", - "integrity": "sha512-tSOPQNT/4KfbvqeMovWC3g23KSYy8czHd3tlN+tOYVNIDLSfxIsrPJihYi5TpNcoV789KWtgChUVedh2y6dDPg==", + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.8.tgz", + "integrity": "sha512-LUIxbTBi+OpvXpg91poGA6BdyoleMDLnfXjVDqyi2RvZmTveY5loE/FgYUBCR5LU2BThW2SoZRh8dTIIy38IPw==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.2.13", - "@smithy/types": "^4.14.0", + "@smithy/service-error-classification": "^4.3.1", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2797,14 +2788,14 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.5.22", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.22.tgz", - "integrity": "sha512-3H8iq/0BfQjUs2/4fbHZ9aG9yNzcuZs24LPkcX1Q7Z+qpqaGM8+qbGmE8zo9m2nCRgamyvS98cHdcWvR6YUsew==", + "version": "4.5.25", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.25.tgz", + "integrity": "sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/types": "^4.14.0", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", @@ -2841,12 +2832,12 @@ } }, "node_modules/@smithy/util-waiter": { - "version": "4.2.15", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.15.tgz", - "integrity": "sha512-oUt9o7n8hBv3BL56sLSneL0XeigZSuem0Hr78JaoK33D9oKieyCvVP8eTSe3j7g2mm/S1DvzxKieG7JEWNJUNg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.3.0.tgz", + "integrity": "sha512-JyjYmLAfS+pdxF92o4yLgEoy0zhayKTw73FU1aofLWwLcJw7iSqIY2exGmMTrl/lmZugP5p/zxdFSippJDfKWA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -2865,10 +2856,16 @@ "node": ">=18.0.0" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "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", - "integrity": "sha512-2uH2WB0H98TOGDtaFWhxIcR42Dro/VB7VDZanz/4bVJsqioIue1m3TUqu3xciDm2W9r+1LXQvYNsYbQfWmD+uQ==", + "version": "2.105.3", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.105.3.tgz", + "integrity": "sha512-hMFuzP++mjRfe0/BUq4/e82CXIDgyjUgg0khLN8waol/gzoM1t2iGmhfJSGvQHQ1dr3XqWpP6ThAw4bLHMot5Q==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -2878,9 +2875,9 @@ } }, "node_modules/@supabase/functions-js": { - "version": "2.102.1", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.102.1.tgz", - "integrity": "sha512-UcrcKTPnAIo+Yp9Jjq9XXwFbsmgRYY637mwka9ZjmTIWcX/xr1pote4OVvaGQycVY1KTiQgjMvpC0Q0yJhRq3w==", + "version": "2.105.3", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.105.3.tgz", + "integrity": "sha512-KyutUwLLUZ9fRXsiFACL6lq7akBVHFl0fnqQnrxjbsPco8jeb4EyirQuvr52QCLnikzjMRC0uxAHOSM54aDrZA==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -2890,15 +2887,15 @@ } }, "node_modules/@supabase/phoenix": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.0.tgz", - "integrity": "sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.1.tgz", + "integrity": "sha512-hWGJkDAfWUNY8k0C080u3sGNFd2ncl9erhKgP7hnGkgJWEfT5Pd/SXal4QmWXBECVlZrannMAc9sBaaRyWpiUA==", "license": "MIT" }, "node_modules/@supabase/postgrest-js": { - "version": "2.102.1", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.102.1.tgz", - "integrity": "sha512-InLvXKAYf8BIqiv9jWOYudWB3rU8A9uMbcip5BQ5sLLNPrbO1Ekkr79OvlhZBgMNSppxVyC7wPPGzLxMcTZhlA==", + "version": "2.105.3", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.105.3.tgz", + "integrity": "sha512-jFVYRHcri0ZMcTzKpQ2r2wWOB8/rPsbj92kxmCmVJUiRrdgiMtuYlkS06Fhs8UJZhEOL0UpGhh06XDwh8JwtBQ==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -2908,12 +2905,12 @@ } }, "node_modules/@supabase/realtime-js": { - "version": "2.102.1", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.102.1.tgz", - "integrity": "sha512-h2fCumib/v6u7XMwSPgxnpfimjX4xCEayUHrxWLC7UurfQjUZJ0pmJDgm6yj80DnUerxuulRghwm5zXYysFG/Q==", + "version": "2.105.3", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.105.3.tgz", + "integrity": "sha512-L+qPiJlq1RKh3QD2fORGCFo2RKDKlvG9mjvPtUEQJ2tMixrx70VIV6j8BdWzQkbc1Nao6mvTWajyDhX3TFgljw==", "license": "MIT", "dependencies": { - "@supabase/phoenix": "^0.4.0", + "@supabase/phoenix": "^0.4.1", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" @@ -2923,9 +2920,9 @@ } }, "node_modules/@supabase/storage-js": { - "version": "2.102.1", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.102.1.tgz", - "integrity": "sha512-eCL9T4Xpe40nmKlkUJ7Zq/hk34db1xPiT0WL3Iv5MbJqHuCAe5TxhV8Rjqd6DNZrzjtfYObZtYl9jKJaHrivqw==", + "version": "2.105.3", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.105.3.tgz", + "integrity": "sha512-M7oPCCcHim/FsR6rKIs10Nd9mW051N2SQvA27jiVLa7oQMFFb7faX5dCQRV4GS5QeFsBcV5J/fWl4Ppoaw8cBQ==", "license": "MIT", "dependencies": { "iceberg-js": "^0.8.1", @@ -2936,16 +2933,16 @@ } }, "node_modules/@supabase/supabase-js": { - "version": "2.102.1", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.102.1.tgz", - "integrity": "sha512-bChxPVeLDnYN9M2d/u4fXsvylwSQG5grAl+HN8f+ZD9a9PuVU+Ru+xGmEsk+b9Iz3rJC9ZQnQUJYQ28fApdWYA==", + "version": "2.105.3", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.105.3.tgz", + "integrity": "sha512-5Dm9+I61LAWwjw+0zcqXhSmTxUJaYHBPyHwMCIBH4TBUNwDn2pYUIsi6oUu0I5r9HtLtaFl7w4wa+DV9gRsbDg==", "license": "MIT", "dependencies": { - "@supabase/auth-js": "2.102.1", - "@supabase/functions-js": "2.102.1", - "@supabase/postgrest-js": "2.102.1", - "@supabase/realtime-js": "2.102.1", - "@supabase/storage-js": "2.102.1" + "@supabase/auth-js": "2.105.3", + "@supabase/functions-js": "2.105.3", + "@supabase/postgrest-js": "2.105.3", + "@supabase/realtime-js": "2.105.3", + "@supabase/storage-js": "2.105.3" }, "engines": { "node": ">=20.0.0" @@ -3033,18 +3030,18 @@ } }, "node_modules/@types/node": { - "version": "22.19.17", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", - "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "version": "22.19.18", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.18.tgz", + "integrity": "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, "node_modules/@types/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", "dev": true, "license": "MIT" }, @@ -3104,9 +3101,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.8.12", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", - "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==", + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -3167,12 +3164,6 @@ } } }, - "node_modules/ajv/node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, "node_modules/append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", @@ -3236,9 +3227,9 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -3249,7 +3240,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.14.0", + "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -3259,6 +3250,36 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/body-parser/node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", @@ -3484,18 +3505,18 @@ } }, "node_modules/docx/node_modules/@types/node": { - "version": "25.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", - "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "version": "25.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz", + "integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==", "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~7.19.0" } }, "node_modules/docx/node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "license": "MIT" }, "node_modules/dom-serializer": { @@ -3554,9 +3575,9 @@ } }, "node_modules/dotenv": { - "version": "17.4.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", - "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -3779,12 +3800,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz", - "integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==", + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", + "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -3803,9 +3824,9 @@ "license": "MIT" }, "node_modules/fast-deep-equal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", - "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, "node_modules/fast-diff": { @@ -3814,10 +3835,16 @@ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "license": "Apache-2.0" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -3831,9 +3858,9 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-builder": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", - "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.9.tgz", + "integrity": "sha512-jcyKVSEX13iseJqg7n/KWw+xnu/7fdrZ333Fac54KjHDIELVCfDDJXYIm6DTJ0Su4gSzrhqiK0DzY/wZbF40mw==", "funding": [ { "type": "github", @@ -3846,9 +3873,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.1.tgz", - "integrity": "sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA==", + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", "funding": [ { "type": "github", @@ -3858,7 +3885,7 @@ "license": "MIT", "dependencies": { "@nodable/entities": "^2.1.0", - "fast-xml-builder": "^1.1.5", + "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, @@ -4027,9 +4054,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.7", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", - "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", "dev": true, "license": "MIT", "dependencies": { @@ -4100,9 +4127,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4112,9 +4139,9 @@ } }, "node_modules/hono": { - "version": "4.12.16", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.16.tgz", - "integrity": "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==", + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -4245,9 +4272,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" @@ -4550,9 +4577,9 @@ } }, "node_modules/nanoid": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.7.tgz", - "integrity": "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==", + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", + "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", "funding": [ { "type": "github", @@ -4773,9 +4800,9 @@ } }, "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" @@ -4794,22 +4821,22 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "7.5.5", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", - "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", + "version": "7.5.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz", + "integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", + "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", + "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" }, @@ -4855,41 +4882,34 @@ } }, "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", + "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.10" } }, - "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", - "peer": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, "engines": { "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "scheduler": "^0.27.0" }, - "peerDependencies": { - "react": "^19.2.4" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/react-promise-suspense": { @@ -4901,6 +4921,12 @@ "fast-deep-equal": "^2.0.1" } }, + "node_modules/react-promise-suspense/node_modules/fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==", + "license": "MIT" + }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -5046,13 +5072,6 @@ "node": ">=11.0.0" } }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT", - "peer": true - }, "node_modules/selderee": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", @@ -5163,13 +5182,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -5221,6 +5240,16 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -5254,9 +5283,9 @@ "license": "MIT" }, "node_modules/strnum": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", - "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", "funding": [ { "type": "github", @@ -5481,9 +5510,9 @@ } }, "node_modules/zod": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz", - "integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/backend/package.json b/backend/package.json index 354a389b..37a0be7d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,12 +6,13 @@ "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", + "test": "node --import tsx --test test/**/*.test.ts", "start": "node dist/index.js" }, "dependencies": { - "@anthropic-ai/sdk": "^0.90.0", - "@aws-sdk/client-s3": "^3.787.0", - "@aws-sdk/s3-request-presigner": "^3.787.0", + "@anthropic-ai/sdk": "^0.95.1", + "@aws-sdk/client-s3": "^3.1044.0", + "@aws-sdk/s3-request-presigner": "^3.1044.0", "@google/genai": "^1.50.1", "@modelcontextprotocol/sdk": "^1.29.0", "@supabase/supabase-js": "^2.49.4", @@ -20,7 +21,7 @@ "dotenv": "^17.4.1", "express": "^4.21.2", "fast-diff": "^1.3.0", - "fast-xml-parser": "^5.7.1", + "fast-xml-parser": "^5.7.3", "jszip": "^3.10.1", "libreoffice-convert": "^1.6.0", "mammoth": "^1.9.0", @@ -28,6 +29,9 @@ "pdfjs-dist": "^4.10.38", "resend": "^4.5.1" }, + "overrides": { + "@xmldom/xmldom": "0.8.13" + }, "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", diff --git a/backend/src/lib/access.ts b/backend/src/lib/access.ts index a139888b..7ea4defa 100644 --- a/backend/src/lib/access.ts +++ b/backend/src/lib/access.ts @@ -100,12 +100,16 @@ export async function ensureReviewAccess( userId: string, userEmail: string | null | undefined, db: Db, -): Promise<{ ok: true; isOwner: boolean } | { ok: false }> { - if (review.user_id === userId) return { ok: true, isOwner: true }; +): Promise< + | { ok: true; isOwner: boolean; via: "owner" | "project" | "direct" } + | { ok: false } +> { + if (review.user_id === userId) + return { ok: true, isOwner: true, via: "owner" }; const email = (userEmail ?? "").toLowerCase(); - if (email && Array.isArray(review.shared_with)) { + if (!review.project_id && email && Array.isArray(review.shared_with)) { if (review.shared_with.some((e) => (e ?? "").toLowerCase() === email)) { - return { ok: true, isOwner: false }; + return { ok: true, isOwner: false, via: "direct" }; } } if (!review.project_id) return { ok: false }; @@ -115,10 +119,16 @@ export async function ensureReviewAccess( userEmail, db, ); - if (access.ok) return { ok: true, isOwner: false }; + if (access.ok) return { ok: true, isOwner: false, via: "project" }; return { ok: false }; } +export function canEditReview( + access: { ok: true; isOwner: boolean; via?: "owner" | "project" | "direct" }, +): boolean { + return access.isOwner || access.via === "project"; +} + /** * Returns the set of project IDs the user can access — own projects plus * any project where their email is in `shared_with`. Used to scope chat diff --git a/backend/src/lib/apiKeys.ts b/backend/src/lib/apiKeys.ts new file mode 100644 index 00000000..7427eb2b --- /dev/null +++ b/backend/src/lib/apiKeys.ts @@ -0,0 +1,78 @@ +import crypto from "crypto"; + +const ENCRYPTED_PREFIX = "enc:v1:"; + +function getEncryptionSecret(): string { + const secret = process.env.USER_API_KEYS_ENCRYPTION_KEY; + if (!secret?.trim()) { + throw new Error( + "USER_API_KEYS_ENCRYPTION_KEY is required to store user API keys", + ); + } + return secret.trim(); +} + +function keyFromSecret(secret: string): Buffer { + return crypto.createHash("sha256").update(secret, "utf8").digest(); +} + +function b64url(buf: Buffer): string { + return buf.toString("base64url"); +} + +function fromB64url(value: string): Buffer { + return Buffer.from(value, "base64url"); +} + +export function isEncryptedApiKey(value: string | null | undefined): boolean { + return typeof value === "string" && value.startsWith(ENCRYPTED_PREFIX); +} + +export function encryptApiKey(value: string | null | undefined): string | null { + const plaintext = value?.trim(); + if (!plaintext) return null; + + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv( + "aes-256-gcm", + keyFromSecret(getEncryptionSecret()), + iv, + ); + const ciphertext = Buffer.concat([ + cipher.update(plaintext, "utf8"), + cipher.final(), + ]); + const tag = cipher.getAuthTag(); + return `${ENCRYPTED_PREFIX}${b64url(iv)}.${b64url(tag)}.${b64url(ciphertext)}`; +} + +export function decryptApiKey(value: string | null | undefined): string | null { + if (!value) return null; + if (!isEncryptedApiKey(value)) { + // Legacy plaintext values are supported so existing deployments can + // continue while getUserApiKeys opportunistically rewrites them. + return value; + } + + const payload = value.slice(ENCRYPTED_PREFIX.length); + const [ivRaw, tagRaw, ciphertextRaw] = payload.split("."); + if (!ivRaw || !tagRaw || !ciphertextRaw) { + throw new Error("Stored API key has an invalid encrypted format"); + } + + const decipher = crypto.createDecipheriv( + "aes-256-gcm", + keyFromSecret(getEncryptionSecret()), + fromB64url(ivRaw), + ); + decipher.setAuthTag(fromB64url(tagRaw)); + const plaintext = Buffer.concat([ + decipher.update(fromB64url(ciphertextRaw)), + decipher.final(), + ]); + return plaintext.toString("utf8"); +} + +export function hasStoredApiKey(value: string | null | undefined): boolean { + return typeof value === "string" && value.trim().length > 0; +} diff --git a/backend/src/lib/chatTools.ts b/backend/src/lib/chatTools.ts index e1db1977..b9e69ecc 100644 --- a/backend/src/lib/chatTools.ts +++ b/backend/src/lib/chatTools.ts @@ -1142,18 +1142,10 @@ async function readDocumentContent( opts?: { emitEvents?: boolean }, ): Promise { const emitEvents = opts?.emitEvents ?? true; - console.log(`[read_document] called with docLabel="${docLabel}"`); const docInfo = docStore.get(docLabel); if (!docInfo) { - console.log( - `[read_document] MISS — docLabel "${docLabel}" not in docStore. Known labels:`, - Array.from(docStore.keys()), - ); return "Document not found."; } - console.log( - `[read_document] docInfo: filename="${docInfo.filename}", file_type="${docInfo.file_type}", storage_path="${docInfo.storage_path}"`, - ); const documentId = docIndex?.[docLabel]?.document_id; const emitDocRead = () => { @@ -1187,93 +1179,39 @@ async function readDocumentContent( current.bytes.byteOffset + current.bytes.byteLength, ) as ArrayBuffer; sourcePath = current.storage_path; - console.log( - `[read_document] using current version path="${sourcePath}" (bytes=${raw.byteLength})`, - ); - } else { - console.log( - `[read_document] loadCurrentVersionBytes returned null for documentId="${documentId}", falling back to original storage_path`, - ); } } if (!raw) { raw = await downloadFile(docInfo.storage_path); - if (raw) { - console.log( - `[read_document] fallback download from storage_path="${docInfo.storage_path}" (bytes=${raw.byteLength})`, - ); - } } if (!raw) { - console.log( - `[read_document] FAILED to download any bytes for docLabel="${docLabel}" (tried path="${sourcePath}")`, - ); emitDocRead(); return "Document could not be read."; } - // Log the first 8 bytes so we can identify real file format regardless - // of the declared file_type. Valid .docx starts with "PK\x03\x04" - // (zip). Legacy .doc starts with "\xD0\xCF\x11\xE0" (OLE/CFB). - // %PDF-1 is a PDF even if mislabeled. Truncated uploads show as all-zero. - { - const head = Buffer.from(raw).subarray(0, 8); - const hex = head.toString("hex"); - const ascii = head - .toString("binary") - .replace(/[^\x20-\x7e]/g, "."); - console.log( - `[read_document] magic bytes hex=${hex} ascii="${ascii}" for filename="${docInfo.filename}"`, - ); - } let text: string; if (docInfo.file_type === "pdf") { text = await extractPdfText(raw); - console.log( - `[read_document] pdf extracted length=${text.length} for filename="${docInfo.filename}"`, - ); } else if (docInfo.file_type === "docx") { // Use the same flattening as the edit_document matcher so the // LLM sees exactly the characters it can anchor against. text = await extractDocxBodyText(Buffer.from(raw)); - console.log( - `[read_document] docx extractDocxBodyText length=${text.length} for filename="${docInfo.filename}"`, - ); if (!text) { - console.log( - `[read_document] docx accepted-view extractor returned empty, falling back to mammoth for filename="${docInfo.filename}"`, - ); const mammoth = await import("mammoth"); const result = await mammoth.extractRawText({ buffer: Buffer.from(raw), }); text = result.value; - console.log( - `[read_document] docx mammoth fallback length=${text.length} for filename="${docInfo.filename}"`, - ); } } else { - console.log( - `[read_document] unknown file_type="${docInfo.file_type}" for filename="${docInfo.filename}", trying mammoth`, - ); const mammoth = await import("mammoth"); const result = await mammoth.extractRawText({ buffer: Buffer.from(raw), }); text = result.value; - console.log( - `[read_document] mammoth length=${text.length} for filename="${docInfo.filename}"`, - ); } - console.log( - `[read_document] DONE filename="${docInfo.filename}" finalTextLength=${text.length} firstChars=${JSON.stringify(text.slice(0, 120))}`, - ); emitDocRead(); return text; } catch (err) { - console.log( - `[read_document] THREW for docLabel="${docLabel}" filename="${docInfo.filename}":`, - err, - ); if (emitEvents) write(`data: ${JSON.stringify({ type: "doc_read", filename: docInfo.filename })}\n\n`); return "Document could not be read."; @@ -2194,7 +2132,6 @@ export async function runToolCalls( } else if (tc.function.name === "generate_docx") { const title = args.title as string; const landscape = !!(args.landscape); - console.log(`[generate_docx] title="${title}" landscape=${landscape} args.landscape=${args.landscape}`); const previewFilename = `${(title.replace(/[^a-zA-Z0-9 _-]/g, "").trim().slice(0, 64) || "document")}.docx`; write(`data: ${JSON.stringify({ type: "doc_created_start", filename: previewFilename })}\n\n`); const result = await generateDocx( @@ -2407,14 +2344,6 @@ export async function runLLMStream(params: { const rawMsgs = apiMessages as { role: string; content: string | null }[]; const systemPrompt = rawMsgs[0]?.role === "system" ? (rawMsgs[0].content ?? "") : ""; - console.log( - "[runLLMStream] system prompt:\n" + - "─".repeat(80) + - "\n" + - systemPrompt + - "\n" + - "─".repeat(80), - ); const chatMessages: LlmMessage[] = rawMsgs .filter((m) => m.role !== "system") .map((m) => ({ @@ -2798,14 +2727,6 @@ export async function buildDocContext( } } - console.log( - "[buildDocContext] available docs:", - Object.entries(docIndex).map(([label, info]) => ({ - label, - filename: info.filename, - document_id: info.document_id, - })), - ); return { docIndex, docStore }; } @@ -2876,15 +2797,6 @@ export async function buildProjectDocContext( if (path) folderPaths.set(docLabel, path); } - console.log( - "[buildProjectDocContext] available docs:", - Object.entries(docIndex).map(([label, info]) => ({ - label, - filename: info.filename, - document_id: info.document_id, - folder: folderPaths.get(label) ?? null, - })), - ); return { docIndex, docStore, folderPaths }; } diff --git a/backend/src/lib/downloadTokens.ts b/backend/src/lib/downloadTokens.ts index de2240af..b0f45372 100644 --- a/backend/src/lib/downloadTokens.ts +++ b/backend/src/lib/downloadTokens.ts @@ -10,16 +10,14 @@ import crypto from "crypto"; */ function getSecret(): string { - const secret = - process.env.DOWNLOAD_SIGNING_SECRET ?? - process.env.SUPABASE_SECRET_KEY; - if (!secret) { + const secret = process.env.DOWNLOAD_SIGNING_SECRET; + if (!secret?.trim()) { throw new Error( - "DOWNLOAD_SIGNING_SECRET (or SUPABASE_SECRET_KEY as a fallback) must be set. " + + "DOWNLOAD_SIGNING_SECRET is required. " + "Generate a strong random value (e.g. `openssl rand -hex 32`) and set it in the environment.", ); } - return secret; + return secret.trim(); } function b64urlEncode(buf: Buffer): string { diff --git a/backend/src/lib/llm/gemini.ts b/backend/src/lib/llm/gemini.ts index ee43d617..57e62d7e 100644 --- a/backend/src/lib/llm/gemini.ts +++ b/backend/src/lib/llm/gemini.ts @@ -77,7 +77,6 @@ export async function streamGemini( let sawThinking = false; for await (const chunk of stream) { - console.log("[gemini stream chunk]", JSON.stringify(chunk, null, 2)); const parts = (chunk as { candidates?: { content?: { parts?: GeminiPart[] } }[] }) .candidates?.[0]?.content?.parts ?? []; diff --git a/backend/src/lib/upload.ts b/backend/src/lib/upload.ts index caa44dbf..7182bf6a 100644 --- a/backend/src/lib/upload.ts +++ b/backend/src/lib/upload.ts @@ -1,10 +1,12 @@ import type { RequestHandler } from "express"; +import JSZip from "jszip"; import multer from "multer"; export const MAX_UPLOAD_SIZE_BYTES = 100 * 1024 * 1024; export const MAX_UPLOAD_SIZE_MB = Math.round( MAX_UPLOAD_SIZE_BYTES / (1024 * 1024), ); +const MAX_DOCX_UNCOMPRESSED_BYTES = 200 * 1024 * 1024; const memoryUpload = multer({ storage: multer.memoryStorage(), @@ -34,3 +36,80 @@ export function singleFileUpload(fieldName: string): RequestHandler { }); }; } + +export const ALLOWED_DOCUMENT_TYPES = new Set(["pdf", "docx", "doc"]); + +export type ValidatedDocumentUpload = { + suffix: "pdf" | "docx" | "doc"; + contentType: string; +}; + +export async function validateDocumentUpload( + file: Express.Multer.File, +): Promise { + const suffix = getFileSuffix(file.originalname); + if (!suffix || !ALLOWED_DOCUMENT_TYPES.has(suffix)) { + throw new Error( + `Unsupported file type: ${suffix}. Allowed: pdf, docx, doc`, + ); + } + + if (suffix === "pdf") { + if (!file.buffer.subarray(0, 5).equals(Buffer.from("%PDF-"))) { + throw new Error("Uploaded PDF does not have a valid PDF header."); + } + return { suffix, contentType: "application/pdf" }; + } + + if (suffix === "doc") { + const oleMagic = Buffer.from([ + 0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1, + ]); + if (!file.buffer.subarray(0, 8).equals(oleMagic)) { + throw new Error("Uploaded DOC does not have a valid legacy Word header."); + } + return { + suffix, + contentType: "application/msword", + }; + } + + try { + const zip = await JSZip.loadAsync(file.buffer); + if (!zip.file("[Content_Types].xml") || !zip.file("word/document.xml")) { + throw new Error("Uploaded DOCX is missing required Word document parts."); + } + + let totalUncompressed = 0; + zip.forEach((_path, entry) => { + const data = entry as unknown as { + _data?: { uncompressedSize?: number }; + }; + totalUncompressed += data._data?.uncompressedSize ?? 0; + }); + if (totalUncompressed > MAX_DOCX_UNCOMPRESSED_BYTES) { + throw new Error("Uploaded DOCX expands beyond the allowed size."); + } + } catch (err) { + if (err instanceof Error && err.message.startsWith("Uploaded DOCX")) { + throw err; + } + throw new Error("Uploaded DOCX is not a valid Word archive."); + } + + return { + suffix, + contentType: + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }; +} + +function getFileSuffix(filename: string): ValidatedDocumentUpload["suffix"] | null { + const suffix = filename.includes(".") + ? filename.split(".").pop()!.toLowerCase() + : ""; + if (suffix === "pdf" || suffix === "docx" || suffix === "doc") { + return suffix; + } + return null; +} diff --git a/backend/src/lib/userSettings.ts b/backend/src/lib/userSettings.ts index 201da3d4..6f953a80 100644 --- a/backend/src/lib/userSettings.ts +++ b/backend/src/lib/userSettings.ts @@ -1,4 +1,9 @@ import { createServerSupabase } from "./supabase"; +import { + decryptApiKey, + encryptApiKey, + isEncryptedApiKey, +} from "./apiKeys"; import { resolveModel, DEFAULT_TITLE_MODEL, @@ -33,11 +38,7 @@ export async function getUserModelSettings( .eq("user_id", userId) .single(); - const api_keys: UserApiKeys = { - claude: data?.claude_api_key ?? null, - gemini: data?.gemini_api_key ?? null, - openrouter: data?.openrouter_api_key ?? null, - }; + const api_keys = await decryptAndUpgradeApiKeys(userId, data, client); return { title_model: resolveTitleModel(api_keys), @@ -56,9 +57,49 @@ export async function getUserApiKeys( .select("claude_api_key, gemini_api_key, openrouter_api_key") .eq("user_id", userId) .single(); - return { - claude: data?.claude_api_key ?? null, - gemini: data?.gemini_api_key ?? null, - openrouter: data?.openrouter_api_key ?? null, + return decryptAndUpgradeApiKeys(userId, data, client); +} + +async function decryptAndUpgradeApiKeys( + userId: string, + data: + | { + claude_api_key?: string | null; + gemini_api_key?: string | null; + openrouter_api_key?: string | null; + } + | null, + client: ReturnType, +): Promise { + const storedClaude = data?.claude_api_key ?? null; + const storedGemini = data?.gemini_api_key ?? null; + const storedOpenrouter = data?.openrouter_api_key ?? null; + const apiKeys: UserApiKeys = { + claude: decryptApiKey(storedClaude), + gemini: decryptApiKey(storedGemini), + openrouter: decryptApiKey(storedOpenrouter), }; + + const updates: Record = {}; + if (apiKeys.claude && storedClaude && !isEncryptedApiKey(storedClaude)) { + updates.claude_api_key = encryptApiKey(apiKeys.claude)!; + } + if (apiKeys.gemini && storedGemini && !isEncryptedApiKey(storedGemini)) { + updates.gemini_api_key = encryptApiKey(apiKeys.gemini)!; + } + if ( + apiKeys.openrouter && + storedOpenrouter && + !isEncryptedApiKey(storedOpenrouter) + ) { + updates.openrouter_api_key = encryptApiKey(apiKeys.openrouter)!; + } + if (Object.keys(updates).length > 0) { + await client + .from("user_profiles") + .update({ ...updates, updated_at: new Date().toISOString() }) + .eq("user_id", userId); + } + + return apiKeys; } diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index b576fb84..6afcd54a 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -12,7 +12,7 @@ import { } from "../lib/chatTools"; import { completeText } from "../lib/llm"; import { getUserApiKeys, getUserModelSettings } from "../lib/userSettings"; -import { checkProjectAccess } from "../lib/access"; +import { checkProjectAccess, listAccessibleProjectIds } from "../lib/access"; import { closeMcpServers, loadEnabledMcpServersForUser, @@ -28,6 +28,7 @@ export const chatRouter = Router(); // listed per-project via GET /projects/:projectId/chats. chatRouter.get("/", requireAuth, async (req, res) => { const userId = res.locals.userId as string; + const userEmail = res.locals.userEmail as string | undefined; const db = createServerSupabase(); const { data: ownProjects, error: projErr } = await db @@ -50,7 +51,16 @@ chatRouter.get("/", requireAuth, async (req, res) => { .or(filter) .order("created_at", { ascending: false }); if (error) return void res.status(500).json({ detail: error.message }); - res.json(data ?? []); + const accessibleProjectIds = new Set( + await listAccessibleProjectIds(userId, userEmail, db), + ); + res.json( + (data ?? []).filter((chat) => { + const projectId = chat.project_id as string | null; + if (!projectId) return chat.user_id === userId; + return accessibleProjectIds.has(projectId); + }), + ); }); // POST /chat/create @@ -59,13 +69,11 @@ chatRouter.post("/create", requireAuth, async (req, res) => { const userEmail = res.locals.userEmail as string | undefined; const projectId: string | null = req.body.project_id ?? null; const db = createServerSupabase(); - if (projectId) { const access = await checkProjectAccess(projectId, userId, userEmail, db); if (!access.ok) return void res.status(404).json({ detail: "Project not found" }); } - const { data, error } = await db .from("chats") .insert({ user_id: userId, project_id: projectId ?? undefined }) @@ -90,9 +98,10 @@ chatRouter.get("/:chatId", requireAuth, async (req, res) => { .single(); if (error || !chat) return void res.status(404).json({ detail: "Chat not found" }); - // Owner of the chat OR a member of the chat's project can view it. - let canView = chat.user_id === userId; - if (!canView && chat.project_id) { + // Standalone chats stay owner-only. Project chats require current project + // access so revoked shares cannot keep using old chat IDs. + let canView = false; + if (chat.project_id) { const access = await checkProjectAccess( chat.project_id, userId, @@ -100,6 +109,8 @@ chatRouter.get("/:chatId", requireAuth, async (req, res) => { db, ); canView = access.ok; + } else { + canView = chat.user_id === userId; } if (!canView) return void res.status(404).json({ detail: "Chat not found" }); @@ -286,8 +297,8 @@ chatRouter.post("/:chatId/generate-title", requireAuth, async (req, res) => { if (error || !chat) return void res.status(404).json({ detail: "Chat not found" }); - let canTitle = chat.user_id === userId; - if (!canTitle && chat.project_id) { + let canTitle = false; + if (chat.project_id) { const access = await checkProjectAccess( chat.project_id, userId, @@ -295,6 +306,8 @@ chatRouter.post("/:chatId/generate-title", requireAuth, async (req, res) => { db, ); canTitle = access.ok; + } else { + canTitle = chat.user_id === userId; } if (!canTitle) return void res.status(404).json({ detail: "Chat not found" }); @@ -335,28 +348,24 @@ chatRouter.post("/", requireAuth, async (req, res) => { model?: string; }; - console.log("[chat/stream] incoming request", { - userId, - chat_id, - project_id, - model, - messageCount: messages?.length, - }); - const userEmail = res.locals.userEmail as string | undefined; const db = createServerSupabase(); let chatId = chat_id ?? null; let chatTitle: string | null = null; + let effectiveProjectId: string | null = project_id ?? null; if (chatId) { - // Either chat owner OR a member of the chat's project can post. + // Standalone chats stay owner-only. Project chats require current + // project access, using the project_id stored on the chat row. const { data: existing } = await db .from("chats") .select("id, title, user_id, project_id") .eq("id", chatId) .single(); - let canUse = !!existing && existing.user_id === userId; - if (!canUse && existing?.project_id) { + if (!existing) + return void res.status(404).json({ detail: "Chat not found" }); + let canUse = false; + if (existing.project_id) { const access = await checkProjectAccess( existing.project_id, userId, @@ -364,9 +373,13 @@ chatRouter.post("/", requireAuth, async (req, res) => { db, ); canUse = access.ok; + } else { + canUse = existing.user_id === userId; } - if (!canUse || !existing) chatId = null; - else chatTitle = existing.title; + if (!canUse) + return void res.status(404).json({ detail: "Chat not found" }); + chatTitle = existing.title; + effectiveProjectId = (existing.project_id as string | null) ?? null; } if (!chatId) { @@ -384,9 +397,10 @@ chatRouter.post("/", requireAuth, async (req, res) => { .status(404) .json({ detail: "Project not found" }); } + effectiveProjectId = project_id ?? null; const { data: newChat, error } = await db .from("chats") - .insert({ user_id: userId, project_id: project_id ?? null }) + .insert({ user_id: userId, project_id: effectiveProjectId }) .select("id, title") .single(); if (error || !newChat) { @@ -399,8 +413,6 @@ chatRouter.post("/", requireAuth, async (req, res) => { chatTitle = newChat.title; } - console.log("[chat/stream] resolved chatId", chatId); - const lastUser = [...messages].reverse().find((m) => m.role === "user"); if (lastUser) { await db.from("chat_messages").insert({ @@ -432,12 +444,6 @@ chatRouter.post("/", requireAuth, async (req, res) => { const workflowStore = await buildWorkflowStore(userId, userEmail, db); - console.log("[chat/stream] starting LLM stream", { - apiMessageCount: apiMessages.length, - docCount: Object.keys(docIndex).length, - workflowCount: Object.keys(workflowStore).length, - }); - res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("Connection", "keep-alive"); @@ -462,15 +468,10 @@ chatRouter.post("/", requireAuth, async (req, res) => { workflowStore, model, apiKeys, - projectId: project_id ?? null, + projectId: effectiveProjectId, mcpServers, }); - console.log("[chat/stream] LLM stream finished", { - fullTextLen: fullText?.length ?? 0, - eventCount: events?.length ?? 0, - }); - const annotations = extractAnnotations(fullText, docIndex, events); await db.from("chat_messages").insert({ chat_id: chatId, diff --git a/backend/src/routes/documents.ts b/backend/src/routes/documents.ts index 32f4b881..159b859e 100644 --- a/backend/src/routes/documents.ts +++ b/backend/src/routes/documents.ts @@ -22,10 +22,9 @@ import { loadActiveVersion, } from "../lib/documentVersions"; import { ensureDocAccess } from "../lib/access"; -import { singleFileUpload } from "../lib/upload"; +import { singleFileUpload, validateDocumentUpload } from "../lib/upload"; export const documentsRouter = Router(); -const ALLOWED_TYPES = new Set(["pdf", "docx", "doc"]); // GET /single-documents documentsRouter.get("/", requireAuth, async (req, res) => { @@ -401,11 +400,19 @@ documentsRouter.post( if (!access.ok) return void res.status(404).json({ detail: "Document not found" }); - // Reject if the uploaded file's extension doesn't match the document's - // declared type — otherwise every downstream viewer/extractor breaks. - const suffix = file.originalname.includes(".") - ? file.originalname.split(".").pop()!.toLowerCase() - : ""; + let validated: Awaited>; + try { + validated = await validateDocumentUpload(file); + } catch (err) { + return void res.status(400).json({ + detail: err instanceof Error ? err.message : "Invalid upload", + }); + } + const suffix = validated.suffix; + + // Reject if the uploaded file's extension/content doesn't match the + // document's declared type — otherwise every downstream viewer/extractor + // breaks or processes an unexpected format. if (doc.file_type && suffix && doc.file_type !== suffix) { return void res.status(400).json({ detail: `Uploaded file type (${suffix}) does not match document type (${doc.file_type}).`, @@ -421,10 +428,6 @@ documentsRouter.post( versionSlug, file.originalname, ); - const contentType = - suffix === "pdf" - ? "application/pdf" - : "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; try { await uploadFile( key, @@ -432,7 +435,7 @@ documentsRouter.post( file.buffer.byteOffset, file.buffer.byteOffset + file.buffer.byteLength, ) as ArrayBuffer, - contentType, + validated.contentType, ); } catch (e) { console.error("[versions/upload] storage write failed", e); @@ -459,10 +462,7 @@ documentsRouter.post( ); pdfStoragePath = pdfKey; } catch (err) { - console.error( - `[versions/upload] DOCX→PDF conversion failed for ${file.originalname}:`, - err, - ); + console.error("[versions/upload] DOCX→PDF conversion failed", err); } } else if (suffix === "pdf") { // For PDF uploads, the uploaded bytes are themselves the PDF rendition. @@ -632,43 +632,31 @@ async function handleEditResolution( const { documentId, editId } = req.params; const db = createServerSupabase(); - console.log(`[edit-resolution] incoming ${mode}`, { - userId, - documentId, - editId, - }); - const { data: edit, error: editErr } = await db .from("document_edits") .select("id, document_id, change_id, del_w_id, ins_w_id, status") .eq("id", editId) .eq("document_id", documentId) .single(); - console.log(`[edit-resolution] fetched edit row`, { edit, editErr }); + if (editErr) + return void res.status(404).json({ detail: "Edit not found" }); if (!edit) { - console.log(`[edit-resolution] edit not found, returning 404`); return void res.status(404).json({ detail: "Edit not found" }); } // Idempotent: if the edit is already resolved, return the current doc // state so stale UI (e.g. an old chat reloaded in a new session) can // reconcile without throwing. if (edit.status !== "pending") { - console.log(`[edit-resolution] edit already resolved`, { - editId, - status: edit.status, - }); const { data: doc } = await db .from("documents") .select("current_version_id, filename, user_id, project_id") .eq("id", documentId) .single(); if (!doc) { - console.log(`[edit-resolution] doc not found for resolved edit`); return void res.status(404).json({ detail: "Document not found" }); } const accessResolved = await ensureDocAccess(doc, userId, userEmail, db); if (!accessResolved.ok) { - console.log(`[edit-resolution] doc access denied for resolved edit`); return void res.status(404).json({ detail: "Document not found" }); } const activeForResolved = await loadActiveVersion(documentId, db); @@ -685,7 +673,6 @@ async function handleEditResolution( : null, remaining_pending: 0, }; - console.log(`[edit-resolution] returning already-resolved payload`, payload); return void res.status(200).json(payload); } @@ -694,7 +681,8 @@ async function handleEditResolution( .select("id, current_version_id, user_id, project_id") .eq("id", documentId) .single(); - console.log(`[edit-resolution] fetched doc`, { doc, docErr }); + if (docErr) + return void res.status(404).json({ detail: "Document not found" }); if (!doc) return void res.status(404).json({ detail: "Document not found" }); const access = await ensureDocAccess(doc, userId, userEmail, db); @@ -703,17 +691,10 @@ async function handleEditResolution( const active = await loadActiveVersion(documentId, db); const latestPath = active?.storage_path ?? null; - console.log(`[edit-resolution] resolved latestPath`, { - latestPath, - current_version_id: doc.current_version_id, - }); if (!latestPath) return void res.status(404).json({ detail: "No file to edit" }); const raw = await downloadFile(latestPath); - console.log(`[edit-resolution] downloaded bytes`, { - byteLength: raw?.byteLength ?? 0, - }); if (!raw) return void res.status(404).json({ detail: "Document bytes not available" }); @@ -725,24 +706,15 @@ async function handleEditResolution( wIds, mode, ); - console.log(`[edit-resolution] resolveTrackedChange result`, { - mode, - change_id: edit.change_id, - wIds, - found, - resolvedByteLength: resolvedBytes?.byteLength ?? 0, - }); if (!found) { - console.log( - `[edit-resolution] change_id not found in docx — updating status only`, - ); // Still update DB status so the UI reflects the decision — the change // may have been auto-consumed by a previous accept/reject pass. const { error: updErr } = await db .from("document_edits") .update({ status: mode === "accept" ? "accepted" : "rejected", resolved_at: new Date().toISOString() }) .eq("id", editId); - console.log(`[edit-resolution] status-only update`, { updErr }); + if (updErr) + return void res.status(500).json({ detail: "Failed to update edit" }); const { data: filenameRow } = await db .from("documents") .select("filename") @@ -757,7 +729,6 @@ async function handleEditResolution( ), remaining_pending: 0, }; - console.log(`[edit-resolution] returning not-found payload`, payload); return void res.status(200).json(payload); } @@ -770,10 +741,6 @@ async function handleEditResolution( resolvedBytes.byteOffset, resolvedBytes.byteOffset + resolvedBytes.byteLength, ) as ArrayBuffer; - console.log(`[edit-resolution] overwriting bytes in place`, { - latestPath, - byteLength: ab.byteLength, - }); await uploadFile( latestPath, ab, @@ -787,18 +754,14 @@ async function handleEditResolution( resolved_at: new Date().toISOString(), }) .eq("id", editId); - console.log(`[edit-resolution] updated document_edits status`, { - editId, - newStatus: mode === "accept" ? "accepted" : "rejected", - statusErr, - }); + if (statusErr) + return void res.status(500).json({ detail: "Failed to update edit" }); const { count: remainingPending } = await db .from("document_edits") .select("id", { count: "exact", head: true }) .eq("document_id", documentId) .eq("status", "pending"); - console.log(`[edit-resolution] remaining pending count`, { remainingPending }); const { data: filenameRow } = await db .from("documents") @@ -814,7 +777,6 @@ async function handleEditResolution( ), remaining_pending: remainingPending ?? 0, }; - console.log(`[edit-resolution] returning success payload`, payload); res.json(payload); } @@ -841,15 +803,15 @@ async function handleDocumentUpload( if (!file) return void res.status(400).json({ detail: "file is required" }); const filename = file.originalname; - const suffix = filename.includes(".") - ? filename.split(".").pop()!.toLowerCase() - : ""; - if (!ALLOWED_TYPES.has(suffix)) - return void res - .status(400) - .json({ - detail: `Unsupported file type: ${suffix}. Allowed: pdf, docx, doc`, - }); + let validated: Awaited>; + try { + validated = await validateDocumentUpload(file); + } catch (err) { + return void res.status(400).json({ + detail: err instanceof Error ? err.message : "Invalid upload", + }); + } + const suffix = validated.suffix; const content = file.buffer; const { data: doc, error: insertErr } = await db @@ -872,17 +834,13 @@ async function handleDocumentUpload( try { const docId = doc.id as string; const key = storageKey(userId, docId, filename); - const contentType = - suffix === "pdf" - ? "application/pdf" - : "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; await uploadFile( key, content.buffer.slice( content.byteOffset, content.byteOffset + content.byteLength, ) as ArrayBuffer, - contentType, + validated.contentType, ); const rawBuf = content.buffer.slice( @@ -908,10 +866,7 @@ async function handleDocumentUpload( ); pdfStoragePath = pdfKey; } catch (err) { - console.error( - `[upload] DOCX→PDF conversion failed for ${filename}:`, - err, - ); + console.error("[upload] DOCX→PDF conversion failed", err); } } else if (suffix === "pdf") { pdfStoragePath = key; diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index a5ad4697..ce2bdc60 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -9,10 +9,9 @@ import { import { downloadFile, uploadFile, storageKey } from "../lib/storage"; import { docxToPdf, convertedPdfKey } from "../lib/convert"; import { checkProjectAccess } from "../lib/access"; -import { singleFileUpload } from "../lib/upload"; +import { singleFileUpload, validateDocumentUpload } from "../lib/upload"; export const projectsRouter = Router(); -const ALLOWED_TYPES = new Set(["pdf", "docx", "doc"]); // GET /projects projectsRouter.get("/", requireAuth, async (req, res) => { @@ -619,15 +618,15 @@ export async function handleDocumentUpload( if (!file) return void res.status(400).json({ detail: "file is required" }); const filename = file.originalname; - const suffix = filename.includes(".") - ? filename.split(".").pop()!.toLowerCase() - : ""; - if (!ALLOWED_TYPES.has(suffix)) - return void res - .status(400) - .json({ - detail: `Unsupported file type: ${suffix}. Allowed: pdf, docx, doc`, - }); + let validated: Awaited>; + try { + validated = await validateDocumentUpload(file); + } catch (err) { + return void res.status(400).json({ + detail: err instanceof Error ? err.message : "Invalid upload", + }); + } + const suffix = validated.suffix; const content = file.buffer; const { data: doc, error: insertErr } = await db @@ -651,17 +650,13 @@ export async function handleDocumentUpload( try { const docId = doc.id as string; const key = storageKey(userId, docId, filename); - const contentType = - suffix === "pdf" - ? "application/pdf" - : "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; await uploadFile( key, content.buffer.slice( content.byteOffset, content.byteOffset + content.byteLength, ) as ArrayBuffer, - contentType, + validated.contentType, ); const rawBuf = content.buffer.slice( @@ -687,10 +682,7 @@ export async function handleDocumentUpload( ); pdfStoragePath = pdfKey; } catch (err) { - console.error( - `[upload] DOCX→PDF conversion failed for ${filename}:`, - err, - ); + console.error("[upload] DOCX→PDF conversion failed", err); } } else if (suffix === "pdf") { pdfStoragePath = key; diff --git a/backend/src/routes/tabular.ts b/backend/src/routes/tabular.ts index 2b4f6db9..f7058a06 100644 --- a/backend/src/routes/tabular.ts +++ b/backend/src/routes/tabular.ts @@ -13,6 +13,7 @@ import { import { completeText, streamChatWithTools } from "../lib/llm"; import { getUserApiKeys, getUserModelSettings } from "../lib/userSettings"; import { + canEditReview, checkProjectAccess, ensureReviewAccess, listAccessibleProjectIds, @@ -45,6 +46,82 @@ function formatPromptSuffix(format?: string, tags?: string[]): string { export const tabularRouter = Router(); +type Db = ReturnType; +type ReviewAccessRow = { + id: string; + user_id: string; + project_id: string | null; + shared_with?: string[] | null; +}; +type ReviewDocumentRow = { + id: string; + filename: string; + file_type?: string | null; + page_count?: number | null; + user_id: string; + project_id: string | null; +}; + +async function loadAuthorizedReviewDocuments( + review: ReviewAccessRow, + documentIds: string[], + db: Db, +): Promise { + const ids = [...new Set(documentIds.filter((id) => typeof id === "string" && id))]; + if (ids.length === 0) return []; + const { data } = await db + .from("documents") + .select("id, filename, file_type, page_count, user_id, project_id") + .in("id", ids); + const docs = ((data ?? []) as ReviewDocumentRow[]).filter((doc) => { + if (review.project_id) return doc.project_id === review.project_id; + return doc.user_id === review.user_id; + }); + const order = new Map(ids.map((id, index) => [id, index])); + return docs.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)); +} + +export async function validateReviewDocumentIds( + review: ReviewAccessRow, + documentIds: string[], + db: Db, +): Promise<{ ok: true; documentIds: string[] } | { ok: false; detail: string }> { + const ids = [...new Set(documentIds.filter((id) => typeof id === "string" && id))]; + if (ids.length !== documentIds.length) { + return { ok: false, detail: "document_ids contains invalid values" }; + } + const docs = await loadAuthorizedReviewDocuments(review, ids, db); + if (docs.length !== ids.length) { + return { ok: false, detail: "One or more documents are not available for this review" }; + } + return { ok: true, documentIds: ids }; +} + +async function validateNewReviewDocumentIds( + args: { + userId: string; + projectId: string | null; + documentIds: string[]; + db: Db; + }, +): Promise<{ ok: true; documentIds: string[] } | { ok: false; detail: string }> { + const reviewLike: ReviewAccessRow = { + id: "new", + user_id: args.userId, + project_id: args.projectId, + }; + return validateReviewDocumentIds(reviewLike, args.documentIds, args.db); +} + +function requireReviewEdit( + access: { ok: true; isOwner: boolean; via?: "owner" | "project" | "direct" }, + res: import("express").Response, +): boolean { + if (canEditReview(access)) return true; + res.status(403).json({ detail: "Review is read-only for this user" }); + return false; +} + // GET /tabular-review tabularRouter.get("/", requireAuth, async (req, res) => { const userId = res.locals.userId as string; @@ -104,7 +181,8 @@ tabularRouter.get("/", requireAuth, async (req, res) => { ? db .from("tabular_reviews") .select("*") - .contains("shared_with", JSON.stringify([userEmail])) + .is("project_id", null) + .contains("shared_with", [userEmail]) .neq("user_id", userId) .order("created_at", { ascending: false }) : Promise.resolve({ @@ -192,6 +270,18 @@ tabularRouter.post("/", requireAuth, async (req, res) => { if (!access.ok) return void res.status(404).json({ detail: "Project not found" }); } + const requestedDocumentIds = Array.isArray(document_ids) + ? document_ids + : []; + const documentValidation = await validateNewReviewDocumentIds({ + userId, + projectId: project_id ?? null, + documentIds: requestedDocumentIds, + db, + }); + if (!documentValidation.ok) { + return void res.status(400).json({ detail: documentValidation.detail }); + } const { data: review, error } = await db .from("tabular_reviews") .insert({ @@ -208,7 +298,7 @@ tabularRouter.post("/", requireAuth, async (req, res) => { .status(500) .json({ detail: error?.message ?? "Failed to create review" }); - const cells = document_ids.flatMap((docId) => + const cells = documentValidation.documentIds.flatMap((docId) => columns_config.map((col) => ({ review_id: review.id, document_id: docId, @@ -315,24 +405,27 @@ tabularRouter.get("/:reviewId", requireAuth, async (req, res) => { .select("*") .eq("review_id", reviewId); const docIds = [...new Set((cells ?? []).map((c) => c.document_id))]; - const docsResult = + const docs = docIds.length > 0 - ? await db.from("documents").select("*").in("id", docIds) + ? await loadAuthorizedReviewDocuments(review as ReviewAccessRow, docIds, db) : review.project_id - ? await db + ? ((await db .from("documents") - .select("*") + .select("id, filename, file_type, page_count, user_id, project_id") .eq("project_id", review.project_id) - .order("created_at", { ascending: true }) - : { data: [] as Record[] }; + .order("created_at", { ascending: true })).data ?? []) as ReviewDocumentRow[] + : []; + const allowedDocIds = new Set(docs.map((doc) => doc.id)); res.json({ review: { ...review, is_owner: access.isOwner }, - cells: (cells ?? []).map((cell) => ({ - ...cell, - content: parseCellContent(cell.content), - })), - documents: docsResult.data ?? [], + cells: (cells ?? []) + .filter((cell) => allowedDocIds.has(cell.document_id)) + .map((cell) => ({ + ...cell, + content: parseCellContent(cell.content), + })), + documents: docs, }); }); @@ -461,6 +554,7 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => { ); if (!access.ok) return void res.status(404).json({ detail: "Review not found" }); + if (!requireReviewEdit(access, res)) return; if (sharedWithUpdate !== undefined) { if (!access.isOwner) return void res @@ -468,6 +562,64 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => { .json({ detail: "Only the review owner can change sharing" }); updates.shared_with = sharedWithUpdate; } + const nextProjectId = + req.body.project_id !== undefined + ? ((req.body.project_id as string | null) ?? null) + : ((existingReview.project_id as string | null) ?? null); + if (nextProjectId) { + const projectAccess = await checkProjectAccess( + nextProjectId, + userId, + userEmail, + db, + ); + if (!projectAccess.ok) + return void res.status(404).json({ detail: "Project not found" }); + } + if (req.body.project_id !== undefined) { + const { data: existingCellsForProjectMove } = await db + .from("tabular_cells") + .select("document_id") + .eq("review_id", reviewId); + const validation = await validateReviewDocumentIds( + { + id: reviewId, + user_id: existingReview.user_id as string, + project_id: nextProjectId, + shared_with: existingReview.shared_with as string[] | null, + }, + [ + ...new Set( + (existingCellsForProjectMove ?? []).map( + (cell) => cell.document_id as string, + ), + ), + ], + db, + ); + if (!validation.ok) + return void res.status(400).json({ detail: validation.detail }); + } + let requestedDocumentIdsValidation: + | { ok: true; documentIds: string[] } + | { ok: false; detail: string } + | null = null; + if (Array.isArray(req.body.document_ids)) { + requestedDocumentIdsValidation = await validateReviewDocumentIds( + { + id: reviewId, + user_id: existingReview.user_id as string, + project_id: nextProjectId, + shared_with: existingReview.shared_with as string[] | null, + }, + req.body.document_ids as string[], + db, + ); + if (!requestedDocumentIdsValidation.ok) + return void res + .status(400) + .json({ detail: requestedDocumentIdsValidation.detail }); + } const { data: updatedReview, error: updateError } = await db .from("tabular_reviews") @@ -498,12 +650,16 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => { if (Array.isArray(req.body.document_ids)) { // document_ids is the new source of truth — delete removed docs' cells - const newDocIds = req.body.document_ids as string[]; + const validation = requestedDocumentIdsValidation; + if (!validation?.ok) + return void res + .status(400) + .json({ detail: "document_ids is invalid" }); const existingDocIds = (existingCells ?? []).map( (cell) => cell.document_id, ); const removedDocIds = existingDocIds.filter( - (id) => !newDocIds.includes(id), + (id) => !validation.documentIds.includes(id), ); if (removedDocIds.length > 0) { @@ -518,7 +674,7 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => { .json({ detail: deleteError.message }); } - documentIds = newDocIds; + documentIds = validation.documentIds; } else { // No document change — derive from existing cells documentIds = [ @@ -526,11 +682,11 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => { (existingCells ?? []).map((cell) => cell.document_id), ), ]; - if (documentIds.length === 0 && existingReview.project_id) { + if (documentIds.length === 0 && nextProjectId) { const { data: projectDocs } = await db .from("documents") .select("id") - .eq("project_id", existingReview.project_id); + .eq("project_id", nextProjectId); documentIds = (projectDocs ?? []).map((doc) => doc.id); } } @@ -605,12 +761,20 @@ tabularRouter.post("/:reviewId/clear-cells", requireAuth, async (req, res) => { const access = await ensureReviewAccess(review, userId, userEmail, db); if (!access.ok) return void res.status(404).json({ detail: "Review not found" }); + if (!requireReviewEdit(access, res)) return; + const validation = await validateReviewDocumentIds( + review as ReviewAccessRow, + document_ids, + db, + ); + if (!validation.ok) + return void res.status(400).json({ detail: validation.detail }); const { error } = await db .from("tabular_cells") .update({ content: null, status: "pending" }) .eq("review_id", reviewId) - .in("document_id", document_ids); + .in("document_id", validation.documentIds); if (error) return void res.status(500).json({ detail: error.message }); res.status(204).send(); }); @@ -644,6 +808,7 @@ tabularRouter.post( const access = await ensureReviewAccess(review, userId, userEmail, db); if (!access.ok) return void res.status(404).json({ detail: "Review not found" }); + if (!requireReviewEdit(access, res)) return; const column = ( review.columns_config as { @@ -657,6 +822,24 @@ tabularRouter.post( if (!column) return void res.status(400).json({ detail: "Column not found" }); + const { data: cell } = await db + .from("tabular_cells") + .select("id") + .eq("review_id", reviewId) + .eq("document_id", document_id) + .eq("column_index", column_index) + .maybeSingle(); + if (!cell) + return void res.status(404).json({ detail: "Cell not found" }); + + const validation = await validateReviewDocumentIds( + review as ReviewAccessRow, + [document_id], + db, + ); + if (!validation.ok) + return void res.status(404).json({ detail: "Document not found" }); + const { data: doc } = await db .from("documents") .select("id, filename, file_type") @@ -743,6 +926,7 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => { const access = await ensureReviewAccess(review, userId, userEmail, db); if (!access.ok) return void res.status(404).json({ detail: "Review not found" }); + if (!requireReviewEdit(access, res)) return; const columns: { index: number; @@ -763,20 +947,20 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => { cellMap.set(`${cell.document_id}:${cell.column_index}`, cell); const docIds = [...new Set((cells ?? []).map((c) => c.document_id))]; - let docs: Record[] = []; + let docs: ReviewDocumentRow[] = []; if (docIds.length > 0) { - const { data } = await db - .from("documents") - .select("id, filename, file_type, page_count") - .in("id", docIds); - docs = data ?? []; + docs = await loadAuthorizedReviewDocuments( + review as ReviewAccessRow, + docIds, + db, + ); } else if (review.project_id) { const { data } = await db .from("documents") - .select("id, filename, file_type, page_count") + .select("id, filename, file_type, page_count, user_id, project_id") .eq("project_id", review.project_id) .order("created_at", { ascending: true }); - docs = data ?? []; + docs = (data ?? []) as ReviewDocumentRow[]; } const { tabular_model, api_keys } = await getUserModelSettings(userId, db); @@ -1148,13 +1332,15 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => { ]; let docs: { id: string; filename: string }[] = []; if (docIds.length > 0) { - const { data } = await db - .from("documents") - .select("id, filename") - .in("id", docIds) - .order("created_at", { ascending: true }); - docs = (data ?? []) as { id: string; filename: string }[]; + docs = ( + await loadAuthorizedReviewDocuments( + review as ReviewAccessRow, + docIds, + db, + ) + ).map((doc) => ({ id: doc.id, filename: doc.filename })); } + const allowedDocIds = new Set(docs.map((doc) => doc.id)); const sortedColumns = ( (review.columns_config ?? []) as { index: number; name: string }[] @@ -1164,10 +1350,12 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => { columns: sortedColumns, documents: docs, cells: new Map( - (cells ?? []).map((c: any) => [ - `${c.column_index}:${c.document_id}`, - parseCellContent(c.content), - ]), + (cells ?? []) + .filter((c: any) => allowedDocIds.has(c.document_id)) + .map((c: any) => [ + `${c.column_index}:${c.document_id}`, + parseCellContent(c.content), + ]), ), }; @@ -1185,9 +1373,7 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => { .select("id, title, review_id, user_id") .eq("id", chatId) .single(); - const canUse = - !!existing && - (existing.review_id === reviewId || existing.user_id === userId); + const canUse = !!existing && existing.review_id === reviewId; if (!canUse || !existing) chatId = null; else chatTitle = existing.title; } diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts index aeddd3ad..a07886b9 100644 --- a/backend/src/routes/user.ts +++ b/backend/src/routes/user.ts @@ -1,23 +1,143 @@ import { Router } from "express"; import { requireAuth } from "../middleware/auth"; import { createServerSupabase } from "../lib/supabase"; +import { encryptApiKey, hasStoredApiKey } from "../lib/apiKeys"; +import { resolveModel, DEFAULT_TABULAR_MODEL } from "../lib/llm"; export const userRouter = Router(); -// POST /user/profile -userRouter.post("/profile", requireAuth, async (req, res) => { - const userId = res.locals.userId as string; - const db = createServerSupabase(); - const { error } = await db +type ProfileRow = { + display_name: string | null; + organisation: string | null; + message_credits_used: number; + credits_reset_date: string; + tier: string; + tabular_model: string; + claude_api_key: string | null; + gemini_api_key: string | null; + openrouter_api_key: string | null; +}; + +const PROFILE_COLUMNS = + "display_name, organisation, message_credits_used, credits_reset_date, tier, tabular_model, claude_api_key, gemini_api_key, openrouter_api_key"; + +function safeProfile(row: ProfileRow) { + return { + display_name: row.display_name, + organisation: row.organisation, + message_credits_used: row.message_credits_used, + credits_reset_date: row.credits_reset_date, + tier: row.tier, + tabular_model: resolveModel(row.tabular_model, DEFAULT_TABULAR_MODEL), + has_claude_api_key: hasStoredApiKey(row.claude_api_key), + has_gemini_api_key: hasStoredApiKey(row.gemini_api_key), + has_openrouter_api_key: hasStoredApiKey(row.openrouter_api_key), + }; +} + +async function ensureProfile( + userId: string, + db: ReturnType, +) { + await db .from("user_profiles") .upsert( { user_id: userId }, { onConflict: "user_id", ignoreDuplicates: true }, ); +} + +// POST /user/profile +userRouter.post("/profile", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const db = createServerSupabase(); + const { error } = await db.from("user_profiles").upsert( + { user_id: userId }, + { onConflict: "user_id", ignoreDuplicates: true }, + ); if (error) return void res.status(500).json({ detail: error.message }); res.json({ ok: true }); }); +// GET /user/profile +userRouter.get("/profile", requireAuth, async (_req, res) => { + const userId = res.locals.userId as string; + const db = createServerSupabase(); + await ensureProfile(userId, db); + const { data, error } = await db + .from("user_profiles") + .select(PROFILE_COLUMNS) + .eq("user_id", userId) + .single(); + if (error || !data) + return void res + .status(500) + .json({ detail: error?.message ?? "Profile not found" }); + res.json(safeProfile(data as ProfileRow)); +}); + +// PATCH /user/profile +userRouter.patch("/profile", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const db = createServerSupabase(); + await ensureProfile(userId, db); + + const updates: Record = {}; + if (typeof req.body.display_name === "string") { + updates.display_name = req.body.display_name.trim().slice(0, 200) || null; + } + if (typeof req.body.organisation === "string") { + updates.organisation = req.body.organisation.trim().slice(0, 200) || null; + } + if (typeof req.body.tabular_model === "string") { + updates.tabular_model = resolveModel( + req.body.tabular_model, + DEFAULT_TABULAR_MODEL, + ); + } + if (req.body.api_keys && typeof req.body.api_keys === "object") { + const apiKeys = req.body.api_keys as { + claude?: string | null; + gemini?: string | null; + openrouter?: string | null; + }; + if ("claude" in apiKeys) { + updates.claude_api_key = encryptApiKey(apiKeys.claude); + } + if ("gemini" in apiKeys) { + updates.gemini_api_key = encryptApiKey(apiKeys.gemini); + } + if ("openrouter" in apiKeys) { + updates.openrouter_api_key = encryptApiKey(apiKeys.openrouter); + } + } + if (req.body.increment_message_credits === true) { + const { data: current } = await db + .from("user_profiles") + .select("message_credits_used") + .eq("user_id", userId) + .single(); + updates.message_credits_used = + ((current?.message_credits_used as number | null) ?? 0) + 1; + } + + if (Object.keys(updates).length === 0) { + return void res.status(400).json({ detail: "No supported fields to update" }); + } + + const { data, error } = await db + .from("user_profiles") + .update({ ...updates, updated_at: new Date().toISOString() }) + .eq("user_id", userId) + .select(PROFILE_COLUMNS) + .single(); + if (error || !data) + return void res + .status(500) + .json({ detail: error?.message ?? "Failed to update profile" }); + res.json(safeProfile(data as ProfileRow)); +}); + // DELETE /user/account userRouter.delete("/account", requireAuth, async (_req, res) => { const userId = res.locals.userId as string; diff --git a/backend/test/access.test.ts b/backend/test/access.test.ts new file mode 100644 index 00000000..4ee34f3b --- /dev/null +++ b/backend/test/access.test.ts @@ -0,0 +1,121 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { canEditReview, ensureReviewAccess } from "../src/lib/access"; +import { validateReviewDocumentIds } from "../src/routes/tabular"; + +describe("review access helpers", () => { + it("treats direct standalone review shares as read-only", () => { + assert.equal( + canEditReview({ ok: true, isOwner: false, via: "direct" }), + false, + ); + assert.equal( + canEditReview({ ok: true, isOwner: false, via: "project" }), + true, + ); + assert.equal( + canEditReview({ ok: true, isOwner: true, via: "owner" }), + true, + ); + }); + + it("validates review document IDs against project or owner scope", async () => { + const docs = [ + { + id: "project-doc", + filename: "Project.docx", + user_id: "owner-a", + project_id: "project-a", + }, + { + id: "other-project-doc", + filename: "Other.docx", + user_id: "owner-a", + project_id: "project-b", + }, + { + id: "standalone-doc", + filename: "Standalone.docx", + user_id: "owner-a", + project_id: null, + }, + ]; + const db = { + from: () => ({ + select() { + return this; + }, + in(_column: string, ids: string[]) { + return Promise.resolve({ + data: docs.filter((doc) => ids.includes(doc.id)), + }); + }, + }), + } as never; + + assert.deepEqual( + await validateReviewDocumentIds( + { + id: "review-a", + user_id: "owner-a", + project_id: "project-a", + }, + ["project-doc"], + db, + ), + { ok: true, documentIds: ["project-doc"] }, + ); + assert.equal( + ( + await validateReviewDocumentIds( + { + id: "review-a", + user_id: "owner-a", + project_id: "project-a", + }, + ["other-project-doc"], + db, + ) + ).ok, + false, + ); + assert.deepEqual( + await validateReviewDocumentIds( + { id: "review-b", user_id: "owner-a", project_id: null }, + ["standalone-doc"], + db, + ), + { ok: true, documentIds: ["standalone-doc"] }, + ); + }); + + it("does not let direct email shares bypass project review access", async () => { + const db = { + from: () => ({ + select() { + return this; + }, + eq() { + return this; + }, + single() { + return Promise.resolve({ data: null }); + }, + }), + } as never; + + assert.deepEqual( + await ensureReviewAccess( + { + user_id: "owner-a", + project_id: "project-a", + shared_with: ["viewer@example.com"], + }, + "viewer-user-id", + "viewer@example.com", + db, + ), + { ok: false }, + ); + }); +}); diff --git a/backend/test/apiKeys.test.ts b/backend/test/apiKeys.test.ts new file mode 100644 index 00000000..db7af441 --- /dev/null +++ b/backend/test/apiKeys.test.ts @@ -0,0 +1,44 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + decryptApiKey, + encryptApiKey, + hasStoredApiKey, + isEncryptedApiKey, +} from "../src/lib/apiKeys"; + +describe("user API key encryption", () => { + it("encrypts and decrypts stored keys", () => { + const previous = process.env.USER_API_KEYS_ENCRYPTION_KEY; + process.env.USER_API_KEYS_ENCRYPTION_KEY = "test-encryption-secret"; + try { + const encrypted = encryptApiKey("sk-test-value"); + assert.ok(encrypted); + assert.ok(isEncryptedApiKey(encrypted)); + assert.notEqual(encrypted, "sk-test-value"); + assert.equal(decryptApiKey(encrypted), "sk-test-value"); + assert.equal(hasStoredApiKey(encrypted), true); + } finally { + if (previous === undefined) { + delete process.env.USER_API_KEYS_ENCRYPTION_KEY; + } else { + process.env.USER_API_KEYS_ENCRYPTION_KEY = previous; + } + } + }); + + it("requires the encryption secret for new stored keys", () => { + const previous = process.env.USER_API_KEYS_ENCRYPTION_KEY; + delete process.env.USER_API_KEYS_ENCRYPTION_KEY; + try { + assert.throws(() => encryptApiKey("sk-test-value"), { + message: /USER_API_KEYS_ENCRYPTION_KEY/, + }); + assert.equal(decryptApiKey("legacy-plaintext"), "legacy-plaintext"); + } finally { + if (previous !== undefined) { + process.env.USER_API_KEYS_ENCRYPTION_KEY = previous; + } + } + }); +}); diff --git a/backend/test/downloadTokens.test.ts b/backend/test/downloadTokens.test.ts new file mode 100644 index 00000000..ebf76238 --- /dev/null +++ b/backend/test/downloadTokens.test.ts @@ -0,0 +1,38 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { signDownload, verifyDownload } from "../src/lib/downloadTokens"; + +describe("download tokens", () => { + it("requires a dedicated signing secret", () => { + const previous = process.env.DOWNLOAD_SIGNING_SECRET; + delete process.env.DOWNLOAD_SIGNING_SECRET; + try { + assert.throws(() => signDownload("docs/a.pdf", "a.pdf"), { + message: /DOWNLOAD_SIGNING_SECRET/, + }); + } finally { + if (previous !== undefined) { + process.env.DOWNLOAD_SIGNING_SECRET = previous; + } + } + }); + + it("verifies valid tokens and rejects tampering", () => { + const previous = process.env.DOWNLOAD_SIGNING_SECRET; + process.env.DOWNLOAD_SIGNING_SECRET = "test-download-secret"; + try { + const token = signDownload("docs/a.pdf", "a.pdf"); + assert.deepEqual(verifyDownload(token), { + path: "docs/a.pdf", + filename: "a.pdf", + }); + assert.equal(verifyDownload(`${token}x`), null); + } finally { + if (previous === undefined) { + delete process.env.DOWNLOAD_SIGNING_SECRET; + } else { + process.env.DOWNLOAD_SIGNING_SECRET = previous; + } + } + }); +}); diff --git a/backend/test/upload.test.ts b/backend/test/upload.test.ts new file mode 100644 index 00000000..fec5fdda --- /dev/null +++ b/backend/test/upload.test.ts @@ -0,0 +1,69 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import JSZip from "jszip"; +import { validateDocumentUpload } from "../src/lib/upload"; + +function upload(name: string, buffer: Buffer): Express.Multer.File { + return { + fieldname: "file", + originalname: name, + encoding: "7bit", + mimetype: "application/octet-stream", + size: buffer.length, + buffer, + stream: null as never, + destination: "", + filename: name, + path: "", + }; +} + +async function minimalDocx(): Promise { + const zip = new JSZip(); + zip.file("[Content_Types].xml", ""); + zip.file("word/document.xml", ""); + return zip.generateAsync({ type: "nodebuffer" }); +} + +describe("document upload validation", () => { + it("accepts files whose bytes match their extension", async () => { + assert.deepEqual( + await validateDocumentUpload(upload("contract.pdf", Buffer.from("%PDF-1.7"))), + { suffix: "pdf", contentType: "application/pdf" }, + ); + assert.deepEqual( + await validateDocumentUpload( + upload( + "contract.doc", + Buffer.from([ + 0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1, + ]), + ), + ), + { suffix: "doc", contentType: "application/msword" }, + ); + assert.deepEqual( + await validateDocumentUpload(upload("contract.docx", await minimalDocx())), + { + suffix: "docx", + contentType: + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }, + ); + }); + + it("rejects mismatched and malformed document bytes", async () => { + await assert.rejects( + validateDocumentUpload(upload("contract.pdf", Buffer.from("not pdf"))), + /valid PDF header/, + ); + await assert.rejects( + validateDocumentUpload(upload("contract.doc", Buffer.from("not doc"))), + /legacy Word header/, + ); + await assert.rejects( + validateDocumentUpload(upload("contract.docx", Buffer.from("not zip"))), + /valid Word archive/, + ); + }); +}); diff --git a/frontend/.env.local.example b/frontend/.env.local.example index 4e00a720..c0ceb714 100644 --- a/frontend/.env.local.example +++ b/frontend/.env.local.example @@ -1,4 +1,3 @@ NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=your-supabase-anon-key -SUPABASE_SECRET_KEY=your-supabase-service-role-key NEXT_PUBLIC_API_BASE_URL=http://localhost:3001 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5782999f..df55c68d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,9 +9,9 @@ "version": "0.1.0", "license": "AGPL-3.0-only", "dependencies": { - "@aws-sdk/client-s3": "^3.1025.0", - "@aws-sdk/s3-request-presigner": "^3.1025.0", - "@opennextjs/cloudflare": "^1.13.1", + "@aws-sdk/client-s3": "^3.1044.0", + "@aws-sdk/s3-request-presigner": "^3.1044.0", + "@opennextjs/cloudflare": "^1.19.8", "@openrouter/sdk": "^0.3.11", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-icons": "^1.3.2", @@ -33,11 +33,11 @@ "lucide-react": "^0.553.0", "mammoth": "^1.11.0", "marked": "^17.0.1", - "next": "16.0.3", + "next": "16.2.5", "nextjs-toploader": "^3.9.17", "pdfjs-dist": "4.10.38", - "react": "19.2.0", - "react-dom": "19.2.0", + "react": "19.2.6", + "react-dom": "19.2.6", "react-markdown": "^10.1.0", "recharts": "^3.7.0", "rehype-katex": "^7.0.1", @@ -59,7 +59,7 @@ "babel-plugin-react-compiler": "1.0.0", "baseline-browser-mapping": "^2.9.11", "eslint": "^9", - "eslint-config-next": "16.0.3", + "eslint-config-next": "16.2.5", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", "typescript": "^5", @@ -497,6 +497,22 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-cloudfront/node_modules/@aws-sdk/util-endpoints": { + "version": "3.984.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.984.0.tgz", + "integrity": "sha512-9ebjLA0hMKHeVvXEtTDCCOBtwjb0bOXiuUV06HNeVdgAjH6gj4x4Zwt4IBti83TiyTGOCl5YfZqGx4ehVsasbQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-dynamodb": { "version": "3.984.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.984.0.tgz", @@ -550,6 +566,22 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-dynamodb/node_modules/@aws-sdk/util-endpoints": { + "version": "3.984.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.984.0.tgz", + "integrity": "sha512-9ebjLA0hMKHeVvXEtTDCCOBtwjb0bOXiuUV06HNeVdgAjH6gj4x4Zwt4IBti83TiyTGOCl5YfZqGx4ehVsasbQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-lambda": { "version": "3.984.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-lambda/-/client-lambda-3.984.0.tgz", @@ -605,82 +637,82 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/client-s3": { - "version": "3.1025.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1025.0.tgz", - "integrity": "sha512-9Byz2fPnuGRRL8DTTD5bYPl1Iwm+ysLiCMgptffa3lNkVLCiUZc5e5TAaOjk0MvyeXieq+jn35AmQL6cgN2KHQ==", + "node_modules/@aws-sdk/client-lambda/node_modules/@aws-sdk/util-endpoints": { + "version": "3.984.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.984.0.tgz", + "integrity": "sha512-9ebjLA0hMKHeVvXEtTDCCOBtwjb0bOXiuUV06HNeVdgAjH6gj4x4Zwt4IBti83TiyTGOCl5YfZqGx4ehVsasbQ==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha1-browser": "5.2.0", - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.26", - "@aws-sdk/credential-provider-node": "^3.972.29", - "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", - "@aws-sdk/middleware-expect-continue": "^3.972.8", - "@aws-sdk/middleware-flexible-checksums": "^3.974.6", - "@aws-sdk/middleware-host-header": "^3.972.8", - "@aws-sdk/middleware-location-constraint": "^3.972.8", - "@aws-sdk/middleware-logger": "^3.972.8", - "@aws-sdk/middleware-recursion-detection": "^3.972.9", - "@aws-sdk/middleware-sdk-s3": "^3.972.27", - "@aws-sdk/middleware-ssec": "^3.972.8", - "@aws-sdk/middleware-user-agent": "^3.972.28", - "@aws-sdk/region-config-resolver": "^3.972.10", - "@aws-sdk/signature-v4-multi-region": "^3.996.15", - "@aws-sdk/types": "^3.973.6", - "@aws-sdk/util-endpoints": "^3.996.5", - "@aws-sdk/util-user-agent-browser": "^3.972.8", - "@aws-sdk/util-user-agent-node": "^3.973.14", - "@smithy/config-resolver": "^4.4.13", - "@smithy/core": "^3.23.13", - "@smithy/eventstream-serde-browser": "^4.2.12", - "@smithy/eventstream-serde-config-resolver": "^4.3.12", - "@smithy/eventstream-serde-node": "^4.2.12", - "@smithy/fetch-http-handler": "^5.3.15", - "@smithy/hash-blob-browser": "^4.2.13", - "@smithy/hash-node": "^4.2.12", - "@smithy/hash-stream-node": "^4.2.12", - "@smithy/invalid-dependency": "^4.2.12", - "@smithy/md5-js": "^4.2.12", - "@smithy/middleware-content-length": "^4.2.12", - "@smithy/middleware-endpoint": "^4.4.28", - "@smithy/middleware-retry": "^4.4.46", - "@smithy/middleware-serde": "^4.2.16", - "@smithy/middleware-stack": "^4.2.12", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/node-http-handler": "^4.5.1", - "@smithy/protocol-http": "^5.3.12", - "@smithy/smithy-client": "^4.12.8", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.44", - "@smithy/util-defaults-mode-node": "^4.2.48", - "@smithy/util-endpoints": "^3.3.3", - "@smithy/util-middleware": "^4.2.12", - "@smithy/util-retry": "^4.2.13", - "@smithy/util-stream": "^4.5.21", - "@smithy/util-utf8": "^4.2.2", - "@smithy/util-waiter": "^4.2.14", + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.5.tgz", - "integrity": "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw==", + "node_modules/@aws-sdk/client-s3": { + "version": "3.1044.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1044.0.tgz", + "integrity": "sha512-yT3g0Oi0b+pJBJswNxRwWLLBoExQhRx9Iz2rUy1xV0slMogTQN+DSjChI95XTDtpGEcY0qnIK6UYX0XCYdhOKg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", - "@smithy/util-endpoints": "^3.3.3", + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/credential-provider-node": "^3.972.39", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.10", + "@aws-sdk/middleware-expect-continue": "^3.972.10", + "@aws-sdk/middleware-flexible-checksums": "^3.974.16", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-location-constraint": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-sdk-s3": "^3.972.37", + "@aws-sdk/middleware-ssec": "^3.972.10", + "@aws-sdk/middleware-user-agent": "^3.972.38", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/signature-v4-multi-region": "^3.996.25", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.24", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/eventstream-serde-browser": "^4.2.14", + "@smithy/eventstream-serde-config-resolver": "^4.3.14", + "@smithy/eventstream-serde-node": "^4.2.14", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-blob-browser": "^4.2.15", + "@smithy/hash-node": "^4.2.14", + "@smithy/hash-stream-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/md5-js": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.7", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", + "@smithy/util-stream": "^4.5.25", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.3.0", "tslib": "^2.6.2" }, "engines": { @@ -739,23 +771,40 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-sqs/node_modules/@aws-sdk/util-endpoints": { + "version": "3.984.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.984.0.tgz", + "integrity": "sha512-9ebjLA0hMKHeVvXEtTDCCOBtwjb0bOXiuUV06HNeVdgAjH6gj4x4Zwt4IBti83TiyTGOCl5YfZqGx4ehVsasbQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/core": { - "version": "3.973.26", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.26.tgz", - "integrity": "sha512-A/E6n2W42ruU+sfWk+mMUOyVXbsSgGrY3MJ9/0Az5qUdG67y8I6HYzzoAa+e/lzxxl1uCYmEL6BTMi9ZiZnplQ==", + "version": "3.974.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.8.tgz", + "integrity": "sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@aws-sdk/xml-builder": "^3.972.16", - "@smithy/core": "^3.23.13", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/property-provider": "^4.2.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/signature-v4": "^5.3.12", - "@smithy/smithy-client": "^4.12.8", - "@smithy/types": "^4.13.1", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.22", + "@smithy/core": "^3.23.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", - "@smithy/util-middleware": "^4.2.12", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -764,12 +813,12 @@ } }, "node_modules/@aws-sdk/crc64-nvme": { - "version": "3.972.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.5.tgz", - "integrity": "sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg==", + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.7.tgz", + "integrity": "sha512-QUagVVBbC8gODCF6e1aV0mE2TXWB9Opz4k8EJFdNrujUVQm5R4AjJa1mpOqzwOuROBzqJU9zawzig7M96L8Ejg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -777,15 +826,15 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.24", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.24.tgz", - "integrity": "sha512-FWg8uFmT6vQM7VuzELzwVo5bzExGaKHdubn0StjgrcU5FvuLExUe+k06kn/40uKv59rYzhez8eFNM4yYE/Yb/w==", + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.34.tgz", + "integrity": "sha512-XT0jtf8Fw9JE6ppsQeoNnZRiG+jqRixMT1v1ZR17G60UvVdsQmTG8nbEyHuEPfMxDXEhfdARaM/XiEhca4lGHQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.26", - "@aws-sdk/types": "^3.973.6", - "@smithy/property-provider": "^4.2.12", - "@smithy/types": "^4.13.1", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -793,20 +842,20 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.26", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.26.tgz", - "integrity": "sha512-CY4ppZ+qHYqcXqBVi//sdHST1QK3KzOEiLtpLsc9W2k2vfZPKExGaQIsOwcyvjpjUEolotitmd3mUNY56IwDEA==", + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.36.tgz", + "integrity": "sha512-DPoGWfy7J7RKxvbf5kOKIGQkD2ek3dbKgzKIGrnLuvZBz5myU+Im/H6pmc14QcnFbqHMqxvtWSgRDSJW3qXLQg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.26", - "@aws-sdk/types": "^3.973.6", - "@smithy/fetch-http-handler": "^5.3.15", - "@smithy/node-http-handler": "^4.5.1", - "@smithy/property-provider": "^4.2.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/smithy-client": "^4.12.8", - "@smithy/types": "^4.13.1", - "@smithy/util-stream": "^4.5.21", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/types": "^3.973.8", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/util-stream": "^4.5.25", "tslib": "^2.6.2" }, "engines": { @@ -814,24 +863,24 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.28", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.28.tgz", - "integrity": "sha512-wXYvq3+uQcZV7k+bE4yDXCTBdzWTU9x/nMiKBfzInmv6yYK1veMK0AKvRfRBd72nGWYKcL6AxwiPg9z/pYlgpw==", + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.38.tgz", + "integrity": "sha512-oDzUBu2MGJFgoar05sPMCwSrhw44ASyccrHzj66vO69OZqi7I6hZZxXfuPLC8OCzW7C+sU+bI73XHij41yekgQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.26", - "@aws-sdk/credential-provider-env": "^3.972.24", - "@aws-sdk/credential-provider-http": "^3.972.26", - "@aws-sdk/credential-provider-login": "^3.972.28", - "@aws-sdk/credential-provider-process": "^3.972.24", - "@aws-sdk/credential-provider-sso": "^3.972.28", - "@aws-sdk/credential-provider-web-identity": "^3.972.28", - "@aws-sdk/nested-clients": "^3.996.18", - "@aws-sdk/types": "^3.973.6", - "@smithy/credential-provider-imds": "^4.2.12", - "@smithy/property-provider": "^4.2.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/credential-provider-env": "^3.972.34", + "@aws-sdk/credential-provider-http": "^3.972.36", + "@aws-sdk/credential-provider-login": "^3.972.38", + "@aws-sdk/credential-provider-process": "^3.972.34", + "@aws-sdk/credential-provider-sso": "^3.972.38", + "@aws-sdk/credential-provider-web-identity": "^3.972.38", + "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/types": "^3.973.8", + "@smithy/credential-provider-imds": "^4.2.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -839,18 +888,18 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.28", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.28.tgz", - "integrity": "sha512-ZSTfO6jqUTCysbdBPtEX5OUR//3rbD0lN7jO3sQeS2Gjr/Y+DT6SbIJ0oT2cemNw3UzKu97sNONd1CwNMthuZQ==", + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.38.tgz", + "integrity": "sha512-g1NosS8qe4OF++G2UFCM5ovSkgipC7YYor5KCWatG0UoMSO5YFj9C8muePlyVmOBV/WTI16Jo3/s1NUo/o1Bww==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.26", - "@aws-sdk/nested-clients": "^3.996.18", - "@aws-sdk/types": "^3.973.6", - "@smithy/property-provider": "^4.2.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -858,22 +907,22 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.29.tgz", - "integrity": "sha512-clSzDcvndpFJAggLDnDb36sPdlZYyEs5Zm6zgZjjUhwsJgSWiWKwFIXUVBcbruidNyBdbpOv2tNDL9sX8y3/0g==", + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.39.tgz", + "integrity": "sha512-HEswDQyxUtadoZ/bJsPPENHg7R0Lzym5LuMksJeHvqhCOpP+rtkDLKI4/ZChH4w3cf5kG8n6bZuI8PzajoiqMg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.24", - "@aws-sdk/credential-provider-http": "^3.972.26", - "@aws-sdk/credential-provider-ini": "^3.972.28", - "@aws-sdk/credential-provider-process": "^3.972.24", - "@aws-sdk/credential-provider-sso": "^3.972.28", - "@aws-sdk/credential-provider-web-identity": "^3.972.28", - "@aws-sdk/types": "^3.973.6", - "@smithy/credential-provider-imds": "^4.2.12", - "@smithy/property-provider": "^4.2.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", + "@aws-sdk/credential-provider-env": "^3.972.34", + "@aws-sdk/credential-provider-http": "^3.972.36", + "@aws-sdk/credential-provider-ini": "^3.972.38", + "@aws-sdk/credential-provider-process": "^3.972.34", + "@aws-sdk/credential-provider-sso": "^3.972.38", + "@aws-sdk/credential-provider-web-identity": "^3.972.38", + "@aws-sdk/types": "^3.973.8", + "@smithy/credential-provider-imds": "^4.2.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -881,16 +930,16 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.24", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.24.tgz", - "integrity": "sha512-Q2k/XLrFXhEztPHqj4SLCNID3hEPdlhh1CDLBpNnM+1L8fq7P+yON9/9M1IGN/dA5W45v44ylERfXtDAlmMNmw==", + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.34.tgz", + "integrity": "sha512-T3IFs4EVmVi1dVN5RciFnklCANSzvrQd/VuHY9ThHSQmYkTogjcGkoJEr+oNUPQZnso52183088NqysMPji1/Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.26", - "@aws-sdk/types": "^3.973.6", - "@smithy/property-provider": "^4.2.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -898,18 +947,18 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.28", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.28.tgz", - "integrity": "sha512-IoUlmKMLEITFn1SiCTjPfR6KrE799FBo5baWyk/5Ppar2yXZoUdaRqZzJzK6TcJxx450M8m8DbpddRVYlp5R/A==", + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.38.tgz", + "integrity": "sha512-5ZxG+t0+3Q3QPh8KEjX6syskhgNf7I0MN7oGioTf6Lm1NTjfP7sIcYGNsthXC2qR8vcD3edNZwCr2ovfSSWuRA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.26", - "@aws-sdk/nested-clients": "^3.996.18", - "@aws-sdk/token-providers": "3.1021.0", - "@aws-sdk/types": "^3.973.6", - "@smithy/property-provider": "^4.2.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/token-providers": "3.1041.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -917,17 +966,17 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.28", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.28.tgz", - "integrity": "sha512-d+6h0SD8GGERzKe27v5rOzNGKOl0D+l0bWJdqrxH8WSQzHzjsQFIAPgIeOTUwBHVsKKwtSxc91K/SWax6XgswQ==", + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.38.tgz", + "integrity": "sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.26", - "@aws-sdk/nested-clients": "^3.996.18", - "@aws-sdk/types": "^3.973.6", - "@smithy/property-provider": "^4.2.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -935,15 +984,14 @@ } }, "node_modules/@aws-sdk/dynamodb-codec": { - "version": "3.972.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.972.27.tgz", - "integrity": "sha512-S7IWE0K+aqbvjP8PHnOyDJK1fzrazAismH5XutJtS3YBvRvmfLb8Ac7Z1ZC4LBWvO8Gx1t/szFe46K51FqZn/A==", + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.973.8.tgz", + "integrity": "sha512-dYQ/cQqHZd23hcl8oEGwPphTqyGnmvf2HrVmz4J90Q5Bv89oJjlwcBcifiiTvApqsVpx7Pr0IebMpkYwWJvZlQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.26", - "@smithy/core": "^3.23.13", - "@smithy/smithy-client": "^4.12.8", - "@smithy/types": "^4.13.1", + "@aws-sdk/core": "^3.974.8", + "@smithy/core": "^3.23.17", + "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" }, @@ -965,16 +1013,16 @@ } }, "node_modules/@aws-sdk/middleware-bucket-endpoint": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.8.tgz", - "integrity": "sha512-WR525Rr2QJSETa9a050isktyWi/4yIGcmY3BQ1kpHqb0LqUglQHCS8R27dTJxxWNZvQ0RVGtEZjTCbZJpyF3Aw==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.10.tgz", + "integrity": "sha512-Vbc2frZH7wXlMNd+ZZSXUEs/l1Sv8Jj4zUnIfwrYF5lwaLdXHZ9xx4U3rjUcaye3HRhFVc+E5DbBxpRAbB16BA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.6", + "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-arn-parser": "^3.972.3", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, @@ -983,16 +1031,16 @@ } }, "node_modules/@aws-sdk/middleware-endpoint-discovery": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.9.tgz", - "integrity": "sha512-1503Y5Xk14SdXY0ucXwc08CY+aVuoY1tmQxsR/apwAVAwcLT7FFzqjYJYLq8JOkKJyzIB8M6J27e1ZcagGK+Fg==", + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.11.tgz", + "integrity": "sha512-vXARCZVFQHdsd6qPPZyC/hh+5x2XsCYKqUQDCqnUlpGpChMpDojOOacQWdLJ+FFXKN8X3cmLOGrtgx/zysCKqQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/endpoint-cache": "^3.972.5", - "@aws-sdk/types": "^3.973.6", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", + "@aws-sdk/types": "^3.973.8", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1000,14 +1048,14 @@ } }, "node_modules/@aws-sdk/middleware-expect-continue": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.8.tgz", - "integrity": "sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.10.tgz", + "integrity": "sha512-2Yn0f1Qiq/DjxYR3wfI3LokXnjOhFM7Ssn4LTdFDIxRMCE6I32MAsVnhPX1cUZsuVA9tiZtwwhlSLAtFGxAZlQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1015,23 +1063,23 @@ } }, "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.974.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.6.tgz", - "integrity": "sha512-YckB8k1ejbyCg/g36gUMFLNzE4W5cERIa4MtsdO+wpTmJEP0+TB7okWIt7d8TDOvnb7SwvxJ21E4TGOBxFpSWQ==", + "version": "3.974.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.16.tgz", + "integrity": "sha512-6ru8doI0/XzszqLIPXf0E/V7HhAw1Pu94010XCKYtBUfD0LxF0BuOzrUf8OQGR6j2o6wgKTHUniOmndQycHwCA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", - "@aws-sdk/core": "^3.973.26", - "@aws-sdk/crc64-nvme": "^3.972.5", - "@aws-sdk/types": "^3.973.6", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/crc64-nvme": "^3.972.7", + "@aws-sdk/types": "^3.973.8", "@smithy/is-array-buffer": "^4.2.2", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "@smithy/util-middleware": "^4.2.12", - "@smithy/util-stream": "^4.5.21", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1040,14 +1088,14 @@ } }, "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.8.tgz", - "integrity": "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.10.tgz", + "integrity": "sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1055,13 +1103,13 @@ } }, "node_modules/@aws-sdk/middleware-location-constraint": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.8.tgz", - "integrity": "sha512-KaUoFuoFPziIa98DSQsTPeke1gvGXlc5ZGMhy+b+nLxZ4A7jmJgLzjEF95l8aOQN2T/qlPP3MrAyELm8ExXucw==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.10.tgz", + "integrity": "sha512-rI3NZvJcEvjoD0+0PI0iUAwlPw2IlSlhyvgBK/3WkKJQE/YiKFedd9dMN2lVacdNxPNhxL/jzQaKQdrGtQagjQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@smithy/types": "^4.13.1", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1069,13 +1117,13 @@ } }, "node_modules/@aws-sdk/middleware-logger": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.8.tgz", - "integrity": "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.10.tgz", + "integrity": "sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@smithy/types": "^4.13.1", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1083,15 +1131,15 @@ } }, "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.9.tgz", - "integrity": "sha512-/Wt5+CT8dpTFQxEJ9iGy/UGrXr7p2wlIOEHvIr/YcHYByzoLjrqkYqXdJjd9UIgWjv7eqV2HnFJen93UTuwfTQ==", + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.11.tgz", + "integrity": "sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.6", + "@aws-sdk/types": "^3.973.8", "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1099,23 +1147,23 @@ } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.27.tgz", - "integrity": "sha512-gomO6DZwx+1D/9mbCpcqO5tPBqYBK7DtdgjTIjZ4yvfh/S7ETwAPS0XbJgP2JD8Ycr5CwVrEkV1sFtu3ShXeOw==", + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.37.tgz", + "integrity": "sha512-Km7M+i8DrLArVzrid1gfxeGhYHBd3uxvE77g0s5a52zPSVosxzQBnJ0gwWb6NIp/DOk8gsBMhi7V+cpJG0ndTA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.26", - "@aws-sdk/types": "^3.973.6", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-arn-parser": "^3.972.3", - "@smithy/core": "^3.23.13", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/signature-v4": "^5.3.12", - "@smithy/smithy-client": "^4.12.8", - "@smithy/types": "^4.13.1", + "@smithy/core": "^3.23.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-middleware": "^4.2.12", - "@smithy/util-stream": "^4.5.21", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1124,14 +1172,14 @@ } }, "node_modules/@aws-sdk/middleware-sdk-sqs": { - "version": "3.972.18", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sqs/-/middleware-sdk-sqs-3.972.18.tgz", - "integrity": "sha512-BdsGFuBJUX5PnuZkEV6JRB5g/6ts7iGmN3pXwyoiGCCM2HHXrlFqjkBs+iPX7yO884WqYeQJpme7nwn4DzU5xw==", + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-sqs/-/middleware-sdk-sqs-3.972.22.tgz", + "integrity": "sha512-DtR3mEiOUJcnEX/QuXmvbJto6xvQzp2ftnHb29c0aQYdmmzbKf0gsu9ovx1i/yy4ZR6m0rttTucS0iiP32dlGA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@smithy/smithy-client": "^4.12.8", - "@smithy/types": "^4.13.1", + "@aws-sdk/types": "^3.973.8", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" @@ -1141,13 +1189,13 @@ } }, "node_modules/@aws-sdk/middleware-ssec": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.8.tgz", - "integrity": "sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.10.tgz", + "integrity": "sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@smithy/types": "^4.13.1", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1155,34 +1203,18 @@ } }, "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.28", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.28.tgz", - "integrity": "sha512-cfWZFlVh7Va9lRay4PN2A9ARFzaBYcA097InT5M2CdRS05ECF5yaz86jET8Wsl2WcyKYEvVr/QNmKtYtafUHtQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.26", - "@aws-sdk/types": "^3.973.6", - "@aws-sdk/util-endpoints": "^3.996.5", - "@smithy/core": "^3.23.13", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "@smithy/util-retry": "^4.2.13", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-user-agent/node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.5.tgz", - "integrity": "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw==", + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.38.tgz", + "integrity": "sha512-iz+B29TXcAZsJpwB+AwG/TTGA5l/VnmMZ2UxtiySOZjI6gCdmviXPwdgzcmuazMy16rXoPY4mYCGe7zdNKfx5A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", - "@smithy/util-endpoints": "^3.3.3", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@smithy/core": "^3.23.17", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-retry": "^4.3.6", "tslib": "^2.6.2" }, "engines": { @@ -1190,47 +1222,48 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.996.18", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.18.tgz", - "integrity": "sha512-c7ZSIXrESxHKx2Mcopgd8AlzZgoXMr20fkx5ViPWPOLBvmyhw9VwJx/Govg8Ef/IhEon5R9l53Z8fdYSEmp6VA==", + "version": "3.997.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.6.tgz", + "integrity": "sha512-WBDnqatJl+kGObpfmfSxqnXeYTu3Me8wx8WCtvoxX3pfWrrTv8I4WTMSSs7PZqcRcVh8WeUKMgGFjMG+52SR1w==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.26", - "@aws-sdk/middleware-host-header": "^3.972.8", - "@aws-sdk/middleware-logger": "^3.972.8", - "@aws-sdk/middleware-recursion-detection": "^3.972.9", - "@aws-sdk/middleware-user-agent": "^3.972.28", - "@aws-sdk/region-config-resolver": "^3.972.10", - "@aws-sdk/types": "^3.973.6", - "@aws-sdk/util-endpoints": "^3.996.5", - "@aws-sdk/util-user-agent-browser": "^3.972.8", - "@aws-sdk/util-user-agent-node": "^3.973.14", - "@smithy/config-resolver": "^4.4.13", - "@smithy/core": "^3.23.13", - "@smithy/fetch-http-handler": "^5.3.15", - "@smithy/hash-node": "^4.2.12", - "@smithy/invalid-dependency": "^4.2.12", - "@smithy/middleware-content-length": "^4.2.12", - "@smithy/middleware-endpoint": "^4.4.28", - "@smithy/middleware-retry": "^4.4.46", - "@smithy/middleware-serde": "^4.2.16", - "@smithy/middleware-stack": "^4.2.12", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/node-http-handler": "^4.5.1", - "@smithy/protocol-http": "^5.3.12", - "@smithy/smithy-client": "^4.12.8", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-user-agent": "^3.972.38", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/signature-v4-multi-region": "^3.996.25", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.24", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.7", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.44", - "@smithy/util-defaults-mode-node": "^4.2.48", - "@smithy/util-endpoints": "^3.3.3", - "@smithy/util-middleware": "^4.2.12", - "@smithy/util-retry": "^4.2.13", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -1238,32 +1271,16 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.5.tgz", - "integrity": "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", - "@smithy/util-endpoints": "^3.3.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.10.tgz", - "integrity": "sha512-1dq9ToC6e070QvnVhhbAs3bb5r6cQ10gTVc6cyRV5uvQe7P138TV2uG2i6+Yok4bAkVAcx5AqkTEBUvWEtBlsQ==", + "version": "3.972.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.13.tgz", + "integrity": "sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@smithy/config-resolver": "^4.4.13", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/types": "^4.13.1", + "@aws-sdk/types": "^3.973.8", + "@smithy/config-resolver": "^4.4.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1271,18 +1288,18 @@ } }, "node_modules/@aws-sdk/s3-request-presigner": { - "version": "3.1025.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1025.0.tgz", - "integrity": "sha512-5kiXbyfUjPJIIVIvKoLNaiHk0vh93UeB5QUjJa4ZTGPr08dJh7oCzY3JKT/dNdr20uUO+qxVkhVQ4ZI9Tmhx8A==", + "version": "3.1044.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1044.0.tgz", + "integrity": "sha512-ix8UtiNC5g1wv3TIcgTnvWdugyw8dSsBGwZZzVVoGyYjZH9UJLqiOyvVu6apptlPBeE6aV6Fabsx0b1xYFd2ZA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/signature-v4-multi-region": "^3.996.15", - "@aws-sdk/types": "^3.973.6", - "@aws-sdk/util-format-url": "^3.972.8", - "@smithy/middleware-endpoint": "^4.4.28", - "@smithy/protocol-http": "^5.3.12", - "@smithy/smithy-client": "^4.12.8", - "@smithy/types": "^4.13.1", + "@aws-sdk/signature-v4-multi-region": "^3.996.25", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-format-url": "^3.972.10", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1290,16 +1307,16 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.15.tgz", - "integrity": "sha512-Ukw2RpqvaL96CjfH/FgfBmy/ZosHBqoHBCFsN61qGg99F33vpntIVii8aNeh65XuOja73arSduskoa4OJea9RQ==", + "version": "3.996.25", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.25.tgz", + "integrity": "sha512-+CMIt3e1VzlklAECmG+DtP1sV8iKq25FuA0OKpnJ4KA0kxUtd7CgClY7/RU6VzJBQwbN4EJ9Ue6plvqx1qGadw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-sdk-s3": "^3.972.27", - "@aws-sdk/types": "^3.973.6", - "@smithy/protocol-http": "^5.3.12", - "@smithy/signature-v4": "^5.3.12", - "@smithy/types": "^4.13.1", + "@aws-sdk/middleware-sdk-s3": "^3.972.37", + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1307,17 +1324,17 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1021.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1021.0.tgz", - "integrity": "sha512-TKY6h9spUk3OLs5v1oAgW9mAeBE3LAGNBwJokLy96wwmd4W2v/tYlXseProyed9ValDj2u1jK/4Rg1T+1NXyJA==", + "version": "3.1041.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1041.0.tgz", + "integrity": "sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.26", - "@aws-sdk/nested-clients": "^3.996.18", - "@aws-sdk/types": "^3.973.6", - "@smithy/property-provider": "^4.2.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1325,12 +1342,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.6.tgz", - "integrity": "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw==", + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1350,15 +1367,15 @@ } }, "node_modules/@aws-sdk/util-endpoints": { - "version": "3.984.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.984.0.tgz", - "integrity": "sha512-9ebjLA0hMKHeVvXEtTDCCOBtwjb0bOXiuUV06HNeVdgAjH6gj4x4Zwt4IBti83TiyTGOCl5YfZqGx4ehVsasbQ==", + "version": "3.996.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.8.tgz", + "integrity": "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "@smithy/util-endpoints": "^3.2.8", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-endpoints": "^3.4.2", "tslib": "^2.6.2" }, "engines": { @@ -1366,14 +1383,14 @@ } }, "node_modules/@aws-sdk/util-format-url": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.8.tgz", - "integrity": "sha512-J6DS9oocrgxM8xlUTTmQOuwRF6rnAGEujAN9SAzllcrQmwn5iJ58ogxy3SEhD0Q7JZvlA5jvIXBkpQRqEqlE9A==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.10.tgz", + "integrity": "sha512-DEKiHNJVtNxdyTeQspzY+15Po/kHm6sF0Cs4HV9Q2+lplB63+DrvdeiSoOSdWEWAoO2RcY1veoXVDz2tWxWCgQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@smithy/querystring-builder": "^4.2.12", - "@smithy/types": "^4.13.1", + "@aws-sdk/types": "^3.973.8", + "@smithy/querystring-builder": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -1393,27 +1410,27 @@ } }, "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.8.tgz", - "integrity": "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.10.tgz", + "integrity": "sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.6", - "@smithy/types": "^4.13.1", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.14", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.14.tgz", - "integrity": "sha512-vNSB/DYaPOyujVZBg/zUznH9QC142MaTHVmaFlF7uzzfg3CgT9f/l4C0Yi+vU/tbBhxVcXVB90Oohk5+o+ZbWw==", + "version": "3.973.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.24.tgz", + "integrity": "sha512-ZWwlkjcIp7cEL8ZfTpTAPNkwx25p7xol0xlKoWVVf22+nsjwmLcHYtTPjIV1cSpmB/b6DaK4cb1fSkvCXHgRdw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.28", - "@aws-sdk/types": "^3.973.6", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/types": "^4.13.1", + "@aws-sdk/middleware-user-agent": "^3.972.38", + "@aws-sdk/types": "^3.973.8", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, @@ -1430,13 +1447,14 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.16.tgz", - "integrity": "sha512-iu2pyvaqmeatIJLURLqx9D+4jKAdTH20ntzB6BFwjyN7V960r4jK32mx0Zf7YbtOYAbmbtQfDNuL60ONinyw7A==", + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.22.tgz", + "integrity": "sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", - "fast-xml-parser": "5.5.8", + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.2", "tslib": "^2.6.2" }, "engines": { @@ -3419,15 +3437,15 @@ } }, "node_modules/@next/env": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.3.tgz", - "integrity": "sha512-IqgtY5Vwsm14mm/nmQaRMmywCU+yyMIYfk3/MHZ2ZTJvwVbBn3usZnjMi1GacrMVzVcAxJShTCpZlPs26EdEjQ==", + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.5.tgz", + "integrity": "sha512-Lb9ElHD2klcyeVD25vW+siPFqz9QMzDUSgvFZNO+dZEKoMHex4viJhVuzBhrXKqb+UKnih7mVYbt50/7KLsSCA==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.0.3.tgz", - "integrity": "sha512-6sPWmZetzFWMsz7Dhuxsdmbu3fK+/AxKRtj7OB0/3OZAI2MHB/v2FeYh271LZ9abvnM1WIwWc/5umYjx0jo5sQ==", + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.5.tgz", + "integrity": "sha512-PyILm/cw2u5gEG5xOjqFbALUAl/erAqtM47iZtP9lXiSzin+eOIf3KRi+CBC/mFG9j7Iz3JDqCOY94nFLUCccg==", "dev": true, "license": "MIT", "dependencies": { @@ -3435,9 +3453,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.3.tgz", - "integrity": "sha512-MOnbd92+OByu0p6QBAzq1ahVWzF6nyfiH07dQDez4/Nku7G249NjxDVyEfVhz8WkLiOEU+KFVnqtgcsfP2nLXg==", + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.5.tgz", + "integrity": "sha512-BW+8PGVmsruomXHsitD8JG6gny9lEdobctjBwvtPF8AKtxGDR7nR35FOl/oK9UAPXBOBm+vx0k8qtpeHOXQMGQ==", "cpu": [ "arm64" ], @@ -3451,9 +3469,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.3.tgz", - "integrity": "sha512-i70C4O1VmbTivYdRlk+5lj9xRc2BlK3oUikt3yJeHT1unL4LsNtN7UiOhVanFdc7vDAgZn1tV/9mQwMkWOJvHg==", + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.5.tgz", + "integrity": "sha512-ZoCGnCl9LlQJWmqXrZAUlNxvuNmclvE+7zUif+nDydkkehl9FKxHJ+wxSQMj+C37BYFerKiEdX9s9o02ir975Q==", "cpu": [ "x64" ], @@ -3467,9 +3485,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.3.tgz", - "integrity": "sha512-O88gCZ95sScwD00mn/AtalyCoykhhlokxH/wi1huFK+rmiP5LAYVs/i2ruk7xST6SuXN4NI5y4Xf5vepb2jf6A==", + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.5.tgz", + "integrity": "sha512-AwcZzMChaWkOTZt3vu+2ZMIj8g4dYQY+B8VUVhlFSQ2JtvyZpefyYHTe00D6b6L7BysYw7vl3zsvs9jix8tl5Q==", "cpu": [ "arm64" ], @@ -3483,9 +3501,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.3.tgz", - "integrity": "sha512-CEErFt78S/zYXzFIiv18iQCbRbLgBluS8z1TNDQoyPi8/Jr5qhR3e8XHAIxVxPBjDbEMITprqELVc5KTfFj0gg==", + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.5.tgz", + "integrity": "sha512-QqMgqWbCBFsfiQ7BF3dUlW8HJy1LWhpcqbTpoHMWA9IV+TnWwDKozQJA5NdIAHjQ00yX2Q7AUkLr/XK4n77q8A==", "cpu": [ "arm64" ], @@ -3499,9 +3517,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.3.tgz", - "integrity": "sha512-Tc3i+nwt6mQ+Dwzcri/WNDj56iWdycGVh5YwwklleClzPzz7UpfaMw1ci7bLl6GRYMXhWDBfe707EXNjKtiswQ==", + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.5.tgz", + "integrity": "sha512-3hzeiFGZtyATVx9pCeuzTshXmh50vHZitqaeZiyJZaUmjQyrfjsVUgS8apOj1vEJCIpKJM/55F45yPAV2kpjsA==", "cpu": [ "x64" ], @@ -3515,9 +3533,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.3.tgz", - "integrity": "sha512-zTh03Z/5PBBPdTurgEtr6nY0vI9KR9Ifp/jZCcHlODzwVOEKcKRBtQIGrkc7izFgOMuXDEJBmirwpGqdM/ZixA==", + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.5.tgz", + "integrity": "sha512-0mzZV/mAt7Qj2tYNdTB6AqrS8dwng/AQLSYC5Z1YLpZdi2wxqKDPK7RY2RvjB1fXyJfOfdA3l/yTF5yLi+WfuQ==", "cpu": [ "x64" ], @@ -3531,9 +3549,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.3.tgz", - "integrity": "sha512-Jc1EHxtZovcJcg5zU43X3tuqzl/sS+CmLgjRP28ZT4vk869Ncm2NoF8qSTaL99gh6uOzgM99Shct06pSO6kA6g==", + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.5.tgz", + "integrity": "sha512-f/H4nZ2zJBvA8/+HpsB9mNonF9zfQoAU6D0WxJrfzhJDvJLfngVN85oqxUyrDVK99DIFfFYhLpGa5K+c5uotSw==", "cpu": [ "arm64" ], @@ -3547,9 +3565,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.3.tgz", - "integrity": "sha512-N7EJ6zbxgIYpI/sWNzpVKRMbfEGgsWuOIvzkML7wxAAZhPk1Msxuo/JDu1PKjWGrAoOLaZcIX5s+/pF5LIbBBg==", + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.5.tgz", + "integrity": "sha512-nuP7DHs4koAojsIxVPkihNgKiRUKtCU65j5X6DAbSy8VBrfT/o90bCLLHPf51JEdOZwZMFzM6e0NiGWfIWjVAg==", "cpu": [ "x64" ], @@ -3601,6 +3619,18 @@ "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", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@node-minify/core": { "version": "8.0.6", "resolved": "https://registry.npmjs.org/@node-minify/core/-/core-8.0.6.tgz", @@ -3616,9 +3646,9 @@ } }, "node_modules/@node-minify/core/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -3772,9 +3802,9 @@ } }, "node_modules/@opennextjs/aws": { - "version": "3.9.16", - "resolved": "https://registry.npmjs.org/@opennextjs/aws/-/aws-3.9.16.tgz", - "integrity": "sha512-jQQStCysIllNCPqz5W2KSguXpr+ETlOcD8SyNu+h9zwpRVYk4uEPQge+ErG3avI5xsT8vKA7EGLYG59dhj/B6Q==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@opennextjs/aws/-/aws-4.0.1.tgz", + "integrity": "sha512-k+wV8xyl2koaQRp84EY++3tO1J/M0b2KK4zR0LrPSwDgPqcR9EaKYiUu1mugc79A0KVgo839KR+opgk3wpSsXw==", "license": "MIT", "dependencies": { "@ast-grep/napi": "^0.40.5", @@ -3799,7 +3829,7 @@ "open-next": "dist/index.js" }, "peerDependencies": { - "next": "~15.0.8 || ~15.1.12 || ~15.2.9 || ~15.3.9 || ~15.4.11 || ~15.5.10 || ~16.0.11 || ^16.1.5" + "next": ">=15.5.16 <16 || >=16.2.5" } }, "node_modules/@opennextjs/aws/node_modules/@aws-sdk/client-s3": { @@ -3885,15 +3915,32 @@ "node": ">=20.0.0" } }, + "node_modules/@opennextjs/aws/node_modules/@aws-sdk/util-endpoints": { + "version": "3.984.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.984.0.tgz", + "integrity": "sha512-9ebjLA0hMKHeVvXEtTDCCOBtwjb0bOXiuUV06HNeVdgAjH6gj4x4Zwt4IBti83TiyTGOCl5YfZqGx4ehVsasbQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@opennextjs/cloudflare": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@opennextjs/cloudflare/-/cloudflare-1.18.0.tgz", - "integrity": "sha512-JM236YHnKzroFAZqst1t28ZGOShvnkVUDtjrp7TJ/W2P3RLo4b6npJ8VEXOn6frs6lsUfR5rvsKYLYb7h1GIJQ==", + "version": "1.19.8", + "resolved": "https://registry.npmjs.org/@opennextjs/cloudflare/-/cloudflare-1.19.8.tgz", + "integrity": "sha512-4c8gFgVWsuH+g42b1/tmltWeeGrM+vK+yx3v7sQS4ZdnjB5Oh4KHjOBuSyv/ZOKjad+67RxLZT1uP7yDKK/Y6w==", "license": "MIT", "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", - "@opennextjs/aws": "3.9.16", + "@opennextjs/aws": "4.0.1", + "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", @@ -3905,8 +3952,8 @@ "opennextjs-cloudflare": "dist/cli/index.js" }, "peerDependencies": { - "next": "~15.0.8 || ~15.1.12 || ~15.2.9 || ~15.3.9 || ~15.4.11 || ~15.5.10 || ~16.0.11 || ^16.1.5", - "wrangler": "^4.65.0" + "next": ">=15.5.16 <16 || >=16.2.5", + "wrangler": "^4.86.0" } }, "node_modules/@openrouter/sdk": { @@ -4632,16 +4679,16 @@ } }, "node_modules/@smithy/config-resolver": { - "version": "4.4.13", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.13.tgz", - "integrity": "sha512-iIzMC5NmOUP6WL6o8iPBjFhUhBZ9pPjpUpQYWMUFQqKyXXzOftbfK8zcQCz/jFV1Psmf05BK5ypx4K2r4Tnwdg==", + "version": "4.4.17", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.17.tgz", + "integrity": "sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.12", - "@smithy/types": "^4.13.1", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-endpoints": "^3.3.3", - "@smithy/util-middleware": "^4.2.12", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", "tslib": "^2.6.2" }, "engines": { @@ -4649,18 +4696,18 @@ } }, "node_modules/@smithy/core": { - "version": "3.23.13", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.13.tgz", - "integrity": "sha512-J+2TT9D6oGsUVXVEMvz8h2EmdVnkBiy2auCie4aSJMvKlzUtO5hqjEzXhoCUkIMo7gAYjbQcN0g/MMSXEhDs1Q==", + "version": "3.23.17", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.17.tgz", + "integrity": "sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-middleware": "^4.2.12", - "@smithy/util-stream": "^4.5.21", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-stream": "^4.5.25", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" @@ -4670,15 +4717,15 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.12.tgz", - "integrity": "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.14.tgz", + "integrity": "sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.12", - "@smithy/property-provider": "^4.2.12", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", "tslib": "^2.6.2" }, "engines": { @@ -4686,13 +4733,13 @@ } }, "node_modules/@smithy/eventstream-codec": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.12.tgz", - "integrity": "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.14.tgz", + "integrity": "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" }, @@ -4701,13 +4748,13 @@ } }, "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.12.tgz", - "integrity": "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.14.tgz", + "integrity": "sha512-8IelTCtTctWRbb+0Dcy+C0aICh1qa0qWXqgjcXDmMuCvPJRnv26hiDZoAau2ILOniki65mCPKqOQs/BaWvO4CQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.12", - "@smithy/types": "^4.13.1", + "@smithy/eventstream-serde-universal": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4715,12 +4762,12 @@ } }, "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.3.12", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.12.tgz", - "integrity": "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q==", + "version": "4.3.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.14.tgz", + "integrity": "sha512-sqHiHpYRYo3FJlaIxD1J8PhbcmJAm7IuM16mVnwSkCToD7g00IBZzKuiLNMGmftULmEUX6/UAz8/NN5uMP8bVA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4728,13 +4775,13 @@ } }, "node_modules/@smithy/eventstream-serde-node": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.12.tgz", - "integrity": "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.14.tgz", + "integrity": "sha512-Ht/8BuGlKfFTy0H3+8eEu0vdpwGztCnaLLXtpXNdQqiR7Hj4vFScU3T436vRAjATglOIPjJXronY+1WxxNLSiw==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.12", - "@smithy/types": "^4.13.1", + "@smithy/eventstream-serde-universal": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4742,13 +4789,13 @@ } }, "node_modules/@smithy/eventstream-serde-universal": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.12.tgz", - "integrity": "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.14.tgz", + "integrity": "sha512-lWyt4T2XQZUZgK3tQ3Wn0w3XBvZsK/vjTuJl6bXbnGZBHH0ZUSONTYiK9TgjTTzU54xQr3DRFwpjmhp0oLm3gg==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-codec": "^4.2.12", - "@smithy/types": "^4.13.1", + "@smithy/eventstream-codec": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4756,14 +4803,14 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.15", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.15.tgz", - "integrity": "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==", + "version": "5.3.17", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.17.tgz", + "integrity": "sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.12", - "@smithy/querystring-builder": "^4.2.12", - "@smithy/types": "^4.13.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/querystring-builder": "^4.2.14", + "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" }, @@ -4772,14 +4819,14 @@ } }, "node_modules/@smithy/hash-blob-browser": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.13.tgz", - "integrity": "sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g==", + "version": "4.2.15", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.15.tgz", + "integrity": "sha512-0PJ4Al3fg2nM4qKrAIxyNcApgqHAXcBkN8FeizOz69z0rb26uZ6lMESYtxegaTlXB5Hj84JfwMPavMrwDMjucA==", "license": "Apache-2.0", "dependencies": { "@smithy/chunked-blob-reader": "^5.2.2", "@smithy/chunked-blob-reader-native": "^4.2.3", - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4787,12 +4834,12 @@ } }, "node_modules/@smithy/hash-node": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.12.tgz", - "integrity": "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.14.tgz", + "integrity": "sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.1", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" @@ -4802,12 +4849,12 @@ } }, "node_modules/@smithy/hash-stream-node": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.12.tgz", - "integrity": "sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.14.tgz", + "integrity": "sha512-tw4GANWkZPb6+BdD4Fgucqzey2+r73Z/GRo9zklsCdwrnxxumUV83ZIaBDdudV4Ylazw3EPTiJZhpX42105ruQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -4816,12 +4863,12 @@ } }, "node_modules/@smithy/invalid-dependency": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.12.tgz", - "integrity": "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.14.tgz", + "integrity": "sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4841,12 +4888,12 @@ } }, "node_modules/@smithy/md5-js": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.12.tgz", - "integrity": "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.14.tgz", + "integrity": "sha512-V2v0vx+h0iUSNG1Alt+GNBMSLGCrl9iVsdd+Ap67HPM9PN479x12V8LkuMoKImNZxn3MXeuyUjls+/7ZACZghA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -4855,13 +4902,13 @@ } }, "node_modules/@smithy/middleware-content-length": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.12.tgz", - "integrity": "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.14.tgz", + "integrity": "sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4869,18 +4916,18 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.28", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.28.tgz", - "integrity": "sha512-p1gfYpi91CHcs5cBq982UlGlDrxoYUX6XdHSo91cQ2KFuz6QloHosO7Jc60pJiVmkWrKOV8kFYlGFFbQ2WUKKQ==", + "version": "4.4.32", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.32.tgz", + "integrity": "sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.13", - "@smithy/middleware-serde": "^4.2.16", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", - "@smithy/url-parser": "^4.2.12", - "@smithy/util-middleware": "^4.2.12", + "@smithy/core": "^3.23.17", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-middleware": "^4.2.14", "tslib": "^2.6.2" }, "engines": { @@ -4888,18 +4935,19 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.4.46", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.46.tgz", - "integrity": "sha512-SpvWNNOPOrKQGUqZbEPO+es+FRXMWvIyzUKUOYdDgdlA6BdZj/R58p4umoQ76c2oJC44PiM7mKizyyex1IJzow==", + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.7.tgz", + "integrity": "sha512-bRt6ZImqVSeTk39Nm81K20ObIiAZ3WefY7G6+iz/0tZjs4dgRRjvRX2sgsH+zi6iDCRR/aQvQofLKxxz4rPBZg==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/service-error-classification": "^4.2.12", - "@smithy/smithy-client": "^4.12.8", - "@smithy/types": "^4.13.1", - "@smithy/util-middleware": "^4.2.12", - "@smithy/util-retry": "^4.2.13", + "@smithy/core": "^3.23.17", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/service-error-classification": "^4.3.1", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, @@ -4908,14 +4956,14 @@ } }, "node_modules/@smithy/middleware-serde": { - "version": "4.2.16", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.16.tgz", - "integrity": "sha512-beqfV+RZ9RSv+sQqor3xroUUYgRFCGRw6niGstPG8zO9LgTl0B0MCucxjmrH/2WwksQN7UUgI7KNANoZv+KALA==", + "version": "4.2.20", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.20.tgz", + "integrity": "sha512-Lx9JMO9vArPtiChE3wbEZ5akMIDQpWQtlu90lhACQmNOXcGXRbaDywMHDzuDZ2OkZzP+9wQfZi3YJT9F67zTQQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.13", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", + "@smithy/core": "^3.23.17", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4923,12 +4971,12 @@ } }, "node_modules/@smithy/middleware-stack": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.12.tgz", - "integrity": "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.14.tgz", + "integrity": "sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4936,14 +4984,14 @@ } }, "node_modules/@smithy/node-config-provider": { - "version": "4.3.12", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.12.tgz", - "integrity": "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==", + "version": "4.3.14", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.14.tgz", + "integrity": "sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.12", - "@smithy/shared-ini-file-loader": "^4.4.7", - "@smithy/types": "^4.13.1", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4951,14 +4999,14 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.1.tgz", - "integrity": "sha512-ejjxdAXjkPIs9lyYyVutOGNOraqUE9v/NjGMKwwFrfOM354wfSD8lmlj8hVwUzQmlLLF4+udhfCX9Exnbmvfzw==", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.6.1.tgz", + "integrity": "sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.12", - "@smithy/querystring-builder": "^4.2.12", - "@smithy/types": "^4.13.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/querystring-builder": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4966,12 +5014,12 @@ } }, "node_modules/@smithy/property-provider": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.12.tgz", - "integrity": "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.14.tgz", + "integrity": "sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4979,12 +5027,12 @@ } }, "node_modules/@smithy/protocol-http": { - "version": "5.3.12", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.12.tgz", - "integrity": "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw==", + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.14.tgz", + "integrity": "sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4992,12 +5040,12 @@ } }, "node_modules/@smithy/querystring-builder": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.12.tgz", - "integrity": "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.14.tgz", + "integrity": "sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" }, @@ -5006,12 +5054,12 @@ } }, "node_modules/@smithy/querystring-parser": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.12.tgz", - "integrity": "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.14.tgz", + "integrity": "sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -5019,24 +5067,24 @@ } }, "node_modules/@smithy/service-error-classification": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.12.tgz", - "integrity": "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.3.1.tgz", + "integrity": "sha512-aUQuDGh760ts/8MU+APjIZhlLPKhIIfqyzZaJikLEIMrdxFvxuLYD0WxWzaYWpmLbQlXDe9p7EWM3HsBe0K6Gw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1" + "@smithy/types": "^4.14.1" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.7", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.7.tgz", - "integrity": "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw==", + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.9.tgz", + "integrity": "sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -5044,16 +5092,16 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.3.12", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.12.tgz", - "integrity": "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==", + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.14.tgz", + "integrity": "sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==", "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^4.2.2", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-middleware": "^4.2.12", + "@smithy/util-middleware": "^4.2.14", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" @@ -5063,17 +5111,17 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.12.8", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.8.tgz", - "integrity": "sha512-aJaAX7vHe5i66smoSSID7t4rKY08PbD8EBU7DOloixvhOozfYWdcSYE4l6/tjkZ0vBZhGjheWzB2mh31sLgCMA==", + "version": "4.12.13", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.13.tgz", + "integrity": "sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.13", - "@smithy/middleware-endpoint": "^4.4.28", - "@smithy/middleware-stack": "^4.2.12", - "@smithy/protocol-http": "^5.3.12", - "@smithy/types": "^4.13.1", - "@smithy/util-stream": "^4.5.21", + "@smithy/core": "^3.23.17", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-stream": "^4.5.25", "tslib": "^2.6.2" }, "engines": { @@ -5081,9 +5129,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.13.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz", - "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz", + "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -5093,13 +5141,13 @@ } }, "node_modules/@smithy/url-parser": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.12.tgz", - "integrity": "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.14.tgz", + "integrity": "sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.2.12", - "@smithy/types": "^4.13.1", + "@smithy/querystring-parser": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -5170,14 +5218,14 @@ } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.44", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.44.tgz", - "integrity": "sha512-eZg6XzaCbVr2S5cAErU5eGBDaOVTuTo1I65i4tQcHENRcZ8rMWhQy1DaIYUSLyZjsfXvmCqZrstSMYyGFocvHA==", + "version": "4.3.49", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.49.tgz", + "integrity": "sha512-a5bNrdiONYB/qE2BuKegvUMd/+ZDwdg4vsNuuSzYE8qs2EYAdK9CynL+Rzn29PbPiUqoz/cbpRbcLzD5lEevHw==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.12", - "@smithy/smithy-client": "^4.12.8", - "@smithy/types": "^4.13.1", + "@smithy/property-provider": "^4.2.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -5185,17 +5233,17 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.48", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.48.tgz", - "integrity": "sha512-FqOKTlqSaoV3nzO55pMs5NBnZX8EhoI0DGmn9kbYeXWppgHD6dchyuj2HLqp4INJDJbSrj6OFYJkAh/WhSzZPg==", + "version": "4.2.54", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.54.tgz", + "integrity": "sha512-g1cvrJvOnzeJgEdf7AE4luI7gp6L8weE0y9a9wQUSGtjb8QRHDbCJYuE4Sy0SD9N8RrnNPFsPltAz/OSoBR9Zw==", "license": "Apache-2.0", "dependencies": { - "@smithy/config-resolver": "^4.4.13", - "@smithy/credential-provider-imds": "^4.2.12", - "@smithy/node-config-provider": "^4.3.12", - "@smithy/property-provider": "^4.2.12", - "@smithy/smithy-client": "^4.12.8", - "@smithy/types": "^4.13.1", + "@smithy/config-resolver": "^4.4.17", + "@smithy/credential-provider-imds": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -5203,13 +5251,13 @@ } }, "node_modules/@smithy/util-endpoints": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.3.tgz", - "integrity": "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.4.2.tgz", + "integrity": "sha512-a55Tr+3OKld4TTtnT+RhKOQHyPxm3j/xL4OR83WBUhLJaKDS9dnJ7arRMOp3t31dcLhApwG9bgvrRXBHlLdIkg==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.12", - "@smithy/types": "^4.13.1", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -5229,12 +5277,12 @@ } }, "node_modules/@smithy/util-middleware": { - "version": "4.2.12", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.12.tgz", - "integrity": "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.14.tgz", + "integrity": "sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -5242,13 +5290,13 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.13.tgz", - "integrity": "sha512-qQQsIvL0MGIbUjeSrg0/VlQ3jGNKyM3/2iU3FPNgy01z+Sp4OvcaxbgIoFOTvB61ZoohtutuOvOcgmhbD0katQ==", + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.8.tgz", + "integrity": "sha512-LUIxbTBi+OpvXpg91poGA6BdyoleMDLnfXjVDqyi2RvZmTveY5loE/FgYUBCR5LU2BThW2SoZRh8dTIIy38IPw==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.2.12", - "@smithy/types": "^4.13.1", + "@smithy/service-error-classification": "^4.3.1", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -5256,14 +5304,14 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.5.21", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.21.tgz", - "integrity": "sha512-KzSg+7KKywLnkoKejRtIBXDmwBfjGvg1U1i/etkC7XSWUyFCoLno1IohV2c74IzQqdhX5y3uE44r/8/wuK+A7Q==", + "version": "4.5.25", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.25.tgz", + "integrity": "sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.3.15", - "@smithy/node-http-handler": "^4.5.1", - "@smithy/types": "^4.13.1", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", @@ -5300,12 +5348,12 @@ } }, "node_modules/@smithy/util-waiter": { - "version": "4.2.14", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.14.tgz", - "integrity": "sha512-2zqq5o/oizvMaFUlNiTyZ7dbgYv1a893aGut2uaxtbzTx/VYYnRxWzDHuD/ftgcw94ffenua+ZNLrbqwUYE+Bg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.3.0.tgz", + "integrity": "sha512-JyjYmLAfS+pdxF92o4yLgEoy0zhayKTw73FU1aofLWwLcJw7iSqIY2exGmMTrl/lmZugP5p/zxdFSippJDfKWA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.1", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -7070,9 +7118,9 @@ ] }, "node_modules/@xmldom/xmldom": { - "version": "0.8.12", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", - "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==", + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -7573,7 +7621,6 @@ "version": "2.10.14", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz", "integrity": "sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==", - "dev": true, "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -7962,6 +8009,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -8160,9 +8222,9 @@ "license": "MIT" }, "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "license": "MIT", "engines": { "node": ">=18" @@ -9181,13 +9243,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.0.3.tgz", - "integrity": "sha512-5F6qDjcZldf0Y0ZbqvWvap9xzYUxyDf7/of37aeyhvkrQokj/4bT1JYWZdlWUr283aeVa+s52mPq9ogmGg+5dw==", + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.5.tgz", + "integrity": "sha512-fXEkugikngux1FBJ/Vop+52SLAMFjXZFXjyl/+HjGHngnXf8iIfqe3qdjcwN+40RBpSsCVhI04j0/ngEWL5Qng==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.0.3", + "@next/eslint-plugin-next": "16.2.5", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -9823,9 +9885,9 @@ "license": "Unlicense" }, "node_modules/fast-xml-builder": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", - "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.9.tgz", + "integrity": "sha512-jcyKVSEX13iseJqg7n/KWw+xnu/7fdrZ333Fac54KjHDIELVCfDDJXYIm6DTJ0Su4gSzrhqiK0DzY/wZbF40mw==", "funding": [ { "type": "github", @@ -9838,9 +9900,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "5.5.8", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", - "integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz", + "integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==", "funding": [ { "type": "github", @@ -9849,9 +9911,10 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.1.4", - "path-expression-matcher": "^1.2.0", - "strnum": "^2.2.0" + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.5", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" @@ -13522,14 +13585,14 @@ } }, "node_modules/next": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/next/-/next-16.0.3.tgz", - "integrity": "sha512-Ka0/iNBblPFcIubTA1Jjh6gvwqfjrGq1Y2MTI5lbjeLIAfmC+p5bQmojpRZqgHHVu5cG4+qdIiwXiBSm/8lZ3w==", - "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.", + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.5.tgz", + "integrity": "sha512-TkVTm9F2WEulkgGljm4wPwNgvCCWCVw6StUHsZb8WZpHFRjepoUWg3d7L4IMg7IyjcJ4Co9eVhpro8e8O+KarQ==", "license": "MIT", "dependencies": { - "@next/env": "16.0.3", + "@next/env": "16.2.5", "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -13541,15 +13604,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.0.3", - "@next/swc-darwin-x64": "16.0.3", - "@next/swc-linux-arm64-gnu": "16.0.3", - "@next/swc-linux-arm64-musl": "16.0.3", - "@next/swc-linux-x64-gnu": "16.0.3", - "@next/swc-linux-x64-musl": "16.0.3", - "@next/swc-win32-arm64-msvc": "16.0.3", - "@next/swc-win32-x64-msvc": "16.0.3", - "sharp": "^0.34.4" + "@next/swc-darwin-arm64": "16.2.5", + "@next/swc-darwin-x64": "16.2.5", + "@next/swc-linux-arm64-gnu": "16.2.5", + "@next/swc-linux-arm64-musl": "16.2.5", + "@next/swc-linux-x64-gnu": "16.2.5", + "@next/swc-linux-x64-musl": "16.2.5", + "@next/swc-win32-arm64-msvc": "16.2.5", + "@next/swc-win32-x64-msvc": "16.2.5", + "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -13574,52 +13637,6 @@ } } }, - "node_modules/next/node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "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/next/node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "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.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "node_modules/nextjs-toploader": { "version": "3.9.17", "resolved": "https://registry.npmjs.org/nextjs-toploader/-/nextjs-toploader-3.9.17.tgz", @@ -14083,9 +14100,9 @@ } }, "node_modules/path-expression-matcher": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.1.tgz", - "integrity": "sha512-d7gQQmLvAKXKXE2GeP9apIGbMYKz88zWdsn/BN2HRWVQsDFdUY36WSLTY0Jvd4HWi7Fb30gQ62oAOzdgJA6fZw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", "funding": [ { "type": "github", @@ -14207,10 +14224,9 @@ "license": "MIT-0" }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "dev": true, + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "funding": [ { "type": "opencollective", @@ -14239,7 +14255,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -14519,9 +14534,9 @@ } }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -14579,24 +14594,24 @@ } }, "node_modules/react": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", - "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", - "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.0" + "react": "^19.2.6" } }, "node_modules/react-is": { @@ -16066,9 +16081,9 @@ } }, "node_modules/strnum": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", - "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", "funding": [ { "type": "github", @@ -17814,9 +17829,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/frontend/package.json b/frontend/package.json index 520d74dc..aa76d576 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,9 +14,9 @@ "cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts" }, "dependencies": { - "@aws-sdk/client-s3": "^3.1025.0", - "@aws-sdk/s3-request-presigner": "^3.1025.0", - "@opennextjs/cloudflare": "^1.13.1", + "@aws-sdk/client-s3": "^3.1044.0", + "@aws-sdk/s3-request-presigner": "^3.1044.0", + "@opennextjs/cloudflare": "^1.19.8", "@openrouter/sdk": "^0.3.11", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-icons": "^1.3.2", @@ -38,11 +38,11 @@ "lucide-react": "^0.553.0", "mammoth": "^1.11.0", "marked": "^17.0.1", - "next": "16.0.3", + "next": "16.2.5", "nextjs-toploader": "^3.9.17", "pdfjs-dist": "4.10.38", - "react": "19.2.0", - "react-dom": "19.2.0", + "react": "19.2.6", + "react-dom": "19.2.6", "react-markdown": "^10.1.0", "recharts": "^3.7.0", "rehype-katex": "^7.0.1", @@ -54,6 +54,10 @@ "tailwind-merge": "^3.4.0", "tiptap-markdown": "^0.9.0" }, + "overrides": { + "@xmldom/xmldom": "0.8.13", + "postcss": "8.5.10" + }, "devDependencies": { "@tailwindcss/postcss": "^4", "@types/marked": "^5.0.2", @@ -64,7 +68,7 @@ "babel-plugin-react-compiler": "1.0.0", "baseline-browser-mapping": "^2.9.11", "eslint": "^9", - "eslint-config-next": "16.0.3", + "eslint-config-next": "16.2.5", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", "typescript": "^5", diff --git a/frontend/src/app/(pages)/account/models/page.tsx b/frontend/src/app/(pages)/account/models/page.tsx index f10619fc..0a80aa53 100644 --- a/frontend/src/app/(pages)/account/models/page.tsx +++ b/frontend/src/app/(pages)/account/models/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { AlertCircle, Check, ChevronDown, Eye, EyeOff } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -42,9 +42,15 @@ export default function ModelsAndApiKeysPage() { "gemini-3-flash-preview" } apiKeys={{ - claudeApiKey: profile?.claudeApiKey ?? null, - geminiApiKey: profile?.geminiApiKey ?? null, - openrouterApiKey: profile?.openrouterApiKey ?? null, + claudeApiKey: profile?.hasClaudeApiKey + ? "configured" + : null, + geminiApiKey: profile?.hasGeminiApiKey + ? "configured" + : null, + openrouterApiKey: profile?.hasOpenrouterApiKey + ? "configured" + : null, }} onChange={(id) => updateModelPreference("tabularModel", id) @@ -75,25 +81,25 @@ export default function ModelsAndApiKeysPage() { - updateApiKey("claude", value.trim() || null) + updateApiKey("claude", value?.trim() || null) } /> - updateApiKey("gemini", value.trim() || null) + updateApiKey("gemini", value?.trim() || null) } /> - updateApiKey("openrouter", value.trim() || null) + updateApiKey("openrouter", value?.trim() || null) } />
    @@ -192,30 +198,27 @@ function TabularModelDropdown({ function ApiKeyField({ label, placeholder, - initialValue, + hasKey, onSave, }: { label: string; placeholder: string; - initialValue: string; - onSave: (value: string) => Promise; + hasKey: boolean; + onSave: (value: string | null) => Promise; }) { - const [value, setValue] = useState(initialValue); + const [value, setValue] = useState(""); const [reveal, setReveal] = useState(false); const [isSaving, setIsSaving] = useState(false); const [saved, setSaved] = useState(false); - useEffect(() => { - setValue(initialValue); - }, [initialValue]); - - const dirty = value !== initialValue; + const dirty = value.trim().length > 0; const handleSave = async () => { setIsSaving(true); const ok = await onSave(value); setIsSaving(false); if (ok) { + setValue(""); setSaved(true); setTimeout(() => setSaved(false), 2000); } else { @@ -223,6 +226,17 @@ function ApiKeyField({ } }; + const handleClear = async () => { + setIsSaving(true); + const ok = await onSave(null); + setIsSaving(false); + if (!ok) { + alert(`Failed to clear ${label}.`); + } else { + setValue(""); + } + }; + return (
    @@ -232,7 +246,11 @@ function ApiKeyField({ type={reveal ? "text" : "password"} value={value} onChange={(e) => setValue(e.target.value)} - placeholder={placeholder} + placeholder={ + hasKey + ? "Configured - enter a new key to replace" + : placeholder + } className="pr-10" autoComplete="off" spellCheck={false} @@ -266,6 +284,16 @@ function ApiKeyField({ "Save" )} + {hasKey && !dirty && ( + + )}
    ); diff --git a/frontend/src/app/components/assistant/AssistantMessage.tsx b/frontend/src/app/components/assistant/AssistantMessage.tsx index 930a6fe1..46c17d1c 100644 --- a/frontend/src/app/components/assistant/AssistantMessage.tsx +++ b/frontend/src/app/components/assistant/AssistantMessage.tsx @@ -315,14 +315,25 @@ function ResponseStatus({ status }: { status: StatusState }) { const isError = status === "error"; useEffect(() => { + let hideTimer: ReturnType | null = null; if (wasActiveRef.current && !isActive) { - setShowDone(true); - setDoneVisible(true); - const t = setTimeout(() => setDoneVisible(false), 1500); - return () => clearTimeout(t); + const showTimer = setTimeout(() => { + setShowDone(true); + setDoneVisible(true); + hideTimer = setTimeout(() => setDoneVisible(false), 1500); + }, 0); + wasActiveRef.current = isActive; + return () => { + clearTimeout(showTimer); + if (hideTimer) clearTimeout(hideTimer); + }; } else if (!wasActiveRef.current && isActive) { - setShowDone(false); - setDoneVisible(false); + const resetTimer = setTimeout(() => { + setShowDone(false); + setDoneVisible(false); + }, 0); + wasActiveRef.current = isActive; + return () => clearTimeout(resetTimer); } wasActiveRef.current = isActive; }, [isActive]); @@ -976,7 +987,9 @@ function MarkdownContent({ /> ), p: ({ node, ...props }) => { - const parent = (node as any)?.parent; + const parent = ( + node as { parent?: { type?: string } } | undefined + )?.parent; if (parent?.type === "listItem") { return (

    { - console.log( - "[AssistantMessage] citation clicked", - annotation, - ); onCitationClick?.(annotation); }} className="mx-0.5 inline-flex items-center justify-center rounded-full w-4 h-4 text-[10px] font-medium transition-colors align-super bg-gray-100 text-gray-900 hover:bg-gray-200" @@ -1172,7 +1181,6 @@ export function AssistantMessage({ versionId: string | null; downloadUrl: string | null; }) => { - console.log("[AssistantMessage] handleEditResolved", args); if (args.downloadUrl) { setResolvedOverrides((prev) => ({ ...prev, diff --git a/frontend/src/app/components/assistant/ChatInput.tsx b/frontend/src/app/components/assistant/ChatInput.tsx index cfe8a11d..64281938 100644 --- a/frontend/src/app/components/assistant/ChatInput.tsx +++ b/frontend/src/app/components/assistant/ChatInput.tsx @@ -69,9 +69,9 @@ export const ChatInput = forwardRef(function ChatInput( const [model, setModel] = useSelectedModel(); const { profile } = useUserProfile(); const apiKeys = { - claudeApiKey: profile?.claudeApiKey ?? null, - geminiApiKey: profile?.geminiApiKey ?? null, - openrouterApiKey: profile?.openrouterApiKey ?? null, + claudeApiKey: profile?.hasClaudeApiKey ? "configured" : null, + geminiApiKey: profile?.hasGeminiApiKey ? "configured" : null, + openrouterApiKey: profile?.hasOpenrouterApiKey ? "configured" : null, }; const textareaRef = useRef(null); const [docSelectorOpen, setDocSelectorOpen] = useState(false); diff --git a/frontend/src/app/components/assistant/EditCard.tsx b/frontend/src/app/components/assistant/EditCard.tsx index ba2ea617..a84db9ff 100644 --- a/frontend/src/app/components/assistant/EditCard.tsx +++ b/frontend/src/app/components/assistant/EditCard.tsx @@ -19,13 +19,6 @@ function findMatch( const byId = container.querySelector( `${tag}[data-w-id="${opts.w_id}"]`, ) as HTMLElement | null; - console.log("[EditCard] findMatch by w_id", { - tag, - w_id: opts.w_id, - found: !!byId, - totalTagged: container.querySelectorAll(`${tag}[data-w-id]`).length, - totalAny: container.querySelectorAll(tag).length, - }); if (byId) return byId; } const text = opts.text ?? ""; @@ -42,12 +35,6 @@ function findMatch( normalizeText(el.textContent ?? "").includes(target), ) ?? null; - console.log("[EditCard] findMatch by text", { - tag, - target, - found: !!byText, - candidateCount: candidates.length, - }); return byText; } @@ -117,13 +104,6 @@ export function applyOptimisticResolution( const scrolls = document.querySelectorAll( `[data-document-id="${CSS.escape(annotation.document_id)}"]`, ); - console.log("[EditCard] optimistic scrolls found:", scrolls.length, { - document_id: annotation.document_id, - ins_w_id: annotation.ins_w_id, - del_w_id: annotation.del_w_id, - inserted_text: annotation.inserted_text?.slice(0, 40), - deleted_text: annotation.deleted_text?.slice(0, 40), - }); scrolls.forEach((scroll) => { const container = scroll.querySelector(".docx-view-container"); if (!container) return; diff --git a/frontend/src/app/components/shared/DocxView.tsx b/frontend/src/app/components/shared/DocxView.tsx index 1fc81156..3ce26157 100644 --- a/frontend/src/app/components/shared/DocxView.tsx +++ b/frontend/src/app/components/shared/DocxView.tsx @@ -347,13 +347,6 @@ export function DocxView({ const scrollEl = scrollRef.current; const containerEl = containerRef.current; - console.log("[DocxView] render effect fired", { - documentId, - versionId, - refetchKey, - bytesLen: bytes.byteLength, - }); - // Remember scroll position across re-renders so Accept/Reject stays put. lastScrollTopRef.current = scrollEl.scrollTop; const thisRender = ++renderKeyRef.current; diff --git a/frontend/src/app/components/tabular/TRChatPanel.tsx b/frontend/src/app/components/tabular/TRChatPanel.tsx index fa4e4755..b5b456f4 100644 --- a/frontend/src/app/components/tabular/TRChatPanel.tsx +++ b/frontend/src/app/components/tabular/TRChatPanel.tsx @@ -186,16 +186,26 @@ function TRResponseStatus({ isActive }: { isActive: boolean }) { const wasActiveRef = useRef(false); useEffect(() => { + let hideTimer: ReturnType | null = null; if (wasActiveRef.current && !isActive) { - setShowDone(true); - setDoneVisible(true); - const t = setTimeout(() => setDoneVisible(false), 1500); + const showTimer = setTimeout(() => { + setShowDone(true); + setDoneVisible(true); + hideTimer = setTimeout(() => setDoneVisible(false), 1500); + }, 0); wasActiveRef.current = isActive; - return () => clearTimeout(t); + return () => { + clearTimeout(showTimer); + if (hideTimer) clearTimeout(hideTimer); + }; } if (!wasActiveRef.current && isActive) { - setShowDone(false); - setDoneVisible(false); + const resetTimer = setTimeout(() => { + setShowDone(false); + setDoneVisible(false); + }, 0); + wasActiveRef.current = isActive; + return () => clearTimeout(resetTimer); } wasActiveRef.current = isActive; }, [isActive]); @@ -608,9 +618,9 @@ export function TRChatPanel({ }: Props) { const { profile, updateModelPreference } = useUserProfile(); const apiKeys = { - claudeApiKey: profile?.claudeApiKey ?? null, - geminiApiKey: profile?.geminiApiKey ?? null, - openrouterApiKey: profile?.openrouterApiKey ?? null, + claudeApiKey: profile?.hasClaudeApiKey ? "configured" : null, + geminiApiKey: profile?.hasGeminiApiKey ? "configured" : null, + openrouterApiKey: profile?.hasOpenrouterApiKey ? "configured" : null, }; const currentModel = profile?.tabularModel ?? "gemini-3-flash-preview"; const [apiKeyModalProvider, setApiKeyModalProvider] = diff --git a/frontend/src/app/components/tabular/TREditColumnMenu.tsx b/frontend/src/app/components/tabular/TREditColumnMenu.tsx index b16ccb56..46b48313 100644 --- a/frontend/src/app/components/tabular/TREditColumnMenu.tsx +++ b/frontend/src/app/components/tabular/TREditColumnMenu.tsx @@ -85,8 +85,6 @@ export function TREditColumnMenu({ setSaving(false); } } - console.log(tags); - async function handleDelete() { setDeleting(true); try { diff --git a/frontend/src/app/components/tabular/TRSidePanel.tsx b/frontend/src/app/components/tabular/TRSidePanel.tsx index 9a6763ab..737d2fc8 100644 --- a/frontend/src/app/components/tabular/TRSidePanel.tsx +++ b/frontend/src/app/components/tabular/TRSidePanel.tsx @@ -109,10 +109,6 @@ export function TRSidePanel({ const { processed: reasoningText, citations: reasoningCitations } = preprocessCitations(cell.content?.reasoning ?? ""); - useEffect(() => { - console.log("[TRSidePanel] summary:", cell.content?.summary ?? ""); - }, [cell.id, cell.content?.summary]); - return (

    - "{docCitation.quote}" + "{docCitation.quote}"

    {(isTruncated || quoteExpanded) && ( (null); - console.log("[useFetchDocxBytes] init", { - documentId, - versionId, - refetchKey, - initialKey, - cacheHit: initialKey ? bytesCache.has(initialKey) : null, - }); - useEffect(() => { if (!documentId) { - setBytes(null); - setDownloadUrl(null); - return; + const resetTimer = setTimeout(() => { + setBytes(null); + setDownloadUrl(null); + }, 0); + return () => clearTimeout(resetTimer); } const key = cacheKey(documentId, versionId, refetchKey); @@ -73,16 +67,21 @@ export function useFetchDocxBytes( // Cache hit: reuse bytes synchronously, no network, no spinner. const cached = bytesCache.get(key); if (cached) { - setBytes(cached); - setDownloadUrl(url); - setLoading(false); - setError(null); - return; + const cacheTimer = setTimeout(() => { + setBytes(cached); + setDownloadUrl(url); + setLoading(false); + setError(null); + }, 0); + return () => clearTimeout(cacheTimer); } let cancelled = false; - setLoading(true); - setError(null); + const loadingTimer = setTimeout(() => { + if (cancelled) return; + setLoading(true); + setError(null); + }, 0); const pending = inFlight.get(key) ?? @@ -120,6 +119,7 @@ export function useFetchDocxBytes( return () => { cancelled = true; + clearTimeout(loadingTimer); }; }, [documentId, versionId, refetchKey]); diff --git a/frontend/src/app/signup/page.tsx b/frontend/src/app/signup/page.tsx index 21ad9473..ca3c17c8 100644 --- a/frontend/src/app/signup/page.tsx +++ b/frontend/src/app/signup/page.tsx @@ -59,32 +59,32 @@ export default function SignupPage() { const trimmedName = name.trim(); const trimmedOrg = organisation.trim(); if (trimmedName || trimmedOrg) { - // The handle_new_user DB trigger creates the - // user_profiles row synchronously on auth.users insert, - // so we UPDATE rather than upsert — RLS permits update - // of the user's own row but blocks self-INSERT. - const { error: profileError } = await supabase - .from("user_profiles") - .update({ + const apiBase = + process.env.NEXT_PUBLIC_API_BASE_URL ?? + "http://localhost:3001"; + await fetch(`${apiBase}/user/profile`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${data.session.access_token}`, + }, + body: JSON.stringify({ ...(trimmedName && { display_name: trimmedName }), ...(trimmedOrg && { organisation: trimmedOrg }), - updated_at: new Date().toISOString(), - }) - .eq("user_id", data.session.user.id); - if (profileError) { - console.error( - "[signup] failed to persist profile fields", - profileError, - ); - } + }), + }).catch(() => {}); } } setSuccess(true); setTimeout(() => { router.push("/assistant"); }, 2000); - } catch (error: any) { - setError(error.message || "An error occurred during signup"); + } catch (error: unknown) { + setError( + error instanceof Error + ? error.message + : "An error occurred during signup", + ); } finally { setLoading(false); } diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index d2078a9b..03a8a930 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -34,9 +34,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { await fetch(`${apiBase}/user/profile`, { method: "POST", headers: { Authorization: `Bearer ${accessToken}` }, - }).catch((e) => { - console.log(e); - }); + }).catch(() => {}); }; const checkUser = async () => { diff --git a/frontend/src/contexts/UserProfileContext.tsx b/frontend/src/contexts/UserProfileContext.tsx index c9bc2a60..b23e9914 100644 --- a/frontend/src/contexts/UserProfileContext.tsx +++ b/frontend/src/contexts/UserProfileContext.tsx @@ -11,6 +11,22 @@ import React, { import { supabase } from "@/lib/supabase"; import { useAuth } from "@/contexts/AuthContext"; +const API_BASE = + process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001"; +const MONTHLY_CREDIT_LIMIT = 999999; + +interface ServerProfile { + display_name: string | null; + organisation: string | null; + message_credits_used: number; + credits_reset_date: string; + tier: string; + tabular_model: string; + has_claude_api_key: boolean; + has_gemini_api_key: boolean; + has_openrouter_api_key: boolean; +} + interface UserProfile { displayName: string | null; organisation: string | null; @@ -19,9 +35,9 @@ interface UserProfile { creditsRemaining: number; tier: string; tabularModel: string; - claudeApiKey: string | null; - geminiApiKey: string | null; - openrouterApiKey: string | null; + hasClaudeApiKey: boolean; + hasGeminiApiKey: boolean; + hasOpenrouterApiKey: boolean; } interface UserProfileContextType { @@ -45,114 +61,77 @@ const UserProfileContext = createContext( undefined, ); +async function getAuthHeaders(): Promise> { + const { + data: { session }, + } = await supabase.auth.getSession(); + return session?.access_token + ? { Authorization: `Bearer ${session.access_token}` } + : {}; +} + +function fallbackProfile(): UserProfile { + const reset = new Date(); + reset.setDate(reset.getDate() + 30); + return { + displayName: null, + organisation: null, + messageCreditsUsed: 0, + creditsResetDate: reset.toISOString(), + creditsRemaining: MONTHLY_CREDIT_LIMIT, + tier: "Free", + tabularModel: "gemini-3-flash-preview", + hasClaudeApiKey: false, + hasGeminiApiKey: false, + hasOpenrouterApiKey: false, + }; +} + +function mapProfile(data: ServerProfile): UserProfile { + const creditsUsed = data.message_credits_used ?? 0; + return { + displayName: data.display_name, + organisation: data.organisation ?? null, + messageCreditsUsed: creditsUsed, + creditsResetDate: data.credits_reset_date, + creditsRemaining: MONTHLY_CREDIT_LIMIT - creditsUsed, + tier: data.tier || "Free", + tabularModel: data.tabular_model || "gemini-3-flash-preview", + hasClaudeApiKey: !!data.has_claude_api_key, + hasGeminiApiKey: !!data.has_gemini_api_key, + hasOpenrouterApiKey: !!data.has_openrouter_api_key, + }; +} + +async function profileRequest( + method: "GET" | "PATCH", + body?: Record, +): Promise { + const headers = await getAuthHeaders(); + const response = await fetch(`${API_BASE}/user/profile`, { + method, + cache: "no-store", + headers: { + Accept: "application/json", + ...(body ? { "Content-Type": "application/json" } : {}), + ...headers, + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!response.ok) throw new Error(await response.text()); + return mapProfile((await response.json()) as ServerProfile); +} + export function UserProfileProvider({ children }: { children: ReactNode }) { const { user, isAuthenticated } = useAuth(); const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); - const loadProfile = useCallback(async (userId: string) => { + const loadProfile = useCallback(async () => { try { - const { data, error } = await supabase - .from("user_profiles") - .select("*") - .eq("user_id", userId) - .single(); - - // Define credit limit constant - const MONTHLY_CREDIT_LIMIT = 999999; // temporarily unlimited - - // Calculate a default future reset date (30 days from now) - const futureResetDate = new Date(); - futureResetDate.setDate(futureResetDate.getDate() + 30); - const defaultResetDateStr = futureResetDate.toISOString(); - - if (error) { - // Set fallback profile data if profile doesn't exist - setProfile({ - displayName: null, - organisation: null, - messageCreditsUsed: 0, - creditsResetDate: defaultResetDateStr, - creditsRemaining: MONTHLY_CREDIT_LIMIT, - tier: "Free", - tabularModel: "gemini-3-flash-preview", - claudeApiKey: null, - geminiApiKey: null, - openrouterApiKey: null, - }); - return; - } - - // Use fetched data to update profile state - if (data) { - let creditsUsed = data.message_credits_used; - let resetDate = data.credits_reset_date; - let creditsRemaining = MONTHLY_CREDIT_LIMIT - creditsUsed; - let shouldUpdateDb = false; - - // Check if credits have expired and need reset - if (resetDate && new Date() > new Date(resetDate)) { - // Calculate new reset date - const newResetDate = new Date(); - newResetDate.setDate(newResetDate.getDate() + 30); - resetDate = newResetDate.toISOString(); - creditsUsed = 0; - creditsRemaining = MONTHLY_CREDIT_LIMIT; - shouldUpdateDb = true; - } - - // 1. Update local state immediately - setProfile({ - displayName: data.display_name, - organisation: data.organisation ?? null, - messageCreditsUsed: creditsUsed, - creditsResetDate: resetDate, - creditsRemaining: creditsRemaining, - tier: data.tier || "Free", - tabularModel: - data.tabular_model || "gemini-3-flash-preview", - claudeApiKey: data.claude_api_key ?? null, - geminiApiKey: data.gemini_api_key ?? null, - openrouterApiKey: data.openrouter_api_key ?? null, - }); - - // 2. Update database in background if needed - if (shouldUpdateDb) { - supabase - .from("user_profiles") - .update({ - message_credits_used: 0, - credits_reset_date: resetDate, - updated_at: new Date().toISOString(), - }) - .eq("user_id", userId) - .then(({ error }) => { - if (error) - console.error( - "Failed to auto-reset credits", - error, - ); - }); - } - } - } catch (e) { - // Calculate a default future reset date for fallback - const futureResetDate = new Date(); - futureResetDate.setDate(futureResetDate.getDate() + 30); - - // Set fallback profile data on exception - setProfile({ - displayName: null, - organisation: null, - messageCreditsUsed: 0, - creditsResetDate: futureResetDate.toISOString(), - creditsRemaining: 999999, // temporarily unlimited - tier: "Free", - tabularModel: "gemini-3-flash-preview", - claudeApiKey: null, - geminiApiKey: null, - openrouterApiKey: null, - }); + setProfile(await profileRequest("GET")); + } catch { + setProfile(fallbackProfile()); } finally { setLoading(false); } @@ -161,178 +140,57 @@ export function UserProfileProvider({ children }: { children: ReactNode }) { useEffect(() => { if (isAuthenticated && user) { setLoading(true); - loadProfile(user.id); + loadProfile(); } else { setProfile(null); setLoading(false); } }, [isAuthenticated, user, loadProfile]); - const updateDisplayName = useCallback( - async (displayName: string): Promise => { - if (!user) { - return false; - } - - try { - const { error } = await supabase - .from("user_profiles") - .update({ - display_name: displayName, - updated_at: new Date().toISOString(), - }) - .eq("user_id", user.id); - - if (error) { - throw error; - } + const patchProfile = useCallback(async (body: Record) => { + try { + const next = await profileRequest("PATCH", body); + setProfile(next); + return true; + } catch { + return false; + } + }, []); - setProfile((prev) => (prev ? { ...prev, displayName } : null)); - return true; - } catch { - return false; - } - }, - [user], + const updateDisplayName = useCallback( + async (displayName: string): Promise => + patchProfile({ display_name: displayName }), + [patchProfile], ); const updateOrganisation = useCallback( - async (organisation: string): Promise => { - if (!user) return false; - try { - const { error } = await supabase - .from("user_profiles") - .update({ - organisation, - updated_at: new Date().toISOString(), - }) - .eq("user_id", user.id); - if (error) throw error; - setProfile((prev) => - prev ? { ...prev, organisation } : null, - ); - return true; - } catch { - return false; - } - }, - [user], + async (organisation: string): Promise => + patchProfile({ organisation }), + [patchProfile], ); const updateModelPreference = useCallback( - async ( - field: "tabularModel", - value: string, - ): Promise => { - if (!user) return false; - const dbField = field === "tabularModel" ? "tabular_model" : ""; - if (!dbField) return false; - try { - const { error } = await supabase - .from("user_profiles") - .update({ - [dbField]: value, - updated_at: new Date().toISOString(), - }) - .eq("user_id", user.id); - if (error) throw error; - setProfile((prev) => - prev ? { ...prev, [field]: value } : null, - ); - return true; - } catch { - return false; - } - }, - [user], + async (_field: "tabularModel", value: string): Promise => + patchProfile({ tabular_model: value }), + [patchProfile], ); const updateApiKey = useCallback( async ( provider: "claude" | "gemini" | "openrouter", value: string | null, - ): Promise => { - if (!user) return false; - const dbFieldMap: Record = { - claude: "claude_api_key", - gemini: "gemini_api_key", - openrouter: "openrouter_api_key", - }; - const stateFieldMap: Record = { - claude: "claudeApiKey", - gemini: "geminiApiKey", - openrouter: "openrouterApiKey", - }; - const dbField = dbFieldMap[provider]; - const stateField = stateFieldMap[provider]; - const normalized = value?.trim() ? value.trim() : null; - try { - const { error } = await supabase - .from("user_profiles") - .update({ - [dbField]: normalized, - updated_at: new Date().toISOString(), - }) - .eq("user_id", user.id); - if (error) throw error; - setProfile((prev) => - prev ? { ...prev, [stateField]: normalized } : null, - ); - return true; - } catch { - return false; - } - }, - [user], + ): Promise => patchProfile({ api_keys: { [provider]: value } }), + [patchProfile], ); const reloadProfile = useCallback(async () => { - if (user) { - await loadProfile(user.id); - } + if (user) await loadProfile(); }, [user, loadProfile]); const incrementMessageCredits = useCallback(async (): Promise => { - if (!user || !profile) { - return false; - } - - // Check if user has credits remaining - if (profile.creditsRemaining <= 0) { - return false; - } - - try { - const newCreditsUsed = profile.messageCreditsUsed + 1; - - const { error } = await supabase - .from("user_profiles") - .update({ - message_credits_used: newCreditsUsed, - updated_at: new Date().toISOString(), - }) - .eq("user_id", user.id); - - if (error) { - throw error; - } - - // Update local state - setProfile((prev) => - prev - ? { - ...prev, - messageCreditsUsed: newCreditsUsed, - creditsRemaining: 999999 - newCreditsUsed, // temporarily unlimited - } - : null, - ); - - return true; - } catch (err) { - return false; - } - }, [user, profile]); + if (!user || !profile || profile.creditsRemaining <= 0) return false; + return patchProfile({ increment_message_credits: true }); + }, [user, profile, patchProfile]); return ( .r2.cloudflarestorage.com - * R2_ACCESS_KEY_ID — R2 API token (Access Key ID) - * R2_SECRET_ACCESS_KEY — R2 API token (Secret Access Key) - * R2_BUCKET_NAME — bucket name (default: "mike") - */ - -import { - S3Client, - PutObjectCommand, - GetObjectCommand, - DeleteObjectCommand, -} from "@aws-sdk/client-s3"; -import { getSignedUrl as awsGetSignedUrl } from "@aws-sdk/s3-request-presigner"; - -function getClient(): S3Client { - return new S3Client({ - region: "auto", - endpoint: process.env.R2_ENDPOINT_URL!, - credentials: { - accessKeyId: process.env.R2_ACCESS_KEY_ID!, - secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!, - }, - }); -} - -const BUCKET = process.env.R2_BUCKET_NAME ?? "mike"; - -export const storageEnabled = Boolean( - process.env.R2_ENDPOINT_URL && - process.env.R2_ACCESS_KEY_ID && - process.env.R2_SECRET_ACCESS_KEY, -); - -// --------------------------------------------------------------------------- -// Upload -// --------------------------------------------------------------------------- - -export async function uploadFile( - key: string, - content: ArrayBuffer, - contentType: string, -): Promise { - const client = getClient(); - await client.send( - new PutObjectCommand({ - Bucket: BUCKET, - Key: key, - Body: Buffer.from(content), - ContentType: contentType, - }), - ); -} - -// --------------------------------------------------------------------------- -// Download -// --------------------------------------------------------------------------- - -export async function downloadFile(key: string): Promise { - if (!storageEnabled) return null; - try { - const client = getClient(); - const response = await client.send( - new GetObjectCommand({ Bucket: BUCKET, Key: key }), - ); - if (!response.Body) return null; - const bytes = await response.Body.transformToByteArray(); - return bytes.buffer as ArrayBuffer; - } catch { - return null; - } -} - -// --------------------------------------------------------------------------- -// Delete -// --------------------------------------------------------------------------- - -export async function deleteFile(key: string): Promise { - if (!storageEnabled) return; - const client = getClient(); - await client.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: key })); -} - -// --------------------------------------------------------------------------- -// Signed URL (pre-signed for temporary direct access) -// --------------------------------------------------------------------------- - -export async function getSignedUrl( - key: string, - expiresIn = 3600, -): Promise { - if (!storageEnabled) return null; - try { - const client = getClient(); - const command = new GetObjectCommand({ Bucket: BUCKET, Key: key }); - return await awsGetSignedUrl(client, command, { expiresIn }); - } catch { - return null; - } -} - -// --------------------------------------------------------------------------- -// Storage key helpers -// --------------------------------------------------------------------------- - -export function storageKey( - userId: string, - docId: string, - filename: string, -): string { - return `documents/${userId}/${docId}/${filename}`; -} - -export function pdfStorageKey( - userId: string, - docId: string, - stem: string, -): string { - return `documents/${userId}/${docId}/${stem}.pdf`; -} - -export function generatedDocKey( - userId: string, - docId: string, - filename: string, -): string { - return `generated/${userId}/${docId}/${filename}`; -} diff --git a/frontend/src/lib/supabase-server.ts b/frontend/src/lib/supabase-server.ts deleted file mode 100644 index 74b159b8..00000000 --- a/frontend/src/lib/supabase-server.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { createClient } from "@supabase/supabase-js"; - -/** - * Server-side Supabase client using the service role key. - * Bypasses RLS — only use in API routes after verifying the user. - */ -export function createServerSupabase() { - const url = process.env.NEXT_PUBLIC_SUPABASE_URL || ""; - const key = process.env.SUPABASE_SECRET_KEY || ""; - return createClient(url, key, { auth: { persistSession: false } }); -} - -/** - * Extract and verify the Supabase JWT from the Authorization header. - * Returns the user's UUID string, or throws a Response with 401. - */ -export async function getUserIdFromRequest(req: Request): Promise { - const auth = req.headers.get("authorization") ?? ""; - if (!auth.startsWith("Bearer ")) { - throw new Response("Missing or invalid Authorization header", { status: 401 }); - } - const token = auth.slice(7).trim(); - - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || ""; - const serviceKey = process.env.SUPABASE_SECRET_KEY || ""; - - if (!supabaseUrl || !serviceKey) { - // Dev fallback — accept raw token as user ID - return token; - } - - const admin = createClient(supabaseUrl, serviceKey, { auth: { persistSession: false } }); - const { data } = await admin.auth.getUser(token); - if (!data.user) { - throw new Response("Invalid or expired token", { status: 401 }); - } - return data.user.id; -} From 9f40245b128fcb43bed115252a8d9117c1f14218 Mon Sep 17 00:00:00 2001 From: Lef Date: Fri, 8 May 2026 11:49:13 +0200 Subject: [PATCH 11/26] chore(self-host): renumber security migration + fix incremental migration ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups to PR #42 (security overhaul, commit 9979566): 1. PR #42's 001_security_lockdown.sql collides with PR #11's 001_add_openrouter_api_key.sql. Rename to 004_security_lockdown.sql so it slots in after the MCP migrations (002, 003) and runs last. The lockdown only revokes anon/authenticated privileges on tables that exist when it runs — running it after the MCP tables exist means those grants are revoked too, which is the desired shape. 2. The previous init-db.sh shell-glob ('0[1-9][0-9]_*.sql' followed by '00[1-9]_*.sql') would have processed 010-099 BEFORE 001-009 if we ever crossed into double-digit migration numbers. Replace with an ls-sort-while loop that handles strict numeric order for any three-digit prefix and skips 000 explicitly. --- ...urity_lockdown.sql => 004_security_lockdown.sql} | 0 docker/init-db.sh | 13 ++++++++----- 2 files changed, 8 insertions(+), 5 deletions(-) rename backend/migrations/{001_security_lockdown.sql => 004_security_lockdown.sql} (100%) diff --git a/backend/migrations/001_security_lockdown.sql b/backend/migrations/004_security_lockdown.sql similarity index 100% rename from backend/migrations/001_security_lockdown.sql rename to backend/migrations/004_security_lockdown.sql diff --git a/docker/init-db.sh b/docker/init-db.sh index d0db3a1e..b7ea7043 100755 --- a/docker/init-db.sh +++ b/docker/init-db.sh @@ -27,11 +27,14 @@ done echo "init-db: applying /migrations/000_one_shot_schema.sql" psql -v ON_ERROR_STOP=1 -f /migrations/000_one_shot_schema.sql -# Apply incremental migrations (00[1-9]_*.sql) in order. Each is -# idempotent (CREATE OR REPLACE / ADD COLUMN IF NOT EXISTS / etc.) -# so re-running on every boot is safe. -for migration in /migrations/0[1-9][0-9]_*.sql /migrations/00[1-9]_*.sql; do - [ -f "$migration" ] || continue +# Apply incremental migrations in numeric order, skipping 000 (the +# one-shot schema we already applied above). Each is idempotent +# (CREATE OR REPLACE / ADD COLUMN IF NOT EXISTS / etc.) so re-running +# on every boot is safe. +ls /migrations/[0-9][0-9][0-9]_*.sql 2>/dev/null | sort | while IFS= read -r migration; do + case "$(basename "$migration")" in + 000_*) continue ;; + esac echo "init-db: applying $migration" psql -v ON_ERROR_STOP=1 -f "$migration" done From c5e614130b698ac842e234d033e1c57b3caa225e Mon Sep 17 00:00:00 2001 From: Lef Date: Fri, 8 May 2026 11:49:47 +0200 Subject: [PATCH 12/26] chore(self-host): generate and pass DOWNLOAD_SIGNING_SECRET + USER_API_KEYS_ENCRYPTION_KEY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #42 (security overhaul, commit 9979566) requires USER_API_KEYS_ENCRYPTION_KEY at backend startup, and PR #21 (commit 575b41d) made DOWNLOAD_SIGNING_SECRET required (no longer silently falls back to SUPABASE_SECRET_KEY). Add both to: - scripts/generate-secrets.sh — random 32-byte hex, idempotent - .env.example — empty placeholder with comment - docker-compose.yml — passed to mike-backend without an empty default (the backend should fail fast if generate-secrets.sh wasn't run, matching upstream's intent) --- .env.example | 4 ++++ docker-compose.yml | 3 ++- scripts/generate-secrets.sh | 2 ++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index e88af1b3..94ac5956 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,10 @@ JWT_SECRET= # set by generate-secrets.sh SUPABASE_PUBLISHABLE_KEY= # set by generate-secrets.sh (anon JWT) SUPABASE_SECRET_KEY= # set by generate-secrets.sh (service_role JWT) +# --- Backend secrets --------------------------------------------------------- +DOWNLOAD_SIGNING_SECRET= # set by generate-secrets.sh; HMAC for /download/:token +USER_API_KEYS_ENCRYPTION_KEY= # set by generate-secrets.sh; encrypts user-stored LLM keys at rest + # --- GoTrue (laptop defaults; flip to false + add SMTP for real email) ------- GOTRUE_MAILER_AUTOCONFIRM=true GOTRUE_DISABLE_SIGNUP=false diff --git a/docker-compose.yml b/docker-compose.yml index 8fc43c3f..ad270da0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -137,7 +137,8 @@ services: ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} GEMINI_API_KEY: ${GEMINI_API_KEY:-} OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} - DOWNLOAD_SIGNING_SECRET: ${DOWNLOAD_SIGNING_SECRET:-${SUPABASE_SECRET_KEY}} + DOWNLOAD_SIGNING_SECRET: ${DOWNLOAD_SIGNING_SECRET} + USER_API_KEYS_ENCRYPTION_KEY: ${USER_API_KEYS_ENCRYPTION_KEY} BACKEND_PUBLIC_URL: ${BACKEND_PUBLIC_URL:-http://${MIKE_HOST}:${MIKE_PORT}/backend} R2_ENDPOINT_URL: http://garage:3900 R2_BUCKET_NAME: ${R2_BUCKET_NAME} diff --git a/scripts/generate-secrets.sh b/scripts/generate-secrets.sh index 5c89a04c..d75fcab4 100755 --- a/scripts/generate-secrets.sh +++ b/scripts/generate-secrets.sh @@ -76,6 +76,8 @@ ensure_random_hex AUTHENTICATOR_PASSWORD 24 ensure_random_hex GARAGE_RPC_SECRET 32 ensure_random_hex GARAGE_ADMIN_TOKEN 32 ensure_random_hex JWT_SECRET 32 +ensure_random_hex DOWNLOAD_SIGNING_SECRET 32 +ensure_random_hex USER_API_KEYS_ENCRYPTION_KEY 32 # JWTs depend on JWT_SECRET; regenerate them whenever JWT_SECRET changed # (i.e. when the user runs --force) or when they're empty. From 597a21982a75e9d836270bcaea9a8c98f14505e2 Mon Sep 17 00:00:00 2001 From: Lef Date: Fri, 8 May 2026 11:52:44 +0200 Subject: [PATCH 13/26] chore(self-host): use --legacy-peer-deps for backend npm ci MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After PR #11 (OpenRouter) and PR #42 (security overhaul) added new deps and PR #42's conflict resolution regenerated backend/ package-lock.json with --legacy-peer-deps on the host, strict npm ci inside the container fails because the lockfile carries optional peer-dep entries (react, react-dom, scheduler — pulled in transitively, likely via @modelcontextprotocol/sdk) that the strict lockfile-vs-package.json sync check rejects. Match the frontend Dockerfile's pattern (it has done this since the original docker stack landed) by passing --legacy-peer-deps to backend's npm ci. Same pragmatic justification as the frontend case: this is a build-tooling artefact of using vanilla npm against a lockfile that was resolved with the relaxed peer-dep model. --- docker/backend.Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/backend.Dockerfile b/docker/backend.Dockerfile index a037cbcc..5f3637d1 100644 --- a/docker/backend.Dockerfile +++ b/docker/backend.Dockerfile @@ -3,7 +3,7 @@ FROM node:22-bookworm-slim AS build WORKDIR /app COPY backend/package*.json ./ -RUN npm ci +RUN npm ci --legacy-peer-deps COPY backend/ ./ RUN npm run build From c5741fcdd7e3ce7031083a1672c1d7b58c304441 Mon Sep 17 00:00:00 2001 From: Lef Date: Fri, 8 May 2026 15:17:36 +0200 Subject: [PATCH 14/26] fix(security): require DOWNLOAD_SIGNING_SECRET for MCP OAuth state tokens backend/src/lib/mcp/oauth.ts had a "?? \"dev-secret\"" silent fallback on the HMAC signing secret used to sign OAuth state tokens. Because the codebase is public, the fallback secret is known to everyone, so any deployment that forgot to set DOWNLOAD_SIGNING_SECRET could have its MCP OAuth flow hijacked: an attacker mints a state token for an arbitrary {user_id, server_id}, completes the OAuth handshake against the target provider, and plants their own connector tokens (or hijacks a row). This re-opened the same vulnerability commit 575b41d closed for download tokens. lib/downloadTokens.ts already throws with a clear error when the secret is missing; export that helper as getSigningSecret() and have mcp/oauth.ts call it. Same throw, same message, single source of truth for the env var. --- backend/src/lib/downloadTokens.ts | 12 +++++++++--- backend/src/lib/mcp/oauth.ts | 13 +++---------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/backend/src/lib/downloadTokens.ts b/backend/src/lib/downloadTokens.ts index b0f45372..e5d90201 100644 --- a/backend/src/lib/downloadTokens.ts +++ b/backend/src/lib/downloadTokens.ts @@ -9,7 +9,13 @@ import crypto from "crypto"; * expiry or R2 CORS headaches. */ -function getSecret(): string { +/** + * Resolves the shared HMAC signing secret. Used here for download tokens + * and re-exported for other call sites that share the same threat model + * (e.g. MCP OAuth state tokens in lib/mcp/oauth.ts) so a single env var + * gates every signed token in the app. + */ +export function getSigningSecret(): string { const secret = process.env.DOWNLOAD_SIGNING_SECRET; if (!secret?.trim()) { throw new Error( @@ -43,7 +49,7 @@ export function signDownload(path: string, filename: string): string { const payload = JSON.stringify({ p: path, f: filename }); const enc = b64urlEncode(Buffer.from(payload, "utf8")); const sig = crypto - .createHmac("sha256", getSecret()) + .createHmac("sha256", getSigningSecret()) .update(enc) .digest(); return `${enc}.${b64urlEncode(sig)}`; @@ -56,7 +62,7 @@ export function verifyDownload( if (parts.length !== 2) return null; const [enc, sigEnc] = parts; const expected = crypto - .createHmac("sha256", getSecret()) + .createHmac("sha256", getSigningSecret()) .update(enc) .digest(); if (!timingSafeEqStr(sigEnc, b64urlEncode(expected))) return null; diff --git a/backend/src/lib/mcp/oauth.ts b/backend/src/lib/mcp/oauth.ts index 61e41a31..05cfab62 100644 --- a/backend/src/lib/mcp/oauth.ts +++ b/backend/src/lib/mcp/oauth.ts @@ -15,6 +15,7 @@ import type { OAuthTokens, } from "@modelcontextprotocol/sdk/shared/auth.js"; import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; +import { getSigningSecret } from "../downloadTokens"; import type { createServerSupabase } from "../supabase"; const STATE_TTL_SECONDS = 5 * 60; // 5 minutes @@ -39,14 +40,6 @@ export function oauthCallbackUrl(): string { // tokens and would already have to be rotated on compromise. // --------------------------------------------------------------------------- -function getSecret(): string { - return ( - process.env.DOWNLOAD_SIGNING_SECRET ?? - process.env.SUPABASE_SECRET_KEY ?? - "dev-secret" - ); -} - function b64url(buf: Buffer): string { return buf .toString("base64") @@ -70,7 +63,7 @@ export function signOAuthState(payload: { exp: Math.floor(Date.now() / 1000) + STATE_TTL_SECONDS, }; const enc = b64url(Buffer.from(JSON.stringify(body), "utf8")); - const sig = crypto.createHmac("sha256", getSecret()).update(enc).digest(); + const sig = crypto.createHmac("sha256", getSigningSecret()).update(enc).digest(); return `${enc}.${b64url(sig)}`; } @@ -81,7 +74,7 @@ export function verifyOAuthState( if (parts.length !== 2) return null; const [enc, sigEnc] = parts; const expected = crypto - .createHmac("sha256", getSecret()) + .createHmac("sha256", getSigningSecret()) .update(enc) .digest(); const expectedEnc = b64url(expected); From d5e0f745de7a843b32f4d5e2a283d5931e872151 Mon Sep 17 00:00:00 2001 From: Lef Date: Fri, 8 May 2026 15:18:16 +0200 Subject: [PATCH 15/26] fix(security): include user_mcp_servers in PostgREST lockdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #32 (commit 284890d) added the user_mcp_servers table holding MCP connector configuration, including request "headers" (typically a Bearer token) and "oauth_tokens" (refresh + access tokens) for OAuth 2.1 connectors. PR #42 (commit 9979566) introduced 004_security_ lockdown.sql which revokes anon and authenticated grants from every user-data table — but user_mcp_servers was added between PRs and slipped through. The result: a user authenticated via Supabase could query their own oauth_tokens / headers row directly through PostgREST, bypassing the "backend service-role only" pattern PR #42 established. Worse, any future regression of the per-row RLS policy would expose every users credentials, not just app data. Add the missing revoke to both 004 and the equivalent block at the bottom of 000_one_shot_schema.sql so fresh deployments are locked down too. Also alter table ... enable row level security for explicitness even though migration 002 already enables it. --- backend/migrations/000_one_shot_schema.sql | 3 +++ backend/migrations/004_security_lockdown.sql | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/backend/migrations/000_one_shot_schema.sql b/backend/migrations/000_one_shot_schema.sql index a2e37a22..03082ebc 100644 --- a/backend/migrations/000_one_shot_schema.sql +++ b/backend/migrations/000_one_shot_schema.sql @@ -572,3 +572,6 @@ revoke all on public.tabular_reviews from anon, authenticated; revoke all on public.tabular_cells from anon, authenticated; revoke all on public.tabular_review_chats from anon, authenticated; revoke all on public.tabular_review_chat_messages from anon, authenticated; +-- user_mcp_servers carries OAuth tokens and Authorization headers; it +-- absolutely must not be reachable via PostgREST under anon/authenticated. +revoke all on public.user_mcp_servers from anon, authenticated; diff --git a/backend/migrations/004_security_lockdown.sql b/backend/migrations/004_security_lockdown.sql index aa584502..77074ec3 100644 --- a/backend/migrations/004_security_lockdown.sql +++ b/backend/migrations/004_security_lockdown.sql @@ -40,6 +40,9 @@ alter table public.tabular_reviews enable row level security; alter table public.tabular_cells enable row level security; alter table public.tabular_review_chats enable row level security; alter table public.tabular_review_chat_messages enable row level security; +-- user_mcp_servers is RLS-on by default (per migration 002), but include +-- it here so the lockdown is explicit and re-runnable. +alter table public.user_mcp_servers enable row level security; drop policy if exists "Users can view their own profile" on public.user_profiles; drop policy if exists "Users can update their own profile" on public.user_profiles; @@ -59,3 +62,6 @@ revoke all on public.tabular_reviews from anon, authenticated; revoke all on public.tabular_cells from anon, authenticated; revoke all on public.tabular_review_chats from anon, authenticated; revoke all on public.tabular_review_chat_messages from anon, authenticated; +-- user_mcp_servers carries OAuth tokens and Authorization headers; it +-- absolutely must not be reachable via PostgREST under anon/authenticated. +revoke all on public.user_mcp_servers from anon, authenticated; From da0bc6d8e7a661881e868fdab8a634a32a6d7e04 Mon Sep 17 00:00:00 2001 From: Lef Date: Fri, 8 May 2026 15:20:17 +0200 Subject: [PATCH 16/26] fix(security): SSRF guard on MCP server URLs + clear creds on URL change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two MCP-routes findings from review: 1. The previous validateUrl in routes/mcpServers.ts allowed every HTTPS host plus all http://localhost / 127.0.0.1 URLs. From the backend container, single-label cluster service names ("garage", "postgres", "gotrue", etc.) resolve to internal docker IPs, so a user could point an MCP connector at http://garage:3900 (or http://10.0.0.5/admin) and have the backend proxy authenticated requests through. Add point-in-time SSRF screening: reject IPv4 literals in 0.0.0.0/8, 10/8, 127/8, 169.254/16, 172.16/12, 192.168/16; reject IPv6 ::1, fc00::/7, fe80::/10, ::; reject single-label hostnames; reject http:// (force https) by default. All gated behind MCP_ALLOW_PRIVATE_HOSTS=true so laptop devs can keep the previous permissive behaviour. Default-set MCP_ALLOW_PRIVATE_HOSTS=true in the docker-compose stack since the laptop target is the canonical audience; production-shape deployments should drop the flag. Note: this is point-in-time only; a determined attacker can still perform DNS rebinding to a private IP after validation. Closing that loop requires per-request DNS resolution + bind-to-IP at fetch time; flagged as a follow-up. 2. PATCH /user/mcp-servers/:id let a user change the URL on a row whose oauth_tokens, oauth_metadata, and headers were negotiated/issued for a different authorization server. The next call would send those credentials to the new origin (token leak) or fail in confusing ways. When url changes, clear oauth_tokens, oauth_metadata, oauth_code_verifier, and headers — the user must re-supply. --- .env.example | 5 ++ backend/src/routes/mcpServers.ts | 82 +++++++++++++++++++++++++++++--- docker-compose.yml | 1 + 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 94ac5956..e628173e 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,11 @@ OPENROUTER_API_KEY= # the callback if this URL is reachable from the third-party MCP server # (i.e. you've exposed Mike to the public internet over TLS). BACKEND_PUBLIC_URL=http://localhost:80/backend +# Allow MCP server URLs that point at private/loopback IPs and single-label +# hostnames. Required for any laptop dev where you run an MCP server on +# localhost or as a docker service alongside Mike. In production-style +# deployments (real domain, public traffic), leave unset to default-deny. +MCP_ALLOW_PRIVATE_HOSTS=true # --- Garage ------------------------------------------------------------------ GARAGE_RPC_SECRET= # set by generate-secrets.sh diff --git a/backend/src/routes/mcpServers.ts b/backend/src/routes/mcpServers.ts index 6ab9fdbb..0cb1df86 100644 --- a/backend/src/routes/mcpServers.ts +++ b/backend/src/routes/mcpServers.ts @@ -3,6 +3,7 @@ // Mounted at `/user/mcp-servers`. The backend uses Supabase's service role // (bypassing RLS), so every handler MUST filter by `user_id = userId`. +import net from "net"; import { Router } from "express"; import { auth as runOAuth } from "@modelcontextprotocol/sdk/client/auth.js"; import { requireAuth } from "../middleware/auth"; @@ -38,6 +39,40 @@ function deriveSlug(name: string): string { return base || "mcp"; } +// Block obvious SSRF targets at submit time: private/reserved IP literals, +// link-local, and single-label hostnames that almost always resolve to +// cluster-internal services (e.g. "postgres", "garage", "redis"). Set +// MCP_ALLOW_PRIVATE_HOSTS=true to bypass — useful for laptop dev where you +// might run an MCP server on a docker service alias. +// +// This is point-in-time validation only; it does not defend against DNS +// rebinding or runtime resolution to a private IP. Closing that loop would +// require per-request DNS resolution + bind-to-IP at fetch time. +function isPrivateIPv4(host: string): boolean { + if (!net.isIPv4(host)) return false; + const parts = host.split(".").map((n) => Number(n)); + return ( + parts[0] === 0 || + parts[0] === 10 || + (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || + (parts[0] === 192 && parts[1] === 168) || + (parts[0] === 169 && parts[1] === 254) || + parts[0] === 127 + ); +} + +function isPrivateIPv6(host: string): boolean { + if (!net.isIPv6(host)) return false; + const lo = host.toLowerCase(); + return ( + lo === "::1" || + lo.startsWith("fc") || + lo.startsWith("fd") || + lo.startsWith("fe80") || + lo === "::" + ); +} + function validateUrl(raw: string): { ok: true } | { ok: false; error: string } { let parsed: URL; try { @@ -45,14 +80,39 @@ function validateUrl(raw: string): { ok: true } | { ok: false; error: string } { } catch { return { ok: false, error: "url is not a valid URL" }; } - if (parsed.protocol === "https:") return { ok: true }; - if ( - parsed.protocol === "http:" && - (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") - ) { - return { ok: true }; + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + return { ok: false, error: "url must use http or https" }; + } + const allowPrivate = process.env.MCP_ALLOW_PRIVATE_HOSTS === "true"; + const host = parsed.hostname.toLowerCase(); + if (!allowPrivate) { + if (host === "localhost") { + return { + ok: false, + error: + "localhost is blocked; set MCP_ALLOW_PRIVATE_HOSTS=true for local development", + }; + } + if (isPrivateIPv4(host)) { + return { ok: false, error: `${host} is in a private/reserved IPv4 range` }; + } + if (isPrivateIPv6(host)) { + return { ok: false, error: `${host} is in a private/reserved IPv6 range` }; + } + if (!host.includes(".")) { + return { + ok: false, + error: `single-label hostname "${host}" looks cluster-internal; set MCP_ALLOW_PRIVATE_HOSTS=true if intentional`, + }; + } + } + if (parsed.protocol === "http:" && !allowPrivate) { + return { + ok: false, + error: "url must use https (or set MCP_ALLOW_PRIVATE_HOSTS=true for plaintext localhost development)", + }; } - return { ok: false, error: "url must use https (or http://localhost)" }; + return { ok: true }; } function validateHeaders( @@ -186,6 +246,14 @@ mcpServersRouter.patch("/:id", requireAuth, async (req, res) => { const urlOk = validateUrl(url); if (!urlOk.ok) return void res.status(400).json({ detail: urlOk.error }); update.url = url; + // Changing the URL invalidates every credential that was negotiated + // for the previous origin. Without these clears, the next call would + // send the old server's bearer/refresh tokens to the new authority — + // a token leak. Re-running OAuth (or re-supplying headers) is required. + update.oauth_tokens = null; + update.oauth_metadata = null; + update.oauth_code_verifier = null; + update.headers = {}; } if (body.headers !== undefined) { const headersOk = validateHeaders(body.headers); diff --git a/docker-compose.yml b/docker-compose.yml index ad270da0..d8395c7a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -140,6 +140,7 @@ services: DOWNLOAD_SIGNING_SECRET: ${DOWNLOAD_SIGNING_SECRET} USER_API_KEYS_ENCRYPTION_KEY: ${USER_API_KEYS_ENCRYPTION_KEY} BACKEND_PUBLIC_URL: ${BACKEND_PUBLIC_URL:-http://${MIKE_HOST}:${MIKE_PORT}/backend} + MCP_ALLOW_PRIVATE_HOSTS: ${MCP_ALLOW_PRIVATE_HOSTS:-true} R2_ENDPOINT_URL: http://garage:3900 R2_BUCKET_NAME: ${R2_BUCKET_NAME} volumes: From 31c181748bfe35bf641697bd91d81c23ed3f8098 Mon Sep 17 00:00:00 2001 From: Lef Date: Fri, 8 May 2026 15:22:17 +0200 Subject: [PATCH 17/26] fix(mcp): cap tool output bytes + structured ok/truncated result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related issues with the MCP tool-call path: 1. McpHttpClient.callTool returned the joined text content with no upper bound. A misbehaving connector that emits multi-megabyte responses would blow the model context window, run up token cost, and effectively DoS the chat. The user-facing preview was capped via truncateForPreview but the LLM-side content was not. 2. The dispatcher in chatTools.ts inferred ok-vs-error by sniffing "MCP tool " as a string prefix. Any tool legitimately returning a string starting with that prefix would be flagged failed; any rename of the prefix would silently break detection; the regex chars in tool names were never escaped. Replace the string-returning callTool with a structured { ok, content, truncated } result. Cap content at MAX_TOOL_CONTENT_BYTES (default 64 KB; override via MCP_MAX_TOOL_BYTES env var) and append a […truncated N bytes; raise MCP_MAX_TOOL_BYTES to see more] marker so the model knows the output was clipped. Update the LoadedMcpServer contract in mcp/types.ts and the chatTools.ts dispatcher to consume the new shape. --- backend/src/lib/chatTools.ts | 11 +++++--- backend/src/lib/mcp/client.ts | 51 +++++++++++++++++++++++++++++------ backend/src/lib/mcp/types.ts | 13 ++++++++- 3 files changed, 62 insertions(+), 13 deletions(-) diff --git a/backend/src/lib/chatTools.ts b/backend/src/lib/chatTools.ts index b9e69ecc..b3b6d00f 100644 --- a/backend/src/lib/chatTools.ts +++ b/backend/src/lib/chatTools.ts @@ -1511,10 +1511,13 @@ export async function runToolCalls( tool: originalName, })}\n\n`, ); - const content = await server.client.callTool(originalName, args); - // The model gets the untruncated content; the user-facing preview - // is capped to keep chat_messages.content from bloating. - const ok = !content.startsWith(`MCP tool '${originalName}' `); + const { ok, content } = await server.client.callTool( + originalName, + args, + ); + // The model already sees content capped at MCP_MAX_TOOL_BYTES + // (mcp/client.ts); the user-facing preview is further capped here + // to keep chat_messages.content from bloating. const preview: McpToolResultEvent = { type: "mcp_tool_result", server: server.row.name, diff --git a/backend/src/lib/mcp/client.ts b/backend/src/lib/mcp/client.ts index 897d606d..382d5258 100644 --- a/backend/src/lib/mcp/client.ts +++ b/backend/src/lib/mcp/client.ts @@ -53,15 +53,24 @@ export class McpHttpClient { } /** - * Calls a tool and returns its text content joined by blank lines. - * Errors (transport failures, MCP `isError`) are turned into a text - * response so the model can surface them rather than crashing the chat. + * Calls a tool and returns a structured {ok, content, truncated} result. + * Errors (transport failures, MCP `isError`) become ok=false with the + * error message in `content` so the model can surface them rather than + * crashing the chat. `content` is hard-capped at MAX_TOOL_CONTENT_BYTES + * (configurable via MCP_MAX_TOOL_BYTES) to prevent a misbehaving connector + * from blowing the LLM context window or DoSing the chat. */ async callTool( name: string, args: Record, - ): Promise { - if (!this.client) return "MCP client not connected"; + ): Promise<{ ok: boolean; content: string; truncated: boolean }> { + if (!this.client) { + return { + ok: false, + content: "MCP client not connected", + truncated: false, + }; + } try { const result = await withTimeout( this.client.callTool({ name, arguments: args }), @@ -77,12 +86,15 @@ export class McpHttpClient { .map((b) => b.text) .join("\n\n"); if (result.isError) { - return `MCP tool '${name}' returned error: ${text || "(no detail)"}`; + return capContent( + false, + `Error: ${text || "(no detail)"}`, + ); } - return text || "(tool returned no text content)"; + return capContent(true, text || "(tool returned no text content)"); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - return `MCP tool '${name}' failed: ${msg}`; + return { ok: false, content: `Failed: ${msg}`, truncated: false }; } } @@ -102,6 +114,29 @@ export class McpHttpClient { } } +// Cap tool output before it reaches the LLM. A misbehaving connector that +// returns multi-megabyte responses would blow the model's context window, +// rack up token cost, and effectively DoS the chat. Default 64 KB; override +// via MCP_MAX_TOOL_BYTES. +const MAX_TOOL_CONTENT_BYTES = (() => { + const raw = Number(process.env.MCP_MAX_TOOL_BYTES); + return Number.isFinite(raw) && raw > 0 ? raw : 64 * 1024; +})(); + +function capContent( + ok: boolean, + raw: string, +): { ok: boolean; content: string; truncated: boolean } { + const buf = Buffer.from(raw, "utf8"); + if (buf.byteLength <= MAX_TOOL_CONTENT_BYTES) { + return { ok, content: raw, truncated: false }; + } + const head = buf.subarray(0, MAX_TOOL_CONTENT_BYTES).toString("utf8"); + const skipped = buf.byteLength - MAX_TOOL_CONTENT_BYTES; + const marker = `\n\n[…truncated ${skipped} bytes; raise MCP_MAX_TOOL_BYTES to see more]`; + return { ok, content: head + marker, truncated: true }; +} + function withTimeout( p: Promise, ms: number, diff --git a/backend/src/lib/mcp/types.ts b/backend/src/lib/mcp/types.ts index c347ddfa..84d60668 100644 --- a/backend/src/lib/mcp/types.ts +++ b/backend/src/lib/mcp/types.ts @@ -32,7 +32,18 @@ export type LoadedMcpServer = { callTool: ( toolName: string, args: Record, - ) => Promise; + ) => Promise; close: () => Promise; }; }; + +/** + * Structured result from an MCP tool call. `ok` carries success vs error + * directly so callers don't have to sniff message prefixes; `truncated` is + * true when `content` was clipped to the model-side size cap. + */ +export type McpToolResult = { + ok: boolean; + content: string; + truncated: boolean; +}; From 908f8e173b5a075ac4d3d58714cd8478af550077 Mon Sep 17 00:00:00 2001 From: Lef Date: Fri, 8 May 2026 15:23:36 +0200 Subject: [PATCH 18/26] fix(security): lowercase emails on shared_with lookups + jsonb form in tabular MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two latent issues in the route-level shared_with paths: 1. tabular.ts:185 still passed a bare JS array to .contains() against a jsonb column. PostgREST serialises JS arrays as PostgreSQL array literals ({a,b}) which Postgres rejects when casting to jsonb with "invalid input syntax for type json". The catch at line 197 logged it as "shared_with column hasnt been migrated yet" and dropped the error, so direct-share standalone tabular reviews silently never appeared for the recipient. Same root cause as commit 653d055 for projects/access — the tabular site was missed. 2. shared_with values are normalised to lowercase on PATCH/POST (in both projects and tabular), but the read paths in projects.ts:33, projects.ts:118, access.ts:148, and tabular.ts:185 passed userEmail straight from the JWT. Auth providers (Google, Microsoft, magic-link admins) can issue tokens with mixed-case email claims, and the case mismatch silently makes the row invisible to those users — even though RLS policies do use lower() in their WHERE clauses. Lowercase userEmail at the top of each affected scope and use the lowercased value in every .contains() / .includes() shortcut. Stored data is unchanged. --- backend/src/lib/access.ts | 7 +++++-- backend/src/routes/projects.ts | 8 ++++++-- backend/src/routes/tabular.ts | 14 ++++++++++++-- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/backend/src/lib/access.ts b/backend/src/lib/access.ts index 7ea4defa..9f7e00e6 100644 --- a/backend/src/lib/access.ts +++ b/backend/src/lib/access.ts @@ -139,13 +139,16 @@ export async function listAccessibleProjectIds( userEmail: string | null | undefined, db: Db, ): Promise { + // Stored shared_with values are lowercase; lowercase the JWT email too + // so providers that issue mixed-case email claims still match the row. + const email = userEmail?.toLowerCase(); const [{ data: own }, { data: shared }] = await Promise.all([ db.from("projects").select("id").eq("user_id", userId), - userEmail + email ? db .from("projects") .select("id") - .contains("shared_with", JSON.stringify([userEmail])) + .contains("shared_with", JSON.stringify([email])) .neq("user_id", userId) : Promise.resolve({ data: [] as { id: string }[] }), ]); diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index ce2bdc60..a6eb0997 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -16,7 +16,11 @@ export const projectsRouter = Router(); // GET /projects projectsRouter.get("/", requireAuth, async (req, res) => { const userId = res.locals.userId as string; - const userEmail = res.locals.userEmail as string; + // Stored shared_with values are normalised to lowercase on PATCH/POST, + // so the lookup must lowercase the JWT email too — Google/Microsoft/etc. + // can issue tokens with mixed-case emails and a mismatch silently makes + // the row invisible. + const userEmail = (res.locals.userEmail as string | undefined)?.toLowerCase(); const db = createServerSupabase(); const { data: ownProjects, error: ownError } = await db @@ -99,7 +103,7 @@ projectsRouter.post("/", requireAuth, async (req, res) => { // GET /projects/:projectId projectsRouter.get("/:projectId", requireAuth, async (req, res) => { const userId = res.locals.userId as string; - const userEmail = res.locals.userEmail as string; + const userEmail = (res.locals.userEmail as string | undefined)?.toLowerCase(); const { projectId } = req.params; const db = createServerSupabase(); diff --git a/backend/src/routes/tabular.ts b/backend/src/routes/tabular.ts index f7058a06..3ec67741 100644 --- a/backend/src/routes/tabular.ts +++ b/backend/src/routes/tabular.ts @@ -181,8 +181,18 @@ tabularRouter.get("/", requireAuth, async (req, res) => { ? db .from("tabular_reviews") .select("*") - .is("project_id", null) - .contains("shared_with", [userEmail]) + // Two fixes here vs. the previous form: + // 1. shared_with is jsonb, so `.contains` needs the JSON + // array literal (`["foo@bar"]`), not the Postgres array + // literal (`{foo@bar}`) — bare arrays produced + // "invalid input syntax for type json" 400s, swallowed + // by the catch below as if the column hadn't migrated. + // 2. Lowercase the JWT email so providers issuing mixed-case + // emails still match the (lowercased on insert) rows. + .contains( + "shared_with", + JSON.stringify([userEmail.toLowerCase()]), + ) .neq("user_id", userId) .order("created_at", { ascending: false }) : Promise.resolve({ From 3d46ed4d0b599d2564878da1597bc6c7d1d19aa0 Mon Sep 17 00:00:00 2001 From: Lef Date: Fri, 8 May 2026 15:25:18 +0200 Subject: [PATCH 19/26] fix(llm): three OpenRouter bugs (debug log, tool-call id, model slugs) Add DEBUG_LLM_STREAM guard around the SSE chunk console.log in openrouter.ts, mirroring the pattern already used in claude.ts. Without the guard every SSE frame was printed to stdout in production. Backfill the tool-call id when OpenRouter defers it to a later delta. Some models send the real id in chunk 2; the first delta got a synthetic "tool-" fallback that was never replaced, causing tool_result messages to reference an id the model never sent. Fix the two anthropic-via-OpenRouter model slugs in models.ts: the period in claude-sonnet-4.6 and claude-opus-4.7 must be a hyphen (claude-sonnet-4-6, claude-opus-4-7) to match OpenRouter's documented routing slugs. --- backend/src/lib/llm/models.ts | 4 ++-- backend/src/lib/llm/openrouter.ts | 9 ++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/backend/src/lib/llm/models.ts b/backend/src/lib/llm/models.ts index 0afb5a74..b910555b 100644 --- a/backend/src/lib/llm/models.ts +++ b/backend/src/lib/llm/models.ts @@ -13,8 +13,8 @@ export const GEMINI_MAIN_MODELS = [ // OpenRouter main-chat tier export const OPENROUTER_MAIN_MODELS = [ "openrouter/openai/gpt-5.3-chat", - "openrouter/anthropic/claude-sonnet-4.6", - "openrouter/anthropic/claude-opus-4.7", + "openrouter/anthropic/claude-sonnet-4-6", + "openrouter/anthropic/claude-opus-4-7", "openrouter/x-ai/grok-4.3", "openrouter/openai/gpt-4o-mini", ] as const; diff --git a/backend/src/lib/llm/openrouter.ts b/backend/src/lib/llm/openrouter.ts index 16f1e2d5..1466298c 100644 --- a/backend/src/lib/llm/openrouter.ts +++ b/backend/src/lib/llm/openrouter.ts @@ -6,6 +6,7 @@ import type { const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"; const MAX_TOKENS = 16384; +const DEBUG_LLM_STREAM = process.env.DEBUG_LLM_STREAM === "true"; type OpenRouterMessage = { role: "system" | "user" | "assistant" | "tool"; @@ -142,7 +143,9 @@ export async function streamOpenRouter( continue; } - console.log("[openrouter stream chunk]", JSON.stringify(chunk, null, 2)); + if (DEBUG_LLM_STREAM) { + console.log("[openrouter stream chunk]", JSON.stringify(chunk, null, 2)); + } const choice = chunk.choices?.[0]; if (!choice?.delta) continue; @@ -162,6 +165,10 @@ export async function streamOpenRouter( if (tc.function?.arguments) { existing.arguments += tc.function.arguments; } + // Backfill id when first delta used a synthetic fallback + if (tc.id && (!existing.id || existing.id.startsWith("tool-"))) { + existing.id = tc.id; + } } else { // New tool call toolCalls.set(tc.index, { From 2740fa82d232e5cf29b42bb9dd81c90d7d8cfae7 Mon Sep 17 00:00:00 2001 From: Lef Date: Fri, 8 May 2026 15:25:20 +0200 Subject: [PATCH 20/26] fix(upload): scan first 1024 bytes for PDF + drop JSZip private-API zip-size guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix A (PDF magic-byte scan): PDF 1.4 §3.4.1 permits %PDF- within the first 1024 bytes, not strictly at offset 0. Replaced the offset-0 equality check with a subarray(0, 1024).includes() scan so PDFs with leading whitespace or BOM bytes are accepted. Fix B (DOCX zip-bomb guard): The previous guard read JSZip's private _data.uncompressedSize field, which returns undefined for store-only (method=0) entries, silently bypassing the limit. Replaced with an explicit decompression loop using the public entry.async("nodebuffer") API, bailing out as soon as the running total exceeds 100 MB. --- backend/src/lib/upload.ts | 25 +++++++++++++++---------- backend/test/upload.test.ts | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/backend/src/lib/upload.ts b/backend/src/lib/upload.ts index 7182bf6a..5dbc646e 100644 --- a/backend/src/lib/upload.ts +++ b/backend/src/lib/upload.ts @@ -6,7 +6,7 @@ export const MAX_UPLOAD_SIZE_BYTES = 100 * 1024 * 1024; export const MAX_UPLOAD_SIZE_MB = Math.round( MAX_UPLOAD_SIZE_BYTES / (1024 * 1024), ); -const MAX_DOCX_UNCOMPRESSED_BYTES = 200 * 1024 * 1024; +const MAX_DOCX_UNCOMPRESSED_BYTES = 100 * 1024 * 1024; const memoryUpload = multer({ storage: multer.memoryStorage(), @@ -55,7 +55,10 @@ export async function validateDocumentUpload( } if (suffix === "pdf") { - if (!file.buffer.subarray(0, 5).equals(Buffer.from("%PDF-"))) { + // PDF 1.4 §3.4.1 allows %PDF- to appear within the first 1024 bytes, + // not necessarily at offset 0 (e.g. leading whitespace or BOM). + const head = file.buffer.subarray(0, Math.min(1024, file.buffer.length)); + if (!head.includes(Buffer.from("%PDF-"))) { throw new Error("Uploaded PDF does not have a valid PDF header."); } return { suffix, contentType: "application/pdf" }; @@ -80,15 +83,17 @@ export async function validateDocumentUpload( throw new Error("Uploaded DOCX is missing required Word document parts."); } + // Decompressing each entry to measure size is necessary because the + // private JSZip._data.uncompressedSize field returns undefined for + // store-only (method=0) entries, allowing a zip bomb to bypass the check. let totalUncompressed = 0; - zip.forEach((_path, entry) => { - const data = entry as unknown as { - _data?: { uncompressedSize?: number }; - }; - totalUncompressed += data._data?.uncompressedSize ?? 0; - }); - if (totalUncompressed > MAX_DOCX_UNCOMPRESSED_BYTES) { - throw new Error("Uploaded DOCX expands beyond the allowed size."); + for (const [, entry] of Object.entries(zip.files)) { + if (entry.dir) continue; + const buf = await entry.async("nodebuffer"); + totalUncompressed += buf.byteLength; + if (totalUncompressed > MAX_DOCX_UNCOMPRESSED_BYTES) { + throw new Error("Uploaded DOCX expands beyond the allowed size."); + } } } catch (err) { if (err instanceof Error && err.message.startsWith("Uploaded DOCX")) { diff --git a/backend/test/upload.test.ts b/backend/test/upload.test.ts index fec5fdda..73d6dfd5 100644 --- a/backend/test/upload.test.ts +++ b/backend/test/upload.test.ts @@ -52,6 +52,24 @@ describe("document upload validation", () => { ); }); + it("accepts a PDF with leading whitespace before %PDF-", async () => { + const buf = Buffer.concat([Buffer.from("\n"), Buffer.from("%PDF-1.7")]); + assert.deepEqual( + await validateDocumentUpload(upload("contract.pdf", buf)), + { suffix: "pdf", contentType: "application/pdf" }, + ); + }); + + it("rejects a zip renamed to .docx that lacks word/document.xml", async () => { + const zip = new JSZip(); + zip.file("random.txt", "not a word document"); + const buf = await zip.generateAsync({ type: "nodebuffer" }); + await assert.rejects( + validateDocumentUpload(upload("contract.docx", buf)), + /missing required Word document parts/, + ); + }); + it("rejects mismatched and malformed document bytes", async () => { await assert.rejects( validateDocumentUpload(upload("contract.pdf", Buffer.from("not pdf"))), From a96867e30678fc9920c8e51919752da705cc942a Mon Sep 17 00:00:00 2001 From: Lef Date: Fri, 8 May 2026 15:26:05 +0200 Subject: [PATCH 21/26] fix(frontend): 3 post-merge bugs (auth race, openrouter tabular, provider tooltip) - AuthContext: await ensureProfile() at both call sites (checkUser and onAuthStateChange) so the POST /user/profile completes before setAuthLoading(false) fires, eliminating the race where UserProfileContext could GET /user/profile and 404 before the row exists. - TabularReviewView: add openrouterApiKey to the apiKeys object passed to isModelAvailable(), matching ChatInput.tsx and TRChatPanel.tsx; without it an OpenRouter tabular model was silently blocked and the wrong modal opened. - account/models/page.tsx: replace 2-arm conditional in the disabled-model tooltip with a 3-arm one so OpenRouter models no longer incorrectly tell users to "Add a Gemini API key". --- frontend/src/app/(pages)/account/models/page.tsx | 2 +- frontend/src/app/components/tabular/TabularReviewView.tsx | 1 + frontend/src/contexts/AuthContext.tsx | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/(pages)/account/models/page.tsx b/frontend/src/app/(pages)/account/models/page.tsx index 0a80aa53..967da960 100644 --- a/frontend/src/app/(pages)/account/models/page.tsx +++ b/frontend/src/app/(pages)/account/models/page.tsx @@ -169,7 +169,7 @@ function TabularModelDropdown({ onSelect={() => onChange(m.id)} title={ !available - ? `Add a ${provider === "claude" ? "Claude" : "Gemini"} API key to use this model` + ? `Add a ${provider === "claude" ? "Claude" : provider === "gemini" ? "Gemini" : "OpenRouter"} API key to use this model` : undefined } > diff --git a/frontend/src/app/components/tabular/TabularReviewView.tsx b/frontend/src/app/components/tabular/TabularReviewView.tsx index 84b9f702..5e4625ba 100644 --- a/frontend/src/app/components/tabular/TabularReviewView.tsx +++ b/frontend/src/app/components/tabular/TabularReviewView.tsx @@ -90,6 +90,7 @@ export function TRView({ reviewId, projectId }: Props) { const apiKeys = { claudeApiKey: profile?.hasClaudeApiKey ? "configured" : null, geminiApiKey: profile?.hasGeminiApiKey ? "configured" : null, + openrouterApiKey: profile?.hasOpenrouterApiKey ? "configured" : null, }; const tabularModel = profile?.tabularModel ?? "gemini-3-flash-preview"; diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 03a8a930..a13b58be 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -47,7 +47,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { id: session.user.id, email: session.user.email || "", }); - ensureProfile(session.access_token); + await ensureProfile(session.access_token); } setAuthLoading(false); }; @@ -62,7 +62,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { id: session.user.id, email: session.user.email || "", }); - ensureProfile(session.access_token); + await ensureProfile(session.access_token); } else { setUser(null); } From ca110ef6c5e80b933ac60255e3fcde8fae3e65fb Mon Sep 17 00:00:00 2001 From: Lef Date: Fri, 8 May 2026 15:28:29 +0200 Subject: [PATCH 22/26] refactor(backend): consolidate handleDocumentUpload to a single home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The function was duplicated verbatim in both backend/src/routes/documents.ts and backend/src/routes/projects.ts, with only minor comment divergences that would cause future fixes to silently miss one copy. Per the reviewer's finding, documents.ts is the canonical home as it is the document-focused route file and already hosts related helpers (countPdfPages, extractStructureTree). The copy in projects.ts — along with its private countPdfPages and extractStructureTree helpers — has been removed; projects.ts now imports handleDocumentUpload from ./documents instead. --- backend/src/routes/documents.ts | 2 +- backend/src/routes/projects.ts | 213 +------------------------------- 2 files changed, 3 insertions(+), 212 deletions(-) diff --git a/backend/src/routes/documents.ts b/backend/src/routes/documents.ts index 159b859e..f9b1e2b9 100644 --- a/backend/src/routes/documents.ts +++ b/backend/src/routes/documents.ts @@ -792,7 +792,7 @@ documentsRouter.post( (req, res) => void handleEditResolution(req, res, "reject"), ); -async function handleDocumentUpload( +export async function handleDocumentUpload( req: import("express").Request, res: import("express").Response, userId: string, diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index a6eb0997..7205bf85 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -9,7 +9,8 @@ import { import { downloadFile, uploadFile, storageKey } from "../lib/storage"; import { docxToPdf, convertedPdfKey } from "../lib/convert"; import { checkProjectAccess } from "../lib/access"; -import { singleFileUpload, validateDocumentUpload } from "../lib/upload"; +import { singleFileUpload } from "../lib/upload"; +import { handleDocumentUpload } from "./documents"; export const projectsRouter = Router(); @@ -610,213 +611,3 @@ async function loadProjectFolder( .maybeSingle(); return (data as { id: string; parent_folder_id: string | null } | null) ?? null; } - -export async function handleDocumentUpload( - req: import("express").Request, - res: import("express").Response, - userId: string, - projectId: string | null, - db: ReturnType, -) { - const file = req.file; - if (!file) return void res.status(400).json({ detail: "file is required" }); - - const filename = file.originalname; - let validated: Awaited>; - try { - validated = await validateDocumentUpload(file); - } catch (err) { - return void res.status(400).json({ - detail: err instanceof Error ? err.message : "Invalid upload", - }); - } - const suffix = validated.suffix; - - const content = file.buffer; - const { data: doc, error: insertErr } = await db - .from("documents") - .insert({ - project_id: projectId, - user_id: userId, - filename, - file_type: suffix, - size_bytes: content.byteLength, - status: "processing", - }) - .select("*") - .single(); - - if (insertErr || !doc) - return void res - .status(500) - .json({ detail: "Failed to create document record" }); - - try { - const docId = doc.id as string; - const key = storageKey(userId, docId, filename); - await uploadFile( - key, - content.buffer.slice( - content.byteOffset, - content.byteOffset + content.byteLength, - ) as ArrayBuffer, - validated.contentType, - ); - - const rawBuf = content.buffer.slice( - content.byteOffset, - content.byteOffset + content.byteLength, - ) as ArrayBuffer; - const tree = await extractStructureTree(rawBuf, suffix, filename); - const pageCount = suffix === "pdf" ? await countPdfPages(rawBuf) : null; - - // Convert DOCX/DOC → PDF for display. PDFs are their own rendition. - let pdfStoragePath: string | null = null; - if (suffix === "docx" || suffix === "doc") { - try { - const pdfBuf = await docxToPdf(content); - const pdfKey = convertedPdfKey(userId, docId); - await uploadFile( - pdfKey, - pdfBuf.buffer.slice( - pdfBuf.byteOffset, - pdfBuf.byteOffset + pdfBuf.byteLength, - ) as ArrayBuffer, - "application/pdf", - ); - pdfStoragePath = pdfKey; - } catch (err) { - console.error("[upload] DOCX→PDF conversion failed", err); - } - } else if (suffix === "pdf") { - pdfStoragePath = key; - } - - // Storage paths live on document_versions — create the V1 row and - // point documents.current_version_id at it. - const { data: versionRow, error: verErr } = await db - .from("document_versions") - .insert({ - document_id: docId, - storage_path: key, - pdf_storage_path: pdfStoragePath, - source: "upload", - version_number: 1, - display_name: filename, - }) - .select("id") - .single(); - if (verErr || !versionRow) { - throw new Error( - `Failed to record upload version: ${verErr?.message ?? "unknown"}`, - ); - } - - await db - .from("documents") - .update({ - current_version_id: versionRow.id, - size_bytes: content.byteLength, - page_count: pageCount, - structure_tree: tree ?? null, - status: "ready", - updated_at: new Date().toISOString(), - }) - .eq("id", docId); - - const { data: updated } = await db - .from("documents") - .select("*") - .eq("id", docId) - .single(); - const responseDoc = updated - ? { - ...updated, - storage_path: key, - pdf_storage_path: pdfStoragePath, - } - : updated; - return void res.status(201).json(responseDoc); - } catch (e) { - await db.from("documents").update({ status: "error" }).eq("id", doc.id); - return void res - .status(500) - .json({ detail: `Document processing failed: ${String(e)}` }); - } -} - -async function countPdfPages(buf: ArrayBuffer): Promise { - try { - const pdfjsLib = await import("pdfjs-dist/legacy/build/pdf.mjs" as string); - const pdf = await ( - pdfjsLib as unknown as { - getDocument: (opts: unknown) => { - promise: Promise<{ numPages: number }>; - }; - } - ).getDocument({ data: new Uint8Array(buf) }).promise; - return pdf.numPages; - } catch { - return null; - } -} - -async function extractStructureTree( - content: ArrayBuffer, - fileType: string, - filename: string, -): Promise { - try { - if (fileType === "pdf") { - const pdfjsLib = await import( - "pdfjs-dist/legacy/build/pdf.mjs" as string - ); - const pdf = await ( - pdfjsLib as unknown as { - getDocument: (opts: unknown) => { - promise: Promise<{ - numPages: number; - getOutline: () => Promise<{ title?: string }[]>; - }>; - }; - } - ).getDocument({ data: new Uint8Array(content) }).promise; - if (pdf.numPages <= 5) return null; - const outline = await pdf.getOutline(); - if (outline?.length) { - return outline.map((item, i) => ({ - id: `h1-${i}`, - title: item.title ?? `Item ${i + 1}`, - level: 1, - page_number: null, - children: [], - })); - } - return Array.from({ length: pdf.numPages }, (_, i) => ({ - id: `page-${i + 1}`, - title: `Page ${i + 1}`, - level: 1, - page_number: i + 1, - children: [], - })); - } else { - const mammoth = await import("mammoth"); - const result = await mammoth.extractRawText({ - buffer: Buffer.from(content), - }); - const lines = result.value.split("\n").filter((l) => l.trim()); - const nodes = lines - .slice(0, 30) - .map((line, i) => ({ - id: `h1-${i}`, - title: line.slice(0, 100), - level: 1, - page_number: null, - children: [], - })); - return nodes.length ? nodes : null; - } - } catch { - return null; - } -} From 5355113997ebe8f5b5a98e08081a1a7b64456af5 Mon Sep 17 00:00:00 2001 From: Lef Date: Fri, 8 May 2026 15:28:41 +0200 Subject: [PATCH 23/26] refactor(frontend): extract apiKeysFromProfile helper to remove 4-fold dup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boolean→"configured" sentinel mapping for claudeApiKey, geminiApiKey, and openrouterApiKey was repeated verbatim in ChatInput.tsx, TRChatPanel.tsx, TabularReviewView.tsx, and account/models/page.tsx. This duplication directly caused the TabularReviewView regression where openrouterApiKey was missing, flagged by reviewers as Tier 3 #19. A single apiKeysFromProfile helper is now exported from modelAvailability.ts, and UserProfile is exported from UserProfileContext.tsx to type it; all four sites use the helper. --- frontend/src/app/(pages)/account/models/page.tsx | 13 ++----------- frontend/src/app/components/assistant/ChatInput.tsx | 7 ++----- frontend/src/app/components/tabular/TRChatPanel.tsx | 7 ++----- .../app/components/tabular/TabularReviewView.tsx | 7 ++----- frontend/src/app/lib/modelAvailability.ts | 11 +++++++++++ frontend/src/contexts/UserProfileContext.tsx | 2 +- 6 files changed, 20 insertions(+), 27 deletions(-) diff --git a/frontend/src/app/(pages)/account/models/page.tsx b/frontend/src/app/(pages)/account/models/page.tsx index 967da960..df4eb07d 100644 --- a/frontend/src/app/(pages)/account/models/page.tsx +++ b/frontend/src/app/(pages)/account/models/page.tsx @@ -15,6 +15,7 @@ import { import { useUserProfile } from "@/contexts/UserProfileContext"; import { MODELS } from "@/app/components/assistant/ModelToggle"; import { + apiKeysFromProfile, isModelAvailable, modelGroupToProvider, } from "@/app/lib/modelAvailability"; @@ -41,17 +42,7 @@ export default function ModelsAndApiKeysPage() { profile?.tabularModel ?? "gemini-3-flash-preview" } - apiKeys={{ - claudeApiKey: profile?.hasClaudeApiKey - ? "configured" - : null, - geminiApiKey: profile?.hasGeminiApiKey - ? "configured" - : null, - openrouterApiKey: profile?.hasOpenrouterApiKey - ? "configured" - : null, - }} + apiKeys={apiKeysFromProfile(profile)} onChange={(id) => updateModelPreference("tabularModel", id) } diff --git a/frontend/src/app/components/assistant/ChatInput.tsx b/frontend/src/app/components/assistant/ChatInput.tsx index 64281938..5ca2722a 100644 --- a/frontend/src/app/components/assistant/ChatInput.tsx +++ b/frontend/src/app/components/assistant/ChatInput.tsx @@ -26,6 +26,7 @@ import { ModelToggle } from "./ModelToggle"; import { useSelectedModel } from "@/app/hooks/useSelectedModel"; import { useUserProfile } from "@/contexts/UserProfileContext"; import { + apiKeysFromProfile, getModelProvider, isModelAvailable, type ModelProvider, @@ -68,11 +69,7 @@ export const ChatInput = forwardRef(function ChatInput( } | null>(null); const [model, setModel] = useSelectedModel(); const { profile } = useUserProfile(); - const apiKeys = { - claudeApiKey: profile?.hasClaudeApiKey ? "configured" : null, - geminiApiKey: profile?.hasGeminiApiKey ? "configured" : null, - openrouterApiKey: profile?.hasOpenrouterApiKey ? "configured" : null, - }; + const apiKeys = apiKeysFromProfile(profile); const textareaRef = useRef(null); const [docSelectorOpen, setDocSelectorOpen] = useState(false); const [workflowModalOpen, setWorkflowModalOpen] = useState(false); diff --git a/frontend/src/app/components/tabular/TRChatPanel.tsx b/frontend/src/app/components/tabular/TRChatPanel.tsx index b5b456f4..9151d7ff 100644 --- a/frontend/src/app/components/tabular/TRChatPanel.tsx +++ b/frontend/src/app/components/tabular/TRChatPanel.tsx @@ -33,6 +33,7 @@ import { ApiKeyMissingModal } from "../shared/ApiKeyMissingModal"; import { PreResponseWrapper } from "../shared/PreResponseWrapper"; import { useUserProfile } from "@/contexts/UserProfileContext"; import { + apiKeysFromProfile, getModelProvider, isModelAvailable, type ModelProvider, @@ -617,11 +618,7 @@ export function TRChatPanel({ onChatIdChange, }: Props) { const { profile, updateModelPreference } = useUserProfile(); - const apiKeys = { - claudeApiKey: profile?.hasClaudeApiKey ? "configured" : null, - geminiApiKey: profile?.hasGeminiApiKey ? "configured" : null, - openrouterApiKey: profile?.hasOpenrouterApiKey ? "configured" : null, - }; + const apiKeys = apiKeysFromProfile(profile); const currentModel = profile?.tabularModel ?? "gemini-3-flash-preview"; const [apiKeyModalProvider, setApiKeyModalProvider] = useState(null); diff --git a/frontend/src/app/components/tabular/TabularReviewView.tsx b/frontend/src/app/components/tabular/TabularReviewView.tsx index 5e4625ba..ab37884b 100644 --- a/frontend/src/app/components/tabular/TabularReviewView.tsx +++ b/frontend/src/app/components/tabular/TabularReviewView.tsx @@ -31,6 +31,7 @@ import { RenameableTitle } from "../shared/RenameableTitle"; import { useAuth } from "@/contexts/AuthContext"; import { useUserProfile } from "@/contexts/UserProfileContext"; import { + apiKeysFromProfile, getModelProvider, isModelAvailable, type ModelProvider, @@ -87,11 +88,7 @@ export function TRView({ reviewId, projectId }: Props) { const tableRef = useRef(null); const router = useRouter(); const { profile } = useUserProfile(); - const apiKeys = { - claudeApiKey: profile?.hasClaudeApiKey ? "configured" : null, - geminiApiKey: profile?.hasGeminiApiKey ? "configured" : null, - openrouterApiKey: profile?.hasOpenrouterApiKey ? "configured" : null, - }; + const apiKeys = apiKeysFromProfile(profile); const tabularModel = profile?.tabularModel ?? "gemini-3-flash-preview"; useEffect(() => { diff --git a/frontend/src/app/lib/modelAvailability.ts b/frontend/src/app/lib/modelAvailability.ts index 285d8fb5..b6ed2eb4 100644 --- a/frontend/src/app/lib/modelAvailability.ts +++ b/frontend/src/app/lib/modelAvailability.ts @@ -1,4 +1,5 @@ import { MODELS, type ModelOption } from "../components/assistant/ModelToggle"; +import type { UserProfile } from "@/contexts/UserProfileContext"; export type ModelProvider = "claude" | "gemini" | "openrouter"; @@ -40,6 +41,16 @@ export function providerLabel(provider: ModelProvider): string { return ""; } +export function apiKeysFromProfile(profile: UserProfile | null) { + return { + claudeApiKey: profile?.hasClaudeApiKey ? ("configured" as const) : null, + geminiApiKey: profile?.hasGeminiApiKey ? ("configured" as const) : null, + openrouterApiKey: profile?.hasOpenrouterApiKey + ? ("configured" as const) + : null, + }; +} + export function modelGroupToProvider( group: ModelOption["group"], ): ModelProvider { diff --git a/frontend/src/contexts/UserProfileContext.tsx b/frontend/src/contexts/UserProfileContext.tsx index b23e9914..7efe9edb 100644 --- a/frontend/src/contexts/UserProfileContext.tsx +++ b/frontend/src/contexts/UserProfileContext.tsx @@ -27,7 +27,7 @@ interface ServerProfile { has_openrouter_api_key: boolean; } -interface UserProfile { +export interface UserProfile { displayName: string | null; organisation: string | null; messageCreditsUsed: number; From 701535beb675b928fdac37c0a25d79bee5277b32 Mon Sep 17 00:00:00 2001 From: Lef Date: Fri, 8 May 2026 15:30:07 +0200 Subject: [PATCH 24/26] refactor(backend): centralize provider-key encrypt/decrypt mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit userSettings.ts had three near-identical "if stored && plaintext then re-encrypt" blocks (one per provider). routes/user.ts re-implemented the same claude→claude_api_key / encryptApiKey mapping independently. Adds to apiKeys.ts: - PROVIDER_KEY_COLUMNS / ProviderKeyColumn - the canonical list of encrypted columns - PROVIDER_KEY_COLUMN_BY_INPUT - maps frontend names to DB columns - buildPlaintextUpgrades() - opportunistic-upgrade helper used by userSettings.ts (replaces the three per-provider if-blocks with a loop) - encryptApiKeyInputs() - PATCH-path helper used by routes/user.ts (replaces the three per-provider if/encryptApiKey calls) --- backend/src/lib/apiKeys.ts | 49 +++++++++++++++++++++++++++++++++ backend/src/lib/userSettings.ts | 22 ++++----------- backend/src/routes/user.ts | 17 ++---------- 3 files changed, 57 insertions(+), 31 deletions(-) diff --git a/backend/src/lib/apiKeys.ts b/backend/src/lib/apiKeys.ts index 7427eb2b..802e62f2 100644 --- a/backend/src/lib/apiKeys.ts +++ b/backend/src/lib/apiKeys.ts @@ -76,3 +76,52 @@ export function decryptApiKey(value: string | null | undefined): string | null { export function hasStoredApiKey(value: string | null | undefined): boolean { return typeof value === "string" && value.trim().length > 0; } + +/** The set of LLM provider keys we encrypt at rest. */ +export const PROVIDER_KEY_COLUMNS = [ + "claude_api_key", + "gemini_api_key", + "openrouter_api_key", +] as const; +export type ProviderKeyColumn = (typeof PROVIDER_KEY_COLUMNS)[number]; + +/** Maps the API names the frontend sends to the column names we store. */ +export const PROVIDER_KEY_COLUMN_BY_INPUT: Record = { + claude: "claude_api_key", + gemini: "gemini_api_key", + openrouter: "openrouter_api_key", +}; + +/** + * For each provider key column that is stored as plaintext, returns a record + * of `{ column: encryptedValue }` suitable for a database UPDATE (opportunistic + * upgrade path). Returns an empty object when no upgrades are needed. + */ +export function buildPlaintextUpgrades( + row: Partial>, +): Partial> { + const updates: Partial> = {}; + for (const col of PROVIDER_KEY_COLUMNS) { + const stored = row[col] ?? null; + if (stored && !isEncryptedApiKey(stored)) { + updates[col] = encryptApiKey(stored)!; + } + } + return updates; +} + +/** + * Converts a frontend `api_keys` payload (e.g. `{ claude: "sk-…" }`) into a + * record of encrypted column values ready to be merged into a database UPDATE. + */ +export function encryptApiKeyInputs( + apiKeys: Partial>, +): Partial> { + const updates: Partial> = {}; + for (const [input, col] of Object.entries(PROVIDER_KEY_COLUMN_BY_INPUT)) { + if (input in apiKeys) { + updates[col] = encryptApiKey(apiKeys[input]); + } + } + return updates; +} diff --git a/backend/src/lib/userSettings.ts b/backend/src/lib/userSettings.ts index 6f953a80..ce07897c 100644 --- a/backend/src/lib/userSettings.ts +++ b/backend/src/lib/userSettings.ts @@ -1,8 +1,7 @@ import { createServerSupabase } from "./supabase"; import { decryptApiKey, - encryptApiKey, - isEncryptedApiKey, + buildPlaintextUpgrades, } from "./apiKeys"; import { resolveModel, @@ -80,20 +79,11 @@ async function decryptAndUpgradeApiKeys( openrouter: decryptApiKey(storedOpenrouter), }; - const updates: Record = {}; - if (apiKeys.claude && storedClaude && !isEncryptedApiKey(storedClaude)) { - updates.claude_api_key = encryptApiKey(apiKeys.claude)!; - } - if (apiKeys.gemini && storedGemini && !isEncryptedApiKey(storedGemini)) { - updates.gemini_api_key = encryptApiKey(apiKeys.gemini)!; - } - if ( - apiKeys.openrouter && - storedOpenrouter && - !isEncryptedApiKey(storedOpenrouter) - ) { - updates.openrouter_api_key = encryptApiKey(apiKeys.openrouter)!; - } + const updates = buildPlaintextUpgrades({ + claude_api_key: storedClaude, + gemini_api_key: storedGemini, + openrouter_api_key: storedOpenrouter, + }); if (Object.keys(updates).length > 0) { await client .from("user_profiles") diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts index a07886b9..0d30c472 100644 --- a/backend/src/routes/user.ts +++ b/backend/src/routes/user.ts @@ -1,7 +1,7 @@ import { Router } from "express"; import { requireAuth } from "../middleware/auth"; import { createServerSupabase } from "../lib/supabase"; -import { encryptApiKey, hasStoredApiKey } from "../lib/apiKeys"; +import { encryptApiKeyInputs, hasStoredApiKey } from "../lib/apiKeys"; import { resolveModel, DEFAULT_TABULAR_MODEL } from "../lib/llm"; export const userRouter = Router(); @@ -96,20 +96,7 @@ userRouter.patch("/profile", requireAuth, async (req, res) => { ); } if (req.body.api_keys && typeof req.body.api_keys === "object") { - const apiKeys = req.body.api_keys as { - claude?: string | null; - gemini?: string | null; - openrouter?: string | null; - }; - if ("claude" in apiKeys) { - updates.claude_api_key = encryptApiKey(apiKeys.claude); - } - if ("gemini" in apiKeys) { - updates.gemini_api_key = encryptApiKey(apiKeys.gemini); - } - if ("openrouter" in apiKeys) { - updates.openrouter_api_key = encryptApiKey(apiKeys.openrouter); - } + Object.assign(updates, encryptApiKeyInputs(req.body.api_keys as Record)); } if (req.body.increment_message_credits === true) { const { data: current } = await db From 693f133fba54f5855d01cccfd5a86181496d86ec Mon Sep 17 00:00:00 2001 From: Lef Date: Fri, 8 May 2026 15:31:40 +0200 Subject: [PATCH 25/26] refactor(backend): use native Buffer base64url everywhere Three places implemented base64url encoding independently: downloadTokens.ts and mcp/oauth.ts each had manual b64urlEncode/b64urlDecode helpers using .replace() chains to swap +/- and /_ and pad/strip =, while apiKeys.ts already used the native base64url encoding but still wrapped it in thin b64url/fromB64url functions. Node 16+ supports Buffer.toString("base64url") and Buffer.from(s, "base64url") natively, so all three manual variants are deleted and every call site is inlined to the built-in codec directly. --- backend/src/lib/apiKeys.ts | 16 ++++------------ backend/src/lib/downloadTokens.ts | 22 ++++------------------ backend/src/lib/mcp/oauth.ts | 22 ++++------------------ 3 files changed, 12 insertions(+), 48 deletions(-) diff --git a/backend/src/lib/apiKeys.ts b/backend/src/lib/apiKeys.ts index 802e62f2..02dc6c12 100644 --- a/backend/src/lib/apiKeys.ts +++ b/backend/src/lib/apiKeys.ts @@ -16,14 +16,6 @@ function keyFromSecret(secret: string): Buffer { return crypto.createHash("sha256").update(secret, "utf8").digest(); } -function b64url(buf: Buffer): string { - return buf.toString("base64url"); -} - -function fromB64url(value: string): Buffer { - return Buffer.from(value, "base64url"); -} - export function isEncryptedApiKey(value: string | null | undefined): boolean { return typeof value === "string" && value.startsWith(ENCRYPTED_PREFIX); } @@ -43,7 +35,7 @@ export function encryptApiKey(value: string | null | undefined): string | null { cipher.final(), ]); const tag = cipher.getAuthTag(); - return `${ENCRYPTED_PREFIX}${b64url(iv)}.${b64url(tag)}.${b64url(ciphertext)}`; + return `${ENCRYPTED_PREFIX}${iv.toString("base64url")}.${tag.toString("base64url")}.${ciphertext.toString("base64url")}`; } export function decryptApiKey(value: string | null | undefined): string | null { @@ -63,11 +55,11 @@ export function decryptApiKey(value: string | null | undefined): string | null { const decipher = crypto.createDecipheriv( "aes-256-gcm", keyFromSecret(getEncryptionSecret()), - fromB64url(ivRaw), + Buffer.from(ivRaw, "base64url"), ); - decipher.setAuthTag(fromB64url(tagRaw)); + decipher.setAuthTag(Buffer.from(tagRaw, "base64url")); const plaintext = Buffer.concat([ - decipher.update(fromB64url(ciphertextRaw)), + decipher.update(Buffer.from(ciphertextRaw, "base64url")), decipher.final(), ]); return plaintext.toString("utf8"); diff --git a/backend/src/lib/downloadTokens.ts b/backend/src/lib/downloadTokens.ts index e5d90201..1804d2f7 100644 --- a/backend/src/lib/downloadTokens.ts +++ b/backend/src/lib/downloadTokens.ts @@ -26,20 +26,6 @@ export function getSigningSecret(): string { return secret.trim(); } -function b64urlEncode(buf: Buffer): string { - return buf - .toString("base64") - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/g, ""); -} - -function b64urlDecode(s: string): Buffer { - let t = s.replace(/-/g, "+").replace(/_/g, "/"); - while (t.length % 4) t += "="; - return Buffer.from(t, "base64"); -} - function timingSafeEqStr(a: string, b: string): boolean { if (a.length !== b.length) return false; return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); @@ -47,12 +33,12 @@ function timingSafeEqStr(a: string, b: string): boolean { export function signDownload(path: string, filename: string): string { const payload = JSON.stringify({ p: path, f: filename }); - const enc = b64urlEncode(Buffer.from(payload, "utf8")); + const enc = Buffer.from(payload, "utf8").toString("base64url"); const sig = crypto .createHmac("sha256", getSigningSecret()) .update(enc) .digest(); - return `${enc}.${b64urlEncode(sig)}`; + return `${enc}.${sig.toString("base64url")}`; } export function verifyDownload( @@ -65,9 +51,9 @@ export function verifyDownload( .createHmac("sha256", getSigningSecret()) .update(enc) .digest(); - if (!timingSafeEqStr(sigEnc, b64urlEncode(expected))) return null; + if (!timingSafeEqStr(sigEnc, expected.toString("base64url"))) return null; try { - const parsed = JSON.parse(b64urlDecode(enc).toString("utf8")) as { + const parsed = JSON.parse(Buffer.from(enc, "base64url").toString("utf8")) as { p: string; f: string; }; diff --git a/backend/src/lib/mcp/oauth.ts b/backend/src/lib/mcp/oauth.ts index 05cfab62..528bc2db 100644 --- a/backend/src/lib/mcp/oauth.ts +++ b/backend/src/lib/mcp/oauth.ts @@ -40,20 +40,6 @@ export function oauthCallbackUrl(): string { // tokens and would already have to be rotated on compromise. // --------------------------------------------------------------------------- -function b64url(buf: Buffer): string { - return buf - .toString("base64") - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/g, ""); -} - -function b64urlDecode(s: string): Buffer { - let t = s.replace(/-/g, "+").replace(/_/g, "/"); - while (t.length % 4) t += "="; - return Buffer.from(t, "base64"); -} - export function signOAuthState(payload: { user_id: string; server_id: string; @@ -62,9 +48,9 @@ export function signOAuthState(payload: { ...payload, exp: Math.floor(Date.now() / 1000) + STATE_TTL_SECONDS, }; - const enc = b64url(Buffer.from(JSON.stringify(body), "utf8")); + const enc = Buffer.from(JSON.stringify(body), "utf8").toString("base64url"); const sig = crypto.createHmac("sha256", getSigningSecret()).update(enc).digest(); - return `${enc}.${b64url(sig)}`; + return `${enc}.${sig.toString("base64url")}`; } export function verifyOAuthState( @@ -77,7 +63,7 @@ export function verifyOAuthState( .createHmac("sha256", getSigningSecret()) .update(enc) .digest(); - const expectedEnc = b64url(expected); + const expectedEnc = expected.toString("base64url"); if (sigEnc.length !== expectedEnc.length) return null; if ( !crypto.timingSafeEqual(Buffer.from(sigEnc), Buffer.from(expectedEnc)) @@ -85,7 +71,7 @@ export function verifyOAuthState( return null; } try { - const body = JSON.parse(b64urlDecode(enc).toString("utf8")) as { + const body = JSON.parse(Buffer.from(enc, "base64url").toString("utf8")) as { user_id: string; server_id: string; exp: number; From c4ff092fe431e755dd9ea8ebcc23c8362c951faa Mon Sep 17 00:00:00 2001 From: Lef Date: Fri, 8 May 2026 15:39:58 +0200 Subject: [PATCH 26/26] feat(mcp): encrypt headers + oauth_tokens at rest with lazy upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Threat model: a database compromise or stolen backup of user_mcp_servers should not yield usable bearer tokens or refresh tokens for third-party connectors. Migration 003 explicitly deferred encryption ("Per-row encryption is intentionally deferred to a separate hardening PR"); this is that PR. Storage format: serialize the JSON value, encrypt the resulting string with the existing AES-256-GCM "enc:v1:" envelope from apiKeys.ts, and store the ciphertext as a JSON-string scalar in the existing jsonb column (a JSON string is itself valid jsonb, so no schema change is needed). Encrypting the whole blob keeps the format trivial, avoids leaking shape ("this row has a refresh_token"), and minimizes cipher operations vs per-leaf encryption. On read we sniff typeof === "string" && startsWith("enc:v1:") to distinguish from legacy plaintext objects. New helpers encryptJsonBlob / decryptJsonBlob / needsJsonBlobUpgrade live alongside the existing per-string helpers in apiKeys.ts and reuse them underneath. Call sites updated: DbOAuthProvider.tokens() / saveTokens() encrypt+decrypt oauth_tokens; saveCodeVerifier() / codeVerifier() encrypt the PKCE verifier (using the per-string helper since it's a single text column); mcpServers.ts POST/PATCH/test encrypt+decrypt headers; loadEnabledMcpServersForUser decrypts each row in place and fires off a best-effort UPDATE to upgrade legacy plaintext rows in the background, mirroring the lazy-upgrade pattern PR #42 introduced for LLM provider keys (commit 701535b). The upgrade write is fire-and-forget so a failing-forever encryption write cannot block the chat hot path on every turn, but errors are logged so they can be detected. oauth_metadata stays plaintext — it's discovery + DCR data, not secret. If USER_API_KEYS_ENCRYPTION_KEY is unset, encryptApiKey throws and the write fails closed, which is the correct behavior. --- backend/src/lib/apiKeys.ts | 60 ++++++++++++++++ backend/src/lib/mcp/oauth.ts | 25 +++++-- backend/src/lib/mcp/servers.ts | 62 ++++++++++++++++ backend/src/routes/mcpServers.ts | 30 ++++++-- backend/test/apiKeys.test.ts | 119 +++++++++++++++++++++++++++++++ 5 files changed, 284 insertions(+), 12 deletions(-) diff --git a/backend/src/lib/apiKeys.ts b/backend/src/lib/apiKeys.ts index 02dc6c12..1adff591 100644 --- a/backend/src/lib/apiKeys.ts +++ b/backend/src/lib/apiKeys.ts @@ -117,3 +117,63 @@ export function encryptApiKeyInputs( } return updates; } + +// --------------------------------------------------------------------------- +// JSON-blob helpers — used for MCP credentials (headers + oauth_tokens). +// +// Storage format: we JSON-serialize the value, encrypt the resulting string +// with the same AES-256-GCM envelope as the per-key helpers above, and write +// the ciphertext string into the jsonb column. A JSON string is itself valid +// jsonb, so this round-trips cleanly without needing a column type change. +// On read we sniff `typeof value === "string" && startsWith("enc:v1:")` to +// distinguish encrypted blobs from legacy plaintext objects. +// +// Encrypting the whole blob (rather than each leaf) keeps the format simple, +// minimizes cipher operations, and avoids leaking the shape (e.g. "this row +// has a refresh_token" vs "only an access_token") to anyone who can read the +// table. +// --------------------------------------------------------------------------- + +/** + * Encrypts an arbitrary JSON-serializable value to an `enc:v1:` ciphertext + * string suitable for storing in a jsonb column. Returns null for null/ + * undefined/empty inputs so callers can pass through "no value" cleanly. + */ +export function encryptJsonBlob(value: unknown): string | null { + if (value === null || value === undefined) return null; + const serialized = JSON.stringify(value); + if (!serialized) return null; + return encryptApiKey(serialized); +} + +/** + * Reverse of {@link encryptJsonBlob}. Accepts an encrypted ciphertext string, + * a legacy plaintext object/array (returned as-is), or null/undefined. Throws + * only if the value is an `enc:v1:` envelope but the ciphertext is malformed + * or the encryption key is wrong. + */ +export function decryptJsonBlob( + value: unknown, +): T | null { + if (value === null || value === undefined) return null; + if (typeof value === "string" && isEncryptedApiKey(value)) { + const plaintext = decryptApiKey(value); + if (plaintext === null) return null; + return JSON.parse(plaintext) as T; + } + // Legacy plaintext path: the column was written before encryption was + // enabled, so the jsonb already holds the structured value directly. + return value as T; +} + +/** + * True if the given jsonb-shaped value looks like a legacy plaintext blob + * that should be opportunistically re-encrypted. Strings that already carry + * the `enc:v1:` envelope are skipped; null/undefined are skipped; everything + * else (objects, arrays, plain strings without the envelope) is a candidate. + */ +export function needsJsonBlobUpgrade(value: unknown): boolean { + if (value === null || value === undefined) return false; + if (typeof value === "string" && isEncryptedApiKey(value)) return false; + return true; +} diff --git a/backend/src/lib/mcp/oauth.ts b/backend/src/lib/mcp/oauth.ts index 528bc2db..4177ca81 100644 --- a/backend/src/lib/mcp/oauth.ts +++ b/backend/src/lib/mcp/oauth.ts @@ -15,6 +15,12 @@ import type { OAuthTokens, } from "@modelcontextprotocol/sdk/shared/auth.js"; import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; +import { + decryptApiKey, + decryptJsonBlob, + encryptApiKey, + encryptJsonBlob, +} from "../apiKeys"; import { getSigningSecret } from "../downloadTokens"; import type { createServerSupabase } from "../supabase"; @@ -170,7 +176,10 @@ export class DbOAuthProvider implements OAuthClientProvider { .select("oauth_tokens") .eq("id", this.serverId) .single(); - const t = (data?.oauth_tokens ?? null) as OAuthTokens | null; + // oauth_tokens may be either an `enc:v1:` ciphertext string or, for + // rows written before encryption-at-rest landed, the raw token jsonb. + // decryptJsonBlob returns plaintext in both cases. + const t = decryptJsonBlob(data?.oauth_tokens); this.tokensCache = t; return t ?? undefined; } @@ -183,7 +192,7 @@ export class DbOAuthProvider implements OAuthClientProvider { await this.db .from("user_mcp_servers") .update({ - oauth_tokens: tokens, + oauth_tokens: encryptJsonBlob(tokens), oauth_code_verifier: null, last_error: null, }) @@ -207,7 +216,7 @@ export class DbOAuthProvider implements OAuthClientProvider { this.codeVerifierCache = codeVerifier; await this.db .from("user_mcp_servers") - .update({ oauth_code_verifier: codeVerifier }) + .update({ oauth_code_verifier: encryptApiKey(codeVerifier) }) .eq("id", this.serverId); } @@ -221,8 +230,14 @@ export class DbOAuthProvider implements OAuthClientProvider { if (!data?.oauth_code_verifier) { throw new Error("Missing PKCE verifier — start the flow again"); } - this.codeVerifierCache = data.oauth_code_verifier; - return data.oauth_code_verifier; + // decryptApiKey passes legacy plaintext through unchanged, so a row + // written before encryption-at-rest landed still works mid-flow. + const plaintext = decryptApiKey(data.oauth_code_verifier); + if (!plaintext) { + throw new Error("Missing PKCE verifier — start the flow again"); + } + this.codeVerifierCache = plaintext; + return plaintext; } async invalidateCredentials( diff --git a/backend/src/lib/mcp/servers.ts b/backend/src/lib/mcp/servers.ts index 5b36a0d4..3c47a88b 100644 --- a/backend/src/lib/mcp/servers.ts +++ b/backend/src/lib/mcp/servers.ts @@ -7,12 +7,52 @@ // the dispatcher in chatTools can route calls back to the right server. import { createHash } from "crypto"; +import { + decryptJsonBlob, + encryptJsonBlob, + needsJsonBlobUpgrade, +} from "../apiKeys"; import type { OpenAIToolSchema } from "../llm/types"; import type { createServerSupabase } from "../supabase"; import { McpHttpClient } from "./client"; import { DbOAuthProvider, ReauthRequiredError } from "./oauth"; import type { LoadedMcpServer, McpServerRow } from "./types"; +/** + * Decodes the credential-bearing jsonb columns on a freshly-fetched row, + * mutating the row in place to expose plaintext to the rest of the loader. + * Returns a partial UPDATE patch for any column that was stored as legacy + * plaintext and should be re-written encrypted; the caller fires that off + * best-effort so the hot path isn't blocked on the upgrade write. + * + * `oauth_code_verifier` is a per-text-column secret read on demand by + * DbOAuthProvider.codeVerifier(); we don't touch it here. + */ +export function decryptMcpRowCredentials( + row: McpServerRow, +): Partial> { + const upgrades: Partial> = {}; + + const rawHeaders = row.headers as unknown; + const decryptedHeaders = + decryptJsonBlob>(rawHeaders) ?? {}; + row.headers = decryptedHeaders; + if (needsJsonBlobUpgrade(rawHeaders)) { + const enc = encryptJsonBlob(decryptedHeaders); + if (enc) upgrades.headers = enc; + } + + const rawTokens = row.oauth_tokens as unknown; + const decryptedTokens = decryptJsonBlob>(rawTokens); + row.oauth_tokens = decryptedTokens; + if (needsJsonBlobUpgrade(rawTokens) && decryptedTokens) { + const enc = encryptJsonBlob(decryptedTokens); + if (enc) upgrades.oauth_tokens = enc; + } + + return upgrades; +} + const TOOL_NAME_MAX = 64; const TOOL_PREFIX = "mcp__"; @@ -28,6 +68,28 @@ export async function loadEnabledMcpServersForUser( if (error || !data || data.length === 0) return []; const rows = data as McpServerRow[]; + + // Decrypt credential columns in place + opportunistically rewrite any + // legacy plaintext rows in the background. We don't await the upgrade + // so a failing-forever encryption write can't block tool loading on + // every chat turn — but we do log it so callers can detect it. + for (const row of rows) { + const upgrades = decryptMcpRowCredentials(row); + if (Object.keys(upgrades).length > 0) { + void db + .from("user_mcp_servers") + .update(upgrades) + .eq("id", row.id) + .then(({ error: upgErr }) => { + if (upgErr) { + console.warn( + `[mcp] failed to encrypt-at-rest upgrade row ${row.id}: ${upgErr.message}`, + ); + } + }); + } + } + const results = await Promise.allSettled( rows.map((row) => loadOne(row, userId, db)), ); diff --git a/backend/src/routes/mcpServers.ts b/backend/src/routes/mcpServers.ts index 0cb1df86..f50aede8 100644 --- a/backend/src/routes/mcpServers.ts +++ b/backend/src/routes/mcpServers.ts @@ -10,6 +10,7 @@ import { requireAuth } from "../middleware/auth"; import { createServerSupabase } from "../lib/supabase"; import { McpHttpClient } from "../lib/mcp/client"; import { DbOAuthProvider } from "../lib/mcp/oauth"; +import { decryptJsonBlob, encryptJsonBlob } from "../lib/apiKeys"; export const mcpServersRouter = Router(); @@ -147,17 +148,23 @@ function publicShape>(row: T) { oauth_code_verifier: _cv, ...rest } = row as T & { - headers?: Record; + headers?: unknown; oauth_metadata?: unknown; oauth_tokens?: unknown; oauth_code_verifier?: unknown; }; + // Headers may be either an `enc:v1:` ciphertext string or, for legacy + // rows, the raw plaintext jsonb object — decryptJsonBlob normalizes both + // to a plain object so we can read header names. Token presence is a + // boolean, so we don't even bother decrypting it; non-null is enough. + const decryptedHeaders = + decryptJsonBlob>(headers) ?? {}; return { ...rest, - header_keys: headers ? Object.keys(headers) : [], + header_keys: Object.keys(decryptedHeaders), // Boolean only — never round-trip the actual access token to the // browser, even to the row's owner. - oauth_authorized: !!tokens, + oauth_authorized: tokens !== null && tokens !== undefined, }; } @@ -206,6 +213,11 @@ mcpServersRouter.post("/", requireAuth, async (req, res) => { const enabled = body.enabled === false ? false : true; const db = createServerSupabase(); + // headers is encrypted-at-rest as an `enc:v1:` string in the jsonb column. + // OAuth-mode rows have no static headers, so we store an empty (encrypted) + // object rather than `null` for shape consistency on read. + const headersToStore = + auth_type === "oauth" ? {} : headersOk.value; const { data, error } = await db .from("user_mcp_servers") .insert({ @@ -213,7 +225,7 @@ mcpServersRouter.post("/", requireAuth, async (req, res) => { slug, name, url, - headers: auth_type === "oauth" ? {} : headersOk.value, + headers: encryptJsonBlob(headersToStore) ?? {}, enabled, auth_type, }) @@ -253,12 +265,12 @@ mcpServersRouter.patch("/:id", requireAuth, async (req, res) => { update.oauth_tokens = null; update.oauth_metadata = null; update.oauth_code_verifier = null; - update.headers = {}; + update.headers = encryptJsonBlob({}) ?? {}; } if (body.headers !== undefined) { const headersOk = validateHeaders(body.headers); if (!headersOk.ok) return void res.status(400).json({ detail: headersOk.error }); - update.headers = headersOk.value; + update.headers = encryptJsonBlob(headersOk.value) ?? {}; } if (body.enabled !== undefined) { update.enabled = body.enabled === true; @@ -318,9 +330,13 @@ mcpServersRouter.post("/:id/test", requireAuth, async (req, res) => { row.auth_type === "oauth" ? new DbOAuthProvider(db, id, userId, "use") : undefined; + // headers may be encrypted-at-rest; decryptJsonBlob handles both the + // ciphertext and legacy plaintext-jsonb cases transparently. + const decryptedHeaders = + decryptJsonBlob>(row.headers) ?? {}; const client = new McpHttpClient( row.url, - (row.headers ?? {}) as Record, + decryptedHeaders, provider, ); try { diff --git a/backend/test/apiKeys.test.ts b/backend/test/apiKeys.test.ts index db7af441..7895e76d 100644 --- a/backend/test/apiKeys.test.ts +++ b/backend/test/apiKeys.test.ts @@ -2,9 +2,12 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { decryptApiKey, + decryptJsonBlob, encryptApiKey, + encryptJsonBlob, hasStoredApiKey, isEncryptedApiKey, + needsJsonBlobUpgrade, } from "../src/lib/apiKeys"; describe("user API key encryption", () => { @@ -42,3 +45,119 @@ describe("user API key encryption", () => { } }); }); + +describe("MCP credential JSON blob encryption", () => { + it("encrypts and decrypts a JSON object roundtrip", () => { + const previous = process.env.USER_API_KEYS_ENCRYPTION_KEY; + process.env.USER_API_KEYS_ENCRYPTION_KEY = "test-encryption-secret"; + try { + const value = { Authorization: "Bearer sk-secret-token" }; + const encrypted = encryptJsonBlob(value); + assert.ok(encrypted, "expected ciphertext to be returned"); + assert.ok(isEncryptedApiKey(encrypted)); + assert.notEqual(encrypted, JSON.stringify(value)); + assert.deepEqual(decryptJsonBlob(encrypted), value); + } finally { + if (previous === undefined) { + delete process.env.USER_API_KEYS_ENCRYPTION_KEY; + } else { + process.env.USER_API_KEYS_ENCRYPTION_KEY = previous; + } + } + }); + + it("encrypts oauth-token-shaped blobs without leaking shape", () => { + const previous = process.env.USER_API_KEYS_ENCRYPTION_KEY; + process.env.USER_API_KEYS_ENCRYPTION_KEY = "test-encryption-secret"; + try { + const tokens = { + access_token: "at-123", + refresh_token: "rt-456", + token_type: "Bearer", + expires_in: 3600, + }; + const encrypted = encryptJsonBlob(tokens); + assert.ok(encrypted); + assert.equal(encrypted.includes("refresh_token"), false); + assert.equal(encrypted.includes("at-123"), false); + assert.deepEqual(decryptJsonBlob(encrypted), tokens); + } finally { + if (previous === undefined) { + delete process.env.USER_API_KEYS_ENCRYPTION_KEY; + } else { + process.env.USER_API_KEYS_ENCRYPTION_KEY = previous; + } + } + }); + + it("treats null and undefined as null on encrypt and decrypt", () => { + const previous = process.env.USER_API_KEYS_ENCRYPTION_KEY; + process.env.USER_API_KEYS_ENCRYPTION_KEY = "test-encryption-secret"; + try { + assert.equal(encryptJsonBlob(null), null); + assert.equal(encryptJsonBlob(undefined), null); + assert.equal(decryptJsonBlob(null), null); + assert.equal(decryptJsonBlob(undefined), null); + } finally { + if (previous === undefined) { + delete process.env.USER_API_KEYS_ENCRYPTION_KEY; + } else { + process.env.USER_API_KEYS_ENCRYPTION_KEY = previous; + } + } + }); + + it("passes legacy plaintext jsonb objects through unchanged on decrypt", () => { + const previous = process.env.USER_API_KEYS_ENCRYPTION_KEY; + process.env.USER_API_KEYS_ENCRYPTION_KEY = "test-encryption-secret"; + try { + const legacy = { Authorization: "Bearer legacy" }; + // Simulates a row written before encryption-at-rest landed: the + // jsonb column still holds the structured object directly. + assert.deepEqual(decryptJsonBlob(legacy), legacy); + assert.deepEqual(decryptJsonBlob([1, 2, 3]), [1, 2, 3]); + } finally { + if (previous === undefined) { + delete process.env.USER_API_KEYS_ENCRYPTION_KEY; + } else { + process.env.USER_API_KEYS_ENCRYPTION_KEY = previous; + } + } + }); + + it("flags legacy values as needing upgrade and skips null + already-encrypted", () => { + const previous = process.env.USER_API_KEYS_ENCRYPTION_KEY; + process.env.USER_API_KEYS_ENCRYPTION_KEY = "test-encryption-secret"; + try { + assert.equal(needsJsonBlobUpgrade(null), false); + assert.equal(needsJsonBlobUpgrade(undefined), false); + assert.equal( + needsJsonBlobUpgrade({ Authorization: "Bearer x" }), + true, + ); + const encrypted = encryptJsonBlob({ a: 1 })!; + assert.equal(needsJsonBlobUpgrade(encrypted), false); + } finally { + if (previous === undefined) { + delete process.env.USER_API_KEYS_ENCRYPTION_KEY; + } else { + process.env.USER_API_KEYS_ENCRYPTION_KEY = previous; + } + } + }); + + it("propagates the missing-secret throw on encrypt-time", () => { + const previous = process.env.USER_API_KEYS_ENCRYPTION_KEY; + delete process.env.USER_API_KEYS_ENCRYPTION_KEY; + try { + assert.throws( + () => encryptJsonBlob({ Authorization: "Bearer x" }), + { message: /USER_API_KEYS_ENCRYPTION_KEY/ }, + ); + } finally { + if (previous !== undefined) { + process.env.USER_API_KEYS_ENCRYPTION_KEY = previous; + } + } + }); +});