diff --git a/app/src/agentworld/pages/ProfilesSection.test.tsx b/app/src/agentworld/pages/ProfilesSection.test.tsx index 74c9748c8..5f8a449a7 100644 --- a/app/src/agentworld/pages/ProfilesSection.test.tsx +++ b/app/src/agentworld/pages/ProfilesSection.test.tsx @@ -6,7 +6,7 @@ * renders one of: loading / wallet_locked / no_handle / payment_required / * error / populated card. All handles/ids are GENERIC placeholders. */ -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { type GqlProfile, PaymentRequiredError } from '../../lib/agentworld/invokeApiClient'; @@ -19,7 +19,7 @@ vi.mock('../AgentWorldShell', () => ({ apiClient: { directory: { reverse: vi.fn() }, follows: { stats: vi.fn() }, - registry: { export: vi.fn() }, + registry: { export: vi.fn(), assignPrimary: vi.fn() }, graphql: { user: vi.fn() }, }, })); @@ -29,6 +29,7 @@ const reverse = vi.mocked(apiClient.directory.reverse); const walletStatus = vi.mocked(fetchWalletStatus); const followStats = vi.mocked(apiClient.follows.stats); const registryExport = vi.mocked(apiClient.registry.export); +const assignPrimary = vi.mocked(apiClient.registry.assignPrimary); const graphqlUser = vi.mocked(apiClient.graphql.user); const SOLANA_ADDR = 'WaLLetSoLanaAddr0123456789'; @@ -416,6 +417,55 @@ describe('graphql-enriched profile card', () => { expect(reverse).not.toHaveBeenCalled(); }); + test('header shows the PRIMARY handle, not identities[0], with multiple handles (#4198)', async () => { + graphqlUser.mockResolvedValueOnce( + makeProfile({ + displayName: 'Multi Agent', + identities: [ + { username: 'firstbought', cryptoId: SOLANA_ADDR, primary: false, ...minimalIdentity }, + { username: 'chosenactive', cryptoId: SOLANA_ADDR, primary: true, ...minimalIdentity }, + ], + }) + ); + render(); + // The header must reflect the primary-flagged handle even though it is not + // first in the array — pre-fix this showed @firstbought. + expect(await screen.findByRole('heading', { name: /@chosenactive/ })).toBeInTheDocument(); + }); + + test('clicking "Make active" promotes a non-primary handle and refetches (#4198)', async () => { + graphqlUser + .mockResolvedValueOnce( + makeProfile({ + identities: [ + { username: 'active1', cryptoId: SOLANA_ADDR, primary: true, ...minimalIdentity }, + { username: 'spare2', cryptoId: SOLANA_ADDR, primary: false, ...minimalIdentity }, + ], + }) + ) + // After the switch, the refetch returns spare2 as the new primary. + .mockResolvedValueOnce( + makeProfile({ + identities: [ + { username: 'active1', cryptoId: SOLANA_ADDR, primary: false, ...minimalIdentity }, + { username: 'spare2', cryptoId: SOLANA_ADDR, primary: true, ...minimalIdentity }, + ], + }) + ); + assignPrimary.mockResolvedValueOnce({ + identity: { username: 'spare2', cryptoId: SOLANA_ADDR, primary: true, ...minimalIdentity }, + }); + render(); + + const makeActive = await screen.findByRole('button', { name: /Make active/i }); + fireEvent.click(makeActive); + + await waitFor(() => expect(assignPrimary).toHaveBeenCalledWith('spare2')); + // Refetch promoted spare2 — the header now shows it. + expect(await screen.findByRole('heading', { name: /@spare2/ })).toBeInTheDocument(); + expect(graphqlUser).toHaveBeenCalledTimes(2); + }); + test('renders profile with empty attestations array — no Verified Accounts section', async () => { graphqlUser.mockResolvedValueOnce( makeProfile({ diff --git a/app/src/agentworld/pages/ProfilesSection.tsx b/app/src/agentworld/pages/ProfilesSection.tsx index 4b437c414..e98d31522 100644 --- a/app/src/agentworld/pages/ProfilesSection.tsx +++ b/app/src/agentworld/pages/ProfilesSection.tsx @@ -77,13 +77,17 @@ type ProfileState = // ── Data hook ───────────────────────────────────────────────────────────────── -/** Pick the primary handle, else the first, from a reverse-lookup result. */ -function pickPrimary(identities: OwnedIdentity[]): OwnedIdentity | undefined { +/** Pick the primary handle, else the first, from a reverse-lookup result. + * Generic over the identity shape so it accepts both the bare directory + * `OwnedIdentity` and the richer GraphQL `Identity` (it only reads `primary`). */ +function pickPrimary(identities: T[]): T | undefined { return identities.find(i => i.primary) ?? identities[0]; } -/** Load the wallet's own identity: wallet_status → reverse-lookup → primary handle. */ -function useMyIdentity(): ProfileState { +/** Load the wallet's own identity: wallet_status → reverse-lookup → primary handle. + * `reloadKey` is bumped by the active-handle switch so the profile refetches and + * the newly-promoted primary is reflected (#4198). */ +function useMyIdentity(reloadKey: number): ProfileState { const [state, setState] = useState({ status: 'loading' }); useEffect(() => { @@ -157,18 +161,21 @@ function useMyIdentity(): ProfileState { return () => { cancelled = true; }; - }, []); + }, [reloadKey]); return state; } // ── Sub-components ──────────────────────────────────────────────────────────── -function AgentProfileCard({ data }: { data: ProfileData }) { +function AgentProfileCard({ data, onSwitched }: { data: ProfileData; onSwitched?: () => void }) { const [followStats, setFollowStats] = useState(null); const [exportData, setExportData] = useState(null); const [exportLoading, setExportLoading] = useState(false); const [exportError, setExportError] = useState(null); + // Handle currently being promoted to primary (in-flight), and any error. + const [switchingHandle, setSwitchingHandle] = useState(null); + const [switchError, setSwitchError] = useState(null); // ── Extract display fields from either data source ───────────────────────── const isGraphql = data.source === 'graphql'; @@ -176,10 +183,13 @@ function AgentProfileCard({ data }: { data: ProfileData }) { const identity = isGraphql ? null : data.identity; // Determine the display name / handle. - // GraphQL with a registered identity → @username. + // GraphQL with a registered identity → @username (the wallet's PRIMARY handle, + // not merely identities[0] — a user with several handles can mark any one + // primary, #4198). // GraphQL without identities (null) → displayName (no @ prefix, not a handle). // Directory fallback → @username from identity. - const primaryIdentityUsername = isGraphql ? (profile!.identities?.[0]?.username ?? null) : null; + const primaryIdentity = isGraphql ? pickPrimary(profile!.identities ?? []) : undefined; + const primaryIdentityUsername = isGraphql ? (primaryIdentity?.username ?? null) : null; const hasHandle = isGraphql ? primaryIdentityUsername !== null : (identity!.username ?? null) !== null; @@ -226,6 +236,26 @@ function AgentProfileCard({ data }: { data: ProfileData }) { } }, [exportLoading, exportData, handle]); + // Promote one of the wallet's handles to primary (active), then ask the + // parent to refetch so the new primary is reflected everywhere (#4198). + const handleSetActive = useCallback( + async (username: string) => { + const name = username.replace(/^@+/, ''); + if (switchingHandle) return; + setSwitchError(null); + setSwitchingHandle(name); + try { + await apiClient.registry.assignPrimary(name); + onSwitched?.(); + } catch (err) { + setSwitchError(String(err)); + } finally { + setSwitchingHandle(null); + } + }, + [switchingHandle, onSwitched] + ); + useEffect(() => { if (!agentId) return; let cancelled = false; @@ -326,10 +356,20 @@ function AgentProfileCard({ data }: { data: ProfileData }) { @{id.username.replace(/^@+/, '')} - {id.primary && ( + {id.primary ? ( - primary + active + ) : ( + )} {id.status} @@ -338,6 +378,9 @@ function AgentProfileCard({ data }: { data: ProfileData }) { ))} + {switchError && ( +

{switchError}

+ )} )} @@ -399,7 +442,8 @@ function StatusBlock({ tone, title, body }: { tone: string; title: string; body? // ── Main export ─────────────────────────────────────────────────────────────── export default function ProfilesSection() { - const state = useMyIdentity(); + const [reloadKey, setReloadKey] = useState(0); + const state = useMyIdentity(reloadKey); let body: React.ReactNode; @@ -444,7 +488,7 @@ export default function ProfilesSection() { } else { // Render the wallet's own profile with either rich GraphQL data or bare // directory.reverse identity. AgentProfileCard handles both shapes internally. - body = ; + body = setReloadKey(k => k + 1)} />; } return {body}; diff --git a/app/src/lib/agentworld/invokeApiClient.test.ts b/app/src/lib/agentworld/invokeApiClient.test.ts index bb04b0248..953e0c829 100644 --- a/app/src/lib/agentworld/invokeApiClient.test.ts +++ b/app/src/lib/agentworld/invokeApiClient.test.ts @@ -479,6 +479,20 @@ describe('registry.export', () => { }); }); +describe('registry.assignPrimary', () => { + test('calls openhuman.tinyplace_registry_assign_primary with name (#4198)', async () => { + mockCallCoreRpc.mockResolvedValueOnce({ identity: { username: '@second', primary: true } }); + const client = createInvokeApiClient(); + const result = await client.registry.assignPrimary('@second'); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.tinyplace_registry_assign_primary', + params: { name: '@second' }, + }); + expect(result.identity?.primary).toBe(true); + }); +}); + describe('registry.get', () => { test('calls openhuman.tinyplace_registry_get with name', async () => { mockCallCoreRpc.mockResolvedValueOnce({ available: true, name: '@atlas' }); diff --git a/app/src/lib/agentworld/invokeApiClient.ts b/app/src/lib/agentworld/invokeApiClient.ts index fcf658383..d60664b18 100644 --- a/app/src/lib/agentworld/invokeApiClient.ts +++ b/app/src/lib/agentworld/invokeApiClient.ts @@ -330,6 +330,12 @@ export interface RegistrationResult { [key: string]: unknown; } +/** Result of `registry.assignPrimary` — the handle now flagged primary. */ +export interface AssignPrimaryResult { + identity?: Identity; + [key: string]: unknown; +} + // -- Registry export types ------------------------------------------------ export interface LedgerReference { @@ -1787,6 +1793,15 @@ export function createInvokeApiClient() { /** Export an identity with its ledger history and cryptographic proofs. */ export: (name: string) => call('openhuman.tinyplace_registry_export', { name }), + /** + * Make one of the wallet's purchased handles its primary (active) identity. + * The backend clears the primary flag on the wallet's other handles, so + * this is how a user with multiple identities switches the active handle + * reflected across feed / profile / directory (#4198). The owning wallet + * is proven by the signer-attached signature, not by params. + */ + assignPrimary: (name: string) => + call('openhuman.tinyplace_registry_assign_primary', { name }), }, directoryIdentities: { /** List identity listings from the directory. */ diff --git a/src/openhuman/tinyplace/manifest.rs b/src/openhuman/tinyplace/manifest.rs index 618425b2d..5ccd7e51b 100644 --- a/src/openhuman/tinyplace/manifest.rs +++ b/src/openhuman/tinyplace/manifest.rs @@ -2396,6 +2396,35 @@ pub(crate) fn handle_tinyplace_registry_export(params: Map) -> Co }) } +/// Assign one of the wallet's purchased handles as its **primary** (active) +/// identity. The backend clears the primary flag on the wallet's other names +/// (one primary per wallet), so this is how a user who owns multiple identities +/// switches which handle is shown as active across feed / profile / directory +/// (#4198). ANTI-SPOOF: the owning wallet is proven by the request signature the +/// SDK attaches from the unlocked signer, never from params — so a caller can +/// only re-point their *own* wallet's primary. +pub(crate) fn handle_tinyplace_registry_assign_primary( + 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()); + } + log::debug!("{LOG_PREFIX} registry_assign_primary name={name}"); + let client = global_state().client().await?; + let identity = client + .registry + .assign_primary(&name) + .await + .map_err(map_err)?; + to_value(serde_json::json!({ "identity": identity })) + }) +} + // ── Users email verification ──────────────────────────────────────────────── pub(crate) fn handle_tinyplace_users_start_email_verification( @@ -5922,6 +5951,21 @@ mod tests { ); } + /// registry_assign_primary rejects a blank/@-only name before any client + /// work (#4198) — the trim + strip-@ must not leave an empty handle that + /// would hit the backend. + #[test] + fn registry_assign_primary_rejects_blank_name() { + let mut params = Map::new(); + params.insert("name".to_string(), Value::String(" @ ".to_string())); + let result = block_on(handle_tinyplace_registry_assign_primary(params)); + assert!(result.is_err()); + assert!( + result.unwrap_err().contains("name"), + "expected 'name' in error" + ); + } + /// bounties_submit rejects blank url before any client work. #[test] fn bounties_submit_rejects_blank_url() { diff --git a/src/openhuman/tinyplace/schemas.rs b/src/openhuman/tinyplace/schemas.rs index f6676b32c..49b6e749d 100644 --- a/src/openhuman/tinyplace/schemas.rs +++ b/src/openhuman/tinyplace/schemas.rs @@ -138,6 +138,7 @@ use crate::openhuman::tinyplace::manifest::{ handle_tinyplace_profiles_broadcasts, handle_tinyplace_profiles_get, handle_tinyplace_profiles_groups, + handle_tinyplace_registry_assign_primary, handle_tinyplace_registry_export, handle_tinyplace_registry_get, handle_tinyplace_registry_register, @@ -1424,6 +1425,23 @@ fn schema_follows_feed() -> ControllerSchema { // ── Registry export schema ───────────────────────────────────────────────────── +fn schema_registry_assign_primary() -> ControllerSchema { + ControllerSchema { + namespace: "tinyplace", + function: "registry_assign_primary", + description: "Assign one of the wallet's handles as its primary (active) identity; \ + clears the primary flag on the wallet's other handles.", + inputs: vec![required_string( + "name", + "The handle to make primary/active (with or without a leading @).", + )], + outputs: vec![json_output( + "identity", + "The updated Identity with primary=true.", + )], + } +} + fn schema_registry_export() -> ControllerSchema { ControllerSchema { namespace: "tinyplace", @@ -2584,6 +2602,7 @@ pub fn all_tinyplace_controller_schemas() -> Vec { schema_marketplace_recent(), schema_registry_get(), schema_registry_register(), + schema_registry_assign_primary(), schema_marketplace_buy_product(), schema_marketplace_buy_identity(), schema_marketplace_bid(), @@ -2823,6 +2842,10 @@ pub fn all_tinyplace_registered_controllers() -> Vec { schema: schema_registry_register(), handler: handle_tinyplace_registry_register, }, + RegisteredController { + schema: schema_registry_assign_primary(), + handler: handle_tinyplace_registry_assign_primary, + }, RegisteredController { schema: schema_marketplace_buy_product(), handler: handle_tinyplace_marketplace_buy_product,