Only ask for new hosts or changed connections

A pairing link now connects without the confirmation when it matches a
saved host exactly (same server id, key and relay). It still asks for a new
host, and for a saved host whose key or relay differs from what is saved,
with the "This link changes how you connect to this host" text.

After the user approves a link that then needs a password, the password
retry no longer asks again. The approval is an `OfferApproval` owned by one
pairing flow (one QR scan, or one open pair-link or add-host modal) and
only covers the exact offer that was approved.

Adds tests for a matching saved host, a changed key or relay, and the
password retry.
This commit is contained in:
Mohamed Boudra
2026-09-30 12:14:27 +02:00
parent fd7130ad44
commit cbf3bd5a80
6 changed files with 221 additions and 36 deletions
+18 -9
View File
@@ -9,7 +9,7 @@ import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { isWeb } from "@/constants/platform";
import { isHostTrustDeclinedError } from "@/runtime/host-trust";
import { isHostTrustDeclinedError, OfferApproval } from "@/runtime/host-trust";
import { BackHeader } from "@/components/headers/back-header";
import { PairLinkModal } from "@/components/pair-link-modal";
@@ -132,7 +132,10 @@ export default function PairScanScreen() {
const [permission, requestPermission] = useCameraPermissions();
const [isPairing, setIsPairing] = useState(false);
const [scanError, setScanError] = useState<string | null>(null);
const [passwordOfferUrl, setPasswordOfferUrl] = useState<string | null>(null);
const [passwordRetry, setPasswordRetry] = useState<{
offerUrl: string;
approval: OfferApproval;
} | null>(null);
const navigateToPairedHost = useCallback(
(serverId: string) => {
@@ -166,14 +169,19 @@ export default function PairScanScreen() {
if (!offerUrl) return;
const store = getHostRuntimeStore();
if (passwordOfferUrl) return;
if (passwordRetry) return;
setIsPairing(true);
setScanError(null);
const approval = new OfferApproval();
void store
.importConnectionLink(offerUrl, source === "onboarding" ? "hostRoot" : "hostSettings")
.importConnectionLink(
offerUrl,
source === "onboarding" ? "hostRoot" : "hostSettings",
approval,
)
.then((outcome) => {
if (outcome.status === "connected") navigateToPairedHost(outcome.serverId);
else setPasswordOfferUrl(offerUrl);
else setPasswordRetry({ offerUrl, approval });
return outcome;
})
.catch((error) => {
@@ -182,11 +190,11 @@ export default function PairScanScreen() {
})
.finally(() => setIsPairing(false));
},
[isPairing, navigateToPairedHost, passwordOfferUrl, source],
[isPairing, navigateToPairedHost, passwordRetry, source],
);
const handleRouterBack = useCallback(() => router.back(), [router]);
const closePasswordModal = useCallback(() => setPasswordOfferUrl(null), []);
const closePasswordModal = useCallback(() => setPasswordRetry(null), []);
const savePasswordPairing = useCallback(
({ serverId }: { serverId: string }) => navigateToPairedHost(serverId),
[navigateToPairedHost],
@@ -258,9 +266,10 @@ export default function PairScanScreen() {
)}
</View>
<PairLinkModal
visible={passwordOfferUrl !== null}
initialUrl={passwordOfferUrl ?? undefined}
visible={passwordRetry !== null}
initialUrl={passwordRetry?.offerUrl}
initialPasswordRequired
approval={passwordRetry?.approval}
onClose={closePasswordModal}
onSaved={savePasswordPairing}
/>
@@ -6,7 +6,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { isHostTrustDeclinedError } from "@/runtime/host-trust";
import { isHostTrustDeclinedError, OfferApproval } from "@/runtime/host-trust";
import {
parseConnectionUri,
serializeConnectionUri,
@@ -313,6 +313,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const [advancedUri, setAdvancedUri] = useState("");
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
const advancedTarget = useRef(new PairingTargetTracker("", true));
const offerApproval = useRef(new OfferApproval());
const clearInput = useCallback(() => {
setHost("");
@@ -323,6 +324,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
setIsAdvancedOpen(false);
setAdvancedUri("");
advancedTarget.current = new PairingTargetTracker("", true);
offerApproval.current = new OfferApproval();
bumpInputResetKey();
}, []);
@@ -381,6 +383,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl(
relayUri,
password || undefined,
offerApproval.current,
);
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
onSaved?.({ profile, serverId, hostname, isNewHost });
@@ -10,7 +10,7 @@ import { parseRelayConnectionUri } from "@/utils/daemon-endpoints";
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { getConnectionAuthFailureReason } from "@/utils/test-daemon-connection";
import { isHostTrustDeclinedError } from "@/runtime/host-trust";
import { isHostTrustDeclinedError, OfferApproval } from "@/runtime/host-trust";
import { PairingTargetTracker } from "./pair-link-credentials";
import { Button } from "@/components/ui/button";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
@@ -65,6 +65,8 @@ export interface PairLinkModalProps {
visible: boolean;
initialUrl?: string;
initialPasswordRequired?: boolean;
/** Approval already given for `initialUrl`, so its password retry does not ask again. */
approval?: OfferApproval;
onClose: () => void;
onCancel?: () => void;
onSaved?: (result: {
@@ -79,6 +81,7 @@ export function PairLinkModal({
visible,
initialUrl,
initialPasswordRequired = false,
approval,
onClose,
onCancel,
onSaved,
@@ -89,6 +92,7 @@ export function PairLinkModal({
visible={visible}
initialUrl={initialUrl}
initialPasswordRequired={initialPasswordRequired}
approval={approval}
onClose={onClose}
onCancel={onCancel}
onSaved={onSaved}
@@ -100,6 +104,7 @@ function PairLinkModalContent({
visible,
initialUrl,
initialPasswordRequired = false,
approval,
onClose,
onCancel,
onSaved,
@@ -112,6 +117,7 @@ function PairLinkModalContent({
const offerUrlRef = useRef(initialUrl ?? "");
const targetTracker = useRef(new PairingTargetTracker(initialUrl));
const offerApproval = useRef(approval ?? new OfferApproval());
const inputRef = useRef<EditingTextInputHandle>(null);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
@@ -166,6 +172,7 @@ function PairLinkModalContent({
const { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl(
raw,
password || undefined,
offerApproval.current,
);
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
onSaved?.({ profile, serverId, hostname, isNewHost });
@@ -34,6 +34,7 @@ import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-creden
import {
setHostTrustConfirmer,
isHostTrustDeclinedError,
OfferApproval,
type HostTrustRequest,
} from "./host-trust";
@@ -3720,6 +3721,119 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]);
});
it("connects a saved host from a matching pairing link without asking", async () => {
const requests: HostTrustRequest[] = [];
setHostTrustConfirmer(async (request) => {
requests.push(request);
return true;
});
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: "paired",
}),
getClientId: async () => "cid_saved_match",
},
storage: createMemoryHostRuntimeStorage(),
});
const offerUrl = encodeOfferUrl(makeOffer());
await store.probeAndUpsertConnectionFromOfferUrl(offerUrl);
expect(requests).toHaveLength(1);
expect(requests[0]?.isKnown).toBe(false);
await store.probeAndUpsertConnectionFromOfferUrl(offerUrl);
await store.importConnectionLink(offerUrl, "openProject");
expect(requests).toHaveLength(1);
expect(store.getHosts()).toHaveLength(1);
store.syncHosts([]);
});
it("asks when a pairing link changes a saved host's key or relay", async () => {
const requests: HostTrustRequest[] = [];
setHostTrustConfirmer(async (request) => {
requests.push(request);
return requests.length === 1;
});
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: "paired",
}),
getClientId: async () => "cid_saved_change",
},
storage: createMemoryHostRuntimeStorage(),
});
await store.probeAndUpsertConnectionFromOfferUrl(encodeOfferUrl(makeOffer()));
const savedHost = store.getHosts()[0];
const changedKeyUrl = encodeOfferUrl(makeOffer({ daemonPublicKeyB64: "pk_other_key" }));
await expect(store.probeAndUpsertConnectionFromOfferUrl(changedKeyUrl)).rejects.toSatisfy(
isHostTrustDeclinedError,
);
const changedRelayUrl = encodeOfferUrl(
makeOffer({ relay: { endpoint: "relay.example:443", useTls: false } }),
);
await expect(store.importConnectionLink(changedRelayUrl, "openProject")).rejects.toSatisfy(
isHostTrustDeclinedError,
);
expect(requests.map((request) => request.isKnown)).toEqual([false, true, true]);
expect(store.getHosts()).toEqual([savedHost]);
store.syncHosts([]);
});
it("does not ask again for the password retry of an approved pairing link", async () => {
const requests: HostTrustRequest[] = [];
setHostTrustConfirmer(async (request) => {
requests.push(request);
return true;
});
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => {
if (host.password !== "correct-password")
throw new DaemonAuthenticationError("password_required");
return {
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: "paired",
};
},
getClientId: async () => "cid_password_retry",
},
storage: createMemoryHostRuntimeStorage(),
});
const offerUrl = encodeOfferUrl(makeOffer());
const approval = new OfferApproval();
await expect(store.importConnectionLink(offerUrl, "hostRoot", approval)).resolves.toEqual({
status: "password_required",
});
expect(requests).toHaveLength(1);
// A separate pairing flow for the same link has not been approved yet.
await expect(
store.probeAndUpsertConnectionFromOfferUrl(offerUrl, "wrong-password", new OfferApproval()),
).rejects.toThrow();
expect(requests).toHaveLength(2);
await store.probeAndUpsertConnectionFromOfferUrl(offerUrl, "correct-password", approval);
expect(requests).toHaveLength(2);
expect(store.getHosts()[0]).toMatchObject({
serverId: "srv_offer",
password: "correct-password",
});
store.syncHosts([]);
});
it("preserves the existing host label when re-pairing an existing relay host", async () => {
const store = new HostRuntimeStore({
deps: {
+48 -22
View File
@@ -14,6 +14,7 @@ import {
normalizeStoredHostProfile,
upsertHostConnectionInProfiles,
registryHasConnection,
hostHasConnection,
StoredHostRegistrySchema,
type HostConnection,
type HostProfile,
@@ -29,7 +30,12 @@ import {
} from "@/utils/daemon-endpoints";
import { resolveAppVersion } from "@/utils/app-version";
import { ConnectionOfferSchema, type ConnectionOffer } from "@getpaseo/protocol/connection-offer";
import { confirmHostTrust, hostTrustRequestFromOffer, HostTrustDeclinedError } from "./host-trust";
import {
confirmHostTrust,
hostTrustRequestFromOffer,
HostTrustDeclinedError,
type OfferApproval,
} from "./host-trust";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { isWeb } from "@/constants/platform";
import { connectToDaemon, getConnectionAuthFailureReason } from "@/utils/test-daemon-connection";
@@ -1429,6 +1435,19 @@ function parseOfferConnectionUrl(input: string): { offer: ConnectionOffer; passw
return { offer: ConnectionOfferSchema.parse(payload) };
}
function relayConnectionFromOffer(offer: ConnectionOffer): HostConnection {
// COMPAT(oldRelayOfferTls): added in v0.1.73, remove after 2026-11-10.
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
const relayEndpoint = normalizeHostPort(offer.relay.endpoint);
return {
id: useTls ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`,
type: "relay",
relayEndpoint,
useTls,
daemonPublicKeyB64: offer.daemonPublicKeyB64.trim(),
};
}
export function hasConfiguredLocalDaemonOverride(): boolean {
return readConfiguredLocalDaemonOverride() !== null;
}
@@ -1937,15 +1956,23 @@ export class HostRuntimeStore {
});
}
// Adding or connecting a host from a pairing link (a URL, a QR code, or a
// pasted link) asks the user to confirm first. Both URL-import primitives go
// through this check, so every pairing-link path asks once.
private async requireOfferTrust(offer: ConnectionOffer): Promise<void> {
const isKnown = this.hosts.some((host) => host.serverId === offer.serverId);
const trusted = await confirmHostTrust(hostTrustRequestFromOffer(offer, { isKnown }));
if (!trusted) {
// A pairing link (a URL, a QR code, or a pasted link) asks the user to
// confirm when it adds a new host or changes a saved host's key or relay. A
// link that matches the saved host connects without asking, and so does a
// retry that carries the approval given earlier in the same pairing flow.
// Both URL-import primitives go through this check.
private async requireOfferTrust(
offer: ConnectionOffer,
approval: OfferApproval | undefined,
): Promise<void> {
if (approval?.covers(offer)) return;
const savedHost = this.hosts.find((host) => host.serverId === offer.serverId);
if (savedHost && hostHasConnection(savedHost, relayConnectionFromOffer(offer))) return;
const request = hostTrustRequestFromOffer(offer, { isKnown: savedHost !== undefined });
if (!(await confirmHostTrust(request))) {
throw new HostTrustDeclinedError(offer.serverId);
}
approval?.record(offer);
}
async upsertConnectionFromOfferUrl(
@@ -1954,27 +1981,20 @@ export class HostRuntimeStore {
password?: string,
): Promise<HostProfile> {
const parsed = parseOfferConnectionUrl(offerUrlOrFragment);
await this.requireOfferTrust(parsed.offer);
await this.requireOfferTrust(parsed.offer, undefined);
return this.upsertConnectionFromOffer(parsed.offer, label, password ?? parsed.password);
}
async probeAndUpsertConnectionFromOfferUrl(
offerUrlOrFragment: string,
password?: string,
approval?: OfferApproval,
): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
const parsed = parseOfferConnectionUrl(offerUrlOrFragment);
const offer = parsed.offer;
await this.requireOfferTrust(offer);
await this.requireOfferTrust(offer, approval);
const credential = password ?? parsed.password;
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
const relayEndpoint = normalizeHostPort(offer.relay.endpoint);
const connection: HostConnection = {
id: useTls ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`,
type: "relay",
relayEndpoint,
useTls,
daemonPublicKeyB64: offer.daemonPublicKeyB64,
};
const connection = relayConnectionFromOffer(offer);
const probeHost: HostProfile = {
serverId: offer.serverId,
...(credential ? { password: credential } : {}),
@@ -1995,13 +2015,18 @@ export class HostRuntimeStore {
async importConnectionLink(
url: string,
target: PairingNavigationTarget,
approval?: OfferApproval,
): Promise<{ status: "connected"; serverId: string } | { status: "password_required" }> {
if (target === "openProject") {
const profile = await this.upsertConnectionFromOfferUrl(url);
return { status: "connected", serverId: profile.serverId };
}
try {
const { serverId } = await this.probeAndUpsertConnectionFromOfferUrl(url);
const { serverId } = await this.probeAndUpsertConnectionFromOfferUrl(
url,
undefined,
approval,
);
return { status: "connected", serverId };
} catch (error) {
if (!getConnectionAuthFailureReason(error)) throw error;
@@ -2793,6 +2818,7 @@ export interface HostMutations {
probeAndUpsertConnectionFromOfferUrl: (
offerUrlOrFragment: string,
password?: string,
approval?: OfferApproval,
) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
renameHost: (serverId: string, label: string) => Promise<void>;
setHostColor: (serverId: string, color: HostColor) => Promise<void>;
@@ -2812,8 +2838,8 @@ export function useHostMutations(): HostMutations {
upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label),
upsertConnectionFromOfferUrl: (url, label, password) =>
store.upsertConnectionFromOfferUrl(url, label, password),
probeAndUpsertConnectionFromOfferUrl: (url, password) =>
store.probeAndUpsertConnectionFromOfferUrl(url, password),
probeAndUpsertConnectionFromOfferUrl: (url, password, approval) =>
store.probeAndUpsertConnectionFromOfferUrl(url, password, approval),
renameHost: (serverId, label) => store.renameHost(serverId, label),
setHostColor: (serverId, color) => store.setHostColor(serverId, color),
setHostBadgeDisplay: (serverId, badgeDisplay) =>
+29 -3
View File
@@ -2,8 +2,9 @@ import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
/**
* A pairing offer (from a URL, a QR code, or a pasted link) names the daemon the
* client will connect to. Before anything is added or connected, the user
* confirms the host.
* client will connect to. Before a new host is added, or a saved host's
* connection changes, the user confirms the host. A link that matches a saved
* host exactly connects without asking.
*
* The host runtime (the model) owns the decision to ask; the UI registers a
* confirmer that renders the prompt. When no confirmer is registered the answer
@@ -16,7 +17,7 @@ export interface HostTrustRequest {
keyFingerprint: string;
/** Relay endpoint the client would connect through. */
endpoint: string;
/** Whether a host with this server id already exists in the registry. */
/** A host with this server id is saved, and the link changes its connection. */
isKnown: boolean;
}
@@ -34,6 +35,31 @@ export function isHostTrustDeclinedError(error: unknown): error is HostTrustDecl
return error instanceof HostTrustDeclinedError;
}
/**
* The approval given during one pairing flow (one QR scan, or one open pairing
* modal). When the approved host then asks for a password, the retry passes the
* same `OfferApproval` so the user is not asked twice. It only covers the exact
* offer that was approved, and a new flow starts with a new one.
*/
export class OfferApproval {
private approved: ConnectionOffer | null = null;
record(offer: ConnectionOffer): void {
this.approved = offer;
}
covers(offer: ConnectionOffer): boolean {
const approved = this.approved;
return (
approved !== null &&
approved.serverId === offer.serverId &&
approved.daemonPublicKeyB64 === offer.daemonPublicKeyB64 &&
approved.relay.endpoint === offer.relay.endpoint &&
approved.relay.useTls === offer.relay.useTls
);
}
}
let activeConfirmer: HostTrustConfirmer | null = null;
/**