Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions apps/self-hosted/src/features/auth/utils/hive-extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HiveExtensionId, DetectedExtension>`, 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' &&
Expand Down
56 changes: 56 additions & 0 deletions apps/self-hosted/src/features/auth/utils/hosting-token.test.ts
Original file line number Diff line number Diff line change
@@ -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));
}
});
});
25 changes: 23 additions & 2 deletions apps/self-hosted/src/features/auth/utils/hosting-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -87,6 +88,26 @@ async function postJson<T>(url: string, body: unknown): Promise<T> {
return response.json() as Promise<T>;
}

/**
* 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.
Expand Down Expand Up @@ -127,7 +148,7 @@ export async function getHostingToken(apiBase: string): Promise<string> {
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<TokenResponse>(`${apiBase}/v1/auth/verify`, {
username: user.username,
Expand Down
Loading