mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
* Add confirmation for new hosts Opening a pairing link (an `#offer=` URL, a scanned QR code, or a pasted link) now asks the user to confirm before the host is added or connected. The prompt shows the host id, its key fingerprint, and the relay. Cancel leaves no new host and no connection. The host runtime owns the check: `upsertConnectionFromOfferUrl` and `probeAndUpsertConnectionFromOfferUrl` ask a registered confirmer before doing anything. `HostTrustPromptModal`, mounted at the app root, registers the confirmer and renders the prompt with the shared confirm sheet. Without a registered confirmer, pairing links are declined. Adds a test that an offer link with the confirmation declined adds no host and attempts no connection. * 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. * 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. * Stop QR scanning after an attempt ends The scan screen now keeps its workflow in a small model. When the user cancels the host confirmation, closes the password prompt, or the attempt fails, scanning stops until they tap Scan QR code again. Before, the code still in front of the camera started a new attempt on the next frame. A host confirmation answer is now tied to the request it answers, so a tap on one sheet never answers a newer one. Relay hosts can no longer be added through HostMutations without the confirmation, the add-host modal drops the reset its per-open mount made redundant, and the fingerprint comment says what the shortened form shows.
98 lines
3.3 KiB
TypeScript
98 lines
3.3 KiB
TypeScript
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 {
|
|
/** Identifies this question, so an answer shown for it never answers a newer one. */
|
|
id: number;
|
|
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 nextRequestId = 1;
|
|
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);
|
|
};
|
|
}
|
|
|
|
/** Answers the request with `requestId`; an answer for any other request is ignored. */
|
|
answer(requestId: number, approved: boolean): void {
|
|
const current = this.pending;
|
|
if (!current || current.request.id !== requestId) 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({
|
|
id: this.nextRequestId++,
|
|
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. Keys longer
|
|
* than 16 characters show only their first and last 8, so two keys that share
|
|
* both ends 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))}`;
|
|
}
|