mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
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:
@@ -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: {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user