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
11 changes: 11 additions & 0 deletions apps/web/src/routes/api.connect.machine-code.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/server/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
claimHandle,
createConnectCode,
createMachineCodeForServerCredential,
lookupMachineCodeForServerCredential,
createServer,
disconnectServer,
removeServer,
Expand Down Expand Up @@ -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);
Expand Down
52 changes: 51 additions & 1 deletion apps/web/src/server/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,56 @@ export async function redeemConnectCode(
};
}

async function machineIdForCode(userId: string, code: string): Promise<string> {
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<Deps, "db">,
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<Deps, "db" | "serverUrlTemplate">,
code: string,
Expand Down Expand Up @@ -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({
Expand Down
58 changes: 58 additions & 0 deletions plugins/connect/README.md
Original file line number Diff line number Diff line change
@@ -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 <first eight ID characters>`. 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`.
1 change: 1 addition & 0 deletions plugins/connect/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 22 additions & 0 deletions plugins/connect/src/machine-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
26 changes: 26 additions & 0 deletions plugins/connect/src/redeem.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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());
}
Loading
Loading