gitea: one instance, a token per person
Gitea is the first service where the server is shared but the account is not.
The owner connects the instance; every other user supplies only their own access
token and sees their own repositories, notifications and issues.
The plumbing was already per-user — createSidecarProxy injects the authenticated
caller's id as X-Officer-User, the sidecar refuses a request without it, and
service_connections is UNIQUE (user_id, service). What was wrong is that url and
token lived in the same row and a save demanded both, so a member would have had
to type the instance URL. That is worse than inconvenient: a member who can name
the URL has a per-user SSRF hop behind a settings form, and the sidecar would
dutifully attach their token to it.
`url` is now nullable, and NULL means "inherit the instance". The owner's row
carries the URL and IS the instance; everyone else's row is a credential. A
member's URL is therefore not stored rather than merely hidden — which is what
makes "members never see the instance URL" a property of the schema instead of a
filter somebody has to remember on every response.
Resolution lives in one place (getResolvedServiceCredentials / getServiceInstanceUrl)
rather than in each sidecar, so there is a single answer to "where is this
service" and no sidecar can accidentally trust a member-supplied URL.
Rules, all enforced in the sidecar rather than the UI, because a form that hides
a field is a suggestion and these are rules:
owner PUT /_config { url, token }, as before
member PUT /_config { token } only; a url in the body is REJECTED, not
ignored — ignoring it would leave someone debugging a
screen quietly talking to a different server
member, no instance 409, "the server owner has not connected a Gitea
instance yet"
member GET /_config has no url to return
owner disconnects members keep their tokens and resolve to nothing; no
instance, no service
GET /_config also now answers `instanceConfigured` and `isOwner`, which is what
lets the UI tell "you have not connected yet" from "there is nothing here to
connect to" — different screens.
memos, slskd and transmission front a single daemon and always store their own
URL; they now treat a null as a malformed row rather than reaching for somebody
else's instance.
Verified on a scratch database: pushing twice adds no diff churn beyond the known
pk_music_now_playing pair, and the resolution behaves — member GET returns a null
url, member credentials resolve to the owner's base with the member's own token,
and deleting the owner's row leaves the member's token intact but resolving to
nothing. All four live rows have a url today, so the column change applies
without touching data.
Not reachable by a real member yet: the account backstop still confines
non-owners to /api/auth + /api/music. This works the moment the capability model
lands, and until then is testable only by minting a token.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
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);
|
||||
}
|
||||
|
||||
// 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) };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user