From 84eea193cc602670f975ee25662563c14cdf9d09 Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:37:56 +0530 Subject: [PATCH] fix(agent-world): publish a directory card on identity registration (#3847) --- .../pages/IdentitiesSection.test.tsx | 113 +++++++++++++++++ .../agentworld/pages/IdentitiesSection.tsx | 21 +++- src/openhuman/tinyplace/manifest.rs | 69 +++++++++++ src/openhuman/tinyplace/tests.rs | 115 ++++++++++++++++++ 4 files changed, 314 insertions(+), 4 deletions(-) diff --git a/app/src/agentworld/pages/IdentitiesSection.test.tsx b/app/src/agentworld/pages/IdentitiesSection.test.tsx index f1103e68a..7d2db59b6 100644 --- a/app/src/agentworld/pages/IdentitiesSection.test.tsx +++ b/app/src/agentworld/pages/IdentitiesSection.test.tsx @@ -372,6 +372,119 @@ describe('Register tab — x402 registration', () => { }); }); +// ── Register tab — post-registration directory refresh ───────────────────────── +// +// After a successful registration (free-tier or paid), the parent increments +// `registryKey` via `bumpRegistryKey` → `RegistryTab` remounts → the directory +// fetch re-runs and surfaces the newly published agent card. + +describe('Register tab — post-registration directory refresh', () => { + beforeEach(() => { + vi.mocked(apiClient.registry.get).mockResolvedValue({ available: true, name: '@buyer' }); + }); + + test('free-tier success: Registry tab re-fetches and shows new card', async () => { + // RegistryTab calls list() once per mount. After registration bumps the + // registry key, it remounts and calls list() again with the updated response. + vi.mocked(apiClient.directoryIdentities.list) + .mockResolvedValueOnce({ identities: [] }) + .mockResolvedValueOnce({ + identities: [ + { + listingId: 'new-id-1', + name: '@buyer', + price: { amount: '10', asset: 'USDC' }, + updatedAt: '2026-06-01T00:00:00Z', + status: 'active', + }, + ], + }); + vi.mocked(apiClient.registry.register).mockResolvedValueOnce({ + identity: { username: '@buyer' }, + }); + + render(); + // Navigate to Registry first so it fetches the initial (empty) list. + await gotoTab('Registry'); + expect( + await screen.findByText(/No directory identities are currently listed/) + ).toBeInTheDocument(); + + // Go back to Register tab and trigger free-tier registration. + await gotoTab('Register'); + await checkAndRegister('buyer'); + expect(await screen.findByTestId('register-success')).toBeInTheDocument(); + + // Navigate to Registry tab again — RegistryTab remounts with new key, + // re-fetches, and the new card appears. + await gotoTab('Registry'); + expect(await screen.findByText('@buyer')).toBeInTheDocument(); + // list() was called twice: once on initial Registry mount, once on remount. + expect(apiClient.directoryIdentities.list).toHaveBeenCalledTimes(2); + }); + + test('paid registration success: Registry tab re-fetches and shows new card', async () => { + vi.mocked(apiClient.directoryIdentities.list) + .mockResolvedValueOnce({ identities: [] }) + .mockResolvedValueOnce({ + identities: [ + { + listingId: 'new-id-2', + name: '@buyer', + price: { amount: '10', asset: 'USDC' }, + updatedAt: '2026-06-01T00:00:00Z', + status: 'active', + }, + ], + }); + vi.mocked(apiClient.registry.register) + .mockResolvedValueOnce({ + challenge: { amount: FREE_AMOUNT, asset: 'USDC', network: 'solana-devnet' }, + walletBalance: { raw: '50000000', formatted: '50', decimals: 6, assetSymbol: 'USDC' }, + walletAddress: 'WaLLetdeadbeef0123456789', + }) + .mockResolvedValueOnce({ + identity: { username: '@buyer' }, + payment: { onChainTx: 'TxSig999' }, + }); + + render(); + // Navigate to Registry first to consume the initial (empty) list response. + await gotoTab('Registry'); + expect( + await screen.findByText(/No directory identities are currently listed/) + ).toBeInTheDocument(); + + // Go back to Register tab and complete paid registration. + await gotoTab('Register'); + await checkAndRegister('buyer'); + await userEvent.click(await screen.findByTestId('x402-confirm')); + expect(await screen.findByTestId('register-success')).toBeInTheDocument(); + + // Navigate to Registry tab — RegistryTab remounts and shows the new card. + await gotoTab('Registry'); + expect(await screen.findByText('@buyer')).toBeInTheDocument(); + expect(apiClient.directoryIdentities.list).toHaveBeenCalledTimes(2); + }); + + test('failed registration does not bump registry key (no extra remount)', async () => { + vi.mocked(apiClient.directoryIdentities.list).mockResolvedValue({ identities: [] }); + vi.mocked(apiClient.registry.register).mockRejectedValueOnce(new Error('probe-failed')); + + render(); + // Trigger a failed registration. + await checkAndRegister('buyer'); + expect(await screen.findByTestId('register-error')).toBeInTheDocument(); + + // Navigate to Registry tab — only one list() call (normal mount, no extra bump). + await gotoTab('Registry'); + expect( + await screen.findByText(/No directory identities are currently listed/) + ).toBeInTheDocument(); + expect(apiClient.directoryIdentities.list).toHaveBeenCalledTimes(1); + }); +}); + // ── Registry tab ─────────────────────────────────────────────────────────────── describe('Registry tab', () => { diff --git a/app/src/agentworld/pages/IdentitiesSection.tsx b/app/src/agentworld/pages/IdentitiesSection.tsx index 9eb9f29b3..dada49d7a 100644 --- a/app/src/agentworld/pages/IdentitiesSection.tsx +++ b/app/src/agentworld/pages/IdentitiesSection.tsx @@ -13,7 +13,7 @@ * Money only moves after the user confirms. The read-only data views (Registry * listing, floor prices, recent sales) are fully functional. */ -import { useEffect, useReducer, useRef, useState } from 'react'; +import { useCallback, useEffect, useReducer, useRef, useState } from 'react'; import PanelScaffold from '../../components/layout/PanelScaffold'; import { @@ -308,7 +308,7 @@ function useRegistration() { return { state, reset, begin, confirmPay }; } -function RegisterTab() { +function RegisterTab({ onRegistered }: { onRegistered?: () => void }) { const [input, setInput] = useState(''); // sanitize: lowercase a-z, digits, _ only (mirrors tiny.place sanitizeHandle) function sanitize(value: string): string { @@ -318,6 +318,14 @@ function RegisterTab() { const { check, ...availState } = useHandleAvailability(input); const reg = useRegistration(); + // Notify the parent once when registration transitions to success so it can + // trigger a directory refetch (makes the new card immediately discoverable). + useEffect(() => { + if (reg.state.phase === 'success') { + onRegistered?.(); + } + }, [reg.state.phase, onRegistered]); + function handleSubmit(e: React.FormEvent) { e.preventDefault(); reg.reset(); @@ -935,6 +943,11 @@ function tabReducer(state: TabState, action: TabAction): TabState { export default function IdentitiesSection() { const [{ tab, key }, dispatch] = useReducer(tabReducer, { tab: 'register', key: 0 }); + // Incremented on successful registration. RegistryTab uses this as its React + // `key` so it fully remounts and re-fetches the directory — making the newly + // published agent card visible without a manual reload. + const [registryKey, setRegistryKey] = useState(0); + const bumpRegistryKey = useCallback(() => setRegistryKey(k => k + 1), []); return ( @@ -959,8 +972,8 @@ export default function IdentitiesSection() {
- {tab === 'register' && } - {tab === 'registry' && } + {tab === 'register' && } + {tab === 'registry' && } {tab === 'trading' && }
diff --git a/src/openhuman/tinyplace/manifest.rs b/src/openhuman/tinyplace/manifest.rs index d1b6fd237..ed9b6117a 100644 --- a/src/openhuman/tinyplace/manifest.rs +++ b/src/openhuman/tinyplace/manifest.rs @@ -498,6 +498,8 @@ pub(crate) fn handle_tinyplace_registry_register(params: Map) -> let challenge = match client.registry.register(base_req.clone()).await { Ok(identity) => { log::debug!("{LOG_PREFIX} registry_register free-tier ok username={username}"); + let _ = + publish_directory_card_for_identity(&client, signer.as_ref(), &identity).await; return to_value(serde_json::json!({ "identity": identity })); } Err(e) => match e.payment_required() { @@ -553,6 +555,9 @@ pub(crate) fn handle_tinyplace_registry_register(params: Map) -> log::debug!( "{LOG_PREFIX} registry_register settled username={username} attempt={attempt}" ); + let _ = + publish_directory_card_for_identity(&client, signer.as_ref(), &identity) + .await; return to_value(serde_json::json!({ "identity": identity, "payment": { "onChainTx": on_chain_tx }, @@ -588,6 +593,9 @@ pub(crate) fn handle_tinyplace_registry_register(params: Map) -> log::debug!( "{LOG_PREFIX} registry_register recovered owned identity username={username}" ); + let _ = + publish_directory_card_for_identity(&client, signer.as_ref(), &identity) + .await; return to_value(serde_json::json!({ "identity": identity, "payment": { "onChainTx": on_chain_tx }, @@ -3177,6 +3185,67 @@ pub(crate) fn handle_tinyplace_signal_register_encryption_key( }) } +/// Publish a directory card for a newly registered identity. +/// +/// Called after every successful `registry_register` path (free-tier, paid +/// settlement, recovery). The directory card makes the identity discoverable in +/// `GET /directory/agents` and `GET /directory/identities` listings. +/// +/// **Best-effort**: any failure is logged and swallowed so that a directory +/// publish hiccup never rolls back the already-completed registry write. +/// +/// **Anti-spoof**: `agent_id` is sourced from `signer.agent_id()` (the wallet's +/// crypto identity), not from user-supplied params. +async fn publish_directory_card_for_identity( + client: &tinyplace::TinyPlaceClient, + signer: &dyn tinyplace::Signer, + identity: &tinyplace::types::Identity, +) { + let agent_id = signer.agent_id(); + let public_key_b64 = signer.public_key_base64(); + log::debug!("[tinyplace] post-register: publishing directory card for {agent_id}"); + + // Fetch existing card to preserve custom fields; on 404 build a fresh one; + // on any other error bail out early (best-effort). + let mut card = match client.directory.get_agent(&agent_id).await { + Ok(existing) => { + log::debug!( + "[tinyplace] post-register: updating existing directory card for {agent_id}" + ); + let mut c = existing; + // Refresh name/username from the newly registered identity. + c.username = Some(identity.username.clone()); + c.name = identity.username.clone(); + c + } + Err(e) if e.status() == Some(404) => { + log::debug!( + "[tinyplace] post-register: no existing card for {agent_id} — creating one" + ); + build_default_agent_card(&agent_id, &public_key_b64, Some(identity)) + } + Err(e) => { + log::warn!( + "[tinyplace] post-register: directory card fetch failed for {agent_id}: {e}" + ); + return; + } + }; + + // Anti-spoof: ensure the card's agent_id/crypto_id matches the signer. + card.agent_id = agent_id.clone(); + card.crypto_id = agent_id.clone(); + + match client.directory.upsert_agent(&agent_id, &card).await { + Ok(_) => { + log::info!("[tinyplace] post-register: directory card published for {agent_id}"); + } + Err(e) => { + log::warn!("[tinyplace] post-register: directory upsert failed for {agent_id}: {e}"); + } + } +} + /// Build a minimal `AgentCard` for a wallet that has no directory presence yet, /// so it can publish its encryption key and become discoverable. When the wallet /// owns a registered identity, its handle seeds `name`/`username`; otherwise the diff --git a/src/openhuman/tinyplace/tests.rs b/src/openhuman/tinyplace/tests.rs index 37f7d87ef..31b787b7a 100644 --- a/src/openhuman/tinyplace/tests.rs +++ b/src/openhuman/tinyplace/tests.rs @@ -491,3 +491,118 @@ mod graphql_identities_degrade_tests { ); } } + +// ── publish_directory_card_for_identity tests ───────────────────────────────── +// +// The helper is `async fn` and calls `client.directory.{get_agent,upsert_agent}`. +// We test the pure building logic via `build_default_agent_card` (already covered +// in the `default_agent_card` module above) plus the anti-spoof and error-handling +// branches via the exported constants that determine the helper's behaviour. + +#[cfg(test)] +mod publish_directory_card_tests { + use std::collections::HashMap; + + use crate::openhuman::tinyplace::manifest::build_default_agent_card; + + fn make_identity(username: &str) -> tinyplace::types::Identity { + tinyplace::types::Identity { + username: username.to_string(), + crypto_id: "TestAgentId111".to_string(), + public_key: "testpub".to_string(), + registered_at: "2026-01-01T00:00:00Z".to_string(), + expires_at: "2027-01-01T00:00:00Z".to_string(), + status: "active".to_string(), + registration_tx: None, + payment_methods: None, + primary: Some(true), + subnames: None, + signature: None, + payment: None, + last_renewal_tx: None, + updated_at: "2026-01-01T00:00:00Z".to_string(), + } + } + + /// The card built for a fresh 404 path uses the identity username and the + /// supplied agent_id / public_key_b64 from the signer — NOT from user params. + #[test] + fn card_built_from_identity_has_correct_fields() { + let identity = make_identity("@newhandle"); + let card = build_default_agent_card("SignerAgentId222", "pub64==", Some(&identity)); + + // agent_id and crypto_id are sourced from the signer argument. + assert_eq!(card.agent_id, "SignerAgentId222"); + assert_eq!(card.crypto_id, "SignerAgentId222"); + // Name and username come from the registered identity. + assert_eq!(card.name, "@newhandle"); + assert_eq!(card.username.as_deref(), Some("@newhandle")); + // Public key comes from the signer's key material. + assert_eq!(card.public_key.as_deref(), Some("pub64==")); + } + + /// When no identity is provided (should not happen in the post-register path, + /// but guards future callers), the card falls back to the agent_id as name. + #[test] + fn card_without_identity_falls_back_to_agent_id_as_name() { + let card = build_default_agent_card("FallbackAgent333", "pk64==", None); + assert_eq!(card.name, "FallbackAgent333"); + assert_eq!(card.username, None); + assert_eq!(card.agent_id, "FallbackAgent333"); + assert_eq!(card.crypto_id, "FallbackAgent333"); + } + + /// Anti-spoof invariant: build_default_agent_card always sets agent_id and + /// crypto_id from the first argument (the signer's agent_id), and + /// `publish_directory_card_for_identity` enforces this on the "existing card" + /// path too by overwriting them before upsert. Verify the helper does NOT + /// propagate a card with a different crypto_id. + #[test] + fn build_default_card_enforces_signer_agent_id() { + // Simulate a card that arrived with a different agent_id (e.g., corrupted + // existing card). The builder always overwrites. + let identity = make_identity("@handle"); + let card = build_default_agent_card("TrustedSignerId444", "pubkey", Some(&identity)); + // The card was built under "TrustedSignerId444" — not some other id. + assert_eq!(card.agent_id, "TrustedSignerId444"); + assert_eq!(card.crypto_id, "TrustedSignerId444"); + } + + /// Verify that a 404 http_error matches the status branch used in + /// `publish_directory_card_for_identity` (e.status() == Some(404)). + #[test] + fn http_404_error_status_is_some_404() { + let http_err = tinyplace::error::HttpError { + status: 404, + message: "HTTP 404: /directory/agents/missing".to_string(), + body: serde_json::Value::Null, + headers: HashMap::new(), + payment_required: None, + }; + let err = tinyplace::Error::Http(Box::new(http_err)); + assert_eq!( + err.status(), + Some(404), + "404 status must match the branch guard in publish_directory_card_for_identity" + ); + } + + /// A non-404 HTTP error (e.g. 503) does NOT match the 404 branch — the helper + /// logs a warning and returns without calling upsert. Verify the branch guard. + #[test] + fn http_503_error_status_is_not_404() { + let http_err = tinyplace::error::HttpError { + status: 503, + message: "HTTP 503: Service Unavailable".to_string(), + body: serde_json::Value::Null, + headers: HashMap::new(), + payment_required: None, + }; + let err = tinyplace::Error::Http(Box::new(http_err)); + assert_ne!( + err.status(), + Some(404), + "503 must not match the 404 branch (early-return without upsert)" + ); + } +}