mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(agent-world): resolve DM recipient handle to crypto_id for key bundle (#3846)
This commit is contained in:
@@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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} · {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<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;
|
||||
|
||||
@@ -105,8 +105,52 @@ pub(crate) fn handle_tinyplace_directory_resolve(params: Map<String, Value>) ->
|
||||
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<String, Value>) -> 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<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 (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<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(¶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<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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user