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.
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`

View file

@ -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<void> {
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(''),
});
const privateKeyHex = Array.from(watcherSecret, (byte) => byte.toString(16).padStart(2, '0')).join('');
const groupsByCoordinator = new Map<string, { pubkey: string; relays: string[]; crews: Crew[] }>();
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);
}
let requests: FetchManyPendingJoinRequestsOutput;
try {
requests = await coordinator.fetchManyPendingJoinRequests({
groups: crews.map((crew) => ({ gid: crew.gid })),
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.requests.length === 0) {
if (requests.length === 0) {
pool.close([COMMUNITY]);
console.log('[cordn-watcher] no pending join requests');
return;
@ -289,7 +325,7 @@ async function main(): Promise<void> {
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<void> {
{
watcher: nip19.npubEncode(watcherPubkey),
crews: crews.length,
pending: requests.requests.length,
pending: requests.length,
newRequests: sent,
},
null,

View file

@ -202,6 +202,7 @@ export function CordnNativePanel({
const [deviceLinkOpen, setDeviceLinkOpen] = useState(false);
const [deviceLinkCode, setDeviceLinkCode] = useState<string | null>(null);
const [deviceLinkPending, setDeviceLinkPending] = useState(false);
const [deviceLinkError, setDeviceLinkError] = useState<string | null>(null);
const [scannerOpen, setScannerOpen] = useState(false);
const scrollRef = useRef<HTMLDivElement>(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);
}
}}
>
<DialogContent className="max-w-sm border-white/10 bg-zinc-950">
@ -1484,6 +1490,13 @@ export function CordnNativePanel({
</Button>
</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">
<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,
): NativeCoordinator | null {
const endpoint = resolveCordnEndpoint(crew);
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 ?? fallback?.pubkey ?? SKATESPOTS_COORDINATOR.pubkey;
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 };
}