From f680e25f8245f350d72e666537b07935a513dd81 Mon Sep 17 00:00:00 2001 From: officer Date: Wed, 19 Aug 2026 07:58:55 +0000 Subject: [PATCH] gitea: the plugin, rendering Gitea's own interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- PLUGIN.md | 91 ++++++++++++ README.md | 40 ++--- affordances/gitea.md | 12 ++ api/router.ts | 110 ++++++++++++++ manifest.ts | 58 ++++++++ sidecar/index.ts | 340 +++++++++++++++++++++++++++++++++++++++++++ sidecar/upstream.ts | 114 +++++++++++++++ web/GiteaFrame.tsx | 98 +++++++++++++ web/layout.ts | 10 ++ web/panels.ts | 11 ++ 10 files changed, 867 insertions(+), 17 deletions(-) create mode 100644 PLUGIN.md create mode 100644 affordances/gitea.md create mode 100644 api/router.ts create mode 100644 manifest.ts create mode 100644 sidecar/index.ts create mode 100644 sidecar/upstream.ts create mode 100644 web/GiteaFrame.tsx create mode 100644 web/layout.ts create mode 100644 web/panels.ts diff --git a/PLUGIN.md b/PLUGIN.md new file mode 100644 index 0000000..acf7ed1 --- /dev/null +++ b/PLUGIN.md @@ -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. diff --git a/README.md b/README.md index 4221e1d..b6762b2 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/affordances/gitea.md b/affordances/gitea.md new file mode 100644 index 0000000..aab6fc3 --- /dev/null +++ b/affordances/gitea.md @@ -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. diff --git a/api/router.ts b/api/router.ts new file mode 100644 index 0000000..70bacd6 --- /dev/null +++ b/api/router.ts @@ -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 `