From f1afc3c47c6858e1be2551a0f65508c977edbda2 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 17 Jul 2026 18:27:01 +0300 Subject: [PATCH] match app-password errors on the typed lex error, not raw message equality isErrorMaybeAppPasswordPermissions compared getErrorName against 'TokenInvalid', a code the PDS never sends - the string fallback was doing all the work. The real wire shape is the GENERIC 'InvalidToken' code (also used for malformed/expired tokens) with the app-password specifics only in the message, so the typed path now matches code AND message ('Bad token scope' / 'Bad token method'). Deactivated and DeactivateAccountDialog used raw switch(e.message) equality; both now go through the helper. Tests updated to pin the actual PDS wire shape (and reject plain malformed-token errors). Co-Authored-By: Claude Fable 5 --- oxlint-suppressions.json | 6 ---- src/lib/strings/__tests__/errors.test.ts | 29 ++++++++++++++++--- src/lib/strings/errors.ts | 17 +++++++++-- src/screens/Deactivated.tsx | 20 ++++++------- .../components/DeactivateAccountDialog.tsx | 20 ++++++------- 5 files changed, 58 insertions(+), 34 deletions(-) diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index cc97daab69..38c3b8accb 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -854,9 +854,6 @@ }, "typescript/no-misused-promises": { "count": 1 - }, - "typescript/no-unsafe-member-access": { - "count": 1 } }, "src/screens/E2E/SharedPreferencesTesterScreen.tsx": { @@ -1140,9 +1137,6 @@ }, "typescript/no-misused-promises": { "count": 1 - }, - "typescript/no-unsafe-member-access": { - "count": 1 } }, "src/screens/Settings/components/DeleteAccountDialog.tsx": { diff --git a/src/lib/strings/__tests__/errors.test.ts b/src/lib/strings/__tests__/errors.test.ts index 04220975fe..ffd413deaa 100644 --- a/src/lib/strings/__tests__/errors.test.ts +++ b/src/lib/strings/__tests__/errors.test.ts @@ -18,12 +18,13 @@ function lexError( status: number, error: string, headers?: Record, + message = `${error} message`, ) { const response = new Response(null, {status, headers}) const method = {} as Procedure | Query return new XrpcResponseError(method, response, { encoding: 'application/json', - body: {error, message: `${error} message`}, + body: {error, message}, }) } @@ -73,13 +74,33 @@ describe('getErrorHeader', () => { }) describe('isErrorMaybeAppPasswordPermissions', () => { - it('matches a TokenInvalid error', () => { + /* + * The PDS wire shape: generic error code `InvalidToken`, with the + * app-password specifics only in the message ('Bad token scope' from + * auth-verifier, 'Bad token method' from pipethrough). + */ + it('matches the InvalidToken + bad-token-message wire shape', () => { expect( - isErrorMaybeAppPasswordPermissions(lexError(400, 'TokenInvalid')), + isErrorMaybeAppPasswordPermissions( + lexError(400, 'InvalidToken', undefined, 'Bad token scope'), + ), + ).toBe(true) + expect( + isErrorMaybeAppPasswordPermissions( + lexError(400, 'InvalidToken', undefined, 'Bad token method'), + ), ).toBe(true) }) - it('still matches the string-based bad-token signals', () => { + it('does not match other InvalidToken errors (malformed/expired tokens)', () => { + expect( + isErrorMaybeAppPasswordPermissions( + lexError(400, 'InvalidToken', undefined, 'Malformed token'), + ), + ).toBe(false) + }) + + it('still matches the string-based bad-token signals on plain errors', () => { expect( isErrorMaybeAppPasswordPermissions(new Error('Bad token scope')), ).toBe(true) diff --git a/src/lib/strings/errors.ts b/src/lib/strings/errors.ts index 7923fd7545..201a669687 100644 --- a/src/lib/strings/errors.ts +++ b/src/lib/strings/errors.ts @@ -67,9 +67,22 @@ export function isNetworkError(e: unknown) { return false } +/** + * True when an error looks like an App Password hitting an endpoint it lacks + * permission for. The PDS reports this under the GENERIC `InvalidToken` error + * code (which also covers malformed/expired tokens), with the app-password + * specifics only in the message: 'Bad token scope' (auth-verifier) or 'Bad + * token method' (pipethrough). So the typed path matches the code AND the + * message. (The old check compared against 'TokenInvalid', which the PDS never + * sends - the string fallback was doing all the work.) + */ export function isErrorMaybeAppPasswordPermissions(e: unknown) { - if (isXrpcError(e) && getErrorName(e) === 'TokenInvalid') { - return true + if (isXrpcError(e)) { + return ( + getErrorName(e) === 'InvalidToken' && + (e.message.includes('Bad token scope') || + e.message.includes('Bad token method')) + ) } const str = String(e) return str.includes('Bad token scope') || str.includes('Bad token method') diff --git a/src/screens/Deactivated.tsx b/src/screens/Deactivated.tsx index db4f60eada..c965e9fca1 100644 --- a/src/screens/Deactivated.tsx +++ b/src/screens/Deactivated.tsx @@ -7,6 +7,7 @@ import {Trans} from '@lingui/react/macro' import {useQueryClient} from '@tanstack/react-query' import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' +import {isErrorMaybeAppPasswordPermissions} from '#/lib/strings/errors' import {logger} from '#/logger' import { type SessionAccount, @@ -75,17 +76,14 @@ export function Deactivated() { await queryClient.resetQueries() await refreshSession() } catch (e: any) { - switch (e.message) { - case 'Bad token scope': - setError( - _( - msg`You're signed in with an App Password. Please sign in with your main password to continue deactivating your account.`, - ), - ) - break - default: - setError(_(msg`Something went wrong, please try again`)) - break + if (isErrorMaybeAppPasswordPermissions(e)) { + setError( + _( + msg`You're signed in with an App Password. Please sign in with your main password to continue deactivating your account.`, + ), + ) + } else { + setError(_(msg`Something went wrong, please try again`)) } logger.error(e, { diff --git a/src/screens/Settings/components/DeactivateAccountDialog.tsx b/src/screens/Settings/components/DeactivateAccountDialog.tsx index 1e47fc9dcf..6560e75192 100644 --- a/src/screens/Settings/components/DeactivateAccountDialog.tsx +++ b/src/screens/Settings/components/DeactivateAccountDialog.tsx @@ -4,6 +4,7 @@ import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' +import {isErrorMaybeAppPasswordPermissions} from '#/lib/strings/errors' import {logger} from '#/logger' import {usePdsClient, useSessionApi} from '#/state/session' import {atoms as a, useTheme} from '#/alf' @@ -48,17 +49,14 @@ function DeactivateAccountDialogInner({ logoutCurrentAccount('Deactivated') }) } catch (e: any) { - switch (e.message) { - case 'Bad token scope': - setError( - _( - msg`You're signed in with an App Password. Please sign in with your main password to continue deactivating your account.`, - ), - ) - break - default: - setError(_(msg`Something went wrong, please try again`)) - break + if (isErrorMaybeAppPasswordPermissions(e)) { + setError( + _( + msg`You're signed in with an App Password. Please sign in with your main password to continue deactivating your account.`, + ), + ) + } else { + setError(_(msg`Something went wrong, please try again`)) } logger.error(e, {