diff --git a/.env.example b/.env.example index 312c7b7..7c80719 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,7 @@ OPENAI_API_KEY=sk-... EMBEDDING_PROVIDER=openai EMBEDDING_MODEL=text-embedding-3-large EMBEDDING_DIMENSIONS=3072 + +# Clerk identity for MCP authorization at /mcp. +CLERK_PUBLISHABLE_KEY=pk_test_... +CLERK_SECRET_KEY=sk_test_... diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8ddc1f4..932444a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -24,7 +24,10 @@ jobs: - run: bun install - name: Build - run: bun run build + run: bun run build:prod + env: + VITE_CLERK_PUBLISHABLE_KEY: ${{ secrets.CLERK_PUBLISHABLE_KEY }} + VITE_API_URL: https://api.ooxml.dev - name: Deploy to Cloudflare Pages uses: cloudflare/wrangler-action@v3 diff --git a/.gitignore b/.gitignore index dcb1cd7..ec0c48e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,8 @@ dist/ .DS_Store dev/ .wrangler/ -.env +.env* +!.env.example .mcp.json .vscode/ @@ -11,4 +12,4 @@ dev/ PLAN.md # XSD/spec artifacts: pulled by scripts/fetch-xsd.ts; never committed. -data/xsd-cache/ \ No newline at end of file +data/xsd-cache/ diff --git a/README.md b/README.md index fbfeb48..b5dbad9 100644 --- a/README.md +++ b/README.md @@ -60,14 +60,22 @@ Three tool families share one server: - **Schema lookup** (over the parsed XSDs): `ooxml_element`, `ooxml_type`, `ooxml_children`, `ooxml_attributes`, `ooxml_enum`, `ooxml_namespace` - **Package metadata** (curated from Part 1 §11.3.x / §12.3.x / §13.3.x / §15.x): `ooxml_package_part` +### Authentication + +`/mcp` uses OAuth 2.1. Compatible MCP clients register automatically, open the ooxml.dev sign-in and consent pages, and receive a token limited to this MCP server. Clerk handles user identity; the MCP server handles dynamic client registration, PKCE, tokens, refresh, and revocation. + ## Development ```bash bun install # Install dependencies bun dev # Dev server at http://localhost:5173 bun run build # Production build +bun run build:prod # Build with the ignored .env.prod file ``` +`build:prod` requires a live Clerk publishable key. This prevents production deploys from using the +auth fallback or a test Clerk instance. + ## Contributing Contributions welcome. Add implementation notes, fix examples, or improve the reference. diff --git a/apps/mcp-server/README.md b/apps/mcp-server/README.md index 8e289ba..1d546fe 100644 --- a/apps/mcp-server/README.md +++ b/apps/mcp-server/README.md @@ -92,3 +92,21 @@ bun run deploy ``` Database setup, ingest pipelines, and tests live at the repo root — see the top-level `README.md`. + +## Authentication + +`/mcp` uses OAuth 2.1 and serves MCP `2026-07-28`, with stateless compatibility for current 2024/2025 clients. `@cloudflare/workers-oauth-provider` owns discovery, dynamic client registration, Client ID Metadata Documents, PKCE, resource-bound tokens, refresh, and revocation. Clerk authenticates the person on the custom ooxml.dev sign-in page before the server shows consent. + +This split is intentional: Clerk identifies users well, but it does not provide the dynamic client registration standard MCP clients need. + +Successful tool calls write the Clerk user ID, dynamic OAuth client ID, tool name, surface, and timestamp to `mcp_usage_events`. Tokens and tool arguments are never recorded. + +```bash +bun test tests/mcp-server/mcp-auth.test.ts tests/mcp-server/oauth-authorization.test.ts +``` + +To see identified users, load `DATABASE_URL` and `CLERK_SECRET_KEY` from the root `.env` and run: + +```bash +bun run mcp:users +``` diff --git a/apps/mcp-server/package.json b/apps/mcp-server/package.json index 17f12d8..a35cc11 100644 --- a/apps/mcp-server/package.json +++ b/apps/mcp-server/package.json @@ -11,9 +11,12 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@clerk/backend": "^3.16.3", + "@cloudflare/workers-oauth-provider": "^0.10.3", + "@modelcontextprotocol/server": "2.0.0", + "@neondatabase/serverless": "^1.0.2", "@ooxml-dev/shared": "workspace:*", - "@modelcontextprotocol/sdk": "^1.25.3", - "@neondatabase/serverless": "^1.0.2" + "zod": "^4.2.0" }, "devDependencies": { "@cloudflare/workers-types": "^4.20260127.0", diff --git a/apps/mcp-server/scripts/users.ts b/apps/mcp-server/scripts/users.ts new file mode 100644 index 0000000..4369f3f --- /dev/null +++ b/apps/mcp-server/scripts/users.ts @@ -0,0 +1,52 @@ +import { createClerkClient } from "@clerk/backend"; +import { isClerkAPIResponseError } from "@clerk/backend/errors"; +import { neon } from "@neondatabase/serverless"; + +const databaseUrl = process.env.DATABASE_URL; +const clerkSecretKey = process.env.CLERK_SECRET_KEY; +if (!databaseUrl || !clerkSecretKey) { + throw new Error("DATABASE_URL and CLERK_SECRET_KEY are required"); +} + +const sql = neon(databaseUrl); +const clerk = createClerkClient({ secretKey: clerkSecretKey, telemetry: { disabled: true } }); +const rows = await sql< + Array<{ + clerk_user_id: string; + last_seen_at: string; + call_count: string; + tools: string[]; + }> +>` + SELECT + clerk_user_id, + MAX(occurred_at)::text AS last_seen_at, + COUNT(*)::text AS call_count, + ARRAY_AGG(DISTINCT tool_name ORDER BY tool_name) AS tools + FROM mcp_usage_events + GROUP BY clerk_user_id + ORDER BY MAX(occurred_at) DESC + LIMIT 100 +`; + +console.log("USER ID\tNAME\tEMAIL\tCALLS\tLAST SEEN\tTOOLS"); +for (const row of rows) { + let name = ""; + let email = ""; + try { + const user = await clerk.users.getUser(row.clerk_user_id); + name = [user.firstName, user.lastName].filter(Boolean).join(" "); + email = + user.emailAddresses.find((item) => item.id === user.primaryEmailAddressId)?.emailAddress ?? + ""; + } catch (error) { + if (!isClerkAPIResponseError(error) || error.status !== 404) throw error; + name = "(deleted user)"; + } + + console.log( + [row.clerk_user_id, name, email, row.call_count, row.last_seen_at, row.tools.join(",")].join( + "\t", + ), + ); +} diff --git a/apps/mcp-server/src/index.ts b/apps/mcp-server/src/index.ts index 3641e54..fa0ed82 100644 --- a/apps/mcp-server/src/index.ts +++ b/apps/mcp-server/src/index.ts @@ -9,14 +9,26 @@ * (ooxml_package_part) */ +import { type OAuthHelpers, OAuthProvider } from "@cloudflare/workers-oauth-provider"; import { createDb } from "./db"; import { embedQuery } from "./embeddings"; -import { handleMcpRequest, TOOLS } from "./mcp"; -import { OOXML_TOOL_DEFS } from "./ooxml-tools"; +import { executeMcpTool } from "./mcp"; +import { + createAuthenticatedMcpHandler, + createDatabaseUsageRecorder, + isMcpAuthorizationProps, + MCP_RESOURCE_URL, + type McpAuthorizationProps, +} from "./mcp-auth"; +import { authenticateClerkUser, handleAuthorizationRequest } from "./oauth-authorization"; export interface Env { DATABASE_URL: string; VOYAGE_API_KEY: string; + CLERK_PUBLISHABLE_KEY: string; + CLERK_SECRET_KEY: string; + OAUTH_KV: KVNamespace; + OAUTH_PROVIDER: OAuthHelpers; } // Part descriptions @@ -31,7 +43,7 @@ const PART_DESCRIPTIONS: Record = { const ALLOWED_ORIGINS = ["https://ooxml.dev", "https://www.ooxml.dev"]; const DEV_ORIGINS = ["http://localhost:5173", "http://127.0.0.1:5173"]; -function getCorsHeaders(request: Request, _env: Env): Record { +function getCorsHeaders(request: Request): Record { const origin = request.headers.get("Origin"); if (!origin) return {}; @@ -42,7 +54,8 @@ function getCorsHeaders(request: Request, _env: Env): Record { return { "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Methods": "GET, POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Allow-Headers": + "Authorization, Content-Type, MCP-Protocol-Version, Mcp-Method, Mcp-Name", }; } @@ -63,12 +76,32 @@ function addCorsHeaders(response: Response, corsHeaders: Record) }); } -export default { - async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { +type OAuthExecutionContext = ExecutionContext & { props?: McpAuthorizationProps }; + +const mcpApiHandler = { + async fetch(request: Request, env: Env, context: ExecutionContext) { + if (new URL(request.url).pathname !== "/mcp") return new Response("Not found", { status: 404 }); + + const props = (context as OAuthExecutionContext).props; + if (!isMcpAuthorizationProps(props)) { + console.error("OAuth provider did not supply valid MCP authorization props"); + return new Response("Authenticated identity is unavailable", { status: 500 }); + } + + const handler = createAuthenticatedMcpHandler({ + usageRecorder: createDatabaseUsageRecorder(env.DATABASE_URL), + toolExecutor: (name, args) => executeMcpTool(name, args, env), + waitUntil: (promise) => context.waitUntil(promise), + }); + return addCorsHeaders(await handler(request, props), getCorsHeaders(request)); + }, +} satisfies ExportedHandler; + +const defaultHandler = { + async fetch(request: Request, env: Env) { const url = new URL(request.url); - const corsHeaders = getCorsHeaders(request, env); + const corsHeaders = getCorsHeaders(request); - // Log request origin for observability console.log("incoming request", { method: request.method, path: url.pathname, @@ -80,130 +113,66 @@ export default { host: request.headers.get("Host") || "unknown", }); - // Handle CORS preflight - if (request.method === "OPTIONS") { - return new Response(null, { - status: 204, - headers: corsHeaders, + if (url.pathname === "/authorize") { + return handleAuthorizationRequest(request, { + oauth: env.OAUTH_PROVIDER, + authenticateUser: (authorizationRequest) => + authenticateClerkUser(authorizationRequest, env), }); } - // Health check - if (url.pathname === "/health") { - return addCorsHeaders( - new Response(JSON.stringify({ status: "ok" }), { - headers: { "Content-Type": "application/json" }, - }), - corsHeaders, - ); + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: corsHeaders }); } - // MCP endpoint - if (url.pathname === "/mcp" || url.pathname === "/sse") { - if (request.method === "POST") { - // MCP protocol (JSON-RPC) - const response = await handleMcpRequest(request, env); - return addCorsHeaders(response, corsHeaders); - } - - if (request.method === "GET") { - const accept = request.headers.get("Accept") || ""; - - // MCP Streamable HTTP: return SSE stream for clients expecting event-stream - if (accept.includes("text/event-stream")) { - const { readable, writable } = new TransformStream(); - const writer = writable.getWriter(); - const encoder = new TextEncoder(); - - ctx.waitUntil( - (async () => { - try { - // Initial keepalive - await writer.write(encoder.encode(":ok\n\n")); - // Send keepalive every 30s to hold connection open - while (true) { - await new Promise((resolve) => setTimeout(resolve, 30000)); - await writer.write(encoder.encode(":keepalive\n\n")); - } - } catch { - // Client disconnected — stream closed - } - })(), - ); - - request.signal.addEventListener("abort", () => { - writer.close().catch(() => {}); - }); - - return addCorsHeaders( - new Response(readable, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - }, - }), - corsHeaders, - ); - } - - // Non-SSE GET returns server info for debugging - return addCorsHeaders(handleMcpInfo(), corsHeaders); - } + if (url.pathname === "/health") { + return addCorsHeaders(Response.json({ status: "ok" }), corsHeaders); } - // REST API endpoints if (url.pathname === "/search" && request.method === "POST") { - const response = await handleSearch(request, env); - return addCorsHeaders(response, corsHeaders); + return addCorsHeaders(await handleSearch(request, env), corsHeaders); } if (url.pathname === "/section" && request.method === "GET") { - const response = await handleGetSection(request, env); - return addCorsHeaders(response, corsHeaders); + return addCorsHeaders(await handleGetSection(request, env), corsHeaders); } if (url.pathname === "/stats") { - const response = await handleStats(env); - return addCorsHeaders(response, corsHeaders); + return addCorsHeaders(await handleStats(env), corsHeaders); } return addCorsHeaders( - new Response( - JSON.stringify({ - name: "OOXML Reference MCP Server", - version: "0.1.0", - endpoints: { - mcp: "/mcp", - health: "/health", - search: "POST /search", - section: "GET /section?id=17.3.2&part=1", - stats: "/stats", - }, - }), - { - headers: { "Content-Type": "application/json" }, + Response.json({ + name: "OOXML Reference MCP Server", + version: "0.1.0", + endpoints: { + mcp: "/mcp", + health: "/health", + search: "POST /search", + section: "GET /section?id=17.3.2&part=1", + stats: "/stats", }, - ), + }), corsHeaders, ); }, -}; - -// MCP info endpoint (GET for debugging). Tool list is derived from the same -// canonical exports as the JSON-RPC tools/list response so they can't drift. -function handleMcpInfo(): Response { - return new Response( - JSON.stringify({ - name: "ooxml", - version: "0.1.0", - description: "OOXML (ECMA-376) reference server: prose search + schema lookup", - tools: [...TOOLS, ...OOXML_TOOL_DEFS], - }), - { - headers: { "Content-Type": "application/json" }, - }, - ); -} +} satisfies ExportedHandler; + +export default new OAuthProvider({ + apiRoute: "/mcp", + apiHandler: mcpApiHandler, + defaultHandler, + authorizeEndpoint: "/authorize", + tokenEndpoint: "/oauth/token", + clientRegistrationEndpoint: "/oauth/register", + clientIdMetadataDocumentEnabled: true, + scopesSupported: ["profile"], + resourceMetadata: { + resource: MCP_RESOURCE_URL, + scopes_supported: ["profile"], + resource_name: "OOXML Reference MCP Server", + }, +}); // REST API handlers for testing async function handleSearch(request: Request, env: Env): Promise { diff --git a/apps/mcp-server/src/mcp-auth.ts b/apps/mcp-server/src/mcp-auth.ts new file mode 100644 index 0000000..463bfed --- /dev/null +++ b/apps/mcp-server/src/mcp-auth.ts @@ -0,0 +1,153 @@ +import { type AuthInfo, createMcpHandler, McpServer } from "@modelcontextprotocol/server"; +import { neon } from "@neondatabase/serverless"; +import { z } from "zod"; +import { ALL_TOOL_DEFS, type ToolDef } from "./mcp"; + +export const MCP_PROTOCOL_VERSION = "2026-07-28"; +export const MCP_RESOURCE_URL = "https://api.ooxml.dev/mcp"; + +export interface McpAuthorizationProps { + userId: string; + clientId: string; + scopes: string[]; +} + +export interface UsageEvent { + userId: string; + tool: string; + surface: "mcp"; + client: string; + occurredAt: string; +} + +export interface UsageRecorder { + record(event: UsageEvent): void | Promise; +} + +interface AuthenticatedMcpOptions { + usageRecorder: UsageRecorder; + toolExecutor: (name: string, args: Record) => Promise; + now?: () => Date; + waitUntil?: (promise: Promise) => void; + onUsageError?: (error: unknown) => void; +} + +type ToolProperty = { + type: "string" | "number"; + description?: string; +}; + +function inputSchemaFor(tool: ToolDef): z.ZodObject> { + const required = new Set(tool.inputSchema.required ?? []); + const shape: Record = {}; + + for (const [name, rawProperty] of Object.entries(tool.inputSchema.properties)) { + const property = rawProperty as ToolProperty; + let schema: z.ZodType = property.type === "number" ? z.number() : z.string(); + if (property.description) schema = schema.describe(property.description); + shape[name] = required.has(name) ? schema : schema.optional(); + } + + return z.object(shape); +} + +function authenticatedIdentity(authInfo: AuthInfo | undefined): McpAuthorizationProps { + const userId = authInfo?.extra?.userId; + if (!authInfo || typeof userId !== "string") { + throw new Error("Authenticated Clerk user ID is missing from the MCP request context"); + } + + return { userId, clientId: authInfo.clientId, scopes: authInfo.scopes }; +} + +export function isMcpAuthorizationProps(value: unknown): value is McpAuthorizationProps { + if (!value || typeof value !== "object") return false; + const props = value as Partial; + return ( + typeof props.userId === "string" && + typeof props.clientId === "string" && + Array.isArray(props.scopes) && + props.scopes.every((scope) => typeof scope === "string") + ); +} + +export function createDatabaseUsageRecorder(connectionString: string): UsageRecorder { + const sql = neon(connectionString); + return { + async record(event) { + await sql` + INSERT INTO mcp_usage_events + (clerk_user_id, oauth_client_id, tool_name, surface, occurred_at) + VALUES + (${event.userId}, ${event.client}, ${event.tool}, ${event.surface}, ${event.occurredAt}) + `; + console.info("mcp usage", event); + }, + }; +} + +function recordUsage(options: AuthenticatedMcpOptions, event: UsageEvent): void { + const reportError = (error: unknown) => { + if (options.onUsageError) options.onUsageError(error); + else console.error("Failed to record MCP usage", error); + }; + + let recording: void | Promise; + try { + recording = options.usageRecorder.record(event); + } catch (error) { + reportError(error); + return; + } + + const completed = Promise.resolve(recording).catch(reportError); + options.waitUntil?.(completed); +} + +export function createAuthenticatedMcpHandler(options: AuthenticatedMcpOptions) { + const now = options.now ?? (() => new Date()); + const handler = createMcpHandler( + ({ authInfo }) => { + const identity = authenticatedIdentity(authInfo); + const server = new McpServer({ name: "ooxml", version: "0.1.0" }); + + for (const tool of ALL_TOOL_DEFS) { + server.registerTool( + tool.name, + { + description: tool.description, + inputSchema: inputSchemaFor(tool), + }, + async (args) => { + const text = await options.toolExecutor(tool.name, args); + recordUsage(options, { + userId: identity.userId, + tool: tool.name, + surface: "mcp", + client: identity.clientId, + occurredAt: now().toISOString(), + }); + return { content: [{ type: "text", text }] }; + }, + ); + } + + return server; + }, + // Keep current clients working while the same factory serves MCP 2026-07-28. + { legacy: "stateless" }, + ); + + return (request: Request, props: McpAuthorizationProps) => { + const token = request.headers.get("Authorization")?.replace(/^Bearer\s+/i, "") ?? ""; + return handler.fetch(request, { + authInfo: { + token, + clientId: props.clientId, + scopes: props.scopes, + resource: new URL(MCP_RESOURCE_URL), + extra: { userId: props.userId }, + }, + }); + }; +} diff --git a/apps/mcp-server/src/mcp.ts b/apps/mcp-server/src/mcp.ts index 90d5b28..ea634fe 100644 --- a/apps/mcp-server/src/mcp.ts +++ b/apps/mcp-server/src/mcp.ts @@ -100,6 +100,17 @@ export const TOOLS: ToolDef[] = [ }, ]; +export const ALL_TOOL_DEFS: ToolDef[] = [...TOOLS, ...OOXML_TOOL_DEFS]; + +const ALL_TOOL_NAMES: ReadonlySet = new Set(ALL_TOOL_DEFS.map((tool) => tool.name)); + +export function isMcpTool(name: string): boolean { + return ALL_TOOL_NAMES.has(name); +} + +export class McpToolInputError extends Error {} +export class McpToolNotFoundError extends Error {} + // JSON-RPC error codes const PARSE_ERROR = -32700; const INVALID_REQUEST = -32600; @@ -149,10 +160,49 @@ function handleToolsList(id: number | string | null): JsonRpcResponse { return { jsonrpc: "2.0", id, - result: { tools: [...TOOLS, ...OOXML_TOOL_DEFS] }, + result: { tools: ALL_TOOL_DEFS }, }; } +export async function executeMcpTool( + name: string, + args: Record, + env: Env, +): Promise { + if (isOoxmlTool(name)) return callOoxmlTool(name, args, env); + + switch (name) { + case "ooxml_search": { + const query = args.query as string; + const part = args.part as number | undefined; + const limit = Math.min((args.limit as number) || 5, 20); + if (!query) throw new McpToolInputError("Missing required parameter: query"); + + const db = createDb(env.DATABASE_URL); + const embedding = await embedQuery(query, env.VOYAGE_API_KEY); + return formatSearchResults(query, await db.search(embedding, { limit, partNumber: part })); + } + + case "ooxml_section": { + const sectionId = args.section_id as string; + const part = args.part as number | undefined; + if (!sectionId) throw new McpToolInputError("Missing required parameter: section_id"); + + const db = createDb(env.DATABASE_URL); + return formatSectionResults(sectionId, await db.getBySection(sectionId, part)); + } + + case "ooxml_parts": { + const part = args.part as number | undefined; + const db = createDb(env.DATABASE_URL); + return formatPartsList(await db.listSections(part), part); + } + + default: + throw new McpToolNotFoundError(`Unknown tool: ${name}`); + } +} + async function handleToolsCall( id: number | string | null, params: unknown, @@ -171,86 +221,19 @@ async function handleToolsCall( } try { - let resultText: string; - - // Structural OOXML tools share the dispatch with the existing semantic - // tools below. - if (isOoxmlTool(name)) { - resultText = await callOoxmlTool(name, args ?? {}, env); - return { - jsonrpc: "2.0", - id, - result: { content: [{ type: "text", text: resultText }] }, - }; - } - - switch (name) { - case "ooxml_search": { - const query = args?.query as string; - const part = args?.part as number | undefined; - const limit = Math.min((args?.limit as number) || 5, 20); - - if (!query) { - return { - jsonrpc: "2.0", - id, - error: { code: INVALID_PARAMS, message: "Missing required parameter: query" }, - }; - } - - const db = createDb(env.DATABASE_URL); - const embedding = await embedQuery(query, env.VOYAGE_API_KEY); - const results = await db.search(embedding, { limit, partNumber: part }); - - resultText = formatSearchResults(query, results); - break; - } - - case "ooxml_section": { - const sectionId = args?.section_id as string; - const part = args?.part as number | undefined; - - if (!sectionId) { - return { - jsonrpc: "2.0", - id, - error: { code: INVALID_PARAMS, message: "Missing required parameter: section_id" }, - }; - } - - const db = createDb(env.DATABASE_URL); - const results = await db.getBySection(sectionId, part); - - resultText = formatSectionResults(sectionId, results); - break; - } - - case "ooxml_parts": { - const part = args?.part as number | undefined; - - const db = createDb(env.DATABASE_URL); - const sections = await db.listSections(part); - - resultText = formatPartsList(sections, part); - break; - } - - default: - return { - jsonrpc: "2.0", - id, - error: { code: METHOD_NOT_FOUND, message: `Unknown tool: ${name}` }, - }; - } - + const resultText = await executeMcpTool(name, args ?? {}, env); return { jsonrpc: "2.0", id, - result: { - content: [{ type: "text", text: resultText }], - }, + result: { content: [{ type: "text", text: resultText }] }, }; } catch (error) { + if (error instanceof McpToolInputError) { + return { jsonrpc: "2.0", id, error: { code: INVALID_PARAMS, message: error.message } }; + } + if (error instanceof McpToolNotFoundError) { + return { jsonrpc: "2.0", id, error: { code: METHOD_NOT_FOUND, message: error.message } }; + } return { jsonrpc: "2.0", id, diff --git a/apps/mcp-server/src/oauth-authorization.ts b/apps/mcp-server/src/oauth-authorization.ts new file mode 100644 index 0000000..8b150a6 --- /dev/null +++ b/apps/mcp-server/src/oauth-authorization.ts @@ -0,0 +1,248 @@ +import { createClerkClient } from "@clerk/backend"; +import type { + AuthorizationError, + AuthRequest, + ClientInfo, + OAuthHelpers, +} from "@cloudflare/workers-oauth-provider"; +import type { McpAuthorizationProps } from "./mcp-auth"; + +const DEFAULT_SIGN_IN_URL = "https://ooxml.dev/sign-in"; +const CLERK_AUTHORIZED_PARTIES = [ + "https://ooxml.dev", + "https://www.ooxml.dev", + "https://api.ooxml.dev", +]; + +interface ClerkAuthEnv { + CLERK_PUBLISHABLE_KEY: string; + CLERK_SECRET_KEY: string; +} + +interface AuthenticatedUser { + userId: string; + headers?: Headers; +} + +type AuthenticationResult = AuthenticatedUser | Response | null; + +interface AuthorizationHandlerOptions { + oauth: Pick; + authenticateUser: (request: Request) => Promise; + signInUrl?: string; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function htmlResponse(body: string, status = 200): Response { + return new Response(body, { + status, + headers: { + "Cache-Control": "no-store", + "Content-Security-Policy": + "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'", + "Content-Type": "text/html; charset=utf-8", + "Referrer-Policy": "origin", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + }, + }); +} + +function withAuthenticationHeaders(response: Response, headers?: Headers): Response { + if (!headers || [...headers].length === 0) return response; + + const mergedHeaders = new Headers(response.headers); + for (const [name, value] of headers) mergedHeaders.append(name, value); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: mergedHeaders, + }); +} + +function errorPage(message: string, status = 400): Response { + return htmlResponse( + `Connection failed | ooxml.dev

We couldn't connect your MCP client

${escapeHtml(message)}

`, + status, + ); +} + +function authorizationErrorResponse(error: AuthorizationError): Response { + if (!error.redirectUri) return errorPage(error.description); + + const redirect = new URL(error.redirectUri); + redirect.searchParams.set("error", error.code); + redirect.searchParams.set("error_description", error.description); + if (error.state) redirect.searchParams.set("state", error.state); + if (error.issuer) redirect.searchParams.set("iss", error.issuer); + return Response.redirect(redirect, 302); +} + +function isAuthorizationError(error: unknown): error is AuthorizationError { + if (!(error instanceof Error)) return false; + const oauthError = error as Partial; + return typeof oauthError.code === "string" && typeof oauthError.description === "string"; +} + +function denyAuthorization(request: AuthRequest): Response { + const redirect = new URL(request.redirectUri); + redirect.searchParams.set("error", "access_denied"); + redirect.searchParams.set("error_description", "You cancelled the connection."); + redirect.searchParams.set("state", request.state); + if (request.issuer) redirect.searchParams.set("iss", request.issuer); + return Response.redirect(redirect, 302); +} + +function signInRedirect(request: Request, signInUrl: string): Response { + const redirect = new URL(signInUrl); + redirect.searchParams.set("redirect_url", request.url); + return Response.redirect(redirect, 302); +} + +function isTrustedConsentPost(request: Request): boolean { + const origin = request.headers.get("Origin"); + if (origin === new URL(request.url).origin) return true; + + // Some browser navigations serialize Origin as null. Sec-Fetch-Site is a + // browser-controlled header, so only accept that form for a same-origin post. + return origin === "null" && request.headers.get("Sec-Fetch-Site") === "same-origin"; +} + +function consentPage(request: Request, client: ClientInfo, oauthRequest: AuthRequest): Response { + const url = new URL(request.url); + const clientName = escapeHtml(client.clientName ?? "Your MCP client"); + const action = escapeHtml(`${url.pathname}${url.search}`); + const scopes = oauthRequest.scope.length + ? `

