diff --git a/src/components/CoordinatorSettingsCard.tsx b/src/components/CoordinatorSettingsCard.tsx index 4600d83..6b752d5 100644 --- a/src/components/CoordinatorSettingsCard.tsx +++ b/src/components/CoordinatorSettingsCard.tsx @@ -95,7 +95,7 @@ function RelayHealthPill({ url }: { url: string }) { export function CoordinatorSettingsCard() { const { t } = useTranslation(); const { coordinator, setCoordinator, removeCoordinator } = useCoordinator(); - const { status, lastSeen, eventCount, name, error, check } = useCoordinatorHealth(coordinator); + const { status, lastSeen, eventCount, name, error, rpcStatus, rpcLatencyMs, check } = useCoordinatorHealth(coordinator); const [input, setInput] = useState(''); const [showQr, setShowQr] = useState(false); @@ -176,6 +176,17 @@ export function CoordinatorSettingsCard() { : ` · ${t('socialUi.coordinator.eventFoundMany', { count: eventCount })}`)}

+ {rpcStatus === 'ok' && ( +

+ {t('socialUi.coordinator.rpcOk', { ms: rpcLatencyMs ?? 0 })} +

+ )} + {rpcStatus === 'error' && ( +

+ {t('socialUi.coordinator.rpcError')} +

+ )} +
)} +
+ + setCoordinatorOperator(e.target.value)} + className="bg-gray-800 border-gray-700 font-mono text-xs" + spellCheck={false} + /> +

{t('socialUi.coordinator.operatorHint')}

