Files
gitea/sidecar/index.ts
T
officer 8125363e0b a real form, and the URL step no longer demands a token
Two things reported from a production instance.

The URL step was refused with "an access token is required the first time" — a
check that predates splitting the screen into two steps, and which made the
first step impossible to complete. Naming the instance is now its own step; a
member still needs a token, since setting one is all they do.

And the fields were a div, so pressing Enter did nothing and the button was the
only way to submit. That is a keyboard trap wearing a layout. Now a <form> with
onSubmit, with the other two buttons marked type="button" so they do not submit
it.

19 panel files across the platform have the same problem. Recorded in the
platform's TODO.md rather than swept blindly — a search box has no submit, and
a multi-field editor wants the useForm hook.

Also dropped the hint under the URL field, which repeated the sentence directly
above it.
2026-08-19 10:32:59 +00:00

460 lines
23 KiB
TypeScript

import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
import { createSidecarConnector } from '@@/sidecar/connect';
import { rewriteHtml, rewriteLocation, rewriteSetCookie } from './rewrite';
import {
getServiceConnection,
saveServiceConnection,
deleteServiceConnection,
recordServiceProbe,
getServiceInstanceUrl,
getOwnerUser,
} from 'officerdb';
import {
callGitea,
getGiteaConfig,
invalidateGiteaConfig,
invalidateAllGiteaConfigs,
normalizeBase,
probe,
reachable,
UnreadableCredentials,
} from './upstream';
import { readTeaLogin, readAnyTeaLogin, writeTeaLogin, osUserFor, testTeaLogin } from './tea-config';
import { API_URL } from '@@/officer-url.mjs';
// The officer-gitea sidecar. Owns the whole Gitea contract: the instance URL and the personal access
// token. The platform side is a thin auth-gated forwarder holding no Gitea credentials.
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// HTTP CONTRACT — the platform strips its /api/gitea mount prefix before forwarding.
//
// GET /_health is the instance up, and does the stored token work
// GET /_config the connection MINUS the token
// PUT/POST /_config { url, token } — validated live before it is stored
// DELETE /_config forget the connection
// ANY /_api/* forwarded to the instance's own /api/*, token attached
//
// That is the SINGLE-CONNECTION contract slskd and transmission already serve, matched deliberately so
// the UI can use the shared `useServiceConnection` / `useServiceConnectionActions` hooks rather than
// growing a fourth copy of them. Which is why PUT is accepted (the shared hook's `save` uses it), why a
// save answers `{ connection }`, and why DELETE answers the connection state rather than `{ ok }`.
// Gitea genuinely is one-of: nobody runs two.
//
// `/_api/*` is a deliberate pass-through rather than a hand-written wrapper per endpoint, for the same
// reason memos does it: Gitea's REST surface is enormous (its swagger is 850KB) and moves between minor
// versions, so re-describing it here would mean a second thing to keep in sync, and the UI is going to
// speak the upstream's shapes anyway. The allow-list below is the one piece of policy: it keeps the
// sidecar from being a general-purpose SSRF hop into whatever else is on that host.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// Everything under /api/v1 the UI legitimately needs.
//
// `/api/v1/admin/*` is excluded ON PURPOSE and should stay excluded. A Gitea token minted by a site
// admin carries the admin API with it — create and delete users, rewrite anyone's repositories, mint
// tokens for other accounts. None of that is a thing a notes-and-repos panel needs, and a token pasted
// into a connection form should not silently become a remote root shell for the instance. If an admin
// screen is ever genuinely wanted it gets its own reviewed entry here, not a widened prefix.
const ALLOWED = [
/^\/api\/v1\/version(\/|$|\?)/,
/^\/api\/v1\/user(\/|$|\?)/,
// Public profile data. Note this prefix also covers `/users/{name}/tokens`, which lists and CREATES
// access tokens — a token-minting door if it were reachable. It is not: Gitea requires basic auth there
// precisely to stop a token escalating its own scopes, and answers `401 auth required` to the
// `Authorization: token …` this sidecar sends. Verified against 1.27.0 on 2026-08-06. That protection is
// GITEA's, not ours, so if this ever needs re-checking on an upgrade, that is the endpoint to re-check.
/^\/api\/v1\/users(\/|$|\?)/,
/^\/api\/v1\/orgs(\/|$|\?)/,
/^\/api\/v1\/org(\/|$|\?)/,
/^\/api\/v1\/teams(\/|$|\?)/,
/^\/api\/v1\/repos(\/|$|\?)/,
/^\/api\/v1\/repositories(\/|$|\?)/,
/^\/api\/v1\/notifications(\/|$|\?)/,
/^\/api\/v1\/issues(\/|$|\?)/,
/^\/api\/v1\/packages(\/|$|\?)/,
// Instance metadata the UI reads to match the instance's own behaviour rather than guess it —
// default branch name, whether the markdown renderer allows raw HTML, which features are enabled.
/^\/api\/v1\/settings(\/|$|\?)/,
/^\/api\/v1\/topics(\/|$|\?)/,
/^\/api\/v1\/licenses(\/|$|\?)/,
/^\/api\/v1\/gitignore(\/|$|\?)/,
/^\/api\/v1\/label(\/|$|\?)/,
// Rendering. Gitea's own web UI renders a README through these, and matching it matters: the
// instance resolves relative links, #issue references and @mentions against the repo context, which a
// client-side markdown pass cannot do. Sanitisation is the instance's too — see renderMarkdown.
/^\/api\/v1\/markdown(\/|$|\?)/,
/^\/api\/v1\/markup(\/|$|\?)/,
];
function getFreePort(): number {
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
const p = probeServer.port;
probeServer.stop(true);
if (p == null) throw new Error('failed to acquire a free port');
return p;
}
const port = getFreePort();
const bad = (error: string, status = 400) => Response.json({ error }, { status });
/** Whoever owns the platform configures the instance; everyone else supplies only their own token. */
async function isOwner(userId: number): Promise<boolean> {
const owner = await getOwnerUser();
return !!owner && owner.id === userId;
}
// A member's row has no URL to return — it is genuinely not stored, not merely withheld — so this needs
// no per-caller filtering. `instanceConfigured` is what the UI needs instead: it distinguishes "you have
// not connected yet" from "there is nothing here to connect to", which are different screens.
//
// `instanceUrl` is the resolved base, returned to members as well as the owner. Not setting it is the
// invariant (a member naming the URL would be a per-user SSRF hop); not SEEING it never was, and could
// not be: every avatar_url and html_url the instance hands back is on that origin and gets rendered.
// The UI needs it as a value rather than as a thousand strings because this instance's ROOT_URL disagrees
// with its public host, so links have to be rebased client-side — see retargetUrls.
async function connectionState(userId: number): Promise<Response> {
// Asked first, and only for its failure: a row whose token cannot be decrypted must NOT be reported as
// configured. It would send the panel to the frame, which then throws on every request — which is
// exactly what happened, and it reads as the sidecar being broken rather than as a token needing
// re-entering.
try {
await getGiteaConfig(userId);
} catch (err) {
if (err instanceof UnreadableCredentials) {
return Response.json({
configured: false,
unreadable: true,
isOwner: await isOwner(userId),
error: err.message,
});
}
throw err;
}
const connection = await getServiceConnection(userId, 'gitea');
const instanceUrl = await getServiceInstanceUrl('gitea');
// The token's home is `tea`'s own config, not the database — see tea-config.ts. So "configured" for a
// MEMBER means tea has a login for this instance; the row only carries the URL, which is the owner's.
//
// Read rather than asked for: on a machine where the owner and their members already ran `tea login`
// by hand, the settings page should say "connected" instead of demanding something they have.
// With an instance known, ask about THAT one. Without, offer whatever tea already has — a machine
// where somebody ran `tea login` by hand knows the URL and holds a working token, and asking them to
// retype it is asking for something the box can already see.
const tea = instanceUrl ? await readTeaLogin(userId, instanceUrl) : await readAnyTeaLogin(userId);
const osUser = await osUserFor(userId);
return Response.json({
configured: !!instanceUrl && !!tea?.hasToken,
connection,
instanceUrl,
instanceConfigured: !!instanceUrl,
isOwner: await isOwner(userId),
// What tea already knows, so the page can say who you are without a round trip to Gitea.
// `url` included so the form can offer it when the platform has no instance yet.
tea: tea ? { name: tea.name, url: tea.url, user: tea.user, hasToken: tea.hasToken } : null,
// Null means never provisioned an OS account, so there is nowhere to write a config. The page says
// so rather than failing on save.
canStoreToken: !!osUser || (await isOwner(userId)),
});
}
async function handleConfig(req: Request, userId: number): Promise<Response> {
if (req.method === 'GET') return connectionState(userId);
if (req.method === 'DELETE') {
const owner = await isOwner(userId);
await deleteServiceConnection(userId, 'gitea');
// The owner disconnecting removes the instance every member's row inherits, so every cached config
// is now wrong — they must all resolve to null rather than keep talking to a server the platform no
// longer claims. A member disconnecting only affects themselves.
if (owner) invalidateAllGiteaConfigs();
else invalidateGiteaConfig(userId);
return connectionState(userId);
}
if (req.method === 'PUT' || req.method === 'POST') {
const body = ((await req.json().catch(() => null)) as { url?: unknown; token?: unknown } | null) ?? {};
// A blank token on a save means "keep the stored one", so the URL can be corrected without the
// token being re-pasted. There is no way to read it back out to re-send it.
const token = typeof body.token === 'string' && body.token.trim() ? body.token.trim() : null;
const owner = await isOwner(userId);
// The instance is the owner's; the token is each person's own. Which half of the form you get is
// decided here rather than in the UI, because a UI that hides a field is a suggestion and this is a
// rule: a member who could name the URL would have a per-user SSRF hop behind a settings form.
let base: string;
if (owner) {
const url = typeof body.url === 'string' ? normalizeBase(body.url) : '';
if (!url) return bad('url is required');
if (!/^https?:\/\//i.test(url)) return bad('url must start with http:// or https://');
base = url;
} else {
// Rejected, not ignored. Ignoring it would let a member believe they had pointed Gitea somewhere
// and leave them debugging a screen that is quietly talking to a different server.
if (body.url !== undefined) return bad('only the server owner can set the Gitea instance URL', 403);
const instance = await getServiceInstanceUrl('gitea');
if (!instance) return bad('the server owner has not connected a Gitea instance yet', 409);
base = instance;
}
const existing = await getServiceConnection(userId, 'gitea');
// NOT required on the first save any more. Naming the instance is its own step — the owner says
// where Gitea is, and the token is asked for (or found in tea's config) afterwards. This check
// predates that split and refused the URL step outright.
//
// A member is different: they set nothing but a token, so an empty save from them means nothing.
if (!owner && !token && !(await readTeaLogin(userId, base))?.hasToken) {
return bad('an access token is required');
}
// Probe with the credentials as they WILL be, so a save either stores something that works or
// fails with the reason. Storing first and discovering later makes every downstream screen fail
// mysteriously instead. For a member this also proves their token is valid against the shared
// instance, which is the only thing that can be wrong on their side.
// Setting the INSTANCE is its own step, and it must not demand a token: the owner names where Gitea
// is first, and only then does the page ask for — or discover — a token for it. Probing with one
// when there is none would refuse a perfectly good URL for the wrong reason.
const current = token ? null : await getGiteaConfig(userId);
const candidate = token ?? current?.token ?? null;
const result = candidate
? await probe({ id: existing?.id ?? 0, base, token: candidate })
: await reachable(base);
if (!result.ok) return bad(result.error ?? 'could not reach the instance');
// THE TOKEN GOES TO `tea`, NOT THE DATABASE.
//
// It used to be stored encrypted in `service_connections`, and nothing needs it there any more: the
// UI is an iframe carrying the browser's own Gitea session, so the only consumer left was this
// probe. `tea` keeps its own copy regardless — it is what the agent uses, and what this plugin's
// affordance tells the agent to use — so storing a second one is a drift waiting to happen: rotate
// with `tea login` and the database quietly keeps the dead one.
//
// Written as the account, so a member's token lands in a home only they can read.
if (token) {
const written = await writeTeaLogin(userId, base, token);
if (!written.ok) return bad(`could not store the token for tea: ${written.error}`);
}
const connection = await saveServiceConnection({
userId,
service: 'gitea',
// Null for anyone but the owner: their row is a credential, and inherits the instance on read.
url: owner ? base : null,
// Deliberately NOT stored. `secret: null` clears anything an earlier version of this wrote, so a
// machine upgraded into this design stops carrying a copy it no longer reads.
secret: null,
version: result.version,
});
// An owner's save may have moved the instance, and every member's cached base was resolved from it.
// Clearing only the caller would leave them pointed at the previous host until this process restarts.
if (owner) invalidateAllGiteaConfigs();
else invalidateGiteaConfig(userId);
return Response.json({ connection, user: result.user, version: result.version });
}
return bad('method not allowed', 405);
}
const server = Bun.serve({
port,
hostname: '127.0.0.1',
// Release attachments and file uploads through the contents API.
maxRequestBodySize: 64 * 1024 * 1024,
async fetch(req) {
const url = new URL(req.url);
const officerUser = req.headers.get('X-Officer-User');
const userId = Number(officerUser);
if (!officerUser || !Number.isInteger(userId) || userId <= 0) {
return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 });
}
try {
if (url.pathname === '/_config') return await handleConfig(req, userId);
const config = await getGiteaConfig(userId);
// Prove the token works, by asking `tea` to use it as this user. Deliberately not a call the
// platform makes with a token it read: the point is to test what the AGENT will get.
if (url.pathname === '/_test') {
return Response.json(await testTeaLogin(userId));
}
if (url.pathname === '/_health') {
if (!config) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 });
const started = Date.now();
const result = await probe(config);
const ms = Date.now() - started;
if (!result.ok) {
return Response.json({ ok: false, configured: true, error: result.error, ms }, { status: 502 });
}
await recordServiceProbe(userId, 'gitea', result.version);
return Response.json({ ok: true, configured: true, user: result.user, version: result.version, ms });
}
// ── The web UI, proxied for the iframe ──
//
// Everything above speaks Gitea's JSON API with the OWNER'S stored token. This does the opposite:
// it forwards the browser's own request, untouched, and injects nothing.
//
// That is deliberate. Gitea's web interface authenticates with its own session cookie, not with an
// API token, and the person looking at the iframe signs in to Gitea as themselves. Injecting the
// stored token here would silently show every user the OWNER'S view of every repository — the one
// failure this whole plugin must not have.
//
// Same-origin is what makes it work at all: the browser is on the platform's origin, so Gitea's
// cookie is first-party and survives. Pointed at gitea.officer.dev directly, an iframe gets a login
// page whose cookie the browser discards.
if (url.pathname.startsWith('/_web')) {
if (!config) return new Response('gitea not connected', { status: 503 });
const subpath = url.pathname.slice('/_web'.length) || '/';
// Hop-by-hop headers and the platform's own auth are dropped; everything else — cookies above
// all — goes through verbatim.
const headers = new Headers(req.headers);
for (const h of ['host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'authorization']) {
headers.delete(h);
}
headers.delete('x-officer-user');
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
let upstream: Response;
try {
upstream = await fetch(`${config.base}${subpath}${url.search}`, {
method: req.method,
headers,
body: hasBody ? await req.arrayBuffer() : undefined,
// MANUAL: a 302 to /user/login is meaningful to the iframe and must reach it as a redirect,
// not be followed here into a page the browser never asked for.
redirect: 'manual',
signal: AbortSignal.timeout(30_000),
});
} catch {
return new Response('gitea unreachable', { status: 502 });
}
const out = new Headers(upstream.headers);
// Gitea refuses to be framed by default, and the whole point here is to frame it. Safe because
// the frame is the platform's own origin, which the browser has already authenticated.
out.delete('x-frame-options');
out.delete('content-security-policy');
for (const h of ['connection', 'keep-alive', 'transfer-encoding']) out.delete(h);
// CONTENT-ENCODING ALWAYS, not just when rewriting.
//
// Bun's `fetch` sets its own Accept-Encoding and decompresses the response transparently, so
// `upstream.body` is plain while `upstream.headers` still says gzip. Passing both on hands the
// next hop a body that does not match its own header, and it dies with ZlibError.
//
// This was fixed for the HTML branch below and missed here, so the PAGE rendered while every
// stylesheet, script and image beside it answered 502 — which looks like a broken asset pipeline
// rather than one header.
out.delete('content-encoding');
out.delete('content-length');
// Where the browser thinks this is. Passed by the plugin's router rather than hardcoded — the
// sidecar has no business knowing how the platform routes to it, and `mountPrefix` is the only
// thing entitled to decide.
const base = req.headers.get('X-Officer-Frame-Base');
const origins = [config.base];
if (base) {
const location = out.get('location');
if (location) out.set('location', rewriteLocation(location, base, origins));
// Every Set-Cookie, not just the first: Gitea sets a session and a CSRF cookie together, and
// `Headers.get` would concatenate them into one broken value.
const cookies = (out as unknown as { getSetCookie?: () => string[] }).getSetCookie?.() ?? [];
if (cookies.length) {
out.delete('set-cookie');
for (const c of cookies) out.append('set-cookie', rewriteSetCookie(c, base));
}
// HTML only. Rewriting JavaScript hits strings that are not URLs and corrupts the file —
// `appSubUrl`, patched below by the same pass, is what makes Gitea's own scripts correct.
if ((out.get('content-type') ?? '').includes('text/html')) {
const body = rewriteHtml(await upstream.text(), base, origins);
// `content-encoding` and `content-length` are already gone, dropped for every response
// above — the body changing length here is one more reason they have to be.
return new Response(body, { status: upstream.status, headers: out });
}
}
return new Response(upstream.body, { status: upstream.status, headers: out });
}
if (url.pathname.startsWith('/_api/')) {
if (!config) return Response.json({ error: 'gitea not connected', configured: false }, { status: 503 });
const subpath = url.pathname.slice('/_api'.length) + url.search;
if (!ALLOWED.some((re) => re.test(subpath))) return bad('path not allowed', 403);
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
const upstream = await callGitea(config, subpath, {
method: req.method,
headers: req.headers.get('content-type') ? { 'content-type': req.headers.get('content-type')! } : {},
body: hasBody ? await req.arrayBuffer() : undefined,
});
return new Response(upstream.body, {
status: upstream.status,
headers: { 'content-type': upstream.headers.get('content-type') ?? 'application/json' },
});
}
} catch (err) {
// Path only. A repository body is the owner's private code.
console.error(`[gitea] ${req.method} ${url.pathname} failed`, String(err));
// Named, because the remedy is specific and the generic message sends people to the logs. This one
// is not a fault: a token was encrypted under a key that no longer exists.
if (err instanceof UnreadableCredentials) {
return Response.json({ error: err.message, unreadable: true }, { status: 409 });
}
return Response.json({ error: 'internal error' }, { status: 500 });
}
return Response.json({ error: 'not found' }, { status: 404 });
},
});
console.log(`[gitea] listening on 127.0.0.1:${port} (instance configured from the UI, stored in service_connections)`);
type ReplyFn = (msg: SidecarEvent) => void;
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
switch (cmd.type) {
case 'ping':
reply({ type: 'pong', id: cmd.id });
break;
default:
reply({
type: 'error',
id: (cmd as SidecarCommand).id,
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
});
}
}
const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`,
name: 'gitea',
handles: ['gitea'],
onCommand(cmd, reply) {
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
},
onConnected() {
connection.send({ type: 'gitea:server', port });
console.log(`[gitea] reported server port ${port} to API`);
},
});
function shutdown(signal: string) {
console.log(`[gitea] ${signal} received, shutting down...`);
try {
server.stop(true);
} catch {
/* already stopped */
}
connection.destroy();
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));