From 99e0b6fc6fb4b8ca2d3ca452e2ef3fa3866e7f29 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 30 Aug 2026 10:25:27 +0200 Subject: [PATCH] feat(transports): report the transport actually in use, and why one is not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three linked defects, found while diagnosing a wallet whose mediator refused its origin. The wallet had silently fallen back to REST, its inbox was dark, and nothing in the UI said so — the status line was naming TSP the whole time. Advertisement is not availability. `buildVtaSession` skips a channel whose mediator it cannot reach and falls through, but `activeTransport` derived its answer from the stored connection alone, so the UI named transports that had never carried a byte. It now takes a `TransportHealth` recorded by `buildVtaSession` at the two places that actually decide. REST records `unknown` rather than `up`: a RestChannel is built from a URL without contacting anything, so construction is not evidence — but `unknown` is not a failure and never removes REST from selection. With nothing observed the answer is unchanged; only the label moves, from "Transport in use" to "Transport expected". A CORS refusal is unreadable, so it is inferred. Chrome gives JS a bare `TypeError: Failed to fetch` for a policy refusal and a dead host alike; the reason goes to the devtools console and nowhere an extension can read. `transport-diagnosis.ts` infers it from one bit: a request that fails at the network layer against a host that answers an opaque `no-cors` probe was refused by policy, not by the network. Errors are discriminated structurally (`TypeError`, `DOMException.name`), never on message text (R3.7). The remediation names the mediator's own config key and deliberately never suggests a host permission, which would exempt the REST handshake but not the WebSocket upgrade the mediator checks server-side — a half-fix sends people down the wrong path. The inbox has its own row. "Connected" and "can be reached" are different states, and only the second decides whether an approval request ever arrives (R7.2). A wallet that has fallen back to REST looks entirely healthy on the operating row while nothing can be pushed to it. Adds a read-only connection self-test whose output is plain text meant to be pasted to whoever runs the failing service. It exists because curl cannot reproduce this class of fault: 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. Also corrects the claim in host-permissions.ts that the mediator is "not subject to CORS". Its WebSocket is not; the auth handshake that must precede it is two ordinary fetches, and that is what fails. Signed-off-by: Glenn Gore --- CLAUDE.md | 56 +++ packages/extension/src/app-shell.tsx | 40 +- packages/extension/src/background.ts | 61 +++ packages/extension/src/bridge-protocol.ts | 117 +++++ packages/extension/src/diagnostics-panel.tsx | 147 +++++++ packages/extension/src/diagnostics-report.ts | 69 +++ packages/extension/src/host-permissions.ts | 16 +- packages/extension/src/network-pane.tsx | 45 +- packages/extension/src/offscreen.ts | 398 +++++++++++++++++- packages/extension/src/setup-pane.tsx | 39 +- packages/extension/src/transport-diagnosis.ts | 195 +++++++++ packages/extension/src/transports.ts | 130 +++++- .../extension/src/use-transport-health.ts | 74 ++++ .../tests/diagnostics-report.test.mts | 93 ++++ .../tests/transport-diagnosis.test.mts | 118 ++++++ packages/extension/tests/transports.test.mts | 65 +++ 16 files changed, 1625 insertions(+), 38 deletions(-) create mode 100644 packages/extension/src/diagnostics-panel.tsx create mode 100644 packages/extension/src/diagnostics-report.ts create mode 100644 packages/extension/src/transport-diagnosis.ts create mode 100644 packages/extension/src/use-transport-health.ts create mode 100644 packages/extension/tests/diagnostics-report.test.mts create mode 100644 packages/extension/tests/transport-diagnosis.test.mts 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", + ); +});