Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions apps/app/src/lib/dev-websocket-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ function installWindowLocation(url: string): void {
location: {
host: location.host,
hostname: location.hostname,
port: location.port,
protocol: location.protocol,
},
});
Expand All @@ -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(
Expand All @@ -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",
);
Expand All @@ -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", () => {
Expand Down
18 changes: 14 additions & 4 deletions apps/app/src/lib/dev-websocket-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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__,
);
}

Expand Down
1 change: 1 addition & 0 deletions apps/app/src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
1 change: 1 addition & 0 deletions apps/app/vite.dev.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
61 changes: 61 additions & 0 deletions apps/connect/src/cloud-dev.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
112 changes: 112 additions & 0 deletions apps/connect/src/cloud-dev.ts
Original file line number Diff line number Diff line change
@@ -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<ConnectRuntime, "accountAppUrl" | "baseDomain">,
): 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);
}
3 changes: 1 addition & 2 deletions apps/connect/src/machine-label.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "")
Expand Down
4 changes: 4 additions & 0 deletions apps/connect/src/protocol-headers.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading
Loading