diff --git a/CLAUDE.md b/CLAUDE.md index bc4f1bc..0af8300 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,6 +128,62 @@ routing either through a channel would overwrite or duplicate a proof. document as the counterparty receives it — a signature copied from another document satisfies an "is there a `proof` member" check and fails this one. +## Advertisement is not availability + +A VTA's DID document says what it *offers*. `buildVtaSession` skips a channel +whose mediator it cannot reach and falls through to the next, so a wallet +routinely advertises TSP, DIDComm and REST while every byte goes over REST. +The UI used to derive "Transport in use" from the stored connection alone and +therefore named transports that had never carried a byte — worse than saying +nothing, because it stops anyone asking the question. + +`activeTransport` (`transports.ts`) now takes a `TransportHealth`, recorded by +`buildVtaSession` at the two places it decides — and only there, because that +is the only code that knows. Three states, and the third is load-bearing: +`up` needs positive evidence (for TSP/DIDComm a completed mediator handshake +and an open socket), `down` is a skip, and REST records **`unknown`** because +a `RestChannel` is built from a URL without contacting anything. Marking a +constructed REST channel `up` would reintroduce the same overconfidence one +layer down. `unknown` is not a failure and never removes REST from selection. + +**What breaks it:** computing the status from `TransportSources` alone again; +recording `up` on construction rather than on evidence; or adding a fourth +transport without recording its outcome, which reads as "not observed" and +silently restores the advertisement-only answer for that channel. + +## A CORS refusal is unreadable, so it is inferred + +Chrome hands JavaScript a bare `TypeError: Failed to fetch` for a CORS +refusal, a dead host and a DNS failure alike; the actual reason ("No +`Access-Control-Allow-Origin` header is present") goes to the devtools console +and nowhere an extension can read. It cannot be recovered from the exception — +don't try. `transport-diagnosis.ts` infers it from one bit instead: a request +that fails at the network layer against a host that answers an opaque +(`mode: "no-cors"`) probe a moment later was refused by policy, not by the +network. Discrimination is structural — `TypeError`, `DOMException.name === +"TimeoutError"` — never message text (R3.7). + +This matters because **the mediator's auth handshake is CORS-governed even +though its WebSocket is not**. `authenticateToMediator` POSTs to +`{authEndpoint}/challenge` before any socket exists, so a mediator whose +`[security] cors_allow_origin` omits this extension's origin takes out TSP and +DIDComm together — they share that handshake — leaving REST carrying +everything and **the inbox dark**. A host permission is deliberately not +requested for it: the mediator applies the same origin policy to the WebSocket +upgrade server-side, where no browser permission reaches, so the fix is the +mediator's config and the wallet must say so rather than imply it can fix it +locally. + +The self-test (`runDiagnostics` in `offscreen.ts`, surfaced by +`diagnostics-panel.tsx`) exists because **`curl` cannot reproduce this**: a +terminal sends no `Origin` header, so the endpoint answers perfectly and the +operator concludes nothing is wrong. The wallet is the only place the question +can be asked truthfully. Its checks are read-only, and its `checkCorsReachable` +must keep using a plain `GET` against the *same* endpoint that fails — no +custom headers, so no preflight, and any status is a pass because reading a +status at all proves the origin was allowed. Swapping it for a health endpoint +would test a different policy than the one that breaks. + ## Repo mechanics worth knowing before you start - **Build `core` before typechecking anything that depends on it.** Each diff --git a/packages/extension/src/app-shell.tsx b/packages/extension/src/app-shell.tsx index ba014c2..5f0e9ef 100644 --- a/packages/extension/src/app-shell.tsx +++ b/packages/extension/src/app-shell.tsx @@ -22,6 +22,7 @@ import { NetworkPane } from "./network-pane.js"; import { SitesPanel } from "./sites-panel.js"; import { getSettings } from "./config.js"; import { activeTransport } from "./transports.js"; +import { useTransportHealth } from "./use-transport-health.js"; import { c, t } from "./theme.js"; import { Button, Note, Pill } from "./ui.js"; import { unlockWalletAndApprover } from "./unlock-wallet.js"; @@ -170,6 +171,12 @@ export function AppShell({ advanced, vault }: { advanced: React.ReactNode; vault const [pane, setPane] = useState(paneFromHash); const connection = useActiveConnection(); const [preferTsp, setPreferTsp] = useState(true); + const { health: transportHealth, sessions } = useTransportHealth(connection?.vtaDid); + // The wallet's own listening session, not just any warm one: an outbound + // channel to the agent's mediator says nothing about whether requests can + // arrive. Absent (no session at all) reads the same as closed, which is + // right — both mean nothing is listening. + const inbox = sessions.find((s) => s.isInbox)?.state ?? "closed"; const [approver, setApprover] = useState<{ minted: boolean; running: boolean }>({ minted: false, running: false, @@ -278,12 +285,41 @@ export function AppShell({ advanced, vault }: { advanced: React.ReactNode; vault note={ connection ? (() => { - const tr = activeTransport(connection, preferTsp); + const tr = activeTransport(connection, preferTsp, transportHealth); return tr ? `over ${tr}` : "no usable transport"; })() : "no agent yet" } - warn={Boolean(connection) && !activeTransport(connection!, preferTsp)} + warn={Boolean(connection) && !activeTransport(connection!, preferTsp, transportHealth)} + /> + {/* The inbox, on its own row, because "connected" and "can be + reached" are different states and only the second one decides + whether an approval request ever arrives. A wallet that has + fallen back to REST looks entirely healthy on the row above + while nothing can be pushed to it at all (R7.2). */} + — + ) : inbox === "live" ? ( + Live + ) : inbox === "connecting" ? ( + Connecting + ) : ( + Offline + ) + } + note={ + !connection + ? "no agent yet" + : inbox === "live" + ? "can receive requests" + : inbox === "connecting" + ? "opening a mediator session" + : "nothing can reach this wallet" + } + warn={Boolean(connection) && inbox !== "live" && inbox !== "connecting"} /> { return { ok: true, result }; } +// What the last session build observed per transport, for the wallet's own UI +// (Setup / Network panes). Unlike `handleMediatorStatus` this is not page +// facing — see the note on `RUNTIME_TRANSPORT_HEALTH`. +// +// Does NOT bring the offscreen document up. An observation only exists +// because a session was built, so starting the document to ask would always +// answer "nothing observed" while making the wallet do work; a wallet that +// has done nothing yet should simply say so. +async function handleTransportHealth(): Promise { + try { + const res = (await chrome.runtime.sendMessage({ + target: OFFSCREEN_TARGET, + type: OFFSCREEN_TRANSPORT_HEALTH, + })) as RuntimeTransportHealthResponse | undefined; + return res ?? { ok: true, result: { byVta: {}, sessions: [] } }; + } catch { + // No offscreen document listening yet — nothing has been observed. + return { ok: true, result: { byVta: {}, sessions: [] } }; + } +} + +// The connection self-test. Unlike `handleTransportHealth` this DOES bring the +// offscreen document up: the user asked for the checks to run, and running +// them is the point — there is no useful answer to give from the worker, which +// has neither the DID resolver nor the wallet's origin-governed fetch. +async function handleRunDiagnostics( + req: RuntimeRunDiagnosticsRequest, +): Promise { + await ensureOffscreenDocument(); + return (await chrome.runtime.sendMessage({ + target: OFFSCREEN_TARGET, + type: OFFSCREEN_RUN_DIAGNOSTICS, + vtaDid: req.vtaDid, + })) as RuntimeRunDiagnosticsResponse; +} + // Onboarding (popup-driven): both phases run in the offscreen doc (DID // resolution + the mediator session need import()/DOM). The background just // brings the offscreen up and relays. @@ -1913,6 +1956,24 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return true; // async sendResponse } + if ((message as { type?: string })?.type === RUNTIME_RUN_DIAGNOSTICS) { + handleRunDiagnostics(message as RuntimeRunDiagnosticsRequest) + .then(sendResponse) + .catch((e: unknown) => + sendResponse({ ok: false, error: e instanceof Error ? e.message : String(e) }), + ); + return true; // async sendResponse + } + + if ((message as { type?: string })?.type === RUNTIME_TRANSPORT_HEALTH) { + handleTransportHealth() + .then(sendResponse) + .catch((e: unknown) => + sendResponse({ ok: false, error: e instanceof Error ? e.message : String(e) }), + ); + return true; // async sendResponse + } + // The offscreen inbound listener can't reach `chrome.tabs`, so it asks us to // broadcast a wallet event to pages (e.g. `consentgranted`). Fire-and-forget. if ((message as { type?: string })?.type === RUNTIME_EMIT_WALLET_EVENT) { diff --git a/packages/extension/src/bridge-protocol.ts b/packages/extension/src/bridge-protocol.ts index dc57fe6..163c7c1 100644 --- a/packages/extension/src/bridge-protocol.ts +++ b/packages/extension/src/bridge-protocol.ts @@ -933,6 +933,106 @@ export type RuntimeVaultListResponse = | { ok: true; result: VaultListResultView } | { ok: false; error: string }; +// ─── Transport health (popup → background → offscreen) ─── +// +// What the last session build actually observed per transport, so the UI can +// name the transport carrying traffic instead of the one the DID document +// advertises. The two differ whenever a mediator is unreachable or refuses +// the extension's origin, which is precisely when a person goes looking. +// +// Deliberately NOT in `PAGE_FACING_RUNTIME_TYPES`: which of a user's +// transports are working is wallet diagnostics, not something an RP page has +// any business enumerating. The page-facing `mediatorStatus` stays as it was. + +export const RUNTIME_TRANSPORT_HEALTH = "vta-wallet/transport-health" as const; + +export interface RuntimeTransportHealthRequest { + type: typeof RUNTIME_TRANSPORT_HEALTH; +} + +/** Mirrors `TransportObservation` in `transports.ts`; duplicated here for the + * same reason `VaultEntryView` is — this file stays import-free so the + * content script can bundle it as a classic script. The two are checked + * against each other by assignability wherever a value crosses this + * boundary, so a drift breaks the build rather than the UI. */ +export interface TransportObservationView { + state: "unknown" | "up" | "down"; + /** A `TRANSPORT_DIAGNOSIS` code when `state` is `"down"`. */ + code?: string; + detail?: string; +} + +export interface TransportHealthView { + TSP?: TransportObservationView; + DIDComm?: TransportObservationView; + REST?: TransportObservationView; +} + +/** One warm mediator session, as the offscreen document sees it. `isInbox` + * marks the wallet's own listening session — the one whose death means + * nothing pushed to this wallet arrives, consent prompts included. */ +export interface InboxSessionView { + vtaDid: string; + mediatorDid: string; + state: "connecting" | "live" | "closed"; + isInbox: boolean; +} + +export interface TransportHealthResult { + /** Keyed by VTA DID. A VTA with no entry has had no session built yet — + * which is "nothing observed", not "nothing works". */ + byVta: { [vtaDid: string]: TransportHealthView }; + /** Every warm mediator session. Empty means none has been opened. */ + sessions: InboxSessionView[]; +} + +// ─── Connection self-test (popup → background → offscreen) ─── +// +// Runs the chain a wallet actually depends on and says which link is broken, +// in a form that can be pasted to whoever operates the service — which, for +// the failure this was built for (a mediator refusing the extension's +// origin), is someone other than the person reading it. + +export const RUNTIME_RUN_DIAGNOSTICS = "vta-wallet/run-diagnostics" as const; + +export interface RuntimeRunDiagnosticsRequest { + type: typeof RUNTIME_RUN_DIAGNOSTICS; + vtaDid: string; +} + +/** `"pass"` = verified working. `"fail"` = verified broken. `"warn"` = works + * but something is worth knowing. `"skip"` = not applicable (a transport + * this agent does not advertise), which is not a failure. */ +export type DiagnosticStatus = "pass" | "fail" | "warn" | "skip"; + +export interface DiagnosticCheck { + /** Stable id, so a report can be diffed across runs. */ + id: string; + label: string; + status: DiagnosticStatus; + detail: string; + /** A `TRANSPORT_DIAGNOSIS` code where one applies. */ + code?: string; + /** What to change, aimed at whoever operates the service. */ + remediation?: string; +} + +export interface DiagnosticsReport { + vtaDid: string; + /** This extension's origin — the string an operator has to allowlist. */ + extensionOrigin: string; + generatedAt: string; + checks: DiagnosticCheck[]; +} + +export type RuntimeRunDiagnosticsResponse = + | { ok: true; result: DiagnosticsReport } + | { ok: false; error: string }; + +export type RuntimeTransportHealthResponse = + | { ok: true; result: TransportHealthResult } + | { ok: false; error: string }; + // ─── Vault write surface (M2A.5) — upsert, delete, release ─── // // Same active-connection lookup as RUNTIME_VAULT_LIST. The popup @@ -1181,6 +1281,12 @@ export const OFFSCREEN_START_INBOUND = "offscreen/start-inbound" as const; /** background → offscreen: report the warm mediator-session status. Reply is * a [`MediatorStatusResult`] via `sendResponse`. */ export const OFFSCREEN_GET_STATUS = "offscreen/get-status" as const; +/** background → offscreen: report what the last session build observed for + * each transport. Reply is a [`TransportHealthResult`] via `sendResponse`. */ +export const OFFSCREEN_TRANSPORT_HEALTH = "offscreen/transport-health" as const; +/** background → offscreen: run the connection self-test for one VTA. Reply is + * a [`DiagnosticsReport`] via `sendResponse`. */ +export const OFFSCREEN_RUN_DIAGNOSTICS = "offscreen/run-diagnostics" as const; /** background → offscreen: resolve a VTA + mint the ephemeral to be granted. */ export const OFFSCREEN_ONBOARD_PREPARE = "offscreen/onboard-prepare" as const; /** background → offscreen: connect as the granted ephemeral and run the @@ -1404,6 +1510,17 @@ export interface OffscreenGetStatusRequest { type: typeof OFFSCREEN_GET_STATUS; } +export interface OffscreenTransportHealthRequest { + target: typeof OFFSCREEN_TARGET; + type: typeof OFFSCREEN_TRANSPORT_HEALTH; +} + +export interface OffscreenRunDiagnosticsRequest { + target: typeof OFFSCREEN_TARGET; + type: typeof OFFSCREEN_RUN_DIAGNOSTICS; + vtaDid: string; +} + export interface OffscreenOnboardPrepareRequest { target: typeof OFFSCREEN_TARGET; type: typeof OFFSCREEN_ONBOARD_PREPARE; diff --git a/packages/extension/src/diagnostics-panel.tsx b/packages/extension/src/diagnostics-panel.tsx new file mode 100644 index 0000000..a458b76 --- /dev/null +++ b/packages/extension/src/diagnostics-panel.tsx @@ -0,0 +1,147 @@ +/// + +// The connection self-test, as a panel. +// +// This exists because the failure it was built for could not be diagnosed +// from inside the wallet at all: a mediator refusing the extension's origin +// produced two `console.warn`s on a page no ordinary user opens, and finding +// the cause meant running `curl` against a server the user did not operate. +// Worse, `curl` cannot reproduce it — a terminal sends no `Origin` header, so +// the endpoint answers perfectly and the operator concludes nothing is wrong. +// +// The wallet is the only place that can ask the question truthfully, so it +// asks, and produces text for the person who can act on the answer. + +import { useState } from "react"; +import { + RUNTIME_RUN_DIAGNOSTICS, + type DiagnosticCheck, + type DiagnosticsReport, + type RuntimeRunDiagnosticsResponse, +} from "./bridge-protocol.js"; +import { formatReport, overallStatus, verdict } from "./diagnostics-report.js"; +import { c, t } from "./theme.js"; +import { Button, Panel, Pill } from "./ui.js"; +import type { PillTone } from "./theme.js"; + +const TONE: Record = { + pass: "ok", + fail: "danger", + warn: "warn", + skip: "off", +}; + +export function DiagnosticsPanel({ vtaDid }: { vtaDid: string | undefined }) { + const [report, setReport] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + + async function run() { + if (!vtaDid) return; + setBusy(true); + setError(null); + setCopied(false); + try { + const res = (await chrome.runtime.sendMessage({ + type: RUNTIME_RUN_DIAGNOSTICS, + vtaDid, + })) as RuntimeRunDiagnosticsResponse; + if (res.ok) setReport(res.result); + else setError(res.error); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + } + + async function copy() { + if (!report) return; + try { + await navigator.clipboard.writeText(formatReport(report)); + setCopied(true); + } catch { + // Clipboard can be refused; the text is on screen and selectable, so + // there is nothing to recover from and an error here would be noise. + } + } + + return ( + +
+ + {report && ( + + )} + {!vtaDid && ( + Connect a trust agent first. + )} +
+ + {error && ( +
{error}
+ )} + + {report && ( + <> +
+ {verdict(report)}{" "} + {overallStatus(report.checks) === "fail" && ( + <> + Most of these are fixed by whoever runs the service, not from this + browser — use Copy report and send it + to them. + + )} +
+ +
+ {report.checks.map((check) => ( +
+
+ {check.status} + {check.label} +
+
{check.detail}
+ {check.remediation && ( +
+ Fix: {check.remediation} +
+ )} + {check.code && ( +
+ {check.code} +
+ )} +
+ ))} +
+ + {/* The origin, on its own, because it is the one string an operator + has to copy exactly and the one most often retyped wrong. */} +
+ This wallet's origin:{" "} + {report.extensionOrigin} +
+ + )} +
+ ); +} diff --git a/packages/extension/src/diagnostics-report.ts b/packages/extension/src/diagnostics-report.ts new file mode 100644 index 0000000..b9c9aac --- /dev/null +++ b/packages/extension/src/diagnostics-report.ts @@ -0,0 +1,69 @@ +// Rendering a self-test result as text someone can paste. +// +// Separated from the panel that shows it, and free of React and `chrome`, for +// the same reason `transports.ts` is: this is the artefact the whole feature +// exists to produce. The person who can fix a mediator's CORS allowlist is +// usually not the person looking at the wallet, so the report has to survive +// being copied into a chat window and read by someone with no access to the +// browser it came from — which means it must carry the origin, the hosts, and +// the failing check's own words, not a screenshot's worth of context. + +import type { DiagnosticCheck, DiagnosticsReport, DiagnosticStatus } from "./bridge-protocol.js"; + +const MARK: Record = { + pass: "PASS", + fail: "FAIL", + warn: "WARN", + skip: "SKIP", +}; + +/** Worst status in the report, for a one-line verdict. `skip` never counts as + * a problem — a transport the agent does not advertise is not a fault. */ +export function overallStatus(checks: readonly DiagnosticCheck[]): DiagnosticStatus { + if (checks.some((c) => c.status === "fail")) return "fail"; + if (checks.some((c) => c.status === "warn")) return "warn"; + if (checks.some((c) => c.status === "pass")) return "pass"; + return "skip"; +} + +/** A one-sentence verdict, written for whoever receives the paste. */ +export function verdict(report: DiagnosticsReport): string { + switch (overallStatus(report.checks)) { + case "fail": + return "Something in the chain is broken — the failing checks below say which link and what to change."; + case "warn": + return "Reachable, but not everything this wallet needs is running."; + case "pass": + return "Every check passed."; + default: + return "Nothing to check."; + } +} + +/** + * The report as plain text. + * + * Deliberately not Markdown-heavy: it gets pasted into chat clients, terminals + * and issue trackers that each render it differently, and a report whose + * meaning depends on being rendered is one that arrives mangled. + */ +export function formatReport(report: DiagnosticsReport): string { + const lines: string[] = []; + lines.push("VTA wallet connection self-test"); + lines.push(`Generated: ${report.generatedAt}`); + lines.push(`Trust agent: ${report.vtaDid}`); + // The single most useful line for the recipient: the string they allowlist. + lines.push(`Wallet origin: ${report.extensionOrigin}`); + lines.push(""); + lines.push(verdict(report)); + lines.push(""); + + for (const check of report.checks) { + lines.push(`[${MARK[check.status]}] ${check.label}`); + lines.push(` ${check.detail}`); + if (check.code) lines.push(` code: ${check.code}`); + if (check.remediation) lines.push(` fix: ${check.remediation}`); + } + + return lines.join("\n"); +} diff --git a/packages/extension/src/host-permissions.ts b/packages/extension/src/host-permissions.ts index 0cba451..108730c 100644 --- a/packages/extension/src/host-permissions.ts +++ b/packages/extension/src/host-permissions.ts @@ -36,7 +36,21 @@ // control that silently degrades is worse than one that isn't there. // A did:webvh host behind a restrictive CORS policy is the known gap — // it surfaces as "unresolved" in the prompt, which fails closed. -// - **Mediator WebSocket** — not subject to CORS. +// - **Mediator** — but only half of it, and the half that is exempt is not +// the half that fails. The WebSocket upgrade is not subject to CORS; the +// authentication handshake that must precede it is two ordinary `fetch` +// calls (`POST {authEndpoint}/challenge`, then the packed response), and +// those are cross-origin like any other. A mediator whose +// `[security] cors_allow_origin` does not carry this extension's origin +// blocks them, and TSP and DIDComm both drop out — they share that +// handshake — leaving REST carrying everything and the inbox dark. +// +// A host grant is deliberately still NOT requested for it, because it +// would not be a fix: the mediator applies the same origin policy to the +// WebSocket upgrade itself (server side, where no browser permission +// reaches), so an origin it refuses stays refused. The fix is the +// mediator's config, and `transport-diagnosis.ts` exists to say so instead +// of leaving a bare "Failed to fetch" for someone to guess at. // // Gesture constraint // ------------------ diff --git a/packages/extension/src/network-pane.tsx b/packages/extension/src/network-pane.tsx index 24385a4..7b284f3 100644 --- a/packages/extension/src/network-pane.tsx +++ b/packages/extension/src/network-pane.tsx @@ -22,12 +22,19 @@ import { type VaultEntryView, } from "./bridge-protocol.js"; import { useAgentNames } from "./use-agent-names.js"; -import { activeTransport, advertisedTransports } from "./transports.js"; +import { + activeTransport, + advertisedTransports, + isObserved, + unavailableTransports, +} from "./transports.js"; +import { useTransportHealth } from "./use-transport-health.js"; import { TrustGraph, displayLabel, type GraphEdge, type GraphNode } from "./trust-graph.js"; import { c, t } from "./theme.js"; import { SignInFlow } from "./signin-flow.js"; import { DtteFlow } from "./dtte-flow.js"; import { Did, Empty, Panel, Pill } from "./ui.js"; +import { DiagnosticsPanel } from "./diagnostics-panel.js"; export function NetworkPane() { const connection = useActiveConnection(); @@ -74,7 +81,8 @@ export function NetworkPane() { ...sites.slice(0, 3).map((s) => s.rpDid), ...entries.slice(0, 6).map((e) => e.principalDid), ]); - const transport = connection ? activeTransport(connection, preferTsp) : undefined; + const { health: transportHealth } = useTransportHealth(connection?.vtaDid); + const transport = connection ? activeTransport(connection, preferTsp, transportHealth) : undefined; if (!connection) { return ( @@ -124,8 +132,23 @@ export function NetworkPane() { facts: [ { label: "DID", value: connection.vtaDid }, { label: "Your role", value: connection.role }, - { label: "Transport in use", value: transport ?? "none usable" }, + { + // Two facts, because they are two different claims and conflating + // them is what made this pane assert a transport that was dead. + label: isObserved(transportHealth) ? "Transport in use" : "Transport expected", + value: transport ?? "none usable", + }, { label: "Advertises", value: advertisedTransports(connection).join(", ") || "nothing" }, + ...(unavailableTransports(connection, transportHealth).length > 0 + ? [ + { + label: "Unavailable", + value: unavailableTransports(connection, transportHealth) + .map((t2) => `${t2} — ${transportHealth[t2]?.detail ?? "could not be opened"}`) + .join(" "), + }, + ] + : []), { label: "REST endpoint", value: connection.restBaseUrl ?? "not advertised" }, { label: "Connected", value: new Date(connection.connectedAt).toLocaleString() }, ], @@ -433,11 +456,13 @@ export function NetworkPane() { + +
- + {transport ? ( - {transport} + {transport} ) : ( none usable )} @@ -445,6 +470,16 @@ export function NetworkPane() { {advertisedTransports(connection).join(", ") || "none"} + {/* The panel exists to be pasted into a bug report, and the reason a + transport is down is the one line that report actually needs — + it is usually fixed by whoever runs the mediator, not by the + person reading this. */} + {unavailableTransports(connection, transportHealth).map((t2) => ( + + {transportHealth[t2]?.detail ?? "could not be opened"} + {transportHealth[t2]?.code ? ` (${transportHealth[t2]!.code})` : ""} + + ))} {preferTsp ? "on" : "off"} {encrypted ? "yes" : "no"} diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 01a7088..5446f8b 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -36,7 +36,9 @@ import { ApproverPrfSecretWrap, type ApproverIdentityResult, type ParsedTaskConsentRequest, + resolveMediatorEndpoint, resolveVtaServices, + withFetchTimeout, type VtaServices, resolveVtaTspEndpoint, resolveVtaTspEndpointCached, @@ -73,9 +75,22 @@ import { base64url } from "@openvtc/vti-didcomm-js"; import { getSettings } from "./config.js"; import { getWalletMediatorDid, loadHolder } from "./holder.js"; import { WebAuthnPrfSecretWrap } from "./webauthn-prf-wrap.js"; +import type { Transport, TransportHealth, TransportObservation } from "./transports.js"; +import { + classifyTransportFailure, + originOf, + probeReachable, + type Reachability, +} from "./transport-diagnosis.js"; import { OFFSCREEN_DIDCOMM_LOGIN, OFFSCREEN_GET_STATUS, + OFFSCREEN_TRANSPORT_HEALTH, + type TransportHealthResult, + OFFSCREEN_RUN_DIAGNOSTICS, + type OffscreenRunDiagnosticsRequest, + type DiagnosticCheck, + type DiagnosticsReport, OFFSCREEN_LOCK_WALLET, OFFSCREEN_CREATE_CONTEXT, OFFSCREEN_DERIVE_SIGNING_KEY_ID, @@ -227,6 +242,22 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { sendResponse({ mediators: statusSnapshot() }); return false; // synchronous response } + if (msg.type === OFFSCREEN_TRANSPORT_HEALTH) { + transportHealthSnapshot() + .then((result) => sendResponse({ ok: true, result })) + .catch((e: unknown) => + sendResponse({ ok: false, error: e instanceof Error ? e.message : String(e) }), + ); + return true; // async sendResponse + } + if (msg.type === OFFSCREEN_RUN_DIAGNOSTICS) { + runDiagnostics((message as OffscreenRunDiagnosticsRequest).vtaDid) + .then((result) => sendResponse({ ok: true, result })) + .catch((e: unknown) => + sendResponse({ ok: false, error: e instanceof Error ? e.message : String(e) }), + ); + return true; // async sendResponse + } if (msg.type === OFFSCREEN_LOCK_WALLET) { WebAuthnPrfSecretWrap.lock(); lockApprovers(); @@ -519,6 +550,354 @@ interface SessionIdentity { * under a DID the operator never granted. */ type MediatorConnector = (mediatorDid: string) => Promise; +// ─── Transport health: what a session build actually observed ─── +// +// `buildVtaSession` is the only place that knows whether a channel was really +// built or quietly skipped, and until this existed it knew it for the length +// of one `console.warn`. The UI then derived its "transport in use" line from +// the DID document instead, and named transports that had never carried a +// byte. Recording the observation here is what makes that line honest — see +// the header of `transports.ts`. +// +// Keyed by VTA DID. Module scope, so it shares the offscreen document's +// lifetime: MV3 may tear the document down at any moment, and losing this is +// harmless — it is an observation cache, not state anything depends on. An +// absent entry means "not observed yet", which the UI renders differently +// from "down". +const vtaTransportHealth = new Map(); + +function recordTransport( + vtaDid: string, + transport: Transport, + observation: TransportObservation, +): void { + const current = vtaTransportHealth.get(vtaDid) ?? {}; + vtaTransportHealth.set(vtaDid, { ...current, [transport]: observation }); +} + +/** Snapshot for the UI, shaped as the bridge declares it. */ +async function transportHealthSnapshot(): Promise { + const byVta: TransportHealthResult["byVta"] = {}; + for (const [vtaDid, health] of vtaTransportHealth) byVta[vtaDid] = health; + // Which mediator is the wallet's own inbox decides whether a dead session + // is "an outbound channel fell back" or "nothing can reach this wallet". + const inbox = await walletMediatorDid().catch(() => undefined); + const sessions = statusSnapshot().map((s) => ({ ...s, isInbox: s.mediatorDid === inbox })); + return { byVta, sessions }; +} + +/** + * Record a channel as down, then work out *why* in the background. + * + * Two steps on purpose. The state is recorded synchronously so the UI is + * never briefly wrong about whether a transport works, while the diagnosis + * needs a network round-trip (resolve the mediator, probe it) that the + * session build must not wait on — this runs on a path where something has + * already failed and the caller is owed its answer promptly. + */ +function noteTransportDown( + vtaDid: string, + transport: Transport, + mediatorDid: string | undefined, + err: unknown, +): void { + const message = err instanceof Error ? err.message : String(err); + console.warn(`[pnm ${transport.toLowerCase()}] skipping ${transport} channel:`, message); + recordTransport(vtaDid, transport, { state: "down", detail: message }); + void diagnoseTransportDown(vtaDid, transport, mediatorDid, err); +} + +/** Resolve the mediator, probe it, and upgrade the recorded reason from the + * browser's opaque `Failed to fetch` to something actionable. Never throws: + * a diagnosis that fails leaves the already-recorded raw message in place, + * which is exactly what the wallet used to report. */ +async function diagnoseTransportDown( + vtaDid: string, + transport: Transport, + mediatorDid: string | undefined, + err: unknown, +): Promise { + try { + // A short leash: this is a diagnostic running behind a failure the user + // is already waiting on the consequences of, not a request that matters. + const fetchImpl = withFetchTimeout(undefined, 5_000); + + let probeUrl: string | undefined; + if (mediatorDid) { + // The auth endpoint is the exact URL that just failed, so probing it + // asks about the thing that broke rather than a health route that may + // be served by something else. It answers `405` to the probe's GET — + // irrelevant, since an opaque response only has to exist. + probeUrl = await resolveMediatorEndpoint(mediatorDid) + .then((m) => m.authEndpoint) + .catch(() => undefined); + } + + const reachable: Reachability = probeUrl + ? await probeReachable(probeUrl, fetchImpl) + : "unprobed"; + + const host = originOf(probeUrl); + const origin = extensionOrigin(); + const diagnosis = classifyTransportFailure({ + error: err, + reachable, + ...(host ? { host } : {}), + ...(origin ? { origin } : {}), + }); + + // Only overwrite while this transport is still the one we diagnosed as + // down. A rebuild that succeeded in the meantime must not be reverted to + // a stale failure. + if (vtaTransportHealth.get(vtaDid)?.[transport]?.state !== "down") return; + recordTransport(vtaDid, transport, { + state: "down", + code: diagnosis.code, + detail: diagnosis.remediation + ? `${diagnosis.detail} ${diagnosis.remediation}` + : diagnosis.detail, + }); + console.warn(`[pnm ${transport.toLowerCase()}] ${diagnosis.code}: ${diagnosis.detail}`); + } catch { + /* diagnosis is best-effort; the raw message stands */ + } +} + +/** This extension's own origin, for a message an operator has to paste into a + * config file. `chrome.runtime` is one of the few APIs an offscreen document + * does have. */ +function extensionOrigin(): string | undefined { + try { + return new URL(chrome.runtime.getURL("")).origin; + } catch { + return undefined; + } +} + +// ─── Connection self-test ─── +// +// Walks the chain a wallet depends on and names the broken link. Written +// because diagnosing the failure it was built for — a mediator whose CORS +// allowlist did not carry this extension's origin — required leaving the +// wallet entirely and running `curl` against someone else's server. Every +// check here is something the wallet can ask on its own behalf, from its own +// origin, which is the only place the answer is true: the same request from a +// terminal succeeds, because `curl` sends no `Origin` header. +// +// Read-only throughout. Nothing here authenticates, mutates, or spends a +// credential — a diagnostic that changes state is one people are afraid to +// run, and this one has to be safe to hand to a stranger mid-incident. + +/** Does a CORS-governed request to `url` succeed from this extension's origin? + * + * A plain GET, deliberately: no custom headers means no preflight, so the + * browser checks `Access-Control-Allow-Origin` on the actual response and a + * refusal surfaces exactly as it does on the real path. The **status does not + * matter** — a `405` from a POST-only auth route is a complete pass, because + * reading any status at all proves the origin was allowed. That is also why + * this cannot be replaced by hitting a health endpoint: it must be governed + * by the same policy as the request that fails. */ +async function checkCorsReachable( + url: string, + fetchImpl: typeof fetch, +): Promise<{ ok: boolean; status?: number; error?: unknown }> { + try { + const res = await fetchImpl(url, { method: "GET", cache: "no-store" }); + return { ok: true, status: res.status }; + } catch (err: unknown) { + return { ok: false, error: err }; + } +} + +/** One mediator's three checks: does its DID resolve, is it up, will it talk + * to us. They are ordered so a failure explains the checks below it. */ +async function diagnoseMediator( + label: string, + mediatorDid: string, + fetchImpl: typeof fetch, + origin: string | undefined, +): Promise { + const checks: DiagnosticCheck[] = []; + const idBase = `mediator.${label}`; + + let authEndpoint: string | undefined; + try { + authEndpoint = (await resolveMediatorEndpoint(mediatorDid)).authEndpoint; + checks.push({ + id: `${idBase}.resolve`, + label: `${label} mediator DID resolves`, + status: "pass", + detail: `${mediatorDid} → ${originOf(authEndpoint) ?? authEndpoint}`, + }); + } catch (err: unknown) { + checks.push({ + id: `${idBase}.resolve`, + label: `${label} mediator DID resolves`, + status: "fail", + detail: err instanceof Error ? err.message : String(err), + remediation: "The mediator's DID document must resolve and advertise a WebSocket endpoint.", + }); + return checks; // nothing below can be attempted without an endpoint + } + + const host = originOf(authEndpoint); + const cors = await checkCorsReachable(authEndpoint, fetchImpl); + if (cors.ok) { + checks.push({ + id: `${idBase}.origin`, + label: `${label} mediator accepts this wallet's origin`, + status: "pass", + // Naming the status makes it obvious to a reader that a 4xx is expected + // and is not the thing being tested. + detail: `${host} answered (HTTP ${cors.status}) with this extension's origin on the request.`, + }); + return checks; + } + + // Refused or unreachable — the probe separates them. + const reachable = await probeReachable(authEndpoint, fetchImpl); + const d = classifyTransportFailure({ + error: cors.error, + reachable, + ...(host ? { host } : {}), + ...(origin ? { origin } : {}), + }); + checks.push({ + id: `${idBase}.origin`, + label: `${label} mediator accepts this wallet's origin`, + status: "fail", + detail: d.detail, + code: d.code, + ...(d.remediation ? { remediation: d.remediation } : {}), + }); + return checks; +} + +/** Run the self-test for one VTA. Never throws for a *check* failure — a + * failed check is a result, and a report that aborts at the first problem + * hides the others. */ +async function runDiagnostics(vtaDid: string): Promise { + // Bounded, and shorter than a real request: someone is watching this run. + const fetchImpl = withFetchTimeout(undefined, 8_000); + const origin = extensionOrigin(); + const checks: DiagnosticCheck[] = []; + + let services: VtaServices | undefined; + try { + services = await resolveVtaServices(vtaDid); + const advertised = [ + services.tsp ? "TSP" : null, + services.didcomm ? "DIDComm" : null, + services.rest ? "REST" : null, + ].filter(Boolean); + checks.push({ + id: "vta.resolve", + label: "Trust agent DID resolves", + status: "pass", + detail: `Advertises ${advertised.join(", ") || "no transport"}.`, + }); + } catch (err: unknown) { + checks.push({ + id: "vta.resolve", + label: "Trust agent DID resolves", + status: "fail", + detail: err instanceof Error ? err.message : String(err), + }); + return { vtaDid, extensionOrigin: origin ?? "unknown", generatedAt: new Date().toISOString(), checks }; + } + + // Both transports usually name the same mediator; check it once and say so, + // rather than reporting one host's failure twice as if they were two faults. + const mediators = new Map(); + if (services.tsp) mediators.set(services.tsp.mediatorDid, ["TSP"]); + if (services.didcomm) { + mediators.set(services.didcomm.mediatorDid, [ + ...(mediators.get(services.didcomm.mediatorDid) ?? []), + "DIDComm", + ]); + } + for (const [mediatorDid, uses] of mediators) { + checks.push(...(await diagnoseMediator(uses.join("+"), mediatorDid, fetchImpl, origin))); + } + if (mediators.size === 0) { + checks.push({ + id: "mediator.none", + label: "Mediator", + status: "skip", + detail: "This agent advertises no mediator — REST only, and nothing can be pushed to this wallet.", + }); + } + + // The wallet's own inbox mediator, which is configurable and often is NOT + // the agent's. This is the session whose death means consent prompts never + // arrive, so it gets its own check rather than being folded into the above. + const inbox = await walletMediatorDid().catch(() => undefined); + if (inbox && !mediators.has(inbox)) { + checks.push(...(await diagnoseMediator("inbox", inbox, fetchImpl, origin))); + } + + if (services.rest) { + const restUrl = services.rest.baseUrl; + const cors = await checkCorsReachable(restUrl, fetchImpl); + if (cors.ok) { + checks.push({ + id: "vta.rest", + label: "Trust agent REST accepts this wallet's origin", + status: "pass", + detail: `${originOf(restUrl) ?? restUrl} answered (HTTP ${cors.status}).`, + }); + } else { + const reachable = await probeReachable(restUrl, fetchImpl); + const d = classifyTransportFailure({ + error: cors.error, + reachable, + ...(originOf(restUrl) ? { host: originOf(restUrl)! } : {}), + ...(origin ? { origin } : {}), + }); + checks.push({ + id: "vta.rest", + label: "Trust agent REST accepts this wallet's origin", + status: "fail", + detail: d.detail, + code: d.code, + remediation: + "vta-service applies an origin allowlist — add this origin to `[server] cors_origins` " + + "in its config.toml and restart. The wallet also needs a host permission for this " + + "origin, which Setup requests.", + }); + } + } + + // Inbox liveness, from the session pool rather than a fresh probe: whether + // the listener is up right now is the question, and opening a second one to + // ask would answer about the probe instead. + const live = statusSnapshot().filter((s) => s.vtaDid === vtaDid && s.state === "live"); + checks.push( + live.length > 0 + ? { + id: "inbox.session", + label: "Inbox session is live", + status: "pass", + detail: `${live.length} mediator session(s) open for this agent.`, + } + : { + id: "inbox.session", + label: "Inbox session is live", + status: "warn", + detail: + "No mediator session is open for this agent. Nothing pushed to this wallet " + + "will arrive — including approval requests — until one is.", + }, + ); + + return { + vtaDid, + extensionOrigin: origin ?? "unknown", + generatedAt: new Date().toISOString(), + checks, + }; +} + // Build a VtaSession for `vtaDid` honouring the advertised transports // (TSP > DIDComm > REST). `restBaseUrl` (from the popup's connection state) is // used when present; otherwise we fall back to the VTA's advertised #vta-rest. @@ -577,10 +956,13 @@ async function buildVtaSession( vta: vtaTsp, }), ); + recordTransport(vtaDid, "TSP", { state: "up" }); } catch (err) { // Resolution failure (e.g. the VTA advertises #tsp but its keys don't - // resolve) shouldn't kill the session — DIDComm/REST still work. - console.warn("[pnm tsp] skipping TSP channel:", (err as Error).message); + // resolve) shouldn't kill the session — DIDComm/REST still work. It is + // still recorded and diagnosed: a silent fallback that nothing reports + // is how a wallet ends up claiming a transport it is not using. + noteTransportDown(vtaDid, "TSP", services.tsp.mediatorDid, err); } } let didcommConn: MediatorConnection | undefined; @@ -590,11 +972,13 @@ async function buildVtaSession( // this is *pre-send*, so nothing has been dispatched and nothing can have // been applied twice — the distinction `VtaSession` draws when it falls // back on `e.client.unsupported` but never on a post-send failure. - const conn = await connect(services.didcomm.mediatorDid).catch((err: unknown) => { - console.warn("[pnm didcomm] skipping DIDComm channel:", (err as Error).message); + const didcommMediator = services.didcomm.mediatorDid; + const conn = await connect(didcommMediator).catch((err: unknown) => { + noteTransportDown(vtaDid, "DIDComm", didcommMediator, err); return undefined; }); if (conn) { + recordTransport(vtaDid, "DIDComm", { state: "up" }); didcommConn = conn; const bridge = new MediatorSessionBridge(conn); // Encrypt/route to the REAL VTA (`service`), NOT `conn.vta`: the warm @@ -616,6 +1000,12 @@ async function buildVtaSession( const rest = restBaseUrl || services.rest?.baseUrl; if (rest) { channels.push(new RestChannel({ baseUrl: rest, holder, signing, service })); + // Deliberately `"unknown"`, not `"up"`. A `RestChannel` is built from a + // URL without contacting anything, so construction is not evidence — and + // a REST channel that turns out to be unreachable fails the caller's + // request visibly, rather than degrading silently the way a skipped + // mediator channel does. + recordTransport(vtaDid, "REST", { state: "unknown" }); } if (channels.length === 0) { throw new Error(`${vtaDid} advertises no usable transport (#tsp, #vta-didcomm or #vta-rest)`); diff --git a/packages/extension/src/setup-pane.tsx b/packages/extension/src/setup-pane.tsx index be3392a..a6a24a6 100644 --- a/packages/extension/src/setup-pane.tsx +++ b/packages/extension/src/setup-pane.tsx @@ -27,7 +27,13 @@ import { requestOriginPermission, } from "./host-permissions.js"; import { didWebvhDomain } from "@openvtc/pnm-core"; -import { activeTransport, transportSummary } from "./transports.js"; +import { + activeTransport, + isObserved, + transportSummary, + unavailableTransports, +} from "./transports.js"; +import { useTransportHealth } from "./use-transport-health.js"; import { c, t } from "./theme.js"; import { Button, Did, DidNamed, Note, Panel, Pill } from "./ui.js"; @@ -173,7 +179,10 @@ export function SetupPane() { // Both the agent and its mediator get looked up: the mediator is a hosted // identity too and usually claims its own name (`…/@mediator`). const agentNames = useAgentNames([connection?.vtaDid, agentMediator]); - const transport = connection ? activeTransport(connection, preferTsp) : undefined; + const { health: transportHealth } = useTransportHealth(connection?.vtaDid); + const transport = connection ? activeTransport(connection, preferTsp, transportHealth) : undefined; + const observed = isObserved(transportHealth); + const broken = connection ? unavailableTransports(connection, transportHealth) : []; async function turnOnLock() { if (!connection) return; @@ -267,11 +276,35 @@ export function SetupPane() { Read from the agent's own record — nothing to enter. {transport && ( <> - {" "}Messages travel over{" "} + {" "}Messages{" "} + {/* "travel" once a session has actually been built; + "should travel" before that, because until then + this is read off the agent's DID document and the + document only says what is offered, not what + works. */} + {observed ? "travel" : "should travel"} over{" "} {transport}. )} + {/* The reason a transport is missing, in the one place + someone looks when messages are not getting through. + This used to be a `console.warn` on a page no ordinary + user opens, which is how a dark inbox went unnoticed. */} + {broken.map((t2) => ( +
+ {t2} unavailable.{" "} + {transportHealth[t2]?.detail ?? "The channel could not be opened."} +
+ ))}
`). */ + origin?: string; +}): TransportDiagnosis { + const { error, reachable, host, origin } = args; + const where = host ? ` at ${host}` : ""; + + if (isFetchTimeout(error)) { + return { + code: TRANSPORT_DIAGNOSIS.timeout, + detail: `The mediator${where} did not answer before the request deadline.`, + }; + } + + // Not a network-layer failure ⇒ the mediator answered and its reply was the + // problem. Keep what it said; a connectivity story here would be a lie. + if (!(error instanceof TypeError)) { + return { + code: TRANSPORT_DIAGNOSIS.rejected, + detail: `The mediator${where} answered and refused the request: ${messageOf(error)}`, + }; + } + + if (reachable === "reachable") { + return { + code: TRANSPORT_DIAGNOSIS.originNotAllowed, + detail: + `The mediator${where} is up and answering, but refused this request. ` + + `A browser extension is a cross-origin caller, so the mediator has to ` + + `allow this wallet's origin explicitly${origin ? ` (${origin})` : ""}.`, + remediation: + `Whoever operates this mediator needs to add the origin to ` + + `\`[security] cors_allow_origin\` in its \`mediator.toml\` and restart it. ` + + `The same setting also gates the WebSocket upgrade, so nothing this ` + + `wallet can grant locally substitutes for it.`, + }; + } + + if (reachable === "unreachable") { + return { + code: TRANSPORT_DIAGNOSIS.unreachable, + detail: `Nothing answered at the mediator's address${where}.`, + remediation: + `Check the mediator is running and that the endpoint in its DID ` + + `document is the one it actually serves.`, + }; + } + + return { + code: TRANSPORT_DIAGNOSIS.unknown, + detail: `The connection to the mediator${where} failed before it produced a response.`, + }; +} + +/** + * Ask whether a host answers at all, without needing its permission to read + * the answer. + * + * `mode: "no-cors"` yields an opaque response — status and headers are + * unreadable, which is fine, because the question is only "did something + * answer". It resolves for a 200 and equally for a 404 or a 500; it rejects + * when the request never reached a server. That is exactly the bit we want, + * and it is obtainable with no CORS cooperation from the host at all. + * + * Never throws: a probe that fails to run reports `"unprobed"` so the + * classifier degrades to a weaker answer rather than replacing the original + * failure with its own. + */ +export async function probeReachable( + url: string, + fetchImpl: typeof fetch, +): Promise { + try { + await fetchImpl(url, { + method: "GET", + mode: "no-cors", + // A cached opaque response would answer for a host that has since gone + // away, which is the one wrong answer this probe must not give. + cache: "no-store", + redirect: "follow", + }); + return "reachable"; + } catch (err: unknown) { + // A timeout means the host did not answer in time — for this question + // that is "unreachable", not "the probe broke". + if (isFetchTimeout(err) || err instanceof TypeError) return "unreachable"; + return "unprobed"; + } +} + +/** The scheme+host of a URL, for a message. Returns undefined rather than + * throwing on input that is not a URL. */ +export function originOf(url: string | undefined): string | undefined { + if (!url) return undefined; + try { + return new URL(url).origin; + } catch { + return undefined; + } +} + +function messageOf(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/packages/extension/src/transports.ts b/packages/extension/src/transports.ts index 3ded7d2..169d9b9 100644 --- a/packages/extension/src/transports.ts +++ b/packages/extension/src/transports.ts @@ -3,9 +3,23 @@ // A connection records every transport its agent advertises, but only one of // them carries traffic. The offscreen `VtaSession` picks in a fixed order — // TSP > DIDComm > REST — with the `preferTsp` setting able to take TSP out of -// the running. Listing all three, as the popup's status line does, answers -// "what could this use?" when the question a user is actually asking is -// "what is it using right now?". +// the running. +// +// **Advertisement is not availability.** `buildVtaSession` skips a channel +// whose mediator it cannot reach and falls through to the next one, so a +// wallet can advertise TSP, DIDComm and REST while every byte goes over REST. +// Deciding the status line from the stored connection alone reported "TSP" in +// exactly that situation — a mediator refusing the extension's origin took +// TSP and DIDComm out silently, and the UI kept naming a transport that had +// not carried a byte. That is worse than saying nothing: it stops anyone +// asking the question. So the selection here takes an optional +// `TransportHealth` — what the last session build actually observed — and a +// transport known to be down is never named as the active one. +// +// Health is *optional* because it genuinely may not exist yet: nothing has +// been observed before the first VTA operation. Absent health reproduces the +// old advertisement-only answer, which is the right guess, and callers that +// want to distinguish "in use" from "expected" can ask `isObserved`. // // Kept free of React and `chrome` so the selection rule is testable and so // both the popup and the app shell resolve it identically. A status display @@ -26,13 +40,47 @@ export type Transport = "TSP" | "DIDComm" | "REST"; /** Priority order, mirroring the offscreen session's own preference. */ export const TRANSPORT_ORDER: Transport[] = ["TSP", "DIDComm", "REST"]; -/** Everything the agent advertises, in priority order. */ +/** + * What a session build observed for one transport. + * + * `"up"` means the channel was constructed — for TSP and DIDComm that + * includes a completed mediator handshake and an open socket, which is real + * evidence. `"down"` means it was skipped, and `code`/`detail` say why. + * + * `"unknown"` is not a failure. REST reports it: a `RestChannel` is built + * from a URL without contacting anything, so construction proves nothing and + * claiming `"up"` would be the same overconfidence this type exists to stop. + * REST is only ever *proven* by a request completing over it. + */ +export type TransportState = "unknown" | "up" | "down"; + +export interface TransportObservation { + state: TransportState; + /** Stable machine-readable cause when `state` is `"down"` — a + * `TRANSPORT_DIAGNOSIS` code. Typed as a plain string to keep this module + * dependency-free; match on the constants, never on `detail` (R3.7). */ + code?: string; + /** One line a person can act on. Safe to render. */ + detail?: string; +} + +/** What the last session build observed, per transport. An absent entry means + * nothing has been observed — not that the transport is down. */ +export type TransportHealth = Partial>; + +/** Whether `c` carries the service entry for `t`. The single place the + * transport→field mapping lives, so the advertised list and the selection + * rule can never drift apart. */ +function advertises(c: TransportSources, t: Transport): boolean { + if (t === "TSP") return Boolean(c.tspMediatorDid); + if (t === "DIDComm") return Boolean(c.mediatorDid); + return Boolean(c.restBaseUrl); +} + +/** Everything the agent advertises, in priority order. Says nothing about + * whether any of it works — see {@link activeTransport}. */ export function advertisedTransports(c: TransportSources): Transport[] { - const out: Transport[] = []; - if (c.tspMediatorDid) out.push("TSP"); - if (c.mediatorDid) out.push("DIDComm"); - if (c.restBaseUrl) out.push("REST"); - return out; + return TRANSPORT_ORDER.filter((t) => advertises(c, t)); } /** @@ -40,26 +88,62 @@ export function advertisedTransports(c: TransportSources): Transport[] { * * `preferTsp` defaults to on (see `WalletSettings`); turning it off pins the * connection to DIDComm/REST, which is the documented workaround for a - * mediator whose TSP delivery misbehaves. Returns undefined when the agent - * advertises nothing usable — a real state the UI must be able to show, not - * an impossible one to assert away. + * mediator whose TSP delivery misbehaves. Returns undefined when nothing + * usable is left — a real state the UI must be able to show, not an + * impossible one to assert away. + * + * Mirrors `buildVtaSession`'s own order and skip rule: a transport observed + * `"down"` is passed over exactly as the session passes over it. */ export function activeTransport( c: TransportSources, preferTsp: boolean, + health: TransportHealth = {}, ): Transport | undefined { - if (preferTsp && c.tspMediatorDid) return "TSP"; - if (c.mediatorDid) return "DIDComm"; - if (c.restBaseUrl) return "REST"; - // TSP advertised but switched off, with no other transport: nothing usable. + for (const t of TRANSPORT_ORDER) { + if (!advertises(c, t)) continue; + // TSP advertised but switched off: the session never builds that channel. + if (t === "TSP" && !preferTsp) continue; + if (health[t]?.state === "down") continue; + return t; + } return undefined; } -/** One-line summary for a status chip: the active transport, noting when - * others are available but idle. */ -export function transportSummary(c: TransportSources, preferTsp: boolean): string { - const active = activeTransport(c, preferTsp); - if (!active) return "no transport"; - const others = advertisedTransports(c).filter((x) => x !== active); - return others.length > 0 ? `${active} · ${others.join(", ")} available` : active; +/** Advertised transports the last build could not use, in priority order. */ +export function unavailableTransports( + c: TransportSources, + health: TransportHealth = {}, +): Transport[] { + return advertisedTransports(c).filter((t) => health[t]?.state === "down"); +} + +/** Whether anything has actually been observed yet. Lets a caller label the + * status honestly — "in use" once a session has been built, "expected" + * before that — instead of asserting either way. */ +export function isObserved(health: TransportHealth = {}): boolean { + return TRANSPORT_ORDER.some((t) => { + const s = health[t]?.state; + return s === "up" || s === "down"; + }); +} + +/** One-line summary for a status chip: the active transport, what else is + * idle, and — the part that was missing — what is advertised but broken. */ +export function transportSummary( + c: TransportSources, + preferTsp: boolean, + health: TransportHealth = {}, +): string { + const active = activeTransport(c, preferTsp, health); + const down = unavailableTransports(c, health).filter((t) => t !== active); + const idle = advertisedTransports(c).filter( + (t) => t !== active && !down.includes(t), + ); + + const parts: string[] = []; + parts.push(active ?? "no transport"); + if (idle.length > 0) parts.push(`${idle.join(", ")} available`); + if (down.length > 0) parts.push(`${down.join(", ")} unavailable`); + return parts.join(" · "); } diff --git a/packages/extension/src/use-transport-health.ts b/packages/extension/src/use-transport-health.ts new file mode 100644 index 0000000..385a831 --- /dev/null +++ b/packages/extension/src/use-transport-health.ts @@ -0,0 +1,74 @@ +/// + +// What the wallet's transports are actually doing, for the screens that say so. +// +// One hook rather than a per-screen effect, for the same reason `transports.ts` +// is one module: three surfaces name the active transport, and a screen that +// resolved it differently from the others would be wrong somewhere. The +// selection rule stays in `transports.ts`; this only fetches the evidence it +// takes. +// +// An empty result is the honest default and the common one — nothing is +// observed until a session has been built. Callers pass it straight to +// `activeTransport`, which then falls back to what the agent advertises. + +import { useEffect, useState } from "react"; +import { + RUNTIME_TRANSPORT_HEALTH, + type InboxSessionView, + type RuntimeTransportHealthResponse, +} from "./bridge-protocol.js"; +import type { TransportHealth } from "./transports.js"; + +export interface TransportDiagnostics { + health: TransportHealth; + /** Every warm mediator session the offscreen document holds. */ + sessions: InboxSessionView[]; +} + +const EMPTY: TransportDiagnostics = { health: {}, sessions: [] }; + +/** + * Transport observations for `vtaDid`. + * + * Failure is deliberately silent: the fallback is the advertised-transport + * answer the UI gave before this existed, which is a reasonable guess, and an + * error banner because a diagnostic lookup failed would be noise about noise. + * + * @param vtaDid the VTA whose session health to report. Undefined (no + * connection yet) yields an empty result. + */ +export function useTransportHealth(vtaDid: string | undefined): TransportDiagnostics { + const [state, setState] = useState(EMPTY); + + useEffect(() => { + if (!vtaDid) { + setState(EMPTY); + return; + } + let live = true; + void (async () => { + try { + const res = (await chrome.runtime.sendMessage({ + type: RUNTIME_TRANSPORT_HEALTH, + })) as RuntimeTransportHealthResponse | undefined; + if (!live) return; + setState( + res?.ok + ? { + health: res.result.byVta[vtaDid] ?? {}, + sessions: res.result.sessions.filter((s) => s.vtaDid === vtaDid), + } + : EMPTY, + ); + } catch { + if (live) setState(EMPTY); + } + })(); + return () => { + live = false; + }; + }, [vtaDid]); + + return state; +} diff --git a/packages/extension/tests/diagnostics-report.test.mts b/packages/extension/tests/diagnostics-report.test.mts new file mode 100644 index 0000000..443030a --- /dev/null +++ b/packages/extension/tests/diagnostics-report.test.mts @@ -0,0 +1,93 @@ +// The self-test report as text — see src/diagnostics-report.ts. +// +// The report is the artefact the whole feature produces: it gets pasted to +// whoever runs the failing service, who has no access to the browser it came +// from. So what is tested here is that it survives that trip — the origin, +// the failing check's own words, and the fix all present in plain text. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { + formatReport, + overallStatus, + verdict, +} from "../src/diagnostics-report.ts"; +import type { DiagnosticsReport } from "../src/bridge-protocol.ts"; + +const REPORT: DiagnosticsReport = { + vtaDid: "did:webvh:abc:agent.example", + extensionOrigin: "chrome-extension://dbjgfkjlgfamanmbiihldgncpjeknphl", + generatedAt: "2026-08-30T06:57:00.000Z", + checks: [ + { + id: "vta.resolve", + label: "Trust agent DID resolves", + status: "pass", + detail: "Advertises TSP, DIDComm, REST.", + }, + { + id: "mediator.TSP+DIDComm.origin", + label: "TSP+DIDComm mediator accepts this wallet's origin", + status: "fail", + detail: "The mediator at https://mediator.example is up and answering, but refused this request.", + code: "mediator/origin-not-allowed", + remediation: "Add the origin to `[security] cors_allow_origin` and restart.", + }, + ], +}; + +test("the origin an operator must allowlist is in the text", () => { + // The single most-retyped string in the whole exchange. If it survives + // nothing else, it has to survive this. + assert.match(formatReport(REPORT), /chrome-extension:\/\/dbjgfkjlgfamanmbiihldgncpjeknphl/); +}); + +test("a failing check carries its own words, its code and its fix", () => { + const text = formatReport(REPORT); + assert.match(text, /\[FAIL\] TSP\+DIDComm mediator accepts this wallet's origin/); + assert.match(text, /up and answering, but refused this request/); + assert.match(text, /code: mediator\/origin-not-allowed/); + assert.match(text, /fix: Add the origin to/); +}); + +test("passing checks are kept, not filtered out", () => { + // The checks that passed are what tell the recipient the fault is theirs + // and not, say, DNS — a report of only failures is missing its own context. + assert.match(formatReport(REPORT), /\[PASS\] Trust agent DID resolves/); +}); + +test("one failure decides the verdict", () => { + assert.equal(overallStatus(REPORT.checks), "fail"); + assert.match(verdict(REPORT), /broken/); +}); + +test("a skipped transport is not a fault", () => { + // A VTA that advertises no mediator legitimately skips those checks; a + // report that called that a failure would send someone chasing nothing. + const checks = [ + { id: "a", label: "a", status: "pass" as const, detail: "" }, + { id: "b", label: "b", status: "skip" as const, detail: "" }, + ]; + assert.equal(overallStatus(checks), "pass"); +}); + +test("warn outranks pass but not fail", () => { + assert.equal( + overallStatus([ + { id: "a", label: "a", status: "pass", detail: "" }, + { id: "b", label: "b", status: "warn", detail: "" }, + ]), + "warn", + ); + assert.equal( + overallStatus([ + { id: "a", label: "a", status: "warn", detail: "" }, + { id: "b", label: "b", status: "fail", detail: "" }, + ]), + "fail", + ); +}); + +test("an empty report does not claim everything passed", () => { + assert.equal(overallStatus([]), "skip"); +}); diff --git a/packages/extension/tests/transport-diagnosis.test.mts b/packages/extension/tests/transport-diagnosis.test.mts new file mode 100644 index 0000000..8cbdcd6 --- /dev/null +++ b/packages/extension/tests/transport-diagnosis.test.mts @@ -0,0 +1,118 @@ +// Transport failure classification — see src/transport-diagnosis.ts. +// +// These pin the inference that replaces a reason the platform refuses to give +// us. The browser hands JavaScript a bare `TypeError: Failed to fetch` for a +// CORS refusal and for a dead host alike; everything actionable is in telling +// those two apart, so that is what is tested hardest. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { + TRANSPORT_DIAGNOSIS, + classifyTransportFailure, + originOf, + probeReachable, +} from "../src/transport-diagnosis.ts"; + +const FETCH_FAILURE = new TypeError("Failed to fetch"); + +test("a host that answers a probe but refused the request is a policy refusal", () => { + // The exact shape of the incident: mediator up, endpoint healthy for a + // native client, extension origin absent from `cors_allow_origin`. + const d = classifyTransportFailure({ + error: FETCH_FAILURE, + reachable: "reachable", + host: "https://mediator.example", + origin: "chrome-extension://abc", + }); + assert.equal(d.code, TRANSPORT_DIAGNOSIS.originNotAllowed); + // The origin has to appear verbatim — an operator pastes it into a config. + assert.match(d.detail, /chrome-extension:\/\/abc/); + assert.match(d.remediation ?? "", /cors_allow_origin/); +}); + +test("the remediation does not promise a fix the wallet cannot deliver", () => { + // A host permission exempts the REST handshake but not the WebSocket + // upgrade, which the mediator checks server-side. Telling someone to grant + // a permission would send them down a path that half-works. + const d = classifyTransportFailure({ error: FETCH_FAILURE, reachable: "reachable" }); + assert.doesNotMatch(d.remediation ?? "", /permission/i); + assert.match(d.remediation ?? "", /WebSocket/); +}); + +test("a host that answers nothing is unreachable, not refused", () => { + const d = classifyTransportFailure({ + error: FETCH_FAILURE, + reachable: "unreachable", + host: "https://mediator.example", + }); + assert.equal(d.code, TRANSPORT_DIAGNOSIS.unreachable); +}); + +test("without a probe the failure is not guessed at", () => { + // Claiming a cause on no evidence is how a diagnostic starts misleading + // people; `unknown` is the honest answer and still beats `Failed to fetch`. + const d = classifyTransportFailure({ error: FETCH_FAILURE, reachable: "unprobed" }); + assert.equal(d.code, TRANSPORT_DIAGNOSIS.unknown); +}); + +test("an error that is not a network failure means the mediator answered", () => { + // `authenticateToMediator` throws a descriptive Error when the mediator + // replies with a refusal. That is not a connectivity story and must not be + // told as one — and the mediator's own words survive. + const d = classifyTransportFailure({ + error: new Error("mediator-auth: challenge response missing session_id"), + reachable: "reachable", + }); + assert.equal(d.code, TRANSPORT_DIAGNOSIS.rejected); + assert.match(d.detail, /missing session_id/); +}); + +test("a timeout is its own cause, not a refusal", () => { + // Reachability says "reachable" here precisely because a probe that lands + // must not turn a slow host into an accusation about its CORS config. + const timeout = new DOMException("The operation timed out.", "TimeoutError"); + const d = classifyTransportFailure({ error: timeout, reachable: "reachable" }); + assert.equal(d.code, TRANSPORT_DIAGNOSIS.timeout); +}); + +test("probe reports reachable when anything answers at all", async () => { + // Opaque responses carry no status, so the probe must not try to read one. + // A 405 from a POST-only auth route is a perfectly good "yes, I am here". + const calls: Array<[string, RequestInit | undefined]> = []; + const fake = (async (url: string | URL | Request, init?: RequestInit) => { + calls.push([String(url), init]); + // A real Response, not an `{ ok }` literal (see CLAUDE.md). 405 is what + // the mediator's POST-only auth route actually answers a GET with — and + // the probe must not care, because an opaque response has no readable + // status at all. Note `status: 0` is not constructible here; a stub that + // tried it would throw and be misread as a broken probe. + return new Response(null, { status: 405 }); + }) as unknown as typeof fetch; + + assert.equal(await probeReachable("https://mediator.example/auth", fake), "reachable"); + assert.equal(calls[0]?.[0], "https://mediator.example/auth"); + assert.equal(calls[0]?.[1]?.mode, "no-cors"); + // A cached opaque response would answer for a host that has since gone away. + assert.equal(calls[0]?.[1]?.cache, "no-store"); +}); + +test("probe reports unreachable when the request never lands", async () => { + const fake = (async () => { + throw new TypeError("Failed to fetch"); + }) as unknown as typeof fetch; + assert.equal(await probeReachable("https://mediator.example/auth", fake), "unreachable"); +}); + +test("a probe that cannot run says so rather than blaming the host", async () => { + const fake = (async () => { + throw new Error("probe misconfigured"); + }) as unknown as typeof fetch; + assert.equal(await probeReachable("https://mediator.example/auth", fake), "unprobed"); +}); + +test("originOf keeps the scheme and drops the path", () => { + assert.equal(originOf("https://mediator.example/mediator/v1/authenticate"), "https://mediator.example"); + assert.equal(originOf("not a url"), undefined); + assert.equal(originOf(undefined), undefined); +}); diff --git a/packages/extension/tests/transports.test.mts b/packages/extension/tests/transports.test.mts index 27010fd..827704a 100644 --- a/packages/extension/tests/transports.test.mts +++ b/packages/extension/tests/transports.test.mts @@ -8,7 +8,9 @@ import assert from "node:assert/strict"; import { activeTransport, advertisedTransports, + isObserved, transportSummary, + unavailableTransports, } from "../src/transports.ts"; const ALL = { @@ -50,3 +52,66 @@ test("summary names the active one and what else is idle", () => { assert.equal(transportSummary({ mediatorDid: "did:x" }, true), "DIDComm"); assert.equal(transportSummary({}, true), "no transport"); }); + +// ─── Health: advertisement is not availability ─── +// +// The gap these close is the one that shipped: a mediator refusing the +// extension's origin took TSP and DIDComm out, and the status line went on +// naming TSP because the stored connection still advertised it. + +const DOWN = { state: "down" as const, code: "mediator/origin-not-allowed" }; +const UP = { state: "up" as const }; + +test("a transport observed down is never named as the active one", () => { + assert.equal(activeTransport(ALL, true, { TSP: DOWN }), "DIDComm"); + assert.equal(activeTransport(ALL, true, { TSP: DOWN, DIDComm: DOWN }), "REST"); +}); + +test("every advertised transport down is no transport, not a false claim", () => { + assert.equal( + activeTransport(ALL, true, { TSP: DOWN, DIDComm: DOWN, REST: DOWN }), + undefined, + ); +}); + +test("absent health reproduces the advertisement-only answer", () => { + // Nothing observed yet is the state on a wallet that has done no work. The + // old answer is the right guess there — it just must not be stated as fact. + assert.equal(activeTransport(ALL, true, {}), activeTransport(ALL, true)); + assert.equal(activeTransport(ALL, false, {}), "DIDComm"); +}); + +test("REST reporting `unknown` does not take it out of the running", () => { + // `unknown` is what a built-but-unproven RestChannel records. Treating it + // as a failure would strand a wallet whose only transport is REST. + assert.equal( + activeTransport(ALL, true, { TSP: DOWN, DIDComm: DOWN, REST: { state: "unknown" } }), + "REST", + ); +}); + +test("isObserved separates `nothing seen yet` from `seen and fine`", () => { + assert.equal(isObserved({}), false); + assert.equal(isObserved({ REST: { state: "unknown" } }), false); + assert.equal(isObserved({ TSP: UP }), true); + assert.equal(isObserved({ TSP: DOWN }), true); +}); + +test("unavailable list names what is advertised but broken", () => { + assert.deepEqual(unavailableTransports(ALL, { TSP: DOWN, DIDComm: DOWN }), ["TSP", "DIDComm"]); + assert.deepEqual(unavailableTransports(ALL, {}), []); + // Not advertised cannot be unavailable — it was never on offer. + assert.deepEqual(unavailableTransports({ restBaseUrl: "https://h" }, { TSP: DOWN }), []); +}); + +test("summary says what is broken, not just what is idle", () => { + assert.equal( + transportSummary(ALL, true, { TSP: DOWN, DIDComm: DOWN }), + "REST · TSP, DIDComm unavailable", + ); + assert.equal(transportSummary(ALL, true, { TSP: DOWN }), "DIDComm · REST available · TSP unavailable"); + assert.equal( + transportSummary(ALL, true, { TSP: DOWN, DIDComm: DOWN, REST: DOWN }), + "no transport · TSP, DIDComm, REST unavailable", + ); +});