fix: support custom coordinator request routing and device-link errors

This commit is contained in:
SkateSpots 2026-11-01 12:34:27 +07:00
parent 7e8d13cbff
commit 4e44ce769b
4 changed files with 91 additions and 27 deletions

View file

@ -1429,6 +1429,13 @@ session with **no other memory of this project**. So:
the adoption path. Keep the coordinator key separate from crew/admin keys. the adoption path. Keep the coordinator key separate from crew/admin keys.
Do not claim multi-coordinator failover; this is one trusted delivery service Do not claim multi-coordinator failover; this is one trusted delivery service
with backups, not BFT. with backups, not BFT.
**Custom coordinator routing (2026-11-01):** `useCordnNative` treats a crew's
own coordinator as authoritative. A custom URL without a service pubkey is
rejected instead of silently falling back to the SkateSpots coordinator. The
join watcher groups crews by coordinator pubkey/relay and polls each one; a
custom coordinator being offline does not stop notifications for other crews.
Device-link creation now shows an inline error/retry instead of a permanent
loading dialog.
`EditProfileForm` accepts a pointer (nprofile / cordn.net link / npub / hex / `EditProfileForm` accepts a pointer (nprofile / cordn.net link / npub / hex /
http URL), **normalises a bare pointer to Cordn's canonical add-link** before http URL), **normalises a bare pointer to Cordn's canonical add-link** before
saving (`coordinatorAddUrl`), and has **"Use my coordinator"**. `NIP19Page` saving (`coordinatorAddUrl`), and has **"Use my coordinator"**. `NIP19Page`

View file

@ -55,6 +55,8 @@ interface Crew {
pubkey: string; pubkey: string;
identifier: string; identifier: string;
recipients: string[]; recipients: string[];
coordinatorPubkey: string;
coordinatorRelays: string[];
} }
interface WatcherState { interface WatcherState {
@ -130,8 +132,18 @@ function crewFromEvent(event: {
const name = event.tags.find(([name]) => name === 'title')?.[1]; const name = event.tags.find(([name]) => name === 'title')?.[1];
if (!d || !name) return null; if (!d || !name) return null;
const coordinatorTag = event.tags.find(([name]) => name === 'coordinator'); const coordinatorTag = event.tags.find(([name]) => name === 'coordinator');
const coordinatorPubkey = coordinatorTag?.[2]; const coordinatorUrl = coordinatorTag?.[1];
if (coordinatorPubkey && coordinatorPubkey !== SKATESPOTS_COORDINATOR.pubkey) return null; const configuredPubkey = coordinatorTag?.[2];
const isDefaultRelay = !!coordinatorUrl && coordinatorUrl === SKATESPOTS_COORDINATOR.relays[0];
// A custom relay without a service pubkey is not addressable. Do not fall
// back to SkateSpots' coordinator on someone else's relay.
if (coordinatorUrl && !configuredPubkey && !isDefaultRelay) return null;
const coordinatorPubkey = configuredPubkey ?? SKATESPOTS_COORDINATOR.pubkey;
if (!/^[0-9a-f]{64}$/i.test(coordinatorPubkey)) return null;
const coordinatorRelays =
coordinatorUrl && /^wss?:\/\//i.test(coordinatorUrl)
? [coordinatorUrl]
: SKATESPOTS_COORDINATOR.relays;
const admins = event.tags const admins = event.tags
.filter(([name, value, marker]) => name === 'p' && /^[0-9a-f]{64}$/i.test(value ?? '') && marker === 'admin') .filter(([name, value, marker]) => name === 'p' && /^[0-9a-f]{64}$/i.test(value ?? '') && marker === 'admin')
.map(([, value]) => value.toLowerCase()); .map(([, value]) => value.toLowerCase());
@ -142,6 +154,8 @@ function crewFromEvent(event: {
pubkey: event.pubkey, pubkey: event.pubkey,
identifier: d, identifier: d,
recipients, recipients,
coordinatorPubkey,
coordinatorRelays,
}; };
} }
@ -264,22 +278,44 @@ async function main(): Promise<void> {
return; return;
} }
const coordinator = new CordnCoordinatorClient({ const privateKeyHex = Array.from(watcherSecret, (byte) => byte.toString(16).padStart(2, '0')).join('');
serverPubkey: SKATESPOTS_COORDINATOR.pubkey, const groupsByCoordinator = new Map<string, { pubkey: string; relays: string[]; crews: Crew[] }>();
relays: SKATESPOTS_COORDINATOR.relays, for (const crew of crews) {
privateKeyHex: Array.from(watcherSecret, (byte) => byte.toString(16).padStart(2, '0')).join(''), const key = `${crew.coordinatorPubkey}|${crew.coordinatorRelays.slice().sort().join(',')}`;
}); const group = groupsByCoordinator.get(key) ?? {
pubkey: crew.coordinatorPubkey,
let requests: FetchManyPendingJoinRequestsOutput; relays: crew.coordinatorRelays,
try { crews: [],
requests = await coordinator.fetchManyPendingJoinRequests({ };
groups: crews.map((crew) => ({ gid: crew.gid })), group.crews.push(crew);
}); groupsByCoordinator.set(key, group);
} finally {
await coordinator.disconnect().catch(() => undefined);
} }
if (requests.requests.length === 0) { const requests: FetchManyPendingJoinRequestsOutput['requests'] = [];
for (const group of groupsByCoordinator.values()) {
const coordinator = new CordnCoordinatorClient({
serverPubkey: group.pubkey,
relays: group.relays,
privateKeyHex,
});
try {
const result = await coordinator.fetchManyPendingJoinRequests({
groups: group.crews.map((crew) => ({ gid: crew.gid })),
});
requests.push(...result.requests);
} catch (error) {
// A custom coordinator can be offline/private while other crews are
// healthy. Keep polling the rest instead of failing the whole watcher.
console.error(
`[cordn-watcher] coordinator ${shortNpub(group.pubkey)} unavailable:`,
error instanceof Error ? error.message : String(error),
);
} finally {
await coordinator.disconnect().catch(() => undefined);
}
}
if (requests.length === 0) {
pool.close([COMMUNITY]); pool.close([COMMUNITY]);
console.log('[cordn-watcher] no pending join requests'); console.log('[cordn-watcher] no pending join requests');
return; return;
@ -289,7 +325,7 @@ async function main(): Promise<void> {
const state = loadState(); const state = loadState();
let sent = 0; let sent = 0;
for (const request of requests.requests) { for (const request of requests) {
const crew = byGid.get(request.gid); const crew = byGid.get(request.gid);
if (!crew) continue; if (!crew) continue;
const key = `${request.gid}:${request.pk}:${request.kp_ref}`; const key = `${request.gid}:${request.pk}:${request.kp_ref}`;
@ -342,7 +378,7 @@ async function main(): Promise<void> {
{ {
watcher: nip19.npubEncode(watcherPubkey), watcher: nip19.npubEncode(watcherPubkey),
crews: crews.length, crews: crews.length,
pending: requests.requests.length, pending: requests.length,
newRequests: sent, newRequests: sent,
}, },
null, null,

View file

@ -202,6 +202,7 @@ export function CordnNativePanel({
const [deviceLinkOpen, setDeviceLinkOpen] = useState(false); const [deviceLinkOpen, setDeviceLinkOpen] = useState(false);
const [deviceLinkCode, setDeviceLinkCode] = useState<string | null>(null); const [deviceLinkCode, setDeviceLinkCode] = useState<string | null>(null);
const [deviceLinkPending, setDeviceLinkPending] = useState(false); const [deviceLinkPending, setDeviceLinkPending] = useState(false);
const [deviceLinkError, setDeviceLinkError] = useState<string | null>(null);
const [scannerOpen, setScannerOpen] = useState(false); const [scannerOpen, setScannerOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const { isMuted, toggle: toggleMute } = useDmMute(); const { isMuted, toggle: toggleMute } = useDmMute();
@ -548,12 +549,13 @@ export function CordnNativePanel({
const openDeviceLink = useCallback(async () => { const openDeviceLink = useCallback(async () => {
setDeviceLinkOpen(true); setDeviceLinkOpen(true);
setDeviceLinkCode(null); setDeviceLinkCode(null);
setDeviceLinkError(null);
setDeviceLinkPending(true); setDeviceLinkPending(true);
setNotice(null); setNotice(null);
const code = await native.createDeviceLink(); const code = await native.createDeviceLink();
if (!code) { if (!code) {
setDeviceLinkPending(false); setDeviceLinkPending(false);
setNotice(t('cordnUi.deviceLinkFailed')); setDeviceLinkError(t('cordnUi.deviceLinkFailed'));
return; return;
} }
setDeviceLinkCode(code); setDeviceLinkCode(code);
@ -1445,7 +1447,11 @@ export function CordnNativePanel({
open={deviceLinkOpen} open={deviceLinkOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
setDeviceLinkOpen(open); setDeviceLinkOpen(open);
if (!open) setDeviceLinkCode(null); if (!open) {
setDeviceLinkCode(null);
setDeviceLinkPending(false);
setDeviceLinkError(null);
}
}} }}
> >
<DialogContent className="max-w-sm border-white/10 bg-zinc-950"> <DialogContent className="max-w-sm border-white/10 bg-zinc-950">
@ -1484,6 +1490,13 @@ export function CordnNativePanel({
</Button> </Button>
</div> </div>
</> </>
) : deviceLinkError ? (
<div className="space-y-3 py-4 text-center">
<p className="text-xs text-red-300">{deviceLinkError}</p>
<Button size="sm" variant="outline" onClick={() => void openDeviceLink()}>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" /> {t('common.tryAgain')}
</Button>
</div>
) : ( ) : (
<div className="flex items-center justify-center gap-2 py-8 text-xs text-gray-400"> <div className="flex items-center justify-center gap-2 py-8 text-xs text-gray-400">
<Loader2 className="h-4 w-4 animate-spin" /> {t('common.loading')} <Loader2 className="h-4 w-4 animate-spin" /> {t('common.loading')}

View file

@ -35,14 +35,22 @@ export function resolveNativeCoordinator(
fallback: { pubkey: string; relays: string[] } | null, fallback: { pubkey: string; relays: string[] } | null,
): NativeCoordinator | null { ): NativeCoordinator | null {
const endpoint = resolveCordnEndpoint(crew); const endpoint = resolveCordnEndpoint(crew);
const serverPubkey = if (crew.coordinator?.url) {
endpoint?.coordinatorPubkey ?? fallback?.pubkey ?? SKATESPOTS_COORDINATOR.pubkey; // A crew's own coordinator is authoritative. If its pointer is incomplete
// (e.g. a custom relay URL with no service pubkey), do not silently connect
// to SkateSpots' coordinator instead — that would send the crew's traffic to
// the wrong service.
const serverPubkey =
endpoint?.coordinatorPubkey ??
(endpoint?.relayUrl === SKATESPOTS_COORDINATOR.relays[0]
? SKATESPOTS_COORDINATOR.pubkey
: null);
if (!endpoint || !serverPubkey) return null;
return { serverPubkey, relays: [endpoint.relayUrl] };
}
const serverPubkey = fallback?.pubkey ?? SKATESPOTS_COORDINATOR.pubkey;
if (!serverPubkey) return null; if (!serverPubkey) return null;
const relays = endpoint const relays = fallback?.relays?.length ? fallback.relays : SKATESPOTS_COORDINATOR.relays;
? [endpoint.relayUrl]
: fallback?.relays?.length
? fallback.relays
: SKATESPOTS_COORDINATOR.relays;
return { serverPubkey, relays }; return { serverPubkey, relays };
} }