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.
7.2 KiB
Gitea — the extraction record
Extracted on 2026-08-19, the eleventh plugin out — and the first that is not a move.
plugins/EXTRACTING-A-PLUGIN.md is the runbook for the ordinary case; this was not it.
The old UI was deleted, not moved
Seventeen files under apps/Gitea/ reimplemented a fraction of an interface Gitea already has, and every
one of them was a promise to keep pace with a project that moves faster than this plugin will. They were
deleted rather than carried across.
That decision removed a platform change nobody has to build now. useGiteaLocation read
:owner/:name/:tab/:item from five route patterns; a plugin gets two, so extracting as-is would have
required teaching the plugin system to declare extra route patterns. Nothing inside an iframe needs a
route, so the requirement evaporated with the screen.
There was no regression window either: gitea's router had been commented out of hono.ts since
2026-08-13 with api: [], and officer-gitea was not running anywhere. The screen rendered and every
request 404'd. Nothing working was lost because nothing was working.
Why a proxy, and not simply an iframe
Officer and Gitea are different registrable domains. An iframe pointed straight at gitea.officer.dev
gets a login page whose session cookie the browser discards as third-party — a frame that can never
stay signed in.
Served through Officer's own origin, the cookie is first-party and survives. X-Frame-Options and
Content-Security-Policy are stripped on the way through, which is safe only because the frame is
the same origin the browser has already authenticated.
Whose Gitea account you are looking at
Your own. /_web/* forwards the browser's own cookies and injects nothing.
That is the one thing here not to get wrong. The sidecar holds an API token, and injecting it into the
web proxy would show every user the OWNER'S view of every repository. The token is used only by
/_api/*, for the small things around the frame.
The consequence is that each person signs in to Gitea themselves, once, inside the frame. Gitea's
reverse-proxy authentication (ENABLE_REVERSE_PROXY_AUTHENTICATION, the same shape as Radicale's
http_x_remote_user) would remove that step, and is deliberately not used: it trusts a header
completely, and this instance is network-reachable rather than loopback-only.
Two doors
| gated by | used by | |
|---|---|---|
/_config, /_health, /_api/* |
the platform's own gate | the panel |
/_web/* |
a minted, path-scoped cookie | the iframe |
An iframe's sub-resources — every stylesheet, script and avatar — are fetched with no Authorization
header, so /_web/* is declared in unauthenticatedPaths and authenticates itself.
The cookie holds 32 random bytes held in memory against the user who asked for it, not dev-proxy's
constant =1, which anyone who knows the cookie name can set. use() verifies it and sets user on the
context — not a convenience: createSidecarProxy reads ctx.get('user').id to fill X-Officer-User,
and the sidecar refuses a request without it. On this path the platform's gate never ran, so the cookie
is where the account comes from.
What that protects is narrow, and worth stating plainly: Gitea authenticates the person itself, so an
unauthenticated caller reaching /_web/ sees a login page. This stops Officer being used as an open
proxy to it. A manners problem, cheaply fixed.
The rewriter
Gitea believes it lives at /. Its pages are full of root-absolute links — /user/login,
/assets/index.js — and inside the frame those resolve against Officer's origin. The page renders
perfectly and falls apart on the first click.
sidecar/rewrite.ts handles four places a URL hides, and they fail differently:
| why it matters | |
|---|---|
href / src / action attributes |
the ordinary case: clicks and assets |
window.config.appSubUrl |
the highest-value line here — Gitea's own client JS prefixes every URL it builds with this, so filling it in makes Gitea correct itself rather than by interception |
Location headers |
the most visible failure: Gitea redirects after every login and form post, and an unrewritten one navigates the frame out of the proxy |
absolute URLs built from ROOT_URL |
the most damaging: they leave for a third-party origin where the session cookie is dropped, so the user is silently signed out |
That last one needs to know 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 — appSubUrl removes the need. And clone URLs, which must keep
working for whoever copies them.
Cookies get their Path narrowed to the frame so Gitea's session stays out of the platform's API.
Secure is left exactly as Gitea set it: adding it breaks a plain-http install, removing it weakens one
that meant it.
Eighteen tests cover it, and they are the only part of this plugin that can be verified 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.
The frame is forwarded by hand
/_web/* does NOT go through createSidecarProxy. That forwarder passes exactly three headers —
content-type, range, if-none-match — and its restraint is what makes it safe to share between every
sidecar. It strips cookies, and this path is nothing but cookies. Loosening it for gitea would loosen
it for the wallet, so this one route forwards the request itself. Everything else still uses the shared
proxy.
What moved
| Was | Is now |
|---|---|
src/servers/sidecar/gitea/* (2 files) |
sidecar/* — plus the new /_web proxy |
src/servers/api/gitea/router.ts |
api/router.ts — plus the cookie door |
src/workspaces/officerdev/src/apps/Gitea/* (17 files) |
deleted |
src/apps/officer-web/Screens/Dashboard/Gitea/* |
deleted |
| — | web/GiteaFrame.tsx, one panel |
It also stopped being a core dock item. Gitea was hardcoded in CORE_DOCK_ITEMS, the only
installable feature that was; its tile now arrives from assets/icon.png like every other plugin's.
Not self-contained
The connection lives in core service_connections, and 'gitea' stays in the platform's ServiceName
union — the same coupling transmission, soulseek and the wallet have. Gitea is the reason that union has
the "member rows carry only a token and inherit the owner's URL" shape at all.
Affordance
Ships one, in affordances/: use plain git for what git already does, and tea for pull requests,
issues and comments. Nothing copies it into capabilities/affordances/ yet — plugin contribution is
listed in TODO.md and gitea is its first customer.
tea itself is not provisioned per user yet either. Note the trap for whoever does it: the Debian
package named tea is a graphical text editor, not Gitea's CLI, and Bun.which('tea') cannot tell them
apart.