diff --git a/app/src/components/intelligence/RelayBadge.test.tsx b/app/src/components/intelligence/RelayBadge.test.tsx new file mode 100644 index 000000000..87d959408 --- /dev/null +++ b/app/src/components/intelligence/RelayBadge.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import RelayBadge from './RelayBadge'; + +vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); + +describe('RelayBadge', () => { + it('renders nothing when relay info is absent', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('shows the staging label + base url and tags the network', () => { + render( + + ); + const badge = screen.getByTestId('tinyplace-relay-badge'); + expect(badge).toHaveAttribute('data-network', 'staging'); + expect(badge).toHaveAttribute('title', 'https://staging-api.tiny.place'); + expect(badge).toHaveTextContent('tinyplaceOrchestration.relay.staging'); + }); + + it('shows the production label for the prod relay', () => { + render(); + const badge = screen.getByTestId('tinyplace-relay-badge'); + expect(badge).toHaveAttribute('data-network', 'prod'); + expect(badge).toHaveTextContent('tinyplaceOrchestration.relay.prod'); + }); +}); diff --git a/app/src/components/intelligence/RelayBadge.tsx b/app/src/components/intelligence/RelayBadge.tsx new file mode 100644 index 000000000..2ee82ba89 --- /dev/null +++ b/app/src/components/intelligence/RelayBadge.tsx @@ -0,0 +1,44 @@ +/** + * RelayBadge — a small always-visible pill naming the tiny.place relay the core + * is talking to (STAGING / PRODUCTION). + * + * Why it exists: during live debugging, claudebot sat on `staging-api.tiny.place` + * while the core silently defaulted to prod `api.tiny.place`, producing a mystery + * 404. Nothing on screen said which relay either side used. Surfacing the target + * relay as a first-class chip makes a mismatch obvious instead of opaque. + * + * Presentational only — the parent fetches {@link RelayInfo} via + * `orchestrationClient.relayInfo()` and passes it down. + */ +import { useT } from '../../lib/i18n/I18nContext'; +import type { RelayInfo } from '../../lib/orchestration/orchestrationClient'; + +export interface RelayBadgeProps { + relay: RelayInfo | null; +} + +export default function RelayBadge({ relay }: RelayBadgeProps): React.ReactElement | null { + const { t } = useT(); + if (!relay) return null; + + const isStaging = relay.network === 'staging'; + const label = isStaging + ? t('tinyplaceOrchestration.relay.staging') + : t('tinyplaceOrchestration.relay.prod'); + + // Staging is the everyday dev target (amber, "you're on the test relay"); + // production is the live one (sage). Colour, not just text, so it's scannable. + const tone = isStaging + ? 'border-amber-400 text-amber-700 dark:border-amber-500/60 dark:text-amber-300' + : 'border-sage-400 text-sage-700 dark:border-sage-500/60 dark:text-sage-300'; + + return ( + + {label} + + ); +} diff --git a/app/src/components/intelligence/SelfIdentityCard.test.tsx b/app/src/components/intelligence/SelfIdentityCard.test.tsx new file mode 100644 index 000000000..ed1739332 --- /dev/null +++ b/app/src/components/intelligence/SelfIdentityCard.test.tsx @@ -0,0 +1,73 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { SelfIdentity } from '../../lib/orchestration/orchestrationClient'; +import SelfIdentityCard from './SelfIdentityCard'; + +vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); + +const discoverable: SelfIdentity = { + agentId: '6wNaBJkatir4B86cw5ykHZWQ3xoNaKygX5vAU9MQbHSh', + handles: [{ username: 'openhuman', primary: true }], + primaryHandle: 'openhuman', + cardPublished: true, + keyPublished: true, + discoverable: true, +}; + +describe('SelfIdentityCard', () => { + beforeEach(() => { + Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } }); + }); + + it('shows a loading state before the identity resolves', () => { + render(); + expect(screen.getByTestId('tinyplace-self-identity')).toHaveTextContent( + 'tinyplaceOrchestration.identity.loading' + ); + }); + + it('renders nothing once loaded with no identity', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders the primary handle, shortened address and discoverable status', () => { + render(); + expect(screen.getByText('@openhuman')).toBeInTheDocument(); + // Address is shortened but the full value is preserved in the title. + expect(screen.getByText('6wNaBJ…QbHSh')).toHaveAttribute('title', discoverable.agentId); + const status = screen.getByTestId('tinyplace-self-identity-status'); + expect(status).toHaveAttribute('data-discoverable', 'true'); + }); + + it('flags an un-messageable identity and shows the register hint', () => { + const undiscoverable: SelfIdentity = { + agentId: 'addrWithNoCardPublishedYet', + handles: [], + cardPublished: false, + keyPublished: false, + discoverable: false, + }; + render(); + expect(screen.getByText('tinyplaceOrchestration.identity.noHandle')).toBeInTheDocument(); + expect(screen.getByTestId('tinyplace-self-identity-status')).toHaveAttribute( + 'data-discoverable', + 'false' + ); + expect( + screen.getByText('tinyplaceOrchestration.identity.undiscoverableHint') + ).toBeInTheDocument(); + }); + + it('copies the address to the clipboard on click', async () => { + render(); + fireEvent.click(screen.getByTestId('tinyplace-self-identity-copy')); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(discoverable.agentId); + await waitFor(() => + expect(screen.getByTestId('tinyplace-self-identity-copy')).toHaveTextContent( + 'tinyplaceOrchestration.identity.copied' + ) + ); + }); +}); diff --git a/app/src/components/intelligence/SelfIdentityCard.tsx b/app/src/components/intelligence/SelfIdentityCard.tsx new file mode 100644 index 000000000..59ad8b69a --- /dev/null +++ b/app/src/components/intelligence/SelfIdentityCard.tsx @@ -0,0 +1,134 @@ +/** + * SelfIdentityCard — pinned at the top of the roster, shows OpenHuman's *own* + * tiny.place identity so the user can hand it to a peer to be messaged inbound. + * + * Why it exists: the tab previously showed peers/contacts but never OpenHuman's + * own agent id, so there was no way to say "here's my address, DM me". Worse, a + * fresh identity can accept contacts yet stay un-messageable until it registers a + * @handle (which is what publishes its directory card + Signal key) — every send + * to it 404s on `/directory/agents/`. This card surfaces both the address + * and the discoverability gap instead of leaving it a mystery. + * + * Presentational only — the parent fetches {@link SelfIdentity} via + * `orchestrationClient.selfIdentity()` and owns refresh. + */ +import { useCallback, useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import type { SelfIdentity } from '../../lib/orchestration/orchestrationClient'; + +export interface SelfIdentityCardProps { + identity: SelfIdentity | null; + loading: boolean; +} + +function shortAddress(address: string): string { + if (address.length <= 14) return address; + return `${address.slice(0, 6)}…${address.slice(-5)}`; +} + +export default function SelfIdentityCard({ + identity, + loading, +}: SelfIdentityCardProps): React.ReactElement { + const { t } = useT(); + const [copied, setCopied] = useState(false); + + const address = identity?.agentId ?? ''; + const onCopy = useCallback(() => { + if (!address) return; + void navigator.clipboard?.writeText(address).then( + () => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + }, + () => setCopied(false) + ); + }, [address]); + + if (loading && !identity) { + return ( + + {t('tinyplaceOrchestration.identity.loading')} + + ); + } + + if (!identity) return <>>; + + const primaryHandle = identity.primaryHandle ?? identity.handles[0]?.username; + + return ( + + + + OH + + + + {primaryHandle ? `@${primaryHandle}` : t('tinyplaceOrchestration.identity.noHandle')} + + + {shortAddress(address)} + + + + {copied + ? t('tinyplaceOrchestration.identity.copied') + : t('tinyplaceOrchestration.identity.copy')} + + + + + {identity.discoverable ? ( + + {t('tinyplaceOrchestration.identity.discoverable')} + + ) : ( + + {t('tinyplaceOrchestration.identity.undiscoverable')} + + )} + + {t('tinyplaceOrchestration.identity.card')}:{' '} + {identity.cardPublished + ? t('tinyplaceOrchestration.identity.published') + : t('tinyplaceOrchestration.identity.notPublished')} + + + {t('tinyplaceOrchestration.identity.key')}:{' '} + {identity.keyPublished + ? t('tinyplaceOrchestration.identity.published') + : t('tinyplaceOrchestration.identity.notPublished')} + + + + {!identity.discoverable ? ( + + {t('tinyplaceOrchestration.identity.undiscoverableHint')} + + ) : null} + + ); +} diff --git a/app/src/components/intelligence/TinyPlaceOrchestrationTab.test.tsx b/app/src/components/intelligence/TinyPlaceOrchestrationTab.test.tsx index 8a8249086..ac8950ab4 100644 --- a/app/src/components/intelligence/TinyPlaceOrchestrationTab.test.tsx +++ b/app/src/components/intelligence/TinyPlaceOrchestrationTab.test.tsx @@ -30,6 +30,8 @@ vi.mock('../../lib/orchestration/orchestrationClient', async importOriginal => { sendMasterMessage: vi.fn(), markRead: vi.fn(), status: vi.fn(), + selfIdentity: vi.fn(), + relayInfo: vi.fn(), }, }; }); @@ -50,6 +52,8 @@ const messagesListMock = vi.mocked(orchestrationClient.messagesList); const sendMasterMock = vi.mocked(orchestrationClient.sendMasterMessage); const markReadMock = vi.mocked(orchestrationClient.markRead); const statusMock = vi.mocked(orchestrationClient.status); +const selfIdentityMock = vi.mocked(orchestrationClient.selfIdentity); +const relayInfoMock = vi.mocked(orchestrationClient.relayInfo); const pairingListMock = vi.mocked(apiClient.orchestrationPairing.list); const pairingLinkMock = vi.mocked(apiClient.orchestrationPairing.linkSession); @@ -105,6 +109,18 @@ describe('TinyPlaceOrchestrationTab', () => { sendMasterMock.mockResolvedValue({ ok: true, messageId: 'm-1' }); markReadMock.mockResolvedValue({ ok: true }); statusMock.mockResolvedValue({}); + selfIdentityMock.mockResolvedValue({ + agentId: '6wNaBJkatir4B86cw5ykHZWQ3xoNaKygX5vAU9MQbHSh', + handles: [{ username: 'openhuman', primary: true }], + primaryHandle: 'openhuman', + cardPublished: true, + keyPublished: true, + discoverable: true, + }); + relayInfoMock.mockResolvedValue({ + baseUrl: 'https://staging-api.tiny.place', + network: 'staging', + }); pairingListMock.mockResolvedValue({ records: [], contacts: { contacts: [] }, @@ -191,6 +207,19 @@ describe('TinyPlaceOrchestrationTab', () => { expect(screen.getByText('OpenHuman app session')).toBeInTheDocument(); }); + it('keeps the relay badge visible when identity discovery fails (locked wallet)', async () => { + // selfIdentity() builds the tiny.place client from the wallet and can reject; + // relayInfo() only reads the base URL and must stay visible regardless. + selfIdentityMock.mockRejectedValue(new Error('wallet locked')); + + render(); + + const badge = await screen.findByTestId('tinyplace-relay-badge'); + expect(badge).toHaveAttribute('data-network', 'staging'); + // Identity read failed → its card must not render. + expect(screen.queryByTestId('tinyplace-self-identity')).not.toBeInTheDocument(); + }); + it('loads and renders messages for the opened chat', async () => { sessionsListMock.mockResolvedValue({ sessions: [ diff --git a/app/src/components/intelligence/TinyPlaceOrchestrationTab.tsx b/app/src/components/intelligence/TinyPlaceOrchestrationTab.tsx index 686d8b36c..14e9b0583 100644 --- a/app/src/components/intelligence/TinyPlaceOrchestrationTab.tsx +++ b/app/src/components/intelligence/TinyPlaceOrchestrationTab.tsx @@ -9,6 +9,11 @@ import { PaymentRequiredError, } from '../../lib/agentworld/invokeApiClient'; import { useT } from '../../lib/i18n/I18nContext'; +import { + orchestrationClient, + type RelayInfo, + type SelfIdentity, +} from '../../lib/orchestration/orchestrationClient'; import { type ChatMessage, type ChatWindow, @@ -17,6 +22,8 @@ import { } from '../../lib/orchestration/useOrchestrationChats'; import { subconsciousTrigger } from '../../utils/tauriCommands/subconscious'; import Button from '../ui/Button'; +import RelayBadge from './RelayBadge'; +import SelfIdentityCard from './SelfIdentityCard'; const debug = debugFactory('brain:tinyplace-orchestration'); @@ -213,6 +220,12 @@ export default function TinyPlaceOrchestrationTab() { // Which contact rows are expanded to reveal their nested sessions. const [expandedContacts, setExpandedContacts] = useState>({}); const [creatingSession, setCreatingSession] = useState(null); + // Own tiny.place identity (discoverability) + the relay the core is on. Both + // best-effort: a failed read leaves the card/badge hidden rather than erroring + // the whole tab. + const [selfIdentity, setSelfIdentity] = useState(null); + const [identityLoading, setIdentityLoading] = useState(true); + const [relayInfo, setRelayInfo] = useState(null); const mountedRef = useRef(true); const toggleContact = useCallback((address: string) => { @@ -257,6 +270,40 @@ export default function TinyPlaceOrchestrationTab() { } }, []); + const loadIdentity = useCallback(async () => { + debug('[tinyplace-orchestration] identity load entry'); + // Identity and relay are independent reads: selfIdentity() builds the + // tiny.place client from the wallet and can reject (locked/unconfigured + // wallet), but relayInfo() only reads the configured base URL and must + // stay visible regardless. Settle them separately so one failure never + // hides the other. Neither failure may break the chat surface. + const [identityResult, relayResult] = await Promise.allSettled([ + orchestrationClient.selfIdentity(), + orchestrationClient.relayInfo(), + ]); + if (!mountedRef.current) return; + if (identityResult.status === 'fulfilled') { + debug( + '[tinyplace-orchestration] identity load ok discoverable=%s', + identityResult.value.discoverable + ); + setSelfIdentity(identityResult.value); + } else { + const reason = identityResult.reason; + const message = reason instanceof Error ? reason.message : String(reason); + debug('[tinyplace-orchestration] identity load error %s', message); + } + if (relayResult.status === 'fulfilled') { + debug('[tinyplace-orchestration] relay load ok network=%s', relayResult.value.network); + setRelayInfo(relayResult.value); + } else { + const reason = relayResult.reason; + const message = reason instanceof Error ? reason.message : String(reason); + debug('[tinyplace-orchestration] relay load error %s', message); + } + setIdentityLoading(false); + }, []); + const runPairingAction = useCallback( async (actionId: string, action: () => Promise) => { debug('[tinyplace-orchestration] pairing action entry id=%s', actionId); @@ -297,7 +344,8 @@ export default function TinyPlaceOrchestrationTab() { const refreshAll = useCallback(() => { void refresh(); void loadPairing(); - }, [refresh, loadPairing]); + void loadIdentity(); + }, [refresh, loadPairing, loadIdentity]); const submitComposer = useCallback( (event: FormEvent) => { @@ -316,12 +364,15 @@ export default function TinyPlaceOrchestrationTab() { useEffect(() => { mountedRef.current = true; - const handle = window.setTimeout(() => void loadPairing(), 0); + const handle = window.setTimeout(() => { + void loadPairing(); + void loadIdentity(); + }, 0); return () => { window.clearTimeout(handle); mountedRef.current = false; }; - }, [loadPairing]); + }, [loadPairing, loadIdentity]); const pinned = chats.filter(chat => chat.pinned); const sessions = chats @@ -416,9 +467,12 @@ export default function TinyPlaceOrchestrationTab() { - - {t('tinyplaceOrchestration.title')} - + + + {t('tinyplaceOrchestration.title')} + + + {t('tinyplaceOrchestration.subtitle')} @@ -444,6 +498,8 @@ export default function TinyPlaceOrchestrationTab() { ) : null} + + call('openhuman.orchestration_status', {}), + + /** + * This agent's own tiny.place identity + discoverability (agent id, @handles, + * whether its directory card and Signal key are published, whether peers can + * DM it). Powers the SelfIdentityCard. + */ + selfIdentity: () => call('openhuman.orchestration_self_identity', {}), + + /** The relay endpoint + network label the core is talking to (RelayBadge). */ + relayInfo: () => call('openhuman.orchestration_relay_info', {}), }; export type OrchestrationClient = typeof orchestrationClient; diff --git a/src/openhuman/orchestration/ops.rs b/src/openhuman/orchestration/ops.rs index b087b9bd5..a79859f30 100644 --- a/src/openhuman/orchestration/ops.rs +++ b/src/openhuman/orchestration/ops.rs @@ -834,12 +834,135 @@ fn session_send_plaintext(session_id: &str, body: &str) -> anyhow::Result, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) primary_handle: Option, + pub(crate) card_published: bool, + pub(crate) key_published: bool, + pub(crate) discoverable: bool, +} + +/// Pure composition of the three tinyplace reads into the renderer shape. Kept +/// here (business logic) so the parsing/discoverability rules are unit-testable +/// without a live tiny.place client; the `schemas` handler supplies the reads. +/// +/// `reverse` is the raw `directory_reverse` JSON (`{ identities: [...] }`), or +/// `None` on a reverse miss. Discoverable = card live AND encryption key +/// published + current — either gap leaves the agent un-messageable. +pub(crate) fn build_self_identity( + agent_id: String, + key_published: bool, + reverse: Option<&Value>, + card_published: bool, +) -> SelfIdentity { + let mut handles: Vec = Vec::new(); + let mut primary_handle: Option = None; + if let Some(idents) = reverse + .and_then(|r| r.get("identities")) + .and_then(Value::as_array) + { + for ident in idents { + let username = ident + .get("username") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()); + let Some(username) = username else { continue }; + let primary = ident + .get("primary") + .and_then(Value::as_bool) + .unwrap_or(false); + if primary && primary_handle.is_none() { + primary_handle = Some(username.to_string()); + } + handles.push(HandleEntry { + username: username.to_string(), + primary, + }); + } + } + // Fall back to the first handle when none is flagged primary. + if primary_handle.is_none() { + primary_handle = handles.first().map(|h| h.username.clone()); + } + SelfIdentity { + agent_id, + handles, + primary_handle, + card_published, + key_published, + discoverable: card_published && key_published, + } +} + #[cfg(test)] mod tests { use super::*; use crate::openhuman::orchestration::types::OrchestrationMessage; use std::sync::atomic::{AtomicUsize, Ordering}; use tinyagents::graph::checkpoint::Checkpointer; + + #[test] + fn self_identity_marks_published_identity_discoverable() { + let reverse = serde_json::json!({ + "identities": [ + { "username": " ", "primary": false }, // blank → skipped + { "username": "openhuman", "primary": false }, + { "username": "oh_primary", "primary": true }, + ] + }); + let id = build_self_identity("addr123".to_string(), true, Some(&reverse), true); + assert_eq!(id.agent_id, "addr123"); + assert_eq!(id.handles.len(), 2, "blank username skipped"); + assert_eq!(id.primary_handle.as_deref(), Some("oh_primary")); + assert!(id.card_published && id.key_published && id.discoverable); + } + + #[test] + fn self_identity_primary_falls_back_to_first_handle() { + let reverse = serde_json::json!({ + "identities": [ { "username": "solo" } ] // no primary flag + }); + let id = build_self_identity("addr".to_string(), true, Some(&reverse), true); + assert_eq!(id.primary_handle.as_deref(), Some("solo")); + } + + #[test] + fn self_identity_undiscoverable_when_card_or_key_missing() { + // No reverse (handle-less), card present but key not published → the + // exact un-messageable case the SelfIdentityCard must flag. + let no_key = build_self_identity("addr".to_string(), false, None, true); + assert!(no_key.handles.is_empty()); + assert!(no_key.primary_handle.is_none()); + assert!(!no_key.discoverable, "key not published → not discoverable"); + + let no_card = build_self_identity("addr".to_string(), true, None, false); + assert!( + !no_card.discoverable, + "card not published → not discoverable" + ); + } use tinyagents::graph::SqliteCheckpointer; fn test_config(tmp: &tempfile::TempDir) -> Config { diff --git a/src/openhuman/orchestration/schemas.rs b/src/openhuman/orchestration/schemas.rs index f7a80135e..55eb1c11a 100644 --- a/src/openhuman/orchestration/schemas.rs +++ b/src/openhuman/orchestration/schemas.rs @@ -28,6 +28,8 @@ pub fn all_controller_schemas() -> Vec { schema_for("orchestration_send_master_message"), schema_for("orchestration_mark_read"), schema_for("orchestration_status"), + schema_for("orchestration_self_identity"), + schema_for("orchestration_relay_info"), ] } @@ -57,6 +59,14 @@ pub fn all_registered_controllers() -> Vec { schema: schema_for("orchestration_status"), handler: handle_status, }, + RegisteredController { + schema: schema_for("orchestration_self_identity"), + handler: handle_self_identity, + }, + RegisteredController { + schema: schema_for("orchestration_relay_info"), + handler: handle_relay_info, + }, ] } @@ -120,6 +130,23 @@ fn schema_for(function: &str) -> ControllerSchema { inputs: vec![], outputs: vec![json_output("result", "OrchestrationStatus.")], }, + "orchestration_self_identity" => ControllerSchema { + namespace: "orchestration", + function: "self_identity", + description: "This agent's own tiny.place identity + discoverability: agent id (address), reverse-resolved @handles, whether its directory card and Signal encryption key are published, and whether peers can therefore DM it. Composes the tinyplace signal/directory reads.", + inputs: vec![], + outputs: vec![json_output( + "result", + "{ agentId, handles: {username, primary}[], primaryHandle?, cardPublished, keyPublished, discoverable }.", + )], + }, + "orchestration_relay_info" => ControllerSchema { + namespace: "orchestration", + function: "relay_info", + description: "The tiny.place relay endpoint the core is talking to, plus a coarse network label (staging | prod) for the renderer's relay badge.", + inputs: vec![], + outputs: vec![json_output("result", "{ baseUrl, network }.")], + }, other => unreachable!("unknown orchestration schema: {other}"), } } @@ -177,6 +204,13 @@ struct OrchestrationStatus { last_error: Option, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct RelayInfo { + base_url: String, + network: String, +} + /// Resolve the `chat` param to a store session id. `master` / `subconscious` map /// to their pinned session ids; anything else is treated as a harness session id. fn chat_to_session_id(chat: &str) -> &str { @@ -568,6 +602,93 @@ fn handle_status(_params: Map) -> ControllerFuture { }) } +/// Own tiny.place identity + discoverability, composed from the internal +/// tinyplace signal/directory reads. Delegates like `send_master` does +/// (`crate::openhuman::tinyplace::handle_tinyplace_*`), so there is no new +/// tiny.place logic here — only aggregation into the shape the renderer needs. +fn handle_self_identity(_params: Map) -> ControllerFuture { + Box::pin(async move { + // 1. Key status → own agent id + whether the encryption key is published + // to the directory and current. `encryptionKeyPublished` is false when + // the card is missing OR the published key is stale (wrong wallet). + let key_status = + crate::openhuman::tinyplace::handle_tinyplace_signal_key_status(Map::new()) + .await + .map_err(|e| format!("self_identity key_status: {e}"))?; + let agent_id = key_status + .get("agentId") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let key_published = key_status + .get("encryptionKeyPublished") + .and_then(Value::as_bool) + .unwrap_or(false); + log::debug!( + target: LOG, + "[orchestration_rpc] self_identity agent_id_len={} key_published={key_published}", + agent_id.len() + ); + + // 2. Reverse-resolve any @handles this wallet holds. Best-effort: a + // handle-less identity is normal (and is exactly the un-messageable + // case the card must flag), so a reverse miss is not an error. + let reverse = if agent_id.is_empty() { + None + } else { + let mut rev_params = Map::new(); + rev_params.insert("cryptoId".to_string(), Value::from(agent_id.clone())); + match crate::openhuman::tinyplace::handle_tinyplace_directory_reverse(rev_params).await + { + Ok(reverse) => Some(reverse), + Err(e) => { + log::debug!(target: LOG, "[orchestration_rpc] self_identity reverse miss: {e}"); + None + } + } + }; + + // 3. Directory card presence: get_agent(self) 404s when no card is + // published. Ok → a card is live; Err → treat as unpublished. + let card_published = if agent_id.is_empty() { + false + } else { + let mut card_params = Map::new(); + card_params.insert("agentId".to_string(), Value::from(agent_id.clone())); + crate::openhuman::tinyplace::handle_tinyplace_directory_get_agent(card_params) + .await + .is_ok() + }; + + let identity = super::ops::build_self_identity( + agent_id, + key_published, + reverse.as_ref(), + card_published, + ); + log::debug!( + target: LOG, + "[orchestration_rpc] self_identity handles={} card_published={card_published} discoverable={}", + identity.handles.len(), + identity.discoverable + ); + to_json(identity) + }) +} + +/// Relay endpoint + network label for the renderer's relay badge. Reads only the +/// configured base URL (no client build / wallet unlock), so it always resolves. +fn handle_relay_info(_params: Map) -> ControllerFuture { + Box::pin(async move { + let (base_url, network) = crate::openhuman::tinyplace::ops::relay_endpoint(); + log::debug!(target: LOG, "[orchestration_rpc] relay_info network={network}"); + to_json(RelayInfo { + base_url, + network: network.to_string(), + }) + }) +} + // ── helpers ───────────────────────────────────────────────────────────────── async fn load_config(action: &str) -> Result { @@ -625,8 +746,16 @@ mod tests { #[test] fn schemas_use_orchestration_namespace() { let schemas = all_controller_schemas(); - assert_eq!(schemas.len(), 6); + assert_eq!(schemas.len(), 8); assert!(schemas.iter().all(|s| s.namespace == "orchestration")); + assert_eq!( + schema_for("orchestration_self_identity").function, + "self_identity" + ); + assert_eq!( + schema_for("orchestration_relay_info").function, + "relay_info" + ); assert_eq!( schema_for("orchestration_messages_list").function, "messages_list" diff --git a/src/openhuman/tinyplace/mod.rs b/src/openhuman/tinyplace/mod.rs index 073a3aefd..6fcaf0f40 100644 --- a/src/openhuman/tinyplace/mod.rs +++ b/src/openhuman/tinyplace/mod.rs @@ -29,7 +29,9 @@ pub(crate) mod agent; mod agent_tools; mod manifest; pub(crate) use manifest::{ - acknowledge_message, decrypt_envelope, handle_tinyplace_signal_send_message, + acknowledge_message, decrypt_envelope, handle_tinyplace_directory_get_agent, + handle_tinyplace_directory_reverse, handle_tinyplace_signal_key_status, + handle_tinyplace_signal_send_message, }; pub(crate) mod ops; mod payment; diff --git a/src/openhuman/tinyplace/ops.rs b/src/openhuman/tinyplace/ops.rs index a34ffd9a2..47521a08b 100644 --- a/src/openhuman/tinyplace/ops.rs +++ b/src/openhuman/tinyplace/ops.rs @@ -19,6 +19,29 @@ pub(crate) fn global_state() -> &'static TinyPlaceState { TINYPLACE_STATE.get_or_init(TinyPlaceState::from_env) } +/// Active relay endpoint the tiny.place client talks to, plus a coarse network +/// label the renderer surfaces as a badge. A base URL containing `staging` +/// resolves to `"staging"`; anything else is treated as `"prod"`. +/// +/// This reads only the configured base URL (no client build, no wallet unlock), +/// so it is safe to call at any time. +pub(crate) fn relay_endpoint() -> (String, &'static str) { + let base_url = global_state().base_url.clone(); + let network = relay_network_label(&base_url); + (base_url, network) +} + +/// Coarse network label for a relay base URL: `staging` when the host carries a +/// `staging` marker, else `prod`. Kept pure so it is unit-testable without the +/// process-global state. +pub(crate) fn relay_network_label(base_url: &str) -> &'static str { + if base_url.contains("staging") { + "staging" + } else { + "prod" + } +} + // ── Error mapping ───────────────────────────────────────────────────────────── /// Map a SDK [`tinyplace::Error`] to a [`String`] the controller layer returns. @@ -69,3 +92,18 @@ fn extract_error_reason(body: &serde_json::Value) -> Option { other => Some(other.to_string()), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relay_network_label_classifies_staging_and_prod() { + assert_eq!( + relay_network_label("https://staging-api.tiny.place"), + "staging" + ); + assert_eq!(relay_network_label("https://api.tiny.place"), "prod"); + assert_eq!(relay_network_label(""), "prod"); + } +}
+ {t('tinyplaceOrchestration.identity.undiscoverableHint')} +
{t('tinyplaceOrchestration.subtitle')}