mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(tinyplace): switch active handle across multiple purchased identities (#4216)
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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(¶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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user