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);
+});