diff --git a/src/servers/sidecar/gitea/index.ts b/src/servers/sidecar/gitea/index.ts index e40cf175..93421375 100644 --- a/src/servers/sidecar/gitea/index.ts +++ b/src/servers/sidecar/gitea/index.ts @@ -8,7 +8,14 @@ import { getServiceInstanceUrl, getOwnerUser, } from 'officerdb'; -import { callGitea, getGiteaConfig, invalidateGiteaConfig, normalizeBase, probe } from './upstream'; +import { + callGitea, + getGiteaConfig, + invalidateGiteaConfig, + invalidateAllGiteaConfigs, + normalizeBase, + probe, +} from './upstream'; // 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. @@ -90,13 +97,20 @@ async function isOwner(userId: number): Promise { // 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 { const connection = await getServiceConnection(userId, 'gitea'); - const instanceConfigured = !!(await getServiceInstanceUrl('gitea')); + const instanceUrl = await getServiceInstanceUrl('gitea'); return Response.json({ configured: !!connection, connection, - instanceConfigured, + instanceUrl, + instanceConfigured: !!instanceUrl, isOwner: await isOwner(userId), }); } @@ -105,8 +119,13 @@ async function handleConfig(req: Request, userId: number): Promise { if (req.method === 'GET') return connectionState(userId); if (req.method === 'DELETE') { + const owner = await isOwner(userId); await deleteServiceConnection(userId, 'gitea'); - invalidateGiteaConfig(userId); + // 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); } @@ -154,7 +173,10 @@ async function handleConfig(req: Request, userId: number): Promise { secret: token ?? undefined, version: result.version, }); - invalidateGiteaConfig(userId); + // 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 }); } diff --git a/src/servers/sidecar/gitea/upstream.ts b/src/servers/sidecar/gitea/upstream.ts index 641b07ff..48a881e2 100644 --- a/src/servers/sidecar/gitea/upstream.ts +++ b/src/servers/sidecar/gitea/upstream.ts @@ -24,6 +24,18 @@ 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. //