diff --git a/apps/app/src/lib/dev-websocket-url.test.ts b/apps/app/src/lib/dev-websocket-url.test.ts
index b8c5ecb0de..e01783eae9 100644
--- a/apps/app/src/lib/dev-websocket-url.test.ts
+++ b/apps/app/src/lib/dev-websocket-url.test.ts
@@ -7,6 +7,7 @@ function installWindowLocation(url: string): void {
location: {
host: location.host,
hostname: location.hostname,
+ port: location.port,
protocol: location.protocol,
},
});
@@ -19,6 +20,7 @@ describe("buildDevWebSocketUrl", () => {
it("connects directly to the backend for HTTP source dev", () => {
vi.stubGlobal("__BB_DEV_WS_BROWSER_HOST_PORT__", 23_802);
+ vi.stubGlobal("__BB_DEV_APP_BROWSER_HOST_PORT__", 15_802);
installWindowLocation("http://devbox.local:15802/threads/thr_1");
expect(buildDevWebSocketUrl({ path: "/ws" })).toBe(
@@ -28,6 +30,7 @@ describe("buildDevWebSocketUrl", () => {
it("uses the proxied app origin for HTTPS bb connect shares", () => {
vi.stubGlobal("__BB_DEV_WS_BROWSER_HOST_PORT__", 23_802);
+ vi.stubGlobal("__BB_DEV_APP_BROWSER_HOST_PORT__", 15_802);
installWindowLocation(
"https://sawyer--15802.getbb.app/threads/thr_jew2ruik89",
);
@@ -37,13 +40,24 @@ describe("buildDevWebSocketUrl", () => {
);
});
+ it("uses the proxied app origin for HTTP local Cloud", () => {
+ vi.stubGlobal("__BB_DEV_WS_BROWSER_HOST_PORT__", 23_802);
+ vi.stubGlobal("__BB_DEV_APP_BROWSER_HOST_PORT__", 15_802);
+ installWindowLocation("http://sawyer.localhost:35802/threads/thr_1");
+
+ expect(buildDevWebSocketUrl({ path: "/ws" })).toBe(
+ "ws://sawyer.localhost:35802/ws",
+ );
+ });
+
it("preserves terminal websocket paths on the proxied app origin", () => {
vi.stubGlobal("__BB_DEV_WS_BROWSER_HOST_PORT__", 23_802);
+ vi.stubGlobal("__BB_DEV_APP_BROWSER_HOST_PORT__", 15_802);
installWindowLocation("https://dev.example.test:15802/threads/thr_1");
- expect(
- buildDevWebSocketUrl({ path: "/ws/terminals/term_1" }),
- ).toBe("wss://dev.example.test:15802/ws/terminals/term_1");
+ expect(buildDevWebSocketUrl({ path: "/ws/terminals/term_1" })).toBe(
+ "wss://dev.example.test:15802/ws/terminals/term_1",
+ );
});
it("returns undefined outside the dev build", () => {
diff --git a/apps/app/src/lib/dev-websocket-url.ts b/apps/app/src/lib/dev-websocket-url.ts
index 9101e33b9c..5ae6bae372 100644
--- a/apps/app/src/lib/dev-websocket-url.ts
+++ b/apps/app/src/lib/dev-websocket-url.ts
@@ -2,25 +2,35 @@ interface BuildDevWebSocketUrlArgs {
path: string;
}
-function resolveBrowserHostDevWebSocketBaseUrl(port: number): string {
+function resolveBrowserHostDevWebSocketBaseUrl(
+ serverPort: number,
+ appPort: number,
+): string {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
// HTTPS dev origins are typically reverse proxies or bb connect shares.
// Their public origin does not expose the backend's local TCP port, so keep
// the socket on the app origin and let Vite proxy /ws to the server.
- if (window.location.protocol === "https:") {
+ if (
+ window.location.protocol === "https:" ||
+ window.location.port !== String(appPort)
+ ) {
return `${protocol}//${window.location.host}/ws`;
}
// Direct sockets remain preferable for ordinary localhost/LAN source dev:
// they survive backend restarts more reliably than Vite's WS proxy.
- return `${protocol}//${window.location.hostname}:${port}/ws`;
+ return `${protocol}//${window.location.hostname}:${serverPort}/ws`;
}
function resolveDevWebSocketBaseUrl(): string | undefined {
- if (typeof __BB_DEV_WS_BROWSER_HOST_PORT__ === "number") {
+ if (
+ typeof __BB_DEV_WS_BROWSER_HOST_PORT__ === "number" &&
+ typeof __BB_DEV_APP_BROWSER_HOST_PORT__ === "number"
+ ) {
return resolveBrowserHostDevWebSocketBaseUrl(
__BB_DEV_WS_BROWSER_HOST_PORT__,
+ __BB_DEV_APP_BROWSER_HOST_PORT__,
);
}
diff --git a/apps/app/src/vite-env.d.ts b/apps/app/src/vite-env.d.ts
index a0ffd0ac7e..f7153037bc 100644
--- a/apps/app/src/vite-env.d.ts
+++ b/apps/app/src/vite-env.d.ts
@@ -2,3 +2,4 @@
/** Injected by vite.dev.config.ts to bypass Vite's WebSocket proxy. */
declare const __BB_DEV_WS_BROWSER_HOST_PORT__: number | undefined;
+declare const __BB_DEV_APP_BROWSER_HOST_PORT__: number | undefined;
diff --git a/apps/app/vite.dev.config.ts b/apps/app/vite.dev.config.ts
index 1d89e9766e..c616dc5f9a 100644
--- a/apps/app/vite.dev.config.ts
+++ b/apps/app/vite.dev.config.ts
@@ -13,6 +13,7 @@ export default defineConfig({
// Connect directly to the server in dev because Vite's WS proxy does not
// handle upstream server restarts reliably.
__BB_DEV_WS_BROWSER_HOST_PORT__: devWebSocketBrowserHostPortDefine,
+ __BB_DEV_APP_BROWSER_HOST_PORT__: JSON.stringify(viteDevConfig.appPort),
},
server: {
// Allow Tailscale MagicDNS names when Vite is behind Tailscale Serve.
diff --git a/apps/connect/src/cloud-dev.test.ts b/apps/connect/src/cloud-dev.test.ts
new file mode 100644
index 0000000000..f478cee086
--- /dev/null
+++ b/apps/connect/src/cloud-dev.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from "vitest";
+import {
+ CLOUD_DEV_HOST_HEADER,
+ publicConnectOrigin,
+ resolveConnectRequestHost,
+ resolveConnectRequestUrl,
+ resolveConnectRuntime,
+} from "./cloud-dev.js";
+
+describe("local Cloud request routing", () => {
+ it("accepts the launcher host and selects HTTP cookies only in local Cloud", () => {
+ const runtime = resolveConnectRuntime({
+ ACCOUNT_APP_URL: "http://bb.localhost:8787",
+ BASE_DOMAIN: "bb.localhost",
+ CLOUD_DEV: "true",
+ });
+ const headers = new Headers({
+ host: "localhost",
+ [CLOUD_DEV_HOST_HEADER]: "sawyer--3000",
+ });
+ expect(resolveConnectRequestHost(headers, runtime)).toBe(
+ "sawyer--3000.bb.localhost",
+ );
+ expect(runtime.sessionCookieName).toBe("better-auth.session_token");
+ expect(runtime.desktopSessionCookieName).toBe("bb-connect.desktop_session");
+ expect(publicConnectOrigin("sawyer--3000", runtime)).toBe(
+ "http://sawyer--3000.bb.localhost:8787",
+ );
+ expect(
+ resolveConnectRequestUrl(
+ "http://127.0.0.1:50743/threads/thr_1?view=full",
+ headers,
+ runtime,
+ ).toString(),
+ ).toBe("http://sawyer--3000.bb.localhost:8787/threads/thr_1?view=full");
+ });
+
+ it("ignores the launcher header in production", () => {
+ const runtime = resolveConnectRuntime({ BASE_DOMAIN: "getbb.app" });
+ const headers = new Headers({
+ host: "sawyer.getbb.app",
+ [CLOUD_DEV_HOST_HEADER]: "attacker",
+ });
+ expect(resolveConnectRequestHost(headers, runtime)).toBe(
+ "sawyer.getbb.app",
+ );
+ expect(runtime.sessionCookieName).toBe(
+ "__Secure-better-auth.session_token",
+ );
+ });
+
+ it("rejects deployed credential auth", () => {
+ expect(() =>
+ resolveConnectRuntime({
+ ACCOUNT_APP_URL: "https://getbb.app",
+ BASE_DOMAIN: "getbb.app",
+ CLOUD_DEV: "true",
+ }),
+ ).toThrow("only allowed for local Cloud development");
+ });
+});
diff --git a/apps/connect/src/cloud-dev.ts b/apps/connect/src/cloud-dev.ts
new file mode 100644
index 0000000000..324407cdc4
--- /dev/null
+++ b/apps/connect/src/cloud-dev.ts
@@ -0,0 +1,112 @@
+export const CLOUD_DEV_HOST_HEADER = "x-bb-cloud-dev-host";
+export const SECURE_SESSION_COOKIE = "__Secure-better-auth.session_token";
+export const LOCAL_SESSION_COOKIE = "better-auth.session_token";
+export const SECURE_DESKTOP_SESSION_COOKIE =
+ "__Secure-bb-connect.desktop_session";
+export const LOCAL_DESKTOP_SESSION_COOKIE = "bb-connect.desktop_session";
+
+export interface ConnectRuntime {
+ accountAppUrl: string;
+ baseDomain: string;
+ localCloud: boolean;
+ sessionCookieName: string;
+ desktopSessionCookieName: string;
+}
+
+function resolveCloudDevLabel(
+ headers: Headers,
+ runtime: ConnectRuntime,
+): string | null {
+ if (!runtime.localCloud) return null;
+ const label = headers.get(CLOUD_DEV_HOST_HEADER)?.trim().toLowerCase();
+ return label && !label.includes(".") && /^[a-z0-9-]+$/u.test(label)
+ ? label
+ : null;
+}
+
+/** Resolve the small, fail-closed set of overrides used by local Cloud. */
+export function resolveConnectRuntime(env: {
+ ACCOUNT_APP_URL?: string;
+ BASE_DOMAIN: string;
+ CLOUD_DEV?: string;
+}): ConnectRuntime {
+ const accountAppUrl = new URL(
+ env.ACCOUNT_APP_URL?.trim() || `https://${env.BASE_DOMAIN}`,
+ );
+ if (
+ (accountAppUrl.protocol !== "http:" &&
+ accountAppUrl.protocol !== "https:") ||
+ accountAppUrl.username !== "" ||
+ accountAppUrl.password !== "" ||
+ accountAppUrl.pathname !== "/" ||
+ accountAppUrl.search !== "" ||
+ accountAppUrl.hash !== ""
+ ) {
+ throw new Error("ACCOUNT_APP_URL must be an HTTP(S) origin");
+ }
+
+ const cloudDevValue = env.CLOUD_DEV?.trim();
+ if (cloudDevValue && cloudDevValue !== "true") {
+ throw new Error("CLOUD_DEV must be true when set");
+ }
+ const localCloud = cloudDevValue === "true";
+ if (localCloud) {
+ const isLocalAccount =
+ accountAppUrl.protocol === "http:" &&
+ accountAppUrl.hostname === env.BASE_DOMAIN &&
+ env.BASE_DOMAIN.endsWith(".localhost");
+ if (!isLocalAccount) {
+ throw new Error("CLOUD_DEV is only allowed for local Cloud development");
+ }
+ }
+
+ return {
+ accountAppUrl: accountAppUrl.origin,
+ baseDomain: env.BASE_DOMAIN,
+ localCloud,
+ sessionCookieName: localCloud
+ ? LOCAL_SESSION_COOKIE
+ : SECURE_SESSION_COOKIE,
+ desktopSessionCookieName: localCloud
+ ? LOCAL_DESKTOP_SESSION_COOKIE
+ : SECURE_DESKTOP_SESSION_COOKIE,
+ };
+}
+
+/** Wrangler replaces wildcard hosts locally; the launcher preserves the label. */
+export function resolveConnectRequestHost(
+ headers: Headers,
+ runtime: ConnectRuntime,
+): string {
+ const ordinaryHost = headers.get("host") ?? "";
+ const label = resolveCloudDevLabel(headers, runtime);
+ return label === null ? ordinaryHost : `${label}.${runtime.baseDomain}`;
+}
+
+export function publicConnectOrigin(
+ label: string,
+ runtime: Pick,
+): string {
+ const url = new URL(runtime.accountAppUrl);
+ url.hostname = `${label}.${runtime.baseDomain}`;
+ return url.origin;
+}
+
+export function resolveConnectRequestUrl(
+ requestUrl: string,
+ headers: Headers,
+ runtime: ConnectRuntime,
+): URL {
+ const parsed = new URL(requestUrl);
+ const label = resolveCloudDevLabel(headers, runtime);
+ if (label === null) return parsed;
+ const publicUrl = new URL(publicConnectOrigin(label, runtime));
+ publicUrl.pathname = parsed.pathname;
+ publicUrl.search = parsed.search;
+ publicUrl.hash = parsed.hash;
+ return publicUrl;
+}
+
+export function stripCloudDevHeader(headers: Headers): void {
+ headers.delete(CLOUD_DEV_HOST_HEADER);
+}
diff --git a/apps/connect/src/machine-label.ts b/apps/connect/src/machine-label.ts
index 134c797a0a..da3c1a9bbb 100644
--- a/apps/connect/src/machine-label.ts
+++ b/apps/connect/src/machine-label.ts
@@ -8,10 +8,9 @@ import {
type ConnectDb,
} from "@bb/connect-db";
import { verifyMachineCredentialDetails } from "./session.js";
+import { MACHINE_CREDENTIAL_HEADER } from "./protocol-headers.js";
import type { Env } from "./tunnel-do.js";
-const MACHINE_CREDENTIAL_HEADER = "x-bb-connect-machine";
-
function fallbackLabel(machineId: string): string {
const idPrefix = machineId
.replace(/[^a-z0-9]/giu, "")
diff --git a/apps/connect/src/protocol-headers.ts b/apps/connect/src/protocol-headers.ts
new file mode 100644
index 0000000000..83fb7559b8
--- /dev/null
+++ b/apps/connect/src/protocol-headers.ts
@@ -0,0 +1,4 @@
+export const TUNNEL_TARGET_HEADER = "x-bb-tunnel-target";
+export const MACHINE_CREDENTIAL_HEADER = "x-bb-connect-machine";
+export const GATE_AUTH_HEADER = "x-bb-gate-auth";
+export const GATE_MACHINE_ID_HEADER = "x-bb-gate-machine-id";
diff --git a/apps/connect/src/servers.test.ts b/apps/connect/src/servers.test.ts
index 8112046dcb..80e59ff594 100644
--- a/apps/connect/src/servers.test.ts
+++ b/apps/connect/src/servers.test.ts
@@ -20,6 +20,7 @@ import {
createDesktopSessionCookie,
listAccountServers,
resolveAccountUserId,
+ revokeServerCredential,
verifyDesktopSessionCookie,
verifyServerCredential,
} from "./servers.js";
@@ -28,6 +29,7 @@ import {
assignMachineLabelForCredential,
sanitizeMachineLabelBase,
} from "./machine-label.js";
+import { SECURE_SESSION_COOKIE } from "./cloud-dev.js";
import { verifyMachineCredential } from "./session.js";
// Real in-memory SQLite (never mock the DB). Same harness as session.test.ts.
@@ -220,6 +222,50 @@ describe("verifyServerCredential / resolveAccountUserId", () => {
expect(await verifyServerCredential("wrong", db)).toBeNull();
});
+ it("revokes only the row authenticated by the presenting server", async () => {
+ seedUser("acct-a");
+ const plaintext = "bbcred_revoke_this_server";
+ seedServer({
+ id: "s1",
+ userId: "acct-a",
+ name: "default",
+ subdomain: "sawyer",
+ credentialHash: await sha256Hex(plaintext),
+ });
+ seedServer({
+ id: "s2",
+ userId: "acct-a",
+ name: "desktop",
+ subdomain: "sawyer-desktop",
+ credentialHash: await sha256Hex("bbcred_keep_this_server"),
+ });
+
+ // Prime the warm-isolate credential cache before revoking.
+ expect(await verifyServerCredential(plaintext, db)).toBe("acct-a");
+ await expect(revokeServerCredential(plaintext, db)).resolves.toEqual({
+ subdomain: "sawyer",
+ });
+
+ const rows = await db
+ .select({
+ id: server.id,
+ credentialHash: server.credentialHash,
+ revokedAt: server.revokedAt,
+ })
+ .from(server)
+ .all();
+ expect(rows.find((row) => row.id === "s1")).toMatchObject({
+ credentialHash: null,
+ revokedAt: expect.any(Date),
+ });
+ expect(rows.find((row) => row.id === "s2")).toMatchObject({
+ credentialHash: await sha256Hex("bbcred_keep_this_server"),
+ revokedAt: null,
+ });
+ await expect(verifyServerCredential(plaintext, db)).resolves.toBeNull();
+ await expect(revokeServerCredential(plaintext, db)).resolves.toBeNull();
+ });
+
it("authenticates a machine credential and isolates cross-account rows", async () => {
seedUser("acct-a");
seedUser("acct-b");
@@ -254,7 +300,12 @@ describe("verifyServerCredential / resolveAccountUserId", () => {
const req = new Request("https://sawyer.getbb.app/api/connect/servers", {
headers: { "x-bb-connect-machine": machinePlain },
});
- const userId = await resolveAccountUserId(req, "secret", db);
+ const userId = await resolveAccountUserId(
+ req,
+ "secret",
+ db,
+ SECURE_SESSION_COOKIE,
+ );
expect(userId).toBe("acct-a");
const listed = await listAccountServers(db, userId!, now.getTime());
expect(listed.map((s) => s.handle)).toEqual(["sawyer"]);
@@ -262,7 +313,9 @@ describe("verifyServerCredential / resolveAccountUserId", () => {
it("returns null (unauthorized) when no credential or session is presented", async () => {
const req = new Request("https://sawyer.getbb.app/api/connect/servers");
- expect(await resolveAccountUserId(req, "secret", db)).toBeNull();
+ expect(
+ await resolveAccountUserId(req, "secret", db, SECURE_SESSION_COOKIE),
+ ).toBeNull();
});
it("accepts a valid owner session cookie", async () => {
@@ -303,7 +356,24 @@ describe("verifyServerCredential / resolveAccountUserId", () => {
cookie: `__Secure-better-auth.session_token=${cookieValue}`,
},
});
- expect(await resolveAccountUserId(req, secret, db)).toBe("acct-a");
+ expect(
+ await resolveAccountUserId(req, secret, db, SECURE_SESSION_COOKIE),
+ ).toBe("acct-a");
+
+ const localRequest = new Request(
+ "http://sawyer.bb.localhost:8787/api/connect/servers",
+ {
+ headers: { cookie: `better-auth.session_token=${cookieValue}` },
+ },
+ );
+ expect(
+ await resolveAccountUserId(
+ localRequest,
+ secret,
+ db,
+ "better-auth.session_token",
+ ),
+ ).toBe("acct-a");
});
});
diff --git a/apps/connect/src/servers.ts b/apps/connect/src/servers.ts
index 3294bac531..0e0b4c3b32 100644
--- a/apps/connect/src/servers.ts
+++ b/apps/connect/src/servers.ts
@@ -11,10 +11,10 @@ import {
verifyMachineCredential,
verifySessionCookie,
} from "./session.js";
+import { resolveConnectRuntime } from "./cloud-dev.js";
+import { MACHINE_CREDENTIAL_HEADER } from "./protocol-headers.js";
import type { Env } from "./tunnel-do.js";
-const SESSION_COOKIE = "__Secure-better-auth.session_token";
-export const DESKTOP_SESSION_COOKIE = "__Secure-bb-connect.desktop_session";
export const DESKTOP_SESSION_TTL_MS = 60 * 60 * 1000;
function bytesToBase64Url(bytes: Uint8Array): string {
@@ -152,6 +152,29 @@ export async function verifyServerCredential(
return userId;
}
+/** Revoke exactly the active server row authenticated by `credential`. */
+export async function revokeServerCredential(
+ credential: string,
+ db: ConnectDb,
+): Promise<{ subdomain: string } | null> {
+ const presented = credential.trim();
+ if (!presented) return null;
+
+ const revoked = await db
+ .update(server)
+ .set({ credentialHash: null, revokedAt: new Date() })
+ .where(
+ and(
+ eq(server.credentialHash, await sha256Hex(presented)),
+ isNull(server.revokedAt),
+ ),
+ )
+ .returning({ subdomain: server.subdomain })
+ .get();
+ serverCredentialCache.delete(presented);
+ return revoked ?? null;
+}
+
/**
* Resolve the authenticated account for account-scoped connect APIs.
*
@@ -167,8 +190,9 @@ export async function resolveAccountUserId(
request: Request,
secret: string,
db: ConnectDb,
+ sessionCookieName: string,
): Promise {
- const presented = request.headers.get("x-bb-connect-machine") ?? "";
+ const presented = request.headers.get(MACHINE_CREDENTIAL_HEADER) ?? "";
if (presented) {
const machineUserId = await verifyMachineCredential(presented, db);
if (machineUserId) return machineUserId;
@@ -179,7 +203,7 @@ export async function resolveAccountUserId(
if (serverUserId) return serverUserId;
}
- const cookie = parseCookie(request.headers.get("cookie"), SESSION_COOKIE);
+ const cookie = parseCookie(request.headers.get("cookie"), sessionCookieName);
if (!cookie) return null;
return verifySessionCookie(cookie, secret, db);
}
@@ -249,10 +273,12 @@ export async function handleListAccountServers(
}
const db = drizzle(env.DB, { schema });
+ const runtime = resolveConnectRuntime(env);
const userId = await resolveAccountUserId(
request,
env.BETTER_AUTH_SECRET,
db,
+ runtime.sessionCookieName,
);
if (!userId) {
return new Response(JSON.stringify({ error: "unauthorized" }), {
@@ -268,6 +294,40 @@ export async function handleListAccountServers(
});
}
+/** `POST /api/connect/disconnect` — revoke the presenting bb itself. */
+export async function handleDisconnectServer(
+ request: Request,
+ env: Env,
+): Promise {
+ if (request.method !== "POST") {
+ return new Response(JSON.stringify({ error: "method_not_allowed" }), {
+ status: 405,
+ headers: {
+ "content-type": "application/json; charset=utf-8",
+ allow: "POST",
+ },
+ });
+ }
+
+ const db = drizzle(env.DB, { schema });
+ const credential = request.headers.get(MACHINE_CREDENTIAL_HEADER) ?? "";
+ const revoked = await revokeServerCredential(credential, db);
+ if (!revoked) {
+ return new Response(JSON.stringify({ error: "unauthorized" }), {
+ status: 401,
+ headers: { "content-type": "application/json; charset=utf-8" },
+ });
+ }
+
+ try {
+ const stub = env.TUNNEL_DO.get(env.TUNNEL_DO.idFromName(revoked.subdomain));
+ await stub.fetch("https://tunnel/__control/close");
+ } catch {
+ // Best-effort: the credential is already revoked, so reconnect is blocked.
+ }
+ return Response.json({ ok: true });
+}
+
/** Exchange a durable pairing credential for a short-lived browser session. */
export async function handleCreateDesktopSession(
request: Request,
@@ -283,10 +343,12 @@ export async function handleCreateDesktopSession(
});
}
const db = drizzle(env.DB, { schema });
+ const runtime = resolveConnectRuntime(env);
const userId = await resolveAccountUserId(
request,
env.BETTER_AUTH_SECRET,
db,
+ runtime.sessionCookieName,
);
if (!userId) {
return new Response(JSON.stringify({ error: "unauthorized" }), {
@@ -305,7 +367,7 @@ export async function handleCreateDesktopSession(
cookie: {
domain: `.${env.BASE_DOMAIN}`,
expiresAt,
- name: DESKTOP_SESSION_COOKIE,
+ name: runtime.desktopSessionCookieName,
value,
},
}),
diff --git a/apps/connect/src/tunnel-do.ts b/apps/connect/src/tunnel-do.ts
index c65df2953f..7eca8777af 100644
--- a/apps/connect/src/tunnel-do.ts
+++ b/apps/connect/src/tunnel-do.ts
@@ -11,12 +11,15 @@ import {
type HeaderPair,
} from "@bb/tunnel-contract";
import { relayedResponse } from "./response-encoding.js";
+import { TUNNEL_TARGET_HEADER } from "./protocol-headers.js";
export interface Env {
TUNNEL_DO: DurableObjectNamespace;
DB: D1Database;
BASE_DOMAIN: string;
BETTER_AUTH_SECRET: string;
+ ACCOUNT_APP_URL?: string;
+ CLOUD_DEV?: string;
}
const TUNNEL_TAG = "tunnel";
@@ -26,9 +29,6 @@ const RESP_HEAD_TIMEOUT_MS = 30_000;
// run JS), kept under the 90s offline window.
const PRESENCE_INTERVAL_MS = 50_000;
-/** Gate → DO header carrying a share target; never forwarded to the origin. */
-const TUNNEL_TARGET_HEADER = "x-bb-tunnel-target";
-
// Standard WebSocket readyState numbering (workerd's READY_STATE_OPEN; the
// constant itself is Cloudflare-only, so tests in Node use the number).
const WS_READY_STATE_OPEN = 1;
diff --git a/apps/connect/src/worker.test.ts b/apps/connect/src/worker.test.ts
index c69c7aa246..bf26c7c222 100644
--- a/apps/connect/src/worker.test.ts
+++ b/apps/connect/src/worker.test.ts
@@ -5,13 +5,15 @@ import { machine } from "@bb/connect-db";
import { cacheKey } from "./cache";
import { parseClientProtocolVersion } from "./tunnel-do";
import {
- GATE_AUTH_HEADER,
- GATE_MACHINE_ID_HEADER,
- TUNNEL_TARGET_HEADER,
cacheNamespace,
dashboardSignInUrl,
requestForTunnelDo,
} from "./worker";
+import {
+ GATE_AUTH_HEADER,
+ GATE_MACHINE_ID_HEADER,
+ TUNNEL_TARGET_HEADER,
+} from "./protocol-headers";
// ── pure helpers ────────────────────────────────────────────────────────────
@@ -112,8 +114,8 @@ vi.mock("./session.js", () => ({
}));
vi.mock("./servers.js", () => ({
- DESKTOP_SESSION_COOKIE: "__Secure-bb-connect.desktop_session",
handleCreateDesktopSession: vi.fn(),
+ handleDisconnectServer: vi.fn(),
handleListAccountServers: vi.fn(),
verifyDesktopSessionCookie: vi.fn(),
}));
@@ -152,11 +154,12 @@ import {
verifySessionCookie,
} from "./session.js";
import {
- DESKTOP_SESSION_COOKIE,
handleCreateDesktopSession,
+ handleDisconnectServer,
handleListAccountServers,
verifyDesktopSessionCookie,
} from "./servers.js";
+import { SECURE_DESKTOP_SESSION_COOKIE as DESKTOP_SESSION_COOKIE } from "./cloud-dev.js";
import { handleAssignMachineLabel } from "./machine-label.js";
import { serveWithCache } from "./cache.js";
import worker, { offlinePage, relativeTime, wantsHtml } from "./worker.js";
@@ -170,6 +173,7 @@ const mockVerifySession = vi.mocked(verifySessionCookie);
const mockServeWithCache = vi.mocked(serveWithCache);
const mockHandleListAccountServers = vi.mocked(handleListAccountServers);
const mockHandleCreateDesktopSession = vi.mocked(handleCreateDesktopSession);
+const mockHandleDisconnectServer = vi.mocked(handleDisconnectServer);
const mockVerifyDesktopSession = vi.mocked(verifyDesktopSessionCookie);
const mockHandleAssignMachineLabel = vi.mocked(handleAssignMachineLabel);
@@ -326,6 +330,27 @@ describe("POST /api/connect/desktop-session", () => {
});
});
+describe("POST /api/connect/disconnect", () => {
+ it("intercepts self-revocation before tunnel routing", async () => {
+ mockHandleDisconnectServer.mockResolvedValue(Response.json({ ok: true }));
+ const { env, ctx, captured } = makeEnv(() => new Response("origin"));
+ const request = visitorRequest(
+ "sawyer.getbb.app",
+ "/api/connect/disconnect",
+ {
+ method: "POST",
+ headers: { "x-bb-connect-machine": "paired" },
+ },
+ );
+ const response = await worker.fetch(request, env as never, ctx);
+
+ expect(response.status).toBe(200);
+ expect(mockHandleDisconnectServer).toHaveBeenCalledWith(request, env);
+ expect(mockResolveLabel).not.toHaveBeenCalled();
+ expect(captured).toHaveLength(0);
+ });
+});
+
describe("POST /api/connect/machine-label", () => {
it("intercepts machine-authenticated assignment before label routing", async () => {
mockHandleAssignMachineLabel.mockResolvedValue(
@@ -377,7 +402,10 @@ describe("gate tunnel authentication", () => {
"sawyer-air.getbb.app",
"/__tunnel?v=1&serverId=victim-server&machineId=spoofed-machine",
{
- headers: { authorization: `Bearer ${credential}` },
+ headers: {
+ authorization: `Bearer ${credential}`,
+ "x-bb-cloud-dev-host": "smuggled",
+ },
},
),
env as never,
@@ -395,6 +423,7 @@ describe("gate tunnel authentication", () => {
"machine-air",
);
expect(new URL(captured[0].url).searchParams.get("serverId")).toBeNull();
+ expect(captured[0].headers.get("x-bb-cloud-dev-host")).toBeNull();
});
it("dials immediately after a negative resolve and label assignment", async () => {
@@ -552,14 +581,20 @@ describe("machine gate auth", () => {
const { env, ctx, captured } = makeEnv(() => new Response("origin"));
const internal = await worker.fetch(
visitorRequest("sawyer.getbb.app", "/internal/session/open", {
- headers: { "x-bb-connect-machine": "bbcm_owner" },
+ headers: {
+ "x-bb-connect-machine": "bbcm_owner",
+ "x-bb-cloud-dev-host": "smuggled",
+ },
}),
env as never,
ctx,
);
const api = await worker.fetch(
visitorRequest("sawyer.getbb.app", "/api/v1/threads", {
- headers: { "x-bb-connect-machine": "bbcm_owner" },
+ headers: {
+ "x-bb-connect-machine": "bbcm_owner",
+ "x-bb-cloud-dev-host": "smuggled",
+ },
}),
env as never,
ctx,
@@ -572,6 +607,11 @@ describe("machine gate auth", () => {
(request) => request.headers.get("x-bb-connect-machine") === null,
),
).toBe(true);
+ expect(
+ captured.every(
+ (request) => request.headers.get("x-bb-cloud-dev-host") === null,
+ ),
+ ).toBe(true);
expect(
captured.every(
(request) => request.headers.get(GATE_AUTH_HEADER) === "machine",
@@ -630,13 +670,16 @@ describe("machine gate auth", () => {
async (path) => {
const { env, ctx, captured } = makeEnv(() => new Response("artifact"));
const response = await worker.fetch(
- visitorRequest("sawyer.getbb.app", path),
+ visitorRequest("sawyer.getbb.app", path, {
+ headers: { "x-bb-cloud-dev-host": "smuggled" },
+ }),
env as never,
ctx,
);
expect(response.status).toBe(200);
expect(await response.text()).toBe("artifact");
expect(captured).toHaveLength(1);
+ expect(captured[0].headers.get("x-bb-cloud-dev-host")).toBeNull();
expect(mockVerifyMachine).not.toHaveBeenCalled();
},
);
@@ -693,6 +736,30 @@ describe("gate worker share hosts", () => {
expect(mockVerifySession).not.toHaveBeenCalled();
});
+ it("renders local machine links with HTTP and the shared gateway port", async () => {
+ mockResolveLabel.mockResolvedValue(resolvedMachine());
+ const { env, ctx } = makeEnv(() => new Response("origin"));
+ Object.assign(env, {
+ ACCOUNT_APP_URL: "http://bb.localhost:42745",
+ BASE_DOMAIN: "bb.localhost",
+ CLOUD_DEV: "true",
+ });
+ const response = await worker.fetch(
+ new Request("http://127.0.0.1:50743/", {
+ headers: {
+ host: "127.0.0.1:50743",
+ "x-bb-cloud-dev-host": "sawyer-air",
+ },
+ }),
+ env as never,
+ ctx,
+ );
+
+ const html = await response.text();
+ expect(html).toContain("sawyer-air--<port>.bb.localhost:42745");
+ expect(html).toContain('href="http://sawyer.bb.localhost:42745"');
+ });
+
it("applies the same owner-session check to machine share hosts", async () => {
mockResolveLabel.mockResolvedValue(resolvedMachine());
const ownerEnv = makeEnv(() => new Response("machine-origin"));
@@ -777,13 +844,17 @@ describe("gate worker share hosts", () => {
const { env, ctx, captured } = makeEnv(() => new Response("ok"));
await worker.fetch(
visitorRequest("sawyer.getbb.app", "/", {
- headers: { [TUNNEL_TARGET_HEADER]: "9999" },
+ headers: {
+ [TUNNEL_TARGET_HEADER]: "9999",
+ "x-bb-cloud-dev-host": "smuggled",
+ },
}),
env as never,
ctx,
);
expect(captured).toHaveLength(1);
expect(captured[0].headers.get(TUNNEL_TARGET_HEADER)).toBeNull();
+ expect(captured[0].headers.get("x-bb-cloud-dev-host")).toBeNull();
expect(mockServeWithCache).toHaveBeenCalledWith(
expect.any(Request),
"sawyer",
@@ -848,6 +919,31 @@ describe("gate worker share hosts", () => {
expect(captured).toHaveLength(0);
});
+ it("preserves the public local URL in the sign-in returnTo", async () => {
+ mockParseCookie.mockReturnValue(null);
+ const { env, ctx } = makeEnv(() => new Response("ok"));
+ Object.assign(env, {
+ ACCOUNT_APP_URL: "http://bb.localhost:42745",
+ BASE_DOMAIN: "bb.localhost",
+ CLOUD_DEV: "true",
+ });
+ const response = await worker.fetch(
+ new Request("http://127.0.0.1:50743/threads/thr_1?view=full", {
+ headers: {
+ host: "127.0.0.1:50743",
+ "x-bb-cloud-dev-host": "sawyer",
+ },
+ }),
+ env as never,
+ ctx,
+ );
+
+ expect(response.status).toBe(401);
+ expect(await response.text()).toContain(
+ "returnTo=http%3A%2F%2Fsawyer.bb.localhost%3A42745%2Fthreads%2Fthr_1%3Fview%3Dfull",
+ );
+ });
+
it("returns 403 when share host session is a different user", async () => {
mockVerifySession.mockResolvedValue(OTHER);
const { env, ctx, captured } = makeEnv(() => new Response("ok"));
diff --git a/apps/connect/src/worker.ts b/apps/connect/src/worker.ts
index 5776891e5a..9935622663 100644
--- a/apps/connect/src/worker.ts
+++ b/apps/connect/src/worker.ts
@@ -9,25 +9,30 @@ import {
verifySessionCookie,
} from "./session.js";
import {
- DESKTOP_SESSION_COOKIE,
handleCreateDesktopSession,
+ handleDisconnectServer,
handleListAccountServers,
verifyDesktopSessionCookie,
} from "./servers.js";
import { serveWithCache } from "./cache.js";
import { BB_ICON_DATA_URI } from "./bb-icon.js";
import { handleAssignMachineLabel } from "./machine-label.js";
+import {
+ publicConnectOrigin,
+ resolveConnectRequestHost,
+ resolveConnectRequestUrl,
+ resolveConnectRuntime,
+ stripCloudDevHeader,
+} from "./cloud-dev.js";
+import {
+ GATE_AUTH_HEADER,
+ GATE_MACHINE_ID_HEADER,
+ MACHINE_CREDENTIAL_HEADER,
+ TUNNEL_TARGET_HEADER,
+} from "./protocol-headers.js";
export { TunnelDO };
-const SESSION_COOKIE = "__Secure-better-auth.session_token";
-
-/** Internal header: gate → TunnelDO, share target (port string). Never trust visitors. */
-export const TUNNEL_TARGET_HEADER = "x-bb-tunnel-target";
-export const MACHINE_CREDENTIAL_HEADER = "x-bb-connect-machine";
-export const GATE_AUTH_HEADER = "x-bb-gate-auth";
-export const GATE_MACHINE_ID_HEADER = "x-bb-gate-machine-id";
-
async function sha256Hex(value: string): Promise {
const digest = await crypto.subtle.digest(
"SHA-256",
@@ -198,13 +203,15 @@ export function offlinePage(
export function machinePage(
label: string,
accountHandle: string,
- baseDomain: string,
+ runtime: ReturnType,
): Response {
- const appHost = `${accountHandle}.${baseDomain}`;
+ const appOrigin = publicConnectOrigin(accountHandle, runtime);
+ const appHost = new URL(appOrigin).host;
+ const baseHost = new URL(runtime.accountAppUrl).host;
return gatePage(
`${escapeHtml(label)} is a machine
- This machine is on ${escapeHtml(accountHandle)}'s account. Its shares appear at ${escapeHtml(label)}--<port>.${escapeHtml(baseDomain)}.
- Open the bb app at ${escapeHtml(appHost)}`,
+ This machine is on ${escapeHtml(accountHandle)}'s account. Its shares appear at ${escapeHtml(label)}--<port>.${escapeHtml(baseHost)}.
+ Open the bb app at ${escapeHtml(appHost)}`,
200,
);
}
@@ -223,6 +230,7 @@ export function requestForTunnelDo(
headers.delete(MACHINE_CREDENTIAL_HEADER);
headers.delete(GATE_AUTH_HEADER);
headers.delete(GATE_MACHINE_ID_HEADER);
+ stripCloudDevHeader(headers);
if (target !== null) {
headers.set(TUNNEL_TARGET_HEADER, target);
}
@@ -265,13 +273,17 @@ export default {
env: Env,
ctx: ExecutionContext,
): Promise {
- const url = new URL(request.url);
+ const runtime = resolveConnectRuntime(env);
+ const url = resolveConnectRequestUrl(request.url, request.headers, runtime);
// Account-scoped APIs are handled on the gate before host/label routing so
// they never proxy through a tunnel to a local bb origin. Auth is
// machine/server credential or owner session — see servers.ts.
if (url.pathname === "/api/connect/servers") {
return handleListAccountServers(request, env);
}
+ if (url.pathname === "/api/connect/disconnect") {
+ return handleDisconnectServer(request, env);
+ }
if (url.pathname === "/api/connect/desktop-session") {
return handleCreateDesktopSession(request, env);
}
@@ -279,7 +291,7 @@ export default {
return handleAssignMachineLabel(request, env);
}
- const host = request.headers.get("host") ?? url.host;
+ const host = resolveConnectRequestHost(request.headers, runtime);
const parsed = parseVisitorHost(host, env.BASE_DOMAIN);
if (!parsed) return text("bb connect: unknown host\n", 404);
// The base label is now ANY server's subdomain (the account handle names the
@@ -291,7 +303,7 @@ export default {
// rather than answering with a confusing "no server" page.
if (RESERVED_HANDLES.has(label)) {
return Response.redirect(
- `https://${env.BASE_DOMAIN}${url.pathname}${url.search}`,
+ `${runtime.accountAppUrl}${url.pathname}${url.search}`,
301,
);
}
@@ -342,7 +354,11 @@ export default {
} else {
forward.searchParams.set("machineId", owner.id);
}
- return stub.fetch(new Request(forward, request));
+ const headers = new Headers(request.headers);
+ stripCloudDevHeader(headers);
+ return stub.fetch(
+ new Request(new Request(forward, request), { headers }),
+ );
}
// Reserve the /__ namespace: never proxy internal paths from outside.
@@ -352,7 +368,7 @@ export default {
// Machine labels route only explicit `
+ {emailPasswordEnabled ? (
+ <>
+
+
+ {mode === "sign-in"
+ ? "New to this local Cloud?"
+ : "Already registered?"}{" "}
+
+
+
+
+ or
+
+
+ >
+ ) : null}