Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 91 additions & 5 deletions apps/frontend/src/components/connect/ConnectList/ConnectList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,22 @@ import {
} from '../../../store/rendezvousSlice';
import rendezvousReducer from '../../../store/rendezvousSlice';
import { useInjectReducer } from '../../../hooks/use-injectreducer';
import { selectActiveProfile } from '../../../store/nodesSelectors';
import { selectActiveProfile, selectNodeProfiles, selectIsSwitchingNode } from '../../../store/nodesSelectors';
import { setActiveProfileId, setIsSwitching, setProfileHealth } from '../../../store/nodesSlice';
import { clearNodeData } from '../../../store/rootSlice';
import { clearCLNStore } from '../../../store/clnSlice';
import { clearBKPRStore } from '../../../store/bkprSlice';
import { clearFactoriesStore } from '../../../store/factoriesSlice';
import { fetchVouches } from '../../../services/nostr.service';
import { RendezvousService } from '../../../services/http.service';
import {
RendezvousService,
NodesService,
RootService,
CLNService,
BookkeeperService,
FactoriesService,
} from '../../../services/http.service';
import logger from '../../../services/logger.service';
import JoinFactoryModal from '../JoinFactoryModal/JoinFactoryModal';
import AcceptInviteModal from '../../factories/AcceptInviteModal/AcceptInviteModal';
import ManualConnectModal from '../ManualConnectModal/ManualConnectModal';
Expand Down Expand Up @@ -125,6 +138,8 @@ const ConnectList = () => {
const isVouchLoading = useSelector(selectVouchesLoading);
const vouchErrors = useSelector(selectVouchErrors);
const activeProfile = useSelector(selectActiveProfile);
const allProfiles = useSelector(selectNodeProfiles);
const isSwitchingProfile = useSelector(selectIsSwitchingNode);
const enabledRelays = useSelector(selectEnabledRelays);

const network = clnNetworkToCoordKey(activeProfile?.network);
Expand All @@ -134,6 +149,54 @@ const ConnectList = () => {
);
const activeCoordinators = useSelector(selectActiveCoords);

/* Polish 2026-05-29: profile-switch CTA for the empty state.
* When the active node's network has no coordinator binding (e.g., regtest),
* surface the user's *other* profiles that ARE covered so a one-click switch
* gets them to a working Connect view. */
const switchCandidates = useMemo(() => {
const out: Array<{ id: string; label: string; net: CoordinatorNetwork }> = [];
for (const p of allProfiles || []) {
if (p.id === activeProfile?.id) continue;
const n = clnNetworkToCoordKey(p.network);
if (!n) continue;
out.push({
id: p.id,
label: p.alias || p.label || p.id.slice(0, 8),
net: n,
});
}
return out;
}, [allProfiles, activeProfile?.id]);

const handleSwitchToProfile = useCallback(async (profileId: string) => {
if (isSwitchingProfile) return;
try {
dispatch(setIsSwitching(true));
const result = await NodesService.switchNode(profileId);
dispatch(clearNodeData());
dispatch(clearCLNStore());
dispatch(clearBKPRStore());
dispatch(clearFactoriesStore());
dispatch(setActiveProfileId(result.profile?.id || profileId));
await RootService.fetchRootData();
await RootService.refreshData();
} catch (err) {
logger.error('CTA profile switch failed:', err);
} finally {
dispatch(setIsSwitching(false));
}
Promise.all([
CLNService.fetchCLNData(),
BookkeeperService.fetchBKPRData(),
FactoriesService.fetchFactoriesData(),
NodesService.fetchAndDispatchNodes(),
NodesService.detectFactoryPlugin(),
NodesService.healthCheck()
.then(h => { if (h?.health) dispatch(setProfileHealth(h.health)); })
.catch(err => logger.warn('Health check after CTA switch failed:', err)),
]).catch(err => logger.error('Background post-CTA-switch refresh failed:', err));
}, [dispatch, isSwitchingProfile]);

const [showAcceptInvite, setShowAcceptInvite] = useState(false);
const [showManualConnect, setShowManualConnect] = useState(false);
const [showSample, setShowSample] = useState(false);
Expand Down Expand Up @@ -387,9 +450,32 @@ const ConnectList = () => {

<Card.Body className='py-0 px-1 channels-scroll-container'>
{!showSample && !network && (
<Row className='text-light fs-6 mt-3 mx-2 text-center'>
Active node&apos;s network ({activeProfile?.network ?? 'unknown'}) is not covered by any
configured coordinator. Switch to a signet, testnet4, or mainnet node to see vouches.
<Row className='text-light fs-6 mt-3 mx-2 text-center' data-testid='connect-no-coord-empty'>
<div className='mb-2'>
Active node&apos;s network ({activeProfile?.network ?? 'unknown'}) is not covered by any
configured coordinator.
</div>
{switchCandidates.length > 0 ? (
<div className='d-flex flex-wrap justify-content-center gap-2'>
{switchCandidates.map((c) => (
<Button
key={c.id}
variant='outline-primary'
size='sm'
disabled={isSwitchingProfile}
onClick={() => handleSwitchToProfile(c.id)}
data-testid={`connect-switch-${c.net}`}
>
{isSwitchingProfile ? <Spinner animation='border' size='sm' className='me-2' /> : null}
Switch to {c.label} ({c.net}) →
</Button>
))}
</div>
) : (
<div className='text-muted' style={{ fontSize: '0.85rem' }}>
Add a signet, testnet4, or mainnet node profile to see vouches.
</div>
)}
</Row>
)}
{!showSample && network && activeCoordinators.length === 0 && (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,30 @@
import { useEffect, useState } from 'react';
import { Modal, Button, Form, Alert, Spinner } from 'react-bootstrap';
import { parseInviteUrl, Invite } from '../../../utilities/inviteUrl';
import { parseInviteUrlDetailed, Invite } from '../../../utilities/inviteUrl';
import { FactoriesService } from '../../../services/http.service';

/* Address class used by the "trust this address" gate. Public IPs mean the
* wallet's about to phone home to a stranger's box; loopback is local dev /
* regtest demo; tor is privacy-preserving; private means inside the same
* NAT (usually fine but worth surfacing). */
function classifyAddress(addr?: string): 'tor' | 'loopback' | 'private' | 'public' | null {
if (!addr) return null;
const host = addr.split(':')[0]?.toLowerCase() ?? '';
if (!host) return null;
if (host.endsWith('.onion')) return 'tor';
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return 'loopback';
if (
/^10\./.test(host) ||
/^192\.168\./.test(host) ||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(host) ||
/^169\.254\./.test(host) ||
/^fe80:/.test(host) ||
/^fc[0-9a-f]{2}:/.test(host) ||
/^fd[0-9a-f]{2}:/.test(host)
) return 'private';
return 'public';
}

/* Session 6a (Tier-2 polish): client-side "Join via invite" modal.
*
* User pastes a superscalar://join?... URL (or scans a QR with an
Expand All @@ -26,22 +48,29 @@ function AcceptInviteModal({ show, onHide }: Props) {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
/* Polish 2026-05-29: explicit trust gate when the invite points at a
* publicly-routable IP. Loopback / tor / private don't require this. */
const [trustAcked, setTrustAcked] = useState(false);

useEffect(() => {
if (!urlInput.trim()) {
setParsed(null);
setError(null);
return;
}
const inv = parseInviteUrl(urlInput);
if (!inv) {
const r = parseInviteUrlDetailed(urlInput);
if (!r.invite) {
setParsed(null);
setError('Not a valid superscalar:// invite. Expected format: superscalar://join?iid=…&lsp=…');
if (r.error === 'expired') {
setError('This invite expired. Ask the LSP operator for a fresh one.');
} else {
setError('Not a valid superscalar:// invite. Expected format: superscalar://join?iid=…&lsp=…');
}
} else {
setParsed(inv);
setParsed(r.invite);
setError(null);
if (inv.contributionMinSats != null && !contribution) {
setContribution(String(inv.contributionMinSats));
if (r.invite.contributionMinSats != null && !contribution) {
setContribution(String(r.invite.contributionMinSats));
}
}
// eslint-disable-next-line
Expand Down Expand Up @@ -140,6 +169,37 @@ function AcceptInviteModal({ show, onHide }: Props) {
</Form.Group>
)}

{parsed && (() => {
const cls = classifyAddress(parsed.address);
if (cls === 'public') {
return (
<Alert variant='warning' className='py-2 mb-3' style={{ fontSize: '0.85rem' }} data-testid='accept-invite-trust-gate'>
<div className='mb-2'>
<strong>Heads up:</strong> sending this join request will phone home to
a publicly-routable IP, <code>{parsed.address}</code>. Make sure you got this
invite from a person you actually trust.
</div>
<Form.Check
type='checkbox'
id='accept-invite-trust-ack'
label="I trust the source of this invite and want to connect."
checked={trustAcked}
onChange={(e) => setTrustAcked(e.target.checked)}
data-testid='accept-invite-trust-ack'
/>
</Alert>
);
}
if (cls === 'tor') {
return (
<Alert variant='info' className='py-2 mb-3' style={{ fontSize: '0.85rem' }} data-testid='accept-invite-tor-note'>
Connecting via Tor onion (<code>{parsed.address}</code>). Privacy-preserving.
</Alert>
);
}
return null;
})()}

{error && <Alert variant='warning' className='py-2 mb-2'>{error}</Alert>}
{success && <Alert variant='success' className='py-2 mb-2'>{success}</Alert>}
</Modal.Body>
Expand All @@ -149,7 +209,12 @@ function AcceptInviteModal({ show, onHide }: Props) {
</Button>
<Button
variant='primary'
disabled={!parsed || submitting || success !== null}
disabled={
!parsed ||
submitting ||
success !== null ||
(classifyAddress(parsed?.address) === 'public' && !trustAcked)
}
onClick={handleJoin}
data-testid='accept-invite-submit'
>
Expand Down
99 changes: 93 additions & 6 deletions apps/frontend/src/components/factories/InviteModal/InviteModal.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,33 @@
import { useMemo, useState } from 'react';
import { Modal, Button, Form, InputGroup } from 'react-bootstrap';
import { Modal, Button, Form, InputGroup, Alert } from 'react-bootstrap';
import { QRCodeSVG } from 'qrcode.react';
import { useSelector } from 'react-redux';
import { selectNodeInfo } from '../../../store/rootSelectors';
import { buildInviteUrl } from '../../../utilities/inviteUrl';

/* Address shape inspection — used for the privacy hint. Public-routable
* IPv4/IPv6 reveal the LSP's location to anyone the URL is shared with;
* .onion is privacy-preserving; loopback is local-only and effectively
* benign for testnets / regtest demos. */
function classifyAddress(addr?: string): 'tor' | 'loopback' | 'private' | 'public' | null {
if (!addr) return null;
const host = addr.split(':')[0]?.toLowerCase() ?? '';
if (!host) return null;
if (host.endsWith('.onion')) return 'tor';
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return 'loopback';
// RFC1918 + loopback + link-local
if (
/^10\./.test(host) ||
/^192\.168\./.test(host) ||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(host) ||
/^169\.254\./.test(host) ||
/^fe80:/.test(host) ||
/^fc[0-9a-f]{2}:/.test(host) ||
/^fd[0-9a-f]{2}:/.test(host)
) return 'private';
return 'public';
}

/* Session 6a (Tier-2 polish): LSP-side invite modal.
*
* Shows a QR code + copyable superscalar:// URL that a client can use to
Expand All @@ -29,17 +52,44 @@ function InviteModal({ show, onHide, factoryInstanceIdHex, factoryLabel }: Props
const [minSats, setMinSats] = useState('');
const [maxSats, setMaxSats] = useState('');
const [copied, setCopied] = useState(false);
/* Polish 2026-05-29: optional expiry. Default off (no field set) so
* existing behavior (permanent invite) is preserved. When the operator
* picks a duration, the URL gains an `expires` param and clients with
* the wallet refuse to fire a join after that timestamp. */
const [expiryDays, setExpiryDays] = useState<string>(''); // '' = no expiry
/* Polish 2026-05-29: prefer tor over public ipv4 if both are present, since
* a privacy-conscious LSP probably wants .onion shared, not their IP.
* Operator can override by editing the URL or address logic later. */
const [preferTor, setPreferTor] = useState(true);

const ourNodeId: string | undefined = nodeInfo?.id;
const address: string | undefined = useMemo(() => {

const candidateAddresses = useMemo(() => {
const addrs = nodeInfo?.address ?? [];
const ipv4 = addrs.find((a: any) => a.type === 'ipv4');
if (ipv4) return `${ipv4.address}:${ipv4.port}`;
const ipv6 = addrs.find((a: any) => a.type === 'ipv6');
const tor = addrs.find((a: any) => String(a.type).startsWith('torv'));
if (tor) return `${tor.address}:${tor.port}`;
return undefined;
return {
ipv4: ipv4 ? `${ipv4.address}:${ipv4.port}` : undefined,
ipv6: ipv6 ? `[${ipv6.address}]:${ipv6.port}` : undefined,
tor: tor ? `${tor.address}:${tor.port}` : undefined,
};
}, [nodeInfo]);

const address: string | undefined = useMemo(() => {
if (preferTor && candidateAddresses.tor) return candidateAddresses.tor;
return candidateAddresses.ipv4 || candidateAddresses.tor || candidateAddresses.ipv6;
}, [candidateAddresses, preferTor]);

const addressClass = classifyAddress(address);

const expiresAt = useMemo(() => {
if (!expiryDays) return undefined;
const days = Number(expiryDays);
if (!Number.isFinite(days) || days <= 0) return undefined;
return Math.floor(Date.now() / 1000) + Math.round(days * 86400);
}, [expiryDays]);

const inviteUrl = useMemo(() => {
if (!ourNodeId) return null;
return buildInviteUrl({
Expand All @@ -49,8 +99,9 @@ function InviteModal({ show, onHide, factoryInstanceIdHex, factoryLabel }: Props
contributionMinSats: minSats ? Number(minSats) : undefined,
contributionMaxSats: maxSats ? Number(maxSats) : undefined,
label: factoryLabel,
expiresAt,
});
}, [factoryInstanceIdHex, ourNodeId, address, minSats, maxSats, factoryLabel]);
}, [factoryInstanceIdHex, ourNodeId, address, minSats, maxSats, factoryLabel, expiresAt]);

const handleCopy = async () => {
if (!inviteUrl) return;
Expand Down Expand Up @@ -96,8 +147,44 @@ function InviteModal({ show, onHide, factoryInstanceIdHex, factoryLabel }: Props
data-testid='invite-max-sats'
/>
</Form.Group>
<Form.Group className='flex-fill'>
<Form.Label className='mb-1' style={{ fontSize: '0.85rem' }}>Expires after</Form.Label>
<Form.Select
value={expiryDays}
onChange={(e) => setExpiryDays(e.target.value)}
data-testid='invite-expiry'
>
<option value=''>Never (recommended off)</option>
<option value='1'>1 day</option>
<option value='7'>1 week</option>
<option value='30'>30 days</option>
<option value='90'>90 days</option>
</Form.Select>
</Form.Group>
</div>

{/* Privacy & address controls */}
{candidateAddresses.tor && candidateAddresses.ipv4 && (
<Form.Check
type='switch'
id='invite-prefer-tor'
label={`Prefer .onion address in URL (${candidateAddresses.tor.split(':')[0].slice(0, 16)}…)`}
checked={preferTor}
onChange={(e) => setPreferTor(e.target.checked)}
className='mb-3'
data-testid='invite-prefer-tor'
/>
)}

{addressClass === 'public' && (
<Alert variant='warning' className='py-2 mb-3' style={{ fontSize: '0.85rem' }} data-testid='invite-privacy-warning'>
<strong>Privacy:</strong> this invite URL embeds your node&apos;s public IP address
(<code>{address}</code>). Anyone with the URL can see it. {candidateAddresses.tor
? 'Toggle "Prefer .onion address" above to share a Tor hidden-service endpoint instead.'
: 'If your node has a Tor onion address, configure CLN to advertise it and re-open this modal — the wallet will prefer it.'}
</Alert>
)}

{!ourNodeId && (
<div className='alert alert-warning'>
Couldn&apos;t read your node ID. Wait a moment and try again.
Expand Down
Loading
Loading