The route I added returned 401 to an <a download>, which Chrome reports as "Try to sign in to the site" — a plain navigation carries no Bearer token, so that advice cannot work. The platform publishes extension/ to /plugins/browser/extension.zip instead, which is served without auth like plugin icons. zip.ts moved to the platform: extensions are meant to be a marketplace of them, not this plugin's private trick. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
202 lines
7.9 KiB
TypeScript
202 lines
7.9 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
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 is NOT served from here. It is published at install time to
|
|
// `/plugins/browser/extension.zip`, which is unauthenticated on purpose — an `<a download>` cannot carry
|
|
// a Bearer token, and the browser reports the resulting 401 as "Try to sign in to the site", which is
|
|
// advice that cannot work. Nothing in an extension is secret; the pairing token is what is secret, and
|
|
// that is not in there.
|
|
|
|
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');
|
|
}
|
|
});
|