From fe72e345f4e7c04527bc1fa140f1587029a457e4 Mon Sep 17 00:00:00 2001 From: feruzm Date: Wed, 5 Aug 2026 07:22:30 +0000 Subject: [PATCH 1/2] Self-hosted: name the extension that was actually asked to sign A failed config save said "Keychain signing was cancelled." regardless of which wallet signed. `keychain` is the login type for every Hive browser extension, not the Keychain product, so the same branch runs for Hive Keeper and Peak Vault. A Keeper user was being sent to check a wallet they never installed, and this app leads with Keeper in both its install list and its detection order, so the wrong name was most likely shown to the users it was most wrong for. The message now names the extension recorded for the session, and stays generic when none is, since the preference is stored per username and a browser that cleared storage still signs. Built as a pure exported function so it can be asserted directly: nothing in a .tsx file is testable under this runner, and login-method copy has already drifted from what the code does once. VALID_EXTENSION_IDS is exported so the test covers every wallet rather than a list that can fall behind a fourth one. --- .../features/auth/utils/hive-extensions.ts | 7 ++- .../auth/utils/hosting-token-copy.test.ts | 56 +++++++++++++++++++ .../src/features/auth/utils/hosting-token.ts | 25 ++++++++- 3 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 apps/self-hosted/src/features/auth/utils/hosting-token-copy.test.ts 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..9978dc7b32 100644 --- a/apps/self-hosted/src/features/auth/utils/hive-extensions.ts +++ b/apps/self-hosted/src/features/auth/utils/hive-extensions.ts @@ -152,7 +152,12 @@ export function hasKeychainLikeExtension(): boolean { const PREFERRED_EXTENSION_MAP_KEY = 'ecency_preferred_hive_extension_by_user'; -const VALID_EXTENSION_IDS: readonly HiveExtensionId[] = [ +/** + * Every extension this app knows how to sign with. Exported so callers that + * have to cover all of them, such as the signing copy, cannot fall behind a + * fourth wallet being added here. + */ +export const VALID_EXTENSION_IDS: readonly HiveExtensionId[] = [ 'keychain', 'hive-keeper', 'peakvault', diff --git a/apps/self-hosted/src/features/auth/utils/hosting-token-copy.test.ts b/apps/self-hosted/src/features/auth/utils/hosting-token-copy.test.ts new file mode 100644 index 0000000000..35d68f1402 --- /dev/null +++ b/apps/self-hosted/src/features/auth/utils/hosting-token-copy.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, From 5fe830770e4a4ee892a67cb868d610e494c2d2c3 Mon Sep 17 00:00:00 2001 From: feruzm Date: Wed, 5 Aug 2026 07:32:09 +0000 Subject: [PATCH 2/2] Self-hosted: derive the extension id list, and drop the -copy test name Two review points on the previous commit. VALID_EXTENSION_IDS was hand-written, so a fourth id added to HiveExtensionId and forgotten here would typecheck fine and leave every caller, including the signing-copy test, quietly covering only the ids someone remembered. It is now derived from EXTENSION_META, which is a Record keyed by HiveExtensionId and so already forced to carry an entry per id. The exhaustiveness is structural rather than remembered. hosting-token-copy.test.ts read as a copy of a hosting-token.test.ts. No such file existed, so the conventional name was free and a later test for token caching would have forced a rename. --- .../src/features/auth/utils/hive-extensions.ts | 18 ++++++++++-------- ...oken-copy.test.ts => hosting-token.test.ts} | 0 2 files changed, 10 insertions(+), 8 deletions(-) rename apps/self-hosted/src/features/auth/utils/{hosting-token-copy.test.ts => hosting-token.test.ts} (100%) 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 9978dc7b32..f2db4293a9 100644 --- a/apps/self-hosted/src/features/auth/utils/hive-extensions.ts +++ b/apps/self-hosted/src/features/auth/utils/hive-extensions.ts @@ -153,15 +153,17 @@ export function hasKeychainLikeExtension(): boolean { const PREFERRED_EXTENSION_MAP_KEY = 'ecency_preferred_hive_extension_by_user'; /** - * Every extension this app knows how to sign with. Exported so callers that - * have to cover all of them, such as the signing copy, cannot fall behind a - * fourth wallet being added here. + * 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[] = [ - 'keychain', - 'hive-keeper', - 'peakvault', -]; +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-copy.test.ts b/apps/self-hosted/src/features/auth/utils/hosting-token.test.ts similarity index 100% rename from apps/self-hosted/src/features/auth/utils/hosting-token-copy.test.ts rename to apps/self-hosted/src/features/auth/utils/hosting-token.test.ts