import { randomUUID } from 'node:crypto'; import { join } from 'node:path'; import { zipDirectory } from './zip'; import { createRouter } from '@@/create-router'; import * as errors from '@@/custom-errors'; import { sendSidecarCommand } from '@@/sidecar-registry'; import { getPairingSalt, setPairingSalt, clearPairing } from '../db/queries'; import { deriveRelayToken } from '../sidecar/relay-auth'; import { captureScreenshot, evaluateJS, navigateTo } from '../sidecar/cdp'; import { BROWSER_SIDECAR_KIND, RELAY_PORT, type BrowserEvent } from '../sidecar/protocol'; export const router = createRouter(); /** * Ask the relay process something, and unwrap its reply. * * Only the two questions whose answer is in-memory on the relay side go this way — who is connected, and * what tabs they have. Everything else the CDP socket already does directly. */ async function ask( type: 'relay:status' | 'relay:targets' | 'relay:token-add' | 'relay:token-remove', payload: Record, ): Promise> { const reply = await sendSidecarCommand(BROWSER_SIDECAR_KIND, { type, id: randomUUID(), ...payload }); if (reply.type === 'error') throw errors.INTERNAL_SERVER_ERROR(reply.error); return reply as Extract; } /** * The port and token for a direct CDP connection. * * Derived HERE as well as in the relay, from the same key and the same inputs — not fetched. A token that * crossed the sidecar channel would be a live credential in whatever traced those messages, and deriving * is cheaper than asking anyway. * * Registers the token with the relay on the way past, because a token nothing has registered is one the * relay will refuse — that is what `relay:token-add` is for. */ async function getOpts(userId: number) { let salt = await getPairingSalt(userId); if (!salt) { salt = randomUUID(); await setPairingSalt(userId, salt); } await ask('relay:token-add', { userId, salt }); return { relayPort: RELAY_PORT, userToken: deriveRelayToken(userId, RELAY_PORT, salt) }; } // ── The extension, zipped from the directory on every request ── // // This route existed once, shelling out to `zip`, and was removed as having no caller: the UI linked at // `/browser-relay-extension.zip`, a static file in public/. That file was then found to be 58 lines // behind the source it claimed to be — built from something older and never rebuilt. // // So it is back, without the host binary (see zip.ts) and with the caller inside this plugin. There is // no second copy to fall behind, which is the only durable fix for that. router.get('/extension.zip', async (ctx) => { const dir = join(import.meta.dir, '..', 'extension'); const bytes = zipDirectory(dir); // `.buffer` because Bun's BodyInit does not accept a Uint8Array view directly. return new Response(bytes.buffer as ArrayBuffer, { headers: { 'content-type': 'application/zip', 'content-disposition': 'attachment; filename="officer-browser-relay.zip"', // Deterministic bytes for an unchanged directory, so a repeat download is a 304 rather than 50 KB. etag: `"${Bun.hash(bytes).toString(16)}"`, 'cache-control': 'private, no-cache', }, }); }); router.get('/status', async (ctx) => { const user = ctx.get('user'); const { extensionConnected, targetCount } = await ask<'relay:status'>('relay:status', { userId: user.id }); return ctx.json({ extensionConnected, targetCount }); }); // The pair of values the extension is configured with. Shown as often as asked — unlike an API key, this // is derived from a stored salt rather than generated, so there is nothing lost by showing it twice. router.get('/relay-token', async (ctx) => { const user = ctx.get('user'); let salt = await getPairingSalt(user.id); if (!salt) { salt = randomUUID(); await setPairingSalt(user.id, salt); } const { token, port } = await ask<'relay:token'>('relay:token-add', { userId: user.id, salt }); return ctx.json({ token, port }); }); // Revoke and re-issue. The OLD token is unregistered first: the salt row is replaced by the write below, // so if this ran the other way round the relay would keep accepting the old one until it restarted. router.post('/relay-token/regenerate', async (ctx) => { const user = ctx.get('user'); const oldSalt = await getPairingSalt(user.id); if (oldSalt) await ask('relay:token-remove', { userId: user.id, salt: oldSalt }); const newSalt = randomUUID(); await setPairingSalt(user.id, newSalt); const { token, port } = await ask<'relay:token'>('relay:token-add', { userId: user.id, salt: newSalt }); return ctx.json({ token, port }); }); router.delete('/relay-token', async (ctx) => { const user = ctx.get('user'); const salt = await getPairingSalt(user.id); if (salt) { await ask('relay:token-remove', { userId: user.id, salt }); await clearPairing(user.id); } return ctx.json({ ok: true }); }); router.get('/targets', async (ctx) => { const user = ctx.get('user'); const { targets } = await ask<'relay:targets'>('relay:targets', { userId: user.id }); return ctx.json( targets.map((t) => ({ id: t.targetId, sessionId: t.sessionId, type: t.targetInfo.type ?? 'page', title: t.targetInfo.title ?? '', url: t.targetInfo.url ?? '', })), ); }); router.get('/targets/:id/screenshot', async (ctx) => { const user = ctx.get('user'); const targetId = ctx.req.param('id'); const format = (ctx.req.query('format') as 'png' | 'jpeg') || 'png'; const opts = await getOpts(user.id); // The session id, not the target id — CDP addresses a session. Asked of the relay, which is where the // live map is; this used to be an in-process lookup. const { targets } = await ask<'relay:targets'>('relay:targets', { userId: user.id }); const target = targets.find((t) => t.targetId === targetId); if (!target) throw errors.NOT_FOUND('Target not found'); try { const data = await captureScreenshot({ ...opts, targetId: target.sessionId }, format); return ctx.json({ data, format }); } catch (err) { throw errors.INTERNAL_SERVER_ERROR(err instanceof Error ? err.message : 'Screenshot failed'); } }); router.post('/targets/:id/evaluate', async (ctx) => { const user = ctx.get('user'); const targetId = ctx.req.param('id'); const body = ctx.get('body') as { expression?: string }; if (!body.expression) throw errors.BAD_REQUEST('expression is required'); const opts = await getOpts(user.id); const { targets } = await ask<'relay:targets'>('relay:targets', { userId: user.id }); const target = targets.find((t) => t.targetId === targetId); if (!target) throw errors.NOT_FOUND('Target not found'); try { const result = await evaluateJS({ ...opts, targetId: target.sessionId }, body.expression); return ctx.json({ result }); } catch (err) { throw errors.INTERNAL_SERVER_ERROR(err instanceof Error ? err.message : 'Evaluation failed'); } }); router.post('/targets/:id/navigate', async (ctx) => { const user = ctx.get('user'); const targetId = ctx.req.param('id'); const body = ctx.get('body') as { url?: string }; if (!body.url) throw errors.BAD_REQUEST('url is required'); const opts = await getOpts(user.id); const { targets } = await ask<'relay:targets'>('relay:targets', { userId: user.id }); const target = targets.find((t) => t.targetId === targetId); if (!target) throw errors.NOT_FOUND('Target not found'); try { await navigateTo({ ...opts, targetId: target.sessionId }, body.url); return ctx.json({ ok: true }); } catch (err) { throw errors.INTERNAL_SERVER_ERROR(err instanceof Error ? err.message : 'Navigation failed'); } }); router.post('/targets/:id/activate', async (ctx) => { const user = ctx.get('user'); const targetId = ctx.req.param('id'); const { relayPort, userToken } = await getOpts(user.id); try { const res = await fetch(`http://127.0.0.1:${relayPort}/json/activate/${encodeURIComponent(targetId)}`, { headers: { 'x-officer-relay-token': userToken }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return ctx.json({ ok: true }); } catch (err) { throw errors.INTERNAL_SERVER_ERROR(err instanceof Error ? err.message : 'Activate failed'); } }); router.post('/targets/:id/close', async (ctx) => { const user = ctx.get('user'); const targetId = ctx.req.param('id'); const { relayPort, userToken } = await getOpts(user.id); try { const res = await fetch(`http://127.0.0.1:${relayPort}/json/close/${encodeURIComponent(targetId)}`, { headers: { 'x-officer-relay-token': userToken }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return ctx.json({ ok: true }); } catch (err) { throw errors.INTERNAL_SERVER_ERROR(err instanceof Error ? err.message : 'Close failed'); } });