Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 38 additions & 2 deletions packages/extension/src/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -170,6 +171,12 @@ export function AppShell({ advanced, vault }: { advanced: React.ReactNode; vault
const [pane, setPane] = useState<PaneId>(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,
Expand Down Expand Up @@ -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). */}
<RoleStatus
label="Inbox"
pill={
!connection ? (
<Pill tone="off">—</Pill>
) : inbox === "live" ? (
<Pill tone="ok">Live</Pill>
) : inbox === "connecting" ? (
<Pill tone="warn">Connecting</Pill>
) : (
<Pill tone="warn">Offline</Pill>
)
}
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"}
/>
<RoleStatus
label="Approval"
Expand Down
61 changes: 61 additions & 0 deletions packages/extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import { WebAuthnPrfSecretWrap } from "./webauthn-prf-wrap.js";
import {
OFFSCREEN_DIDCOMM_LOGIN,
OFFSCREEN_GET_STATUS,
OFFSCREEN_TRANSPORT_HEALTH,
OFFSCREEN_RUN_DIAGNOSTICS,
OFFSCREEN_CREATE_CONTEXT,
OFFSCREEN_DERIVE_SIGNING_KEY_ID,
OFFSCREEN_HOLDER_STATE,
Expand Down Expand Up @@ -69,6 +71,8 @@ import {
RUNTIME_LOGIN,
RUNTIME_LOGIN_DIDCOMM,
RUNTIME_MEDIATOR_STATUS,
RUNTIME_TRANSPORT_HEALTH,
RUNTIME_RUN_DIAGNOSTICS,
RUNTIME_EMIT_WALLET_EVENT,
type WalletEventKind,
RUNTIME_CREATE_CONTEXT,
Expand Down Expand Up @@ -97,6 +101,9 @@ import {
RUNTIME_VERIFY_RP_DID,
RUNTIME_WALLET_DEFAULTS,
type MediatorStatusResult,
type RuntimeTransportHealthResponse,
type RuntimeRunDiagnosticsRequest,
type RuntimeRunDiagnosticsResponse,
type OffscreenDidcommLoginRequest,
type OffscreenSetWakeResponse,
type OffscreenStepUpVtaRequest,
Expand Down Expand Up @@ -1097,6 +1104,42 @@ async function handleMediatorStatus(): Promise<RuntimeMediatorStatusResponse> {
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<RuntimeTransportHealthResponse> {
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<RuntimeRunDiagnosticsResponse> {
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.
Expand Down Expand Up @@ -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) {
Expand Down
117 changes: 117 additions & 0 deletions packages/extension/src/bridge-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading