fix: support custom coordinator request routing and device-link errors
This commit is contained in:
parent
7e8d13cbff
commit
4e44ce769b
4 changed files with 91 additions and 27 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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(''),
|
||||
});
|
||||
|
||||
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<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);
|
||||
}
|
||||
|
||||
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<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,
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue