diff --git a/sidecar/index.ts b/sidecar/index.ts index 2dfea50..9d09ecf 100644 --- a/sidecar/index.ts +++ b/sidecar/index.ts @@ -16,6 +16,7 @@ import { invalidateAllGiteaConfigs, normalizeBase, probe, + UnreadableCredentials, } from './upstream'; import { API_URL } from '@@/officer-url.mjs'; @@ -109,6 +110,23 @@ async function isOwner(userId: number): Promise { // 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 { + // Asked first, and only for its failure: a row whose token cannot be decrypted must NOT be reported as + // configured. It would send the panel to the frame, which then throws on every request — which is + // exactly what happened, and it reads as the sidecar being broken rather than as a token needing + // re-entering. + try { + await getGiteaConfig(userId); + } catch (err) { + if (err instanceof UnreadableCredentials) { + return Response.json({ + configured: false, + unreadable: true, + isOwner: await isOwner(userId), + error: err.message, + }); + } + throw err; + } const connection = await getServiceConnection(userId, 'gitea'); const instanceUrl = await getServiceInstanceUrl('gitea'); return Response.json({ @@ -316,6 +334,11 @@ const server = Bun.serve({ } catch (err) { // Path only. A repository body is the owner's private code. console.error(`[gitea] ${req.method} ${url.pathname} failed`, String(err)); + // Named, because the remedy is specific and the generic message sends people to the logs. This one + // is not a fault: a token was encrypted under a key that no longer exists. + if (err instanceof UnreadableCredentials) { + return Response.json({ error: err.message, unreadable: true }, { status: 409 }); + } return Response.json({ error: 'internal error' }, { status: 500 }); } diff --git a/sidecar/upstream.ts b/sidecar/upstream.ts index 48a881e..af2b43a 100644 --- a/sidecar/upstream.ts +++ b/sidecar/upstream.ts @@ -46,9 +46,37 @@ export function invalidateAllGiteaConfigs(): void { // // 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. +/** + * Thrown when the stored token exists but cannot be decrypted. + * + * Not a hypothetical. The platform's encryption key is minted on first use and NOT backed up, so losing + * the key file re-mints it and every previously encrypted column becomes unreadable — which happened on + * this project's own server, taking slskd, memos and gitea credentials with it. AES-GCM reports it as + * "Unsupported state or unable to authenticate data", a message that says nothing to whoever sees it. + * + * Distinguished from "not configured" because the remedy differs: nothing is broken, a token simply has + * to be pasted again. + */ +export class UnreadableCredentials extends Error { + constructor() { + super('stored gitea credentials cannot be decrypted — the token needs entering again'); + this.name = 'UnreadableCredentials'; + } +} + export async function getGiteaConfig(userId: number): Promise { if (cache.has(userId)) return cache.get(userId) ?? null; - const row = await getResolvedServiceCredentials(userId, 'gitea'); + let row: Awaited>; + try { + row = await getResolvedServiceCredentials(userId, 'gitea'); + } catch (err) { + // Only the decrypt failure is translated. Anything else — a database that is down, a bug — must keep + // its own error, or a real fault would be reported to the owner as "re-enter your token". + if (err instanceof Error && /unable to authenticate data|unsupported state/i.test(err.message)) { + throw new UnreadableCredentials(); + } + throw err; + } const config = row ? { id: row.id, base: normalizeBase(row.url), token: row.secret } : null; cache.set(userId, config); return config; diff --git a/web/GiteaConnection.tsx b/web/GiteaConnection.tsx index 896820a..4ab3bbc 100644 --- a/web/GiteaConnection.tsx +++ b/web/GiteaConnection.tsx @@ -19,6 +19,15 @@ export type ConnectionState = { url?: string | null; user?: string | null; isOwner?: boolean; + /** + * A token is stored but cannot be decrypted. + * + * The platform's encryption key is minted on first use and not backed up, so losing it re-mints one + * and every previously encrypted column becomes unreadable. Reported separately from "not configured" + * because the person needs telling that nothing is broken and their URL is still there — only the + * token has to be pasted again. + */ + unreadable?: boolean; }; type Props = { state: ConnectionState; onSaved: () => void }; @@ -49,11 +58,13 @@ export const GiteaConnection = ({ state, onSaved }: Props) => { return (
-

Connect Gitea

+

{state.unreadable ? 'Re-enter your token' : 'Connect Gitea'}

- {state.isOwner === false - ? 'Your own access token. The instance is already set.' - : 'Where Gitea runs, and a token for it.'} + {state.unreadable + ? "The stored token can no longer be read — this server's encryption key changed. Nothing else is lost; paste it again." + : state.isOwner === false + ? 'Your own access token. The instance is already set.' + : 'Where Gitea runs, and a token for it.'}

@@ -74,13 +85,13 @@ export const GiteaConnection = ({ state, onSaved }: Props) => { type="password" value={token} onChange={(ev) => setToken(ev.target.value)} - placeholder={state.configured ? '•••••••• (unchanged)' : 'Settings → Applications in Gitea'} + placeholder={state.configured && !state.unreadable ? '•••••••• (unchanged)' : 'Settings → Applications in Gitea'} /> {error &&

{error}

} -