feat: verify coordinator RPC and operator identity
This commit is contained in:
parent
bcc277a054
commit
20de6ba8e1
8 changed files with 165 additions and 3 deletions
|
|
@ -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 })}`)}
|
||||
</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">
|
||||
<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')} />
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import { useCrewGoal } from '@/hooks/useCrewGoal';
|
|||
import { useCoordinator, useCoordinatorHealth } from '@/hooks/useCoordinator';
|
||||
import { normalizeUrlInput, sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
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 { 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';
|
||||
|
|
@ -336,6 +336,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
crewCoordinatorInputValue(existing?.coordinator) ||
|
||||
(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.
|
||||
const { coordinator: myCoordinator } = useCoordinator();
|
||||
// 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' });
|
||||
return;
|
||||
}
|
||||
const operatorPubkey = coordinatorOperator.trim() ? parseOperatorPubkey(coordinatorOperator) : null;
|
||||
if (coordinatorOperator.trim() && !operatorPubkey) {
|
||||
toast({ title: t('socialUi.coordinator.invalidOperator'), variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const identifier = existing?.identifier ?? `crew-${Date.now().toString(36)}`;
|
||||
|
|
@ -614,6 +620,10 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
if (parsedCoordinator) {
|
||||
const coordinatorTag = ['coordinator', parsedCoordinator.url];
|
||||
if (parsedCoordinator.pubkey) coordinatorTag.push(parsedCoordinator.pubkey);
|
||||
if (operatorPubkey) {
|
||||
if (!parsedCoordinator.pubkey) coordinatorTag.push('');
|
||||
coordinatorTag.push(operatorPubkey);
|
||||
}
|
||||
tags.push(coordinatorTag);
|
||||
}
|
||||
|
||||
|
|
@ -1242,6 +1252,21 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
</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">
|
||||
<Label htmlFor="crew-cordn-group" className="text-xs text-gray-400">{t('socialUi.form.cordnGroup')}</Label>
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ export interface CoordinatorHealth {
|
|||
name: string | null;
|
||||
error: string | 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,
|
||||
error: 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.
|
||||
*
|
||||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
18
src/lib/coordinator.test.ts
Normal file
18
src/lib/coordinator.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
22
src/lib/cordn/native/rpc.live.test.ts
Normal file
22
src/lib/cordn/native/rpc.live.test.ts
Normal 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);
|
||||
});
|
||||
Loading…
Reference in a new issue