From 2c6d214108a23449a8f7f7f3b8f1d7eb9a4163b5 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 30 Sep 2026 20:03:54 +0100 Subject: [PATCH 01/19] feat(cloud): add Cloudflare D1 real-time sync backend and streaming watcher - Add Cloudflare Edge package (cloud/) with D1 multi-tenant SQLite schema - Implement dual-version MCP server (2026-07-28 stateless + 2024-11-05 SSE) - Add GitHub Device Flow auth with RFC 9728 discovery and Web Crypto JWTs - Add real-time byte-offset transcript tailer watching Antigravity and Claude Code - Add repo-anchored path normalizer for cross-device relative paths - Register xtctx login and xtctx watch CLI commands --- cloud/README.md | 94 +++++++++++++++++++ cloud/package.json | 23 +++++ cloud/schema.sql | 79 ++++++++++++++++ cloud/src/auth.ts | 121 ++++++++++++++++++++++++ cloud/src/db.ts | 170 +++++++++++++++++++++++++++++++++ cloud/src/index.ts | 208 +++++++++++++++++++++++++++++++++++++++++ cloud/src/mcp.ts | 191 +++++++++++++++++++++++++++++++++++++ cloud/src/types.ts | 67 +++++++++++++ cloud/tsconfig.json | 14 +++ cloud/wrangler.toml | 26 ++++++ src/cli/index.ts | 21 +++++ src/cli/login.ts | 85 +++++++++++++++++ src/cli/watch.ts | 75 +++++++++++++++ src/sync/client.ts | 85 +++++++++++++++++ src/sync/normalizer.ts | 63 +++++++++++++ src/sync/watcher.ts | 134 ++++++++++++++++++++++++++ 16 files changed, 1456 insertions(+) create mode 100644 cloud/README.md create mode 100644 cloud/package.json create mode 100644 cloud/schema.sql create mode 100644 cloud/src/auth.ts create mode 100644 cloud/src/db.ts create mode 100644 cloud/src/index.ts create mode 100644 cloud/src/mcp.ts create mode 100644 cloud/src/types.ts create mode 100644 cloud/tsconfig.json create mode 100644 cloud/wrangler.toml create mode 100644 src/cli/login.ts create mode 100644 src/cli/watch.ts create mode 100644 src/sync/client.ts create mode 100644 src/sync/normalizer.ts create mode 100644 src/sync/watcher.ts diff --git a/cloud/README.md b/cloud/README.md new file mode 100644 index 0000000..dbb7b0c --- /dev/null +++ b/cloud/README.md @@ -0,0 +1,94 @@ +# xtctx-cloud + +Cloudflare Edge continuous memory store and Model Context Protocol (MCP) server for `xtctx`. + +## Features +- **Dual-Version MCP Server**: Supports both modern stateless **2026-07-28** specification (`POST /mcp`) and baseline **2024-11-05** SSE streaming (`GET /sse` + `POST /message`) for full compatibility across Cursor, Claude Desktop, Claude Code, and Antigravity. +- **Continuous Memory Store**: Powered by Cloudflare D1 (serverless SQLite) with millisecond multi-device ingestion. +- **GitHub Device Flow Auth**: Friction-free OAuth 2.1 authentication without needing local redirect ports. +- **RFC 9728 Discovery**: Exposes `/.well-known/oauth-protected-resource` for automated MCP client authorization. + +--- + +## Deployment Quickstart + +### 1. Install Dependencies +```bash +npm install +``` + +### 2. Create Cloudflare D1 Database +```bash +npx wrangler d1 create xtctx-db +``` +Copy the generated `database_id` into `cloud/wrangler.toml`: +```toml +[[d1_databases]] +binding = "DB" +database_name = "xtctx-db" +database_id = "your-database-id-here" +``` + +### 3. Run Schema Migrations +For local testing: +```bash +npm run d1:migrate:local +``` +For production: +```bash +npm run d1:migrate:remote +``` + +### 4. Configure GitHub OAuth App +1. Go to [GitHub Developer Settings](https://github.com/settings/developers) -> **OAuth Apps** -> **New OAuth App**. +2. Set Application Name to `xtctx`. +3. Set Homepage URL to `https://xtctx.com`. +4. Enable **Device Flow** checkbox in the OAuth App settings. +5. Put your `Client ID` in `wrangler.toml` under `GITHUB_CLIENT_ID`. + +### 5. Set JWT Secret +```bash +npx wrangler secret put JWT_SECRET +``` +*(Enter any secure random string)* + +### 6. Deploy to Cloudflare +```bash +npm run deploy +``` + +--- + +## Custom Domains (xtctx.com) +In your Cloudflare dashboard (or `wrangler.toml`), map: +- `mcp.xtctx.com` -> `xtctx-cloud` Worker (used by Cursor, Claude, Antigravity) +- `sync.xtctx.com` -> `xtctx-cloud` Worker (used by `xtctx watch` and `xtctx login`) + +--- + +## Connecting Clients + +### Authenticate CLI +```bash +xtctx login +``` + +### Start Real-Time Watcher +```bash +xtctx watch +``` + +### Configure Cursor (`.cursor/mcp.json`) or Claude Desktop +```json +{ + "mcpServers": { + "xtctx-cloud": { + "url": "https://mcp.xtctx.com/sse", + "headers": { + "Authorization": "Bearer YOUR_JWT_TOKEN" + } + } + } +} +``` +*(Note: If using modern clients supporting MCP 2026-07-28, you can also connect directly to `https://mcp.xtctx.com/mcp`)* diff --git a/cloud/package.json b/cloud/package.json new file mode 100644 index 0000000..d25bd53 --- /dev/null +++ b/cloud/package.json @@ -0,0 +1,23 @@ +{ + "name": "xtctx-cloud", + "version": "0.1.0", + "description": "Cloudflare Edge MCP server and real-time continuous memory store for xtctx", + "type": "module", + "private": true, + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "d1:create": "wrangler d1 create xtctx-db", + "d1:migrate:local": "wrangler d1 execute xtctx-db --local --file=./schema.sql", + "d1:migrate:remote": "wrangler d1 execute xtctx-db --remote --file=./schema.sql", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250224.0", + "typescript": "^5.8.0", + "wrangler": "^3.111.0" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.6.0" + } +} diff --git a/cloud/schema.sql b/cloud/schema.sql new file mode 100644 index 0000000..f3f2077 --- /dev/null +++ b/cloud/schema.sql @@ -0,0 +1,79 @@ +-- xtctx Cloudflare D1 Multi-Tenant Memory Schema + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, -- e.g. "github:123456" + username TEXT NOT NULL, -- e.g. "fstubner" + display_name TEXT, + email TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS devices ( + id TEXT PRIMARY KEY, -- e.g. UUID or machine slug + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + device_name TEXT NOT NULL, -- e.g. "felix-desktop", "macbook-air" + hostname TEXT, + last_seen_at INTEGER NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_devices_user ON devices(user_id); + +CREATE TABLE IF NOT EXISTS sessions ( + session_ref TEXT PRIMARY KEY, -- e.g. "usr_123:claude-code:abc123" + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + device_id TEXT NOT NULL REFERENCES devices(id), + tool TEXT NOT NULL, -- "claude-code", "antigravity", "cursor", "copilot", "codex" + source_session_id TEXT NOT NULL, + repo_url TEXT NOT NULL, -- e.g. "github.com/fstubner/xtctx" + project_root TEXT NOT NULL, -- local root path on the origin device + git_branch TEXT, + git_commit TEXT, + started_at TEXT NOT NULL, + last_activity_at TEXT NOT NULL, + message_count INTEGER NOT NULL DEFAULT 0, + preview TEXT, + source_path TEXT, + status TEXT NOT NULL DEFAULT 'active', -- 'active', 'idle', 'closed' + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_sessions_user_repo + ON sessions(user_id, repo_url, last_activity_at DESC); +CREATE INDEX IF NOT EXISTS idx_sessions_user_activity + ON sessions(user_id, last_activity_at DESC); + +CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + session_ref TEXT NOT NULL REFERENCES sessions(session_ref) ON DELETE CASCADE, + tool TEXT NOT NULL, + source_session_id TEXT NOT NULL, + timestamp TEXT NOT NULL, + role TEXT NOT NULL, -- "user", "assistant", "system", "tool" + content TEXT NOT NULL, + message_index INTEGER NOT NULL, + content_hash TEXT NOT NULL, + metadata_json TEXT NOT NULL, -- JSON string with tool_calls, tokens, etc. + source_pointer TEXT, + indexed_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_messages_session_order + ON messages(session_ref, message_index ASC); + +CREATE TABLE IF NOT EXISTS retrieval_units ( + id TEXT PRIMARY KEY, + session_ref TEXT NOT NULL REFERENCES sessions(session_ref) ON DELETE CASCADE, + tool TEXT NOT NULL, + message_start_index INTEGER NOT NULL, + message_end_index INTEGER NOT NULL, + started_at TEXT NOT NULL, + ended_at TEXT NOT NULL, + content TEXT NOT NULL, + content_hash TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_retrieval_units_session + ON retrieval_units(session_ref, message_start_index, message_end_index); diff --git a/cloud/src/auth.ts b/cloud/src/auth.ts new file mode 100644 index 0000000..b55dbd9 --- /dev/null +++ b/cloud/src/auth.ts @@ -0,0 +1,121 @@ +import type { Env, AuthUser } from "./types.js"; + +const DEFAULT_JWT_SECRET = "xtctx-cloud-default-secret-change-in-production"; + +/** + * Mint an HMAC-SHA256 JWT using standard Web Crypto API. + */ +export async function createJwt(payload: Record, secretStr: string): Promise { + const encoder = new TextEncoder(); + const secretKey = await crypto.subtle.importKey( + "raw", + encoder.encode(secretStr), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + + const header = { alg: "HS256", typ: "JWT" }; + const encodedHeader = btoa(JSON.stringify(header)).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); + + const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 90; // 90 days validity + const fullPayload = { ...payload, exp, iat: Math.floor(Date.now() / 1000) }; + const encodedPayload = btoa(JSON.stringify(fullPayload)).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); + + const dataToSign = encoder.encode(`${encodedHeader}.${encodedPayload}`); + const signatureBuffer = await crypto.subtle.sign("HMAC", secretKey, dataToSign); + + const signatureBytes = new Uint8Array(signatureBuffer); + let binary = ""; + for (let i = 0; i < signatureBytes.length; i++) { + binary += String.fromCharCode(signatureBytes[i]); + } + const encodedSignature = btoa(binary).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); + + return `${encodedHeader}.${encodedPayload}.${encodedSignature}`; +} + +/** + * Verify an HMAC-SHA256 JWT using standard Web Crypto API. + */ +export async function verifyJwt(token: string, secretStr: string): Promise { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + + const [encodedHeader, encodedPayload, encodedSignature] = parts; + const encoder = new TextEncoder(); + + const secretKey = await crypto.subtle.importKey( + "raw", + encoder.encode(secretStr), + { name: "HMAC", hash: "SHA-256" }, + false, + ["verify"] + ); + + // Decode signature from base64url + let base64 = encodedSignature.replace(/-/g, "+").replace(/_/g, "/"); + while (base64.length % 4) base64 += "="; + const binary = atob(base64); + const signatureBytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + signatureBytes[i] = binary.charCodeAt(i); + } + + const dataToVerify = encoder.encode(`${encodedHeader}.${encodedPayload}`); + const isValid = await crypto.subtle.verify("HMAC", secretKey, signatureBytes, dataToVerify); + if (!isValid) return null; + + // Decode payload + let payloadBase64 = encodedPayload.replace(/-/g, "+").replace(/_/g, "/"); + while (payloadBase64.length % 4) payloadBase64 += "="; + const payload = JSON.parse(atob(payloadBase64)); + + if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) { + return null; // Expired + } + + return { + userId: payload.sub, + username: payload.username, + deviceId: payload.device_id, + }; + } catch { + return null; + } +} + +/** + * Extract and authenticate user from request Authorization header or query param. + */ +export async function authenticateRequest(request: Request, env: Env): Promise { + const authHeader = request.headers.get("Authorization"); + const secret = env.JWT_SECRET || DEFAULT_JWT_SECRET; + + if (authHeader && authHeader.startsWith("Bearer ")) { + const token = authHeader.slice(7).trim(); + return await verifyJwt(token, secret); + } + + // Also support token query param for SSE transports that cannot set custom headers + const url = new URL(request.url); + const tokenParam = url.searchParams.get("token"); + if (tokenParam) { + return await verifyJwt(tokenParam, secret); + } + + return null; +} + +/** + * Return RFC 9728 OAuth Protected Resource Metadata for native MCP clients. + */ +export function getProtectedResourceMetadata(baseUrl: string) { + return { + resource: baseUrl, + authorization_servers: ["https://github.com/login/oauth"], + bearer_methods_supported: ["header"], + scopes_supported: ["read:user", "user:email"], + }; +} diff --git a/cloud/src/db.ts b/cloud/src/db.ts new file mode 100644 index 0000000..830818b --- /dev/null +++ b/cloud/src/db.ts @@ -0,0 +1,170 @@ +import type { Env, AuthUser, StreamTurnDelta, SessionRecord, MessageRecord } from "./types.js"; + +/** + * Atomically ingest a stream turn delta from an agent device into Cloudflare D1. + */ +export async function ingestTurnDelta( + env: Env, + user: AuthUser, + delta: StreamTurnDelta +): Promise<{ success: boolean; sessionRef: string }> { + const sessionRef = `${user.userId}:${delta.tool}:${delta.sourceSessionId}`; + const now = new Date().toISOString(); + const nowTs = Date.now(); + + const statements: D1PreparedStatement[] = []; + + // 1. Ensure user and device records exist/updated + statements.push( + env.DB.prepare( + `INSERT INTO users (id, username, created_at, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET updated_at = excluded.updated_at` + ).bind(user.userId, user.username, nowTs, nowTs) + ); + + const deviceName = delta.deviceName || delta.deviceId; + statements.push( + env.DB.prepare( + `INSERT INTO devices (id, user_id, device_name, last_seen_at, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET last_seen_at = excluded.last_seen_at` + ).bind(delta.deviceId, user.userId, deviceName, nowTs, nowTs) + ); + + // 2. Upsert session + statements.push( + env.DB.prepare( + `INSERT INTO sessions ( + session_ref, user_id, device_id, tool, source_session_id, + repo_url, project_root, git_branch, git_commit, + started_at, last_activity_at, message_count, preview, source_path, status, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?) + ON CONFLICT(session_ref) DO UPDATE SET + last_activity_at = excluded.last_activity_at, + message_count = message_count + 1, + preview = COALESCE(excluded.preview, preview), + status = excluded.status, + updated_at = excluded.updated_at` + ).bind( + sessionRef, + user.userId, + delta.deviceId, + delta.tool, + delta.sourceSessionId, + delta.repoUrl, + delta.projectRoot, + delta.gitBranch || null, + delta.gitCommit || null, + delta.timestamp, + delta.timestamp, + delta.preview || (delta.content ? delta.content.slice(0, 160) : null), + delta.sourcePointer || null, + delta.status || "active", + now + ) + ); + + // 3. Insert message turn + const messageId = `${sessionRef}:${delta.messageIndex}:${delta.contentHash.slice(0, 8)}`; + statements.push( + env.DB.prepare( + `INSERT INTO messages ( + id, session_ref, tool, source_session_id, timestamp, role, + content, message_index, content_hash, metadata_json, source_pointer, indexed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO NOTHING` + ).bind( + messageId, + sessionRef, + delta.tool, + delta.sourceSessionId, + delta.timestamp, + delta.role, + delta.content, + delta.messageIndex, + delta.contentHash, + delta.metadataJson || "{}", + delta.sourcePointer || null, + now + ) + ); + + await env.DB.batch(statements); + + return { success: true, sessionRef }; +} + +/** + * Fetch recent sessions for a user, optionally filtered by git repo URL or branches. + */ +export async function getRecentSessions( + env: Env, + userId: string, + options: { + repoUrl?: string; + branchFilter?: string[]; + toolFilter?: string[]; + limit?: number; + } +): Promise { + const limit = Math.min(options.limit || 5, 25); + let query = `SELECT * FROM sessions WHERE user_id = ?`; + const bindings: unknown[] = [userId]; + + if (options.repoUrl) { + query += ` AND repo_url = ?`; + bindings.push(options.repoUrl); + } + + if (options.toolFilter && options.toolFilter.length > 0) { + const placeholders = options.toolFilter.map(() => "?").join(", "); + query += ` AND tool IN (${placeholders})`; + bindings.push(...options.toolFilter); + } + + if (options.branchFilter && options.branchFilter.length > 0) { + const placeholders = options.branchFilter.map(() => "?").join(", "); + query += ` AND git_branch IN (${placeholders})`; + bindings.push(...options.branchFilter); + } + + query += ` ORDER BY last_activity_at DESC LIMIT ?`; + bindings.push(limit); + + const result = await env.DB.prepare(query).bind(...bindings).all(); + return result.results || []; +} + +/** + * Fetch messages for a specific session. + */ +export async function getSessionMessages( + env: Env, + userId: string, + sessionRef: string, + options: { offset?: number; limit?: number } = {} +): Promise<{ session: SessionRecord | null; messages: MessageRecord[] }> { + const session = await env.DB.prepare( + `SELECT * FROM sessions WHERE session_ref = ? AND user_id = ?` + ).bind(sessionRef, userId).first(); + + if (!session) { + return { session: null, messages: [] }; + } + + const offset = options.offset || 0; + const limit = Math.min(options.limit || 50, 100); + + const messagesResult = await env.DB.prepare( + `SELECT * FROM messages + WHERE session_ref = ? + ORDER BY message_index ASC + LIMIT ? OFFSET ?` + ).bind(sessionRef, limit, offset).all(); + + return { + session, + messages: messagesResult.results || [], + }; +} diff --git a/cloud/src/index.ts b/cloud/src/index.ts new file mode 100644 index 0000000..f09053f --- /dev/null +++ b/cloud/src/index.ts @@ -0,0 +1,208 @@ +import type { Env } from "./types.js"; +import { authenticateRequest, getProtectedResourceMetadata, createJwt } from "./auth.js"; +import { ingestTurnDelta } from "./db.js"; +import { processJsonRpc } from "./mcp.js"; + +// Active SSE client streams mapped by sessionId +const sseStreams = new Map; user: any }>(); + +function corsHeaders(extra: Record = {}) { + return { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, Mcp-Session-Id", + ...extra, + }; +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (request.method === "OPTIONS") { + return new Response(null, { headers: corsHeaders() }); + } + + // 1. Health check & status + if (url.pathname === "/" || url.pathname === "/health") { + return new Response(JSON.stringify({ + status: "ok", + service: "xtctx-cloud", + version: "0.22.0", + mcp: { + supportedVersions: ["2026-07-28", "2024-11-05"], + endpoints: { + stateless: "/mcp", + sse: "/sse" + } + } + }, null, 2), { + headers: corsHeaders({ "Content-Type": "application/json" }) + }); + } + + // 2. OAuth Discovery (RFC 9728) for MCP Clients + if (url.pathname === "/.well-known/oauth-protected-resource") { + return new Response(JSON.stringify(getProtectedResourceMetadata(url.origin), null, 2), { + headers: corsHeaders({ "Content-Type": "application/json" }) + }); + } + + // 3. GitHub Device Authorization Flow + if (url.pathname === "/auth/device/code" && request.method === "POST") { + const clientId = env.GITHUB_CLIENT_ID; + const ghRes = await fetch("https://github.com/login/device/code", { + method: "POST", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: clientId, + scope: "read:user user:email", + }), + }); + const data = await ghRes.json(); + return new Response(JSON.stringify(data), { + headers: corsHeaders({ "Content-Type": "application/json" }) + }); + } + + if (url.pathname === "/auth/device/poll" && request.method === "POST") { + const body = await request.json() as { device_code: string; device_name?: string }; + const clientId = env.GITHUB_CLIENT_ID; + + const ghRes = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: clientId, + device_code: body.device_code, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + + const data = await ghRes.json() as { access_token?: string; error?: string }; + if (!data.access_token) { + return new Response(JSON.stringify(data), { + status: 400, + headers: corsHeaders({ "Content-Type": "application/json" }) + }); + } + + // Fetch user profile from GitHub + const userRes = await fetch("https://api.github.com/user", { + headers: { + "Authorization": `Bearer ${data.access_token}`, + "User-Agent": "xtctx-cloud", + }, + }); + const ghUser = await userRes.json() as { id: number; login: string; name?: string; email?: string }; + + // Mint xtctx JWT + const secret = env.JWT_SECRET || "xtctx-cloud-default-secret-change-in-production"; + const token = await createJwt({ + sub: `github:${ghUser.id}`, + username: ghUser.login, + device_id: body.device_name || "default", + }, secret); + + return new Response(JSON.stringify({ + token, + user: { + id: `github:${ghUser.id}`, + username: ghUser.login, + name: ghUser.name, + } + }), { + headers: corsHeaders({ "Content-Type": "application/json" }) + }); + } + + // 4. Authenticate all protected API & MCP routes + const user = await authenticateRequest(request, env); + if (!user) { + return new Response(JSON.stringify({ error: "Unauthorized: Invalid or missing token" }), { + status: 401, + headers: corsHeaders({ + "Content-Type": "application/json", + "WWW-Authenticate": 'Bearer realm="xtctx-cloud", error="invalid_token"' + }) + }); + } + + // 5. Real-Time Stream Ingestion (Called by local xtctx daemon) + if (url.pathname === "/api/stream" && request.method === "POST") { + const delta = await request.json() as any; + const result = await ingestTurnDelta(env, user, delta); + return new Response(JSON.stringify(result), { + headers: corsHeaders({ "Content-Type": "application/json" }) + }); + } + + // 6. Modern MCP Endpoint (Stateless, 2026-07-28 Spec) + if (url.pathname === "/mcp" && request.method === "POST") { + const body = await request.json() as Record; + const responsePayload = await processJsonRpc(env, user, body); + if (!responsePayload) { + return new Response(null, { status: 204, headers: corsHeaders() }); + } + return new Response(JSON.stringify(responsePayload), { + headers: corsHeaders({ "Content-Type": "application/json" }) + }); + } + + // 7. Baseline MCP Endpoint (SSE Stream, 2024-11-05 Spec) + if (url.pathname === "/sse" && request.method === "GET") { + const sessionId = crypto.randomUUID(); + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); + const encoder = new TextEncoder(); + + sseStreams.set(sessionId, { writer, user }); + + // Send endpoint event as required by baseline MCP SSE spec + const endpointUrl = `${url.origin}/message?sessionId=${sessionId}`; + void writer.write(encoder.encode(`event: endpoint\ndata: ${endpointUrl}\n\n`)); + + request.signal.addEventListener("abort", () => { + sseStreams.delete(sessionId); + void writer.close().catch(() => {}); + }); + + return new Response(readable, { + headers: corsHeaders({ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive" + }) + }); + } + + // 8. Baseline MCP Message POST Endpoint + if (url.pathname === "/message" && request.method === "POST") { + const sessionId = url.searchParams.get("sessionId"); + if (!sessionId || !sseStreams.has(sessionId)) { + return new Response("Session not found", { status: 404, headers: corsHeaders() }); + } + + const session = sseStreams.get(sessionId)!; + const body = await request.json() as Record; + const responsePayload = await processJsonRpc(env, session.user, body); + + if (responsePayload) { + const encoder = new TextEncoder(); + void session.writer.write(encoder.encode(`event: message\ndata: ${JSON.stringify(responsePayload)}\n\n`)); + } + + return new Response(JSON.stringify({ status: "accepted" }), { + headers: corsHeaders({ "Content-Type": "application/json" }) + }); + } + + return new Response("Not Found", { status: 404, headers: corsHeaders() }); + } +}; diff --git a/cloud/src/mcp.ts b/cloud/src/mcp.ts new file mode 100644 index 0000000..03a689d --- /dev/null +++ b/cloud/src/mcp.ts @@ -0,0 +1,191 @@ +import type { Env, AuthUser } from "./types.js"; +import { getRecentSessions, getSessionMessages } from "./db.js"; + +export const MCP_TOOLS = [ + { + name: "xtctx_recent_sessions", + description: "List recent transcript sessions across all your local devices for this project. Call this when you need cross-device or cross-tool handoff context.", + inputSchema: { + type: "object", + properties: { + repo_url: { type: "string", description: "Optional git repository URL (e.g. github.com/user/repo) to filter sessions" }, + limit: { type: "number", description: "Max sessions to return. Default: 5" }, + tool_filter: { + type: "array", + items: { type: "string" }, + description: "Optional tool ids: claude-code, antigravity, cursor, copilot, codex" + }, + branch_filter: { + type: "array", + items: { type: "string" }, + description: "Optional git branches to include" + }, + format: { + type: "string", + enum: ["markdown", "json"], + description: "Response format. Default: markdown" + } + } + } + }, + { + name: "xtctx_session_detail", + description: "Return raw messages from a session_ref returned by xtctx_recent_sessions.", + inputSchema: { + type: "object", + required: ["session_ref"], + properties: { + session_ref: { type: "string", description: "Session reference string" }, + offset: { type: "number", description: "Message offset for pagination" }, + limit: { type: "number", description: "Max messages to return. Default: 50" } + } + } + } +]; + +export async function handleToolCall( + env: Env, + user: AuthUser, + name: string, + args: Record +): Promise { + if (name === "xtctx_recent_sessions") { + const sessions = await getRecentSessions(env, user.userId, { + repoUrl: typeof args.repo_url === "string" ? args.repo_url : undefined, + limit: typeof args.limit === "number" ? args.limit : 5, + toolFilter: Array.isArray(args.tool_filter) ? (args.tool_filter as string[]) : undefined, + branchFilter: Array.isArray(args.branch_filter) ? (args.branch_filter as string[]) : undefined, + }); + + if (args.format === "json") { + return { + content: [{ type: "text", text: JSON.stringify(sessions, null, 2) }] + }; + } + + if (sessions.length === 0) { + return { + content: [{ type: "text", text: "No recent cross-device sessions found for this project." }] + }; + } + + const lines: string[] = ["## Recent Cross-Device Sessions\n"]; + for (const s of sessions) { + const branchInfo = s.git_branch ? ` (branch: \`${s.git_branch}\`)` : ""; + const deviceTag = `[Device: ${s.device_id}]`; + const timeStr = new Date(s.last_activity_at).toLocaleString(); + lines.push( + `- **${s.tool}** on **${deviceTag}**${branchInfo}\n` + + ` - Last active: ${timeStr} (${s.message_count} messages)\n` + + ` - Preview: ${s.preview || "No preview"}\n` + + ` - Ref: \`${s.session_ref}\`\n` + ); + } + + return { + content: [{ type: "text", text: lines.join("\n") }] + }; + } + + if (name === "xtctx_session_detail") { + const sessionRef = String(args.session_ref); + const { session, messages } = await getSessionMessages(env, user.userId, sessionRef, { + offset: typeof args.offset === "number" ? args.offset : 0, + limit: typeof args.limit === "number" ? args.limit : 50, + }); + + if (!session) { + return { + isError: true, + content: [{ type: "text", text: `Session '${sessionRef}' not found.` }] + }; + } + + return { + content: [ + { + type: "text", + text: JSON.stringify({ session, messages }, null, 2) + } + ] + }; + } + + throw new Error(`Unknown tool: ${name}`); +} + +/** + * Handle incoming JSON-RPC payload for both modern (2026-07-28) and baseline (2024-11-05). + */ +export async function processJsonRpc( + env: Env, + user: AuthUser, + body: Record +): Promise | null> { + const { id, method, params } = body as { id?: unknown; method: string; params?: Record }; + + if (method === "initialize") { + // Protocol negotiation: accept client's requested version or default to baseline + const clientVersion = (params?.protocolVersion as string) || "2024-11-05"; + return { + jsonrpc: "2.0", + id, + result: { + protocolVersion: clientVersion === "2026-07-28" ? "2026-07-28" : "2024-11-05", + capabilities: { + tools: { listChanged: false } + }, + serverInfo: { + name: "xtctx-cloud", + version: "0.22.0" + } + } + }; + } + + if (method === "notifications/initialized") { + // Notification, no response required + return null; + } + + if (method === "tools/list") { + return { + jsonrpc: "2.0", + id, + result: { + tools: MCP_TOOLS + } + }; + } + + if (method === "tools/call") { + const toolName = (params?.name as string) || ""; + const toolArgs = (params?.arguments as Record) || {}; + try { + const result = await handleToolCall(env, user, toolName, toolArgs); + return { + jsonrpc: "2.0", + id, + result + }; + } catch (err: unknown) { + return { + jsonrpc: "2.0", + id, + error: { + code: -32000, + message: err instanceof Error ? err.message : String(err) + } + }; + } + } + + return { + jsonrpc: "2.0", + id, + error: { + code: -32601, + message: `Method not found: ${method}` + } + }; +} diff --git a/cloud/src/types.ts b/cloud/src/types.ts new file mode 100644 index 0000000..165e1fe --- /dev/null +++ b/cloud/src/types.ts @@ -0,0 +1,67 @@ +export interface Env { + DB: D1Database; + GITHUB_CLIENT_ID: string; + GITHUB_CLIENT_SECRET?: string; + JWT_SECRET?: string; + ENVIRONMENT?: string; +} + +export interface AuthUser { + userId: string; + username: string; + deviceId?: string; +} + +export interface StreamTurnDelta { + deviceId: string; + deviceName?: string; + tool: string; + sourceSessionId: string; + repoUrl: string; + projectRoot: string; + gitBranch?: string; + gitCommit?: string; + timestamp: string; + role: "user" | "assistant" | "system" | "tool"; + content: string; + messageIndex: number; + contentHash: string; + metadataJson?: string; + sourcePointer?: string; + preview?: string; + status?: "active" | "idle" | "closed"; +} + +export interface SessionRecord { + session_ref: string; + user_id: string; + device_id: string; + tool: string; + source_session_id: string; + repo_url: string; + project_root: string; + git_branch: string | null; + git_commit: string | null; + started_at: string; + last_activity_at: string; + message_count: number; + preview: string | null; + source_path: string | null; + status: string; + updated_at: string; +} + +export interface MessageRecord { + id: string; + session_ref: string; + tool: string; + source_session_id: string; + timestamp: string; + role: string; + content: string; + message_index: number; + content_hash: string; + metadata_json: string; + source_pointer: string | null; + indexed_at: string; +} diff --git a/cloud/tsconfig.json b/cloud/tsconfig.json new file mode 100644 index 0000000..b8b7e82 --- /dev/null +++ b/cloud/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ESNext"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true + }, + "include": ["src/**/*"] +} diff --git a/cloud/wrangler.toml b/cloud/wrangler.toml new file mode 100644 index 0000000..eed951c --- /dev/null +++ b/cloud/wrangler.toml @@ -0,0 +1,26 @@ +name = "xtctx-cloud" +main = "src/index.ts" +compatibility_date = "2026-07-28" +compatibility_flags = ["nodejs_compat"] + +# D1 Database Binding +[[d1_databases]] +binding = "DB" +database_name = "xtctx-db" +database_id = "YOUR_D1_DATABASE_ID_HERE" + +# Custom domain routing for xtctx.com +# Uncomment when ready to map custom domains in Cloudflare Dashboard: +# routes = [ +# { pattern = "mcp.xtctx.com", custom_domain = true }, +# { pattern = "sync.xtctx.com", custom_domain = true } +# ] + +[vars] +# Public GitHub OAuth App Client ID (for Device Flow) +GITHUB_CLIENT_ID = "YOUR_GITHUB_CLIENT_ID" +ENVIRONMENT = "production" + +# Secrets to set via `wrangler secret put `: +# - JWT_SECRET: Secret key for signing user tokens +# - GITHUB_CLIENT_SECRET: (Optional) If using Web Application flow instead of Device Flow diff --git a/src/cli/index.ts b/src/cli/index.ts index 38a4cef..5ef4302 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -6,6 +6,8 @@ import { runHook } from "./hook.js"; import { runScan } from "./scan.js"; import { runSetup } from "./setup.js"; import { runStatus } from "./status.js"; +import { runLogin } from "./login.js"; +import { runWatch } from "./watch.js"; import { createProjectServices } from "../runtime/services.js"; import { startMcpServer } from "../mcp/server.js"; import { readXtctxPackage } from "../utils/package-info.js"; @@ -157,6 +159,25 @@ export async function main(argv = process.argv): Promise { await runStatus({ projectPath: options.project ?? globalOptions.project, verbose: options.verbose }); }); + program + .command("login") + .option("--sync-url ", "Sync server URL (default: https://sync.xtctx.com)") + .option("--device ", "Friendly name for this device") + .description("Authenticate this machine with xtctx cloud via GitHub Device Flow") + .action(async (options: { syncUrl?: string; device?: string }) => { + await runLogin({ syncUrl: options.syncUrl, deviceName: options.device }); + }); + + program + .command("watch") + .alias("sync") + .option("-p, --project ", "Project root (defaults to cwd)") + .description("Stream active agent sessions and turns to xtctx cloud in real time") + .action(async (options: { project?: string }) => { + const globalOptions = program.opts<{ project?: string }>(); + await runWatch({ projectDir: options.project ?? globalOptions.project }); + }); + program .command("scan") .option("-p, --project ", "Project root (defaults to cwd)") diff --git a/src/cli/login.ts b/src/cli/login.ts new file mode 100644 index 0000000..3162868 --- /dev/null +++ b/src/cli/login.ts @@ -0,0 +1,85 @@ +import { hostname } from "node:os"; +import { saveCredentials } from "../sync/client.js"; + +interface DeviceCodeResponse { + device_code: string; + user_code: string; + verification_uri: string; + expires_in: number; + interval: number; +} + +export async function runLogin(options: { syncUrl?: string; deviceName?: string }): Promise { + const syncUrl = options.syncUrl || process.env.XTCTX_SYNC_URL || "https://sync.xtctx.com"; + const deviceName = options.deviceName || hostname() || "default-device"; + + console.log(`\nConnecting to xtctx cloud at ${syncUrl}...`); + + let codeData: DeviceCodeResponse; + try { + const res = await fetch(`${syncUrl.replace(/\/$/, "")}/auth/device/code`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + if (!res.ok) { + throw new Error(`Failed to request device authorization (${res.status}): ${await res.text()}`); + } + codeData = (await res.json()) as DeviceCodeResponse; + } catch (err: unknown) { + console.error("\n❌ Could not connect to auth service:", err instanceof Error ? err.message : String(err)); + process.exit(1); + } + + console.log("\n======================================================="); + console.log(` 1. Copy your one-time code: \x1b[1m\x1b[32m${codeData.user_code}\x1b[0m`); + console.log(` 2. Open in your browser: \x1b[34m${codeData.verification_uri}\x1b[0m`); + console.log("=======================================================\n"); + + console.log("Waiting for authorization in browser..."); + + const interval = (codeData.interval || 5) * 1000; + const pollUrl = `${syncUrl.replace(/\/$/, "")}/auth/device/poll`; + + while (true) { + await new Promise((r) => setTimeout(r, interval)); + + try { + const pollRes = await fetch(pollUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + device_code: codeData.device_code, + device_name: deviceName, + }), + }); + + const pollData = (await pollRes.json()) as { + token?: string; + user?: { id: string; username: string; name?: string }; + error?: string; + }; + + if (pollData.token && pollData.user) { + await saveCredentials({ + token: pollData.token, + user: pollData.user, + deviceId: `dev_${Date.now()}`, + deviceName, + syncUrl, + }); + + console.log(`\n✓ Successfully authenticated as \x1b[1m${pollData.user.username}\x1b[0m`); + console.log(`✓ Device registered as: \x1b[32m${deviceName}\x1b[0m`); + console.log("✓ Credentials saved to ~/.xtctx/credentials.json\n"); + return; + } + + if (pollData.error && pollData.error !== "authorization_pending") { + console.error(`\n❌ Authentication error: ${pollData.error}`); + process.exit(1); + } + } catch { + // Continue polling until timeout or success + } + } +} diff --git a/src/cli/watch.ts b/src/cli/watch.ts new file mode 100644 index 0000000..e4673e8 --- /dev/null +++ b/src/cli/watch.ts @@ -0,0 +1,75 @@ +import { loadCredentials } from "../sync/client.js"; +import { RealtimeTranscriptWatcher } from "../sync/watcher.js"; +import { readdir } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +export async function runWatch(options: { projectDir?: string }): Promise { + const projectDir = options.projectDir || process.cwd(); + const credentials = await loadCredentials(); + + if (!credentials) { + console.error("❌ Not authenticated. Please run `xtctx login` first."); + process.exit(1); + } + + console.log(`\nStarting xtctx real-time memory streamer...`); + console.log(`- User: ${credentials.user.username}`); + console.log(`- Device: ${credentials.deviceName}`); + console.log(`- Server: ${credentials.syncUrl}`); + console.log(`- Target: ${projectDir}\n`); + + const watcher = new RealtimeTranscriptWatcher({ + projectDir, + credentials, + }); + + // 1. Check Antigravity transcripts + const antigravityBrain = process.platform === "win32" + ? join(homedir(), ".gemini", "antigravity", "brain") + : join(homedir(), ".gemini", "antigravity", "brain"); + + if (existsSync(antigravityBrain)) { + try { + const convs = await readdir(antigravityBrain); + for (const conv of convs) { + const transcriptPath = join(antigravityBrain, conv, ".system_generated", "logs", "transcript.jsonl"); + if (existsSync(transcriptPath)) { + await watcher.watchTranscriptFile(transcriptPath, "antigravity", conv); + } + } + console.log(`✓ Watching Antigravity active transcripts`); + } catch { + // Ignore + } + } + + // 2. Check Claude Code transcripts + const claudeProjects = join(homedir(), ".claude", "projects"); + if (existsSync(claudeProjects)) { + try { + const dirs = await readdir(claudeProjects); + for (const d of dirs) { + const fullDir = join(claudeProjects, d); + const files = await readdir(fullDir); + for (const f of files) { + if (f.endsWith(".jsonl")) { + await watcher.watchTranscriptFile(join(fullDir, f), "claude-code", f.replace(/\.jsonl$/, "")); + } + } + } + console.log(`✓ Watching Claude Code active transcripts`); + } catch { + // Ignore + } + } + + console.log("\nListening for agent turns in real time. Press Ctrl+C to stop.\n"); + + process.on("SIGINT", () => { + watcher.close(); + console.log("\nStopped real-time streamer."); + process.exit(0); + }); +} diff --git a/src/sync/client.ts b/src/sync/client.ts new file mode 100644 index 0000000..8abe6c8 --- /dev/null +++ b/src/sync/client.ts @@ -0,0 +1,85 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { existsSync } from "node:fs"; + +export interface SyncCredentials { + token: string; + user: { + id: string; + username: string; + name?: string; + }; + deviceId: string; + deviceName: string; + syncUrl: string; +} + +const DEFAULT_SYNC_URL = "https://sync.xtctx.com"; + +export function getCredentialsPath(): string { + return join(homedir(), ".xtctx", "credentials.json"); +} + +export async function loadCredentials(): Promise { + const credPath = getCredentialsPath(); + if (process.env.XTCTX_TOKEN) { + return { + token: process.env.XTCTX_TOKEN, + user: { id: "env-user", username: "developer" }, + deviceId: "env-device", + deviceName: "cli-env", + syncUrl: process.env.XTCTX_SYNC_URL || DEFAULT_SYNC_URL, + }; + } + + if (!existsSync(credPath)) { + return null; + } + + try { + const raw = await readFile(credPath, "utf-8"); + return JSON.parse(raw); + } catch { + return null; + } +} + +export async function saveCredentials(creds: SyncCredentials): Promise { + const credPath = getCredentialsPath(); + const dir = join(homedir(), ".xtctx"); + if (!existsSync(dir)) { + await mkdir(dir, { recursive: true }); + } + await writeFile(credPath, JSON.stringify(creds, null, 2), "utf-8"); +} + +/** + * Stream a turn delta to the Cloudflare Worker backend. + */ +export async function pushTurnDelta( + creds: SyncCredentials, + delta: Record +): Promise<{ success: boolean; sessionRef?: string }> { + const url = `${creds.syncUrl.replace(/\/$/, "")}/api/stream`; + + const res = await fetch(url, { + method: "POST", + headers: { + "Authorization": `Bearer ${creds.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...delta, + deviceId: creds.deviceId, + deviceName: creds.deviceName, + }), + }); + + if (!res.ok) { + const errText = await res.text(); + throw new Error(`Sync failed (${res.status}): ${errText}`); + } + + return (await res.json()) as { success: boolean; sessionRef?: string }; +} diff --git a/src/sync/normalizer.ts b/src/sync/normalizer.ts new file mode 100644 index 0000000..735502d --- /dev/null +++ b/src/sync/normalizer.ts @@ -0,0 +1,63 @@ +import { exec } from "node:child_process"; +import { promisify } from "node:util"; +import { relative, resolve } from "node:path"; +import { readFile, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; + +const execAsync = promisify(exec); + +/** + * Extract canonical git repository identifier (e.g. "github.com/fstubner/xtctx"). + * Falls back to stable .xtctx/project.id UUID if untracked by git. + */ +export async function getCanonicalRepoId(projectDir: string): Promise<{ repoId: string; repoRoot: string }> { + try { + const { stdout: rootStdout } = await execAsync("git rev-parse --show-toplevel", { cwd: projectDir }); + const repoRoot = rootStdout.trim(); + + const { stdout: originStdout } = await execAsync("git config --get remote.origin.url", { cwd: repoRoot }); + const rawOrigin = originStdout.trim(); + + if (rawOrigin) { + // Normalize ssh and https URLs to clean identifier: + // "git@github.com:user/repo.git" -> "github.com/user/repo" + // "https://github.com/user/repo.git" -> "github.com/user/repo" + let cleaned = rawOrigin + .replace(/\.git$/i, "") + .replace(/^git@([^:]+):/, "$1/") + .replace(/^https?:\/\//, ""); + + return { repoId: cleaned, repoRoot }; + } + + // In git repo without remote origin, use root directory basename + commit or branch + const { stdout: branchStdout } = await execAsync("git branch --show-current", { cwd: repoRoot }); + return { repoId: `local-git:${repoRoot.replace(/[\\/]/g, "-")}`, repoRoot }; + } catch { + // Non-git folder fallback: use or create .xtctx/project.id + const xtctxDir = join(projectDir, ".xtctx"); + const idFile = join(xtctxDir, "project.id"); + if (existsSync(idFile)) { + const id = (await readFile(idFile, "utf-8")).trim(); + return { repoId: `untracked:${id}`, repoRoot: projectDir }; + } + + const newId = randomUUID(); + try { + await writeFile(idFile, newId, "utf-8"); + } catch { + // Ignore write errors + } + return { repoId: `untracked:${newId}`, repoRoot: projectDir }; + } +} + +/** + * Convert an absolute file path into a POSIX repository-relative path. + */ +export function toRepoRelativePath(absolutePath: string, repoRoot: string): string { + const rel = relative(repoRoot, resolve(absolutePath)); + return rel.replace(/\\/g, "/"); +} diff --git a/src/sync/watcher.ts b/src/sync/watcher.ts new file mode 100644 index 0000000..69507e0 --- /dev/null +++ b/src/sync/watcher.ts @@ -0,0 +1,134 @@ +import { open, stat } from "node:fs/promises"; +import { watch, type FSWatcher } from "node:fs"; +import { createHash } from "node:crypto"; +import { getCanonicalRepoId } from "./normalizer.js"; +import { pushTurnDelta, type SyncCredentials } from "./client.js"; + +export interface TailerOptions { + projectDir: string; + credentials: SyncCredentials; + pollIntervalMs?: number; +} + +export interface WatchState { + filePath: string; + tool: string; + sourceSessionId: string; + offset: number; + messageIndex: number; +} + +/** + * Lightweight real-time transcript file tailer. + * Uses byte-offset tracking with shared read handles to avoid file locking. + */ +export class RealtimeTranscriptWatcher { + private activeWatchers: FSWatcher[] = []; + private fileOffsets = new Map(); + private isProcessing = false; + + constructor(private options: TailerOptions) {} + + /** + * Tail a specific JSONL transcript file as turns are appended. + */ + async watchTranscriptFile(filePath: string, tool: string, sourceSessionId: string): Promise { + try { + const stats = await stat(filePath); + // Start tailing from current end or 0 + this.fileOffsets.set(filePath, { + filePath, + tool, + sourceSessionId, + offset: stats.size, // tail from current moment onwards + messageIndex: 0, + }); + + const watcher = watch(filePath, async (eventType) => { + if (eventType === "change") { + await this.handleFileChange(filePath); + } + }); + + this.activeWatchers.push(watcher); + } catch { + // File might not exist yet; watcher can retry + } + } + + private async handleFileChange(filePath: string): Promise { + if (this.isProcessing) return; + this.isProcessing = true; + + try { + const state = this.fileOffsets.get(filePath); + if (!state) return; + + const stats = await stat(filePath); + if (stats.size <= state.offset) { + if (stats.size < state.offset) { + // File was truncated / recreated + state.offset = 0; + state.messageIndex = 0; + } else { + return; + } + } + + const bytesToRead = stats.size - state.offset; + const buffer = Buffer.alloc(bytesToRead); + + const fileHandle = await open(filePath, "r"); + try { + await fileHandle.read(buffer, 0, bytesToRead, state.offset); + } finally { + await fileHandle.close(); + } + + state.offset = stats.size; + + const text = buffer.toString("utf-8"); + const lines = text.split("\n").filter((l) => l.trim().length > 0); + + const { repoId, repoRoot } = await getCanonicalRepoId(this.options.projectDir); + + for (const line of lines) { + try { + const parsed = JSON.parse(line); + state.messageIndex += 1; + + const content = parsed.content || parsed.message || (typeof parsed === "string" ? parsed : JSON.stringify(parsed)); + const contentHash = createHash("sha256").update(content).digest("hex"); + + await pushTurnDelta(this.options.credentials, { + tool: state.tool, + sourceSessionId: state.sourceSessionId, + repoUrl: repoId, + projectRoot: repoRoot, + timestamp: parsed.created_at || new Date().toISOString(), + role: parsed.role || (parsed.type === "USER_INPUT" ? "user" : "assistant"), + content, + messageIndex: state.messageIndex, + contentHash, + status: "active", + }); + } catch { + // Skip invalid JSON lines + } + } + } finally { + this.isProcessing = false; + } + } + + close(): void { + for (const w of this.activeWatchers) { + try { + w.close(); + } catch { + // Ignore + } + } + this.activeWatchers = []; + } +} From f0013e4285a0f300fd5c45b682c817d78981fe65 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 30 Sep 2026 20:20:34 +0100 Subject: [PATCH 02/19] chore(cloud): link D1 database id and commit package-lock --- .gitignore | 1 + cloud/package-lock.json | 2822 +++++++++++++++++++++++++++++++++++++++ cloud/wrangler.toml | 2 +- 3 files changed, 2824 insertions(+), 1 deletion(-) create mode 100644 cloud/package-lock.json diff --git a/.gitignore b/.gitignore index ca991cc..566168a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ dist/ landing/dist/ +.wrangler/ .xtctx/.store/ .xtctx/config.yaml .xtctx/skills/ diff --git a/cloud/package-lock.json b/cloud/package-lock.json new file mode 100644 index 0000000..2ed7e4f --- /dev/null +++ b/cloud/package-lock.json @@ -0,0 +1,2822 @@ +{ + "name": "xtctx-cloud", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "xtctx-cloud", + "version": "0.1.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.6.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250224.0", + "typescript": "^5.8.0", + "wrangler": "^3.111.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.3.4.tgz", + "integrity": "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "mime": "^3.0.0" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.0.2.tgz", + "integrity": "sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.14", + "workerd": "^1.20250124.0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250718.0.tgz", + "integrity": "sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250718.0.tgz", + "integrity": "sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250718.0.tgz", + "integrity": "sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250718.0.tgz", + "integrity": "sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250718.0.tgz", + "integrity": "sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260702.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260702.1.tgz", + "integrity": "sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild-plugins/node-globals-polyfill": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.2.3.tgz", + "integrity": "sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild-plugins/node-modules-polyfill": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-modules-polyfill/-/node-modules-polyfill-0.2.2.tgz", + "integrity": "sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==", + "dev": true, + "license": "ISC", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "rollup-plugin-node-polyfills": "^0.2.1" + }, + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz", + "integrity": "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz", + "integrity": "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.19.tgz", + "integrity": "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz", + "integrity": "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz", + "integrity": "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz", + "integrity": "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz", + "integrity": "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz", + "integrity": "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz", + "integrity": "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz", + "integrity": "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz", + "integrity": "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz", + "integrity": "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz", + "integrity": "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz", + "integrity": "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz", + "integrity": "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz", + "integrity": "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz", + "integrity": "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz", + "integrity": "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz", + "integrity": "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz", + "integrity": "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz", + "integrity": "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz", + "integrity": "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@hono/node-server": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.3.tgz", + "integrity": "sha512-TA//nWMqPhbfdfneACk6t5a9eqbS9lABEPyKn0/xZTah3H3U2XaVg85rJFl0/Fyit0I552YDHgXGVSf3GwqbUw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.31.0.tgz", + "integrity": "sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "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/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/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "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/as-table": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", + "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "printable-characters": "^1.0.42" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "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/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "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": "2.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", + "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", + "dev": true, + "license": "MIT" + }, + "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/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.19.tgz", + "integrity": "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.17.19", + "@esbuild/android-arm64": "0.17.19", + "@esbuild/android-x64": "0.17.19", + "@esbuild/darwin-arm64": "0.17.19", + "@esbuild/darwin-x64": "0.17.19", + "@esbuild/freebsd-arm64": "0.17.19", + "@esbuild/freebsd-x64": "0.17.19", + "@esbuild/linux-arm": "0.17.19", + "@esbuild/linux-arm64": "0.17.19", + "@esbuild/linux-ia32": "0.17.19", + "@esbuild/linux-loong64": "0.17.19", + "@esbuild/linux-mips64el": "0.17.19", + "@esbuild/linux-ppc64": "0.17.19", + "@esbuild/linux-riscv64": "0.17.19", + "@esbuild/linux-s390x": "0.17.19", + "@esbuild/linux-x64": "0.17.19", + "@esbuild/netbsd-x64": "0.17.19", + "@esbuild/openbsd-x64": "0.17.19", + "@esbuild/sunos-x64": "0.17.19", + "@esbuild/win32-arm64": "0.17.19", + "@esbuild/win32-ia32": "0.17.19", + "@esbuild/win32-x64": "0.17.19" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "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.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/exit-hook": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", + "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/exsolve": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", + "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", + "dev": true, + "license": "MIT" + }, + "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/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "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/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-source": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", + "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "data-uri-to-buffer": "^2.0.0", + "source-map": "^0.6.1" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.12", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.12.tgz", + "integrity": "sha512-6E2QDAc9Ick9Sq77ZrGS/dk2WUYni91aufTw6LJKpV7w8kW5/GxVUc650FOADOmlwg3K+f7Pun6XlV+pYmW6gw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "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/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "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", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "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/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.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "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/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "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/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/miniflare": { + "version": "3.20250718.3", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20250718.3.tgz", + "integrity": "sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "acorn": "8.14.0", + "acorn-walk": "8.3.2", + "exit-hook": "2.2.1", + "glob-to-regexp": "0.4.1", + "stoppable": "1.1.0", + "undici": "^5.28.5", + "workerd": "1.20250718.0", + "ws": "8.18.0", + "youch": "3.3.4", + "zod": "3.22.3" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/miniflare/node_modules/zod": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", + "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "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/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ohash": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", + "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", + "dev": true, + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "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/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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": "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/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/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/printable-characters": { + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/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/rollup-plugin-inject": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rollup-plugin-inject/-/rollup-plugin-inject-3.0.2.tgz", + "integrity": "sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==", + "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1", + "magic-string": "^0.25.3", + "rollup-pluginutils": "^2.8.1" + } + }, + "node_modules/rollup-plugin-node-polyfills": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-node-polyfills/-/rollup-plugin-node-polyfills-0.2.1.tgz", + "integrity": "sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rollup-plugin-inject": "^3.0.0" + } + }, + "node_modules/rollup-pluginutils": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", + "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1" + } + }, + "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/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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/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/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "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.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true, + "license": "MIT" + }, + "node_modules/stacktracey": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.2.0.tgz", + "integrity": "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "as-table": "^1.0.36", + "get-source": "^2.0.12" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stoppable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", + "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "npm": ">=6" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.14", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.14.tgz", + "integrity": "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "exsolve": "^1.0.1", + "ohash": "^2.0.10", + "pathe": "^2.0.3", + "ufo": "^1.5.4" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.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/workerd": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250718.0.tgz", + "integrity": "sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20250718.0", + "@cloudflare/workerd-darwin-arm64": "1.20250718.0", + "@cloudflare/workerd-linux-64": "1.20250718.0", + "@cloudflare/workerd-linux-arm64": "1.20250718.0", + "@cloudflare/workerd-windows-64": "1.20250718.0" + } + }, + "node_modules/wrangler": { + "version": "3.114.17", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-3.114.17.tgz", + "integrity": "sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.3.4", + "@cloudflare/unenv-preset": "2.0.2", + "@esbuild-plugins/node-globals-polyfill": "0.2.3", + "@esbuild-plugins/node-modules-polyfill": "0.2.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.17.19", + "miniflare": "3.20250718.3", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.14", + "workerd": "1.20250718.0" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=16.17.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2", + "sharp": "^0.33.5" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20250408.0" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/wrangler/node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "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.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/youch/-/youch-3.3.4.tgz", + "integrity": "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie": "^0.7.1", + "mustache": "^4.2.0", + "stacktracey": "^2.1.8" + } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "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/cloud/wrangler.toml b/cloud/wrangler.toml index eed951c..bc73abb 100644 --- a/cloud/wrangler.toml +++ b/cloud/wrangler.toml @@ -7,7 +7,7 @@ compatibility_flags = ["nodejs_compat"] [[d1_databases]] binding = "DB" database_name = "xtctx-db" -database_id = "YOUR_D1_DATABASE_ID_HERE" +database_id = "f64a7fc9-60a3-41be-964f-efbca6897f88" # Custom domain routing for xtctx.com # Uncomment when ready to map custom domains in Cloudflare Dashboard: From ff5472cb09e42d2252c85061155a57586b0416af Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 30 Sep 2026 20:24:57 +0100 Subject: [PATCH 03/19] fix(cloud): add top-level try-catch error handler in worker fetch --- cloud/src/index.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/cloud/src/index.ts b/cloud/src/index.ts index f09053f..517a7b2 100644 --- a/cloud/src/index.ts +++ b/cloud/src/index.ts @@ -17,11 +17,12 @@ function corsHeaders(extra: Record = {}) { export default { async fetch(request: Request, env: Env): Promise { - const url = new URL(request.url); + try { + const url = new URL(request.url); - if (request.method === "OPTIONS") { - return new Response(null, { headers: corsHeaders() }); - } + if (request.method === "OPTIONS") { + return new Response(null, { headers: corsHeaders() }); + } // 1. Health check & status if (url.pathname === "/" || url.pathname === "/health") { @@ -203,6 +204,14 @@ export default { }); } - return new Response("Not Found", { status: 404, headers: corsHeaders() }); + return new Response("Not Found", { status: 404, headers: corsHeaders() }); + } catch (err: unknown) { + const errorMsg = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + return new Response(JSON.stringify({ error: errorMsg, stack }), { + status: 500, + headers: corsHeaders({ "Content-Type": "application/json" }) + }); + } } }; From 7ba0b9ea97f17637cf1340fd78758242cf6b1c72 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 30 Sep 2026 20:25:32 +0100 Subject: [PATCH 04/19] chore(cloud): upgrade wrangler to v4 and workers-types to v5 --- cloud/package-lock.json | 1281 +++++++++++++++++++-------------------- cloud/package.json | 4 +- 2 files changed, 634 insertions(+), 651 deletions(-) diff --git a/cloud/package-lock.json b/cloud/package-lock.json index 2ed7e4f..415dca9 100644 --- a/cloud/package-lock.json +++ b/cloud/package-lock.json @@ -11,33 +11,30 @@ "@modelcontextprotocol/sdk": "^1.6.0" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20250224.0", + "@cloudflare/workers-types": "^5.20260930.2", "typescript": "^5.8.0", - "wrangler": "^3.111.0" + "wrangler": "^4.145.0" } }, "node_modules/@cloudflare/kv-asset-handler": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.3.4.tgz", - "integrity": "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", "dev": true, "license": "MIT OR Apache-2.0", - "dependencies": { - "mime": "^3.0.0" - }, "engines": { - "node": ">=16.13" + "node": ">=22.0.0" } }, "node_modules/@cloudflare/unenv-preset": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.0.2.tgz", - "integrity": "sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.2.tgz", + "integrity": "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==", "dev": true, "license": "MIT OR Apache-2.0", "peerDependencies": { - "unenv": "2.0.0-rc.14", - "workerd": "^1.20250124.0" + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" }, "peerDependenciesMeta": { "workerd": { @@ -46,9 +43,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20250718.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250718.0.tgz", - "integrity": "sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g==", + "version": "1.20260930.2", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260930.2.tgz", + "integrity": "sha512-wxKsfekWeev6xkjHEMx3x7nz6WfWOn1/scQ8geqWQhUm8qRl1ZRZhjnj+rucIMLhLhP9RGSYMZIjEm3AV0zwVQ==", "cpu": [ "x64" ], @@ -63,9 +60,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20250718.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250718.0.tgz", - "integrity": "sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q==", + "version": "1.20260930.2", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260930.2.tgz", + "integrity": "sha512-cviPpheZGQ2H5X+Ctxd3wfxhksNrdyoNprjSgg3br2+psetKacjdEvK8CILdN/2GF+u1KOExvr45amSL6lgzGA==", "cpu": [ "arm64" ], @@ -80,9 +77,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20250718.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250718.0.tgz", - "integrity": "sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg==", + "version": "1.20260930.2", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260930.2.tgz", + "integrity": "sha512-cwrJAC5y/avPd1OzGyhc5OvUUfPXBFc065TY5CjXp7Hb3dhiTx6JeB5Pc8L3G8L8M44HyAfRHSpoLtRr+SR3cQ==", "cpu": [ "x64" ], @@ -97,9 +94,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20250718.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250718.0.tgz", - "integrity": "sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog==", + "version": "1.20260930.2", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260930.2.tgz", + "integrity": "sha512-FHucB4gak1IuT+IxLx3KbtMBMapZhcqD9EdhHvBxk1FpHwD2QU5x26MRRVN53jIKG/O25HUmE9yffb3cMANQgw==", "cpu": [ "arm64" ], @@ -114,9 +111,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20250718.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250718.0.tgz", - "integrity": "sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg==", + "version": "1.20260930.2", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260930.2.tgz", + "integrity": "sha512-JHFMdGFMqP+M2QiVzZq1S8Ie66Gvxo7Og5EYz/KtmDvGCqge5L3lK+ksZxAhF3j/tpSLqdvlUvcTKoR9PkwTTQ==", "cpu": [ "x64" ], @@ -131,9 +128,9 @@ } }, "node_modules/@cloudflare/workers-types": { - "version": "4.20260702.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260702.1.tgz", - "integrity": "sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==", + "version": "5.20260930.2", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260930.2.tgz", + "integrity": "sha512-zOJM3vdCVB7iszFDvXvusORu1Ssre6+6x1t+opmbJ4ahi4keylnK/hB6/dKuRPLG7eqS/DO7W4IMjvas6pCvJg==", "dev": true, "license": "MIT OR Apache-2.0" }, @@ -161,34 +158,27 @@ "tslib": "^2.4.0" } }, - "node_modules/@esbuild-plugins/node-globals-polyfill": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.2.3.tgz", - "integrity": "sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==", - "dev": true, - "license": "ISC", - "peerDependencies": { - "esbuild": "*" - } - }, - "node_modules/@esbuild-plugins/node-modules-polyfill": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-modules-polyfill/-/node-modules-polyfill-0.2.2.tgz", - "integrity": "sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "escape-string-regexp": "^4.0.0", - "rollup-plugin-node-polyfills": "^0.2.1" - }, - "peerDependencies": { - "esbuild": "*" + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz", - "integrity": "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -199,13 +189,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz", - "integrity": "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -216,13 +206,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.19.tgz", - "integrity": "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -233,13 +223,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz", - "integrity": "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -250,13 +240,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz", - "integrity": "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -267,13 +257,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz", - "integrity": "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -284,13 +274,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz", - "integrity": "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -301,13 +291,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz", - "integrity": "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -318,13 +308,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz", - "integrity": "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -335,13 +325,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz", - "integrity": "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -352,13 +342,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz", - "integrity": "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -369,13 +359,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz", - "integrity": "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -386,13 +376,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz", - "integrity": "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -403,13 +393,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz", - "integrity": "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -420,13 +410,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz", - "integrity": "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -437,13 +427,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz", - "integrity": "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -454,13 +444,30 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz", - "integrity": "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -471,13 +478,30 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz", - "integrity": "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -488,13 +512,30 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz", - "integrity": "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -505,13 +546,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz", - "integrity": "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -522,13 +563,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz", - "integrity": "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -539,13 +580,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz", - "integrity": "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -556,17 +597,7 @@ "win32" ], "engines": { - "node": ">=12" - } - }, - "node_modules/@fastify/busboy": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", - "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" + "node": ">=18" } }, "node_modules/@hono/node-server": { @@ -581,10 +612,20 @@ "hono": "^4" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", "cpu": [ "arm64" ], @@ -595,19 +636,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" + "@img/sharp-libvips-darwin-arm64": "1.3.3" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", "cpu": [ "x64" ], @@ -618,19 +659,39 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", "cpu": [ "arm64" ], @@ -645,9 +706,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", "cpu": [ "x64" ], @@ -662,9 +723,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", "cpu": [ "arm" ], @@ -682,9 +743,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", "cpu": [ "arm64" ], @@ -701,10 +762,50 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", - "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", "cpu": [ "s390x" ], @@ -722,9 +823,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", "cpu": [ "x64" ], @@ -742,9 +843,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", "cpu": [ "arm64" ], @@ -762,9 +863,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", "cpu": [ "x64" ], @@ -782,9 +883,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", "cpu": [ "arm" ], @@ -798,19 +899,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" + "@img/sharp-libvips-linux-arm": "1.3.3" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", "cpu": [ "arm64" ], @@ -824,19 +925,71 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" + "@img/sharp-libvips-linux-riscv64": "1.3.3" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", - "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", "cpu": [ "s390x" ], @@ -850,19 +1003,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.4" + "@img/sharp-libvips-linux-s390x": "1.3.3" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", "cpu": [ "x64" ], @@ -876,19 +1029,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" + "@img/sharp-libvips-linux-x64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "cpu": [ "arm64" ], @@ -902,19 +1055,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "cpu": [ "x64" ], @@ -928,39 +1081,76 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", - "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", "cpu": [ "wasm32" ], "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "license": "Apache-2.0", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.2.0" + "@img/sharp-wasm32": "0.35.4" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", - "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "cpu": [ "ia32" ], @@ -971,16 +1161,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "cpu": [ "x64" ], @@ -991,7 +1181,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -1065,40 +1255,66 @@ } } }, - "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==", + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, "license": "MIT", "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" + "kleur": "^4.1.5" } }, - "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", "dev": true, "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, "engines": { - "node": ">=0.4.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" } }, - "node_modules/acorn-walk": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", - "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", "dev": true, + "license": "CC0-1.0" + }, + "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.4.0" + "node": ">= 0.6" } }, "node_modules/ajv": { @@ -1134,16 +1350,6 @@ } } }, - "node_modules/as-table": { - "version": "1.0.55", - "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", - "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "printable-characters": "^1.0.42" - } - }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -1226,55 +1432,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -1346,13 +1503,6 @@ "node": ">= 8" } }, - "node_modules/data-uri-to-buffer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", - "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", - "dev": true, - "license": "MIT" - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1370,13 +1520,6 @@ } } }, - "node_modules/defu": { - "version": "6.1.7", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", - "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", - "dev": true, - "license": "MIT" - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1392,7 +1535,6 @@ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } @@ -1426,6 +1568,16 @@ "node": ">= 0.8" } }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1457,9 +1609,9 @@ } }, "node_modules/esbuild": { - "version": "0.17.19", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.19.tgz", - "integrity": "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1467,31 +1619,35 @@ "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/android-arm": "0.17.19", - "@esbuild/android-arm64": "0.17.19", - "@esbuild/android-x64": "0.17.19", - "@esbuild/darwin-arm64": "0.17.19", - "@esbuild/darwin-x64": "0.17.19", - "@esbuild/freebsd-arm64": "0.17.19", - "@esbuild/freebsd-x64": "0.17.19", - "@esbuild/linux-arm": "0.17.19", - "@esbuild/linux-arm64": "0.17.19", - "@esbuild/linux-ia32": "0.17.19", - "@esbuild/linux-loong64": "0.17.19", - "@esbuild/linux-mips64el": "0.17.19", - "@esbuild/linux-ppc64": "0.17.19", - "@esbuild/linux-riscv64": "0.17.19", - "@esbuild/linux-s390x": "0.17.19", - "@esbuild/linux-x64": "0.17.19", - "@esbuild/netbsd-x64": "0.17.19", - "@esbuild/openbsd-x64": "0.17.19", - "@esbuild/sunos-x64": "0.17.19", - "@esbuild/win32-arm64": "0.17.19", - "@esbuild/win32-ia32": "0.17.19", - "@esbuild/win32-x64": "0.17.19" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escape-html": { @@ -1500,26 +1656,6 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/estree-walker": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", - "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", - "dev": true, - "license": "MIT" - }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -1550,19 +1686,6 @@ "node": ">=18.0.0" } }, - "node_modules/exit-hook": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", - "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -1625,13 +1748,6 @@ "express": ">= 4.11" } }, - "node_modules/exsolve": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", - "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1754,24 +1870,6 @@ "node": ">= 0.4" } }, - "node_modules/get-source": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", - "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", - "dev": true, - "license": "Unlicense", - "dependencies": { - "data-uri-to-buffer": "^2.0.0", - "source-map": "^0.6.1" - } - }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true, - "license": "BSD-2-Clause" - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1877,14 +1975,6 @@ "node": ">= 0.10" } }, - "node_modules/is-arrayish": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", - "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -1918,14 +2008,14 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, - "node_modules/magic-string": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", - "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", "dev": true, "license": "MIT", - "dependencies": { - "sourcemap-codec": "^1.4.8" + "engines": { + "node": ">=6" } }, "node_modules/math-intrinsics": { @@ -1962,19 +2052,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mime": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", - "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -2001,39 +2078,21 @@ } }, "node_modules/miniflare": { - "version": "3.20250718.3", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20250718.3.tgz", - "integrity": "sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==", + "version": "5.20260930.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260930.0-alpha.tgz", + "integrity": "sha512-vZljfRtOEeynbhJtuvYsnCU8t6zF5wIUm9ql4YFHB7zvYw2+F2tpT0BzXjzaa++eiUIml1gsODCf02qShBmJdg==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", - "acorn": "8.14.0", - "acorn-walk": "8.3.2", - "exit-hook": "2.2.1", - "glob-to-regexp": "0.4.1", - "stoppable": "1.1.0", - "undici": "^5.28.5", - "workerd": "1.20250718.0", - "ws": "8.18.0", - "youch": "3.3.4", - "zod": "3.22.3" - }, - "bin": { - "miniflare": "bootstrap.js" + "sharp": "0.35.4", + "undici": "7.29.1", + "workerd": "1.20260930.2", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" }, "engines": { - "node": ">=16.13" - } - }, - "node_modules/miniflare/node_modules/zod": { - "version": "3.22.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", - "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" + "node": ">=22.0.0" } }, "node_modules/ms": { @@ -2042,16 +2101,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/mustache": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", - "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", - "dev": true, - "license": "MIT", - "bin": { - "mustache": "bin/mustache" - } - }, "node_modules/negotiator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", @@ -2102,13 +2151,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/ohash": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", - "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", - "dev": true, - "license": "MIT" - }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2174,13 +2216,6 @@ "node": ">=16.20.0" } }, - "node_modules/printable-characters": { - "version": "1.0.42", - "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", - "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", - "dev": true, - "license": "Unlicense" - }, "node_modules/proxy-addr": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", @@ -2251,39 +2286,6 @@ "node": ">=0.10.0" } }, - "node_modules/rollup-plugin-inject": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rollup-plugin-inject/-/rollup-plugin-inject-3.0.2.tgz", - "integrity": "sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==", - "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.", - "dev": true, - "license": "MIT", - "dependencies": { - "estree-walker": "^0.6.1", - "magic-string": "^0.25.3", - "rollup-pluginutils": "^2.8.1" - } - }, - "node_modules/rollup-plugin-node-polyfills": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/rollup-plugin-node-polyfills/-/rollup-plugin-node-polyfills-0.2.1.tgz", - "integrity": "sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==", - "dev": true, - "license": "MIT", - "dependencies": { - "rollup-plugin-inject": "^3.0.0" - } - }, - "node_modules/rollup-pluginutils": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", - "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "estree-walker": "^0.6.1" - } - }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -2312,7 +2314,6 @@ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, @@ -2372,44 +2373,53 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", - "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "dev": true, - "hasInstallScript": true, "license": "Apache-2.0", - "optional": true, "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.6.3" + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.5", - "@img/sharp-darwin-x64": "0.33.5", - "@img/sharp-libvips-darwin-arm64": "1.0.4", - "@img/sharp-libvips-darwin-x64": "1.0.4", - "@img/sharp-libvips-linux-arm": "1.0.5", - "@img/sharp-libvips-linux-arm64": "1.0.4", - "@img/sharp-libvips-linux-s390x": "1.0.4", - "@img/sharp-libvips-linux-x64": "1.0.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", - "@img/sharp-libvips-linuxmusl-x64": "1.0.4", - "@img/sharp-linux-arm": "0.33.5", - "@img/sharp-linux-arm64": "0.33.5", - "@img/sharp-linux-s390x": "0.33.5", - "@img/sharp-linux-x64": "0.33.5", - "@img/sharp-linuxmusl-arm64": "0.33.5", - "@img/sharp-linuxmusl-x64": "0.33.5", - "@img/sharp-wasm32": "0.33.5", - "@img/sharp-win32-ia32": "0.33.5", - "@img/sharp-win32-x64": "0.33.5" + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/shebang-command": { @@ -2505,46 +2515,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/simple-swizzle": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", - "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sourcemap-codec": { - "version": "1.4.8", - "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", - "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", - "deprecated": "Please use @jridgewell/sourcemap-codec instead", - "dev": true, - "license": "MIT" - }, - "node_modules/stacktracey": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.2.0.tgz", - "integrity": "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==", - "dev": true, - "license": "Unlicense", - "dependencies": { - "as-table": "^1.0.36", - "get-source": "^2.0.12" - } - }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2554,15 +2524,17 @@ "node": ">= 0.8" } }, - "node_modules/stoppable": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", - "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", "dev": true, "license": "MIT", "engines": { - "node": ">=4", - "npm": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, "node_modules/toidentifier": { @@ -2627,38 +2599,24 @@ "node": ">=14.17" } }, - "node_modules/ufo": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", - "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", - "dev": true, - "license": "MIT" - }, "node_modules/undici": { - "version": "5.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", - "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", "dev": true, "license": "MIT", - "dependencies": { - "@fastify/busboy": "^2.0.0" - }, "engines": { - "node": ">=14.0" + "node": ">=20.18.1" } }, "node_modules/unenv": { - "version": "2.0.0-rc.14", - "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.14.tgz", - "integrity": "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==", + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "dev": true, "license": "MIT", "dependencies": { - "defu": "^6.1.4", - "exsolve": "^1.0.1", - "ohash": "^2.0.10", - "pathe": "^2.0.3", - "ufo": "^1.5.4" + "pathe": "^2.0.3" } }, "node_modules/unpipe": { @@ -2695,9 +2653,9 @@ } }, "node_modules/workerd": { - "version": "1.20250718.0", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250718.0.tgz", - "integrity": "sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg==", + "version": "1.20260930.2", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260930.2.tgz", + "integrity": "sha512-k1lQzEiGyCDqaRsR5+o9PBT5wVU5omtas6hXx3spbEHHMFo8a7tkAixVCMsWFeiT7xhrmg21Hd6a9jLNrkcUMg==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -2708,44 +2666,42 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20250718.0", - "@cloudflare/workerd-darwin-arm64": "1.20250718.0", - "@cloudflare/workerd-linux-64": "1.20250718.0", - "@cloudflare/workerd-linux-arm64": "1.20250718.0", - "@cloudflare/workerd-windows-64": "1.20250718.0" + "@cloudflare/workerd-darwin-64": "1.20260930.2", + "@cloudflare/workerd-darwin-arm64": "1.20260930.2", + "@cloudflare/workerd-linux-64": "1.20260930.2", + "@cloudflare/workerd-linux-arm64": "1.20260930.2", + "@cloudflare/workerd-windows-64": "1.20260930.2" } }, "node_modules/wrangler": { - "version": "3.114.17", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-3.114.17.tgz", - "integrity": "sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA==", + "version": "4.145.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.145.0.tgz", + "integrity": "sha512-TdO9l7RNqM2Kb5/AynhlRPF9WPhaI0W4Gcpq2FkyU1iZ+xPW25sfV8Yc48PsI1GsNEFOfdh2RTOIpW8U22AqHw==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { - "@cloudflare/kv-asset-handler": "0.3.4", - "@cloudflare/unenv-preset": "2.0.2", - "@esbuild-plugins/node-globals-polyfill": "0.2.3", - "@esbuild-plugins/node-modules-polyfill": "0.2.2", + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.2", "blake3-wasm": "2.1.5", - "esbuild": "0.17.19", - "miniflare": "3.20250718.3", + "esbuild": "0.28.1", + "miniflare": "5.20260930.0-alpha", "path-to-regexp": "6.3.0", - "unenv": "2.0.0-rc.14", - "workerd": "1.20250718.0" + "unenv": "2.0.0-rc.24", + "workerd": "1.20260930.2" }, "bin": { + "cf-wrangler": "bin/cf-wrangler.js", "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" }, "engines": { - "node": ">=16.17.0" + "node": ">=22.0.0" }, "optionalDependencies": { - "fsevents": "~2.3.2", - "sharp": "^0.33.5" + "fsevents": "2.3.3" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.20250408.0" + "@cloudflare/workers-types": "^5.20260930.2" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { @@ -2767,9 +2723,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { @@ -2789,15 +2745,42 @@ } }, "node_modules/youch": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/youch/-/youch-3.3.4.tgz", - "integrity": "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==", + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", "dev": true, "license": "MIT", "dependencies": { - "cookie": "^0.7.1", - "mustache": "^4.2.0", - "stacktracey": "^2.1.8" + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + }, + "node_modules/youch/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/zod": { diff --git a/cloud/package.json b/cloud/package.json index d25bd53..141d1ee 100644 --- a/cloud/package.json +++ b/cloud/package.json @@ -13,9 +13,9 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20250224.0", + "@cloudflare/workers-types": "^5.20260930.2", "typescript": "^5.8.0", - "wrangler": "^3.111.0" + "wrangler": "^4.145.0" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.6.0" From 0d78edb61128fd44b5f0d2d49c3be151c7cd4d69 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 30 Sep 2026 20:52:20 +0100 Subject: [PATCH 05/19] feat(cloud): configure mcp.xtctx.com and sync.xtctx.com custom domains --- cloud/wrangler.toml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cloud/wrangler.toml b/cloud/wrangler.toml index bc73abb..8418d9d 100644 --- a/cloud/wrangler.toml +++ b/cloud/wrangler.toml @@ -2,6 +2,13 @@ name = "xtctx-cloud" main = "src/index.ts" compatibility_date = "2026-07-28" compatibility_flags = ["nodejs_compat"] +workers_dev = true + +# Custom domain routing for xtctx.com +routes = [ + { pattern = "mcp.xtctx.com", custom_domain = true }, + { pattern = "sync.xtctx.com", custom_domain = true } +] # D1 Database Binding [[d1_databases]] @@ -9,13 +16,6 @@ binding = "DB" database_name = "xtctx-db" database_id = "f64a7fc9-60a3-41be-964f-efbca6897f88" -# Custom domain routing for xtctx.com -# Uncomment when ready to map custom domains in Cloudflare Dashboard: -# routes = [ -# { pattern = "mcp.xtctx.com", custom_domain = true }, -# { pattern = "sync.xtctx.com", custom_domain = true } -# ] - [vars] # Public GitHub OAuth App Client ID (for Device Flow) GITHUB_CLIENT_ID = "YOUR_GITHUB_CLIENT_ID" From 04982f104451580cc55fd68126e1d1c70155bf37 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 30 Sep 2026 20:54:48 +0100 Subject: [PATCH 06/19] feat(cloud): configure production GITHUB_CLIENT_ID for device flow --- cloud/wrangler.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloud/wrangler.toml b/cloud/wrangler.toml index 8418d9d..53303f4 100644 --- a/cloud/wrangler.toml +++ b/cloud/wrangler.toml @@ -18,7 +18,7 @@ database_id = "f64a7fc9-60a3-41be-964f-efbca6897f88" [vars] # Public GitHub OAuth App Client ID (for Device Flow) -GITHUB_CLIENT_ID = "YOUR_GITHUB_CLIENT_ID" +GITHUB_CLIENT_ID = "Ov23liFUTodqfoH9mgHA" ENVIRONMENT = "production" # Secrets to set via `wrangler secret put `: From 54c61a3354fc0afde5642b3527edca3c061fc52e Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 30 Sep 2026 20:55:31 +0100 Subject: [PATCH 07/19] chore: update root package-lock with build dependencies --- package-lock.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package-lock.json b/package-lock.json index 6e0903f..25f6e47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1713,6 +1713,7 @@ "version": "13.0.3", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "hasInstallScript": true, "license": "MIT", "dependencies": { "node-addon-api": "^8.0.0" From 0c86754eaff97d3c6e6fd73820b9a0017b9bc96b Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 30 Sep 2026 21:31:50 +0100 Subject: [PATCH 08/19] feat(sync): implement automatic zero-flag incremental diff sync and batch ingestion --- cloud/src/db.ts | 157 ++++++++++++++++++++++-------------------- src/cli/index.ts | 17 +++-- src/cli/watch.ts | 35 +++++++--- src/sync/diff-sync.ts | 108 +++++++++++++++++++++++++++++ 4 files changed, 230 insertions(+), 87 deletions(-) create mode 100644 src/sync/diff-sync.ts diff --git a/cloud/src/db.ts b/cloud/src/db.ts index 830818b..1084676 100644 --- a/cloud/src/db.ts +++ b/cloud/src/db.ts @@ -6,15 +6,18 @@ import type { Env, AuthUser, StreamTurnDelta, SessionRecord, MessageRecord } fro export async function ingestTurnDelta( env: Env, user: AuthUser, - delta: StreamTurnDelta -): Promise<{ success: boolean; sessionRef: string }> { - const sessionRef = `${user.userId}:${delta.tool}:${delta.sourceSessionId}`; + input: StreamTurnDelta | StreamTurnDelta[] +): Promise<{ success: boolean; count: number; sessionRef?: string }> { + const deltas = Array.isArray(input) ? input : [input]; + if (deltas.length === 0) { + return { success: true, count: 0 }; + } + const now = new Date().toISOString(); const nowTs = Date.now(); - const statements: D1PreparedStatement[] = []; - // 1. Ensure user and device records exist/updated + // Ensure user record exists/updated statements.push( env.DB.prepare( `INSERT INTO users (id, username, created_at, updated_at) @@ -23,76 +26,84 @@ export async function ingestTurnDelta( ).bind(user.userId, user.username, nowTs, nowTs) ); - const deviceName = delta.deviceName || delta.deviceId; - statements.push( - env.DB.prepare( - `INSERT INTO devices (id, user_id, device_name, last_seen_at, created_at) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET last_seen_at = excluded.last_seen_at` - ).bind(delta.deviceId, user.userId, deviceName, nowTs, nowTs) - ); - - // 2. Upsert session - statements.push( - env.DB.prepare( - `INSERT INTO sessions ( - session_ref, user_id, device_id, tool, source_session_id, - repo_url, project_root, git_branch, git_commit, - started_at, last_activity_at, message_count, preview, source_path, status, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?) - ON CONFLICT(session_ref) DO UPDATE SET - last_activity_at = excluded.last_activity_at, - message_count = message_count + 1, - preview = COALESCE(excluded.preview, preview), - status = excluded.status, - updated_at = excluded.updated_at` - ).bind( - sessionRef, - user.userId, - delta.deviceId, - delta.tool, - delta.sourceSessionId, - delta.repoUrl, - delta.projectRoot, - delta.gitBranch || null, - delta.gitCommit || null, - delta.timestamp, - delta.timestamp, - delta.preview || (delta.content ? delta.content.slice(0, 160) : null), - delta.sourcePointer || null, - delta.status || "active", - now - ) - ); - - // 3. Insert message turn - const messageId = `${sessionRef}:${delta.messageIndex}:${delta.contentHash.slice(0, 8)}`; - statements.push( - env.DB.prepare( - `INSERT INTO messages ( - id, session_ref, tool, source_session_id, timestamp, role, - content, message_index, content_hash, metadata_json, source_pointer, indexed_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO NOTHING` - ).bind( - messageId, - sessionRef, - delta.tool, - delta.sourceSessionId, - delta.timestamp, - delta.role, - delta.content, - delta.messageIndex, - delta.contentHash, - delta.metadataJson || "{}", - delta.sourcePointer || null, - now - ) - ); + for (const delta of deltas) { + const sessionRef = `${user.userId}:${delta.tool}:${delta.sourceSessionId}`; + const deviceName = delta.deviceName || delta.deviceId; + + statements.push( + env.DB.prepare( + `INSERT INTO devices (id, user_id, device_name, last_seen_at, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET last_seen_at = excluded.last_seen_at` + ).bind(delta.deviceId, user.userId, deviceName, nowTs, nowTs) + ); + + statements.push( + env.DB.prepare( + `INSERT INTO sessions ( + session_ref, user_id, device_id, tool, source_session_id, + repo_url, project_root, git_branch, git_commit, + started_at, last_activity_at, message_count, preview, source_path, status, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?) + ON CONFLICT(session_ref) DO UPDATE SET + last_activity_at = excluded.last_activity_at, + message_count = message_count + 1, + preview = COALESCE(excluded.preview, preview), + status = excluded.status, + updated_at = excluded.updated_at` + ).bind( + sessionRef, + user.userId, + delta.deviceId, + delta.tool, + delta.sourceSessionId, + delta.repoUrl, + delta.projectRoot, + delta.gitBranch || null, + delta.gitCommit || null, + delta.timestamp, + delta.timestamp, + delta.preview || (delta.content ? delta.content.slice(0, 160) : null), + delta.sourcePointer || null, + delta.status || "active", + now + ) + ); + + const messageId = `${sessionRef}:${delta.messageIndex}:${delta.contentHash.slice(0, 8)}`; + statements.push( + env.DB.prepare( + `INSERT INTO messages ( + id, session_ref, tool, source_session_id, timestamp, role, + content, message_index, content_hash, metadata_json, source_pointer, indexed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO NOTHING` + ).bind( + messageId, + sessionRef, + delta.tool, + delta.sourceSessionId, + delta.timestamp, + delta.role, + delta.content, + delta.messageIndex, + delta.contentHash, + delta.metadataJson || "{}", + delta.sourcePointer || null, + now + ) + ); + } - await env.DB.batch(statements); + // D1 batch execution in chunks of 100 statements + const CHUNK_SIZE = 100; + for (let i = 0; i < statements.length; i += CHUNK_SIZE) { + const chunk = statements.slice(i, i + CHUNK_SIZE); + await env.DB.batch(chunk); + } - return { success: true, sessionRef }; + const lastSessionRef = `${user.userId}:${deltas[deltas.length - 1].tool}:${deltas[deltas.length - 1].sourceSessionId}`; + return { success: true, count: deltas.length, sessionRef: lastSessionRef }; } /** diff --git a/src/cli/index.ts b/src/cli/index.ts index 5ef4302..cee6b83 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -7,7 +7,7 @@ import { runScan } from "./scan.js"; import { runSetup } from "./setup.js"; import { runStatus } from "./status.js"; import { runLogin } from "./login.js"; -import { runWatch } from "./watch.js"; +import { runSync } from "./watch.js"; import { createProjectServices } from "../runtime/services.js"; import { startMcpServer } from "../mcp/server.js"; import { readXtctxPackage } from "../utils/package-info.js"; @@ -168,14 +168,23 @@ export async function main(argv = process.argv): Promise { await runLogin({ syncUrl: options.syncUrl, deviceName: options.device }); }); + program + .command("sync") + .option("-p, --project ", "Project root (defaults to cwd)") + .option("-w, --watch", "Keep running and stream new turns continuously in real time", false) + .description("Sync local transcript diffs to xtctx cloud") + .action(async (options: { project?: string; watch?: boolean }) => { + const globalOptions = program.opts<{ project?: string }>(); + await runSync({ projectDir: options.project ?? globalOptions.project, watch: options.watch }); + }); + program .command("watch") - .alias("sync") .option("-p, --project ", "Project root (defaults to cwd)") - .description("Stream active agent sessions and turns to xtctx cloud in real time") + .description("Keep running and stream new turns to xtctx cloud continuously in real time") .action(async (options: { project?: string }) => { const globalOptions = program.opts<{ project?: string }>(); - await runWatch({ projectDir: options.project ?? globalOptions.project }); + await runSync({ projectDir: options.project ?? globalOptions.project, watch: true }); }); program diff --git a/src/cli/watch.ts b/src/cli/watch.ts index e4673e8..8333ea3 100644 --- a/src/cli/watch.ts +++ b/src/cli/watch.ts @@ -1,11 +1,12 @@ import { loadCredentials } from "../sync/client.js"; import { RealtimeTranscriptWatcher } from "../sync/watcher.js"; +import { runDiffSync } from "../sync/diff-sync.js"; import { readdir } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; -export async function runWatch(options: { projectDir?: string }): Promise { +export async function runSync(options: { projectDir?: string; watch?: boolean }): Promise { const projectDir = options.projectDir || process.cwd(); const credentials = await loadCredentials(); @@ -14,9 +15,26 @@ export async function runWatch(options: { projectDir?: string }): Promise process.exit(1); } - console.log(`\nStarting xtctx real-time memory streamer...`); - console.log(`- User: ${credentials.user.username}`); - console.log(`- Device: ${credentials.deviceName}`); + // 1. Run automatic diff sync first + console.log(`\nChecking cloud sync for ${credentials.deviceName} (${credentials.user.username})...`); + try { + const diff = await runDiffSync({ projectDir }); + if (diff.upToDate) { + console.log(`✓ Cloud sync is up to date (0 pending diffs)`); + } else { + console.log(`✓ Successfully synced ${diff.syncedCount} pending turns to cloud`); + } + } catch (err: unknown) { + console.warn(`⚠️ Warning: Diff sync encountered an error:`, err instanceof Error ? err.message : String(err)); + } + + // If not watching, we are done + if (!options.watch) { + return; + } + + // 2. Start real-time continuous watcher + console.log(`\nStarting continuous real-time watcher...`); console.log(`- Server: ${credentials.syncUrl}`); console.log(`- Target: ${projectDir}\n`); @@ -25,11 +43,8 @@ export async function runWatch(options: { projectDir?: string }): Promise credentials, }); - // 1. Check Antigravity transcripts - const antigravityBrain = process.platform === "win32" - ? join(homedir(), ".gemini", "antigravity", "brain") - : join(homedir(), ".gemini", "antigravity", "brain"); - + // Antigravity transcripts + const antigravityBrain = join(homedir(), ".gemini", "antigravity", "brain"); if (existsSync(antigravityBrain)) { try { const convs = await readdir(antigravityBrain); @@ -45,7 +60,7 @@ export async function runWatch(options: { projectDir?: string }): Promise } } - // 2. Check Claude Code transcripts + // Claude Code transcripts const claudeProjects = join(homedir(), ".claude", "projects"); if (existsSync(claudeProjects)) { try { diff --git a/src/sync/diff-sync.ts b/src/sync/diff-sync.ts new file mode 100644 index 0000000..eaea681 --- /dev/null +++ b/src/sync/diff-sync.ts @@ -0,0 +1,108 @@ +import Database, { type Database as DatabaseHandle } from "better-sqlite3"; +import { join } from "node:path"; +import { existsSync } from "node:fs"; +import { loadCredentials, type SyncCredentials } from "./client.js"; +import { getCanonicalRepoId } from "./normalizer.js"; +import { getSetting, setSetting } from "../handoff/schema.js"; + +const BATCH_SIZE = 100; +const CLOUD_SYNC_KEY = "cloud_last_synced_indexed_at"; + +export interface DiffSyncResult { + syncedCount: number; + latestIndexedAt: string | null; + upToDate: boolean; +} + +/** + * Execute an automatic incremental diff sync. + * Queries turns added/indexed since the last high-water mark across all scrapers + * and streams them in batches to sync.xtctx.com. + */ +export async function runDiffSync(options: { projectDir?: string }): Promise { + const projectDir = options.projectDir || process.cwd(); + const credentials = await loadCredentials(); + + if (!credentials) { + throw new Error("Not authenticated. Please run `xtctx login` first."); + } + + const dbPath = join(projectDir, ".xtctx", "state", "xtctx.db"); + if (!existsSync(dbPath)) { + return { syncedCount: 0, latestIndexedAt: null, upToDate: true }; + } + + const db: DatabaseHandle = new Database(dbPath); + try { + const lastSynced = getSetting(db, CLOUD_SYNC_KEY) || "1970-01-01T00:00:00.000Z"; + const { repoId, repoRoot } = await getCanonicalRepoId(projectDir); + + let totalSynced = 0; + let currentHighWater = lastSynced; + + const queryStmt = db.prepare(` + SELECT m.id, m.session_ref, m.tool, m.source_session_id, m.timestamp, m.role, + m.content, m.message_index, m.content_hash, m.metadata_json, m.source_pointer, m.indexed_at, + s.git_branch, s.git_commit, s.preview, s.status + FROM messages m + JOIN sessions s ON m.session_ref = s.session_ref + WHERE m.indexed_at > ? + ORDER BY m.indexed_at ASC + LIMIT ? + `); + + while (true) { + const rows = queryStmt.all(currentHighWater, BATCH_SIZE) as any[]; + if (rows.length === 0) break; + + const deltas = rows.map((r) => ({ + deviceId: credentials.deviceId, + deviceName: credentials.deviceName, + tool: r.tool, + sourceSessionId: r.source_session_id, + repoUrl: repoId, + projectRoot: repoRoot, + gitBranch: r.git_branch || undefined, + gitCommit: r.git_commit || undefined, + timestamp: r.timestamp, + role: r.role, + content: r.content, + messageIndex: r.message_index, + contentHash: r.content_hash, + metadataJson: r.metadata_json, + sourcePointer: r.source_pointer, + preview: r.preview, + status: r.status || "active", + })); + + // Push batch delta to Cloudflare + const url = `${credentials.syncUrl.replace(/\/$/, "")}/api/stream`; + const res = await fetch(url, { + method: "POST", + headers: { + "Authorization": `Bearer ${credentials.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(deltas), + }); + + if (!res.ok) { + throw new Error(`Sync batch failed (${res.status}): ${await res.text()}`); + } + + totalSynced += rows.length; + currentHighWater = rows[rows.length - 1].indexed_at; + setSetting(db, CLOUD_SYNC_KEY, currentHighWater); + + if (rows.length < BATCH_SIZE) break; + } + + return { + syncedCount: totalSynced, + latestIndexedAt: currentHighWater, + upToDate: totalSynced === 0, + }; + } finally { + db.close(); + } +} From fb84f8f0a291ed3c187ffa693102b3492300114d Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 30 Sep 2026 21:49:50 +0100 Subject: [PATCH 09/19] feat(sync): auto-sync upon login and background sync during MCP startup --- src/cli/login.ts | 16 +++++++++++++++- src/runtime/background.ts | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/cli/login.ts b/src/cli/login.ts index 3162868..13421b2 100644 --- a/src/cli/login.ts +++ b/src/cli/login.ts @@ -1,5 +1,6 @@ import { hostname } from "node:os"; import { saveCredentials } from "../sync/client.js"; +import { runDiffSync } from "../sync/diff-sync.js"; interface DeviceCodeResponse { device_code: string; @@ -70,7 +71,20 @@ export async function runLogin(options: { syncUrl?: string; deviceName?: string console.log(`\n✓ Successfully authenticated as \x1b[1m${pollData.user.username}\x1b[0m`); console.log(`✓ Device registered as: \x1b[32m${deviceName}\x1b[0m`); - console.log("✓ Credentials saved to ~/.xtctx/credentials.json\n"); + console.log("✓ Credentials saved to ~/.xtctx/credentials.json"); + + // Automatically trigger initial diff sync + console.log("\nSyncing existing local sessions to xtctx cloud..."); + try { + const diff = await runDiffSync({ projectDir: process.cwd() }); + if (diff.upToDate) { + console.log("✓ Cloud sync is up to date (0 pending diffs)\n"); + } else { + console.log(`✓ Automatically synced ${diff.syncedCount} turns to xtctx cloud!\n`); + } + } catch (err: unknown) { + console.warn(`⚠️ Note: Cloud sync deferred (${err instanceof Error ? err.message : String(err)})\n`); + } return; } diff --git a/src/runtime/background.ts b/src/runtime/background.ts index d393389..040b1c7 100644 --- a/src/runtime/background.ts +++ b/src/runtime/background.ts @@ -53,6 +53,7 @@ export async function runBackgroundWork(deps: BackgroundDeps): Promise { await deps.sessions.listRecentSessions(1); await deps.sessions.whenScanSettled(); await drainIfAffordable(deps.sessions); + await syncToCloudIfAuthenticated(log); } catch (error) { // Reported rather than swallowed. The drain records its own failure in // `embedding_error`; this line is for everything else, and for anyone @@ -63,6 +64,23 @@ export async function runBackgroundWork(deps: BackgroundDeps): Promise { } } +async function syncToCloudIfAuthenticated(log: (line: string) => void): Promise { + try { + const { loadCredentials } = await import("../sync/client.js"); + const creds = await loadCredentials(); + if (!creds) return; + + const { runDiffSync } = await import("../sync/diff-sync.js"); + const result = await runDiffSync({ projectDir: process.cwd() }); + if (!result.upToDate && result.syncedCount > 0) { + log(`xtctx: synced ${result.syncedCount} new turns to cloud`); + } + } catch (err: unknown) { + // Non-blocking: background sync failure should never crash the MCP server + log(`xtctx: background cloud sync skipped: ${err instanceof Error ? err.message : String(err)}`); + } +} + /** * Measure this machine's embedding devices, once per machine, before any * model load. From 0d18981eba3d2c923d01e74be35376c8184ba59e Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 30 Sep 2026 21:59:54 +0100 Subject: [PATCH 10/19] feat(daemon): implement self-healing detached background sync daemon with auto-resurrection --- src/cli/index.ts | 36 ++++++++++ src/cli/login.ts | 20 +++++- src/runtime/background.ts | 5 ++ src/sync/daemon-manager.ts | 133 +++++++++++++++++++++++++++++++++++++ 4 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 src/sync/daemon-manager.ts diff --git a/src/cli/index.ts b/src/cli/index.ts index cee6b83..eb3bbc1 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -187,6 +187,42 @@ export async function main(argv = process.argv): Promise { await runSync({ projectDir: options.project ?? globalOptions.project, watch: true }); }); + program + .command("daemon") + .argument("[action]", "start, stop, or status (default: status)", "status") + .description("Inspect or control the background real-time sync daemon") + .action(async (action: string) => { + const { getDaemonStatus, ensureDaemonRunning, stopDaemon, getDaemonLogPath } = await import( + "../sync/daemon-manager.js" + ); + + if (action === "start") { + const res = await ensureDaemonRunning(); + if (res.started) { + console.log(`✓ Started background sync daemon (PID: ${res.pid})`); + } else if (res.pid) { + console.log(`✓ Background sync daemon is already running (PID: ${res.pid})`); + } else { + console.log(`❌ Could not start daemon (are you logged in? Run xtctx login)`); + } + } else if (action === "stop") { + const stopped = await stopDaemon(); + if (stopped) { + console.log(`✓ Stopped background sync daemon`); + } else { + console.log(`✓ Background sync daemon was not running`); + } + } else { + const status = await getDaemonStatus(); + if (status.running) { + console.log(`✓ Background sync daemon: ACTIVE (PID: ${status.pid})`); + } else { + console.log(`○ Background sync daemon: STOPPED`); + } + console.log(`- Logs: ${getDaemonLogPath()}`); + } + }); + program .command("scan") .option("-p, --project ", "Project root (defaults to cwd)") diff --git a/src/cli/login.ts b/src/cli/login.ts index 13421b2..6a1d0c9 100644 --- a/src/cli/login.ts +++ b/src/cli/login.ts @@ -1,6 +1,7 @@ import { hostname } from "node:os"; import { saveCredentials } from "../sync/client.js"; import { runDiffSync } from "../sync/diff-sync.js"; +import { ensureDaemonRunning } from "../sync/daemon-manager.js"; interface DeviceCodeResponse { device_code: string; @@ -78,13 +79,26 @@ export async function runLogin(options: { syncUrl?: string; deviceName?: string try { const diff = await runDiffSync({ projectDir: process.cwd() }); if (diff.upToDate) { - console.log("✓ Cloud sync is up to date (0 pending diffs)\n"); + console.log("✓ Cloud sync is up to date (0 pending diffs)"); } else { - console.log(`✓ Automatically synced ${diff.syncedCount} turns to xtctx cloud!\n`); + console.log(`✓ Automatically synced ${diff.syncedCount} turns to xtctx cloud!`); } } catch (err: unknown) { - console.warn(`⚠️ Note: Cloud sync deferred (${err instanceof Error ? err.message : String(err)})\n`); + console.warn(`⚠️ Note: Cloud sync deferred (${err instanceof Error ? err.message : String(err)})`); } + + // Automatically start detached background sync daemon + try { + const daemon = await ensureDaemonRunning(); + if (daemon.started) { + console.log(`✓ Background sync daemon started (PID: ${daemon.pid})`); + } else if (daemon.pid) { + console.log(`✓ Background sync daemon is active (PID: ${daemon.pid})`); + } + } catch { + // Ignore + } + console.log(); return; } diff --git a/src/runtime/background.ts b/src/runtime/background.ts index 040b1c7..9615436 100644 --- a/src/runtime/background.ts +++ b/src/runtime/background.ts @@ -70,11 +70,16 @@ async function syncToCloudIfAuthenticated(log: (line: string) => void): Promise< const creds = await loadCredentials(); if (!creds) return; + // 1. Run quick incremental diff sync const { runDiffSync } = await import("../sync/diff-sync.js"); const result = await runDiffSync({ projectDir: process.cwd() }); if (!result.upToDate && result.syncedCount > 0) { log(`xtctx: synced ${result.syncedCount} new turns to cloud`); } + + // 2. Ensure real-time background watcher daemon is running (self-healing) + const { ensureDaemonRunning } = await import("../sync/daemon-manager.js"); + await ensureDaemonRunning(); } catch (err: unknown) { // Non-blocking: background sync failure should never crash the MCP server log(`xtctx: background cloud sync skipped: ${err instanceof Error ? err.message : String(err)}`); diff --git a/src/sync/daemon-manager.ts b/src/sync/daemon-manager.ts new file mode 100644 index 0000000..9c132c7 --- /dev/null +++ b/src/sync/daemon-manager.ts @@ -0,0 +1,133 @@ +import { spawn } from "node:child_process"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { existsSync, openSync, closeSync } from "node:fs"; +import { readFile, writeFile, unlink, mkdir } from "node:fs/promises"; +import { loadCredentials } from "./client.js"; + +function getXtctxDir(): string { + return join(homedir(), ".xtctx"); +} + +function getPidPath(): string { + return join(getXtctxDir(), "daemon.pid"); +} + +export function getDaemonLogPath(): string { + return join(getXtctxDir(), "daemon.log"); +} + +/** + * Check if the daemon process with the given PID is currently active. + */ +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** + * Get the current status of the background sync daemon. + */ +export async function getDaemonStatus(): Promise<{ running: boolean; pid?: number; logPath: string }> { + const pidPath = getPidPath(); + const logPath = getDaemonLogPath(); + + if (!existsSync(pidPath)) { + return { running: false, logPath }; + } + + try { + const rawPid = await readFile(pidPath, "utf-8"); + const pid = parseInt(rawPid.trim(), 10); + + if (isNaN(pid) || !isProcessAlive(pid)) { + // Stale PID file, clean it up + await unlink(pidPath).catch(() => {}); + return { running: false, logPath }; + } + + return { running: true, pid, logPath }; + } catch { + return { running: false, logPath }; + } +} + +/** + * Stop the running background sync daemon. + */ +export async function stopDaemon(): Promise { + const status = await getDaemonStatus(); + if (!status.running || !status.pid) { + return false; + } + + try { + if (process.platform === "win32") { + // On Windows, taskkill cleanly kills process tree + const { exec } = await import("node:child_process"); + await new Promise((resolve) => { + exec(`taskkill /pid ${status.pid} /T /F`, () => resolve()); + }); + } else { + process.kill(status.pid, "SIGTERM"); + } + } catch { + // Ignore error if process already exited + } + + await unlink(getPidPath()).catch(() => {}); + return true; +} + +/** + * Ensure the background sync daemon is running if the user is authenticated. + * Spawns a detached background process if it is not running. + */ +export async function ensureDaemonRunning(cliPath?: string): Promise<{ started: boolean; pid?: number }> { + // Only auto-spawn if authenticated + const creds = await loadCredentials(); + if (!creds) { + return { started: false }; + } + + const status = await getDaemonStatus(); + if (status.running && status.pid) { + return { started: false, pid: status.pid }; + } + + const xtctxDir = getXtctxDir(); + if (!existsSync(xtctxDir)) { + await mkdir(xtctxDir, { recursive: true }); + } + + const logPath = getDaemonLogPath(); + const logFd = openSync(logPath, "a"); + + const entrypoint = cliPath || process.argv[1] || resolve(import.meta.dirname, "../cli/index.js"); + + const child = spawn(process.execPath, [entrypoint, "watch"], { + detached: true, + stdio: ["ignore", logFd, logFd], + windowsHide: true, + }); + + child.unref(); + + // Close parent's handle to logfile + try { + closeSync(logFd); + } catch { + // Ignore + } + + if (child.pid) { + await writeFile(getPidPath(), String(child.pid), "utf-8"); + return { started: true, pid: child.pid }; + } + + return { started: false }; +} From dd94c032d815044f3c9e202486de96fbe3b57881 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 30 Sep 2026 22:09:29 +0100 Subject: [PATCH 11/19] feat(sync): support JWT decoding and dynamic device identity for env-configured sync --- src/sync/client.ts | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/sync/client.ts b/src/sync/client.ts index 8abe6c8..f474bdb 100644 --- a/src/sync/client.ts +++ b/src/sync/client.ts @@ -24,11 +24,31 @@ export function getCredentialsPath(): string { export async function loadCredentials(): Promise { const credPath = getCredentialsPath(); if (process.env.XTCTX_TOKEN) { + const token = process.env.XTCTX_TOKEN; + let userId = "env-user"; + let username = "developer"; + let deviceId = process.env.XTCTX_DEVICE_ID; + + try { + const parts = token.split("."); + if (parts.length >= 2) { + const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8")); + if (payload.sub) userId = payload.sub; + if (payload.username) username = payload.username; + if (payload.device_id && !deviceId) deviceId = payload.device_id; + } + } catch { + // Ignore JWT decode errors and use defaults + } + + const { hostname } = await import("node:os"); + const machineName = hostname(); + return { - token: process.env.XTCTX_TOKEN, - user: { id: "env-user", username: "developer" }, - deviceId: "env-device", - deviceName: "cli-env", + token, + user: { id: userId, username }, + deviceId: deviceId || `device-${machineName.toLowerCase().replace(/[^a-z0-9_-]/g, "")}`, + deviceName: process.env.XTCTX_DEVICE_NAME || machineName, syncUrl: process.env.XTCTX_SYNC_URL || DEFAULT_SYNC_URL, }; } From f68469e64555b44e5ff49437eda8a93bb0e3d71a Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Thu, 1 Oct 2026 03:12:16 +0100 Subject: [PATCH 12/19] fix(cloud): fail closed without JWT_SECRET, header-only revocable tokens, logout and delete-my-data, SSE in a Durable Object No built-in signing secret: without JWT_SECRET every route but /health answers 500. Tokens come from the Authorization header only, last 30 days, and carry the user's token_version, so POST /auth/logout revokes them. DELETE /api/me removes a user's rows and account. Errors return a generic body and log the detail. CORS is off unless ALLOWED_ORIGINS lists the origin. Ingest validates its body and scopes device ids to the user. SSE streams live in a SseSession Durable Object so /message works from any isolate. Needs migrations/0001 applied before deploy. --- cloud/migrations/0001_token_version.sql | 7 + cloud/package-lock.json | 1275 ++++++++++++++++++++++- cloud/package.json | 3 + cloud/schema.sql | 3 +- cloud/src/auth.ts | 142 ++- cloud/src/db.ts | 78 +- cloud/src/index.ts | 335 +++--- cloud/src/mcp.ts | 5 +- cloud/src/sse-session.ts | 57 + cloud/src/types.ts | 5 + cloud/test/fake-env.ts | 69 ++ cloud/test/worker.test.ts | 227 ++++ cloud/vitest.config.ts | 3 + cloud/wrangler.toml | 12 +- 14 files changed, 1907 insertions(+), 314 deletions(-) create mode 100644 cloud/migrations/0001_token_version.sql create mode 100644 cloud/src/sse-session.ts create mode 100644 cloud/test/fake-env.ts create mode 100644 cloud/test/worker.test.ts create mode 100644 cloud/vitest.config.ts diff --git a/cloud/migrations/0001_token_version.sql b/cloud/migrations/0001_token_version.sql new file mode 100644 index 0000000..dc0cd76 --- /dev/null +++ b/cloud/migrations/0001_token_version.sql @@ -0,0 +1,7 @@ +-- Revocation: a token carries the user's token_version when it is minted and +-- is refused once the user's has moved on (logout bumps it). +-- +-- Additive, and safe to run once on a database created from the earlier +-- schema.sql. A database created from the current schema.sql already has the +-- column and must not run this. +ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0; diff --git a/cloud/package-lock.json b/cloud/package-lock.json index 415dca9..fc49e45 100644 --- a/cloud/package-lock.json +++ b/cloud/package-lock.json @@ -12,7 +12,9 @@ }, "devDependencies": { "@cloudflare/workers-types": "^5.20260930.2", + "@types/node": "^24.19.0", "typescript": "^5.8.0", + "vitest": "^4.1.11", "wrangler": "^4.145.0" } }, @@ -1255,6 +1257,16 @@ } } }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, "node_modules/@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -1284,6 +1296,286 @@ "dev": true, "license": "MIT" }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@sindresorhus/is": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", @@ -1304,6 +1596,161 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz", + "integrity": "sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -1350,6 +1797,16 @@ } } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -1432,6 +1889,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -1454,6 +1921,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -1596,6 +2070,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -1656,6 +2137,16 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -1686,6 +2177,16 @@ "node": ">=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -1770,6 +2271,24 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -1963,59 +2482,342 @@ "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", "license": "MIT", "engines": { - "node": ">= 12" + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "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/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.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "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/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "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/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.12", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", - "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", - "license": "MIT", + "node": ">= 12.0.0" + }, "funding": { - "url": "https://github.com/sponsors/panva" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "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/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6" + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/math-intrinsics": { @@ -2101,6 +2903,25 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/negotiator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", @@ -2151,6 +2972,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2207,6 +3042,26 @@ "dev": true, "license": "MIT" }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -2216,6 +3071,35 @@ "node": ">=16.20.0" } }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/proxy-addr": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", @@ -2286,6 +3170,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.152.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -2515,6 +3433,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2524,6 +3466,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, "node_modules/supports-color": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", @@ -2537,6 +3486,50 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -2609,6 +3602,13 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, "node_modules/unenv": { "version": "2.0.0-rc.24", "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", @@ -2637,6 +3637,174 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.9", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2652,6 +3820,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/workerd": { "version": "1.20260930.2", "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260930.2.tgz", diff --git a/cloud/package.json b/cloud/package.json index 141d1ee..a60da29 100644 --- a/cloud/package.json +++ b/cloud/package.json @@ -10,11 +10,14 @@ "d1:create": "wrangler d1 create xtctx-db", "d1:migrate:local": "wrangler d1 execute xtctx-db --local --file=./schema.sql", "d1:migrate:remote": "wrangler d1 execute xtctx-db --remote --file=./schema.sql", + "test": "vitest run", "typecheck": "tsc --noEmit" }, "devDependencies": { "@cloudflare/workers-types": "^5.20260930.2", + "@types/node": "^24.19.0", "typescript": "^5.8.0", + "vitest": "^4.1.11", "wrangler": "^4.145.0" }, "dependencies": { diff --git a/cloud/schema.sql b/cloud/schema.sql index f3f2077..4217c30 100644 --- a/cloud/schema.sql +++ b/cloud/schema.sql @@ -6,7 +6,8 @@ CREATE TABLE IF NOT EXISTS users ( display_name TEXT, email TEXT, created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL + updated_at INTEGER NOT NULL, + token_version INTEGER NOT NULL DEFAULT 0 -- bumped on logout; see migrations/0001 ); CREATE TABLE IF NOT EXISTS devices ( diff --git a/cloud/src/auth.ts b/cloud/src/auth.ts index b55dbd9..cbd4a1d 100644 --- a/cloud/src/auth.ts +++ b/cloud/src/auth.ts @@ -1,85 +1,76 @@ import type { Env, AuthUser } from "./types.js"; +import { getTokenVersion } from "./db.js"; -const DEFAULT_JWT_SECRET = "xtctx-cloud-default-secret-change-in-production"; +/** Tokens last a month. Revocation (below) is what ends one sooner. */ +export const TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30; + +const encoder = new TextEncoder(); + +function toBase64Url(bytes: Uint8Array): string { + let binary = ""; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); +} + +function fromBase64Url(text: string): Uint8Array { + let base64 = text.replace(/-/g, "+").replace(/_/g, "/"); + while (base64.length % 4) base64 += "="; + const binary = atob(base64); + return Uint8Array.from(binary, (c) => c.charCodeAt(0)); +} + +function hmacKey(secret: string, usage: "sign" | "verify"): Promise { + return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [usage]); +} /** * Mint an HMAC-SHA256 JWT using standard Web Crypto API. */ -export async function createJwt(payload: Record, secretStr: string): Promise { - const encoder = new TextEncoder(); - const secretKey = await crypto.subtle.importKey( - "raw", - encoder.encode(secretStr), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"] - ); - - const header = { alg: "HS256", typ: "JWT" }; - const encodedHeader = btoa(JSON.stringify(header)).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); - - const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 90; // 90 days validity - const fullPayload = { ...payload, exp, iat: Math.floor(Date.now() / 1000) }; - const encodedPayload = btoa(JSON.stringify(fullPayload)).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); - - const dataToSign = encoder.encode(`${encodedHeader}.${encodedPayload}`); - const signatureBuffer = await crypto.subtle.sign("HMAC", secretKey, dataToSign); - - const signatureBytes = new Uint8Array(signatureBuffer); - let binary = ""; - for (let i = 0; i < signatureBytes.length; i++) { - binary += String.fromCharCode(signatureBytes[i]); - } - const encodedSignature = btoa(binary).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); +export async function createJwt( + payload: Record, + secret: string, + ttlSeconds = TOKEN_TTL_SECONDS, +): Promise { + const now = Math.floor(Date.now() / 1000); + const header = toBase64Url(encoder.encode(JSON.stringify({ alg: "HS256", typ: "JWT" }))); + const body = toBase64Url(encoder.encode(JSON.stringify({ ...payload, exp: now + ttlSeconds, iat: now }))); + const signature = await crypto.subtle.sign("HMAC", await hmacKey(secret, "sign"), encoder.encode(`${header}.${body}`)); + return `${header}.${body}.${toBase64Url(new Uint8Array(signature))}`; +} - return `${encodedHeader}.${encodedPayload}.${encodedSignature}`; +interface VerifiedToken extends AuthUser { + tokenVersion: number; } /** - * Verify an HMAC-SHA256 JWT using standard Web Crypto API. + * Check a token's signature and expiry. Says nothing about revocation; that + * needs the database, see `authenticateRequest`. */ -export async function verifyJwt(token: string, secretStr: string): Promise { +export async function verifyJwt(token: string, secret: string): Promise { try { const parts = token.split("."); if (parts.length !== 3) return null; + const [header, body, signature] = parts; - const [encodedHeader, encodedPayload, encodedSignature] = parts; - const encoder = new TextEncoder(); + if (JSON.parse(new TextDecoder().decode(fromBase64Url(header))).alg !== "HS256") return null; - const secretKey = await crypto.subtle.importKey( - "raw", - encoder.encode(secretStr), - { name: "HMAC", hash: "SHA-256" }, - false, - ["verify"] + const valid = await crypto.subtle.verify( + "HMAC", + await hmacKey(secret, "verify"), + fromBase64Url(signature), + encoder.encode(`${header}.${body}`), ); + if (!valid) return null; - // Decode signature from base64url - let base64 = encodedSignature.replace(/-/g, "+").replace(/_/g, "/"); - while (base64.length % 4) base64 += "="; - const binary = atob(base64); - const signatureBytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - signatureBytes[i] = binary.charCodeAt(i); - } - - const dataToVerify = encoder.encode(`${encodedHeader}.${encodedPayload}`); - const isValid = await crypto.subtle.verify("HMAC", secretKey, signatureBytes, dataToVerify); - if (!isValid) return null; - - // Decode payload - let payloadBase64 = encodedPayload.replace(/-/g, "+").replace(/_/g, "/"); - while (payloadBase64.length % 4) payloadBase64 += "="; - const payload = JSON.parse(atob(payloadBase64)); - - if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) { - return null; // Expired - } + const payload = JSON.parse(new TextDecoder().decode(fromBase64Url(body))); + if (typeof payload.sub !== "string" || typeof payload.exp !== "number") return null; + if (payload.exp < Math.floor(Date.now() / 1000)) return null; return { userId: payload.sub, - username: payload.username, + username: String(payload.username ?? ""), deviceId: payload.device_id, + tokenVersion: Number(payload.ver ?? -1), }; } catch { return null; @@ -87,25 +78,28 @@ export async function verifyJwt(token: string, secretStr: string): Promise { - const authHeader = request.headers.get("Authorization"); - const secret = env.JWT_SECRET || DEFAULT_JWT_SECRET; + if (!env.JWT_SECRET) return null; - if (authHeader && authHeader.startsWith("Bearer ")) { - const token = authHeader.slice(7).trim(); - return await verifyJwt(token, secret); - } + const header = request.headers.get("Authorization"); + if (!header?.startsWith("Bearer ")) return null; - // Also support token query param for SSE transports that cannot set custom headers - const url = new URL(request.url); - const tokenParam = url.searchParams.get("token"); - if (tokenParam) { - return await verifyJwt(tokenParam, secret); - } + const verified = await verifyJwt(header.slice(7).trim(), env.JWT_SECRET); + if (!verified) return null; + + const current = await getTokenVersion(env, verified.userId); + if (current === null || current !== verified.tokenVersion) return null; - return null; + return { userId: verified.userId, username: verified.username, deviceId: verified.deviceId }; } /** diff --git a/cloud/src/db.ts b/cloud/src/db.ts index 1084676..a5108da 100644 --- a/cloud/src/db.ts +++ b/cloud/src/db.ts @@ -17,25 +17,19 @@ export async function ingestTurnDelta( const nowTs = Date.now(); const statements: D1PreparedStatement[] = []; - // Ensure user record exists/updated - statements.push( - env.DB.prepare( - `INSERT INTO users (id, username, created_at, updated_at) - VALUES (?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET updated_at = excluded.updated_at` - ).bind(user.userId, user.username, nowTs, nowTs) - ); - for (const delta of deltas) { const sessionRef = `${user.userId}:${delta.tool}:${delta.sourceSessionId}`; const deviceName = delta.deviceName || delta.deviceId; + // Device ids come from the client and are the table's primary key, so two + // users picking the same one would otherwise share a row. Scoped to the user. + const deviceId = `${user.userId}:${delta.deviceId}`; statements.push( env.DB.prepare( `INSERT INTO devices (id, user_id, device_name, last_seen_at, created_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET last_seen_at = excluded.last_seen_at` - ).bind(delta.deviceId, user.userId, deviceName, nowTs, nowTs) + ).bind(deviceId, user.userId, deviceName, nowTs, nowTs) ); statements.push( @@ -54,7 +48,7 @@ export async function ingestTurnDelta( ).bind( sessionRef, user.userId, - delta.deviceId, + deviceId, delta.tool, delta.sourceSessionId, delta.repoUrl, @@ -179,3 +173,65 @@ export async function getSessionMessages( messages: messagesResult.results || [], }; } + +const ROLES = new Set(["user", "assistant", "system", "tool"]); +const MAX_BATCH = 200; + +/** + * Validate an ingest body. Returns null for anything malformed, so a bad + * request is a 400 and never reaches a query. + */ +export function parseTurnDeltas(input: unknown): StreamTurnDelta[] | null { + const list = Array.isArray(input) ? input : [input]; + if (list.length === 0 || list.length > MAX_BATCH) return null; + + const text = (v: unknown) => typeof v === "string" && v.length > 0; + for (const d of list as Record[]) { + if (typeof d !== "object" || d === null) return null; + if ( + !text(d.deviceId) || !text(d.tool) || !text(d.sourceSessionId) || !text(d.repoUrl) || + !text(d.projectRoot) || !text(d.timestamp) || !text(d.contentHash) || + typeof d.content !== "string" || !ROLES.has(d.role as string) || + !Number.isInteger(d.messageIndex) + ) { + return null; + } + } + return list as StreamTurnDelta[]; +} + +export async function upsertUser(env: Env, userId: string, username: string): Promise { + const now = Date.now(); + await env.DB.prepare( + `INSERT INTO users (id, username, created_at, updated_at) VALUES (?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET username = excluded.username, updated_at = excluded.updated_at` + ).bind(userId, username, now, now).run(); +} + +/** The user's current token version, or null when there is no such user. */ +export async function getTokenVersion(env: Env, userId: string): Promise { + const row = await env.DB.prepare(`SELECT token_version FROM users WHERE id = ?`) + .bind(userId).first<{ token_version: number }>(); + return row ? row.token_version : null; +} + +/** Invalidate every token issued to this user so far. */ +export async function bumpTokenVersion(env: Env, userId: string): Promise { + await env.DB.prepare(`UPDATE users SET token_version = token_version + 1 WHERE id = ?`).bind(userId).run(); +} + +/** + * Remove everything held for a user, children first, then the account row. + * With the row gone no token for it authenticates, so this also signs the user + * out everywhere. + */ +export async function deleteUserData(env: Env, userId: string): Promise { + const owned = `(SELECT session_ref FROM sessions WHERE user_id = ?)`; + await env.DB.batch([ + env.DB.prepare(`DELETE FROM retrieval_units WHERE session_ref IN ${owned}`).bind(userId), + env.DB.prepare(`DELETE FROM messages WHERE session_ref IN ${owned}`).bind(userId), + env.DB.prepare(`DELETE FROM sessions WHERE user_id = ?`).bind(userId), + env.DB.prepare(`DELETE FROM devices WHERE user_id = ?`).bind(userId), + env.DB.prepare(`DELETE FROM users WHERE id = ?`).bind(userId), + ]); +} diff --git a/cloud/src/index.ts b/cloud/src/index.ts index 517a7b2..ba8485f 100644 --- a/cloud/src/index.ts +++ b/cloud/src/index.ts @@ -1,217 +1,190 @@ import type { Env } from "./types.js"; import { authenticateRequest, getProtectedResourceMetadata, createJwt } from "./auth.js"; -import { ingestTurnDelta } from "./db.js"; +import { + bumpTokenVersion, + deleteUserData, + getTokenVersion, + ingestTurnDelta, + parseTurnDeltas, + upsertUser, +} from "./db.js"; import { processJsonRpc } from "./mcp.js"; -// Active SSE client streams mapped by sessionId -const sseStreams = new Map; user: any }>(); - -function corsHeaders(extra: Record = {}) { - return { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, Mcp-Session-Id", - ...extra, - }; +export { SseSession } from "./sse-session.js"; + +const SESSION_ID = /^[0-9a-f-]{36}$/; + +/** + * CORS is for browsers, and nothing here is called from one: the CLI and MCP + * clients are not subject to it. So the default is no CORS headers at all, + * and an origin gets them only by being listed in ALLOWED_ORIGINS. + */ +function corsHeaders(request: Request, env: Env): Record { + const origin = request.headers.get("Origin"); + const allowed = (env.ALLOWED_ORIGINS ?? "").split(",").map((o) => o.trim()).filter(Boolean); + const headers: Record = { Vary: "Origin" }; + if (origin && allowed.includes(origin)) { + headers["Access-Control-Allow-Origin"] = origin; + headers["Access-Control-Allow-Methods"] = "GET, POST, DELETE, OPTIONS"; + headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, Mcp-Session-Id"; + } + return headers; } export default { async fetch(request: Request, env: Env): Promise { + const cors = corsHeaders(request, env); + const json = (body: unknown, status = 200, extra: Record = {}) => + new Response(JSON.stringify(body), { + status, + headers: { ...cors, "Content-Type": "application/json", ...extra }, + }); + try { const url = new URL(request.url); if (request.method === "OPTIONS") { - return new Response(null, { headers: corsHeaders() }); + return new Response(null, { status: 204, headers: cors }); } - // 1. Health check & status - if (url.pathname === "/" || url.pathname === "/health") { - return new Response(JSON.stringify({ - status: "ok", - service: "xtctx-cloud", - version: "0.22.0", - mcp: { - supportedVersions: ["2026-07-28", "2024-11-05"], - endpoints: { - stateless: "/mcp", - sse: "/sse" - } - } - }, null, 2), { - headers: corsHeaders({ "Content-Type": "application/json" }) - }); - } - - // 2. OAuth Discovery (RFC 9728) for MCP Clients - if (url.pathname === "/.well-known/oauth-protected-resource") { - return new Response(JSON.stringify(getProtectedResourceMetadata(url.origin), null, 2), { - headers: corsHeaders({ "Content-Type": "application/json" }) - }); - } + if (url.pathname === "/" || url.pathname === "/health") { + return json({ + status: "ok", + service: "xtctx-cloud", + mcp: { + supportedVersions: ["2026-07-28", "2024-11-05"], + endpoints: { stateless: "/mcp", sse: "/sse" }, + }, + }); + } - // 3. GitHub Device Authorization Flow - if (url.pathname === "/auth/device/code" && request.method === "POST") { - const clientId = env.GITHUB_CLIENT_ID; - const ghRes = await fetch("https://github.com/login/device/code", { - method: "POST", - headers: { - "Accept": "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - client_id: clientId, - scope: "read:user user:email", - }), - }); - const data = await ghRes.json(); - return new Response(JSON.stringify(data), { - headers: corsHeaders({ "Content-Type": "application/json" }) - }); - } + // Without a signing secret nothing below can be trusted, so none of it + // runs. There used to be a built-in default, which made every token + // forgeable on a deployment that forgot to set one. + if (!env.JWT_SECRET) { + console.error("JWT_SECRET is not set; refusing to serve"); + return json({ error: "server_misconfigured" }, 500); + } - if (url.pathname === "/auth/device/poll" && request.method === "POST") { - const body = await request.json() as { device_code: string; device_name?: string }; - const clientId = env.GITHUB_CLIENT_ID; - - const ghRes = await fetch("https://github.com/login/oauth/access_token", { - method: "POST", - headers: { - "Accept": "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - client_id: clientId, - device_code: body.device_code, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }), - }); + if (url.pathname === "/.well-known/oauth-protected-resource") { + return json(getProtectedResourceMetadata(url.origin)); + } - const data = await ghRes.json() as { access_token?: string; error?: string }; - if (!data.access_token) { - return new Response(JSON.stringify(data), { - status: 400, - headers: corsHeaders({ "Content-Type": "application/json" }) + if (url.pathname === "/auth/device/code" && request.method === "POST") { + const ghRes = await fetch("https://github.com/login/device/code", { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ client_id: env.GITHUB_CLIENT_ID, scope: "read:user user:email" }), }); + return json(await ghRes.json()); } - // Fetch user profile from GitHub - const userRes = await fetch("https://api.github.com/user", { - headers: { - "Authorization": `Bearer ${data.access_token}`, - "User-Agent": "xtctx-cloud", - }, - }); - const ghUser = await userRes.json() as { id: number; login: string; name?: string; email?: string }; - - // Mint xtctx JWT - const secret = env.JWT_SECRET || "xtctx-cloud-default-secret-change-in-production"; - const token = await createJwt({ - sub: `github:${ghUser.id}`, - username: ghUser.login, - device_id: body.device_name || "default", - }, secret); - - return new Response(JSON.stringify({ - token, - user: { - id: `github:${ghUser.id}`, - username: ghUser.login, - name: ghUser.name, - } - }), { - headers: corsHeaders({ "Content-Type": "application/json" }) - }); - } - - // 4. Authenticate all protected API & MCP routes - const user = await authenticateRequest(request, env); - if (!user) { - return new Response(JSON.stringify({ error: "Unauthorized: Invalid or missing token" }), { - status: 401, - headers: corsHeaders({ - "Content-Type": "application/json", - "WWW-Authenticate": 'Bearer realm="xtctx-cloud", error="invalid_token"' - }) - }); - } + if (url.pathname === "/auth/device/poll" && request.method === "POST") { + const body = (await request.json()) as { device_code?: string; device_name?: string }; + if (typeof body.device_code !== "string") return json({ error: "invalid_request" }, 400); + + const ghRes = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ + client_id: env.GITHUB_CLIENT_ID, + device_code: body.device_code, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + const data = (await ghRes.json()) as { access_token?: string; error?: string }; + if (!data.access_token) return json(data, 400); - // 5. Real-Time Stream Ingestion (Called by local xtctx daemon) - if (url.pathname === "/api/stream" && request.method === "POST") { - const delta = await request.json() as any; - const result = await ingestTurnDelta(env, user, delta); - return new Response(JSON.stringify(result), { - headers: corsHeaders({ "Content-Type": "application/json" }) - }); - } + const userRes = await fetch("https://api.github.com/user", { + headers: { Authorization: `Bearer ${data.access_token}`, "User-Agent": "xtctx-cloud" }, + }); + const ghUser = (await userRes.json()) as { id?: number; login?: string; name?: string }; + if (!userRes.ok || typeof ghUser.id !== "number" || !ghUser.login) { + return json({ error: "github_user_unavailable" }, 502); + } - // 6. Modern MCP Endpoint (Stateless, 2026-07-28 Spec) - if (url.pathname === "/mcp" && request.method === "POST") { - const body = await request.json() as Record; - const responsePayload = await processJsonRpc(env, user, body); - if (!responsePayload) { - return new Response(null, { status: 204, headers: corsHeaders() }); + const userId = `github:${ghUser.id}`; + await upsertUser(env, userId, ghUser.login); + const token = await createJwt( + { + sub: userId, + username: ghUser.login, + device_id: body.device_name || "default", + ver: await getTokenVersion(env, userId), + }, + env.JWT_SECRET, + ); + return json({ token, user: { id: userId, username: ghUser.login, name: ghUser.name } }); } - return new Response(JSON.stringify(responsePayload), { - headers: corsHeaders({ "Content-Type": "application/json" }) - }); - } - // 7. Baseline MCP Endpoint (SSE Stream, 2024-11-05 Spec) - if (url.pathname === "/sse" && request.method === "GET") { - const sessionId = crypto.randomUUID(); - const { readable, writable } = new TransformStream(); - const writer = writable.getWriter(); - const encoder = new TextEncoder(); - - sseStreams.set(sessionId, { writer, user }); - - // Send endpoint event as required by baseline MCP SSE spec - const endpointUrl = `${url.origin}/message?sessionId=${sessionId}`; - void writer.write(encoder.encode(`event: endpoint\ndata: ${endpointUrl}\n\n`)); + const user = await authenticateRequest(request, env); + if (!user) { + return json({ error: "unauthorized" }, 401, { + "WWW-Authenticate": 'Bearer realm="xtctx-cloud", error="invalid_token"', + }); + } - request.signal.addEventListener("abort", () => { - sseStreams.delete(sessionId); - void writer.close().catch(() => {}); - }); + // Sign out everywhere: tokens carry the user's version, and this moves it. + if (url.pathname === "/auth/logout" && request.method === "POST") { + await bumpTokenVersion(env, user.userId); + return json({ success: true }); + } - return new Response(readable, { - headers: corsHeaders({ - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - "Connection": "keep-alive" - }) - }); - } + // Delete everything held for this user, then their account row, which + // also ends every token they hold. + if (url.pathname === "/api/me" && request.method === "DELETE") { + await deleteUserData(env, user.userId); + return json({ success: true }); + } - // 8. Baseline MCP Message POST Endpoint - if (url.pathname === "/message" && request.method === "POST") { - const sessionId = url.searchParams.get("sessionId"); - if (!sessionId || !sseStreams.has(sessionId)) { - return new Response("Session not found", { status: 404, headers: corsHeaders() }); + if (url.pathname === "/api/stream" && request.method === "POST") { + const deltas = parseTurnDeltas(await request.json()); + if (!deltas) return json({ error: "invalid_request" }, 400); + return json(await ingestTurnDelta(env, user, deltas)); } - const session = sseStreams.get(sessionId)!; - const body = await request.json() as Record; - const responsePayload = await processJsonRpc(env, session.user, body); + // Modern MCP endpoint (stateless, 2026-07-28 spec) + if (url.pathname === "/mcp" && request.method === "POST") { + const payload = await processJsonRpc(env, user, (await request.json()) as Record); + if (!payload) return new Response(null, { status: 204, headers: cors }); + return json(payload); + } - if (responsePayload) { - const encoder = new TextEncoder(); - void session.writer.write(encoder.encode(`event: message\ndata: ${JSON.stringify(responsePayload)}\n\n`)); + // Baseline MCP endpoint (SSE stream, 2024-11-05 spec). The stream is held + // in a Durable Object so /message reaches it from any isolate. + if (url.pathname === "/sse" && request.method === "GET") { + const sessionId = crypto.randomUUID(); + const stub = env.SSE.get(env.SSE.idFromName(sessionId)); + const stream = await stub.fetch("https://sse/connect", { + method: "POST", + body: JSON.stringify({ userId: user.userId, endpoint: `${url.origin}/message?sessionId=${sessionId}` }), + }); + return new Response(stream.body, { + headers: { ...cors, "Content-Type": "text/event-stream", "Cache-Control": "no-cache" }, + }); } - return new Response(JSON.stringify({ status: "accepted" }), { - headers: corsHeaders({ "Content-Type": "application/json" }) - }); - } + if (url.pathname === "/message" && request.method === "POST") { + const sessionId = url.searchParams.get("sessionId") ?? ""; + if (!SESSION_ID.test(sessionId)) return json({ error: "session_not_found" }, 404); + + const payload = await processJsonRpc(env, user, (await request.json()) as Record); + if (payload) { + const stub = env.SSE.get(env.SSE.idFromName(sessionId)); + const sent = await stub.fetch("https://sse/send", { + method: "POST", + body: JSON.stringify({ userId: user.userId, payload }), + }); + if (!sent.ok) return json({ error: "session_not_found" }, 404); + } + return json({ status: "accepted" }, 202); + } - return new Response("Not Found", { status: 404, headers: corsHeaders() }); + return json({ error: "not_found" }, 404); } catch (err: unknown) { - const errorMsg = err instanceof Error ? err.message : String(err); - const stack = err instanceof Error ? err.stack : undefined; - return new Response(JSON.stringify({ error: errorMsg, stack }), { - status: 500, - headers: corsHeaders({ "Content-Type": "application/json" }) - }); + // The detail is for whoever reads the Worker's logs, not for the caller. + console.error("unhandled error", err); + return json({ error: "internal_error" }, 500); } - } + }, }; diff --git a/cloud/src/mcp.ts b/cloud/src/mcp.ts index 03a689d..96473d3 100644 --- a/cloud/src/mcp.ts +++ b/cloud/src/mcp.ts @@ -169,12 +169,15 @@ export async function processJsonRpc( result }; } catch (err: unknown) { + // Detail goes to the Worker's log; the caller gets nothing that names + // a table or a query. + console.error("tool call failed", err); return { jsonrpc: "2.0", id, error: { code: -32000, - message: err instanceof Error ? err.message : String(err) + message: "Tool call failed" } }; } diff --git a/cloud/src/sse-session.ts b/cloud/src/sse-session.ts new file mode 100644 index 0000000..08a5c40 --- /dev/null +++ b/cloud/src/sse-session.ts @@ -0,0 +1,57 @@ +import type { Env } from "./types.js"; + +/** + * One baseline-MCP SSE stream, held in a Durable Object. + * + * `GET /sse` and the later `POST /message` are separate requests, and a + * Worker may serve them from different isolates, so the open stream cannot + * live in a module-level Map: a message that landed elsewhere found no stream. + * Both requests address the same object by session id instead, and the object + * is where the writer lives. + * + * The Worker does the authentication and runs the JSON-RPC call; this object + * only owns the stream and delivers what it is handed. + */ +export class SseSession { + private writer: WritableStreamDefaultWriter | undefined; + private userId: string | undefined; + + // The platform constructs this with (state, env); neither is needed. + constructor(_state?: DurableObjectState, _env?: Env) {} + + async fetch(request: Request): Promise { + const path = new URL(request.url).pathname; + const body = (await request.json()) as { userId: string; endpoint?: string; payload?: unknown }; + + if (path === "/connect") { + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); + this.writer = writer; + this.userId = body.userId; + // The client going away closes the stream; forget it so a late message + // is refused instead of written to nothing. + writer.closed.catch(() => undefined).finally(() => { + if (this.writer === writer) this.writer = undefined; + }); + void writer.write(new TextEncoder().encode(`event: endpoint\ndata: ${body.endpoint}\n\n`)).catch(() => undefined); + return new Response(readable, { + headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" }, + }); + } + + if (path === "/send") { + // The user check is the isolation between tenants: a session id alone + // must not let someone else write into this stream. + if (!this.writer || this.userId !== body.userId) return new Response("Session not found", { status: 404 }); + // Not awaited: a write completes when the client reads it, and one slow + // reader must not hold up the POST that delivered the message. + const writer = this.writer; + writer.write(new TextEncoder().encode(`event: message\ndata: ${JSON.stringify(body.payload)}\n\n`)).catch(() => { + if (this.writer === writer) this.writer = undefined; + }); + return new Response(null, { status: 202 }); + } + + return new Response("Not Found", { status: 404 }); + } +} diff --git a/cloud/src/types.ts b/cloud/src/types.ts index 165e1fe..95b68b5 100644 --- a/cloud/src/types.ts +++ b/cloud/src/types.ts @@ -1,8 +1,13 @@ export interface Env { DB: D1Database; + /** Holds the open baseline-MCP SSE streams; see sse-session.ts. */ + SSE: DurableObjectNamespace; GITHUB_CLIENT_ID: string; GITHUB_CLIENT_SECRET?: string; + /** Required. Requests are refused with a 500 while it is unset. */ JWT_SECRET?: string; + /** Comma-separated browser origins to send CORS headers to. Unset means none. */ + ALLOWED_ORIGINS?: string; ENVIRONMENT?: string; } diff --git a/cloud/test/fake-env.ts b/cloud/test/fake-env.ts new file mode 100644 index 0000000..47f2053 --- /dev/null +++ b/cloud/test/fake-env.ts @@ -0,0 +1,69 @@ +import { DatabaseSync } from "node:sqlite"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { SseSession } from "../src/sse-session.js"; +import type { Env } from "../src/types.js"; + +/** + * Just enough of D1 over an in-memory SQLite for the Worker's own queries. + * The real schema.sql is loaded, so a column the code needs and the schema + * lacks fails here the way it would in production. + */ +class FakeStatement { + constructor(private db: DatabaseSync, readonly sql: string, readonly params: unknown[] = []) {} + bind(...params: unknown[]) { + return new FakeStatement(this.db, this.sql, params); + } + async first() { + return ((this.db.prepare(this.sql).get(...(this.params as never[])) as T | undefined) ?? null) as T | null; + } + async all() { + return { results: this.db.prepare(this.sql).all(...(this.params as never[])) as T[] }; + } + async run() { + this.db.prepare(this.sql).run(...(this.params as never[])); + return { success: true }; + } +} + +function fakeD1(db: DatabaseSync) { + return { + prepare: (sql: string) => new FakeStatement(db, sql), + async batch(statements: FakeStatement[]) { + db.exec("BEGIN"); + try { + for (const s of statements) await s.run(); + db.exec("COMMIT"); + } catch (err) { + db.exec("ROLLBACK"); + throw err; + } + }, + }; +} + +/** Durable Object namespace: one object per name, shared by everyone holding the namespace. */ +function fakeNamespace() { + const objects = new Map(); + return { + idFromName: (name: string) => name, + get: (id: string) => { + if (!objects.has(id)) objects.set(id, new SseSession()); + return { fetch: (input: string, init?: RequestInit) => objects.get(id)!.fetch(new Request(input, init)) }; + }, + }; +} + +export function createEnv(overrides: Partial> = {}) { + const db = new DatabaseSync(":memory:"); + db.exec("PRAGMA foreign_keys = ON"); + db.exec(readFileSync(fileURLToPath(new URL("../schema.sql", import.meta.url)), "utf8")); + const env = { + DB: fakeD1(db), + SSE: fakeNamespace(), + GITHUB_CLIENT_ID: "test-client", + JWT_SECRET: "test-secret-for-the-worker-tests", + ...overrides, + } as unknown as Env; + return { env, db }; +} diff --git a/cloud/test/worker.test.ts b/cloud/test/worker.test.ts new file mode 100644 index 0000000..41f64f9 --- /dev/null +++ b/cloud/test/worker.test.ts @@ -0,0 +1,227 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import worker from "../src/index.js"; +import { createJwt } from "../src/auth.js"; +import { createEnv } from "./fake-env.js"; + +const OLD_DEFAULT_SECRET = "xtctx-cloud-default-secret-change-in-production"; + +function call(env: unknown, path: string, init: RequestInit & { token?: string } = {}) { + const { token, ...rest } = init; + const headers = new Headers(rest.headers); + if (token) headers.set("Authorization", `Bearer ${token}`); + return worker.fetch(new Request(`https://sync.test${path}`, { ...rest, headers }), env as never); +} + +/** The GitHub side of the device flow, answered as a user with this id. */ +function stubGitHub(id: number, login: string) { + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => + String(url).includes("oauth/access_token") + ? Response.json({ access_token: "gh-token" }) + : Response.json({ id, login, name: login }), + ), + ); +} + +async function login(env: unknown, id: number, name: string): Promise { + stubGitHub(id, name); + const res = await call(env, "/auth/device/poll", { + method: "POST", + body: JSON.stringify({ device_code: "dc", device_name: "laptop" }), + }); + expect(res.status).toBe(200); + return ((await res.json()) as { token: string }).token; +} + +const turn = (over: Record = {}) => ({ + deviceId: "laptop", + tool: "claude-code", + sourceSessionId: "s1", + repoUrl: "github.com/a/b", + projectRoot: "b", + timestamp: "2026-10-01T00:00:00.000Z", + role: "user", + content: "hello", + messageIndex: 0, + contentHash: "abcdef0123456789", + ...over, +}); + +const listTools = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }); + +afterEach(() => vi.unstubAllGlobals()); + +describe("without a signing secret", () => { + it("refuses everything but the health check, even for a token signed with the old built-in default", async () => { + const { env } = createEnv({ JWT_SECRET: undefined }); + const forged = await createJwt({ sub: "github:1", username: "x", ver: 0 }, OLD_DEFAULT_SECRET); + const spy = vi.spyOn(console, "error").mockImplementation(() => undefined); + + for (const path of ["/api/stream", "/mcp", "/sse", "/auth/device/code"]) { + const res = + path === "/sse" + ? await call(env, path, { token: forged }) + : await call(env, path, { method: "POST", token: forged, body: "{}" }); + expect(res.status, path).toBe(500); + expect(await res.json()).toEqual({ error: "server_misconfigured" }); + } + expect((await call(env, "/health")).status).toBe(200); + spy.mockRestore(); + }); +}); + +describe("authentication", () => { + it("does not take a token from the query string", async () => { + const { env } = createEnv(); + const token = await login(env, 1, "alice"); + + expect((await call(env, "/mcp", { method: "POST", token, body: listTools })).status).toBe(200); + expect((await call(env, `/mcp?token=${token}`, { method: "POST", body: listTools })).status).toBe(401); + }); + + it("stops accepting a token after logout", async () => { + const { env } = createEnv(); + const token = await login(env, 1, "alice"); + expect((await call(env, "/mcp", { method: "POST", token, body: listTools })).status).toBe(200); + + expect((await call(env, "/auth/logout", { method: "POST", token })).status).toBe(200); + + expect((await call(env, "/mcp", { method: "POST", token, body: listTools })).status).toBe(401); + // Signing in again gives a token that works. + const fresh = await login(env, 1, "alice"); + expect((await call(env, "/mcp", { method: "POST", token: fresh, body: listTools })).status).toBe(200); + }); +}); + +describe("delete my data", () => { + it("removes the caller's rows and tokens and leaves other users alone", async () => { + const { env, db } = createEnv(); + const alice = await login(env, 1, "alice"); + const bob = await login(env, 2, "bob"); + for (const token of [alice, bob]) { + const res = await call(env, "/api/stream", { method: "POST", token, body: JSON.stringify([turn()]) }); + expect(res.status).toBe(200); + } + const messages = (user: string) => + (db.prepare("SELECT count(*) n FROM messages WHERE session_ref LIKE ?").get(`${user}:%`) as { n: number }).n; + const owned = (table: string, user: string) => + (db.prepare(`SELECT count(*) n FROM ${table} WHERE user_id = ?`).get(user) as { n: number }).n; + expect(messages("github:1")).toBe(1); + + expect((await call(env, "/api/me", { method: "DELETE", token: alice })).status).toBe(200); + + expect(messages("github:1")).toBe(0); + for (const table of ["sessions", "devices"]) expect(owned(table, "github:1"), table).toBe(0); + expect(db.prepare("SELECT count(*) n FROM users WHERE id = 'github:1'").get()).toEqual({ n: 0 }); + expect(messages("github:2")).toBe(1); + expect((await call(env, "/mcp", { method: "POST", token: alice, body: listTools })).status).toBe(401); + }); +}); + +describe("ingest", () => { + it("answers 400 to a malformed body and 200 to a good one", async () => { + const { env } = createEnv(); + const token = await login(env, 1, "alice"); + const post = (body: unknown) => call(env, "/api/stream", { method: "POST", token, body: JSON.stringify(body) }); + + expect((await post([turn({ role: "wizard" })])).status).toBe(400); + expect((await post([turn({ messageIndex: "0" })])).status).toBe(400); + expect((await post([])).status).toBe(400); + expect((await post([turn()])).status).toBe(200); + }); + + it("keeps two users who pick the same device id apart", async () => { + const { env, db } = createEnv(); + const alice = await login(env, 1, "alice"); + const bob = await login(env, 2, "bob"); + for (const token of [alice, bob]) { + await call(env, "/api/stream", { method: "POST", token, body: JSON.stringify([turn()]) }); + } + const devices = db.prepare("SELECT id, user_id FROM devices ORDER BY user_id").all(); + expect(devices).toEqual([ + { id: "github:1:laptop", user_id: "github:1" }, + { id: "github:2:laptop", user_id: "github:2" }, + ]); + }); +}); + +describe("errors", () => { + it("returns a generic 500 with no message or stack", async () => { + const { env } = createEnv(); + const token = await login(env, 1, "alice"); + const spy = vi.spyOn(console, "error").mockImplementation(() => undefined); + + const res = await call(env, "/mcp", { method: "POST", token, body: "{ not json" }); + + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: "internal_error" }); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); +}); + +describe("CORS", () => { + it("sends no CORS headers by default, and only to a listed origin when configured", async () => { + const plain = createEnv().env; + const res = await call(plain, "/health", { headers: { Origin: "https://evil.example" } }); + expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull(); + + const configured = createEnv({ ALLOWED_ORIGINS: "https://app.example" }).env; + const ok = await call(configured, "/health", { headers: { Origin: "https://app.example" } }); + expect(ok.headers.get("Access-Control-Allow-Origin")).toBe("https://app.example"); + const other = await call(configured, "/health", { headers: { Origin: "https://evil.example" } }); + expect(other.headers.get("Access-Control-Allow-Origin")).toBeNull(); + }); +}); + +describe("SSE transport", () => { + async function openStream(env: unknown, token: string) { + const res = await call(env, "/sse", { token }); + expect(res.status).toBe(200); + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + const next = async () => decoder.decode((await reader.read()).value); + const endpoint = /data: (\S+)/.exec(await next())![1]; + return { endpoint: new URL(endpoint), next, reader }; + } + + it("delivers a message posted through a separately loaded copy of the Worker", async () => { + const { env } = createEnv(); + const token = await login(env, 1, "alice"); + const { endpoint, next, reader } = await openStream(env, token); + + // Another isolate: a fresh module instance, sharing only the bindings. + vi.resetModules(); + const other = (await import("../src/index.js")).default; + const post = await other.fetch( + new Request(`https://sync.test${endpoint.pathname}${endpoint.search}`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify({ jsonrpc: "2.0", id: 7, method: "tools/list" }), + }), + env as never, + ); + expect(post.status).toBe(202); + + const event = await next(); + expect(event).toContain("event: message"); + expect(JSON.parse(/data: (.*)/.exec(event)![1]).id).toBe(7); + await reader.cancel(); + }); + + it("will not let another user write into someone's stream", async () => { + const { env } = createEnv(); + const alice = await login(env, 1, "alice"); + const bob = await login(env, 2, "bob"); + const { endpoint, reader } = await openStream(env, alice); + + const res = await call(env, `${endpoint.pathname}${endpoint.search}`, { + method: "POST", + token: bob, + body: listTools, + }); + expect(res.status).toBe(404); + await reader.cancel(); + }); +}); diff --git a/cloud/vitest.config.ts b/cloud/vitest.config.ts new file mode 100644 index 0000000..5539adc --- /dev/null +++ b/cloud/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ test: { include: ["test/**/*.test.ts"] } }); diff --git a/cloud/wrangler.toml b/cloud/wrangler.toml index 53303f4..c1a367b 100644 --- a/cloud/wrangler.toml +++ b/cloud/wrangler.toml @@ -16,11 +16,21 @@ binding = "DB" database_name = "xtctx-db" database_id = "f64a7fc9-60a3-41be-964f-efbca6897f88" +# Holds the open SSE streams for the baseline MCP transport. +[[durable_objects.bindings]] +name = "SSE" +class_name = "SseSession" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["SseSession"] + [vars] # Public GitHub OAuth App Client ID (for Device Flow) GITHUB_CLIENT_ID = "Ov23liFUTodqfoH9mgHA" ENVIRONMENT = "production" # Secrets to set via `wrangler secret put `: -# - JWT_SECRET: Secret key for signing user tokens +# - JWT_SECRET: REQUIRED. Key for signing user tokens; the Worker answers 500 until it is set. +# Optional var: ALLOWED_ORIGINS (comma-separated browser origins to send CORS headers to; none by default). # - GITHUB_CLIENT_SECRET: (Optional) If using Web Application flow instead of Device Flow From 1ae4243a7a3014a06c8daf5c5633f504c50acfbe Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Thu, 1 Oct 2026 03:23:26 +0100 Subject: [PATCH 13/19] feat(sync): cloud upload is opt-in per project, runs inside the MCP server, and the daemon is gone Logging in no longer uploads or starts anything. A project uploads only when it is on ~/.xtctx/cloud-projects.json (xtctx sync enable) AND someone is logged in; XTCTX_TOKEN alone does nothing. The MCP server syncs every 10s while it runs and once on shutdown; xtctx sync --watch is the foreground version. The machine-wide transcript tailer and the self-resurrecting daemon are removed, and uploads come from the project's own index. Also: credentials.json written 0600 and atomically; xtctx logout [--delete-data]; device-flow polling honours expires_in and slow_down; no .xtctx/project.id written; sync URL must be https (loopback excepted); the sync query selected sessions.status, which the index does not have, so it could never have uploaded; a (indexed_at, id) cursor so a batch boundary inside a same-timestamp run skips nothing; per-account cursor; folder name sent rather than the absolute path. Cloud Worker tests join verify:release and CI. --- .github/workflows/ci.yml | 7 ++ README.md | 4 +- cloud/README.md | 36 ++++-- docs/cloud-sync.md | 39 +++++++ docs/embedding-providers.md | 3 +- package.json | 3 +- scripts/check-review-coverage.mjs | 7 +- src/cli/index.ts | 83 ++++++-------- src/cli/login.ts | 184 ++++++++++++++++-------------- src/cli/sync.ts | 80 +++++++++++++ src/cli/watch.ts | 90 --------------- src/runtime/background.ts | 23 ---- src/sync/auto-sync.ts | 77 +++++++++++++ src/sync/client.ts | 78 ++++++------- src/sync/consent.ts | 49 ++++++++ src/sync/daemon-manager.ts | 133 --------------------- src/sync/diff-sync.ts | 91 +++++++++------ src/sync/normalizer.ts | 70 +++++------- src/sync/watcher.ts | 134 ---------------------- src/utils/atomic-file.ts | 4 +- tests/sync/auto-sync.test.ts | 69 +++++++++++ tests/sync/credentials.test.ts | 41 +++++++ tests/sync/diff-sync.test.ts | 127 +++++++++++++++++++++ tests/sync/helpers.ts | 52 +++++++++ tests/sync/login.test.ts | 79 +++++++++++++ 25 files changed, 906 insertions(+), 657 deletions(-) create mode 100644 docs/cloud-sync.md create mode 100644 src/cli/sync.ts delete mode 100644 src/cli/watch.ts create mode 100644 src/sync/auto-sync.ts create mode 100644 src/sync/consent.ts delete mode 100644 src/sync/daemon-manager.ts delete mode 100644 src/sync/watcher.ts create mode 100644 tests/sync/auto-sync.test.ts create mode 100644 tests/sync/credentials.test.ts create mode 100644 tests/sync/diff-sync.test.ts create mode 100644 tests/sync/helpers.ts create mode 100644 tests/sync/login.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85ad4ad..713d917 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,6 +100,7 @@ jobs: cache-dependency-path: | package-lock.json landing/package-lock.json + cloud/package-lock.json - name: Install root dependencies run: npm ci @@ -107,6 +108,9 @@ jobs: - name: Install landing dependencies run: npm --prefix landing ci + - name: Install cloud dependencies + run: npm --prefix cloud ci + # Every job here loads the real embedding model, so each one pulls the # 86MB MiniLM from HuggingFace: four jobs a run, and HuggingFace starts # answering 429. That blocked a merge on 2026-08-28 with nothing wrong @@ -133,6 +137,9 @@ jobs: - name: Typecheck (including the test tree) run: npm run typecheck + - name: Cloud Worker typecheck and tests + run: npm run test:cloud + - name: Root security tests and checklist run: | npm run test:security diff --git a/README.md b/README.md index 760e520..8d56be2 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,9 @@ startup hooks; others receive MCP config plus managed instructions only. ## Limits - xtctx is local-only by default: it never uploads transcripts and runs no - telemetry. A project can opt into an external embedding endpoint by writing + telemetry. Cloud sync exists but sends nothing until you log in *and* opt a + project in with `xtctx sync enable` ([`docs/cloud-sync.md`](docs/cloud-sync.md)). + A project can opt into an external embedding endpoint by writing one into `.xtctx/config.yaml`, in which case window text is sent there to be vectorized — never inferred from an environment variable, and `xtctx status` names the endpoint in full whenever one is configured. diff --git a/cloud/README.md b/cloud/README.md index dbb7b0c..d832be5 100644 --- a/cloud/README.md +++ b/cloud/README.md @@ -29,15 +29,19 @@ database_name = "xtctx-db" database_id = "your-database-id-here" ``` -### 3. Run Schema Migrations -For local testing: +### 3. Create the schema + +A new database: ```bash -npm run d1:migrate:local +npm run d1:migrate:remote ``` -For production: +That applies `schema.sql`, which already includes everything below. + +A database created from an earlier `schema.sql` needs the files in `migrations/`, **in order, before you deploy the code that uses them**: ```bash -npm run d1:migrate:remote +npx wrangler d1 execute xtctx-db --remote --file=./migrations/0001_token_version.sql ``` +Run each once. For local testing use `--local` instead of `--remote`. ### 4. Configure GitHub OAuth App 1. Go to [GitHub Developer Settings](https://github.com/settings/developers) -> **OAuth Apps** -> **New OAuth App**. @@ -46,11 +50,11 @@ npm run d1:migrate:remote 4. Enable **Device Flow** checkbox in the OAuth App settings. 5. Put your `Client ID` in `wrangler.toml` under `GITHUB_CLIENT_ID`. -### 5. Set JWT Secret +### 5. Set the JWT secret (required) ```bash npx wrangler secret put JWT_SECRET ``` -*(Enter any secure random string)* +Use a long random value, for example `openssl rand -base64 48`. There is no default: **until it is set, every route except `/health` answers 500.** Changing it later signs everyone out. ### 6. Deploy to Cloudflare ```bash @@ -59,10 +63,20 @@ npm run deploy --- +## Security notes + +- Tokens are accepted from the `Authorization` header only, last 30 days, and are revoked by `POST /auth/logout` (all of an account's tokens) or `DELETE /api/me` (which also deletes the account's data). +- Browsers get no CORS headers unless their origin is listed in the `ALLOWED_ORIGINS` variable (comma-separated). The CLI and MCP clients do not need CORS. +- Errors return a generic body; the detail goes to the Worker's log. +- The baseline-MCP `/sse` streams live in the `SseSession` Durable Object, so `/message` works from any isolate. +- `npm test` runs the Worker's tests against an in-memory SQLite. + +--- + ## Custom Domains (xtctx.com) In your Cloudflare dashboard (or `wrangler.toml`), map: - `mcp.xtctx.com` -> `xtctx-cloud` Worker (used by Cursor, Claude, Antigravity) -- `sync.xtctx.com` -> `xtctx-cloud` Worker (used by `xtctx watch` and `xtctx login`) +- `sync.xtctx.com` -> `xtctx-cloud` Worker (used by `xtctx login` and `xtctx sync`) --- @@ -73,10 +87,12 @@ In your Cloudflare dashboard (or `wrangler.toml`), map: xtctx login ``` -### Start Real-Time Watcher +### Choose what uploads ```bash -xtctx watch +xtctx sync enable # run in a project: opt it in +xtctx logout # revoke tokens; add --delete-data to erase your uploads ``` +See [`docs/cloud-sync.md`](../docs/cloud-sync.md) for what is sent and when. ### Configure Cursor (`.cursor/mcp.json`) or Claude Desktop ```json diff --git a/docs/cloud-sync.md b/docs/cloud-sync.md new file mode 100644 index 0000000..bde07ea --- /dev/null +++ b/docs/cloud-sync.md @@ -0,0 +1,39 @@ +# Cloud sync + +Optional. xtctx stays local unless you do both of these: + +1. **Log in:** `xtctx login` signs you in with GitHub and stores a token. It uploads nothing. +2. **Opt a project in:** `xtctx sync enable`, run in that project. Each project is its own decision. + +Either one alone sends nothing. `XTCTX_TOKEN` in the environment counts as logging in, not as opting in. + +## What is uploaded + +For an opted-in project, the messages in **that project's own index** (`.xtctx/state/xtctx.db`): each message's text, role, timestamp, tool and session id, the git branch and commit, and a project identity. The identity is the normalised git remote (`github.com/you/repo`), or a hash of the folder's location when there is no remote. The project's folder name is sent; its absolute path is not. + +Sessions that xtctx has not attributed to the project are never read. Nothing is written into your repository for sync. + +The opt-in list is `~/.xtctx/cloud-projects.json` and the login is `~/.xtctx/credentials.json` (readable by you only). Both live in your home directory, not in `.xtctx/config.yaml`, so a repository cannot opt its readers in by committing a file. + +## When it uploads + +- While an agent runs xtctx in an opted-in project, the MCP server uploads what is new every 10 seconds, and once more when it shuts down. Nothing runs when no agent does: there is no daemon. +- `xtctx sync` uploads once. `xtctx sync --watch` keeps doing it in the foreground until you press Ctrl+C. + +Uploads are incremental: a position is kept per account, so a message is sent once. + +## Commands + +| Command | What it does | +|---|---| +| `xtctx login` | Sign in with GitHub (device code). | +| `xtctx sync enable` / `disable` / `status` | Choose whether this project uploads. | +| `xtctx sync` | Upload now. `--watch` repeats every 10 seconds. | +| `xtctx logout` | Revoke every token for your account, then forget the local login. | +| `xtctx logout --delete-data` | Delete everything uploaded for your account first, then log out. | + +`disable` stops further uploads but does not delete what was already sent; use `logout --delete-data` for that. + +## Server + +The service is the Worker in [`cloud/`](../cloud/README.md). The sync URL must be `https`; plain `http` is accepted only for `localhost`. diff --git a/docs/embedding-providers.md b/docs/embedding-providers.md index 17aeff5..75480b4 100644 --- a/docs/embedding-providers.md +++ b/docs/embedding-providers.md @@ -31,7 +31,8 @@ local-only. It does not upload transcripts or run telemetry" is true of what xtctx does on its own and should say so: > xtctx is local-only by default: it never uploads transcripts and runs no -> telemetry. A project can opt into an external embedding endpoint, in which +> telemetry; cloud sync sends nothing until you log in and opt a project in. +> A project can opt into an external embedding endpoint, in which > case window text is sent there for vectorizing — `xtctx status` reports the > endpoint whenever one is configured. diff --git a/package.json b/package.json index 81e7e7c..d092a18 100644 --- a/package.json +++ b/package.json @@ -28,10 +28,11 @@ "demo:public": "node scripts/public-demo-smoke.mjs", "landing:dev": "npm --prefix landing run dev", "landing:build": "npm --prefix landing run build", + "test:cloud": "npm --prefix cloud run typecheck && npm --prefix cloud test", "landing:preview": "npm --prefix landing run preview", "prepare": "npm run build", "smoke:cli": "node dist/src/cli/index.js --help", - "verify:release": "npm run lint && npm run typecheck && npm test && npm run test:security && npm run security:checklist && npm run check:review-coverage && npm run check:workflows && npm run audit:production && npm --prefix landing audit && npm run test:drift && npm run test:integration && npm run test:smoke && npm run test:eval && npm pack --dry-run && npm run demo:public && npm run landing:build && npm run smoke:cli", + "verify:release": "npm run lint && npm run typecheck && npm test && npm run test:cloud && npm run test:security && npm run security:checklist && npm run check:review-coverage && npm run check:workflows && npm run audit:production && npm --prefix landing audit && npm run test:drift && npm run test:integration && npm run test:smoke && npm run test:eval && npm pack --dry-run && npm run demo:public && npm run landing:build && npm run smoke:cli", "sync:version": "node scripts/sync-version.mjs", "version": "node scripts/sync-version.mjs", "test:eval": "vitest run tests/eval", diff --git a/scripts/check-review-coverage.mjs b/scripts/check-review-coverage.mjs index ca1b97f..c3dc66c 100644 --- a/scripts/check-review-coverage.mjs +++ b/scripts/check-review-coverage.mjs @@ -46,7 +46,7 @@ const LAYERS = [ name: "product-source", question: "Is it correct? What input makes it do the wrong thing?", why: "The ordinary review. It is listed first so it is visibly not the only one.", - match: [/^src\//], + match: [/^src\//, /^cloud\/src\//], }, { name: "test-suite", @@ -55,7 +55,7 @@ const LAYERS = [ why: "Reading tests does not find this. Two were found only by hardcoding a value in the source " + "and seeing the suite stay green — one of them covering a feature that was broken at the time.", - match: [/^tests\//, /^vitest\.config\.ts$/], + match: [/^tests\//, /^cloud\/test\//, /^vitest\.config\.ts$/, /^cloud\/vitest\.config\.ts$/], }, { name: "gates", @@ -91,6 +91,7 @@ const LAYERS = [ // same blank region as no layer at all. /^(?!design-direction\.md$|ux-walkthrough\.md$)[^/]+\.md$/, /^docs\//, + /^cloud\/README\.md$/, /^LICENSE$/, /^\.xtctx\//, // Written by xtctx into its own repository, and read by an agent — a @@ -120,6 +121,8 @@ const LAYERS = [ /^\.gitignore$/, /^\.gitattributes$/, /^\.github\/dependabot\.yml$/, + // The Worker's deploy surface: what it binds, what its database looks like. + /^cloud\/(wrangler\.toml|package(-lock)?\.json|tsconfig\.json|schema\.sql|migrations\/.*)$/, ], }, ]; diff --git a/src/cli/index.ts b/src/cli/index.ts index eb3bbc1..c8493b9 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -6,8 +6,9 @@ import { runHook } from "./hook.js"; import { runScan } from "./scan.js"; import { runSetup } from "./setup.js"; import { runStatus } from "./status.js"; -import { runLogin } from "./login.js"; -import { runSync } from "./watch.js"; +import { runLogin, runLogout } from "./login.js"; +import { runSync, runSyncSetting } from "./sync.js"; +import { startAutoSync, type AutoSync } from "../sync/auto-sync.js"; import { createProjectServices } from "../runtime/services.js"; import { startMcpServer } from "../mcp/server.js"; import { readXtctxPackage } from "../utils/package-info.js"; @@ -23,6 +24,7 @@ export async function main(argv = process.argv): Promise { // like a configured project with no history. const unconfiguredProjectRoot = services.config.present ? undefined : services.projectRoot; let closed = false; + let autoSync: AutoSync | undefined; const shutdown = (exit: boolean) => { if (closed) return; closed = true; @@ -37,14 +39,18 @@ export async function main(argv = process.argv): Promise { // So give the clean close a moment, then leave. Nothing is lost by not // waiting: the index is derived data, every chunk is committed as it is // written, and an unfinished scan simply resumes on the next run. - const graceMs = 2_000; + // The cloud flush has its own bound, added on top: it is a network call, + // not the scan this grace window exists to cut short. + const graceMs = 2_000 + (autoSync ? 1_500 : 0); const timer = setTimeout(() => { if (exit) process.exit(0); }, graceMs); timer.unref?.(); - void services.sessions - .close() + // Upload what the last interval left, then close the index it reads. + void (autoSync?.stop() ?? Promise.resolve()) + .catch(() => {}) + .then(() => services.sessions.close()) .catch(() => {}) .finally(() => { clearTimeout(timer); @@ -100,6 +106,12 @@ export async function main(argv = process.argv): Promise { // 19GB Codex store, and the cursor design keeps it from re-reading. if (!unconfiguredProjectRoot && !services.config.error) { void runBackgroundWork({ sessions: services.sessions }); + // Uploads only when logged in and this project is opted in; see auto-sync.ts. + autoSync = startAutoSync({ + projectRoot: services.projectRoot, + log: (line) => process.stderr.write(`${line} +`), + }); } return; } @@ -163,63 +175,32 @@ export async function main(argv = process.argv): Promise { .command("login") .option("--sync-url ", "Sync server URL (default: https://sync.xtctx.com)") .option("--device ", "Friendly name for this device") - .description("Authenticate this machine with xtctx cloud via GitHub Device Flow") + .description("Sign in to xtctx cloud with GitHub (uploads nothing by itself)") .action(async (options: { syncUrl?: string; device?: string }) => { await runLogin({ syncUrl: options.syncUrl, deviceName: options.device }); }); program - .command("sync") - .option("-p, --project ", "Project root (defaults to cwd)") - .option("-w, --watch", "Keep running and stream new turns continuously in real time", false) - .description("Sync local transcript diffs to xtctx cloud") - .action(async (options: { project?: string; watch?: boolean }) => { - const globalOptions = program.opts<{ project?: string }>(); - await runSync({ projectDir: options.project ?? globalOptions.project, watch: options.watch }); + .command("logout") + .option("--delete-data", "Also delete everything uploaded to your cloud account", false) + .description("Sign out of xtctx cloud and revoke this account's tokens") + .action(async (options: { deleteData?: boolean }) => { + await runLogout({ deleteData: options.deleteData }); }); program - .command("watch") + .command("sync") + .argument("[action]", "enable, disable or status for this project; omit to upload once") .option("-p, --project ", "Project root (defaults to cwd)") - .description("Keep running and stream new turns to xtctx cloud continuously in real time") - .action(async (options: { project?: string }) => { + .option("-w, --watch", "Keep uploading every few seconds until interrupted", false) + .description("Cloud sync: choose whether this project uploads, or upload now") + .action(async (action: string | undefined, options: { project?: string; watch?: boolean }) => { const globalOptions = program.opts<{ project?: string }>(); - await runSync({ projectDir: options.project ?? globalOptions.project, watch: true }); - }); - - program - .command("daemon") - .argument("[action]", "start, stop, or status (default: status)", "status") - .description("Inspect or control the background real-time sync daemon") - .action(async (action: string) => { - const { getDaemonStatus, ensureDaemonRunning, stopDaemon, getDaemonLogPath } = await import( - "../sync/daemon-manager.js" - ); - - if (action === "start") { - const res = await ensureDaemonRunning(); - if (res.started) { - console.log(`✓ Started background sync daemon (PID: ${res.pid})`); - } else if (res.pid) { - console.log(`✓ Background sync daemon is already running (PID: ${res.pid})`); - } else { - console.log(`❌ Could not start daemon (are you logged in? Run xtctx login)`); - } - } else if (action === "stop") { - const stopped = await stopDaemon(); - if (stopped) { - console.log(`✓ Stopped background sync daemon`); - } else { - console.log(`✓ Background sync daemon was not running`); - } + const projectDir = options.project ?? globalOptions.project; + if (action) { + await runSyncSetting(action, { projectDir }); } else { - const status = await getDaemonStatus(); - if (status.running) { - console.log(`✓ Background sync daemon: ACTIVE (PID: ${status.pid})`); - } else { - console.log(`○ Background sync daemon: STOPPED`); - } - console.log(`- Logs: ${getDaemonLogPath()}`); + await runSync({ projectDir, watch: options.watch }); } }); diff --git a/src/cli/login.ts b/src/cli/login.ts index 6a1d0c9..2c3a7f5 100644 --- a/src/cli/login.ts +++ b/src/cli/login.ts @@ -1,7 +1,14 @@ import { hostname } from "node:os"; -import { saveCredentials } from "../sync/client.js"; -import { runDiffSync } from "../sync/diff-sync.js"; -import { ensureDaemonRunning } from "../sync/daemon-manager.js"; +import { randomUUID } from "node:crypto"; +import { + DEFAULT_SYNC_URL, + assertSecureSyncUrl, + callCloud, + deleteCredentials, + getCredentialsPath, + loadCredentials, + saveCredentials, +} from "../sync/client.js"; interface DeviceCodeResponse { device_code: string; @@ -11,103 +18,112 @@ interface DeviceCodeResponse { interval: number; } -export async function runLogin(options: { syncUrl?: string; deviceName?: string }): Promise { - const syncUrl = options.syncUrl || process.env.XTCTX_SYNC_URL || "https://sync.xtctx.com"; +interface PollResponse { + token?: string; + user?: { id: string; username: string; name?: string }; + error?: string; + interval?: number; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** + * Sign in with GitHub's device flow. Signing in is only that: it stores a + * token. Nothing is uploaded until a project is opted in with + * `xtctx sync enable`. + */ +export async function runLogin(options: { + syncUrl?: string; + deviceName?: string; + /** Test seam; the real flow waits as long as GitHub says. */ + wait?: (ms: number) => Promise; +}): Promise { + const syncUrl = options.syncUrl || process.env.XTCTX_SYNC_URL || DEFAULT_SYNC_URL; const deviceName = options.deviceName || hostname() || "default-device"; + const wait = options.wait ?? sleep; + const base = syncUrl.replace(/\/$/, ""); - console.log(`\nConnecting to xtctx cloud at ${syncUrl}...`); + assertSecureSyncUrl(syncUrl); - let codeData: DeviceCodeResponse; - try { - const res = await fetch(`${syncUrl.replace(/\/$/, "")}/auth/device/code`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - }); - if (!res.ok) { - throw new Error(`Failed to request device authorization (${res.status}): ${await res.text()}`); - } - codeData = (await res.json()) as DeviceCodeResponse; - } catch (err: unknown) { - console.error("\n❌ Could not connect to auth service:", err instanceof Error ? err.message : String(err)); - process.exit(1); + const codeRes = await fetch(`${base}/auth/device/code`, { method: "POST" }); + if (!codeRes.ok) { + throw new Error(`Could not start login (${codeRes.status}).`); } + const code = (await codeRes.json()) as DeviceCodeResponse; - console.log("\n======================================================="); - console.log(` 1. Copy your one-time code: \x1b[1m\x1b[32m${codeData.user_code}\x1b[0m`); - console.log(` 2. Open in your browser: \x1b[34m${codeData.verification_uri}\x1b[0m`); - console.log("=======================================================\n"); + console.log(`\n 1. Copy your one-time code: ${code.user_code}`); + console.log(` 2. Open in your browser: ${code.verification_uri}\n`); + console.log("Waiting for authorization in the browser..."); - console.log("Waiting for authorization in browser..."); + // GitHub says how long the code lives and how often to ask; ignoring either + // gets the client rate-limited or leaves it waiting on a code that is dead. + const deadline = Date.now() + (code.expires_in || 900) * 1000; + let intervalMs = (code.interval || 5) * 1000; - const interval = (codeData.interval || 5) * 1000; - const pollUrl = `${syncUrl.replace(/\/$/, "")}/auth/device/poll`; - - while (true) { - await new Promise((r) => setTimeout(r, interval)); + while (Date.now() < deadline) { + await wait(intervalMs); + let poll: PollResponse; try { - const pollRes = await fetch(pollUrl, { + const res = await fetch(`${base}/auth/device/poll`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - device_code: codeData.device_code, - device_name: deviceName, - }), + body: JSON.stringify({ device_code: code.device_code, device_name: deviceName }), }); + poll = (await res.json()) as PollResponse; + } catch { + continue; // a network blip; the deadline bounds how long this goes on + } - const pollData = (await pollRes.json()) as { - token?: string; - user?: { id: string; username: string; name?: string }; - error?: string; - }; - - if (pollData.token && pollData.user) { - await saveCredentials({ - token: pollData.token, - user: pollData.user, - deviceId: `dev_${Date.now()}`, - deviceName, - syncUrl, - }); - - console.log(`\n✓ Successfully authenticated as \x1b[1m${pollData.user.username}\x1b[0m`); - console.log(`✓ Device registered as: \x1b[32m${deviceName}\x1b[0m`); - console.log("✓ Credentials saved to ~/.xtctx/credentials.json"); + if (poll.token && poll.user) { + await saveCredentials({ + token: poll.token, + user: poll.user, + deviceId: `dev_${randomUUID()}`, + deviceName, + syncUrl, + }); + console.log(`\nLogged in as ${poll.user.username}. Saved to ${getCredentialsPath()}.`); + console.log("Nothing is uploaded yet. To upload a project's transcripts, run in it: xtctx sync enable"); + return; + } - // Automatically trigger initial diff sync - console.log("\nSyncing existing local sessions to xtctx cloud..."); - try { - const diff = await runDiffSync({ projectDir: process.cwd() }); - if (diff.upToDate) { - console.log("✓ Cloud sync is up to date (0 pending diffs)"); - } else { - console.log(`✓ Automatically synced ${diff.syncedCount} turns to xtctx cloud!`); - } - } catch (err: unknown) { - console.warn(`⚠️ Note: Cloud sync deferred (${err instanceof Error ? err.message : String(err)})`); - } + if (poll.error === "slow_down") { + intervalMs = poll.interval ? poll.interval * 1000 : intervalMs + 5000; + } else if (poll.error && poll.error !== "authorization_pending") { + throw new Error(`Login failed: ${poll.error}`); + } + } + throw new Error("The login code expired before it was authorized. Run `xtctx login` again."); +} - // Automatically start detached background sync daemon - try { - const daemon = await ensureDaemonRunning(); - if (daemon.started) { - console.log(`✓ Background sync daemon started (PID: ${daemon.pid})`); - } else if (daemon.pid) { - console.log(`✓ Background sync daemon is active (PID: ${daemon.pid})`); - } - } catch { - // Ignore - } - console.log(); - return; - } +/** + * Sign out: ask the server to revoke this account's tokens, then forget ours. + * With deleteData it first deletes everything the account has uploaded, and + * keeps the local login if that fails so it can be retried. + */ +export async function runLogout(options: { deleteData?: boolean }): Promise { + const creds = await loadCredentials(); + if (!creds) { + console.log("Not logged in."); + return; + } - if (pollData.error && pollData.error !== "authorization_pending") { - console.error(`\n❌ Authentication error: ${pollData.error}`); - process.exit(1); - } - } catch { - // Continue polling until timeout or success + try { + const res = options.deleteData + ? await callCloud(creds, "DELETE", "/api/me") + : await callCloud(creds, "POST", "/auth/logout"); + // 401 means the server already refuses this token, which is the state we want. + if (!res.ok && res.status !== 401) throw new Error(`server answered ${res.status}`); + if (options.deleteData) console.log("Deleted everything xtctx cloud held for your account."); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (options.deleteData) { + throw new Error(`Could not delete your cloud data (${message}). You are still logged in; try again.`); } + console.warn(`Could not reach the server to revoke your token (${message}). It stays valid until it expires.`); } + + await deleteCredentials(); + console.log("Logged out."); } diff --git a/src/cli/sync.ts b/src/cli/sync.ts new file mode 100644 index 0000000..d4fce6a --- /dev/null +++ b/src/cli/sync.ts @@ -0,0 +1,80 @@ +import { resolve } from "node:path"; +import { loadCredentials } from "../sync/client.js"; +import { isOptedIn, setOptedIn } from "../sync/consent.js"; +import { NotLoggedInError, NotOptedInError, runDiffSync } from "../sync/diff-sync.js"; + +const WATCH_INTERVAL_MS = 10_000; + +/** + * Upload this project's new turns once, or with --watch keep doing it in the + * foreground until interrupted. Same upload as the MCP server's own, so there + * is nothing here that runs unless you ran it. + */ +export async function runSync(options: { projectDir?: string; watch?: boolean }): Promise { + const projectDir = resolve(options.projectDir ?? process.cwd()); + + const once = async (): Promise => { + try { + const result = await runDiffSync({ projectDir }); + console.log(result.upToDate ? "Cloud sync is up to date." : `Synced ${result.syncedCount} turns to cloud.`); + return true; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (err instanceof NotLoggedInError || err instanceof NotOptedInError) { + console.error(message); + process.exitCode = 1; + return false; + } + console.error(`Cloud sync failed: ${message}`); + return true; // keep going when watching; the next pass retries + } + }; + + if (!(await once()) || !options.watch) return; + + console.log(`Watching ${projectDir}; uploading every ${WATCH_INTERVAL_MS / 1000}s. Ctrl+C to stop.`); + await new Promise((done) => { + let busy = false; + const timer = setInterval(() => { + if (busy) return; + busy = true; + void once().finally(() => { + busy = false; + }); + }, WATCH_INTERVAL_MS); + process.once("SIGINT", () => { + clearInterval(timer); + done(); + }); + }); +} + +/** `xtctx sync enable | disable | status`: the per-project upload decision. */ +export async function runSyncSetting(action: string, options: { projectDir?: string }): Promise { + const projectDir = resolve(options.projectDir ?? process.cwd()); + + if (action === "enable") { + await setOptedIn(projectDir, true); + console.log(`Cloud sync is ON for ${projectDir}.`); + console.log("Its transcripts are uploaded to your xtctx cloud account while an agent runs xtctx here."); + if (!(await loadCredentials())) console.log("You are not logged in yet: run `xtctx login`."); + return; + } + + if (action === "disable") { + await setOptedIn(projectDir, false); + console.log(`Cloud sync is OFF for ${projectDir}. Nothing more is uploaded from it.`); + console.log("Already-uploaded data stays until you run `xtctx logout --delete-data`."); + return; + } + + if (action === "status") { + const creds = await loadCredentials(); + console.log(creds ? `Logged in as ${creds.user.username} (${creds.syncUrl})` : "Not logged in."); + console.log(`This project: cloud sync ${(await isOptedIn(projectDir)) ? "ON" : "OFF"}`); + return; + } + + console.error(`Unknown action "${action}". Use enable, disable or status, or no action to upload once.`); + process.exitCode = 1; +} diff --git a/src/cli/watch.ts b/src/cli/watch.ts deleted file mode 100644 index 8333ea3..0000000 --- a/src/cli/watch.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { loadCredentials } from "../sync/client.js"; -import { RealtimeTranscriptWatcher } from "../sync/watcher.js"; -import { runDiffSync } from "../sync/diff-sync.js"; -import { readdir } from "node:fs/promises"; -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import { homedir } from "node:os"; - -export async function runSync(options: { projectDir?: string; watch?: boolean }): Promise { - const projectDir = options.projectDir || process.cwd(); - const credentials = await loadCredentials(); - - if (!credentials) { - console.error("❌ Not authenticated. Please run `xtctx login` first."); - process.exit(1); - } - - // 1. Run automatic diff sync first - console.log(`\nChecking cloud sync for ${credentials.deviceName} (${credentials.user.username})...`); - try { - const diff = await runDiffSync({ projectDir }); - if (diff.upToDate) { - console.log(`✓ Cloud sync is up to date (0 pending diffs)`); - } else { - console.log(`✓ Successfully synced ${diff.syncedCount} pending turns to cloud`); - } - } catch (err: unknown) { - console.warn(`⚠️ Warning: Diff sync encountered an error:`, err instanceof Error ? err.message : String(err)); - } - - // If not watching, we are done - if (!options.watch) { - return; - } - - // 2. Start real-time continuous watcher - console.log(`\nStarting continuous real-time watcher...`); - console.log(`- Server: ${credentials.syncUrl}`); - console.log(`- Target: ${projectDir}\n`); - - const watcher = new RealtimeTranscriptWatcher({ - projectDir, - credentials, - }); - - // Antigravity transcripts - const antigravityBrain = join(homedir(), ".gemini", "antigravity", "brain"); - if (existsSync(antigravityBrain)) { - try { - const convs = await readdir(antigravityBrain); - for (const conv of convs) { - const transcriptPath = join(antigravityBrain, conv, ".system_generated", "logs", "transcript.jsonl"); - if (existsSync(transcriptPath)) { - await watcher.watchTranscriptFile(transcriptPath, "antigravity", conv); - } - } - console.log(`✓ Watching Antigravity active transcripts`); - } catch { - // Ignore - } - } - - // Claude Code transcripts - const claudeProjects = join(homedir(), ".claude", "projects"); - if (existsSync(claudeProjects)) { - try { - const dirs = await readdir(claudeProjects); - for (const d of dirs) { - const fullDir = join(claudeProjects, d); - const files = await readdir(fullDir); - for (const f of files) { - if (f.endsWith(".jsonl")) { - await watcher.watchTranscriptFile(join(fullDir, f), "claude-code", f.replace(/\.jsonl$/, "")); - } - } - } - console.log(`✓ Watching Claude Code active transcripts`); - } catch { - // Ignore - } - } - - console.log("\nListening for agent turns in real time. Press Ctrl+C to stop.\n"); - - process.on("SIGINT", () => { - watcher.close(); - console.log("\nStopped real-time streamer."); - process.exit(0); - }); -} diff --git a/src/runtime/background.ts b/src/runtime/background.ts index 9615436..d393389 100644 --- a/src/runtime/background.ts +++ b/src/runtime/background.ts @@ -53,7 +53,6 @@ export async function runBackgroundWork(deps: BackgroundDeps): Promise { await deps.sessions.listRecentSessions(1); await deps.sessions.whenScanSettled(); await drainIfAffordable(deps.sessions); - await syncToCloudIfAuthenticated(log); } catch (error) { // Reported rather than swallowed. The drain records its own failure in // `embedding_error`; this line is for everything else, and for anyone @@ -64,28 +63,6 @@ export async function runBackgroundWork(deps: BackgroundDeps): Promise { } } -async function syncToCloudIfAuthenticated(log: (line: string) => void): Promise { - try { - const { loadCredentials } = await import("../sync/client.js"); - const creds = await loadCredentials(); - if (!creds) return; - - // 1. Run quick incremental diff sync - const { runDiffSync } = await import("../sync/diff-sync.js"); - const result = await runDiffSync({ projectDir: process.cwd() }); - if (!result.upToDate && result.syncedCount > 0) { - log(`xtctx: synced ${result.syncedCount} new turns to cloud`); - } - - // 2. Ensure real-time background watcher daemon is running (self-healing) - const { ensureDaemonRunning } = await import("../sync/daemon-manager.js"); - await ensureDaemonRunning(); - } catch (err: unknown) { - // Non-blocking: background sync failure should never crash the MCP server - log(`xtctx: background cloud sync skipped: ${err instanceof Error ? err.message : String(err)}`); - } -} - /** * Measure this machine's embedding devices, once per machine, before any * model load. diff --git a/src/sync/auto-sync.ts b/src/sync/auto-sync.ts new file mode 100644 index 0000000..f77fac2 --- /dev/null +++ b/src/sync/auto-sync.ts @@ -0,0 +1,77 @@ +import { NotLoggedInError, NotOptedInError, runDiffSync, type DiffSyncResult } from "./diff-sync.js"; + +export interface AutoSync { + /** Stop the timer, let a sync in flight finish, then upload once more. */ + stop(): Promise; +} + +export interface AutoSyncOptions { + projectRoot: string; + log: (line: string) => void; + intervalMs?: number; + /** How long the final upload on shutdown may take before it is abandoned. */ + flushTimeoutMs?: number; + sync?: (options: { projectDir: string }) => Promise; +} + +/** + * Keep an opted-in project's uploads current while the MCP server runs. + * + * Transcripts only change while an agent is running, and an agent with xtctx + * wired runs this server for the whole session, so syncing from here is as + * fresh as a separate daemon was without leaving a process behind. A crash + * loses at most the last interval, which the next start uploads. + * + * Each tick asks `runDiffSync`, which refuses unless someone is logged in and + * the project is opted in, so a project that is neither costs two small file + * reads per tick and sends nothing. Ticks never overlap: the next is + * scheduled when the previous one finishes. + */ +export function startAutoSync(options: AutoSyncOptions): AutoSync { + const { projectRoot, log } = options; + const intervalMs = options.intervalMs ?? 10_000; + const flushTimeoutMs = options.flushTimeoutMs ?? 1_500; + const sync = options.sync ?? runDiffSync; + + let timer: NodeJS.Timeout | undefined; + let inFlight: Promise = Promise.resolve(); + let stopped = false; + let lastError = ""; + + const tick = async (): Promise => { + try { + const result = await sync({ projectDir: projectRoot }); + if (result.syncedCount > 0) log(`xtctx: synced ${result.syncedCount} new turns to cloud`); + lastError = ""; + } catch (err) { + if (err instanceof NotLoggedInError || err instanceof NotOptedInError) return; + // Said once per distinct failure, not every ten seconds. + const message = err instanceof Error ? err.message : String(err); + if (message !== lastError) log(`xtctx: cloud sync failed: ${message}`); + lastError = message; + } + }; + + const schedule = (): void => { + if (stopped) return; + timer = setTimeout(() => { + inFlight = tick().then(schedule); + }, intervalMs); + timer.unref?.(); + }; + schedule(); + + return { + async stop() { + stopped = true; + clearTimeout(timer); + await inFlight; + let abandon: NodeJS.Timeout | undefined; + const deadline = new Promise((resolve) => { + abandon = setTimeout(resolve, flushTimeoutMs); + }); + await Promise.race([tick(), deadline]); + clearTimeout(abandon); + }, + }; +} diff --git a/src/sync/client.ts b/src/sync/client.ts index f474bdb..394f522 100644 --- a/src/sync/client.ts +++ b/src/sync/client.ts @@ -1,7 +1,8 @@ -import { homedir } from "node:os"; +import { hostname } from "node:os"; import { join } from "node:path"; -import { readFile, writeFile, mkdir } from "node:fs/promises"; -import { existsSync } from "node:fs"; +import { readFile, rm } from "node:fs/promises"; +import { writeFileAtomic } from "../utils/atomic-file.js"; +import { xtctxHome } from "./consent.js"; export interface SyncCredentials { token: string; @@ -15,14 +16,18 @@ export interface SyncCredentials { syncUrl: string; } -const DEFAULT_SYNC_URL = "https://sync.xtctx.com"; +export const DEFAULT_SYNC_URL = "https://sync.xtctx.com"; export function getCredentialsPath(): string { - return join(homedir(), ".xtctx", "credentials.json"); + return join(xtctxHome(), "credentials.json"); } +/** + * The saved login, or one built from XTCTX_TOKEN for a machine nobody logs in + * on. Having credentials does not mean anything is uploaded: that also needs + * the project to be opted in (see consent.ts). + */ export async function loadCredentials(): Promise { - const credPath = getCredentialsPath(); if (process.env.XTCTX_TOKEN) { const token = process.env.XTCTX_TOKEN; let userId = "env-user"; @@ -38,12 +43,10 @@ export async function loadCredentials(): Promise { if (payload.device_id && !deviceId) deviceId = payload.device_id; } } catch { - // Ignore JWT decode errors and use defaults + // Not a decodable JWT; the server is what judges the token. } - const { hostname } = await import("node:os"); const machineName = hostname(); - return { token, user: { id: userId, username }, @@ -53,53 +56,38 @@ export async function loadCredentials(): Promise { }; } - if (!existsSync(credPath)) { - return null; - } - try { - const raw = await readFile(credPath, "utf-8"); - return JSON.parse(raw); + return JSON.parse(await readFile(getCredentialsPath(), "utf-8")) as SyncCredentials; } catch { return null; } } +/** Written 0600 and atomically: it holds a bearer token. */ export async function saveCredentials(creds: SyncCredentials): Promise { - const credPath = getCredentialsPath(); - const dir = join(homedir(), ".xtctx"); - if (!existsSync(dir)) { - await mkdir(dir, { recursive: true }); - } - await writeFile(credPath, JSON.stringify(creds, null, 2), "utf-8"); + await writeFileAtomic(getCredentialsPath(), JSON.stringify(creds, null, 2), { mode: 0o600 }); +} + +export async function deleteCredentials(): Promise { + await rm(getCredentialsPath(), { force: true }); } /** - * Stream a turn delta to the Cloudflare Worker backend. + * A token must not travel in the clear. Plain http is for a server on this + * machine, which is what development uses. */ -export async function pushTurnDelta( - creds: SyncCredentials, - delta: Record -): Promise<{ success: boolean; sessionRef?: string }> { - const url = `${creds.syncUrl.replace(/\/$/, "")}/api/stream`; - - const res = await fetch(url, { - method: "POST", - headers: { - "Authorization": `Bearer ${creds.token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - ...delta, - deviceId: creds.deviceId, - deviceName: creds.deviceName, - }), - }); - - if (!res.ok) { - const errText = await res.text(); - throw new Error(`Sync failed (${res.status}): ${errText}`); +export function assertSecureSyncUrl(syncUrl: string): void { + const url = new URL(syncUrl); + const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) { + throw new Error(`Refusing ${syncUrl}: the sync server must be https (http is allowed only for localhost).`); } +} - return (await res.json()) as { success: boolean; sessionRef?: string }; +export async function callCloud(creds: SyncCredentials, method: string, path: string): Promise { + assertSecureSyncUrl(creds.syncUrl); + return fetch(`${creds.syncUrl.replace(/\/$/, "")}${path}`, { + method, + headers: { Authorization: `Bearer ${creds.token}` }, + }); } diff --git a/src/sync/consent.ts b/src/sync/consent.ts new file mode 100644 index 0000000..0e2313c --- /dev/null +++ b/src/sync/consent.ts @@ -0,0 +1,49 @@ +import { realpath, readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { writeFileAtomic } from "../utils/atomic-file.js"; + +/** Where xtctx keeps what belongs to the user rather than to one project. */ +export function xtctxHome(): string { + return join(homedir(), ".xtctx"); +} + +function consentPath(): string { + return join(xtctxHome(), "cloud-projects.json"); +} + +/** + * Which projects may upload to xtctx cloud. + * + * A list in the user's home, not a setting in the project: `.xtctx/config.yaml` + * is the file a repository can commit, and a clone must not be able to opt + * its reader in to sending their transcripts anywhere. Logging in does not + * add to it either; the two are separate decisions. + */ +async function projectKey(projectRoot: string): Promise { + const real = await realpath(projectRoot).catch(() => projectRoot); + return process.platform === "win32" ? real.toLowerCase() : real; +} + +async function readList(): Promise { + try { + const parsed = JSON.parse(await readFile(consentPath(), "utf-8")) as { projects?: unknown }; + return Array.isArray(parsed.projects) ? parsed.projects.filter((p): p is string => typeof p === "string") : []; + } catch { + return []; + } +} + +async function writeList(projects: string[]): Promise { + await writeFileAtomic(consentPath(), JSON.stringify({ projects }, null, 2), { mode: 0o600 }); +} + +export async function isOptedIn(projectRoot: string): Promise { + return (await readList()).includes(await projectKey(projectRoot)); +} + +export async function setOptedIn(projectRoot: string, optedIn: boolean): Promise { + const key = await projectKey(projectRoot); + const rest = (await readList()).filter((p) => p !== key); + await writeList(optedIn ? [...rest, key] : rest); +} diff --git a/src/sync/daemon-manager.ts b/src/sync/daemon-manager.ts deleted file mode 100644 index 9c132c7..0000000 --- a/src/sync/daemon-manager.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { spawn } from "node:child_process"; -import { homedir } from "node:os"; -import { join, resolve } from "node:path"; -import { existsSync, openSync, closeSync } from "node:fs"; -import { readFile, writeFile, unlink, mkdir } from "node:fs/promises"; -import { loadCredentials } from "./client.js"; - -function getXtctxDir(): string { - return join(homedir(), ".xtctx"); -} - -function getPidPath(): string { - return join(getXtctxDir(), "daemon.pid"); -} - -export function getDaemonLogPath(): string { - return join(getXtctxDir(), "daemon.log"); -} - -/** - * Check if the daemon process with the given PID is currently active. - */ -export function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -/** - * Get the current status of the background sync daemon. - */ -export async function getDaemonStatus(): Promise<{ running: boolean; pid?: number; logPath: string }> { - const pidPath = getPidPath(); - const logPath = getDaemonLogPath(); - - if (!existsSync(pidPath)) { - return { running: false, logPath }; - } - - try { - const rawPid = await readFile(pidPath, "utf-8"); - const pid = parseInt(rawPid.trim(), 10); - - if (isNaN(pid) || !isProcessAlive(pid)) { - // Stale PID file, clean it up - await unlink(pidPath).catch(() => {}); - return { running: false, logPath }; - } - - return { running: true, pid, logPath }; - } catch { - return { running: false, logPath }; - } -} - -/** - * Stop the running background sync daemon. - */ -export async function stopDaemon(): Promise { - const status = await getDaemonStatus(); - if (!status.running || !status.pid) { - return false; - } - - try { - if (process.platform === "win32") { - // On Windows, taskkill cleanly kills process tree - const { exec } = await import("node:child_process"); - await new Promise((resolve) => { - exec(`taskkill /pid ${status.pid} /T /F`, () => resolve()); - }); - } else { - process.kill(status.pid, "SIGTERM"); - } - } catch { - // Ignore error if process already exited - } - - await unlink(getPidPath()).catch(() => {}); - return true; -} - -/** - * Ensure the background sync daemon is running if the user is authenticated. - * Spawns a detached background process if it is not running. - */ -export async function ensureDaemonRunning(cliPath?: string): Promise<{ started: boolean; pid?: number }> { - // Only auto-spawn if authenticated - const creds = await loadCredentials(); - if (!creds) { - return { started: false }; - } - - const status = await getDaemonStatus(); - if (status.running && status.pid) { - return { started: false, pid: status.pid }; - } - - const xtctxDir = getXtctxDir(); - if (!existsSync(xtctxDir)) { - await mkdir(xtctxDir, { recursive: true }); - } - - const logPath = getDaemonLogPath(); - const logFd = openSync(logPath, "a"); - - const entrypoint = cliPath || process.argv[1] || resolve(import.meta.dirname, "../cli/index.js"); - - const child = spawn(process.execPath, [entrypoint, "watch"], { - detached: true, - stdio: ["ignore", logFd, logFd], - windowsHide: true, - }); - - child.unref(); - - // Close parent's handle to logfile - try { - closeSync(logFd); - } catch { - // Ignore - } - - if (child.pid) { - await writeFile(getPidPath(), String(child.pid), "utf-8"); - return { started: true, pid: child.pid }; - } - - return { started: false }; -} diff --git a/src/sync/diff-sync.ts b/src/sync/diff-sync.ts index eaea681..2fb6cf3 100644 --- a/src/sync/diff-sync.ts +++ b/src/sync/diff-sync.ts @@ -1,12 +1,24 @@ import Database, { type Database as DatabaseHandle } from "better-sqlite3"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { existsSync } from "node:fs"; -import { loadCredentials, type SyncCredentials } from "./client.js"; +import { assertSecureSyncUrl, loadCredentials } from "./client.js"; +import { isOptedIn } from "./consent.js"; import { getCanonicalRepoId } from "./normalizer.js"; import { getSetting, setSetting } from "../handoff/schema.js"; const BATCH_SIZE = 100; -const CLOUD_SYNC_KEY = "cloud_last_synced_indexed_at"; + +export class NotLoggedInError extends Error { + constructor() { + super("Not logged in to xtctx cloud. Run `xtctx login` first."); + } +} + +export class NotOptedInError extends Error { + constructor() { + super("This project is not opted in to cloud sync. Run `xtctx sync enable` in it first."); + } +} export interface DiffSyncResult { syncedCount: number; @@ -15,17 +27,22 @@ export interface DiffSyncResult { } /** - * Execute an automatic incremental diff sync. - * Queries turns added/indexed since the last high-water mark across all scrapers - * and streams them in batches to sync.xtctx.com. + * Upload what this project's index has gained since the last upload. + * + * Reads the project's own index, so only sessions already attributed to this + * project can leave the machine. Refuses unless someone is logged in AND this + * project is on the user's opt-in list; either alone uploads nothing. + * + * The high-water mark is kept per account: after logging in as someone else + * the project has to be uploaded to them in full, not from where the previous + * account left off. */ -export async function runDiffSync(options: { projectDir?: string }): Promise { - const projectDir = options.projectDir || process.cwd(); +export async function runDiffSync(options: { projectDir: string }): Promise { + const { projectDir } = options; const credentials = await loadCredentials(); - - if (!credentials) { - throw new Error("Not authenticated. Please run `xtctx login` first."); - } + if (!credentials) throw new NotLoggedInError(); + if (!(await isOptedIn(projectDir))) throw new NotOptedInError(); + assertSecureSyncUrl(credentials.syncUrl); const dbPath = join(projectDir, ".xtctx", "state", "xtctx.db"); if (!existsSync(dbPath)) { @@ -34,25 +51,32 @@ export async function runDiffSync(options: { projectDir?: string }): Promise ? - ORDER BY m.indexed_at ASC + WHERE (m.indexed_at, m.id) > (?, ?) + ORDER BY m.indexed_at ASC, m.id ASC LIMIT ? `); while (true) { - const rows = queryStmt.all(currentHighWater, BATCH_SIZE) as any[]; + const rows = query.all(cursorAt, cursorId, BATCH_SIZE) as Array>; if (rows.length === 0) break; const deltas = rows.map((r) => ({ @@ -61,7 +85,9 @@ export async function runDiffSync(options: { projectDir?: string }): Promise { try { const { stdout: rootStdout } = await execAsync("git rev-parse --show-toplevel", { cwd: projectDir }); const repoRoot = rootStdout.trim(); - const { stdout: originStdout } = await execAsync("git config --get remote.origin.url", { cwd: repoRoot }); - const rawOrigin = originStdout.trim(); - - if (rawOrigin) { - // Normalize ssh and https URLs to clean identifier: - // "git@github.com:user/repo.git" -> "github.com/user/repo" - // "https://github.com/user/repo.git" -> "github.com/user/repo" - let cleaned = rawOrigin - .replace(/\.git$/i, "") - .replace(/^git@([^:]+):/, "$1/") - .replace(/^https?:\/\//, ""); - - return { repoId: cleaned, repoRoot }; - } - - // In git repo without remote origin, use root directory basename + commit or branch - const { stdout: branchStdout } = await execAsync("git branch --show-current", { cwd: repoRoot }); - return { repoId: `local-git:${repoRoot.replace(/[\\/]/g, "-")}`, repoRoot }; + const origin = originStdout.trim(); + if (origin) return { repoId: normalizeRemote(origin), repoRoot }; + return { repoId: await pathId(repoRoot), repoRoot }; } catch { - // Non-git folder fallback: use or create .xtctx/project.id - const xtctxDir = join(projectDir, ".xtctx"); - const idFile = join(xtctxDir, "project.id"); - if (existsSync(idFile)) { - const id = (await readFile(idFile, "utf-8")).trim(); - return { repoId: `untracked:${id}`, repoRoot: projectDir }; - } - - const newId = randomUUID(); - try { - await writeFile(idFile, newId, "utf-8"); - } catch { - // Ignore write errors - } - return { repoId: `untracked:${newId}`, repoRoot: projectDir }; + // Not a git repository, or one with no origin. + return { repoId: await pathId(projectDir), repoRoot: projectDir }; } } /** - * Convert an absolute file path into a POSIX repository-relative path. + * "git@github.com:user/repo.git" and "https://github.com/user/repo.git" both + * become "github.com/user/repo". Credentials in a URL are dropped. */ -export function toRepoRelativePath(absolutePath: string, repoRoot: string): string { - const rel = relative(repoRoot, resolve(absolutePath)); - return rel.replace(/\\/g, "/"); +export function normalizeRemote(remote: string): string { + return remote + .replace(/\.git$/i, "") + .replace(/^git@([^:]+):/, "$1/") + .replace(/^[a-z+]+:\/\//i, "") + .replace(/^[^@/]+@/, ""); +} + +async function pathId(dir: string): Promise { + const real = await realpath(dir).catch(() => dir); + return `local:${createHash("sha256").update(real).digest("hex").slice(0, 16)}`; } diff --git a/src/sync/watcher.ts b/src/sync/watcher.ts deleted file mode 100644 index 69507e0..0000000 --- a/src/sync/watcher.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { open, stat } from "node:fs/promises"; -import { watch, type FSWatcher } from "node:fs"; -import { createHash } from "node:crypto"; -import { getCanonicalRepoId } from "./normalizer.js"; -import { pushTurnDelta, type SyncCredentials } from "./client.js"; - -export interface TailerOptions { - projectDir: string; - credentials: SyncCredentials; - pollIntervalMs?: number; -} - -export interface WatchState { - filePath: string; - tool: string; - sourceSessionId: string; - offset: number; - messageIndex: number; -} - -/** - * Lightweight real-time transcript file tailer. - * Uses byte-offset tracking with shared read handles to avoid file locking. - */ -export class RealtimeTranscriptWatcher { - private activeWatchers: FSWatcher[] = []; - private fileOffsets = new Map(); - private isProcessing = false; - - constructor(private options: TailerOptions) {} - - /** - * Tail a specific JSONL transcript file as turns are appended. - */ - async watchTranscriptFile(filePath: string, tool: string, sourceSessionId: string): Promise { - try { - const stats = await stat(filePath); - // Start tailing from current end or 0 - this.fileOffsets.set(filePath, { - filePath, - tool, - sourceSessionId, - offset: stats.size, // tail from current moment onwards - messageIndex: 0, - }); - - const watcher = watch(filePath, async (eventType) => { - if (eventType === "change") { - await this.handleFileChange(filePath); - } - }); - - this.activeWatchers.push(watcher); - } catch { - // File might not exist yet; watcher can retry - } - } - - private async handleFileChange(filePath: string): Promise { - if (this.isProcessing) return; - this.isProcessing = true; - - try { - const state = this.fileOffsets.get(filePath); - if (!state) return; - - const stats = await stat(filePath); - if (stats.size <= state.offset) { - if (stats.size < state.offset) { - // File was truncated / recreated - state.offset = 0; - state.messageIndex = 0; - } else { - return; - } - } - - const bytesToRead = stats.size - state.offset; - const buffer = Buffer.alloc(bytesToRead); - - const fileHandle = await open(filePath, "r"); - try { - await fileHandle.read(buffer, 0, bytesToRead, state.offset); - } finally { - await fileHandle.close(); - } - - state.offset = stats.size; - - const text = buffer.toString("utf-8"); - const lines = text.split("\n").filter((l) => l.trim().length > 0); - - const { repoId, repoRoot } = await getCanonicalRepoId(this.options.projectDir); - - for (const line of lines) { - try { - const parsed = JSON.parse(line); - state.messageIndex += 1; - - const content = parsed.content || parsed.message || (typeof parsed === "string" ? parsed : JSON.stringify(parsed)); - const contentHash = createHash("sha256").update(content).digest("hex"); - - await pushTurnDelta(this.options.credentials, { - tool: state.tool, - sourceSessionId: state.sourceSessionId, - repoUrl: repoId, - projectRoot: repoRoot, - timestamp: parsed.created_at || new Date().toISOString(), - role: parsed.role || (parsed.type === "USER_INPUT" ? "user" : "assistant"), - content, - messageIndex: state.messageIndex, - contentHash, - status: "active", - }); - } catch { - // Skip invalid JSON lines - } - } - } finally { - this.isProcessing = false; - } - } - - close(): void { - for (const w of this.activeWatchers) { - try { - w.close(); - } catch { - // Ignore - } - } - this.activeWatchers = []; - } -} diff --git a/src/utils/atomic-file.ts b/src/utils/atomic-file.ts index 5716a47..806d7fd 100644 --- a/src/utils/atomic-file.ts +++ b/src/utils/atomic-file.ts @@ -3,6 +3,8 @@ import { mkdir, realpath, rename, rm, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; interface WriteFileAtomicOptions { + /** File mode for a secret, e.g. 0o600. Ignored on Windows, which has no such bits. */ + mode?: number; /** * Directory the write must stay inside, after symlinks are resolved. * @@ -88,7 +90,7 @@ export async function writeFileAtomic( // Random suffix, and `wx` so the open fails rather than following a // symlink someone pre-created at a guessable temp path. const tmpPath = `${filePath}.${randomBytes(6).toString("hex")}.xtctx-tmp`; - await writeFile(tmpPath, content, { encoding: "utf-8", flag: "wx" }); + await writeFile(tmpPath, content, { encoding: "utf-8", flag: "wx", mode: options.mode }); try { await rename(tmpPath, filePath); } catch (err) { diff --git a/tests/sync/auto-sync.test.ts b/tests/sync/auto-sync.test.ts new file mode 100644 index 0000000..f526496 --- /dev/null +++ b/tests/sync/auto-sync.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { startAutoSync } from "@xtctx/sync/auto-sync"; +import { NotOptedInError, type DiffSyncResult } from "@xtctx/sync/diff-sync"; + +const result = (n: number): DiffSyncResult => ({ syncedCount: n, latestIndexedAt: null, upToDate: n === 0 }); + +describe("auto sync", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("syncs on an interval and uploads once more on stop", async () => { + const sync = vi.fn(async () => result(0)); + const sink = startAutoSync({ projectRoot: "/p", log: () => {}, intervalMs: 1000, sync }); + + expect(sync).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(3000); + expect(sync).toHaveBeenCalledTimes(3); + + await sink.stop(); + expect(sync).toHaveBeenCalledTimes(4); // the flush + await vi.advanceTimersByTimeAsync(5000); + expect(sync).toHaveBeenCalledTimes(4); // and then nothing + }); + + it("never runs two syncs at once", async () => { + let running = 0; + let overlapped = false; + const sync = vi.fn(async () => { + running++; + overlapped ||= running > 1; + await new Promise((r) => setTimeout(r, 2500)); // slower than the interval + running--; + return result(0); + }); + const sink = startAutoSync({ projectRoot: "/p", log: () => {}, intervalMs: 1000, sync }); + + await vi.advanceTimersByTimeAsync(10_000); + const stopped = sink.stop(); + await vi.advanceTimersByTimeAsync(10_000); + await stopped; + expect(overlapped).toBe(false); + }); + + it("is quiet when the project is not opted in, and says a real failure once", async () => { + const lines: string[] = []; + let mode: "optout" | "boom" = "optout"; + const sync = vi.fn(async () => { + throw mode === "optout" ? new NotOptedInError() : new Error("boom"); + }); + const sink = startAutoSync({ projectRoot: "/p", log: (l) => lines.push(l), intervalMs: 1000, sync }); + + await vi.advanceTimersByTimeAsync(3000); + expect(lines).toEqual([]); + + mode = "boom"; + await vi.advanceTimersByTimeAsync(3000); + expect(lines).toEqual(["xtctx: cloud sync failed: boom"]); + await sink.stop(); + }); + + it("gives up on a flush that hangs instead of holding shutdown", async () => { + const sync = vi.fn(() => new Promise(() => {})); + const sink = startAutoSync({ projectRoot: "/p", log: () => {}, intervalMs: 60_000, flushTimeoutMs: 500, sync }); + + const stopped = sink.stop(); + await vi.advanceTimersByTimeAsync(500); + await expect(stopped).resolves.toBeUndefined(); + }); +}); diff --git a/tests/sync/credentials.test.ts b/tests/sync/credentials.test.ts new file mode 100644 index 0000000..b1905ef --- /dev/null +++ b/tests/sync/credentials.test.ts @@ -0,0 +1,41 @@ +import { readFileSync, statSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { deleteCredentials, getCredentialsPath, saveCredentials } from "@xtctx/sync/client"; +import { isOptedIn, setOptedIn } from "@xtctx/sync/consent"; +import { sandbox } from "./helpers"; + +const creds = { + token: "secret-token", + user: { id: "github:1", username: "u" }, + deviceId: "dev", + deviceName: "laptop", + syncUrl: "https://sync.test", +}; + +describe("credentials and consent files", () => { + let box: ReturnType; + beforeEach(() => { + box = sandbox(); + }); + afterEach(() => box.cleanup()); + + it.skipIf(process.platform === "win32")("writes credentials readable by the owner only", async () => { + await saveCredentials(creds); + expect(statSync(getCredentialsPath()).mode & 0o777).toBe(0o600); + }); + + it("round-trips and deletes credentials", async () => { + await saveCredentials(creds); + expect(JSON.parse(readFileSync(getCredentialsPath(), "utf-8")).token).toBe("secret-token"); + await deleteCredentials(); + expect(() => statSync(getCredentialsPath())).toThrow(); + }); + + it("keeps the opt-in per project, in the home directory", async () => { + expect(await isOptedIn(box.project)).toBe(false); + await setOptedIn(box.project, true); + expect(await isOptedIn(box.project)).toBe(true); + await setOptedIn(box.project, false); + expect(await isOptedIn(box.project)).toBe(false); + }); +}); diff --git a/tests/sync/diff-sync.test.ts b/tests/sync/diff-sync.test.ts new file mode 100644 index 0000000..c8f99cc --- /dev/null +++ b/tests/sync/diff-sync.test.ts @@ -0,0 +1,127 @@ +/** + * What leaves the machine, and when. + * + * Every case runs against the real index schema in a throwaway home. The first + * version of this feature selected a column the index does not have, so it + * could never have uploaded anything; only a real database shows that. + */ +import { existsSync } from "node:fs"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { saveCredentials, type SyncCredentials } from "@xtctx/sync/client"; +import { setOptedIn } from "@xtctx/sync/consent"; +import { NotOptedInError, runDiffSync } from "@xtctx/sync/diff-sync"; +import { sandbox, seedIndex } from "./helpers"; + +const creds = (id = "github:1"): SyncCredentials => ({ + token: "tok", + user: { id, username: "u" }, + deviceId: "dev", + deviceName: "laptop", + syncUrl: "https://sync.test", +}); + +describe("diff sync", () => { + let box: ReturnType; + let uploads: Array>>; + + beforeEach(() => { + box = sandbox(); + uploads = []; + vi.stubGlobal( + "fetch", + vi.fn(async (_url: string, init: RequestInit) => { + uploads.push(JSON.parse(String(init.body))); + return Response.json({ success: true }); + }), + ); + }); + afterEach(() => { + vi.unstubAllGlobals(); + box.cleanup(); + }); + + it("uploads nothing for a logged-in user whose project is not opted in", async () => { + await saveCredentials(creds()); + seedIndex(box.project, 3); + + await expect(runDiffSync({ projectDir: box.project })).rejects.toBeInstanceOf(NotOptedInError); + expect(uploads).toEqual([]); + }); + + it("uploads nothing when only XTCTX_TOKEN is set", async () => { + vi.stubEnv("XTCTX_TOKEN", "a.b.c"); + seedIndex(box.project, 3); + + await expect(runDiffSync({ projectDir: box.project })).rejects.toBeInstanceOf(NotOptedInError); + expect(uploads).toEqual([]); + }); + + it("uploads an opted-in project, and only what is new on the next run", async () => { + await saveCredentials(creds()); + await setOptedIn(box.project, true); + seedIndex(box.project, 3); + + expect((await runDiffSync({ projectDir: box.project })).syncedCount).toBe(3); + expect((await runDiffSync({ projectDir: box.project })).syncedCount).toBe(0); + + seedIndex(box.project, 2, "2026-10-01T00:00:05.000Z", 3); + expect((await runDiffSync({ projectDir: box.project })).syncedCount).toBe(2); + expect(uploads.map((b) => b.length)).toEqual([3, 2]); + }); + + it("does not skip messages that share a timestamp across a batch boundary", async () => { + await saveCredentials(creds()); + await setOptedIn(box.project, true); + seedIndex(box.project, 250); + + expect((await runDiffSync({ projectDir: box.project })).syncedCount).toBe(250); + const sent = new Set(uploads.flat().map((d) => d.messageIndex)); + expect(sent.size).toBe(250); + }); + + it("starts over for a different account", async () => { + await setOptedIn(box.project, true); + seedIndex(box.project, 3); + await saveCredentials(creds("github:1")); + await runDiffSync({ projectDir: box.project }); + + await saveCredentials(creds("github:2")); + expect((await runDiffSync({ projectDir: box.project })).syncedCount).toBe(3); + }); + + it("sends the folder name, not the absolute path, and writes nothing into the project", async () => { + await saveCredentials(creds()); + await setOptedIn(box.project, true); + seedIndex(box.project, 1); + const before = existsSync(join(box.project, ".xtctx", "project.id")); + + await runDiffSync({ projectDir: box.project }); + + expect(uploads[0][0].projectRoot).toBe("project"); + expect(String(uploads[0][0].repoUrl)).toMatch(/^local:[0-9a-f]{16}$/); + expect(before).toBe(false); + expect(existsSync(join(box.project, ".xtctx", "project.id"))).toBe(false); + }); + + it("refuses to send a token over plain http to a remote host", async () => { + await saveCredentials({ ...creds(), syncUrl: "http://example.com" }); + await setOptedIn(box.project, true); + seedIndex(box.project, 1); + + await expect(runDiffSync({ projectDir: box.project })).rejects.toThrow(/https/); + expect(uploads).toEqual([]); + }); + + it("an opt-in for one project does not cover another", async () => { + await saveCredentials(creds()); + const other = join(box.project, "..", "other"); + mkdirSync(other); + writeFileSync(join(other, "marker"), ""); + await setOptedIn(other, true); + seedIndex(box.project, 1); + + await expect(runDiffSync({ projectDir: box.project })).rejects.toBeInstanceOf(NotOptedInError); + }); +}); diff --git a/tests/sync/helpers.ts b/tests/sync/helpers.ts new file mode 100644 index 0000000..1a6f797 --- /dev/null +++ b/tests/sync/helpers.ts @@ -0,0 +1,52 @@ +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { vi } from "vitest"; +import { openDatabase } from "@xtctx/handoff/schema"; + +/** + * A throwaway home and project, with every variable os.homedir() could read + * pointed at the first. Nothing here may touch the real ~/.xtctx. + */ +export function sandbox() { + const root = mkdtempSync(join(tmpdir(), "xtctx-sync-")); + const home = join(root, "home"); + const project = join(root, "project"); + mkdirSync(home); + mkdirSync(project); + for (const name of ["HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA"]) vi.stubEnv(name, home); + vi.stubEnv("XTCTX_TOKEN", ""); + return { + home, + project, + cleanup() { + vi.unstubAllEnvs(); + rmSync(root, { recursive: true, force: true }); + }, + }; +} + +/** Add `count` messages to the project's real index, all stamped with the same time. */ +export function seedIndex(project: string, count: number, indexedAt = "2026-10-01T00:00:00.000Z", from = 0) { + mkdirSync(join(project, ".xtctx", "state"), { recursive: true }); + const db = openDatabase(join(project, ".xtctx", "state", "xtctx.db")); + try { + db.prepare( + `INSERT OR IGNORE INTO sessions (session_ref, tool, source_session_id, project_root, started_at, last_activity_at, updated_at) + VALUES ('claude-code:s1', 'claude-code', 's1', ?, ?, ?, ?)`, + ).run(project, indexedAt, indexedAt, indexedAt); + const insert = db.prepare( + `INSERT INTO messages (id, session_ref, tool, source_session_id, timestamp, role, content, message_index, content_hash, metadata_json, indexed_at) + VALUES (?, 'claude-code:s1', 'claude-code', 's1', ?, 'user', ?, ?, ?, '{}', ?)`, + ); + for (let i = from; i < from + count; i++) { + insert.run(`m${String(i).padStart(5, "0")}`, indexedAt, `message ${i}`, i, `hash${i}`, indexedAt); + } + } finally { + db.close(); + } +} + +export function loginFile(home: string): string { + return join(home, ".xtctx", "credentials.json"); +} diff --git a/tests/sync/login.test.ts b/tests/sync/login.test.ts new file mode 100644 index 0000000..56d69a7 --- /dev/null +++ b/tests/sync/login.test.ts @@ -0,0 +1,79 @@ +import { existsSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { runLogin, runLogout } from "@xtctx/cli/login"; +import { getCredentialsPath, saveCredentials } from "@xtctx/sync/client"; +import { isOptedIn } from "@xtctx/sync/consent"; +import { sandbox } from "./helpers"; + +const code = { device_code: "dc", user_code: "ABCD-1234", verification_uri: "https://github.com/login/device", expires_in: 60, interval: 5 }; +const user = { id: "github:1", username: "u" }; + +describe("login and logout", () => { + let box: ReturnType; + beforeEach(() => { + box = sandbox(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.useFakeTimers({ toFake: ["Date"] }); + }); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + box.cleanup(); + }); + + /** A clock the injected wait advances, so deadlines can be reached without sleeping. */ + const clock = () => { + const waits: number[] = []; + return { waits, wait: async (ms: number) => void (waits.push(ms), vi.setSystemTime(Date.now() + ms)) }; + }; + + it("signs in without opting any project in", async () => { + const polls = [{ error: "authorization_pending" }, { token: "tok", user }]; + vi.stubGlobal("fetch", vi.fn(async (url: string) => + Response.json(String(url).endsWith("/code") ? code : polls.shift()), + )); + const { wait } = clock(); + + await runLogin({ syncUrl: "https://sync.test", wait }); + + expect(existsSync(getCredentialsPath())).toBe(true); + expect(await isOptedIn(box.project)).toBe(false); + }); + + it("slows down when told to, and gives up when the code expires", async () => { + vi.stubGlobal("fetch", vi.fn(async (url: string) => + Response.json(String(url).endsWith("/code") ? { ...code, expires_in: 30 } : { error: "slow_down" }), + )); + const { wait, waits } = clock(); + + await expect(runLogin({ syncUrl: "https://sync.test", wait })).rejects.toThrow(/expired/); + + expect(waits[0]).toBe(5000); + expect(waits[1]).toBe(10_000); // slow_down adds five seconds + expect(waits.reduce((a, b) => a + b, 0)).toBeGreaterThanOrEqual(30_000); + expect(waits.length).toBeLessThan(10); // bounded, not a forever loop + }); + + it("logout revokes at the server and forgets the token", async () => { + await saveCredentials({ token: "tok", user, deviceId: "d", deviceName: "n", syncUrl: "https://sync.test" }); + const fetchMock = vi.fn(async () => Response.json({ success: true })); + vi.stubGlobal("fetch", fetchMock); + + await runLogout({}); + + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("https://sync.test/auth/logout"); + expect(init.method).toBe("POST"); + expect(existsSync(getCredentialsPath())).toBe(false); + }); + + it("delete-data keeps the login when the server could not delete", async () => { + await saveCredentials({ token: "tok", user, deviceId: "d", deviceName: "n", syncUrl: "https://sync.test" }); + vi.stubGlobal("fetch", vi.fn(async () => new Response("nope", { status: 500 }))); + + await expect(runLogout({ deleteData: true })).rejects.toThrow(/still logged in/); + expect(existsSync(getCredentialsPath())).toBe(true); + }); +}); From 9de89695f0e95e226b99c0559151b732a5536ab2 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Thu, 1 Oct 2026 10:42:07 +0100 Subject: [PATCH 14/19] fix(cloud)!: authoritative uploads, revocation that survives deletion, OAuth sign-in for MCP clients Data integrity - POST /api/stream takes a v2 body ({device, project, sessions[]}) and writes a request as ONE D1 batch. Messages are keyed by the client's local id, message_count is recounted from the rows, and a session's keepIds deletes everything else, so replays are idempotent and local deletions/rewrites reach the cloud. started_at/last_activity_at use MIN/MAX, branch and device update, the constant 'active' status is gone. - Limits: 1 MiB body (read capped, 413 body_too_large), 64 KiB per message and 4 KiB metadata (413 message_too_large naming the message), 10 sessions and 500 messages per request, which keeps a batch under the free plan's 50 queries. The pre-0.2 array body gets 426 client_outdated. Auth - Token epochs live in token_epochs, which delete-my-data never deletes, so a token from before a deletion stays dead after the same account signs in again (it used to restart at version 0 and revive). - ALLOWED_GITHUB_IDS gates both sign-in routes before any row is written; empty means nobody. Removal ends access at the next request. - The Worker is an OAuth 2.1 authorization server for ${PUBLIC_URL}/mcp via @cloudflare/workers-oauth-provider: protected-resource metadata at the /mcp and root forms, AS metadata, CIMD and DCR registration, /authorize with a consent page, GitHub web flow, PKCE S256 required, 1 h read-only access tokens bound to the resource, 30-day rotating refresh tokens. Refresh after logout/deletion fails with invalid_grant. - CLI tokens carry scope (read sync:write account:delete) and aud; pasted read-only tokens from POST /api/tokens. Tokens without scope are refused. MCP - Dual-era /mcp: legacy initialize (2025-11-25, 2025-06-18) and stateless 2026-07-28 with header/_meta validation (-32020, -32022, -32602), server/discover, resultType, and ttlMs/cacheScope on list results (Claude Code 2.1.278 rejected tools/list without them). ping answers {}, notifications 202 with no body, unknown methods -32601 (404 when modern), bad JSON 400 -32700, missing method / batches 400 -32600, GET/DELETE 405, foreign Origin 403. - Tools renamed xtctx_cloud_recent_sessions / _session_detail, plus xtctx_cloud_status. limit is clamped to 1..N, session_ref is required, detail has a format option and orders by (timestamp, message_index, id), device names are joined in. Unknown tool -32602; bad arguments isError. Ops - Schema is managed by `wrangler d1 migrations apply`: schema.sql is now migrations/0000_baseline.sql (all IF NOT EXISTS) and 0001_sync_v2.sql carries the changes, including dropping the columns that held absolute paths. workers_dev = false, [observability] on, version in /health and X-Xtctx-Server-Version, structured ingest_failed logs without content. - Removed the unused @modelcontextprotocol/sdk dependency. BREAKING CHANGE: clients before 0.2 get 426 on upload, and every existing token is refused; users run `xtctx login` again. Apply migrations before deploying, and set OAUTH_KV, ALLOWED_GITHUB_IDS and GITHUB_CLIENT_SECRET. --- cloud/README.md | 184 ++- .../0000_baseline.sql} | 9 +- cloud/migrations/0001_sync_v2.sql | 40 + cloud/migrations/0001_token_version.sql | 7 - cloud/package-lock.json | 1229 +---------------- cloud/package.json | 10 +- cloud/src/app.ts | 276 ++++ cloud/src/auth.ts | 169 ++- cloud/src/db.ts | 537 ++++--- cloud/src/index.ts | 316 ++--- cloud/src/mcp.ts | 520 +++++-- cloud/src/oauth.ts | 250 ++++ cloud/src/types.ts | 46 +- cloud/src/version.ts | 5 + cloud/test/cloudflare-workers-stub.ts | 6 + cloud/test/fake-env.ts | 97 +- cloud/test/helpers.ts | 102 ++ cloud/test/mcp.test.ts | 212 +++ cloud/test/migrations.test.ts | 49 + cloud/test/oauth.test.ts | 339 +++++ cloud/test/worker.test.ts | 491 +++++-- cloud/tsconfig.json | 3 +- cloud/vitest.config.ts | 12 +- cloud/wrangler.toml | 38 +- 24 files changed, 3015 insertions(+), 1932 deletions(-) rename cloud/{schema.sql => migrations/0000_baseline.sql} (86%) create mode 100644 cloud/migrations/0001_sync_v2.sql delete mode 100644 cloud/migrations/0001_token_version.sql create mode 100644 cloud/src/app.ts create mode 100644 cloud/src/oauth.ts create mode 100644 cloud/src/version.ts create mode 100644 cloud/test/cloudflare-workers-stub.ts create mode 100644 cloud/test/helpers.ts create mode 100644 cloud/test/mcp.test.ts create mode 100644 cloud/test/migrations.test.ts create mode 100644 cloud/test/oauth.test.ts diff --git a/cloud/README.md b/cloud/README.md index d832be5..d5207ec 100644 --- a/cloud/README.md +++ b/cloud/README.md @@ -1,110 +1,142 @@ # xtctx-cloud -Cloudflare Edge continuous memory store and Model Context Protocol (MCP) server for `xtctx`. +The optional server behind [xtctx cloud sync](../docs/cloud-sync.md): a Cloudflare Worker that stores what opted-in projects upload (D1) and serves it back over MCP. Nothing reaches it unless a user logs in **and** opts a project in. -## Features -- **Dual-Version MCP Server**: Supports both modern stateless **2026-07-28** specification (`POST /mcp`) and baseline **2024-11-05** SSE streaming (`GET /sse` + `POST /message`) for full compatibility across Cursor, Claude Desktop, Claude Code, and Antigravity. -- **Continuous Memory Store**: Powered by Cloudflare D1 (serverless SQLite) with millisecond multi-device ingestion. -- **GitHub Device Flow Auth**: Friction-free OAuth 2.1 authentication without needing local redirect ports. -- **RFC 9728 Discovery**: Exposes `/.well-known/oauth-protected-resource` for automated MCP client authorization. +## What it serves ---- +| Route | Who calls it | Auth | +|---|---|---| +| `POST /mcp` | MCP clients (Streamable HTTP; 2026-07-28 per-request, and 2025-11-25 / 2025-06-18 with `initialize`) | OAuth token with `read`, or one of this server's own tokens with `read` | +| `GET /sse`, `POST /message` | MCP clients on the deprecated 2024-11-05 SSE transport | this server's own token with `read` | +| `/.well-known/oauth-protected-resource/mcp` and `/.well-known/oauth-protected-resource` | MCP clients, discovery | none | +| `/.well-known/oauth-authorization-server`, `/oauth/register`, `/oauth/token`, `/authorize`, `/oauth/github/callback` | MCP clients signing in | see below | +| `POST /auth/device/code`, `POST /auth/device/poll` | `xtctx login` (GitHub device flow) | none | +| `POST /api/stream` | uploads from `xtctx` | CLI token, `sync:write` | +| `POST /api/tokens` | `xtctx sync token` | CLI token, `sync:write` | +| `POST /auth/logout`, `DELETE /api/me` | `xtctx logout [--delete-data]` | CLI token (`account:delete` for the delete) | +| `GET /health` | anyone | none; reports the version, also sent as `X-Xtctx-Server-Version` on every response | -## Deployment Quickstart +### Sign-in + +The Worker is an OAuth 2.1 authorization server for its own MCP resource, `${PUBLIC_URL}/mcp`, built on Cloudflare's [`@cloudflare/workers-oauth-provider`](https://github.com/cloudflare/workers-oauth-provider). GitHub is only the identity step. + +- **MCP clients** find it from the `401` on `/mcp` (whose `WWW-Authenticate` names the protected-resource metadata), register through a Client ID Metadata Document (MCP 2026-07-28) or dynamic registration (older clients), and send the user to `/authorize`. That page names the client and where the access goes, then hands off to GitHub's web flow; the callback checks the allowlist and finishes the grant. PKCE with S256 is required of every client. Tokens are bound to the `/mcp` resource, carry only `read`, last an hour, and refresh for 30 days. +- **The CLI** uses GitHub's device flow (`/auth/device/*`) and gets this server's own token (HS256, 30 days) with `read sync:write account:delete`, valid at `/mcp` and `/api/*`. +- **Pasted tokens** (`xtctx sync token`, `POST /api/tokens`) are this server's own tokens with `read` only, valid 90 days at `/mcp` and `/sse`, for clients that cannot sign in by themselves. + +Only GitHub accounts listed in `ALLOWED_GITHUB_IDS` can sign in, by either route. An empty list lets nobody in. Taking someone off the list ends their access at their next request; they can still log out and delete their data. + +`POST /auth/logout` and `DELETE /api/me` move the account's token epoch, which every token carries and every request checks, and revoke its OAuth grants. The epoch is kept when the account's data is deleted, so a token from before a deletion stays dead after the same GitHub account signs in again. + +### Uploads + +`POST /api/stream` takes `{ device, project, sessions: [{ ..., messages, keepIds? }] }` and writes it as **one D1 batch**: all of it or none. Messages are keyed by the client's own ids, so a replay rewrites the same rows; `message_count` is recounted from the rows; `keepIds`, when sent, deletes every other stored message of that session. Limits, each answered with a JSON error the client acts on: + +| Limit | Value | Over it | +|---|---|---| +| body | 1 MiB | `413 body_too_large` | +| one message's text | 64 KiB (UTF-8) | `413 message_too_large` naming the session and message; the client skips that message | +| one message's metadata | 4 KiB | the same | +| sessions per request | 10 | `400` | +| messages per request | 500 | `400` | +| the pre-0.2 upload format | | `426 client_outdated` | + +These keep a request inside D1's limits: messages go in as one JSON parameter per session, and a request makes at most 4 statements per session plus 2 in its batch, and 2 reads around it: 44 queries for 10 sessions, under the free plan's 50 per invocation. A failed write is logged as `{"event":"ingest_failed","userId",...,"bytes","sessions","messages","error"}`, never with content. + +## Deploying + +In this order. Nothing here is run by the tests or by CI. + +### 1. Install -### 1. Install Dependencies ```bash +cd cloud npm install ``` -### 2. Create Cloudflare D1 Database -```bash -npx wrangler d1 create xtctx-db -``` -Copy the generated `database_id` into `cloud/wrangler.toml`: -```toml -[[d1_databases]] -binding = "DB" -database_name = "xtctx-db" -database_id = "your-database-id-here" -``` +### 2. D1 database -### 3. Create the schema +New deployment only: `npx wrangler d1 create xtctx-db`, then put the printed `database_id` in `wrangler.toml`. -A new database: -```bash -npm run d1:migrate:remote -``` -That applies `schema.sql`, which already includes everything below. +### 3. KV namespace for OAuth -A database created from an earlier `schema.sql` needs the files in `migrations/`, **in order, before you deploy the code that uses them**: ```bash -npx wrangler d1 execute xtctx-db --remote --file=./migrations/0001_token_version.sql +npx wrangler kv namespace create OAUTH_KV ``` -Run each once. For local testing use `--local` instead of `--remote`. -### 4. Configure GitHub OAuth App -1. Go to [GitHub Developer Settings](https://github.com/settings/developers) -> **OAuth Apps** -> **New OAuth App**. -2. Set Application Name to `xtctx`. -3. Set Homepage URL to `https://xtctx.com`. -4. Enable **Device Flow** checkbox in the OAuth App settings. -5. Put your `Client ID` in `wrangler.toml` under `GITHUB_CLIENT_ID`. +Put the printed id in `wrangler.toml` under `[[kv_namespaces]]`, replacing `REPLACE_WITH_OAUTH_KV_NAMESPACE_ID`, which is not a namespace. -### 5. Set the JWT secret (required) -```bash -npx wrangler secret put JWT_SECRET -``` -Use a long random value, for example `openssl rand -base64 48`. There is no default: **until it is set, every route except `/health` answers 500.** Changing it later signs everyone out. +### 4. Schema -### 6. Deploy to Cloudflare ```bash -npm run deploy +npm run d1:migrate:remote # wrangler d1 migrations apply xtctx-db --remote ``` ---- +Migrations live in `migrations/` and `wrangler` records which ran in the database's `d1_migrations` table. Apply them **before** deploying code that needs them. `0000_baseline.sql` is the schema as it was when tracking started; `0001_sync_v2.sql` adds the token epochs and the upload status table, and drops the columns that held absolute paths. + +#### A database created before tracked migrations -## Security notes +A database made from the old `schema.sql` (with or without the old `0001_token_version.sql`) is brought onto the tracked path without losing data: -- Tokens are accepted from the `Authorization` header only, last 30 days, and are revoked by `POST /auth/logout` (all of an account's tokens) or `DELETE /api/me` (which also deletes the account's data). -- Browsers get no CORS headers unless their origin is listed in the `ALLOWED_ORIGINS` variable (comma-separated). The CLI and MCP clients do not need CORS. -- Errors return a generic body; the detail goes to the Worker's log. -- The baseline-MCP `/sse` streams live in the `SseSession` Durable Object, so `/message` works from any isolate. -- `npm test` runs the Worker's tests against an in-memory SQLite. +1. Check that `users` has the `token_version` column: + ```bash + npx wrangler d1 execute xtctx-db --remote --command "SELECT name FROM pragma_table_info('users') WHERE name = 'token_version'" + ``` + If that returns no row, add it first (this was the old `0001_token_version.sql`): + ```bash + npx wrangler d1 execute xtctx-db --remote --command "ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0" + ``` +2. Run `npm run d1:migrate:remote`. It applies `0000_baseline.sql`, which is all `CREATE ... IF NOT EXISTS` and so changes nothing on this database, then `0001_sync_v2.sql`, which copies each user's `token_version` into `token_epochs` (nobody is signed out by the copy) and drops `users.token_version`, `sessions.status`, `sessions.source_path` and `messages.source_pointer`. ---- +Existing rows stay. Messages uploaded by clients before 0.2 have ids in the old scheme; the first sync from each device after it updates replaces them, because the session counts differ and the client then sends the session whole with its ids. -## Custom Domains (xtctx.com) -In your Cloudflare dashboard (or `wrangler.toml`), map: -- `mcp.xtctx.com` -> `xtctx-cloud` Worker (used by Cursor, Claude, Antigravity) -- `sync.xtctx.com` -> `xtctx-cloud` Worker (used by `xtctx login` and `xtctx sync`) +### 5. GitHub OAuth app ---- +At [GitHub Developer Settings](https://github.com/settings/developers) → OAuth Apps, on the app whose client id is `GITHUB_CLIENT_ID` in `wrangler.toml`: -## Connecting Clients +1. Enable **Device Flow** (for `xtctx login`). +2. Set the **Authorization callback URL** to `https://mcp.xtctx.com/oauth/github/callback`, that is `PUBLIC_URL` + `/oauth/github/callback`. +3. Generate a client secret for the next step. + +### 6. Secrets and variables -### Authenticate CLI ```bash -xtctx login +npx wrangler secret put JWT_SECRET # required; e.g. openssl rand -base64 48 +npx wrangler secret put GITHUB_CLIENT_SECRET # the OAuth app's secret, for browser sign-in ``` -### Choose what uploads +- Until `JWT_SECRET` is set every route except `/health` answers 500. Changing it later invalidates every CLI and pasted token. +- Without `GITHUB_CLIENT_SECRET` the CLI's device flow still works and `/authorize` answers 503. +- Set `ALLOWED_GITHUB_IDS` in `wrangler.toml` `[vars]` to the comma-separated numeric GitHub ids allowed to sign in (yours is `"id"` in `https://api.github.com/users/`). It ships empty, which lets nobody in. +- `PUBLIC_URL` (in `wrangler.toml`) is the origin MCP clients connect to; tokens are bound to `PUBLIC_URL/mcp`, so it must match the domain clients use. +- `ALLOWED_ORIGINS` (optional, comma-separated) is the only way a browser origin gets CORS headers, and the only foreign `Origin` `/mcp` accepts. + +### 7. Deploy + ```bash -xtctx sync enable # run in a project: opt it in -xtctx logout # revoke tokens; add --delete-data to erase your uploads +npm run deploy ``` -See [`docs/cloud-sync.md`](../docs/cloud-sync.md) for what is sent and when. - -### Configure Cursor (`.cursor/mcp.json`) or Claude Desktop -```json -{ - "mcpServers": { - "xtctx-cloud": { - "url": "https://mcp.xtctx.com/sse", - "headers": { - "Authorization": "Bearer YOUR_JWT_TOKEN" - } - } - } -} + +The Worker is served only on its custom domains (`workers_dev = false`): `mcp.xtctx.com` for MCP clients and `sync.xtctx.com` for the CLI. Logs and traces go to Workers Observability (`[observability]`). + +After a deploy, CLI users of a version before 0.2 get `426` on upload and must update xtctx, and tokens from before this version (they carry no scopes) are refused, so everyone runs `xtctx login` once. + +## Running it locally + +```bash +cd cloud +node node_modules/wrangler/bin/wrangler.js d1 migrations apply xtctx-db --local --persist-to +node node_modules/wrangler/bin/wrangler.js dev --local --port 8787 \ + --local-upstream localhost:8787 --upstream-protocol http --persist-to \ + --var PUBLIC_URL:http://localhost:8787 --var JWT_SECRET: \ + --var GITHUB_CLIENT_SECRET: --var ALLOWED_GITHUB_IDS: ``` -*(Note: If using modern clients supporting MCP 2026-07-28, you can also connect directly to `https://mcp.xtctx.com/mcp`)* + +- `--local-upstream` and `--upstream-protocol`: without them `wrangler dev` presents requests as `https://mcp.xtctx.com/...` (the first route), so the resource metadata and challenge do not match `http://localhost:8787` and MCP clients cannot discover the sign-in. +- On Windows keep `--persist-to` short (`C:\tmp\xw`, say); a long path runs past `MAX_PATH` and D1 commands fail. +- The browser sign-in needs a GitHub OAuth app whose callback is `http://localhost:8787/oauth/github/callback`; a GitHub OAuth app has one callback URL, so use a separate app for local work. +- Point the CLI at it with `xtctx login --sync-url http://localhost:8787` (plain `http` is accepted only for `localhost`). + +## Tests + +`npm test` runs the Worker in Node against an in-memory SQLite D1 (built from `migrations/`, in order) and an in-memory KV, with GitHub stubbed: uploads, revocation, tenant isolation, the MCP protocol for both eras, and the whole OAuth flow from registration to a token used at `/mcp`. `npm run typecheck` checks the Worker's sources. diff --git a/cloud/schema.sql b/cloud/migrations/0000_baseline.sql similarity index 86% rename from cloud/schema.sql rename to cloud/migrations/0000_baseline.sql index 4217c30..1ad988c 100644 --- a/cloud/schema.sql +++ b/cloud/migrations/0000_baseline.sql @@ -1,4 +1,9 @@ --- xtctx Cloudflare D1 Multi-Tenant Memory Schema +-- Baseline: the schema as it stood when migrations started being tracked by +-- `wrangler d1 migrations apply` (schema.sql plus the old 0001_token_version). +-- +-- Every statement is IF NOT EXISTS, so applying this to a database created +-- before then changes nothing; see cloud/README.md ("A database created +-- before tracked migrations") for the one check to make first. CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, -- e.g. "github:123456" @@ -7,7 +12,7 @@ CREATE TABLE IF NOT EXISTS users ( email TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, - token_version INTEGER NOT NULL DEFAULT 0 -- bumped on logout; see migrations/0001 + token_version INTEGER NOT NULL DEFAULT 0 -- superseded by token_epochs in 0001 ); CREATE TABLE IF NOT EXISTS devices ( diff --git a/cloud/migrations/0001_sync_v2.sql b/cloud/migrations/0001_sync_v2.sql new file mode 100644 index 0000000..c5452fb --- /dev/null +++ b/cloud/migrations/0001_sync_v2.sql @@ -0,0 +1,40 @@ +-- Sync v2: revocation that survives account deletion, authoritative +-- per-session uploads, and no absolute paths held in the cloud. + +-- Revocation state. A token carries its user's epoch when it is minted and +-- is refused once the epoch has moved on: logout and delete-my-data both move +-- it. It lived on `users.token_version`, which delete-my-data removed along +-- with the row, so signing in again started the count over at 0 and every +-- token from before the deletion worked again. This table is never deleted +-- from. +CREATE TABLE IF NOT EXISTS token_epochs ( + user_id TEXT PRIMARY KEY, + epoch INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL +); +INSERT OR IGNORE INTO token_epochs (user_id, epoch, updated_at) + SELECT id, token_version, updated_at FROM users; +ALTER TABLE users DROP COLUMN token_version; + +-- Status was always the constant 'active'. source_path and source_pointer +-- held absolute paths from the uploading machine; nothing reads them. +ALTER TABLE sessions DROP COLUMN status; +ALTER TABLE sessions DROP COLUMN source_path; +ALTER TABLE messages DROP COLUMN source_pointer; + +-- Session detail reads in this order. +DROP INDEX IF EXISTS idx_messages_session_order; +CREATE INDEX IF NOT EXISTS idx_messages_session_time + ON messages(session_ref, timestamp, message_index, id); + +-- One row per user, device and project: when it last uploaded. Lets a reader +-- tell a project that has synced and has nothing from one that never synced. +CREATE TABLE IF NOT EXISTS uploads ( + user_id TEXT NOT NULL, + device_id TEXT NOT NULL, + repo_url TEXT NOT NULL, + project_name TEXT NOT NULL, + last_upload_at TEXT NOT NULL, + client_version TEXT, + PRIMARY KEY (user_id, device_id, repo_url) +); diff --git a/cloud/migrations/0001_token_version.sql b/cloud/migrations/0001_token_version.sql deleted file mode 100644 index dc0cd76..0000000 --- a/cloud/migrations/0001_token_version.sql +++ /dev/null @@ -1,7 +0,0 @@ --- Revocation: a token carries the user's token_version when it is minted and --- is refused once the user's has moved on (logout bumps it). --- --- Additive, and safe to run once on a database created from the earlier --- schema.sql. A database created from the current schema.sql already has the --- column and must not run this. -ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0; diff --git a/cloud/package-lock.json b/cloud/package-lock.json index fc49e45..04a38ad 100644 --- a/cloud/package-lock.json +++ b/cloud/package-lock.json @@ -1,14 +1,14 @@ { "name": "xtctx-cloud", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xtctx-cloud", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { - "@modelcontextprotocol/sdk": "^1.6.0" + "@cloudflare/workers-oauth-provider": "1.2.1" }, "devDependencies": { "@cloudflare/workers-types": "^5.20260930.2", @@ -129,6 +129,12 @@ "node": ">=16" } }, + "node_modules/@cloudflare/workers-oauth-provider": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-oauth-provider/-/workers-oauth-provider-1.2.1.tgz", + "integrity": "sha512-5bw7JJI9Nd4ArHfNnvTF8H2OpSbA2GbqTmX80ly47h7dR+atgjFsn9Wfz+biKlAKClTEUYE0KAr791RkED41VA==", + "license": "MIT" + }, "node_modules/@cloudflare/workers-types": { "version": "5.20260930.2", "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260930.2.tgz", @@ -602,18 +608,6 @@ "node": ">=18" } }, - "node_modules/@hono/node-server": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.3.tgz", - "integrity": "sha512-TA//nWMqPhbfdfneACk6t5a9eqbS9lABEPyKn0/xZTah3H3U2XaVg85rJFl0/Fyit0I552YDHgXGVSf3GwqbUw==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "hono": "^4" - } - }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -1217,46 +1211,6 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.31.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.31.0.tgz", - "integrity": "sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw==", - "license": "MIT", - "dependencies": { - "@hono/node-server": "^1.19.9 || ^2.0.5", - "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/@oxc-project/types": { "version": "0.152.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", @@ -1751,52 +1705,6 @@ "url": "https://opencollective.com/vitest" } }, - "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/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/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1814,81 +1722,6 @@ "dev": true, "license": "MIT" }, - "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^2.0.0", - "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", - "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/body-parser/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/chai": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", @@ -1899,28 +1732,6 @@ "node": ">=18" } }, - "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/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1928,81 +1739,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "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/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "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/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/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2013,35 +1749,6 @@ "node": ">=8" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/error-stack-parser-es": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", @@ -2052,24 +1759,6 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-module-lexer": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", @@ -2077,18 +1766,6 @@ "dev": true, "license": "MIT" }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -2131,12 +1808,6 @@ "@esbuild/win32-x64": "0.28.1" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -2147,36 +1818,6 @@ "@types/estree": "^1.0.0" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "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.1.1", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", - "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -2187,90 +1828,6 @@ "node": ">=12.0.0" } }, - "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/express-rate-limit": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", - "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "ip-address": "^10.2.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "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/fast-uri": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2289,45 +1846,6 @@ } } }, - "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/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "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/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2343,211 +1861,27 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">=6" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" + "detect-libc": "^2.0.3" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hono": { - "version": "4.13.12", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.12.tgz", - "integrity": "sha512-6E2QDAc9Ick9Sq77ZrGS/dk2WUYni91aufTw6LJKpV7w8kW5/GxVUc650FOADOmlwg3K+f7Pun6XlV+pYmW6gw==", - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", - "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/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ip-address": { - "version": "10.7.2", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", - "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", - "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", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "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/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.12", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", - "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "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/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/lightningcss": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", - "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" + "node": ">= 12.0.0" }, "funding": { "type": "opencollective", @@ -2820,65 +2154,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", - "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "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/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/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/miniflare": { "version": "5.20260930.0-alpha", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260930.0-alpha.tgz", @@ -2897,12 +2172,6 @@ "node": ">=22.0.0" } }, - "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/nanoid": { "version": "3.3.19", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", @@ -2922,56 +2191,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/negotiator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", - "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", - "license": "MIT", - "dependencies": { - "content-type": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/negotiator/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/obug": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", @@ -2986,55 +2205,6 @@ "node": ">=12.20.0" } }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "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/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "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": "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/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3062,15 +2232,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "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/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", @@ -3100,76 +2261,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/proxy-addr": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", - "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/qs": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", - "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", - "license": "BSD-3-Clause", - "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", - "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "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/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/rolldown": { "version": "1.2.12", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", @@ -3204,28 +2295,6 @@ "@rolldown/binding-win32-x64-msvc": "1.2.12" } }, - "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/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -3239,57 +2308,6 @@ "node": ">=10" } }, - "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/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/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, "node_modules/sharp": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", @@ -3340,99 +2358,6 @@ } } }, - "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.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "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.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -3457,15 +2382,6 @@ "dev": true, "license": "MIT" }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/std-env": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", @@ -3530,15 +2446,6 @@ "node": ">=14.0.0" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3547,37 +2454,6 @@ "license": "0BSD", "optional": true }, - "node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", - "license": "MIT", - "dependencies": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/type-is/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3619,24 +2495,6 @@ "pathe": "^2.0.3" } }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/vite": { "version": "8.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", @@ -3805,21 +2663,6 @@ } } }, - "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/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -3901,12 +2744,6 @@ "dev": true, "license": "MIT" }, - "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.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", @@ -3967,24 +2804,6 @@ "type": "opencollective", "url": "https://opencollective.com/express" } - }, - "node_modules/zod": { - "version": "4.6.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", - "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", - "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/cloud/package.json b/cloud/package.json index a60da29..3c9193b 100644 --- a/cloud/package.json +++ b/cloud/package.json @@ -1,15 +1,15 @@ { "name": "xtctx-cloud", - "version": "0.1.0", - "description": "Cloudflare Edge MCP server and real-time continuous memory store for xtctx", + "version": "0.2.0", + "description": "Optional xtctx cloud: an MCP server over transcripts uploaded from opted-in projects", "type": "module", "private": true, "scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy", "d1:create": "wrangler d1 create xtctx-db", - "d1:migrate:local": "wrangler d1 execute xtctx-db --local --file=./schema.sql", - "d1:migrate:remote": "wrangler d1 execute xtctx-db --remote --file=./schema.sql", + "d1:migrate:local": "wrangler d1 migrations apply xtctx-db --local", + "d1:migrate:remote": "wrangler d1 migrations apply xtctx-db --remote", "test": "vitest run", "typecheck": "tsc --noEmit" }, @@ -21,6 +21,6 @@ "wrangler": "^4.145.0" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.6.0" + "@cloudflare/workers-oauth-provider": "1.2.1" } } diff --git a/cloud/src/app.ts b/cloud/src/app.ts new file mode 100644 index 0000000..edd4369 --- /dev/null +++ b/cloud/src/app.ts @@ -0,0 +1,276 @@ +import type { Env, AuthUser } from "./types.js"; +import { SCOPE_ACCOUNT_DELETE, SCOPE_READ, SCOPE_SYNC_WRITE } from "./types.js"; +import { + CLI_SCOPES, + CLI_TOKEN_TTL_SECONDS, + READ_TOKEN_TTL_SECONDS, + apiAudience, + authenticateToken, + bearerChallenge, + isAllowedUser, + mcpResource, + mintToken, + type VerifiedToken, +} from "./auth.js"; +import { LIMITS, bumpTokenEpoch, deleteUserData, ingestUpload, parseUpload, upsertUser } from "./db.js"; +import { processMessage } from "./mcp.js"; +import { GITHUB_CALLBACK_PATH, githubUser, handleAuthorize, handleGithubCallback, revokeOAuthGrants } from "./oauth.js"; + +const SESSION_ID = /^[0-9a-f-]{36}$/; +/** An MCP request is one small JSON-RPC message. */ +export const MAX_MCP_BODY_BYTES = 256 * 1024; + +/** + * CORS is for browsers, and nothing here is called from one: the CLI and MCP + * clients are not subject to it. So the default is no CORS headers at all, + * and an origin gets them only by being listed in ALLOWED_ORIGINS. + */ +export function allowedOrigins(env: Env): string[] { + return (env.ALLOWED_ORIGINS ?? "").split(",").map((o) => o.trim()).filter(Boolean); +} + +function corsHeaders(request: Request, env: Env): Record { + const origin = request.headers.get("Origin"); + const headers: Record = { Vary: "Origin" }; + if (origin && allowedOrigins(env).includes(origin)) { + headers["Access-Control-Allow-Origin"] = origin; + headers["Access-Control-Allow-Methods"] = "GET, POST, DELETE, OPTIONS"; + headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, MCP-Protocol-Version, Mcp-Method, Mcp-Name"; + } + return headers; +} + +/** + * Read a body without holding more than `max` bytes of it. Null when it is + * larger, whether or not Content-Length said so. + */ +export async function readBodyCapped(request: Request, max: number): Promise { + const declared = Number(request.headers.get("Content-Length") ?? "NaN"); + if (Number.isFinite(declared) && declared > max) return null; + if (!request.body) return ""; + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > max) { + await reader.cancel().catch(() => undefined); + return null; + } + chunks.push(value); + } + const all = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + all.set(c, offset); + offset += c.byteLength; + } + return new TextDecoder().decode(all); +} + +async function readJson(request: Request, max: number): Promise<{ ok: true; value: unknown } | { ok: false; status: 400 | 413 }> { + const text = await readBodyCapped(request, max); + if (text === null) return { ok: false, status: 413 }; + try { + return { ok: true, value: JSON.parse(text) }; + } catch { + return { ok: false, status: 400 }; + } +} + +/** Everything that is not `/mcp` or one of the provider's own OAuth endpoints. */ +export async function handleApp(request: Request, env: Env): Promise { + const cors = corsHeaders(request, env); + const json = (body: unknown, status = 200, extra: Record = {}) => + new Response(JSON.stringify(body), { status, headers: { ...cors, "Content-Type": "application/json", ...extra } }); + const url = new URL(request.url); + + if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: cors }); + + if (url.pathname === "/authorize") return handleAuthorize(request, env); + if (url.pathname === GITHUB_CALLBACK_PATH && request.method === "GET") return handleGithubCallback(request, env); + + if (url.pathname === "/auth/device/code" && request.method === "POST") { + const ghRes = await fetch("https://github.com/login/device/code", { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ client_id: env.GITHUB_CLIENT_ID, scope: "read:user" }), + }).catch(() => null); + if (!ghRes) return json({ error: "github_unavailable" }, 502); + return json(await ghRes.json().catch(() => ({ error: "github_unavailable" })), ghRes.ok ? 200 : 502); + } + + if (url.pathname === "/auth/device/poll" && request.method === "POST") { + const body = await readJson(request, 4096); + if (!body.ok) return json({ error: "invalid_request" }, 400); + const deviceCode = (body.value as { device_code?: unknown } | null)?.device_code; + if (typeof deviceCode !== "string" || !deviceCode) return json({ error: "invalid_request" }, 400); + + const ghRes = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ + client_id: env.GITHUB_CLIENT_ID, + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }).catch(() => null); + if (!ghRes) return json({ error: "github_unavailable" }, 502); + const data = (await ghRes.json().catch(() => ({}))) as { access_token?: string; error?: string; interval?: number }; + // authorization_pending, slow_down, expired_token, access_denied: the + // client acts on these, so they pass through as GitHub named them. + if (!data.access_token) { + return json({ error: data.error ?? "github_error", ...(data.interval ? { interval: data.interval } : {}) }, 400); + } + + const ghUser = await githubUser(data.access_token).catch(() => null); + if (!ghUser) return json({ error: "github_user_unavailable" }, 502); + + const userId = `github:${ghUser.id}`; + // Before anything is written for this account: ALLOWED_GITHUB_IDS, and + // nobody when it is empty. + if (!isAllowedUser(env, userId)) { + return json({ error: "not_allowed", detail: "This GitHub account is not on this server's allowlist." }, 403); + } + + const epoch = await upsertUser(env, userId, ghUser.login); + const token = await mintToken( + env, + { userId, username: ghUser.login, epoch, scopes: CLI_SCOPES }, + CLI_TOKEN_TTL_SECONDS, + ); + return json({ token, scope: CLI_SCOPES.join(" "), user: { id: userId, username: ghUser.login } }); + } + + // From here every route needs a token. + const requireToken = async ( + audience: string, + scope: string, + options: { mcp?: boolean; ignoreAllowlist?: boolean } = {}, + ): Promise => { + const verified = await authenticateToken(request, env, audience, options); + if (!verified) { + return json({ error: "unauthorized" }, 401, { + "WWW-Authenticate": bearerChallenge(env, { mcp: options.mcp, error: "invalid_token" }), + }); + } + if (!verified.scopes.includes(scope)) { + return json({ error: "insufficient_scope", scope }, 403, { + "WWW-Authenticate": bearerChallenge(env, { mcp: options.mcp, error: "insufficient_scope", scope: [scope] }), + }); + } + return verified; + }; + const asUser = (v: VerifiedToken): AuthUser => ({ userId: v.userId, username: v.username, deviceId: v.deviceId }); + + // Sign out everywhere: every token carries the user's epoch, and this moves it. + if (url.pathname === "/auth/logout" && request.method === "POST") { + const auth = await requireToken(apiAudience(env), SCOPE_READ, { ignoreAllowlist: true }); + if (auth instanceof Response) return auth; + await bumpTokenEpoch(env, auth.userId); + await revokeOAuthGrants(env, auth.userId); + return json({ success: true }); + } + + // Delete everything held for this user and end every token they hold. + if (url.pathname === "/api/me" && request.method === "DELETE") { + const auth = await requireToken(apiAudience(env), SCOPE_ACCOUNT_DELETE, { ignoreAllowlist: true }); + if (auth instanceof Response) return auth; + await deleteUserData(env, auth.userId); + await revokeOAuthGrants(env, auth.userId); + return json({ success: true }); + } + + // A read-only token to paste into an MCP client that cannot do the OAuth sign-in. + if (url.pathname === "/api/tokens" && request.method === "POST") { + const auth = await requireToken(apiAudience(env), SCOPE_SYNC_WRITE); + if (auth instanceof Response) return auth; + const token = await mintToken( + env, + { userId: auth.userId, username: auth.username, epoch: auth.epoch, scopes: [SCOPE_READ] }, + READ_TOKEN_TTL_SECONDS, + ); + return json({ + token, + scope: SCOPE_READ, + mcp_url: mcpResource(env), + expires_at: new Date(Date.now() + READ_TOKEN_TTL_SECONDS * 1000).toISOString(), + }); + } + + if (url.pathname === "/api/stream" && request.method === "POST") { + const auth = await requireToken(apiAudience(env), SCOPE_SYNC_WRITE); + if (auth instanceof Response) return auth; + const text = await readBodyCapped(request, LIMITS.maxBodyBytes); + if (text === null) return json({ error: "body_too_large", limit: LIMITS.maxBodyBytes }, 413); + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + return json({ error: "invalid_request", detail: "body is not JSON" }, 400); + } + const parsed = parseUpload(raw); + if (!parsed.ok) return json(parsed.error, parsed.status); + const messages = parsed.body.sessions.reduce((n, s) => n + s.messages.length, 0); + try { + return json(await ingestUpload(env, asUser(auth), parsed.body, request.headers.get("X-Xtctx-Client"))); + } catch (err) { + // Sizes and counts, never content. + console.error( + JSON.stringify({ + event: "ingest_failed", + userId: auth.userId, + bytes: text.length, + sessions: parsed.body.sessions.length, + messages, + error: err instanceof Error ? err.message : String(err), + }), + ); + return json({ error: "internal_error" }, 500); + } + } + + // Baseline MCP transport (SSE stream, 2024-11-05). The stream is held in a + // Durable Object so /message reaches it from any isolate. Takes this + // server's own tokens (the CLI's, or a pasted read token): OAuth clients use + // /mcp. + if (url.pathname === "/sse" && request.method === "GET") { + const auth = await requireToken(mcpResource(env), SCOPE_READ, { mcp: true }); + if (auth instanceof Response) return auth; + const sessionId = crypto.randomUUID(); + const stub = env.SSE.get(env.SSE.idFromName(sessionId)); + const stream = await stub.fetch("https://sse/connect", { + method: "POST", + body: JSON.stringify({ userId: auth.userId, endpoint: `${url.origin}/message?sessionId=${sessionId}` }), + }); + return new Response(stream.body, { + headers: { ...cors, "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "X-Accel-Buffering": "no" }, + }); + } + + if (url.pathname === "/message" && request.method === "POST") { + const auth = await requireToken(mcpResource(env), SCOPE_READ, { mcp: true }); + if (auth instanceof Response) return auth; + const sessionId = url.searchParams.get("sessionId") ?? ""; + if (!SESSION_ID.test(sessionId)) return json({ error: "session_not_found" }, 404); + + const body = await readJson(request, MAX_MCP_BODY_BYTES); + if (!body.ok) { + return json({ jsonrpc: "2.0", id: null, error: { code: body.status === 413 ? -32600 : -32700, message: "Parse error" } }, body.status); + } + const outcome = await processMessage(env, asUser(auth), body.value); + if (outcome.body) { + const stub = env.SSE.get(env.SSE.idFromName(sessionId)); + const sent = await stub.fetch("https://sse/send", { + method: "POST", + body: JSON.stringify({ userId: auth.userId, payload: outcome.body }), + }); + if (!sent.ok) return json({ error: "session_not_found" }, 404); + } + return json({ status: "accepted" }, 202); + } + + return json({ error: "not_found" }, 404); +} diff --git a/cloud/src/auth.ts b/cloud/src/auth.ts index cbd4a1d..0db1372 100644 --- a/cloud/src/auth.ts +++ b/cloud/src/auth.ts @@ -1,8 +1,14 @@ import type { Env, AuthUser } from "./types.js"; -import { getTokenVersion } from "./db.js"; +import { SCOPE_ACCOUNT_DELETE, SCOPE_READ, SCOPE_SYNC_WRITE } from "./types.js"; +import { getAccountState } from "./db.js"; -/** Tokens last a month. Revocation (below) is what ends one sooner. */ -export const TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30; +/** A CLI login lasts a month. Revocation (below) is what ends one sooner. */ +export const CLI_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30; +/** A read-only token for pasting into an MCP client config. */ +export const READ_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 90; + +/** What `xtctx login` gets: everything the CLI does. */ +export const CLI_SCOPES = [SCOPE_READ, SCOPE_SYNC_WRITE, SCOPE_ACCOUNT_DELETE]; const encoder = new TextEncoder(); @@ -23,13 +29,27 @@ function hmacKey(secret: string, usage: "sign" | "verify"): Promise { return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [usage]); } +/** The canonical MCP resource: what OAuth tokens and pasted tokens are bound to. */ +export function mcpResource(env: Env): string { + return `${publicOrigin(env)}/mcp`; +} + +/** The audience of tokens accepted by `/api/*`; only the CLI's own tokens carry it. */ +export function apiAudience(env: Env): string { + return `${publicOrigin(env)}/api`; +} + +export function publicOrigin(env: Env): string { + return new URL(env.PUBLIC_URL).origin; +} + /** * Mint an HMAC-SHA256 JWT using standard Web Crypto API. */ export async function createJwt( payload: Record, secret: string, - ttlSeconds = TOKEN_TTL_SECONDS, + ttlSeconds = CLI_TOKEN_TTL_SECONDS, ): Promise { const now = Math.floor(Date.now() / 1000); const header = toBase64Url(encoder.encode(JSON.stringify({ alg: "HS256", typ: "JWT" }))); @@ -38,13 +58,40 @@ export async function createJwt( return `${header}.${body}.${toBase64Url(new Uint8Array(signature))}`; } -interface VerifiedToken extends AuthUser { - tokenVersion: number; +/** Mint a token for the CLI or for pasting, bound to this deployment's audiences. */ +export async function mintToken( + env: Env, + claims: { userId: string; username: string; deviceId?: string; epoch: number; scopes: string[] }, + ttlSeconds: number, +): Promise { + const audiences = [mcpResource(env)]; + if (claims.scopes.includes(SCOPE_SYNC_WRITE) || claims.scopes.includes(SCOPE_ACCOUNT_DELETE)) { + audiences.push(apiAudience(env)); + } + return createJwt( + { + iss: publicOrigin(env), + aud: audiences, + sub: claims.userId, + username: claims.username, + device_id: claims.deviceId, + ver: claims.epoch, + scope: claims.scopes.join(" "), + }, + env.JWT_SECRET!, + ttlSeconds, + ); +} + +export interface VerifiedToken extends AuthUser { + epoch: number; + scopes: string[]; + audiences: string[]; } /** * Check a token's signature and expiry. Says nothing about revocation; that - * needs the database, see `authenticateRequest`. + * needs the database, see `authenticateToken`. */ export async function verifyJwt(token: string, secret: string): Promise { try { @@ -64,52 +111,104 @@ export async function verifyJwt(token: string, secret: string): Promise typeof a === "string"), }; } catch { return null; } } +/** The GitHub ids allowed to sign in. Empty means nobody. */ +export function allowedGithubIds(env: Env): Set { + return new Set( + (env.ALLOWED_GITHUB_IDS ?? "") + .split(",") + .map((id) => id.trim()) + .filter((id) => /^\d+$/.test(id)), + ); +} + +export function isAllowedUser(env: Env, userId: string): boolean { + const match = /^github:(\d+)$/.exec(userId); + return match !== null && allowedGithubIds(env).has(match[1]); +} + +/** + * Whether an account may still use a token minted at `epoch`: the account + * exists, the epoch has not moved since (logout and delete-my-data move it), + * and, unless `ignoreAllowlist`, its GitHub id is still on the allowlist. + * Removing someone from ALLOWED_GITHUB_IDS ends their access at once; they + * keep the ability to sign out and delete their data. + */ +export async function isAccountCurrent( + env: Env, + userId: string, + epoch: number, + options: { ignoreAllowlist?: boolean } = {}, +): Promise { + if (!options.ignoreAllowlist && !isAllowedUser(env, userId)) return false; + const state = await getAccountState(env, userId); + return state.exists && state.epoch === epoch; +} + /** - * Authenticate from the Authorization header, and only from there: a token in - * the URL ends up in access logs and browser history. + * Authenticate one of this server's own JWTs (the CLI's, or a pasted read + * token) from the Authorization header, and only from there: a token in the + * URL ends up in access logs and browser history. * - * A valid signature is not enough. The user must still exist and the token's - * version must match the user's current one, which is how logout and - * delete-my-data take effect on a token that has not expired. Fails closed - * when no secret is configured; the caller reports that as a server fault - * before getting here. + * The token must name `audience`, and its account must be current; see + * `isAccountCurrent`. Scope checks are the caller's. */ -export async function authenticateRequest(request: Request, env: Env): Promise { +export async function authenticateToken( + request: Request, + env: Env, + audience: string, + options: { ignoreAllowlist?: boolean } = {}, +): Promise { if (!env.JWT_SECRET) return null; - const header = request.headers.get("Authorization"); - if (!header?.startsWith("Bearer ")) return null; - - const verified = await verifyJwt(header.slice(7).trim(), env.JWT_SECRET); - if (!verified) return null; - - const current = await getTokenVersion(env, verified.userId); - if (current === null || current !== verified.tokenVersion) return null; + const match = header ? /^Bearer[\t ]+(\S+)$/i.exec(header) : null; + if (!match) return null; + return verifyBearer(match[1], env, audience, options); +} - return { userId: verified.userId, username: verified.username, deviceId: verified.deviceId }; +export async function verifyBearer( + token: string, + env: Env, + audience: string, + options: { ignoreAllowlist?: boolean } = {}, +): Promise { + if (!env.JWT_SECRET) return null; + const verified = await verifyJwt(token, env.JWT_SECRET); + if (!verified || !verified.audiences.includes(audience)) return null; + if (!(await isAccountCurrent(env, verified.userId, verified.epoch, options))) return null; + return verified; } /** - * Return RFC 9728 OAuth Protected Resource Metadata for native MCP clients. + * RFC 6750 challenge for the routes this file guards. The MCP ones (`/sse`, + * `/message`) point at the protected-resource metadata so a client can find + * the authorization server; `/api/*` has no OAuth resource of its own. */ -export function getProtectedResourceMetadata(baseUrl: string) { - return { - resource: baseUrl, - authorization_servers: ["https://github.com/login/oauth"], - bearer_methods_supported: ["header"], - scopes_supported: ["read:user", "user:email"], - }; +export function bearerChallenge( + env: Env, + options: { mcp?: boolean; error?: "invalid_token" | "insufficient_scope"; scope?: string[] } = {}, +): string { + const parts = [`Bearer realm="xtctx-cloud"`]; + if (options.mcp) parts.push(`resource_metadata="${publicOrigin(env)}/.well-known/oauth-protected-resource/mcp"`); + if (options.error) parts.push(`error="${options.error}"`); + if (options.scope?.length) parts.push(`scope="${options.scope.join(" ")}"`); + return parts.join(", "); } diff --git a/cloud/src/db.ts b/cloud/src/db.ts index a5108da..1b68acc 100644 --- a/cloud/src/db.ts +++ b/cloud/src/db.ts @@ -1,236 +1,455 @@ -import type { Env, AuthUser, StreamTurnDelta, SessionRecord, MessageRecord } from "./types.js"; +import type { Env, AuthUser, SessionRecord, MessageRecord } from "./types.js"; /** - * Atomically ingest a stream turn delta from an agent device into Cloudflare D1. + * Limits on what one upload may carry. + * + * Every session in a request is written in ONE D1 batch, so a request is + * all-or-nothing. The numbers keep that batch inside D1's limits: messages go + * in as one JSON parameter per session (a bound value may be up to 2 MB, and + * the body cap keeps every one well under), and the statement count stays + * under the 50 queries a Worker invocation may make on the free plan + * (at most 4 per session + 2 in the batch, and 2 reads around it; see + * `ingestUpload`). */ -export async function ingestTurnDelta( - env: Env, - user: AuthUser, - input: StreamTurnDelta | StreamTurnDelta[] -): Promise<{ success: boolean; count: number; sessionRef?: string }> { - const deltas = Array.isArray(input) ? input : [input]; - if (deltas.length === 0) { - return { success: true, count: 0 }; +export const LIMITS = { + /** Whole request body, bytes. */ + maxBodyBytes: 1024 * 1024, + /** One message's `content`, UTF-8 bytes. The client truncates to fit with a marker. */ + maxMessageBytes: 64 * 1024, + /** One message's `metadataJson`, bytes. */ + maxMetadataBytes: 4 * 1024, + maxSessionsPerRequest: 10, + maxMessagesPerRequest: 500, +} as const; + +const ROLES = new Set(["user", "assistant", "system", "tool"]); +const TOOL_ID = /^[a-z0-9][a-z0-9-]{0,39}$/; +const LOCAL_ID = /^[0-9a-f]{16,64}$/; + +export interface UploadMessage { + /** The uploading index's own message id; the cloud id is `${sessionRef}:${id}`. */ + id: string; + timestamp: string; + role: "user" | "assistant" | "system" | "tool"; + content: string; + messageIndex: number; + contentHash: string; + metadataJson: string; +} + +export interface UploadSession { + tool: string; + sourceSessionId: string; + gitBranch: string | null; + gitCommit: string | null; + startedAt: string; + lastActivityAt: string; + preview: string | null; + messages: UploadMessage[]; + /** + * The complete set of this session's message ids, when the client sends it. + * Every stored message of the session not in it is deleted, in the same + * batch, so the cloud copy ends up exactly the local one. + */ + keepIds?: string[]; +} + +export interface UploadBody { + device: { id: string; name: string }; + project: { repoUrl: string; name: string }; + sessions: UploadSession[]; +} + +export type ParseResult = + | { ok: true; body: UploadBody } + | { ok: false; status: 400 | 413 | 426; error: Record }; + +const byteLength = (text: string) => new TextEncoder().encode(text).byteLength; + +/** + * Validate an upload body. Anything malformed is a 400 that never reaches a + * query; anything over a limit is a 413 that names the message, so the client + * can skip exactly that message and carry on. + */ +export function parseUpload(input: unknown): ParseResult { + const bad = (detail: string): ParseResult => ({ ok: false, status: 400, error: { error: "invalid_request", detail } }); + // The body of every client before this version: a flat list of turns. + if (Array.isArray(input)) { + return { ok: false, status: 426, error: { error: "client_outdated", detail: "Update xtctx: this server takes the v2 upload format." } }; } + if (!isObject(input)) return bad("body must be an object"); - const now = new Date().toISOString(); - const nowTs = Date.now(); + const text = (v: unknown, max = 512): v is string => typeof v === "string" && v.length > 0 && v.length <= max; + const optionalText = (v: unknown, max = 512) => v === undefined || v === null || text(v, max); + + const { device, project, sessions } = input; + if (!isObject(device) || !text(device.id, 128) || !text(device.name, 128)) return bad("device"); + if (!isObject(project) || !text(project.repoUrl, 512) || !text(project.name, 256)) return bad("project"); + if (!Array.isArray(sessions) || sessions.length > LIMITS.maxSessionsPerRequest) return bad("sessions"); + + let messageTotal = 0; + const out: UploadSession[] = []; + for (const s of sessions) { + if (!isObject(s)) return bad("session"); + if (!text(s.tool, 40) || !TOOL_ID.test(s.tool) || !text(s.sourceSessionId, 256)) return bad("session id"); + if (!text(s.startedAt, 64) || !text(s.lastActivityAt, 64)) return bad("session times"); + if (!optionalText(s.gitBranch) || !optionalText(s.gitCommit) || !optionalText(s.preview, 4096)) return bad("session fields"); + if (!Array.isArray(s.messages)) return bad("messages"); + messageTotal += s.messages.length; + if (messageTotal > LIMITS.maxMessagesPerRequest) return bad("too many messages in one request"); + + const messages: UploadMessage[] = []; + for (const m of s.messages) { + if (!isObject(m)) return bad("message"); + if (!text(m.id, 64) || !LOCAL_ID.test(m.id)) return bad("message id"); + if (!text(m.timestamp, 64) || !ROLES.has(m.role as string) || typeof m.content !== "string") return bad("message fields"); + if (!Number.isInteger(m.messageIndex) || !text(m.contentHash, 128)) return bad("message fields"); + const metadataJson = m.metadataJson === undefined ? "{}" : m.metadataJson; + if (typeof metadataJson !== "string") return bad("metadataJson"); + if (byteLength(m.content) > LIMITS.maxMessageBytes || byteLength(metadataJson) > LIMITS.maxMetadataBytes) { + return { + ok: false, + status: 413, + error: { + error: "message_too_large", + limit: LIMITS.maxMessageBytes, + tool: s.tool, + sourceSessionId: s.sourceSessionId, + messageId: m.id, + }, + }; + } + messages.push({ + id: m.id, + timestamp: m.timestamp as string, + role: m.role as UploadMessage["role"], + content: m.content, + messageIndex: m.messageIndex as number, + contentHash: m.contentHash as string, + metadataJson, + }); + } + + let keepIds: string[] | undefined; + if (s.keepIds !== undefined) { + if (!Array.isArray(s.keepIds) || !s.keepIds.every((id) => typeof id === "string" && LOCAL_ID.test(id))) { + return bad("keepIds"); + } + keepIds = s.keepIds as string[]; + } + + out.push({ + tool: s.tool, + sourceSessionId: s.sourceSessionId, + gitBranch: (s.gitBranch as string | undefined) ?? null, + gitCommit: (s.gitCommit as string | undefined) ?? null, + startedAt: s.startedAt, + lastActivityAt: s.lastActivityAt, + preview: typeof s.preview === "string" ? s.preview.slice(0, 160) : null, + messages, + keepIds, + }); + } + + return { + ok: true, + body: { + device: { id: device.id as string, name: device.name as string }, + project: { repoUrl: project.repoUrl as string, name: project.name as string }, + sessions: out, + }, + }; +} + +function isObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +export function sessionRefFor(userId: string, tool: string, sourceSessionId: string): string { + return `${userId}:${tool}:${sourceSessionId}`; +} + +export interface IngestResult { + success: true; + sessions: Array<{ tool: string; sourceSessionId: string; messageCount: number }>; +} + +/** + * Write one upload as a single D1 batch: every session in it, or none. + * + * Idempotent: messages are keyed by the client's own message id, so a replay + * rewrites the same rows, and `message_count` is recounted from the rows + * rather than incremented. A session's `keepIds`, when sent, deletes every + * stored message not in it, which is how a local re-read that removed or + * replaced messages reaches the cloud. + */ +export async function ingestUpload( + env: Env, + user: AuthUser, + body: UploadBody, + clientVersion: string | null, +): Promise { + const nowIso = new Date().toISOString(); + const nowMs = Date.now(); + // Device ids come from the client and are the table's primary key, so two + // users picking the same one would otherwise share a row. Scoped to the user. + const deviceId = `${user.userId}:${body.device.id}`; const statements: D1PreparedStatement[] = []; - for (const delta of deltas) { - const sessionRef = `${user.userId}:${delta.tool}:${delta.sourceSessionId}`; - const deviceName = delta.deviceName || delta.deviceId; - // Device ids come from the client and are the table's primary key, so two - // users picking the same one would otherwise share a row. Scoped to the user. - const deviceId = `${user.userId}:${delta.deviceId}`; + statements.push( + env.DB.prepare( + `INSERT INTO devices (id, user_id, device_name, last_seen_at, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET device_name = excluded.device_name, last_seen_at = excluded.last_seen_at + WHERE devices.user_id = excluded.user_id`, + ).bind(deviceId, user.userId, body.device.name, nowMs, nowMs), + ); - statements.push( - env.DB.prepare( - `INSERT INTO devices (id, user_id, device_name, last_seen_at, created_at) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET last_seen_at = excluded.last_seen_at` - ).bind(deviceId, user.userId, deviceName, nowTs, nowTs) - ); + const refs: string[] = []; + for (const s of body.sessions) { + const sessionRef = sessionRefFor(user.userId, s.tool, s.sourceSessionId); + refs.push(sessionRef); + // Earliest start, latest activity, newest known branch: a replay or an + // out-of-order upload can only move these forward. statements.push( env.DB.prepare( `INSERT INTO sessions ( - session_ref, user_id, device_id, tool, source_session_id, - repo_url, project_root, git_branch, git_commit, - started_at, last_activity_at, message_count, preview, source_path, status, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?) + session_ref, user_id, device_id, tool, source_session_id, repo_url, project_root, + git_branch, git_commit, started_at, last_activity_at, message_count, preview, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?) ON CONFLICT(session_ref) DO UPDATE SET - last_activity_at = excluded.last_activity_at, - message_count = message_count + 1, - preview = COALESCE(excluded.preview, preview), - status = excluded.status, - updated_at = excluded.updated_at` + device_id = excluded.device_id, + repo_url = excluded.repo_url, + project_root = excluded.project_root, + git_branch = COALESCE(excluded.git_branch, sessions.git_branch), + git_commit = COALESCE(excluded.git_commit, sessions.git_commit), + started_at = MIN(sessions.started_at, excluded.started_at), + last_activity_at = MAX(sessions.last_activity_at, excluded.last_activity_at), + preview = COALESCE(excluded.preview, sessions.preview), + updated_at = excluded.updated_at + WHERE sessions.user_id = excluded.user_id`, ).bind( sessionRef, user.userId, deviceId, - delta.tool, - delta.sourceSessionId, - delta.repoUrl, - delta.projectRoot, - delta.gitBranch || null, - delta.gitCommit || null, - delta.timestamp, - delta.timestamp, - delta.preview || (delta.content ? delta.content.slice(0, 160) : null), - delta.sourcePointer || null, - delta.status || "active", - now - ) + s.tool, + s.sourceSessionId, + body.project.repoUrl, + body.project.name, + s.gitBranch, + s.gitCommit, + s.startedAt, + s.lastActivityAt, + s.preview, + nowIso, + ), ); - const messageId = `${sessionRef}:${delta.messageIndex}:${delta.contentHash.slice(0, 8)}`; + if (s.messages.length > 0) { + // One statement for the whole session, however many messages: they + // travel as a single JSON parameter. + statements.push( + env.DB.prepare( + `INSERT INTO messages ( + id, session_ref, tool, source_session_id, timestamp, role, content, + message_index, content_hash, metadata_json, indexed_at + ) + SELECT ? || ':' || json_extract(value, '$.id'), ?, ?, ?, + json_extract(value, '$.timestamp'), json_extract(value, '$.role'), + json_extract(value, '$.content'), json_extract(value, '$.messageIndex'), + json_extract(value, '$.contentHash'), json_extract(value, '$.metadataJson'), ? + FROM json_each(?) WHERE true + ON CONFLICT(id) DO UPDATE SET + timestamp = excluded.timestamp, + role = excluded.role, + content = excluded.content, + message_index = excluded.message_index, + content_hash = excluded.content_hash, + metadata_json = excluded.metadata_json`, + ).bind(sessionRef, sessionRef, s.tool, s.sourceSessionId, nowIso, JSON.stringify(s.messages)), + ); + } + + if (s.keepIds) { + statements.push( + env.DB.prepare( + `DELETE FROM messages + WHERE session_ref = ? + AND id NOT IN (SELECT ? || ':' || value FROM json_each(?))`, + ).bind(sessionRef, sessionRef, JSON.stringify(s.keepIds)), + ); + } + statements.push( env.DB.prepare( - `INSERT INTO messages ( - id, session_ref, tool, source_session_id, timestamp, role, - content, message_index, content_hash, metadata_json, source_pointer, indexed_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO NOTHING` - ).bind( - messageId, - sessionRef, - delta.tool, - delta.sourceSessionId, - delta.timestamp, - delta.role, - delta.content, - delta.messageIndex, - delta.contentHash, - delta.metadataJson || "{}", - delta.sourcePointer || null, - now - ) + `UPDATE sessions + SET message_count = (SELECT COUNT(*) FROM messages WHERE messages.session_ref = sessions.session_ref) + WHERE session_ref = ? AND user_id = ?`, + ).bind(sessionRef, user.userId), ); } - // D1 batch execution in chunks of 100 statements - const CHUNK_SIZE = 100; - for (let i = 0; i < statements.length; i += CHUNK_SIZE) { - const chunk = statements.slice(i, i + CHUNK_SIZE); - await env.DB.batch(chunk); - } + statements.push( + env.DB.prepare( + `INSERT INTO uploads (user_id, device_id, repo_url, project_name, last_upload_at, client_version) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, device_id, repo_url) DO UPDATE SET + project_name = excluded.project_name, + last_upload_at = excluded.last_upload_at, + client_version = excluded.client_version`, + ).bind(user.userId, deviceId, body.project.repoUrl, body.project.name, nowIso, clientVersion), + ); + + await env.DB.batch(statements); - const lastSessionRef = `${user.userId}:${deltas[deltas.length - 1].tool}:${deltas[deltas.length - 1].sourceSessionId}`; - return { success: true, count: deltas.length, sessionRef: lastSessionRef }; + if (refs.length === 0) return { success: true, sessions: [] }; + const counted = await env.DB.prepare( + `SELECT tool, source_session_id, message_count FROM sessions + WHERE user_id = ? AND session_ref IN (SELECT value FROM json_each(?))`, + ) + .bind(user.userId, JSON.stringify(refs)) + .all<{ tool: string; source_session_id: string; message_count: number }>(); + return { + success: true, + sessions: (counted.results ?? []).map((r) => ({ + tool: r.tool, + sourceSessionId: r.source_session_id, + messageCount: r.message_count, + })), + }; } -/** - * Fetch recent sessions for a user, optionally filtered by git repo URL or branches. - */ +/** Sessions for a user, newest first, with the name of the device that uploaded each. */ export async function getRecentSessions( env: Env, userId: string, - options: { - repoUrl?: string; - branchFilter?: string[]; - toolFilter?: string[]; - limit?: number; - } + options: { repoUrl?: string; branchFilter?: string[]; toolFilter?: string[]; limit: number }, ): Promise { - const limit = Math.min(options.limit || 5, 25); - let query = `SELECT * FROM sessions WHERE user_id = ?`; + let query = `SELECT s.*, d.device_name FROM sessions s LEFT JOIN devices d ON d.id = s.device_id WHERE s.user_id = ?`; const bindings: unknown[] = [userId]; if (options.repoUrl) { - query += ` AND repo_url = ?`; + query += ` AND s.repo_url = ?`; bindings.push(options.repoUrl); } - if (options.toolFilter && options.toolFilter.length > 0) { - const placeholders = options.toolFilter.map(() => "?").join(", "); - query += ` AND tool IN (${placeholders})`; + query += ` AND s.tool IN (${options.toolFilter.map(() => "?").join(", ")})`; bindings.push(...options.toolFilter); } - if (options.branchFilter && options.branchFilter.length > 0) { - const placeholders = options.branchFilter.map(() => "?").join(", "); - query += ` AND git_branch IN (${placeholders})`; + query += ` AND s.git_branch IN (${options.branchFilter.map(() => "?").join(", ")})`; bindings.push(...options.branchFilter); } - - query += ` ORDER BY last_activity_at DESC LIMIT ?`; - bindings.push(limit); + query += ` ORDER BY s.last_activity_at DESC, s.session_ref ASC LIMIT ?`; + bindings.push(options.limit); const result = await env.DB.prepare(query).bind(...bindings).all(); - return result.results || []; + return result.results ?? []; } -/** - * Fetch messages for a specific session. - */ +/** One session and a page of its messages, in conversation order. Null when it is not this user's. */ export async function getSessionMessages( env: Env, userId: string, sessionRef: string, - options: { offset?: number; limit?: number } = {} + options: { offset: number; limit: number }, ): Promise<{ session: SessionRecord | null; messages: MessageRecord[] }> { const session = await env.DB.prepare( - `SELECT * FROM sessions WHERE session_ref = ? AND user_id = ?` - ).bind(sessionRef, userId).first(); - - if (!session) { - return { session: null, messages: [] }; - } + `SELECT s.*, d.device_name FROM sessions s LEFT JOIN devices d ON d.id = s.device_id + WHERE s.session_ref = ? AND s.user_id = ?`, + ) + .bind(sessionRef, userId) + .first(); + if (!session) return { session: null, messages: [] }; - const offset = options.offset || 0; - const limit = Math.min(options.limit || 50, 100); - - const messagesResult = await env.DB.prepare( - `SELECT * FROM messages - WHERE session_ref = ? - ORDER BY message_index ASC - LIMIT ? OFFSET ?` - ).bind(sessionRef, limit, offset).all(); - - return { - session, - messages: messagesResult.results || [], - }; + const messages = await env.DB.prepare( + `SELECT id, session_ref, tool, source_session_id, timestamp, role, content, + message_index, content_hash, metadata_json, indexed_at + FROM messages + WHERE session_ref = ? + ORDER BY timestamp ASC, message_index ASC, id ASC + LIMIT ? OFFSET ?`, + ) + .bind(sessionRef, options.limit, options.offset) + .all(); + return { session, messages: messages.results ?? [] }; } -const ROLES = new Set(["user", "assistant", "system", "tool"]); -const MAX_BATCH = 200; +export interface UploadStatusRow { + repo_url: string; + project_name: string; + device_id: string; + device_name: string | null; + last_upload_at: string; + client_version: string | null; + sessions: number; +} -/** - * Validate an ingest body. Returns null for anything malformed, so a bad - * request is a 400 and never reaches a query. - */ -export function parseTurnDeltas(input: unknown): StreamTurnDelta[] | null { - const list = Array.isArray(input) ? input : [input]; - if (list.length === 0 || list.length > MAX_BATCH) return null; - - const text = (v: unknown) => typeof v === "string" && v.length > 0; - for (const d of list as Record[]) { - if (typeof d !== "object" || d === null) return null; - if ( - !text(d.deviceId) || !text(d.tool) || !text(d.sourceSessionId) || !text(d.repoUrl) || - !text(d.projectRoot) || !text(d.timestamp) || !text(d.contentHash) || - typeof d.content !== "string" || !ROLES.has(d.role as string) || - !Number.isInteger(d.messageIndex) - ) { - return null; - } - } - return list as StreamTurnDelta[]; +/** When each of the user's devices last uploaded each project. */ +export async function getUploadStatus(env: Env, userId: string, repoUrl?: string): Promise { + const result = await env.DB.prepare( + `SELECT u.repo_url, u.project_name, u.device_id, d.device_name, u.last_upload_at, u.client_version, + (SELECT COUNT(*) FROM sessions s + WHERE s.user_id = u.user_id AND s.repo_url = u.repo_url AND s.device_id = u.device_id) AS sessions + FROM uploads u LEFT JOIN devices d ON d.id = u.device_id + WHERE u.user_id = ? AND (? IS NULL OR u.repo_url = ?) + ORDER BY u.last_upload_at DESC`, + ) + .bind(userId, repoUrl ?? null, repoUrl ?? null) + .all(); + return result.results ?? []; } -export async function upsertUser(env: Env, userId: string, username: string): Promise { +/** Create or rename the account; returns its current token epoch. */ +export async function upsertUser(env: Env, userId: string, username: string): Promise { const now = Date.now(); - await env.DB.prepare( - `INSERT INTO users (id, username, created_at, updated_at) VALUES (?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET username = excluded.username, updated_at = excluded.updated_at` - ).bind(userId, username, now, now).run(); + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO users (id, username, created_at, updated_at) VALUES (?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET username = excluded.username, updated_at = excluded.updated_at`, + ).bind(userId, username, now, now), + env.DB.prepare(`INSERT OR IGNORE INTO token_epochs (user_id, epoch, updated_at) VALUES (?, 0, ?)`).bind(userId, now), + ]); + return (await getAccountState(env, userId)).epoch; } -/** The user's current token version, or null when there is no such user. */ -export async function getTokenVersion(env: Env, userId: string): Promise { - const row = await env.DB.prepare(`SELECT token_version FROM users WHERE id = ?`) - .bind(userId).first<{ token_version: number }>(); - return row ? row.token_version : null; +/** Whether the account exists, and the epoch its tokens must carry. */ +export async function getAccountState(env: Env, userId: string): Promise<{ exists: boolean; epoch: number }> { + const row = await env.DB.prepare( + `SELECT (SELECT 1 FROM users WHERE id = ?) AS user_exists, + (SELECT epoch FROM token_epochs WHERE user_id = ?) AS epoch`, + ) + .bind(userId, userId) + .first<{ user_exists: number | null; epoch: number | null }>(); + return { exists: row?.user_exists === 1, epoch: row?.epoch ?? 0 }; } +const bumpEpochStatement = (env: Env, userId: string) => + env.DB.prepare( + `INSERT INTO token_epochs (user_id, epoch, updated_at) VALUES (?, 1, ?) + ON CONFLICT(user_id) DO UPDATE SET epoch = token_epochs.epoch + 1, updated_at = excluded.updated_at`, + ).bind(userId, Date.now()); + /** Invalidate every token issued to this user so far. */ -export async function bumpTokenVersion(env: Env, userId: string): Promise { - await env.DB.prepare(`UPDATE users SET token_version = token_version + 1 WHERE id = ?`).bind(userId).run(); +export async function bumpTokenEpoch(env: Env, userId: string): Promise { + await bumpEpochStatement(env, userId).run(); } /** - * Remove everything held for a user, children first, then the account row. - * With the row gone no token for it authenticates, so this also signs the user - * out everywhere. + * Remove everything held for a user, children first, then the account row, + * and move the token epoch in the same batch. The epoch row itself is kept: + * it is what stops a token from before the deletion working again after the + * same GitHub account signs in later. */ export async function deleteUserData(env: Env, userId: string): Promise { const owned = `(SELECT session_ref FROM sessions WHERE user_id = ?)`; await env.DB.batch([ + bumpEpochStatement(env, userId), env.DB.prepare(`DELETE FROM retrieval_units WHERE session_ref IN ${owned}`).bind(userId), env.DB.prepare(`DELETE FROM messages WHERE session_ref IN ${owned}`).bind(userId), env.DB.prepare(`DELETE FROM sessions WHERE user_id = ?`).bind(userId), + env.DB.prepare(`DELETE FROM uploads WHERE user_id = ?`).bind(userId), env.DB.prepare(`DELETE FROM devices WHERE user_id = ?`).bind(userId), env.DB.prepare(`DELETE FROM users WHERE id = ?`).bind(userId), ]); diff --git a/cloud/src/index.ts b/cloud/src/index.ts index ba8485f..38713e9 100644 --- a/cloud/src/index.ts +++ b/cloud/src/index.ts @@ -1,190 +1,190 @@ -import type { Env } from "./types.js"; -import { authenticateRequest, getProtectedResourceMetadata, createJwt } from "./auth.js"; -import { - bumpTokenVersion, - deleteUserData, - getTokenVersion, - ingestTurnDelta, - parseTurnDeltas, - upsertUser, -} from "./db.js"; -import { processJsonRpc } from "./mcp.js"; +import { OAuthError, OAuthProvider, insufficientScope } from "@cloudflare/workers-oauth-provider"; +import type { Env, AuthUser } from "./types.js"; +import { SCOPE_READ } from "./types.js"; +import { isAccountCurrent, mcpResource, publicOrigin, verifyBearer } from "./auth.js"; +import { MAX_MCP_BODY_BYTES, allowedOrigins, handleApp, readBodyCapped } from "./app.js"; +import { handleMcpPost, LEGACY_VERSIONS, MODERN_VERSIONS } from "./mcp.js"; +import type { OAuthGrantProps } from "./oauth.js"; +import { SERVER_NAME, SERVER_VERSION } from "./version.js"; export { SseSession } from "./sse-session.js"; -const SESSION_ID = /^[0-9a-f-]{36}$/; +/** OAuth access tokens are short-lived; the refresh token carries the grant. */ +const ACCESS_TOKEN_TTL_SECONDS = 60 * 60; +const REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30; + +interface CliTokenProps { + kind: "cli"; + userId: string; + username: string; + deviceId?: string; + epoch: number; + scopes: string[]; +} + +type McpProps = OAuthGrantProps | CliTokenProps; /** - * CORS is for browsers, and nothing here is called from one: the CLI and MCP - * clients are not subject to it. So the default is no CORS headers at all, - * and an origin gets them only by being listed in ALLOWED_ORIGINS. + * `POST /mcp`, reached only with a token the provider accepted for the MCP + * resource: one it issued through `/authorize`, or one of this server's own + * (see `resolveExternalToken`). */ -function corsHeaders(request: Request, env: Env): Record { - const origin = request.headers.get("Origin"); - const allowed = (env.ALLOWED_ORIGINS ?? "").split(",").map((o) => o.trim()).filter(Boolean); - const headers: Record = { Vary: "Origin" }; - if (origin && allowed.includes(origin)) { - headers["Access-Control-Allow-Origin"] = origin; - headers["Access-Control-Allow-Methods"] = "GET, POST, DELETE, OPTIONS"; - headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, Mcp-Session-Id"; - } - return headers; +const mcpApiHandler = { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + const { props, auth } = ctx as unknown as { + props: McpProps; + auth: { scope: string[]; audience: string; token: string }; + }; + const scopes = props.kind === "oauth" ? auth.scope : props.scopes; + // The provider checked the token; whether the account behind it still may + // read is ours: logout and delete-my-data move the epoch, and the + // allowlist can drop someone. + if (props.kind === "oauth" && !(await isAccountCurrent(env, props.userId, props.epoch))) { + return new Response(JSON.stringify({ error: "invalid_token" }), { + status: 401, + headers: { + "Content-Type": "application/json", + "WWW-Authenticate": `Bearer realm="OAuth", resource_metadata="${publicOrigin(env)}/.well-known/oauth-protected-resource/mcp", error="invalid_token"`, + }, + }); + } + if (!scopes.includes(SCOPE_READ)) return insufficientScope(auth as never, [SCOPE_READ]); + + const text = await readBodyCapped(request, MAX_MCP_BODY_BYTES); + if (text === null) { + return Response.json({ jsonrpc: "2.0", id: null, error: { code: -32600, message: "Request too large" } }, { status: 413 }); + } + const user: AuthUser = { userId: props.userId, username: props.username }; + return handleMcpPost(env, user, request, text); + }, +}; + +const providers = new Map>(); + +/** One provider per public origin: its endpoints and resource are absolute URLs. */ +function providerFor(env: Env): OAuthProvider { + const origin = publicOrigin(env); + let provider = providers.get(origin); + if (provider) return provider; + + provider = new OAuthProvider({ + apiRoute: "/mcp", + apiHandler: mcpApiHandler as never, + defaultHandler: { fetch: (request: Request, env: Env) => handleApp(request, env) } as never, + authorizeEndpoint: `${origin}/authorize`, + tokenEndpoint: `${origin}/oauth/token`, + // Deprecated by MCP 2026-07-28 in favour of Client ID Metadata Documents + // (enabled below), and kept because clients in use today still register + // this way. + clientRegistrationEndpoint: `${origin}/oauth/register`, + clientIdMetadataDocumentEnabled: true, + scopesSupported: [SCOPE_READ], + requiredScopes: [SCOPE_READ], + accessTokenTTL: ACCESS_TOKEN_TTL_SECONDS, + refreshTokenTTL: REFRESH_TOKEN_TTL_SECONDS, + resourceMetadata: { + resource: mcpResource(env), + authorization_servers: [origin], + bearer_methods_supported: ["header"], + resource_name: "xtctx cloud", + }, + // This server's own JWTs at /mcp: the CLI's login, or a pasted read + // token. Both name the MCP resource in `aud`, so this is the same + // audience check the provider applies to its own tokens. + resolveExternalToken: async ({ token, env }) => { + const verified = await verifyBearer(token, env, mcpResource(env)); + if (!verified) return null; + const props: CliTokenProps = { + kind: "cli", + userId: verified.userId, + username: verified.username, + deviceId: verified.deviceId, + epoch: verified.epoch, + scopes: verified.scopes, + }; + return { props, audience: mcpResource(env) }; + }, + // A refresh after logout or delete-my-data, or after the account left the + // allowlist, ends the grant instead of minting a token that /mcp refuses. + tokenExchangeCallback: async ({ grantType, props, env }) => { + if (grantType !== "refresh_token") return; + const p = props as OAuthGrantProps; + if (!(await isAccountCurrent(env, p.userId, p.epoch))) { + throw new OAuthError("invalid_grant", { description: "This sign-in has been revoked" }); + } + }, + onError: ({ code, status, internal }) => { + if (status >= 500) console.error(JSON.stringify({ event: "oauth_error", code, status, internal })); + }, + }); + providers.set(origin, provider); + return provider; } +function withServerHeaders(response: Response): Response { + const headers = new Headers(response.headers); + headers.set("X-Xtctx-Server-Version", SERVER_VERSION); + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); +} + +const PROTECTED_RESOURCE_ROOT = "/.well-known/oauth-protected-resource"; + export default { - async fetch(request: Request, env: Env): Promise { - const cors = corsHeaders(request, env); + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { const json = (body: unknown, status = 200, extra: Record = {}) => - new Response(JSON.stringify(body), { - status, - headers: { ...cors, "Content-Type": "application/json", ...extra }, - }); + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", ...extra } }); try { const url = new URL(request.url); - if (request.method === "OPTIONS") { - return new Response(null, { status: 204, headers: cors }); - } - if (url.pathname === "/" || url.pathname === "/health") { - return json({ - status: "ok", - service: "xtctx-cloud", - mcp: { - supportedVersions: ["2026-07-28", "2024-11-05"], - endpoints: { stateless: "/mcp", sse: "/sse" }, - }, - }); + return withServerHeaders( + json({ + status: "ok", + service: SERVER_NAME, + version: SERVER_VERSION, + mcp: { + supportedVersions: [...MODERN_VERSIONS, ...LEGACY_VERSIONS], + endpoints: { streamableHttp: "/mcp", sse: "/sse" }, + }, + }), + ); } // Without a signing secret nothing below can be trusted, so none of it // runs. There used to be a built-in default, which made every token // forgeable on a deployment that forgot to set one. - if (!env.JWT_SECRET) { - console.error("JWT_SECRET is not set; refusing to serve"); - return json({ error: "server_misconfigured" }, 500); - } - - if (url.pathname === "/.well-known/oauth-protected-resource") { - return json(getProtectedResourceMetadata(url.origin)); + if (!env.JWT_SECRET || !env.PUBLIC_URL) { + console.error(JSON.stringify({ event: "misconfigured", jwtSecret: !!env.JWT_SECRET, publicUrl: !!env.PUBLIC_URL })); + return withServerHeaders(json({ error: "server_misconfigured" }, 500)); } - if (url.pathname === "/auth/device/code" && request.method === "POST") { - const ghRes = await fetch("https://github.com/login/device/code", { - method: "POST", - headers: { Accept: "application/json", "Content-Type": "application/json" }, - body: JSON.stringify({ client_id: env.GITHUB_CLIENT_ID, scope: "read:user user:email" }), - }); - return json(await ghRes.json()); - } - - if (url.pathname === "/auth/device/poll" && request.method === "POST") { - const body = (await request.json()) as { device_code?: string; device_name?: string }; - if (typeof body.device_code !== "string") return json({ error: "invalid_request" }, 400); - - const ghRes = await fetch("https://github.com/login/oauth/access_token", { - method: "POST", - headers: { Accept: "application/json", "Content-Type": "application/json" }, - body: JSON.stringify({ - client_id: env.GITHUB_CLIENT_ID, - device_code: body.device_code, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }), - }); - const data = (await ghRes.json()) as { access_token?: string; error?: string }; - if (!data.access_token) return json(data, 400); - - const userRes = await fetch("https://api.github.com/user", { - headers: { Authorization: `Bearer ${data.access_token}`, "User-Agent": "xtctx-cloud" }, - }); - const ghUser = (await userRes.json()) as { id?: number; login?: string; name?: string }; - if (!userRes.ok || typeof ghUser.id !== "number" || !ghUser.login) { - return json({ error: "github_user_unavailable" }, 502); + if (url.pathname === "/mcp" || url.pathname.startsWith("/mcp/")) { + // Streamable HTTP: a present Origin that is not ours is refused + // (DNS rebinding). CLI and agent clients send none. + const origin = request.headers.get("Origin"); + if (origin && origin !== publicOrigin(env) && !allowedOrigins(env).includes(origin)) { + return withServerHeaders(json({ jsonrpc: "2.0", error: { code: -32600, message: "Origin not allowed" } }, 403)); + } + // No GET stream and no sessions to DELETE: both eras get 405. + if (request.method !== "POST" && request.method !== "OPTIONS") { + return withServerHeaders(new Response(null, { status: 405, headers: { Allow: "POST" } })); } - - const userId = `github:${ghUser.id}`; - await upsertUser(env, userId, ghUser.login); - const token = await createJwt( - { - sub: userId, - username: ghUser.login, - device_id: body.device_name || "default", - ver: await getTokenVersion(env, userId), - }, - env.JWT_SECRET, - ); - return json({ token, user: { id: userId, username: ghUser.login, name: ghUser.name } }); - } - - const user = await authenticateRequest(request, env); - if (!user) { - return json({ error: "unauthorized" }, 401, { - "WWW-Authenticate": 'Bearer realm="xtctx-cloud", error="invalid_token"', - }); - } - - // Sign out everywhere: tokens carry the user's version, and this moves it. - if (url.pathname === "/auth/logout" && request.method === "POST") { - await bumpTokenVersion(env, user.userId); - return json({ success: true }); - } - - // Delete everything held for this user, then their account row, which - // also ends every token they hold. - if (url.pathname === "/api/me" && request.method === "DELETE") { - await deleteUserData(env, user.userId); - return json({ success: true }); - } - - if (url.pathname === "/api/stream" && request.method === "POST") { - const deltas = parseTurnDeltas(await request.json()); - if (!deltas) return json({ error: "invalid_request" }, 400); - return json(await ingestTurnDelta(env, user, deltas)); - } - - // Modern MCP endpoint (stateless, 2026-07-28 spec) - if (url.pathname === "/mcp" && request.method === "POST") { - const payload = await processJsonRpc(env, user, (await request.json()) as Record); - if (!payload) return new Response(null, { status: 204, headers: cors }); - return json(payload); - } - - // Baseline MCP endpoint (SSE stream, 2024-11-05 spec). The stream is held - // in a Durable Object so /message reaches it from any isolate. - if (url.pathname === "/sse" && request.method === "GET") { - const sessionId = crypto.randomUUID(); - const stub = env.SSE.get(env.SSE.idFromName(sessionId)); - const stream = await stub.fetch("https://sse/connect", { - method: "POST", - body: JSON.stringify({ userId: user.userId, endpoint: `${url.origin}/message?sessionId=${sessionId}` }), - }); - return new Response(stream.body, { - headers: { ...cors, "Content-Type": "text/event-stream", "Cache-Control": "no-cache" }, - }); } - if (url.pathname === "/message" && request.method === "POST") { - const sessionId = url.searchParams.get("sessionId") ?? ""; - if (!SESSION_ID.test(sessionId)) return json({ error: "session_not_found" }, 404); - - const payload = await processJsonRpc(env, user, (await request.json()) as Record); - if (payload) { - const stub = env.SSE.get(env.SSE.idFromName(sessionId)); - const sent = await stub.fetch("https://sse/send", { - method: "POST", - body: JSON.stringify({ userId: user.userId, payload }), - }); - if (!sent.ok) return json({ error: "session_not_found" }, 404); - } - return json({ status: "accepted" }, 202); + // RFC 9728 puts this resource's metadata at the /mcp form; some clients + // (Claude Code among them) also try the root form, so it answers too, + // with the same document. + if (url.pathname === PROTECTED_RESOURCE_ROOT && (request.method === "GET" || request.method === "HEAD")) { + const aliased = new Request(`${publicOrigin(env)}${PROTECTED_RESOURCE_ROOT}/mcp`, { method: request.method }); + return withServerHeaders(await providerFor(env).fetch(aliased, env, ctx)); } - return json({ error: "not_found" }, 404); + return withServerHeaders(await providerFor(env).fetch(request, env, ctx)); } catch (err: unknown) { // The detail is for whoever reads the Worker's logs, not for the caller. - console.error("unhandled error", err); - return json({ error: "internal_error" }, 500); + console.error(JSON.stringify({ event: "unhandled_error", error: err instanceof Error ? err.stack ?? err.message : String(err) })); + return withServerHeaders(json({ error: "internal_error" }, 500)); } }, }; diff --git a/cloud/src/mcp.ts b/cloud/src/mcp.ts index 96473d3..c645ef8 100644 --- a/cloud/src/mcp.ts +++ b/cloud/src/mcp.ts @@ -1,194 +1,428 @@ -import type { Env, AuthUser } from "./types.js"; -import { getRecentSessions, getSessionMessages } from "./db.js"; +import type { Env, AuthUser, MessageRecord, SessionRecord } from "./types.js"; +import { getRecentSessions, getSessionMessages, getUploadStatus } from "./db.js"; +import { SERVER_NAME, SERVER_VERSION } from "./version.js"; + +/** + * MCP over JSON-RPC, for two eras of client on one endpoint. + * + * Modern (2026-07-28): no handshake; every request carries its protocol + * version in `params._meta` and, over HTTP, mirrors it and the method into + * headers that must agree with the body. Legacy (2025-11-25, 2025-06-18, and + * 2024-11-05 over the `/sse` transport): an `initialize` handshake. Neither + * keeps session state here, so nothing depends on which isolate answers. + * + * JSON-RPC batches are refused: 2025-06-18 removed them and 2026-07-28 allows + * one message per POST. + */ + +export const MODERN_VERSIONS = ["2026-07-28"]; +export const LEGACY_VERSIONS = ["2025-11-25", "2025-06-18", "2024-11-05"]; + +const META_VERSION = "io.modelcontextprotocol/protocolVersion"; +const META_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities"; +const META_SERVER_INFO = "io.modelcontextprotocol/serverInfo"; + +export const ERR = { + parse: -32700, + invalidRequest: -32600, + methodNotFound: -32601, + invalidParams: -32602, + internal: -32603, + headerMismatch: -32020, + unsupportedVersion: -32022, +} as const; + +const SERVER_INFO = { name: SERVER_NAME, version: SERVER_VERSION }; + +/** + * 2026-07-28 caching hints, required on server/discover and tools/list. The + * tool list and the discovery result are the same for every user, so they + * are public; they change only with a deploy. + */ +const CACHE_PUBLIC = { ttlMs: 60 * 60 * 1000, cacheScope: "public" } as const; + +const INSTRUCTIONS = + "Transcripts you uploaded to xtctx cloud from your other machines. " + + "Call xtctx_cloud_status first to see which projects and devices have uploaded and when; " + + "then xtctx_cloud_recent_sessions and xtctx_cloud_session_detail. " + + "For this machine's own sessions use the local xtctx tools."; export const MCP_TOOLS = [ { - name: "xtctx_recent_sessions", - description: "List recent transcript sessions across all your local devices for this project. Call this when you need cross-device or cross-tool handoff context.", + name: "xtctx_cloud_recent_sessions", + description: + "List sessions uploaded to your xtctx cloud account, newest first. Covers every device and every " + + "project you have uploaded unless repo_url is given, which restricts it to one repository. " + + "Use it to pick up work done on another machine; this machine's sessions are in the local xtctx tools.", inputSchema: { type: "object", properties: { - repo_url: { type: "string", description: "Optional git repository URL (e.g. github.com/user/repo) to filter sessions" }, - limit: { type: "number", description: "Max sessions to return. Default: 5" }, - tool_filter: { - type: "array", - items: { type: "string" }, - description: "Optional tool ids: claude-code, antigravity, cursor, copilot, codex" + repo_url: { + type: "string", + description: "Only sessions from this repository, as xtctx names it (e.g. github.com/user/repo).", }, - branch_filter: { + limit: { type: "integer", minimum: 1, maximum: 25, description: "Max sessions to return, 1-25. Default 5." }, + tool_filter: { type: "array", items: { type: "string" }, - description: "Optional git branches to include" + description: "Only these tools: claude-code, antigravity, cursor, copilot, codex, opencode.", }, - format: { - type: "string", - enum: ["markdown", "json"], - description: "Response format. Default: markdown" - } - } - } + branch_filter: { type: "array", items: { type: "string" }, description: "Only these git branches." }, + format: { type: "string", enum: ["markdown", "json"], description: "Default markdown." }, + }, + }, }, { - name: "xtctx_session_detail", - description: "Return raw messages from a session_ref returned by xtctx_recent_sessions.", + name: "xtctx_cloud_session_detail", + description: "Return the messages of one uploaded session, in conversation order, by the session_ref that xtctx_cloud_recent_sessions gave.", inputSchema: { type: "object", required: ["session_ref"], properties: { - session_ref: { type: "string", description: "Session reference string" }, - offset: { type: "number", description: "Message offset for pagination" }, - limit: { type: "number", description: "Max messages to return. Default: 50" } - } - } - } + session_ref: { type: "string", description: "A session_ref from xtctx_cloud_recent_sessions." }, + offset: { type: "integer", minimum: 0, description: "Messages to skip, for paging. Default 0." }, + limit: { type: "integer", minimum: 1, maximum: 100, description: "Max messages to return, 1-100. Default 50." }, + format: { type: "string", enum: ["markdown", "json"], description: "Default markdown." }, + }, + }, + }, + { + name: "xtctx_cloud_status", + description: + "When each of your devices last uploaded each project to xtctx cloud, and how many sessions it holds. " + + "Tells a project that synced and has nothing new apart from one that never synced.", + inputSchema: { + type: "object", + properties: { + repo_url: { type: "string", description: "Only this repository." }, + format: { type: "string", enum: ["markdown", "json"], description: "Default markdown." }, + }, + }, + }, ]; +/** Thrown for arguments a tool cannot use; reported to the model as a tool error so it can correct them. */ +class ArgumentError extends Error {} + +function optionalString(args: Record, key: string): string | undefined { + const v = args[key]; + if (v === undefined || v === null) return undefined; + if (typeof v !== "string") throw new ArgumentError(`${key} must be a string`); + return v; +} + +function optionalStrings(args: Record, key: string): string[] | undefined { + const v = args[key]; + if (v === undefined || v === null) return undefined; + if (!Array.isArray(v) || !v.every((x) => typeof x === "string") || v.length > 20) { + throw new ArgumentError(`${key} must be a list of at most 20 strings`); + } + return v as string[]; +} + +/** An integer clamped into [min, max]; anything that is not a number is an error, not "unlimited". */ +function clampInt(args: Record, key: string, min: number, max: number, fallback: number): number { + const v = args[key]; + if (v === undefined || v === null) return fallback; + if (typeof v !== "number" || !Number.isFinite(v)) throw new ArgumentError(`${key} must be a number`); + return Math.min(max, Math.max(min, Math.trunc(v))); +} + +function format(args: Record): "markdown" | "json" { + const v = optionalString(args, "format") ?? "markdown"; + if (v !== "markdown" && v !== "json") throw new ArgumentError(`format must be "markdown" or "json"`); + return v; +} + +const text = (body: string) => ({ content: [{ type: "text", text: body }] }); +const oneLine = (s: string | null | undefined) => (s ?? "").replace(/\s+/g, " ").trim(); +const deviceLabel = (s: { device_name?: string | null; device_id: string }) => s.device_name || s.device_id; + +function publicSession(s: SessionRecord) { + return { + session_ref: s.session_ref, + tool: s.tool, + repo_url: s.repo_url, + project: s.project_root, + device: deviceLabel(s), + git_branch: s.git_branch, + git_commit: s.git_commit, + started_at: s.started_at, + last_activity_at: s.last_activity_at, + message_count: s.message_count, + preview: s.preview, + }; +} + +function publicMessage(m: MessageRecord) { + let metadata: unknown = {}; + try { + metadata = JSON.parse(m.metadata_json); + } catch { + // stored as sent; a bad one is shown as empty rather than failing the call + } + return { message_index: m.message_index, timestamp: m.timestamp, role: m.role, content: m.content, metadata }; +} + +/** Unknown tool: null, which the caller reports as a protocol error (-32602). */ export async function handleToolCall( env: Env, user: AuthUser, name: string, - args: Record -): Promise { - if (name === "xtctx_recent_sessions") { - const sessions = await getRecentSessions(env, user.userId, { - repoUrl: typeof args.repo_url === "string" ? args.repo_url : undefined, - limit: typeof args.limit === "number" ? args.limit : 5, - toolFilter: Array.isArray(args.tool_filter) ? (args.tool_filter as string[]) : undefined, - branchFilter: Array.isArray(args.branch_filter) ? (args.branch_filter as string[]) : undefined, - }); - - if (args.format === "json") { - return { - content: [{ type: "text", text: JSON.stringify(sessions, null, 2) }] - }; + args: Record, +): Promise | null> { + try { + if (name === "xtctx_cloud_recent_sessions") { + const fmt = format(args); + const sessions = await getRecentSessions(env, user.userId, { + repoUrl: optionalString(args, "repo_url"), + limit: clampInt(args, "limit", 1, 25, 5), + toolFilter: optionalStrings(args, "tool_filter"), + branchFilter: optionalStrings(args, "branch_filter"), + }); + if (fmt === "json") return text(JSON.stringify(sessions.map(publicSession), null, 2)); + if (sessions.length === 0) { + return text( + "No uploaded sessions match. Call xtctx_cloud_status to see whether this project has uploaded at all.", + ); + } + const lines = ["## Sessions in xtctx cloud", ""]; + for (const s of sessions) { + const branch = s.git_branch ? ` on branch \`${s.git_branch}\`` : ""; + lines.push( + `- **${s.tool}** from **${deviceLabel(s)}** in ${s.repo_url}${branch}`, + ` - Last active ${s.last_activity_at} (${s.message_count} messages)`, + ` - Preview: ${oneLine(s.preview) || "none"}`, + ` - session_ref: \`${s.session_ref}\``, + ); + } + return text(lines.join("\n")); } - if (sessions.length === 0) { - return { - content: [{ type: "text", text: "No recent cross-device sessions found for this project." }] - }; + if (name === "xtctx_cloud_session_detail") { + const sessionRef = optionalString(args, "session_ref"); + if (!sessionRef) throw new ArgumentError("session_ref is required"); + const fmt = format(args); + const offset = clampInt(args, "offset", 0, Number.MAX_SAFE_INTEGER, 0); + const limit = clampInt(args, "limit", 1, 100, 50); + const { session, messages } = await getSessionMessages(env, user.userId, sessionRef, { offset, limit }); + if (!session) { + return { isError: true, ...text(`Session '${sessionRef}' not found. Use a session_ref from xtctx_cloud_recent_sessions.`) }; + } + const page = { offset, returned: messages.length, total: session.message_count }; + if (fmt === "json") { + return text(JSON.stringify({ session: publicSession(session), page, messages: messages.map(publicMessage) }, null, 2)); + } + const lines = [ + `## ${session.tool} session from ${deviceLabel(session)}`, + `${session.repo_url}${session.git_branch ? ` on \`${session.git_branch}\`` : ""}, ${session.started_at} to ${session.last_activity_at}`, + `Messages ${offset + 1}-${offset + messages.length} of ${session.message_count}.`, + "", + ]; + for (const m of messages) { + lines.push(`### ${m.role} (#${m.message_index}, ${m.timestamp})`, "", m.content, ""); + } + if (offset + messages.length < session.message_count) { + lines.push(`More: call again with offset ${offset + messages.length}.`); + } + return text(lines.join("\n")); } - const lines: string[] = ["## Recent Cross-Device Sessions\n"]; - for (const s of sessions) { - const branchInfo = s.git_branch ? ` (branch: \`${s.git_branch}\`)` : ""; - const deviceTag = `[Device: ${s.device_id}]`; - const timeStr = new Date(s.last_activity_at).toLocaleString(); - lines.push( - `- **${s.tool}** on **${deviceTag}**${branchInfo}\n` + - ` - Last active: ${timeStr} (${s.message_count} messages)\n` + - ` - Preview: ${s.preview || "No preview"}\n` + - ` - Ref: \`${s.session_ref}\`\n` - ); + if (name === "xtctx_cloud_status") { + const fmt = format(args); + const rows = await getUploadStatus(env, user.userId, optionalString(args, "repo_url")); + if (fmt === "json") return text(JSON.stringify(rows, null, 2)); + if (rows.length === 0) { + return text( + "Nothing has been uploaded to this xtctx cloud account" + + (args.repo_url ? " for that repository" : "") + + ". On the machine that has the sessions, run `xtctx sync enable` in the project, then `xtctx sync`.", + ); + } + const lines = ["## xtctx cloud uploads", "", "| Project | Device | Last upload | Sessions |", "|---|---|---|---|"]; + for (const r of rows) { + lines.push(`| ${r.project_name} (${r.repo_url}) | ${r.device_name ?? r.device_id} | ${r.last_upload_at} | ${r.sessions} |`); + } + return text(lines.join("\n")); } - - return { - content: [{ type: "text", text: lines.join("\n") }] - }; + } catch (err) { + if (err instanceof ArgumentError) return { isError: true, ...text(`Invalid arguments: ${err.message}`) }; + throw err; } + return null; +} - if (name === "xtctx_session_detail") { - const sessionRef = String(args.session_ref); - const { session, messages } = await getSessionMessages(env, user.userId, sessionRef, { - offset: typeof args.offset === "number" ? args.offset : 0, - limit: typeof args.limit === "number" ? args.limit : 50, - }); +export interface RpcOutcome { + /** HTTP status for the HTTP transport. */ + status: number; + /** JSON-RPC message to send back, or null for none (a notification). */ + body: Record | null; +} - if (!session) { - return { - isError: true, - content: [{ type: "text", text: `Session '${sessionRef}' not found.` }] - }; - } +const error = (status: number, id: unknown, code: number, message: string, data?: unknown): RpcOutcome => ({ + status, + body: { jsonrpc: "2.0", id: id ?? null, error: data === undefined ? { code, message } : { code, message, data } }, +}); - return { - content: [ - { - type: "text", - text: JSON.stringify({ session, messages }, null, 2) - } - ] - }; +/** Base64 sentinel decoding for mirrored header values (`=?base64?...?=`). */ +function decodeHeaderValue(value: string): string { + const m = /^=\?base64\?([A-Za-z0-9+/=]*)\?=$/.exec(value); + if (!m) return value; + try { + return new TextDecoder().decode(Uint8Array.from(atob(m[1]), (c) => c.charCodeAt(0))); + } catch { + return "\u0000invalid"; } - - throw new Error(`Unknown tool: ${name}`); } +const isObject = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v); + /** - * Handle incoming JSON-RPC payload for both modern (2026-07-28) and baseline (2024-11-05). + * Handle one parsed JSON-RPC message. `headers` is given for Streamable HTTP, + * whose modern-era header checks it enables; the SSE transport passes none. */ -export async function processJsonRpc( +export async function processMessage( env: Env, user: AuthUser, - body: Record -): Promise | null> { - const { id, method, params } = body as { id?: unknown; method: string; params?: Record }; + message: unknown, + headers?: Headers, +): Promise { + if (!isObject(message) || message.jsonrpc !== "2.0") { + return error(400, null, ERR.invalidRequest, "Invalid Request: expected a single JSON-RPC 2.0 object"); + } + const { id, method } = message; - if (method === "initialize") { - // Protocol negotiation: accept client's requested version or default to baseline - const clientVersion = (params?.protocolVersion as string) || "2024-11-05"; - return { - jsonrpc: "2.0", - id, - result: { - protocolVersion: clientVersion === "2026-07-28" ? "2026-07-28" : "2024-11-05", - capabilities: { - tools: { listChanged: false } - }, - serverInfo: { - name: "xtctx-cloud", - version: "0.22.0" - } - } - }; + if (typeof method !== "string") { + // A response from the client (to nothing we sent) is accepted and dropped. + if ("result" in message || "error" in message) return { status: 202, body: null }; + return error(400, id, ERR.invalidRequest, "Invalid Request: missing method"); + } + // Any notification: accepted, never answered. + if (!("id" in message)) return { status: 202, body: null }; + if (!(typeof id === "string" || (typeof id === "number" && Number.isInteger(id)))) { + return error(400, null, ERR.invalidRequest, "Invalid Request: id must be a string or an integer"); + } + if (message.params !== undefined && !isObject(message.params)) { + return error(400, id, ERR.invalidRequest, "Invalid Request: params must be an object"); } + const params = (message.params ?? {}) as Record; + const meta = isObject(params._meta) ? params._meta : {}; + const metaVersion = meta[META_VERSION]; + const headerVersion = headers?.get("MCP-Protocol-Version") ?? null; - if (method === "notifications/initialized") { - // Notification, no response required - return null; + const modern = + method !== "initialize" && + (metaVersion !== undefined || (headerVersion !== null && !LEGACY_VERSIONS.includes(headerVersion))); + + if (modern) { + if (typeof metaVersion !== "string") { + return error(400, id, ERR.invalidParams, `Missing _meta["${META_VERSION}"]`); + } + if (headers && headerVersion === null) { + return error(400, id, ERR.headerMismatch, "Header mismatch: MCP-Protocol-Version header is required"); + } + if (headers && headerVersion !== metaVersion) { + return error(400, id, ERR.headerMismatch, `Header mismatch: MCP-Protocol-Version '${headerVersion}' does not match body '${metaVersion}'`); + } + if (!MODERN_VERSIONS.includes(metaVersion)) { + return error(400, id, ERR.unsupportedVersion, "Unsupported protocol version", { + supported: [...MODERN_VERSIONS, ...LEGACY_VERSIONS], + requested: metaVersion, + }); + } + if (!isObject(meta[META_CAPABILITIES])) { + return error(400, id, ERR.invalidParams, `Missing _meta["${META_CAPABILITIES}"]`); + } + if (headers) { + const headerMethod = headers.get("Mcp-Method"); + if (headerMethod !== method) { + return error(400, id, ERR.headerMismatch, `Header mismatch: Mcp-Method '${headerMethod ?? ""}' does not match body '${method}'`); + } + if (method === "tools/call") { + const headerName = headers.get("Mcp-Name"); + if (headerName === null || decodeHeaderValue(headerName) !== params.name) { + return error(400, id, ERR.headerMismatch, "Header mismatch: Mcp-Name does not match params.name"); + } + } + } + } else if (headerVersion !== null && !LEGACY_VERSIONS.includes(headerVersion)) { + return error(400, id, ERR.invalidRequest, `Unsupported MCP-Protocol-Version '${headerVersion}'`); } - if (method === "tools/list") { - return { + const reply = (result: Record): RpcOutcome => ({ + status: 200, + body: { jsonrpc: "2.0", id, - result: { - tools: MCP_TOOLS + result: modern ? { resultType: "complete", ...result, _meta: { [META_SERVER_INFO]: SERVER_INFO } } : result, + }, + }); + + switch (method) { + case "initialize": { + const requested = typeof params.protocolVersion === "string" ? params.protocolVersion : ""; + return reply({ + protocolVersion: LEGACY_VERSIONS.includes(requested) ? requested : LEGACY_VERSIONS[0], + capabilities: { tools: { listChanged: false } }, + serverInfo: SERVER_INFO, + instructions: INSTRUCTIONS, + }); + } + case "server/discover": + return reply({ + supportedVersions: MODERN_VERSIONS, + capabilities: { tools: {} }, + instructions: INSTRUCTIONS, + ...CACHE_PUBLIC, + }); + case "ping": + return reply({}); + case "tools/list": + return reply(modern ? { tools: MCP_TOOLS, ...CACHE_PUBLIC } : { tools: MCP_TOOLS }); + case "tools/call": { + const name = params.name; + if (typeof name !== "string") return error(modern ? 400 : 200, id, ERR.invalidParams, "params.name is required"); + if (params.arguments !== undefined && !isObject(params.arguments)) { + return error(modern ? 400 : 200, id, ERR.invalidParams, "params.arguments must be an object"); } - }; + try { + const result = await handleToolCall(env, user, name, (params.arguments ?? {}) as Record); + if (!result) return error(modern ? 400 : 200, id, ERR.invalidParams, `Unknown tool: ${name}`); + return reply(result); + } catch (err) { + // Detail goes to the Worker's log; the caller gets nothing that names + // a table or a query. + console.error(JSON.stringify({ event: "tool_call_failed", tool: name, userId: user.userId, error: String(err) })); + return error(200, id, ERR.internal, "Internal error"); + } + } + default: + // Modern HTTP answers an unknown method with 404, which tells it apart + // from a legacy server's 404 for a missing endpoint. + return error(modern && headers ? 404 : 200, id, ERR.methodNotFound, `Method not found: ${method}`); } +} - if (method === "tools/call") { - const toolName = (params?.name as string) || ""; - const toolArgs = (params?.arguments as Record) || {}; - try { - const result = await handleToolCall(env, user, toolName, toolArgs); - return { - jsonrpc: "2.0", - id, - result - }; - } catch (err: unknown) { - // Detail goes to the Worker's log; the caller gets nothing that names - // a table or a query. - console.error("tool call failed", err); - return { - jsonrpc: "2.0", - id, - error: { - code: -32000, - message: "Tool call failed" - } - }; - } +/** Streamable HTTP (`POST /mcp`): one JSON-RPC message per request. */ +export async function handleMcpPost(env: Env, user: AuthUser, request: Request, bodyText: string): Promise { + let parsed: unknown; + try { + parsed = JSON.parse(bodyText); + } catch { + return json(400, { jsonrpc: "2.0", id: null, error: { code: ERR.parse, message: "Parse error" } }); + } + if (Array.isArray(parsed)) { + return json(400, { + jsonrpc: "2.0", + id: null, + error: { code: ERR.invalidRequest, message: "Invalid Request: JSON-RPC batches are not supported" }, + }); } + const outcome = await processMessage(env, user, parsed, request.headers); + if (!outcome.body) return new Response(null, { status: 202 }); + return json(outcome.status, outcome.body); +} - return { - jsonrpc: "2.0", - id, - error: { - code: -32601, - message: `Method not found: ${method}` - } - }; +function json(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); } diff --git a/cloud/src/oauth.ts b/cloud/src/oauth.ts new file mode 100644 index 0000000..8e0d82d --- /dev/null +++ b/cloud/src/oauth.ts @@ -0,0 +1,250 @@ +import { + AuthorizationError, + CimdFetchError, + authorizationErrorRedirect, + type ConsentDescription, + type OAuthHelpers, +} from "@cloudflare/workers-oauth-provider"; +import type { Env } from "./types.js"; +import { SCOPE_READ } from "./types.js"; +import { isAllowedUser, publicOrigin } from "./auth.js"; +import { upsertUser } from "./db.js"; + +/** + * Browser sign-in for MCP clients: this Worker is the OAuth 2.1 authorization + * server for its own `/mcp` resource, and GitHub is only the identity step. + * + * GET /authorize validate the client's request (PKCE S256 + * required), show a consent page naming it + * POST /authorize Allow: on to GitHub; Deny: back to the client + * GET /oauth/github/callback GitHub says who it is; check the allowlist, + * finish the grant, back to the client + * + * workers-oauth-provider owns everything else: metadata, client registration + * (Client ID Metadata Documents and dynamic registration), the token endpoint, + * refresh rotation and revocation, and the cookies that bind each step to the + * browser that started it. + * + * An OAuth client is only ever granted `read`, and its tokens are bound to the + * `/mcp` resource: an agent connected this way can read uploaded transcripts, + * never upload or delete. Those stay with the CLI's own login. + */ + +export const GITHUB_CALLBACK_PATH = "/oauth/github/callback"; + +/** The provider's user id may not contain ':' (it delimits its tokens). */ +export function grantUserId(userId: string): string { + return userId.replace(/:/g, "-"); +} + +export interface OAuthGrantProps { + kind: "oauth"; + userId: string; + username: string; + epoch: number; +} + +const escape = (value: string) => value.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); + +function page(title: string, body: string, status = 200, headers = new Headers()): Response { + headers.set("Content-Type", "text/html; charset=utf-8"); + headers.set("Cache-Control", "no-store"); + headers.set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'"); + headers.set("X-Frame-Options", "DENY"); + return new Response( + `` + + `${escape(title)}` + + `${body}`, + { status, headers }, + ); +} + +function consentPage(details: ConsentDescription, handle: string): string { + const name = escape(details.clientName); + const who = details.clientDomain + ? `Published by ${escape(details.clientDomain)}.` + : "This app registered itself; its name is not verified."; + return `

Allow ${name} to read your xtctx cloud?

+

${who} Access will be sent to ${escape(details.redirectHost)}.

+${ + details.redirectIsLoopback + ? `

This sends access to an app on your computer. Continue only if you just started connecting from it.

` + : "" +} +

It will be able to:

+
  • read: list and read the transcripts uploaded to your xtctx cloud account, from every device and project.
+

It will not be able to upload, change or delete anything. You sign in with GitHub next.

+
+ + + +
`; +} + +function renderAuthError(err: unknown): Response { + if (err instanceof AuthorizationError && err.redirectTo) return Response.redirect(err.redirectTo, 302); + if (err instanceof AuthorizationError || err instanceof CimdFetchError) { + const message = err instanceof AuthorizationError ? err.description : "This app could not be verified."; + return page("Sign-in failed", `

Sign-in failed

${escape(message ?? "Invalid request.")}

Start again from the app.

`, 400); + } + throw err; +} + +async function s256(verifier: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); + let binary = ""; + for (const b of new Uint8Array(digest)) binary += String.fromCharCode(b); + return btoa(binary).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); +} + +function randomVerifier(): string { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + let binary = ""; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); +} + +export async function handleAuthorize(request: Request, env: Env): Promise { + const oauth = env.OAUTH_PROVIDER as OAuthHelpers; + if (!env.GITHUB_CLIENT_SECRET) { + console.error(JSON.stringify({ event: "authorize_unconfigured", reason: "GITHUB_CLIENT_SECRET is not set" })); + return page("Not available", "

Browser sign-in is not configured on this server.

", 503); + } + + try { + if (request.method === "GET") { + const authRequest = await oauth.parseAuthRequest(request); + // The library leaves PKCE optional for confidential clients; here it is + // required of every client, and only S256. + if (authRequest.codeChallengeMethod !== "S256" || !authRequest.codeChallenge) { + return Response.redirect( + authorizationErrorRedirect(authRequest, "invalid_request", "PKCE with code_challenge_method=S256 is required"), + 302, + ); + } + // Whatever was asked for, an OAuth client is offered read and nothing else. + const offered = { ...authRequest, scope: [SCOPE_READ] }; + const details = await oauth.describeConsent(offered); + const consent = await oauth.beginConsent(offered); + return page(`Authorize ${details.clientName}`, consentPage(details, consent.handle), 200, consent.headers); + } + + if (request.method === "POST") { + const form = await request.formData(); + const handle = String(form.get("handle") ?? ""); + if (form.get("decision") !== "approve") { + const denied = await oauth.denyConsent(request, handle); + return new Response(null, { status: 302, headers: denied.headers }); + } + const approved = await oauth.approveConsent(request, handle, { scope: [SCOPE_READ] }); + const verifier = randomVerifier(); + const { state, headers } = await oauth.beginUpstream(approved.request, { + data: { verifier }, + headers: approved.headers, + }); + const github = new URL("https://github.com/login/oauth/authorize"); + github.searchParams.set("client_id", env.GITHUB_CLIENT_ID); + github.searchParams.set("redirect_uri", `${publicOrigin(env)}${GITHUB_CALLBACK_PATH}`); + github.searchParams.set("scope", "read:user"); + github.searchParams.set("state", state); + github.searchParams.set("code_challenge", await s256(verifier)); + github.searchParams.set("code_challenge_method", "S256"); + github.searchParams.set("allow_signup", "false"); + headers.set("Location", github.toString()); + return new Response(null, { status: 302, headers }); + } + + return new Response(null, { status: 405, headers: { Allow: "GET, POST" } }); + } catch (err) { + return renderAuthError(err); + } +} + +interface GitHubUser { + id: number; + login: string; +} + +/** Exchange a web-flow code for the GitHub user it belongs to. Null on any failure. */ +async function githubUserFromCode(env: Env, code: string, verifier: string): Promise { + const tokenRes = await fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify({ + client_id: env.GITHUB_CLIENT_ID, + client_secret: env.GITHUB_CLIENT_SECRET, + code, + code_verifier: verifier, + redirect_uri: `${publicOrigin(env)}${GITHUB_CALLBACK_PATH}`, + }), + }); + const token = (await tokenRes.json().catch(() => ({}))) as { access_token?: string }; + if (!tokenRes.ok || !token.access_token) return null; + return githubUser(token.access_token); +} + +/** Who a GitHub access token belongs to. Null on any failure. */ +export async function githubUser(accessToken: string): Promise { + const res = await fetch("https://api.github.com/user", { + headers: { Authorization: `Bearer ${accessToken}`, "User-Agent": "xtctx-cloud", Accept: "application/vnd.github+json" }, + }); + const user = (await res.json().catch(() => ({}))) as Partial; + if (!res.ok || typeof user.id !== "number" || typeof user.login !== "string" || !user.login) return null; + return { id: user.id, login: user.login }; +} + +export async function handleGithubCallback(request: Request, env: Env): Promise { + const oauth = env.OAUTH_PROVIDER as OAuthHelpers; + try { + const { request: original, data, headers } = await oauth.finishUpstream<{ verifier: string }>(request); + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const fail = (description: string) => { + headers.set("Location", authorizationErrorRedirect(original, "access_denied", description)); + return new Response(null, { status: 302, headers }); + }; + if (url.searchParams.get("error") || !code) return fail("GitHub sign-in was cancelled"); + + const user = await githubUserFromCode(env, code, data.verifier); + if (!user) return fail("GitHub sign-in failed"); + + const userId = `github:${user.id}`; + // Before anything is written for this account. + if (!isAllowedUser(env, userId)) return fail("This GitHub account is not allowed on this server"); + + const epoch = await upsertUser(env, userId, user.login); + const props: OAuthGrantProps = { kind: "oauth", userId, username: user.login, epoch }; + const { redirectTo } = await oauth.completeAuthorization({ + request: original, + userId: grantUserId(userId), + metadata: { login: user.login }, + scope: original.scope.filter((s) => s === SCOPE_READ), + props, + }); + headers.set("Location", redirectTo); + return new Response(null, { status: 302, headers }); + } catch (err) { + return renderAuthError(err); + } +} + +/** + * End every OAuth grant the user holds. Best effort: the token epoch, which + * the caller has already moved, is what actually refuses those tokens. + */ +export async function revokeOAuthGrants(env: Env, userId: string): Promise { + const oauth = env.OAUTH_PROVIDER; + if (!oauth) return; + try { + let cursor: string | undefined; + do { + const page = await oauth.listUserGrants(grantUserId(userId), { cursor }); + for (const grant of page.items) await oauth.revokeGrant(grant.id, grantUserId(userId)); + cursor = page.cursor; + } while (cursor); + } catch (err) { + console.error(JSON.stringify({ event: "grant_revocation_failed", userId, error: String(err) })); + } +} diff --git a/cloud/src/types.ts b/cloud/src/types.ts index 95b68b5..de31116 100644 --- a/cloud/src/types.ts +++ b/cloud/src/types.ts @@ -1,11 +1,28 @@ +import type { OAuthHelpers } from "@cloudflare/workers-oauth-provider"; + export interface Env { DB: D1Database; + /** Grants, OAuth tokens and registered clients; owned by workers-oauth-provider. */ + OAUTH_KV: KVNamespace; + /** Set by workers-oauth-provider before a handler runs. */ + OAUTH_PROVIDER?: OAuthHelpers; /** Holds the open baseline-MCP SSE streams; see sse-session.ts. */ SSE: DurableObjectNamespace; + /** + * The canonical origin MCP clients reach, e.g. "https://mcp.xtctx.com". + * The MCP resource is `${PUBLIC_URL}/mcp`, and every token is bound to it. + */ + PUBLIC_URL: string; GITHUB_CLIENT_ID: string; + /** Needed only for the browser sign-in (`/authorize`); the CLI's device flow works without it. */ GITHUB_CLIENT_SECRET?: string; /** Required. Requests are refused with a 500 while it is unset. */ JWT_SECRET?: string; + /** + * Comma-separated numeric GitHub user ids allowed to sign in. Unset or empty + * means nobody: the server fails closed. + */ + ALLOWED_GITHUB_IDS?: string; /** Comma-separated browser origins to send CORS headers to. Unset means none. */ ALLOWED_ORIGINS?: string; ENVIRONMENT?: string; @@ -17,30 +34,18 @@ export interface AuthUser { deviceId?: string; } -export interface StreamTurnDelta { - deviceId: string; - deviceName?: string; - tool: string; - sourceSessionId: string; - repoUrl: string; - projectRoot: string; - gitBranch?: string; - gitCommit?: string; - timestamp: string; - role: "user" | "assistant" | "system" | "tool"; - content: string; - messageIndex: number; - contentHash: string; - metadataJson?: string; - sourcePointer?: string; - preview?: string; - status?: "active" | "idle" | "closed"; -} +/** The MCP tools. */ +export const SCOPE_READ = "read"; +/** POST /api/stream. Only the CLI's own login carries it. */ +export const SCOPE_SYNC_WRITE = "sync:write"; +/** DELETE /api/me. Only the CLI's own login carries it. */ +export const SCOPE_ACCOUNT_DELETE = "account:delete"; export interface SessionRecord { session_ref: string; user_id: string; device_id: string; + device_name?: string | null; tool: string; source_session_id: string; repo_url: string; @@ -51,8 +56,6 @@ export interface SessionRecord { last_activity_at: string; message_count: number; preview: string | null; - source_path: string | null; - status: string; updated_at: string; } @@ -67,6 +70,5 @@ export interface MessageRecord { message_index: number; content_hash: string; metadata_json: string; - source_pointer: string | null; indexed_at: string; } diff --git a/cloud/src/version.ts b/cloud/src/version.ts new file mode 100644 index 0000000..6b87279 --- /dev/null +++ b/cloud/src/version.ts @@ -0,0 +1,5 @@ +import pkg from "../package.json"; + +/** Reported in /health, the X-Xtctx-Server-Version header, and MCP serverInfo. */ +export const SERVER_NAME = "xtctx-cloud"; +export const SERVER_VERSION: string = pkg.version; diff --git a/cloud/test/cloudflare-workers-stub.ts b/cloud/test/cloudflare-workers-stub.ts new file mode 100644 index 0000000..c76bcfb --- /dev/null +++ b/cloud/test/cloudflare-workers-stub.ts @@ -0,0 +1,6 @@ +/** + * `cloudflare:workers` exists only inside workerd. workers-oauth-provider + * imports WorkerEntrypoint from it to tell handler classes from handler + * objects; this Worker passes objects, so an empty class is all it needs. + */ +export class WorkerEntrypoint {} diff --git a/cloud/test/fake-env.ts b/cloud/test/fake-env.ts index 47f2053..20b916b 100644 --- a/cloud/test/fake-env.ts +++ b/cloud/test/fake-env.ts @@ -1,12 +1,20 @@ import { DatabaseSync } from "node:sqlite"; -import { readFileSync } from "node:fs"; +import { readFileSync, readdirSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { SseSession } from "../src/sse-session.js"; import type { Env } from "../src/types.js"; +const MIGRATIONS = fileURLToPath(new URL("../migrations/", import.meta.url)); + +/** Every file in migrations/, in order, the way `wrangler d1 migrations apply` runs them. */ +export function applyMigrations(db: DatabaseSync, upTo = Infinity): void { + const files = readdirSync(MIGRATIONS).filter((f) => f.endsWith(".sql")).sort(); + for (const file of files.slice(0, upTo)) db.exec(readFileSync(MIGRATIONS + file, "utf8")); +} + /** * Just enough of D1 over an in-memory SQLite for the Worker's own queries. - * The real schema.sql is loaded, so a column the code needs and the schema + * The real migrations are applied, so a column the code needs and the schema * lacks fails here the way it would in production. */ class FakeStatement { @@ -26,10 +34,15 @@ class FakeStatement { } } -function fakeD1(db: DatabaseSync) { +export interface FakeD1Stats { + batches: number[]; +} + +function fakeD1(db: DatabaseSync, stats: FakeD1Stats) { return { prepare: (sql: string) => new FakeStatement(db, sql), async batch(statements: FakeStatement[]) { + stats.batches.push(statements.length); db.exec("BEGIN"); try { for (const s of statements) await s.run(); @@ -38,6 +51,65 @@ function fakeD1(db: DatabaseSync) { db.exec("ROLLBACK"); throw err; } + return statements.map(() => ({ success: true })); + }, + }; +} + +/** KV in a Map: the calls workers-oauth-provider makes, with TTLs and metadata. */ +export function fakeKV() { + const store = new Map(); + const live = (key: string) => { + const entry = store.get(key); + if (entry?.expiresAt !== undefined && entry.expiresAt <= Date.now()) { + store.delete(key); + return undefined; + } + return entry; + }; + const decode = (value: string, type: unknown) => { + const t = typeof type === "string" ? type : (type as { type?: string } | undefined)?.type; + return t === "json" ? JSON.parse(value) : value; + }; + return { + store, + async get(key: string, type?: unknown) { + const entry = live(key); + return entry ? decode(entry.value, type) : null; + }, + async getWithMetadata(key: string, type?: unknown) { + const entry = live(key); + return { value: entry ? decode(entry.value, type) : null, metadata: entry?.metadata ?? null }; + }, + async put(key: string, value: string, options: { expirationTtl?: number; expiration?: number; metadata?: unknown } = {}) { + const expiresAt = options.expirationTtl + ? Date.now() + options.expirationTtl * 1000 + : options.expiration + ? options.expiration * 1000 + : undefined; + store.set(key, { value: String(value), expiresAt, metadata: options.metadata }); + }, + async delete(key: string) { + store.delete(key); + }, + async list(options: { prefix?: string; limit?: number; cursor?: string } = {}) { + const keys = [...store.keys()].filter((k) => live(k) && k.startsWith(options.prefix ?? "")).sort(); + const start = options.cursor ? Number(options.cursor) : 0; + const limit = options.limit ?? 1000; + const page = keys.slice(start, start + limit); + const done = start + limit >= keys.length; + return { + keys: page.map((name) => { + const entry = store.get(name)!; + return { + name, + metadata: entry.metadata, + expiration: entry.expiresAt ? Math.floor(entry.expiresAt / 1000) : undefined, + }; + }), + list_complete: done, + cursor: done ? undefined : String(start + limit), + }; }, }; } @@ -54,16 +126,29 @@ function fakeNamespace() { }; } +export const PUBLIC_URL = "https://mcp.test"; + export function createEnv(overrides: Partial> = {}) { const db = new DatabaseSync(":memory:"); db.exec("PRAGMA foreign_keys = ON"); - db.exec(readFileSync(fileURLToPath(new URL("../schema.sql", import.meta.url)), "utf8")); + applyMigrations(db); + const stats: FakeD1Stats = { batches: [] }; + const kv = fakeKV(); const env = { - DB: fakeD1(db), + DB: fakeD1(db, stats), + OAUTH_KV: kv, SSE: fakeNamespace(), + PUBLIC_URL, GITHUB_CLIENT_ID: "test-client", + GITHUB_CLIENT_SECRET: "test-client-secret", JWT_SECRET: "test-secret-for-the-worker-tests", + ALLOWED_GITHUB_IDS: "1,2,3", ...overrides, } as unknown as Env; - return { env, db }; + return { env, db, kv, stats }; +} + +/** What the runtime hands `fetch` as its third argument. */ +export function fakeCtx(): ExecutionContext { + return { waitUntil() {}, passThroughOnException() {}, props: {} } as unknown as ExecutionContext; } diff --git a/cloud/test/helpers.ts b/cloud/test/helpers.ts new file mode 100644 index 0000000..d1cbfd5 --- /dev/null +++ b/cloud/test/helpers.ts @@ -0,0 +1,102 @@ +import { expect, vi } from "vitest"; +import worker from "../src/index.js"; +import { fakeCtx, PUBLIC_URL } from "./fake-env.js"; + +export function call(env: unknown, path: string, init: RequestInit & { token?: string; base?: string } = {}) { + const { token, base, ...rest } = init; + const headers = new Headers(rest.headers); + if (token) headers.set("Authorization", `Bearer ${token}`); + return worker.fetch(new Request(`${base ?? PUBLIC_URL}${path}`, { ...rest, headers }), env as never, fakeCtx()); +} + +/** GitHub's side of both sign-in flows, answered as a user with this id. Records what was asked. */ +export function stubGitHub(id: number, login: string, options: { tokenError?: string; userStatus?: number } = {}) { + const seen: Array<{ url: string; body?: string }> = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const u = String(url instanceof Request ? url.url : url); + seen.push({ url: u, body: typeof init?.body === "string" ? init.body : undefined }); + if (u.includes("login/device/code")) { + return Response.json({ device_code: "dc", user_code: "ABCD-1234", verification_uri: "https://github.com/login/device", expires_in: 900, interval: 5 }); + } + if (u.includes("oauth/access_token")) { + return options.tokenError ? Response.json({ error: options.tokenError }) : Response.json({ access_token: "gh-token" }); + } + if (u.includes("api.github.com/user")) { + return options.userStatus ? new Response("nope", { status: options.userStatus }) : Response.json({ id, login, name: login }); + } + throw new Error(`unexpected fetch ${u}`); + }), + ); + return seen; +} + +/** Sign in through the CLI's device flow; returns the CLI token. */ +export async function login(env: unknown, id: number, name = `user${id}`): Promise { + stubGitHub(id, name); + const res = await call(env, "/auth/device/poll", { method: "POST", body: JSON.stringify({ device_code: "dc" }) }); + expect(res.status).toBe(200); + vi.unstubAllGlobals(); + return ((await res.json()) as { token: string }).token; +} + +export const hex = (n: number) => n.toString(16).padStart(16, "0"); + +export function message(i: number, over: Record = {}) { + return { + id: hex(i), + timestamp: `2026-10-01T00:00:${String(i % 60).padStart(2, "0")}.000Z`, + role: "user", + content: `message ${i}`, + messageIndex: i, + contentHash: `hash${i}`, + metadataJson: "{}", + ...over, + }; +} + +export function session(over: Record = {}, messages = [message(0)]) { + return { + tool: "claude-code", + sourceSessionId: "s1", + gitBranch: "main", + gitCommit: null, + startedAt: "2026-10-01T00:00:00.000Z", + lastActivityAt: "2026-10-01T00:01:00.000Z", + preview: "hello", + messages, + ...over, + }; +} + +export function upload(sessions: unknown[], over: Record = {}) { + return { + device: { id: "laptop", name: "Laptop" }, + project: { repoUrl: "github.com/a/b", name: "b" }, + sessions, + ...over, + }; +} + +export function post(env: unknown, token: string, body: unknown) { + return call(env, "/api/stream", { method: "POST", token, body: JSON.stringify(body) }); +} + +/** A legacy-era JSON-RPC request to /mcp. */ +export async function rpc(env: unknown, token: string, method: string, params?: Record) { + const res = await call(env, "/mcp", { + method: "POST", + token, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, ...(params ? { params } : {}) }), + }); + return { status: res.status, body: (await res.json()) as Record }; +} + +export async function callTool(env: unknown, token: string, name: string, args: Record = {}) { + const { body } = await rpc(env, token, "tools/call", { name, arguments: args }); + return body; +} + +export const toolText = (body: Record) => String(body.result?.content?.[0]?.text ?? ""); diff --git a/cloud/test/mcp.test.ts b/cloud/test/mcp.test.ts new file mode 100644 index 0000000..3606b99 --- /dev/null +++ b/cloud/test/mcp.test.ts @@ -0,0 +1,212 @@ +/** + * Streamable HTTP and JSON-RPC behaviour of POST /mcp, for both eras: + * legacy (initialize handshake, 2025-06-18 / 2025-11-25) and modern + * (per-request _meta and mirrored headers, 2026-07-28). + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createEnv } from "./fake-env.js"; +import { call, login } from "./helpers.js"; + +afterEach(() => vi.unstubAllGlobals()); + +const MODERN = "2026-07-28"; +const meta = { "io.modelcontextprotocol/protocolVersion": MODERN, "io.modelcontextprotocol/clientCapabilities": {} }; + +async function setup() { + const { env } = createEnv(); + return { env, token: await login(env, 1) }; +} + +function send(env: unknown, token: string, body: unknown, headers: Record = {}) { + return call(env, "/mcp", { + method: "POST", + token, + headers: { "Content-Type": "application/json", ...headers }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); +} + +function modern(env: unknown, token: string, method: string, params: Record = {}, headers: Record = {}) { + return send( + env, + token, + { jsonrpc: "2.0", id: 1, method, params: { ...params, _meta: meta } }, + { "MCP-Protocol-Version": MODERN, "Mcp-Method": method, ...(params.name ? { "Mcp-Name": String(params.name) } : {}), ...headers }, + ); +} + +describe("legacy era", () => { + it("negotiates a supported version on initialize and answers ping with {}", async () => { + const { env, token } = await setup(); + const init = await send(env, token, { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {} } }); + expect(((await init.json()) as any).result.protocolVersion).toBe("2025-06-18"); + const old = await send(env, token, { jsonrpc: "2.0", id: 2, method: "initialize", params: { protocolVersion: "1999-01-01" } }); + expect(((await old.json()) as any).result.protocolVersion).toBe("2025-11-25"); + + const ping = await send(env, token, { jsonrpc: "2.0", id: 3, method: "ping" }, { "MCP-Protocol-Version": "2025-11-25" }); + expect(await ping.json()).toEqual({ jsonrpc: "2.0", id: 3, result: {} }); + }); + + it("answers every notification with 202 and no body", async () => { + const { env, token } = await setup(); + for (const method of ["notifications/initialized", "notifications/cancelled", "notifications/whatever"]) { + const res = await send(env, token, { jsonrpc: "2.0", method }); + expect(res.status, method).toBe(202); + expect(await res.text()).toBe(""); + } + }); + + it("answers an unknown method with -32601", async () => { + const { env, token } = await setup(); + const res = await send(env, token, { jsonrpc: "2.0", id: 1, method: "resources/list" }); + expect(((await res.json()) as any).error.code).toBe(-32601); + }); + + it("refuses an unsupported MCP-Protocol-Version header with 400", async () => { + const { env, token } = await setup(); + const res = await send(env, token, { jsonrpc: "2.0", id: 1, method: "tools/list" }, { "MCP-Protocol-Version": "2024-01-01" }); + expect(res.status).toBe(400); + }); +}); + +describe("malformed input", () => { + it("answers bad JSON with 400 and -32700", async () => { + const { env, token } = await setup(); + const res = await send(env, token, "{ not json"); + expect(res.status).toBe(400); + expect(((await res.json()) as any).error.code).toBe(-32700); + }); + + it("answers a missing method, a bad id or a non-2.0 message with 400 and -32600", async () => { + const { env, token } = await setup(); + for (const body of [{ jsonrpc: "2.0", id: 1 }, { jsonrpc: "2.0", id: null, method: "ping" }, { jsonrpc: "1.0", id: 1, method: "ping" }, 42]) { + const res = await send(env, token, body); + expect(res.status, JSON.stringify(body)).toBe(400); + expect(((await res.json()) as any).error.code).toBe(-32600); + } + }); + + it("rejects JSON-RPC batches with -32600", async () => { + const { env, token } = await setup(); + const res = await send(env, token, [{ jsonrpc: "2.0", id: 1, method: "ping" }]); + expect(res.status).toBe(400); + expect(((await res.json()) as any).error.code).toBe(-32600); + }); + + it("accepts a JSON-RPC response from the client with 202", async () => { + const { env, token } = await setup(); + expect((await send(env, token, { jsonrpc: "2.0", id: 5, result: {} })).status).toBe(202); + }); +}); + +describe("transport methods", () => { + it("answers GET and DELETE /mcp with 405", async () => { + const { env, token } = await setup(); + for (const method of ["GET", "DELETE"]) { + const res = await call(env, "/mcp", { method, token }); + expect(res.status, method).toBe(405); + expect(res.headers.get("Allow")).toBe("POST"); + } + }); +}); + +describe("modern era (2026-07-28)", () => { + it("implements server/discover", async () => { + const { env, token } = await setup(); + const res = await modern(env, token, "server/discover"); + expect(res.status).toBe(200); + const result = ((await res.json()) as any).result; + expect(result.resultType).toBe("complete"); + expect(result.supportedVersions).toEqual([MODERN]); + expect(result.capabilities).toHaveProperty("tools"); + expect(result._meta["io.modelcontextprotocol/serverInfo"].name).toBe("xtctx-cloud"); + expect(result.ttlMs).toBeGreaterThan(0); + expect(result.cacheScope).toBe("public"); + }); + + it("puts the required caching hints on tools/list", async () => { + // Claude Code 2.1.278 refused a modern tools/list without them. + const { env, token } = await setup(); + const result = ((await (await modern(env, token, "tools/list")).json()) as any).result; + expect(result.tools).toHaveLength(3); + expect(Number.isInteger(result.ttlMs) && result.ttlMs >= 0).toBe(true); + expect(result.cacheScope).toBe("public"); + }); + + it("calls tools statelessly with resultType on every result", async () => { + const { env, token } = await setup(); + const res = await modern(env, token, "tools/call", { name: "xtctx_cloud_status", arguments: {} }); + expect(res.status).toBe(200); + const result = ((await res.json()) as any).result; + expect(result.resultType).toBe("complete"); + expect(result.content[0].text).toContain("Nothing has been uploaded"); + const ping = await modern(env, token, "ping"); + expect(((await ping.json()) as any).result.resultType).toBe("complete"); + }); + + it("answers an unknown method with 404 and -32601", async () => { + const { env, token } = await setup(); + const res = await modern(env, token, "prompts/list"); + expect(res.status).toBe(404); + expect(((await res.json()) as any).error.code).toBe(-32601); + }); + + it("answers an unsupported version with 400 -32022 listing what is supported", async () => { + const { env, token } = await setup(); + const res = await send( + env, + token, + { jsonrpc: "2.0", id: 1, method: "ping", params: { _meta: { ...meta, "io.modelcontextprotocol/protocolVersion": "2099-01-01" } } }, + { "MCP-Protocol-Version": "2099-01-01", "Mcp-Method": "ping" }, + ); + expect(res.status).toBe(400); + const error = ((await res.json()) as any).error; + expect(error.code).toBe(-32022); + expect(error.data.supported).toContain(MODERN); + expect(error.data.requested).toBe("2099-01-01"); + }); + + it("refuses headers that disagree with the body with 400 -32020", async () => { + const { env, token } = await setup(); + const cases: Array> = [ + { "MCP-Protocol-Version": "2025-11-25" }, + { "Mcp-Method": "tools/list" }, + { "Mcp-Name": "xtctx_cloud_recent_sessions" }, + ]; + for (const headers of cases) { + const res = await modern(env, token, "tools/call", { name: "xtctx_cloud_status", arguments: {} }, headers); + expect(res.status, JSON.stringify(headers)).toBe(400); + expect(((await res.json()) as any).error.code).toBe(-32020); + } + }); + + it("decodes a base64 Mcp-Name before comparing it", async () => { + const { env, token } = await setup(); + const encoded = `=?base64?${btoa("xtctx_cloud_status")}?=`; + const res = await modern(env, token, "tools/call", { name: "xtctx_cloud_status", arguments: {} }, { "Mcp-Name": encoded }); + expect(res.status).toBe(200); + }); + + it("answers a request missing required _meta fields with 400 -32602", async () => { + const { env, token } = await setup(); + const res = await send( + env, + token, + { jsonrpc: "2.0", id: 1, method: "ping", params: { _meta: { "io.modelcontextprotocol/protocolVersion": MODERN } } }, + { "MCP-Protocol-Version": MODERN, "Mcp-Method": "ping" }, + ); + expect(res.status).toBe(400); + expect(((await res.json()) as any).error.code).toBe(-32602); + }); +}); + +describe("unauthenticated", () => { + it("gets a 401 whose challenge names the resource metadata and the read scope", async () => { + const { env } = createEnv(); + const res = await call(env, "/mcp", { method: "POST", body: "{}" }); + expect(res.status).toBe(401); + const challenge = res.headers.get("WWW-Authenticate") ?? ""; + expect(challenge).toContain('resource_metadata="https://mcp.test/.well-known/oauth-protected-resource/mcp"'); + expect(challenge).toContain('scope="read"'); + }); +}); diff --git a/cloud/test/migrations.test.ts b/cloud/test/migrations.test.ts new file mode 100644 index 0000000..913a4a9 --- /dev/null +++ b/cloud/test/migrations.test.ts @@ -0,0 +1,49 @@ +import { DatabaseSync } from "node:sqlite"; +import { readFileSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { applyMigrations } from "./fake-env.js"; + +const dir = fileURLToPath(new URL("../migrations/", import.meta.url)); +const columns = (db: DatabaseSync, table: string) => + (db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>).map((c) => c.name); + +describe("migrations", () => { + it("bring a database created before tracked migrations forward without losing data or signing anyone out", () => { + const db = new DatabaseSync(":memory:"); + db.exec("PRAGMA foreign_keys = ON"); + // What production has: the old schema.sql (now the baseline), in use. + applyMigrations(db, 1); + db.exec(` + INSERT INTO users (id, username, created_at, updated_at, token_version) VALUES ('github:1', 'alice', 1, 1, 3); + INSERT INTO devices (id, user_id, device_name, last_seen_at, created_at) VALUES ('github:1:d', 'github:1', 'host', 1, 1); + INSERT INTO sessions (session_ref, user_id, device_id, tool, source_session_id, repo_url, project_root, + started_at, last_activity_at, message_count, source_path, status, updated_at) + VALUES ('github:1:claude-code:s', 'github:1', 'github:1:d', 'claude-code', 's', 'r', 'p', 't', 't', 1, '/home/alice/x.jsonl', 'active', 't'); + INSERT INTO messages (id, session_ref, tool, source_session_id, timestamp, role, content, message_index, + content_hash, metadata_json, source_pointer, indexed_at) + VALUES ('m', 'github:1:claude-code:s', 'claude-code', 's', 't', 'user', 'hello', 0, 'h', '{}', '/home/alice/x.jsonl', 't'); + `); + + // The rest, in order, the way `wrangler d1 migrations apply` runs them; + // the baseline again too, which must be a no-op on a database that has it. + const files = readdirSync(dir).filter((f) => f.endsWith(".sql")).sort(); + db.exec(readFileSync(dir + files[0], "utf8")); + for (const file of files.slice(1)) db.exec(readFileSync(dir + file, "utf8")); + + expect(db.prepare("SELECT epoch FROM token_epochs WHERE user_id = 'github:1'").get()).toEqual({ epoch: 3 }); + expect(db.prepare("SELECT content FROM messages").get()).toEqual({ content: "hello" }); + expect(db.prepare("SELECT message_count FROM sessions").get()).toEqual({ message_count: 1 }); + expect(columns(db, "users")).not.toContain("token_version"); + expect(columns(db, "sessions")).not.toContain("source_path"); + expect(columns(db, "sessions")).not.toContain("status"); + expect(columns(db, "messages")).not.toContain("source_pointer"); + }); + + it("create the full schema on an empty database", () => { + const db = new DatabaseSync(":memory:"); + applyMigrations(db); + const tables = (db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all() as Array<{ name: string }>).map((t) => t.name); + expect(tables).toEqual(expect.arrayContaining(["users", "devices", "sessions", "messages", "token_epochs", "uploads"])); + }); +}); diff --git a/cloud/test/oauth.test.ts b/cloud/test/oauth.test.ts new file mode 100644 index 0000000..82e9b2a --- /dev/null +++ b/cloud/test/oauth.test.ts @@ -0,0 +1,339 @@ +/** + * The browser sign-in MCP clients use, end to end against a stubbed GitHub: + * discovery, client registration, /authorize with consent, GitHub's callback, + * the token endpoint, and the resulting token at /mcp. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createEnv, PUBLIC_URL } from "./fake-env.js"; +import { call, login, stubGitHub } from "./helpers.js"; + +afterEach(() => vi.unstubAllGlobals()); + +const REDIRECT = "http://127.0.0.1:33418/callback"; +const RESOURCE = `${PUBLIC_URL}/mcp`; + +/** Cookies set by one response, as a Cookie header for the next. */ +class Jar { + private cookies = new Map(); + take(res: Response) { + for (const line of res.headers.getSetCookie()) { + const [pair] = line.split(";"); + const eq = pair.indexOf("="); + this.cookies.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()); + } + return res; + } + header() { + return [...this.cookies].map(([k, v]) => `${k}=${v}`).join("; "); + } +} + +function b64url(bytes: ArrayBuffer | Uint8Array) { + return Buffer.from(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)).toString("base64url"); +} + +async function pkce() { + const verifier = b64url(crypto.getRandomValues(new Uint8Array(32))); + const challenge = b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))); + return { verifier, challenge }; +} + +async function register(env: unknown) { + const res = await call(env, "/oauth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: "Test Agent", + redirect_uris: [REDIRECT], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + }), + }); + expect(res.status).toBe(201); + return ((await res.json()) as { client_id: string }).client_id; +} + +/** + * Walk the browser through /authorize, consent and GitHub, as the GitHub user + * `githubId`. Returns where the browser lands back at the client. + */ +async function authorize(env: unknown, clientId: string, githubId: number, options: { challenge?: string; method?: string; decision?: string } = {}) { + const jar = new Jar(); + const { challenge } = options.challenge ? { challenge: options.challenge } : await pkce(); + const params = new URLSearchParams({ + response_type: "code", + client_id: clientId, + redirect_uri: REDIRECT, + state: "client-state", + scope: "read", + resource: RESOURCE, + code_challenge: challenge, + code_challenge_method: options.method ?? "S256", + }); + const page = jar.take(await call(env, `/authorize?${params}`)); + if (page.status !== 200) return { landing: page.headers.get("Location"), page }; + const html = await page.text(); + const handle = /name="handle" value="([^"]+)"/.exec(html)![1]; + + const form = new URLSearchParams({ handle, decision: options.decision ?? "approve" }); + const approved = jar.take( + await call(env, "/authorize", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", Cookie: jar.header() }, + body: form.toString(), + }), + ); + expect(approved.status).toBe(302); + const toGithub = new URL(approved.headers.get("Location")!); + if (toGithub.origin !== "https://github.com") return { landing: toGithub.toString(), html }; + + const seen = stubGitHub(githubId, `user${githubId}`); + const back = await call(env, `/oauth/github/callback?code=gh-code&state=${toGithub.searchParams.get("state")}`, { + headers: { Cookie: jar.header() }, + }); + vi.unstubAllGlobals(); + expect(back.status).toBe(302); + return { landing: back.headers.get("Location")!, html, toGithub, seen }; +} + +async function exchange(env: unknown, form: Record) { + const res = await call(env, "/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(form).toString(), + }); + return { status: res.status, body: (await res.json()) as Record }; +} + +async function signIn(env: unknown, githubId = 1) { + const clientId = await register(env); + const { verifier, challenge } = await pkce(); + const { landing } = await authorize(env, clientId, githubId, { challenge }); + const code = new URL(landing!).searchParams.get("code")!; + const tokens = await exchange(env, { + grant_type: "authorization_code", + code, + redirect_uri: REDIRECT, + client_id: clientId, + code_verifier: verifier, + resource: RESOURCE, + }); + expect(tokens.status).toBe(200); + return { clientId, ...tokens.body } as { clientId: string; access_token: string; refresh_token: string; scope: string; expires_in: number }; +} + +const statusCall = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "xtctx_cloud_status", arguments: {} } }); + +describe("discovery", () => { + it("serves protected-resource metadata at the /mcp and root forms, unauthenticated, naming the MCP URL", async () => { + const { env } = createEnv(); + for (const path of ["/.well-known/oauth-protected-resource/mcp", "/.well-known/oauth-protected-resource"]) { + const res = await call(env, path); + expect(res.status, path).toBe(200); + const doc = (await res.json()) as Record; + expect(doc.resource).toBe(RESOURCE); + expect(doc.authorization_servers).toEqual([PUBLIC_URL]); + expect(doc.scopes_supported).toEqual(["read"]); + } + }); + + it("serves authorization-server metadata with PKCE S256, CIMD and registration", async () => { + const { env } = createEnv(); + const res = await call(env, "/.well-known/oauth-authorization-server"); + expect(res.status).toBe(200); + const doc = (await res.json()) as Record; + expect(doc.issuer).toBe(PUBLIC_URL); + expect(doc.authorization_endpoint).toBe(`${PUBLIC_URL}/authorize`); + expect(doc.token_endpoint).toBe(`${PUBLIC_URL}/oauth/token`); + expect(doc.registration_endpoint).toBe(`${PUBLIC_URL}/oauth/register`); + expect(doc.code_challenge_methods_supported).toEqual(["S256"]); + expect(doc.scopes_supported).toEqual(["read"]); + expect(doc.authorization_response_iss_parameter_supported).toBe(true); + }); +}); + +describe("authorize", () => { + it("shows a consent page naming the client and where access goes, and sends the browser to GitHub with PKCE", async () => { + const { env } = createEnv(); + const clientId = await register(env); + const jar = new Jar(); + const { challenge } = await pkce(); + const page = jar.take( + await call(env, `/authorize?${new URLSearchParams({ response_type: "code", client_id: clientId, redirect_uri: REDIRECT, state: "s", resource: RESOURCE, code_challenge: challenge, code_challenge_method: "S256" })}`), + ); + expect(page.status).toBe(200); + expect(page.headers.get("X-Frame-Options")).toBe("DENY"); + const html = await page.text(); + expect(html).toContain("Allow Test Agent to read your xtctx cloud?"); + expect(html).toContain("127.0.0.1"); + expect(html).toContain("app on your computer"); + + const handle = /name="handle" value="([^"]+)"/.exec(html)![1]; + const res = await call(env, "/authorize", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", Cookie: jar.header() }, + body: new URLSearchParams({ handle, decision: "approve" }).toString(), + }); + const github = new URL(res.headers.get("Location")!); + expect(github.origin + github.pathname).toBe("https://github.com/login/oauth/authorize"); + expect(github.searchParams.get("client_id")).toBe("test-client"); + expect(github.searchParams.get("redirect_uri")).toBe(`${PUBLIC_URL}/oauth/github/callback`); + expect(github.searchParams.get("code_challenge_method")).toBe("S256"); + expect(github.searchParams.get("state")).toBeTruthy(); + }); + + it("escapes a hostile client name", async () => { + const { env } = createEnv(); + const res = await call(env, "/oauth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ client_name: "", redirect_uris: [REDIRECT], token_endpoint_auth_method: "none" }), + }); + const clientId = ((await res.json()) as { client_id: string }).client_id; + const { challenge } = await pkce(); + const page = await call(env, `/authorize?${new URLSearchParams({ response_type: "code", client_id: clientId, redirect_uri: REDIRECT, state: "s", code_challenge: challenge, code_challenge_method: "S256" })}`); + const html = await page.text(); + expect(html).not.toContain(""); + expect(html).toContain("<script>"); + }); + + it("requires PKCE with S256, even from a client that registered with a secret", async () => { + const { env } = createEnv(); + const res = await call(env, "/oauth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ client_name: "Confidential", redirect_uris: [REDIRECT], token_endpoint_auth_method: "client_secret_post" }), + }); + const clientId = ((await res.json()) as { client_id: string }).client_id; + const page = await call(env, `/authorize?${new URLSearchParams({ response_type: "code", client_id: clientId, redirect_uri: REDIRECT, state: "s" })}`); + expect(page.status).toBe(302); + const back = new URL(page.headers.get("Location")!); + expect(back.searchParams.get("error")).toBe("invalid_request"); + expect(back.searchParams.get("state")).toBe("s"); + }); + + it("sends a Deny back to the client as access_denied", async () => { + const { env } = createEnv(); + const clientId = await register(env); + const { landing } = await authorize(env, clientId, 1, { decision: "deny" }); + const back = new URL(landing!); + expect(back.searchParams.get("error")).toBe("access_denied"); + expect(back.searchParams.get("iss")).toBe(PUBLIC_URL); + }); + + it("refuses a GitHub account that is not on the allowlist, without creating it", async () => { + const { env, db } = createEnv({ ALLOWED_GITHUB_IDS: "1" }); + const clientId = await register(env); + const { landing } = await authorize(env, clientId, 99); + expect(new URL(landing!).searchParams.get("error")).toBe("access_denied"); + expect(db.prepare("SELECT count(*) n FROM users").get()).toEqual({ n: 0 }); + }); + + it("answers 503 when browser sign-in is not configured", async () => { + const { env } = createEnv({ GITHUB_CLIENT_SECRET: undefined }); + vi.spyOn(console, "error").mockImplementation(() => undefined); + expect((await call(env, "/authorize?client_id=x")).status).toBe(503); + }); +}); + +describe("tokens from the authorization flow", () => { + it("are short-lived, scoped read, bound to /mcp, and work there", async () => { + const { env } = createEnv(); + const tokens = await signIn(env); + expect(tokens.scope).toBe("read"); + expect(tokens.expires_in).toBeLessThanOrEqual(3600); + expect(tokens.refresh_token).toBeTruthy(); + + const res = await call(env, "/mcp", { method: "POST", token: tokens.access_token, body: statusCall }); + expect(res.status).toBe(200); + expect(((await res.json()) as any).result.content[0].text).toContain("Nothing has been uploaded"); + }); + + it("cannot upload, delete or mint tokens: those need the CLI's own login", async () => { + const { env } = createEnv(); + const { access_token } = await signIn(env); + expect((await call(env, "/api/stream", { method: "POST", token: access_token, body: "{}" })).status).toBe(401); + expect((await call(env, "/api/me", { method: "DELETE", token: access_token })).status).toBe(401); + expect((await call(env, "/api/tokens", { method: "POST", token: access_token })).status).toBe(401); + }); + + it("are not accepted at a different resource", async () => { + const { env } = createEnv(); + const { access_token } = await signIn(env); + const elsewhere = await call(env, "/mcp", { method: "POST", token: access_token, body: statusCall, base: "https://sync.test" }); + expect(elsewhere.status).toBe(401); + }); + + it("refresh, and stop working everywhere after the CLI logs out", async () => { + const { env } = createEnv(); + const tokens = await signIn(env); + const refreshed = await exchange(env, { grant_type: "refresh_token", refresh_token: tokens.refresh_token, client_id: tokens.clientId }); + expect(refreshed.status).toBe(200); + + const cli = await login(env, 1); + expect((await call(env, "/auth/logout", { method: "POST", token: cli })).status).toBe(200); + + expect((await call(env, "/mcp", { method: "POST", token: refreshed.body.access_token, body: statusCall })).status).toBe(401); + const again = await exchange(env, { grant_type: "refresh_token", refresh_token: refreshed.body.refresh_token, client_id: tokens.clientId }); + expect(again.status).toBe(400); + expect(again.body.error).toBe("invalid_grant"); + }); + + it("stop working after delete-my-data, even if the grant survived in KV", async () => { + const { env, kv } = createEnv(); + const tokens = await signIn(env); + const snapshot = new Map(kv.store); + const cli = await login(env, 1); + expect((await call(env, "/api/me", { method: "DELETE", token: cli })).status).toBe(200); + // Put the grant and token records back, as if KV revocation had failed. + for (const [k, v] of snapshot) kv.store.set(k, v); + await login(env, 1); // the account exists again, with a newer epoch + expect((await call(env, "/mcp", { method: "POST", token: tokens.access_token, body: statusCall })).status).toBe(401); + }); + + it("refuse a code exchanged without the right PKCE verifier", async () => { + const { env } = createEnv(); + const clientId = await register(env); + const { challenge } = await pkce(); + const { landing } = await authorize(env, clientId, 1, { challenge }); + const code = new URL(landing!).searchParams.get("code")!; + const res = await exchange(env, { grant_type: "authorization_code", code, redirect_uri: REDIRECT, client_id: clientId, code_verifier: "wrong".repeat(10), resource: RESOURCE }); + expect(res.status).toBe(400); + }); +}); + +describe("Client ID Metadata Documents", () => { + it("is advertised when the runtime has global_fetch_strictly_public", async () => { + vi.stubGlobal("Cloudflare", { compatibilityFlags: { global_fetch_strictly_public: true } }); + const { env } = createEnv(); + const doc = (await (await call(env, "/.well-known/oauth-authorization-server")).json()) as Record; + expect(doc.client_id_metadata_document_supported).toBe(true); + }); + + it("accepts a client identified by an HTTPS metadata URL and shows its domain", async () => { + // What wrangler.toml's compatibility flag gives the Worker in production. + vi.stubGlobal("Cloudflare", { compatibilityFlags: { global_fetch_strictly_public: true } }); + const { env } = createEnv(); + const clientId = "https://client.example/oauth/metadata.json"; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string | URL | Request) => { + const u = String(url instanceof Request ? url.url : url); + if (u === clientId) { + return Response.json( + { client_id: clientId, client_name: "Example Agent", redirect_uris: [REDIRECT], token_endpoint_auth_method: "none" }, + { headers: { "Cache-Control": "max-age=60" } }, + ); + } + throw new Error(`unexpected fetch ${u}`); + }), + ); + const { challenge } = await pkce(); + const page = await call(env, `/authorize?${new URLSearchParams({ response_type: "code", client_id: clientId, redirect_uri: REDIRECT, state: "s", resource: RESOURCE, code_challenge: challenge, code_challenge_method: "S256" })}`); + expect(page.status).toBe(200); + const html = await page.text(); + expect(html).toContain("Example Agent"); + expect(html).toContain("Published by client.example"); + }); +}); diff --git a/cloud/test/worker.test.ts b/cloud/test/worker.test.ts index 41f64f9..78b6423 100644 --- a/cloud/test/worker.test.ts +++ b/cloud/test/worker.test.ts @@ -1,178 +1,381 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import worker from "../src/index.js"; -import { createJwt } from "../src/auth.js"; +import { createJwt, mintToken } from "../src/auth.js"; +import { LIMITS } from "../src/db.js"; +import { SERVER_VERSION } from "../src/version.js"; import { createEnv } from "./fake-env.js"; +import { call, callTool, login, message, post, rpc, session, stubGitHub, toolText, upload } from "./helpers.js"; const OLD_DEFAULT_SECRET = "xtctx-cloud-default-secret-change-in-production"; - -function call(env: unknown, path: string, init: RequestInit & { token?: string } = {}) { - const { token, ...rest } = init; - const headers = new Headers(rest.headers); - if (token) headers.set("Authorization", `Bearer ${token}`); - return worker.fetch(new Request(`https://sync.test${path}`, { ...rest, headers }), env as never); -} - -/** The GitHub side of the device flow, answered as a user with this id. */ -function stubGitHub(id: number, login: string) { - vi.stubGlobal( - "fetch", - vi.fn(async (url: string) => - String(url).includes("oauth/access_token") - ? Response.json({ access_token: "gh-token" }) - : Response.json({ id, login, name: login }), - ), - ); -} - -async function login(env: unknown, id: number, name: string): Promise { - stubGitHub(id, name); - const res = await call(env, "/auth/device/poll", { - method: "POST", - body: JSON.stringify({ device_code: "dc", device_name: "laptop" }), - }); - expect(res.status).toBe(200); - return ((await res.json()) as { token: string }).token; -} - -const turn = (over: Record = {}) => ({ - deviceId: "laptop", - tool: "claude-code", - sourceSessionId: "s1", - repoUrl: "github.com/a/b", - projectRoot: "b", - timestamp: "2026-10-01T00:00:00.000Z", - role: "user", - content: "hello", - messageIndex: 0, - contentHash: "abcdef0123456789", - ...over, -}); - const listTools = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }); -afterEach(() => vi.unstubAllGlobals()); +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); describe("without a signing secret", () => { it("refuses everything but the health check, even for a token signed with the old built-in default", async () => { const { env } = createEnv({ JWT_SECRET: undefined }); const forged = await createJwt({ sub: "github:1", username: "x", ver: 0 }, OLD_DEFAULT_SECRET); - const spy = vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); - for (const path of ["/api/stream", "/mcp", "/sse", "/auth/device/code"]) { + for (const path of ["/api/stream", "/mcp", "/sse", "/auth/device/code", "/authorize"]) { const res = - path === "/sse" + path === "/sse" || path === "/authorize" ? await call(env, path, { token: forged }) : await call(env, path, { method: "POST", token: forged, body: "{}" }); expect(res.status, path).toBe(500); expect(await res.json()).toEqual({ error: "server_misconfigured" }); } expect((await call(env, "/health")).status).toBe(200); - spy.mockRestore(); }); }); -describe("authentication", () => { - it("does not take a token from the query string", async () => { +describe("health", () => { + it("reports the package version, in the body and a header on every response", async () => { const { env } = createEnv(); - const token = await login(env, 1, "alice"); + const res = await call(env, "/health"); + expect(((await res.json()) as { version: string }).version).toBe(SERVER_VERSION); + expect(res.headers.get("X-Xtctx-Server-Version")).toBe(SERVER_VERSION); + expect((await call(env, "/nowhere")).headers.get("X-Xtctx-Server-Version")).toBe(SERVER_VERSION); + }); +}); +describe("sign-in allowlist", () => { + it("refuses a GitHub account that is not listed, before writing anything for it", async () => { + const { env, db } = createEnv({ ALLOWED_GITHUB_IDS: "1" }); + stubGitHub(99, "mallory"); + const res = await call(env, "/auth/device/poll", { method: "POST", body: JSON.stringify({ device_code: "dc" }) }); + expect(res.status).toBe(403); + expect(((await res.json()) as { error: string }).error).toBe("not_allowed"); + expect(db.prepare("SELECT count(*) n FROM users").get()).toEqual({ n: 0 }); + }); + + it("lets nobody in when the list is empty or unset", async () => { + for (const value of ["", undefined, " , ,abc"]) { + const { env } = createEnv({ ALLOWED_GITHUB_IDS: value }); + stubGitHub(1, "alice"); + const res = await call(env, "/auth/device/poll", { method: "POST", body: JSON.stringify({ device_code: "dc" }) }); + expect(res.status, String(value)).toBe(403); + } + }); + + it("stops a signed-in account's access once it is taken off the list", async () => { + const { env } = createEnv({ ALLOWED_GITHUB_IDS: "1" }); + const token = await login(env, 1); expect((await call(env, "/mcp", { method: "POST", token, body: listTools })).status).toBe(200); - expect((await call(env, `/mcp?token=${token}`, { method: "POST", body: listTools })).status).toBe(401); + (env as { ALLOWED_GITHUB_IDS: string }).ALLOWED_GITHUB_IDS = "2"; + expect((await call(env, "/mcp", { method: "POST", token, body: listTools })).status).toBe(401); }); +}); + +describe("/auth/device/poll error paths", () => { + const poll = (env: unknown, body: string) => call(env, "/auth/device/poll", { method: "POST", body }); - it("stops accepting a token after logout", async () => { + it("answers 400 to a body that is not JSON or has no device code", async () => { const { env } = createEnv(); - const token = await login(env, 1, "alice"); + stubGitHub(1, "alice"); + expect((await poll(env, "{ not json")).status).toBe(400); + expect((await poll(env, "{}")).status).toBe(400); + expect((await poll(env, JSON.stringify({ device_code: 7 }))).status).toBe(400); + }); + + it("passes GitHub's pending and slow-down answers through for the client to act on", async () => { + const { env } = createEnv(); + for (const error of ["authorization_pending", "slow_down", "expired_token", "access_denied"]) { + stubGitHub(1, "alice", { tokenError: error }); + const res = await poll(env, JSON.stringify({ device_code: "dc" })); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: string }).error).toBe(error); + } + }); + + it("answers 502 when GitHub will not say who the user is", async () => { + const { env, db } = createEnv(); + stubGitHub(1, "alice", { userStatus: 500 }); + expect((await poll(env, JSON.stringify({ device_code: "dc" }))).status).toBe(502); + expect(db.prepare("SELECT count(*) n FROM users").get()).toEqual({ n: 0 }); + }); + + it("answers 502 when GitHub cannot be reached", async () => { + const { env } = createEnv(); + vi.stubGlobal("fetch", vi.fn(async () => { throw new TypeError("network down"); })); + expect((await poll(env, JSON.stringify({ device_code: "dc" }))).status).toBe(502); + }); +}); + +describe("tokens", () => { + it("does not take a token from the query string", async () => { + const { env } = createEnv(); + const token = await login(env, 1); expect((await call(env, "/mcp", { method: "POST", token, body: listTools })).status).toBe(200); + expect((await call(env, `/mcp?token=${token}`, { method: "POST", body: listTools })).status).toBe(401); + }); - expect((await call(env, "/auth/logout", { method: "POST", token })).status).toBe(200); + it("refuses an expired token", async () => { + const { env } = createEnv(); + await login(env, 1); + const expired = await mintToken(env, { userId: "github:1", username: "u", epoch: 0, scopes: ["read", "sync:write"] }, -1); + expect((await call(env, "/mcp", { method: "POST", token: expired, body: listTools })).status).toBe(401); + expect((await post(env, expired, upload([session()]))).status).toBe(401); + }); + it("refuses a token from before scopes existed", async () => { + const { env } = createEnv(); + await login(env, 1); + const legacy = await createJwt({ sub: "github:1", username: "u", ver: 0 }, "test-secret-for-the-worker-tests"); + expect((await call(env, "/mcp", { method: "POST", token: legacy, body: listTools })).status).toBe(401); + }); + + it("stops accepting a token after logout, and a fresh login works", async () => { + const { env } = createEnv(); + const token = await login(env, 1); + expect((await call(env, "/auth/logout", { method: "POST", token })).status).toBe(200); expect((await call(env, "/mcp", { method: "POST", token, body: listTools })).status).toBe(401); - // Signing in again gives a token that works. - const fresh = await login(env, 1, "alice"); + const fresh = await login(env, 1); expect((await call(env, "/mcp", { method: "POST", token: fresh, body: listTools })).status).toBe(200); }); + + it("a pasted read-only token reads but cannot upload, delete or mint more", async () => { + const { env } = createEnv(); + const cli = await login(env, 1); + const res = await call(env, "/api/tokens", { method: "POST", token: cli }); + expect(res.status).toBe(200); + const { token: read, scope } = (await res.json()) as { token: string; scope: string }; + expect(scope).toBe("read"); + + expect((await call(env, "/mcp", { method: "POST", token: read, body: listTools })).status).toBe(200); + expect((await post(env, read, upload([session()]))).status).toBe(401); + expect((await call(env, "/api/me", { method: "DELETE", token: read })).status).toBe(401); + expect((await call(env, "/api/tokens", { method: "POST", token: read })).status).toBe(401); + + // And logout ends it too. + await call(env, "/auth/logout", { method: "POST", token: cli }); + expect((await call(env, "/mcp", { method: "POST", token: read, body: listTools })).status).toBe(401); + }); + + it("answers a missing scope with 403 insufficient_scope", async () => { + const { env } = createEnv(); + await login(env, 1); + const noDelete = await mintToken(env, { userId: "github:1", username: "u", epoch: 0, scopes: ["read", "sync:write"] }, 60); + const res = await call(env, "/api/me", { method: "DELETE", token: noDelete }); + expect(res.status).toBe(403); + expect(res.headers.get("WWW-Authenticate")).toContain('error="insufficient_scope"'); + }); }); describe("delete my data", () => { it("removes the caller's rows and tokens and leaves other users alone", async () => { const { env, db } = createEnv(); - const alice = await login(env, 1, "alice"); - const bob = await login(env, 2, "bob"); - for (const token of [alice, bob]) { - const res = await call(env, "/api/stream", { method: "POST", token, body: JSON.stringify([turn()]) }); - expect(res.status).toBe(200); - } - const messages = (user: string) => - (db.prepare("SELECT count(*) n FROM messages WHERE session_ref LIKE ?").get(`${user}:%`) as { n: number }).n; + const alice = await login(env, 1); + const bob = await login(env, 2); + for (const token of [alice, bob]) expect((await post(env, token, upload([session()]))).status).toBe(200); const owned = (table: string, user: string) => (db.prepare(`SELECT count(*) n FROM ${table} WHERE user_id = ?`).get(user) as { n: number }).n; - expect(messages("github:1")).toBe(1); + const messages = (user: string) => + (db.prepare("SELECT count(*) n FROM messages WHERE session_ref LIKE ?").get(`${user}:%`) as { n: number }).n; expect((await call(env, "/api/me", { method: "DELETE", token: alice })).status).toBe(200); expect(messages("github:1")).toBe(0); - for (const table of ["sessions", "devices"]) expect(owned(table, "github:1"), table).toBe(0); + for (const table of ["sessions", "devices", "uploads"]) expect(owned(table, "github:1"), table).toBe(0); expect(db.prepare("SELECT count(*) n FROM users WHERE id = 'github:1'").get()).toEqual({ n: 0 }); expect(messages("github:2")).toBe(1); expect((await call(env, "/mcp", { method: "POST", token: alice, body: listTools })).status).toBe(401); }); + + it("does not revive a token from before the deletion when the same account signs in again", async () => { + const { env } = createEnv(); + const old = await login(env, 1); + expect((await call(env, "/api/me", { method: "DELETE", token: old })).status).toBe(200); + + const fresh = await login(env, 1); + + expect((await call(env, "/mcp", { method: "POST", token: old, body: listTools })).status).toBe(401); + expect((await post(env, old, upload([session()]))).status).toBe(401); + expect((await call(env, "/mcp", { method: "POST", token: fresh, body: listTools })).status).toBe(200); + }); }); describe("ingest", () => { - it("answers 400 to a malformed body and 200 to a good one", async () => { + it("answers 400 to a malformed body, 426 to the old format, and 200 to a good one", async () => { const { env } = createEnv(); - const token = await login(env, 1, "alice"); - const post = (body: unknown) => call(env, "/api/stream", { method: "POST", token, body: JSON.stringify(body) }); + const token = await login(env, 1); + expect((await post(env, token, upload([session({}, [message(0, { role: "wizard" })])]))).status).toBe(400); + expect((await post(env, token, upload([session({}, [message(0, { messageIndex: "0" })])]))).status).toBe(400); + expect((await post(env, token, upload([session({}, [message(0, { id: "not-hex" })])]))).status).toBe(400); + expect((await post(env, token, upload([session({ tool: "Bad Tool" })]))).status).toBe(400); + expect((await post(env, token, { sessions: [] })).status).toBe(400); + expect((await call(env, "/api/stream", { method: "POST", token, body: "{ nope" })).status).toBe(400); + expect((await post(env, token, [{ deviceId: "x" }])).status).toBe(426); + expect((await post(env, token, upload([session()]))).status).toBe(200); + }); - expect((await post([turn({ role: "wizard" })])).status).toBe(400); - expect((await post([turn({ messageIndex: "0" })])).status).toBe(400); - expect((await post([])).status).toBe(400); - expect((await post([turn()])).status).toBe(200); + it("rejects a body over the byte limit with 413, without reading it all", async () => { + const { env } = createEnv(); + const token = await login(env, 1); + const huge = "x".repeat(LIMITS.maxBodyBytes + 1); + const res = await call(env, "/api/stream", { method: "POST", token, body: huge }); + expect(res.status).toBe(413); + expect(((await res.json()) as { error: string }).error).toBe("body_too_large"); }); - it("keeps two users who pick the same device id apart", async () => { + it("rejects an oversized message with 413 naming it, and writes nothing from that request", async () => { + const { env, db } = createEnv(); + const token = await login(env, 1); + const big = message(1, { content: "é".repeat(LIMITS.maxMessageBytes / 2 + 1) }); // over the limit in bytes, not chars + const res = await post(env, token, upload([session({}, [message(0), big])])); + expect(res.status).toBe(413); + expect(await res.json()).toMatchObject({ error: "message_too_large", messageId: big.id, sourceSessionId: "s1" }); + expect(db.prepare("SELECT count(*) n FROM messages").get()).toEqual({ n: 0 }); + }); + + it("refuses more sessions or messages per request than one batch may hold", async () => { + const { env } = createEnv(); + const token = await login(env, 1); + const sessions = Array.from({ length: LIMITS.maxSessionsPerRequest + 1 }, (_, i) => session({ sourceSessionId: `s${i}` })); + expect((await post(env, token, upload(sessions))).status).toBe(400); + const many = Array.from({ length: LIMITS.maxMessagesPerRequest + 1 }, (_, i) => message(i)); + expect((await post(env, token, upload([session({}, many)]))).status).toBe(400); + }); + + it("writes a whole request as one batch inside D1's per-invocation query limit", async () => { + const { env, stats } = createEnv(); + const token = await login(env, 1); + stats.batches.length = 0; + const sessions = Array.from({ length: LIMITS.maxSessionsPerRequest }, (_, i) => + session({ sourceSessionId: `s${i}`, keepIds: [] }, Array.from({ length: 50 }, (_, j) => message(j))), + ); + expect((await post(env, token, upload(sessions))).status).toBe(200); + expect(stats.batches).toHaveLength(1); + // Free plan: 50 queries per invocation. Plus auth (1) and the recount read (1). + expect(stats.batches[0] + 2).toBeLessThanOrEqual(50); + }); + + it("is idempotent: replaying an upload does not inflate counts or duplicate rows", async () => { const { env, db } = createEnv(); - const alice = await login(env, 1, "alice"); - const bob = await login(env, 2, "bob"); - for (const token of [alice, bob]) { - await call(env, "/api/stream", { method: "POST", token, body: JSON.stringify([turn()]) }); + const token = await login(env, 1); + const body = upload([session({}, [message(0), message(1), message(2)])]); + for (let i = 0; i < 3; i++) { + const res = await post(env, token, body); + expect(((await res.json()) as { sessions: Array<{ messageCount: number }> }).sessions[0].messageCount).toBe(3); } - const devices = db.prepare("SELECT id, user_id FROM devices ORDER BY user_id").all(); - expect(devices).toEqual([ + expect(db.prepare("SELECT message_count n FROM sessions").get()).toEqual({ n: 3 }); + expect(db.prepare("SELECT count(*) n FROM messages").get()).toEqual({ n: 3 }); + }); + + it("makes the cloud copy match the local one when keepIds is sent", async () => { + const { env, db } = createEnv(); + const token = await login(env, 1); + await post(env, token, upload([session({}, [message(0), message(1), message(2)])])); + // A local re-read replaced message 1 (new id) and dropped message 2. + const replaced = message(9, { messageIndex: 1, content: "message 1, edited" }); + const res = await post(env, token, upload([session({ keepIds: [message(0).id, replaced.id] }, [replaced])])); + expect(((await res.json()) as { sessions: Array<{ messageCount: number }> }).sessions[0].messageCount).toBe(2); + const contents = db.prepare("SELECT content FROM messages ORDER BY message_index").all().map((r) => (r as { content: string }).content); + expect(contents).toEqual(["message 0", "message 1, edited"]); + expect(db.prepare("SELECT message_count n FROM sessions").get()).toEqual({ n: 2 }); + }); + + it("keeps the earliest start and latest activity, and updates branch and device", async () => { + const { env, db } = createEnv(); + const token = await login(env, 1); + await post(env, token, upload([session({ startedAt: "2026-10-01T00:00:10.000Z", lastActivityAt: "2026-10-01T00:05:00.000Z" })])); + await post( + env, + token, + upload([session({ startedAt: "2026-10-01T00:00:00.000Z", lastActivityAt: "2026-10-01T00:01:00.000Z", gitBranch: "feat" })], { + device: { id: "desktop", name: "Desktop" }, + }), + ); + expect(db.prepare("SELECT started_at, last_activity_at, git_branch, device_id FROM sessions").get()).toEqual({ + started_at: "2026-10-01T00:00:00.000Z", + last_activity_at: "2026-10-01T00:05:00.000Z", + git_branch: "feat", + device_id: "github:1:desktop", + }); + }); + + it("keeps two users who pick the same device id apart", async () => { + const { env, db } = createEnv(); + for (const token of [await login(env, 1), await login(env, 2)]) await post(env, token, upload([session()])); + expect(db.prepare("SELECT id, user_id FROM devices ORDER BY user_id").all()).toEqual([ { id: "github:1:laptop", user_id: "github:1" }, { id: "github:2:laptop", user_id: "github:2" }, ]); }); + + it("logs a failed write with sizes and counts but no content", async () => { + const { env } = createEnv(); + const token = await login(env, 1); + const spy = vi.spyOn(console, "error").mockImplementation(() => undefined); + (env.DB as unknown as { batch: () => Promise }).batch = async () => { + throw new Error("D1_ERROR: boom"); + }; + const res = await post(env, token, upload([session({}, [message(0, { content: "very secret words" })])])); + expect(res.status).toBe(500); + const logged = String(spy.mock.calls[0][0]); + expect(JSON.parse(logged)).toMatchObject({ event: "ingest_failed", userId: "github:1", sessions: 1, messages: 1 }); + expect(logged).not.toContain("very secret words"); + }); +}); + +describe("tool calls are isolated between tenants", () => { + it("another user's session_ref is not found, and their sessions are never listed", async () => { + const { env } = createEnv(); + const alice = await login(env, 1); + const bob = await login(env, 2); + await post(env, alice, upload([session({}, [message(0, { content: "alice's secret" })])])); + + const detail = await callTool(env, bob, "xtctx_cloud_session_detail", { session_ref: "github:1:claude-code:s1" }); + expect(detail.result.isError).toBe(true); + expect(JSON.stringify(detail)).not.toContain("alice's secret"); + + const recent = await callTool(env, bob, "xtctx_cloud_recent_sessions", { format: "json" }); + expect(JSON.parse(toolText(recent))).toEqual([]); + const status = await callTool(env, bob, "xtctx_cloud_status", { format: "json" }); + expect(JSON.parse(toolText(status))).toEqual([]); + + // And alice does see hers. + const own = await callTool(env, alice, "xtctx_cloud_session_detail", { session_ref: "github:1:claude-code:s1" }); + expect(toolText(own)).toContain("alice's secret"); + }); }); describe("errors", () => { it("returns a generic 500 with no message or stack", async () => { const { env } = createEnv(); - const token = await login(env, 1, "alice"); + const token = await login(env, 1); const spy = vi.spyOn(console, "error").mockImplementation(() => undefined); - - const res = await call(env, "/mcp", { method: "POST", token, body: "{ not json" }); - + (env.DB as unknown as { prepare: () => never }).prepare = () => { + throw new Error("no such table: secret_table"); + }; + const res = await call(env, "/mcp", { method: "POST", token, body: listTools }); expect(res.status).toBe(500); expect(await res.json()).toEqual({ error: "internal_error" }); expect(spy).toHaveBeenCalled(); - spy.mockRestore(); }); }); describe("CORS", () => { it("sends no CORS headers by default, and only to a listed origin when configured", async () => { const plain = createEnv().env; - const res = await call(plain, "/health", { headers: { Origin: "https://evil.example" } }); + const res = await call(plain, "/nowhere", { headers: { Origin: "https://evil.example" } }); expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull(); const configured = createEnv({ ALLOWED_ORIGINS: "https://app.example" }).env; - const ok = await call(configured, "/health", { headers: { Origin: "https://app.example" } }); + const ok = await call(configured, "/nowhere", { headers: { Origin: "https://app.example" } }); expect(ok.headers.get("Access-Control-Allow-Origin")).toBe("https://app.example"); - const other = await call(configured, "/health", { headers: { Origin: "https://evil.example" } }); + const other = await call(configured, "/nowhere", { headers: { Origin: "https://evil.example" } }); expect(other.headers.get("Access-Control-Allow-Origin")).toBeNull(); }); + + it("refuses /mcp from a foreign browser origin", async () => { + const { env } = createEnv(); + const token = await login(env, 1); + const res = await call(env, "/mcp", { method: "POST", token, body: listTools, headers: { Origin: "https://evil.example" } }); + expect(res.status).toBe(403); + }); }); describe("SSE transport", () => { @@ -188,21 +391,23 @@ describe("SSE transport", () => { it("delivers a message posted through a separately loaded copy of the Worker", async () => { const { env } = createEnv(); - const token = await login(env, 1, "alice"); + const token = await login(env, 1); const { endpoint, next, reader } = await openStream(env, token); // Another isolate: a fresh module instance, sharing only the bindings. vi.resetModules(); const other = (await import("../src/index.js")).default; - const post = await other.fetch( - new Request(`https://sync.test${endpoint.pathname}${endpoint.search}`, { + const { fakeCtx } = await import("./fake-env.js"); + const res = await other.fetch( + new Request(`https://mcp.test${endpoint.pathname}${endpoint.search}`, { method: "POST", headers: { Authorization: `Bearer ${token}` }, body: JSON.stringify({ jsonrpc: "2.0", id: 7, method: "tools/list" }), }), env as never, + fakeCtx(), ); - expect(post.status).toBe(202); + expect(res.status).toBe(202); const event = await next(); expect(event).toContain("event: message"); @@ -212,16 +417,94 @@ describe("SSE transport", () => { it("will not let another user write into someone's stream", async () => { const { env } = createEnv(); - const alice = await login(env, 1, "alice"); - const bob = await login(env, 2, "bob"); + const alice = await login(env, 1); + const bob = await login(env, 2); const { endpoint, reader } = await openStream(env, alice); - - const res = await call(env, `${endpoint.pathname}${endpoint.search}`, { - method: "POST", - token: bob, - body: listTools, - }); + const res = await call(env, `${endpoint.pathname}${endpoint.search}`, { method: "POST", token: bob, body: listTools }); expect(res.status).toBe(404); await reader.cancel(); }); + + it("answers an unauthenticated stream with a challenge naming the resource metadata", async () => { + const { env } = createEnv(); + const res = await call(env, "/sse"); + expect(res.status).toBe(401); + expect(res.headers.get("WWW-Authenticate")).toContain("resource_metadata="); + }); +}); + +describe("tool results", () => { + it("recent sessions name the device, and session detail pages in conversation order", async () => { + const { env } = createEnv(); + const token = await login(env, 1); + // Out of order on purpose: same timestamp for 1 and 2, index decides. + const msgs = [ + message(2, { timestamp: "2026-10-01T00:00:05.000Z" }), + message(0, { timestamp: "2026-10-01T00:00:01.000Z" }), + message(1, { timestamp: "2026-10-01T00:00:05.000Z" }), + ]; + await post(env, token, upload([session({}, msgs)])); + + const recent = toolText(await callTool(env, token, "xtctx_cloud_recent_sessions")); + expect(recent).toContain("Laptop"); + + const page = JSON.parse( + toolText(await callTool(env, token, "xtctx_cloud_session_detail", { session_ref: "github:1:claude-code:s1", format: "json" })), + ); + expect(page.session.device).toBe("Laptop"); + expect(page.messages.map((m: { message_index: number }) => m.message_index)).toEqual([0, 1, 2]); + + const md = toolText(await callTool(env, token, "xtctx_cloud_session_detail", { session_ref: "github:1:claude-code:s1", limit: 1, offset: 1 })); + expect(md).toContain("message 1"); + expect(md).toContain("offset 2"); + }); + + it("status says when each device last uploaded each project", async () => { + const { env } = createEnv(); + const token = await login(env, 1); + expect(toolText(await callTool(env, token, "xtctx_cloud_status"))).toContain("Nothing has been uploaded"); + await post(env, token, upload([])); + const rows = JSON.parse(toolText(await callTool(env, token, "xtctx_cloud_status", { format: "json" }))); + expect(rows).toEqual([ + expect.objectContaining({ repo_url: "github.com/a/b", project_name: "b", device_name: "Laptop", sessions: 0 }), + ]); + }); + + it("clamps limit to 1..25, and a negative one is not unlimited", async () => { + const { env } = createEnv(); + const token = await login(env, 1); + for (let batch = 0; batch < 3; batch++) { + const sessions = Array.from({ length: 10 }, (_, i) => session({ sourceSessionId: `s${batch}-${i}` })); + await post(env, token, upload(sessions)); + } + const count = async (limit: unknown) => + JSON.parse(toolText(await callTool(env, token, "xtctx_cloud_recent_sessions", { limit, format: "json" }))).length; + expect(await count(-1)).toBe(1); + expect(await count(0)).toBe(1); + expect(await count(2.7)).toBe(2); + expect(await count(1000)).toBe(25); + const bad = await callTool(env, token, "xtctx_cloud_recent_sessions", { limit: "lots" }); + expect(bad.result.isError).toBe(true); + }); + + it("session_detail requires session_ref, and an unknown tool is -32602", async () => { + const { env } = createEnv(); + const token = await login(env, 1); + const missing = await callTool(env, token, "xtctx_cloud_session_detail", {}); + expect(missing.result.isError).toBe(true); + expect(toolText(missing)).toContain("session_ref is required"); + const unknown = await callTool(env, token, "xtctx_recent_sessions", {}); + expect(unknown.error.code).toBe(-32602); + }); + + it("names the tools so they cannot collide with the local server's", async () => { + const { env } = createEnv(); + const token = await login(env, 1); + const { body } = await rpc(env, token, "tools/list"); + expect(body.result.tools.map((t: { name: string }) => t.name)).toEqual([ + "xtctx_cloud_recent_sessions", + "xtctx_cloud_session_detail", + "xtctx_cloud_status", + ]); + }); }); diff --git a/cloud/tsconfig.json b/cloud/tsconfig.json index b8b7e82..ad79127 100644 --- a/cloud/tsconfig.json +++ b/cloud/tsconfig.json @@ -8,7 +8,8 @@ "strict": true, "skipLibCheck": true, "noEmit": true, - "isolatedModules": true + "isolatedModules": true, + "resolveJsonModule": true }, "include": ["src/**/*"] } diff --git a/cloud/vitest.config.ts b/cloud/vitest.config.ts index 5539adc..8849c2a 100644 --- a/cloud/vitest.config.ts +++ b/cloud/vitest.config.ts @@ -1,3 +1,13 @@ +import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; -export default defineConfig({ test: { include: ["test/**/*.test.ts"] } }); +export default defineConfig({ + resolve: { + alias: { "cloudflare:workers": fileURLToPath(new URL("./test/cloudflare-workers-stub.ts", import.meta.url)) }, + }, + test: { + include: ["test/**/*.test.ts"], + // Run the library through Vite so the alias above applies to it too. + server: { deps: { inline: ["@cloudflare/workers-oauth-provider"] } }, + }, +}); diff --git a/cloud/wrangler.toml b/cloud/wrangler.toml index c1a367b..4cd01b7 100644 --- a/cloud/wrangler.toml +++ b/cloud/wrangler.toml @@ -1,20 +1,35 @@ name = "xtctx-cloud" main = "src/index.ts" compatibility_date = "2026-07-28" -compatibility_flags = ["nodejs_compat"] -workers_dev = true +# global_fetch_strictly_public: the OAuth provider fetches Client ID Metadata +# Documents from URLs a client names; this keeps those fetches on the public +# internet (SSRF protection), and the provider only advertises CIMD with it. +compatibility_flags = ["nodejs_compat", "global_fetch_strictly_public"] +# Only the custom domains below serve this Worker; no *.workers.dev alias that +# would answer with a different origin than the one tokens are bound to. +workers_dev = false -# Custom domain routing for xtctx.com routes = [ { pattern = "mcp.xtctx.com", custom_domain = true }, { pattern = "sync.xtctx.com", custom_domain = true } ] -# D1 Database Binding +[observability] +enabled = true + +# D1: sessions, messages, accounts, revocation epochs. +# Schema: `npm run d1:migrate:remote` (wrangler d1 migrations apply). [[d1_databases]] binding = "DB" database_name = "xtctx-db" database_id = "f64a7fc9-60a3-41be-964f-efbca6897f88" +migrations_dir = "migrations" + +# KV: OAuth grants, tokens and registered clients (workers-oauth-provider). +# Create it with `npx wrangler kv namespace create OAUTH_KV` and put the id here. +[[kv_namespaces]] +binding = "OAUTH_KV" +id = "REPLACE_WITH_OAUTH_KV_NAMESPACE_ID" # Holds the open SSE streams for the baseline MCP transport. [[durable_objects.bindings]] @@ -26,11 +41,18 @@ tag = "v1" new_sqlite_classes = ["SseSession"] [vars] -# Public GitHub OAuth App Client ID (for Device Flow) +# The origin MCP clients connect to; the MCP resource is PUBLIC_URL + "/mcp" +# and every token is bound to it. +PUBLIC_URL = "https://mcp.xtctx.com" +# Public GitHub OAuth App client id (device flow for the CLI, web flow for /authorize). GITHUB_CLIENT_ID = "Ov23liFUTodqfoH9mgHA" +# Comma-separated numeric GitHub user ids allowed to sign in. Empty means +# nobody can (fail closed). Find yours: https://api.github.com/users/ -> "id". +ALLOWED_GITHUB_IDS = "" ENVIRONMENT = "production" -# Secrets to set via `wrangler secret put `: -# - JWT_SECRET: REQUIRED. Key for signing user tokens; the Worker answers 500 until it is set. +# Secrets, set with `npx wrangler secret put `: +# - JWT_SECRET: REQUIRED. Signs the CLI's tokens; every route but /health answers 500 until it is set. +# - GITHUB_CLIENT_SECRET: needed for browser sign-in at /authorize (MCP clients). Without it the +# CLI's device flow still works and /authorize answers 503. # Optional var: ALLOWED_ORIGINS (comma-separated browser origins to send CORS headers to; none by default). -# - GITHUB_CLIENT_SECRET: (Optional) If using Web Application flow instead of Device Flow From ae3c588e1d7f85d34b63aaf73e322e84f6315813 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Thu, 1 Oct 2026 10:42:20 +0100 Subject: [PATCH 15/19] fix(sync)!: upload only this project, keep each session's cloud copy exact, and say when it fails - Only sessions the index attributes to this project are read, compared with the index's own root canonicalisation (PROJECT_ROOT_SQL). - Each changed session is sent under local message ids; when the server's count differs from the local one, the session is sent whole with keepIds so deletions and rewrites reach the cloud. Requests are packed under the server's limits, oversized content is truncated with a marker, and a message the server still refuses is skipped and recorded instead of blocking every later upload. - The position, last success, last error and skipped list live per project and account in ~/.xtctx/sync/, not in the index; a rebuild re-sends once, harmlessly. The cursor only moves after a complete run and never backwards. - A lock in ~/.xtctx/sync/ (stale takeover by dead pid or 10 min) lets one MCP server per project upload at a time. - XTCTX_TOKEN / XTCTX_SYNC_URL that differ from the saved login (or with no saved login) refuse to upload unless XTCTX_ALLOW_ENV_CREDENTIALS=1. - Metadata is reduced to an allowlist with absolute paths dropped; source_pointer is never sent; the default device name is a random label, settable with `login --device` or `sync device `. - `xtctx sync` exits non-zero on failure, --watch prints a repeated error once, `sync status` and `xtctx status` show the last upload, the last failure and skipped messages. `sync token` prints a read-only token. - `logout --delete-data` treats 401 as failure: nothing was deleted, the login is kept, and it says to log in again. - Every request sends X-Xtctx-Client. BREAKING CHANGE: speaks the v2 upload format, which servers before 0.2 do not accept. --- src/cli/index.ts | 9 +- src/cli/login.ts | 64 +++-- src/cli/status.ts | 8 + src/cli/sync.ts | 87 +++++-- src/sync/auto-sync.ts | 13 +- src/sync/client.ts | 128 +++++++--- src/sync/consent.ts | 2 +- src/sync/diff-sync.ts | 461 ++++++++++++++++++++++++++++------- src/sync/report.ts | 39 +++ src/sync/state.ts | 176 +++++++++++++ src/sync/upload-format.ts | 83 +++++++ tests/sync/auto-sync.test.ts | 2 +- tests/sync/diff-sync.test.ts | 318 ++++++++++++++++++++---- tests/sync/helpers.ts | 132 +++++++++- tests/sync/login.test.ts | 35 ++- tests/sync/sync-cli.test.ts | 63 +++++ 16 files changed, 1400 insertions(+), 220 deletions(-) create mode 100644 src/sync/report.ts create mode 100644 src/sync/state.ts create mode 100644 src/sync/upload-format.ts create mode 100644 tests/sync/sync-cli.test.ts diff --git a/src/cli/index.ts b/src/cli/index.ts index c8493b9..266ff61 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -174,7 +174,7 @@ export async function main(argv = process.argv): Promise { program .command("login") .option("--sync-url ", "Sync server URL (default: https://sync.xtctx.com)") - .option("--device ", "Friendly name for this device") + .option("--device ", "Name this device shows as in the cloud (default: a random label)") .description("Sign in to xtctx cloud with GitHub (uploads nothing by itself)") .action(async (options: { syncUrl?: string; device?: string }) => { await runLogin({ syncUrl: options.syncUrl, deviceName: options.device }); @@ -190,15 +190,16 @@ export async function main(argv = process.argv): Promise { program .command("sync") - .argument("[action]", "enable, disable or status for this project; omit to upload once") + .argument("[action]", "enable, disable or status for this project; device [name]; token; omit to upload once") + .argument("[value]", "the new name, for `sync device `") .option("-p, --project ", "Project root (defaults to cwd)") .option("-w, --watch", "Keep uploading every few seconds until interrupted", false) .description("Cloud sync: choose whether this project uploads, or upload now") - .action(async (action: string | undefined, options: { project?: string; watch?: boolean }) => { + .action(async (action: string | undefined, value: string | undefined, options: { project?: string; watch?: boolean }) => { const globalOptions = program.opts<{ project?: string }>(); const projectDir = options.project ?? globalOptions.project; if (action) { - await runSyncSetting(action, { projectDir }); + await runSyncSetting(action, { projectDir, value }); } else { await runSync({ projectDir, watch: options.watch }); } diff --git a/src/cli/login.ts b/src/cli/login.ts index 2c3a7f5..26a9041 100644 --- a/src/cli/login.ts +++ b/src/cli/login.ts @@ -1,12 +1,13 @@ -import { hostname } from "node:os"; import { randomUUID } from "node:crypto"; import { DEFAULT_SYNC_URL, assertSecureSyncUrl, callCloud, + clientHeader, deleteCredentials, getCredentialsPath, - loadCredentials, + loadSavedCredentials, + randomDeviceName, saveCredentials, } from "../sync/client.js"; @@ -22,6 +23,7 @@ interface PollResponse { token?: string; user?: { id: string; username: string; name?: string }; error?: string; + detail?: string; interval?: number; } @@ -39,13 +41,14 @@ export async function runLogin(options: { wait?: (ms: number) => Promise; }): Promise { const syncUrl = options.syncUrl || process.env.XTCTX_SYNC_URL || DEFAULT_SYNC_URL; - const deviceName = options.deviceName || hostname() || "default-device"; + // Not the hostname: see randomDeviceName. + const deviceName = options.deviceName || randomDeviceName(); const wait = options.wait ?? sleep; const base = syncUrl.replace(/\/$/, ""); assertSecureSyncUrl(syncUrl); - const codeRes = await fetch(`${base}/auth/device/code`, { method: "POST" }); + const codeRes = await fetch(`${base}/auth/device/code`, { method: "POST", headers: { "X-Xtctx-Client": clientHeader() } }); if (!codeRes.ok) { throw new Error(`Could not start login (${codeRes.status}).`); } @@ -67,8 +70,8 @@ export async function runLogin(options: { try { const res = await fetch(`${base}/auth/device/poll`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ device_code: code.device_code, device_name: deviceName }), + headers: { "Content-Type": "application/json", "X-Xtctx-Client": clientHeader() }, + body: JSON.stringify({ device_code: code.device_code }), }); poll = (await res.json()) as PollResponse; } catch { @@ -90,6 +93,10 @@ export async function runLogin(options: { if (poll.error === "slow_down") { intervalMs = poll.interval ? poll.interval * 1000 : intervalMs + 5000; + } else if (poll.error === "not_allowed") { + throw new Error( + "Login failed: this GitHub account is not allowed on this xtctx cloud server (its ALLOWED_GITHUB_IDS does not list it).", + ); } else if (poll.error && poll.error !== "authorization_pending") { throw new Error(`Login failed: ${poll.error}`); } @@ -100,28 +107,45 @@ export async function runLogin(options: { /** * Sign out: ask the server to revoke this account's tokens, then forget ours. * With deleteData it first deletes everything the account has uploaded, and - * keeps the local login if that fails so it can be retried. + * keeps the local login whenever that did not happen, so it can be retried. */ export async function runLogout(options: { deleteData?: boolean }): Promise { - const creds = await loadCredentials(); + // The saved login only: logout is about what `xtctx login` stored. + const creds = await loadSavedCredentials(); if (!creds) { console.log("Not logged in."); return; } - try { - const res = options.deleteData - ? await callCloud(creds, "DELETE", "/api/me") - : await callCloud(creds, "POST", "/auth/logout"); - // 401 means the server already refuses this token, which is the state we want. - if (!res.ok && res.status !== 401) throw new Error(`server answered ${res.status}`); - if (options.deleteData) console.log("Deleted everything xtctx cloud held for your account."); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - if (options.deleteData) { - throw new Error(`Could not delete your cloud data (${message}). You are still logged in; try again.`); + if (options.deleteData) { + let res: Response; + try { + res = await callCloud(creds, "DELETE", "/api/me"); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Could not delete your cloud data (${message}). Nothing was deleted and you are still logged in; try again.`); + } + // A 401 here means the server no longer accepts this login, so it deleted + // nothing: reporting success would leave the data there while saying it is gone. + if (res.status === 401) { + throw new Error( + "Your xtctx cloud login has expired or was signed out, so nothing was deleted. " + + "Run `xtctx login`, then `xtctx logout --delete-data` again.", + ); + } + if (!res.ok) { + throw new Error(`Could not delete your cloud data (server answered ${res.status}). Nothing was deleted and you are still logged in; try again.`); + } + console.log("Deleted everything xtctx cloud held for your account, and signed out every device."); + } else { + try { + const res = await callCloud(creds, "POST", "/auth/logout"); + // 401: the server already refuses this token, which is the state we want. + if (!res.ok && res.status !== 401) throw new Error(`server answered ${res.status}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn(`Could not reach the server to revoke your token (${message}). It stays valid until it expires.`); } - console.warn(`Could not reach the server to revoke your token (${message}). It stays valid until it expires.`); } await deleteCredentials(); diff --git a/src/cli/status.ts b/src/cli/status.ts index cb62347..a7e7ccb 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -12,6 +12,7 @@ import { import { readDriftLog, type DriftLogFile } from "../scrapers/drift-log.js"; import { SUPPORTED_TOOLS } from "../tools/sources.js"; import { readXtctxPackage } from "../utils/package-info.js"; +import { describeCloudSync } from "../sync/report.js"; interface StatusOptions { projectPath?: string; @@ -174,6 +175,13 @@ export async function renderStatusBlock( const endpoint = status.vector_model.slice("openai:".length); lines.push(`Embedding external endpoint — window text is sent to ${endpoint}`); } + // Cloud upload is the other thing that sends transcripts off this machine, + // so whether it is on here, and whether it is working, is always stated. + const cloud = await describeCloudSync(services.projectRoot).catch((err: unknown) => [ + `unknown (${err instanceof Error ? err.message : String(err)})`, + ]); + lines.push(`Cloud ${cloud[0]}`); + for (const line of cloud.slice(1)) lines.push(` ${line}`); lines.push(""); lines.push("Tools:"); diff --git a/src/cli/sync.ts b/src/cli/sync.ts index d4fce6a..0861307 100644 --- a/src/cli/sync.ts +++ b/src/cli/sync.ts @@ -1,36 +1,50 @@ import { resolve } from "node:path"; -import { loadCredentials } from "../sync/client.js"; -import { isOptedIn, setOptedIn } from "../sync/consent.js"; -import { NotLoggedInError, NotOptedInError, runDiffSync } from "../sync/diff-sync.js"; +import { callCloud, loadSavedCredentials, saveCredentials } from "../sync/client.js"; +import { setOptedIn } from "../sync/consent.js"; +import { runDiffSync, type DiffSyncResult } from "../sync/diff-sync.js"; +import { describeCloudSync } from "../sync/report.js"; const WATCH_INTERVAL_MS = 10_000; +function describe(result: DiffSyncResult): string { + if (result.busy) return "Another xtctx process is uploading this project right now; it will pick up these changes."; + const lines = [result.syncedCount === 0 ? "Cloud sync is up to date." : `Sent ${result.syncedCount} message(s) from ${result.sessionCount} session(s) to xtctx cloud.`]; + for (const s of result.skipped) lines.push(`Skipped message ${s.messageId} in ${s.sessionRef}: ${s.reason}.`); + return lines.join("\n"); +} + /** - * Upload this project's new turns once, or with --watch keep doing it in the - * foreground until interrupted. Same upload as the MCP server's own, so there - * is nothing here that runs unless you ran it. + * Upload this project's changes once, or with --watch keep doing it in the + * foreground until interrupted. Same upload as the MCP server's own. + * + * Exits non-zero when the upload failed (once: that run; --watch: the last + * run before Ctrl+C). The outcome is also recorded for `xtctx sync status`. */ export async function runSync(options: { projectDir?: string; watch?: boolean }): Promise { const projectDir = resolve(options.projectDir ?? process.cwd()); + let lastError = ""; const once = async (): Promise => { try { const result = await runDiffSync({ projectDir }); - console.log(result.upToDate ? "Cloud sync is up to date." : `Synced ${result.syncedCount} turns to cloud.`); + // Quiet when watching and there is nothing to say. + if (!options.watch || !result.upToDate || lastError) console.log(describe(result)); + lastError = ""; + process.exitCode = 0; return true; } catch (err) { const message = err instanceof Error ? err.message : String(err); - if (err instanceof NotLoggedInError || err instanceof NotOptedInError) { - console.error(message); - process.exitCode = 1; - return false; - } - console.error(`Cloud sync failed: ${message}`); - return true; // keep going when watching; the next pass retries + // Said once per distinct failure, not every ten seconds. + if (message !== lastError) console.error(`Cloud sync failed: ${message}`); + lastError = message; + process.exitCode = 1; + return false; } }; - if (!(await once()) || !options.watch) return; + const ok = await once(); + if (!options.watch) return; + if (!ok && /Not logged in|not opted in/.test(lastError)) return; console.log(`Watching ${projectDir}; uploading every ${WATCH_INTERVAL_MS / 1000}s. Ctrl+C to stop.`); await new Promise((done) => { @@ -49,15 +63,15 @@ export async function runSync(options: { projectDir?: string; watch?: boolean }) }); } -/** `xtctx sync enable | disable | status`: the per-project upload decision. */ -export async function runSyncSetting(action: string, options: { projectDir?: string }): Promise { +/** `xtctx sync enable | disable | status | device | token`. */ +export async function runSyncSetting(action: string, options: { projectDir?: string; value?: string }): Promise { const projectDir = resolve(options.projectDir ?? process.cwd()); if (action === "enable") { await setOptedIn(projectDir, true); console.log(`Cloud sync is ON for ${projectDir}.`); console.log("Its transcripts are uploaded to your xtctx cloud account while an agent runs xtctx here."); - if (!(await loadCredentials())) console.log("You are not logged in yet: run `xtctx login`."); + if (!(await loadSavedCredentials())) console.log("You are not logged in yet: run `xtctx login`."); return; } @@ -69,12 +83,41 @@ export async function runSyncSetting(action: string, options: { projectDir?: str } if (action === "status") { - const creds = await loadCredentials(); - console.log(creds ? `Logged in as ${creds.user.username} (${creds.syncUrl})` : "Not logged in."); - console.log(`This project: cloud sync ${(await isOptedIn(projectDir)) ? "ON" : "OFF"}`); + for (const [i, line] of (await describeCloudSync(projectDir)).entries()) { + console.log(i === 0 ? `Cloud sync for ${projectDir}: ${line}` : ` ${line}`); + } + return; + } + + if (action === "device") { + const creds = await loadSavedCredentials(); + if (!creds) throw new Error("Not logged in. Run `xtctx login --device ` instead."); + const name = options.value?.trim(); + if (!name) { + console.log(`This device uploads as "${creds.deviceName}". Rename it with: xtctx sync device `); + return; + } + if (name.length > 64) throw new Error("A device name is at most 64 characters."); + await saveCredentials({ ...creds, deviceName: name }); + console.log(`This device now uploads as "${name}" (from its next upload).`); + return; + } + + if (action === "token") { + const creds = await loadSavedCredentials(); + if (!creds) throw new Error("Not logged in. Run `xtctx login` first."); + const res = await callCloud(creds, "POST", "/api/tokens"); + if (res.status === 401) throw new Error("Your xtctx cloud login is no longer valid. Run `xtctx login`."); + if (!res.ok) throw new Error(`Could not create a token (server answered ${res.status}).`); + const body = (await res.json()) as { token: string; mcp_url: string; expires_at: string }; + console.error( + `A read-only token for ${body.mcp_url}, valid until ${body.expires_at}. It can read every transcript in your account;\n` + + "keep it out of files you commit. `xtctx logout` revokes it. Prefer the client's own sign-in where it has one.", + ); + console.log(body.token); return; } - console.error(`Unknown action "${action}". Use enable, disable or status, or no action to upload once.`); + console.error(`Unknown action "${action}". Use enable, disable, status, device or token, or no action to upload once.`); process.exitCode = 1; } diff --git a/src/sync/auto-sync.ts b/src/sync/auto-sync.ts index f77fac2..4875310 100644 --- a/src/sync/auto-sync.ts +++ b/src/sync/auto-sync.ts @@ -25,7 +25,9 @@ export interface AutoSyncOptions { * Each tick asks `runDiffSync`, which refuses unless someone is logged in and * the project is opted in, so a project that is neither costs two small file * reads per tick and sends nothing. Ticks never overlap: the next is - * scheduled when the previous one finishes. + * scheduled when the previous one finishes. Across processes (two agents + * open in one project each run a server) a lock in ~/.xtctx lets one upload + * at a time; the others find it busy and wait for their next tick. */ export function startAutoSync(options: AutoSyncOptions): AutoSync { const { projectRoot, log } = options; @@ -41,11 +43,16 @@ export function startAutoSync(options: AutoSyncOptions): AutoSync { const tick = async (): Promise => { try { const result = await sync({ projectDir: projectRoot }); - if (result.syncedCount > 0) log(`xtctx: synced ${result.syncedCount} new turns to cloud`); + if (result.busy) return; // another process is uploading this project + if (result.syncedCount > 0) log(`xtctx: sent ${result.syncedCount} message(s) to xtctx cloud`); + for (const s of result.skipped) { + log(`xtctx: cloud sync skipped message ${s.messageId} in ${s.sessionRef}: ${s.reason}`); + } lastError = ""; } catch (err) { if (err instanceof NotLoggedInError || err instanceof NotOptedInError) return; - // Said once per distinct failure, not every ten seconds. + // Said once per distinct failure, not every ten seconds. The failure is + // also recorded for `xtctx sync status`. const message = err instanceof Error ? err.message : String(err); if (message !== lastError) log(`xtctx: cloud sync failed: ${message}`); lastError = message; diff --git a/src/sync/client.ts b/src/sync/client.ts index 394f522..0a40ea2 100644 --- a/src/sync/client.ts +++ b/src/sync/client.ts @@ -1,7 +1,9 @@ +import { createHash, randomBytes } from "node:crypto"; import { hostname } from "node:os"; import { join } from "node:path"; import { readFile, rm } from "node:fs/promises"; import { writeFileAtomic } from "../utils/atomic-file.js"; +import { readXtctxPackage } from "../utils/package-info.js"; import { xtctxHome } from "./consent.js"; export interface SyncCredentials { @@ -23,39 +25,16 @@ export function getCredentialsPath(): string { } /** - * The saved login, or one built from XTCTX_TOKEN for a machine nobody logs in - * on. Having credentials does not mean anything is uploaded: that also needs - * the project to be opted in (see consent.ts). + * A name for this device that says nothing about it. The hostname used to be + * the default, and it often carries a person's name or an employer's asset + * tag; `xtctx login --device ` or `xtctx sync device ` sets one. */ -export async function loadCredentials(): Promise { - if (process.env.XTCTX_TOKEN) { - const token = process.env.XTCTX_TOKEN; - let userId = "env-user"; - let username = "developer"; - let deviceId = process.env.XTCTX_DEVICE_ID; - - try { - const parts = token.split("."); - if (parts.length >= 2) { - const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8")); - if (payload.sub) userId = payload.sub; - if (payload.username) username = payload.username; - if (payload.device_id && !deviceId) deviceId = payload.device_id; - } - } catch { - // Not a decodable JWT; the server is what judges the token. - } - - const machineName = hostname(); - return { - token, - user: { id: userId, username }, - deviceId: deviceId || `device-${machineName.toLowerCase().replace(/[^a-z0-9_-]/g, "")}`, - deviceName: process.env.XTCTX_DEVICE_NAME || machineName, - syncUrl: process.env.XTCTX_SYNC_URL || DEFAULT_SYNC_URL, - }; - } +export function randomDeviceName(): string { + return `device-${randomBytes(3).toString("hex")}`; +} +/** The saved login from `xtctx login`, or null. */ +export async function loadSavedCredentials(): Promise { try { return JSON.parse(await readFile(getCredentialsPath(), "utf-8")) as SyncCredentials; } catch { @@ -63,6 +42,76 @@ export async function loadCredentials(): Promise { } } +/** Credentials described by XTCTX_TOKEN / XTCTX_SYNC_URL, or null when neither is set. */ +function credentialsFromEnv(saved: SyncCredentials | null): SyncCredentials | null { + const envToken = process.env.XTCTX_TOKEN || undefined; + const envUrl = process.env.XTCTX_SYNC_URL || undefined; + if (!envToken && !envUrl) return null; + if (!envToken) return saved ? { ...saved, syncUrl: envUrl! } : null; + + let userId = "env-user"; + let username = "developer"; + try { + const payload = JSON.parse(Buffer.from(envToken.split(".")[1] ?? "", "base64url").toString("utf-8")); + if (typeof payload.sub === "string") userId = payload.sub; + if (typeof payload.username === "string") username = payload.username; + } catch { + // Not a decodable JWT; the server is what judges the token. + } + // Stable across runs without saying anything about the machine. + const stableId = `device-${createHash("sha256").update(hostname()).digest("hex").slice(0, 12)}`; + return { + token: envToken, + user: { id: userId, username }, + deviceId: process.env.XTCTX_DEVICE_ID || saved?.deviceId || stableId, + deviceName: process.env.XTCTX_DEVICE_NAME || saved?.deviceName || stableId, + syncUrl: envUrl || saved?.syncUrl || DEFAULT_SYNC_URL, + }; +} + +/** + * The login in effect: XTCTX_TOKEN / XTCTX_SYNC_URL when set, otherwise the + * saved one. For showing who is logged in; uploads go through + * `resolveUploadCredentials`, which is stricter about the environment. + */ +export async function loadCredentials(): Promise { + const saved = await loadSavedCredentials(); + return credentialsFromEnv(saved) ?? saved; +} + +export class EnvCredentialsRefusedError extends Error { + constructor() { + super( + "XTCTX_TOKEN or XTCTX_SYNC_URL is set and differs from your saved login, so this project's uploads " + + "would go to another account or server than the one you signed in to. Refusing to upload. " + + "Unset them, or set XTCTX_ALLOW_ENV_CREDENTIALS=1 if that is what you want.", + ); + this.name = "EnvCredentialsRefusedError"; + } +} + +const sameUrl = (a: string, b: string) => a.replace(/\/+$/, "") === b.replace(/\/+$/, ""); + +/** + * The credentials an upload may use. + * + * Environment variables reach a process from places the user does not see, + * such as an MCP config's `env` block, which a repository can ship. One that + * sets XTCTX_TOKEN or XTCTX_SYNC_URL could otherwise send an opted-in + * project's transcripts to someone else's account or server. So when they + * name anything other than the saved login (including when there is no saved + * login), uploading also needs XTCTX_ALLOW_ENV_CREDENTIALS=1. Null when there + * is no login at all. + */ +export async function resolveUploadCredentials(): Promise { + const saved = await loadSavedCredentials(); + const fromEnv = credentialsFromEnv(saved); + if (!fromEnv) return saved; + const matchesSaved = saved !== null && fromEnv.token === saved.token && sameUrl(fromEnv.syncUrl, saved.syncUrl); + if (!matchesSaved && process.env.XTCTX_ALLOW_ENV_CREDENTIALS !== "1") throw new EnvCredentialsRefusedError(); + return fromEnv; +} + /** Written 0600 and atomically: it holds a bearer token. */ export async function saveCredentials(creds: SyncCredentials): Promise { await writeFileAtomic(getCredentialsPath(), JSON.stringify(creds, null, 2), { mode: 0o600 }); @@ -84,10 +133,23 @@ export function assertSecureSyncUrl(syncUrl: string): void { } } -export async function callCloud(creds: SyncCredentials, method: string, path: string): Promise { +/** Sent on every request, so the server can tell which client versions are out there. */ +export function clientHeader(): string { + return `xtctx/${readXtctxPackage(import.meta.url).version}`; +} + +export async function callCloud( + creds: SyncCredentials, + method: string, + path: string, + body?: unknown, +): Promise { assertSecureSyncUrl(creds.syncUrl); + const headers: Record = { Authorization: `Bearer ${creds.token}`, "X-Xtctx-Client": clientHeader() }; + if (body !== undefined) headers["Content-Type"] = "application/json"; return fetch(`${creds.syncUrl.replace(/\/$/, "")}${path}`, { method, - headers: { Authorization: `Bearer ${creds.token}` }, + headers, + body: body === undefined ? undefined : JSON.stringify(body), }); } diff --git a/src/sync/consent.ts b/src/sync/consent.ts index 0e2313c..188870e 100644 --- a/src/sync/consent.ts +++ b/src/sync/consent.ts @@ -20,7 +20,7 @@ function consentPath(): string { * its reader in to sending their transcripts anywhere. Logging in does not * add to it either; the two are separate decisions. */ -async function projectKey(projectRoot: string): Promise { +export async function projectKey(projectRoot: string): Promise { const real = await realpath(projectRoot).catch(() => projectRoot); return process.platform === "win32" ? real.toLowerCase() : real; } diff --git a/src/sync/diff-sync.ts b/src/sync/diff-sync.ts index 2fb6cf3..13a5bc8 100644 --- a/src/sync/diff-sync.ts +++ b/src/sync/diff-sync.ts @@ -1,12 +1,19 @@ import Database, { type Database as DatabaseHandle } from "better-sqlite3"; +import { existsSync, realpathSync } from "node:fs"; import { basename, join } from "node:path"; -import { existsSync } from "node:fs"; -import { assertSecureSyncUrl, loadCredentials } from "./client.js"; +import { assertSecureSyncUrl, callCloud, loadCredentials, resolveUploadCredentials, type SyncCredentials } from "./client.js"; import { isOptedIn } from "./consent.js"; import { getCanonicalRepoId } from "./normalizer.js"; -import { getSetting, setSetting } from "../handoff/schema.js"; - -const BATCH_SIZE = 100; +import { + accountKey, + acquireUploadLock, + readAccountState, + updateAccountState, + type SkippedMessage, + type UploadLock, +} from "./state.js"; +import { UPLOAD_LIMITS, fitContent, sanitizeMetadata } from "./upload-format.js"; +import { PROJECT_ROOT_SQL, normalizeRootForCompare } from "../handoff/queries.js"; export class NotLoggedInError extends Error { constructor() { @@ -20,105 +27,395 @@ export class NotOptedInError extends Error { } } +/** The server no longer accepts this login: expired, signed out, or deleted. */ +export class CloudAuthError extends Error { + constructor(status: number) { + super( + status === 403 + ? "xtctx cloud refused this login (403): the account is not allowed on this server, or the token lacks the sync:write scope. Run `xtctx login` again." + : "Your xtctx cloud login is no longer valid (expired or signed out). Run `xtctx login`.", + ); + } +} + export interface DiffSyncResult { + /** Messages sent this run. */ syncedCount: number; - latestIndexedAt: string | null; + /** Sessions sent this run. */ + sessionCount: number; + /** Messages the server refused as too large this run; they are skipped from now on. */ + skipped: SkippedMessage[]; upToDate: boolean; + /** Another process was uploading this project; nothing was done. */ + busy: boolean; +} + +/** + * Changes from the last minute are sent again on the next run. A write to the + * index can be stamped before this run's read and committed after it; going + * back a minute picks those up, and re-sending is harmless because uploads are + * idempotent. + */ +const CURSOR_LAG_MS = 60_000; +/** With nothing to send, still tell the server the project is in sync this often. */ +const REPORT_EVERY_MS = 15 * 60_000; +/** Above this many message ids the per-session reconcile does not fit in one request. */ +const MAX_KEEP_IDS = Math.floor((UPLOAD_LIMITS.targetBodyBytes - 4096) / 70); + +interface SessionRow { + session_ref: string; + tool: string; + source_session_id: string; + git_branch: string | null; + git_commit: string | null; + started_at: string; + last_activity_at: string; + preview: string | null; +} + +interface MessageRow { + id: string; + timestamp: string; + role: string; + content: string; + message_index: number; + content_hash: string; + metadata_json: string; +} + +interface UploadMessage { + id: string; + timestamp: string; + role: string; + content: string; + messageIndex: number; + contentHash: string; + metadataJson: string; +} + +/** One session's slice of an upload. A session too big for one request goes as several. */ +interface Part { + session: SessionRow; + messages: UploadMessage[]; + /** On a session's final part when reconciling: everything else the cloud holds for it is deleted. */ + keepIds?: string[]; +} + +function canonicalRoot(projectDir: string): string { + try { + return realpathSync(projectDir); + } catch { + return projectDir; + } +} + +const skipKey = (sessionRef: string, messageId: string) => `${sessionRef}\u0000${messageId}`; +const sessionKey = (tool: string, sourceSessionId: string) => `${tool}:${sourceSessionId}`; + +function toUpload(row: MessageRow): UploadMessage { + return { + id: row.id, + timestamp: row.timestamp, + role: row.role, + content: fitContent(row.content), + messageIndex: row.message_index, + contentHash: row.content_hash, + metadataJson: sanitizeMetadata(row.metadata_json), + }; +} + +const messageBytes = (m: UploadMessage) => Buffer.byteLength(JSON.stringify(m)) + 1; +const SESSION_OVERHEAD = 1024; + +/** Split one session's messages into parts that each fit a request. */ +function partsFor(session: SessionRow, messages: UploadMessage[], keepIds?: string[]): Part[] { + const parts: Part[] = []; + let current: UploadMessage[] = []; + let bytes = SESSION_OVERHEAD; + for (const m of messages) { + const size = messageBytes(m); + if (current.length > 0 && (bytes + size > UPLOAD_LIMITS.targetBodyBytes || current.length >= UPLOAD_LIMITS.maxMessagesPerRequest)) { + parts.push({ session, messages: current }); + current = []; + bytes = SESSION_OVERHEAD; + } + current.push(m); + bytes += size; + } + if (current.length > 0 || parts.length === 0) parts.push({ session, messages: current }); + if (keepIds) parts.push({ session, messages: [], keepIds }); + return parts; +} + +const partBytes = (p: Part) => + SESSION_OVERHEAD + p.messages.reduce((n, m) => n + messageBytes(m), 0) + (p.keepIds?.length ?? 0) * 70; + +/** Group parts into requests, in order, each under every per-request limit. */ +function packRequests(parts: Part[]): Part[][] { + const requests: Part[][] = []; + let current: Part[] = []; + let bytes = 0; + let messages = 0; + for (const p of parts) { + const size = partBytes(p); + if ( + current.length > 0 && + (bytes + size > UPLOAD_LIMITS.targetBodyBytes || + current.length >= UPLOAD_LIMITS.maxSessionsPerRequest || + messages + p.messages.length > UPLOAD_LIMITS.maxMessagesPerRequest) + ) { + requests.push(current); + current = []; + bytes = 0; + messages = 0; + } + current.push(p); + bytes += size; + messages += p.messages.length; + } + if (current.length > 0) requests.push(current); + return requests; } /** - * Upload what this project's index has gained since the last upload. + * Upload what this project's index has gained or changed since the last + * upload, for the logged-in account. + * + * Only sessions the index attributes to THIS project are read, compared the + * same way the index's own reads compare roots: an index can hold rows for + * another root (a copied `.xtctx/`, a renamed folder), and those stay here. * - * Reads the project's own index, so only sessions already attributed to this - * project can leave the machine. Refuses unless someone is logged in AND this - * project is on the user's opt-in list; either alone uploads nothing. + * Each session's upload is authoritative. New and changed messages are sent + * keyed by their local id, and the server answers with how many it now holds + * for the session. When that differs from the local count (a re-read here + * deleted or replaced messages, or an earlier version uploaded under other + * ids), the whole session is sent again with the full list of its ids, and + * the server deletes everything else. Every request is idempotent. * - * The high-water mark is kept per account: after logging in as someone else - * the project has to be uploaded to them in full, not from where the previous - * account left off. + * Refuses unless someone is logged in AND this project is on the user's + * opt-in list. Only one process uploads a project at a time; another one + * finds it busy and does nothing. The position only moves after a run that + * sent everything, so a failure resends rather than skips. */ export async function runDiffSync(options: { projectDir: string }): Promise { const { projectDir } = options; - const credentials = await loadCredentials(); - if (!credentials) throw new NotLoggedInError(); + if (!(await loadCredentials())) throw new NotLoggedInError(); if (!(await isOptedIn(projectDir))) throw new NotOptedInError(); + const credentials = await resolveUploadCredentials(); + if (!credentials) throw new NotLoggedInError(); assertSecureSyncUrl(credentials.syncUrl); - const dbPath = join(projectDir, ".xtctx", "state", "xtctx.db"); - if (!existsSync(dbPath)) { - return { syncedCount: 0, latestIndexedAt: null, upToDate: true }; + const account = accountKey(credentials.user.id, credentials.syncUrl); + const lock = await acquireUploadLock(projectDir); + if (!lock) return { syncedCount: 0, sessionCount: 0, skipped: [], upToDate: false, busy: true }; + + const attemptAt = new Date().toISOString(); + try { + const outcome = await upload(projectDir, credentials, account, lock); + await updateAccountState(projectDir, account, (s) => ({ + ...s, + cursor: outcome.cursor, + lastAttemptAt: attemptAt, + lastSuccessAt: new Date().toISOString(), + lastError: undefined, + lastErrorAt: undefined, + lastReportedAt: outcome.reported ? new Date().toISOString() : s.lastReportedAt, + })); + return outcome.result; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await updateAccountState(projectDir, account, (s) => ({ + ...s, + lastAttemptAt: attemptAt, + lastError: message, + lastErrorAt: new Date().toISOString(), + })).catch(() => undefined); + throw err; + } finally { + await lock.release(); } +} + +async function upload( + projectDir: string, + credentials: SyncCredentials, + account: string, + lock: UploadLock, +): Promise<{ result: DiffSyncResult; cursor: string; reported: boolean }> { + const result: DiffSyncResult = { syncedCount: 0, sessionCount: 0, skipped: [], upToDate: true, busy: false }; + const state = await readAccountState(projectDir, account); + const cursor = state.cursor ?? ""; + const nextCursor = new Date(Date.now() - CURSOR_LAG_MS).toISOString(); - const db: DatabaseHandle = new Database(dbPath); + const dbPath = join(projectDir, ".xtctx", "state", "xtctx.db"); + if (!existsSync(dbPath)) return { result, cursor: nextCursor, reported: false }; + + const skipped = new Set(state.skipped.map((s) => skipKey(s.sessionRef, s.messageId))); + const { repoId } = await getCanonicalRepoId(projectDir); + const envelope = { + device: { id: credentials.deviceId, name: credentials.deviceName }, + project: { repoUrl: repoId, name: basename(projectDir) }, + }; + + const db: DatabaseHandle = new Database(dbPath, { readonly: true, fileMustExist: true }); try { - const key = `cloud_last_synced_indexed_at:${credentials.user.id}`; - const { repoId } = await getCanonicalRepoId(projectDir); - const projectName = basename(projectDir); - - // Position is (indexed_at, id), not indexed_at alone: a scan stamps many - // messages with the same time, and a batch boundary that fell inside such - // a run would skip the rest of it for good. - const saved = getSetting(db, key) ?? "1970-01-01T00:00:00.000Z|"; - const split = saved.indexOf("|"); - let cursorAt = saved.slice(0, split); - let cursorId = saved.slice(split + 1); - let totalSynced = 0; - - const query = db.prepare(` - SELECT m.id, m.session_ref, m.tool, m.source_session_id, m.timestamp, m.role, - m.content, m.message_index, m.content_hash, m.metadata_json, m.source_pointer, m.indexed_at, - s.git_branch, s.git_commit, s.preview - FROM messages m - JOIN sessions s ON m.session_ref = s.session_ref - WHERE (m.indexed_at, m.id) > (?, ?) - ORDER BY m.indexed_at ASC, m.id ASC - LIMIT ? - `); - - while (true) { - const rows = query.all(cursorAt, cursorId, BATCH_SIZE) as Array>; - if (rows.length === 0) break; - - const deltas = rows.map((r) => ({ - deviceId: credentials.deviceId, - deviceName: credentials.deviceName, - tool: r.tool, - sourceSessionId: r.source_session_id, - repoUrl: repoId, - // The folder name, not the path: the absolute one carries the user's - // account name and directory layout and the server has no use for it. - projectRoot: projectName, - gitBranch: r.git_branch || undefined, - gitCommit: r.git_commit || undefined, - timestamp: r.timestamp, - role: r.role, - content: r.content, - messageIndex: r.message_index, - contentHash: r.content_hash, - metadataJson: r.metadata_json, - sourcePointer: r.source_pointer, - preview: r.preview, - status: "active", - })); - - const res = await fetch(`${credentials.syncUrl.replace(/\/$/, "")}/api/stream`, { - method: "POST", - headers: { Authorization: `Bearer ${credentials.token}`, "Content-Type": "application/json" }, - body: JSON.stringify(deltas), - }); - if (!res.ok) { - throw new Error(`Sync batch failed (${res.status}): ${await res.text()}`); + const scopedRoot = normalizeRootForCompare(canonicalRoot(projectDir)); + const changed = db + .prepare( + `SELECT s.session_ref, s.tool, s.source_session_id, s.git_branch, s.git_commit, + s.started_at, s.last_activity_at, s.preview + FROM sessions s + WHERE ${PROJECT_ROOT_SQL.replace("project_root", "s.project_root")} = ? + AND (s.updated_at > ? + OR EXISTS (SELECT 1 FROM messages m WHERE m.session_ref = s.session_ref AND m.indexed_at > ?)) + ORDER BY s.last_activity_at ASC, s.session_ref ASC`, + ) + .all(scopedRoot, cursor, cursor) as SessionRow[]; + + const messagesSince = db.prepare( + `SELECT id, timestamp, role, content, message_index, content_hash, metadata_json + FROM messages WHERE session_ref = ? AND indexed_at > ? + ORDER BY timestamp ASC, message_index ASC, id ASC`, + ); + const localIds = (sessionRef: string) => + (db.prepare(`SELECT id FROM messages WHERE session_ref = ?`).all(sessionRef) as Array<{ id: string }>) + .map((r) => r.id) + .filter((id) => !skipped.has(skipKey(sessionRef, id))); + const unskipped = (sessionRef: string, rows: MessageRow[]) => + rows.filter((r) => !skipped.has(skipKey(sessionRef, r.id))).map(toUpload); + + let reported = false; + const send = async (parts: Part[]): Promise> => { + const counts = new Map(); + for (const request of packRequests(parts)) { + await sendRequest(request, counts); + reported = true; + await lock.touch(); } + return counts; + }; + + /** + * Send one request, skipping exactly the message the server refuses as + * too large and splitting a request it refuses as too big overall, so one + * oversized message never blocks the rest of the project. + */ + const sendRequest = async (parts: Part[], counts: Map): Promise => { + for (;;) { + const res = await callCloud(credentials, "POST", "/api/stream", { + ...envelope, + sessions: parts.map((p) => ({ + tool: p.session.tool, + sourceSessionId: p.session.source_session_id, + gitBranch: p.session.git_branch, + gitCommit: p.session.git_commit, + startedAt: p.session.started_at, + lastActivityAt: p.session.last_activity_at, + // What the server keeps; no more is sent than it stores. + preview: p.session.preview?.slice(0, 160) ?? null, + messages: p.messages, + ...(p.keepIds ? { keepIds: p.keepIds } : {}), + })), + }); + + if (res.ok) { + const body = (await res.json().catch(() => ({}))) as { + sessions?: Array<{ tool: string; sourceSessionId: string; messageCount: number }>; + }; + for (const s of body.sessions ?? []) counts.set(sessionKey(s.tool, s.sourceSessionId), s.messageCount); + for (const p of parts) result.syncedCount += p.messages.length; + return; + } - totalSynced += rows.length; - cursorAt = String(rows[rows.length - 1].indexed_at); - cursorId = String(rows[rows.length - 1].id); - setSetting(db, key, `${cursorAt}|${cursorId}`); + const detail = (await res.json().catch(() => ({}))) as Record; + if (res.status === 401 || res.status === 403) throw new CloudAuthError(res.status); + if (res.status === 426) { + throw new Error(`xtctx cloud needs a newer client: ${String(detail.detail ?? "update xtctx")}`); + } + if (res.status === 413 && detail.error === "message_too_large" && typeof detail.messageId === "string") { + const part = parts.find( + (p) => + p.session.tool === detail.tool && + p.session.source_session_id === detail.sourceSessionId && + p.messages.some((m) => m.id === detail.messageId), + ); + if (part) { + await skip(part, detail.messageId, `larger than the server accepts (${String(detail.limit)} bytes)`); + continue; + } + } + if (res.status === 413) { + if (parts.length > 1) { + const half = Math.ceil(parts.length / 2); + await sendRequest(parts.slice(0, half), counts); + await sendRequest(parts.slice(half), counts); + return; + } + const [only] = parts; + if (only.messages.length > 1) { + const half = Math.ceil(only.messages.length / 2); + await sendRequest([{ ...only, messages: only.messages.slice(0, half), keepIds: undefined }], counts); + await sendRequest([{ ...only, messages: only.messages.slice(half) }], counts); + return; + } + if (only.messages.length === 1) { + await skip(only, only.messages[0].id, "the request carrying it alone was larger than the server accepts"); + continue; + } + } + throw new Error(`Upload failed (${res.status}): ${String(detail.error ?? res.statusText)}`); + } + }; + + const skip = async (part: Part, messageId: string, reason: string) => { + const entry: SkippedMessage = { + sessionRef: part.session.session_ref, + messageId, + reason, + at: new Date().toISOString(), + }; + part.messages = part.messages.filter((m) => m.id !== messageId); + if (part.keepIds) part.keepIds = part.keepIds.filter((id) => id !== messageId); + skipped.add(skipKey(entry.sessionRef, messageId)); + result.skipped.push(entry); + await updateAccountState(projectDir, account, (s) => ({ ...s, skipped: [...s.skipped, entry] })); + }; + + // Pass 1: what changed, per session. + const firstPass: Part[] = []; + for (const session of changed) { + const rows = messagesSince.all(session.session_ref, cursor) as MessageRow[]; + firstPass.push(...partsFor(session, unskipped(session.session_ref, rows))); + } + const counts = await send(firstPass); + + // Pass 2: sessions whose cloud copy does not match, sent whole with their ids. + const reconcile: Part[] = []; + for (const session of changed) { + const ids = localIds(session.session_ref); + if (counts.get(sessionKey(session.tool, session.source_session_id)) === ids.length) continue; + const rows = db + .prepare( + `SELECT id, timestamp, role, content, message_index, content_hash, metadata_json + FROM messages WHERE session_ref = ? ORDER BY timestamp ASC, message_index ASC, id ASC`, + ) + .all(session.session_ref) as MessageRow[]; + // A session too large to name in one request is sent but not pruned. + reconcile.push(...partsFor(session, unskipped(session.session_ref, rows), ids.length <= MAX_KEEP_IDS ? ids : undefined)); + } + if (reconcile.length > 0) await send(reconcile); - if (rows.length < BATCH_SIZE) break; + // Nothing to send: still let the server know this project is in sync, + // now and then, so a reader can tell "synced, nothing new" from "never". + const stale = !state.lastReportedAt || Date.now() - Date.parse(state.lastReportedAt) > REPORT_EVERY_MS; + if (changed.length === 0 && stale) { + await sendRequest([], new Map()); + reported = true; } - return { syncedCount: totalSynced, latestIndexedAt: totalSynced ? cursorAt : null, upToDate: totalSynced === 0 }; + result.sessionCount = changed.length; + result.upToDate = result.syncedCount === 0 && result.skipped.length === 0; + return { result, cursor: nextCursor, reported }; } finally { db.close(); } diff --git a/src/sync/report.ts b/src/sync/report.ts new file mode 100644 index 0000000..e9492fd --- /dev/null +++ b/src/sync/report.ts @@ -0,0 +1,39 @@ +import { accountKey, readAccountState } from "./state.js"; +import { EnvCredentialsRefusedError, loadSavedCredentials, resolveUploadCredentials } from "./client.js"; +import { isOptedIn } from "./consent.js"; + +/** + * Cloud sync, as lines for a status report: whether this project uploads, + * as whom, and how the last attempt went. Reads files in ~/.xtctx only; it + * never calls the server. + */ +export async function describeCloudSync(projectRoot: string): Promise { + const optedIn = await isOptedIn(projectRoot); + const saved = await loadSavedCredentials(); + let effective = saved; + let envProblem: string | undefined; + try { + effective = await resolveUploadCredentials(); + } catch (err) { + if (err instanceof EnvCredentialsRefusedError) envProblem = err.message; + else throw err; + } + + if (!optedIn) { + return [`off for this project${effective ? ` (logged in as ${effective.user.username})` : ""}; \`xtctx sync enable\` turns it on`]; + } + if (envProblem) return ["on, but NOT uploading:", envProblem]; + if (!effective) return ["on, but NOT uploading: not logged in (run `xtctx login`)"]; + + const state = await readAccountState(projectRoot, accountKey(effective.user.id, effective.syncUrl)); + const lines = [`on; uploading as ${effective.user.username} to ${effective.syncUrl}, device "${effective.deviceName}"`]; + lines.push(`last upload: ${state.lastSuccessAt ?? "never"}`); + if (state.lastError && (!state.lastSuccessAt || (state.lastErrorAt ?? "") > state.lastSuccessAt)) { + lines.push(`last attempt FAILED at ${state.lastErrorAt}: ${state.lastError}`); + } + if (state.skipped.length > 0) { + lines.push(`${state.skipped.length} message(s) skipped as too large for the server:`); + for (const s of state.skipped.slice(-5)) lines.push(` ${s.sessionRef} ${s.messageId}: ${s.reason}`); + } + return lines; +} diff --git a/src/sync/state.ts b/src/sync/state.ts new file mode 100644 index 0000000..bb23078 --- /dev/null +++ b/src/sync/state.ts @@ -0,0 +1,176 @@ +import { createHash } from "node:crypto"; +import { mkdir, open, readFile, stat, unlink, utimes } from "node:fs/promises"; +import { join } from "node:path"; +import { writeFileAtomic } from "../utils/atomic-file.js"; +import { projectKey, xtctxHome } from "./consent.js"; + +/** + * Upload bookkeeping, per project and per account, in the user's home. + * + * Not in the project's index: the index is derived data that gets set aside + * and rebuilt, and a `.xtctx/` copied into another folder would carry another + * project's position with it. Keyed by account (user and server) so that + * logging in as someone else uploads the project to them in full. + */ + +export interface SkippedMessage { + /** The local session_ref, "tool:sourceSessionId". */ + sessionRef: string; + messageId: string; + reason: string; + at: string; +} + +export interface AccountSyncState { + /** Messages and sessions changed after this local `indexed_at`/`updated_at` are still to upload. */ + cursor?: string; + lastAttemptAt?: string; + lastSuccessAt?: string; + lastError?: string; + lastErrorAt?: string; + /** When the server was last told this project is in sync, even with nothing to send. */ + lastReportedAt?: string; + /** Messages the server refused as too large; never sent again, and left out of the count. */ + skipped: SkippedMessage[]; +} + +interface ProjectSyncState { + projectRoot: string; + accounts: Record; +} + +function syncDir(): string { + return join(xtctxHome(), "sync"); +} + +async function fileStem(projectRoot: string): Promise { + return createHash("sha256").update(await projectKey(projectRoot)).digest("hex").slice(0, 24); +} + +export function accountKey(userId: string, syncUrl: string): string { + return `${userId}@${syncUrl.replace(/\/+$/, "")}`; +} + +async function readProjectState(projectRoot: string): Promise { + try { + const parsed = JSON.parse(await readFile(join(syncDir(), `${await fileStem(projectRoot)}.json`), "utf-8")); + if (parsed && typeof parsed === "object" && parsed.accounts && typeof parsed.accounts === "object") { + return parsed as ProjectSyncState; + } + } catch { + // none yet, or unreadable: start clean, which at worst re-uploads (harmless) + } + return { projectRoot, accounts: {} }; +} + +export async function readAccountState(projectRoot: string, account: string): Promise { + const state = await readProjectState(projectRoot); + const found = state.accounts[account]; + return { ...found, skipped: Array.isArray(found?.skipped) ? found.skipped : [] }; +} + +/** Every account's state for this project, for status output. */ +export async function readAllAccountStates(projectRoot: string): Promise> { + return (await readProjectState(projectRoot)).accounts; +} + +/** + * Change one account's state. The cursor only ever moves forward, whatever + * the update says: two writers racing must not hand a later run an earlier + * position (which would re-upload) or, worse, a run with stale data a later + * one (which would skip). + */ +export async function updateAccountState( + projectRoot: string, + account: string, + update: (current: AccountSyncState) => AccountSyncState, +): Promise { + const state = await readProjectState(projectRoot); + const current = { ...state.accounts[account], skipped: state.accounts[account]?.skipped ?? [] }; + const next = update(current); + if (current.cursor && (!next.cursor || next.cursor < current.cursor)) next.cursor = current.cursor; + state.projectRoot = projectRoot; + state.accounts[account] = next; + await mkdir(syncDir(), { recursive: true }); + await writeFileAtomic(join(syncDir(), `${await fileStem(projectRoot)}.json`), JSON.stringify(state, null, 2), { + mode: 0o600, + }); + return next; +} + +/** A lock not touched for this long belongs to a process that died mid-upload. */ +export const LOCK_STALE_MS = 10 * 60 * 1000; + +export interface UploadLock { + /** Mark the lock as still in use, during a long upload. */ + touch(): Promise; + release(): Promise; +} + +function alive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err) { + // EPERM: it exists, it is just not ours to signal. + return (err as NodeJS.ErrnoException).code === "EPERM"; + } +} + +/** + * One uploader per project at a time, across every process on the machine. + * + * Every MCP server process for an opted-in project syncs on a timer, and two + * agents open in one project means two servers. Null when another live + * process holds the lock; a lock left by a process that is gone, or not + * touched in LOCK_STALE_MS, is taken over. + */ +export async function acquireUploadLock(projectRoot: string): Promise { + await mkdir(syncDir(), { recursive: true }); + const path = join(syncDir(), `${await fileStem(projectRoot)}.lock`); + const token = `${process.pid}:${Date.now()}:${Math.random()}`; + + const create = async (): Promise => { + try { + const handle = await open(path, "wx", 0o600); + await handle.writeFile(JSON.stringify({ pid: process.pid, token })); + await handle.close(); + return true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EEXIST") return false; + throw err; + } + }; + + if (!(await create())) { + let stale = false; + try { + const [info, raw] = await Promise.all([stat(path), readFile(path, "utf-8")]); + const holder = JSON.parse(raw) as { pid?: number }; + stale = Date.now() - info.mtimeMs > LOCK_STALE_MS || (typeof holder.pid === "number" && !alive(holder.pid)); + } catch { + // Unreadable or half-written by a creator that just won: leave it; the + // next tick looks again. + return null; + } + if (!stale) return null; + await unlink(path).catch(() => undefined); + // Two processes taking over at once: one create wins, the other is busy. + if (!(await create())) return null; + } + + return { + async touch() { + const now = new Date(); + await utimes(path, now, now).catch(() => undefined); + }, + async release() { + try { + const holder = JSON.parse(await readFile(path, "utf-8")) as { token?: string }; + if (holder.token === token) await unlink(path); + } catch { + // already gone + } + }, + }; +} diff --git a/src/sync/upload-format.ts b/src/sync/upload-format.ts new file mode 100644 index 0000000..cd31f20 --- /dev/null +++ b/src/sync/upload-format.ts @@ -0,0 +1,83 @@ +/** + * What a message looks like on its way to xtctx cloud. + * + * The limits mirror the server's (cloud/src/db.ts `LIMITS`). The client stays + * under them on its own, so a refusal only happens when the two disagree, + * and then the sync skips the one message and carries on. + */ +export const UPLOAD_LIMITS = { + maxBodyBytes: 1024 * 1024, + maxMessageBytes: 64 * 1024, + maxMetadataBytes: 4 * 1024, + maxSessionsPerRequest: 10, + maxMessagesPerRequest: 500, + /** Requests are packed to this, leaving room for the envelope under maxBodyBytes. */ + targetBodyBytes: 768 * 1024, +} as const; + +/** + * Metadata fields that may leave the machine. Everything else is dropped: + * `sourcePath` (an absolute transcript or working-directory path), + * `referencedFiles` (paths), and anything a scraper adds later until it is + * looked at and listed here. + */ +const UPLOADED_METADATA_KEYS = [ + "messageIndex", + "tokenEstimate", + "toolCalls", + "toolName", + "model", + "stepType", + "artifactType", + "artifactName", + "sessionType", + "approvalMode", + "sandboxed", + "layer", + "costUsd", + "gitBranch", + "gitCommit", +] as const; + +export const UPLOADED_METADATA_FIELDS: readonly string[] = UPLOADED_METADATA_KEYS; + +const ABSOLUTE_PATH = /^(?:[a-zA-Z]:[\\/]|\\\\|\/|~[\\/]|file:)/; + +function safeValue(value: unknown): unknown { + if (typeof value === "string") return ABSOLUTE_PATH.test(value.trim()) ? undefined : value; + if (typeof value === "number" || typeof value === "boolean") return value; + if (Array.isArray(value)) { + const kept = value.map(safeValue).filter((v) => v !== undefined && typeof v !== "object"); + return kept.length > 0 ? kept : undefined; + } + return undefined; // nested objects are not uploaded +} + +/** The stored metadata JSON reduced to UPLOADED_METADATA_FIELDS, with no absolute paths. */ +export function sanitizeMetadata(json: string | null | undefined): string { + let parsed: unknown; + try { + parsed = JSON.parse(json ?? "{}"); + } catch { + return "{}"; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return "{}"; + const out: Record = {}; + for (const key of UPLOADED_METADATA_KEYS) { + const value = safeValue((parsed as Record)[key]); + if (value !== undefined) out[key] = value; + } + const text = JSON.stringify(out); + return Buffer.byteLength(text) > UPLOAD_LIMITS.maxMetadataBytes ? "{}" : text; +} + +/** Content cut to the server's per-message limit, with a marker saying so. */ +export function fitContent(content: string): string { + const bytes = Buffer.byteLength(content); + if (bytes <= UPLOAD_LIMITS.maxMessageBytes) return content; + const marker = `\n\n[xtctx: truncated for upload; the original is ${bytes} bytes]`; + // A few bytes of slack: cutting inside a multi-byte character leaves a + // replacement character, which can be longer than what it replaced. + const keep = UPLOAD_LIMITS.maxMessageBytes - Buffer.byteLength(marker) - 4; + return Buffer.from(content, "utf-8").subarray(0, keep).toString("utf-8") + marker; +} diff --git a/tests/sync/auto-sync.test.ts b/tests/sync/auto-sync.test.ts index f526496..6f40a2e 100644 --- a/tests/sync/auto-sync.test.ts +++ b/tests/sync/auto-sync.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { startAutoSync } from "@xtctx/sync/auto-sync"; import { NotOptedInError, type DiffSyncResult } from "@xtctx/sync/diff-sync"; -const result = (n: number): DiffSyncResult => ({ syncedCount: n, latestIndexedAt: null, upToDate: n === 0 }); +const result = (n: number): DiffSyncResult => ({ syncedCount: n, sessionCount: n ? 1 : 0, skipped: [], upToDate: n === 0, busy: false }); describe("auto sync", () => { beforeEach(() => vi.useFakeTimers()); diff --git a/tests/sync/diff-sync.test.ts b/tests/sync/diff-sync.test.ts index c8f99cc..23b4074 100644 --- a/tests/sync/diff-sync.test.ts +++ b/tests/sync/diff-sync.test.ts @@ -5,80 +5,198 @@ * version of this feature selected a column the index does not have, so it * could never have uploaded anything; only a real database shows that. */ -import { existsSync } from "node:fs"; -import { mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { hostname } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { saveCredentials, type SyncCredentials } from "@xtctx/sync/client"; +import { EnvCredentialsRefusedError, saveCredentials, type SyncCredentials } from "@xtctx/sync/client"; import { setOptedIn } from "@xtctx/sync/consent"; -import { NotOptedInError, runDiffSync } from "@xtctx/sync/diff-sync"; -import { sandbox, seedIndex } from "./helpers"; +import { CloudAuthError, NotOptedInError, runDiffSync } from "@xtctx/sync/diff-sync"; +import { accountKey, acquireUploadLock, readAccountState, updateAccountState } from "@xtctx/sync/state"; +import { UPLOAD_LIMITS } from "@xtctx/sync/upload-format"; +import { editIndex, fakeCloud, localId, sandbox, seedIndex } from "./helpers"; -const creds = (id = "github:1"): SyncCredentials => ({ +const creds = (id = "github:1", over: Partial = {}): SyncCredentials => ({ token: "tok", user: { id, username: "u" }, deviceId: "dev", - deviceName: "laptop", + deviceName: "device-abc123", syncUrl: "https://sync.test", + ...over, }); describe("diff sync", () => { let box: ReturnType; - let uploads: Array>>; + let cloud: ReturnType; beforeEach(() => { box = sandbox(); - uploads = []; - vi.stubGlobal( - "fetch", - vi.fn(async (_url: string, init: RequestInit) => { - uploads.push(JSON.parse(String(init.body))); - return Response.json({ success: true }); - }), - ); + cloud = fakeCloud(); + vi.stubGlobal("fetch", cloud.fetch); }); afterEach(() => { vi.unstubAllGlobals(); box.cleanup(); }); + const optedIn = async (c = creds()) => { + await saveCredentials(c); + await setOptedIn(box.project, true); + }; + const state = () => readAccountState(box.project, accountKey("github:1", "https://sync.test")); + it("uploads nothing for a logged-in user whose project is not opted in", async () => { await saveCredentials(creds()); seedIndex(box.project, 3); - await expect(runDiffSync({ projectDir: box.project })).rejects.toBeInstanceOf(NotOptedInError); - expect(uploads).toEqual([]); + expect(cloud.requests).toEqual([]); }); it("uploads nothing when only XTCTX_TOKEN is set", async () => { vi.stubEnv("XTCTX_TOKEN", "a.b.c"); seedIndex(box.project, 3); - await expect(runDiffSync({ projectDir: box.project })).rejects.toBeInstanceOf(NotOptedInError); - expect(uploads).toEqual([]); + expect(cloud.requests).toEqual([]); }); it("uploads an opted-in project, and only what is new on the next run", async () => { - await saveCredentials(creds()); - await setOptedIn(box.project, true); + await optedIn(); seedIndex(box.project, 3); expect((await runDiffSync({ projectDir: box.project })).syncedCount).toBe(3); expect((await runDiffSync({ projectDir: box.project })).syncedCount).toBe(0); - seedIndex(box.project, 2, "2026-10-01T00:00:05.000Z", 3); + // Stamped when written, as the scanner does. + seedIndex(box.project, 2, new Date().toISOString(), 3); expect((await runDiffSync({ projectDir: box.project })).syncedCount).toBe(2); - expect(uploads.map((b) => b.length)).toEqual([3, 2]); + expect(cloud.stored()).toHaveLength(5); + expect(cloud.uploads()[0].headers.get("X-Xtctx-Client")).toMatch(/^xtctx\/\d/); }); - it("does not skip messages that share a timestamp across a batch boundary", async () => { - await saveCredentials(creds()); - await setOptedIn(box.project, true); - seedIndex(box.project, 250); + it("does not advance past a failed upload: the next run sends it all", async () => { + await optedIn(); + seedIndex(box.project, 3); + cloud.state.failStatus = 500; - expect((await runDiffSync({ projectDir: box.project })).syncedCount).toBe(250); - const sent = new Set(uploads.flat().map((d) => d.messageIndex)); - expect(sent.size).toBe(250); + await expect(runDiffSync({ projectDir: box.project })).rejects.toThrow(/500/); + const failed = await state(); + expect(failed.cursor).toBeUndefined(); + expect(failed.lastError).toMatch(/500/); + + cloud.state.failStatus = undefined; + expect((await runDiffSync({ projectDir: box.project })).syncedCount).toBe(3); + const ok = await state(); + expect(ok.lastError).toBeUndefined(); + expect(ok.lastSuccessAt).toBeTruthy(); + }); + + it("makes a local deletion reach the cloud", async () => { + await optedIn(); + seedIndex(box.project, 3); + await runDiffSync({ projectDir: box.project }); + expect(cloud.stored()).toHaveLength(3); + + // A re-read dropped a message and touched the session. + editIndex(box.project, (db) => { + db.prepare("DELETE FROM messages WHERE id = ?").run(localId(2)); + db.prepare("UPDATE sessions SET updated_at = ?").run(new Date().toISOString()); + }); + await runDiffSync({ projectDir: box.project }); + + expect(cloud.stored().map((m) => m.id).sort()).toEqual([localId(0), localId(1)]); + }); + + it("replaces a message whose content changed at the same position instead of duplicating it", async () => { + await optedIn(); + seedIndex(box.project, 2); + await runDiffSync({ projectDir: box.project }); + + // The scanner's re-read: the old row goes, the new one (new id) comes in. + editIndex(box.project, (db) => db.prepare("DELETE FROM messages WHERE id = ?").run(localId(1))); + seedIndex(box.project, 1, { from: 1, indexedAt: new Date().toISOString(), content: () => "message 1, edited" }); + // Same index 1 but a fresh id would collide with localId(1); give it another. + editIndex(box.project, (db) => db.prepare("UPDATE messages SET id = ? WHERE content = 'message 1, edited'").run(localId(99))); + + await runDiffSync({ projectDir: box.project }); + expect(cloud.stored().map((m) => m.content).sort()).toEqual(["message 0", "message 1, edited"]); + }); + + it("uploads only the sessions the index attributes to this project, compared the way the index compares roots", async () => { + await optedIn(); + // Stored with a trailing separator and in another case: the same root to the index. + const sameRoot = (process.platform === "win32" ? box.project.toUpperCase() : box.project) + "/"; + seedIndex(box.project, 2, { projectRoot: sameRoot }); + seedIndex(box.project, 2, { sessionId: "elsewhere", projectRoot: join(box.root, "some-other-project") }); + + await runDiffSync({ projectDir: box.project }); + + expect(cloud.stored("s1")).toHaveLength(2); + expect(cloud.stored("elsewhere")).toHaveLength(0); + expect(JSON.stringify(cloud.requests)).not.toContain("elsewhere"); + }); + + it("sends no absolute paths, source pointers or hostname", async () => { + await optedIn(); + seedIndex(box.project, 1, { + sourcePointer: "/home/alice/.gemini/antigravity/brain/x/task.md", + metadata: { + messageIndex: 0, + toolName: "run_command", + sourcePath: "C:\\Users\\alice\\repo", + referencedFiles: ["/home/alice/repo/a.ts"], + artifactName: "/home/alice/notes.md", + model: "gemini", + }, + }); + + await runDiffSync({ projectDir: box.project }); + + const sent = JSON.stringify(cloud.uploads()); + expect(sent).not.toContain("alice"); + expect(sent).not.toContain("sourcePointer"); + expect(sent).not.toContain(hostname()); + expect(JSON.parse(cloud.stored()[0].metadataJson)).toEqual({ messageIndex: 0, toolName: "run_command", model: "gemini" }); + expect(cloud.uploads()[0].body.project.name).toBe("project"); + expect(cloud.uploads()[0].body.device.name).toBe("device-abc123"); + }); + + it("truncates a message over the server's limit, with a marker", async () => { + await optedIn(); + seedIndex(box.project, 1, { content: () => "x".repeat(UPLOAD_LIMITS.maxMessageBytes * 2) }); + await runDiffSync({ projectDir: box.project }); + const content = cloud.stored()[0].content; + expect(Buffer.byteLength(content)).toBeLessThanOrEqual(UPLOAD_LIMITS.maxMessageBytes); + expect(content).toContain("[xtctx: truncated for upload"); + }); + + it("skips and records a message the server refuses as too large, and the rest still uploads", async () => { + await optedIn(); + seedIndex(box.project, 3); + cloud.state.tooLarge.add(localId(1)); + + const result = await runDiffSync({ projectDir: box.project }); + + expect(result.skipped.map((s) => s.messageId)).toEqual([localId(1)]); + expect(cloud.stored().map((m) => m.id).sort()).toEqual([localId(0), localId(2)]); + expect((await state()).skipped).toEqual([expect.objectContaining({ messageId: localId(1), sessionRef: "claude-code:s1" })]); + + // Not offered again, and not counted against the session. + const before = cloud.sentMessages().length; + editIndex(box.project, (db) => db.prepare("UPDATE sessions SET updated_at = ?").run(new Date().toISOString())); + await runDiffSync({ projectDir: box.project }); + expect(cloud.sentMessages().slice(before).map((m) => m.id)).not.toContain(localId(1)); + }); + + it("splits a large backlog across requests that stay inside the limits", async () => { + await optedIn(); + seedIndex(box.project, 1200, { content: (i) => `message ${i} ${"y".repeat(1500)}` }); + await runDiffSync({ projectDir: box.project }); + expect(cloud.stored()).toHaveLength(1200); + for (const r of cloud.uploads()) { + expect(Buffer.byteLength(JSON.stringify(r.body))).toBeLessThanOrEqual(UPLOAD_LIMITS.maxBodyBytes); + const messages = r.body.sessions.reduce((n, s) => n + s.messages.length, 0); + expect(messages).toBeLessThanOrEqual(UPLOAD_LIMITS.maxMessagesPerRequest); + } }); it("starts over for a different account", async () => { @@ -91,27 +209,45 @@ describe("diff sync", () => { expect((await runDiffSync({ projectDir: box.project })).syncedCount).toBe(3); }); - it("sends the folder name, not the absolute path, and writes nothing into the project", async () => { - await saveCredentials(creds()); - await setOptedIn(box.project, true); + it("writes nothing into the project, and keeps its position outside the index", async () => { + await optedIn(); seedIndex(box.project, 1); - const before = existsSync(join(box.project, ".xtctx", "project.id")); + await runDiffSync({ projectDir: box.project }); + expect(existsSync(join(box.project, ".xtctx", "project.id"))).toBe(false); + expect(String(cloud.uploads()[0].body.project.repoUrl)).toMatch(/^local:[0-9a-f]{16}$/); + expect((await state()).cursor).toBeTruthy(); + }); + it("tells the server a project with nothing new is in sync, but not on every run", async () => { + await optedIn(); + seedIndex(box.project, 1); await runDiffSync({ projectDir: box.project }); + const after = cloud.uploads().length; + await runDiffSync({ projectDir: box.project }); + expect(cloud.uploads().length).toBe(after); // reported moments ago - expect(uploads[0][0].projectRoot).toBe("project"); - expect(String(uploads[0][0].repoUrl)).toMatch(/^local:[0-9a-f]{16}$/); - expect(before).toBe(false); - expect(existsSync(join(box.project, ".xtctx", "project.id"))).toBe(false); + await updateAccountState(box.project, accountKey("github:1", "https://sync.test"), (s) => ({ + ...s, + lastReportedAt: "2026-01-01T00:00:00.000Z", + })); + await runDiffSync({ projectDir: box.project }); + expect(cloud.uploads().length).toBe(after + 1); + expect(cloud.uploads().at(-1)!.body.sessions).toEqual([]); }); it("refuses to send a token over plain http to a remote host", async () => { - await saveCredentials({ ...creds(), syncUrl: "http://example.com" }); - await setOptedIn(box.project, true); + await optedIn(creds("github:1", { syncUrl: "http://example.com" })); seedIndex(box.project, 1); - await expect(runDiffSync({ projectDir: box.project })).rejects.toThrow(/https/); - expect(uploads).toEqual([]); + expect(cloud.requests).toEqual([]); + }); + + it("says to log in again when the server refuses the token", async () => { + await optedIn(); + seedIndex(box.project, 1); + cloud.state.failStatus = 401; + await expect(runDiffSync({ projectDir: box.project })).rejects.toBeInstanceOf(CloudAuthError); + expect((await state()).lastError).toMatch(/xtctx login/); }); it("an opt-in for one project does not cover another", async () => { @@ -121,7 +257,101 @@ describe("diff sync", () => { writeFileSync(join(other, "marker"), ""); await setOptedIn(other, true); seedIndex(box.project, 1); - await expect(runDiffSync({ projectDir: box.project })).rejects.toBeInstanceOf(NotOptedInError); }); }); + +describe("environment credentials", () => { + let box: ReturnType; + let cloud: ReturnType; + beforeEach(async () => { + box = sandbox(); + cloud = fakeCloud(); + vi.stubGlobal("fetch", cloud.fetch); + await saveCredentials(creds()); + await setOptedIn(box.project, true); + seedIndex(box.project, 1); + }); + afterEach(() => { + vi.unstubAllGlobals(); + box.cleanup(); + }); + + it("refuses to upload with an XTCTX_TOKEN that differs from the saved login", async () => { + vi.stubEnv("XTCTX_TOKEN", "someone-elses.token.x"); + await expect(runDiffSync({ projectDir: box.project })).rejects.toBeInstanceOf(EnvCredentialsRefusedError); + expect(cloud.requests).toEqual([]); + }); + + it("refuses to send the saved token to an XTCTX_SYNC_URL that differs from the saved one", async () => { + vi.stubEnv("XTCTX_SYNC_URL", "https://collector.example"); + await expect(runDiffSync({ projectDir: box.project })).rejects.toBeInstanceOf(EnvCredentialsRefusedError); + expect(cloud.requests).toEqual([]); + }); + + it("uploads with them when XTCTX_ALLOW_ENV_CREDENTIALS=1 says that is intended", async () => { + vi.stubEnv("XTCTX_SYNC_URL", "https://other.example"); + vi.stubEnv("XTCTX_ALLOW_ENV_CREDENTIALS", "1"); + await runDiffSync({ projectDir: box.project }); + expect(cloud.uploads()[0].url).toBe("https://other.example/api/stream"); + }); + + it("is fine with env values that match the saved login", async () => { + vi.stubEnv("XTCTX_TOKEN", "tok"); + vi.stubEnv("XTCTX_SYNC_URL", "https://sync.test/"); + expect((await runDiffSync({ projectDir: box.project })).syncedCount).toBe(1); + }); +}); + +describe("one uploader per project", () => { + let box: ReturnType; + let cloud: ReturnType; + beforeEach(async () => { + box = sandbox(); + cloud = fakeCloud(); + vi.stubGlobal("fetch", cloud.fetch); + await saveCredentials(creds()); + await setOptedIn(box.project, true); + seedIndex(box.project, 2); + }); + afterEach(() => { + vi.unstubAllGlobals(); + box.cleanup(); + }); + + it("does nothing while another live process holds the lock", async () => { + const held = await acquireUploadLock(box.project); + expect(held).not.toBeNull(); + const result = await runDiffSync({ projectDir: box.project }); + expect(result.busy).toBe(true); + expect(cloud.requests).toEqual([]); + await held!.release(); + expect((await runDiffSync({ projectDir: box.project })).syncedCount).toBe(2); + }); + + it("takes over a lock left by a process that is gone", async () => { + const { join: j } = await import("node:path"); + const { createHash } = await import("node:crypto"); + const { realpathSync } = await import("node:fs"); + const key = process.platform === "win32" ? realpathSync(box.project).toLowerCase() : realpathSync(box.project); + const lockPath = j(box.home, ".xtctx", "sync", `${createHash("sha256").update(key).digest("hex").slice(0, 24)}.lock`); + mkdirSync(j(box.home, ".xtctx", "sync"), { recursive: true }); + writeFileSync(lockPath, JSON.stringify({ pid: 2 ** 22 + 12345, token: "dead" })); + + expect((await runDiffSync({ projectDir: box.project })).syncedCount).toBe(2); + expect(existsSync(lockPath)).toBe(false); + }); + + it("never moves the position backwards", async () => { + const account = accountKey("github:1", "https://sync.test"); + await updateAccountState(box.project, account, (s) => ({ ...s, cursor: "2026-10-01T12:00:00.000Z" })); + await updateAccountState(box.project, account, (s) => ({ ...s, cursor: "2026-10-01T11:00:00.000Z" })); + expect((await readAccountState(box.project, account)).cursor).toBe("2026-10-01T12:00:00.000Z"); + }); + + it("runs two concurrent syncs as one upload", async () => { + const [a, b] = await Promise.all([runDiffSync({ projectDir: box.project }), runDiffSync({ projectDir: box.project })]); + expect([a.busy, b.busy].filter(Boolean)).toHaveLength(1); + expect(cloud.stored()).toHaveLength(2); + }); +}); diff --git a/tests/sync/helpers.ts b/tests/sync/helpers.ts index 1a6f797..005178c 100644 --- a/tests/sync/helpers.ts +++ b/tests/sync/helpers.ts @@ -15,8 +15,11 @@ export function sandbox() { mkdirSync(home); mkdirSync(project); for (const name of ["HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA"]) vi.stubEnv(name, home); - vi.stubEnv("XTCTX_TOKEN", ""); + for (const name of ["XTCTX_TOKEN", "XTCTX_SYNC_URL", "XTCTX_ALLOW_ENV_CREDENTIALS", "XTCTX_DEVICE_ID", "XTCTX_DEVICE_NAME"]) { + vi.stubEnv(name, ""); + } return { + root, home, project, cleanup() { @@ -26,27 +29,138 @@ export function sandbox() { }; } -/** Add `count` messages to the project's real index, all stamped with the same time. */ -export function seedIndex(project: string, count: number, indexedAt = "2026-10-01T00:00:00.000Z", from = 0) { +export const localId = (i: number) => i.toString(16).padStart(64, "0"); + +interface SeedOptions { + indexedAt?: string; + from?: number; + sessionId?: string; + projectRoot?: string; + metadata?: Record; + sourcePointer?: string | null; + content?: (i: number) => string; +} + +/** Add `count` messages to one session of the project's real index, all stamped with the same time. */ +export function seedIndex(project: string, count: number, indexedAtOrOptions: string | SeedOptions = {}, fromArg?: number) { + const options: SeedOptions = + typeof indexedAtOrOptions === "string" ? { indexedAt: indexedAtOrOptions, from: fromArg } : indexedAtOrOptions; + const indexedAt = options.indexedAt ?? "2026-10-01T00:00:00.000Z"; + const from = options.from ?? 0; + const sessionId = options.sessionId ?? "s1"; + const sessionRef = `claude-code:${sessionId}`; mkdirSync(join(project, ".xtctx", "state"), { recursive: true }); const db = openDatabase(join(project, ".xtctx", "state", "xtctx.db")); try { db.prepare( - `INSERT OR IGNORE INTO sessions (session_ref, tool, source_session_id, project_root, started_at, last_activity_at, updated_at) - VALUES ('claude-code:s1', 'claude-code', 's1', ?, ?, ?, ?)`, - ).run(project, indexedAt, indexedAt, indexedAt); + `INSERT INTO sessions (session_ref, tool, source_session_id, project_root, started_at, last_activity_at, updated_at) + VALUES (?, 'claude-code', ?, ?, ?, ?, ?) + ON CONFLICT(session_ref) DO UPDATE SET updated_at = excluded.updated_at, last_activity_at = excluded.last_activity_at`, + ).run(sessionRef, sessionId, options.projectRoot ?? project, indexedAt, indexedAt, indexedAt); const insert = db.prepare( - `INSERT INTO messages (id, session_ref, tool, source_session_id, timestamp, role, content, message_index, content_hash, metadata_json, indexed_at) - VALUES (?, 'claude-code:s1', 'claude-code', 's1', ?, 'user', ?, ?, ?, '{}', ?)`, + `INSERT INTO messages (id, session_ref, tool, source_session_id, timestamp, role, content, message_index, content_hash, metadata_json, source_pointer, indexed_at) + VALUES (?, ?, 'claude-code', ?, ?, 'user', ?, ?, ?, ?, ?, ?)`, ); for (let i = from; i < from + count; i++) { - insert.run(`m${String(i).padStart(5, "0")}`, indexedAt, `message ${i}`, i, `hash${i}`, indexedAt); + insert.run( + localId(i + (sessionId === "s1" ? 0 : 1_000_000)), + sessionRef, + sessionId, + indexedAt, + options.content ? options.content(i) : `message ${i}`, + i, + `hash${i}`, + JSON.stringify(options.metadata ?? {}), + options.sourcePointer ?? null, + indexedAt, + ); } } finally { db.close(); } } +/** Run SQL against the project's index, as a re-read by the scanner would. */ +export function editIndex(project: string, fn: (db: ReturnType) => void) { + const db = openDatabase(join(project, ".xtctx", "state", "xtctx.db")); + try { + fn(db); + } finally { + db.close(); + } +} + +interface StoredMessage { + id: string; + content: string; + metadataJson: string; +} + +interface UploadSession { + tool: string; + sourceSessionId: string; + messages: StoredMessage[]; + keepIds?: string[]; +} + +interface UploadBody { + device: { id: string; name: string }; + project: { repoUrl: string; name: string }; + sessions: UploadSession[]; +} + +/** + * The server's upload contract, in memory: messages upserted by id per + * session, `keepIds` deleting the rest, and the session's count in reply. + * Mirrors cloud/src/db.ts `ingestUpload`; the Worker's own tests cover that. + */ +export function fakeCloud() { + const sessions = new Map>(); + const requests: Array<{ url: string; method: string; headers: Headers; body: UploadBody }> = []; + const state = { + failStatus: undefined as number | undefined, + tooLarge: new Set(), + }; + + const fetchImpl = vi.fn(async (url: string, init: RequestInit = {}) => { + const headers = new Headers(init.headers); + const body = (init.body ? JSON.parse(String(init.body)) : undefined) as UploadBody; + requests.push({ url: String(url), method: init.method ?? "GET", headers, body }); + if (state.failStatus) return Response.json({ error: "boom" }, { status: state.failStatus }); + + for (const s of body?.sessions ?? []) { + for (const m of s.messages) { + if (state.tooLarge.has(m.id)) { + return Response.json( + { error: "message_too_large", limit: 65536, tool: s.tool, sourceSessionId: s.sourceSessionId, messageId: m.id }, + { status: 413 }, + ); + } + } + } + const counts = []; + for (const s of body?.sessions ?? []) { + const key = `${s.tool}:${s.sourceSessionId}`; + const stored = sessions.get(key) ?? new Map(); + sessions.set(key, stored); + for (const m of s.messages) stored.set(m.id, m); + if (s.keepIds) for (const id of [...stored.keys()]) if (!s.keepIds.includes(id)) stored.delete(id); + counts.push({ tool: s.tool, sourceSessionId: s.sourceSessionId, messageCount: stored.size }); + } + return Response.json({ success: true, sessions: counts }); + }); + + return { + fetch: fetchImpl, + sessions, + requests, + state, + uploads: () => requests.filter((r) => r.url.endsWith("/api/stream")), + sentMessages: () => requests.flatMap((r) => (r.body?.sessions ?? []).flatMap((s) => s.messages)), + stored: (sessionId = "s1") => [...(sessions.get(`claude-code:${sessionId}`)?.values() ?? [])], + }; +} + export function loginFile(home: string): string { return join(home, ".xtctx", "credentials.json"); } diff --git a/tests/sync/login.test.ts b/tests/sync/login.test.ts index 56d69a7..8abf9a2 100644 --- a/tests/sync/login.test.ts +++ b/tests/sync/login.test.ts @@ -1,4 +1,5 @@ -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; +import { hostname } from "node:os"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { runLogin, runLogout } from "@xtctx/cli/login"; import { getCredentialsPath, saveCredentials } from "@xtctx/sync/client"; @@ -69,6 +70,38 @@ describe("login and logout", () => { expect(existsSync(getCredentialsPath())).toBe(false); }); + it("names the device with a random label, not the hostname, unless told otherwise", async () => { + vi.stubGlobal("fetch", vi.fn(async (url: string) => Response.json(String(url).endsWith("/code") ? code : { token: "tok", user }))); + const { wait } = clock(); + await runLogin({ syncUrl: "https://sync.test", wait }); + const saved = JSON.parse(readFileSync(getCredentialsPath(), "utf-8")); + expect(saved.deviceName).toMatch(/^device-[0-9a-f]{6}$/); + expect(saved.deviceName).not.toContain(hostname()); + + await runLogin({ syncUrl: "https://sync.test", wait, deviceName: "work laptop" }); + expect(JSON.parse(readFileSync(getCredentialsPath(), "utf-8")).deviceName).toBe("work laptop"); + }); + + it("says plainly when the account is not on the server's allowlist", async () => { + vi.stubGlobal("fetch", vi.fn(async (url: string) => + String(url).endsWith("/code") ? Response.json(code) : Response.json({ error: "not_allowed" }, { status: 403 }), + )); + const { wait } = clock(); + await expect(runLogin({ syncUrl: "https://sync.test", wait })).rejects.toThrow(/not allowed on this xtctx cloud server/); + expect(existsSync(getCredentialsPath())).toBe(false); + }); + + it("delete-data with a login the server no longer accepts deletes nothing, says so, and keeps the login", async () => { + await saveCredentials({ token: "tok", user, deviceId: "d", deviceName: "n", syncUrl: "https://sync.test" }); + vi.stubGlobal("fetch", vi.fn(async () => Response.json({ error: "unauthorized" }, { status: 401 }))); + const log = vi.spyOn(console, "log"); + + await expect(runLogout({ deleteData: true })).rejects.toThrow(/nothing was deleted.*xtctx login/s); + + expect(existsSync(getCredentialsPath())).toBe(true); + expect(log.mock.calls.flat().join(" ")).not.toMatch(/Deleted/); + }); + it("delete-data keeps the login when the server could not delete", async () => { await saveCredentials({ token: "tok", user, deviceId: "d", deviceName: "n", syncUrl: "https://sync.test" }); vi.stubGlobal("fetch", vi.fn(async () => new Response("nope", { status: 500 }))); diff --git a/tests/sync/sync-cli.test.ts b/tests/sync/sync-cli.test.ts new file mode 100644 index 0000000..91631ff --- /dev/null +++ b/tests/sync/sync-cli.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { runSync, runSyncSetting } from "@xtctx/cli/sync"; +import { saveCredentials } from "@xtctx/sync/client"; +import { setOptedIn } from "@xtctx/sync/consent"; +import { editIndex, fakeCloud, sandbox, seedIndex } from "./helpers"; + +const creds = { token: "tok", user: { id: "github:1", username: "alice" }, deviceId: "d", deviceName: "device-abc123", syncUrl: "https://sync.test" }; + +describe("xtctx sync", () => { + let box: ReturnType; + let cloud: ReturnType; + let out: string[]; + beforeEach(async () => { + box = sandbox(); + cloud = fakeCloud(); + vi.stubGlobal("fetch", cloud.fetch); + out = []; + vi.spyOn(console, "log").mockImplementation((...a) => void out.push(a.join(" "))); + vi.spyOn(console, "error").mockImplementation((...a) => void out.push(a.join(" "))); + process.exitCode = 0; + await saveCredentials(creds); + await setOptedIn(box.project, true); + seedIndex(box.project, 2); + }); + afterEach(() => { + process.exitCode = 0; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + box.cleanup(); + }); + + it("exits non-zero when the upload fails, and zero when it works", async () => { + cloud.state.failStatus = 500; + await runSync({ projectDir: box.project }); + expect(process.exitCode).toBe(1); + expect(out.join("\n")).toMatch(/Cloud sync failed/); + + cloud.state.failStatus = undefined; + await runSync({ projectDir: box.project }); + expect(process.exitCode).toBe(0); + }); + + it("status shows the last upload and the last failure", async () => { + await runSync({ projectDir: box.project }); + cloud.state.failStatus = 503; + editIndex(box.project, (db) => db.prepare("UPDATE sessions SET updated_at = ?").run(new Date().toISOString())); + await runSync({ projectDir: box.project }); + out.length = 0; + + await runSyncSetting("status", { projectDir: box.project }); + + const text = out.join("\n"); + expect(text).toMatch(/on; uploading as alice to https:\/\/sync.test, device "device-abc123"/); + expect(text).toMatch(/last upload: 20\d\d-/); + expect(text).toMatch(/last attempt FAILED at .*503/); + }); + + it("renames this device", async () => { + await runSyncSetting("device", { projectDir: box.project, value: "studio mac" }); + await runSync({ projectDir: box.project }); + expect(cloud.uploads()[0].body.device.name).toBe("studio mac"); + }); +}); From 11124de49ce99896d6cde85527b2a7f3d5131ad1 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Thu, 1 Oct 2026 10:42:20 +0100 Subject: [PATCH 16/19] chore(deps): have dependabot watch cloud/ --- .github/dependabot.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b72087e..e09610e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -27,3 +27,15 @@ updates: interval: weekly commit-message: prefix: deps + + # The Worker: wrangler, workers-types and the OAuth provider move often, and + # the provider is security-relevant. + - package-ecosystem: npm + directory: /cloud + schedule: + interval: weekly + commit-message: + prefix: deps + groups: + dev-dependencies: + dependency-type: development From e02357d7eddca5c565a0dc4545b957ae2089a648 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Thu, 1 Oct 2026 10:42:21 +0100 Subject: [PATCH 17/19] docs: describe cloud sync as optional and opt-in per project, and exactly what it sends docs/cloud-sync.md now lists every uploaded field and what is not sent, says message text goes as written, and covers the env-credential guard, skipped messages, status, read-only tokens and OAuth sign-in for MCP clients. README Limits, PRODUCT.md and docs/architecture.md stop saying there is no cloud at all and point to it as the one optional, opt-in part. --- PRODUCT.md | 10 ++++-- README.md | 9 ++++-- docs/architecture.md | 13 ++++++-- docs/cloud-sync.md | 75 +++++++++++++++++++++++++++++++++++--------- 4 files changed, 87 insertions(+), 20 deletions(-) diff --git a/PRODUCT.md b/PRODUCT.md index e79da26..7e788bc 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -26,7 +26,11 @@ reported by `xtctx status`. tools) that need stable session references and raw-detail pointers — served by `xtctx_handoff_manifest`. -Single-user, single-machine. There is no team, sync, or server component. +Single-user. Handoff itself is single-machine and needs no server. The one +exception is optional cloud sync, opt-in per project: a logged-in user can +upload an opted-in project's transcripts so agents on their other machines can +read them over MCP ([docs/cloud-sync.md](docs/cloud-sync.md)). There is no +team or shared component. ## Success @@ -66,7 +70,9 @@ Single-user, single-machine. There is no team, sync, or server component. Out of scope (deliberately, and documented everywhere the product speaks): no daemon, no API server, no dashboard, no generated summaries or briefs, -no durable memory, no write-back tools, no cloud anything. +no durable memory, no write-back tools, and nothing leaves the machine unless +the user opts a project in to cloud sync, which is optional and off by +default. ## Constraints diff --git a/README.md b/README.md index 8d56be2..73e46fa 100644 --- a/README.md +++ b/README.md @@ -251,8 +251,13 @@ startup hooks; others receive MCP config plus managed instructions only. ## Limits - xtctx is local-only by default: it never uploads transcripts and runs no - telemetry. Cloud sync exists but sends nothing until you log in *and* opt a - project in with `xtctx sync enable` ([`docs/cloud-sync.md`](docs/cloud-sync.md)). + telemetry. Cloud sync is optional and opt-in per project: it sends nothing + until you log in (`xtctx login`) *and* opt a project in (`xtctx sync enable`), + and then sends that project's transcript text, including whatever paths or + output the agents wrote into it, to the xtctx cloud server, where your other + machines' agents can read it over MCP. `xtctx status` says whether it is on + for the project and when it last uploaded + ([`docs/cloud-sync.md`](docs/cloud-sync.md)). A project can opt into an external embedding endpoint by writing one into `.xtctx/config.yaml`, in which case window text is sent there to be vectorized — never inferred from an environment variable, and `xtctx status` diff --git a/docs/architecture.md b/docs/architecture.md index 2476eff..2be2b69 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,8 +8,17 @@ detail on demand. xtctx does not run a background service, web UI, generated-summary pipeline, or durable memory writeback layer. The architecture is intentionally scoped to one local developer switching -between coding tools. Shared team memory, cloud sync, telemetry, and hosted -retrieval are outside the product surface. +between coding tools. Shared team memory and telemetry are outside the product +surface. + +Cloud sync is the one optional part that leaves the machine, and it is opt-in +per project: only when the user has logged in (`xtctx login`) and opted the +project in (`xtctx sync enable`) does the MCP server upload that project's +index to the Worker in `cloud/`, every 10 seconds while it runs and once on +shutdown. The Worker serves the uploads back over MCP to the same user's +agents on other machines. It is not on the handoff path: everything above +works with it off, which is the default. See +[cloud-sync.md](cloud-sync.md) and [`cloud/README.md`](../cloud/README.md). ## Runtime Shape diff --git a/docs/cloud-sync.md b/docs/cloud-sync.md index bde07ea..960b197 100644 --- a/docs/cloud-sync.md +++ b/docs/cloud-sync.md @@ -1,39 +1,86 @@ # Cloud sync -Optional. xtctx stays local unless you do both of these: +Optional, and opt-in per project. xtctx stays local unless you do both of these: 1. **Log in:** `xtctx login` signs you in with GitHub and stores a token. It uploads nothing. 2. **Opt a project in:** `xtctx sync enable`, run in that project. Each project is its own decision. Either one alone sends nothing. `XTCTX_TOKEN` in the environment counts as logging in, not as opting in. +What it is for: reading, from an agent on another machine, what your agents did here. The cloud serves the uploads back over MCP (see [Reading it back](#reading-it-back)). + ## What is uploaded -For an opted-in project, the messages in **that project's own index** (`.xtctx/state/xtctx.db`): each message's text, role, timestamp, tool and session id, the git branch and commit, and a project identity. The identity is the normalised git remote (`github.com/you/repo`), or a hash of the folder's location when there is no remote. The project's folder name is sent; its absolute path is not. +For an opted-in project, only the sessions **that project's own index** (`.xtctx/state/xtctx.db`) attributes to that project, compared the way the index's own reads compare project roots. Sessions an index holds for another folder (a copied `.xtctx/`, a renamed folder) are not sent. + +Per session: + +- the tool (`claude-code`, `codex`, ...) and the tool's session id +- git branch and commit, when recorded +- first and last activity time +- the preview: the start of the session's first message, up to 160 characters + +Per message: + +- the message text, role, timestamp and position +- a content hash, and the index's own message id +- these metadata fields and no others: `messageIndex`, `tokenEstimate`, `toolCalls`, `toolName`, `model`, `stepType`, `artifactType`, `artifactName`, `sessionType`, `approvalMode`, `sandboxed`, `layer`, `costUsd`, `gitBranch`, `gitCommit`. A value among them that looks like an absolute path is dropped. -Sessions that xtctx has not attributed to the project are never read. Nothing is written into your repository for sync. +Per upload: -The opt-in list is `~/.xtctx/cloud-projects.json` and the login is `~/.xtctx/credentials.json` (readable by you only). Both live in your home directory, not in `.xtctx/config.yaml`, so a repository cannot opt its readers in by committing a file. +- a project identity: the normalised git remote (`github.com/you/repo`), or a hash of the folder's location when there is no remote +- the project's folder name (not its path) +- this device's id (random, made at login) and its name, which is a random label like `device-3f9a1c` unless you set one +- the client version (`X-Xtctx-Client: xtctx/`) + +**Not uploaded:** the absolute path of the project or of any transcript file (the index's `source_pointer`, Antigravity's `sourcePath`), `referencedFiles`, your hostname, and any metadata field not listed above. + +**Message text is uploaded as written**, and it can contain anything your agents saw or printed: file paths, command output, a secret pasted into a chat. Opt in only projects whose transcripts you would put on that server. A message over 64 KB is cut to 64 KB and ends with a `[xtctx: truncated for upload ...]` marker. + +The opt-in list is `~/.xtctx/cloud-projects.json` and the login is `~/.xtctx/credentials.json` (readable by you only). Both live in your home directory, not in `.xtctx/config.yaml`, so a repository cannot opt its readers in by committing a file. Nothing is written into your repository for sync. ## When it uploads -- While an agent runs xtctx in an opted-in project, the MCP server uploads what is new every 10 seconds, and once more when it shuts down. Nothing runs when no agent does: there is no daemon. -- `xtctx sync` uploads once. `xtctx sync --watch` keeps doing it in the foreground until you press Ctrl+C. +- While an agent runs xtctx in an opted-in project, its MCP server uploads every 10 seconds, and once more when it shuts down. Nothing runs when no agent does: there is no daemon. With two agents open in one project, a lock in `~/.xtctx/sync/` lets one server upload at a time. +- `xtctx sync` uploads once, and exits non-zero if that failed. `xtctx sync --watch` repeats every 10 seconds in the foreground until Ctrl+C, and prints a repeated failure once. + +Each session's cloud copy is kept equal to the local one. New and changed messages are sent under their local ids, and the server answers with how many messages it holds for the session. When that differs from the local count (a re-read here removed or replaced messages), the whole session is sent with its full list of ids and the server deletes the rest. Uploads are idempotent, so sending something twice changes nothing. -Uploads are incremental: a position is kept per account, so a message is sent once. +What has been sent is tracked per project and per account in `~/.xtctx/sync/`, not in the index, so logging in as someone else uploads the project to them in full. Rebuilding the index sends everything once more, which changes nothing in the cloud for the same reason. A failed upload is retried in full on the next run. + +If the server refuses one message as too large, that message is skipped from then on and recorded, and the rest of the project keeps uploading. `xtctx sync status` lists skipped messages. + +## Environment variables + +`XTCTX_TOKEN` and `XTCTX_SYNC_URL` override the saved login. An MCP config's `env` block can set them, and a repository can ship such a config, so when they name anything other than your saved login (another token, another server, or no saved login at all) xtctx **refuses to upload** and says why. Set `XTCTX_ALLOW_ENV_CREDENTIALS=1` as well when that is intended, such as on a machine nobody logs in on. `XTCTX_DEVICE_ID` and `XTCTX_DEVICE_NAME` name the device for environment credentials. ## Commands | Command | What it does | |---|---| -| `xtctx login` | Sign in with GitHub (device code). | -| `xtctx sync enable` / `disable` / `status` | Choose whether this project uploads. | -| `xtctx sync` | Upload now. `--watch` repeats every 10 seconds. | -| `xtctx logout` | Revoke every token for your account, then forget the local login. | -| `xtctx logout --delete-data` | Delete everything uploaded for your account first, then log out. | +| `xtctx login [--device ]` | Sign in with GitHub (device code). This login can read, upload and delete. | +| `xtctx sync enable` / `disable` | Choose whether this project uploads. | +| `xtctx sync status` | Whether this project uploads, as whom, the last upload, the last failure, and skipped messages. `xtctx status` shows the same under `Cloud`. | +| `xtctx sync` | Upload now. `--watch` repeats every 10 seconds. Exits non-zero on failure. | +| `xtctx sync device []` | Show or set the name this device uploads as. | +| `xtctx sync token` | Print a read-only token (valid 90 days) for an MCP client that cannot sign in by itself. | +| `xtctx logout` | Revoke every token and every client sign-in for your account, then forget the local login. | +| `xtctx logout --delete-data` | Delete everything uploaded for your account, then log out. If the server no longer accepts your login it deletes nothing and says so; run `xtctx login` and try again. | + +`disable` stops further uploads but does not delete what was already sent; use `logout --delete-data` for that. Deleting also revokes every token issued before it, and they stay revoked when you sign in again. + +## Reading it back + +The server is an MCP server at `https://mcp.xtctx.com/mcp` with three tools, named so they cannot collide with the local server's when both are connected: + +- `xtctx_cloud_status`: when each of your devices last uploaded each project, and how many sessions it holds. Tells "synced, nothing new" from "never synced". +- `xtctx_cloud_recent_sessions`: sessions from every device and every project, newest first; `repo_url` restricts it to one repository. +- `xtctx_cloud_session_detail`: one session's messages in order, as `markdown` (default) or `json`. + +**MCP clients sign in with OAuth.** Add the URL to the client, for Claude Code `claude mcp add --transport http xtctx-cloud https://mcp.xtctx.com/mcp`, and it finds the sign-in itself, registers, and opens a browser where you approve it and sign in with GitHub. A client signed in this way can only read (scope `read`). Its access token lasts an hour and is refreshed for up to 30 days; it cannot upload or delete. Uploading and deleting need the CLI's own login. -`disable` stops further uploads but does not delete what was already sent; use `logout --delete-data` for that. +A client without OAuth support can send a token from `xtctx sync token` as an `Authorization: Bearer ...` header. It is read-only as well, and `xtctx logout` revokes it. ## Server -The service is the Worker in [`cloud/`](../cloud/README.md). The sync URL must be `https`; plain `http` is accepted only for `localhost`. +The service is the Worker in [`cloud/`](../cloud/README.md), which also describes running your own. The sync URL must be `https`; plain `http` is accepted only for `localhost`. Only the GitHub accounts the server's operator lists can sign in. From fe9917d03089087bedab6e36ef5c99144badc0b6 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Thu, 1 Oct 2026 12:15:16 +0100 Subject: [PATCH 18/19] fix(sync): close each cloud connection, so the server does not crash on exit on Windows Node 24.14 on Windows aborts with 'Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)' (exit 0xC0000409) when process.exit runs while fetch holds a pooled keep-alive socket after a POST. The MCP server uploads on a tick and again on shutdown, then exits: 10 of 10 such runs crashed. Sending Connection: close on every cloud request: the built client exited cleanly in 10 of 10 runs against a server that crashed plain fetch in 5 of 5. --- src/cli/login.ts | 5 +++-- src/sync/client.ts | 19 ++++++++++++++++++- tests/sync/no-keep-alive.test.ts | 23 +++++++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 tests/sync/no-keep-alive.test.ts diff --git a/src/cli/login.ts b/src/cli/login.ts index 26a9041..523e455 100644 --- a/src/cli/login.ts +++ b/src/cli/login.ts @@ -4,6 +4,7 @@ import { assertSecureSyncUrl, callCloud, clientHeader, + NO_KEEP_ALIVE, deleteCredentials, getCredentialsPath, loadSavedCredentials, @@ -48,7 +49,7 @@ export async function runLogin(options: { assertSecureSyncUrl(syncUrl); - const codeRes = await fetch(`${base}/auth/device/code`, { method: "POST", headers: { "X-Xtctx-Client": clientHeader() } }); + const codeRes = await fetch(`${base}/auth/device/code`, { method: "POST", headers: { "X-Xtctx-Client": clientHeader(), ...NO_KEEP_ALIVE } }); if (!codeRes.ok) { throw new Error(`Could not start login (${codeRes.status}).`); } @@ -70,7 +71,7 @@ export async function runLogin(options: { try { const res = await fetch(`${base}/auth/device/poll`, { method: "POST", - headers: { "Content-Type": "application/json", "X-Xtctx-Client": clientHeader() }, + headers: { "Content-Type": "application/json", "X-Xtctx-Client": clientHeader(), ...NO_KEEP_ALIVE }, body: JSON.stringify({ device_code: code.device_code }), }); poll = (await res.json()) as PollResponse; diff --git a/src/sync/client.ts b/src/sync/client.ts index 0a40ea2..5fbb24a 100644 --- a/src/sync/client.ts +++ b/src/sync/client.ts @@ -133,6 +133,19 @@ export function assertSecureSyncUrl(syncUrl: string): void { } } +/** + * Every cloud request closes its connection when it is done. + * + * On Windows, Node 24.14 aborts with `Assertion failed: !(handle->flags & + * UV_HANDLE_CLOSING)` (exit 0xC0000409) when `process.exit` runs while fetch + * still holds a pooled keep-alive socket after a POST. The MCP server does + * exactly that: an upload tick, then the final upload on shutdown, then exit. + * It crashed in 10 of 10 such runs; with this header, 0 of 8 in a standalone + * reproduction. Uploads are seconds apart, so reusing the connection saves + * nothing worth the crash. + */ +export const NO_KEEP_ALIVE = { Connection: "close" } as const; + /** Sent on every request, so the server can tell which client versions are out there. */ export function clientHeader(): string { return `xtctx/${readXtctxPackage(import.meta.url).version}`; @@ -145,7 +158,11 @@ export async function callCloud( body?: unknown, ): Promise { assertSecureSyncUrl(creds.syncUrl); - const headers: Record = { Authorization: `Bearer ${creds.token}`, "X-Xtctx-Client": clientHeader() }; + const headers: Record = { + Authorization: `Bearer ${creds.token}`, + "X-Xtctx-Client": clientHeader(), + ...NO_KEEP_ALIVE, + }; if (body !== undefined) headers["Content-Type"] = "application/json"; return fetch(`${creds.syncUrl.replace(/\/$/, "")}${path}`, { method, diff --git a/tests/sync/no-keep-alive.test.ts b/tests/sync/no-keep-alive.test.ts new file mode 100644 index 0000000..3aefc21 --- /dev/null +++ b/tests/sync/no-keep-alive.test.ts @@ -0,0 +1,23 @@ +/** + * Cloud requests close their connection. Without it the MCP server crashed on + * exit on Windows (Node 24.14: `Assertion failed: !(handle->flags & + * UV_HANDLE_CLOSING)`, exit 0xC0000409) after an upload tick followed by the + * shutdown upload; see NO_KEEP_ALIVE in src/sync/client.ts. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { callCloud } from "@xtctx/sync/client"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("cloud requests", () => { + it("ask the server to close the connection", async () => { + const fetchMock = vi.fn(async () => Response.json({})); + vi.stubGlobal("fetch", fetchMock); + const creds = { token: "t", user: { id: "github:1", username: "u" }, deviceId: "d", deviceName: "n", syncUrl: "https://sync.test" }; + + await callCloud(creds, "POST", "/api/stream", { sessions: [] }); + + const init = (fetchMock.mock.calls[0] as unknown as [string, RequestInit])[1]; + expect(new Headers(init.headers).get("connection")).toBe("close"); + }); +}); From 63c4254990520bccad15bbab0eceec86e359dbac Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Fri, 2 Oct 2026 23:05:36 +0100 Subject: [PATCH 19/19] test(sync): resolve the sandbox temp dir, as the index resolves project roots On macOS the temp dir sits behind /var -> /private/var, so roots seeded unresolved never matched the upload's project filter and nothing was sent. --- tests/sync/helpers.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/sync/helpers.ts b/tests/sync/helpers.ts index 005178c..1185f13 100644 --- a/tests/sync/helpers.ts +++ b/tests/sync/helpers.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { vi } from "vitest"; @@ -9,7 +9,9 @@ import { openDatabase } from "@xtctx/handoff/schema"; * pointed at the first. Nothing here may touch the real ~/.xtctx. */ export function sandbox() { - const root = mkdtempSync(join(tmpdir(), "xtctx-sync-")); + // Resolved, as the index stores project roots: on macOS the temp dir is + // behind /var -> /private/var, and a root seeded unresolved never matches. + const root = realpathSync(mkdtempSync(join(tmpdir(), "xtctx-sync-"))); const home = join(root, "home"); const project = join(root, "project"); mkdirSync(home);