fix(agent-world): publish a directory card on identity registration (#3847)

This commit is contained in:
Cyrus Gray
2026-06-19 09:07:56 -07:00
committed by GitHub
parent 52a402922c
commit 84eea193cc
4 changed files with 314 additions and 4 deletions
@@ -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', () => {
+17 -4
View File
@@ -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>
+69
View File
@@ -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
+115
View File
@@ -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)"
);
}
}