mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(agent-world): publish a directory card on identity registration (#3847)
This commit is contained in:
@@ -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(<IdentitiesSection />);
|
||||
// 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(<IdentitiesSection />);
|
||||
// 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(<IdentitiesSection />);
|
||||
// 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', () => {
|
||||
|
||||
@@ -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 (
|
||||
<PanelScaffold description="Claim handles, manage your registry, and trade identities">
|
||||
@@ -959,8 +972,8 @@ export default function IdentitiesSection() {
|
||||
</div>
|
||||
|
||||
<div key={key}>
|
||||
{tab === 'register' && <RegisterTab />}
|
||||
{tab === 'registry' && <RegistryTab />}
|
||||
{tab === 'register' && <RegisterTab onRegistered={bumpRegistryKey} />}
|
||||
{tab === 'registry' && <RegistryTab key={registryKey} />}
|
||||
{tab === 'trading' && <TradingTab />}
|
||||
</div>
|
||||
</PanelScaffold>
|
||||
|
||||
@@ -498,6 +498,8 @@ pub(crate) fn handle_tinyplace_registry_register(params: Map<String, Value>) ->
|
||||
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<String, Value>) ->
|
||||
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<String, Value>) ->
|
||||
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
|
||||
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user