diff --git a/AGENTS.md b/AGENTS.md index 3a84589..a8d0cba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. Do not claim multi-coordinator failover; this is one trusted delivery service 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 / http URL), **normalises a bare pointer to Cordn's canonical add-link** before saving (`coordinatorAddUrl`), and has **"Use my coordinator"**. `NIP19Page` diff --git a/scripts/cordn-join-watcher.ts b/scripts/cordn-join-watcher.ts index 543ed2d..41bc15c 100644 --- a/scripts/cordn-join-watcher.ts +++ b/scripts/cordn-join-watcher.ts @@ -55,6 +55,8 @@ interface Crew { pubkey: string; identifier: string; recipients: string[]; + coordinatorPubkey: string; + coordinatorRelays: string[]; } interface WatcherState { @@ -130,8 +132,18 @@ function crewFromEvent(event: { const name = event.tags.find(([name]) => name === 'title')?.[1]; if (!d || !name) return null; const coordinatorTag = event.tags.find(([name]) => name === 'coordinator'); - const coordinatorPubkey = coordinatorTag?.[2]; - if (coordinatorPubkey && coordinatorPubkey !== SKATESPOTS_COORDINATOR.pubkey) return null; + const coordinatorUrl = coordinatorTag?.[1]; + 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 .filter(([name, value, marker]) => name === 'p' && /^[0-9a-f]{64}$/i.test(value ?? '') && marker === 'admin') .map(([, value]) => value.toLowerCase()); @@ -142,6 +154,8 @@ function crewFromEvent(event: { pubkey: event.pubkey, identifier: d, recipients, + coordinatorPubkey, + coordinatorRelays, }; } @@ -264,22 +278,44 @@ async function main(): Promise { return; } - const coordinator = new CordnCoordinatorClient({ - serverPubkey: SKATESPOTS_COORDINATOR.pubkey, - relays: SKATESPOTS_COORDINATOR.relays, - privateKeyHex: Array.from(watcherSecret, (byte) => byte.toString(16).padStart(2, '0')).join(''), - }); - - let requests: FetchManyPendingJoinRequestsOutput; - try { - requests = await coordinator.fetchManyPendingJoinRequests({ - groups: crews.map((crew) => ({ gid: crew.gid })), - }); - } finally { - await coordinator.disconnect().catch(() => undefined); + const privateKeyHex = Array.from(watcherSecret, (byte) => byte.toString(16).padStart(2, '0')).join(''); + const groupsByCoordinator = new Map(); + for (const crew of crews) { + const key = `${crew.coordinatorPubkey}|${crew.coordinatorRelays.slice().sort().join(',')}`; + const group = groupsByCoordinator.get(key) ?? { + pubkey: crew.coordinatorPubkey, + relays: crew.coordinatorRelays, + crews: [], + }; + group.crews.push(crew); + groupsByCoordinator.set(key, group); } - 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]); console.log('[cordn-watcher] no pending join requests'); return; @@ -289,7 +325,7 @@ async function main(): Promise { const state = loadState(); let sent = 0; - for (const request of requests.requests) { + for (const request of requests) { const crew = byGid.get(request.gid); if (!crew) continue; const key = `${request.gid}:${request.pk}:${request.kp_ref}`; @@ -342,7 +378,7 @@ async function main(): Promise { { watcher: nip19.npubEncode(watcherPubkey), crews: crews.length, - pending: requests.requests.length, + pending: requests.length, newRequests: sent, }, null, diff --git a/src/components/crew/CordnNativePanel.tsx b/src/components/crew/CordnNativePanel.tsx index 0595282..ccd6b9c 100644 --- a/src/components/crew/CordnNativePanel.tsx +++ b/src/components/crew/CordnNativePanel.tsx @@ -202,6 +202,7 @@ export function CordnNativePanel({ const [deviceLinkOpen, setDeviceLinkOpen] = useState(false); const [deviceLinkCode, setDeviceLinkCode] = useState(null); const [deviceLinkPending, setDeviceLinkPending] = useState(false); + const [deviceLinkError, setDeviceLinkError] = useState(null); const [scannerOpen, setScannerOpen] = useState(false); const scrollRef = useRef(null); const { isMuted, toggle: toggleMute } = useDmMute(); @@ -548,12 +549,13 @@ export function CordnNativePanel({ const openDeviceLink = useCallback(async () => { setDeviceLinkOpen(true); setDeviceLinkCode(null); + setDeviceLinkError(null); setDeviceLinkPending(true); setNotice(null); const code = await native.createDeviceLink(); if (!code) { setDeviceLinkPending(false); - setNotice(t('cordnUi.deviceLinkFailed')); + setDeviceLinkError(t('cordnUi.deviceLinkFailed')); return; } setDeviceLinkCode(code); @@ -1445,7 +1447,11 @@ export function CordnNativePanel({ open={deviceLinkOpen} onOpenChange={(open) => { setDeviceLinkOpen(open); - if (!open) setDeviceLinkCode(null); + if (!open) { + setDeviceLinkCode(null); + setDeviceLinkPending(false); + setDeviceLinkError(null); + } }} > @@ -1484,6 +1490,13 @@ export function CordnNativePanel({ + ) : deviceLinkError ? ( +
+

{deviceLinkError}

+ +
) : (
{t('common.loading')} diff --git a/src/hooks/useCordnNative.ts b/src/hooks/useCordnNative.ts index 06e458d..8ef077a 100644 --- a/src/hooks/useCordnNative.ts +++ b/src/hooks/useCordnNative.ts @@ -35,14 +35,22 @@ export function resolveNativeCoordinator( fallback: { pubkey: string; relays: string[] } | null, ): NativeCoordinator | null { const endpoint = resolveCordnEndpoint(crew); - const serverPubkey = - endpoint?.coordinatorPubkey ?? fallback?.pubkey ?? SKATESPOTS_COORDINATOR.pubkey; + if (crew.coordinator?.url) { + // 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; - const relays = endpoint - ? [endpoint.relayUrl] - : fallback?.relays?.length - ? fallback.relays - : SKATESPOTS_COORDINATOR.relays; + const relays = fallback?.relays?.length ? fallback.relays : SKATESPOTS_COORDINATOR.relays; return { serverPubkey, relays }; }