Author SHA1 Message Date
pastilhas c480facadc manifest names the release it is 2026-09-05 21:05:13 +01:00
pastilhas ed40b6798c a gzipped response was handed on still labelled gzipped
`fetch` decompresses a response body before anyone sees it, and the headers it
returns still say `content-encoding: gzip`. Copying those onto the decoded bytes
tells the client to gunzip plain JSON: `Decompression error: ZlibError`, which
surfaced as a 502 from the catch that wraps the forward.

It hides well, and that is the part worth writing down. A 401 body is a few bytes
and is not compressed, so every unauthenticated probe passes. The first request
carrying a real credential is the first one big enough to be gzipped, and the
first one to fail - which reads as "the credential broke it".

Measured on alpha in exactly that order: the extension door answered 401 without
a token and 502 with one.

`content-length` goes too. It describes the compressed length and the body being
handed on is not that length.

Response headers are now a different function from request headers, because they
are a different question and treating them as one is what caused this.
2026-09-05 21:05:06 +01:00
pastilhas b820317916 manifest names the release it is 2026-09-05 21:03:38 +01:00
pastilhas 09148130a8 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.
2026-09-05 21:03:31 +01:00
4 changed files with 73 additions and 5 deletions
+47 -2
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, stripResponseHeaders } 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: stripResponseHeaders(upstream.headers) });
} catch {
return ctx.text('Linkwarden unreachable', 502);
}
});
// ── where the instance is ────────────────────────────────────────────────────────────────────────
@@ -129,7 +174,7 @@ router.all('/api/*', async (ctx) => {
try {
const upstream = await fetch(`${base}${subpath}${url.search}`, init);
return new Response(upstream.body, { status: upstream.status, headers: upstream.headers });
return new Response(upstream.body, { status: upstream.status, headers: stripResponseHeaders(upstream.headers) });
} catch {
return ctx.json({ error: 'Linkwarden unreachable' }, 502);
}
+1 -1
View File
@@ -32,7 +32,7 @@ import type { PluginManifest } from '@@/plugins/manifest';
*/
export const manifest: PluginManifest = {
publisher: 'officerdev',
version: '2026.09.05.01',
version: '2026.09.05.03',
platform: '>=1.0.0',
label: 'Linkwarden',
+2 -2
View File
@@ -1,6 +1,6 @@
import { createSidecarConnector } from '@@/sidecar/connect';
import { API_URL } from '@@/officer-url.mjs';
import { getLinkwardenBase, stripHopByHop, redactPath } from './upstream';
import { getLinkwardenBase, stripHopByHop, stripResponseHeaders, redactPath } from './upstream';
// The officer-linkwarden sidecar: a singleton process that registers with the API server and owns a
// transparent reverse-proxy to a self-hosted Linkwarden. The platform API is a thin forwarder to this;
@@ -86,7 +86,7 @@ Bun.serve({
return new Response(upstream.body, {
status: upstream.status,
headers: stripHopByHop(upstream.headers),
headers: stripResponseHeaders(upstream.headers),
});
},
});
+23
View File
@@ -79,6 +79,29 @@ export function stripHopByHop(src: Headers): Headers {
return out;
}
/**
* Headers for a response we are handing back, which is NOT the same as headers for a request.
*
* ── `content-encoding` has to go, and finding out why cost a 502 ──
*
* `fetch` decompresses a gzipped response body before anyone sees it. The headers it returns still say
* `content-encoding: gzip`, so copying them onto the decoded bytes tells the client to gunzip plain
* JSON — `Decompression error: ZlibError`, surfacing as a 502 from the catch below.
*
* It hides well. A 401 body is a few bytes and is not compressed, so every unauthenticated probe passes
* and the first REAL request is the one that fails. Measured exactly that way on alpha: the door
* answered 401 correctly without a token and 502 with one.
*
* `content-length` goes with it for the same reason — it describes the compressed length, and the body
* being handed on is not that length.
*/
export function stripResponseHeaders(src: Headers): Headers {
const out = stripHopByHop(src);
out.delete('content-encoding');
out.delete('content-length');
return out;
}
/*
Paths are logged; tokens are not.