gitea, extracted into plugins/gitea — the platform half

Eleventh extraction, and the only one that deleted its UI instead of moving it.
Seventeen files under apps/Gitea/ reimplemented a fraction of an interface
Gitea already has; the plugin renders the real one in an iframe over a
same-origin proxy.

Deleting rather than moving removed a platform change nobody now has to build:
useGiteaLocation read :owner/:name/:tab/:item from five route patterns where a
plugin gets two, so an as-is extraction would have required teaching the plugin
system to declare extra route patterns. Nothing inside an iframe needs a route.

Nothing working was lost. The 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, like soulseek, jellyfin and invoices before
it.

Gitea also stops being a CORE DOCK ITEM. It was hardcoded in CORE_DOCK_ITEMS,
the only installable feature that was, on the reasoning that it fronts a remote
instance and installs nothing locally. As a plugin its tile arrives from
assets/icon.png like every other — which is also why the icon added to
public/icons/ this morning was reverted rather than kept.

'gitea' stays in the ServiceName union: the connection lives in core
service_connections, and gitea is the reason that table has its "member rows
carry only a token and inherit the owner's URL" shape at all.

registry.test.ts's grantable-permission test named gitea as "the case this was
built for"; a plugin's permissions are registered at install and are never in
that compile-time list, so it now names a core one. catalogue.test.ts
re-anchored to vnc/notify.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 07:59:10 +00:00
co-authored by Claude Opus 5
parent d8cccad84c
commit d49776307d
35 changed files with 29 additions and 3527 deletions
-16
View File
@@ -1,16 +0,0 @@
import { createSidecarProxy } from '../../sidecar/create-proxy';
// /api/gitea/* — auth, then forward to officer-gitea. No routes of its own and no Gitea knowledge:
// this file must never grow app logic.
//
// The sidecar owns the Gitea contract and holds the personal access token. The platform knows neither.
const proxy = createSidecarProxy({
name: 'gitea',
prefix: '/api/gitea',
});
export const giteaRouter = proxy.router;
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
export const getGiteaServerUrl = proxy.getHttpUrl;
+1 -1
View File
@@ -111,7 +111,7 @@ describe('member provisioning is declared for every entry', () => {
// against an API that does not exist. Transmission was an example until 2026-08-18 and slskd until
// 2026-08-19, when they became `plugins/transmission/` and `plugins/soulseek/` — hence the list
// rather than a single id: it must stay non-empty, or this assertion passes by iterating nothing.
for (const id of ['vnc', 'notify', 'gitea']) expect(byId(id)!.members).toBe('none');
for (const id of ['vnc', 'notify']) expect(byId(id)!.members).toBe('none');
});
// A test asserting the vault is invite-only lived here until 2026-08-18, when it left to become
-28
View File
@@ -196,34 +196,6 @@ export const CATALOGUE: CatalogueEntry[] = [
// Deleting the entry is REQUIRED, not tidying — see the note where photos used to sit, and the trap in
// `EXTRACTING-A-PLUGIN.md`. A leftover entry makes the permission permanently `unavailable`, because
// `permissionAvailability()` derives from `sidecar_installs` and a plugin never gets a row there.
{
id: 'gitea',
ui: { name: 'Gitea', icon: 'GitBranch', color: '#34d399', rootRoute: '/gitea', routes: ['/gitea'] },
process: 'officer-gitea',
label: 'Gitea',
summary: 'Repositories, issues and pull requests from your own Gitea account',
// 'none' because there is nothing for the INSTALLER to provision, not because Gitea is single-tenant —
// it is the most per-user service here. The owner's connection carries the instance URL; every member
// adds their own access token from /gitea and acts only as themselves upstream, with Gitea's own
// permissions as the second and real gate. A provisioner could not do that part for them without
// holding an admin token and minting tokens on their behalf, which is more authority than this needs.
members: 'none',
// `existing` only, and there is no compose template. Gitea here is always something the owner already
// runs — on this machine, on another, or hosted. Provisioning one would mean owning the migration,
// backup and upgrade story for a service that is nobody's side feature.
modes: ['existing'],
permission: 'gitea',
existingFields: [
{ key: 'url', label: 'Gitea URL', type: 'url', required: true, placeholder: 'https://gitea.example.com' },
{
key: 'secret',
label: 'Your access token',
type: 'secret',
required: true,
help: 'A personal access token from your own Gitea account. Members add their own from /gitea.',
},
],
},
// Headscale was here until 2026-08-14. It became a core sidecar, and then on 2026-08-15 it left the
// repository entirely: it is the `offscale` PLUGIN now, installed from the marketplace, and neither
// this catalogue nor `CORE_PROCESSES` mentions it. The note below is kept because the FAILURE it
-2
View File
@@ -27,7 +27,6 @@ import { agentHandoffRouter } from './api/agent-handoff/router';
import { terminalRouter } from './api/terminal/sidecar-server';
import { caldavRouter } from './api/dav/sidecar-server';
// import { memosRouter } from './api/memos/router';
// import { giteaRouter } from './api/gitea/router';
import { appStoreRouter } from './api/app-store/router';
import { davSyncRouter } from './api/dav/sync-router';
import { davRouter } from './api/dav/router';
@@ -139,7 +138,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
['/file-browser', fileBrowserRouter],
['/terminal', terminalRouter],
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
// ['/gitea', giteaRouter], // plugin — switched off 2026-08-13
['/app-store', appStoreRouter],
['/plugins', pluginsRouter],
['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI
+7 -4
View File
@@ -73,9 +73,9 @@ describe('totality', () => {
}
});
test('signin is exempt and gitea is not', () => {
test('signin is exempt and an ordinary route is not', () => {
expect(isExemptApiPath('/api/auth/signin')).toBe(true);
expect(isExemptApiPath('/api/gitea')).toBe(false);
expect(isExemptApiPath('/api/chat')).toBe(false);
expect(isExemptApiPath('/api/dashboards/4')).toBe(false);
});
});
@@ -254,8 +254,11 @@ describe('kinds', () => {
}
});
test('gitea is grantable — the case this was built for', () => {
expect(GRANTABLE_PERMISSIONS.some((c) => c.key === 'gitea')).toBe(true);
// `gitea` was this test's subject until 2026-08-19, when it became `plugins/gitea/`. A plugin's
// permissions are registered at INSTALL from its manifest and are always `app`, so they are never in
// this compile-time list — which is why the assertion has to name a core one to mean anything.
test('an app permission is grantable — the case this was built for', () => {
expect(GRANTABLE_PERMISSIONS.some((c) => c.key === 'chat')).toBe(true);
});
/*
-18
View File
@@ -143,24 +143,6 @@ const CORE_REGISTRY: Permission[] = [
},
// ── app: the grantable surface ──────────────────────────────────────────────────────────────────
{
key: 'gitea',
label: 'Gitea',
description: 'Repositories, issues, pull requests and notifications from your own Gitea account',
kind: 'app',
// api: ['/gitea'], // plugin — switched off 2026-08-13
api: [],
routes: ['/gitea'],
// Every Gitea call is already scoped to the caller's own personal access token — the sidecar resolves
// the token from the caller's row and the instance from the owner's, so a member holding `read` still
// acts only as themselves upstream. Gitea's own permissions are the second gate and the real one:
// a token cannot reach a repository its account cannot reach, whatever this platform thinks.
//
// Which is why the whole permission is `personal` rather than a list of sub-paths. Nothing under
// /api/gitea can affect another Officer user, so withholding write here would only stop someone
// commenting on their own issues — security theatre with a real cost and no benefit.
personal: ['/'],
},
{
key: 'memos',
label: 'Memos',
+21
View File
@@ -234,6 +234,27 @@ const BUNDLED: CatalogueEntry[] = [
// See its PLUGIN.md; this was a deliberate decision, not a side effect of extraction.
has: { api: true, schema: true, sidecar: true, web: true },
},
{
appName: 'gitea',
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Gitea',
summary: 'Repositories, issues and pull requests — your own Gitea, rendered in Officer',
color: '#609926',
icon: 'GitBranch',
source: 'https://gitea.officer.dev/plugins/gitea.git',
permissions: [
{
key: 'gitea',
label: 'Gitea',
description: 'Repositories, issues, pull requests and notifications from your own Gitea account',
},
],
// `schema: false` — the connection lives in core `service_connections`, like transmission's. The web
// half is one iframe over a proxy of Gitea's own UI rather than panels; see its PLUGIN.md.
has: { api: true, schema: false, sidecar: true, web: true },
},
{
appName: 'vault',
publisher: 'officerdev',
-290
View File
@@ -1,290 +0,0 @@
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../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 });
}
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
@@ -1,114 +0,0 @@
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) };
}
}