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() {
|
export function CoordinatorSettingsCard() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { coordinator, setCoordinator, removeCoordinator } = useCoordinator();
|
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 [input, setInput] = useState('');
|
||||||
const [showQr, setShowQr] = useState(false);
|
const [showQr, setShowQr] = useState(false);
|
||||||
|
|
@ -176,6 +176,17 @@ export function CoordinatorSettingsCard() {
|
||||||
: ` · ${t('socialUi.coordinator.eventFoundMany', { count: eventCount })}`)}
|
: ` · ${t('socialUi.coordinator.eventFoundMany', { count: eventCount })}`)}
|
||||||
</p>
|
</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">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button size="sm" variant="outline" onClick={() => void check()} disabled={status === 'checking'}>
|
<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')} />
|
<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 { useCoordinator, useCoordinatorHealth } from '@/hooks/useCoordinator';
|
||||||
import { normalizeUrlInput, sanitizeUrl } from '@/lib/sanitizeUrl';
|
import { normalizeUrlInput, sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||||
import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor';
|
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 { useRelays } from '@/lib/relays';
|
||||||
import { cn } from '@/lib/utils';
|
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';
|
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) ||
|
crewCoordinatorInputValue(existing?.coordinator) ||
|
||||||
(isEditing ? '' : crewCoordinatorInputValue({ url: SKATESPOTS_COORDINATOR.relays[0], pubkey: SKATESPOTS_COORDINATOR.pubkey })),
|
(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.
|
// The coordinator the user set in Settings → Coordinator, for a one-tap prefill.
|
||||||
const { coordinator: myCoordinator } = useCoordinator();
|
const { coordinator: myCoordinator } = useCoordinator();
|
||||||
// Live parse so the founder gets feedback (and the publish path reuses it).
|
// 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' });
|
toast({ title: t('socialUi.coordinator.invalid'), variant: 'destructive' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const operatorPubkey = coordinatorOperator.trim() ? parseOperatorPubkey(coordinatorOperator) : null;
|
||||||
|
if (coordinatorOperator.trim() && !operatorPubkey) {
|
||||||
|
toast({ title: t('socialUi.coordinator.invalidOperator'), variant: 'destructive' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const identifier = existing?.identifier ?? `crew-${Date.now().toString(36)}`;
|
const identifier = existing?.identifier ?? `crew-${Date.now().toString(36)}`;
|
||||||
|
|
@ -614,6 +620,10 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
if (parsedCoordinator) {
|
if (parsedCoordinator) {
|
||||||
const coordinatorTag = ['coordinator', parsedCoordinator.url];
|
const coordinatorTag = ['coordinator', parsedCoordinator.url];
|
||||||
if (parsedCoordinator.pubkey) coordinatorTag.push(parsedCoordinator.pubkey);
|
if (parsedCoordinator.pubkey) coordinatorTag.push(parsedCoordinator.pubkey);
|
||||||
|
if (operatorPubkey) {
|
||||||
|
if (!parsedCoordinator.pubkey) coordinatorTag.push('');
|
||||||
|
coordinatorTag.push(operatorPubkey);
|
||||||
|
}
|
||||||
tags.push(coordinatorTag);
|
tags.push(coordinatorTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1242,6 +1252,21 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
</Button>
|
</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">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="crew-cordn-group" className="text-xs text-gray-400">{t('socialUi.form.cordnGroup')}</Label>
|
<Label htmlFor="crew-cordn-group" className="text-xs text-gray-400">{t('socialUi.form.cordnGroup')}</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,10 @@ export interface CoordinatorHealth {
|
||||||
name: string | null;
|
name: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
checkedAt: number | 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,
|
name: null,
|
||||||
error: null,
|
error: null,
|
||||||
checkedAt: 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.
|
* Presence probe for a coordinator.
|
||||||
*
|
*
|
||||||
|
|
@ -86,7 +134,16 @@ export function useCoordinatorHealth(coordinator: CoordinatorConfig | null) {
|
||||||
|
|
||||||
const check = useCallback(async () => {
|
const check = useCallback(async () => {
|
||||||
if (!coordinator) return;
|
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 {
|
try {
|
||||||
const signal = AbortSignal.timeout(8000);
|
const signal = AbortSignal.timeout(8000);
|
||||||
|
|
@ -113,6 +170,7 @@ export function useCoordinatorHealth(coordinator: CoordinatorConfig | null) {
|
||||||
name: null,
|
name: null,
|
||||||
error: null,
|
error: null,
|
||||||
checkedAt: Date.now(),
|
checkedAt: Date.now(),
|
||||||
|
...rpc,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -127,6 +185,7 @@ export function useCoordinatorHealth(coordinator: CoordinatorConfig | null) {
|
||||||
name: coordinatorNameFromEvent(newest),
|
name: coordinatorNameFromEvent(newest),
|
||||||
error: null,
|
error: null,
|
||||||
checkedAt: Date.now(),
|
checkedAt: Date.now(),
|
||||||
|
...rpc,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setResult({
|
setResult({
|
||||||
|
|
@ -136,6 +195,7 @@ export function useCoordinatorHealth(coordinator: CoordinatorConfig | null) {
|
||||||
name: null,
|
name: null,
|
||||||
error: err instanceof Error ? err.message : 'Could not reach the relays',
|
error: err instanceof Error ? err.message : 'Could not reach the relays',
|
||||||
checkedAt: Date.now(),
|
checkedAt: Date.now(),
|
||||||
|
...rpc,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [coordinator, relays, nostr]);
|
}, [coordinator, relays, nostr]);
|
||||||
|
|
|
||||||
|
|
@ -3925,6 +3925,8 @@ socialUi: {
|
||||||
eventFoundOne: '{{count}} event found.',
|
eventFoundOne: '{{count}} event found.',
|
||||||
eventFoundMany: '{{count}} events found.',
|
eventFoundMany: '{{count}} events found.',
|
||||||
check: 'Check',
|
check: 'Check',
|
||||||
|
rpcOk: 'RPC reachable in {{ms}}ms',
|
||||||
|
rpcError: 'Presence may be visible, but Coordinator RPC is not reachable.',
|
||||||
addLinkCopied: 'Add link copied',
|
addLinkCopied: 'Add link copied',
|
||||||
copyFailed: 'Copy failed',
|
copyFailed: 'Copy failed',
|
||||||
copyManually: 'Copy it manually.',
|
copyManually: 'Copy it manually.',
|
||||||
|
|
@ -3958,6 +3960,10 @@ socialUi: {
|
||||||
customBadge: '· experimental · your own relay',
|
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",
|
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',
|
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.",
|
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',
|
cordnCoordinator: 'Cordn coordinator',
|
||||||
invalid: "That doesn't look like a coordinator. Paste an nprofile, a cordn.net link, or an https/wss URL.",
|
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.',
|
eventFoundOne: '{{count}} evento encontrado.',
|
||||||
eventFoundMany: '{{count}} eventos encontrados.',
|
eventFoundMany: '{{count}} eventos encontrados.',
|
||||||
check: 'Comprobar',
|
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',
|
addLinkCopied: 'Enlace de añadir copiado',
|
||||||
copyFailed: 'No se pudo copiar',
|
copyFailed: 'No se pudo copiar',
|
||||||
copyManually: 'Cópialo manualmente.',
|
copyManually: 'Cópialo manualmente.',
|
||||||
|
|
@ -3958,6 +3960,10 @@ socialUi: {
|
||||||
customBadge: '· experimental · tu propio relé',
|
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',
|
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',
|
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.',
|
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',
|
cordnCoordinator: 'coordinador Cordn',
|
||||||
invalid: 'Eso no parece un coordinador. Pega un nprofile, un enlace de cordn.net o una URL https/wss.',
|
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;
|
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). */
|
/** Rebuild a paste-able input from a stored crew coordinator (round-trips). */
|
||||||
export function crewCoordinatorInputValue(
|
export function crewCoordinatorInputValue(
|
||||||
coordinator: { url: string; pubkey?: string } | undefined,
|
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