Files
browser/api/router.ts
T
pastilhasandClaude Opus 5 c0e0848e6a pairing moves in, and the extension download stops being a snapshot
Two halves of the same coupling, both left behind by this morning's extraction.

The pairing UI was Settings/IntegrationsSettings/BrowserRelay.tsx — a PLATFORM screen driving this
plugin's pairing, calling /browser/relay-token, and working only because the plugin happens to serve
that path. It is a panel here now, beside the tab list rather than buried in settings: it shows
whether the extension is connected and how many tabs it holds, which is the first thing anyone looks
at when a capture does not work, and that is not configuration you set once.

The download link pointed at public/browser-relay-extension.zip, a static file. That file was 58 lines
behind the source it claimed to be — built in August from something that predated a February commit,
never rebuilt, and serving an extension missing the multi-session recovery path that the same February
commit added. Anyone downloading it got the version that cannot recover from exactly the failure they
were about to hit.

So the archive is built from the extension directory per request. There is no second copy to fall
behind, which is the only durable fix for that class.

zip.ts is hand-rolled and stored rather than deflated. The route's own history says it used to shell
out to `zip`; this manifest's "nothing to declare" is worth more than 30 KB on a download that happens
once, and Chrome accepts stored entries. Timestamps are fixed at the format's zero so the same
directory always produces identical bytes — which is what makes the ETag meaningful and "did this
change" answerable by comparing two files. Verified by unzipping the output and diffing background.js
against the source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:20:32 +00:00

222 lines
8.8 KiB
TypeScript

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<T extends BrowserEvent['type']>(
type: 'relay:status' | 'relay:targets' | 'relay:token-add' | 'relay:token-remove',
payload: Record<string, unknown>,
): Promise<Extract<BrowserEvent, { type: T }>> {
const reply = await sendSidecarCommand<BrowserEvent>(BROWSER_SIDECAR_KIND, { type, id: randomUUID(), ...payload });
if (reply.type === 'error') throw errors.INTERNAL_SERVER_ERROR(reply.error);
return reply as Extract<BrowserEvent, { type: T }>;
}
/**
* 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');
}
});