diff --git a/apps/web/app/(dashboard)/app/wallets/page.tsx b/apps/web/app/(dashboard)/app/wallets/page.tsx index ce4d407..b722bf8 100644 --- a/apps/web/app/(dashboard)/app/wallets/page.tsx +++ b/apps/web/app/(dashboard)/app/wallets/page.tsx @@ -3,6 +3,7 @@ import { currentAddress } from '@/lib/server/session'; import { getAccountWallets } from '@/lib/server/account'; import { isRegistryConfigured, lookupWallet } from '@/lib/server/registry-read'; import { stellarExpertAccountUrl } from '@/lib/network'; +import { UnlinkWalletButton } from './unlink-wallet-button'; function truncate(a: string): string { return a.length > 18 ? `${a.slice(0, 8)}…${a.slice(-6)}` : a; @@ -35,7 +36,10 @@ export default async function WalletsPage() { return (
-

+

Wallets

@@ -64,7 +68,10 @@ export default async function WalletsPage() { {truncate(w.pubkey)} {w.isPrimary && ( - + Primary )} @@ -75,15 +82,21 @@ export default async function WalletsPage() { {SOURCE_BADGE[w.source].label} - - Explorer ↗ - +
+ + Explorer ↗ + + {/* The primary wallet is the handle's on-chain claim; unlinking + it is a registry operation (release/transfer), not a + dashboard edit, so no button is offered for it here. */} + {!w.isPrimary && } +
)) )} diff --git a/apps/web/app/(dashboard)/app/wallets/unlink-wallet-button.tsx b/apps/web/app/(dashboard)/app/wallets/unlink-wallet-button.tsx new file mode 100644 index 0000000..96c5646 --- /dev/null +++ b/apps/web/app/(dashboard)/app/wallets/unlink-wallet-button.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { trpc } from '@/lib/trpc-client'; + +type State = { status: 'idle' | 'confirming' | 'busy' } | { status: 'error'; message: string }; + +const mono = { fontFamily: 'var(--font-mono)' } as const; + +/** + * Removes a non-primary wallet from the caller's own profile. Two-step + * confirm (Unlink → Confirm/Cancel) instead of a native `confirm()` dialog, to + * match the rest of the dashboard's styling. The primary wallet is never + * offered this button — see `wallets/page.tsx` — since unlinking it is a + * registry operation the server refuses anyway. + */ +export function UnlinkWalletButton({ pubkey }: { pubkey: string }) { + const router = useRouter(); + const [state, setState] = useState({ status: 'idle' }); + + async function confirmUnlink() { + setState({ status: 'busy' }); + try { + await trpc.account.unlinkWallet.mutate({ wallet: pubkey }); + router.refresh(); + } catch (err) { + setState({ + status: 'error', + message: err instanceof Error ? err.message : 'Could not unlink wallet', + }); + } + } + + if (state.status === 'confirming') { + return ( + + Unlink this wallet? + + + + ); + } + + if (state.status === 'error') { + return ( + + {state.message} + + + ); + } + + return ( + + ); +} diff --git a/apps/web/lib/server/account.test.ts b/apps/web/lib/server/account.test.ts index adbd732..fc1f49e 100644 --- a/apps/web/lib/server/account.test.ts +++ b/apps/web/lib/server/account.test.ts @@ -1,7 +1,7 @@ import { test, afterEach } from 'node:test'; import assert from 'node:assert/strict'; import { nativeToScVal, type Transaction } from '@stellar/stellar-sdk'; -import { getAccount, normalizeAccountUpdate } from './account.ts'; +import { getAccount, normalizeAccountUpdate, unlinkWallet, type WalletStore } from './account.ts'; import type { SimulatingServer } from './registry-read.ts'; // These tests run without DATABASE_URL, which is exactly the configuration the @@ -92,3 +92,66 @@ test('normalizeAccountUpdate rejects an over-long bio', () => { /280 characters or fewer/, ); }); + +// ─── unlinkWallet ─────────────────────────────────────────────────────────── + +/** In-memory WalletStore backed by a plain map, keyed by pubkey. */ +function fakeWalletStore(rows: Record): { + store: WalletStore; + deleted: string[]; +} { + const deleted: string[] = []; + const store: WalletStore = { + wallet: { + findUnique: async ({ where: { pubkey } }) => rows[pubkey] ?? null, + delete: async ({ where: { pubkey } }) => { + deleted.push(pubkey); + }, + }, + }; + return { store, deleted }; +} + +test('unlinkWallet requires a configured database', async () => { + await assert.rejects(() => unlinkWallet(WALLET, 'GOTHER'), /database/i); +}); + +test('unlinkWallet refuses a caller with no profile of their own', async () => { + const { store } = fakeWalletStore({}); + await assert.rejects(() => unlinkWallet(WALLET, 'GOTHER', store), /No profile is bound/); +}); + +test('unlinkWallet refuses a wallet that does not exist', async () => { + const { store } = fakeWalletStore({ + [WALLET]: { profileId: 'p1', isPrimary: true }, + }); + await assert.rejects(() => unlinkWallet(WALLET, 'GMISSING', store), /not found/i); +}); + +test('unlinkWallet refuses a wallet bound to a different profile', async () => { + const { store, deleted } = fakeWalletStore({ + [WALLET]: { profileId: 'p1', isPrimary: true }, + GOTHERSPROFILE: { profileId: 'p2', isPrimary: false }, + }); + // Same "not found" message as a nonexistent pubkey — the caller must not be + // able to tell "doesn't exist" from "belongs to someone else". + await assert.rejects(() => unlinkWallet(WALLET, 'GOTHERSPROFILE', store), /not found/i); + assert.equal(deleted.length, 0); +}); + +test('unlinkWallet refuses the primary wallet', async () => { + const { store, deleted } = fakeWalletStore({ + [WALLET]: { profileId: 'p1', isPrimary: true }, + }); + await assert.rejects(() => unlinkWallet(WALLET, WALLET, store), /primary/i); + assert.equal(deleted.length, 0); +}); + +test("unlinkWallet deletes a non-primary wallet on the caller's own profile", async () => { + const { store, deleted } = fakeWalletStore({ + [WALLET]: { profileId: 'p1', isPrimary: true }, + GSECOND: { profileId: 'p1', isPrimary: false }, + }); + await unlinkWallet(WALLET, 'GSECOND', store); + assert.deepEqual(deleted, ['GSECOND']); +}); diff --git a/apps/web/lib/server/account.ts b/apps/web/lib/server/account.ts index eabf8e3..4797158 100644 --- a/apps/web/lib/server/account.ts +++ b/apps/web/lib/server/account.ts @@ -176,3 +176,61 @@ export async function updateAccount(address: string, update: AccountUpdate): Pro editable: true, }; } + +/** + * Minimal slice of the Prisma client `unlinkWallet` touches. Declaring it as + * an interface (mirroring the indexer worker stores) lets tests inject a + * lightweight mock instead of depending on a real database, which is how the + * cross-profile refusal below is exercised. + */ +export interface WalletStore { + wallet: { + findUnique(args: { + where: { pubkey: string }; + select: { profileId: true; isPrimary: true }; + }): Promise<{ profileId: string; isPrimary: boolean } | null>; + delete(args: { where: { pubkey: string } }): Promise; + }; +} + +/** + * Remove a wallet binding from the signed-in account's own profile. + * + * Two refusals guard this: the primary wallet is the handle→wallet claim + * itself, so removing it is a registry operation (release/transfer on-chain), + * never a dashboard edit; and a wallet bound to a *different* profile is + * refused with the same "not found" message a nonexistent pubkey gets, so one + * signed-in wallet can never delete — or even confirm the existence of — + * another profile's binding. + */ +export async function unlinkWallet( + address: string, + pubkey: string, + store?: WalletStore, +): Promise { + const db = store ?? ((await getPrisma()) as unknown as WalletStore | null); + if (!db) { + throw new Error('Wallet unlinking requires a configured database'); + } + + const caller = await db.wallet.findUnique({ + where: { pubkey: address }, + select: { profileId: true, isPrimary: true }, + }); + if (!caller) { + throw new Error('No profile is bound to this wallet yet — claim a handle on-chain first'); + } + + const target = await db.wallet.findUnique({ + where: { pubkey }, + select: { profileId: true, isPrimary: true }, + }); + if (!target || target.profileId !== caller.profileId) { + throw new Error('Wallet not found'); + } + if (target.isPrimary) { + throw new Error('Cannot unlink the primary wallet — releasing it is a registry operation'); + } + + await db.wallet.delete({ where: { pubkey } }); +} diff --git a/apps/web/lib/server/trpc.test.ts b/apps/web/lib/server/trpc.test.ts index eb692b7..72fb209 100644 --- a/apps/web/lib/server/trpc.test.ts +++ b/apps/web/lib/server/trpc.test.ts @@ -85,6 +85,41 @@ test('account.update without a database surfaces a clear error', async () => { await assert.rejects(() => c.account.update({ displayName: 'Ada', bio: 'hi' }), /database/i); }); +test('account.unlinkWallet is rejected without a session', async () => { + __resetRateLimit(); + await assert.rejects( + () => + caller('10.0.2.5').account.unlinkWallet({ + wallet: 'GDWUSKGGFDI4FRXK5EBTRECZSVQSSWJHHJOGH6JWG3AUMFFMQ435DIAG', + }), + /Unauthorized|Cross-origin/, + ); +}); + +test('account.unlinkWallet rejects a malformed wallet address', async () => { + __resetRateLimit(); + const c = authedCaller('10.0.2.6', 'GTESTADDRESS', { + host: 'localhost', + origin: 'http://localhost', + }); + await assert.rejects(() => c.account.unlinkWallet({ wallet: 'not-a-valid-address' })); +}); + +test('account.unlinkWallet without a database surfaces a clear error', async () => { + __resetRateLimit(); + const c = authedCaller('10.0.2.7', 'GTESTADDRESS', { + host: 'localhost', + origin: 'http://localhost', + }); + await assert.rejects( + () => + c.account.unlinkWallet({ + wallet: 'GDWUSKGGFDI4FRXK5EBTRECZSVQSSWJHHJOGH6JWG3AUMFFMQ435DIAG', + }), + /database/i, + ); +}); + test('rate limiter blocks a caller after the window max', async () => { __resetRateLimit(); const c = caller('10.0.0.99'); @@ -119,8 +154,8 @@ test('registry.resolve normalises the handle to lowercase', async () => { test('registry.lookup rejects a malformed wallet address', async () => { __resetRateLimit(); - await assert.rejects( - () => caller('10.0.0.13').registry.lookup({ wallet: 'not-a-valid-address' }), + await assert.rejects(() => + caller('10.0.0.13').registry.lookup({ wallet: 'not-a-valid-address' }), ); }); @@ -182,8 +217,21 @@ test('a cross-origin mutation is FORBIDDEN', async () => { host: 'signet.dev', origin: 'https://evil.example', }); + assert.equal(await codeOf(() => c.account.update({ displayName: 'x', bio: null })), 'FORBIDDEN'); +}); + +test('a cross-origin unlinkWallet mutation is FORBIDDEN', async () => { + __resetRateLimit(); + const c = authedCaller('10.0.3.6', 'GTESTADDRESS', { + host: 'signet.dev', + origin: 'https://evil.example', + }); assert.equal( - await codeOf(() => c.account.update({ displayName: 'x', bio: null })), + await codeOf(() => + c.account.unlinkWallet({ + wallet: 'GDWUSKGGFDI4FRXK5EBTRECZSVQSSWJHHJOGH6JWG3AUMFFMQ435DIAG', + }), + ), 'FORBIDDEN', ); }); diff --git a/apps/web/lib/server/trpc.ts b/apps/web/lib/server/trpc.ts index 60d952f..d033b48 100644 --- a/apps/web/lib/server/trpc.ts +++ b/apps/web/lib/server/trpc.ts @@ -10,7 +10,7 @@ import { logger } from '../logger.ts'; import { rateLimit } from '../rate-limit.ts'; import { verifySession, SESSION_COOKIE } from '../auth.ts'; import { clientIp, isSameOriginHeaders } from '../security.ts'; -import { getAccount, updateAccount, normalizeAccountUpdate } from './account.ts'; +import { getAccount, unlinkWallet, updateAccount, normalizeAccountUpdate } from './account.ts'; import { boundCount, lookupWallet, resolveHandle } from './registry-read.ts'; /** @@ -145,6 +145,14 @@ const accountRouter = router({ update: protectedProcedure .input(normalizeAccountUpdate) .mutation(({ ctx, input }) => updateAccount(ctx.address, input)), + + // Removes a wallet from the caller's own profile. `unlinkWallet` itself + // refuses the primary wallet and any wallet bound to a different profile + // (see account.ts); `protectedProcedure` supplies the session + same-origin + // guard every other mutation here gets. + unlinkWallet: protectedProcedure + .input(walletInput) + .mutation(({ ctx, input }) => unlinkWallet(ctx.address, input.wallet)), }); /**