+
+
b.toString(16).padStart(2, '0')).join(''); +} + +async function probeCoordinatorRpc( + coordinator: CoordinatorConfig, + relays: string[], +): Promise> { + let client: { listAvailableKeyPackages: () => Promise; disconnect: () => Promise } | null = null; + const started = Date.now(); + try { + const { CordnCoordinatorClient } = await import('@/lib/cordn/native/coordinator'); + client = new CordnCoordinatorClient({ + serverPubkey: coordinator.pubkey, + relays, + privateKeyHex: randomKeyHex(), + }); + await Promise.race([ + client.listAvailableKeyPackages(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Coordinator RPC timed out.')), 7_000), + ), + ]); + return { rpcStatus: 'ok', rpcLatencyMs: Date.now() - started, rpcError: null }; + } catch (e) { + return { + rpcStatus: 'error', + rpcLatencyMs: null, + rpcError: e instanceof Error ? e.message : 'Coordinator RPC failed.', + }; + } finally { + if (client) await client.disconnect().catch(() => undefined); + } +} + /** * Presence probe for a coordinator. * @@ -86,7 +134,16 @@ export function useCoordinatorHealth(coordinator: CoordinatorConfig | null) { const check = useCallback(async () => { if (!coordinator) return; - setResult((r) => ({ ...r, status: 'checking', error: null })); + setResult((r) => ({ + ...r, + status: 'checking', + error: null, + rpcStatus: 'checking', + rpcLatencyMs: null, + rpcError: null, + })); + + const rpc = await probeCoordinatorRpc(coordinator, relays); try { const signal = AbortSignal.timeout(8000); @@ -113,6 +170,7 @@ export function useCoordinatorHealth(coordinator: CoordinatorConfig | null) { name: null, error: null, checkedAt: Date.now(), + ...rpc, }); return; } @@ -127,6 +185,7 @@ export function useCoordinatorHealth(coordinator: CoordinatorConfig | null) { name: coordinatorNameFromEvent(newest), error: null, checkedAt: Date.now(), + ...rpc, }); } catch (err) { setResult({ @@ -136,6 +195,7 @@ export function useCoordinatorHealth(coordinator: CoordinatorConfig | null) { name: null, error: err instanceof Error ? err.message : 'Could not reach the relays', checkedAt: Date.now(), + ...rpc, }); } }, [coordinator, relays, nostr]); diff --git a/src/i18n/en.ts b/src/i18n/en.ts index f4eb811..c5b760b 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -3925,6 +3925,8 @@ socialUi: { eventFoundOne: '{{count}} event found.', eventFoundMany: '{{count}} events found.', check: 'Check', + rpcOk: 'RPC reachable in {{ms}}ms', + rpcError: 'Presence may be visible, but Coordinator RPC is not reachable.', addLinkCopied: 'Add link copied', copyFailed: 'Copy failed', copyManually: 'Copy it manually.', @@ -3958,6 +3960,10 @@ socialUi: { customBadge: '· experimental · your own relay', customDescShort: "Optional — your crew already has end-to-end encrypted chat built in, so you don't need this. Only set it if you run your own", remove: 'Remove', + operatorLabel: 'Coordinator operator (optional)', + operatorPlaceholder: 'npub1… or 64-character hex pubkey', + operatorHint: 'Who runs this coordinator? This is a label, not an admin key.', + invalidOperator: 'Enter a valid npub or 64-character hex operator pubkey.', pointerNote: "Pointer only for now: a wss:// endpoint is used as the crew's chat relay; an https:// one via the shared ContextVM relay. Real Cordn MLS isn't wired yet.", cordnCoordinator: 'Cordn coordinator', invalid: "That doesn't look like a coordinator. Paste an nprofile, a cordn.net link, or an https/wss URL.", diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 8308777..6212a9e 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -3925,6 +3925,8 @@ socialUi: { eventFoundOne: '{{count}} evento encontrado.', eventFoundMany: '{{count}} eventos encontrados.', check: 'Comprobar', + rpcOk: 'RPC accesible en {{ms}} ms', + rpcError: 'La presencia puede ser visible, pero el RPC del coordinador no responde.', addLinkCopied: 'Enlace de añadir copiado', copyFailed: 'No se pudo copiar', copyManually: 'Cópialo manualmente.', @@ -3958,6 +3960,10 @@ socialUi: { customBadge: '· experimental · tu propio relé', customDescShort: 'Opcional: tu peña ya tiene chat cifrado de extremo a extremo integrado, así que no lo necesitas. Configúralo solo si usas tu propio', remove: 'Quitar', + operatorLabel: 'Operador del coordinador (opcional)', + operatorPlaceholder: 'npub1… o pubkey hex de 64 caracteres', + operatorHint: '¿Quién ejecuta este coordinador? Es una etiqueta, no una clave de admin.', + invalidOperator: 'Introduce un npub válido o una pubkey hex de 64 caracteres.', pointerNote: 'Por ahora es solo un puntero: un endpoint wss:// se usa como relé del chat de la peña y uno https:// a través del relé ContextVM compartido. El MLS real de Cordn aún no está conectado.', cordnCoordinator: 'coordinador Cordn', invalid: 'Eso no parece un coordinador. Pega un nprofile, un enlace de cordn.net o una URL https/wss.', diff --git a/src/lib/coordinator.test.ts b/src/lib/coordinator.test.ts new file mode 100644 index 0000000..4cf0c8e --- /dev/null +++ b/src/lib/coordinator.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { nip19 } from 'nostr-tools'; +import { parseOperatorPubkey } from './coordinator'; + +const PUBKEY = 'a'.repeat(64); + +describe('parseOperatorPubkey', () => { + it('accepts npub and raw hex', () => { + expect(parseOperatorPubkey(nip19.npubEncode(PUBKEY))).toBe(PUBKEY); + expect(parseOperatorPubkey(PUBKEY)).toBe(PUBKEY); + }); + + it('rejects empty, malformed and coordinator URLs', () => { + expect(parseOperatorPubkey('')).toBeNull(); + expect(parseOperatorPubkey('not-a-key')).toBeNull(); + expect(parseOperatorPubkey('https://cordn.net/chat/coordinators?c=nprofile1bad')).toBeNull(); + }); +}); diff --git a/src/lib/coordinator.ts b/src/lib/coordinator.ts index 8bb0358..7bb0102 100644 --- a/src/lib/coordinator.ts +++ b/src/lib/coordinator.ts @@ -139,6 +139,20 @@ export function parseCrewCoordinatorInput(raw: string): CrewCoordinatorPointer | return null; } +/** Parse an operator pubkey from npub/nprofile/hex. Not a coordinator pointer. */ +export function parseOperatorPubkey(raw: string): string | null { + const input = raw.trim(); + if (!input) return null; + try { + const decoded = nip19.decode(input); + if (decoded.type === 'npub') return decoded.data; + if (decoded.type === 'nprofile') return decoded.data.pubkey; + } catch { + // fall through to raw hex + } + return HEX64.test(input) ? input.toLowerCase() : null; +} + /** Rebuild a paste-able input from a stored crew coordinator (round-trips). */ export function crewCoordinatorInputValue( coordinator: { url: string; pubkey?: string } | undefined, diff --git a/src/lib/cordn/native/rpc.live.test.ts b/src/lib/cordn/native/rpc.live.test.ts new file mode 100644 index 0000000..bf97c19 --- /dev/null +++ b/src/lib/cordn/native/rpc.live.test.ts @@ -0,0 +1,22 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { randomBytes } from 'node:crypto'; +import { CordnCoordinatorClient } from './coordinator'; +import { SKATESPOTS_COORDINATOR } from '../../coordinator'; + +const live = process.env.CORDN_LIVE === '1'; + +describe.skipIf(!live)('coordinator RPC health', () => { + it('lists key packages with a throwaway client key', async () => { + const client = new CordnCoordinatorClient({ + serverPubkey: SKATESPOTS_COORDINATOR.pubkey, + relays: SKATESPOTS_COORDINATOR.relays, + privateKeyHex: randomBytes(32).toString('hex'), + }); + try { + await expect(client.listAvailableKeyPackages()).resolves.toBeTruthy(); + } finally { + await client.disconnect(); + } + }, 30_000); +});