gitea: the plugin, rendering Gitea's own interface

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.
This commit is contained in:
officer
2026-08-19 07:58:55 +00:00
parent fbc85acaca
commit f680e25f82
10 changed files with 867 additions and 17 deletions
+91
View File
@@ -0,0 +1,91 @@
# 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.
## 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.
+23 -17
View File
@@ -1,26 +1,32 @@
# Gitea
Gitea inside Officer — repositories, issues and pull requests.
Your own [Gitea](https://about.gitea.com) inside Officer — repositories, issues and pull requests,
rendered as the real thing rather than a copy of it.
**Not built yet.** This repository exists so artwork can land before the code does.
## What you get
## Assets
Gitea's own web interface, in an Officer panel. Not a reimplementation: the actual application, proxied
through Officer so it stays signed in, with an **open in a new tab** button for anything the frame
handles badly.
```
assets/icon.png the dock tile
assets/splash.png the launch scene, for the mobile app and the credits screen
```
## Requirements
Presence is the declaration — no manifest field. `discover.ts` reads those two exact paths, and
installing publishes the whole directory to `/plugins/gitea/`. A shipped `icon.png` wins over the
manifest's lucide glyph automatically.
A Gitea instance Officer can reach.
## What this plugin will be
## Setup
Unlike the other plugins, the UI is not a set of panels over a JSON API. Gitea already has a web
interface, so Officer proxies it and renders it in an iframe — same-origin, because a third-party iframe
cannot keep a session cookie.
Install the plugin, open **/gitea**, and set the instance URL and an API token in the connection panel.
Then sign in to Gitea inside the frame, once — the frame shows **your** Gitea account, not the owner's.
It will also ship an affordance telling agents to use plain `git` for what git already does, and `tea`
for pull requests, issues and comments — `tea` being logged in as the current user, with that user's own
permissions.
The stored token is used only for the small things around the frame. Everything you see in it is your own
session.
## For agents
This plugin ships an affordance: use plain `git` for clone, fetch, pull and push; use `tea` for pull
requests, issues, comments and releases. `tea` is logged in as you, with your permissions.
## Permissions
One permission, `gitea`, grantable per role like any other app permission. Each account reaches its own
Gitea identity.
+12
View File
@@ -0,0 +1,12 @@
---
name: Gitea
description: Use when working with git repositories, or with Gitea issues and pull requests.
version: 1
---
For anything git already does — clone, fetch, pull, push, branches, tags — use plain `git`. It talks to
the server directly.
For anything git does not — pull requests, issues, comments, releases, creating a repository — use `tea`.
It is already logged in as the current user, with that user's own permissions. Do not configure it, and
do not ask the user for a token.
+110
View File
@@ -0,0 +1,110 @@
import { randomBytes } from 'node:crypto';
import { createSidecarProxy } from '@@/sidecar/create-proxy';
import { mountPrefix } from '@@/plugins/manifest';
import { manifest } from '../manifest';
// Two doors, because an iframe cannot carry a bearer token.
//
// ── The ordinary door ──
//
// `/_config`, `/_health` and `/_api/*` are normal API calls from the panel. They go through the
// platform's gate like any plugin's, and `createSidecarProxy` forwards them.
//
// ── The iframe door ──
//
// Gitea already has a web interface, and this plugin renders it rather than reimplementing it. That
// means an `<iframe>`, and an iframe's sub-resources — every stylesheet, script and avatar — are fetched
// by the browser with no Authorization header. So `/_web/*` is served ABOVE the platform's gate
// (`unauthenticatedPaths` in the manifest) and authenticates itself with a cookie instead.
//
// The cookie holds a MINTED token, not a constant. `dev-proxy` sets `=1`, which is fine for a
// tailnet-only owner-gated preview but means anyone who knows the cookie name can set it themselves.
// Here the value is 32 random bytes, held in memory against the user who asked for it, and it expires.
//
// What it protects is narrow, and worth being honest about: Gitea authenticates the person itself, with
// its own session cookie, so an unauthenticated caller reaching `/_web/` sees Gitea's login page and
// nothing else. This stops Officer being used as an open proxy to it — not a data leak, a manners
// problem, but a cheap one to fix.
const PREFIX = `/api${mountPrefix({ appName: 'gitea', manifest })}`;
const proxy = createSidecarProxy({ name: 'gitea', prefix: PREFIX });
/**
* Mint the frame cookie. Registered BEFORE the proxy's `/*`, because hono matches in registration order
* and the wildcard would otherwise swallow it and forward `/session` to the sidecar, which has no such
* route and would answer 404 — a 404 that looks exactly like the plugin being broken.
*/
proxy.router.post('/session', (ctx) => {
const user = ctx.get('user') as { id: number } | undefined;
if (!user) return ctx.json({ error: 'not authenticated' }, 401);
ctx.header('set-cookie', mintFrameCookie(user.id, PREFIX));
return ctx.json({ ok: true });
});
/**
* The iframe door: verify the cookie, and turn it back into a user.
*
* `use` rather than `all`, so it runs ahead of the proxy's `/*` regardless of registration order.
*
* Setting `user` is 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 nothing has set it. The cookie IS the authentication here, and this is where it resolves to
* the account it was minted for.
*/
proxy.router.use('/_web/*', async (ctx, next) => {
const userId = frameUser(ctx.req.header('cookie') ?? null);
if (userId === null) {
// Deliberately terse. Whoever reaches this without a cookie is a stray request, not a person — the
// panel mints one before the frame loads.
return ctx.text('no gitea frame session', 401);
}
ctx.set('user', { id: userId } as never);
return next();
});
export const router = proxy.router;
/** Base URL of the sidecar's HTTP server, or null if it has not reported in yet. */
export const getGiteaServerUrl = proxy.getHttpUrl;
// ── The cookie ──
const COOKIE = 'officer_gitea_frame';
const TTL_MS = 12 * 60 * 60 * 1000;
/** token → { userId, expires }. In memory on purpose: a restart closing every frame is correct. */
const sessions = new Map<string, { userId: number; expires: number }>();
const sweep = () => {
const now = Date.now();
for (const [token, s] of sessions) if (s.expires < now) sessions.delete(token);
};
/** Mint a frame token for an authenticated caller, and the Set-Cookie that carries it. */
export function mintFrameCookie(userId: number, prefix: string): string {
sweep();
const token = randomBytes(32).toString('base64url');
sessions.set(token, { userId, expires: Date.now() + TTL_MS });
// Path-scoped to the frame, so it is never sent to the rest of the platform. `Lax` rather than `None`
// because the frame is same-origin — which is the entire reason this is proxied instead of pointed at
// gitea.officer.dev, where the cookie would be third-party and dropped.
return `${COOKIE}=${token}; Path=${prefix}/_web; Max-Age=${Math.floor(TTL_MS / 1000)}; HttpOnly; SameSite=Lax`;
}
/** The user this request's frame cookie belongs to, or null. */
export function frameUser(cookieHeader: string | null): number | null {
if (!cookieHeader) return null;
const raw = cookieHeader
.split(';')
.map((p) => p.trim())
.find((p) => p.startsWith(`${COOKIE}=`));
if (!raw) return null;
const found = sessions.get(raw.slice(COOKIE.length + 1));
if (!found) return null;
if (found.expires < Date.now()) {
sessions.delete(raw.slice(COOKIE.length + 1));
return null;
}
return found.userId;
}
+58
View File
@@ -0,0 +1,58 @@
import type { PluginManifest } from '@@/plugins/manifest';
/**
* Gitea — repositories, issues and pull requests, inside Officer.
*
* ── This one renders Gitea's own web interface ──
*
* Every other plugin is panels over a JSON API. Gitea already HAS a web interface, and a good one, so
* reimplementing it was seventeen files of screen that could only ever be a worse Gitea. The panel is an
* iframe, and the platform proxies the real thing into it.
*
* The proxy is not decoration, it is what makes the iframe possible at all. Officer and Gitea are
* different registrable domains, so an iframe pointed straight at gitea.officer.dev gets a login page
* whose session cookie the browser drops as third-party. Served through Officer's own origin the cookie
* is first-party and survives — and Gitea's `X-Frame-Options` is stripped on the way through, which is
* safe only because the frame is the same origin the browser already authenticated.
*
* ── Whose Gitea account ──
*
* The person's own. The web proxy injects NOTHING and forwards the browser's own cookies, so whoever is
* looking at the frame is signed in to Gitea as themselves. The stored API token is used only by
* `/_api/*`, which the panel uses for the small things around the frame.
*
* That distinction is the one thing not to get wrong here: injecting the stored token into the web proxy
* would show every user the owner's view of every repository.
*
* ── `unauthenticatedPaths` ──
*
* `/_web/*` is served above the platform's gate because an iframe's sub-resources carry no Authorization
* header. It authenticates with a minted, path-scoped cookie instead — see `api/router.ts`. Gitea
* authenticates the person itself regardless, so the worst an unauthenticated caller sees is a login
* page.
*
* ── `tea` ──
*
* The affordance in `affordances/` tells agents to use plain `git` for what git already does, and `tea`
* for pull requests, issues and comments. Provisioning `tea` per user is not built yet.
*/
export const manifest: PluginManifest = {
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Gitea',
summary: 'Repositories, issues and pull requests — your own Gitea, rendered in Officer',
icon: 'GitBranch',
color: '#609926',
unauthenticatedPaths: ['/_web/*'],
permissions: [
{
key: 'gitea',
label: 'Gitea',
description: 'Repositories, issues, pull requests and notifications from your own Gitea account',
},
],
};
+340
View File
@@ -0,0 +1,340 @@
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
import { createSidecarConnector } from '@@/sidecar/connect';
import {
getServiceConnection,
saveServiceConnection,
deleteServiceConnection,
recordServiceProbe,
getServiceInstanceUrl,
getOwnerUser,
} from 'officerdb';
import {
callGitea,
getGiteaConfig,
invalidateGiteaConfig,
invalidateAllGiteaConfigs,
normalizeBase,
probe,
} from './upstream';
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> {
const connection = await getServiceConnection(userId, 'gitea');
const instanceUrl = await getServiceInstanceUrl('gitea');
return Response.json({
configured: !!connection,
connection,
instanceUrl,
instanceConfigured: !!instanceUrl,
isOwner: 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');
if (!token && !existing?.hasSecret) return bad('an access token is required the first time');
// 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.
const current = token ? null : await getGiteaConfig(userId);
const result = await probe({ id: existing?.id ?? 0, base, token: token ?? current?.token ?? null });
if (!result.ok) return bad(result.error ?? 'could not reach the instance');
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,
secret: token ?? undefined,
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);
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);
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));
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'));
+114
View File
@@ -0,0 +1,114 @@
import { getResolvedServiceCredentials } from 'officerdb';
// Where the Gitea instance is and what it takes to talk to it. The credential lives here and only here —
// the platform forwards to this sidecar and holds nothing.
//
// Gitea authenticates API calls with a personal access token minted from the instance's own
// Settings → Applications → Access Tokens. That is the OWNER'S to create and paste in; there is no way to
// derive one from a platform session, and no reason to want one.
//
// THE HEADER IS `token `, NOT `Bearer `. Gitea's own scheme, confirmed against a live 1.27.0 instance's
// swagger: `AuthorizationHeaderToken` is documented as "API tokens must be prepended with \"token\"
// followed by a space." Recent Gitea also tolerates `Bearer`, but `token` is the documented spelling and
// the one that works on every version we might be pointed at. The query-string forms (`?access_token=` /
// `?token=`) are deprecated for removal and must not be used — they leak the credential into access logs.
export type GiteaConfig = { id: number; base: string; token: string | null };
/** Trailing slash stripped, so `${base}/api/v1/...` never doubles the separator. */
export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, '');
const cache = new Map<number, GiteaConfig | null>();
export function invalidateGiteaConfig(userId: number): void {
cache.delete(userId);
}
/**
* Clear every user's cached config.
*
* Needed because a member's entry holds the OWNER'S base, resolved at read time. Dropping only the
* caller's entry was correct while each row was self-contained; once rows inherit, changing the instance
* leaves every member cached against the previous host until this process restarts — pointing their
* tokens at a server the owner has just moved away from. Call this whenever the owner's row changes.
*/
export function invalidateAllGiteaConfigs(): void {
cache.clear();
}
// One Gitea instance, one token per person. The base comes from whoever owns the platform; the token is
// this caller's own, so what they see is their account — their repos, their notifications, their issues.
//
// getResolvedServiceCredentials does the inheriting: the owner's row carries the URL, everyone else's
// carries only a token and resolves the base from the owner's. It returns null when the pairing is not
// usable — no row for this user, or a row waiting on an instance the owner has not connected — and both
// cases mean the same thing to every caller here: Gitea is not available to you yet.
//
// A member's URL is never read from their own row because it is never written there. That is what stops
// a settings form from becoming a per-user SSRF hop.
export async function getGiteaConfig(userId: number): Promise<GiteaConfig | null> {
if (cache.has(userId)) return cache.get(userId) ?? null;
const row = await getResolvedServiceCredentials(userId, 'gitea');
const config = row ? { id: row.id, base: normalizeBase(row.url), token: row.secret } : null;
cache.set(userId, config);
return config;
}
export const authHeaders = (config: GiteaConfig): Record<string, string> =>
config.token ? { Authorization: `token ${config.token}` } : {};
/**
* One call against the instance. Returns the raw Response so callers can stream or inspect status —
* nothing here parses a repository or an issue, because that is the owner's code and this layer has no
* business reading it.
*/
export async function callGitea(config: GiteaConfig, path: string, init?: RequestInit): Promise<Response> {
const headers = { ...authHeaders(config), ...((init?.headers as Record<string, string>) ?? {}) };
return fetch(`${config.base}${path}`, { ...init, headers, signal: init?.signal ?? AbortSignal.timeout(20_000) });
}
export type ProbeResult = { ok: boolean; version: string | null; user: string | null; error?: string };
/**
* Is the instance up, and does the stored token actually work?
*
* Both calls carry the token, which is the one place this deliberately departs from the memos sidecar's
* shape. Memos probes `/healthz` unauthenticated first so a bad URL is distinguishable from a bad token.
* Gitea has no such endpoint under a common configuration: with `REQUIRE_SIGNIN_VIEW` enabled — which the
* instance this was built against has — even `/api/v1/version` answers 403 with "Only signed in user is
* allowed to call APIs". An unauthenticated pre-flight would therefore report "unreachable" for a
* perfectly healthy server, which is the most misleading answer available.
*
* So the split is by failure mode instead, which is what the UI actually needs to tell apart:
* throw (DNS, refused, TLS, timeout) → unreachable, the URL is wrong
* 401/403 → reached it fine, the token is wrong
* 200 → both good
* Verified against the live instance: unauthenticated /version → 403, /version with a junk token → 401.
*/
export async function probe(config: GiteaConfig): Promise<ProbeResult> {
if (!config.token) return { ok: false, version: null, user: null, error: 'no access token stored' };
let version: string | null = null;
try {
const res = await callGitea(config, '/api/v1/version', { signal: AbortSignal.timeout(8000) });
if (res.status === 401 || res.status === 403) {
return { ok: false, version: null, user: null, error: `token rejected (${res.status})` };
}
if (!res.ok) return { ok: false, version: null, user: null, error: `instance returned ${res.status}` };
const body = (await res.json().catch(() => ({}))) as { version?: string };
version = body.version ?? null;
} catch (err) {
return { ok: false, version: null, user: null, error: `unreachable: ${String(err)}` };
}
// `/version` is satisfied by any valid token; `/user` is what proves the token is bound to an actual
// account and yields the identity the connection screen shows back to the owner.
try {
const me = await callGitea(config, '/api/v1/user');
if (!me.ok) return { ok: false, version, user: null, error: `token rejected (${me.status})` };
const body = (await me.json().catch(() => ({}))) as { login?: string; full_name?: string; username?: string };
return { ok: true, version, user: body.login || body.username || body.full_name || null };
} catch (err) {
return { ok: false, version, user: null, error: String(err) };
}
}
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react';
import { ExternalLink, Loader2, TriangleAlert } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
// Gitea's own web interface, rendered in place.
//
// ── Why an iframe and not panels ──
//
// Gitea has a complete web interface. The version of this that shipped before was seventeen files
// reimplementing a fraction of it, and every one of those files was a promise to keep up with a project
// that moves faster than this plugin will.
//
// ── The one thing this component must do ──
//
// Get a cookie before the frame loads. Everything inside the iframe — stylesheets, scripts, avatars — is
// fetched by the browser with no Authorization header, so the frame's URL is served above the platform's
// gate and authenticates with a path-scoped cookie instead. That cookie is minted by a normal
// authenticated call, which is what this does first.
//
// Load the iframe before that call returns and every request in it is unauthenticated, which reads as
// Gitea being down.
const FRAME_SRC = '/api/gitea/_web/';
export const GiteaFrame = () => {
const { post } = useClient();
const [state, setState] = useState<'opening' | 'ready' | 'failed'>('opening');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
// Not in a dependency array: `useClient()` returns a new object every render, so depending on `post`
// would re-mint on every keystroke elsewhere in the app. Mount-only is what is meant.
void (async () => {
try {
await post('/gitea/session', {});
if (!cancelled) setState('ready');
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : 'could not open a session');
setState('failed');
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (state === 'opening') {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span className="text-sm">Opening Gitea…</span>
</div>
);
}
if (state === 'failed') {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<TriangleAlert className="h-6 w-6 text-warning" />
<div>
<p className="text-sm font-medium">Could not open Gitea</p>
{/* The message, not a generic one: the interesting failure here is almost always "the sidecar
is not running" or "no instance is configured", and those want different answers. */}
<p className="mt-1 text-xs text-muted-foreground">{error}</p>
</div>
</div>
);
}
return (
<div className="relative h-full w-full">
{/* The escape hatch, and it is not an admission of defeat: the whole point of proxying an app that
already has a UI is that its own UI stays available. Anything the frame handles badly, this
opens properly. */}
<Button
size="sm"
variant="outline"
className="absolute right-3 top-3 z-10 opacity-60 hover:opacity-100"
onClick={() => window.open(FRAME_SRC, '_blank', 'noopener')}
title="Open Gitea in a new tab"
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
<iframe
src={FRAME_SRC}
title="Gitea"
className="h-full w-full border-0"
// Same origin, so `allow-same-origin` is what lets Gitea keep its session at all. Scripts and
// forms are Gitea's own — this is its interface, not untrusted content.
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-downloads"
/>
</div>
);
};
+10
View File
@@ -0,0 +1,10 @@
import type { LayoutNode } from 'officerdev';
// A single panel filling the workspace. The framework is still here — the screen renders `WorkspaceView`
// like every other — but there is nothing to arrange, because what is being arranged is one iframe.
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'gitea-root',
direction: 'horizontal',
children: [{ node: { type: 'panel', id: 'gitea-frame', appType: 'gitea-frame' }, size: 100 }],
};
+11
View File
@@ -0,0 +1,11 @@
import type { AppRegistryMeta } from 'officerdev';
import { GitBranch } from 'lucide-react';
import { GiteaFrame } from './GiteaFrame';
// ONE panel, where the old screen had seventeen files behind two.
//
// There is no nav panel because Gitea has its own, inside the frame. A second navigation beside it would
// be two things claiming to say where you are, and the one that could not actually move you.
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'gitea-frame', name: 'Gitea', icon: GitBranch, component: GiteaFrame, availableOnPanel: false },
];