the extension download is a published file, not an authed route
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>
This commit is contained in:
+5
-25
@@ -1,6 +1,4 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { join } from 'node:path';
|
|
||||||
import { zipDirectory } from './zip';
|
|
||||||
import { createRouter } from '@@/create-router';
|
import { createRouter } from '@@/create-router';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
import { sendSidecarCommand } from '@@/sidecar-registry';
|
import { sendSidecarCommand } from '@@/sidecar-registry';
|
||||||
@@ -46,29 +44,11 @@ async function getOpts(userId: number) {
|
|||||||
return { relayPort: RELAY_PORT, userToken: deriveRelayToken(userId, RELAY_PORT, salt) };
|
return { relayPort: RELAY_PORT, userToken: deriveRelayToken(userId, RELAY_PORT, salt) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── The extension, zipped from the directory on every request ──
|
// 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
|
||||||
// This route existed once, shelling out to `zip`, and was removed as having no caller: the UI linked at
|
// a Bearer token, and the browser reports the resulting 401 as "Try to sign in to the site", which is
|
||||||
// `/browser-relay-extension.zip`, a static file in public/. That file was then found to be 58 lines
|
// advice that cannot work. Nothing in an extension is secret; the pairing token is what is secret, and
|
||||||
// behind the source it claimed to be — built from something older and never rebuilt.
|
// that is not in there.
|
||||||
//
|
|
||||||
// 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) => {
|
router.get('/status', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
|
|||||||
-122
@@ -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);
|
|
||||||
}
|
|
||||||
Submodule
+1
Submodule plugins/browser added at c0e0848e6a
@@ -5,9 +5,11 @@ import { useState } from 'react';
|
|||||||
// pairing, calling /browser/relay-token, which worked only because the plugin happened to serve that
|
// 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.
|
// 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
|
// 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
|
// regenerated and that was 58 lines behind the source. The installer now builds it from this plugin's
|
||||||
// from the extension directory per request.
|
// `extension/` directory and publishes it to /plugins/browser/extension.zip, which is served WITHOUT
|
||||||
|
// authentication — deliberately. An `<a download>` 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Copy, Check, Download, ExternalLink, RefreshCw, Trash2 } from 'lucide-react';
|
import { Copy, Check, Download, ExternalLink, RefreshCw, Trash2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -97,7 +99,7 @@ export const PairingPanel = () => {
|
|||||||
Download and unzip the extension, then load it in Chrome:
|
Download and unzip the extension, then load it in Chrome:
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="/api/browser/extension.zip"
|
href="/plugins/browser/extension.zip"
|
||||||
download
|
download
|
||||||
className="flex items-center justify-center gap-2 w-full h-11 rounded-md border border-duck-dark/15 dark:border-foreground/15 text-sm font-medium hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors"
|
className="flex items-center justify-center gap-2 w-full h-11 rounded-md border border-duck-dark/15 dark:border-foreground/15 text-sm font-medium hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user