Requested access: ${escapeHtml(oauthRequest.scope.join(", "))}

` + : ""; + + return htmlResponse(` + + + + + Connect MCP client | ooxml.dev + + + +
+

<ooxml.dev/>

+

Connect ${clientName}?

+

This client will be able to use the OOXML reference as you.

+
  • Search and read the OOXML spec
  • Record which MCP tools your account uses
+ ${scopes} +
+ + +
+
+ +`); +} + +export async function authenticateClerkUser( + request: Request, + env: ClerkAuthEnv, +): Promise { + const clerk = createClerkClient({ + publishableKey: env.CLERK_PUBLISHABLE_KEY, + secretKey: env.CLERK_SECRET_KEY, + telemetry: { disabled: true }, + }); + const requestState = await clerk.authenticateRequest(request, { + authorizedParties: CLERK_AUTHORIZED_PARTIES, + domain: "api.ooxml.dev", + isSatellite: true, + // Keep the satellite handshake on the existing Clerk custom domain. + proxyUrl: "https://clerk.ooxml.dev", + satelliteAutoSync: true, + signInUrl: DEFAULT_SIGN_IN_URL, + signUpUrl: "https://ooxml.dev/sign-up", + }); + const handshakeLocation = requestState.headers.get("Location"); + if (handshakeLocation) { + return new Response(null, { status: 307, headers: requestState.headers }); + } + if (requestState.status === "handshake") { + throw new Error("Clerk returned a session handshake without a redirect"); + } + if (!requestState.isAuthenticated) return null; + + const { userId } = requestState.toAuth(); + return userId ? { userId, headers: requestState.headers } : null; +} + +export async function handleAuthorizationRequest( + request: Request, + options: AuthorizationHandlerOptions, +): Promise { + if (request.method !== "GET" && request.method !== "POST") { + return new Response("Method not allowed", { status: 405, headers: { Allow: "GET, POST" } }); + } + + let oauthRequest: AuthRequest; + try { + oauthRequest = await options.oauth.parseAuthRequest(request); + } catch (error) { + if (isAuthorizationError(error)) return authorizationErrorResponse(error); + throw error; + } + + const client = await options.oauth.lookupClient(oauthRequest.clientId); + if (!client) return errorPage("This OAuth client isn't registered."); + + const authentication = await options.authenticateUser(request); + if (authentication instanceof Response) return authentication; + if (!authentication) return signInRedirect(request, options.signInUrl ?? DEFAULT_SIGN_IN_URL); + const respond = (response: Response) => + withAuthenticationHeaders(response, authentication.headers); + + if (request.method === "GET") return respond(consentPage(request, client, oauthRequest)); + + if (!isTrustedConsentPost(request)) { + return respond(errorPage("The consent request did not come from ooxml.dev.", 403)); + } + + const form = await request.formData(); + if (form.get("decision") !== "approve") return respond(denyAuthorization(oauthRequest)); + + const props: McpAuthorizationProps = { + userId: authentication.userId, + clientId: oauthRequest.clientId, + scopes: oauthRequest.scope, + }; + const { redirectTo } = await options.oauth.completeAuthorization({ + request: oauthRequest, + userId: authentication.userId, + metadata: { clientName: client.clientName ?? null }, + scope: oauthRequest.scope, + props, + }); + + return respond(Response.redirect(redirectTo, 302)); +} diff --git a/apps/mcp-server/wrangler.toml b/apps/mcp-server/wrangler.toml index 89f5209..29bab06 100644 --- a/apps/mcp-server/wrangler.toml +++ b/apps/mcp-server/wrangler.toml @@ -1,7 +1,7 @@ name = "ooxml-mcp" main = "src/index.ts" compatibility_date = "2026-01-28" -compatibility_flags = ["nodejs_compat"] +compatibility_flags = ["nodejs_compat", "global_fetch_strictly_public"] # Custom domain. Wrangler claims api.ooxml.dev for this worker on deploy and # manages the DNS record on Cloudflare's side; we don't add it manually. @@ -9,13 +9,16 @@ routes = [ { pattern = "api.ooxml.dev", custom_domain = true } ] +[[kv_namespaces]] +binding = "OAUTH_KV" +id = "ba18d2194ddf4ebf8a057f96cf24e6aa" + # Secrets (set via wrangler secret): # wrangler secret put DATABASE_URL # wrangler secret put VOYAGE_API_KEY +# wrangler secret put CLERK_PUBLISHABLE_KEY +# wrangler secret put CLERK_SECRET_KEY [observability] enabled = true head_sampling_rate = 1 - -[vars] -# Non-secret environment variables can go here diff --git a/apps/web/.env.example b/apps/web/.env.example index 2cf82d8..a4573bd 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -2,3 +2,6 @@ # Local dev: http://localhost:8787 # Production: https://api.ooxml.dev VITE_API_URL=http://localhost:8787 + +# Public browser key used by the custom sign-in and sign-up pages. +VITE_CLERK_PUBLISHABLE_KEY=pk_test_... diff --git a/apps/web/package.json b/apps/web/package.json index 7353769..23533b7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,11 +6,14 @@ "scripts": { "dev": "vite", "build": "tsc && vite build && bun scripts/prerender.ts", + "build:prod": "bun --env-file=${OOXML_PROD_ENV_FILE:-../../.env.prod} scripts/validate-production-env.ts && bun --env-file=${OOXML_PROD_ENV_FILE:-../../.env.prod} run build", "preview": "vite preview", "typecheck": "tsc --noEmit", - "deploy": "bun run build && wrangler pages deploy dist --project-name=ooxml-dev" + "deploy": "bun run build:prod && wrangler pages deploy dist --project-name=ooxml-dev --branch=main --commit-dirty=true" }, "dependencies": { + "@clerk/react": "^6.14.1", + "@clerk/shared": "^4.28.1", "clsx": "^2.1.1", "fumadocs-core": "^16.4.9", "fumadocs-ui": "^16.4.9", diff --git a/apps/web/scripts/prerender.ts b/apps/web/scripts/prerender.ts index 32b6b1b..4cc98dc 100644 --- a/apps/web/scripts/prerender.ts +++ b/apps/web/scripts/prerender.ts @@ -14,6 +14,10 @@ import { getAllPaths, getSeoMeta } from "../src/data/seo"; const DIST = resolve(import.meta.dir, "../dist"); const SITE_URL = "https://ooxml.dev"; +const AUTH_PAGES = [ + { path: "/sign-in", title: "Sign in | ooxml.dev" }, + { path: "/sign-up", title: "Create an account | ooxml.dev" }, +]; // Read the built index.html as template const template = readFileSync(resolve(DIST, "index.html"), "utf-8"); @@ -324,6 +328,16 @@ function render404Page(): string { return html; } +function renderAuthPage(title: string): string { + let html = template; + html = html.replace(/[^<]*<\/title>/, `<title>${escapeHtml(title)}`); + html = html.replace( + "", + ' \n ', + ); + return html; +} + // --- Main --- const paths = getAllPaths(); @@ -340,6 +354,14 @@ for (const path of paths) { console.log(` ✓ ${path}`); } +// Auth routes need real output files for direct Clerk redirects, but stay out of the sitemap. +for (const authPage of AUTH_PAGES) { + const filePath = resolve(DIST, `${authPage.path.slice(1)}/index.html`); + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, renderAuthPage(authPage.title)); + console.log(` ✓ ${authPage.path}`); +} + // Generate 404 page (Cloudflare Pages serves this with 404 status) const notFoundHtml = render404Page(); writeFileSync(resolve(DIST, "404.html"), notFoundHtml); @@ -420,4 +442,6 @@ const llmsFullTxt = generateLlmsFullTxt(); writeFileSync(resolve(DIST, "llms-full.txt"), llmsFullTxt); console.log(` ✓ /llms-full.txt`); -console.log(`\nPre-rendered ${count} pages + 404 + sitemap + llms-full.txt.`); +console.log( + `\nPre-rendered ${count} pages + ${AUTH_PAGES.length} auth routes + 404 + sitemap + llms-full.txt.`, +); diff --git a/apps/web/scripts/validate-production-env.ts b/apps/web/scripts/validate-production-env.ts new file mode 100644 index 0000000..1446a2c --- /dev/null +++ b/apps/web/scripts/validate-production-env.ts @@ -0,0 +1,11 @@ +const publishableKey = process.env.VITE_CLERK_PUBLISHABLE_KEY ?? process.env.CLERK_PUBLISHABLE_KEY; + +if (!publishableKey) { + throw new Error("A Clerk publishable key is missing from the production environment file"); +} + +if (!publishableKey.startsWith("pk_live_")) { + throw new Error("Production builds require a live Clerk publishable key"); +} + +console.log("✓ Production Clerk publishable key is configured"); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index d428470..8d6e5dd 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -16,6 +16,28 @@ const router = createBrowserRouter([ { path: "/", element: }, { path: "/mcp", element: }, { path: "/spec", element: }, + { + lazy: async () => { + const { AuthProvider } = await import("./pages/auth/AuthProvider"); + return { Component: AuthProvider }; + }, + children: [ + { + path: "/sign-in/*", + lazy: async () => { + const { SignIn } = await import("./pages/auth/SignIn"); + return { Component: SignIn }; + }, + }, + { + path: "/sign-up/*", + lazy: async () => { + const { SignUp } = await import("./pages/auth/SignUp"); + return { Component: SignUp }; + }, + }, + ], + }, { path: "/docs", element: , diff --git a/apps/web/src/pages/auth/AuthCard.tsx b/apps/web/src/pages/auth/AuthCard.tsx new file mode 100644 index 0000000..9da3e06 --- /dev/null +++ b/apps/web/src/pages/auth/AuthCard.tsx @@ -0,0 +1,181 @@ +import { type ReactNode, useEffect } from "react"; +import { Link } from "react-router-dom"; + +interface AuthPageProps { + title: string; + children: ReactNode; +} + +interface AuthCardProps { + title: string; + subtitle: ReactNode; + children?: ReactNode; + footer?: ReactNode; +} + +interface AuthInputProps { + id: string; + label: string; + type?: "email" | "text"; + value: string; + onChange: (value: string) => void; + placeholder: string; + autoComplete: string; + inputMode?: "email" | "numeric"; + maxLength?: number; + invalid?: boolean; + autoFocus?: boolean; +} + +export function AuthPage({ title, children }: AuthPageProps) { + useEffect(() => { + const previousTitle = document.title; + const existingRobots = document.querySelector('meta[name="robots"]'); + const previousRobots = existingRobots?.content; + const existingAuthMeta = existingRobots?.hasAttribute("data-auth-page") ?? false; + const robots = existingRobots ?? document.createElement("meta"); + + if (!existingRobots) { + robots.name = "robots"; + document.head.appendChild(robots); + } + + document.title = title; + robots.content = "noindex, nofollow"; + robots.setAttribute("data-auth-page", ""); + + return () => { + document.title = previousTitle; + if (existingRobots && !existingAuthMeta && previousRobots !== undefined) { + existingRobots.content = previousRobots; + existingRobots.removeAttribute("data-auth-page"); + } else { + robots.remove(); + } + }; + }, [title]); + + return ( +
+ {children} +
+ ); +} + +export function AuthCard({ title, subtitle, children, footer }: AuthCardProps) { + return ( +
+ + + < + + ooxml.dev + + /> + + + +

+ {title} +

+

+ {subtitle} +

+ + {children} + + {footer ? ( +
+ {footer} +
+ ) : null} +
+ ); +} + +export function AuthInput({ + id, + label, + type = "text", + value, + onChange, + placeholder, + autoComplete, + inputMode, + maxLength, + invalid = false, + autoFocus = false, +}: AuthInputProps) { + return ( +
+ + onChange(event.target.value)} + placeholder={placeholder} + autoComplete={autoComplete} + inputMode={inputMode} + maxLength={maxLength} + autoFocus={autoFocus} + required + aria-invalid={invalid} + className="w-full rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-bg-primary)] px-3 py-2.5 text-sm text-[var(--color-text-primary)] outline-none transition-[border-color,box-shadow] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)] focus:shadow-[0_0_0_3px_rgba(194,65,12,0.12)]" + /> +
+ ); +} + +export function AuthSubmitButton({ + children, + disabled, +}: { + children: ReactNode; + disabled?: boolean; +}) { + return ( + + ); +} + +export function AuthMessage({ + children, + tone = "error", +}: { + children: ReactNode; + tone?: "error" | "info"; +}) { + return ( +

+ {children} +

+ ); +} + +export const authLinkClassName = + "font-medium text-[var(--color-accent)] no-underline hover:text-[var(--color-accent-hover)] hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)]"; diff --git a/apps/web/src/pages/auth/AuthProvider.tsx b/apps/web/src/pages/auth/AuthProvider.tsx new file mode 100644 index 0000000..b2ac325 --- /dev/null +++ b/apps/web/src/pages/auth/AuthProvider.tsx @@ -0,0 +1,97 @@ +import { ClerkFailed, ClerkLoaded, ClerkLoading, ClerkProvider, useAuth } from "@clerk/react"; +import { useEffect, useRef, useState } from "react"; +import { Outlet, useNavigate } from "react-router-dom"; +import { AuthCard, AuthPage } from "./AuthCard"; +import { useAuthContinueNavigation } from "./useAuthNavigation"; + +const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY; + +function AuthRoutes() { + const { isLoaded, isSignedIn } = useAuth(); + const continueNavigation = useAuthContinueNavigation(); + const [sessionOnEntry, setSessionOnEntry] = useState(); + const hasContinued = useRef(false); + + useEffect(() => { + if (sessionOnEntry === undefined && isLoaded) { + // Freeze the entry state so a session created by these forms finishes through finalize(). + setSessionOnEntry(isSignedIn === true); + } + }, [isLoaded, isSignedIn, sessionOnEntry]); + + useEffect(() => { + if (sessionOnEntry !== true || hasContinued.current) return; + hasContinued.current = true; + void continueNavigation(); + }, [continueNavigation, sessionOnEntry]); + + if (sessionOnEntry !== false) { + const isContinuing = sessionOnEntry === true; + return ( + + + + ); + } + + return ; +} + +export function AuthProvider() { + const navigate = useNavigate(); + + if (!clerkPublishableKey) { + return ( + + + + ); + } + + const navigateWithRouter = (to: string, replace: boolean) => { + const destination = new URL(to, window.location.origin); + if (destination.origin !== window.location.origin) { + window.location.assign(destination.toString()); + return; + } + + void navigate(`${destination.pathname}${destination.search}${destination.hash}`, { replace }); + }; + + return ( + navigateWithRouter(to, false)} + routerReplace={(to) => navigateWithRouter(to, true)} + // The auth screens are custom, so Clerk's prebuilt UI bundle is unnecessary here. + prefetchUI={false} + > + + + + + + + + + + + + + + + ); +} diff --git a/apps/web/src/pages/auth/SignIn.tsx b/apps/web/src/pages/auth/SignIn.tsx new file mode 100644 index 0000000..4f38cae --- /dev/null +++ b/apps/web/src/pages/auth/SignIn.tsx @@ -0,0 +1,205 @@ +import { useSignIn } from "@clerk/react"; +import { type FormEvent, useState } from "react"; +import { Link, useLocation } from "react-router-dom"; +import { + AuthCard, + AuthInput, + AuthMessage, + AuthPage, + AuthSubmitButton, + authLinkClassName, +} from "./AuthCard"; +import { useAuthFinalizeNavigation } from "./useAuthNavigation"; + +type Step = "email" | "code"; + +function displayError(error: { longMessage?: string; message?: string } | null | undefined) { + return error?.longMessage ?? error?.message; +} + +export function SignIn() { + const { signIn, errors, fetchStatus } = useSignIn(); + const finalizeNavigation = useAuthFinalizeNavigation(); + const location = useLocation(); + const [step, setStep] = useState("email"); + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [localError, setLocalError] = useState(); + const [notice, setNotice] = useState(); + const isLoading = fetchStatus === "fetching"; + const fieldError = step === "email" ? errors.fields.identifier : errors.fields.code; + const errorMessage = localError ?? displayError(fieldError) ?? displayError(errors.global?.[0]); + + async function sendCode(event: FormEvent) { + event.preventDefault(); + setLocalError(undefined); + setNotice(undefined); + + try { + const result = await signIn.emailCode.sendCode({ emailAddress: email.trim() }); + if (result.error) { + setLocalError(displayError(result.error) ?? "We couldn't send the code. Try again."); + return; + } + + setEmail(email.trim()); + setStep("code"); + } catch { + setLocalError("We couldn't send the code. Check your connection and try again."); + } + } + + async function verifyCode(event: FormEvent) { + event.preventDefault(); + setLocalError(undefined); + setNotice(undefined); + + try { + const verification = await signIn.emailCode.verifyCode({ code }); + if (verification.error) { + setLocalError( + displayError(verification.error) ?? "That code didn't work. Check it and try again.", + ); + return; + } + + if (signIn.status !== "complete") { + setLocalError( + "This account needs another verification step that isn't supported here yet.", + ); + return; + } + + const finalized = await signIn.finalize({ navigate: finalizeNavigation }); + if (finalized.error) { + setLocalError(displayError(finalized.error) ?? "We couldn't finish signing you in."); + } + } catch { + setLocalError("We couldn't verify the code. Check your connection and try again."); + } + } + + async function resendCode() { + setLocalError(undefined); + setNotice(undefined); + + try { + const result = await signIn.emailCode.sendCode(); + if (result.error) { + setLocalError(displayError(result.error) ?? "We couldn't send another code."); + return; + } + setNotice("A new code is on its way."); + } catch { + setLocalError("We couldn't send another code. Try again."); + } + } + + async function changeEmail() { + if (isLoading) return; + await signIn.reset(); + setCode(""); + setLocalError(undefined); + setNotice(undefined); + setStep("email"); + } + + if (step === "code") { + return ( + + + Enter the six-digit code we sent to {email}. + + } + footer={ + <> + Wrong email?{" "} + + + } + > +
+ setCode(value.replace(/\D/g, "").slice(0, 6))} + placeholder="123456" + autoComplete="one-time-code" + inputMode="numeric" + maxLength={6} + invalid={Boolean(errorMessage)} + autoFocus + /> + {errorMessage ? {errorMessage} : null} + {notice ? {notice} : null} + + {isLoading ? "Checking…" : "Verify code"} + + +

+ Didn't get it?{" "} + +

+
+
+ ); + } + + return ( + + + New here?{" "} + + Create an account + + + } + > +
+ +
+ {errorMessage ? {errorMessage} : null} + + {isLoading ? "Sending code…" : "Continue"} + + + + + ); +} diff --git a/apps/web/src/pages/auth/SignUp.tsx b/apps/web/src/pages/auth/SignUp.tsx new file mode 100644 index 0000000..3de0018 --- /dev/null +++ b/apps/web/src/pages/auth/SignUp.tsx @@ -0,0 +1,209 @@ +import { useSignUp } from "@clerk/react"; +import { type FormEvent, useState } from "react"; +import { Link, useLocation } from "react-router-dom"; +import { + AuthCard, + AuthInput, + AuthMessage, + AuthPage, + AuthSubmitButton, + authLinkClassName, +} from "./AuthCard"; +import { useAuthFinalizeNavigation } from "./useAuthNavigation"; + +type Step = "email" | "code"; + +function displayError(error: { longMessage?: string; message?: string } | null | undefined) { + return error?.longMessage ?? error?.message; +} + +export function SignUp() { + const { signUp, errors, fetchStatus } = useSignUp(); + const finalizeNavigation = useAuthFinalizeNavigation(); + const location = useLocation(); + const [step, setStep] = useState("email"); + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [localError, setLocalError] = useState(); + const [notice, setNotice] = useState(); + const isLoading = fetchStatus === "fetching"; + const fieldError = step === "email" ? errors.fields.emailAddress : errors.fields.code; + const errorMessage = localError ?? displayError(fieldError) ?? displayError(errors.global?.[0]); + + async function sendCode(event: FormEvent) { + event.preventDefault(); + setLocalError(undefined); + setNotice(undefined); + + try { + const created = await signUp.create({ emailAddress: email.trim() }); + if (created.error) { + setLocalError(displayError(created.error) ?? "We couldn't create the account. Try again."); + return; + } + + const sent = await signUp.verifications.sendEmailCode(); + if (sent.error) { + setLocalError(displayError(sent.error) ?? "We couldn't send the code. Try again."); + return; + } + + setEmail(email.trim()); + setStep("code"); + } catch { + setLocalError("We couldn't create the account. Check your connection and try again."); + } + } + + async function verifyCode(event: FormEvent) { + event.preventDefault(); + setLocalError(undefined); + setNotice(undefined); + + try { + const verification = await signUp.verifications.verifyEmailCode({ code }); + if (verification.error) { + setLocalError( + displayError(verification.error) ?? "That code didn't work. Check it and try again.", + ); + return; + } + + if (signUp.status !== "complete") { + setLocalError("This account needs more information that isn't supported here yet."); + return; + } + + const finalized = await signUp.finalize({ navigate: finalizeNavigation }); + if (finalized.error) { + setLocalError(displayError(finalized.error) ?? "We couldn't finish creating the account."); + } + } catch { + setLocalError("We couldn't verify the code. Check your connection and try again."); + } + } + + async function resendCode() { + setLocalError(undefined); + setNotice(undefined); + + try { + const result = await signUp.verifications.sendEmailCode(); + if (result.error) { + setLocalError(displayError(result.error) ?? "We couldn't send another code."); + return; + } + setNotice("A new code is on its way."); + } catch { + setLocalError("We couldn't send another code. Try again."); + } + } + + async function changeEmail() { + if (isLoading) return; + await signUp.reset(); + setCode(""); + setLocalError(undefined); + setNotice(undefined); + setStep("email"); + } + + if (step === "code") { + return ( + + + Enter the six-digit code we sent to {email}. + + } + footer={ + <> + Wrong email?{" "} + + + } + > +
+ setCode(value.replace(/\D/g, "").slice(0, 6))} + placeholder="123456" + autoComplete="one-time-code" + inputMode="numeric" + maxLength={6} + invalid={Boolean(errorMessage)} + autoFocus + /> + {errorMessage ? {errorMessage} : null} + {notice ? {notice} : null} + + {isLoading ? "Checking…" : "Verify code"} + + +

+ Didn't get it?{" "} + +

+
+
+ ); + } + + return ( + + + Already have one?{" "} + + Sign in + + + } + > +
+ +
+ {errorMessage ? {errorMessage} : null} + + {isLoading ? "Sending code…" : "Continue"} + + + + + ); +} diff --git a/apps/web/src/pages/auth/useAuthNavigation.ts b/apps/web/src/pages/auth/useAuthNavigation.ts new file mode 100644 index 0000000..144ce96 --- /dev/null +++ b/apps/web/src/pages/auth/useAuthNavigation.ts @@ -0,0 +1,96 @@ +import { useClerk } from "@clerk/react"; +import { buildAccountsBaseUrl } from "@clerk/shared/buildAccountsBaseUrl"; +import { useCallback } from "react"; +import { useNavigate } from "react-router-dom"; + +interface FinalizeNavigationParams { + decorateUrl: (url: string) => string; +} + +type UrlDecorator = (url: string) => string; + +const keepUrl = (url: string) => url; +const DEFAULT_MCP_API_URL = "https://api.ooxml.dev"; + +function isMcpAuthorizationRedirect(destination: URL, mcpApiUrl: string): boolean { + return destination.origin === new URL(mcpApiUrl).origin && destination.pathname === "/authorize"; +} + +export function safeRequestedRedirect( + frontendApi: string, + requested: string | null, + currentOrigin: string, + mcpApiUrl = DEFAULT_MCP_API_URL, +): string { + if (!requested) return "/"; + + try { + const destination = new URL(requested, currentOrigin); + const clerkOrigin = new URL( + frontendApi.includes("://") ? frontendApi : `https://${frontendApi}`, + ).origin; + const accountsOrigin = new URL(buildAccountsBaseUrl(frontendApi)).origin; + + // Clerk owns sign-in, while the API owns MCP authorization and consent. + if ( + destination.origin !== currentOrigin && + destination.origin !== clerkOrigin && + destination.origin !== accountsOrigin && + !isMcpAuthorizationRedirect(destination, mcpApiUrl) + ) { + return "/"; + } + + if (destination.origin !== currentOrigin) return destination.toString(); + + // A path beginning with `//` is reinterpreted as a different host when it is + // later passed back to URL. Keep same-origin redirects as local paths. + const pathname = destination.pathname.replace(/^\/+/, "/"); + return `${pathname}${destination.search}${destination.hash}`; + } catch { + return "/"; + } +} + +function useRequestedRedirectNavigation() { + const clerk = useClerk(); + const navigate = useNavigate(); + + return useCallback( + (decorateUrl: UrlDecorator) => { + const requested = safeRequestedRedirect( + clerk.frontendApi, + new URLSearchParams(window.location.search).get("redirect_url"), + window.location.origin, + import.meta.env.VITE_API_URL ?? DEFAULT_MCP_API_URL, + ); + const decorated = decorateUrl(requested); + const destination = new URL(decorated, window.location.origin); + + if (destination.origin !== window.location.origin) { + window.location.assign(destination.toString()); + return; + } + + return navigate(`${destination.pathname}${destination.search}${destination.hash}`, { + replace: true, + }); + }, + [clerk.frontendApi, navigate], + ); +} + +export function useAuthContinueNavigation() { + const navigateToRequestedRedirect = useRequestedRedirectNavigation(); + + return useCallback(() => navigateToRequestedRedirect(keepUrl), [navigateToRequestedRedirect]); +} + +export function useAuthFinalizeNavigation() { + const navigateToRequestedRedirect = useRequestedRedirectNavigation(); + + return useCallback( + ({ decorateUrl }: FinalizeNavigationParams) => navigateToRequestedRedirect(decorateUrl), + [navigateToRequestedRedirect], + ); +} diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 5e4be34..46c69eb 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -1,5 +1,14 @@ /// +interface ImportMetaEnv { + readonly VITE_CLERK_PUBLISHABLE_KEY?: string; + readonly VITE_API_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} + declare module "*.png" { const src: string; export default src; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 0e3c3db..ce3dfab 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,13 +1,31 @@ import { resolve } from "node:path"; import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; -import { defineConfig } from "vite"; +import { defineConfig, loadEnv } from "vite"; -export default defineConfig({ - plugins: [tailwindcss(), react()], - resolve: { - alias: { - "@": resolve(__dirname, "src"), +export default defineConfig(({ mode }) => { + const envKeys = ["VITE_CLERK_PUBLISHABLE_KEY", "CLERK_PUBLISHABLE_KEY"]; + const rootEnv = loadEnv(mode, resolve(__dirname, "../.."), envKeys); + const webEnv = loadEnv(mode, __dirname, envKeys); + const clerkPublishableKey = + process.env.VITE_CLERK_PUBLISHABLE_KEY ?? + process.env.CLERK_PUBLISHABLE_KEY ?? + webEnv.VITE_CLERK_PUBLISHABLE_KEY ?? + webEnv.CLERK_PUBLISHABLE_KEY ?? + rootEnv.VITE_CLERK_PUBLISHABLE_KEY ?? + rootEnv.CLERK_PUBLISHABLE_KEY ?? + ""; + + return { + plugins: [tailwindcss(), react()], + // The browser only receives Clerk's public key; secret keys remain server-only. + define: { + "import.meta.env.VITE_CLERK_PUBLISHABLE_KEY": JSON.stringify(clerkPublishableKey), + }, + resolve: { + alias: { + "@": resolve(__dirname, "src"), + }, }, - }, + }; }); diff --git a/bun.lock b/bun.lock index a026652..a61b964 100644 --- a/bun.lock +++ b/bun.lock @@ -18,11 +18,14 @@ }, "apps/mcp-server": { "name": "@ooxml-dev/mcp-server", - "version": "0.13.1", + "version": "1.3.0", "dependencies": { - "@modelcontextprotocol/sdk": "^1.25.3", + "@clerk/backend": "^3.16.3", + "@cloudflare/workers-oauth-provider": "^0.10.3", + "@modelcontextprotocol/server": "2.0.0", "@neondatabase/serverless": "^1.0.2", "@ooxml-dev/shared": "workspace:*", + "zod": "^4.2.0", }, "devDependencies": { "@cloudflare/workers-types": "^4.20260127.0", @@ -32,8 +35,10 @@ }, "apps/web": { "name": "@ooxml-dev/web", - "version": "0.13.1", + "version": "1.3.0", "dependencies": { + "@clerk/react": "^6.14.1", + "@clerk/shared": "^4.28.1", "clsx": "^2.1.1", "fumadocs-core": "^16.4.9", "fumadocs-ui": "^16.4.9", @@ -131,6 +136,12 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.13", "", { "os": "win32", "cpu": "x64" }, "sha512-trDw2ogdM2lyav9WFQsdsfdVy1dvZALymRpgmWsvSez0BJzBjulhOT/t+wyKeh3pZWvwP3VMs1SoOKwO3wecMQ=="], + "@clerk/backend": ["@clerk/backend@3.16.3", "", { "dependencies": { "@clerk/shared": "^4.28.1", "standardwebhooks": "^1.0.0", "tslib": "2.8.1" } }, "sha512-6y2jfFVavG1rcRdlTdLgFWPaWVtDzzFZLXSq2Acca+KnkI2VYPjnHJqu8rAxyuRW/aOSsDSSuM8oeaHttB1VIQ=="], + + "@clerk/react": ["@clerk/react@6.14.1", "", { "dependencies": { "@clerk/shared": "^4.28.1", "tslib": "2.8.1" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" } }, "sha512-rSFJUyfOuMeySbmkTv+g4DpPp492lpgVKERtrzm0wo+PwnWXoGjVkrpWb6mZMKLQAvq2dU2LnKitljcbLDKUXA=="], + + "@clerk/shared": ["@clerk/shared@4.28.1", "", { "dependencies": { "@tanstack/query-core": "^5.100.6", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.7" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-OhpUczN6t8CYJ+g8HdzN1O4SFC2EANOZRDwlE3rXxKu13eNtyFbLspt+d6Nhb/PmcThS/fIAKYnQQrwv0vIEwg=="], + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.11.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "^1.20260115.0" }, "optionalPeers": ["workerd"] }, "sha512-z3hxFajL765VniNPGV0JRStZolNz63gU3B3AktwoGdDlnQvz5nP+Ah4RL04PONlZQjwmDdGHowEStJ94+RsaJg=="], @@ -145,6 +156,8 @@ "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260124.0", "", { "os": "win32", "cpu": "x64" }, "sha512-j9O11pwQQV6Vi3peNrJoyIas3SrZHlPj0Ah+z1hDW9o1v35euVBQJw/PuzjPOXxTFUlGQoMJdfzPsO9xP86g7A=="], + "@cloudflare/workers-oauth-provider": ["@cloudflare/workers-oauth-provider@0.10.3", "", {}, "sha512-25ufMONJir9PllqVpK4GwOOoSFgpYm3+bM6NBedj7ufMCIfNf5jk4OI0LPuTJBaJgLl2lGCLqFqEPJXcSuPopQ=="], + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260127.0", "", {}, "sha512-4M1HLcWViSdT/pAeDGEB5x5P3sqW7UIi34QrBRnxXbqjAY9if8vBU/lWRWnM+UqKzxWGB2LYjEVOzZrp0jZL+w=="], "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], @@ -219,8 +232,6 @@ "@fumadocs/ui": ["@fumadocs/ui@16.4.9", "", { "dependencies": { "next-themes": "^0.4.6", "postcss-selector-parser": "^7.1.1", "tailwind-merge": "^3.4.0" }, "peerDependencies": { "@types/react": "*", "fumadocs-core": "16.4.9", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", "tailwindcss": "^4.0.0" }, "optionalPeers": ["@types/react", "next", "tailwindcss"] }, "sha512-MRIwJHm3SObwwPYqsMX4DfY71PYCtuXyBRI+g7NjbhdOD4HNnB2cOZf81I44+DCDvlRlnx6nf/SgsESSY/7cxQ=="], - "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], - "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], @@ -281,7 +292,9 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.3", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "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.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ=="], + "@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.0.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA=="], + + "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "zod": "^4.2.0" } }, "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw=="], "@neondatabase/serverless": ["@neondatabase/serverless@1.0.2", "", { "dependencies": { "@types/node": "^22.15.30", "@types/pg": "^8.8.0" } }, "sha512-I5sbpSIAHiB+b6UttofhrN/UJXII+4tZPAq1qugzwCwLIL8EZLV7F/JyHUrEIiGgQpEXzpnjlJ+zwcEhheGvCw=="], @@ -489,6 +502,8 @@ "@speed-highlight/core": ["@speed-highlight/core@1.2.14", "", {}, "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA=="], + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], + "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], @@ -519,6 +534,8 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], + "@tanstack/query-core": ["@tanstack/query-core@5.101.4", "", {}, "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -555,16 +572,10 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="], - "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], - "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], - - "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - "ansi-escapes": ["ansi-escapes@7.2.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw=="], "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], @@ -589,20 +600,12 @@ "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], - "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], - "bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], - "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], - - "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - - "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "caniuse-lite": ["caniuse-lite@1.0.30001766", "", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="], @@ -645,10 +648,6 @@ "config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="], - "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], - - "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], - "conventional-changelog-angular": ["conventional-changelog-angular@8.1.0", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-GGf2Nipn1RUCAktxuVauVr1e3r8QrLP/B0lEUsFktmGqc3ddbQkhoJZHJctVU829U1c6mTSWftrVOCHaL85Q3w=="], "conventional-changelog-writer": ["conventional-changelog-writer@8.2.0", "", { "dependencies": { "conventional-commits-filter": "^5.0.0", "handlebars": "^4.7.7", "meow": "^13.0.0", "semver": "^7.5.2" }, "bin": { "conventional-changelog-writer": "dist/cli/index.js" } }, "sha512-Y2aW4596l9AEvFJRwFGJGiQjt2sBYTjPD18DdvxX9Vpz0Z7HQ+g1Z+6iYDAm1vR3QOJrDBkRHixHK/+FhkR6Pw=="], @@ -661,14 +660,10 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], - - "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], - "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], - "cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -685,8 +680,6 @@ "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], - "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], - "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -699,20 +692,14 @@ "dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="], - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="], - "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "electron-to-chromium": ["electron-to-chromium@1.5.279", "", {}, "sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg=="], "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "emojilib": ["emojilib@2.4.0", "", {}, "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw=="], - "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - "enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="], "env-ci": ["env-ci@11.2.0", "", { "dependencies": { "execa": "^8.0.0", "java-properties": "^1.0.2" } }, "sha512-D5kWfzkmaOQDioPmiviWAVtKmpPT4/iJmMVQxWxMPJTFyTkdc5JQUfc5iXEeWxcOdsYTKSAiA/Age4NUOqKsRA=="], @@ -725,18 +712,10 @@ "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], - "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], - - "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - - "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], - "esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], - "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "estree-util-attach-comments": ["estree-util-attach-comments@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw=="], @@ -745,25 +724,13 @@ "estree-util-value-to-estree": ["estree-util-value-to-estree@3.5.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-aMV56R27Gv3QmfmF1MY12GWkGzzeAezAX+UplqHVASfjc9wNzI/X6hC0S9oxq61WT4aQesLGslWP9tKk6ghRZQ=="], - "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], - - "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], - - "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], - "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], - "express": ["express@5.2.1", "", { "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" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - - "express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="], - "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - - "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], "fast-xml-builder": ["fast-xml-builder@1.1.5", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA=="], @@ -775,18 +742,12 @@ "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], - "finalhandler": ["finalhandler@2.1.1", "", { "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" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], - "find-up": ["find-up@2.1.0", "", { "dependencies": { "locate-path": "^2.0.0" } }, "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ=="], "find-up-simple": ["find-up-simple@1.0.1", "", {}, "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ=="], "find-versions": ["find-versions@6.0.0", "", { "dependencies": { "semver-regex": "^4.0.5", "super-regex": "^1.0.0" } }, "sha512-2kCCtc+JvcZ86IGAz3Z2Y0A1baIz9fL31pH/0S1IqZr9Iwnjq8izfPtrCyQKO6TLMPELLsQMre7VDqeIKCsHkA=="], - "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], - - "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], - "from2": ["from2@2.3.0", "", { "dependencies": { "inherits": "^2.0.1", "readable-stream": "^2.0.0" } }, "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g=="], "fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="], @@ -797,8 +758,6 @@ "fumadocs-ui": ["fumadocs-ui@16.4.9", "", { "dependencies": { "@fumadocs/ui": "16.4.9", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-direction": "^1.1.1", "@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-presence": "^1.1.5", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "class-variance-authority": "^0.7.1", "lucide-react": "^0.563.0", "next-themes": "^0.4.6", "react-medium-image-zoom": "^5.4.0", "scroll-into-view-if-needed": "^3.1.0" }, "peerDependencies": { "@types/react": "*", "fumadocs-core": "16.4.9", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", "tailwindcss": "^4.0.0" }, "optionalPeers": ["@types/react", "next", "tailwindcss"] }, "sha512-6OY9zAHz8FUkE/jQ5C1YI540j3UIMIjS40woF4TYSGyTVSzR5cYTQ8Bf1IfjvRzaUR0nv7A6tR3CWX+Kfv6pDQ=="], - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - "function-timeout": ["function-timeout@1.0.2", "", {}, "sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], @@ -807,19 +766,15 @@ "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], - "get-intrinsic": ["get-intrinsic@1.3.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" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], - "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], - "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - "get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "git-log-parser": ["git-log-parser@1.2.1", "", { "dependencies": { "argv-formatter": "~1.0.0", "spawn-error-forwarder": "~1.0.0", "split2": "~1.0.0", "stream-combiner2": "~1.1.1", "through2": "~2.0.0", "traverse": "0.6.8" } }, "sha512-PI+sPDvHXNPl5WNOErAK05s3j0lgwUzMN6o8cyQrDaKfT3qd7TmNJKeXX+SknI5I0QhG5fVPAEwSY4tRGDtYoQ=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], - "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], @@ -827,10 +782,6 @@ "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], - - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - "hast-util-to-estree": ["hast-util-to-estree@3.1.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-attach-comments": "^3.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w=="], "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], @@ -843,24 +794,18 @@ "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], - "hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="], - "hook-std": ["hook-std@4.0.0", "", {}, "sha512-IHI4bEVOt3vRUDJ+bFA9VUJlo7SzvFARPNLw75pqSmAOP2HmTWfFJtPvLBrDrlgjEYXY9zs7SFdHPQaJShkSCQ=="], "hosted-git-info": ["hosted-git-info@9.0.2", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg=="], "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], - "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], - "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - "image-size": ["image-size@2.0.2", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w=="], "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], @@ -883,8 +828,6 @@ "into-stream": ["into-stream@7.0.0", "", { "dependencies": { "from2": "^2.3.0", "p-is-promise": "^3.0.0" } }, "sha512-2dYz766i9HprMBasCMvHMuazJ7u4WzhJwo5kb3iPSiW/iRYV6uPari3zHoqZlnuaR7V1bEiNMxikhp37rdBXbw=="], - "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], - "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], @@ -903,8 +846,6 @@ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], - "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], - "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], @@ -919,7 +860,7 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], + "js-cookie": ["js-cookie@3.0.7", "", {}, "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -931,10 +872,6 @@ "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], - "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], - "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], @@ -1027,8 +964,6 @@ "marked-terminal": ["marked-terminal@7.3.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "ansi-regex": "^6.1.0", "chalk": "^5.4.1", "cli-highlight": "^2.1.11", "cli-table3": "^0.6.5", "node-emoji": "^2.2.0", "supports-hyperlinks": "^3.1.0" }, "peerDependencies": { "marked": ">=1 <16" } }, "sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw=="], - "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="], @@ -1059,12 +994,8 @@ "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], - "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], - "meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], - "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], - "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], @@ -1127,10 +1058,6 @@ "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], - "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - - "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], "miniflare": ["miniflare@4.20260124.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.18.2", "workerd": "1.20260124.0", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-Co8onUh+POwOuLty4myQg+Nzg9/xZ5eAJc1oqYBzRovHd/XIpb5WAnRVaubcfAQJ85awWtF3yXUHCDx6cIaN3w=="], @@ -1167,14 +1094,8 @@ "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], - "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "obuf": ["obuf@1.1.2", "", {}, "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="], - "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], - - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], @@ -1215,8 +1136,6 @@ "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@6.0.1", "", { "dependencies": { "parse5": "^6.0.1" } }, "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA=="], - "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], - "path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], @@ -1243,8 +1162,6 @@ "pify": ["pify@3.0.0", "", {}, "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg=="], - "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], - "pkg-conf": ["pkg-conf@2.1.0", "", { "dependencies": { "find-up": "^2.0.0", "load-json-file": "^4.0.0" } }, "sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], @@ -1271,14 +1188,6 @@ "proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="], - "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], - - "qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="], - - "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], - - "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], - "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], @@ -1325,18 +1234,12 @@ "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], - "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], "rollup": ["rollup@4.57.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.0", "@rollup/rollup-android-arm64": "4.57.0", "@rollup/rollup-darwin-arm64": "4.57.0", "@rollup/rollup-darwin-x64": "4.57.0", "@rollup/rollup-freebsd-arm64": "4.57.0", "@rollup/rollup-freebsd-x64": "4.57.0", "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", "@rollup/rollup-linux-arm-musleabihf": "4.57.0", "@rollup/rollup-linux-arm64-gnu": "4.57.0", "@rollup/rollup-linux-arm64-musl": "4.57.0", "@rollup/rollup-linux-loong64-gnu": "4.57.0", "@rollup/rollup-linux-loong64-musl": "4.57.0", "@rollup/rollup-linux-ppc64-gnu": "4.57.0", "@rollup/rollup-linux-ppc64-musl": "4.57.0", "@rollup/rollup-linux-riscv64-gnu": "4.57.0", "@rollup/rollup-linux-riscv64-musl": "4.57.0", "@rollup/rollup-linux-s390x-gnu": "4.57.0", "@rollup/rollup-linux-x64-gnu": "4.57.0", "@rollup/rollup-linux-x64-musl": "4.57.0", "@rollup/rollup-openbsd-x64": "4.57.0", "@rollup/rollup-openharmony-arm64": "4.57.0", "@rollup/rollup-win32-arm64-msvc": "4.57.0", "@rollup/rollup-win32-ia32-msvc": "4.57.0", "@rollup/rollup-win32-x64-gnu": "4.57.0", "@rollup/rollup-win32-x64-msvc": "4.57.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA=="], - "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], - "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="], @@ -1349,16 +1252,10 @@ "semver-regex": ["semver-regex@4.0.5", "", {}, "sha512-hunMQrEy1T6Jr2uEVjrAIqjwWcQTgOAcIM52C8MY1EZSD3DDNft04XzvYKPqjED65bNVVko0YI38nYeEHCX3yw=="], - "send": ["send@1.2.1", "", { "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" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], - - "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], - "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], - "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -1367,14 +1264,6 @@ "shiki": ["shiki@3.21.0", "", { "dependencies": { "@shikijs/core": "3.21.0", "@shikijs/engine-javascript": "3.21.0", "@shikijs/engine-oniguruma": "3.21.0", "@shikijs/langs": "3.21.0", "@shikijs/themes": "3.21.0", "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-N65B/3bqL/TI2crrXr+4UivctrAGEjmsib5rPMMPpFp1xAx/w03v8WZ9RDDFYteXoEgY7qZ4HGgl5KBIu1153w=="], - "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], - - "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], - - "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], - - "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "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" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], - "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "signale": ["signale@1.4.0", "", { "dependencies": { "chalk": "^2.3.2", "figures": "^2.0.0", "pkg-conf": "^2.1.0" } }, "sha512-iuh+gPf28RkltuJC7W5MRi6XAjTDCAPC/prJUpQoG4vIP3MJZ+GTydVnodXA7pwvTKb2cA0m9OFZW/cdWy/I/w=="], @@ -1399,7 +1288,7 @@ "split2": ["split2@1.0.0", "", { "dependencies": { "through2": "~2.0.0" } }, "sha512-NKywug4u4pX/AZBB1FCPzZ6/7O+Xhz1qMVbzTvvKvikjO99oPN87SkK08mEY9P63/5lWjK+wgOOgApnTg5r6qg=="], - "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], "stream-combiner2": ["stream-combiner2@1.1.1", "", { "dependencies": { "duplexer2": "~0.1.0", "readable-stream": "^2.0.2" } }, "sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw=="], @@ -1453,8 +1342,6 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], - "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - "traverse": ["traverse@0.6.8", "", {}, "sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], @@ -1467,8 +1354,6 @@ "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], - "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], @@ -1501,8 +1386,6 @@ "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], - "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "url-join": ["url-join@5.0.0", "", {}, "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA=="], @@ -1515,8 +1398,6 @@ "validate-npm-package-license": ["validate-npm-package-license@3.0.4", "", { "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew=="], - "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], @@ -1535,8 +1416,6 @@ "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], @@ -1557,8 +1436,6 @@ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], - "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "@actions/http-client/undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="], @@ -1957,14 +1834,10 @@ "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], - "react-router/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], - "read-package-up/type-fest": ["type-fest@5.4.2", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-FLEenlVYf7Zcd34ISMLo3ZzRE1gRjY1nMDTp+bQRBiPsaKyIW8K3Zr99ioHDUgA9OGuGGJPyYpNcffGmBhJfGg=="], "read-pkg/type-fest": ["type-fest@5.4.2", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-FLEenlVYf7Zcd34ISMLo3ZzRE1gRjY1nMDTp+bQRBiPsaKyIW8K3Zr99ioHDUgA9OGuGGJPyYpNcffGmBhJfGg=="], - "router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], - "semantic-release/aggregate-error": ["aggregate-error@5.0.0", "", { "dependencies": { "clean-stack": "^5.2.0", "indent-string": "^5.0.0" } }, "sha512-gOsf2YwSlleG6IjRYG2A7k0HmBMEo6qVNk9Bp/EaLgAJT5ngH6PXbqa4ItvnEwCm/velL5jAnQgsHsWnjhGmvw=="], "semantic-release/p-reduce": ["p-reduce@3.0.0", "", {}, "sha512-xsrIUgI0Kn6iyDYm9StOpOeK29XM1aboGji26+QEortiFST1hGZaUQOLhtEbqHErPpGW/aSz6allwK2qcptp0Q=="], @@ -1981,8 +1854,6 @@ "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "youch/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], - "@neondatabase/serverless/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "@semantic-release/git/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], diff --git a/db/migrations/0006_mcp_usage_events.sql b/db/migrations/0006_mcp_usage_events.sql new file mode 100644 index 0000000..6f2d8de --- /dev/null +++ b/db/migrations/0006_mcp_usage_events.sql @@ -0,0 +1,18 @@ +-- Keep authenticated MCP usage queryable after Cloudflare log retention ends. +-- Store only Clerk's stable user id and request metadata; names and email stay +-- in Clerk and can be resolved by the admin report when needed. + +CREATE TABLE IF NOT EXISTS mcp_usage_events ( + id BIGSERIAL PRIMARY KEY, + clerk_user_id TEXT NOT NULL, + oauth_client_id TEXT NOT NULL, + tool_name TEXT NOT NULL, + surface TEXT NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_mcp_usage_events_user_time + ON mcp_usage_events(clerk_user_id, occurred_at DESC); + +CREATE INDEX IF NOT EXISTS idx_mcp_usage_events_time + ON mcp_usage_events(occurred_at DESC); diff --git a/db/schema.sql b/db/schema.sql index d7b1ef5..f54302c 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -43,6 +43,22 @@ CREATE INDEX idx_content_part ON spec_content(part_number); CREATE INDEX idx_content_section ON spec_content(section_id); CREATE INDEX idx_content_source ON spec_content(source_id); +-- Authenticated MCP usage is kept separate from spec content because it is +-- operational product data. Clerk remains the source of truth for identity. +CREATE TABLE mcp_usage_events ( + id BIGSERIAL PRIMARY KEY, + clerk_user_id TEXT NOT NULL, + oauth_client_id TEXT NOT NULL, + tool_name TEXT NOT NULL, + surface TEXT NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_mcp_usage_events_user_time + ON mcp_usage_events(clerk_user_id, occurred_at DESC); +CREATE INDEX idx_mcp_usage_events_time + ON mcp_usage_events(occurred_at DESC); + -- ---------------------------------------------------------------------------- -- XSD schema graph -- diff --git a/package.json b/package.json index 1d4317b..501f977 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev": "bun run --cwd apps/web dev", "dev:mcp": "bun run --cwd apps/mcp-server dev", "build": "bun run --cwd apps/web build", + "build:prod": "bun run --cwd apps/web build:prod", "preview": "bun run --cwd apps/web preview", "lint": "biome check .", "format": "biome check --write .", @@ -20,6 +21,7 @@ "db:reset": "docker compose down -v && docker compose up -d", "db:shell": "docker compose exec db psql -U postgres -d ecma_spec", "db:migrate": "bun scripts/db-migrate.ts", + "mcp:users": "bun --env-file=.env apps/mcp-server/scripts/users.ts", "sources:sync": "bun scripts/sources-sync.ts", "pdf:ingest": "bun scripts/ingest-pdf/pipeline.ts", "pdf:chunk": "bun scripts/ingest-pdf/chunk.ts", @@ -28,7 +30,7 @@ "pdf:setup": "pip install -r scripts/requirements.txt", "xsd:fetch": "bun scripts/ingest-xsd/fetch.ts", "xsd:ingest": "bun scripts/ingest-xsd/ingest.ts", - "test": "export TEST_DATABASE_URL=${TEST_DATABASE_URL:-postgresql://postgres:postgres@localhost:5432/ecma_spec} && bun test tests/db/ && bun test tests/ingest-xsd/ && bun test tests/mcp-server/" + "test": "export TEST_DATABASE_URL=${TEST_DATABASE_URL:-postgresql://postgres:postgres@localhost:5432/ecma_spec} && bun test tests/web/ && bun test tests/db/ && bun test tests/ingest-xsd/ && bun test tests/mcp-server/" }, "devDependencies": { "@biomejs/biome": "^2.3.13", diff --git a/tests/mcp-server/mcp-auth.test.ts b/tests/mcp-server/mcp-auth.test.ts new file mode 100644 index 0000000..5606de1 --- /dev/null +++ b/tests/mcp-server/mcp-auth.test.ts @@ -0,0 +1,195 @@ +import { expect, test } from "bun:test"; +import { + createAuthenticatedMcpHandler, + isMcpAuthorizationProps, + MCP_PROTOCOL_VERSION, + MCP_RESOURCE_URL, + type McpAuthorizationProps, + type UsageEvent, +} from "../../apps/mcp-server/src/mcp-auth.ts"; + +const USER_ID = "user_test_mcp"; +const CLIENT_ID = "dynamic_client_test"; +const EXPECTED_TOOL_NAMES = [ + "ooxml_search", + "ooxml_section", + "ooxml_parts", + "ooxml_element", + "ooxml_type", + "ooxml_children", + "ooxml_attributes", + "ooxml_enum", + "ooxml_namespace", + "ooxml_package_part", +]; + +const identity: McpAuthorizationProps = { + userId: USER_ID, + clientId: CLIENT_ID, + scopes: ["profile"], +}; + +function modernRequest(method: string, params: Record): Request { + const headers = new Headers({ + Authorization: "Bearer test-provider-token", + "Content-Type": "application/json", + "MCP-Protocol-Version": MCP_PROTOCOL_VERSION, + "Mcp-Method": method, + }); + if (typeof params.name === "string") headers.set("Mcp-Name", params.name); + + return new Request(MCP_RESOURCE_URL, { + method: "POST", + headers, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method, + params: { + ...params, + _meta: { + "io.modelcontextprotocol/protocolVersion": MCP_PROTOCOL_VERSION, + "io.modelcontextprotocol/clientInfo": { + name: "ooxml-auth-test", + version: "1.0.0", + }, + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + }), + }); +} + +function legacyInitializeRequest(): Request { + return new Request(MCP_RESOURCE_URL, { + method: "POST", + headers: { + Accept: "application/json, text/event-stream", + Authorization: "Bearer test-provider-token", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "codex-mcp-client", version: "0.147.0" }, + }, + }), + }); +} + +test("an OAuth provider identity reaches a real OOXML tool and records identified use", async () => { + expect(MCP_PROTOCOL_VERSION).toBe("2026-07-28"); + expect(MCP_RESOURCE_URL).toBe("https://api.ooxml.dev/mcp"); + const events: UsageEvent[] = []; + const calls: Array<{ name: string; args: Record }> = []; + const handler = createAuthenticatedMcpHandler({ + usageRecorder: { record: (event) => events.push(event) }, + toolExecutor: async (name, args) => { + calls.push({ name, args }); + return "Element w:p"; + }, + now: () => new Date("2026-08-11T12:30:00.000Z"), + }); + + const response = await handler( + modernRequest("tools/call", { + name: "ooxml_element", + arguments: { qname: "w:p" }, + }), + identity, + ); + const body = (await response.json()) as { + result?: { content?: Array<{ text?: string }> }; + }; + + expect(response.status).toBe(200); + expect(body.result?.content?.[0]?.text).toBe("Element w:p"); + expect(calls).toEqual([{ name: "ooxml_element", args: { qname: "w:p" } }]); + expect(events).toEqual([ + { + userId: USER_ID, + tool: "ooxml_element", + surface: "mcp", + client: CLIENT_ID, + occurredAt: "2026-08-11T12:30:00.000Z", + }, + ]); +}); + +test("authenticated tools/list exposes only the public OOXML tools", async () => { + const handler = createAuthenticatedMcpHandler({ + usageRecorder: { record: () => {} }, + toolExecutor: async () => "unused", + }); + + const response = await handler(modernRequest("tools/list", {}), identity); + const body = (await response.json()) as { result?: { tools?: Array<{ name: string }> } }; + const names = body.result?.tools?.map((tool) => tool.name) ?? []; + + expect(response.status).toBe(200); + expect(names).toEqual(EXPECTED_TOOL_NAMES); +}); + +test("usage recording failures do not discard a successful tool result", async () => { + const usageErrors: unknown[] = []; + const backgroundTasks: Promise[] = []; + const handler = createAuthenticatedMcpHandler({ + usageRecorder: { + async record() { + throw new Error("usage database unavailable"); + }, + }, + toolExecutor: async () => "Element w:p", + waitUntil: (promise) => backgroundTasks.push(promise), + onUsageError: (error) => usageErrors.push(error), + }); + + const response = await handler( + modernRequest("tools/call", { + name: "ooxml_element", + arguments: { qname: "w:p" }, + }), + identity, + ); + const body = (await response.json()) as { + result?: { content?: Array<{ text?: string }> }; + }; + await Promise.all(backgroundTasks); + + expect(response.status).toBe(200); + expect(body.result?.content?.[0]?.text).toBe("Element w:p"); + expect(backgroundTasks).toHaveLength(1); + expect(usageErrors).toHaveLength(1); +}); + +test("authenticated MCP 2024-11-05 clients can initialize through the compatibility path", async () => { + const handler = createAuthenticatedMcpHandler({ + usageRecorder: { record: () => {} }, + toolExecutor: async () => "unused", + }); + + const response = await handler(legacyInitializeRequest(), identity); + const event = await response.text(); + const data = event + .split("\n") + .find((line) => line.startsWith("data: ")) + ?.slice("data: ".length); + const body = JSON.parse(data ?? "{}") as { + result?: { protocolVersion?: string; serverInfo?: { name?: string } }; + }; + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toContain("text/event-stream"); + expect(body.result?.protocolVersion).toBe("2024-11-05"); + expect(body.result?.serverInfo?.name).toBe("ooxml"); +}); + +test("OAuth token props must include Clerk user, dynamic client, and scopes", () => { + expect(isMcpAuthorizationProps(identity)).toBe(true); + expect(isMcpAuthorizationProps({ userId: USER_ID, clientId: CLIENT_ID })).toBe(false); + expect(isMcpAuthorizationProps({ userId: USER_ID, clientId: 123, scopes: [] })).toBe(false); +}); diff --git a/tests/mcp-server/oauth-authorization.test.ts b/tests/mcp-server/oauth-authorization.test.ts new file mode 100644 index 0000000..e70dc29 --- /dev/null +++ b/tests/mcp-server/oauth-authorization.test.ts @@ -0,0 +1,217 @@ +import { expect, test } from "bun:test"; +import type { + AuthRequest, + ClientInfo, + CompleteAuthorizationOptions, +} from "@cloudflare/workers-oauth-provider"; +import { handleAuthorizationRequest } from "../../apps/mcp-server/src/oauth-authorization.ts"; + +const AUTHORIZE_URL = + "https://api.ooxml.dev/authorize?response_type=code&client_id=dynamic-client&redirect_uri=http%3A%2F%2F127.0.0.1%3A45123%2Fcallback&scope=profile&state=test-state&code_challenge=test-challenge&code_challenge_method=S256&resource=https%3A%2F%2Fapi.ooxml.dev%2Fmcp"; + +const oauthRequest: AuthRequest = { + responseType: "code", + clientId: "dynamic-client", + redirectUri: "http://127.0.0.1:45123/callback", + scope: ["profile"], + state: "test-state", + codeChallenge: "test-challenge", + codeChallengeMethod: "S256", + resource: "https://api.ooxml.dev/mcp", + issuer: "https://api.ooxml.dev", +}; + +const client: ClientInfo = { + clientId: "dynamic-client", + clientName: "Codex", + redirectUris: [oauthRequest.redirectUri], + tokenEndpointAuthMethod: "none", +}; + +function options(overrides?: { + userId?: string | null; + authentication?: { userId: string; headers?: Headers } | Response | null; + clientInfo?: ClientInfo | null; + complete?: (value: CompleteAuthorizationOptions) => void; +}) { + return { + oauth: { + parseAuthRequest: async () => oauthRequest, + lookupClient: async () => (overrides?.clientInfo === undefined ? client : overrides.clientInfo), + completeAuthorization: async (value: CompleteAuthorizationOptions) => { + overrides?.complete?.(value); + return { redirectTo: `${oauthRequest.redirectUri}?code=test-code&state=test-state` }; + }, + }, + authenticateUser: async () => { + if (overrides && "authentication" in overrides) return overrides.authentication ?? null; + const userId = overrides?.userId === undefined ? "user_test" : overrides.userId; + return userId ? { userId } : null; + }, + }; +} + +test("Clerk satellite handshakes are returned to the browser", async () => { + const response = await handleAuthorizationRequest( + new Request(AUTHORIZE_URL), + options({ + authentication: new Response(null, { + status: 307, + headers: { Location: "https://clerk.ooxml.dev/v1/client/handshake" }, + }), + }), + ); + + expect(response.status).toBe(307); + expect(response.headers.get("Location")).toBe( + "https://clerk.ooxml.dev/v1/client/handshake", + ); +}); + +test("cookies from a completed Clerk handshake are kept on the consent response", async () => { + const response = await handleAuthorizationRequest( + new Request(AUTHORIZE_URL), + options({ + authentication: { + userId: "user_test", + headers: new Headers({ "Set-Cookie": "__session=test; HttpOnly; Secure" }), + }, + }), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("Set-Cookie")).toContain("__session=test"); +}); + +test("unsigned users continue through the custom Clerk sign-in page", async () => { + const response = await handleAuthorizationRequest( + new Request(AUTHORIZE_URL), + options({ userId: null }), + ); + const redirect = new URL(response.headers.get("Location") ?? ""); + + expect(response.status).toBe(302); + expect(`${redirect.origin}${redirect.pathname}`).toBe("https://ooxml.dev/sign-in"); + expect(redirect.searchParams.get("redirect_url")).toBe(AUTHORIZE_URL); +}); + +test("signed-in users see the client and an explicit consent choice", async () => { + const response = await handleAuthorizationRequest(new Request(AUTHORIZE_URL), options()); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(response.headers.get("Content-Security-Policy")).not.toContain("form-action"); + expect(body).toContain("Connect Codex?"); + expect(body).toContain('name="decision" value="approve"'); + expect(body).toContain('name="decision" value="deny"'); +}); + +test("client names are escaped in the consent page", async () => { + const response = await handleAuthorizationRequest( + new Request(AUTHORIZE_URL), + options({ clientInfo: { ...client, clientName: "" } }), + ); + const body = await response.text(); + + expect(body).not.toContain(""); + expect(body).toContain("<script>alert(1)</script>"); +}); + +test("approval binds the Clerk user and dynamic client to the OAuth grant", async () => { + let completed: CompleteAuthorizationOptions | undefined; + const response = await handleAuthorizationRequest( + new Request(AUTHORIZE_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Origin: "https://api.ooxml.dev", + }, + body: "decision=approve", + }), + options({ complete: (value) => (completed = value) }), + ); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toContain("code=test-code"); + expect(completed).toMatchObject({ + userId: "user_test", + scope: ["profile"], + props: { + userId: "user_test", + clientId: "dynamic-client", + scopes: ["profile"], + }, + }); +}); + +test("cancel returns an OAuth access_denied response without creating a grant", async () => { + let completed = false; + const response = await handleAuthorizationRequest( + new Request(AUTHORIZE_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Origin: "https://api.ooxml.dev", + }, + body: "decision=deny", + }), + options({ complete: () => (completed = true) }), + ); + const redirect = new URL(response.headers.get("Location") ?? ""); + + expect(response.status).toBe(302); + expect(redirect.searchParams.get("error")).toBe("access_denied"); + expect(redirect.searchParams.get("state")).toBe("test-state"); + expect(completed).toBe(false); +}); + +test("consent posts from another origin are rejected", async () => { + const response = await handleAuthorizationRequest( + new Request(AUTHORIZE_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Origin: "https://attacker.example", + }, + body: "decision=approve", + }), + options(), + ); + + expect(response.status).toBe(403); +}); + +test("same-origin browser posts with a null Origin are accepted", async () => { + const response = await handleAuthorizationRequest( + new Request(AUTHORIZE_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Origin: "null", + "Sec-Fetch-Site": "same-origin", + }, + body: "decision=approve", + }), + options(), + ); + + expect(response.status).toBe(302); +}); + +test("cross-site browser posts with a null Origin are rejected", async () => { + const response = await handleAuthorizationRequest( + new Request(AUTHORIZE_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Origin: "null", + "Sec-Fetch-Site": "cross-site", + }, + body: "decision=approve", + }), + options(), + ); + + expect(response.status).toBe(403); +}); diff --git a/tests/web/auth-navigation.test.ts b/tests/web/auth-navigation.test.ts new file mode 100644 index 0000000..f99b46f --- /dev/null +++ b/tests/web/auth-navigation.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test"; +import { safeRequestedRedirect } from "../../apps/web/src/pages/auth/useAuthNavigation"; + +const APP_ORIGIN = "https://ooxml.dev"; +const CLERK_FRONTEND_API = "clerk.ooxml.dev"; + +describe("safeRequestedRedirect", () => { + test("returns home when no OAuth redirect was requested", () => { + expect(safeRequestedRedirect(CLERK_FRONTEND_API, null, APP_ORIGIN)).toBe("/"); + }); + + test("keeps same-origin redirects relative", () => { + expect( + safeRequestedRedirect( + CLERK_FRONTEND_API, + "https://ooxml.dev/mcp?connected=true#status", + APP_ORIGIN, + ), + ).toBe("/mcp?connected=true#status"); + }); + + test("keeps network-path-looking redirects on ooxml.dev", () => { + const requested = "https://ooxml.dev//attacker.example/path%60"; + const redirect = safeRequestedRedirect(CLERK_FRONTEND_API, requested, APP_ORIGIN); + + expect(redirect).toBe("/attacker.example/path%60"); + expect(new URL(redirect, APP_ORIGIN).origin).toBe(APP_ORIGIN); + }); + + test("allows the Clerk OAuth flow to continue", () => { + const requested = "https://clerk.ooxml.dev/v1/oauth/authorize?client_id=test"; + + expect(safeRequestedRedirect(CLERK_FRONTEND_API, requested, APP_ORIGIN)).toBe(requested); + }); + + test("allows MCP authorization to continue after Clerk sign-in", () => { + const requested = + "https://api.ooxml.dev/authorize?client_id=test&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback"; + + expect(safeRequestedRedirect(CLERK_FRONTEND_API, requested, APP_ORIGIN)).toBe(requested); + }); + + test("allows the configured local MCP authorization origin", () => { + const requested = "http://localhost:8787/authorize?client_id=test"; + + expect( + safeRequestedRedirect( + CLERK_FRONTEND_API, + requested, + "http://localhost:5173", + "http://localhost:8787", + ), + ).toBe(requested); + }); + + test("rejects other API paths as auth redirects", () => { + expect( + safeRequestedRedirect( + CLERK_FRONTEND_API, + "https://api.ooxml.dev/search", + APP_ORIGIN, + ), + ).toBe("/"); + }); + + test("rejects unrelated external redirects", () => { + expect( + safeRequestedRedirect( + CLERK_FRONTEND_API, + "https://attacker.example/callback", + APP_ORIGIN, + ), + ).toBe("/"); + }); +});