diff --git a/apps/self-hosted/src/features/auth/utils/hive-extensions.ts b/apps/self-hosted/src/features/auth/utils/hive-extensions.ts index e22f8a9dcf..f2db4293a9 100644 --- a/apps/self-hosted/src/features/auth/utils/hive-extensions.ts +++ b/apps/self-hosted/src/features/auth/utils/hive-extensions.ts @@ -152,11 +152,18 @@ export function hasKeychainLikeExtension(): boolean { const PREFERRED_EXTENSION_MAP_KEY = 'ecency_preferred_hive_extension_by_user'; -const VALID_EXTENSION_IDS: readonly HiveExtensionId[] = [ - 'keychain', - 'hive-keeper', - 'peakvault', -]; +/** + * Every extension this app knows how to sign with. + * + * Derived from `EXTENSION_META` rather than written out again. That is a + * `Record`, so the type checker already + * forces an entry per id; a hand-written list would typecheck perfectly while + * missing one, and everything reading this, including the signing copy and its + * test, would silently cover only the ids someone remembered. + */ +export const VALID_EXTENSION_IDS: readonly HiveExtensionId[] = Object.keys( + EXTENSION_META, +) as HiveExtensionId[]; function asHiveExtensionId(value: unknown): HiveExtensionId | null { return typeof value === 'string' && diff --git a/apps/self-hosted/src/features/auth/utils/hosting-token.test.ts b/apps/self-hosted/src/features/auth/utils/hosting-token.test.ts new file mode 100644 index 0000000000..35d68f1402 --- /dev/null +++ b/apps/self-hosted/src/features/auth/utils/hosting-token.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { + getExtensionName, + VALID_EXTENSION_IDS as EXTENSION_IDS, +} from './hive-extensions'; +import { extensionCancelledMessage } from './hosting-token'; + +/** + * `keychain` is the login type for every Hive browser extension, not the + * Keychain product. The same branch of `getHostingToken` runs for Hive Keeper + * and Peak Vault, so a message naming Keychain told a Keeper user to go and + * check a wallet they never installed, at the moment their config save failed. + * + * The app leads with Hive Keeper in both its install list and its detection + * order, so the wrong name is most likely to be shown to exactly the users it + * is most wrong for. + */ +describe('extension cancelled message', () => { + it('names the extension that was actually asked to sign', () => { + for (const id of EXTENSION_IDS) { + const message = extensionCancelledMessage(id); + expect(message, id).toContain(getExtensionName(id)); + } + }); + + /** + * The real point: no message may name a product the signer is not using. + * Asserting only that the right name appears would pass on + * "Signing with Hive Keeper (Keychain) was cancelled." + */ + it('names no other wallet', () => { + for (const id of EXTENSION_IDS) { + const message = extensionCancelledMessage(id); + const others = EXTENSION_IDS.filter((other) => other !== id).map( + getExtensionName, + ); + for (const name of others) { + // Hive Keeper and Keychain share no substring, so a plain check holds. + expect(message, `${id} must not mention ${name}`).not.toContain(name); + } + } + }); + + /** + * A session can carry no recorded extension: the preference is stored per + * username, and a browser that cleared storage still signs. Falling back to a + * product name would be a guess. + */ + it('stays generic when no extension was recorded', () => { + const message = extensionCancelledMessage(undefined); + expect(message).toMatch(/browser extension/i); + for (const id of EXTENSION_IDS) { + expect(message).not.toContain(getExtensionName(id)); + } + }); +}); diff --git a/apps/self-hosted/src/features/auth/utils/hosting-token.ts b/apps/self-hosted/src/features/auth/utils/hosting-token.ts index bd46f5c887..42592415d7 100644 --- a/apps/self-hosted/src/features/auth/utils/hosting-token.ts +++ b/apps/self-hosted/src/features/auth/utils/hosting-token.ts @@ -11,7 +11,8 @@ */ import { authenticationStore } from '@/store'; -import { signBufferWithExtension } from './hive-extensions'; +import type { HiveExtensionId } from '../types'; +import { getExtensionName, signBufferWithExtension } from './hive-extensions'; const STORAGE_KEY = 'ecency_hosting_token'; // Refuse a cached token that expires within a minute so an in-flight save can't outlive it. @@ -87,6 +88,26 @@ async function postJson(url: string, body: unknown): Promise { return response.json() as Promise; } +/** + * What to say when an extension signing request comes back empty. + * + * `keychain` is the login type for every Hive browser extension, not the + * Keychain product: the same branch runs for Hive Keeper and Peak Vault, which + * are what this app offers first. Naming Keychain there sent a Keeper user to + * check a wallet they never installed. + * + * Exported so it can be asserted directly. Nothing in a `.tsx` file is testable + * under this runner, and the copy for a login method has already drifted from + * what the code does once. + */ +export function extensionCancelledMessage( + extension: HiveExtensionId | undefined, +): string { + return extension + ? `Signing with ${getExtensionName(extension)} was cancelled.` + : 'Signing with your browser extension was cancelled.'; +} + /** * Get a hosting API token for the logged-in user, exchanging the current session for one * when there is no valid cached token. Throws with a user-readable message on failure. @@ -127,7 +148,7 @@ export async function getHostingToken(apiBase: string): Promise { user.extension, ); if (typeof signed.result !== 'string' || signed.result.length === 0) { - throw new Error('Keychain signing was cancelled.'); + throw new Error(extensionCancelledMessage(user.extension)); } result = await postJson(`${apiBase}/v1/auth/verify`, { username: user.username,