diff --git a/apps/connect/package.json b/apps/connect/package.json index 3fc507ac6d..6be287efcd 100644 --- a/apps/connect/package.json +++ b/apps/connect/package.json @@ -16,10 +16,12 @@ }, "devDependencies": { "@bb/tsconfig": "workspace:*", + "@better-auth/drizzle-adapter": "^1.6.23", "@cloudflare/workers-types": "^4.20260610.0", "@types/better-sqlite3": "^7.6.12", "@types/node": "^22.0.0", "better-sqlite3": "12.10.0", + "better-auth": "^1.6.23", "esbuild": "^0.28.1", "miniflare": "^4.20260701.0", "typescript": "npm:@typescript/typescript6@^6.0.2", diff --git a/apps/connect/src/account-session.ts b/apps/connect/src/account-session.ts new file mode 100644 index 0000000000..e46e5ac569 --- /dev/null +++ b/apps/connect/src/account-session.ts @@ -0,0 +1,50 @@ +type AuthFetch = (request: Request) => Promise; + +interface HeadersWithGetSetCookie extends Headers { + getSetCookie(): string[]; +} + +function hasGetSetCookie(headers: Headers): headers is HeadersWithGetSetCookie { + return ( + "getSetCookie" in headers && typeof headers.getSetCookie === "function" + ); +} + +function getSetCookies(headers: Headers): string[] { + // Node implements the standard API; the Workerd types/runtime expose the + // older getAll API for preserving separate Set-Cookie field values. + return hasGetSetCookie(headers) + ? headers.getSetCookie() + : headers.getAll("set-cookie"); +} + +/** Let the account worker's Better Auth route own refresh and cookie policy. */ +export async function refreshAccountSessionCookies( + cookieHeader: string, + accountAppUrl: string, + authFetch: AuthFetch, +): Promise { + try { + const url = new URL("/api/auth/get-session", accountAppUrl); + url.searchParams.set("disableCookieCache", "true"); + const response = await authFetch( + new Request(url, { headers: { cookie: cookieHeader } }), + ); + if (!response.ok) return null; + + const body: unknown = await response.json(); + if ( + typeof body !== "object" || + body === null || + !("session" in body) || + !("user" in body) + ) { + return null; + } + const setCookies = getSetCookies(response.headers); + return setCookies.length === 0 ? null : setCookies; + } catch (error) { + console.error("bb connect: session refresh failed", error); + return null; + } +} diff --git a/apps/connect/src/cache.ts b/apps/connect/src/cache.ts index e1d5c96b2d..762d338a94 100644 --- a/apps/connect/src/cache.ts +++ b/apps/connect/src/cache.ts @@ -30,6 +30,12 @@ function isCacheable(resp: Response): boolean { return maxAge ? Number(maxAge[1]) >= MIN_CACHEABLE_MAX_AGE : false; } +export interface CacheResult { + /** True for both edge-cache hits and cacheable origin misses. */ + cacheable: boolean; + response: Response; +} + /** * Serve `request` from the edge cache when possible, else run `fetchOrigin` * (the tunnel) and populate the cache when the response is cacheable. @@ -42,8 +48,10 @@ export async function serveWithCache( namespace: string, ctx: ExecutionContext, fetchOrigin: () => Promise, -): Promise { - if (request.method !== "GET") return fetchOrigin(); +): Promise { + if (request.method !== "GET") { + return { cacheable: false, response: await fetchOrigin() }; + } const url = new URL(request.url); const key = cacheKey(namespace, url); @@ -57,7 +65,7 @@ export async function serveWithCache( // labelled text/html. This is NOT symmetric with the miss path below. const r = rebuiltResponse(hit.body, hit); r.headers.set("x-bb-cache", "hit"); - return r; + return { cacheable: true, response: r }; } const resp = await fetchOrigin(); @@ -70,7 +78,7 @@ export async function serveWithCache( // this one pre-encoded would advertise a gzip body that isn't gzipped. const r = new Response(resp.body, resp); r.headers.set("x-bb-cache", "miss"); - return r; + return { cacheable: true, response: r }; } - return resp; + return { cacheable: false, response: resp }; } diff --git a/apps/connect/src/session.test.ts b/apps/connect/src/session.test.ts index 4596464019..585fb66ac3 100644 --- a/apps/connect/src/session.test.ts +++ b/apps/connect/src/session.test.ts @@ -2,15 +2,20 @@ import { readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import Database from "better-sqlite3"; +import { drizzleAdapter } from "@better-auth/drizzle-adapter"; +import { betterAuth } from "better-auth/minimal"; import { eq } from "drizzle-orm"; import { drizzle } from "drizzle-orm/better-sqlite3"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { + CONNECT_SESSION_EXPIRES_IN_SECONDS, + CONNECT_SESSION_UPDATE_AGE_SECONDS, labelClaim, machine, profile, schema, server, + session, user, } from "@bb/connect-db"; @@ -19,7 +24,9 @@ import { markMachineSeen, resolveLabel, verifyMachineCredentialDetails, + verifySessionCookieDetails, } from "./session.js"; +import { refreshAccountSessionCookies } from "./account-session.js"; import { assignMachineLabel } from "./machine-label.js"; // Real in-memory SQLite (never mock the DB). resolveLabel accepts any Drizzle @@ -113,6 +120,26 @@ function seedMachine(over: { } } +async function signedSessionCookie( + token: string, + secret: string, +): Promise { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(token), + ); + const encoded = btoa(String.fromCharCode(...new Uint8Array(signature))); + return encodeURIComponent(`${token}.${encoded}`); +} + describe("resolveLabel — label → server row (multi-server)", () => { it("resolves the primary bb by its backfilled handle-label subdomain", async () => { seedUser("acct-a"); @@ -347,6 +374,179 @@ describe("resolveLabel — label → server row (multi-server)", () => { }); }); +describe("account session refresh", () => { + const secret = "test-better-auth-secret-32-chars"; + const expiresInMs = CONNECT_SESSION_EXPIRES_IN_SECONDS * 1000; + const updateAgeMs = CONNECT_SESSION_UPDATE_AGE_SECONDS * 1000; + + function seedSession( + token: string, + refreshAt: number, + expiresAt: number, + ): void { + seedUser(`user-${token}`); + db.insert(session) + .values({ + id: `id-${token}`, + token, + expiresAt: new Date(expiresAt), + userId: `user-${token}`, + createdAt: new Date(refreshAt - updateAgeMs), + updatedAt: new Date(refreshAt - updateAgeMs), + }) + .run(); + } + + function createAuthFetch(baseURL: string, baseDomain: string) { + const auth = betterAuth({ + secret, + baseURL, + database: drizzleAdapter(db, { + provider: "sqlite", + schema: { session, user }, + }), + session: { + expiresIn: CONNECT_SESSION_EXPIRES_IN_SECONDS, + updateAge: CONNECT_SESSION_UPDATE_AGE_SECONDS, + }, + advanced: { + crossSubDomainCookies: { + enabled: true, + domain: `.${baseDomain}`, + }, + }, + }); + return (request: Request) => auth.handler(request); + } + + it("reports Better Auth's update-age boundary from the session expiry", async () => { + const checkedAt = Date.now(); + const freshToken = `fresh-${crypto.randomUUID()}`; + const dueToken = `due-${crypto.randomUUID()}`; + seedSession(freshToken, checkedAt, checkedAt + expiresInMs); + seedSession( + dueToken, + checkedAt, + checkedAt + expiresInMs - updateAgeMs - 1000, + ); + + await expect( + verifySessionCookieDetails( + await signedSessionCookie(freshToken, secret), + secret, + db, + ), + ).resolves.toEqual({ + userId: `user-${freshToken}`, + needsRefresh: false, + }); + await expect( + verifySessionCookieDetails( + await signedSessionCookie(dueToken, secret), + secret, + db, + ), + ).resolves.toEqual({ + userId: `user-${dueToken}`, + needsRefresh: true, + }); + }); + + it("lets Better Auth renew the database session and production cookie", async () => { + const refreshAt = Date.now(); + const token = `session-${crypto.randomUUID()}`; + const oldExpiresAt = refreshAt + expiresInMs - updateAgeMs; + seedSession(token, refreshAt, oldExpiresAt); + const cookie = await signedSessionCookie(token, secret); + const beforeRefresh = Date.now(); + + const setCookies = await refreshAccountSessionCookies( + `__Secure-better-auth.session_token=${cookie}`, + "https://getbb.app", + createAuthFetch("https://getbb.app", "getbb.app"), + ); + const afterRefresh = Date.now(); + const refreshed = db + .select() + .from(session) + .where(eq(session.token, token)) + .get(); + expect(refreshed?.expiresAt.getTime()).toBeGreaterThanOrEqual( + beforeRefresh + expiresInMs, + ); + expect(refreshed?.expiresAt.getTime()).toBeLessThanOrEqual( + afterRefresh + expiresInMs, + ); + expect(setCookies).toHaveLength(1); + expect(setCookies?.[0]).toContain("__Secure-better-auth.session_token="); + expect(setCookies?.[0]).toContain("Max-Age=604800"); + expect(setCookies?.[0]).toContain("Domain=.getbb.app"); + expect(setCookies?.[0]).toContain("Secure"); + }); + + it("leaves a fresh session unchanged before the update-age boundary", async () => { + const refreshAt = Date.now(); + const token = `session-${crypto.randomUUID()}`; + const expiresAt = refreshAt + expiresInMs; + seedSession(token, refreshAt, expiresAt); + const cookie = await signedSessionCookie(token, secret); + + await expect( + refreshAccountSessionCookies( + `__Secure-better-auth.session_token=${cookie}`, + "https://getbb.app", + createAuthFetch("https://getbb.app", "getbb.app"), + ), + ).resolves.toBeNull(); + expect( + db + .select({ expiresAt: session.expiresAt }) + .from(session) + .where(eq(session.token, token)) + .get() + ?.expiresAt.getTime(), + ).toBe(expiresAt); + }); + + it("passes through Better Auth's non-secure local Cloud cookie", async () => { + const refreshAt = Date.now(); + const token = `session-${crypto.randomUUID()}`; + seedSession(token, refreshAt, refreshAt + expiresInMs - updateAgeMs); + const cookie = await signedSessionCookie(token, secret); + + const setCookies = await refreshAccountSessionCookies( + `better-auth.session_token=${cookie}`, + "http://bb.localhost:42745", + createAuthFetch("http://bb.localhost:42745", "bb.localhost"), + ); + expect(setCookies).toHaveLength(1); + expect(setCookies?.[0]).toContain("better-auth.session_token="); + expect(setCookies?.[0]).toContain("Domain=.bb.localhost"); + expect(setCookies?.[0]).not.toContain("Secure"); + }); + + it("preserves multiple Better Auth cookies as separate values", async () => { + const headers = new Headers(); + headers.append("set-cookie", "session=renewed; Path=/; HttpOnly"); + headers.append("set-cookie", "session-data=cached; Path=/; HttpOnly"); + + await expect( + refreshAccountSessionCookies( + "session=old", + "https://getbb.app", + async () => + Response.json( + { session: { id: "session" }, user: { id: "user" } }, + { headers }, + ), + ), + ).resolves.toEqual([ + "session=renewed; Path=/; HttpOnly", + "session-data=cached; Path=/; HttpOnly", + ]); + }); +}); + describe("machine credential presence", () => { it("verifies the owning machine and throttles lastSeenAt writes", async () => { seedUser("acct-machine"); diff --git a/apps/connect/src/session.ts b/apps/connect/src/session.ts index 3011884f5d..c6d36dfd2a 100644 --- a/apps/connect/src/session.ts +++ b/apps/connect/src/session.ts @@ -1,5 +1,7 @@ import { and, eq, gt, isNull } from "drizzle-orm"; import { + CONNECT_SESSION_EXPIRES_IN_SECONDS, + CONNECT_SESSION_UPDATE_AGE_SECONDS, type ConnectDb, labelClaim, machine, @@ -18,13 +20,26 @@ import { // reach a disconnected server). const LABEL_TTL_MS = 15_000; const SESSION_TTL_MS = 20_000; +const SESSION_REFRESH_BEFORE_EXPIRY_MS = + (CONNECT_SESSION_EXPIRES_IN_SECONDS - CONNECT_SESSION_UPDATE_AGE_SECONDS) * + 1000; interface CacheEntry { value: T; expires: number; } const labelCache = new Map>(); -const sessionCache = new Map>(); + +interface CachedSession { + userId: string; + expiresAt: number; +} + +const sessionCache = new Map>(); + +export function invalidateSessionCookie(cookieValue: string): void { + sessionCache.delete(safeDecode(cookieValue)); +} function cacheGet( map: Map>, @@ -165,17 +180,34 @@ export async function resolveLabel( return resolvedMachine; } +export interface VerifiedSessionCookie { + userId: string; + /** A hint only; Better Auth rechecks the session before refreshing it. */ + needsRefresh: boolean; +} + +function verifiedSession( + session: CachedSession, + now: number, +): VerifiedSessionCookie { + return { + userId: session.userId, + needsRefresh: session.expiresAt <= now + SESSION_REFRESH_BEFORE_EXPIRY_MS, + }; +} + /** * Verify a better-auth session cookie directly against D1 (no cross-worker * call), cached per-isolate. Mirrors better-auth's - * `${token}.${base64(hmac-sha256(token,secret))}` scheme. Returns the userId - * when the signature is valid and the session row exists and is unexpired. + * `${token}.${base64(hmac-sha256(token,secret))}` scheme. The refresh hint + * matches Better Auth's update-age boundary but is not authoritative; Better + * Auth rechecks the session before writing or reissuing its cookie. */ -export async function verifySessionCookie( +export async function verifySessionCookieDetails( cookieValue: string, secret: string, db: ConnectDb, -): Promise { +): Promise { // better-auth URL-encodes the cookie value, so the base64 signature arrives // with %2F/%2B/%3D. Decode before splitting/comparing (the hex token is // unaffected by decoding). @@ -192,7 +224,8 @@ export async function verifySessionCookie( // one would negative-poison the real token). The full-cookie key makes the // cache reflect exactly what passed verification. const cached = cacheGet(sessionCache, decoded, now); - if (cached !== undefined) return cached; + if (cached !== undefined) + return cached === null ? null : verifiedSession(cached, now); const key = await crypto.subtle.importKey( "raw", @@ -213,13 +246,32 @@ export async function verifySessionCookie( } const row = await db - .select({ userId: session.userId }) + .select({ expiresAt: session.expiresAt, userId: session.userId }) .from(session) - .where(and(eq(session.token, token), gt(session.expiresAt, new Date()))) + .where(and(eq(session.token, token), gt(session.expiresAt, new Date(now)))) .get(); - const userId = row?.userId ?? null; - sessionCache.set(decoded, { value: userId, expires: now + SESSION_TTL_MS }); - return userId; + const cachedSession = row + ? { userId: row.userId, expiresAt: row.expiresAt.getTime() } + : null; + sessionCache.set(decoded, { + value: cachedSession, + expires: + row === undefined + ? now + SESSION_TTL_MS + : Math.min(now + SESSION_TTL_MS, row.expiresAt.getTime()), + }); + return cachedSession === null ? null : verifiedSession(cachedSession, now); +} + +/** Returns the owning user for callers that do not participate in refresh. */ +export async function verifySessionCookie( + cookieValue: string, + secret: string, + db: ConnectDb, +): Promise { + return ( + (await verifySessionCookieDetails(cookieValue, secret, db))?.userId ?? null + ); } const machineLastSeenWrites = new Map(); diff --git a/apps/connect/src/worker.test.ts b/apps/connect/src/worker.test.ts index ca536b6ce0..4371671467 100644 --- a/apps/connect/src/worker.test.ts +++ b/apps/connect/src/worker.test.ts @@ -106,11 +106,16 @@ describe("parseClientProtocolVersion", () => { // ── gate worker (mocked session + DO stub) ────────────────────────────────── vi.mock("./session.js", () => ({ + invalidateSessionCookie: vi.fn(), markMachineSeen: vi.fn(), parseCookie: vi.fn(), resolveLabel: vi.fn(), verifyMachineCredentialDetails: vi.fn(), - verifySessionCookie: vi.fn(), + verifySessionCookieDetails: vi.fn(), +})); + +vi.mock("./account-session.js", () => ({ + refreshAccountSessionCookies: vi.fn(), })); vi.mock("./servers.js", () => ({ @@ -135,7 +140,7 @@ vi.mock("./cache.js", async () => { _namespace: string, _ctx: ExecutionContext, fetchOrigin: () => Promise, - ) => fetchOrigin(), + ) => ({ cacheable: false, response: await fetchOrigin() }), ), }; }); @@ -147,12 +152,14 @@ vi.mock("drizzle-orm/d1", () => ({ import { drizzle } from "drizzle-orm/d1"; import { + invalidateSessionCookie, markMachineSeen, parseCookie, resolveLabel, verifyMachineCredentialDetails, - verifySessionCookie, + verifySessionCookieDetails, } from "./session.js"; +import { refreshAccountSessionCookies } from "./account-session.js"; import { handleCreateDesktopSession, handleDisconnectServer, @@ -166,10 +173,12 @@ import worker, { offlinePage, relativeTime, wantsHtml } from "./worker.js"; import { TUNNEL_OFFLINE_HEADER, TunnelDO } from "./tunnel-do.js"; const mockParseCookie = vi.mocked(parseCookie); +const mockInvalidateSession = vi.mocked(invalidateSessionCookie); +const mockRefreshAccountSession = vi.mocked(refreshAccountSessionCookies); const mockResolveLabel = vi.mocked(resolveLabel); const mockMarkMachineSeen = vi.mocked(markMachineSeen); const mockVerifyMachine = vi.mocked(verifyMachineCredentialDetails); -const mockVerifySession = vi.mocked(verifySessionCookie); +const mockVerifySessionDetails = vi.mocked(verifySessionCookieDetails); const mockServeWithCache = vi.mocked(serveWithCache); const mockHandleListAccountServers = vi.mocked(handleListAccountServers); const mockHandleCreateDesktopSession = vi.mocked(handleCreateDesktopSession); @@ -177,6 +186,10 @@ const mockHandleDisconnectServer = vi.mocked(handleDisconnectServer); const mockVerifyDesktopSession = vi.mocked(verifyDesktopSessionCookie); const mockHandleAssignMachineLabel = vi.mocked(handleAssignMachineLabel); +function sessionDetails(userId = OWNER, needsRefresh = false) { + return { userId, needsRefresh }; +} + /** A resolved server row; overrides let a test tweak one field. */ function resolvedServer( over: Partial<{ @@ -713,7 +726,7 @@ describe("bb mobile app-link association files", () => { expect(response.headers.get("content-type")).toBe("application/json"); expect(captured).toHaveLength(0); expect(mockResolveLabel).not.toHaveBeenCalled(); - expect(mockVerifySession).not.toHaveBeenCalled(); + expect(mockVerifySessionDetails).not.toHaveBeenCalled(); }, ); @@ -802,7 +815,8 @@ describe("gate worker share hosts", () => { vi.clearAllMocks(); mockResolveLabel.mockResolvedValue(resolvedServer()); mockParseCookie.mockReturnValue("session-token"); - mockVerifySession.mockResolvedValue(OWNER); + mockVerifySessionDetails.mockResolvedValue(sessionDetails()); + mockRefreshAccountSession.mockResolvedValue(null); }); afterEach(() => { @@ -829,6 +843,105 @@ describe("gate worker share hosts", () => { ); }); + it("renews an active owner session on an ordinary HTTP response", async () => { + mockVerifySessionDetails.mockResolvedValue(sessionDetails(OWNER, true)); + mockRefreshAccountSession.mockResolvedValue([ + "__Secure-better-auth.session_token=renewed; Max-Age=604800; Domain=.getbb.app; Path=/; HttpOnly; SameSite=Lax; Secure", + "__Secure-better-auth.session_data=cached; Max-Age=300; Domain=.getbb.app; Path=/; HttpOnly; SameSite=Lax; Secure", + ]); + const { env, ctx } = makeEnv(() => new Response("ok")); + const response = await worker.fetch( + visitorRequest("sawyer.getbb.app", "/api/v1/threads"), + env as never, + ctx, + ); + + expect(mockRefreshAccountSession).toHaveBeenCalledWith( + "__Secure-better-auth.session_token=session-token", + "https://getbb.app", + expect.any(Function), + ); + expect(mockInvalidateSession).toHaveBeenCalledWith("session-token"); + expect(response.headers.get("set-cookie")).toContain( + "__Secure-better-auth.session_token=renewed", + ); + expect(response.headers.get("set-cookie")).toContain( + "__Secure-better-auth.session_data=cached", + ); + }); + + it("does not call the account worker before the update-age boundary", async () => { + const { env, ctx } = makeEnv(() => new Response("ok")); + const response = await worker.fetch( + visitorRequest("sawyer.getbb.app", "/api/v1/threads"), + env as never, + ctx, + ); + + expect(response.status).toBe(200); + expect(mockInvalidateSession).not.toHaveBeenCalled(); + expect(mockRefreshAccountSession).not.toHaveBeenCalled(); + expect(response.headers.get("set-cookie")).toBeNull(); + }); + + it.each(["hit", "miss"] as const)( + "does not renew an owner session on an edge-cache %s", + async (cacheStatus) => { + mockVerifySessionDetails.mockResolvedValue(sessionDetails(OWNER, true)); + mockRefreshAccountSession.mockResolvedValue(["should-not-be-used"]); + mockServeWithCache.mockResolvedValueOnce({ + cacheable: true, + response: new Response("cached asset", { + headers: { "x-bb-cache": cacheStatus }, + }), + }); + const { env, ctx } = makeEnv(() => new Response("origin")); + + const response = await worker.fetch( + visitorRequest("sawyer.getbb.app", "/assets/app.js"), + env as never, + ctx, + ); + + expect(mockRefreshAccountSession).not.toHaveBeenCalled(); + expect(response.headers.get("set-cookie")).toBeNull(); + expect(response.headers.get("x-bb-cache")).toBe(cacheStatus); + await expect(response.text()).resolves.toBe("cached asset"); + }, + ); + + it("reissues the local Cloud cookie without the Secure attribute", async () => { + mockVerifySessionDetails.mockResolvedValue(sessionDetails(OWNER, true)); + mockRefreshAccountSession.mockResolvedValue([ + "better-auth.session_token=renewed; Max-Age=604800; Domain=.bb.localhost; Path=/; HttpOnly; SameSite=Lax", + ]); + 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/api/v1/threads", { + headers: { + host: "127.0.0.1:50743", + "x-bb-cloud-dev-host": "sawyer", + }, + }), + env as never, + ctx, + ); + + expect(response.headers.get("set-cookie")).toBe( + "better-auth.session_token=renewed; Max-Age=604800; Domain=.bb.localhost; Path=/; HttpOnly; SameSite=Lax", + ); + expect(mockRefreshAccountSession).toHaveBeenCalledWith( + "better-auth.session_token=session-token", + "http://bb.localhost:42745", + expect.any(Function), + ); + }); + it("renders a bare machine-label page without proxying to the DO", async () => { mockResolveLabel.mockResolvedValue(resolvedMachine()); mockParseCookie.mockReturnValue(null); @@ -845,7 +958,7 @@ describe("gate worker share hosts", () => { expect(html).toContain("sawyer-air--<port>.getbb.app"); expect(html).toContain("sawyer.getbb.app"); expect(captured).toHaveLength(0); - expect(mockVerifySession).not.toHaveBeenCalled(); + expect(mockVerifySessionDetails).not.toHaveBeenCalled(); }); it("renders local machine links with HTTP and the shared gateway port", async () => { @@ -889,7 +1002,7 @@ describe("gate worker share hosts", () => { expect.any(Function), ); - mockVerifySession.mockResolvedValue(OTHER); + mockVerifySessionDetails.mockResolvedValue(sessionDetails(OTHER)); const otherEnv = makeEnv(() => new Response("machine-origin")); const otherResponse = await worker.fetch( visitorRequest("sawyer-air--3000.getbb.app", "/"), @@ -935,7 +1048,7 @@ describe("gate worker share hosts", () => { expect(blockedEnv.routingKeys).toEqual(["shared-machine:generation-b"]); expect(blockedEnv.captured).toHaveLength(0); - mockVerifySession.mockResolvedValue(OTHER); + mockVerifySessionDetails.mockResolvedValue(sessionDetails(OTHER)); const newEnv = makeEnv(() => new Response("owner-b")); const newResponse = await worker.fetch( visitorRequest("shared-machine--3000.getbb.app", "/asset.js"), @@ -1057,7 +1170,7 @@ describe("gate worker share hosts", () => { }); it("returns 403 when share host session is a different user", async () => { - mockVerifySession.mockResolvedValue(OTHER); + mockVerifySessionDetails.mockResolvedValue(sessionDetails(OTHER)); const { env, ctx, captured } = makeEnv(() => new Response("ok")); const res = await worker.fetch( visitorRequest("sawyer--8000.getbb.app", "/"), @@ -1067,6 +1180,7 @@ describe("gate worker share hosts", () => { expect(res.status).toBe(403); expect(await res.text()).toContain("not your server"); expect(captured).toHaveLength(0); + expect(mockRefreshAccountSession).not.toHaveBeenCalled(); }); it("accepts the short-lived desktop cookie for the owning account", async () => { @@ -1092,7 +1206,7 @@ describe("gate worker share hosts", () => { mockParseCookie.mockImplementation((_header, name) => name === DESKTOP_SESSION_COOKIE ? "desktop-token" : "github-token", ); - mockVerifySession.mockResolvedValue(OTHER); + mockVerifySessionDetails.mockResolvedValue(sessionDetails(OTHER)); mockVerifyDesktopSession.mockResolvedValue(OWNER); const { env, ctx, captured } = makeEnv(() => new Response("ok")); const response = await worker.fetch( @@ -1144,6 +1258,7 @@ describe("gate worker share hosts", () => { }); it("forwards websocket upgrades on share hosts with the target header", async () => { + mockVerifySessionDetails.mockResolvedValue(sessionDetails(OWNER, true)); // Node's Response rejects status 101; the gate only needs the upgrade path. const { env, ctx, captured } = makeEnv( () => new Response("upgraded", { status: 200 }), @@ -1159,6 +1274,7 @@ describe("gate worker share hosts", () => { expect(captured[0].headers.get(TUNNEL_TARGET_HEADER)).toBe("8000"); expect(captured[0].headers.get("upgrade")).toBe("websocket"); expect(mockServeWithCache).not.toHaveBeenCalled(); + expect(mockRefreshAccountSession).not.toHaveBeenCalled(); }); it("does not apply machine-credential branch on share hosts", async () => { @@ -1211,7 +1327,7 @@ describe("gate offline page", () => { beforeEach(() => { vi.clearAllMocks(); mockParseCookie.mockReturnValue("session-token"); - mockVerifySession.mockResolvedValue(OWNER); + mockVerifySessionDetails.mockResolvedValue(sessionDetails()); }); it("renders the styled offline page on a browser navigation, using last-seen", async () => { diff --git a/apps/connect/src/worker.ts b/apps/connect/src/worker.ts index 194e12fc3b..4bc7896d59 100644 --- a/apps/connect/src/worker.ts +++ b/apps/connect/src/worker.ts @@ -5,13 +5,15 @@ import { parseVisitorHost, schema, } from "@bb/connect-db"; +import { refreshAccountSessionCookies } from "./account-session.js"; import { TUNNEL_OFFLINE_HEADER, TunnelDO, type Env } from "./tunnel-do.js"; import { + invalidateSessionCookie, parseCookie, markMachineSeen, resolveLabel, verifyMachineCredentialDetails, - verifySessionCookie, + verifySessionCookieDetails, } from "./session.js"; import { handleCreateDesktopSession, @@ -55,6 +57,19 @@ function text(body: string, status: number): Response { }); } +function withSetCookies( + response: Response, + setCookies: readonly string[], +): Response { + const headers = new Headers(response.headers); + for (const setCookie of setCookies) headers.append("set-cookie", setCookie); + return new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }); +} + // Matches the bb dashboard's visual language (Inter, --canvas/--ink tokens, // dark primary button, bb logo) since this plain worker can't bundle React. export function dashboardSignInUrl(appUrl: string, returnTo: string): string { @@ -458,9 +473,10 @@ export default { const appUrl = runtime.accountAppUrl; if (!cookie && !desktopCookie) return signInPage(label, appUrl, url.toString()); - const sessionUserId = cookie - ? await verifySessionCookie(cookie, env.BETTER_AUTH_SECRET, db) + const verifiedSession = cookie + ? await verifySessionCookieDetails(cookie, env.BETTER_AUTH_SECRET, db) : null; + const sessionUserId = verifiedSession?.userId ?? null; const desktopUserId = desktopCookie ? await verifyDesktopSessionCookie(desktopCookie, env.BETTER_AUTH_SECRET) : null; @@ -475,7 +491,6 @@ export default { } const doRequest = requestForTunnelDo(request, target, "session"); - // WebSocket upgrades (bb's /ws, terminals) can't be cached — proxy directly. if (request.headers.get("upgrade")?.toLowerCase() === "websocket") { return stub.fetch(doRequest); @@ -483,12 +498,13 @@ export default { // Everything else: serve from the edge cache when the origin allows it, // otherwise proxy through the tunnel. Namespace by full host label so a // share response never collides with bare-label app assets. - const response = await serveWithCache( + const cached = await serveWithCache( request, cacheNamespace(routingKey, target), ctx, () => stub.fetch(doRequest), ); + let response = cached.response; // Tunnel down + a browser navigation → the styled offline page, using the // last_seen_at already resolved for this server. API/asset/fetch requests // (no text/html Accept) keep the DO's plain 503 so clients handle it. @@ -497,13 +513,36 @@ export default { response.headers.get(TUNNEL_OFFLINE_HEADER) === "1" && wantsHtml(request) ) { - return offlinePage( + response = offlinePage( resolved.kind === "server" ? resolved.server.lastSeenAt : resolved.machine.lastSeenAt, resolved.kind, ); } + + // Edge-cacheable responses do not count as session activity. Once Better + // Auth's update-age boundary arrives, refresh both the D1 row and browser + // cookie on a non-cacheable HTTP response. This keeps their expiry in sync + // without rebuilding a pre-encoded cache hit or calling the account worker + // on every dynamic request. + if ( + !cached.cacheable && + cookie !== null && + sessionUserId === resolved.userId && + verifiedSession?.needsRefresh === true + ) { + // The account worker will re-read D1. Drop this isolate's old expiration + // hint so the next request observes the renewed row instead of repeating + // the cross-worker check for the rest of the short verification TTL. + invalidateSessionCookie(cookie); + const setCookies = await refreshAccountSessionCookies( + `${runtime.sessionCookieName}=${cookie}`, + runtime.accountAppUrl, + (authRequest) => fetch(authRequest), + ); + if (setCookies !== null) return withSetCookies(response, setCookies); + } return response; }, } satisfies ExportedHandler; diff --git a/apps/connect/test/encoding-fixture.ts b/apps/connect/test/encoding-fixture.ts index 736161ad14..02226767b7 100644 --- a/apps/connect/test/encoding-fixture.ts +++ b/apps/connect/test/encoding-fixture.ts @@ -78,6 +78,8 @@ export default { return new Response(cached.body, cached); } - return serveWithCache(request, NAMESPACE, ctx, () => stub.fetch(request)); + return ( + await serveWithCache(request, NAMESPACE, ctx, () => stub.fetch(request)) + ).response; }, }; diff --git a/apps/connect/wrangler.jsonc b/apps/connect/wrangler.jsonc index 3b92eb1176..e6b3e802e5 100644 --- a/apps/connect/wrangler.jsonc +++ b/apps/connect/wrangler.jsonc @@ -19,7 +19,7 @@ "main": "./src/worker.ts", "account_id": "7bb84c630057dafa53e2aacbe6bd094f", "compatibility_date": "2026-06-11", - "compatibility_flags": ["nodejs_compat"], + "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], "observability": { "enabled": true }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["TunnelDO"] }], "durable_objects": { diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts index 2097915a52..d42eb16306 100644 --- a/apps/web/src/server/auth.ts +++ b/apps/web/src/server/auth.ts @@ -1,7 +1,14 @@ import { betterAuth } from "better-auth"; import { drizzleAdapter } from "@better-auth/drizzle-adapter"; import { drizzle } from "drizzle-orm/d1"; -import { account, session, user, verification } from "@bb/connect-db"; +import { + account, + CONNECT_SESSION_EXPIRES_IN_SECONDS, + CONNECT_SESSION_UPDATE_AGE_SECONDS, + session, + user, + verification, +} from "@bb/connect-db"; import type { Env } from "./env.js"; import { resolveDevEmailPasswordEnabled } from "./local-auth.js"; @@ -33,6 +40,10 @@ export function createAuth(env: Env) { provider: "sqlite", schema: { user, session, account, verification }, }) as unknown as Parameters[0]["database"], + session: { + expiresIn: CONNECT_SESSION_EXPIRES_IN_SECONDS, + updateAge: CONNECT_SESSION_UPDATE_AGE_SECONDS, + }, emailAndPassword: { enabled: devEmailPasswordEnabled }, user: { additionalFields: { diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 20def48c83..a8bd87aa99 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -53,7 +53,15 @@ export default defineConfig(({ command }) => { plugins: [ cloudflare(cloudflareConfig), tailwindcss(), - tanstackStart(), + tanstackStart({ + router: { + routeTreeFileHeader: [ + "/* oxlint-disable */", + "// @ts-nocheck", + "// noinspection JSUnusedGlobalSymbols", + ], + }, + }), viteReact(), ], }; diff --git a/packages/connect-db/src/constants.ts b/packages/connect-db/src/constants.ts index 84b87389f4..ee871a56b1 100644 --- a/packages/connect-db/src/constants.ts +++ b/packages/connect-db/src/constants.ts @@ -130,6 +130,14 @@ export const CONNECT_CODE_TTL_MS = 10 * 60 * 1000; /** A server is shown "offline" if no heartbeat within this window. */ export const SERVER_OFFLINE_AFTER_MS = 90 * 1000; +/** + * Account-session lifetime and sliding-refresh cadence. Better Auth owns this + * policy; after eligible activity on `