From 06be00c75e60eb27e295bc8a4743b3b681912b5e Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:33:45 +0530 Subject: [PATCH] fix(tinyplace): wire handle transfer end-to-end (Closes #4929) (#4998) Co-authored-by: github-actions[bot] Co-authored-by: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Co-authored-by: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Co-authored-by: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Co-authored-by: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com> Co-authored-by: Steven Enamakel Co-authored-by: CodeGhost21 <164498022+CodeGhost21@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: sanil-23 Co-authored-by: M3gA-Mind Co-authored-by: oxoxDev --- .../components/TransferHandleModal.test.tsx | 95 ++++ .../components/TransferHandleModal.tsx | 155 ++++++ .../agentworld/pages/ProfilesSection.test.tsx | 70 ++- app/src/agentworld/pages/ProfilesSection.tsx | 34 ++ app/src/lib/agentworld/invokeApiClient.ts | 16 + app/src/lib/i18n/ar.ts | 12 + app/src/lib/i18n/bn.ts | 14 +- app/src/lib/i18n/de.ts | 12 + app/src/lib/i18n/en.ts | 12 + app/src/lib/i18n/es.ts | 12 + app/src/lib/i18n/fr.ts | 12 + app/src/lib/i18n/hi.ts | 12 + app/src/lib/i18n/id.ts | 12 + app/src/lib/i18n/it.ts | 12 + app/src/lib/i18n/ko.ts | 12 + app/src/lib/i18n/pl.ts | 12 + app/src/lib/i18n/pt.ts | 12 + app/src/lib/i18n/ru.ts | 12 + app/src/lib/i18n/zh-CN.ts | 10 + src/openhuman/tinyplace/manifest.rs | 477 ++++++++++++++++++ src/openhuman/tinyplace/schemas.rs | 35 ++ 21 files changed, 1048 insertions(+), 2 deletions(-) create mode 100644 app/src/agentworld/components/TransferHandleModal.test.tsx create mode 100644 app/src/agentworld/components/TransferHandleModal.tsx diff --git a/app/src/agentworld/components/TransferHandleModal.test.tsx b/app/src/agentworld/components/TransferHandleModal.test.tsx new file mode 100644 index 000000000..be9f250d5 --- /dev/null +++ b/app/src/agentworld/components/TransferHandleModal.test.tsx @@ -0,0 +1,95 @@ +/** + * Tests for TransferHandleModal (GH-4929) — the confirm + execute dialog for a + * Tiny Place handle transfer. A transfer is destructive/irreversible, so these + * assert the wiring to `apiClient.registry.transfer`, that confirm is gated on a + * recipient, and that the flow fails CLOSED (error keeps the dialog open and + * never reports success). + * + * All handles/recipients are generic placeholders, never real identities. + */ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { renderWithProviders } from '../../test/test-utils'; +import { apiClient } from '../AgentWorldShell'; +import TransferHandleModal from './TransferHandleModal'; + +vi.mock('../AgentWorldShell', () => ({ apiClient: { registry: { transfer: vi.fn() } } })); + +const transfer = vi.mocked(apiClient.registry.transfer); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function setup() { + const onClose = vi.fn(); + const onTransferred = vi.fn(); + renderWithProviders( + + ); + return { onClose, onTransferred }; +} + +describe('TransferHandleModal', () => { + test('shows the handle + irreversible warning and gates confirm on a recipient', () => { + setup(); + expect(screen.getByTestId('transfer-handle-modal')).toBeInTheDocument(); + expect(screen.getByText('@alpha')).toBeInTheDocument(); + expect(screen.getByText(/permanent and cannot be undone/i)).toBeInTheDocument(); + // Confirm is disabled until a recipient is entered. + expect(screen.getByTestId('transfer-handle-confirm')).toBeDisabled(); + }); + + test('keeps confirm disabled until the exact handle is re-typed', async () => { + const user = userEvent.setup(); + setup(); + const confirmBtn = screen.getByTestId('transfer-handle-confirm'); + // A recipient alone is not enough for a destructive action. + await user.type(screen.getByPlaceholderText(/Recipient @handle/i), 'bravo'); + expect(confirmBtn).toBeDisabled(); + // A wrong handle keeps it disabled. + await user.type(screen.getByTestId('transfer-handle-confirm-input'), 'wrong'); + expect(confirmBtn).toBeDisabled(); + // The exact handle (case- and @-insensitive) enables it. + await user.clear(screen.getByTestId('transfer-handle-confirm-input')); + await user.type(screen.getByTestId('transfer-handle-confirm-input'), '@ALPHA'); + expect(confirmBtn).toBeEnabled(); + }); + + test('confirming transfers to the resolved recipient, then closes on success', async () => { + const user = userEvent.setup(); + transfer.mockResolvedValueOnce({ identity: { username: 'alpha' } as never }); + const { onClose, onTransferred } = setup(); + + await user.type(screen.getByPlaceholderText(/Recipient @handle/i), '@bravo'); + // The irreversible action is gated behind re-typing the handle. + await user.type(screen.getByTestId('transfer-handle-confirm-input'), '@alpha'); + await user.click(screen.getByTestId('transfer-handle-confirm')); + + // Leading @ is stripped before the RPC; handle passed through verbatim. + await waitFor(() => expect(transfer).toHaveBeenCalledWith('alpha', 'bravo')); + await waitFor(() => expect(onTransferred).toHaveBeenCalledTimes(1)); + expect(onClose).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId('transfer-handle-error')).not.toBeInTheDocument(); + }); + + test('fails closed: on error it shows the message and does not report success', async () => { + const user = userEvent.setup(); + transfer.mockRejectedValueOnce(new Error('recipient handle is not registered on tiny.place')); + const { onClose, onTransferred } = setup(); + + await user.type(screen.getByPlaceholderText(/Recipient @handle/i), 'bravo'); + await user.type(screen.getByTestId('transfer-handle-confirm-input'), 'alpha'); + await user.click(screen.getByTestId('transfer-handle-confirm')); + + await waitFor(() => + expect(screen.getByTestId('transfer-handle-error')).toHaveTextContent(/not registered/i) + ); + // Fail closed: no success callbacks, dialog stays open. + expect(onTransferred).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.getByTestId('transfer-handle-modal')).toBeInTheDocument(); + }); +}); diff --git a/app/src/agentworld/components/TransferHandleModal.tsx b/app/src/agentworld/components/TransferHandleModal.tsx new file mode 100644 index 000000000..98464ca2b --- /dev/null +++ b/app/src/agentworld/components/TransferHandleModal.tsx @@ -0,0 +1,155 @@ +/** + * TransferHandleModal — confirm + execute a Tiny Place handle transfer (GH-4929). + * + * A handle transfer is DESTRUCTIVE and irreversible for the sender: on success + * the recipient becomes the handle's sole owner. So this modal states that + * plainly, requires an explicit recipient, requires the user to re-type the + * handle to confirm intent, and takes an explicit confirm click — and it fails + * **closed**: on any error it keeps the dialog open with the message and never + * reports success. The core handler resolves the recipient @handle and + * read-back-confirms the new owner before this promise resolves, so a resolved + * transfer means the reassignment actually landed. + */ +import debugFactory from 'debug'; +import { useCallback, useState } from 'react'; + +import Button from '../../components/ui/Button'; +import { ModalShell } from '../../components/ui/ModalShell'; +import { useT } from '../../lib/i18n/I18nContext'; +import { apiClient } from '../AgentWorldShell'; + +// Namespaced already ('agentworld:identity'), so messages carry no prefix. +const debug = debugFactory('agentworld:identity'); + +export interface TransferHandleModalProps { + /** The handle being transferred away (without a leading @). */ + handle: string; + onClose: () => void; + /** Called after a confirmed, read-back-verified transfer. */ + onTransferred: () => void; +} + +/** Normalize a handle for comparison: strip leading @, trim, lowercase. */ +function normalizeHandle(value: string): string { + return value.trim().replace(/^@+/, '').toLowerCase(); +} + +export default function TransferHandleModal({ + handle, + onClose, + onTransferred, +}: TransferHandleModalProps) { + const { t } = useT(); + const [recipient, setRecipient] = useState(''); + const [confirmText, setConfirmText] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const handleClean = handle.replace(/^@+/, ''); + // Guard the irreversible action: the user must re-type the exact handle. + const confirmMatches = normalizeHandle(confirmText) === normalizeHandle(handleClean); + + const submit = useCallback(async () => { + const target = recipient.trim().replace(/^@+/, ''); + if (!target) { + setError(t('agentWorld.transferHandle.recipientRequired')); + return; + } + // Belt-and-suspenders: the button is disabled without a match, but never + // execute a destructive transfer unless the typed confirmation matches. + if (!confirmMatches) { + setError(t('agentWorld.transferHandle.confirmMismatch')); + return; + } + setSubmitting(true); + setError(null); + // Never log the handle or recipient — both identify a user. + debug('handle transfer requested'); + try { + // Send the normalized handle (not the raw prop) so the invariant is local + // and doesn't rest on every caller pre-cleaning the value (#4998 review). + await apiClient.registry.transfer(handleClean, target); + debug('handle transfer confirmed'); + onTransferred(); + onClose(); + } catch (err) { + // Fail closed: keep the dialog open, show why, report no success. + // Log only the status (no raw error — it can carry backend/SDK detail); + // the raw message still surfaces in the UI via setError. + debug('handle transfer failed'); + setError(String(err)); + setSubmitting(false); + } + }, [recipient, confirmMatches, handleClean, t, onTransferred, onClose]); + + return ( + undefined : onClose}> +
+

@{handleClean}

+

+ {t('agentWorld.transferHandle.warning')} +

+ + { + setRecipient(e.target.value); + setError(null); + }} + disabled={submitting} + placeholder={t('agentWorld.transferHandle.recipientPlaceholder')} + aria-label={t('agentWorld.transferHandle.recipientPlaceholder')} + className="w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-content placeholder-content-faint outline-none focus:border-primary-500" + /> + + {/* Type-to-confirm guard for the irreversible action. */} +
+

