the extension door answered 500, because the shared proxy wants a user

`createSidecarProxy` is for AUTHENTICATED proxying. Its forwarder does
`ctx.get('user').id`, because every other sidecar behind it is reached by
somebody holding an Officer session. This door is declared in
`unauthenticatedPaths`, so there is no user, and mounting the factory's router
there answered every request with `TypeError: undefined is not an object` as a
500.

Found on alpha with a real request rather than by reading: the authenticated door
returned 401 as it should and the extension door returned 500.

Vault hand-writes its public router for exactly this reason. I reached for the
shared thing because it was shared, which is the wrong reason - it is shared
among routes that have a caller, and this one deliberately does not.

The factory is still constructed and still earns it: getHttpUrl(), so the port
the sidecar announced is learned in one place, and registerProxiedPrefix, which
runs at construction and keeps the body parser off /api/linkwarden/lw. Neither of
those is the router.
This commit is contained in:
2026-09-05 21:03:31 +01:00
parent c2850fe8ed
commit 09148130a8
+46 -1
View File
@@ -1,6 +1,7 @@
import { createRouter } from '@@/create-router';
import { createSidecarProxy } from '@@/sidecar/create-proxy';
import { getLinkwardenConfig, setLinkwardenConfig, getAccount } from '../db/queries';
import { stripHopByHop } from '../sidecar/upstream';
/**
* Mounted at `/api/linkwarden`.
@@ -32,7 +33,51 @@ const proxy = createSidecarProxy({
timeoutSeconds: 120,
});
router.route('/lw', proxy.router);
/*
── The factory's ROUTER is not used, and that is the whole reason this is written out ──
`createSidecarProxy` is for AUTHENTICATED proxying: its forwarder does `ctx.get('user').id`, because
every other sidecar behind it is reached by somebody with an Officer session. This door is declared in
`unauthenticatedPaths`, so there is no user, and mounting the factory's router here answered every
request with `TypeError: undefined is not an object` as a 500. Measured on alpha, not reasoned about.
Vault hand-writes its public router for exactly this reason and I should have followed it rather than
reaching for the shared thing because it was shared.
What the factory IS still used for, and why it is still constructed: `getHttpUrl()`, so the port the
sidecar announced is discovered in one place; and `registerProxiedPrefix`, which runs at construction
and tells the body parser to leave `/api/linkwarden/lw` alone. Both matter, and neither is the router.
*/
router.all('/lw/*', async (ctx) => {
const base = proxy.getHttpUrl();
if (!base) return ctx.text('the Linkwarden sidecar is not running', 503);
const url = new URL(ctx.req.url);
const subpath = url.pathname.slice('/api/linkwarden/lw'.length) || '/';
/*
Authorization is FORWARDED, not replaced — the opposite of the door below.
The client's own credential is the only one in play here, and Linkwarden is the only thing entitled
to judge it. Officer adds no security to a stock extension because it has nothing to check: this is
the same exposure through one address instead of two, which is what lets Linkwarden stop listening
on its own.
*/
const hasBody = ctx.req.method !== 'GET' && ctx.req.method !== 'HEAD';
const init: RequestInit & { duplex?: 'half' } = {
method: ctx.req.method,
headers: stripHopByHop(ctx.req.raw.headers),
redirect: 'manual',
...(hasBody ? { body: ctx.req.raw.body, duplex: 'half' as const } : {}),
};
try {
const upstream = await fetch(`${base}${subpath}${url.search}`, init);
return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) });
} catch {
return ctx.text('Linkwarden unreachable', 502);
}
});
// ── where the instance is ────────────────────────────────────────────────────────────────────────