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}.
+
+ 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.
+
+ );
+}
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.
+ 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.
+
+
+
+ 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.
+
+
- 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.