Every asset in the frame answered 502. `fetch()` decompresses transparently
while `upstream.headers` still claims content-encoding: gzip, so re-emitting
those headers with the decompressed body hands the next hop a lie and it dies
with ZlibError.
The HTML escaped it only because the sidecar deletes that header when it
rewrites — which is why the page rendered and every stylesheet, script and
image beside it failed.
Not asking for compression removes the mismatch rather than chasing it through
two hops. The cost is nil: this hop is loopback, and the browser's own
connection is compressed by whatever terminates TLS.
'gitea unreachable' on its own is not a diagnosis. The sidecar was answering
200 to a direct curl while the panel showed that message, which sent the search
to the wrong half of the system twice.
/session and /_web/* were both dead. createSidecarProxy registers all('/*')
when it is CALLED, so anything added to proxy.router afterwards never matches —
hono goes in registration order and the wildcard had already claimed
everything.
The symptom was `[gitea] proxy POST /session -> 500`: forwarded to a sidecar
with no such route, answered "internal error", and looking exactly like the
sidecar being broken. Which is what the panel showed.
A comment two lines above the bug claimed these were "registered BEFORE the
proxy's /*". They were not, and writing that down did not make it so.
Now a router of our own with the proxy's mounted under it at the end, so
everything above genuinely wins.
Gitea believes it lives at /. Its pages are full of root-absolute links, and
inside the frame those resolve against OFFICER'S origin — the page renders
perfectly and falls apart on the first click.
Four places a URL hides, each failing differently: href/src/action attributes,
window.config.appSubUrl, Location headers, and absolute URLs built from
ROOT_URL.
appSubUrl is the highest-value line. Gitea already supports being hosted under
a subpath and its client JS prefixes every URL it builds with that value, so
filling it in makes Gitea correct ITSELF rather than by interception.
The ROOT_URL case needs Gitea's public origin, which the sidecar does not have
— it fetches from loopback. Rather than add a second setting that can disagree
with Gitea's own, appUrlOrigin reads it out of the page: Gitea emits "appUrl"
in the same config blob as appSubUrl.
Not rewritten, deliberately: JavaScript bodies, because a blanket rewrite of
absolute paths inside a script corrupts strings that are not URLs; and clone
URLs, which must keep working for whoever copies them.
/_web/* now forwards by hand rather than through createSidecarProxy. That
forwarder passes three headers and nothing else, on purpose — it is shared by
every sidecar and its restraint is what makes it safe to reuse. It strips
cookies, and this path is nothing but cookies. Loosening it for gitea would
loosen it for the wallet.
Eighteen tests, the only part of this plugin verifiable without a running
Gitea. One caught a real bug: rewriteAbsolute turns a full URL into <base>/path
and the attribute pass then prefixed it again.
Eleventh plugin out, and the first that is not a move. The seventeen files that
reimplemented a fraction of Gitea's interface were deleted rather than carried
across — each one a promise to keep pace with a project that moves faster than
this plugin will.
That removed a platform change nobody now has to build. useGiteaLocation read
:owner/:name/:tab/:item from five route patterns and a plugin gets two, so
extracting as-is would have needed the plugin system to declare extra routes.
Nothing inside an iframe needs a route, so the requirement went with the screen.
No regression window: gitea's router had been commented out of hono.ts since
2026-08-13 with api: [], and officer-gitea was running nowhere. The screen
rendered and every request 404'd.
The proxy is what makes the iframe possible, not decoration. Officer and Gitea
are different registrable domains, so a frame pointed straight at the instance
gets a login page whose cookie the browser drops as third-party. Through
Officer's origin it is first-party and survives; X-Frame-Options and CSP are
stripped, safe only because the frame is the origin the browser already
authenticated.
/_web/* forwards the browser's own cookies and injects NOTHING. The sidecar
holds an API token and injecting it there would show every user the owner's
view of every repository. It is used only by /_api/*.
Two doors, because an iframe's sub-resources carry no Authorization header:
the ordinary routes go through the platform's gate, and /_web/* is declared
unauthenticated and authenticates itself with a minted, path-scoped cookie —
32 random bytes against the user who asked, not dev-proxy's constant =1 which
anyone knowing the cookie name can set. Verifying it also sets `user`, because
createSidecarProxy fills X-Officer-User from it and the sidecar refuses a
request without one.
Ships an affordance: git for what git does, tea for pull requests and issues.
Nothing copies it into capabilities/ yet — gitea is that mechanism's first
customer.