mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
Move host confirmation into the host runtime
The host runtime now owns the pending host confirmation and the decision to ask. The sheet reads the pending confirmation and sends the answer; nothing registers into a module variable. A pairing flow is a LinkPairing the runtime hands out, so the password retry for a confirmed link doesn't ask again without views creating or resetting approval objects. Cancel is a result instead of an error. Pairing waits for saved hosts to load before deciding whether to ask.
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openSettings } from "../support/helpers/app";
|
||||
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||
import {
|
||||
buildPairingLink,
|
||||
cancelHostConfirmation,
|
||||
connectToConfirmedHost,
|
||||
expectHostConfirmationFor,
|
||||
expectHostInHostPicker,
|
||||
hostConfirmation,
|
||||
openPairingLink,
|
||||
saveLinkedHostBeforeLoad,
|
||||
type LinkedHost,
|
||||
} from "../support/helpers/host-confirmation";
|
||||
|
||||
// An unreachable relay: the link saves the host without connecting first.
|
||||
const linkedHost: LinkedHost = {
|
||||
serverId: "srv_link_confirmation",
|
||||
relayEndpoint: "127.0.0.1:59998",
|
||||
daemonPublicKeyB64: "bGlua0NvbmZpcm1hdGlvbktleQ",
|
||||
};
|
||||
|
||||
test("a pairing link for a new host asks before saving it", async ({ page }) => {
|
||||
const link = buildPairingLink(linkedHost);
|
||||
|
||||
await test.step("Cancel saves nothing and stays on the page", async () => {
|
||||
await openPairingLink(page, link);
|
||||
await expectHostConfirmationFor(page, linkedHost);
|
||||
await cancelHostConfirmation(page);
|
||||
await expectAppRoute(page, "/settings/general");
|
||||
await expectHostInHostPicker(page, linkedHost.serverId, "not listed");
|
||||
});
|
||||
|
||||
await test.step("Connect saves the host and opens a project", async () => {
|
||||
await openPairingLink(page, link);
|
||||
await expectHostConfirmationFor(page, linkedHost);
|
||||
await connectToConfirmedHost(page);
|
||||
await expectAppRoute(page, "/open-project");
|
||||
await openSettings(page);
|
||||
await expectHostInHostPicker(page, linkedHost.serverId, "listed");
|
||||
});
|
||||
});
|
||||
|
||||
test("a pairing link for a saved host connects without asking", async ({ page }) => {
|
||||
await saveLinkedHostBeforeLoad(page, linkedHost);
|
||||
|
||||
await openPairingLink(page, buildPairingLink(linkedHost));
|
||||
|
||||
await expectAppRoute(page, "/open-project", { timeout: 30_000 });
|
||||
await expect(hostConfirmation(page)).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts";
|
||||
|
||||
export interface LinkedHost {
|
||||
serverId: string;
|
||||
relayEndpoint: string;
|
||||
daemonPublicKeyB64: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pairing link that opens on the settings page. `/` redirects to
|
||||
* `/open-project` on its own, so starting in settings leaves the pairing link
|
||||
* as the only thing that can move the app to `/open-project`.
|
||||
*/
|
||||
export function buildPairingLink(host: LinkedHost): string {
|
||||
const offer = {
|
||||
v: 2,
|
||||
serverId: host.serverId,
|
||||
daemonPublicKeyB64: host.daemonPublicKeyB64,
|
||||
relay: { endpoint: host.relayEndpoint, useTls: false },
|
||||
};
|
||||
const encoded = Buffer.from(JSON.stringify(offer), "utf8").toString("base64url");
|
||||
return `/settings/general#offer=${encoded}`;
|
||||
}
|
||||
|
||||
/** Loads the app from a pairing link, the way opening the link in a browser does. */
|
||||
export async function openPairingLink(page: Page, link: string): Promise<void> {
|
||||
// Leave the link's page first so the link always loads the app from scratch.
|
||||
await page.goto("/");
|
||||
await page.goto(link);
|
||||
}
|
||||
|
||||
/** Saves a relay host before the app loads, as if it had been paired in an earlier session. */
|
||||
export async function saveLinkedHostBeforeLoad(page: Page, host: LinkedHost): Promise<void> {
|
||||
await page.goto("/");
|
||||
const nowIso = new Date().toISOString();
|
||||
const connectionId = `relay:${host.relayEndpoint}`;
|
||||
const savedHost = {
|
||||
serverId: host.serverId,
|
||||
label: host.serverId,
|
||||
connections: [
|
||||
{
|
||||
id: connectionId,
|
||||
type: "relay",
|
||||
relayEndpoint: host.relayEndpoint,
|
||||
useTls: false,
|
||||
daemonPublicKeyB64: host.daemonPublicKeyB64,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: connectionId,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
await page.evaluate(({ key, hosts }) => localStorage.setItem(key, JSON.stringify(hosts)), {
|
||||
key: EXTRA_HOSTS_KEY,
|
||||
hosts: [savedHost],
|
||||
});
|
||||
}
|
||||
|
||||
export function hostConfirmation(page: Page): Locator {
|
||||
return page.getByTestId("host-confirmation");
|
||||
}
|
||||
|
||||
export async function expectHostConfirmationFor(page: Page, host: LinkedHost): Promise<void> {
|
||||
const sheet = hostConfirmation(page);
|
||||
await expect(sheet.getByText("Connect to this host?", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(sheet.getByTestId("host-confirmation-server-id")).toHaveText(host.serverId);
|
||||
await expect(sheet.getByTestId("host-confirmation-relay")).toHaveText(host.relayEndpoint);
|
||||
}
|
||||
|
||||
export async function cancelHostConfirmation(page: Page): Promise<void> {
|
||||
await hostConfirmation(page).getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(hostConfirmation(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function connectToConfirmedHost(page: Page): Promise<void> {
|
||||
await hostConfirmation(page).getByRole("button", { name: "Connect", exact: true }).click();
|
||||
await expect(hostConfirmation(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Checks the host picker on the settings page the app is already showing. */
|
||||
export async function expectHostInHostPicker(
|
||||
page: Page,
|
||||
serverId: string,
|
||||
expected: "listed" | "not listed",
|
||||
): Promise<void> {
|
||||
await page.getByTestId("settings-host-picker").click();
|
||||
await expect(page.getByTestId(`settings-host-picker-item-${serverId}`)).toHaveCount(
|
||||
expected === "listed" ? 1 : 0,
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
@@ -45,8 +45,7 @@ import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
||||
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
|
||||
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
|
||||
import { HostTrustPromptModal } from "@/hosts/host-trust-prompt";
|
||||
import { isHostTrustDeclinedError } from "@/runtime/host-trust";
|
||||
import { HostConfirmationSheet } from "@/hosts/host-confirmation-sheet";
|
||||
import {
|
||||
getIsElectronRuntime,
|
||||
HEADER_INNER_HEIGHT,
|
||||
@@ -608,7 +607,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<CommandCenter />
|
||||
<AddProjectFlowHost />
|
||||
<HostChooserModal />
|
||||
<HostTrustPromptModal />
|
||||
<HostConfirmationSheet />
|
||||
<ProviderSettingsHost />
|
||||
<WorkspaceSetupDialog />
|
||||
<KeyboardShortcutsDialog />
|
||||
@@ -712,7 +711,6 @@ function OfferLinkListener() {
|
||||
const result = await getHostRuntimeStore().importConnectionLink(url, "openProject");
|
||||
if (!cancelled && result.status === "connected") router.replace(buildOpenProjectRoute());
|
||||
} catch (error) {
|
||||
if (isHostTrustDeclinedError(error)) return;
|
||||
console.warn("[OfferLinkListener] Pairing link failed", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,10 +6,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||
import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { getHostRuntimeStore, type PasswordRequiredPairing } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { isHostTrustDeclinedError, OfferApproval } from "@/runtime/host-trust";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { PairLinkModal } from "@/components/pair-link-modal";
|
||||
|
||||
@@ -132,10 +131,7 @@ export default function PairScanScreen() {
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [isPairing, setIsPairing] = useState(false);
|
||||
const [scanError, setScanError] = useState<string | null>(null);
|
||||
const [passwordRetry, setPasswordRetry] = useState<{
|
||||
offerUrl: string;
|
||||
approval: OfferApproval;
|
||||
} | null>(null);
|
||||
const [passwordRequired, setPasswordRequired] = useState<PasswordRequiredPairing | null>(null);
|
||||
|
||||
const navigateToPairedHost = useCallback(
|
||||
(serverId: string) => {
|
||||
@@ -169,32 +165,24 @@ export default function PairScanScreen() {
|
||||
if (!offerUrl) return;
|
||||
|
||||
const store = getHostRuntimeStore();
|
||||
if (passwordRetry) return;
|
||||
if (passwordRequired) return;
|
||||
setIsPairing(true);
|
||||
setScanError(null);
|
||||
const approval = new OfferApproval();
|
||||
void store
|
||||
.importConnectionLink(
|
||||
offerUrl,
|
||||
source === "onboarding" ? "hostRoot" : "hostSettings",
|
||||
approval,
|
||||
)
|
||||
.importConnectionLink(offerUrl, source === "onboarding" ? "hostRoot" : "hostSettings")
|
||||
.then((outcome) => {
|
||||
if (outcome.status === "connected") navigateToPairedHost(outcome.serverId);
|
||||
else setPasswordRetry({ offerUrl, approval });
|
||||
if (outcome.status === "password_required") setPasswordRequired(outcome);
|
||||
return outcome;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isHostTrustDeclinedError(error)) return;
|
||||
setScanError(error instanceof Error ? error.message : String(error));
|
||||
})
|
||||
.catch((error) => setScanError(error instanceof Error ? error.message : String(error)))
|
||||
.finally(() => setIsPairing(false));
|
||||
},
|
||||
[isPairing, navigateToPairedHost, passwordRetry, source],
|
||||
[isPairing, navigateToPairedHost, passwordRequired, source],
|
||||
);
|
||||
|
||||
const handleRouterBack = useCallback(() => router.back(), [router]);
|
||||
const closePasswordModal = useCallback(() => setPasswordRetry(null), []);
|
||||
const closePasswordModal = useCallback(() => setPasswordRequired(null), []);
|
||||
const savePasswordPairing = useCallback(
|
||||
({ serverId }: { serverId: string }) => navigateToPairedHost(serverId),
|
||||
[navigateToPairedHost],
|
||||
@@ -266,10 +254,8 @@ export default function PairScanScreen() {
|
||||
)}
|
||||
</View>
|
||||
<PairLinkModal
|
||||
visible={passwordRetry !== null}
|
||||
initialUrl={passwordRetry?.offerUrl}
|
||||
initialPasswordRequired
|
||||
approval={passwordRetry?.approval}
|
||||
visible={passwordRequired !== null}
|
||||
passwordRequired={passwordRequired ?? undefined}
|
||||
onClose={closePasswordModal}
|
||||
onSaved={savePasswordPairing}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,6 @@ 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, OfferApproval } from "@/runtime/host-trust";
|
||||
import {
|
||||
parseConnectionUri,
|
||||
serializeConnectionUri,
|
||||
@@ -295,11 +294,23 @@ export interface AddHostModalProps {
|
||||
}
|
||||
|
||||
export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
|
||||
return (
|
||||
<AddHostModalContent
|
||||
key={String(visible)}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
onCancel={onCancel}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const daemons = useHosts();
|
||||
const { probeAndUpsertDirectConnection, probeAndUpsertConnectionFromOfferUrl } =
|
||||
useHostMutations();
|
||||
const { probeAndUpsertDirectConnection, beginLinkPairing } = useHostMutations();
|
||||
const [linkPairing] = useState(() => beginLinkPairing());
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -313,7 +324,6 @@ 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("");
|
||||
@@ -324,7 +334,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
setIsAdvancedOpen(false);
|
||||
setAdvancedUri("");
|
||||
advancedTarget.current = new PairingTargetTracker("", true);
|
||||
offerApproval.current = new OfferApproval();
|
||||
bumpInputResetKey();
|
||||
}, []);
|
||||
|
||||
@@ -380,18 +389,13 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
const { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl(
|
||||
relayUri,
|
||||
password || undefined,
|
||||
offerApproval.current,
|
||||
);
|
||||
const result = await linkPairing.submit(relayUri, password || undefined);
|
||||
if (result.status === "cancelled") return;
|
||||
const { profile, serverId, hostname } = result;
|
||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
|
||||
onSaved?.({ profile, serverId, hostname, isNewHost });
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
if (isHostTrustDeclinedError(error)) {
|
||||
return;
|
||||
}
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : directConnectionLabels.invalidConnection,
|
||||
);
|
||||
@@ -403,9 +407,9 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
daemons,
|
||||
directConnectionLabels.invalidConnection,
|
||||
handleClose,
|
||||
linkPairing,
|
||||
onSaved,
|
||||
password,
|
||||
probeAndUpsertConnectionFromOfferUrl,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -5,12 +5,11 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Link } from "lucide-react-native";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||
import { useHosts, useHostMutations, type PasswordRequiredPairing } from "@/runtime/host-runtime";
|
||||
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, 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";
|
||||
@@ -63,10 +62,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
|
||||
export interface PairLinkModalProps {
|
||||
visible: boolean;
|
||||
initialUrl?: string;
|
||||
initialPasswordRequired?: boolean;
|
||||
/** Approval already given for `initialUrl`, so its password retry does not ask again. */
|
||||
approval?: OfferApproval;
|
||||
/** Continues a confirmed pairing whose host asked for a password. */
|
||||
passwordRequired?: PasswordRequiredPairing;
|
||||
onClose: () => void;
|
||||
onCancel?: () => void;
|
||||
onSaved?: (result: {
|
||||
@@ -79,20 +76,16 @@ export interface PairLinkModalProps {
|
||||
|
||||
export function PairLinkModal({
|
||||
visible,
|
||||
initialUrl,
|
||||
initialPasswordRequired = false,
|
||||
approval,
|
||||
passwordRequired,
|
||||
onClose,
|
||||
onCancel,
|
||||
onSaved,
|
||||
}: PairLinkModalProps) {
|
||||
return (
|
||||
<PairLinkModalContent
|
||||
key={`${visible}:${initialUrl ?? ""}:${initialPasswordRequired}`}
|
||||
key={`${visible}:${passwordRequired?.link ?? ""}`}
|
||||
visible={visible}
|
||||
initialUrl={initialUrl}
|
||||
initialPasswordRequired={initialPasswordRequired}
|
||||
approval={approval}
|
||||
passwordRequired={passwordRequired}
|
||||
onClose={onClose}
|
||||
onCancel={onCancel}
|
||||
onSaved={onSaved}
|
||||
@@ -102,9 +95,7 @@ export function PairLinkModal({
|
||||
|
||||
function PairLinkModalContent({
|
||||
visible,
|
||||
initialUrl,
|
||||
initialPasswordRequired = false,
|
||||
approval,
|
||||
passwordRequired,
|
||||
onClose,
|
||||
onCancel,
|
||||
onSaved,
|
||||
@@ -112,17 +103,18 @@ function PairLinkModalContent({
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const daemons = useHosts();
|
||||
const { probeAndUpsertConnectionFromOfferUrl } = useHostMutations();
|
||||
const { beginLinkPairing } = useHostMutations();
|
||||
const [pairing] = useState(() => passwordRequired?.pairing ?? beginLinkPairing());
|
||||
const initialUrl = passwordRequired?.link;
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
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("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [needsPassword, setNeedsPassword] = useState(initialPasswordRequired);
|
||||
const [needsPassword, setNeedsPassword] = useState(passwordRequired !== undefined);
|
||||
const [passwordResetKey, resetPasswordInput] = useReducer((key: number) => key + 1, 0);
|
||||
|
||||
const clearInput = useCallback(() => {
|
||||
@@ -169,18 +161,13 @@ function PairLinkModalContent({
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setErrorMessage("");
|
||||
const { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl(
|
||||
raw,
|
||||
password || undefined,
|
||||
offerApproval.current,
|
||||
);
|
||||
const result = await pairing.submit(raw, password || undefined);
|
||||
if (result.status === "cancelled") return;
|
||||
const { profile, serverId, hostname } = result;
|
||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
|
||||
onSaved?.({ profile, serverId, hostname, isNewHost });
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
if (isHostTrustDeclinedError(error)) {
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : t("pairing.link.errors.unableToPair");
|
||||
setErrorMessage(message);
|
||||
@@ -195,16 +182,7 @@ function PairLinkModalContent({
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
daemons,
|
||||
handleClose,
|
||||
isMobile,
|
||||
isSaving,
|
||||
onSaved,
|
||||
password,
|
||||
t,
|
||||
probeAndUpsertConnectionFromOfferUrl,
|
||||
],
|
||||
[daemons, handleClose, isMobile, isSaving, onSaved, password, t, pairing],
|
||||
);
|
||||
|
||||
const handleChangeOfferUrl = useCallback((next: string) => {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { useHostConfirmation } from "@/runtime/host-runtime";
|
||||
|
||||
/** Shows the host confirmation the host runtime is waiting on. Mount once at the app root. */
|
||||
export function HostConfirmationSheet() {
|
||||
const { t } = useTranslation();
|
||||
const { pending, answer } = useHostConfirmation();
|
||||
|
||||
const cancel = useCallback(() => answer(false), [answer]);
|
||||
const connect = useCallback(() => answer(true), [answer]);
|
||||
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.hostConfirmation.title") }), [t]);
|
||||
|
||||
if (!pending) return null;
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet header={header} visible onClose={cancel} testID="host-confirmation">
|
||||
<Text style={styles.confirmText}>
|
||||
{pending.kind === "changedConnection"
|
||||
? t("pairing.hostConfirmation.descriptionChanged")
|
||||
: t("pairing.hostConfirmation.description")}
|
||||
</Text>
|
||||
<View style={settingsStyles.card}>
|
||||
<DetailRow
|
||||
label={t("pairing.hostConfirmation.hostLabel")}
|
||||
value={pending.serverId}
|
||||
testID="host-confirmation-server-id"
|
||||
/>
|
||||
<DetailRow
|
||||
label={t("pairing.hostConfirmation.fingerprintLabel")}
|
||||
value={pending.keyFingerprint}
|
||||
testID="host-confirmation-fingerprint"
|
||||
showBorder
|
||||
/>
|
||||
<DetailRow
|
||||
label={t("pairing.hostConfirmation.relayLabel")}
|
||||
value={pending.relayEndpoint}
|
||||
testID="host-confirmation-relay"
|
||||
showBorder
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.confirmActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
style={FLEX_1_STYLE}
|
||||
onPress={cancel}
|
||||
testID="host-confirmation-cancel"
|
||||
>
|
||||
{t("common.actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
style={FLEX_1_STYLE}
|
||||
onPress={connect}
|
||||
testID="host-confirmation-connect"
|
||||
>
|
||||
{t("pairing.hostConfirmation.connect")}
|
||||
</Button>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
value,
|
||||
showBorder = false,
|
||||
testID,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
showBorder?: boolean;
|
||||
testID?: string;
|
||||
}) {
|
||||
return (
|
||||
<View style={[settingsStyles.row, showBorder && settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{label}</Text>
|
||||
<Text style={settingsStyles.rowHint} selectable testID={testID}>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Same shape as the remove-host and remove-connection confirm sheets in
|
||||
// screens/settings/host-page.tsx.
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
confirmText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
confirmActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
marginTop: theme.spacing[4],
|
||||
},
|
||||
}));
|
||||
|
||||
const FLEX_1_STYLE = { flex: 1 };
|
||||
@@ -1,150 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { create } from "zustand";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { setHostTrustConfirmer, type HostTrustRequest } from "@/runtime/host-trust";
|
||||
|
||||
interface PendingTrustPrompt {
|
||||
id: number;
|
||||
request: HostTrustRequest;
|
||||
resolve: (trusted: boolean) => void;
|
||||
}
|
||||
|
||||
interface HostTrustPromptState {
|
||||
pending: PendingTrustPrompt | null;
|
||||
open: (input: Omit<PendingTrustPrompt, "id">) => void;
|
||||
settle: (trusted: boolean) => void;
|
||||
}
|
||||
|
||||
let nextPromptId = 1;
|
||||
|
||||
const useHostTrustPromptStore = create<HostTrustPromptState>((set, get) => ({
|
||||
pending: null,
|
||||
open: (input) => {
|
||||
// A newer prompt supersedes an unanswered one; decline the old request so
|
||||
// its caller unwinds rather than hangs.
|
||||
const previous = get().pending;
|
||||
if (previous) previous.resolve(false);
|
||||
set({ pending: { ...input, id: nextPromptId++ } });
|
||||
},
|
||||
settle: (trusted) => {
|
||||
const current = get().pending;
|
||||
if (!current) return;
|
||||
set({ pending: null });
|
||||
current.resolve(trusted);
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* Mount once at the app root. While mounted, pairing links ask for
|
||||
* confirmation through this sheet; unmounted, the runtime declines them.
|
||||
*/
|
||||
export function HostTrustPromptModal() {
|
||||
const { t } = useTranslation();
|
||||
const pending = useHostTrustPromptStore((state) => state.pending);
|
||||
const open = useHostTrustPromptStore((state) => state.open);
|
||||
const settle = useHostTrustPromptStore((state) => state.settle);
|
||||
|
||||
useEffect(() => {
|
||||
setHostTrustConfirmer(
|
||||
(request) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
open({ request, resolve });
|
||||
}),
|
||||
);
|
||||
return () => setHostTrustConfirmer(null);
|
||||
}, [open]);
|
||||
|
||||
const cancel = useCallback(() => settle(false), [settle]);
|
||||
const connect = useCallback(() => settle(true), [settle]);
|
||||
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.trust.title") }), [t]);
|
||||
|
||||
if (!pending) return null;
|
||||
|
||||
const { request } = pending;
|
||||
return (
|
||||
<AdaptiveModalSheet header={header} visible onClose={cancel} testID="host-trust">
|
||||
<Text style={styles.confirmText}>
|
||||
{request.isKnown ? t("pairing.trust.descriptionKnown") : t("pairing.trust.description")}
|
||||
</Text>
|
||||
<View style={settingsStyles.card}>
|
||||
<DetailRow
|
||||
label={t("pairing.trust.hostLabel")}
|
||||
value={request.serverId}
|
||||
testID="host-trust-server-id"
|
||||
/>
|
||||
<DetailRow
|
||||
label={t("pairing.trust.fingerprintLabel")}
|
||||
value={request.keyFingerprint}
|
||||
testID="host-trust-fingerprint"
|
||||
showBorder
|
||||
/>
|
||||
<DetailRow label={t("pairing.trust.endpointLabel")} value={request.endpoint} showBorder />
|
||||
</View>
|
||||
<View style={styles.confirmActions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
style={FLEX_1_STYLE}
|
||||
onPress={cancel}
|
||||
testID="host-trust-cancel"
|
||||
>
|
||||
{t("common.actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
style={FLEX_1_STYLE}
|
||||
onPress={connect}
|
||||
testID="host-trust-connect"
|
||||
>
|
||||
{t("pairing.trust.connect")}
|
||||
</Button>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
value,
|
||||
showBorder = false,
|
||||
testID,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
showBorder?: boolean;
|
||||
testID?: string;
|
||||
}) {
|
||||
return (
|
||||
<View style={[settingsStyles.row, showBorder && settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{label}</Text>
|
||||
<Text style={settingsStyles.rowHint} selectable testID={testID}>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Same shape as the remove-host and remove-connection confirm sheets in
|
||||
// screens/settings/host-page.tsx.
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
confirmText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
confirmActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
marginTop: theme.spacing[4],
|
||||
},
|
||||
}));
|
||||
|
||||
const FLEX_1_STYLE = { flex: 1 };
|
||||
@@ -1621,15 +1621,15 @@ export const ar: TranslationResources = {
|
||||
title: "كلمة المرور لـ {{host}}",
|
||||
label: "كلمة مرور المضيف",
|
||||
},
|
||||
trust: {
|
||||
hostConfirmation: {
|
||||
title: "الاتصال بهذا المضيف؟",
|
||||
description:
|
||||
"سيتمكن هذا المضيف من تشغيل تعليمات برمجية في هذا التطبيق والوصول إلى مضيفيك الآخرين المتصلين. اتصل فقط إذا كنت تعرفه.",
|
||||
descriptionKnown:
|
||||
descriptionChanged:
|
||||
"يغيّر هذا الرابط طريقة اتصالك بهذا المضيف. سيتمكن المضيف من تشغيل تعليمات برمجية في هذا التطبيق والوصول إلى مضيفيك الآخرين المتصلين. اتصل فقط إذا كنت تعرفه.",
|
||||
hostLabel: "المضيف",
|
||||
fingerprintLabel: "بصمة المفتاح",
|
||||
endpointLabel: "المُرحّل",
|
||||
relayLabel: "المُرحّل",
|
||||
connect: "اتصال",
|
||||
},
|
||||
connectionMethods: {
|
||||
|
||||
@@ -1646,15 +1646,15 @@ export const en = {
|
||||
title: "Password for {{host}}",
|
||||
label: "Host password",
|
||||
},
|
||||
trust: {
|
||||
hostConfirmation: {
|
||||
title: "Connect to this host?",
|
||||
description:
|
||||
"This host will be able to run code in this app and reach your other connected hosts. Only connect if you recognize it.",
|
||||
descriptionKnown:
|
||||
descriptionChanged:
|
||||
"This link changes how you connect to this host. The host will be able to run code in this app and reach your other connected hosts. Only connect if you recognize it.",
|
||||
hostLabel: "Host",
|
||||
fingerprintLabel: "Key fingerprint",
|
||||
endpointLabel: "Relay",
|
||||
relayLabel: "Relay",
|
||||
connect: "Connect",
|
||||
},
|
||||
connectionMethods: {
|
||||
|
||||
@@ -1667,15 +1667,15 @@ export const es: TranslationResources = {
|
||||
title: "Contraseña de {{host}}",
|
||||
label: "Contraseña del host",
|
||||
},
|
||||
trust: {
|
||||
hostConfirmation: {
|
||||
title: "¿Conectar con este host?",
|
||||
description:
|
||||
"Este host podrá ejecutar código en esta app y acceder a tus otros hosts conectados. Conéctate solo si lo reconoces.",
|
||||
descriptionKnown:
|
||||
descriptionChanged:
|
||||
"Este enlace cambia cómo te conectas a este host. El host podrá ejecutar código en esta app y acceder a tus otros hosts conectados. Conéctate solo si lo reconoces.",
|
||||
hostLabel: "Host",
|
||||
fingerprintLabel: "Huella de la clave",
|
||||
endpointLabel: "Relay",
|
||||
relayLabel: "Relay",
|
||||
connect: "Conectar",
|
||||
},
|
||||
connectionMethods: {
|
||||
|
||||
@@ -1671,15 +1671,15 @@ export const fr: TranslationResources = {
|
||||
title: "Mot de passe pour {{host}}",
|
||||
label: "Mot de passe de l’hôte",
|
||||
},
|
||||
trust: {
|
||||
hostConfirmation: {
|
||||
title: "Se connecter à cet hôte ?",
|
||||
description:
|
||||
"Cet hôte pourra exécuter du code dans cette application et accéder à vos autres hôtes connectés. Connectez-vous uniquement si vous le reconnaissez.",
|
||||
descriptionKnown:
|
||||
descriptionChanged:
|
||||
"Ce lien modifie la façon dont vous vous connectez à cet hôte. L'hôte pourra exécuter du code dans cette application et accéder à vos autres hôtes connectés. Connectez-vous uniquement si vous le reconnaissez.",
|
||||
hostLabel: "Hôte",
|
||||
fingerprintLabel: "Empreinte de la clé",
|
||||
endpointLabel: "Relais",
|
||||
relayLabel: "Relais",
|
||||
connect: "Se connecter",
|
||||
},
|
||||
connectionMethods: {
|
||||
|
||||
@@ -1638,15 +1638,15 @@ export const ja: TranslationResources = {
|
||||
title: "{{host}} のパスワード",
|
||||
label: "ホストのパスワード",
|
||||
},
|
||||
trust: {
|
||||
hostConfirmation: {
|
||||
title: "このホストに接続しますか?",
|
||||
description:
|
||||
"このホストはこのアプリ内でコードを実行し、接続中の他のホストにアクセスできるようになります。心当たりがある場合のみ接続してください。",
|
||||
descriptionKnown:
|
||||
descriptionChanged:
|
||||
"このリンクはこのホストへの接続方法を変更します。ホストはこのアプリ内でコードを実行し、接続中の他のホストにアクセスできるようになります。心当たりがある場合のみ接続してください。",
|
||||
hostLabel: "ホスト",
|
||||
fingerprintLabel: "鍵のフィンガープリント",
|
||||
endpointLabel: "リレー",
|
||||
relayLabel: "リレー",
|
||||
connect: "接続",
|
||||
},
|
||||
connectionMethods: {
|
||||
|
||||
@@ -1631,15 +1631,15 @@ export const ko: TranslationResources = {
|
||||
title: "{{host}}의 비밀번호",
|
||||
label: "호스트 비밀번호",
|
||||
},
|
||||
trust: {
|
||||
hostConfirmation: {
|
||||
title: "이 호스트에 연결할까요?",
|
||||
description:
|
||||
"이 호스트는 이 앱에서 코드를 실행하고 연결된 다른 호스트에 접근할 수 있게 됩니다. 알고 있는 호스트인 경우에만 연결하세요.",
|
||||
descriptionKnown:
|
||||
descriptionChanged:
|
||||
"이 링크는 이 호스트에 연결하는 방식을 변경합니다. 호스트는 이 앱에서 코드를 실행하고 연결된 다른 호스트에 접근할 수 있게 됩니다. 알고 있는 호스트인 경우에만 연결하세요.",
|
||||
hostLabel: "호스트",
|
||||
fingerprintLabel: "키 지문",
|
||||
endpointLabel: "릴레이",
|
||||
relayLabel: "릴레이",
|
||||
connect: "연결",
|
||||
},
|
||||
connectionMethods: {
|
||||
|
||||
@@ -1652,15 +1652,15 @@ export const ptBR: TranslationResources = {
|
||||
title: "Senha de {{host}}",
|
||||
label: "Senha do host",
|
||||
},
|
||||
trust: {
|
||||
hostConfirmation: {
|
||||
title: "Conectar a este host?",
|
||||
description:
|
||||
"Este host poderá executar código neste app e acessar seus outros hosts conectados. Conecte apenas se você o reconhecer.",
|
||||
descriptionKnown:
|
||||
descriptionChanged:
|
||||
"Este link muda como você se conecta a este host. O host poderá executar código neste app e acessar seus outros hosts conectados. Conecte apenas se você o reconhecer.",
|
||||
hostLabel: "Host",
|
||||
fingerprintLabel: "Impressão da chave",
|
||||
endpointLabel: "Relay",
|
||||
relayLabel: "Relay",
|
||||
connect: "Conectar",
|
||||
},
|
||||
connectionMethods: {
|
||||
|
||||
@@ -1650,15 +1650,15 @@ export const ru: TranslationResources = {
|
||||
title: "Пароль для {{host}}",
|
||||
label: "Пароль хоста",
|
||||
},
|
||||
trust: {
|
||||
hostConfirmation: {
|
||||
title: "Подключиться к этому хосту?",
|
||||
description:
|
||||
"Этот хост сможет выполнять код в приложении и обращаться к другим вашим подключённым хостам. Подключайтесь, только если узнаёте его.",
|
||||
descriptionKnown:
|
||||
descriptionChanged:
|
||||
"Эта ссылка меняет способ подключения к этому хосту. Хост сможет выполнять код в приложении и обращаться к другим вашим подключённым хостам. Подключайтесь, только если узнаёте его.",
|
||||
hostLabel: "Хост",
|
||||
fingerprintLabel: "Отпечаток ключа",
|
||||
endpointLabel: "Реле",
|
||||
relayLabel: "Реле",
|
||||
connect: "Подключить",
|
||||
},
|
||||
connectionMethods: {
|
||||
|
||||
@@ -1604,14 +1604,14 @@ export const zhCN: TranslationResources = {
|
||||
title: "{{host}} 的密码",
|
||||
label: "主机密码",
|
||||
},
|
||||
trust: {
|
||||
hostConfirmation: {
|
||||
title: "连接到此主机?",
|
||||
description: "此主机将能在此应用中运行代码,并访问你其他已连接的主机。仅在你认识它时才连接。",
|
||||
descriptionKnown:
|
||||
descriptionChanged:
|
||||
"此链接会改变你连接此主机的方式。该主机将能在此应用中运行代码,并访问你其他已连接的主机。仅在你认识它时才连接。",
|
||||
hostLabel: "主机",
|
||||
fingerprintLabel: "密钥指纹",
|
||||
endpointLabel: "中继",
|
||||
relayLabel: "中继",
|
||||
connect: "连接",
|
||||
},
|
||||
connectionMethods: {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
|
||||
import {
|
||||
hostHasConnection,
|
||||
relayConnectionFromOffer,
|
||||
type HostProfile,
|
||||
} from "@/types/host-connection";
|
||||
|
||||
/**
|
||||
* The question shown before a pairing link adds a host that is not saved, or
|
||||
* changes the key, relay, or TLS setting of a saved one.
|
||||
*/
|
||||
export interface HostConfirmationRequest {
|
||||
serverId: string;
|
||||
/** Grouped daemon public key the user can compare with the one their daemon shows. */
|
||||
keyFingerprint: string;
|
||||
relayEndpoint: string;
|
||||
kind: "newHost" | "changedConnection";
|
||||
}
|
||||
|
||||
interface PendingConfirmation {
|
||||
request: HostConfirmationRequest;
|
||||
resolve: (approved: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the one host confirmation the app is waiting on. The host runtime asks
|
||||
* through `confirmLink`; the view reads `getPending` and replies with `answer`.
|
||||
* A question waits until it is answered, whether or not a view is showing it.
|
||||
*/
|
||||
export class HostConfirmations {
|
||||
private pending: PendingConfirmation | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
getPending(): HostConfirmationRequest | null {
|
||||
return this.pending?.request ?? null;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
answer(approved: boolean): void {
|
||||
const current = this.pending;
|
||||
if (!current) return;
|
||||
this.pending = null;
|
||||
this.emit();
|
||||
current.resolve(approved);
|
||||
}
|
||||
|
||||
/** Resolves true without asking when the link matches a saved host exactly. */
|
||||
confirmLink(offer: ConnectionOffer, savedHosts: HostProfile[]): Promise<boolean> {
|
||||
const connection = relayConnectionFromOffer(offer);
|
||||
const savedHost = savedHosts.find((host) => host.serverId === offer.serverId);
|
||||
if (savedHost && hostHasConnection(savedHost, connection)) return Promise.resolve(true);
|
||||
return this.ask({
|
||||
serverId: offer.serverId,
|
||||
keyFingerprint: formatDaemonKeyFingerprint(connection.daemonPublicKeyB64),
|
||||
relayEndpoint: connection.relayEndpoint,
|
||||
kind: savedHost ? "changedConnection" : "newHost",
|
||||
});
|
||||
}
|
||||
|
||||
private ask(request: HostConfirmationRequest): Promise<boolean> {
|
||||
// One question at a time: a newer one cancels the unanswered one.
|
||||
this.pending?.resolve(false);
|
||||
return new Promise<boolean>((resolve) => {
|
||||
this.pending = { request, resolve };
|
||||
this.emit();
|
||||
});
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the key material itself (not a re-hash) in groups of four, so two
|
||||
* different keys never show the same value.
|
||||
*/
|
||||
function formatDaemonKeyFingerprint(daemonPublicKeyB64: string): string {
|
||||
const normalized = daemonPublicKeyB64.replace(/[^A-Za-z0-9]/g, "");
|
||||
const group = (value: string): string => value.replace(/(.{4})/g, "$1 ").trim();
|
||||
if (normalized.length <= 16) return group(normalized);
|
||||
return `${group(normalized.slice(0, 8))} … ${group(normalized.slice(-8))}`;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AppStateStatus } from "react-native";
|
||||
import {
|
||||
bindHostRuntimeAppState,
|
||||
@@ -31,12 +31,7 @@ import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store";
|
||||
|
||||
import { subscriptionFixture } from "./subscription-fixture";
|
||||
import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-credential";
|
||||
import {
|
||||
setHostTrustConfirmer,
|
||||
isHostTrustDeclinedError,
|
||||
OfferApproval,
|
||||
type HostTrustRequest,
|
||||
} from "./host-trust";
|
||||
import type { HostConfirmationRequest } from "./host-confirmation";
|
||||
|
||||
it("requests the managed connection credential through desktop main without a web hint", async () => {
|
||||
const requests: string[] = [];
|
||||
@@ -277,16 +272,8 @@ class FakeDaemonClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Offer imports require host-trust approval. Existing tests exercise the
|
||||
// post-approval flow, so grant approval by default; the trust-gate tests below
|
||||
// override this to deny.
|
||||
beforeEach(() => {
|
||||
setHostTrustConfirmer(async () => true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
setHostTrustConfirmer(null);
|
||||
delete (globalThis as Record<string, unknown>).__PASEO_INITIAL_DAEMON_CONNECTION__;
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
});
|
||||
@@ -521,6 +508,56 @@ function makeConnectedProbeClient(latencyMs: number): FakeDaemonClient {
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers every host confirmation the store asks, the way the sheet does, and
|
||||
* records each request.
|
||||
*/
|
||||
function answerHostConfirmations(
|
||||
store: HostRuntimeStore,
|
||||
answer: boolean | ((request: HostConfirmationRequest, index: number) => boolean),
|
||||
): HostConfirmationRequest[] {
|
||||
const requests: HostConfirmationRequest[] = [];
|
||||
store.subscribeHostConfirmation(() => {
|
||||
const pending = store.getPendingHostConfirmation();
|
||||
if (!pending) return;
|
||||
requests.push(pending);
|
||||
store.answerHostConfirmation(
|
||||
typeof answer === "function" ? answer(pending, requests.length - 1) : answer,
|
||||
);
|
||||
});
|
||||
return requests;
|
||||
}
|
||||
|
||||
function createPairingStore(
|
||||
input: {
|
||||
hostname?: string;
|
||||
password?: string;
|
||||
connectAttempts?: string[];
|
||||
serverIdForHost?: (host: HostProfile) => string;
|
||||
storage?: HostRuntimeStorage;
|
||||
} = {},
|
||||
): HostRuntimeStore {
|
||||
return new HostRuntimeStore({
|
||||
storage: input.storage ?? createMemoryHostRuntimeStorage(),
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host, connection }) => {
|
||||
input.connectAttempts?.push(connection.id);
|
||||
if (input.password && host.password !== input.password) {
|
||||
throw new DaemonAuthenticationError("password_required");
|
||||
}
|
||||
return {
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: input.serverIdForHost?.(host) ?? host.serverId,
|
||||
hostname: input.hostname ?? "paired",
|
||||
};
|
||||
},
|
||||
getClientId: async () => "cid_pairing",
|
||||
readInitialConnectionHint: () => null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createMemoryHostRuntimeStorage(entries: Record<string, string> = {}): HostRuntimeStorage {
|
||||
const values = new Map(Object.entries(entries));
|
||||
return {
|
||||
@@ -3561,19 +3598,10 @@ describe("HostRuntimeStore", () => {
|
||||
});
|
||||
|
||||
it("uses the advertised hostname when adding a relay host from a pairing offer", async () => {
|
||||
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: host.label ?? null,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
const store = createPairingStore({ hostname: "mbp" });
|
||||
answerHostConfirmations(store, true);
|
||||
|
||||
await store.upsertConnectionFromOffer(makeOffer(), "mbp");
|
||||
await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer()));
|
||||
|
||||
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
|
||||
expect(pairedHost?.label).toBe("mbp");
|
||||
@@ -3582,27 +3610,14 @@ describe("HostRuntimeStore", () => {
|
||||
});
|
||||
|
||||
it("stores relay TLS from a pairing offer", async () => {
|
||||
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: host.label ?? null,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
const store = createPairingStore();
|
||||
answerHostConfirmations(store, true);
|
||||
|
||||
await store.upsertConnectionFromOffer(
|
||||
makeOffer({
|
||||
relay: {
|
||||
endpoint: "relay.example.com:443",
|
||||
useTls: true,
|
||||
},
|
||||
}),
|
||||
"tls relay",
|
||||
);
|
||||
await store
|
||||
.beginLinkPairing()
|
||||
.submit(
|
||||
encodeOfferUrl(makeOffer({ relay: { endpoint: "relay.example.com:443", useTls: true } })),
|
||||
);
|
||||
|
||||
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
|
||||
expect(pairedHost?.connections).toEqual([
|
||||
@@ -3619,17 +3634,8 @@ describe("HostRuntimeStore", () => {
|
||||
});
|
||||
|
||||
it("uses TLS for old pairing URLs that omit relay TLS on port 443", async () => {
|
||||
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: host.label ?? null,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
const store = createPairingStore();
|
||||
answerHostConfirmations(store, true);
|
||||
const oldPairingUrl = encodeOfferUrl({
|
||||
v: 2,
|
||||
serverId: "srv_offer",
|
||||
@@ -3637,7 +3643,7 @@ describe("HostRuntimeStore", () => {
|
||||
relay: { endpoint: "relay.paseo.sh:443" },
|
||||
});
|
||||
|
||||
await store.upsertConnectionFromOfferUrl(oldPairingUrl, "old relay");
|
||||
await store.importConnectionLink(oldPairingUrl, "openProject");
|
||||
|
||||
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
|
||||
expect(pairedHost?.connections).toEqual([
|
||||
@@ -3654,199 +3660,236 @@ describe("HostRuntimeStore", () => {
|
||||
});
|
||||
|
||||
it("probes a pairing link immediately and saves only after admission", async () => {
|
||||
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 host",
|
||||
};
|
||||
},
|
||||
getClientId: async () => "cid_pairing",
|
||||
},
|
||||
});
|
||||
const store = createPairingStore({ password: "correct-password" });
|
||||
answerHostConfirmations(store, true);
|
||||
const offerUrl = encodeOfferUrl(makeOffer());
|
||||
await expect(store.probeAndUpsertConnectionFromOfferUrl(offerUrl)).rejects.toThrow(
|
||||
"Password required",
|
||||
);
|
||||
const pairing = store.beginLinkPairing();
|
||||
|
||||
await expect(pairing.submit(offerUrl)).rejects.toThrow("Password required");
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
const result = await store.probeAndUpsertConnectionFromOfferUrl(offerUrl, "correct-password");
|
||||
expect(result.serverId).toBe("srv_offer");
|
||||
const result = await pairing.submit(offerUrl, "correct-password");
|
||||
expect(result).toMatchObject({ status: "connected", serverId: "srv_offer" });
|
||||
expect(store.getHosts()[0]?.password).toBe("correct-password");
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("does not add or connect a host from an offer link when trust is declined", async () => {
|
||||
let connectAttempts = 0;
|
||||
const requests: HostTrustRequest[] = [];
|
||||
setHostTrustConfirmer(async (request) => {
|
||||
requests.push(request);
|
||||
return false;
|
||||
});
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host }) => {
|
||||
connectAttempts += 1;
|
||||
return {
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label ?? null,
|
||||
};
|
||||
},
|
||||
getClientId: async () => "cid_trust",
|
||||
},
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
});
|
||||
it("asks before a pairing link adds a new host, and Cancel saves and connects nothing", async () => {
|
||||
const connectAttempts: string[] = [];
|
||||
const store = createPairingStore({ connectAttempts });
|
||||
const requests = answerHostConfirmations(store, false);
|
||||
const offerUrl = encodeOfferUrl(makeOffer());
|
||||
const relayLink = "relay://relay.example:443/srv_relay?key=AAAA&ssl=true";
|
||||
|
||||
// The auto-import path (deep link / QR).
|
||||
await expect(store.importConnectionLink(offerUrl, "openProject")).rejects.toSatisfy(
|
||||
isHostTrustDeclinedError,
|
||||
);
|
||||
// The paste-link / add-host path.
|
||||
await expect(store.probeAndUpsertConnectionFromOfferUrl(offerUrl)).rejects.toSatisfy(
|
||||
isHostTrustDeclinedError,
|
||||
);
|
||||
// `#offer=` in the app URL, then a QR scan, then the pair-link and add-host modals.
|
||||
await expect(store.importConnectionLink(offerUrl, "openProject")).resolves.toEqual({
|
||||
status: "cancelled",
|
||||
});
|
||||
await expect(store.importConnectionLink(offerUrl, "hostRoot")).resolves.toEqual({
|
||||
status: "cancelled",
|
||||
});
|
||||
await expect(store.beginLinkPairing().submit(offerUrl)).resolves.toEqual({
|
||||
status: "cancelled",
|
||||
});
|
||||
await expect(store.beginLinkPairing().submit(relayLink)).resolves.toEqual({
|
||||
status: "cancelled",
|
||||
});
|
||||
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
expect(connectAttempts).toBe(0);
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(requests[0]?.serverId).toBe("srv_offer");
|
||||
expect(requests[0]?.keyFingerprint).toContain("pk");
|
||||
expect(requests).toEqual([
|
||||
...Array.from({ length: 3 }, () => ({
|
||||
serverId: "srv_offer",
|
||||
keyFingerprint: "pkte stof fer",
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
kind: "newHost",
|
||||
})),
|
||||
{
|
||||
serverId: "srv_relay",
|
||||
keyFingerprint: "AAAA",
|
||||
relayEndpoint: "relay.example:443",
|
||||
kind: "newHost",
|
||||
},
|
||||
]);
|
||||
expect(store.getHosts()).toEqual([]);
|
||||
expect(connectAttempts).toEqual([]);
|
||||
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 store = createPairingStore();
|
||||
const requests = answerHostConfirmations(store, true);
|
||||
const offerUrl = encodeOfferUrl(makeOffer());
|
||||
|
||||
await store.probeAndUpsertConnectionFromOfferUrl(offerUrl);
|
||||
await store.beginLinkPairing().submit(offerUrl);
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0]?.isKnown).toBe(false);
|
||||
|
||||
await store.probeAndUpsertConnectionFromOfferUrl(offerUrl);
|
||||
await store.importConnectionLink(offerUrl, "openProject");
|
||||
await expect(store.importConnectionLink(offerUrl, "openProject")).resolves.toEqual({
|
||||
status: "connected",
|
||||
serverId: "srv_offer",
|
||||
});
|
||||
await expect(store.importConnectionLink(offerUrl, "hostRoot")).resolves.toEqual({
|
||||
status: "connected",
|
||||
serverId: "srv_offer",
|
||||
});
|
||||
await expect(store.beginLinkPairing().submit(offerUrl)).resolves.toMatchObject({
|
||||
status: "connected",
|
||||
});
|
||||
|
||||
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];
|
||||
it("asks when a pairing link changes a saved host's key, relay, or TLS setting", async () => {
|
||||
const store = createPairingStore();
|
||||
const requests = answerHostConfirmations(store, (_request, index) => index === 0);
|
||||
await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer()));
|
||||
const savedHosts = store.getHosts();
|
||||
|
||||
const changedKeyUrl = encodeOfferUrl(makeOffer({ daemonPublicKeyB64: "pk_other_key" }));
|
||||
await expect(store.probeAndUpsertConnectionFromOfferUrl(changedKeyUrl)).rejects.toSatisfy(
|
||||
isHostTrustDeclinedError,
|
||||
);
|
||||
const changedRelayUrl = encodeOfferUrl(
|
||||
const changedLinks = [
|
||||
makeOffer({ daemonPublicKeyB64: "pk_other_key" }),
|
||||
makeOffer({ relay: { endpoint: "relay.example:443", useTls: false } }),
|
||||
);
|
||||
await expect(store.importConnectionLink(changedRelayUrl, "openProject")).rejects.toSatisfy(
|
||||
isHostTrustDeclinedError,
|
||||
);
|
||||
makeOffer({ relay: { endpoint: "relay.paseo.sh:443", useTls: true } }),
|
||||
].map(encodeOfferUrl);
|
||||
for (const link of changedLinks) {
|
||||
await expect(store.importConnectionLink(link, "openProject")).resolves.toEqual({
|
||||
status: "cancelled",
|
||||
});
|
||||
await expect(store.beginLinkPairing().submit(link)).resolves.toEqual({
|
||||
status: "cancelled",
|
||||
});
|
||||
}
|
||||
|
||||
expect(requests.map((request) => request.isKnown)).toEqual([false, true, true]);
|
||||
expect(store.getHosts()).toEqual([savedHost]);
|
||||
expect(requests.map((request) => request.kind)).toEqual([
|
||||
"newHost",
|
||||
...Array.from({ length: 6 }, () => "changedConnection"),
|
||||
]);
|
||||
expect(store.getHosts()).toEqual(savedHosts);
|
||||
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(),
|
||||
});
|
||||
it("asks once per pairing flow and link when the host asks for a password", async () => {
|
||||
const store = createPairingStore({ password: "correct-password" });
|
||||
const requests = answerHostConfirmations(store, (_request, index) => index < 2);
|
||||
const offerUrl = encodeOfferUrl(makeOffer());
|
||||
const approval = new OfferApproval();
|
||||
const pairing = store.beginLinkPairing();
|
||||
|
||||
await expect(store.importConnectionLink(offerUrl, "hostRoot", approval)).resolves.toEqual({
|
||||
status: "password_required",
|
||||
});
|
||||
await expect(pairing.submit(offerUrl)).rejects.toThrow("Password required");
|
||||
await expect(pairing.submit(offerUrl, "wrong-password")).rejects.toThrow();
|
||||
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();
|
||||
// A new pairing flow for the same link asks again.
|
||||
await expect(store.beginLinkPairing().submit(offerUrl, "wrong-password")).rejects.toThrow();
|
||||
expect(requests).toHaveLength(2);
|
||||
|
||||
await store.probeAndUpsertConnectionFromOfferUrl(offerUrl, "correct-password", approval);
|
||||
await expect(pairing.submit(offerUrl, "correct-password")).resolves.toMatchObject({
|
||||
status: "connected",
|
||||
});
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(store.getHosts()[0]).toMatchObject({
|
||||
serverId: "srv_offer",
|
||||
password: "correct-password",
|
||||
});
|
||||
|
||||
// A different link in the same flow asks again.
|
||||
const otherKeyUrl = encodeOfferUrl(makeOffer({ daemonPublicKeyB64: "pk_other_key" }));
|
||||
await expect(pairing.submit(otherKeyUrl, "correct-password")).resolves.toEqual({
|
||||
status: "cancelled",
|
||||
});
|
||||
expect(requests).toHaveLength(3);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("continues a scanned link's password retry without asking again", async () => {
|
||||
const store = createPairingStore({ password: "correct-password" });
|
||||
const requests = answerHostConfirmations(store, true);
|
||||
const link = "relay://relay.example:443/srv_pair?key=AAAA&ssl=true";
|
||||
|
||||
const outcome = await store.importConnectionLink(link, "hostRoot");
|
||||
expect(outcome).toMatchObject({ status: "password_required", link });
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
if (outcome.status !== "password_required") throw new Error("expected password_required");
|
||||
|
||||
await outcome.pairing.submit(outcome.link, "correct-password");
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(store.getHosts()[0]).toMatchObject({
|
||||
serverId: "srv_pair",
|
||||
password: "correct-password",
|
||||
});
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("keeps a host confirmation pending until it is answered, and a newer one cancels it", async () => {
|
||||
const store = createPairingStore();
|
||||
const first = store.importConnectionLink(encodeOfferUrl(makeOffer()), "openProject");
|
||||
await vi.waitFor(() =>
|
||||
expect(store.getPendingHostConfirmation()).toMatchObject({ serverId: "srv_offer" }),
|
||||
);
|
||||
|
||||
const second = store.importConnectionLink(
|
||||
encodeOfferUrl(makeOffer({ serverId: "srv_second" })),
|
||||
"openProject",
|
||||
);
|
||||
await expect(first).resolves.toEqual({ status: "cancelled" });
|
||||
await vi.waitFor(() =>
|
||||
expect(store.getPendingHostConfirmation()).toMatchObject({ serverId: "srv_second" }),
|
||||
);
|
||||
|
||||
store.answerHostConfirmation(true);
|
||||
await expect(second).resolves.toEqual({ status: "connected", serverId: "srv_second" });
|
||||
expect(store.getPendingHostConfirmation()).toBeNull();
|
||||
expect(store.getHosts().map((host) => host.serverId)).toEqual(["srv_second"]);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("waits for saved hosts to load before deciding whether a pairing link asks", async () => {
|
||||
const savedHost = makeHost({
|
||||
serverId: "srv_offer",
|
||||
connections: [
|
||||
{
|
||||
id: "relay:relay.paseo.sh:443",
|
||||
type: "relay",
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
useTls: false,
|
||||
daemonPublicKeyB64: "pk_test_offer",
|
||||
},
|
||||
],
|
||||
});
|
||||
const store = createPairingStore({
|
||||
storage: createMemoryHostRuntimeStorage({
|
||||
"@paseo:daemon-registry": JSON.stringify([savedHost]),
|
||||
"@paseo:e2e": "1",
|
||||
}),
|
||||
});
|
||||
const requests = answerHostConfirmations(store, true);
|
||||
|
||||
await expect(
|
||||
store.importConnectionLink(encodeOfferUrl(makeOffer()), "openProject"),
|
||||
).resolves.toEqual({ status: "connected", serverId: "srv_offer" });
|
||||
|
||||
expect(requests).toEqual([]);
|
||||
expect(store.getHosts().map((host) => host.serverId)).toEqual(["srv_offer"]);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("adds hosts by address and the default localhost without asking", async () => {
|
||||
const store = createPairingStore({
|
||||
serverIdForHost: (host) =>
|
||||
host.connections[0]?.type === "directTcp" ? `srv_${host.connections[0].endpoint}` : "",
|
||||
});
|
||||
const requests = answerHostConfirmations(store, false);
|
||||
|
||||
await store.boot();
|
||||
await store.probeAndUpsertDirectConnection({ endpoint: "10.0.0.5:6767" });
|
||||
|
||||
expect(requests).toEqual([]);
|
||||
expect(store.getHosts().map((host) => host.serverId)).toEqual([
|
||||
"srv_localhost:6767",
|
||||
"srv_10.0.0.5:6767",
|
||||
]);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("preserves the existing host label when re-pairing an existing relay host", async () => {
|
||||
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: host.label ?? null,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
});
|
||||
const store = createPairingStore({ hostname: "mbp" });
|
||||
answerHostConfirmations(store, true);
|
||||
|
||||
await store.upsertRelayConnection({
|
||||
serverId: "srv_offer",
|
||||
@@ -3855,7 +3898,7 @@ describe("HostRuntimeStore", () => {
|
||||
label: "Custom name",
|
||||
});
|
||||
|
||||
await store.upsertConnectionFromOffer(makeOffer(), "mbp");
|
||||
await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer()));
|
||||
|
||||
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
|
||||
expect(pairedHost?.label).toBe("Custom name");
|
||||
@@ -4131,35 +4174,6 @@ describe("HostRuntimeStore initial connection hint bootstrap", () => {
|
||||
expect(probeAttempts).toBe(attemptsBeforeRetry);
|
||||
store.syncHosts([]);
|
||||
});
|
||||
it("imports a pairing link after a password retry in the add flow", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
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_link_import",
|
||||
},
|
||||
});
|
||||
const link = "relay://relay.example:443/srv_pair?key=AAAA&ssl=true";
|
||||
await expect(store.importConnectionLink(link, "hostRoot")).resolves.toEqual({
|
||||
status: "password_required",
|
||||
});
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
await store.probeAndUpsertConnectionFromOfferUrl(link, "correct-password");
|
||||
expect(store.getHosts()[0]).toMatchObject({
|
||||
serverId: "srv_pair",
|
||||
password: "correct-password",
|
||||
});
|
||||
store.syncHosts([]);
|
||||
});
|
||||
it("imports a deep link without waiting for admission, then leaves a rejected saved host red", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
@@ -4171,6 +4185,7 @@ describe("HostRuntimeStore initial connection hint bootstrap", () => {
|
||||
getClientId: async () => "cid_deep_link",
|
||||
},
|
||||
});
|
||||
answerHostConfirmations(store, true);
|
||||
const link = "relay://relay.example:443/srv_deep?key=AAAA&ssl=true";
|
||||
await expect(store.importConnectionLink(link, "openProject")).resolves.toEqual({
|
||||
status: "connected",
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
normalizeStoredHostProfile,
|
||||
upsertHostConnectionInProfiles,
|
||||
registryHasConnection,
|
||||
hostHasConnection,
|
||||
relayConnectionFromOffer,
|
||||
StoredHostRegistrySchema,
|
||||
type HostConnection,
|
||||
type HostProfile,
|
||||
@@ -30,12 +30,7 @@ import {
|
||||
} from "@/utils/daemon-endpoints";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { ConnectionOfferSchema, type ConnectionOffer } from "@getpaseo/protocol/connection-offer";
|
||||
import {
|
||||
confirmHostTrust,
|
||||
hostTrustRequestFromOffer,
|
||||
HostTrustDeclinedError,
|
||||
type OfferApproval,
|
||||
} from "./host-trust";
|
||||
import { HostConfirmations, type HostConfirmationRequest } from "./host-confirmation";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { connectToDaemon, getConnectionAuthFailureReason } from "@/utils/test-daemon-connection";
|
||||
@@ -96,6 +91,27 @@ export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "of
|
||||
export type PairingNavigationTarget = "openProject" | "hostRoot" | "hostSettings";
|
||||
export type HostRegistryStatus = "loading" | "ready";
|
||||
|
||||
/** One pairing flow started with `HostRuntimeStore.beginLinkPairing`. */
|
||||
export interface LinkPairing {
|
||||
/** Probes the linked host and saves it. Connection and password errors throw. */
|
||||
submit(link: string, password?: string): Promise<LinkPairingResult>;
|
||||
}
|
||||
|
||||
export type LinkPairingResult =
|
||||
| { status: "connected"; profile: HostProfile; serverId: string; hostname: string | null }
|
||||
| { status: "cancelled" };
|
||||
|
||||
/** A pairing link the user confirmed whose host then asked for a password. */
|
||||
export interface PasswordRequiredPairing {
|
||||
link: string;
|
||||
pairing: LinkPairing;
|
||||
}
|
||||
|
||||
export type ConnectionLinkImport =
|
||||
| { status: "connected"; serverId: string }
|
||||
| ({ status: "password_required" } & PasswordRequiredPairing)
|
||||
| { status: "cancelled" };
|
||||
|
||||
export type ActiveConnection =
|
||||
| { type: "directTcp"; endpoint: string; display: string }
|
||||
| { type: "directSocket"; endpoint: string; display: "socket" }
|
||||
@@ -1422,6 +1438,13 @@ function readConfiguredLocalDaemonOverride(): string | null {
|
||||
return value && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function isSameLinkedConnection(left: ConnectionOffer, right: ConnectionOffer): boolean {
|
||||
return (
|
||||
left.serverId === right.serverId &&
|
||||
equal(relayConnectionFromOffer(left), relayConnectionFromOffer(right))
|
||||
);
|
||||
}
|
||||
|
||||
function parseOfferConnectionUrl(input: string): { offer: ConnectionOffer; password?: string } {
|
||||
if (input.trim().startsWith("relay://") || input.includes("#connect=")) {
|
||||
return parseRelayConnectionUri(input);
|
||||
@@ -1435,19 +1458,6 @@ 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;
|
||||
}
|
||||
@@ -1496,6 +1506,8 @@ export class HostRuntimeStore {
|
||||
private timelineReplicaByServer = new Map<string, TimelineReplica>();
|
||||
private configuredOverrideBootstrapInFlight: Promise<void> | null = null;
|
||||
private bootPromise: Promise<void> | null = null;
|
||||
private registryLoad: Promise<void> | null = null;
|
||||
private readonly hostConfirmations = new HostConfirmations();
|
||||
private storage: HostRuntimeStorage;
|
||||
private replicaCache: ReplicaCache;
|
||||
private readonly revokePushNotifications: typeof revokePushNotifications;
|
||||
@@ -1546,8 +1558,7 @@ export class HostRuntimeStore {
|
||||
|
||||
private async runBoot(): Promise<void> {
|
||||
const override = readConfiguredLocalDaemonOverride();
|
||||
await this.loadFromStorage();
|
||||
this.markHostRegistryLoaded();
|
||||
await this.loadRegistry();
|
||||
|
||||
let isE2E: string | null = null;
|
||||
try {
|
||||
@@ -1580,6 +1591,13 @@ export class HostRuntimeStore {
|
||||
}
|
||||
}
|
||||
|
||||
private loadRegistry(): Promise<void> {
|
||||
if (!this.registryLoad) {
|
||||
this.registryLoad = this.loadFromStorage().then(() => this.markHostRegistryLoaded());
|
||||
}
|
||||
return this.registryLoad;
|
||||
}
|
||||
|
||||
private async loadFromStorage(): Promise<void> {
|
||||
let shouldPersistHosts = false;
|
||||
let profiles: HostProfile[] = [];
|
||||
@@ -1939,65 +1957,91 @@ export class HostRuntimeStore {
|
||||
return profile;
|
||||
}
|
||||
|
||||
async upsertConnectionFromOffer(
|
||||
// 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, relay, or
|
||||
// TLS setting. The answer needs the saved hosts, so it waits for them to load.
|
||||
private async confirmLink(offer: ConnectionOffer): Promise<boolean> {
|
||||
await this.loadRegistry();
|
||||
return this.hostConfirmations.confirmLink(offer, this.hosts);
|
||||
}
|
||||
|
||||
getPendingHostConfirmation(): HostConfirmationRequest | null {
|
||||
return this.hostConfirmations.getPending();
|
||||
}
|
||||
|
||||
subscribeHostConfirmation(listener: () => void): () => void {
|
||||
return this.hostConfirmations.subscribe(listener);
|
||||
}
|
||||
|
||||
answerHostConfirmation(approved: boolean): void {
|
||||
this.hostConfirmations.answer(approved);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts one pairing flow, such as one open pairing modal. Once the user
|
||||
* confirms a link, submitting the same link again in this flow (the
|
||||
* password retry) does not ask again. A new flow asks again.
|
||||
*/
|
||||
beginLinkPairing(): LinkPairing {
|
||||
let approvedOffer: ConnectionOffer | null = null;
|
||||
return {
|
||||
submit: async (link, password) => {
|
||||
const { offer, password: linkPassword } = parseOfferConnectionUrl(link);
|
||||
if (!approvedOffer || !isSameLinkedConnection(approvedOffer, offer)) {
|
||||
if (!(await this.confirmLink(offer))) return { status: "cancelled" };
|
||||
approvedOffer = offer;
|
||||
}
|
||||
const result = await this.probeAndUpsertOffer(offer, password ?? linkPassword);
|
||||
return { status: "connected", ...result };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async importConnectionLink(
|
||||
url: string,
|
||||
target: PairingNavigationTarget,
|
||||
): Promise<ConnectionLinkImport> {
|
||||
if (target === "openProject") {
|
||||
const { offer, password } = parseOfferConnectionUrl(url);
|
||||
if (!(await this.confirmLink(offer))) return { status: "cancelled" };
|
||||
const profile = await this.upsertConnectionFromOffer(offer, undefined, password);
|
||||
return { status: "connected", serverId: profile.serverId };
|
||||
}
|
||||
const pairing = this.beginLinkPairing();
|
||||
try {
|
||||
const result = await pairing.submit(url);
|
||||
if (result.status === "cancelled") return result;
|
||||
return { status: "connected", serverId: result.serverId };
|
||||
} catch (error) {
|
||||
if (!getConnectionAuthFailureReason(error)) throw error;
|
||||
return { status: "password_required", link: url, pairing };
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertConnectionFromOffer(
|
||||
offer: ConnectionOffer,
|
||||
label?: string,
|
||||
password?: string,
|
||||
): Promise<HostProfile> {
|
||||
// COMPAT(oldRelayOfferTls): added in v0.1.73, remove after 2026-11-10.
|
||||
const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint);
|
||||
const connection = relayConnectionFromOffer(offer);
|
||||
return this.upsertRelayConnection({
|
||||
serverId: offer.serverId,
|
||||
relayEndpoint: offer.relay.endpoint,
|
||||
useTls,
|
||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||
relayEndpoint: connection.relayEndpoint,
|
||||
useTls: connection.useTls,
|
||||
daemonPublicKeyB64: connection.daemonPublicKeyB64,
|
||||
label,
|
||||
password,
|
||||
});
|
||||
}
|
||||
|
||||
// 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(
|
||||
private async probeAndUpsertOffer(
|
||||
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(
|
||||
offerUrlOrFragment: string,
|
||||
label?: string,
|
||||
password?: string,
|
||||
): Promise<HostProfile> {
|
||||
const parsed = parseOfferConnectionUrl(offerUrlOrFragment);
|
||||
await this.requireOfferTrust(parsed.offer, undefined);
|
||||
return this.upsertConnectionFromOffer(parsed.offer, label, password ?? parsed.password);
|
||||
}
|
||||
|
||||
async probeAndUpsertConnectionFromOfferUrl(
|
||||
offerUrlOrFragment: string,
|
||||
password?: string,
|
||||
approval?: OfferApproval,
|
||||
password: string | undefined,
|
||||
): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
|
||||
const parsed = parseOfferConnectionUrl(offerUrlOrFragment);
|
||||
const offer = parsed.offer;
|
||||
await this.requireOfferTrust(offer, approval);
|
||||
const credential = password ?? parsed.password;
|
||||
const connection = relayConnectionFromOffer(offer);
|
||||
const probeHost: HostProfile = {
|
||||
serverId: offer.serverId,
|
||||
...(credential ? { password: credential } : {}),
|
||||
...(password ? { password } : {}),
|
||||
label: offer.serverId,
|
||||
appearance: defaultHostAppearance(),
|
||||
lifecycle: {},
|
||||
@@ -2008,32 +2052,10 @@ export class HostRuntimeStore {
|
||||
};
|
||||
const { client, hostname } = await this.deps.connectToDaemon({ host: probeHost, connection });
|
||||
await client.close().catch(() => undefined);
|
||||
const profile = await this.upsertConnectionFromOffer(offer, hostname ?? undefined, credential);
|
||||
const profile = await this.upsertConnectionFromOffer(offer, hostname ?? undefined, password);
|
||||
return { profile, serverId: offer.serverId, hostname };
|
||||
}
|
||||
|
||||
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,
|
||||
undefined,
|
||||
approval,
|
||||
);
|
||||
return { status: "connected", serverId };
|
||||
} catch (error) {
|
||||
if (!getConnectionAuthFailureReason(error)) throw error;
|
||||
return { status: "password_required" };
|
||||
}
|
||||
}
|
||||
|
||||
async upsertConnectionFromListen(input: {
|
||||
listenAddress: string;
|
||||
serverId: string;
|
||||
@@ -2781,6 +2803,22 @@ export function useHostRegistryLoaded(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function useHostConfirmation(): {
|
||||
pending: HostConfirmationRequest | null;
|
||||
answer: (approved: boolean) => void;
|
||||
} {
|
||||
const store = getHostRuntimeStore();
|
||||
const pending = useSyncExternalStore(
|
||||
(onStoreChange) => store.subscribeHostConfirmation(onStoreChange),
|
||||
() => store.getPendingHostConfirmation(),
|
||||
() => store.getPendingHostConfirmation(),
|
||||
);
|
||||
return useMemo(
|
||||
() => ({ pending, answer: (approved: boolean) => store.answerHostConfirmation(approved) }),
|
||||
[pending, store],
|
||||
);
|
||||
}
|
||||
|
||||
export interface HostMutations {
|
||||
upsertDirectConnection: (input: {
|
||||
serverId: string;
|
||||
@@ -2809,17 +2847,7 @@ export interface HostMutations {
|
||||
label?: string;
|
||||
password?: string;
|
||||
}) => Promise<HostProfile>;
|
||||
upsertConnectionFromOffer: (offer: ConnectionOffer, label?: string) => Promise<HostProfile>;
|
||||
upsertConnectionFromOfferUrl: (
|
||||
offerUrlOrFragment: string,
|
||||
label?: string,
|
||||
password?: string,
|
||||
) => Promise<HostProfile>;
|
||||
probeAndUpsertConnectionFromOfferUrl: (
|
||||
offerUrlOrFragment: string,
|
||||
password?: string,
|
||||
approval?: OfferApproval,
|
||||
) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
|
||||
beginLinkPairing: () => LinkPairing;
|
||||
renameHost: (serverId: string, label: string) => Promise<void>;
|
||||
setHostColor: (serverId: string, color: HostColor) => Promise<void>;
|
||||
setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay) => Promise<void>;
|
||||
@@ -2835,11 +2863,7 @@ export function useHostMutations(): HostMutations {
|
||||
probeAndUpsertDirectConnection: (input) => store.probeAndUpsertDirectConnection(input),
|
||||
probeAndUpsertRemoteSshConnection: (input) => store.probeAndUpsertRemoteSshConnection(input),
|
||||
upsertRelayConnection: (input) => store.upsertRelayConnection(input),
|
||||
upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label),
|
||||
upsertConnectionFromOfferUrl: (url, label, password) =>
|
||||
store.upsertConnectionFromOfferUrl(url, label, password),
|
||||
probeAndUpsertConnectionFromOfferUrl: (url, password, approval) =>
|
||||
store.probeAndUpsertConnectionFromOfferUrl(url, password, approval),
|
||||
beginLinkPairing: () => store.beginLinkPairing(),
|
||||
renameHost: (serverId, label) => store.renameHost(serverId, label),
|
||||
setHostColor: (serverId, color) => store.setHostColor(serverId, color),
|
||||
setHostBadgeDisplay: (serverId, badgeDisplay) =>
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
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 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
|
||||
* is "no", so a pairing link never connects without confirmation.
|
||||
*/
|
||||
export interface HostTrustRequest {
|
||||
/** Stable daemon identifier carried by the offer. */
|
||||
serverId: string;
|
||||
/** Human-comparable fingerprint of the daemon's public key. */
|
||||
keyFingerprint: string;
|
||||
/** Relay endpoint the client would connect through. */
|
||||
endpoint: string;
|
||||
/** A host with this server id is saved, and the link changes its connection. */
|
||||
isKnown: boolean;
|
||||
}
|
||||
|
||||
export type HostTrustConfirmer = (request: HostTrustRequest) => Promise<boolean>;
|
||||
|
||||
/** Thrown when the user cancels the trust prompt. Callers treat it as a no-op. */
|
||||
export class HostTrustDeclinedError extends Error {
|
||||
constructor(serverId: string) {
|
||||
super(`Connection to ${serverId} was not approved`);
|
||||
this.name = "HostTrustDeclinedError";
|
||||
}
|
||||
}
|
||||
|
||||
export function isHostTrustDeclinedError(error: unknown): error is HostTrustDeclinedError {
|
||||
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;
|
||||
|
||||
/**
|
||||
* Register the UI that answers trust prompts. Passing `null` unregisters it,
|
||||
* which returns the module to declining every request.
|
||||
*/
|
||||
export function setHostTrustConfirmer(confirmer: HostTrustConfirmer | null): void {
|
||||
activeConfirmer = confirmer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask whether the user trusts this host. Returns `false` when no confirmer is
|
||||
* registered so an offer link can never add or connect a host without consent.
|
||||
*/
|
||||
export async function confirmHostTrust(request: HostTrustRequest): Promise<boolean> {
|
||||
if (!activeConfirmer) return false;
|
||||
return activeConfirmer(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a daemon public key as a grouped fingerprint the user can compare
|
||||
* against the key shown by their daemon. This shows the key material itself
|
||||
* (not a re-hash), so different keys show different values.
|
||||
*/
|
||||
export function formatDaemonKeyFingerprint(daemonPublicKeyB64: string): string {
|
||||
const normalized = daemonPublicKeyB64.replace(/[^A-Za-z0-9]/g, "");
|
||||
if (normalized.length <= 16) {
|
||||
return normalized.replace(/(.{4})/g, "$1 ").trim();
|
||||
}
|
||||
const head = normalized.slice(0, 8);
|
||||
const tail = normalized.slice(-8);
|
||||
const group = (value: string): string => value.replace(/(.{4})/g, "$1 ").trim();
|
||||
return `${group(head)} … ${group(tail)}`;
|
||||
}
|
||||
|
||||
export function hostTrustRequestFromOffer(
|
||||
offer: ConnectionOffer,
|
||||
options: { isKnown: boolean },
|
||||
): HostTrustRequest {
|
||||
return {
|
||||
serverId: offer.serverId,
|
||||
keyFingerprint: formatDaemonKeyFingerprint(offer.daemonPublicKeyB64),
|
||||
endpoint: offer.relay.endpoint,
|
||||
isKnown: options.isKnown,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
|
||||
import {
|
||||
normalizeHostPort,
|
||||
normalizeLoopbackToLocalhost,
|
||||
shouldUseTlsForDefaultHostedRelay,
|
||||
} from "@getpaseo/protocol/daemon-endpoints";
|
||||
import {
|
||||
DirectTcpHostConnectionSchema,
|
||||
@@ -354,6 +356,19 @@ export function connectionFromListen(listen: string): HostConnection | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function relayConnectionFromOffer(offer: ConnectionOffer): RelayHostConnection {
|
||||
// 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 createRemoteSshHostConnection(input: {
|
||||
host: string;
|
||||
sshPort?: number;
|
||||
|
||||
Reference in New Issue
Block a user