diff --git a/api/router.ts b/api/router.ts index b0cf459..9a997b3 100644 --- a/api/router.ts +++ b/api/router.ts @@ -1,6 +1,4 @@ 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'; @@ -46,29 +44,11 @@ async function getOpts(userId: number) { 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', - }, - }); -}); +// The extension is NOT served from here. It is published at install time to +// `/plugins/browser/extension.zip`, which is unauthenticated on purpose — an `` 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'); diff --git a/api/zip.ts b/api/zip.ts deleted file mode 100644 index 28c83eb..0000000 --- a/api/zip.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { readdirSync, readFileSync, statSync } from 'node:fs'; -import { join, relative } from 'node:path'; - -// A ZIP writer, in about seventy lines, so the extension download can never be stale. -// -// ── Why this exists rather than a file in public/ ── -// -// It was a file in public/: `browser-relay-extension.zip`, built by hand and served as a static asset. -// On 2026-08-21 it was found to be 58 lines behind the source it claimed to be — built in August from -// something that predated a February commit, and never rebuilt since. Anyone who downloaded it got an -// extension missing the multi-session recovery path, which is exactly the failure they would then hit. -// -// A snapshot drifts because nothing makes it not drift. Building from the directory at request time -// removes the class: there is no second copy to fall behind. -// -// ── Why hand-rolled, and why STORED rather than deflated ── -// -// The router's own history says the previous version of this shelled out to `zip`. That works and it -// makes the plugin need a host binary for a convenience feature — this manifest's "nothing to declare" -// is worth more than the compression. Bun ships no zip writer, and the stored format is genuinely small: -// a local header per file, a central directory, and an end record. -// -// Chrome accepts stored entries. The extension is ~50 KB of text; deflate would save perhaps 30 KB on a -// download that happens once. - -/** CRC-32, the one thing in the format that is not just bytes in order. */ -function crc32(bytes: Uint8Array): number { - let crc = 0xffffffff; - for (const byte of bytes) { - crc ^= byte; - for (let i = 0; i < 8; i++) crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; - } - return (crc ^ 0xffffffff) >>> 0; -} - -type Entry = { name: string; data: Uint8Array; crc: number; offset: number }; - -const u16 = (v: number) => [v & 0xff, (v >>> 8) & 0xff]; -const u32 = (v: number) => [v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff]; - -/** Every file under `dir`, depth first, with paths relative to it. */ -function collect(dir: string, root = dir): { name: string; data: Uint8Array }[] { - const out: { name: string; data: Uint8Array }[] = []; - for (const item of readdirSync(dir).sort()) { - const path = join(dir, item); - if (statSync(path).isDirectory()) out.push(...collect(path, root)); - else out.push({ name: relative(root, path), data: new Uint8Array(readFileSync(path)) }); - } - return out; -} - -/** - * Zip a directory, stored, with a fixed timestamp. - * - * The timestamp is fixed (1980-01-01, the format's own zero) so the same directory always produces - * byte-identical output. That makes the download cacheable and, more usefully, makes "did this change" - * answerable by comparing two files rather than unpacking them. - */ -export function zipDirectory(dir: string): Uint8Array { - const files = collect(dir); - const parts: number[] = []; - const entries: Entry[] = []; - - for (const file of files) { - const nameBytes = [...new TextEncoder().encode(file.name)]; - const crc = crc32(file.data); - entries.push({ ...file, crc, offset: parts.length }); - parts.push( - ...u32(0x04034b50), // local file header - ...u16(20), // version needed - ...u16(0), // flags - ...u16(0), // method 0 = stored - ...u16(0), // time - ...u16(0x0021), // date — 1980-01-01 - ...u32(crc), - ...u32(file.data.length), // compressed size == uncompressed, stored - ...u32(file.data.length), - ...u16(nameBytes.length), - ...u16(0), // extra length - ...nameBytes, - ...file.data, - ); - } - - const centralStart = parts.length; - for (const entry of entries) { - const nameBytes = [...new TextEncoder().encode(entry.name)]; - parts.push( - ...u32(0x02014b50), // central directory header - ...u16(20), // version made by - ...u16(20), // version needed - ...u16(0), - ...u16(0), - ...u16(0), - ...u16(0x0021), - ...u32(entry.crc), - ...u32(entry.data.length), - ...u32(entry.data.length), - ...u16(nameBytes.length), - ...u16(0), // extra - ...u16(0), // comment - ...u16(0), // disk - ...u16(0), // internal attrs - ...u32(0), // external attrs - ...u32(entry.offset), - ...nameBytes, - ); - } - - parts.push( - ...u32(0x06054b50), // end of central directory - ...u16(0), - ...u16(0), - ...u16(entries.length), - ...u16(entries.length), - ...u32(parts.length - centralStart), - ...u32(centralStart), - ...u16(0), // comment length - ); - - return new Uint8Array(parts); -} diff --git a/plugins/browser b/plugins/browser new file mode 160000 index 0000000..c0e0848 --- /dev/null +++ b/plugins/browser @@ -0,0 +1 @@ +Subproject commit c0e0848e6a7c7690af84f9ff2d297824a734e5ee diff --git a/web/PairingPanel.tsx b/web/PairingPanel.tsx index 0ab2669..914838e 100644 --- a/web/PairingPanel.tsx +++ b/web/PairingPanel.tsx @@ -5,9 +5,11 @@ import { useState } from 'react'; // pairing, calling /browser/relay-token, which worked only because the plugin happened to serve that // path. Same coupling the extraction removed everywhere else, and the last piece of it. // -// The download link changed with it: it pointed at a static zip in the platform's public/ that nothing -// regenerated and that was 58 lines behind the source. It now asks the plugin, which builds the archive -// from the extension directory per request. +// The download link changed with it. It pointed at a static zip in the platform's public/ that nothing +// regenerated and that was 58 lines behind the source. The installer now builds it from this plugin's +// `extension/` directory and publishes it to /plugins/browser/extension.zip, which is served WITHOUT +// authentication — deliberately. An `` is a plain navigation carrying no Bearer token, so an +// authenticated URL makes Chrome say "Try to sign in to the site", which is advice that cannot work. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Copy, Check, Download, ExternalLink, RefreshCw, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; @@ -97,7 +99,7 @@ export const PairingPanel = () => { Download and unzip the extension, then load it in Chrome: