feat: verify coordinator RPC and operator identity

This commit is contained in:
SkateSpots 2026-10-31 16:49:38 +07:00
parent bcc277a054
commit 20de6ba8e1
8 changed files with 165 additions and 3 deletions

View file

@ -95,7 +95,7 @@ function RelayHealthPill({ url }: { url: string }) {
export function CoordinatorSettingsCard() { export function CoordinatorSettingsCard() {
const { t } = useTranslation(); const { t } = useTranslation();
const { coordinator, setCoordinator, removeCoordinator } = useCoordinator(); 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 [input, setInput] = useState('');
const [showQr, setShowQr] = useState(false); const [showQr, setShowQr] = useState(false);
@ -176,6 +176,17 @@ export function CoordinatorSettingsCard() {
: ` · ${t('socialUi.coordinator.eventFoundMany', { count: eventCount })}`)} : ` · ${t('socialUi.coordinator.eventFoundMany', { count: eventCount })}`)}
</p> </p>
{rpcStatus === 'ok' && (
<p className="text-[11px] font-medium text-green-400">
{t('socialUi.coordinator.rpcOk', { ms: rpcLatencyMs ?? 0 })}
</p>
)}
{rpcStatus === 'error' && (
<p className="text-[11px] text-amber-400">
{t('socialUi.coordinator.rpcError')}
</p>
)}
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline" onClick={() => void check()} disabled={status === 'checking'}> <Button size="sm" variant="outline" onClick={() => void check()} disabled={status === 'checking'}>
<RefreshCw className={cn('mr-1.5 h-3.5 w-3.5', status === 'checking' && 'animate-spin')} /> <RefreshCw className={cn('mr-1.5 h-3.5 w-3.5', status === 'checking' && 'animate-spin')} />

View file

@ -29,7 +29,7 @@ import { useCrewGoal } from '@/hooks/useCrewGoal';
import { useCoordinator, useCoordinatorHealth } from '@/hooks/useCoordinator'; import { useCoordinator, useCoordinatorHealth } from '@/hooks/useCoordinator';
import { normalizeUrlInput, sanitizeUrl } from '@/lib/sanitizeUrl'; import { normalizeUrlInput, sanitizeUrl } from '@/lib/sanitizeUrl';
import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor'; import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor';
import { coordinatorAddUrl, crewCoordinatorInputValue, parseCrewCoordinatorInput, shortNpub, SKATESPOTS_COORDINATOR, CORDN_DEPLOY_KIT_URL } from '@/lib/coordinator'; import { coordinatorAddUrl, crewCoordinatorInputValue, parseCrewCoordinatorInput, parseOperatorPubkey, shortNpub, SKATESPOTS_COORDINATOR, CORDN_DEPLOY_KIT_URL } from '@/lib/coordinator';
import { useRelays } from '@/lib/relays'; import { useRelays } from '@/lib/relays';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye, Store, Film, Check, ArrowRight, ArrowLeft as ArrowLeftIcon, Server, ChevronDown, Zap } from 'lucide-react'; import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye, Store, Film, Check, ArrowRight, ArrowLeft as ArrowLeftIcon, Server, ChevronDown, Zap } from 'lucide-react';
@ -336,6 +336,7 @@ export function CrewForm({ existing }: CrewFormProps) {
crewCoordinatorInputValue(existing?.coordinator) || crewCoordinatorInputValue(existing?.coordinator) ||
(isEditing ? '' : crewCoordinatorInputValue({ url: SKATESPOTS_COORDINATOR.relays[0], pubkey: SKATESPOTS_COORDINATOR.pubkey })), (isEditing ? '' : crewCoordinatorInputValue({ url: SKATESPOTS_COORDINATOR.relays[0], pubkey: SKATESPOTS_COORDINATOR.pubkey })),
); );
const [coordinatorOperator, setCoordinatorOperator] = useState(existing?.coordinator?.operator ?? '');
// The coordinator the user set in Settings → Coordinator, for a one-tap prefill. // The coordinator the user set in Settings → Coordinator, for a one-tap prefill.
const { coordinator: myCoordinator } = useCoordinator(); const { coordinator: myCoordinator } = useCoordinator();
// Live parse so the founder gets feedback (and the publish path reuses it). // Live parse so the founder gets feedback (and the publish path reuses it).
@ -494,6 +495,11 @@ export function CrewForm({ existing }: CrewFormProps) {
toast({ title: t('socialUi.coordinator.invalid'), variant: 'destructive' }); toast({ title: t('socialUi.coordinator.invalid'), variant: 'destructive' });
return; return;
} }
const operatorPubkey = coordinatorOperator.trim() ? parseOperatorPubkey(coordinatorOperator) : null;
if (coordinatorOperator.trim() && !operatorPubkey) {
toast({ title: t('socialUi.coordinator.invalidOperator'), variant: 'destructive' });
return;
}
try { try {
const identifier = existing?.identifier ?? `crew-${Date.now().toString(36)}`; const identifier = existing?.identifier ?? `crew-${Date.now().toString(36)}`;
@ -614,6 +620,10 @@ export function CrewForm({ existing }: CrewFormProps) {
if (parsedCoordinator) { if (parsedCoordinator) {
const coordinatorTag = ['coordinator', parsedCoordinator.url]; const coordinatorTag = ['coordinator', parsedCoordinator.url];
if (parsedCoordinator.pubkey) coordinatorTag.push(parsedCoordinator.pubkey); if (parsedCoordinator.pubkey) coordinatorTag.push(parsedCoordinator.pubkey);
if (operatorPubkey) {
if (!parsedCoordinator.pubkey) coordinatorTag.push('');
coordinatorTag.push(operatorPubkey);
}
tags.push(coordinatorTag); tags.push(coordinatorTag);
} }
@ -1242,6 +1252,21 @@ export function CrewForm({ existing }: CrewFormProps) {
</Button> </Button>
)} )}
<div className="space-y-1.5">
<Label htmlFor="crew-coordinator-operator" className="text-xs text-gray-400">
{t('socialUi.coordinator.operatorLabel')}
</Label>
<Input
id="crew-coordinator-operator"
placeholder={t('socialUi.coordinator.operatorPlaceholder')}
value={coordinatorOperator}
onChange={(e) => setCoordinatorOperator(e.target.value)}
className="bg-gray-800 border-gray-700 font-mono text-xs"
spellCheck={false}
/>
<p className="text-[10px] text-gray-600">{t('socialUi.coordinator.operatorHint')}</p>
</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="crew-cordn-group" className="text-xs text-gray-400">{t('socialUi.form.cordnGroup')}</Label> <Label htmlFor="crew-cordn-group" className="text-xs text-gray-400">{t('socialUi.form.cordnGroup')}</Label>
<Input <Input

View file

