diff --git a/app/src/agentworld/pages/MessagingSection.test.tsx b/app/src/agentworld/pages/MessagingSection.test.tsx index 34d500ead..27db8ff51 100644 --- a/app/src/agentworld/pages/MessagingSection.test.tsx +++ b/app/src/agentworld/pages/MessagingSection.test.tsx @@ -59,6 +59,17 @@ vi.mock('../AgentWorldShell', () => ({ updatedAt: '2026-06-17T00:00:00Z', }), }, + directory: { + resolve: vi + .fn() + .mockResolvedValue({ identity: { cryptoId: 'resolved-crypto-id' }, agent: null }), + getAgent: vi.fn().mockResolvedValue({ agentId: 'resolved-crypto-id' }), + listAgents: vi.fn().mockResolvedValue({ agents: [] }), + reverse: vi.fn().mockResolvedValue({ cryptoId: 'resolved-crypto-id', identities: [] }), + listIdentities: vi.fn().mockResolvedValue({ identities: [] }), + skills: vi.fn().mockResolvedValue({ agents: [] }), + findByEncryptionKey: vi.fn().mockResolvedValue(null), + }, messages: { list: vi.fn().mockResolvedValue({ messages: [] }), acknowledge: vi.fn().mockResolvedValue(undefined), @@ -174,7 +185,7 @@ describe('DMs panel (E2E enabled)', () => { await userEvent.click(dmsButton); // Should see the peer input, not the "coming soon" placeholder - expect(screen.getByPlaceholderText(/Recipient agent ID/)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/Recipient @handle/)).toBeInTheDocument(); expect(screen.queryByTestId('dms-coming-soon')).not.toBeInTheDocument(); }); @@ -187,11 +198,16 @@ describe('DMs panel (E2E enabled)', () => { encrypted: true, }); + // directory.resolve returns 'resolved-crypto-id' by default (from mock) + vi.mocked(apiClient.directory.resolve).mockResolvedValue({ + identity: { cryptoId: 'resolved-crypto-id' }, + }); + render(); await user.click(screen.getByRole('button', { name: 'DMs' })); - // Enter peer ID and open DM - const peerInput = screen.getByPlaceholderText(/Recipient agent ID/); + // Enter a handle (non-base58) — will be resolved to resolved-crypto-id + const peerInput = screen.getByPlaceholderText(/Recipient @handle/); await user.type(peerInput, 'peer123'); await user.click(screen.getByRole('button', { name: 'Open DM' })); @@ -200,19 +216,48 @@ describe('DMs panel (E2E enabled)', () => { await user.type(composeInput, 'Hello encrypted world'); await user.click(screen.getByRole('button', { name: 'Send' })); + // sendMessage is called with the RESOLVED crypto_id, not the raw input expect(vi.mocked(apiClient.signal.sendMessage)).toHaveBeenCalledWith({ - recipient: 'peer123', + recipient: 'resolved-crypto-id', plaintext: 'Hello encrypted world', }); }); + test('our own sent message stays visible after refresh (relay only echoes incoming)', async () => { + const user = userEvent.setup(); + // The relay never returns our outgoing message, so refresh sees nothing. + vi.mocked(apiClient.messages.list).mockResolvedValue({ messages: [] }); + vi.mocked(apiClient.signal.sendMessage).mockResolvedValue({ + messageId: 'sent-1', + timestamp: '2026-06-17T00:00:00Z', + encrypted: true, + }); + vi.mocked(apiClient.directory.resolve).mockResolvedValue({ + identity: { cryptoId: 'resolved-crypto-id' }, + }); + + render(); + await user.click(screen.getByRole('button', { name: 'DMs' })); + await user.type(screen.getByPlaceholderText(/Recipient @handle/), 'peer123'); + await user.click(screen.getByRole('button', { name: 'Open DM' })); + + const composeInput = await screen.findByPlaceholderText(/Type a message/); + await user.type(composeInput, 'Persisted hello'); + await user.click(screen.getByRole('button', { name: 'Send' })); + + // The optimistic outgoing message must remain after send + the empty refresh. + expect(await screen.findByText('Persisted hello')).toBeInTheDocument(); + // The input is cleared and the message is not wiped by the refresh. + expect(screen.getByText('Persisted hello')).toBeInTheDocument(); + }); + test('shows an empty-state in an opened DM with no messages, alongside the compose box', async () => { const user = userEvent.setup(); vi.mocked(apiClient.messages.list).mockResolvedValue({ messages: [] }); render(); await user.click(screen.getByRole('button', { name: 'DMs' })); - const peerInput = screen.getByPlaceholderText(/Recipient agent ID/); + const peerInput = screen.getByPlaceholderText(/Recipient @handle/); await user.type(peerInput, 'peerEmpty'); await user.click(screen.getByRole('button', { name: 'Open DM' })); @@ -228,7 +273,7 @@ describe('DMs panel (E2E enabled)', () => { render(); await user.click(screen.getByRole('button', { name: 'DMs' })); - const peerInput = screen.getByPlaceholderText(/Recipient agent ID/); + const peerInput = screen.getByPlaceholderText(/Recipient @handle/); await user.type(peerInput, 'peer456'); await user.click(screen.getByRole('button', { name: 'Open DM' })); @@ -241,11 +286,13 @@ describe('DMs panel (E2E enabled)', () => { }); test('received messages are decrypted before display', async () => { + // directory.resolve returns 'resolved-crypto-id'; messages must use that id + // in the 'from' field to survive the peerId filter in useDirectMessages. vi.mocked(apiClient.messages.list).mockResolvedValue({ messages: [ { id: 'msg1', - from: 'peer789', + from: 'resolved-crypto-id', to: 'a', timestamp: '2026-06-17T00:00:00Z', deviceId: 1, @@ -257,14 +304,14 @@ describe('DMs panel (E2E enabled)', () => { }); vi.mocked(apiClient.signal.decryptMessage).mockResolvedValue({ plaintext: 'Decrypted secret', - from: 'peer789', + from: 'resolved-crypto-id', messageId: 'msg1', }); const user = userEvent.setup(); render(); await user.click(screen.getByRole('button', { name: 'DMs' })); - const peerInput = screen.getByPlaceholderText(/Recipient agent ID/); + const peerInput = screen.getByPlaceholderText(/Recipient @handle/); await user.type(peerInput, 'peer789'); await user.click(screen.getByRole('button', { name: 'Open DM' })); @@ -280,12 +327,167 @@ describe('DMs panel (E2E enabled)', () => { render(); await user.click(screen.getByRole('button', { name: 'DMs' })); - const peerInput = screen.getByPlaceholderText(/Recipient agent ID/); + const peerInput = screen.getByPlaceholderText(/Recipient @handle/); await user.type(peerInput, 'peer999'); await user.click(screen.getByRole('button', { name: 'Open DM' })); expect(await screen.findByText('Encrypted')).toBeInTheDocument(); }); + + test('DmsPanel resolves @handle to cryptoId before opening DM', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.directory.resolve).mockResolvedValueOnce({ + identity: { cryptoId: 'alice-crypto-id' }, + }); + vi.mocked(apiClient.messages.list).mockResolvedValue({ messages: [] }); + + render(); + await user.click(screen.getByRole('button', { name: 'DMs' })); + const peerInput = screen.getByPlaceholderText(/Recipient @handle/); + await user.type(peerInput, '@alice'); + await user.click(screen.getByRole('button', { name: 'Open DM' })); + + // directory.resolve should be called with 'alice' (stripped @) + expect(vi.mocked(apiClient.directory.resolve)).toHaveBeenCalledWith('alice'); + // The DM view should open (compose box appears) + expect(await screen.findByPlaceholderText(/Type a message/)).toBeInTheDocument(); + }); + + test('DmsPanel shows error when directory.resolve returns no agent', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.directory.resolve).mockResolvedValueOnce({ + identity: undefined, + agent: undefined, + }); + + render(); + await user.click(screen.getByRole('button', { name: 'DMs' })); + const peerInput = screen.getByPlaceholderText(/Recipient @handle/); + await user.type(peerInput, '@unknown-user'); + await user.click(screen.getByRole('button', { name: 'Open DM' })); + + const err = await screen.findByTestId('dm-resolve-error'); + expect(err).toHaveTextContent(/No agent found/); + // DM view should NOT be open + expect(screen.queryByPlaceholderText(/Type a message/)).not.toBeInTheDocument(); + }); + + test('DmsPanel falls back to agent.cryptoId when identity is absent', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.directory.resolve).mockResolvedValueOnce({ + identity: undefined, + agent: { agentId: 'agent-99', cryptoId: 'fallback-crypto-id', name: 'Bob' }, + }); + vi.mocked(apiClient.messages.list).mockResolvedValue({ messages: [] }); + + render(); + await user.click(screen.getByRole('button', { name: 'DMs' })); + const peerInput = screen.getByPlaceholderText(/Recipient @handle/); + await user.type(peerInput, 'bob.agent'); + await user.click(screen.getByRole('button', { name: 'Open DM' })); + + // DM view should open using the agent's cryptoId + expect(await screen.findByPlaceholderText(/Type a message/)).toBeInTheDocument(); + }); + + test('DmsPanel passes raw base58 wallet address without calling resolve', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.messages.list).mockResolvedValue({ messages: [] }); + + render(); + await user.click(screen.getByRole('button', { name: 'DMs' })); + const peerInput = screen.getByPlaceholderText(/Recipient @handle/); + // A valid 44-char base58 string — should bypass resolution + await user.type(peerInput, '61KcG5aGLqpnJz2fXyzABCDEFGHJKLMNPQRSTUVWXY'); + await user.click(screen.getByRole('button', { name: 'Open DM' })); + + // directory.resolve must NOT be called for a raw base58 address + expect(vi.mocked(apiClient.directory.resolve)).not.toHaveBeenCalled(); + // DM view should open directly + expect(await screen.findByPlaceholderText(/Type a message/)).toBeInTheDocument(); + }); + + test('DmsPanel shows error when directory.resolve throws', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.directory.resolve).mockRejectedValueOnce( + new Error('directory service unavailable') + ); + + render(); + await user.click(screen.getByRole('button', { name: 'DMs' })); + const peerInput = screen.getByPlaceholderText(/Recipient @handle/); + await user.type(peerInput, '@broken-handle'); + await user.click(screen.getByRole('button', { name: 'Open DM' })); + + const err = await screen.findByTestId('dm-resolve-error'); + expect(err).toHaveTextContent('directory service unavailable'); + }); + + // ── Directory-card fallback tests ───────────────────────────────────────── + // The Rust handler now synthesises a ResolveResponse from the directory + // agent card when the registry 404s. The frontend sees the same shape + // (agent.cryptoId present, identity absent) regardless of which code path + // resolved the name. + + test('DmsPanel opens DM via directory-card agent when registry misses (synthesised response)', async () => { + // Simulate what the new Rust backend returns after directory-card fallback: + // identity is null but agent has a cryptoId. + const user = userEvent.setup(); + vi.mocked(apiClient.directory.resolve).mockResolvedValueOnce({ + identity: null, + agent: { + agentId: 'dir-agent-99', + cryptoId: 'dir-crypto-id-99', + name: 'stevejobs', + username: 'stevejobs', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + }); + vi.mocked(apiClient.messages.list).mockResolvedValue({ messages: [] }); + + render(); + await user.click(screen.getByRole('button', { name: 'DMs' })); + const peerInput = screen.getByPlaceholderText(/Recipient @handle/); + await user.type(peerInput, 'stevejobs'); + await user.click(screen.getByRole('button', { name: 'Open DM' })); + + // directory.resolve must have been called (not bypassed as base58) + expect(vi.mocked(apiClient.directory.resolve)).toHaveBeenCalledWith('stevejobs'); + // DM view should open using the directory card crypto_id + expect(await screen.findByPlaceholderText(/Type a message/)).toBeInTheDocument(); + expect(screen.queryByTestId('dm-resolve-error')).not.toBeInTheDocument(); + }); + + test('DmsPanel shows clear human-readable error when handle is unresolvable', async () => { + // Simulate the Rust backend returning a clear error string when neither + // the registry nor the directory have the handle. The frontend catches + // the thrown error and shows it. + const user = userEvent.setup(); + vi.mocked(apiClient.directory.resolve).mockRejectedValueOnce( + new Error( + 'No agent found for "ghosthandle". Check the handle or paste their wallet address directly.' + ) + ); + + render(); + await user.click(screen.getByRole('button', { name: 'DMs' })); + const peerInput = screen.getByPlaceholderText(/Recipient @handle/); + await user.type(peerInput, 'ghosthandle'); + await user.click(screen.getByRole('button', { name: 'Open DM' })); + + const err = await screen.findByTestId('dm-resolve-error'); + expect(err).toHaveTextContent('ghosthandle'); + expect(err).not.toHaveTextContent('404'); + // DM view must NOT open + expect(screen.queryByPlaceholderText(/Type a message/)).not.toBeInTheDocument(); + }); + + test('renders DM compose UI with updated placeholder text', async () => { + render(); + await userEvent.click(screen.getByRole('button', { name: 'DMs' })); + expect(screen.getByPlaceholderText('Recipient @handle or wallet address')).toBeInTheDocument(); + }); }); // ── Tab navigation ──────────────────────────────────────────────────────────── diff --git a/app/src/agentworld/pages/MessagingSection.tsx b/app/src/agentworld/pages/MessagingSection.tsx index 91034af3b..611173f8f 100644 --- a/app/src/agentworld/pages/MessagingSection.tsx +++ b/app/src/agentworld/pages/MessagingSection.tsx @@ -1041,6 +1041,12 @@ interface DecryptedMessage { plaintext: string; timestamp: string; encrypted: true; + /** + * True for messages WE sent. The relay only returns incoming envelopes + * (and a sender can't decrypt their own outgoing ciphertext anyway), so we + * keep the plaintext locally and merge it into the thread on refresh. + */ + outgoing?: boolean; } function useDirectMessages(peerId: string) { @@ -1070,7 +1076,15 @@ function useDirectMessages(peerId: string) { log('failed to decrypt message %s: %s', env.id, String(decryptErr)); } } - setMessages(decrypted); + // Merge fresh incoming with the outgoing messages we sent this session — + // the relay only echoes incoming, so replacing would wipe our own sent + // messages. De-dupe by messageId, keep chronological order. + setMessages(prev => { + const outgoing = prev.filter(m => m.outgoing); + const seen = new Set(decrypted.map(m => m.messageId)); + const merged = [...decrypted, ...outgoing.filter(m => !seen.has(m.messageId))]; + return merged.sort((a, b) => a.timestamp.localeCompare(b.timestamp)); + }); } catch (err) { setError(String(err)); } finally { @@ -1083,7 +1097,20 @@ function useDirectMessages(peerId: string) { setSending(true); setError(null); try { - await apiClient.signal.sendMessage({ recipient: peerId, plaintext }); + const result = await apiClient.signal.sendMessage({ recipient: peerId, plaintext }); + // Optimistically show our own message: the relay won't return it and we + // can't decrypt our own ciphertext, so keep the plaintext locally. + const sentMessage: DecryptedMessage = { + messageId: result?.messageId ?? `local-${peerId}-${plaintext.length}-${Date.now()}`, + from: 'You', + plaintext, + timestamp: new Date().toISOString(), + encrypted: true, + outgoing: true, + }; + setMessages(prev => + [...prev, sentMessage].sort((a, b) => a.timestamp.localeCompare(b.timestamp)) + ); await refresh(); } catch (err) { setError(String(err)); @@ -1171,7 +1198,11 @@ function ActiveDmView({ {messages.map(msg => (
+ className={`max-w-[80%] rounded-lg px-3 py-2 text-sm ${ + msg.outgoing + ? 'ml-auto bg-primary-500/15 dark:bg-primary-500/20' + : 'mr-auto bg-stone-100 dark:bg-neutral-800' + }`}>

{msg.plaintext}

{msg.from} · {msg.timestamp} @@ -1204,10 +1235,18 @@ function ActiveDmView({ ); } +/** Quick heuristic matching Rust `looks_like_base58_pubkey`: 32–44 chars of + * the Bitcoin base58 alphabet [1-9A-HJ-NP-Za-km-z]. */ +function looksLikeBase58Pubkey(s: string): boolean { + return s.length >= 32 && s.length <= 44 && /^[1-9A-HJ-NP-Za-km-z]+$/.test(s); +} + function DmsPanel() { const [peerId, setPeerId] = useState(''); const [activePeer, setActivePeer] = useState(null); const [composeText, setComposeText] = useState(''); + const [resolving, setResolving] = useState(false); + const [resolveError, setResolveError] = useState(null); if (!E2E_MESSAGING_ENABLED) { return ( @@ -1252,24 +1291,70 @@ function DmsPanel() { ); } + const handleOpenDm = async () => { + const input = peerId.trim(); + if (!input) return; + + setResolving(true); + setResolveError(null); + + try { + // If the input looks like a raw crypto_id (base58 wallet address) we can + // open the DM directly — no resolution round-trip needed. The Rust handler + // will do the same check and use it verbatim. + if (looksLikeBase58Pubkey(input)) { + setActivePeer(input); + return; + } + + // Handle or bare name: resolve via directory first for immediate UX feedback. + const name = input.startsWith('@') ? input.slice(1) : input; + const resolved = await apiClient.directory.resolve(name); + const identity = resolved?.identity as + | { cryptoId?: string; [key: string]: unknown } + | null + | undefined; + const agent = resolved?.agent as { cryptoId?: string; [key: string]: unknown } | null; + + const cryptoId = identity?.cryptoId ?? agent?.cryptoId; + if (cryptoId) { + setActivePeer(cryptoId); + } else { + setResolveError(`No agent found for "${input}"`); + } + } catch (err) { + setResolveError(String(err)); + } finally { + setResolving(false); + } + }; + return (

setPeerId(e.target.value)} - placeholder="Recipient agent ID (base58)" + onChange={e => { + setPeerId(e.target.value); + setResolveError(null); + }} + placeholder="Recipient @handle or wallet address" className="flex-1 rounded border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400" />
+ {resolveError && ( +

+ {resolveError} +

+ )}
); } diff --git a/app/src/lib/agentworld/invokeApiClient.ts b/app/src/lib/agentworld/invokeApiClient.ts index 4bae30f8b..195cdf7c9 100644 --- a/app/src/lib/agentworld/invokeApiClient.ts +++ b/app/src/lib/agentworld/invokeApiClient.ts @@ -86,6 +86,7 @@ export interface AgentQueryParams { export interface AgentCard { agentId: string; + cryptoId?: string; name?: string; description?: string; username?: string; diff --git a/src/openhuman/tinyplace/manifest.rs b/src/openhuman/tinyplace/manifest.rs index c1723b247..ba3077b66 100644 --- a/src/openhuman/tinyplace/manifest.rs +++ b/src/openhuman/tinyplace/manifest.rs @@ -105,8 +105,52 @@ pub(crate) fn handle_tinyplace_directory_resolve(params: Map) -> let name = req_str(¶ms, "name")?.to_string(); log::debug!("{LOG_PREFIX} directory_resolve name={name}"); let client = global_state().client().await?; - let result = client.directory.resolve(&name).await.map_err(map_err)?; - to_value(result) + + // Try registry first. + match client.directory.resolve(&name).await { + Ok(resolved) if resolved.identity.is_some() || resolved.agent.is_some() => { + // Registry hit — return the full response as-is. + return to_value(resolved); + } + Ok(_) => { + // Registry returned 200 but with no identity and no agent card. + // Fall through to the directory-card fallback. + log::debug!( + "{LOG_PREFIX} directory_resolve: registry miss for '{name}', \ + trying directory card fallback" + ); + } + Err(e) if e.status() == Some(404) => { + // Registry 404 — agent may be directory-only; fall through. + log::debug!( + "{LOG_PREFIX} directory_resolve: registry 404 for '{name}', \ + trying directory card fallback" + ); + } + Err(e) => return Err(map_err(e)), + } + + // Directory-card fallback: query by username. + match directory_card_fallback(&client, &name).await { + Some(card) => { + log::debug!( + "{LOG_PREFIX} directory_resolve: found '{name}' via directory card \ + crypto_id={}", + card.crypto_id + ); + // Synthesise a ResolveResponse from the agent card so the + // frontend gets the same shape regardless of code path. + let response = tinyplace::types::ResolveResponse { + identity: None, + agent: Some(card), + }; + to_value(response) + } + None => Err(format!( + "No agent found for \"{name}\". \ + Check the handle or paste their wallet address directly." + )), + } }) } @@ -2771,12 +2815,30 @@ pub(crate) fn handle_tinyplace_signal_rotate_signed_pre_key( /// Fetch a peer's published Signal pre-key bundle (public endpoint, no auth). /// The `get_bundle` endpoint does not require a signer or the signal store. +/// Accepts @handles and bare handle names in addition to raw crypto_ids; the +/// identifier is resolved to a canonical crypto_id via directory.resolve before +/// the bundle lookup. pub(crate) fn handle_tinyplace_signal_get_bundle(params: Map) -> ControllerFuture { Box::pin(async move { - let agent_id = req_str(¶ms, "agentId")?.to_string(); - log::debug!("{LOG_PREFIX} signal_get_bundle agent_id={agent_id}"); + let raw_agent_id = req_str(¶ms, "agentId")?.to_string(); + log::debug!("{LOG_PREFIX} signal_get_bundle raw_agent_id='{raw_agent_id}'"); let client = global_state().client().await?; - let result = client.keys.get_bundle(&agent_id).await.map_err(map_err)?; + + // Resolve the identifier (handle or crypto_id) before the bundle lookup. + let agent_id = resolve_recipient_to_agent_id(&client, &raw_agent_id).await?; + log::debug!("{LOG_PREFIX} signal_get_bundle resolved agent_id={agent_id}"); + + let result = match client.keys.get_bundle(&agent_id).await { + Ok(b) => b, + Err(e) if e.status() == Some(404) => { + return Err(format!( + "Recipient has not set up encrypted messaging yet. \ + They need to provision Signal keys before receiving DMs. \ + (agent_id: {agent_id})" + )); + } + Err(e) => return Err(map_err(e)), + }; to_value(result) }) } @@ -2902,12 +2964,170 @@ fn decode_ed25519_pub( decode_32_byte_b64(b64, "peer Ed25519 publicKey") } +// ── Recipient resolution helpers ────────────────────────────────────────────── + +/// Quick heuristic: Solana/wallet base58 public keys are 32–44 chars of the +/// Bitcoin base58 alphabet [1-9A-HJ-NP-Za-km-z] with no 0/I/O/l. +/// False negatives (short base58 strings treated as handles) are safe — they +/// get an extra directory.resolve call rather than a wrong key lookup. +fn looks_like_base58_pubkey(s: &str) -> bool { + s.len() >= 32 + && s.len() <= 44 + && s.chars().all(|c| { + matches!(c, + '1'..='9' | 'A'..='H' | 'J'..='N' | 'P'..='Z' | 'a'..='k' | 'm'..='z') + }) +} + +/// Case-insensitive username match across a slice of agent cards. +/// +/// Extracted as a pure function so it can be unit-tested without a live HTTP +/// client. +fn match_agent_card_by_username<'a>( + cards: &'a [tinyplace::types::AgentCard], + name: &str, +) -> Option<&'a tinyplace::types::AgentCard> { + let name_lower = name.to_lowercase(); + cards.iter().find(|a| { + a.username + .as_deref() + .map(|u| u.to_lowercase() == name_lower) + .unwrap_or(false) + }) +} + +/// Query the agent directory for a card whose `username` matches `name` +/// (case-insensitive). Uses the server-side `username` filter when available +/// (limit 20) and falls back to a client-side match in the returned page. +/// +/// Returns `None` if no matching card is found. +async fn directory_card_fallback( + client: &tinyplace::TinyPlaceClient, + name: &str, +) -> Option { + let query = tinyplace::types::AgentQueryParams { + username: Some(name.to_string()), + limit: Some(20), + ..Default::default() + }; + log::debug!("{LOG_PREFIX} directory_card_fallback: querying agents with username='{name}'"); + match client.directory.list_agents(Some(&query)).await { + Ok(resp) => { + if resp.agents.len() == 20 { + log::debug!( + "{LOG_PREFIX} directory_card_fallback: page full (20 agents) for '{name}', \ + result may be truncated" + ); + } + match_agent_card_by_username(&resp.agents, name).cloned() + } + Err(e) => { + log::debug!( + "{LOG_PREFIX} directory_card_fallback: list_agents failed for '{name}': {}", + e + ); + None + } + } +} + +/// Resolve a recipient identifier (handle, wallet address, or crypto_id) to +/// the canonical `crypto_id` used by the `/keys/{id}/bundle` endpoint. +/// +/// Resolution rules: +/// 1. Starts with `@` → strip `@` and resolve via `directory.resolve`. +/// 2. Looks like a base58 public key (32–44 chars, valid alphabet) → use as-is +/// (it IS the crypto_id; attempting resolve is unnecessary and fragile). +/// 3. Anything else → treat as a bare handle name and call `directory.resolve`. +/// 4. If the registry lookup returns 404 or no identity/agent, fall back to a +/// directory-card search by username (`list_agents?username=…`). +/// +/// SECURITY: never log private key material — only the resolved crypto_id +/// (public wallet address) is emitted to logs. +async fn resolve_recipient_to_agent_id( + client: &tinyplace::TinyPlaceClient, + raw_recipient: &str, +) -> std::result::Result { + let trimmed = raw_recipient.trim(); + + let lookup_name = if let Some(without_at) = trimmed.strip_prefix('@') { + // Explicit @handle — resolve it. + without_at + } else if looks_like_base58_pubkey(trimmed) { + // Already a crypto_id — pass through without a round-trip. + log::debug!( + "{LOG_PREFIX} resolve_recipient: '{trimmed}' looks like a crypto_id, using directly" + ); + return Ok(trimmed.to_string()); + } else { + // Bare handle name — resolve it. + trimmed + }; + + log::debug!("{LOG_PREFIX} resolve_recipient: resolving handle '{lookup_name}' via registry"); + + // ── Step 1: try the registry ───────────────────────────────────────────── + let registry_result = client.directory.resolve(lookup_name).await; + match registry_result { + Ok(resolved) => { + // Prefer the registered identity's crypto_id; fall back to the agent card. + if let Some(identity) = resolved.identity { + let crypto_id = identity.crypto_id.clone(); + log::debug!( + "{LOG_PREFIX} resolve_recipient: handle '{lookup_name}' -> crypto_id={crypto_id}" + ); + return Ok(crypto_id); + } else if let Some(agent) = resolved.agent { + let crypto_id = agent.crypto_id.clone(); + log::debug!( + "{LOG_PREFIX} resolve_recipient: handle '{lookup_name}' resolved via \ + registry agent card -> crypto_id={crypto_id}" + ); + return Ok(crypto_id); + } + // Registry returned 200 but empty — fall through to directory. + log::debug!( + "{LOG_PREFIX} resolve_recipient: registry returned empty for '{lookup_name}', \ + trying directory card fallback" + ); + } + Err(e) if e.status() == Some(404) => { + // 404 from the registry — agent may be directory-only; fall through. + log::debug!( + "{LOG_PREFIX} resolve_recipient: registry 404 for '{lookup_name}', \ + trying directory card fallback" + ); + } + Err(e) => { + return Err(format!( + "failed to resolve recipient '{trimmed}': {}", + map_err(e) + )); + } + } + + // ── Step 2: directory-card fallback ────────────────────────────────────── + if let Some(card) = directory_card_fallback(client, lookup_name).await { + let crypto_id = card.crypto_id.clone(); + log::debug!( + "{LOG_PREFIX} resolve_recipient: '{lookup_name}' found via directory card \ + -> crypto_id={crypto_id}" + ); + return Ok(crypto_id); + } + + Err(format!( + "No agent found for \"{lookup_name}\". \ + Check the handle or paste their wallet address directly." + )) +} + pub(crate) fn handle_tinyplace_signal_send_message(params: Map) -> ControllerFuture { Box::pin(async move { let recipient = req_str(¶ms, "recipient")?.to_string(); let plaintext = req_str(¶ms, "plaintext")?.to_string(); log::debug!( - "{LOG_PREFIX} signal_send_message to={recipient} len={}", + "{LOG_PREFIX} signal_send_message raw_recipient='{recipient}' len={}", plaintext.len() ); @@ -2922,10 +3142,26 @@ pub(crate) fn handle_tinyplace_signal_send_message(params: Map) - .map_err(|e| format!("identity key: {e}"))? .public_key; + // Resolve the recipient identifier (@handle, bare handle, or crypto_id) to + // the canonical crypto_id before any key-bundle or directory lookup. + let agent_id = resolve_recipient_to_agent_id(&client, &recipient).await?; + log::debug!("{LOG_PREFIX} signal_send_message resolved to agent_id={agent_id}"); + // Fetch recipient's published key bundle (always needed for the X25519 // identity key used in associated-data computation, even for existing - // sessions). - let bundle = client.keys.get_bundle(&recipient).await.map_err(map_err)?; + // sessions). Provide a user-friendly error when the recipient has not yet + // provisioned Signal keys (404 from the backend). + let bundle = match client.keys.get_bundle(&agent_id).await { + Ok(b) => b, + Err(e) if e.status() == Some(404) => { + return Err(format!( + "Recipient has not set up encrypted messaging yet. \ + They need to provision Signal keys before receiving DMs. \ + (resolved agent_id: {agent_id})" + )); + } + Err(e) => return Err(map_err(e)), + }; // Ed25519 -> X25519 conversion: the backend serves the Ed25519 identity; // SignalSession::encrypt takes the X25519 form. decode_identity_key // performs this conversion and must be preserved. @@ -2938,20 +3174,18 @@ pub(crate) fn handle_tinyplace_signal_send_message(params: Map) - our_identity_pub, ); let has_session = signal_session - .has_session(&recipient) + .has_session(&agent_id) .await .map_err(|e| format!("check session: {e}"))?; let (bundle_opt, ed25519_opt) = if has_session { - log::debug!("{LOG_PREFIX} signal_send_message using existing session for {recipient}"); + log::debug!("{LOG_PREFIX} signal_send_message using existing session for {agent_id}"); (None, None) } else { - log::debug!( - "{LOG_PREFIX} signal_send_message establishing new session for {recipient}" - ); + log::debug!("{LOG_PREFIX} signal_send_message establishing new session for {agent_id}"); let peer_entry = client .directory - .get_agent(&recipient) + .get_agent(&agent_id) .await .map_err(map_err)?; let peer_ed25519_pub = decode_ed25519_pub(&peer_entry)?; @@ -2966,7 +3200,7 @@ pub(crate) fn handle_tinyplace_signal_send_message(params: Map) - // point leaves no partial state). let encrypted = signal_session .encrypt( - &recipient, + &agent_id, &their_x25519_identity, plaintext.as_bytes(), bundle_opt.as_ref(), @@ -2975,7 +3209,7 @@ pub(crate) fn handle_tinyplace_signal_send_message(params: Map) - .await .map_err(|e| { log::error!( - "{LOG_PREFIX} signal_send_message ENCRYPTION FAILED for {recipient}: {e} \ + "{LOG_PREFIX} signal_send_message ENCRYPTION FAILED for {agent_id}: {e} \ — aborting send (plaintext will NOT be sent)" ); format!("encryption failed — message NOT sent: {e}") @@ -2983,10 +3217,14 @@ pub(crate) fn handle_tinyplace_signal_send_message(params: Map) - // Map EncryptedMessage -> MessageEnvelope (wire-format preserving). // Field correspondence is verified in phase-signalsession-spec.md §4. + // Use the resolved agent_id (not raw recipient) so the backend routes correctly. let envelope = tinyplace::types::MessageEnvelope { - id: String::new(), + // The backend requires a non-empty client-generated message id + // (POST /messages rejects an empty id with "message id, from, and to + // are required"). The SDK's send() only fills timestamp, not id. + id: uuid::Uuid::new_v4().to_string(), from: our_agent_id.clone(), - to: recipient.clone(), + to: agent_id.clone(), timestamp: String::new(), device_id: 1, envelope_type: encrypted.message_type.clone(), // "PREKEY_BUNDLE" or "CIPHERTEXT" @@ -2997,7 +3235,7 @@ pub(crate) fn handle_tinyplace_signal_send_message(params: Map) - let sent = client.messages.send(envelope).await.map_err(map_err)?; log::info!( - "{LOG_PREFIX} signal_send_message sent encrypted message to={recipient} \ + "{LOG_PREFIX} signal_send_message sent encrypted message to={agent_id} \ id={} type={} len={}", sent.id, sent.envelope_type, @@ -4844,13 +5082,6 @@ mod tests { assert!(err.contains("streamId"), "got: {err}"); } - /// `signal_get_bundle` rejects a missing `agentId` before any client work. - #[test] - fn signal_get_bundle_requires_agent_id() { - let err = block_on(handle_tinyplace_signal_get_bundle(Map::new())).unwrap_err(); - assert!(err.contains("agentId"), "got: {err}"); - } - /// `signal_provision` has no required params; it must fail at `global_signal_store` /// (wallet/config not available in unit tests), NOT at param extraction. /// Uses a Tokio runtime because `global_signal_store` internally calls @@ -4906,6 +5137,63 @@ mod tests { assert!(!err.contains("missing required param"), "got: {err}"); } + // ── looks_like_base58_pubkey heuristic ─────────────────────────────────── + + #[test] + fn base58_pubkey_heuristic_accepts_valid_keys() { + // 44-char base58 string using all valid alphabet chars + assert!( + looks_like_base58_pubkey("61KcG5aGLqpnJz2fXyzABCDEFGHJKLMNPQRSTUVWXY"), + "44-char valid base58 must match" + ); + // 32-char minimum + assert!( + looks_like_base58_pubkey("11111111111111111111111111111111"), + "32-char all-1s must match (system program)" + ); + } + + #[test] + fn base58_pubkey_heuristic_rejects_handles_and_invalid() { + assert!( + !looks_like_base58_pubkey("@alice"), + "@ prefix must not match" + ); + assert!( + !looks_like_base58_pubkey("alice"), + "too short to be a pubkey" + ); + assert!(!looks_like_base58_pubkey(""), "empty must not match"); + // Contains '!' which is outside the base58 alphabet + assert!( + !looks_like_base58_pubkey("61KcG5aGLqpn!Jz2fXyzABCDEFGHJKLMNPQRSTUVWX"), + "invalid char must not match" + ); + // Contains '0' which is NOT in base58 (confused with 'O') + assert!( + !looks_like_base58_pubkey("61KcG5aGLqpnJz2fXyzABCDEFGHJKLMNPQRS0UVWXY"), + "zero char not in base58 alphabet" + ); + } + + #[test] + fn base58_pubkey_heuristic_rejects_45_char_string() { + // 45 chars is too long for a 32-byte base58 pubkey (valid range is 32..=44) + let s = "61KcG5aGLqpnJz2fXyzABCDEFGHJKLMNPQRSTUVWXYZ12"; + assert_eq!(s.len(), 45, "test fixture must be exactly 45 chars"); + assert!( + !looks_like_base58_pubkey(s), + "45-char string must not match" + ); + } + + /// `signal_get_bundle` rejects a missing `agentId` before any resolution. + #[test] + fn signal_get_bundle_requires_agent_id() { + let err = block_on(handle_tinyplace_signal_get_bundle(Map::new())).unwrap_err(); + assert!(err.contains("agentId"), "got: {err}"); + } + #[test] fn signal_send_requires_recipient_and_plaintext() { let mut params = Map::new(); @@ -5407,4 +5695,121 @@ mod tests { "actor must not be a client param: {err}" ); } + + // ── match_agent_card_by_username — pure matching logic ─────────────────── + + fn make_test_card(agent_id: &str, username: Option<&str>) -> tinyplace::types::AgentCard { + tinyplace::types::AgentCard { + agent_id: agent_id.to_string(), + name: agent_id.to_string(), + description: None, + username: username.map(str::to_string), + crypto_id: format!("crypto-{agent_id}"), + public_key: None, + url: None, + endpoint: None, + supported_interfaces: None, + skills: None, + capabilities: None, + tags: None, + payment_methods: None, + payment_requirements: None, + groups: None, + docs: None, + webhooks: None, + metadata: None, + signature: None, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + } + } + + /// Directory-card matcher finds an exact username match. + #[test] + fn match_agent_card_exact_username_hit() { + let cards = vec![ + make_test_card("agent-1", Some("alice")), + make_test_card("agent-2", Some("stevejobs")), + make_test_card("agent-3", Some("bob")), + ]; + let found = match_agent_card_by_username(&cards, "stevejobs"); + assert!(found.is_some(), "should find stevejobs"); + assert_eq!(found.unwrap().crypto_id, "crypto-agent-2"); + } + + /// Directory-card matcher is case-insensitive. + #[test] + fn match_agent_card_case_insensitive() { + let cards = vec![make_test_card("agent-1", Some("SteveJobs"))]; + let found = match_agent_card_by_username(&cards, "stevejobs"); + assert!(found.is_some(), "case-insensitive match must succeed"); + } + + /// Directory-card matcher returns None when no card has the given username. + #[test] + fn match_agent_card_miss() { + let cards = vec![ + make_test_card("agent-1", Some("alice")), + make_test_card("agent-2", None), // no username + ]; + let found = match_agent_card_by_username(&cards, "notexist"); + assert!(found.is_none(), "should return None when no match"); + } + + /// Directory-card matcher skips cards with no username without panicking. + #[test] + fn match_agent_card_skips_cards_without_username() { + let cards = vec![ + make_test_card("agent-1", None), + make_test_card("agent-2", None), + make_test_card("agent-3", Some("target")), + ]; + let found = match_agent_card_by_username(&cards, "target"); + assert!(found.is_some(), "should find the card that has a username"); + assert_eq!(found.unwrap().agent_id, "agent-3"); + } + + /// looks_like_base58_pubkey passthrough: resolve_recipient should NOT call + /// directory.resolve for a raw crypto_id — it short-circuits and returns it + /// directly. We can verify this by supplying a valid 44-char base58 string + /// which, without a live client, would error if it tried to resolve. + /// + /// Because `resolve_recipient_to_agent_id` requires a live client we test + /// the passthrough indirectly via `looks_like_base58_pubkey`. + #[test] + fn base58_passthrough_identified_correctly() { + // 44-char valid base58 — same as used elsewhere in this test suite. + let key = "61KcG5aGLqpnJz2fXyzABCDEFGHJKLMNPQRSTUVWXY"; + assert!( + looks_like_base58_pubkey(key), + "44-char base58 must be identified as a crypto_id to skip resolution" + ); + } + + /// Resolver error message does NOT expose the raw 404 HTTP text. + /// The error string produced when no agent is found must be human-readable + /// and must not look like a raw network error. + #[test] + fn resolve_not_found_error_is_human_readable() { + // Test the error message produced in handle_tinyplace_directory_resolve + // when both registry and directory miss — replicated here as the literal + // format used in the production code. + let name = "unknownuser"; + let err = format!( + "No agent found for \"{name}\". \ + Check the handle or paste their wallet address directly." + ); + assert!( + err.contains("unknownuser"), + "error must name the handle: {err}" + ); + assert!( + !err.contains("404"), + "error must not expose raw HTTP status: {err}" + ); + assert!( + !err.contains("not found"), + "error must not use raw 404 body text: {err}" + ); + } }