fix(tinyplace): switch active handle across multiple purchased identities (#4216)

This commit is contained in:
Mega Mind
2026-06-28 21:26:40 -07:00
committed by GitHub
parent 090360e38a
commit 51aeefd49f
6 changed files with 204 additions and 14 deletions
@@ -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(<ProfilesSection />);
// 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(<ProfilesSection />);
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({
+56 -12
View File
@@ -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<T extends { primary?: boolean }>(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<ProfileState>({ 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<FollowStats | null>(null);
const [exportData, setExportData] = useState<IdentityExport | null>(null);
const [exportLoading, setExportLoading] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
// Handle currently being promoted to primary (in-flight), and any error.
const [switchingHandle, setSwitchingHandle] = useState<string | null>(null);
const [switchError, setSwitchError] = useState<string | null>(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(/^@+/, '')}
</span>
<span className="flex items-center gap-2">
{id.primary && (
{id.primary ? (
<span className="rounded-full bg-primary-50 px-1.5 py-0.5 text-[10px] font-medium text-primary-600 dark:bg-primary-900/30 dark:text-primary-300">
primary
active
</span>
) : (
<Button
variant="secondary"
size="sm"
disabled={switchingHandle !== null}
onClick={() => void handleSetActive(id.username)}>
{switchingHandle === id.username.replace(/^@+/, '')
? 'Switching…'
: 'Make active'}
</Button>
)}
<span className="text-[10px] uppercase tracking-wide text-content-faint">
{id.status}
@@ -338,6 +378,9 @@ function AgentProfileCard({ data }: { data: ProfileData }) {
</div>
))}
</div>
{switchError && (
<p className="mt-2 text-xs text-red-600 dark:text-red-400">{switchError}</p>
)}
</div>
)}
@@ -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 = <AgentProfileCard data={state.data} />;
body = <AgentProfileCard data={state.data} onSwitched={() => setReloadKey(k => k + 1)} />;
}
return <PanelScaffold description="Your agent profile">{body}</PanelScaffold>;
@@ -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' });
+15
View File
@@ -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<IdentityExport>('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<AssignPrimaryResult>('openhuman.tinyplace_registry_assign_primary', { name }),
},
directoryIdentities: {
/** List identity listings from the directory. */
+44
View File
@@ -2396,6 +2396,35 @@ pub(crate) fn handle_tinyplace_registry_export(params: Map<String, Value>) -> 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<String, Value>,
) -> ControllerFuture {
Box::pin(async move {
let name = req_str(&params, "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() {
+23
View File
@@ -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<ControllerSchema> {
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<RegisteredController> {
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,