@ -50,6 +50,10 @@ export interface CoordinatorHealth {
name: string | null; name: string | null;
error: string | null; error: string | null;
checkedAt: number | null; checkedAt: number | null;
/** Actual ContextVM RPC probe, not just presence. */
rpcStatus: 'idle' | 'checking' | 'ok' | 'error';
rpcLatencyMs: number | null;
rpcError: string | null;
} }
/** /**
@ -65,8 +69,52 @@ const INITIAL: CoordinatorHealth = {
name: null, name: null,
error: null, error: null,
checkedAt: null, checkedAt: null,
rpcStatus: 'idle',
rpcLatencyMs: null,
rpcError: null,
}; };
function randomKeyHex(): string {
const bytes = new Uint8Array(32);
if (globalThis.crypto?.getRandomValues) {
globalThis.crypto.getRandomValues(bytes);
} else {
for (let i = 0; i < bytes.length; i += 1) bytes[i] = Math.floor(Math.random() * 256);
}
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
async function probeCoordinatorRpc(
coordinator: CoordinatorConfig,
relays: string[],
): Promise<Pick<CoordinatorHealth, 'rpcStatus' | 'rpcLatencyMs' | 'rpcError'>> {
let client: { listAvailableKeyPackages: () => Promise<unknown>; disconnect: () => Promise<void> } | 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<never>((_, 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. * Presence probe for a coordinator.
* *
@ -86,7 +134,16 @@ export function useCoordinatorHealth(coordinator: CoordinatorConfig | null) {
const check = useCallback(async () => { const check = useCallback(async () => {
if (!coordinator) return; 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 { try {
const signal = AbortSignal.timeout(8000); const signal = AbortSignal.timeout(8000);
@ -113,6 +170,7 @@ export function useCoordinatorHealth(coordinator: CoordinatorConfig | null) {
name: null, name: null,
error: null, error: null,
checkedAt: Date.now(), checkedAt: Date.now(),
...rpc,
}); });
return; return;
} }
@ -127,6 +185,7 @@ export function useCoordinatorHealth(coordinator: CoordinatorConfig | null) {
name: coordinatorNameFromEvent(newest), name: coordinatorNameFromEvent(newest),
error: null, error: null,
checkedAt: Date.now(), checkedAt: Date.now(),
...rpc,
}); });
} catch (err) { } catch (err) {
setResult({ setResult({
@ -136,6 +195,7 @@ export function useCoordinatorHealth(coordinator: CoordinatorConfig | null) {
name: null, name: null,
error: err instanceof Error ? err.message : 'Could not reach the relays', error: err instanceof Error ? err.message : 'Could not reach the relays',
checkedAt: Date.now(), checkedAt: Date.now(),
...rpc,
}); });
} }
}, [coordinator, relays, nostr]); }, [coordinator, relays, nostr]);

View file

@ -3925,6 +3925,8 @@ socialUi: {
eventFoundOne: '{{count}} event found.', eventFoundOne: '{{count}} event found.',
eventFoundMany: '{{count}} events found.', eventFoundMany: '{{count}} events found.',
check: 'Check', check: 'Check',
rpcOk: 'RPC reachable in {{ms}}ms',
rpcError: 'Presence may be visible, but Coordinator RPC is not reachable.',
addLinkCopied: 'Add link copied', addLinkCopied: 'Add link copied',
copyFailed: 'Copy failed', copyFailed: 'Copy failed',
copyManually: 'Copy it manually.', copyManually: 'Copy it manually.',
@ -3958,6 +3960,10 @@ socialUi: {
customBadge: '· experimental · your own relay', 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", 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', 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.", 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', cordnCoordinator: 'Cordn coordinator',
invalid: "That doesn't look like a coordinator. Paste an nprofile, a cordn.net link, or an https/wss URL.", invalid: "That doesn't look like a coordinator. Paste an nprofile, a cordn.net link, or an https/wss URL.",

View file

@ -3925,6 +3925,8 @@ socialUi: {
eventFoundOne: '{{count}} evento encontrado.', eventFoundOne: '{{count}} evento encontrado.',
eventFoundMany: '{{count}} eventos encontrados.', eventFoundMany: '{{count}} eventos encontrados.',
check: 'Comprobar', 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', addLinkCopied: 'Enlace de añadir copiado',
copyFailed: 'No se pudo copiar', copyFailed: 'No se pudo copiar',
copyManually: 'Cópialo manualmente.', copyManually: 'Cópialo manualmente.',
@ -3958,6 +3960,10 @@ socialUi: {
customBadge: '· experimental · tu propio relé', 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', 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', 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.', 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', cordnCoordinator: 'coordinador Cordn',
invalid: 'Eso no parece un coordinador. Pega un nprofile, un enlace de cordn.net o una URL https/wss.', invalid: 'Eso no parece un coordinador. Pega un nprofile, un enlace de cordn.net o una URL https/wss.',

View file

@ -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();
});
});

View file

@ -139,6 +139,20 @@ export function parseCrewCoordinatorInput(raw: string): CrewCoordinatorPointer |
return null; 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). */ /** Rebuild a paste-able input from a stored crew coordinator (round-trips). */
export function crewCoordinatorInputValue( export function crewCoordinatorInputValue(
coordinator: { url: string; pubkey?: string } | undefined, coordinator: { url: string; pubkey?: string } | undefined,

View file

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