From 7a602ce250a34df1f8ed40bc2848fd104b2c4c55 Mon Sep 17 00:00:00 2001 From: hallelx2 Date: Thu, 8 Oct 2026 01:49:02 +0100 Subject: [PATCH 1/4] feat(relay): MCP server for AI coding agents and an AI agents dashboard page A coding agent can now run the whole webhook loop against the user's real local server with nothing installed: get a permanent URL for a port, fire a realistic signed provider webhook, read exactly what the handler answered, fix the code, replay. Relay - /mcp: MCP Streamable HTTP, stateless with JSON responses (official SDK 1.32 web-standard transport), running inside the Worker. Auth: agent token (Bearer dvc_...), any device token, or the session; 401 with WWW-Authenticate otherwise. Server instructions describe the loop. - Tools: list_webhook_urls, create_webhook_url (same port, same URL; the relay keeps only a public key until the extension adopts the channel), set_response_mode, send_test_event (Stripe, GitHub, OpenAI Standard Webhooks, ElevenLabs, Vapi, generic; signed like the provider when given the endpoint secret; delivered through full intake, returns the sync reply or waits for the answer), wait_for_webhook, get_event, list_events, replay_event (waits for the new answer). All owner-scoped. - Agent tokens: device kind "agent", excluded from the device cap, at most 10 per account. - rotate-key accepts device tokens, so the extension adopts channels created by an agent (also needed by the CLI). - Replay and owned-event reads move to event-store.ts, shared by the dashboard API and MCP. Provider samples and signers in test-events.ts, checked against Node's HMAC and the ElevenLabs SDK's verification. - "mcp" is a reserved path segment. Web: Dashboard > AI agents mints a token, shows it once with setup for Claude Code, Cursor, VS Code and Codex, lists and revokes tokens; agent tokens no longer appear under Devices. Docs: AI agents (MCP) page. Copy no longer mentions a CLI that has not shipped. --- apps/web/src/App.tsx | 9 + apps/web/src/components/DashboardLayout.tsx | 1 + apps/web/src/lib/me-api.ts | 11 +- apps/web/src/pages/AiAgents.tsx | 244 +++++++ apps/web/src/pages/ChannelsList.tsx | 3 +- apps/web/src/pages/DevicesList.tsx | 3 +- docs/src/App.tsx | 2 + docs/src/components/Layout.tsx | 1 + docs/src/pages/AiAgents.tsx | 130 ++++ pnpm-lock.yaml | 716 ++++++++++++++++++++ relay/package.json | 4 +- relay/src/access.ts | 4 +- relay/src/event-store.ts | 169 +++++ relay/src/index.ts | 43 ++ relay/src/mcp.ts | 501 ++++++++++++++ relay/src/routes/me-devices.ts | 30 +- relay/src/routes/me.ts | 158 +---- relay/src/sync.ts | 2 +- relay/src/test-events.test.ts | 82 +++ relay/src/test-events.ts | 247 +++++++ relay/src/webhook-url.ts | 2 +- relay/tsconfig.json | 3 +- 22 files changed, 2206 insertions(+), 159 deletions(-) create mode 100644 apps/web/src/pages/AiAgents.tsx create mode 100644 docs/src/pages/AiAgents.tsx create mode 100644 relay/src/event-store.ts create mode 100644 relay/src/mcp.ts create mode 100644 relay/src/test-events.test.ts create mode 100644 relay/src/test-events.ts diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 6622b92..a46a1a6 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -11,6 +11,7 @@ import { FinalCTA } from "./components/FinalCTA"; import { Footer } from "./components/Footer"; import { Nav } from "./components/Nav"; import { ScrollFlow } from "./components/ScrollFlow"; +import { AiAgents } from "./pages/AiAgents"; import { AuthCallback } from "./pages/AuthCallback"; import { Billing } from "./pages/Billing"; import { ChannelsList } from "./pages/ChannelsList"; @@ -122,6 +123,14 @@ export function App() { } /> + + + + } + /> + meFetch<{ token: string; deviceId: string; label: string; kind: "agent" }>( + "/devices/self-register", + { method: "POST", body: JSON.stringify({ kind: "agent", label }) }, + ), + }, + devices: { list: () => meFetch<{ devices: MeDevice[] }>("/devices"), rename: (id: string, label: string) => diff --git a/apps/web/src/pages/AiAgents.tsx b/apps/web/src/pages/AiAgents.tsx new file mode 100644 index 0000000..710c7d1 --- /dev/null +++ b/apps/web/src/pages/AiAgents.tsx @@ -0,0 +1,244 @@ +/** + * AI agents: connect a coding agent (Claude Code, Cursor, VS Code, Codex) to + * BridgeHook's MCP server. Mints agent tokens (device kind "agent", not + * counted as devices), shows each once with copy-paste setup, and lists or + * revokes existing tokens. + */ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { DashboardLayout } from "../components/DashboardLayout"; +import { type MeDevice, me } from "../lib/me-api"; + +const RELAY_URL = import.meta.env.VITE_RELAY_URL || "http://localhost:8787"; +const MCP_URL = `${RELAY_URL.replace(/\/$/, "")}/mcp`; + +type Client = "claude" | "cursor" | "vscode" | "codex"; +const CLIENTS: { id: Client; name: string }[] = [ + { id: "claude", name: "Claude Code" }, + { id: "cursor", name: "Cursor" }, + { id: "vscode", name: "VS Code" }, + { id: "codex", name: "Codex" }, +]; + +function setupFor(client: Client, token: string): { where: string; code: string } { + switch (client) { + case "claude": + return { + where: "Run in your project", + code: `claude mcp add --transport http bridgehook ${MCP_URL} \\\n --header "Authorization: Bearer ${token}"`, + }; + case "cursor": + return { + where: "~/.cursor/mcp.json", + code: JSON.stringify( + { + mcpServers: { + bridgehook: { url: MCP_URL, headers: { Authorization: `Bearer ${token}` } }, + }, + }, + null, + 2, + ), + }; + case "vscode": + return { + where: ".vscode/mcp.json", + code: JSON.stringify( + { + servers: { + bridgehook: { + type: "http", + url: MCP_URL, + headers: { Authorization: `Bearer ${token}` }, + }, + }, + }, + null, + 2, + ), + }; + case "codex": + return { + where: "~/.codex/config.toml, with BRIDGEHOOK_TOKEN exported in your shell", + code: `[mcp_servers.bridgehook]\nurl = "${MCP_URL}"\nbearer_token_env_var = "BRIDGEHOOK_TOKEN"\n\n# export BRIDGEHOOK_TOKEN=${token}`, + }; + } +} + +export function AiAgents() { + return ( + + + + ); +} + +function AiAgentsView() { + const [tokens, setTokens] = useState(null); + const [error, setError] = useState(null); + const [label, setLabel] = useState("Claude Code"); + const [creating, setCreating] = useState(false); + const [fresh, setFresh] = useState<{ token: string; label: string } | null>(null); + const [client, setClient] = useState("claude"); + + const load = useCallback(async () => { + try { + const d = await me.devices.list(); + setTokens(d.devices.filter((x) => x.kind === "agent")); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, []); + useEffect(() => { + load(); + }, [load]); + + async function create() { + setCreating(true); + setError(null); + try { + const r = await me.agents.create(label.trim() || "AI agent"); + setFresh({ token: r.token, label: r.label }); + await load(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setCreating(false); + } + } + + async function revoke(id: string) { + try { + await me.devices.revoke(id); + setTokens((prev) => (prev ? prev.filter((t) => t.id !== id) : prev)); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + } + + const setup = useMemo(() => (fresh ? setupFor(client, fresh.token) : null), [fresh, client]); + + return ( +
+

AI agents

+

+ Let your coding agent build and debug webhook handlers against your real local server. It + can get a webhook URL for a port, fire signed Stripe, GitHub, OpenAI, ElevenLabs or Vapi + test events, read exactly what your handler answered, and replay after a fix. Nothing to + install: BridgeHook is a remote MCP server at{" "} + {MCP_URL}. +

+ + {error && ( +
+ {error} +
+ )} + +
+

Connect an agent

+
+ setLabel(e.target.value)} + maxLength={96} + className="bg-gray-900 border border-gray-800 rounded px-2 py-1 text-sm w-56" + aria-label="Token name" + placeholder="Token name" + /> + +
+ + {fresh && setup && ( +
+

+ Copy this now: the token for “{fresh.label}” is shown only once. +

+
+ {CLIENTS.map((c) => ( + + ))} +
+

{setup.where}

+ +

+ Then ask your agent something like: “Use BridgeHook to send a signed Stripe + checkout.session.completed to my app on port 3000 at /api/webhooks/stripe and fix + whatever the handler gets wrong.” +

+
+ )} +
+ +
+

Agent tokens

+ {tokens === null ? ( +

Loading…

+ ) : tokens.length === 0 ? ( +

No agent tokens yet.

+ ) : ( +
    + {tokens.map((t) => ( +
  • +
    +
    {t.label}
    +
    + {t.id} · last used{" "} + {t.lastSeenAt ? new Date(t.lastSeenAt).toLocaleString() : "never"} +
    +
    + +
  • + ))} +
+ )} +

+ Agent tokens do not count toward your device limit. Up to 10 per account. +

+
+
+ ); +} + +function CodeBlock({ code }: { code: string }) { + const [copied, setCopied] = useState(false); + return ( +
+
+				{code}
+			
+ +
+ ); +} diff --git a/apps/web/src/pages/ChannelsList.tsx b/apps/web/src/pages/ChannelsList.tsx index 5d08ff5..e011f3c 100644 --- a/apps/web/src/pages/ChannelsList.tsx +++ b/apps/web/src/pages/ChannelsList.tsx @@ -130,8 +130,7 @@ function ChannelsView() { GitHub, OpenAI). Your server's reply holds the request until your local server answers and returns that response, for tool calls from voice and agent platforms (Vapi, ElevenLabs) and GET verification challenges. The - extension or CLI must be running; from a dashboard tab, your local server must allow - CORS. + extension must be running; from a dashboard tab, your local server must allow CORS.

{" "}
diff --git a/apps/web/src/pages/DevicesList.tsx b/apps/web/src/pages/DevicesList.tsx index 7cb23dd..c15ee66 100644 --- a/apps/web/src/pages/DevicesList.tsx +++ b/apps/web/src/pages/DevicesList.tsx @@ -25,7 +25,8 @@ function DevicesView() { useEffect(() => { me.devices .list() - .then((d) => setDevices(d.devices)) + // Agent tokens are listed on the AI agents page. + .then((d) => setDevices(d.devices.filter((x) => x.kind !== "agent"))) .catch((err) => setError(err instanceof Error ? err.message : String(err))); }, []); diff --git a/docs/src/App.tsx b/docs/src/App.tsx index 20b9e85..ec0a71b 100644 --- a/docs/src/App.tsx +++ b/docs/src/App.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { Layout } from "./components/Layout"; +import { AiAgents } from "./pages/AiAgents"; import { Architecture } from "./pages/Architecture"; import { Auth } from "./pages/Auth"; import { Billing } from "./pages/Billing"; @@ -35,6 +36,7 @@ const PAGES: Record React.JSX.Element> = { "vs-cloudflare-tunnel": VsCloudflareTunnel, "vs-localtunnel": VsLocaltunnel, tradeoffs: Tradeoffs, + "ai-agents": AiAgents, "relay-api": RelayAPI, "sse-events": SSEEvents, "self-hosting": SelfHosting, diff --git a/docs/src/components/Layout.tsx b/docs/src/components/Layout.tsx index 4d55b02..96c2e3c 100644 --- a/docs/src/components/Layout.tsx +++ b/docs/src/components/Layout.tsx @@ -45,6 +45,7 @@ const NAV_SECTIONS = [ { title: "API Reference", items: [ + { id: "ai-agents", label: "AI agents (MCP)" }, { id: "relay-api", label: "Relay API" }, { id: "sse-events", label: "SSE Events" }, ], diff --git a/docs/src/pages/AiAgents.tsx b/docs/src/pages/AiAgents.tsx new file mode 100644 index 0000000..b42f368 --- /dev/null +++ b/docs/src/pages/AiAgents.tsx @@ -0,0 +1,130 @@ +import { Callout } from "../components/Illustrations"; + +export function AiAgents() { + return ( + <> +

AI agents (MCP)

+

+ BridgeHook is a remote MCP server, so a coding agent such as Claude Code, Cursor, VS Code or + Codex can build and debug webhook handlers against your real local server. The agent gets a + permanent webhook URL for a port, fires a realistic provider webhook at it, reads exactly + what your handler answered, and replays it after fixing the code. There is nothing to + install. +

+ + + https://relay.bridgehook.dev/mcp (Streamable HTTP). Authenticate with an agent + token from Dashboard → AI agents, sent as Authorization: Bearer dvc_…. + Agent tokens do not count toward your device limit and can be revoked at any time. + + +

Connect

+

Claude Code

+
+				{`claude mcp add --transport http bridgehook https://relay.bridgehook.dev/mcp \\
+  --header "Authorization: Bearer dvc_…"`}
+			
+

Cursor (~/.cursor/mcp.json)

+
+				{`{ "mcpServers": { "bridgehook": {
+  "url": "https://relay.bridgehook.dev/mcp",
+  "headers": { "Authorization": "Bearer dvc_…" } } } }`}
+			
+

VS Code (.vscode/mcp.json)

+
+				{`{ "servers": { "bridgehook": {
+  "type": "http",
+  "url": "https://relay.bridgehook.dev/mcp",
+  "headers": { "Authorization": "Bearer dvc_…" } } } }`}
+			
+

Codex (~/.codex/config.toml)

+
+				{`[mcp_servers.bridgehook]
+url = "https://relay.bridgehook.dev/mcp"
+bearer_token_env_var = "BRIDGEHOOK_TOKEN"   # export BRIDGEHOOK_TOKEN=dvc_…`}
+			
+ +

Tools

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ToolWhat it does
+ create_webhook_url + + Permanent URL for a local port (the same port always gets the same URL); optional + reply mode. +
+ send_test_event + + Realistic Stripe, GitHub, OpenAI, ElevenLabs, Vapi or generic webhook, signed like the + provider when you pass the endpoint secret, then waits for your handler's answer. +
+ wait_for_webhook + + Blocks until a real webhook matching filters arrives, optionally until it is answered. +
+ get_event + Full request plus your handler's status, headers, body, latency and error.
+ replay_event + Send an earlier webhook again (optionally edited) and return the new answer.
+ list_events, list_webhook_urls + History and channels.
+ set_response_mode + + sync returns your handler's reply to the sender, for Vapi and + ElevenLabs tool calls and GET verification challenges. +
+ +

How forwarding happens

+

+ Webhooks reach localhost through the BridgeHook Chrome extension. A URL created by an agent + is picked up the moment you add the same port in the extension, which keeps the URL. + Webhooks that arrive before then, or while your machine is off, are queued and delivered in + order. +

+ +

Example prompt

+
+				
+					Use BridgeHook to send a signed Stripe checkout.session.completed to my app on port 3000
+					at /api/webhooks/stripe (secret whsec_…), then fix whatever the handler gets wrong and
+					replay until it returns 200.
+				
+			
+ + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4087787..88ea340 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,6 +172,9 @@ importers: '@bridgehook/shared': specifier: workspace:* version: link:../packages/shared + '@modelcontextprotocol/sdk': + specifier: 1.32.1 + version: 1.32.1(zod@4.6.5) better-auth: specifier: ^1.7.7 version: 1.7.7(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260408.1)(@neondatabase/serverless@0.10.4)(@types/pg@8.11.6)(gel@2.2.0)(kysely@0.28.17))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@5.0.3(@types/node@25.5.2)(vite@6.4.4(@types/node@25.5.2)(jiti@1.21.7)(tsx@4.21.0))) @@ -187,6 +190,9 @@ importers: resend: specifier: ^6.12.3 version: 6.12.3 + zod: + specifier: ^4 + version: 4.6.5 devDependencies: '@cloudflare/workers-types': specifier: ^4.20241230.0 @@ -1079,6 +1085,12 @@ packages: resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} engines: {node: '>=14'} + '@hono/node-server@2.1.3': + resolution: {integrity: sha512-TA//nWMqPhbfdfneACk6t5a9eqbS9lABEPyKn0/xZTah3H3U2XaVg85rJFl0/Fyit0I552YDHgXGVSf3GwqbUw==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 + '@img/sharp-darwin-arm64@0.33.5': resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1206,6 +1218,16 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@modelcontextprotocol/sdk@1.32.1': + resolution: {integrity: sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@neondatabase/serverless@0.10.4': resolution: {integrity: sha512-2nZuh3VUO9voBauuh+IGYRhGU/MskWHt1IuZvHcJw6GLjDgtqj/KViKo7SIrLdGLdot7vFbiRRw+BgEy3wT9HA==} @@ -1511,6 +1533,10 @@ packages: '@vitest/spy@5.0.3': resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-walk@8.3.2: resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==} engines: {node: '>=0.4.0'} @@ -1520,6 +1546,17 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -1623,6 +1660,10 @@ packages: blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -1635,6 +1676,18 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + camelcase-css@2.0.1: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} @@ -1668,9 +1721,25 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + cookie@0.7.2: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} @@ -1679,6 +1748,14 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -1702,6 +1779,10 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1808,16 +1889,39 @@ packages: sqlite3: optional: true + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + electron-to-chromium@1.5.334: resolution: {integrity: sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + env-paths@3.0.0: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + esbuild@0.17.19: resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==} engines: {node: '>=12'} @@ -1842,6 +1946,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1852,6 +1959,18 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + exit-hook@2.2.1: resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} engines: {node: '>=6'} @@ -1860,9 +1979,22 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.7.1: + resolution: {integrity: sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -1870,6 +2002,9 @@ packages: fast-sha256@1.3.0: resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1886,9 +2021,21 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1906,6 +2053,14 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-source@2.0.12: resolution: {integrity: sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==} @@ -1923,6 +2078,14 @@ packages: glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -1931,6 +2094,25 @@ packages: resolution: {integrity: sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==} engines: {node: '>=16.9.0'} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.7.3: + resolution: {integrity: sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-arrayish@0.3.4: resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} @@ -1954,6 +2136,12 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isexe@3.1.5: resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} engines: {node: '>=18'} @@ -1973,6 +2161,12 @@ packages: engines: {node: '>=6'} hasBin: true + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -2003,6 +2197,18 @@ packages: magic-string@1.4.3: resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -2011,6 +2217,14 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} @@ -2045,6 +2259,10 @@ packages: resolution: {integrity: sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA==} engines: {node: ^20.0.0 || >=22.0.0} + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + node-releases@2.0.37: resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} @@ -2060,6 +2278,10 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obuf@1.1.2: resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} @@ -2070,12 +2292,30 @@ packages: ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -2117,6 +2357,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + postal-mime@2.7.4: resolution: {integrity: sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==} @@ -2189,9 +2433,25 @@ packages: printable-characters@1.0.42: resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==} + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} + engines: {node: '>= 0.10'} + + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -2229,6 +2489,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resend@6.12.3: resolution: {integrity: sha512-FkEi6YPnVL96/LvH8+QP7NaeaBy5brYXwlRqUCqZZeNL0/iyKij18IPmyPXYauT/2ODn1JG04qKz+qlJfzqzTw==} engines: {node: '>=20'} @@ -2268,9 +2532,16 @@ packages: rou3@0.9.2: resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -2288,20 +2559,55 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} set-cookie-parser@3.1.2: resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.33.5: resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + shell-quote@1.12.0: resolution: {integrity: sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==} engines: {node: '>= 0.4'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + simple-swizzle@0.2.4: resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} @@ -2326,6 +2632,10 @@ packages: standardwebhooks@1.0.0: resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@4.3.0: resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} @@ -2377,6 +2687,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -2388,6 +2702,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -2406,6 +2724,10 @@ packages: unenv@2.0.0-rc.14: resolution: {integrity: sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -2415,6 +2737,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vite@6.4.4: resolution: {integrity: sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -2496,6 +2822,11 @@ packages: jsdom: optional: true + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + which@4.0.0: resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} engines: {node: ^16.13.0 || >=18.0.0} @@ -2521,6 +2852,9 @@ packages: '@cloudflare/workers-types': optional: true + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.18.0: resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} engines: {node: '>=10.0.0'} @@ -2539,6 +2873,11 @@ packages: youch@3.3.4: resolution: {integrity: sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod@3.22.3: resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==} @@ -3101,6 +3440,10 @@ snapshots: '@fastify/busboy@2.1.1': {} + '@hono/node-server@2.1.3(hono@4.13.13)': + dependencies: + hono: 4.13.13 + '@img/sharp-darwin-arm64@0.33.5': optionalDependencies: '@img/sharp-libvips-darwin-arm64': 1.0.4 @@ -3202,6 +3545,28 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@modelcontextprotocol/sdk@1.32.1(zod@4.6.5)': + dependencies: + '@hono/node-server': 2.1.3(hono@4.13.13) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.2.1 + express-rate-limit: 8.7.1(express@5.2.1) + hono: 4.13.13 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.6.5 + zod-to-json-schema: 3.25.2(zod@4.6.5) + transitivePeerDependencies: + - supports-color + '@neondatabase/serverless@0.10.4': dependencies: '@types/pg': 8.11.6 @@ -3447,10 +3812,26 @@ snapshots: '@vitest/spy@5.0.3': {} + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + acorn-walk@8.3.2: {} acorn@8.14.0: {} + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + any-promise@1.3.0: {} anymatch@3.1.3: @@ -3518,6 +3899,20 @@ snapshots: blake3-wasm@2.1.5: {} + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.16.0 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -3532,6 +3927,18 @@ snapshots: buffer-from@1.1.2: {} + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + camelcase-css@2.0.1: {} caniuse-lite@1.0.30001787: {} @@ -3572,12 +3979,31 @@ snapshots: commander@4.1.1: {} + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + cookie@0.7.2: {} cookie@1.1.1: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + cssesc@3.0.0: {} csstype@3.2.3: {} @@ -3590,6 +4016,8 @@ snapshots: defu@6.1.7: {} + depd@2.0.0: {} + detect-libc@2.1.2: optional: true @@ -3612,13 +4040,31 @@ snapshots: gel: 2.2.0 kysely: 0.28.17 + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + electron-to-chromium@1.5.334: {} + encodeurl@2.0.0: {} + env-paths@3.0.0: optional: true + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@2.3.2: {} + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + esbuild@0.17.19: optionalDependencies: '@esbuild/android-arm': 0.17.19 @@ -3729,6 +4175,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} estree-walker@0.6.1: {} @@ -3737,12 +4185,63 @@ snapshots: dependencies: '@types/estree': 1.0.8 + etag@1.8.1: {} + + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + exit-hook@2.2.1: {} expect-type@1.4.0: {} + express-rate-limit@8.7.1(express@5.2.1): + dependencies: + debug: 4.4.3 + express: 5.2.1 + ip-address: 10.7.3 + transitivePeerDependencies: + - supports-color + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.8 + qs: 6.16.0 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + exsolve@1.0.8: {} + fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3753,6 +4252,8 @@ snapshots: fast-sha256@1.3.0: {} + fast-uri@3.1.8: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -3769,8 +4270,23 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + fraction.js@5.3.4: {} + fresh@2.0.0: {} + fsevents@2.3.3: optional: true @@ -3790,6 +4306,24 @@ snapshots: gensync@1.0.0-beta.2: {} + 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.2 + 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 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + get-source@2.0.12: dependencies: data-uri-to-buffer: 2.0.2 @@ -3809,12 +4343,34 @@ snapshots: glob-to-regexp@0.4.1: {} + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + hasown@2.0.2: dependencies: function-bind: 1.1.2 hono@4.13.13: {} + 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 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ip-address@10.7.3: {} + + ipaddr.js@1.9.1: {} + is-arrayish@0.3.4: optional: true @@ -3834,6 +4390,10 @@ snapshots: is-number@7.0.0: {} + is-promise@4.0.0: {} + + isexe@2.0.0: {} + isexe@3.1.5: optional: true @@ -3845,6 +4405,10 @@ snapshots: jsesc@3.1.0: {} + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + json5@2.2.3: {} kysely@0.28.17: {} @@ -3869,6 +4433,12 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + math-intrinsics@1.1.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -3876,6 +4446,12 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mime@3.0.0: {} miniflare@3.20250718.3: @@ -3911,6 +4487,10 @@ snapshots: nanostores@1.3.0: {} + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + node-releases@2.0.37: {} normalize-path@3.0.0: {} @@ -3919,6 +4499,8 @@ snapshots: object-hash@3.0.0: {} + object-inspect@1.13.4: {} + obuf@1.1.2: optional: true @@ -3926,10 +4508,24 @@ snapshots: ohash@2.0.11: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-key@3.1.1: {} + path-parse@1.0.7: {} path-to-regexp@6.3.0: {} + path-to-regexp@8.4.2: {} + pathe@2.0.3: {} pg-int8@1.0.1: @@ -3964,6 +4560,8 @@ snapshots: pirates@4.0.7: {} + pkce-challenge@5.0.1: {} + postal-mime@2.7.4: {} postcss-import@15.1.0(postcss@8.5.29): @@ -4023,8 +4621,27 @@ snapshots: printable-characters@1.0.42: {} + proxy-addr@2.0.8: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + queue-microtask@1.2.3: {} + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 @@ -4056,6 +4673,8 @@ snapshots: dependencies: picomatch: 2.3.2 + require-from-string@2.0.2: {} + resend@6.12.3: dependencies: postal-mime: 2.7.4 @@ -4118,10 +4737,22 @@ snapshots: rou3@0.9.2: {} + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 + safer-buffer@2.1.2: {} + scheduler@0.27.0: {} semver@6.3.1: {} @@ -4132,10 +4763,37 @@ snapshots: semver@7.8.5: optional: true + 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.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + set-cookie-parser@2.7.2: {} set-cookie-parser@3.1.2: {} + setprototypeof@1.2.0: {} + sharp@0.33.5: dependencies: color: 4.2.3 @@ -4163,9 +4821,43 @@ snapshots: '@img/sharp-win32-x64': 0.33.5 optional: true + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + shell-quote@1.12.0: optional: true + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + simple-swizzle@0.2.4: dependencies: is-arrayish: 0.3.4 @@ -4192,6 +4884,8 @@ snapshots: '@stablelib/base64': 1.0.1 fast-sha256: 1.3.0 + statuses@2.0.2: {} + std-env@4.3.0: {} stoppable@1.1.0: {} @@ -4266,6 +4960,8 @@ snapshots: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: {} + ts-interface-checker@0.1.13: {} tslib@2.8.1: @@ -4278,6 +4974,12 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + typescript@5.9.3: {} ufo@1.6.3: {} @@ -4297,6 +4999,8 @@ snapshots: pathe: 2.0.3 ufo: 1.6.3 + unpipe@1.0.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 @@ -4305,6 +5009,8 @@ snapshots: util-deprecate@1.0.2: {} + vary@1.1.2: {} + vite@6.4.4(@types/node@25.5.2)(jiti@1.21.7)(tsx@4.21.0): dependencies: esbuild: 0.25.12 @@ -4340,6 +5046,10 @@ snapshots: transitivePeerDependencies: - msw + which@2.0.2: + dependencies: + isexe: 2.0.0 + which@4.0.0: dependencies: isexe: 3.1.5 @@ -4375,6 +5085,8 @@ snapshots: - bufferutil - utf-8-validate + wrappy@1.0.2: {} + ws@8.18.0: {} yallist@3.1.1: {} @@ -4385,6 +5097,10 @@ snapshots: mustache: 4.2.0 stacktracey: 2.2.0 + zod-to-json-schema@3.25.2(zod@4.6.5): + dependencies: + zod: 4.6.5 + zod@3.22.3: {} zod@4.6.5: {} diff --git a/relay/package.json b/relay/package.json index 581e5c3..f0c3b42 100644 --- a/relay/package.json +++ b/relay/package.json @@ -14,11 +14,13 @@ }, "dependencies": { "@bridgehook/shared": "workspace:*", + "@modelcontextprotocol/sdk": "1.32.1", "better-auth": "^1.7.7", "drizzle-orm": "^0.45.2", "hono": "^4.13.13", "nanoid": "^5.1.16", - "resend": "^6.12.3" + "resend": "^6.12.3", + "zod": "^4" }, "devDependencies": { "@cloudflare/workers-types": "^4.20241230.0", diff --git a/relay/src/access.ts b/relay/src/access.ts index fe33079..988aa22 100644 --- a/relay/src/access.ts +++ b/relay/src/access.ts @@ -20,7 +20,7 @@ */ import { PLANS, type PlanId } from "@bridgehook/shared"; import { events, channels, devices, subscriptions, user } from "@bridgehook/shared/db/schema"; -import { and, count, eq, gte, isNull } from "drizzle-orm"; +import { and, count, eq, gte, isNull, ne } from "drizzle-orm"; import type { DB } from "./db.js"; export interface UserAccess { @@ -195,7 +195,7 @@ async function countActiveDevices(db: DB, userId: string): Promise { const [{ n }] = await db .select({ n: count() }) .from(devices) - .where(and(eq(devices.userId, userId), isNull(devices.revokedAt))); + .where(and(eq(devices.userId, userId), isNull(devices.revokedAt), ne(devices.kind, "agent"))); return Number(n); } diff --git a/relay/src/event-store.ts b/relay/src/event-store.ts new file mode 100644 index 0000000..ae39a62 --- /dev/null +++ b/relay/src/event-store.ts @@ -0,0 +1,169 @@ +/** + * Owner-scoped event reads and replay, shared by the dashboard API + * (routes/me.ts) and the MCP server (mcp.ts) so both behave identically. + */ +import { events, channels } from "@bridgehook/shared/db/schema"; +import { and, eq } from "drizzle-orm"; +import type { DB } from "./db.js"; + +const EVENT_ID_LEN = 16; +export const MAX_REPLAY_BODY_BYTES = 1_048_576; // 1 MB + +/** Wakes executors after a replay is queued. */ +export interface ReplayNotifier { + getChannelDO(channelId: string): { fetch(req: Request): Promise }; + notifyUser(userId: string | null, payload: string): void; +} + +export function safeJsonObject(raw: string | null): Record { + if (!raw) return {}; + try { + const v = JSON.parse(raw); + if (!v || typeof v !== "object" || Array.isArray(v)) return {}; + const out: Record = {}; + for (const [k, val] of Object.entries(v as Record)) { + if (typeof val === "string") out[k] = val; + } + return out; + } catch { + return {}; + } +} + +export function freshEventId(): string { + return crypto.randomUUID().replace(/-/g, "").slice(0, EVENT_ID_LEN); +} + +/** An event, only if it belongs to one of the user's channels. */ +export async function loadOwnedEvent(db: DB, userId: string, eventId: string) { + const [row] = await db + .select({ + id: events.id, + channelId: events.channelId, + method: events.method, + path: events.path, + requestHeaders: events.requestHeaders, + requestBody: events.requestBody, + responseStatus: events.responseStatus, + responseHeaders: events.responseHeaders, + responseBody: events.responseBody, + latencyMs: events.latencyMs, + error: events.error, + receivedAt: events.receivedAt, + kind: events.kind, + replayOf: events.replayOf, + replayedByUserId: events.replayedByUserId, + deviceId: events.deviceId, + }) + .from(events) + .innerJoin(channels, eq(events.channelId, channels.id)) + .where(and(eq(events.id, eventId), eq(channels.userId, userId))) + .limit(1); + return row ?? null; +} + +export type OwnedEvent = NonNullable>>; + +export function serializeEventDetail(r: OwnedEvent) { + return { + id: r.id, + channelId: r.channelId, + method: r.method, + path: r.path, + requestHeaders: safeJsonObject(r.requestHeaders), + requestBody: r.requestBody, + responseStatus: r.responseStatus, + responseHeaders: r.responseHeaders ? safeJsonObject(r.responseHeaders) : null, + responseBody: r.responseBody, + latencyMs: r.latencyMs, + error: r.error, + receivedAt: r.receivedAt.toISOString(), + kind: r.kind, + replayOf: r.replayOf, + replayedByUserId: r.replayedByUserId, + deviceId: r.deviceId, + }; +} + +export type ReplayResult = + | { ok: true; replayId: string; channelId: string; receivedAt: string } + | { ok: false; status: 400 | 413 | 500; error: string }; + +/** + * Queue a copy of `source` (optionally with a new body and headers) as a + * replay event on the same channel and wake its executors. + */ +export async function queueReplay( + db: DB, + notifier: ReplayNotifier, + userId: string, + source: OwnedEvent, + edits: { body?: unknown; headers?: unknown }, +): Promise { + let nextBody: string | null; + if (edits.body === undefined || edits.body === null) { + nextBody = source.requestBody; + } else if (typeof edits.body === "string") { + if (edits.body.length > MAX_REPLAY_BODY_BYTES) { + return { ok: false, status: 413, error: "Body too large" }; + } + nextBody = edits.body; + } else { + return { ok: false, status: 400, error: "body must be a string or null" }; + } + + let nextHeaders: Record; + if (edits.headers === undefined || edits.headers === null) { + nextHeaders = safeJsonObject(source.requestHeaders); + } else if (typeof edits.headers === "object" && !Array.isArray(edits.headers)) { + nextHeaders = {}; + for (const [k, v] of Object.entries(edits.headers as Record)) { + if (typeof v === "string") nextHeaders[k] = v; + } + } else { + return { ok: false, status: 400, error: "headers must be an object or null" }; + } + + const [inserted] = await db + .insert(events) + .values({ + id: freshEventId(), + channelId: source.channelId, + method: source.method, + path: source.path, + requestHeaders: JSON.stringify(nextHeaders), + requestBody: nextBody, + kind: "replay", + replayOf: source.id, + replayedByUserId: userId, + }) + .returning(); + if (!inserted) return { ok: false, status: 500, error: "Could not queue replay" }; + + // Wake executors at once; they also catch it on their next poll if the + // notify fails. + const ssePayload = JSON.stringify({ + type: "webhook", + id: inserted.id, + channelId: source.channelId, + method: inserted.method, + path: inserted.path, + headers: nextHeaders, + body: nextBody ?? "", + receivedAt: inserted.receivedAt.toISOString(), + kind: "replay", + replayOf: source.id, + }); + notifier + .getChannelDO(source.channelId) + .fetch(new Request("https://do/notify", { method: "POST", body: ssePayload })) + .catch((err) => console.error("DO notify failed:", err)); + notifier.notifyUser(userId, ssePayload); + + return { + ok: true, + replayId: inserted.id, + channelId: inserted.channelId, + receivedAt: inserted.receivedAt.toISOString(), + }; +} diff --git a/relay/src/index.ts b/relay/src/index.ts index 5c47e87..dad5ce2 100644 --- a/relay/src/index.ts +++ b/relay/src/index.ts @@ -14,6 +14,7 @@ import { createAuth, getAvailableAuthProviders, getSessionUser } from "./auth.js import { createPolarClient } from "./billing.js"; import { type DB, getDb } from "./db.js"; import { getOrCreateSelfHostUser, resolveCaller, touchDevice } from "./identity.js"; +import { handleMcpRequest } from "./mcp.js"; import { originPolicy } from "./origin-policy.js"; import { buildAuthDeviceRoutes, cleanupExpiredDeviceCodes } from "./routes/auth-device.js"; import { buildBillingRoutes } from "./routes/billing.js"; @@ -609,6 +610,48 @@ app.all("/auth/*", async (c) => { return auth.handler(c.req.raw); }); +// ── MCP server for AI coding agents ── +// Stateless Streamable HTTP at /mcp. Auth: agent token (Bearer dvc_…) from +// the dashboard's AI agents page, any device token, or the session. +app.all("/mcp", async (c) => { + const env = c.env; + const db = getDb(env); + const auth = createAuth(env); + let userId: string; + let deviceId: string | null = null; + if (auth) { + const caller = await resolveCaller(auth, db, c.req.raw); + if (!caller) { + return c.json( + { + error: + "BridgeHook MCP needs an agent token: create one at https://app.bridgehook.dev/dashboard/agents and send it as Authorization: Bearer .", + }, + 401, + { "WWW-Authenticate": 'Bearer realm="BridgeHook"' }, + ); + } + userId = caller.userId; + deviceId = caller.deviceId; + if (deviceId) await touchDevice(db, deviceId); + } else { + userId = await getOrCreateSelfHostUser(db, env); + } + return handleMcpRequest(c.req.raw, { + db, + userId, + deviceId, + requestUrl: new URL(c.req.url), + tunnelDomain: env.TUNNEL_DOMAIN, + notifier: { + getChannelDO: (channelId: string) => getChannelDO(env, channelId), + notifyUser: (uid: string | null, payload: string) => notifyUserDO(env, uid, payload), + }, + deliver: (channelId: string, request: Request) => + handleWebhookIntake(channelId, new URL(request.url).pathname || "/", request, env), + }); +}); + // ── Health ── app.get("/health", (c) => c.json({ status: "ok" })); diff --git a/relay/src/mcp.ts b/relay/src/mcp.ts new file mode 100644 index 0000000..b6d4ef5 --- /dev/null +++ b/relay/src/mcp.ts @@ -0,0 +1,501 @@ +/** + * BridgeHook MCP server: `https://relay.bridgehook.dev/mcp`. + * + * Lets a coding agent (Claude Code, Cursor, Codex, any MCP client) run the + * whole webhook loop against the user's real local server, with nothing to + * install: get a permanent webhook URL for a port, fire a realistic signed + * provider webhook at it, wait for it to arrive, read what the local handler + * answered, and replay it after fixing the code. + * + * Transport: MCP Streamable HTTP, stateless (a fresh server per request, + * JSON responses), so it runs in the relay Worker with no session storage. + * Auth: `Authorization: Bearer dvc_…`, an agent token minted from the + * dashboard (or any device token); the browser session also works. + */ +import { events, channels } from "@bridgehook/shared/db/schema"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; +import { and, asc, desc, eq, gt, like, sql } from "drizzle-orm"; +import { z } from "zod"; +import { checkChannelCreate, loadUserAccess } from "./access.js"; +import type { DB } from "./db.js"; +import { + type ReplayNotifier, + loadOwnedEvent, + queueReplay, + serializeEventDetail, +} from "./event-store.js"; +import { clampSyncTimeout } from "./sync.js"; +import { TEST_PROVIDERS, buildTestEvent } from "./test-events.js"; +import { buildWebhookUrl } from "./webhook-url.js"; + +export interface McpContext { + db: DB; + userId: string; + deviceId: string | null; + /** URL of the /mcp request; its origin builds path-form webhook URLs. */ + requestUrl: URL; + tunnelDomain?: string; + notifier: ReplayNotifier; + /** + * Deliver a request to a channel exactly as if the provider had sent it + * (full intake: allow-list, quota, storage, sync wait). + */ + deliver(channelId: string, request: Request): Promise; +} + +const INSTRUCTIONS = `BridgeHook delivers real webhooks to the user's local server through a permanent URL per port, even while their machine is off (queued, delivered in order). Use it to build and debug webhook handlers end to end. + +Typical loop: +1. create_webhook_url(port) for the port the user's app listens on. Register that URL with the provider, or skip that and use send_test_event. +2. send_test_event(channel_id, provider, signing_secret?) fires a realistic, optionally signed payload (stripe, github, openai, elevenlabs, vapi, generic), or wait_for_webhook catches a real one. +3. get_event(event_id) shows the request AND what the local handler answered (status, headers, body, latency, error). +4. Fix the handler, then replay_event(event_id) and compare. + +Forwarding to localhost is done by the user's BridgeHook Chrome extension. If an event stays unanswered, ask the user to add the port in the BridgeHook extension (it reuses the same URL). For tool-call webhooks whose reply matters (Vapi, ElevenLabs server tools) or GET verification challenges, set_response_mode(channel_id, "sync").`; + +const BODY_LIMIT = 16_000; + +function text(v: unknown) { + return { + content: [ + { type: "text" as const, text: typeof v === "string" ? v : JSON.stringify(v, null, 2) }, + ], + }; +} +function fail(message: string) { + return { content: [{ type: "text" as const, text: message }], isError: true }; +} +function clip(s: string | null): string | null { + if (s == null || s.length <= BODY_LIMIT) return s; + return `${s.slice(0, BODY_LIMIT)}\n… [truncated: ${s.length} chars total; use the dashboard for the full body]`; +} +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +async function ownedChannel(db: DB, userId: string, channelId: string) { + const [ch] = await db + .select() + .from(channels) + .where(and(eq(channels.id, channelId), eq(channels.userId, userId))) + .limit(1); + return ch ?? null; +} + +async function newPublicKeyHex(): Promise { + // The relay keeps only the public key. The extension + // adopts the channel later by rotating in its own key. + const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [ + "sign", + "verify", + ])) as CryptoKeyPair; + const raw = new Uint8Array((await crypto.subtle.exportKey("raw", pair.publicKey)) as ArrayBuffer); + return Array.from(raw, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +/** Poll an event row until an executor has answered it, or time runs out. */ +async function waitForAnswer(db: DB, userId: string, eventId: string, deadline: number) { + for (;;) { + const evt = await loadOwnedEvent(db, userId, eventId); + if (!evt) return null; + if (evt.responseStatus !== null || evt.error !== null || Date.now() >= deadline) return evt; + await sleep(1000); + } +} + +function summarize(evt: ReturnType) { + return { + ...evt, + requestBody: clip(evt.requestBody), + responseBody: clip(evt.responseBody), + status: + evt.responseStatus === null && evt.error === null + ? "pending: no executor has answered yet" + : evt.responseStatus === 0 + ? "failed: local server unreachable or kept failing" + : `answered ${evt.responseStatus}`, + }; +} + +export function buildMcpServer(ctx: McpContext): McpServer { + const server = new McpServer( + { name: "bridgehook", version: "1.0.0" }, + { instructions: INSTRUCTIONS }, + ); + const urlFor = (id: string) => buildWebhookUrl(id, ctx.requestUrl, ctx.tunnelDomain); + + server.registerTool( + "list_webhook_urls", + { + title: "List webhook URLs", + description: + "The user's BridgeHook channels: webhook URL, local port, reply mode, last event.", + inputSchema: {}, + annotations: { readOnlyHint: true }, + }, + async () => { + const rows = await ctx.db + .select({ + id: channels.id, + port: channels.port, + label: channels.label, + responseMode: channels.responseMode, + syncTimeoutMs: channels.syncTimeoutMs, + lastEventAt: sql< + number | null + >`(select max(${events.receivedAt}) from ${events} where ${events.channelId} = ${channels.id})`, + }) + .from(channels) + .where(eq(channels.userId, ctx.userId)) + .orderBy(desc(channels.createdAt)); + return text({ + channels: rows.map((r) => ({ + channel_id: r.id, + webhook_url: urlFor(r.id), + local_port: r.port, + label: r.label, + reply_mode: r.responseMode, + sync_timeout_seconds: r.syncTimeoutMs / 1000, + last_event_at: r.lastEventAt ? new Date(r.lastEventAt).toISOString() : null, + })), + }); + }, + ); + + server.registerTool( + "create_webhook_url", + { + title: "Get a webhook URL for a local port", + description: + "Returns the permanent webhook URL for a local port, creating it if needed (the same port always gets the same URL). Optionally sets the reply mode.", + inputSchema: { + port: z.number().int().min(1).max(65535).describe("Port the local app listens on"), + label: z.string().max(64).optional().describe("Name shown in the dashboard"), + reply_mode: z + .enum(["async", "sync"]) + .optional() + .describe("async: sender gets 202 at once. sync: sender gets the local server's reply"), + sync_timeout_seconds: z.number().min(1).max(100).optional(), + }, + }, + async ({ port, label, reply_mode, sync_timeout_seconds }) => { + const patch: { responseMode?: "async" | "sync"; syncTimeoutMs?: number; label?: string } = {}; + if (reply_mode) patch.responseMode = reply_mode; + if (sync_timeout_seconds !== undefined) { + patch.syncTimeoutMs = clampSyncTimeout(sync_timeout_seconds * 1000) ?? undefined; + } + const [existing] = await ctx.db + .select() + .from(channels) + .where(and(eq(channels.userId, ctx.userId), eq(channels.port, port))) + .orderBy(asc(channels.createdAt)) + .limit(1); + if (existing) { + if (Object.keys(patch).length > 0) { + await ctx.db.update(channels).set(patch).where(eq(channels.id, existing.id)); + } + return text({ + channel_id: existing.id, + webhook_url: urlFor(existing.id), + local_port: port, + reply_mode: patch.responseMode ?? existing.responseMode, + created: false, + }); + } + const access = await loadUserAccess(ctx.db, ctx.userId); + if (!access) return fail("Account not found"); + const gate = await checkChannelCreate(ctx.db, access); + if (!gate.ok) return fail(gate.error); + const id = crypto.randomUUID().replace(/-/g, "").slice(0, 12); + await ctx.db.insert(channels).values({ + id, + publicKey: await newPublicKeyHex(), + port, + allowedPaths: "[]", + userId: ctx.userId, + deviceId: ctx.deviceId, + label: label ?? null, + expiresAt: null, + responseMode: patch.responseMode ?? "async", + syncTimeoutMs: patch.syncTimeoutMs ?? 25_000, + }); + return text({ + channel_id: id, + webhook_url: urlFor(id), + local_port: port, + reply_mode: patch.responseMode ?? "async", + created: true, + next_step: `Webhooks to this URL are queued (nothing is lost). To forward them to localhost:${port}, the user adds port ${port} in the BridgeHook Chrome extension, which adopts this same URL.`, + }); + }, + ); + + server.registerTool( + "set_response_mode", + { + title: "Set reply mode", + description: + "sync: the webhook sender gets the local server's actual reply (for Vapi/ElevenLabs tool calls and GET verification challenges). async: the sender gets 202 at once.", + inputSchema: { + channel_id: z.string(), + reply_mode: z.enum(["async", "sync"]), + sync_timeout_seconds: z.number().min(1).max(100).optional(), + }, + }, + async ({ channel_id, reply_mode, sync_timeout_seconds }) => { + const ch = await ownedChannel(ctx.db, ctx.userId, channel_id); + if (!ch) return fail("Channel not found"); + const syncTimeoutMs = + sync_timeout_seconds === undefined + ? ch.syncTimeoutMs + : (clampSyncTimeout(sync_timeout_seconds * 1000) ?? ch.syncTimeoutMs); + await ctx.db + .update(channels) + .set({ responseMode: reply_mode, syncTimeoutMs }) + .where(eq(channels.id, ch.id)); + return text({ channel_id: ch.id, reply_mode, sync_timeout_seconds: syncTimeoutMs / 1000 }); + }, + ); + + server.registerTool( + "send_test_event", + { + title: "Send a test webhook", + description: + "Fire a realistic provider webhook at a channel, signed like the provider when signing_secret is given (Stripe, GitHub, OpenAI Standard Webhooks, ElevenLabs), then wait for the local server's answer.", + inputSchema: { + channel_id: z.string(), + provider: z.enum(TEST_PROVIDERS), + event_type: z + .string() + .max(80) + .optional() + .describe("e.g. checkout.session.completed, push, batch.completed"), + path: z + .string() + .max(256) + .optional() + .describe("Path on the local server, e.g. /api/webhooks/stripe"), + signing_secret: z + .string() + .max(256) + .optional() + .describe("The endpoint secret the handler verifies with"), + wait_seconds: z + .number() + .min(0) + .max(55) + .optional() + .describe("How long to wait for the answer (default 20)"), + }, + }, + async ({ channel_id, provider, event_type, path, signing_secret, wait_seconds }) => { + const ch = await ownedChannel(ctx.db, ctx.userId, channel_id); + if (!ch) return fail("Channel not found"); + const testEvent = await buildTestEvent(provider, { + type: event_type, + secret: signing_secret, + }); + const p = path ? (path.startsWith("/") ? path : `/${path}`) : "/"; + const target = ctx.tunnelDomain + ? `https://${ch.id}.${ctx.tunnelDomain}${p}` + : `${ctx.requestUrl.origin}/${ch.id}${p === "/" ? "" : p}`; + const req = new Request(target, { + method: testEvent.method, + headers: { ...testEvent.headers, host: new URL(target).host }, + body: testEvent.body, + }); + const res = await ctx.deliver(ch.id, req); + const resText = await res.text(); + if (ch.responseMode === "sync") { + return text({ + sent: testEvent.description, + signed: testEvent.signed, + reply_mode: "sync", + reply_returned_to_sender: { + status: res.status, + event_id: res.headers.get("x-bridgehook-event-id"), + content_type: res.headers.get("content-type"), + body: clip(resText), + }, + }); + } + let eventId: string | null = null; + try { + eventId = (JSON.parse(resText) as { eventId?: string }).eventId ?? null; + } catch {} + if (res.status !== 202 || !eventId) { + return fail(`Relay refused the test webhook: ${res.status} ${resText.slice(0, 300)}`); + } + const deadline = Date.now() + (wait_seconds ?? 20) * 1000; + const evt = await waitForAnswer(ctx.db, ctx.userId, eventId, deadline); + return text({ + sent: testEvent.description, + signed: testEvent.signed, + event: evt ? summarize(serializeEventDetail(evt)) : { event_id: eventId }, + }); + }, + ); + + server.registerTool( + "wait_for_webhook", + { + title: "Wait for a webhook", + description: + "Block until a webhook matching the filters arrives (default: any new webhook on any channel), optionally until the local server has answered it.", + inputSchema: { + channel_id: z.string().optional(), + method: z.string().max(10).optional(), + path_contains: z.string().max(200).optional(), + since: z.string().optional().describe("ISO time; default: now"), + timeout_seconds: z.number().min(1).max(55).optional().describe("default 30"), + wait_for_answer: z + .boolean() + .optional() + .describe("Also wait until the local server answered"), + }, + annotations: { readOnlyHint: true }, + }, + async ({ channel_id, method, path_contains, since, timeout_seconds, wait_for_answer }) => { + const start = since ? new Date(since) : new Date(Date.now() - 1000); + if (Number.isNaN(start.getTime())) return fail("since must be an ISO date-time"); + const deadline = Date.now() + (timeout_seconds ?? 30) * 1000; + const conds = [eq(channels.userId, ctx.userId), gt(events.receivedAt, start)]; + if (channel_id) conds.push(eq(events.channelId, channel_id)); + if (method) conds.push(eq(events.method, method.toUpperCase())); + if (path_contains) { + const pat = `%${path_contains.replace(/[%_\\]/g, (s) => `\\${s}`)}%`; + conds.push(sql`${events.path} LIKE ${pat} ESCAPE '\\'`); + } + for (;;) { + const [hit] = await ctx.db + .select({ id: events.id }) + .from(events) + .innerJoin(channels, eq(events.channelId, channels.id)) + .where(and(...conds)) + .orderBy(asc(events.receivedAt)) + .limit(1); + if (hit) { + const evt = wait_for_answer + ? await waitForAnswer(ctx.db, ctx.userId, hit.id, deadline) + : await loadOwnedEvent(ctx.db, ctx.userId, hit.id); + return text(evt ? summarize(serializeEventDetail(evt)) : { event_id: hit.id }); + } + if (Date.now() >= deadline) { + return text({ + received: false, + message: "No matching webhook arrived before the timeout.", + }); + } + await sleep(1000); + } + }, + ); + + server.registerTool( + "get_event", + { + title: "Get a webhook event", + description: + "The full request and what the local server answered (status, headers, body, latency, error).", + inputSchema: { event_id: z.string() }, + annotations: { readOnlyHint: true }, + }, + async ({ event_id }) => { + const evt = await loadOwnedEvent(ctx.db, ctx.userId, event_id); + if (!evt) return fail("Event not found"); + return text(summarize(serializeEventDetail(evt))); + }, + ); + + server.registerTool( + "list_events", + { + title: "List recent webhook events", + description: "Most recent events, newest first.", + inputSchema: { + channel_id: z.string().optional(), + path_contains: z.string().max(200).optional(), + limit: z.number().int().min(1).max(50).optional(), + }, + annotations: { readOnlyHint: true }, + }, + async ({ channel_id, path_contains, limit }) => { + const conds = [eq(channels.userId, ctx.userId)]; + if (channel_id) conds.push(eq(events.channelId, channel_id)); + if (path_contains) { + conds.push(like(events.path, `%${path_contains.replace(/[%_\\]/g, "")}%`)); + } + const rows = await ctx.db + .select({ + id: events.id, + channelId: events.channelId, + method: events.method, + path: events.path, + responseStatus: events.responseStatus, + latencyMs: events.latencyMs, + error: events.error, + kind: events.kind, + receivedAt: events.receivedAt, + }) + .from(events) + .innerJoin(channels, eq(events.channelId, channels.id)) + .where(and(...conds)) + .orderBy(desc(events.receivedAt)) + .limit(limit ?? 20); + return text({ + events: rows.map((r) => ({ + event_id: r.id, + channel_id: r.channelId, + method: r.method, + path: r.path, + response_status: r.responseStatus, + latency_ms: r.latencyMs, + error: r.error, + kind: r.kind, + received_at: r.receivedAt.toISOString(), + })), + }); + }, + ); + + server.registerTool( + "replay_event", + { + title: "Replay a webhook", + description: + "Send an earlier webhook to the local server again (optionally with a new body or headers) and wait for the new answer. Use after fixing the handler.", + inputSchema: { + event_id: z.string(), + body: z.string().optional(), + headers: z.record(z.string(), z.string()).optional(), + wait_seconds: z.number().min(0).max(55).optional().describe("default 20"), + }, + }, + async ({ event_id, body, headers, wait_seconds }) => { + const source = await loadOwnedEvent(ctx.db, ctx.userId, event_id); + if (!source) return fail("Event not found"); + const replay = await queueReplay(ctx.db, ctx.notifier, ctx.userId, source, { body, headers }); + if (!replay.ok) return fail(replay.error); + const evt = await waitForAnswer( + ctx.db, + ctx.userId, + replay.replayId, + Date.now() + (wait_seconds ?? 20) * 1000, + ); + return text(evt ? summarize(serializeEventDetail(evt)) : { replay_id: replay.replayId }); + }, + ); + + return server; +} + +/** Handle one MCP request (stateless Streamable HTTP, JSON responses). */ +export async function handleMcpRequest(request: Request, ctx: McpContext): Promise { + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + const server = buildMcpServer(ctx); + await server.connect(transport); + return transport.handleRequest(request); +} diff --git a/relay/src/routes/me-devices.ts b/relay/src/routes/me-devices.ts index cf68f29..ab3143d 100644 --- a/relay/src/routes/me-devices.ts +++ b/relay/src/routes/me-devices.ts @@ -5,7 +5,7 @@ * implicit single-user setup has no concept of "my devices." */ import { devices } from "@bridgehook/shared/db/schema"; -import { and, desc, eq, isNull } from "drizzle-orm"; +import { and, count, desc, eq, isNull } from "drizzle-orm"; import { Hono } from "hono"; import { checkDevicePair, loadUserAccess } from "../access.js"; import { type Auth, getSessionUser } from "../auth.js"; @@ -25,7 +25,9 @@ async function sha256Hex(input: string): Promise { return out; } -const DEVICE_KIND_RE = /^(extension|desktop|cli|web)$/; +const DEVICE_KIND_RE = /^(extension|desktop|cli|web|agent)$/; +/** Agent tokens (MCP clients) are API keys, not devices: capped separately. */ +const MAX_AGENT_TOKENS = 10; export function buildMeDevicesRoutes(getDeps: (c: { env: unknown }) => MeDevicesEnv | null) { const app = new Hono(); @@ -73,7 +75,7 @@ export function buildMeDevicesRoutes(getDeps: (c: { env: unknown }) => MeDevices const raw = (body ?? {}) as { kind?: unknown; label?: unknown; userAgent?: unknown }; const kind = typeof raw.kind === "string" ? raw.kind : "extension"; if (!DEVICE_KIND_RE.test(kind)) { - return c.json({ error: "kind must be 'extension'|'desktop'|'cli'|'web'" }, 400); + return c.json({ error: "kind must be 'extension'|'desktop'|'cli'|'web'|'agent'" }, 400); } const labelInput = typeof raw.label === "string" && raw.label.trim().length > 0 @@ -85,8 +87,18 @@ export function buildMeDevicesRoutes(getDeps: (c: { env: unknown }) => MeDevices // session-authed shortcut to skip the Hobby/Pro device cap. const access = await loadUserAccess(deps.db, sessionUser.id); if (!access) return c.json({ error: "User not found" }, 404); - const gate = await checkDevicePair(deps.db, access); - if (!gate.ok) return c.json({ error: gate.error, code: "quota" }, gate.status); + if (kind === "agent") { + const n = await countActiveAgentTokens(deps.db, sessionUser.id); + if (n >= MAX_AGENT_TOKENS) { + return c.json( + { error: `At most ${MAX_AGENT_TOKENS} agent tokens. Revoke one first.`, code: "quota" }, + 402, + ); + } + } else { + const gate = await checkDevicePair(deps.db, access); + if (!gate.ok) return c.json({ error: gate.error, code: "quota" }, gate.status); + } const token = newDeviceToken(); const tokenHash = await sha256Hex(token); @@ -175,3 +187,11 @@ export function buildMeDevicesRoutes(getDeps: (c: { env: unknown }) => MeDevices return app; } + +async function countActiveAgentTokens(db: DB, userId: string): Promise { + const [{ n }] = await db + .select({ n: count() }) + .from(devices) + .where(and(eq(devices.userId, userId), eq(devices.kind, "agent"), isNull(devices.revokedAt))); + return Number(n); +} diff --git a/relay/src/routes/me.ts b/relay/src/routes/me.ts index bed59ea..691b978 100644 --- a/relay/src/routes/me.ts +++ b/relay/src/routes/me.ts @@ -14,6 +14,12 @@ import { Hono } from "hono"; import { checkReplay, finiteOrNull, loadDailyEventCount, loadUserAccess } from "../access.js"; import { type Auth, getSessionUser } from "../auth.js"; import type { DB } from "../db.js"; +import { + loadOwnedEvent, + queueReplay, + safeJsonObject, + serializeEventDetail, +} from "../event-store.js"; import { resolveCaller } from "../identity.js"; import { clampSyncTimeout, isResponseMode } from "../sync.js"; import { buildWebhookUrl } from "../webhook-url.js"; @@ -24,8 +30,6 @@ const MAX_FEED_LIMIT = 100; /** Per-filter list cap; keeps every query well under D1's 100 bound parameters. */ const MAX_FILTER_IDS = 20; const DEFAULT_FEED_LIMIT = 50; -const EVENT_ID_LEN = 16; -const MAX_REPLAY_BODY_BYTES = 1_048_576; // 1 MB const ONE_DAY_MS = 24 * 60 * 60 * 1000; /** @@ -275,7 +279,9 @@ export function buildMeRoutes(getDeps: (c: { env: unknown }) => MeEnv | null) { const deps = getDeps(c); if (!deps) return c.json({ error: "Auth not configured" }, 404); - const sessionUser = await getSessionUser(deps.auth, c.req.raw); + // Device tokens too: an extension or CLI adopting a channel created + // elsewhere (dashboard, MCP agent) rotates in its own key. + const sessionUser = await resolveReader(deps, c.req.raw); if (!sessionUser) return c.json({ error: "Not signed in" }, 401); const channelId = c.req.param("channelId"); @@ -576,82 +582,12 @@ export function buildMeRoutes(getDeps: (c: { env: unknown }) => MeEnv | null) { const body = await c.req.json().catch(() => ({})); const edits = (body ?? {}) as { body?: unknown; headers?: unknown }; - - // Body: optional override; otherwise reuse the source's body. - let nextBody: string | null; - if (edits.body === undefined || edits.body === null) { - nextBody = source.requestBody; - } else if (typeof edits.body === "string") { - if (edits.body.length > MAX_REPLAY_BODY_BYTES) { - return c.json({ error: "Body too large" }, 413); - } - nextBody = edits.body; - } else { - return c.json({ error: "body must be a string or null" }, 400); - } - - // Headers: optional override; sanitize to a flat string→string map. - let nextHeaders: Record; - if (edits.headers === undefined || edits.headers === null) { - nextHeaders = safeJsonObject(source.requestHeaders); - } else if ( - edits.headers && - typeof edits.headers === "object" && - !Array.isArray(edits.headers) - ) { - nextHeaders = {}; - for (const [k, v] of Object.entries(edits.headers as Record)) { - if (typeof k === "string" && typeof v === "string") { - nextHeaders[k] = v; - } - } - } else { - return c.json({ error: "headers must be an object or null" }, 400); - } - - const replayId = freshEventId(); - const [inserted] = await deps.db - .insert(events) - .values({ - id: replayId, - channelId: source.channelId, - method: source.method, - path: source.path, - requestHeaders: JSON.stringify(nextHeaders), - requestBody: nextBody, - kind: "replay", - replayOf: source.id, - replayedByUserId: sessionUser.id, - }) - .returning(); - if (!inserted) { - return c.json({ error: "Could not queue replay" }, 500); - } - - // Wake any SSE subscribers immediately. Best-effort — extension still - // catches it on its next 2s poll if the DO notify fails. - const ssePayload = JSON.stringify({ - type: "webhook", - id: inserted.id, - channelId: source.channelId, - method: inserted.method, - path: inserted.path, - headers: nextHeaders, - body: nextBody ?? "", - receivedAt: inserted.receivedAt.toISOString(), - kind: "replay", - replayOf: source.id, - }); - const stub = deps.notifier.getChannelDO(source.channelId); - stub - .fetch(new Request("https://do/notify", { method: "POST", body: ssePayload })) - .catch((err) => console.error("DO notify failed:", err)); - deps.notifier.notifyUser(sessionUser.id, ssePayload); - + const replay = await queueReplay(deps.db, deps.notifier, sessionUser.id, source, edits); + if (!replay.ok) return c.json({ error: replay.error }, replay.status); return c.json({ - replayId: inserted.id, - channelId: inserted.channelId, - receivedAt: inserted.receivedAt.toISOString(), + replayId: replay.replayId, + channelId: replay.channelId, + receivedAt: replay.receivedAt, }); }); @@ -794,73 +730,7 @@ function hexToBytes(hex: string): Uint8Array { return out; } -function safeJsonObject(raw: string | null): Record { - if (!raw) return {}; - try { - const v = JSON.parse(raw); - if (!v || typeof v !== "object" || Array.isArray(v)) return {}; - const out: Record = {}; - for (const [k, val] of Object.entries(v as Record)) { - if (typeof val === "string") out[k] = val; - } - return out; - } catch { - return {}; - } -} - -function freshEventId(): string { - return crypto.randomUUID().replace(/-/g, "").slice(0, EVENT_ID_LEN); -} - /** * Ownership-gated event load. Joins channels to verify the user owns the * event's channel; returns null otherwise. */ -async function loadOwnedEvent(db: DB, userId: string, eventId: string) { - const [row] = await db - .select({ - id: events.id, - channelId: events.channelId, - method: events.method, - path: events.path, - requestHeaders: events.requestHeaders, - requestBody: events.requestBody, - responseStatus: events.responseStatus, - responseHeaders: events.responseHeaders, - responseBody: events.responseBody, - latencyMs: events.latencyMs, - error: events.error, - receivedAt: events.receivedAt, - kind: events.kind, - replayOf: events.replayOf, - replayedByUserId: events.replayedByUserId, - deviceId: events.deviceId, - }) - .from(events) - .innerJoin(channels, eq(events.channelId, channels.id)) - .where(and(eq(events.id, eventId), eq(channels.userId, userId))) - .limit(1); - return row ?? null; -} - -function serializeEventDetail(r: NonNullable>>) { - return { - id: r.id, - channelId: r.channelId, - method: r.method, - path: r.path, - requestHeaders: safeJsonObject(r.requestHeaders), - requestBody: r.requestBody, - responseStatus: r.responseStatus, - responseHeaders: r.responseHeaders ? safeJsonObject(r.responseHeaders) : null, - responseBody: r.responseBody, - latencyMs: r.latencyMs, - error: r.error, - receivedAt: r.receivedAt.toISOString(), - kind: r.kind, - replayOf: r.replayOf, - replayedByUserId: r.replayedByUserId, - deviceId: r.deviceId, - }; -} diff --git a/relay/src/sync.ts b/relay/src/sync.ts index ebcc3d1..9660ecf 100644 --- a/relay/src/sync.ts +++ b/relay/src/sync.ts @@ -104,7 +104,7 @@ export function syncResponse( { error: "Your local server did not answer in time", eventId, - hint: "Is the BridgeHook extension, dashboard tab or CLI running for this URL? The event stays queued and will still be delivered.", + hint: "Is the BridgeHook extension (or a dashboard tab) running for this URL? The event stays queued and will still be delivered.", }, { status: 504, headers: { "Access-Control-Allow-Origin": corsOrigin } }, ); diff --git a/relay/src/test-events.test.ts b/relay/src/test-events.test.ts new file mode 100644 index 0000000..434bd43 --- /dev/null +++ b/relay/src/test-events.test.ts @@ -0,0 +1,82 @@ +import { createHmac } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { + TEST_PROVIDERS, + buildTestEvent, + githubSignature, + standardWebhooksSignature, + stripeSignature, +} from "./test-events.js"; + +describe("provider signatures match the providers' reference algorithms", () => { + const body = '{"hello":"world"}'; + + it("Stripe v1", async () => { + const expect1 = createHmac("sha256", "whsec_test").update(`1700000000.${body}`).digest("hex"); + expect(await stripeSignature("whsec_test", body, 1700000000)).toBe( + `t=1700000000,v1=${expect1}`, + ); + }); + + it("GitHub sha256", async () => { + const ref = createHmac("sha256", "s3cret").update(body).digest("hex"); + expect(await githubSignature("s3cret", body)).toBe(`sha256=${ref}`); + }); + + it("Standard Webhooks (OpenAI) with a whsec_ base64 key", async () => { + const keyB64 = Buffer.from("0123456789abcdef0123456789abcdef").toString("base64"); + const ref = createHmac("sha256", Buffer.from(keyB64, "base64")) + .update(`wh_1.1700000000.${body}`) + .digest("base64"); + expect(await standardWebhooksSignature(`whsec_${keyB64}`, "wh_1", 1700000000, body)).toBe( + `v1,${ref}`, + ); + }); +}); + +describe("buildTestEvent", () => { + it("builds valid JSON for every provider", async () => { + for (const p of TEST_PROVIDERS) { + const e = await buildTestEvent(p); + expect(() => JSON.parse(e.body)).not.toThrow(); + expect(e.headers["content-type"]).toBe("application/json"); + expect(e.signed).toBe(false); + } + }); + + it("signs Stripe when given a secret, verifiable with the reference HMAC", async () => { + const e = await buildTestEvent("stripe", { secret: "whsec_abc", now: 1_700_000_000_000 }); + const [t, v1] = e.headers["stripe-signature"].split(",").map((x) => x.split("=")[1]); + expect(t).toBe("1700000000"); + expect(v1).toBe(createHmac("sha256", "whsec_abc").update(`${t}.${e.body}`).digest("hex")); + expect(e.signed).toBe(true); + }); + + it("OpenAI carries Standard Webhooks headers", async () => { + const keyB64 = Buffer.from("k".repeat(32)).toString("base64"); + const e = await buildTestEvent("openai", { + type: "batch.completed", + secret: `whsec_${keyB64}`, + }); + expect(e.headers["webhook-id"]).toMatch(/^wh_/); + expect(e.headers["webhook-signature"]).toMatch(/^v1,/); + expect(JSON.parse(e.body).type).toBe("batch.completed"); + }); + + it("signs ElevenLabs like the SDK's constructEvent verifies (t=…,v0=hex HMAC of t.body)", async () => { + const e = await buildTestEvent("elevenlabs", { secret: "wsec_el", now: 1_700_000_000_000 }); + const parts = Object.fromEntries( + e.headers["elevenlabs-signature"].split(",").map((p) => p.split("=")), + ); + expect(parts.t).toBe("1700000000"); + expect(parts.v0).toBe( + createHmac("sha256", "wsec_el").update(`${parts.t}.${e.body}`).digest("hex"), + ); + }); + + it("Vapi tool-calls carries a toolCallId and says what it expects back", async () => { + const e = await buildTestEvent("vapi"); + const id = JSON.parse(e.body).message.toolCallList[0].id; + expect(e.description).toContain(id); + }); +}); diff --git a/relay/src/test-events.ts b/relay/src/test-events.ts new file mode 100644 index 0000000..3708349 --- /dev/null +++ b/relay/src/test-events.ts @@ -0,0 +1,247 @@ +/** + * Realistic sample webhooks for `send_test_event`, so a coding agent can drive + * a local handler end to end without a provider account. When the caller + * passes the endpoint's signing secret, the request is signed exactly the way + * the provider signs it, so the handler's signature verification is exercised + * too. + */ + +export const TEST_PROVIDERS = [ + "stripe", + "github", + "openai", + "elevenlabs", + "vapi", + "generic", +] as const; +export type TestProvider = (typeof TEST_PROVIDERS)[number]; + +export interface TestEvent { + method: "POST"; + headers: Record; + body: string; + /** What was sent, for the agent's benefit. */ + description: string; + signed: boolean; +} + +const enc = new TextEncoder(); + +async function hmac(key: Uint8Array, data: string): Promise { + // Copy into a plain ArrayBuffer-backed view, as WebCrypto's types require. + const k = await crypto.subtle.importKey( + "raw", + new Uint8Array(key), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + return new Uint8Array(await crypto.subtle.sign("HMAC", k, enc.encode(data))); +} +const hex = (b: Uint8Array) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); +const b64 = (b: Uint8Array) => btoa(String.fromCharCode(...b)); +function b64decode(s: string): Uint8Array { + const bin = atob(s); + return Uint8Array.from(bin, (c) => c.charCodeAt(0)); +} +const rid = (prefix: string) => `${prefix}${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`; + +/** Stripe: `Stripe-Signature: t=,v1=HMAC_SHA256(secret, ".")`. */ +export async function stripeSignature(secret: string, body: string, t: number): Promise { + return `t=${t},v1=${hex(await hmac(enc.encode(secret), `${t}.${body}`))}`; +} + +/** GitHub: `X-Hub-Signature-256: sha256=HMAC_SHA256(secret, body)`. */ +export async function githubSignature(secret: string, body: string): Promise { + return `sha256=${hex(await hmac(enc.encode(secret), body))}`; +} + +/** + * Standard Webhooks (OpenAI): `webhook-signature: v1,base64(HMAC_SHA256(key, + * ".."))` where key is the base64 part of `whsec_…`. + */ +export async function standardWebhooksSignature( + secret: string, + id: string, + ts: number, + body: string, +): Promise { + const key = b64decode(secret.startsWith("whsec_") ? secret.slice(6) : secret); + return `v1,${b64(await hmac(key, `${id}.${ts}.${body}`))}`; +} + +export async function buildTestEvent( + provider: TestProvider, + opts: { type?: string; secret?: string; now?: number } = {}, +): Promise { + const now = Math.floor((opts.now ?? Date.now()) / 1000); + const json = { "content-type": "application/json", "user-agent": "BridgeHook-Test/1.0" }; + + switch (provider) { + case "stripe": { + const type = opts.type ?? "checkout.session.completed"; + const body = JSON.stringify({ + id: rid("evt_test_"), + object: "event", + api_version: "2025-09-30.clover", + created: now, + type, + livemode: false, + data: { + object: { + id: rid("cs_test_"), + object: "checkout.session", + amount_total: 2000, + currency: "usd", + customer_email: "test@example.com", + payment_status: "paid", + status: "complete", + }, + }, + }); + const headers: Record = { + ...json, + "user-agent": "Stripe/1.0 (+https://stripe.com/docs/webhooks)", + }; + if (opts.secret) headers["stripe-signature"] = await stripeSignature(opts.secret, body, now); + return { + method: "POST", + headers, + body, + description: `Stripe ${type}`, + signed: !!opts.secret, + }; + } + case "github": { + const type = opts.type ?? "push"; + const body = JSON.stringify({ + ref: "refs/heads/main", + before: "0000000000000000000000000000000000000000", + after: hex(crypto.getRandomValues(new Uint8Array(20))), + repository: { id: 1, full_name: "octo-org/hello-world", private: false }, + pusher: { name: "octocat", email: "octocat@github.com" }, + commits: [ + { id: "abc123", message: "Test commit from BridgeHook", author: { name: "octocat" } }, + ], + }); + const headers: Record = { + ...json, + "user-agent": "GitHub-Hookshot/bridgehook", + "x-github-event": type, + "x-github-delivery": crypto.randomUUID(), + }; + if (opts.secret) headers["x-hub-signature-256"] = await githubSignature(opts.secret, body); + return { + method: "POST", + headers, + body, + description: `GitHub ${type}`, + signed: !!opts.secret, + }; + } + case "openai": { + const type = opts.type ?? "response.completed"; + const id = rid("wh_"); + const body = JSON.stringify({ + id: rid("evt_"), + object: "event", + created_at: now, + type, + data: { id: type.startsWith("batch.") ? rid("batch_") : rid("resp_") }, + }); + const headers: Record = { + ...json, + "user-agent": "OpenAI/1.0 (+https://platform.openai.com/docs/webhooks)", + "webhook-id": id, + "webhook-timestamp": String(now), + }; + if (opts.secret) { + headers["webhook-signature"] = await standardWebhooksSignature(opts.secret, id, now, body); + } + return { + method: "POST", + headers, + body, + description: `OpenAI ${type} (Standard Webhooks)`, + signed: !!opts.secret, + }; + } + case "elevenlabs": { + const type = opts.type ?? "post_call_transcription"; + const body = JSON.stringify({ + type, + event_timestamp: now, + data: { + agent_id: rid("agent_"), + conversation_id: rid("conv_"), + status: "done", + transcript: [ + { role: "agent", message: "Hi! How can I help you today?", time_in_call_secs: 0 }, + { + role: "user", + message: "I'd like to book a table for two tonight.", + time_in_call_secs: 3, + }, + ], + metadata: { call_duration_secs: 42 }, + analysis: { + call_successful: "success", + transcript_summary: "Caller booked a table for two.", + }, + }, + }); + const headers: Record = { ...json, "user-agent": "ElevenLabs/1.0" }; + if (opts.secret) { + headers["elevenlabs-signature"] = + `t=${now},v0=${hex(await hmac(enc.encode(opts.secret), `${now}.${body}`))}`; + } + return { + method: "POST", + headers, + body, + description: `ElevenLabs ${type}`, + signed: !!opts.secret, + }; + } + case "vapi": { + const toolCallId = rid("call_"); + const body = JSON.stringify({ + message: { + type: opts.type ?? "tool-calls", + timestamp: now * 1000, + call: { id: rid("call-"), type: "webCall" }, + toolCallList: [ + { + id: toolCallId, + type: "function", + function: { name: "get_weather", arguments: { city: "Lagos" } }, + }, + ], + }, + }); + const headers: Record = { ...json, "user-agent": "Vapi/1.0" }; + if (opts.secret) headers["x-vapi-secret"] = opts.secret; + return { + method: "POST", + headers, + body, + description: `Vapi ${opts.type ?? "tool-calls"} (expects {"results":[{"toolCallId":"${toolCallId}","result":…}]})`, + signed: !!opts.secret, + }; + } + default: { + const body = JSON.stringify({ + event: opts.type ?? "test.event", + id: rid("evt_"), + created: now, + }); + return { + method: "POST", + headers: json, + body, + description: "Generic JSON test event", + signed: false, + }; + } + } +} diff --git a/relay/src/webhook-url.ts b/relay/src/webhook-url.ts index 214933c..32106cd 100644 --- a/relay/src/webhook-url.ts +++ b/relay/src/webhook-url.ts @@ -23,7 +23,7 @@ export const CHANNEL_ID_RE = /^[a-z0-9]{1,24}$/; * never equal one of these (ids are 12 random hex chars, so this only guards * against hand-typed URLs reaching a webhook handler). */ -export const RESERVED_SEGMENTS = new Set(["api", "auth", "hook", "health"]); +export const RESERVED_SEGMENTS = new Set(["api", "auth", "hook", "health", "mcp"]); /** Methods a webhook URL accepts; anything else is not forwarded. */ export const WEBHOOK_METHODS = ["POST", "PUT", "PATCH", "DELETE"] as const; diff --git a/relay/tsconfig.json b/relay/tsconfig.json index 37fe824..99b0a6e 100644 --- a/relay/tsconfig.json +++ b/relay/tsconfig.json @@ -6,5 +6,6 @@ "types": ["@cloudflare/workers-types"], "noEmit": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.test.ts"] } From 0733214fc72b38bf3552bb44eca1fa5020375363 Mon Sep 17 00:00:00 2001 From: hallelx2 Date: Thu, 8 Oct 2026 01:54:13 +0100 Subject: [PATCH 2/4] fix(relay): scope agent tokens to /mcp; fix test-event path, wait and secret errors Review of PR #7: - Agent tokens only authenticate on /mcp. Elsewhere they are rejected, so they cannot run as uncapped devices (POST /api/channels) or rotate a channel's key out from under the user's extension and answer its sync requests. Real executors still count against the device cap. - send_test_event passes the forward path to intake explicitly, so a self-hosted relay without TUNNEL_DOMAIN no longer forwards to //path. - In sync mode send_test_event returns after wait_seconds instead of blocking for the channel's whole sync timeout. - A malformed OpenAI signing secret is a clear tool error, not a crash. - Dashboard and docs say agent tokens only work with the MCP server. --- apps/web/src/pages/AiAgents.tsx | 3 ++- docs/src/pages/AiAgents.tsx | 3 ++- relay/src/identity.ts | 6 ++++- relay/src/index.ts | 6 ++--- relay/src/mcp.ts | 41 ++++++++++++++++++++++----------- relay/src/test-events.ts | 7 +++++- 6 files changed, 46 insertions(+), 20 deletions(-) diff --git a/apps/web/src/pages/AiAgents.tsx b/apps/web/src/pages/AiAgents.tsx index 710c7d1..ec87478 100644 --- a/apps/web/src/pages/AiAgents.tsx +++ b/apps/web/src/pages/AiAgents.tsx @@ -214,7 +214,8 @@ function AiAgentsView() { )}

- Agent tokens do not count toward your device limit. Up to 10 per account. + Agent tokens only work with BridgeHook's MCP server, so they do not count toward your + device limit. Up to 10 per account.

diff --git a/docs/src/pages/AiAgents.tsx b/docs/src/pages/AiAgents.tsx index b42f368..3aee0b9 100644 --- a/docs/src/pages/AiAgents.tsx +++ b/docs/src/pages/AiAgents.tsx @@ -15,7 +15,8 @@ export function AiAgents() { https://relay.bridgehook.dev/mcp (Streamable HTTP). Authenticate with an agent token from Dashboard → AI agents, sent as Authorization: Bearer dvc_…. - Agent tokens do not count toward your device limit and can be revoked at any time. + Agent tokens only work with the MCP server, do not count toward your device limit, and can + be revoked at any time.

Connect

diff --git a/relay/src/identity.ts b/relay/src/identity.ts index 13e81e4..998984a 100644 --- a/relay/src/identity.ts +++ b/relay/src/identity.ts @@ -71,6 +71,7 @@ export async function resolveCaller( auth: Auth | null, db: DB, request: Request, + opts: { allowAgentTokens?: boolean } = {}, ): Promise { // 1. Device token bearer const authHeader = request.headers.get("Authorization") || request.headers.get("authorization"); @@ -83,7 +84,10 @@ export async function resolveCaller( .from(devices) .where(and(eq(devices.tokenHash, tokenHash), isNull(devices.revokedAt))) .limit(1); - if (device) { + // Agent tokens (MCP clients) are scoped to /mcp: anywhere else they + // could run as an uncapped device or rotate a channel's key out from + // under the user's extension. + if (device && (device.kind !== "agent" || opts.allowAgentTokens)) { return { userId: device.userId, deviceId: device.id, via: "device-token" }; } } diff --git a/relay/src/index.ts b/relay/src/index.ts index dad5ce2..eb160bc 100644 --- a/relay/src/index.ts +++ b/relay/src/index.ts @@ -620,7 +620,7 @@ app.all("/mcp", async (c) => { let userId: string; let deviceId: string | null = null; if (auth) { - const caller = await resolveCaller(auth, db, c.req.raw); + const caller = await resolveCaller(auth, db, c.req.raw, { allowAgentTokens: true }); if (!caller) { return c.json( { @@ -647,8 +647,8 @@ app.all("/mcp", async (c) => { getChannelDO: (channelId: string) => getChannelDO(env, channelId), notifyUser: (uid: string | null, payload: string) => notifyUserDO(env, uid, payload), }, - deliver: (channelId: string, request: Request) => - handleWebhookIntake(channelId, new URL(request.url).pathname || "/", request, env), + deliver: (channelId: string, forwardPath: string, request: Request) => + handleWebhookIntake(channelId, forwardPath, request, env), }); }); diff --git a/relay/src/mcp.ts b/relay/src/mcp.ts index b6d4ef5..7dae06d 100644 --- a/relay/src/mcp.ts +++ b/relay/src/mcp.ts @@ -41,7 +41,7 @@ export interface McpContext { * Deliver a request to a channel exactly as if the provider had sent it * (full intake: allow-list, quota, storage, sync wait). */ - deliver(channelId: string, request: Request): Promise; + deliver(channelId: string, forwardPath: string, request: Request): Promise; } const INSTRUCTIONS = `BridgeHook delivers real webhooks to the user's local server through a permanent URL per port, even while their machine is off (queued, delivered in order). Use it to build and debug webhook handlers end to end. @@ -291,10 +291,12 @@ export function buildMcpServer(ctx: McpContext): McpServer { async ({ channel_id, provider, event_type, path, signing_secret, wait_seconds }) => { const ch = await ownedChannel(ctx.db, ctx.userId, channel_id); if (!ch) return fail("Channel not found"); - const testEvent = await buildTestEvent(provider, { - type: event_type, - secret: signing_secret, - }); + let testEvent: Awaited>; + try { + testEvent = await buildTestEvent(provider, { type: event_type, secret: signing_secret }); + } catch (err) { + return fail(err instanceof Error ? err.message : String(err)); + } const p = path ? (path.startsWith("/") ? path : `/${path}`) : "/"; const target = ctx.tunnelDomain ? `https://${ch.id}.${ctx.tunnelDomain}${p}` @@ -304,21 +306,35 @@ export function buildMcpServer(ctx: McpContext): McpServer { headers: { ...testEvent.headers, host: new URL(target).host }, body: testEvent.body, }); - const res = await ctx.deliver(ch.id, req); - const resText = await res.text(); + const waitMs = (wait_seconds ?? 20) * 1000; if (ch.responseMode === "sync") { + // The sender is held up to the channel's sync timeout; the agent + // waits at most wait_seconds and can look the event up afterwards. + const delivered = ctx.deliver(ch.id, p, req); + const raced = await Promise.race([delivered, sleep(waitMs).then(() => null)]); + if (!raced) { + return text({ + sent: testEvent.description, + signed: testEvent.signed, + reply_mode: "sync", + still_waiting: `The local server had not answered after ${waitMs / 1000}s. Check list_events / get_event for the outcome.`, + }); + } + const body = await raced.text(); return text({ sent: testEvent.description, signed: testEvent.signed, reply_mode: "sync", reply_returned_to_sender: { - status: res.status, - event_id: res.headers.get("x-bridgehook-event-id"), - content_type: res.headers.get("content-type"), - body: clip(resText), + status: raced.status, + event_id: raced.headers.get("x-bridgehook-event-id"), + content_type: raced.headers.get("content-type"), + body: clip(body), }, }); } + const res = await ctx.deliver(ch.id, p, req); + const resText = await res.text(); let eventId: string | null = null; try { eventId = (JSON.parse(resText) as { eventId?: string }).eventId ?? null; @@ -326,8 +342,7 @@ export function buildMcpServer(ctx: McpContext): McpServer { if (res.status !== 202 || !eventId) { return fail(`Relay refused the test webhook: ${res.status} ${resText.slice(0, 300)}`); } - const deadline = Date.now() + (wait_seconds ?? 20) * 1000; - const evt = await waitForAnswer(ctx.db, ctx.userId, eventId, deadline); + const evt = await waitForAnswer(ctx.db, ctx.userId, eventId, Date.now() + waitMs); return text({ sent: testEvent.description, signed: testEvent.signed, diff --git a/relay/src/test-events.ts b/relay/src/test-events.ts index 3708349..00672ef 100644 --- a/relay/src/test-events.ts +++ b/relay/src/test-events.ts @@ -66,7 +66,12 @@ export async function standardWebhooksSignature( ts: number, body: string, ): Promise { - const key = b64decode(secret.startsWith("whsec_") ? secret.slice(6) : secret); + let key: Uint8Array; + try { + key = b64decode(secret.startsWith("whsec_") ? secret.slice(6) : secret); + } catch { + throw new Error("OpenAI signing secret must be a whsec_… key (base64 after the prefix)"); + } return `v1,${b64(await hmac(key, `${id}.${ts}.${body}`))}`; } From b5edf80a72dbf1a7f3eb8a0a00993a4b5fc3f941 Mon Sep 17 00:00:00 2001 From: hallelx2 Date: Thu, 8 Oct 2026 02:12:49 +0100 Subject: [PATCH 3/4] fix(relay): gate MCP replays by plan and surface sync relay refusals Review follow-ups on the MCP server: - replay_event runs checkReplay like the dashboard route, so a read-only account (ended trial, canceled plan) cannot queue replays through /mcp. - create_webhook_url applies `label` to an existing port's channel instead of dropping it. - send_test_event on a sync channel no longer leaves the delivery promise unobserved: it is settled, kept alive with waitUntil, and a relay refusal (allow-list 403, daily cap 429) is reported as an error rather than "still waiting". The race gives at least 2s so those refusals surface even at wait_seconds 0. - 504/502 sync replies carry X-BridgeHook-Event-Id like a delivered reply, so senders and the agent can look the event up. - The resolveReader docblock now says rotate-key accepts device tokens. --- relay/src/index.ts | 1 + relay/src/mcp.ts | 41 ++++++++++++++++-- relay/src/routes/me.ts | 5 ++- relay/src/sync.test.ts | 11 +++++ relay/src/sync.ts | 10 ++++- relay/wrangler.test.toml | 93 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 relay/wrangler.test.toml diff --git a/relay/src/index.ts b/relay/src/index.ts index eb160bc..91d305c 100644 --- a/relay/src/index.ts +++ b/relay/src/index.ts @@ -649,6 +649,7 @@ app.all("/mcp", async (c) => { }, deliver: (channelId: string, forwardPath: string, request: Request) => handleWebhookIntake(channelId, forwardPath, request, env), + waitUntil: (p: Promise) => c.executionCtx.waitUntil(p), }); }); diff --git a/relay/src/mcp.ts b/relay/src/mcp.ts index 7dae06d..565056b 100644 --- a/relay/src/mcp.ts +++ b/relay/src/mcp.ts @@ -17,7 +17,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; import { and, asc, desc, eq, gt, like, sql } from "drizzle-orm"; import { z } from "zod"; -import { checkChannelCreate, loadUserAccess } from "./access.js"; +import { checkChannelCreate, checkReplay, loadUserAccess } from "./access.js"; import type { DB } from "./db.js"; import { type ReplayNotifier, @@ -42,6 +42,8 @@ export interface McpContext { * (full intake: allow-list, quota, storage, sync wait). */ deliver(channelId: string, forwardPath: string, request: Request): Promise; + /** Keep work alive after the response (Workers `ctx.waitUntil`). */ + waitUntil?(promise: Promise): void; } const INSTRUCTIONS = `BridgeHook delivers real webhooks to the user's local server through a permanent URL per port, even while their machine is off (queued, delivered in order). Use it to build and debug webhook handlers end to end. @@ -178,8 +180,13 @@ export function buildMcpServer(ctx: McpContext): McpServer { }, }, async ({ port, label, reply_mode, sync_timeout_seconds }) => { - const patch: { responseMode?: "async" | "sync"; syncTimeoutMs?: number; label?: string } = {}; + const patch: { + responseMode?: "async" | "sync"; + syncTimeoutMs?: number; + label?: string | null; + } = {}; if (reply_mode) patch.responseMode = reply_mode; + if (label !== undefined) patch.label = label.trim() || null; if (sync_timeout_seconds !== undefined) { patch.syncTimeoutMs = clampSyncTimeout(sync_timeout_seconds * 1000) ?? undefined; } @@ -213,7 +220,7 @@ export function buildMcpServer(ctx: McpContext): McpServer { allowedPaths: "[]", userId: ctx.userId, deviceId: ctx.deviceId, - label: label ?? null, + label: patch.label ?? null, expiresAt: null, responseMode: patch.responseMode ?? "async", syncTimeoutMs: patch.syncTimeoutMs ?? 25_000, @@ -310,8 +317,28 @@ export function buildMcpServer(ctx: McpContext): McpServer { if (ch.responseMode === "sync") { // The sender is held up to the channel's sync timeout; the agent // waits at most wait_seconds and can look the event up afterwards. + // Relay-side rejections (allow-list 403, daily cap 429) answer at + // once, so the race always gives them a moment even at wait 0. const delivered = ctx.deliver(ch.id, p, req); - const raced = await Promise.race([delivered, sleep(waitMs).then(() => null)]); + const settled = delivered.then( + (res) => ({ res, err: null as unknown }), + (err: unknown) => ({ res: null, err }), + ); + ctx.waitUntil?.(settled); + const outcome = await Promise.race([ + settled, + sleep(Math.max(waitMs, 2000)).then(() => null), + ]); + if (outcome?.err) { + return fail( + `Relay error: ${outcome.err instanceof Error ? outcome.err.message : String(outcome.err)}`, + ); + } + const raced = outcome?.res ?? null; + if (raced && !raced.headers.get("x-bridgehook-event-id")) { + // No event id: the relay refused it before storing anything. + return fail(`Relay refused the event (${raced.status}): ${clip(await raced.text())}`); + } if (!raced) { return text({ sent: testEvent.description, @@ -487,6 +514,12 @@ export function buildMcpServer(ctx: McpContext): McpServer { }, }, async ({ event_id, body, headers, wait_seconds }) => { + // Same gate as the dashboard: read-only accounts (expired trial, + // canceled plan) can read past events but not queue new ones. + const access = await loadUserAccess(ctx.db, ctx.userId); + if (!access) return fail("Account not found"); + const gate = checkReplay(access); + if (!gate.ok) return fail(gate.error); const source = await loadOwnedEvent(ctx.db, ctx.userId, event_id); if (!source) return fail("Event not found"); const replay = await queueReplay(ctx.db, ctx.notifier, ctx.userId, source, { body, headers }); diff --git a/relay/src/routes/me.ts b/relay/src/routes/me.ts index 691b978..f02f2b6 100644 --- a/relay/src/routes/me.ts +++ b/relay/src/routes/me.ts @@ -57,7 +57,10 @@ export interface MeEnv { * Caller for read endpoints: a device token (`Authorization: Bearer dvc_…`) * or the session cookie. A paired extension has to work with no dashboard * session at all, and it reads identity, plan, usage and its channels here. - * Mutations (channel edits, key rotation, replays, deletes) stay session-only. + * Key rotation also accepts device tokens, so an extension or CLI can adopt a + * channel created elsewhere (dashboard, MCP agent); agent tokens are refused + * by resolveCaller. Other mutations (channel edits, replays, deletes) stay + * session-only. */ async function resolveReader(deps: MeEnv, request: Request): Promise<{ id: string } | null> { const caller = await resolveCaller(deps.auth, deps.db, request); diff --git a/relay/src/sync.test.ts b/relay/src/sync.test.ts index f90f23f..234c800 100644 --- a/relay/src/sync.test.ts +++ b/relay/src/sync.test.ts @@ -122,6 +122,17 @@ describe("syncResponse", () => { ).toBe(502); }); + it("504 and 502 name the event so the sender can look it up", () => { + expect( + syncResponse({ kind: "timeout" }, "e1", "POST").headers.get("x-bridgehook-event-id"), + ).toBe("e1"); + expect( + syncResponse({ kind: "response", result: result(0, "refused") }, "e2", "POST").headers.get( + "x-bridgehook-event-id", + ), + ).toBe("e2"); + }); + it("cannot run script or set cookies, whatever localhost sends", async () => { const res = syncResponse( { diff --git a/relay/src/sync.ts b/relay/src/sync.ts index 9660ecf..7fe8542 100644 --- a/relay/src/sync.ts +++ b/relay/src/sync.ts @@ -106,7 +106,10 @@ export function syncResponse( eventId, hint: "Is the BridgeHook extension (or a dashboard tab) running for this URL? The event stays queued and will still be delivered.", }, - { status: 504, headers: { "Access-Control-Allow-Origin": corsOrigin } }, + { + status: 504, + headers: { "Access-Control-Allow-Origin": corsOrigin, "X-BridgeHook-Event-Id": eventId }, + }, ); } const { result } = outcome; @@ -119,7 +122,10 @@ export function syncResponse( eventId, detail: result.body.slice(0, 500), }, - { status: 502, headers: { "Access-Control-Allow-Origin": corsOrigin } }, + { + status: 502, + headers: { "Access-Control-Allow-Origin": corsOrigin, "X-BridgeHook-Event-Id": eventId }, + }, ); } const headers = senderHeaders(result.headers); diff --git a/relay/wrangler.test.toml b/relay/wrangler.test.toml new file mode 100644 index 0000000..51c2749 --- /dev/null +++ b/relay/wrangler.test.toml @@ -0,0 +1,93 @@ +name = "bridgehook-relay" +main = "src/index.ts" +compatibility_date = "2025-01-01" +compatibility_flags = ["nodejs_compat"] + +# ── Hosts (hosted bridgehook.dev deployment) ────────────────────────────── +# relay.bridgehook.dev internal API, claims, responses, live stream +# .bridgehook.dev the webhook URL people paste (src/webhook-url.ts) +# DNS: a proxied wildcard `*` record; relay/app/docs have their own records, +# which take precedence. app./docs. are Pages and are kept off this Worker +# by script-less zone routes (app.bridgehook.dev/*, docs.bridgehook.dev/*). +# Self-hosters: replace these with your own hostnames, or drop the wildcard +# route and TUNNEL_DOMAIN to use path URLs on the relay host. + +[vars] +TUNNEL_DOMAIN = "bridgehook.dev" + +# ── Database ────────────────────────────────────────────────────────────── +# Cloudflare D1. Schema: packages/shared/src/db/schema.ts → `pnpm db:generate` +# → migrations/; apply with `npx wrangler d1 migrations apply bridgehook +# --local` (dev) or `--remote` (production) before deploying code that needs it. +[[d1_databases]] +binding = "DB" +database_name = "bridgehook" +database_id = "1c61a656-2d2e-419b-b2e8-d86ff5498d3c" +migrations_dir = "migrations" + +[durable_objects] +bindings = [ + { name = "CHANNEL", class_name = "ChannelDO" }, + { name = "USER", class_name = "UserDO" }, +] + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["ChannelDO"] + +[[migrations]] +tag = "v2" +new_sqlite_classes = ["UserDO"] + +# ── Scheduled cleanup ───────────────────────────────────────────────────── +# Runs hourly. The handler in src/index.ts deletes channels whose +# expires_at < now() (events cascade via FK). +[triggers] +crons = ["0 * * * *"] + +# ── Rate-limit KV namespace ─────────────────────────────────────────────── +# Per-IP token bucket for POST /api/channels (10 req/min). +# Soft-fails if binding is absent, so dev still works without KV. +[[kv_namespaces]] +binding = "RATE_LIMIT" +id = "647c7df6a0e64bf3882738057ad42f38" + +# ── Auth / billing / mailer config ──────────────────────────────────────── +# All optional. When BETTER_AUTH_SECRET is unset the relay runs in self-host +# mode: /auth/** returns 404, channel creates resolve to the implicit +# SELF_HOST_USER_ID user (or auto-created self-host@local), no quotas, no +# Stripe paywall. Set the secrets via `wrangler secret put`. +# +# Required to enable hosted-mode auth: +# BETTER_AUTH_SECRET — random 32+ byte secret (cookie signing, CSRF) +# BETTER_AUTH_URL — e.g. https://relay.bridgehook.dev +# +# Optional: +# AUTH_COOKIE_DOMAIN — e.g. ".bridgehook.dev" for cross-subdomain cookies +# AUTH_TRUSTED_ORIGINS — comma-separated dashboard origins. Used for Better-Auth +# CSRF and for credentialed CORS (src/origin-policy.ts): +# in hosted mode, only these + WEB_URL + BETTER_AUTH_URL +# origins may call the relay with the session cookie. +# One leading wildcard label is allowed, e.g. +# https://*.bridgehook-web.pages.dev for Pages previews. +# WEB_URL — base URL of the dashboard (e.g. https://app.bridgehook.dev), +# used to build the verificationUrl during device pairing +# RESEND_API_KEY — magic-link delivery (falls back to console mailer) +# MAIL_FROM — sender address ("BridgeHook ") +# SELF_HOST_USER_ID — explicit single-user id for self-hosted instances +# +# Polar billing (all optional — unset = self-host shape, /api/me/billing/* → 503): +# POLAR_ACCESS_TOKEN — server token from polar.sh dashboard +# POLAR_WEBHOOK_SECRET — webhook secret (whsec_…) for signature verification +# POLAR_PRODUCT_ID_HOBBY — Polar product id for the Hobby tier +# POLAR_PRODUCT_ID_PRO — Polar product id for the Pro tier +# POLAR_PRODUCT_ID_TEAM — Polar product id for the Team tier +# +# TUNNEL_DOMAIN (in [vars] above) makes `.` the +# webhook URL. Without it the relay hands out `https:///`. +# +# Never set AUTH_COOKIE_DOMAIN to the TUNNEL_DOMAIN apex: the session cookie +# would then reach every channel host and could be captured in a channel +# owner's stored headers. +# +# Non-secret defaults can also live under [vars] block if needed. From ef93ff468595f5ac5da83a3366878e78e5af59f4 Mon Sep 17 00:00:00 2001 From: hallelx2 Date: Thu, 8 Oct 2026 02:13:03 +0100 Subject: [PATCH 4/4] chore(relay): keep the local wrangler.test.toml out of the repo --- relay/.gitignore | 1 + relay/wrangler.test.toml | 93 ---------------------------------------- 2 files changed, 1 insertion(+), 93 deletions(-) create mode 100644 relay/.gitignore delete mode 100644 relay/wrangler.test.toml diff --git a/relay/.gitignore b/relay/.gitignore new file mode 100644 index 0000000..487aaa3 --- /dev/null +++ b/relay/.gitignore @@ -0,0 +1 @@ +wrangler.test.toml diff --git a/relay/wrangler.test.toml b/relay/wrangler.test.toml deleted file mode 100644 index 51c2749..0000000 --- a/relay/wrangler.test.toml +++ /dev/null @@ -1,93 +0,0 @@ -name = "bridgehook-relay" -main = "src/index.ts" -compatibility_date = "2025-01-01" -compatibility_flags = ["nodejs_compat"] - -# ── Hosts (hosted bridgehook.dev deployment) ────────────────────────────── -# relay.bridgehook.dev internal API, claims, responses, live stream -# .bridgehook.dev the webhook URL people paste (src/webhook-url.ts) -# DNS: a proxied wildcard `*` record; relay/app/docs have their own records, -# which take precedence. app./docs. are Pages and are kept off this Worker -# by script-less zone routes (app.bridgehook.dev/*, docs.bridgehook.dev/*). -# Self-hosters: replace these with your own hostnames, or drop the wildcard -# route and TUNNEL_DOMAIN to use path URLs on the relay host. - -[vars] -TUNNEL_DOMAIN = "bridgehook.dev" - -# ── Database ────────────────────────────────────────────────────────────── -# Cloudflare D1. Schema: packages/shared/src/db/schema.ts → `pnpm db:generate` -# → migrations/; apply with `npx wrangler d1 migrations apply bridgehook -# --local` (dev) or `--remote` (production) before deploying code that needs it. -[[d1_databases]] -binding = "DB" -database_name = "bridgehook" -database_id = "1c61a656-2d2e-419b-b2e8-d86ff5498d3c" -migrations_dir = "migrations" - -[durable_objects] -bindings = [ - { name = "CHANNEL", class_name = "ChannelDO" }, - { name = "USER", class_name = "UserDO" }, -] - -[[migrations]] -tag = "v1" -new_sqlite_classes = ["ChannelDO"] - -[[migrations]] -tag = "v2" -new_sqlite_classes = ["UserDO"] - -# ── Scheduled cleanup ───────────────────────────────────────────────────── -# Runs hourly. The handler in src/index.ts deletes channels whose -# expires_at < now() (events cascade via FK). -[triggers] -crons = ["0 * * * *"] - -# ── Rate-limit KV namespace ─────────────────────────────────────────────── -# Per-IP token bucket for POST /api/channels (10 req/min). -# Soft-fails if binding is absent, so dev still works without KV. -[[kv_namespaces]] -binding = "RATE_LIMIT" -id = "647c7df6a0e64bf3882738057ad42f38" - -# ── Auth / billing / mailer config ──────────────────────────────────────── -# All optional. When BETTER_AUTH_SECRET is unset the relay runs in self-host -# mode: /auth/** returns 404, channel creates resolve to the implicit -# SELF_HOST_USER_ID user (or auto-created self-host@local), no quotas, no -# Stripe paywall. Set the secrets via `wrangler secret put`. -# -# Required to enable hosted-mode auth: -# BETTER_AUTH_SECRET — random 32+ byte secret (cookie signing, CSRF) -# BETTER_AUTH_URL — e.g. https://relay.bridgehook.dev -# -# Optional: -# AUTH_COOKIE_DOMAIN — e.g. ".bridgehook.dev" for cross-subdomain cookies -# AUTH_TRUSTED_ORIGINS — comma-separated dashboard origins. Used for Better-Auth -# CSRF and for credentialed CORS (src/origin-policy.ts): -# in hosted mode, only these + WEB_URL + BETTER_AUTH_URL -# origins may call the relay with the session cookie. -# One leading wildcard label is allowed, e.g. -# https://*.bridgehook-web.pages.dev for Pages previews. -# WEB_URL — base URL of the dashboard (e.g. https://app.bridgehook.dev), -# used to build the verificationUrl during device pairing -# RESEND_API_KEY — magic-link delivery (falls back to console mailer) -# MAIL_FROM — sender address ("BridgeHook ") -# SELF_HOST_USER_ID — explicit single-user id for self-hosted instances -# -# Polar billing (all optional — unset = self-host shape, /api/me/billing/* → 503): -# POLAR_ACCESS_TOKEN — server token from polar.sh dashboard -# POLAR_WEBHOOK_SECRET — webhook secret (whsec_…) for signature verification -# POLAR_PRODUCT_ID_HOBBY — Polar product id for the Hobby tier -# POLAR_PRODUCT_ID_PRO — Polar product id for the Pro tier -# POLAR_PRODUCT_ID_TEAM — Polar product id for the Team tier -# -# TUNNEL_DOMAIN (in [vars] above) makes `.` the -# webhook URL. Without it the relay hands out `https:///`. -# -# Never set AUTH_COOKIE_DOMAIN to the TUNNEL_DOMAIN apex: the session cookie -# would then reach every channel host and could be captured in a channel -# owner's stored headers. -# -# Non-secret defaults can also live under [vars] block if needed.