diff --git a/apps/web/src/routes/api.connect.machine-code.tsx b/apps/web/src/routes/api.connect.machine-code.tsx index e3c80ae6f9..496e12bb48 100644 --- a/apps/web/src/routes/api.connect.machine-code.tsx +++ b/apps/web/src/routes/api.connect.machine-code.tsx @@ -2,12 +2,23 @@ import { createFileRoute } from "@tanstack/react-router"; import { createMachineCodeForServerCredential, depsFromEnv, + lookupMachineCodeForServerCredential, } from "@/server/api"; import { getEnv } from "@/server/env"; export const Route = createFileRoute("/api/connect/machine-code")({ server: { handlers: { + GET: async ({ request }) => { + const result = await lookupMachineCodeForServerCredential( + depsFromEnv(getEnv()), + request.headers.get("x-bb-connect-machine") ?? "", + request.headers.get("x-bb-connect-code") ?? "", + ); + return Response.json(result, { + status: "status" in result ? result.status : 200, + }); + }, POST: async ({ request }) => { const credential = request.headers.get("x-bb-connect-machine") ?? ""; const result = await createMachineCodeForServerCredential( diff --git a/apps/web/src/server/api.test.ts b/apps/web/src/server/api.test.ts index 427e281931..601e157418 100644 --- a/apps/web/src/server/api.test.ts +++ b/apps/web/src/server/api.test.ts @@ -20,6 +20,7 @@ import { claimHandle, createConnectCode, createMachineCodeForServerCredential, + lookupMachineCodeForServerCredential, createServer, disconnectServer, removeServer, @@ -542,8 +543,38 @@ describe("server-authenticated machine-code round trip", () => { if ("status" in minted) throw new Error(minted.error); expect(minted.serverUrl).toBe("https://sawyer-desktop.getbb.app"); + expect( + await lookupMachineCodeForServerCredential( + deps, + serverCredential, + minted.code, + ), + ).toEqual({ consumed: false, machineId: null }); const redeemed = await redeemMachineCode(deps, minted.code); if ("error" in redeemed) throw new Error(redeemed.error); + expect( + await lookupMachineCodeForServerCredential( + deps, + serverCredential, + minted.code, + ), + ).toEqual({ consumed: true, machineId: redeemed.machineId }); + expect( + await lookupMachineCodeForServerCredential(deps, "bogus", minted.code), + ).toMatchObject({ status: 401 }); + const other = await createServer(deps, "u1", "sawyer-other"); + if (!("ok" in other)) throw new Error("server setup failed"); + db.update(server) + .set({ credentialHash: await sha256Hex("bbcred_other") }) + .where(eq(server.id, other.server.id)) + .run(); + expect( + await lookupMachineCodeForServerCredential( + deps, + "bbcred_other", + minted.code, + ), + ).toMatchObject({ status: 404 }); expect(redeemed.credential.startsWith("bbcm_")).toBe(true); expect(redeemed.serverUrl).toBe("https://sawyer-desktop.getbb.app"); expect(db.select().from(machine).all()).toHaveLength(1); diff --git a/apps/web/src/server/api.ts b/apps/web/src/server/api.ts index c77dc8eb17..6acec9e34a 100644 --- a/apps/web/src/server/api.ts +++ b/apps/web/src/server/api.ts @@ -730,6 +730,56 @@ export async function redeemConnectCode( }; } +async function machineIdForCode(userId: string, code: string): Promise { + const hash = await sha256Hex(JSON.stringify([userId, code])); + return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-${hash.slice(12, 16)}-${hash.slice(16, 20)}-${hash.slice(20, 32)}`; +} + +export async function lookupMachineCodeForServerCredential( + deps: Pick, + credential: string, + code: string, +): Promise< + | { consumed: boolean; machineId: string | null } + | { error: string; status: number } +> { + const srv = await deps.db + .select() + .from(server) + .where( + and( + eq(server.credentialHash, await sha256Hex(credential.trim())), + isNull(server.revokedAt), + ), + ) + .get(); + if (!credential.trim() || !srv) return { error: "unauthorized", status: 401 }; + const row = await deps.db + .select() + .from(connectCode) + .where( + and( + eq(connectCode.code, code.trim().toUpperCase()), + eq(connectCode.serverId, srv.id), + eq(connectCode.userId, srv.userId), + eq(connectCode.purpose, "machine-pair"), + ), + ) + .get(); + if (!row) return { error: "invalid-code", status: 404 }; + const device = await deps.db + .select({ id: machine.id }) + .from(machine) + .where( + and( + eq(machine.id, await machineIdForCode(row.userId, row.code)), + eq(machine.userId, srv.userId), + ), + ) + .get(); + return { consumed: row.consumedAt !== null, machineId: device?.id ?? null }; +} + export async function redeemMachineCode( deps: Pick, code: string, @@ -777,7 +827,7 @@ export async function redeemMachineCode( return { error: "already-used", status: 409 }; const credential = generateToken("bbcm_", 32); - const machineId = crypto.randomUUID(); + const machineId = await machineIdForCode(row.userId, normalized); await db .insert(machine) .values({ diff --git a/plugins/connect/README.md b/plugins/connect/README.md new file mode 100644 index 0000000000..8b662f5111 --- /dev/null +++ b/plugins/connect/README.md @@ -0,0 +1,58 @@ +# Connect server access + +The server-access provider redeems Cloud machine codes on the server. It returns +`{ id, serverUrl, headers: { "x-bb-connect-machine": credential } }` and persists +the Cloud `connectMachineId` with the grant before returning it. Release uses +that identity to revoke access even if the machine never enrolled; failures keep +the record for retry, including across server restart. Existing enrolled grants +can still resolve their identity from host detail. + +This closes the previous redeem-before-enrollment cleanup gap for new grants. +Old grants redeemed by an earlier machine without reporting their Cloud identity +cannot be reconstructed from a code; those legacy devices still require dashboard +revocation. Pending v1 bundles are upgraded by core on preparation to v2 headers. + +The Cloud redeem endpoint accepts only a code and stores a new device ID, owner, +credential hash and creation time. It does not derive a name from the caller's +hostname, IP or user agent. The dashboard uses the nullable stored name and falls +back to `Machine `. Moving redemption to the server +therefore does not change device naming. Production checks are recorded in the +PR verification report; no caller name field is invented. + +Acquisition intent and credential-bearing grants use the SDK secret settings path +(private 0600 files outside SQLite). KV holds only grant/device IDs, acquisition +keys, hashed code IDs and recovery messages. All existing plaintext grants migrate +during plugin initialization, before the provider is registered. Secret persistence succeeds before each KV value is +replaced with metadata; a failed migration retries on the next initialization. + +Intent, including code expiry, is durable before redemption. A lookup-confirmed +unconsumed code is renewed when expired (or when legacy intent has no expiry); +a still-valid code is reused. Ambiguous lookup results retain the recovery warning. +After an interrupted request, acquire and release use authenticated GET /api/connect/machine-code with the original code +in x-bb-connect-code. Cloud resolves the exact server-owned code to its device; +the plugin revokes that device before requesting a replacement. Cloud must deploy +the lookup and deterministic code-derived device identity together. Until then, +lookup failure retains the intent and reports “Cloud device may need dashboard +revocation” in machine status and Settings → Machines. Retries never silently +mint a replacement while the original device is unresolved. Old randomly named +devices cannot be resolved by this lookup and retain the same visible warning. + +Already delivered v1 bundles remain accepted by the CLI and installer. A legacy +Connect client redeems once on the machine, saves the upgraded headers locally, +and uses those headers for artifact download, enrollment and daemon requests. + +Release revokes both the stored grant device and any distinct trusted Cloud +identity reported by the host. This covers a delivered v1 bundle redeemed after +the server separately upgraded its pending copy; both identities are retained +for retry until revocation completes. + +Malformed legacy payloads are scrubbed from KV during initialization. Only +validated cleanup identities and a quarantine flag remain; raw malformed data +is discarded. A safe diagnostic is logged, and General → Machine access shows +“N legacy access records need attention” without disabling healthy grants. +Quarantined hosts cannot acquire replacement access until their known device is +revoked through normal removal; records without a recoverable device identity +retain the dashboard-revocation diagnostic. + +The same diagnostic is available in `serverAccess.providers[].attention` through +SDK `system.config()` and `bb settings show --json`. diff --git a/plugins/connect/package.json b/plugins/connect/package.json index b2ea723fca..5d53b77169 100644 --- a/plugins/connect/package.json +++ b/plugins/connect/package.json @@ -35,6 +35,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@bb/db": "workspace:*", "@get-bb/plugin-sdk": "workspace:*", "@radix-ui/react-dialog": "^1.1.19", "@testing-library/react": "^16.3.2", diff --git a/plugins/connect/src/machine-code.ts b/plugins/connect/src/machine-code.ts index 9442027546..b0710fbb45 100644 --- a/plugins/connect/src/machine-code.ts +++ b/plugins/connect/src/machine-code.ts @@ -49,3 +49,25 @@ export async function fetchMachineCode( serverUrl: parsed.data.serverUrl, }; } + +export async function lookupMachineCode( + credential: ConnectCredential, + code: string, +) { + const response = await fetch( + `${deriveConnectBaseUrl(credential.serverUrl)}/api/connect/machine-code`, + { + method: "GET", + headers: { + "x-bb-connect-machine": credential.credential, + "x-bb-connect-code": code, + }, + signal: AbortSignal.timeout(10_000), + }, + ); + if (!response.ok) + throw new Error(`Machine code lookup failed (${response.status})`); + return z + .object({ consumed: z.boolean(), machineId: z.string().nullable() }) + .parse(await response.json()); +} diff --git a/plugins/connect/src/redeem.ts b/plugins/connect/src/redeem.ts index 12a390493c..444609168b 100644 --- a/plugins/connect/src/redeem.ts +++ b/plugins/connect/src/redeem.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; +import { deriveConnectBaseUrl } from "@bb/connect-client"; export const DEFAULT_CONNECT_BASE_URL = "https://getbb.app"; export function resolveDefaultConnectBaseUrl(env: NodeJS.ProcessEnv): string { @@ -95,3 +97,27 @@ export async function redeemConnectCode(args: { const data = (await res.json()) as RedeemedCredential; return { credential: data.credential, handle: data.handle }; } + +export async function redeemMachineCode(args: { + code: string; + serverUrl: string; +}): Promise<{ credential: string; machineId: string; serverUrl: string }> { + const response = await fetch( + `${deriveConnectBaseUrl(args.serverUrl)}/api/connect/redeem-machine`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ code: args.code }), + signal: AbortSignal.timeout(10_000), + }, + ); + if (!response.ok) + throw new Error(`Machine redeem failed (${response.status})`); + return z + .object({ + credential: z.string().min(1), + machineId: z.string().min(1), + serverUrl: z.string().url(), + }) + .parse(await response.json()); +} diff --git a/plugins/connect/src/server-access.test.ts b/plugins/connect/src/server-access.test.ts new file mode 100644 index 0000000000..66a2596324 --- /dev/null +++ b/plugins/connect/src/server-access.test.ts @@ -0,0 +1,543 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createFakePluginHost, + type FakePluginHost, +} from "@get-bb/plugin-sdk/testing"; +import { + createConnection, + migrate, + getPluginKvValue, + setPluginKvValue, + deletePluginKvValue, + pluginKv, + listPluginKvKeys, +} from "@bb/db"; +import { registerServerAccess } from "./server-access.js"; + +const credential = { + serverUrl: "https://test.getbb.app", + handle: "test", + credential: "bbcred_private_server", +}; +const tunnel = { + getCredential: () => credential, + status: () => ({ paired: true }), +}; +const request = { + key: "launch-key", + hostId: "host-pending", + signal: new AbortController().signal, +}; +const key = "server-access-grant:host-pending"; +const hosts: FakePluginHost[] = []; +const databases: ReturnType[] = []; +async function setup( + beforeInit?: (host: FakePluginHost) => Promise, + settings?: Record, +) { + const host = createFakePluginHost({ + pluginId: "connect", + settings, + sdk: { hosts: { get: async () => ({ connectMachineId: null }) } }, + }); + const db = createConnection(":memory:"); + migrate(db); + databases.push(db); + Object.assign(host.bb.storage.kv, { + list: async (prefix?: string) => listPluginKvKeys(db, "connect", prefix), + get: async (key: string) => { + const value = getPluginKvValue(db, "connect", key); + return value === undefined ? undefined : JSON.parse(value); + }, + set: async (key: string, value: unknown) => { + setPluginKvValue(db, "connect", key, JSON.stringify(value)); + }, + delete: async (key: string) => { + deletePluginKvValue(db, "connect", key); + }, + }); + hosts.push(host); + await beforeInit?.(host); + await registerServerAccess(host.bb, tunnel); + return host; +} +function provider(host: FakePluginHost) { + const p = host.harness.registrations.serverAccessProviders.get("connect"); + if (!p) throw new Error("Missing provider"); + return p; +} +function cloud() { + let active = false; + let failRevoke = false; + const fetchMock = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input); + if (path.endsWith("/machine-code")) + return Response.json({ + code: "PRIVATE-CODE", + expiresInMs: 600000, + serverUrl: credential.serverUrl, + }); + if (path.endsWith("/redeem-machine")) { + expect(JSON.parse(String(init?.body))).toEqual({ + code: "PRIVATE-CODE", + }); + active = true; + return Response.json({ + credential: "bbcm_private", + machineId: "cloud-id", + serverUrl: credential.serverUrl, + }); + } + expect(path).toBe("https://getbb.app/api/connect/revoke-machine"); + expect(JSON.parse(String(init?.body))).toEqual({ machineId: "cloud-id" }); + if (failRevoke) return new Response(null, { status: 503 }); + active = false; + return Response.json({ ok: true }); + }, + ); + vi.stubGlobal("fetch", fetchMock); + return { + fetchMock, + active: () => active, + failRevoke: (value: boolean) => { + failRevoke = value; + }, + }; +} +afterEach(async () => { + for (const host of hosts.splice(0)) await host.harness.lifecycle.dispose(); + for (const db of databases.splice(0)) db.$client.close(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); +describe("Connect server-owned machine access", () => { + it("persists redemption before enrollment and revokes after restart", async () => { + const api = cloud(); + const original = await setup(); + const grant = await provider(original).acquire(request); + expect(grant).toEqual({ + id: request.hostId, + serverUrl: credential.serverUrl, + headers: { "x-bb-connect-machine": "bbcm_private" }, + }); + expect(await original.bb.storage.kv.get(key)).toMatchObject({ + connectMachineId: "cloud-id", + }); + const restarted = await original.harness.lifecycle.reload((bb) => + registerServerAccess(bb, tunnel), + ); + Object.assign(restarted.bb.storage.kv, original.bb.storage.kv); + hosts.push(restarted); + expect(await provider(restarted).acquire(request)).toEqual(grant); + expect(api.fetchMock).toHaveBeenCalledTimes(2); + await provider(restarted).release({ + key: request.key, + hostId: request.hostId, + grantId: grant.id, + }); + expect(api.active()).toBe(false); + expect(await restarted.bb.storage.kv.get(key)).toBeUndefined(); + }); + it("retains the device ID on revoke failure and retries after restart", async () => { + const api = cloud(); + const original = await setup(); + await provider(original).acquire(request); + api.failRevoke(true); + await expect( + provider(original).release({ + key: request.key, + hostId: request.hostId, + grantId: request.hostId, + }), + ).rejects.toThrow("503"); + const restarted = await original.harness.lifecycle.reload((bb) => + registerServerAccess(bb, tunnel), + ); + Object.assign(restarted.bb.storage.kv, original.bb.storage.kv); + hosts.push(restarted); + api.failRevoke(false); + await provider(restarted).release({ + key: request.key, + hostId: request.hostId, + grantId: request.hostId, + }); + expect(api.active()).toBe(false); + expect(await restarted.bb.storage.kv.get(key)).toBeUndefined(); + }); + it("retains the device ID when pairing is unavailable", async () => { + cloud(); + const host = await setup(); + await provider(host).acquire(request); + const restarted = await host.harness.lifecycle.reload((bb) => + registerServerAccess(bb, { ...tunnel, getCredential: () => null }), + ); + Object.assign(restarted.bb.storage.kv, host.bb.storage.kv); + hosts.push(restarted); + await expect( + provider(restarted).release({ + key: request.key, + hostId: request.hostId, + grantId: request.hostId, + }), + ).rejects.toThrow("Pair this bb instance"); + expect(await host.bb.storage.kv.get(key)).toMatchObject({ + connectMachineId: "cloud-id", + }); + }); +}); + +it("moves plaintext grants into secret settings before replacing SQLite metadata", async () => { + const host = await setup(); + cloud(); + const grant = { + id: request.hostId, + serverUrl: credential.serverUrl, + headers: { "x-bb-connect-machine": "bbcm_private" }, + }; + await host.bb.storage.kv.set(key, { connectMachineId: "cloud-id", grant }); + expect(await provider(host).acquire(request)).toEqual(grant); + const rows = databases.at(-1)!.select().from(pluginKv).all(); + expect(JSON.stringify(rows)).not.toContain("bbcm_private"); + expect(await host.bb.storage.kv.get(key)).toEqual({ + grantId: request.hostId, + connectMachineId: "cloud-id", + }); + await provider(host).release({ + key: request.key, + hostId: request.hostId, + grantId: grant.id, + }); +}); + +it.each([true, false])( + "reconciles lost redemption responses with lookup available=%s without blindly minting", + async (available) => { + const host = await setup(); + const active = new Set(); + let minted = 0; + let redeemed = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/machine-code") && init?.method === "GET") { + return available + ? Response.json({ consumed: true, machineId: "device-1" }) + : new Response("bb", { + headers: { "content-type": "text/html" }, + }); + } + if (url.endsWith("/machine-code")) { + minted++; + return Response.json({ + code: `CODE-${minted}`, + expiresInMs: 600000, + serverUrl: credential.serverUrl, + }); + } + if (url.endsWith("/redeem-machine")) { + const id = `device-${++redeemed}`; + active.add(id); + if (redeemed === 1) + throw new Error("Response lost after Cloud commit"); + return Response.json({ + credential: "private-bearer", + machineId: id, + serverUrl: credential.serverUrl, + }); + } + active.delete(JSON.parse(String(init?.body)).machineId); + return Response.json({ ok: true }); + }), + ); + await expect(provider(host).acquire(request)).rejects.toThrow( + "Cloud device may need dashboard revocation", + ); + if (available) { + await provider(host).acquire(request); + await provider(host).release({ + key: request.key, + hostId: request.hostId, + grantId: request.hostId, + }); + expect(active.size).toBe(0); + } else { + await expect(provider(host).acquire(request)).rejects.toThrow( + "Cloud device may need dashboard revocation", + ); + await expect( + provider(host).release({ + key: request.key, + hostId: request.hostId, + grantId: null, + }), + ).rejects.toThrow("Cloud device may need dashboard revocation"); + expect(minted).toBe(1); + expect(redeemed).toBe(1); + expect(await host.bb.storage.kv.get(key)).toMatchObject({ + message: expect.stringContaining( + "Cloud device may need dashboard revocation", + ), + }); + } + expect( + JSON.stringify(databases.at(-1)!.select().from(pluginKv).all()), + ).not.toContain("private-bearer"); + }, +); + +it("serializes concurrent acquisitions so release revokes every created device", async () => { + const host = await setup(); + const api = cloud(); + const grants = await Promise.all([ + provider(host).acquire(request), + provider(host).acquire(request), + ]); + expect(grants[0]).toEqual(grants[1]); + expect(api.fetchMock).toHaveBeenCalledTimes(2); + await provider(host).release({ + key: request.key, + hostId: request.hostId, + grantId: request.hostId, + }); + expect(api.active()).toBe(false); +}); + +it("revokes a known device from SQLite metadata even if its secret is missing", async () => { + const host = await setup(); + const api = cloud(); + await host.bb.storage.kv.set(key, { + connectMachineId: "cloud-id", + grantId: request.hostId, + }); + await provider(host).release({ + key: request.key, + hostId: request.hostId, + grantId: request.hostId, + }); + expect(api.fetchMock).toHaveBeenCalledOnce(); + expect(api.fetchMock.mock.calls[0]?.[0]).toBe( + "https://getbb.app/api/connect/revoke-machine", + ); + expect(await host.bb.storage.kv.get(key)).toBeUndefined(); +}); + +it("revokes both server-owned and delivered-v1 device identities after a pending bundle upgrade", async () => { + const host = await setup(); + cloud(); + await provider(host).acquire(request); + host.harness.sdk.stub("hosts.get", async () => ({ + connectMachineId: "legacy-delivered-device", + })); + const revoked: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + revoked.push(JSON.parse(String(init?.body)).machineId); + return Response.json({ ok: true }); + }), + ); + await provider(host).release({ + key: request.key, + hostId: request.hostId, + grantId: request.hostId, + }); + expect(revoked).toEqual(["cloud-id", "legacy-delivered-device"]); +}); + +it("migrates every dormant plaintext grant before registering access", async () => { + const host = await setup(async (host) => { + for (const id of ["dormant-a", "dormant-b"]) { + await host.bb.storage.kv.set(`server-access-grant:${id}`, { + connectMachineId: `device-${id}`, + grant: { + id, + serverUrl: credential.serverUrl, + headers: { authorization: `private-${id}` }, + }, + }); + } + }); + expect(provider(host)).toBeDefined(); + expect( + JSON.stringify(databases.at(-1)!.select().from(pluginKv).all()), + ).not.toContain("private-"); + for (const id of ["dormant-a", "dormant-b"]) { + expect( + await provider(host).acquire({ ...request, hostId: id }), + ).toMatchObject({ headers: { authorization: `private-${id}` } }); + } +}); + +it("leaves plaintext intact after a failed secret migration and retries next initialization", async () => { + await expect( + setup(async (host) => { + await host.bb.storage.kv.set(key, { + connectMachineId: "old-device", + grant: { + id: request.hostId, + serverUrl: credential.serverUrl, + headers: { authorization: "old-private" }, + }, + }); + const define = host.bb.settings.define.bind(host.bb.settings); + vi.spyOn(host.bb.settings, "define").mockImplementation( + (descriptors) => ({ + ...define(descriptors), + experimental_set: async () => { + throw new Error("Secret write failed"); + }, + }), + ); + }), + ).rejects.toThrow("Secret write failed"); + const host = hosts.at(-1)!; + expect(host.harness.registrations.serverAccessProviders.size).toBe(0); + expect(JSON.stringify(await host.bb.storage.kv.get(key))).toContain( + "old-private", + ); + vi.restoreAllMocks(); + const restarted = await host.harness.lifecycle.reload(async (bb) => { + Object.assign(bb.storage.kv, host.bb.storage.kv); + await registerServerAccess(bb, tunnel); + }); + hosts.push(restarted); + expect(JSON.stringify(await restarted.bb.storage.kv.get(key))).not.toContain( + "old-private", + ); + expect(await provider(restarted).acquire(request)).toMatchObject({ + headers: { authorization: "old-private" }, + }); +}); + +it.each(["expired", "valid", "legacy"])( + "renews only definitively unconsumed expired or undated intents: %s", + async (age) => { + const host = await setup(undefined, { + machineAccessSecrets: JSON.stringify({ + [request.hostId]: { + intent: { + key: request.key, + hostId: request.hostId, + code: "OLD-CODE", + serverUrl: credential.serverUrl, + ...(age === "legacy" + ? {} + : { + expiresAt: Date.now() + (age === "expired" ? -1000 : 600000), + }), + }, + }, + }), + }); + const issued: string[] = []; + const redeemed: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "GET") + return Response.json({ consumed: false, machineId: null }); + if (String(input).endsWith("/machine-code")) { + issued.push("NEW-CODE"); + return Response.json({ + code: "NEW-CODE", + expiresInMs: 600000, + serverUrl: credential.serverUrl, + }); + } + expect(String(input)).toContain("/redeem-machine"); + redeemed.push(JSON.parse(String(init?.body)).code); + return Response.json({ + credential: "new-private", + machineId: "new-device", + serverUrl: credential.serverUrl, + }); + }), + ); + await expect(provider(host).acquire(request)).resolves.toMatchObject({ + headers: { "x-bb-connect-machine": "new-private" }, + }); + expect(issued).toEqual(age === "valid" ? [] : ["NEW-CODE"]); + expect(redeemed).toEqual([age === "valid" ? "OLD-CODE" : "NEW-CODE"]); + expect(await host.bb.storage.kv.get(key)).toEqual({ + connectMachineId: "new-device", + grantId: request.hostId, + }); + }, +); + +it.each(["invalid-url", "invalid-headers", "unexpected-headers"])( + "scrubs malformed legacy payloads among valid and migrated records: %s", + async (kind) => { + const warnings = vi.fn(); + const valid = { + connectMachineId: "valid-device", + grant: { + id: "valid", + serverUrl: credential.serverUrl, + headers: { authorization: "valid-private" }, + }, + }; + const migrated = { + connectMachineId: "migrated-device", + grantId: "migrated", + }; + const bad = + kind === "unexpected-headers" + ? { ...migrated, headers: { authorization: "malformed-private" } } + : { + connectMachineId: "cloud-id", + grant: { + id: "bad", + serverUrl: + kind === "invalid-url" ? "invalid" : credential.serverUrl, + headers: + kind === "invalid-headers" + ? ["malformed-private"] + : { authorization: "malformed-private" }, + }, + }; + const host = await setup(async (host) => { + vi.spyOn(host.bb.log, "warn").mockImplementation(warnings); + await host.bb.storage.kv.set("server-access-grant:valid", valid); + await host.bb.storage.kv.set("server-access-grant:migrated", migrated); + await host.bb.storage.kv.set("server-access-grant:bad", bad); + }); + expect( + JSON.stringify(databases.at(-1)!.select().from(pluginKv).all()), + ).not.toContain("private"); + expect( + await host.bb.storage.kv.get("server-access-grant:migrated"), + ).toEqual(migrated); + expect(await host.bb.storage.kv.get("server-access-grant:bad")).toEqual({ + grantId: "bad", + connectMachineId: bad.connectMachineId, + quarantined: true, + }); + expect(warnings).toHaveBeenCalledWith( + "Malformed legacy access record scrubbed; cleanup requires attention", + ); + expect(JSON.stringify(warnings.mock.calls)).not.toContain("private"); + expect(await provider(host).experimental_attention?.()).toBe( + "1 legacy access records need attention", + ); + expect(await provider(host).availability()).toEqual({ + status: "available", + }); + expect( + await provider(host).acquire({ ...request, hostId: "valid" }), + ).toEqual(valid.grant); + await expect( + provider(host).acquire({ ...request, hostId: "bad" }), + ).rejects.toThrow("Legacy access record needs attention"); + const restarted = await host.harness.lifecycle.reload(async (bb) => { + Object.assign(bb.storage.kv, host.bb.storage.kv); + await registerServerAccess(bb, tunnel); + }); + hosts.push(restarted); + expect(await provider(restarted).experimental_attention?.()).toBe( + "1 legacy access records need attention", + ); + }, +); diff --git a/plugins/connect/src/server-access.ts b/plugins/connect/src/server-access.ts new file mode 100644 index 0000000000..068f7bc614 --- /dev/null +++ b/plugins/connect/src/server-access.ts @@ -0,0 +1,308 @@ +import type { BbPluginApi } from "@get-bb/plugin-sdk"; +import { z } from "zod"; +import { createHash } from "node:crypto"; +import { lookupMachineCode } from "./machine-code.js"; +import type { ConnectTunnel } from "./tunnel.js"; +import { fetchMachineCode } from "./machine-code.js"; +import { revokeMachine } from "./revoke-machine.js"; +import { redeemMachineCode } from "./redeem.js"; + +const grantSchema = z.object({ + connectMachineId: z.string().min(1), + grant: z.object({ + id: z.string().min(1), + serverUrl: z.string().url(), + headers: z.record(z.string(), z.string()), + }), +}); + +const metadataSchema = z.strictObject({ + grantId: z.string().optional(), + key: z.string().optional(), + message: z.string().optional(), + quarantined: z.boolean().optional(), + connectMachineId: z.string().optional(), + connectMachineIds: z.array(z.string()).optional(), + codeId: z.string().optional(), +}); + +function recoveryError(message: string): Error { + return Object.assign(new Error(message), { + name: "experimental_ServerAccessRecoveryError", + }); +} + +function grantKey(hostId: string): string { + return `server-access-grant:${hostId}`; +} + +export async function registerServerAccess( + bb: BbPluginApi, + tunnel: { + getCredential: ConnectTunnel["getCredential"]; + status(): { paired: boolean }; + }, +) { + const secrets = bb.settings.define({ + machineAccessSecrets: { + type: "string", + label: "Machine access credentials", + secret: true, + }, + }); + const intentSchema = z.object({ + key: z.string(), + hostId: z.string(), + code: z.string(), + expiresAt: z.number().finite().nullable().default(null), + serverUrl: z.string().url(), + }); + const stateSchema = z.record( + z.string(), + z.object({ + result: grantSchema.optional(), + intent: intentSchema.optional(), + }), + ); + let queue = Promise.resolve(); + function serialized(action: () => Promise): Promise { + const result = queue.then(action); + queue = result.then( + () => undefined, + () => undefined, + ); + return result; + } + async function readState() { + const raw = (await secrets.get()).machineAccessSecrets; + try { + return stateSchema.parse(raw ? JSON.parse(raw) : {}); + } catch { + throw new Error("Stored machine access credentials are invalid"); + } + } + async function storeState(state: z.infer) { + await secrets.experimental_set({ + machineAccessSecrets: JSON.stringify(state), + }); + } + async function load(hostId: string) { + const state = await readState(); + const raw = await bb.storage.kv.get(grantKey(hostId)); + const legacy = grantSchema.safeParse(raw); + if (legacy.success) { + state[hostId] = { result: legacy.data }; + await storeState(state); + await bb.storage.kv.set(grantKey(hostId), { + connectMachineId: legacy.data.connectMachineId, + grantId: legacy.data.grant.id, + }); + } else if (raw !== undefined && !metadataSchema.safeParse(raw).success) { + const identifiers = z + .object({ + connectMachineId: z.string().optional().catch(undefined), + connectMachineIds: z.array(z.string()).optional().catch(undefined), + }) + .safeParse(raw); + await bb.storage.kv.set(grantKey(hostId), { + ...(identifiers.success ? identifiers.data : {}), + grantId: hostId, + quarantined: true, + }); + bb.log.warn( + "Malformed legacy access record scrubbed; cleanup requires attention", + ); + } + return state; + } + async function reconcile( + hostId: string, + intent: z.infer, + ) { + const credential = tunnel.getCredential(); + if (!credential) + throw new Error( + "Pair this bb instance with bb Cloud to revoke machine access", + ); + try { + const status = await lookupMachineCode(credential, intent.code); + if (status.consumed && !status.machineId) + throw new Error("Device identity unavailable"); + if (status.machineId) await revokeMachine(credential, status.machineId); + return status.consumed; + } catch { + const message = + "Cloud device may need dashboard revocation: interrupted machine access acquisition; retry after Cloud lookup is available"; + await bb.storage.kv.set(grantKey(hostId), { + grantId: hostId, + key: intent.key, + codeId: createHash("sha256").update(intent.code).digest("hex"), + message, + }); + throw recoveryError(message); + } + } + await serialized(async () => { + for (const key of await bb.storage.kv.list("server-access-grant:")) { + await load(key.slice("server-access-grant:".length)); + } + }); + bb.experimental_serverAccess.register({ + id: "connect", + displayName: "bb Cloud", + experimental_attention: async () => { + let count = 0; + for (const key of await bb.storage.kv.list("server-access-grant:")) { + const metadata = metadataSchema.safeParse(await bb.storage.kv.get(key)); + if (metadata.success && metadata.data.quarantined) count += 1; + } + return count > 0 ? `${count} legacy access records need attention` : null; + }, + availability: () => + tunnel.status().paired + ? { status: "available" } + : { + status: "setup-required", + message: "Pair this bb instance with bb Cloud", + }, + acquire({ key, hostId, signal }) { + return serialized(async () => { + signal.throwIfAborted(); + const state = await load(hostId); + const existing = state[hostId]; + if (existing?.result) return existing.result.grant; + const credential = tunnel.getCredential(); + if (!credential) throw new Error("Pair this bb instance with bb Cloud"); + const metadata = metadataSchema.safeParse( + await bb.storage.kv.get(grantKey(hostId)), + ); + if (metadata.success && metadata.data.quarantined) + throw recoveryError( + "Legacy access record needs attention before machine access can be acquired", + ); + if (!existing?.intent && metadata.success) { + if (metadata.data.connectMachineId) + await revokeMachine(credential, metadata.data.connectMachineId); + else if (metadata.data.codeId) + throw recoveryError( + "Cloud device may need dashboard revocation: acquisition secret is missing", + ); + } + let intent = existing?.intent; + if (intent) { + const consumed = await reconcile(hostId, intent); + if ( + consumed || + intent.expiresAt === null || + intent.expiresAt <= Date.now() + ) + intent = undefined; + } + if (!intent) { + const code = await fetchMachineCode(credential); + intent = { + key, + hostId, + code: code.code, + serverUrl: code.serverUrl, + expiresAt: code.expiresAt, + }; + } + state[hostId] = { intent }; + await storeState(state); + await bb.storage.kv.set(grantKey(hostId), { + grantId: hostId, + key, + codeId: createHash("sha256").update(intent.code).digest("hex"), + }); + const pending = intent; + const redeemed = await redeemMachineCode(pending).catch(async () => { + const message = + "Cloud device may need dashboard revocation: interrupted machine access acquisition"; + await bb.storage.kv.set(grantKey(hostId), { + grantId: hostId, + key, + codeId: createHash("sha256").update(pending.code).digest("hex"), + message, + }); + throw recoveryError(message); + }); + const grant = { + id: hostId, + serverUrl: redeemed.serverUrl, + headers: { "x-bb-connect-machine": redeemed.credential }, + }; + state[hostId] = { + result: { + connectMachineId: redeemed.machineId, + grant, + }, + }; + await storeState(state); + await bb.storage.kv.set(grantKey(hostId), { + connectMachineId: redeemed.machineId, + grantId: hostId, + }); + return grant; + }); + }, + release({ hostId: grantId }) { + return serialized(async () => { + const state = await load(grantId); + const stored = state[grantId]; + if (stored?.intent) await reconcile(grantId, stored.intent); + const metadata = metadataSchema.safeParse( + await bb.storage.kv.get(grantKey(grantId)), + ); + if ( + !stored && + metadata.success && + metadata.data.codeId && + !metadata.data.connectMachineId + ) { + throw recoveryError( + "Cloud device may need dashboard revocation: acquisition secret is missing", + ); + } + const connectMachineId = + stored?.result?.connectMachineId ?? + (metadata.success ? metadata.data.connectMachineId : undefined); + const reportedMachineId = (await bb.sdk.hosts.get({ hostId: grantId })) + .connectMachineId; + const ids = [ + ...new Set([ + ...(connectMachineId ? [connectMachineId] : []), + ...(reportedMachineId ? [reportedMachineId] : []), + ...(metadata.success + ? (metadata.data.connectMachineIds ?? []) + : []), + ]), + ]; + if (ids.length === 0 && metadata.success && metadata.data.quarantined) + throw recoveryError( + "Cloud device may need dashboard revocation: malformed legacy access record has no device identity", + ); + if (ids.length > 0) { + const credential = tunnel.getCredential(); + if (!credential) + throw new Error( + "Pair this bb instance with bb Cloud to revoke machine access", + ); + await bb.storage.kv.set(grantKey(grantId), { + grantId, + connectMachineId, + connectMachineIds: ids, + ...(metadata.success && metadata.data.quarantined + ? { quarantined: true } + : {}), + }); + for (const id of ids) await revokeMachine(credential, id); + } + delete state[grantId]; + await storeState(state); + await bb.storage.kv.delete(grantKey(grantId)); + await bb.storage.kv.delete(`server-access-expiry:${grantId}`); + }); + }, + }); +} diff --git a/plugins/connect/src/server.ts b/plugins/connect/src/server.ts index 9aee8e7785..ccf6d04dad 100644 --- a/plugins/connect/src/server.ts +++ b/plugins/connect/src/server.ts @@ -1,3 +1,4 @@ +import { registerServerAccess } from "./server-access.js"; import type { BbPluginApi } from "@get-bb/plugin-sdk"; import { registerConnectCli } from "./cli.js"; import { createKvCredentialStore } from "./credential.js"; @@ -48,6 +49,8 @@ export default async function plugin(bb: BbPluginApi) { bb.realtime.publish(CONNECT_REALTIME_CHANNEL, status), }); + await registerServerAccess(bb, tunnel); + const mobilePairing: MobilePairingGate = { enabled: async () => (await bb.sdk.system.config()).experiments.mobileApp, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef572890c6..2e26bff606 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3053,6 +3053,9 @@ importers: specifier: 4.3.6 version: 4.3.6 devDependencies: + '@bb/db': + specifier: workspace:* + version: link:../../packages/db '@get-bb/plugin-sdk': specifier: workspace:* version: link:../../packages/plugin-sdk