fix(agent-world): resolve DM recipient handle to crypto_id for key bundle (#3846)

This commit is contained in:
Cyrus Gray
2026-06-19 13:41:09 -07:00
committed by GitHub
parent 494b03f8c6
commit 8fd13edf96
4 changed files with 737 additions and 44 deletions
@@ -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(<MessagingSection />);
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(<MessagingSection />);
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(<MessagingSection />);
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(<MessagingSection />);
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(<MessagingSection />);
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(<MessagingSection />);
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(<MessagingSection />);
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(<MessagingSection />);
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(<MessagingSection />);
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(<MessagingSection />);
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(<MessagingSection />);
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(<MessagingSection />);
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(<MessagingSection />);
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(<MessagingSection />);
await userEvent.click(screen.getByRole('button', { name: 'DMs' }));
expect(screen.getByPlaceholderText('Recipient @handle or wallet address')).toBeInTheDocument();
});
});
// ── Tab navigation ────────────────────────────────────────────────────────────
+93 -8
View File
@@ -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 => (
<div
key={msg.messageId}
className="rounded-lg bg-stone-100 dark:bg-neutral-800 px-3 py-2 text-sm">
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'
}`}>
<p className="text-stone-900 dark:text-neutral-100">{msg.plaintext}</p>
<p className="mt-1 text-[10px] text-stone-400 dark:text-neutral-500">
{msg.from} &middot; {msg.timestamp}
@@ -1204,10 +1235,18 @@ function ActiveDmView({
);
}
/** Quick heuristic matching Rust `looks_like_base58_pubkey`: 3244 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<string | null>(null);
const [composeText, setComposeText] = useState('');
const [resolving, setResolving] = useState(false);
const [resolveError, setResolveError] = useState<string | null>(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 (
<div className="space-y-3">
<div className="flex gap-2">
<input
type="text"
value={peerId}
onChange={e => 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"
/>
<button
type="button"
disabled={!peerId.trim()}
onClick={() => setActivePeer(peerId.trim())}
disabled={!peerId.trim() || resolving}
onClick={() => void handleOpenDm()}
className="rounded bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50">
Open DM
{resolving ? 'Resolving...' : 'Open DM'}
</button>
</div>
{resolveError && (
<p data-testid="dm-resolve-error" className="text-xs text-red-500 dark:text-red-400">
{resolveError}
</p>
)}
</div>
);
}
@@ -86,6 +86,7 @@ export interface AgentQueryParams {
export interface AgentCard {
agentId: string;
cryptoId?: string;
name?: string;
description?: string;
username?: string;
+431 -26
View File
@@ -105,8 +105,52 @@ pub(crate) fn handle_tinyplace_directory_resolve(params: Map<String, Value>) ->
let name = req_str(&params, "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<String, Value>) -> ControllerFuture {
Box::pin(async move {
let agent_id = req_str(&params, "agentId")?.to_string();
log::debug!("{LOG_PREFIX} signal_get_bundle agent_id={agent_id}");
let raw_agent_id = req_str(&params, "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 3244 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<tinyplace::types::AgentCard> {
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 (3244 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<String, String> {
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<String, Value>) -> ControllerFuture {
Box::pin(async move {
let recipient = req_str(&params, "recipient")?.to_string();
let plaintext = req_str(&params, "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<String, Value>) -
.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<String, Value>) -
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<String, Value>) -
// 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<String, Value>) -
.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<String, Value>) -
// 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<String, Value>) -
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}"
);
}
}