+ {t('agentWorld.transferHandle.confirmLabel')} +

+ { + setConfirmText(e.target.value); + setError(null); + }} + disabled={submitting} + placeholder={`@${handleClean}`} + aria-label={t('agentWorld.transferHandle.confirmLabel')} + data-testid="transfer-handle-confirm-input" + className="w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-content placeholder-content-faint outline-none focus:border-primary-500" + /> +
+ + {error && ( +

+ {error} +

+ )} + +
+ + +
+
+
+ ); +} diff --git a/app/src/agentworld/pages/ProfilesSection.test.tsx b/app/src/agentworld/pages/ProfilesSection.test.tsx index ddcea8893..5469436d8 100644 --- a/app/src/agentworld/pages/ProfilesSection.test.tsx +++ b/app/src/agentworld/pages/ProfilesSection.test.tsx @@ -19,7 +19,7 @@ vi.mock('../AgentWorldShell', () => ({ apiClient: { directory: { reverse: vi.fn() }, follows: { stats: vi.fn() }, - registry: { export: vi.fn(), assignPrimary: vi.fn() }, + registry: { export: vi.fn(), assignPrimary: vi.fn(), transfer: vi.fn() }, graphql: { user: vi.fn() }, users: { get: vi.fn(), updateProfile: vi.fn() }, }, @@ -362,6 +362,74 @@ function makeProfile(overrides: Partial = {}): GqlProfile { }; } +// GH-4929: a non-primary owned handle offers an enabled Transfer action that +// opens the destructive-confirm modal. #4998 M3: a primary (active) handle now +// renders a DISABLED Transfer control with an explanation (make another handle +// active first) instead of silently omitting it. A handle with no explicit +// primary flag (unknown state) shows no Transfer control at all. +describe('handle transfer action', () => { + test('opens the transfer confirm modal for a non-primary owned handle', async () => { + graphqlUser.mockResolvedValueOnce( + makeProfile({ + displayName: 'Owner', + identities: [ + { ...minimalIdentity, username: 'primaryhandle', cryptoId: SOLANA_ADDR, primary: true }, + { ...minimalIdentity, username: 'giftme', cryptoId: SOLANA_ADDR, primary: false }, + ], + }) + ); + render(); + + // Both handles render a Transfer control (#4998 M3): the non-primary one is + // enabled, the primary one disabled. Pick the enabled one — it opens the modal. + const transferButtons = await screen.findAllByRole('button', { name: 'Transfer' }); + const enabled = transferButtons.find(b => !(b as HTMLButtonElement).disabled); + expect(enabled, 'the non-primary handle must expose an enabled Transfer control').toBeDefined(); + fireEvent.click(enabled!); + + // The destructive-confirm modal opens with its explicit confirm control. + expect(await screen.findByTestId('transfer-handle-modal')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Transfer handle' })).toBeInTheDocument(); + }); + + test('shows a disabled Transfer control with an explanation for a primary handle (#4998 M3)', async () => { + graphqlUser.mockResolvedValueOnce( + makeProfile({ + displayName: 'Owner', + identities: [ + { ...minimalIdentity, username: 'onlyprimary', cryptoId: SOLANA_ADDR, primary: true }, + ], + }) + ); + render(); + + // Rendered, but disabled and explained — not an invisible dead end. + const transferButton = await screen.findByRole('button', { name: 'Transfer' }); + expect(transferButton).toBeDisabled(); + expect(transferButton).toHaveAttribute( + 'title', + 'A primary handle cannot be transferred. Make another handle active first.' + ); + + // Clicking the locked control must NOT open the destructive modal. + fireEvent.click(transferButton); + expect(screen.queryByTestId('transfer-handle-modal')).not.toBeInTheDocument(); + }); + + test('shows no Transfer control when the primary flag is absent (unknown state)', async () => { + graphqlUser.mockResolvedValueOnce( + makeProfile({ + displayName: 'Owner', + identities: [{ ...minimalIdentity, username: 'unknownstate', cryptoId: SOLANA_ADDR }], + }) + ); + render(); + + expect(await screen.findByRole('button', { name: 'Make active' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Transfer' })).not.toBeInTheDocument(); + }); +}); + describe('graphql-enriched profile card', () => { test('renders rich profile from graphql.user when available', async () => { graphqlUser.mockResolvedValueOnce( diff --git a/app/src/agentworld/pages/ProfilesSection.tsx b/app/src/agentworld/pages/ProfilesSection.tsx index 29268e8c9..f86bcce22 100644 --- a/app/src/agentworld/pages/ProfilesSection.tsx +++ b/app/src/agentworld/pages/ProfilesSection.tsx @@ -22,6 +22,7 @@ import { import { useT } from '../../lib/i18n/I18nContext'; import { fetchWalletStatus } from '../../services/walletApi'; import { apiClient } from '../AgentWorldShell'; +import TransferHandleModal from '../components/TransferHandleModal'; const log = debug('agentworld:profile'); @@ -348,6 +349,8 @@ function AgentProfileCard({ data, onSwitched }: { data: ProfileData; onSwitched? // Handle currently being promoted to primary (in-flight), and any error. const [switchingHandle, setSwitchingHandle] = useState(null); const [switchError, setSwitchError] = useState(null); + // The owned handle whose transfer modal is open (without @), or null. + const [transferHandle, setTransferHandle] = useState(null); // ── Extract display fields from either data source ───────────────────────── const isGraphql = data.source === 'graphql'; @@ -568,6 +571,29 @@ function AgentProfileCard({ data, onSwitched }: { data: ProfileData; onSwitched? : 'Make active'} )} + {/* Transfer is destructive/irreversible — the modal confirms + intent and fails closed. A primary (active) handle is + locked from sale/transfer server-side, so it renders + disabled with an explanation of the path out (make another + handle active first) rather than silently vanishing (#4998 + M3). Only an explicit non-primary handle is transferable. */} + {id.primary === false && ( + + )} + {id.primary === true && ( + + )} {id.status} @@ -581,6 +607,14 @@ function AgentProfileCard({ data, onSwitched }: { data: ProfileData; onSwitched? )} + {transferHandle && ( + setTransferHandle(null)} + onTransferred={() => onSwitched?.()} + /> + )} + {followStats && (
diff --git a/app/src/lib/agentworld/invokeApiClient.ts b/app/src/lib/agentworld/invokeApiClient.ts index 1023e403a..e9386f5bf 100644 --- a/app/src/lib/agentworld/invokeApiClient.ts +++ b/app/src/lib/agentworld/invokeApiClient.ts @@ -336,6 +336,12 @@ export interface AssignPrimaryResult { [key: string]: unknown; } +/** Result of a handle transfer: the Identity now owned by the recipient. */ +export interface TransferHandleResult { + identity?: Identity; + [key: string]: unknown; +} + // -- Registry export types ------------------------------------------------ export interface LedgerReference { @@ -1863,6 +1869,16 @@ export function createInvokeApiClient() { */ assignPrimary: (name: string) => call('openhuman.tinyplace_registry_assign_primary', { name }), + /** + * Transfer one of the wallet's handles to another tiny.place identity + * (#4929). DESTRUCTIVE + irreversible: on success the `recipient` handle's + * owner becomes the sole owner of `name`. The recipient is resolved from + * their @handle server-side; an unregistered recipient or an unconfirmed + * read-back fails closed. The owning wallet is proven by the + * signer-attached signature, not by params. + */ + transfer: (name: string, recipient: string) => + call('openhuman.tinyplace_registry_transfer', { name, recipient }), }, directoryIdentities: { /** List identity listings from the directory. */ diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 33b2e136d..45986f039 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -498,6 +498,18 @@ const messages: TranslationMap = { 'agentWorld.directory.profile.loadError': 'تعذّر تحميل الملف الشخصي الكامل.', 'agentWorld.identities': 'الهويات', 'agentWorld.profiles': 'الملفات الشخصية', + 'agentWorld.transferHandle.action': 'نقل', + 'agentWorld.transferHandle.title': 'نقل المعرّف', + 'agentWorld.transferHandle.warning': + 'نقل المعرّف نهائي ولا يمكن التراجع عنه. يصبح المستلم مالكه الوحيد.', + 'agentWorld.transferHandle.recipientPlaceholder': 'معرّف@ المستلم', + 'agentWorld.transferHandle.confirm': 'نقل المعرّف', + 'agentWorld.transferHandle.submitting': 'جارٍ النقل…', + 'agentWorld.transferHandle.recipientRequired': 'أدخل معرّف المستلم.', + 'agentWorld.transferHandle.confirmLabel': 'اكتب المعرّف للتأكيد', + 'agentWorld.transferHandle.confirmMismatch': 'المعرّف المكتوب غير مطابق.', + 'agentWorld.transferHandle.primaryLocked': + 'لا يمكن نقل المعرّف الأساسي. اجعل معرّفًا آخر نشطًا أولًا.', 'agentWorld.profile.edit': 'تعديل الملف الشخصي', 'agentWorld.profile.displayName': 'الاسم المعروض', 'agentWorld.profile.bio': 'نبذة', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 917a5e74c..aa378de5e 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -487,7 +487,7 @@ const messages: TranslationMap = { 'agentWorld.profileViewer.notFoundTitle': 'প্রোফাইল পাওয়া যায়নি', 'agentWorld.profileViewer.notFoundBody': 'এই হ্যান্ডেলের জন্য এখনও কোনো প্রকাশিত প্রোফাইল নেই।', 'agentWorld.profileViewer.errorTitle': 'প্রোফাইল লোড করা যায়নি', - 'agentWorld.profileViewer.follow': 'অনুসরণ', + 'agentWorld.profileViewer.follow': 'অনুসরণ করুন', 'agentWorld.profileViewer.following': 'অনুসরণ করছেন', 'agentWorld.profileViewer.copyLink': 'লিঙ্ক কপি করুন', 'agentWorld.profileViewer.linkCopied': 'লিঙ্ক কপি হয়েছে', @@ -514,6 +514,18 @@ const messages: TranslationMap = { 'agentWorld.directory.profile.loadError': 'সম্পূর্ণ প্রোফাইল লোড করা যায়নি।', 'agentWorld.identities': 'পরিচয়', 'agentWorld.profiles': 'প্রোফাইল', + 'agentWorld.transferHandle.action': 'স্থানান্তর', + 'agentWorld.transferHandle.title': 'হ্যান্ডেল স্থানান্তর', + 'agentWorld.transferHandle.warning': + 'হ্যান্ডেল স্থানান্তর স্থায়ী এবং এটি ফেরানো যায় না। প্রাপক একমাত্র মালিক হয়ে যাবেন।', + 'agentWorld.transferHandle.recipientPlaceholder': 'প্রাপকের @handle', + 'agentWorld.transferHandle.confirm': 'হ্যান্ডেল স্থানান্তর', + 'agentWorld.transferHandle.submitting': 'স্থানান্তর করা হচ্ছে…', + 'agentWorld.transferHandle.recipientRequired': 'প্রাপকের হ্যান্ডেল লিখুন।', + 'agentWorld.transferHandle.confirmLabel': 'নিশ্চিত করতে হ্যান্ডেলটি টাইপ করুন', + 'agentWorld.transferHandle.confirmMismatch': 'টাইপ করা হ্যান্ডেল মিলছে না।', + 'agentWorld.transferHandle.primaryLocked': + 'প্রাথমিক হ্যান্ডেল স্থানান্তর করা যায় না। প্রথমে অন্য একটি হ্যান্ডেল সক্রিয় করুন।', 'agentWorld.profile.edit': 'প্রোফাইল সম্পাদনা', 'agentWorld.profile.displayName': 'প্রদর্শন নাম', 'agentWorld.profile.bio': 'পরিচিতি', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index aedf03a3c..a4caa901c 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -541,6 +541,18 @@ const messages: TranslationMap = { 'agentWorld.directory.profile.loadError': 'Vollständiges Profil konnte nicht geladen werden.', 'agentWorld.identities': 'Identitäten', 'agentWorld.profiles': 'Profile', + 'agentWorld.transferHandle.action': 'Übertragen', + 'agentWorld.transferHandle.title': 'Handle übertragen', + 'agentWorld.transferHandle.warning': + 'Das Übertragen eines Handles ist dauerhaft und kann nicht rückgängig gemacht werden. Der Empfänger wird alleiniger Eigentümer.', + 'agentWorld.transferHandle.recipientPlaceholder': 'Empfänger-@handle', + 'agentWorld.transferHandle.confirm': 'Handle übertragen', + 'agentWorld.transferHandle.submitting': 'Wird übertragen…', + 'agentWorld.transferHandle.recipientRequired': 'Gib den Empfänger-Handle ein.', + 'agentWorld.transferHandle.confirmLabel': 'Zum Bestätigen den Handle eingeben', + 'agentWorld.transferHandle.confirmMismatch': 'Der eingegebene Handle stimmt nicht überein.', + 'agentWorld.transferHandle.primaryLocked': + 'Ein primäres Handle kann nicht übertragen werden. Mache zuerst ein anderes Handle aktiv.', 'agentWorld.profile.edit': 'Profil bearbeiten', 'agentWorld.profile.displayName': 'Anzeigename', 'agentWorld.profile.bio': 'Bio', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 32241d06c..2f644781e 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -221,6 +221,18 @@ const en: TranslationMap = { 'agentWorld.directory.profile.loadError': "Couldn't load the full profile.", 'agentWorld.identities': 'Identities', 'agentWorld.profiles': 'Profiles', + 'agentWorld.transferHandle.action': 'Transfer', + 'agentWorld.transferHandle.title': 'Transfer handle', + 'agentWorld.transferHandle.warning': + 'Transferring a handle is permanent and cannot be undone. The recipient becomes its sole owner.', + 'agentWorld.transferHandle.recipientPlaceholder': 'Recipient @handle', + 'agentWorld.transferHandle.confirm': 'Transfer handle', + 'agentWorld.transferHandle.submitting': 'Transferring…', + 'agentWorld.transferHandle.recipientRequired': 'Enter the recipient handle.', + 'agentWorld.transferHandle.confirmLabel': 'Type the handle to confirm', + 'agentWorld.transferHandle.confirmMismatch': "The typed handle doesn't match.", + 'agentWorld.transferHandle.primaryLocked': + 'A primary handle cannot be transferred. Make another handle active first.', 'agentWorld.profile.edit': 'Edit profile', 'agentWorld.profile.displayName': 'Display name', 'agentWorld.profile.bio': 'Bio', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 8da92e799..c90000eab 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -525,6 +525,18 @@ const messages: TranslationMap = { 'agentWorld.directory.profile.loadError': 'No se pudo cargar el perfil completo.', 'agentWorld.identities': 'Identidades', 'agentWorld.profiles': 'Perfiles', + 'agentWorld.transferHandle.action': 'Transferir', + 'agentWorld.transferHandle.title': 'Transferir handle', + 'agentWorld.transferHandle.warning': + 'Transferir un handle es permanente y no se puede deshacer. El destinatario se convierte en su único propietario.', + 'agentWorld.transferHandle.recipientPlaceholder': '@handle del destinatario', + 'agentWorld.transferHandle.confirm': 'Transferir handle', + 'agentWorld.transferHandle.submitting': 'Transfiriendo…', + 'agentWorld.transferHandle.recipientRequired': 'Introduce el handle del destinatario.', + 'agentWorld.transferHandle.confirmLabel': 'Escribe el handle para confirmar', + 'agentWorld.transferHandle.confirmMismatch': 'El handle escrito no coincide.', + 'agentWorld.transferHandle.primaryLocked': + 'Un identificador principal no se puede transferir. Activa primero otro identificador.', 'agentWorld.profile.edit': 'Editar perfil', 'agentWorld.profile.displayName': 'Nombre visible', 'agentWorld.profile.bio': 'Biografía', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 8ce9af17f..7e48babb8 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -534,6 +534,18 @@ const messages: TranslationMap = { 'agentWorld.directory.profile.loadError': 'Impossible de charger le profil complet.', 'agentWorld.identities': 'Identités', 'agentWorld.profiles': 'Profils', + 'agentWorld.transferHandle.action': 'Transférer', + 'agentWorld.transferHandle.title': 'Transférer le handle', + 'agentWorld.transferHandle.warning': + "Le transfert d'un handle est définitif et irréversible. Le destinataire en devient le seul propriétaire.", + 'agentWorld.transferHandle.recipientPlaceholder': '@handle du destinataire', + 'agentWorld.transferHandle.confirm': 'Transférer le handle', + 'agentWorld.transferHandle.submitting': 'Transfert…', + 'agentWorld.transferHandle.recipientRequired': 'Saisissez le handle du destinataire.', + 'agentWorld.transferHandle.confirmLabel': 'Saisissez le handle pour confirmer', + 'agentWorld.transferHandle.confirmMismatch': 'Le handle saisi ne correspond pas.', + 'agentWorld.transferHandle.primaryLocked': + "Un identifiant principal ne peut pas être transféré. Activez d'abord un autre identifiant.", 'agentWorld.profile.edit': 'Modifier le profil', 'agentWorld.profile.displayName': 'Nom affiché', 'agentWorld.profile.bio': 'Bio', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index df6772c6f..ad4b7b88d 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -514,6 +514,18 @@ const messages: TranslationMap = { 'agentWorld.directory.profile.loadError': 'पूरा प्रोफ़ाइल लोड नहीं हो सका।', 'agentWorld.identities': 'पहचान', 'agentWorld.profiles': 'प्रोफ़ाइल', + 'agentWorld.transferHandle.action': 'स्थानांतरित करें', + 'agentWorld.transferHandle.title': 'हैंडल स्थानांतरित करें', + 'agentWorld.transferHandle.warning': + 'हैंडल स्थानांतरण स्थायी है और इसे पूर्ववत नहीं किया जा सकता। प्राप्तकर्ता इसका एकमात्र स्वामी बन जाता है।', + 'agentWorld.transferHandle.recipientPlaceholder': 'प्राप्तकर्ता का @handle', + 'agentWorld.transferHandle.confirm': 'हैंडल स्थानांतरित करें', + 'agentWorld.transferHandle.submitting': 'स्थानांतरित किया जा रहा है…', + 'agentWorld.transferHandle.recipientRequired': 'प्राप्तकर्ता का हैंडल दर्ज करें।', + 'agentWorld.transferHandle.confirmLabel': 'पुष्टि के लिए हैंडल टाइप करें', + 'agentWorld.transferHandle.confirmMismatch': 'टाइप किया गया हैंडल मेल नहीं खाता।', + 'agentWorld.transferHandle.primaryLocked': + 'प्राथमिक हैंडल स्थानांतरित नहीं किया जा सकता। पहले कोई अन्य हैंडल सक्रिय करें।', 'agentWorld.profile.edit': 'प्रोफ़ाइल संपादित करें', 'agentWorld.profile.displayName': 'प्रदर्शित नाम', 'agentWorld.profile.bio': 'परिचय', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index d16a94f35..c100de505 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -520,6 +520,18 @@ const messages: TranslationMap = { 'agentWorld.directory.profile.loadError': 'Tidak dapat memuat profil lengkap.', 'agentWorld.identities': 'Identitas', 'agentWorld.profiles': 'Profil', + 'agentWorld.transferHandle.action': 'Pindahkan', + 'agentWorld.transferHandle.title': 'Pindahkan handle', + 'agentWorld.transferHandle.warning': + 'Memindahkan handle bersifat permanen dan tidak dapat dibatalkan. Penerima menjadi satu-satunya pemilik.', + 'agentWorld.transferHandle.recipientPlaceholder': '@handle penerima', + 'agentWorld.transferHandle.confirm': 'Pindahkan handle', + 'agentWorld.transferHandle.submitting': 'Memindahkan…', + 'agentWorld.transferHandle.recipientRequired': 'Masukkan handle penerima.', + 'agentWorld.transferHandle.confirmLabel': 'Ketik handle untuk mengonfirmasi', + 'agentWorld.transferHandle.confirmMismatch': 'Handle yang diketik tidak cocok.', + 'agentWorld.transferHandle.primaryLocked': + 'Handle utama tidak dapat ditransfer. Aktifkan handle lain terlebih dahulu.', 'agentWorld.profile.edit': 'Edit profil', 'agentWorld.profile.displayName': 'Nama tampilan', 'agentWorld.profile.bio': 'Bio', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 9196f50b3..89d425190 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -528,6 +528,18 @@ const messages: TranslationMap = { 'agentWorld.directory.profile.loadError': 'Impossibile caricare il profilo completo.', 'agentWorld.identities': 'Identità', 'agentWorld.profiles': 'Profili', + 'agentWorld.transferHandle.action': 'Trasferisci', + 'agentWorld.transferHandle.title': 'Trasferisci handle', + 'agentWorld.transferHandle.warning': + "Il trasferimento di un handle è permanente e non può essere annullato. Il destinatario ne diventa l'unico proprietario.", + 'agentWorld.transferHandle.recipientPlaceholder': '@handle del destinatario', + 'agentWorld.transferHandle.confirm': 'Trasferisci handle', + 'agentWorld.transferHandle.submitting': 'Trasferimento…', + 'agentWorld.transferHandle.recipientRequired': "Inserisci l'handle del destinatario.", + 'agentWorld.transferHandle.confirmLabel': "Digita l'handle per confermare", + 'agentWorld.transferHandle.confirmMismatch': "L'handle digitato non corrisponde.", + 'agentWorld.transferHandle.primaryLocked': + 'Un handle primario non può essere trasferito. Rendi prima attivo un altro handle.', 'agentWorld.profile.edit': 'Modifica profilo', 'agentWorld.profile.displayName': 'Nome visualizzato', 'agentWorld.profile.bio': 'Bio', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 7af3e8c14..b0601b9eb 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -507,6 +507,18 @@ const messages: TranslationMap = { 'agentWorld.directory.profile.loadError': '전체 프로필을 불러오지 못했습니다.', 'agentWorld.identities': '아이덴티티', 'agentWorld.profiles': '프로필', + 'agentWorld.transferHandle.action': '이전', + 'agentWorld.transferHandle.title': '핸들 이전', + 'agentWorld.transferHandle.warning': + '핸들 이전은 영구적이며 되돌릴 수 없습니다. 수신자가 유일한 소유자가 됩니다.', + 'agentWorld.transferHandle.recipientPlaceholder': '수신자 @handle', + 'agentWorld.transferHandle.confirm': '핸들 이전', + 'agentWorld.transferHandle.submitting': '이전 중…', + 'agentWorld.transferHandle.recipientRequired': '수신자 핸들을 입력하세요.', + 'agentWorld.transferHandle.confirmLabel': '확인하려면 핸들을 입력하세요', + 'agentWorld.transferHandle.confirmMismatch': '입력한 핸들이 일치하지 않습니다.', + 'agentWorld.transferHandle.primaryLocked': + '기본 핸들은 이전할 수 없습니다. 먼저 다른 핸들을 활성화하세요.', 'agentWorld.profile.edit': '프로필 편집', 'agentWorld.profile.displayName': '표시 이름', 'agentWorld.profile.bio': '소개', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index c67e7cea0..737288fdf 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -527,6 +527,18 @@ const messages: TranslationMap = { 'agentWorld.directory.profile.loadError': 'Nie udało się załadować pełnego profilu.', 'agentWorld.identities': 'Tożsamości', 'agentWorld.profiles': 'Profile', + 'agentWorld.transferHandle.action': 'Przekaż', + 'agentWorld.transferHandle.title': 'Przekaż handle', + 'agentWorld.transferHandle.warning': + "Przekazanie handle'a jest trwałe i nie można go cofnąć. Odbiorca staje się jego jedynym właścicielem.", + 'agentWorld.transferHandle.recipientPlaceholder': '@handle odbiorcy', + 'agentWorld.transferHandle.confirm': 'Przekaż handle', + 'agentWorld.transferHandle.submitting': 'Przekazywanie…', + 'agentWorld.transferHandle.recipientRequired': 'Podaj handle odbiorcy.', + 'agentWorld.transferHandle.confirmLabel': 'Wpisz handle, aby potwierdzić', + 'agentWorld.transferHandle.confirmMismatch': 'Wpisany handle nie pasuje.', + 'agentWorld.transferHandle.primaryLocked': + "Podstawowego handle'a nie można przekazać. Najpierw ustaw inny handle jako aktywny.", 'agentWorld.profile.edit': 'Edytuj profil', 'agentWorld.profile.displayName': 'Wyświetlana nazwa', 'agentWorld.profile.bio': 'Bio', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 7c7ebeeeb..6169621c2 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -520,6 +520,18 @@ const messages: TranslationMap = { 'agentWorld.directory.profile.loadError': 'Não foi possível carregar o perfil completo.', 'agentWorld.identities': 'Identidades', 'agentWorld.profiles': 'Perfis', + 'agentWorld.transferHandle.action': 'Transferir', + 'agentWorld.transferHandle.title': 'Transferir handle', + 'agentWorld.transferHandle.warning': + 'Transferir um handle é permanente e não pode ser desfeito. O destinatário torna-se o seu único proprietário.', + 'agentWorld.transferHandle.recipientPlaceholder': '@handle do destinatário', + 'agentWorld.transferHandle.confirm': 'Transferir handle', + 'agentWorld.transferHandle.submitting': 'A transferir…', + 'agentWorld.transferHandle.recipientRequired': 'Introduza o handle do destinatário.', + 'agentWorld.transferHandle.confirmLabel': 'Digite o handle para confirmar', + 'agentWorld.transferHandle.confirmMismatch': 'O handle digitado não corresponde.', + 'agentWorld.transferHandle.primaryLocked': + 'Um identificador principal não pode ser transferido. Ative outro identificador primeiro.', 'agentWorld.profile.edit': 'Editar perfil', 'agentWorld.profile.displayName': 'Nome de exibição', 'agentWorld.profile.bio': 'Bio', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 9d382dee4..909afae43 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -520,6 +520,18 @@ const messages: TranslationMap = { 'agentWorld.directory.profile.loadError': 'Не удалось загрузить полный профиль.', 'agentWorld.identities': 'Идентичности', 'agentWorld.profiles': 'Профили', + 'agentWorld.transferHandle.action': 'Передать', + 'agentWorld.transferHandle.title': 'Передать хэндл', + 'agentWorld.transferHandle.warning': + 'Передача хэндла необратима и не может быть отменена. Получатель становится его единственным владельцем.', + 'agentWorld.transferHandle.recipientPlaceholder': '@handle получателя', + 'agentWorld.transferHandle.confirm': 'Передать хэндл', + 'agentWorld.transferHandle.submitting': 'Передача…', + 'agentWorld.transferHandle.recipientRequired': 'Введите хэндл получателя.', + 'agentWorld.transferHandle.confirmLabel': 'Введите хэндл для подтверждения', + 'agentWorld.transferHandle.confirmMismatch': 'Введённый хэндл не совпадает.', + 'agentWorld.transferHandle.primaryLocked': + 'Основной хэндл нельзя передать. Сначала сделайте активным другой хэндл.', 'agentWorld.profile.edit': 'Изменить профиль', 'agentWorld.profile.displayName': 'Отображаемое имя', 'agentWorld.profile.bio': 'О себе', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index a7c7a1030..437620a9f 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -482,6 +482,16 @@ const messages: TranslationMap = { 'agentWorld.directory.profile.loadError': '无法加载完整资料。', 'agentWorld.identities': '身份', 'agentWorld.profiles': '档案', + 'agentWorld.transferHandle.action': '转移', + 'agentWorld.transferHandle.title': '转移句柄', + 'agentWorld.transferHandle.warning': '句柄转移是永久性的,无法撤销。接收者将成为其唯一所有者。', + 'agentWorld.transferHandle.recipientPlaceholder': '接收者 @handle', + 'agentWorld.transferHandle.confirm': '转移句柄', + 'agentWorld.transferHandle.submitting': '正在转移…', + 'agentWorld.transferHandle.recipientRequired': '请输入接收者的句柄。', + 'agentWorld.transferHandle.confirmLabel': '输入句柄以确认', + 'agentWorld.transferHandle.confirmMismatch': '输入的句柄不匹配。', + 'agentWorld.transferHandle.primaryLocked': '无法转移主用户名。请先将另一个用户名设为活跃。', 'agentWorld.profile.edit': '编辑资料', 'agentWorld.profile.displayName': '显示名称', 'agentWorld.profile.bio': '简介', diff --git a/src/openhuman/tinyplace/manifest.rs b/src/openhuman/tinyplace/manifest.rs index fabc43a6b..bb57f642d 100644 --- a/src/openhuman/tinyplace/manifest.rs +++ b/src/openhuman/tinyplace/manifest.rs @@ -2496,6 +2496,249 @@ pub(crate) fn handle_tinyplace_registry_assign_primary( }) } +fn transfer_allowed_by_primary_state(primary: Option) -> bool { + primary == Some(false) +} + +/// Transfer one of the wallet's handles to another tiny.place identity +/// (gift / account move, #4929). DESTRUCTIVE and irreversible for the sender: +/// on success the recipient becomes the sole owner of `name`. +/// +/// The recipient is given as a `recipient` handle (with or without a leading +/// @) — we resolve it to the recipient's `cryptoId` + `publicKey` via +/// `registry.get`, so the caller never has to know raw key material and an +/// unresolvable recipient fails **closed** before anything is signed. The +/// SDK attaches the owning wallet's signature over the transfer payload, so +/// the backend only lets a caller transfer a handle their *own* wallet owns. +/// +/// Ordering (read-only preflight → sign+POST → real read-back): +/// 1. Resolve the recipient and validate it is a live registration whose +/// `available`/`status` don't flag a stale/expired record (M2, #4998). +/// 2. Read the sender's *own* current ownership. If it already reads back as +/// the recipient — a retry after a lost-but-applied POST — return the +/// existing state without re-signing (idempotency, M1, #4998), and reject a +/// self-transfer / unregistered / primary handle before signing. +/// 3. Sign + POST the transfer. +/// 4. **Read-back via `registry.get`**, NOT the POST response body. The POST has +/// already returned 2xx by this point, so a mismatch means "submitted but +/// unconfirmed", never "did not happen" — we never claim the handle wasn't +/// reassigned, because the handler cannot know that (B1, #4998). +pub(crate) fn handle_tinyplace_registry_transfer(params: Map) -> ControllerFuture { + Box::pin(async move { + let name = req_str(¶ms, "name")? + .trim() + .trim_start_matches('@') + .to_string(); + if name.is_empty() { + return Err("missing required param 'name'".to_string()); + } + let recipient = req_str(¶ms, "recipient")? + .trim() + .trim_start_matches('@') + .to_string(); + if recipient.is_empty() { + return Err("missing required param 'recipient'".to_string()); + } + // Cheap reject before any network/signing: a self-transfer burns a + // signature as a no-op and would read back as trivially "confirmed". + if name.eq_ignore_ascii_case(&recipient) { + return Err("cannot transfer a handle to itself".to_string()); + } + // Never log the handle or recipient — both are user-identifying. + log::debug!("{LOG_PREFIX} registry_transfer requested"); + + let client = global_state().client().await?; + + // (1) Resolve the recipient handle to a verified cryptoId + publicKey. + // An unknown/expired/available recipient fails closed here, before we + // sign. We query with the `@`-prefixed form, matching the proven + // availability path (`useHandleAvailability` → registry.get(`@handle`)). + let availability = client + .registry + .get(&format!("@{recipient}")) + .await + .map_err(map_err)?; + let (recipient_crypto_id, recipient_public_key) = + validate_recipient_availability(&availability)?; + log::debug!("{LOG_PREFIX} registry_transfer recipient resolved"); + + // (2) Read the sender's own current ownership BEFORE signing. This + // hoisted, side-effect-free read lets us (a) short-circuit a retry whose + // transfer already landed, and (b) reject a not-registered / primary + // handle with the right diagnosis — separately from the nonexistent case. + let own = client + .registry + .get(&format!("@{name}")) + .await + .map_err(map_err)?; + match preflight_own_handle(&own, &recipient_crypto_id) { + OwnHandlePreflight::AlreadyTransferred => { + // Idempotency (M1): the handle already reads back as the + // recipient — a prior POST landed but its response was lost. Do + // NOT re-sign; report the existing state. + log::debug!("{LOG_PREFIX} registry_transfer already applied; idempotent no-op"); + return to_value(serde_json::json!({ + "identity": own.identity, + "alreadyTransferred": true, + })); + } + OwnHandlePreflight::Reject(message) => { + log::warn!("{LOG_PREFIX} registry_transfer rejected in preflight"); + return Err(message); + } + OwnHandlePreflight::Proceed => {} + } + + // (3) Sign + POST the transfer. + let request = tinyplace::types::IdentityTransferRequest { + crypto_id: recipient_crypto_id.clone(), + public_key: recipient_public_key, + ..Default::default() + }; + client + .registry + .transfer(&name, request) + .await + .map_err(map_err)?; + + // (4) Real read-back: re-query the registry rather than trusting the POST + // response body (`Identity::crypto_id` is `#[serde(default)]`, so an + // enveloped/renamed/partial body deserializes to "" and would falsely + // fail a transfer that DID land — B1). The transfer is already submitted, + // so an unconfirmed read-back is "submitted but unconfirmed", NOT "did + // not happen": we must never tell the user they still own it. + let readback_identity = client + .registry + .get(&format!("@{name}")) + .await + .ok() + .and_then(|r| r.identity); + let confirmed_owner = readback_identity + .as_ref() + .map(|i| i.crypto_id.trim().to_string()) + .unwrap_or_default(); + match classify_transfer_readback(&confirmed_owner, &recipient_crypto_id) { + TransferReadback::Confirmed => { + log::debug!("{LOG_PREFIX} registry_transfer confirmed by read-back"); + // Return the identity from the SAME read-back we confirmed against + // (no redundant second GET — the confirmed owner is already here). + to_value(serde_json::json!({ "identity": readback_identity, "confirmed": true })) + } + TransferReadback::Unconfirmed => { + log::warn!("{LOG_PREFIX} registry_transfer submitted but unconfirmed by read-back"); + Err( + "handle transfer was submitted but could not be confirmed. Do not retry \ + until you have checked the handle's current owner." + .to_string(), + ) + } + } + }) +} + +/// Validate a recipient's availability lookup before a transfer (#4998, M2). +/// +/// Returns the recipient's `(cryptoId, publicKey)` when it is a live +/// registration, or an error when the record is stale/expired/incomplete — so +/// an irreversible transfer never targets a wallet the recipient may no longer +/// control at that `@handle`. Pure, so the policy is unit-testable without a +/// live registry. +fn validate_recipient_availability( + availability: &tinyplace::types::AvailabilityResponse, +) -> Result<(String, String), String> { + // A registered handle reads back as NOT available. `available == true` means + // the name is currently free (never registered, or expired-and-released), + // so any attached `identity` is stale — refuse rather than transfer to it. + if availability.available { + return Err( + "recipient handle is not currently registered; refusing to transfer".to_string(), + ); + } + let identity = availability + .identity + .as_ref() + .ok_or("recipient handle is not registered on tiny.place")?; + // An expired identity carries a stale owner record even before the name is + // released back to `available` — never send an irreversible transfer to it. + if identity.status.trim().eq_ignore_ascii_case("expired") { + return Err("recipient handle has expired; refusing to transfer".to_string()); + } + let crypto_id = identity.crypto_id.trim().to_string(); + let public_key = identity.public_key.trim().to_string(); + if crypto_id.is_empty() || public_key.is_empty() { + return Err( + "recipient identity is missing key material; cannot transfer safely".to_string(), + ); + } + Ok((crypto_id, public_key)) +} + +/// Outcome of the pre-sign check on the sender's own handle (#4998, M1/M2). +#[derive(Debug, PartialEq, Eq)] +enum OwnHandlePreflight { + /// The handle already reads back as owned by the recipient — a retry after a + /// lost-but-applied POST. Return the existing state; do NOT re-sign. + AlreadyTransferred, + /// Safe to proceed to signing + POST. + Proceed, + /// Reject before signing with this message. + Reject(String), +} + +/// Decide whether to proceed with, short-circuit, or reject a transfer based on +/// the sender's own current ownership. Pure, so unit-testable without a client. +fn preflight_own_handle( + own: &tinyplace::types::AvailabilityResponse, + recipient_crypto_id: &str, +) -> OwnHandlePreflight { + let Some(identity) = own.identity.as_ref() else { + // Not registered at all — a distinct diagnosis from the primary case, so + // a nonexistent handle doesn't get "mark it non-primary" (M2 tail). + return OwnHandlePreflight::Reject( + "this handle is not registered on tiny.place".to_string(), + ); + }; + // Idempotency (M1): already owned by the recipient — a prior transfer landed. + if identity.crypto_id.trim() == recipient_crypto_id { + return OwnHandlePreflight::AlreadyTransferred; + } + // A primary (active) handle is locked from transfer server-side; mirror that + // client-side so a direct JSON-RPC caller can't bypass the UI gate. + if !transfer_allowed_by_primary_state(identity.primary) { + return OwnHandlePreflight::Reject( + "cannot transfer this handle while it is your active (primary) handle; make \ + another handle active first" + .to_string(), + ); + } + OwnHandlePreflight::Proceed +} + +/// Outcome of the post-transfer read-back confirmation (#4998, B1). +#[derive(Debug, PartialEq, Eq)] +enum TransferReadback { + /// The registry reads back the recipient as the current owner. + Confirmed, + /// The read-back neither confirms nor refutes reassignment — the POST is + /// already submitted, so this is "submitted but unconfirmed", never a claim + /// that the transfer did not happen. + Unconfirmed, +} + +/// Classify the post-transfer read-back. Only a non-empty owner that equals the +/// recipient confirms; anything else (empty/enveloped body, mismatch, failed +/// read) is treated as unconfirmed. Pure, so unit-testable without a client. +fn classify_transfer_readback( + confirmed_owner: &str, + recipient_crypto_id: &str, +) -> TransferReadback { + if !confirmed_owner.is_empty() && confirmed_owner == recipient_crypto_id { + TransferReadback::Confirmed + } else { + TransferReadback::Unconfirmed + } +} + // ── Users email verification ──────────────────────────────────────────────── pub(crate) fn handle_tinyplace_users_start_email_verification( @@ -5283,6 +5526,240 @@ mod tests { assert!(err.contains("username"), "got: {err}"); } + /// #4929: transfer rejects a missing/blank `name` or `recipient` before any + /// client/network work — the destructive path never runs on bad input. + #[test] + fn transfer_requires_name_and_recipient() { + // Missing name. + let err = block_on(handle_tinyplace_registry_transfer(Map::new())).unwrap_err(); + assert!(err.contains("name"), "got: {err}"); + + // A bare "@" normalizes to an empty name and is rejected before any + // client/network work (the destructive path never runs). + let mut params = Map::new(); + params.insert("name".to_string(), Value::String("@".to_string())); + params.insert("recipient".to_string(), Value::String("bravo".to_string())); + let err = block_on(handle_tinyplace_registry_transfer(params)).unwrap_err(); + assert!(err.contains("name"), "got: {err}"); + + // Name present, recipient missing. + let mut params = Map::new(); + params.insert("name".to_string(), Value::String("alpha".to_string())); + let err = block_on(handle_tinyplace_registry_transfer(params)).unwrap_err(); + assert!(err.contains("recipient"), "got: {err}"); + + // Name present, recipient blank → still rejected. + let mut params = Map::new(); + params.insert("name".to_string(), Value::String("alpha".to_string())); + params.insert("recipient".to_string(), Value::String(" ".to_string())); + let err = block_on(handle_tinyplace_registry_transfer(params)).unwrap_err(); + assert!(err.contains("recipient"), "got: {err}"); + } + + /// #4929: transfer eligibility fails closed when the registry omits the + /// primary flag; only an explicit `false` may reach the destructive path. + #[test] + fn transfer_requires_explicit_non_primary_state() { + assert!(!transfer_allowed_by_primary_state(None)); + assert!(!transfer_allowed_by_primary_state(Some(true))); + assert!(transfer_allowed_by_primary_state(Some(false))); + } + + // ── #4998 destructive-path coverage (B1 read-back, M1 idempotency, M2 + // recipient validation) — the pure policy helpers the async handler + // delegates to, so the irreversible-transfer decisions are unit-tested. ── + + fn test_identity( + crypto_id: &str, + status: &str, + primary: Option, + ) -> tinyplace::types::Identity { + tinyplace::types::Identity { + username: "handle".to_string(), + crypto_id: crypto_id.to_string(), + public_key: if crypto_id.is_empty() { + String::new() + } else { + "pubkey".to_string() + }, + registered_at: String::new(), + expires_at: String::new(), + status: status.to_string(), + registration_tx: None, + payment_methods: None, + primary, + subnames: None, + signature: None, + payment: None, + last_renewal_tx: None, + updated_at: String::new(), + } + } + + fn availability( + available: bool, + identity: Option, + ) -> tinyplace::types::AvailabilityResponse { + tinyplace::types::AvailabilityResponse { + available, + name: "@handle".to_string(), + identity, + lifecycle: None, + } + } + + /// M2: a recipient whose name is currently `available` (free / released) is + /// refused even if a stale `identity` record is attached — never transfer to + /// a wallet the recipient may no longer control at that @handle. + #[test] + fn recipient_available_is_refused() { + let avail = availability( + true, + Some(test_identity("recipientCid", "active", Some(false))), + ); + let err = validate_recipient_availability(&avail).unwrap_err(); + assert!(err.contains("not currently registered"), "got: {err}"); + } + + /// M2: a recipient with no identity record at all fails closed. + #[test] + fn recipient_missing_identity_is_refused() { + let err = validate_recipient_availability(&availability(false, None)).unwrap_err(); + assert!(err.contains("not registered on tiny.place"), "got: {err}"); + } + + /// M2: an EXPIRED recipient identity (stale owner record before release) is + /// refused — case-insensitively. + #[test] + fn recipient_expired_status_is_refused() { + for status in ["expired", "EXPIRED", "Expired"] { + let avail = availability( + false, + Some(test_identity("recipientCid", status, Some(false))), + ); + let err = validate_recipient_availability(&avail).unwrap_err(); + assert!(err.contains("expired"), "status {status} → {err}"); + } + } + + /// M2: a recipient missing key material can't be transferred to safely. + #[test] + fn recipient_missing_key_material_is_refused() { + let avail = availability(false, Some(test_identity("", "active", Some(false)))); + let err = validate_recipient_availability(&avail).unwrap_err(); + assert!(err.contains("missing key material"), "got: {err}"); + } + + /// A live, registered recipient resolves to its (cryptoId, publicKey). + #[test] + fn recipient_live_resolves_key_material() { + let avail = availability( + false, + Some(test_identity("recipientCid", "active", Some(false))), + ); + let (cid, pk) = validate_recipient_availability(&avail).unwrap(); + assert_eq!(cid, "recipientCid"); + assert_eq!(pk, "pubkey"); + } + + /// M1 idempotency: when the sender's own handle ALREADY reads back as the + /// recipient (a retry after a lost-but-applied POST), the preflight + /// short-circuits and must NOT re-sign. + #[test] + fn preflight_already_transferred_short_circuits() { + let own = availability( + false, + Some(test_identity("recipientCid", "active", Some(false))), + ); + assert_eq!( + preflight_own_handle(&own, "recipientCid"), + OwnHandlePreflight::AlreadyTransferred + ); + } + + /// M2 tail: a nonexistent own-handle gets the right diagnosis, NOT the + /// "mark it non-primary" message. + #[test] + fn preflight_unregistered_own_handle_is_distinct() { + match preflight_own_handle(&availability(true, None), "recipientCid") { + OwnHandlePreflight::Reject(msg) => { + assert!(msg.contains("not registered"), "got: {msg}"); + assert!( + !msg.contains("primary"), + "must not misdiagnose as primary: {msg}" + ); + } + other => panic!("expected Reject, got {other:?}"), + } + } + + /// A primary (active) own-handle is rejected before signing. + #[test] + fn preflight_primary_handle_is_rejected() { + let own = availability(false, Some(test_identity("ownerCid", "active", Some(true)))); + match preflight_own_handle(&own, "recipientCid") { + OwnHandlePreflight::Reject(msg) => assert!(msg.contains("active"), "got: {msg}"), + other => panic!("expected Reject, got {other:?}"), + } + // Missing primary flag also fails closed. + let own_none = availability(false, Some(test_identity("ownerCid", "active", None))); + assert!(matches!( + preflight_own_handle(&own_none, "recipientCid"), + OwnHandlePreflight::Reject(_) + )); + } + + /// A non-primary own-handle owned by someone other than the recipient + /// proceeds to signing. + #[test] + fn preflight_non_primary_proceeds() { + let own = availability( + false, + Some(test_identity("ownerCid", "active", Some(false))), + ); + assert_eq!( + preflight_own_handle(&own, "recipientCid"), + OwnHandlePreflight::Proceed + ); + } + + /// B1: the read-back confirms ONLY when the registry returns a non-empty + /// owner equal to the recipient. An empty owner (enveloped/renamed/partial + /// body, or a failed read) is "unconfirmed" — never a claim the transfer + /// didn't happen. + #[test] + fn readback_confirms_only_on_matching_nonempty_owner() { + assert_eq!( + classify_transfer_readback("recipientCid", "recipientCid"), + TransferReadback::Confirmed + ); + // Empty owner (default-deserialized crypto_id "" from an enveloped body). + assert_eq!( + classify_transfer_readback("", "recipientCid"), + TransferReadback::Unconfirmed + ); + // Owner reads back as someone else. + assert_eq!( + classify_transfer_readback("otherCid", "recipientCid"), + TransferReadback::Unconfirmed + ); + // Defensive: an empty recipient never vacuously confirms on an empty read. + assert_eq!( + classify_transfer_readback("", ""), + TransferReadback::Unconfirmed + ); + } + + /// A self-transfer is rejected before any network/signing work. + #[test] + fn transfer_rejects_self_transfer() { + let mut params = Map::new(); + params.insert("name".to_string(), Value::String("@alpha".to_string())); + params.insert("recipient".to_string(), Value::String("alpha".to_string())); + let err = block_on(handle_tinyplace_registry_transfer(params)).unwrap_err(); + assert!(err.contains("itself"), "got: {err}"); + } + /// Buy handlers reject a missing/blank `id` before any client/network work. #[test] fn buy_handlers_require_id() { diff --git a/src/openhuman/tinyplace/schemas.rs b/src/openhuman/tinyplace/schemas.rs index 407ba2e69..fe2a83af0 100644 --- a/src/openhuman/tinyplace/schemas.rs +++ b/src/openhuman/tinyplace/schemas.rs @@ -151,6 +151,7 @@ use crate::openhuman::tinyplace::manifest::{ handle_tinyplace_registry_export, handle_tinyplace_registry_get, handle_tinyplace_registry_register, + handle_tinyplace_registry_transfer, handle_tinyplace_search_unified, handle_tinyplace_signal_decrypt_message, handle_tinyplace_signal_get_bundle, @@ -1467,6 +1468,35 @@ fn schema_registry_export() -> ControllerSchema { } } +fn schema_registry_transfer() -> ControllerSchema { + ControllerSchema { + namespace: "tinyplace", + function: "registry_transfer", + description: + "Transfer one of the wallet's handles to another tiny.place identity (gift / account \ + move). DESTRUCTIVE and irreversible: on success the recipient becomes the handle's \ + sole owner. The recipient is resolved from their @handle; an unregistered recipient \ + or an unconfirmed read-back fails closed. The owning wallet is proven by the \ + signer-attached signature, so a caller can only transfer a handle their own wallet \ + owns.", + inputs: vec![ + required_string( + "name", + "The handle to transfer away (with or without a leading @).", + ), + required_string( + "recipient", + "The recipient's registered @handle (with or without a leading @); resolved to \ + their cryptoId + publicKey server-side.", + ), + ], + outputs: vec![json_output( + "identity", + "The transferred Identity, now owned by the recipient (read-back confirmed).", + )], + } +} + // ── Feedback schemas ──────────────────────────────────────────────────────── fn schema_feedback_list() -> ControllerSchema { @@ -2727,6 +2757,7 @@ pub fn all_tinyplace_controller_schemas() -> Vec { schema_registry_get(), schema_registry_register(), schema_registry_assign_primary(), + schema_registry_transfer(), schema_marketplace_buy_product(), schema_marketplace_buy_identity(), schema_marketplace_bid(), @@ -2980,6 +3011,10 @@ pub fn all_tinyplace_registered_controllers() -> Vec { schema: schema_registry_assign_primary(), handler: handle_tinyplace_registry_assign_primary, }, + RegisteredController { + schema: schema_registry_transfer(), + handler: handle_tinyplace_registry_transfer, + }, RegisteredController { schema: schema_marketplace_buy_product(), handler: handle_tinyplace_marketplace_buy_product,