diff --git a/packages/extension/src/active-vta.ts b/packages/extension/src/active-vta.ts index 592cbac..7c73e12 100644 --- a/packages/extension/src/active-vta.ts +++ b/packages/extension/src/active-vta.ts @@ -54,49 +54,39 @@ export function parseAllVtaDids(raw: unknown): string[] { } } -/** The DIDComm mediator advertised by an onboarded agent, for a wallet that - * has no inbox of its own yet. +/** Every onboarded agent's advertised DIDComm mediator, keyed by agent DID. * - * Onboarding records each agent's advertised mediator on its `Connection` - * (`store.ts`) and, since the fix that removed the hardcoded default, writes - * it to the wallet's inbox setting too. Wallets onboarded BEFORE that fix - * have the connection but no setting — they were running on the hardcoded - * demo relay — and re-onboarding to acquire one is not a fair ask: it mints - * a fresh holder DID that every RP ACL must then be re-granted. So the - * backfill reads the answer already on disk. This mirrors `tspMediatorDid`, - * which the transport refresh backfills onto existing connections for the - * same reason. + * Onboarding records each agent's advertised relay on its `Connection` + * (`store.ts`) and, since the fix that removed the hardcoded default, in the + * wallet's per-agent inbox map too. Wallets onboarded before that have the + * connection but no inbox — they were running on the hardcoded demo relay — + * and re-onboarding to acquire one is not a fair ask: it mints a fresh holder + * DID that every RP ACL must then be re-granted. So the boot adopt reads the + * answer already on disk. This mirrors `tspMediatorDid`, backfilled onto + * existing connections for the same reason. * - * The active VTA's mediator wins; otherwise the first agent that advertises - * one, so a single-agent wallet backfills whether or not the active pointer - * has been set. Returns `undefined` when no agent advertises a mediator — - * a REST- or TSP-only deployment, where there is genuinely nothing to adopt. */ -export async function readAgentMediatorDid(): Promise { + * Agents advertising no mediator are absent from the map rather than present + * with an empty value: there is genuinely nothing to adopt for them. */ +export async function readAgentMediatorDids(): Promise> { const stored = await chrome.storage.local.get("pnm-connection/v3"); - return parseAgentMediatorDid(stored["pnm-connection/v3"]); + return parseAgentMediatorDids(stored["pnm-connection/v3"]); } -export function parseAgentMediatorDid(raw: unknown): string | undefined { - if (typeof raw !== "string") return undefined; +export function parseAgentMediatorDids(raw: unknown): Record { + if (typeof raw !== "string") return {}; try { const parsed = JSON.parse(raw) as { - state?: { - connections?: { - activeVtaDid?: string | null; - vtas?: Record; - }; - }; + state?: { connections?: { vtas?: Record } }; }; - const conns = parsed.state?.connections; - const vtas = conns?.vtas ?? {}; - const active = conns?.activeVtaDid ? vtas[conns.activeVtaDid]?.mediatorDid : undefined; - if (typeof active === "string" && active) return active; - for (const entry of Object.values(vtas)) { - if (typeof entry?.mediatorDid === "string" && entry.mediatorDid) return entry.mediatorDid; + const out: Record = {}; + for (const [vtaDid, entry] of Object.entries(parsed.state?.connections?.vtas ?? {})) { + if (typeof entry?.mediatorDid === "string" && entry.mediatorDid) { + out[vtaDid] = entry.mediatorDid; + } } - return undefined; + return out; } catch { - return undefined; + return {}; } } diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 466ed96..437ee0a 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -14,7 +14,7 @@ import { parseAllVtaDids, readActiveHolderDid, readActiveVtaDid, - readAgentMediatorDid, + readAgentMediatorDids, readAllVtaDids, } from "./active-vta.js"; import { checkOriginPin, pinOrigin } from "./origin-pin.js"; @@ -181,7 +181,7 @@ import { type RuntimeWalletDefaultsResponse, type VerifyRpDidResult, } from "./bridge-protocol.js"; -import { getSettings, inboxToAdopt, setSettings } from "./config.js"; +import { clearLegacyInbox, getSettings, inboxFor, inboxToAdopt, setInbox, setSettings } from "./config.js"; import { providerMatches, syncProviderRegistration } from "./content-registration.js"; import { AGENT_NAME_UNREADABLE, @@ -470,6 +470,62 @@ async function ensurePushWake(): Promise { let _lastInboundVtaDids: string[] = []; let _lastActiveVtaDid: string | null = null; +/** + * Give every onboarded agent an inbox, for wallets that predate the map. + * + * Two sources, both already on disk, so this costs no network and can run on + * every worker spin-up: + * + * - The **legacy single setting.** Before inboxes were per-agent there was + * one `mediatorDid` for the whole wallet. An `operator` one was a person's + * choice and is carried over to the ACTIVE agent — the one they were + * looking at when they typed it. Anything else is dropped rather than + * spread across every agent: it was most likely the removed hardcoded demo + * relay, which `setSettings` used to persist as though it had been chosen + * (it merged the *defaulted* settings and wrote them back, so any unrelated + * write — the passkey lock, the TSP toggle — froze the default into a value + * nobody picked). + * - The **persisted connection**, which carries each agent's advertised + * mediator from onboarding. + * + * `inboxToAdopt` decides per agent, so an operator pin is never overwritten + * and an agent-sourced entry is not re-adopted on every boot. + */ +async function adoptMissingInboxes(vtaDids: readonly string[]): Promise { + const settings = await getSettings(); + const advertised = await readAgentMediatorDids(); + + // One-time carry-over of the legacy wallet-wide setting. + if (settings.mediatorDid || settings.mediatorDidSource) { + const activeVtaDid = await readActiveVtaDid(); + if (settings.mediatorDidSource === "operator" && settings.mediatorDid && activeVtaDid) { + await setInbox(activeVtaDid, { did: settings.mediatorDid, source: "operator" }); + console.info("[pnm inbound] carried the pinned relay over to", activeVtaDid); + } + // Clear the legacy keys either way, so this runs once. + await clearLegacyInbox(); + } + + const current = await getSettings(); + for (const vtaDid of vtaDids) { + const adopt = inboxToAdopt(inboxFor(current, vtaDid) ?? {}, advertised[vtaDid]); + if (adopt) { + await setInbox(vtaDid, { did: adopt, source: "agent" }); + console.info("[pnm inbound] inbox adopted from agent:", vtaDid, "→", adopt); + } else if (!inboxFor(current, vtaDid) && !advertised[vtaDid]) { + // Neither an inbox nor anything to adopt: said out loud, because the + // alternative is an agent that silently cannot reach this wallet and a + // boot that looks like it did its job. Refreshing that agent's + // transports re-resolves its DID document and fills the connection in. + console.warn( + "[pnm inbound]", + vtaDid, + "has no inbox relay and advertises none to adopt — it cannot reach this wallet.", + ); + } + } +} + async function startInboundListener(): Promise { // Multi-VTA: ship the full list of onboarded VTAs. The offscreen // reconciles — one warm inbox session per holder identity. Empty @@ -478,35 +534,8 @@ async function startInboundListener(): Promise { const vtaDids = (await readAllVtaDids()).sort(); _lastInboundVtaDids = vtaDids; - // Backfill the inbox for a wallet onboarded before onboarding wrote one. - // Those wallets ran on a hardcoded demo relay that has since been removed, - // so without this they come up with no inbox and the only documented route - // back — re-onboarding — mints a new holder DID and invalidates every RP - // ACL. The agent's mediator is already on the persisted connection; adopting - // it applies the same rule onboarding now applies, at the one place that - // runs on every boot. `inboxToAdopt` declines when an inbox is already set, - // so this never moves an address in use. - const settings = await getSettings(); - const adopt = inboxToAdopt( - { did: settings.mediatorDid, source: settings.mediatorDidSource }, - await readAgentMediatorDid(), - ); - if (adopt) { - await setSettings({ mediatorDid: adopt, mediatorDidSource: "agent" }); - console.info("[pnm inbound] inbox mediator adopted from agent:", adopt); - } else if (!settings.mediatorDidSource && vtaDids.length > 0) { - // Nothing adopted and nothing on record choosing what is there: the - // persisted connection carries no mediator to adopt. Said out loud, - // because the alternative is a wallet that silently keeps whatever relay - // it had and a boot that looks like it did its job. Refreshing transports - // (Setup → the agent's transports) re-resolves the DID document and fills - // the connection in. - console.warn( - "[pnm inbound] inbox relay is unattributed and no onboarded agent " + - "advertises one to adopt — refresh the agent's transports, or set a " + - "relay under Setup → Message routing.", - ); - } + await adoptMissingInboxes(vtaDids); + // Seed _lastActiveVtaDid too — otherwise the first chrome.storage // onChanged callback would see _lastActiveVtaDid=null and emit a // spurious connectionchanged. @@ -1406,68 +1435,74 @@ async function handleWalletLockState( } /** - * Follow the agent if it has moved its relay. + * Follow each agent that has moved its relay. * - * Distinct from the adopt-when-blank backfill in `startInboundListener`, and - * for a reason that only shows up when you ask how inbound actually arrives: - * **a v4 holder is a `did:key`, which carries no service endpoint, and the - * wallet publishes its inbox to nobody.** There is no discovery path. So an - * executor pushing to this wallet can only hand the message to a mediator it - * already knows — its own — and the wallet hears it only if it is listening - * there. The inbox is not an independent address the wallet owns; it is - * "wherever my agent's relay is", and a wallet pinned to yesterday's mediator - * goes dark while every check still reports green. + * Distinct from the adopt-when-blank pass in `startInboundListener`, and for a + * reason that only shows up when you ask how inbound actually arrives: **a v4 + * holder is a `did:key`, which carries no service endpoint, and the wallet + * publishes its inbox to nobody.** There is no discovery path. So an executor + * pushing to this wallet can only hand the message to a mediator it already + * knows — its own — and the wallet hears it only if it is listening there. An + * inbox is not an address the wallet owns; it is "wherever that agent's relay + * is", and an agent pinned to yesterday's mediator goes dark while every check + * still reports green. * - * So an `agent`-sourced inbox FOLLOWS the agent's DID document. An + * So an `agent`-sourced inbox FOLLOWS its agent's DID document. An * `operator`-sourced one never moves: someone running more than one relay - * chose it, and this is exactly the override that has to survive. + * chose it, and that override is the whole reason provenance exists. * * Run on browser startup and on update, not per worker spin-up. MV3 respawns - * the worker on almost any event, and a DID-document fetch on each of those - * would be a lot of network for a value that changes when an operator - * redeploys a mediator. The blank/unattributed backfill still runs every - * spin-up — it reads the persisted connection and costs nothing. + * the worker on almost any event, and a DID-document fetch per agent on each + * would be a lot of network for a value that changes when someone redeploys a + * mediator. The adopt-when-blank pass still runs every spin-up — it reads the + * persisted connection and costs nothing. */ async function followAgentInbox(): Promise { + const vtaDids = await readAllVtaDids(); + if (vtaDids.length === 0) return; const settings = await getSettings(); - if (settings.mediatorDidSource === "operator") return; // pinned, deliberately + let moved = false; - const vtaDid = await readActiveVtaDid(); - if (!vtaDid) return; + for (const vtaDid of vtaDids) { + const held = inboxFor(settings, vtaDid); + if (held?.source === "operator") continue; // pinned, deliberately - let live: string | undefined; - try { - const resp = await handleRefreshVtaTransports({ - type: RUNTIME_REFRESH_VTA_TRANSPORTS, - vtaDid, - }); - if (!resp.ok) throw new Error(resp.error); - live = resp.result.mediatorDid; - } catch (e) { - // A DID document we could not read says nothing about where the relay is, - // so it must not be read as "it moved to nowhere". Keep what we have. - console.warn("[pnm inbound] could not re-resolve the agent's relay:", e); - return; - } + let live: string | undefined; + try { + const resp = await handleRefreshVtaTransports({ + type: RUNTIME_REFRESH_VTA_TRANSPORTS, + vtaDid, + }); + if (!resp.ok) throw new Error(resp.error); + live = resp.result.mediatorDid; + } catch (e) { + // A DID document we could not read says nothing about where the relay + // is, so it must not be read as "it moved to nowhere". Keep what we have + // — and keep going: one unreachable agent must not stop the others being + // checked. + console.warn("[pnm inbound] could not re-resolve the relay for", vtaDid, e); + continue; + } - if (!live) { - console.warn( - "[pnm inbound] the agent advertises no DIDComm relay — nothing can be " + - "pushed to this wallet through it.", - ); - return; + if (!live) { + console.warn( + "[pnm inbound]", + vtaDid, + "advertises no DIDComm relay — nothing can be pushed to this wallet through it.", + ); + continue; + } + if (live === held?.did) continue; + + await setInbox(vtaDid, { did: live, source: "agent" }); + moved = true; + console.info("[pnm inbound]", vtaDid, "moved its relay:", held?.did ?? "(none)", "→", live); } - if (live === settings.mediatorDid) return; - await setSettings({ mediatorDid: live, mediatorDidSource: "agent" }); - console.info( - "[pnm inbound] the agent moved its relay:", - settings.mediatorDid ?? "(none)", - "→", - live, - ); - // Re-open on the relay that can actually reach us. - await startInboundListener(); + // One reconcile after the sweep, not one per agent: each call re-reads the + // whole map and reopens what is missing, so doing it inside the loop would + // repeat that work for every agent that moved. + if (moved) await startInboundListener(); } async function handleRefreshVtaTransports( diff --git a/packages/extension/src/bridge-protocol.ts b/packages/extension/src/bridge-protocol.ts index f41fd10..3d0a7d8 100644 --- a/packages/extension/src/bridge-protocol.ts +++ b/packages/extension/src/bridge-protocol.ts @@ -555,8 +555,8 @@ export const MEDIATOR_REQUIRED = "wallet/mediator-required"; /** Stable code meaning "this wallet has no inbox mediator configured, so * nothing can be pushed to it". Distinct from `MEDIATOR_REQUIRED`, which is * about the mediator an *onboarding* needs to route through: this one is - * about the wallet's own inbox, and it is reachable only through a wallet - * that was never onboarded or whose setting was cleared by hand. Matched on + * about an agent's inbox on this wallet, and it is reachable only for an + * agent that advertised no relay or whose entry was cleared by hand. Matched on * directly, never by parsing the message (R3.7). */ export const INBOX_NOT_CONFIGURED = "wallet/inbox-not-configured"; @@ -1584,11 +1584,12 @@ export interface OffscreenVerifyDidRequest { export interface OffscreenStartInboundRequest { target: typeof OFFSCREEN_TARGET; type: typeof OFFSCREEN_START_INBOUND; - /** Which VTAs' holders should be listening on the wallet's inbox - * mediator. The offscreen reconciles: opens missing inbound - * sessions for VTAs in this list, closes existing sessions for - * VTAs no longer present (operator forgot them). Empty list closes - * all inbound listeners — used on fresh-wipe / no-VTA state. */ + /** Which VTAs should be listening. The offscreen reconciles, opening one + * session per (agent, that agent's own relay) — the relay comes from the + * per-agent inbox map in settings, which the offscreen reads directly, not + * from this message. Closes sessions for VTAs no longer present (operator + * forgot them). Empty list closes all inbound listeners — used on + * fresh-wipe / no-VTA state. */ vtaDids: string[]; } diff --git a/packages/extension/src/config.ts b/packages/extension/src/config.ts index b3687c5..1dae03f 100644 --- a/packages/extension/src/config.ts +++ b/packages/extension/src/config.ts @@ -5,10 +5,11 @@ // while IndexedDB is available in every extension context (and is already the // holder identity's backing store). // -// The mediator DID is the wallet's inbox: the relay an RP or executor pushes -// to when it needs to reach this wallet. It is written by onboarding from the -// agent's own advertised DIDComm mediator, and only overridden by hand by an -// operator running more than one relay. +// An inbox is the relay an executor pushes to when it needs to reach this +// wallet, and there is one PER ONBOARDED AGENT — see `inboxes` below for why +// a single wallet-wide relay could only ever serve one agent. Each is written +// by onboarding from that agent's advertised DIDComm mediator, and overridden +// by hand only by an operator running more than one relay. // // It used to be described here as "baked into the holder's `did:peer:2` // service endpoint at first mint, so changing it mints a NEW wallet DID". @@ -19,32 +20,44 @@ import { IndexedDBKVStore } from "@openvtc/pnm-core"; -/** Who put the inbox mediator there. Absent on records written before this +/** Who put an inbox mediator there. Absent on records written before this * existed — treated as "nobody is on record", which is the truth. */ export type InboxSource = "agent" | "operator"; +/** One agent's inbox, and who chose it. `agent` follows that agent's DID + * document; `operator` is pinned and never moved for them. */ +export interface InboxRecord { + did: string; + source: InboxSource; +} + export interface WalletSettings { /** - * The wallet's inbox: the mediator an RP or executor pushes to in order to - * reach this wallet, and the relay the wallet authenticates to for DIDComm - * login. + * The wallet's inboxes, **one per onboarded agent**. + * + * An inbox is the mediator an executor pushes to in order to reach this + * wallet, and the relay that agent's holder authenticates to. It is keyed by + * VTA DID because it has to be: a v4 holder is a `did:key` with no service + * endpoint and the wallet publishes its relay to nobody, so an executor can + * only hand a message to a mediator it already knows — its own. A wallet + * onboarded at two agents on different mediators must therefore listen at + * both, as each agent's holder. This was a single `mediatorDid` for the + * whole wallet, which meant whichever agent the value happened to name was + * reachable and every other agent's pushes were silently lost. * - * **Unset until onboarding writes it**, and unset is a real state, not a - * missing default. It previously fell back to a hardcoded demo mediator on - * a domain no deployment here runs, so every wallet that never touched the - * advanced routing field ran its inbox through a third party's host while - * Setup told the operator it had been "set up automatically from your - * agent". A default that is wrong everywhere but one workspace is worse - * than none: absent, the wallet can say the inbox is not configured; wrong, - * it can only appear to work. (R5 — config absence is the restrictive case.) + * Absent or missing an entry is a real state, not a missing default: that + * agent cannot reach this wallet, and the self-test says so. The value that + * used to fill the gap was a hardcoded demo relay in someone else's + * deployment. (R5 — config absence is the restrictive case.) */ - mediatorDid?: string; + inboxes?: Record; - /** Provenance for `mediatorDid`: `agent` when onboarding (or the boot - * backfill) adopted the agent's advertised relay, `operator` when a person - * typed it into Setup → Message routing. Read by `inboxToAdopt`; see the - * note there for why the value alone was not enough to go on. */ + /** @deprecated Superseded by {@link inboxes}. Read once by the boot + * migration in `background.ts` and then cleared. Never write it. */ + mediatorDid?: string; + /** @deprecated Superseded by {@link inboxes}. See {@link mediatorDid}. */ mediatorDidSource?: InboxSource; + /** Optional default VTA DID prefilled into the step-up flow. */ defaultStepUpVtaDid?: string; /** Optional default VTA mediator DID prefilled into the step-up flow. */ @@ -165,9 +178,9 @@ export function inboxToAdopt( if (!advertised) return undefined; // A person chose this relay. Never overridden. if (current.source === "operator") return undefined; - // Already adopted from an agent. Left alone even when the active agent - // changes: the inbox is an address others route to, and chasing the active - // VTA would move it out from under them. + // Already adopted from this agent. Moving it when the agent moves its relay + // is `followAgentInbox`'s job in `background.ts`, which re-resolves the DID + // document; this function only ever fills a blank. if (current.did && current.source === "agent") return undefined; // Either nothing is set, or something is set that no one recorded choosing — // which is every record written before provenance existed. Adopt. @@ -184,6 +197,18 @@ const SETTINGS_KEY = "pnm/settings/v1"; * the *defaulted* view and wrote it back, so every read-modify-write turned * derived defaults into persisted values that later code could no longer tell * apart from choices. A write must merge onto what is on disk. */ +/** Keep only the entries that are actually an `InboxRecord`. */ +function validInboxes(raw: Record): Record { + const out: Record = {}; + for (const [vtaDid, value] of Object.entries(raw)) { + const rec = value as Partial | null; + if (!rec || typeof rec.did !== "string" || !rec.did) continue; + if (rec.source !== "agent" && rec.source !== "operator") continue; + out[vtaDid] = { did: rec.did, source: rec.source }; + } + return out; +} + async function storedSettings(): Promise> { return (await new IndexedDBKVStore().get>(SETTINGS_KEY)) ?? {}; } @@ -198,6 +223,10 @@ export async function getSettings(): Promise { const encryptHolderSecret = typeof s?.encryptHolderSecret === "boolean" ? s.encryptHolderSecret : false; return { + // Validated on read rather than trusted: a half-written record or one + // from another build must read as absent, not reach the session opener as + // a relay DID that is actually a number. + ...(s?.inboxes ? { inboxes: validInboxes(s.inboxes) } : {}), ...(s?.mediatorDid ? { mediatorDid: s.mediatorDid } : {}), ...(s?.mediatorDidSource === "agent" || s?.mediatorDidSource === "operator" ? { mediatorDidSource: s.mediatorDidSource } @@ -228,3 +257,48 @@ export async function setSettings(patch: Partial): Promise const stored = await storedSettings(); await new IndexedDBKVStore().put(SETTINGS_KEY, { ...stored, ...patch }); } + +/** This agent's inbox, or `undefined` when it has none — meaning that agent + * cannot reach this wallet. */ +export function inboxFor(settings: WalletSettings, vtaDid: string): InboxRecord | undefined { + return settings.inboxes?.[vtaDid]; +} + +/** + * Write one agent's inbox without disturbing the others. + * + * `setSettings` merges shallowly, so handing it a whole `inboxes` object would + * drop every agent absent from the copy the caller happened to be holding — + * and the symptom of that is another agent's pushes going quietly nowhere, + * which is the failure this map exists to end. Read-modify-write of the map + * belongs in one place. + */ +export async function setInbox(vtaDid: string, record: InboxRecord): Promise { + const stored = await storedSettings(); + await new IndexedDBKVStore().put(SETTINGS_KEY, { + ...stored, + inboxes: { ...(stored.inboxes ?? {}), [vtaDid]: record }, + }); +} + +/** Remove the pre-per-agent `mediatorDid` / `mediatorDidSource` keys. + * + * A dedicated deleter rather than `setSettings({ mediatorDid: undefined })`: + * under `exactOptionalPropertyTypes` that is not even expressible, and a + * shallow merge of `undefined` would write the key back as present-and-empty + * rather than removing it — leaving the migration to run on every boot. */ +export async function clearLegacyInbox(): Promise { + const stored = await storedSettings(); + delete stored.mediatorDid; + delete stored.mediatorDidSource; + await new IndexedDBKVStore().put(SETTINGS_KEY, stored); +} + +/** Drop an agent's inbox — used when the operator forgets that agent, so a + * stale relay does not linger and get reported as reachable. */ +export async function forgetInbox(vtaDid: string): Promise { + const stored = await storedSettings(); + const inboxes = { ...(stored.inboxes ?? {}) }; + delete inboxes[vtaDid]; + await new IndexedDBKVStore().put(SETTINGS_KEY, { ...stored, inboxes }); +} diff --git a/packages/extension/src/holder.ts b/packages/extension/src/holder.ts index 87abbd2..22f0add 100644 --- a/packages/extension/src/holder.ts +++ b/packages/extension/src/holder.ts @@ -7,26 +7,6 @@ import { import { getSettings } from "./config.js"; import { WebAuthnPrfSecretWrap } from "./webauthn-prf-wrap.js"; -/** The wallet's inbox mediator DID — the relay RPs and executors push to for - * inbound DIDComm (RP-initiated `confirm` requests, `task-consent/request`), - * and the one the wallet authenticates to for DIDComm login, so it is already - * a registered recipient there. - * - * **`undefined` until onboarding writes it from the agent's advertised - * mediator**, and callers must treat that as "this wallet has no inbox" - * rather than substituting one. There was a hardcoded fallback here; it - * pointed at a demo host belonging to no deployment in use, and silently - * became the inbox of every wallet whose operator never opened the advanced - * routing field. See `config.ts`. - * - * The old note about this being baked into the holder `did:peer:2` at first - * mint no longer applies — a v4 holder is a VTA-minted `did:key` and carries - * no mediator. Changing the inbox re-registers an address; it does not mint - * an identity. */ -export async function getWalletMediatorDid(): Promise { - return (await getSettings()).mediatorDid; -} - /** * Build the secret wrap the load path should use, given the * current `encryptHolderSecret` setting. Returns `undefined` diff --git a/packages/extension/src/network-pane.tsx b/packages/extension/src/network-pane.tsx index 7b284f3..0f08c62 100644 --- a/packages/extension/src/network-pane.tsx +++ b/packages/extension/src/network-pane.tsx @@ -226,7 +226,7 @@ export function NetworkPane() { // cannot approve on your behalf precisely because this key is local. positions["approver"] = { col: 0, row: APPROVER_ROW }; // Requests originate at the agent and reach the approver through the - // wallet's own inbox mediator, so the arrow points inward, not outward. + // agent's inbox relay, so the arrow points inward, not outward. edges.push({ from: mediator ? "mediator" : "agent", to: "approver", diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index f4900b0..4282cb5 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -77,8 +77,8 @@ import { verifyDid, } from "@openvtc/pnm-core"; import { base64url } from "@openvtc/vti-didcomm-js"; -import { getSettings, inboxToAdopt, setSettings } from "./config.js"; -import { getWalletMediatorDid, loadHolder } from "./holder.js"; +import { forgetInbox, getSettings, inboxFor, inboxToAdopt, setInbox } from "./config.js"; +import { loadHolder } from "./holder.js"; import { WebAuthnPrfSecretWrap } from "./webauthn-prf-wrap.js"; import type { Transport, TransportHealth, TransportObservation } from "./transports.js"; import { @@ -604,12 +604,15 @@ function recordTransport( 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(); - // No inbox configured → no session is the inbox. `undefined` never matches a - // real mediator DID, which is the answer we want rather than an accident. - const sessions = statusSnapshot().map((s) => ({ ...s, isInbox: s.mediatorDid === inbox })); + // Whether a session is an inbox decides whether a dead one is "an outbound + // channel fell back" or "this agent can no longer reach us". Matched on the + // (agent, relay) PAIR: with one relay per agent, the same mediator DID can + // be one agent's inbox and another's outbound channel. + const inboxes = await allInboxes(); + const sessions = statusSnapshot().map((s) => ({ + ...s, + isInbox: inboxes.get(s.vtaDid) === s.mediatorDid, + })); return { byVta, sessions }; } @@ -855,28 +858,46 @@ async function runDiagnostics(vtaDid: string): Promise { }); } - // 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(); + // This agent's inbox — the session whose death means its consent prompts + // never arrive — checked separately from the transports above, which are + // about reaching the agent rather than being reached by it. + const inbox = await inboxMediatorFor(vtaDid); if (!inbox) { // The self-test's whole job is to answer "can this wallet be reached?" - // from the one place the answer is true. An unset inbox is the loudest - // possible No, and it used to be unreachable here because the setting - // fell back to a hardcoded mediator — so the report would go on to prove - // a demo host in another deployment was healthy and call that a pass. + // from the one place the answer is true. No inbox is the loudest possible + // No, and it used to be unreachable here because the setting fell back to + // a hardcoded relay — so the report would go on to prove a demo host in + // another deployment was healthy and call that a pass. checks.push({ id: "inbox.unset", - label: "Wallet inbox", + label: "This agent's inbox", status: "fail", detail: - "No inbox relay is configured, so nothing can be pushed to this wallet — " + - "consent requests and approvals will never arrive.", + "No inbox relay is configured for this agent, so it cannot push to this " + + "wallet — its consent requests and approvals will never arrive.", remediation: - "Reconnect to your agent to adopt its relay automatically, or set one under " + - "Setup → Message routing. If your agent advertises no DIDComm mediator, it " + + "Reconnect to this agent to adopt its relay automatically, or set one under " + + "Setup → Message routing. If the agent advertises no DIDComm mediator, it " + "cannot push to a wallet at all and one must be configured by hand.", }); + } else if (services.didcomm && inbox !== services.didcomm.mediatorDid) { + // Reachable when an operator pinned a relay by hand. Worth saying plainly: + // the wallet publishes its inbox to nobody (a `did:key` holder carries no + // service endpoint), so an agent pushes through the relay IT knows. A + // wallet listening somewhere else is listening where nothing arrives. + checks.push(...(await diagnoseMediator("inbox", inbox, fetchImpl, origin))); + checks.push({ + id: "inbox.mismatch", + label: "This agent's inbox is not the relay it pushes through", + status: "warn", + detail: + `This wallet listens for this agent at ${inbox}, but the agent advertises ` + + `${services.didcomm.mediatorDid}. Nothing tells it to use the first, so its ` + + "messages are handed to the second and never picked up.", + remediation: + "Clear the manual relay under Setup → Message routing so the wallet follows " + + "the agent, unless you know this relay reaches it.", + }); } else if (!mediators.has(inbox)) { checks.push(...(await diagnoseMediator("inbox", inbox, fetchImpl, origin))); } @@ -1806,37 +1827,31 @@ async function doOnboardConnect(params: OnboardConnectParams): Promise { - return getWalletMediatorDid(); +// Read rather than memoised. The previous cache was justified as keeping the +// per-session "is this our inbox?" check synchronous inside `onClose`, but +// that check is awaited into `isInbox` before the closure is built — it bought +// nothing and went stale whenever settings were written from the options page, +// a different context. IndexedDB is shared; these paths are not hot. +// +// `undefined` means this agent has no inbox and cannot reach the wallet. Every +// caller decides what that means for it rather than substituting a relay. +async function inboxMediatorFor(vtaDid: string): Promise { + return inboxFor(await getSettings(), vtaDid)?.did; } -/** The inbox mediator for a path that cannot proceed without one. Throws a - * coded error rather than returning `undefined`, so a caller that must open a - * session fails loudly instead of half-working. */ +/** Every (agent → relay) pair this wallet should be listening on. */ +async function allInboxes(): Promise> { + const settings = await getSettings(); + return new Map(Object.entries(settings.inboxes ?? {}).map(([vta, rec]) => [vta, rec.did])); +} + +/** The inbox for a path that cannot proceed without one. Throws a coded error + * rather than returning `undefined`, so a caller that must open a session + * fails loudly instead of half-working. */ export class NoInboxMediatorError extends Error { readonly code = INBOX_NOT_CONFIGURED; - constructor() { + constructor(readonly vtaDid: string) { super( - "this wallet has no inbox mediator configured, so nothing can be " + - "pushed to it. Onboarding sets one from your agent; re-connect to " + - "your agent, or set one under Setup → Message routing.", + `no inbox relay is configured for ${vtaDid}, so that agent cannot push ` + + `to this wallet. Onboarding sets one from the agent; re-connect to it, ` + + `or set one under Setup → Message routing.`, ); this.name = "NoInboxMediatorError"; } } -async function requireInboxMediator(): Promise { - const did = await walletMediatorDid(); - if (!did) throw new NoInboxMediatorError(); +async function requireInboxMediator(vtaDid: string): Promise { + const did = await inboxMediatorFor(vtaDid); + if (!did) throw new NoInboxMediatorError(vtaDid); return did; } @@ -2130,7 +2155,7 @@ async function createWarmSession( vtaDid: string, ): Promise { const { identity, signing } = await loadHolder(vtaDid); - const isInbox = mediatorDid === (await walletMediatorDid()); + const isInbox = mediatorDid === (await inboxMediatorFor(vtaDid)); const key = poolKey(mediatorDid, vtaDid); const conn = await connectMediatorSession({ holder: identity, @@ -2151,8 +2176,8 @@ async function createWarmSession( if (isInbox) scheduleInboundReconnect(vtaDid); }, }); - // Attach the inbound confirm handler whenever this is the wallet's inbox - // mediator — regardless of which operation first opened the session. + // Attach the inbound confirm handler whenever this session is THIS agent's + // inbox — regardless of which operation first opened it. if (isInbox) { // Return the promise: the transport awaits it and acks only once the // message is durably recorded (R1.6). @@ -2226,7 +2251,7 @@ async function doApproverState( // Loaded identity alone is not enough: the inbox session is what receives // requests, so an open connection is the thing worth reporting. - const mediatorDid = await walletMediatorDid(); + const mediatorDid = await inboxMediatorFor(vtaDid); // Minted but with nowhere to listen: report it as not running rather than // inventing a mediator to key the pool by. if (!mediatorDid) return { minted: true, running: false, approverDid: did }; @@ -2257,7 +2282,7 @@ async function doUnlockApprover( } async function getApproverWarmSession(vtaDid: string): Promise { - const mediatorDid = await requireInboxMediator(); + const mediatorDid = await requireInboxMediator(vtaDid); const key = approverPoolKey(mediatorDid, vtaDid); const existing = approverPool.get(key); if (existing) { @@ -2276,7 +2301,7 @@ async function getApproverWarmSession(vtaDid: string): Promise { const approver = approverIdentities.get(vtaDid); if (!approver) throw new Error(`approver for ${vtaDid} is locked`); - const mediatorDid = await requireInboxMediator(); + const mediatorDid = await requireInboxMediator(vtaDid); const key = approverPoolKey(mediatorDid, vtaDid); const conn = await connectMediatorSession({ holder: approver.identity, @@ -2333,7 +2358,7 @@ function lockApprovers(): void { } } -/** Ensure the warm session to the wallet's inbox mediator is live for +/** Ensure the warm session to this agent's inbox relay is live for * a single holder identity (one VTA). Idempotent. Used by the * re-arm-on-drop path in `createWarmSession.onClose` and by * `reconcileInbound` for each VTA in the desired set. @@ -2344,7 +2369,7 @@ function lockApprovers(): void { * subsequent reconcile will pick up the missed listener. */ async function startInbound(vtaDid: string): Promise { try { - const mediatorDid = await requireInboxMediator(); + const mediatorDid = await requireInboxMediator(vtaDid); await getWarmSession(mediatorDid, vtaDid); console.info( "[pnm inbound] listening for confirm requests via", @@ -2409,51 +2434,54 @@ function clearInboundBackoff(vtaDid: string): void { * authenticating identity). One mediator can host many holder * sessions concurrently. */ async function reconcileInbound(vtaDids: readonly string[]): Promise { - const mediatorDid = await walletMediatorDid(); + const inboxes = await allInboxes(); const wanted = new Set(vtaDids); - // No inbox, nothing to reconcile. Returning here rather than letting each - // VTA fail into `scheduleInboundReconnect` is deliberate: backoff exists to - // outlast a mediator outage, and retrying cannot fix an unset setting — it - // would just log the same failure forever at a growing interval and bury - // the one line that says what is actually wrong. Writing the setting - // (onboarding, or Setup → Message routing) re-runs this. - if (!mediatorDid) { - if (vtaDids.length > 0) { - console.warn( - "[pnm inbound] no inbox mediator configured — this wallet cannot " + - "receive consent requests. Re-connect to your agent to set one.", - ); - } - return; - } - - // Open missing — concurrent across VTAs, individual failures stay - // contained (loadHolder may throw for a locked wallet; the rest - // still come up). A VTA that fails to come up (e.g. mediator still - // down, or wallet locked) is put on the backoff retry loop rather than - // left dead; one that comes up clears any prior backoff. `startInbound` - // never throws, so `Promise.all` is safe here. + // One session per (agent, that agent's relay). Concurrent across agents; + // individual failures stay contained (loadHolder throws for a locked + // wallet, one relay may be down) so the rest still come up. An agent that + // fails to come up goes on the backoff retry loop rather than being left + // dead; one that comes up clears any prior backoff. `startInbound` never + // throws, so `Promise.all` is safe. + // + // An agent with no relay is NOT put on backoff: backoff exists to outlast an + // outage, and retrying cannot fill in a setting. It would log the same + // failure forever at a growing interval and bury the one line saying what is + // actually wrong. Writing the inbox (onboarding, the boot adopt, or Setup → + // Message routing) re-runs this. + const unreachable: string[] = []; await Promise.all( vtaDids.map(async (vtaDid) => { + if (!inboxes.has(vtaDid)) { + unreachable.push(vtaDid); + return; + } if (await startInbound(vtaDid)) clearInboundBackoff(vtaDid); else scheduleInboundReconnect(vtaDid); }), ); + if (unreachable.length > 0) { + console.warn( + "[pnm inbound] no inbox relay configured for", + unreachable.join(", "), + "— those agents cannot reach this wallet.", + ); + } // Re-drive anything interrupted before it concluded. Done after the // sessions are up, because finishing an interaction means sending a signed // decision back over one. void drainPendingInbound(vtaDids); - // Close extras: any pool entry whose mediator matches our inbox AND - // whose vtaDid is no longer wanted (operator forgot it). The pool - // also holds outbound sessions to OTHER mediators (the VTA's - // mediator, not the wallet's) — those are filtered out by the - // mediator check. + // Close extras: any pool entry that is an INBOX session — its mediator is + // the one its own agent's inbox names — whose vtaDid is no longer wanted + // (operator forgot it). The pool also holds outbound sessions to other + // mediators; the pair check leaves those alone. Matching the pair rather + // than one wallet-wide DID matters now that each agent has its own relay: + // the same mediator can be one agent's inbox and another's outbound hop. for (const [key, sessionPromise] of warmPool) { const parsed = parsePoolKey(key); - if (parsed.mediatorDid !== mediatorDid) continue; // outbound; leave it + if (inboxes.get(parsed.vtaDid) !== parsed.mediatorDid) continue; // outbound; leave it if (wanted.has(parsed.vtaDid)) continue; // still wanted // No longer wanted. Cancel any pending reconnect for this holder // first, so a backoff timer that fired between drop and reconcile @@ -2467,6 +2495,11 @@ async function reconcileInbound(vtaDids: readonly string[]): Promise { (conn) => conn.close(), () => undefined, // already failed → nothing to close ); + // Drop the inbox record only now, and only here. Deleting it where the + // operator forgets the agent would run BEFORE this reconcile, leaving the + // session unrecognisable as an inbox and therefore open forever. Closing + // first and forgetting second keeps the two in step. + void forgetInbox(parsed.vtaDid); console.info("[pnm inbound] closed listener for forgotten VTA", parsed.vtaDid); } } @@ -2512,7 +2545,7 @@ async function drainPendingInbound(vtaDids: readonly string[]): Promise { true, ); } else { - const mediatorDid = await requireInboxMediator(); + const mediatorDid = await requireInboxMediator(entry.vtaDid); const conn = await getWarmSession(mediatorDid, entry.vtaDid); const { identity, signing } = await loadHolder(entry.vtaDid); console.info("[pnm inbound] re-driving interrupted message", entry.id); diff --git a/packages/extension/src/setup-pane.tsx b/packages/extension/src/setup-pane.tsx index 2c3574f..b8e46f1 100644 --- a/packages/extension/src/setup-pane.tsx +++ b/packages/extension/src/setup-pane.tsx @@ -16,7 +16,7 @@ import { useCallback, useEffect, useState } from "react"; import { useActiveConnection } from "./store.js"; -import { getSettings, setSettings } from "./config.js"; +import { getSettings, inboxFor, setInbox as setInboxRecord } from "./config.js"; import { readActiveHolderDid } from "./active-vta.js"; import { encryptHolderSecretInPopup } from "./encrypt-holder.js"; import { OnboardView } from "./onboard-view.js"; @@ -133,15 +133,17 @@ export function SetupPane() { const load = useCallback(async () => { const s = await getSettings(); - // Unset is a real state now — the inbox is written by onboarding from the - // agent, and there is no hardcoded fallback standing in for it. - setInbox(s.mediatorDid ?? ""); - setSavedInbox(s.mediatorDid ?? ""); + // Per-agent now, and this pane edits the ACTIVE agent's relay. Unset is a + // real state — the inbox is written by onboarding from the agent, and + // there is no hardcoded fallback standing in for it. + const held = connection ? inboxFor(s, connection.vtaDid)?.did : undefined; + setInbox(held ?? ""); + setSavedInbox(held ?? ""); setEncrypted(s.encryptHolderSecret === true); // preferTsp defaults on; only an explicit false pins away from TSP. setPreferTsp(s.preferTsp !== false); setHolderDid((await readActiveHolderDid()) ?? ""); - }, []); + }, [connection]); useEffect(() => { void load(); @@ -207,9 +209,12 @@ export function SetupPane() { setBusy(true); setStatus(null); try { - // Stamped as the operator's choice, which the boot backfill never - // overrides — this is the one place a person picks a relay. - await setSettings({ mediatorDid: inbox.trim(), mediatorDidSource: "operator" }); + if (!connection) throw new Error("no active agent to set a relay for"); + // Stamped as the operator's choice, which neither the boot adopt nor + // `followAgentInbox` overrides — this is the one place a person picks a + // relay. Scoped to the active agent: each agent has its own inbox, and + // writing one wallet-wide is what made every other agent unreachable. + await setInboxRecord(connection.vtaDid, { did: inbox.trim(), source: "operator" }); setSavedInbox(inbox.trim()); setRoutingOpen(false); setHolderDid((await readActiveHolderDid()) ?? ""); @@ -388,14 +393,14 @@ export function SetupPane() { claim about where your messages go has to be checkable at the place it is made (guide §0). */}
- Set up from your agent when you connected. One relay carries messages in both - directions, which is what almost every deployment wants. + Set up from your agent when you connected — one relay per agent, carrying + messages in both directions, which is what almost every deployment wants.
{connected && ( <> {savedInbox ? (
- Messages reach you via{" "} + This agent reaches you via{" "} - Nothing can reach this wallet. No relay is set, so consent - requests and approvals sent to you will never arrive. Reconnect to your - agent to pick one up automatically, or set one below. + This agent can't reach you. No relay is set for it, so + its consent requests and approvals will never arrive. Reconnect to it to + pick one up automatically, or set one below. )} {holderDid && ( @@ -438,10 +443,11 @@ export function SetupPane() { people from fixing the routing it was describing. State what actually changes. */} - Only change this if you run more than one relay. Your - wallet address stays the same, but it is where others send to: until you - tell each agent and site that already routes to you, messages will keep - going to the old relay and you won't see them. + Only change this if you run more than one relay. This + sets the relay for this agent only — your other agents keep + theirs. Your wallet address stays the same, but an agent pushes through + the relay it knows: point this somewhere the agent doesn't use and + its messages will keep arriving where you aren't listening. { +test("adopts the agent's relay when that agent has none", () => { assert.equal(inboxToAdopt({}, AGENT), AGENT); }); -test("leaves an inbox the operator chose", () => { +test("leaves a relay the operator chose", () => { // The operator running two relays picked this one on purpose. assert.equal(inboxToAdopt({ did: OTHER, source: "operator" }, AGENT), undefined); }); -test("a second onboarding does not move an address others already route to", () => { +test("adopts over a stored relay nobody is on record choosing", () => { + // The case that defeated the first migration. By value it is + // indistinguishable from a deliberate choice; by provenance it is not. + const DEMO = "did:webvh:QmDemoRelay:demo.example:mediator"; + assert.equal(inboxToAdopt({ did: DEMO }, AGENT), AGENT); +}); + +test("the blank-filling adoption happens once, not on every boot", () => { + // Moving an agent-sourced relay when the agent moves it is + // `followAgentInbox`'s job — it re-resolves the DID document, where this + // function only reads a cached connection. assert.equal(inboxToAdopt({ did: AGENT, source: "agent" }, OTHER), undefined); }); -test("an agent advertising no mediator leaves the inbox unset, not invented", () => { - // Unset is reported by the self-test as "nothing can reach this wallet". +test("an agent advertising no relay gets none invented for it", () => { + // Absent is reported by the self-test as "this agent cannot reach you". // Substituting anything here is what caused the original defect. assert.equal(inboxToAdopt({}, undefined), undefined); assert.equal(inboxToAdopt({ did: "" }, undefined), undefined); }); -test("adopts over a stored inbox nobody is on record choosing", () => { - // The case that defeated the first migration. `setSettings` merged the - // DEFAULTED settings and wrote them back, so any unrelated write — the - // passkey lock, the TSP toggle — persisted the old hardcoded demo mediator - // as though it had been picked. By value it is indistinguishable from a - // deliberate choice; by provenance it is not. - const DEMO = "did:webvh:QmDemoRelay:demo.example:mediator"; - assert.equal(inboxToAdopt({ did: DEMO }, AGENT), AGENT); +// ─── Reading each agent's advertised relay off the persisted connections ─── +// +// Wallets onboarded before the inbox map ran on the removed hardcoded relay. +// Re-onboarding to acquire one mints a fresh holder DID and invalidates every +// RP ACL, so the answer is read off what is already on disk instead. + +const envelope = (connections: unknown) => JSON.stringify({ state: { connections }, version: 3 }); + +test("every agent's relay is read, not just the active one", () => { + // The multi-VTA fix in one assertion: a wallet onboarded at two agents on + // two relays must listen at both. + const raw = envelope({ + activeVtaDid: VTA_A, + vtas: { [VTA_A]: { mediatorDid: AGENT }, [VTA_B]: { mediatorDid: OTHER } }, + }); + assert.deepEqual(parseAgentMediatorDids(raw), { [VTA_A]: AGENT, [VTA_B]: OTHER }); }); -test("the blank-filling adoption happens once, not on every boot", () => { - // Stamped `agent` on the way in, so the per-spin-up backfill leaves it - // alone. Moving an agent-sourced inbox when the agent moves its relay is - // `followAgentInbox`'s job — it re-resolves the DID document, where this - // function only ever reads a cached connection. - assert.equal(inboxToAdopt({ did: AGENT, source: "agent" }, OTHER), undefined); +test("an agent advertising no relay is absent, not present-and-empty", () => { + // Absent means "nothing to adopt". Present-and-empty would reach the session + // opener as a relay DID that is the empty string. + const raw = envelope({ + activeVtaDid: VTA_A, + vtas: { [VTA_A]: {}, [VTA_B]: { mediatorDid: OTHER } }, + }); + assert.deepEqual(parseAgentMediatorDids(raw), { [VTA_B]: OTHER }); }); -// ─── No mediator may be baked into the source again ─── +test("unreadable or absent storage yields nothing rather than guessing", () => { + assert.deepEqual(parseAgentMediatorDids(undefined), {}); + assert.deepEqual(parseAgentMediatorDids("not json"), {}); + assert.deepEqual(parseAgentMediatorDids(envelope(undefined)), {}); + // A non-string relay (a half-written record) must read as absent. + assert.deepEqual( + parseAgentMediatorDids(envelope({ vtas: { [VTA_A]: { mediatorDid: 42 } } })), + {}, + ); +}); + +// ─── No relay may be baked into the source again ─── const SRC = fileURLToPath(new URL("../src/", import.meta.url)); @@ -85,55 +129,3 @@ test("no hardcoded DID is shipped in src — a wallet's relay is configuration", offenders.join("\n"), ); }); - -// ─── Backfill for wallets onboarded before onboarding wrote an inbox ─── -// -// These ran on the removed hardcoded relay. Re-onboarding to acquire one -// mints a fresh holder DID and invalidates every RP ACL, so the answer is -// read off the connection already on disk instead. - -const envelope = (connections: unknown) => JSON.stringify({ state: { connections }, version: 3 }); - -test("backfill prefers the active agent's mediator", () => { - const raw = envelope({ - activeVtaDid: "did:webvh:QmActiveAgent:agent.example:vta", - vtas: { - "did:webvh:QmActiveAgent:agent.example:vta": { mediatorDid: AGENT }, - "did:webvh:QmOtherAgent:other.example:vta": { mediatorDid: OTHER }, - }, - }); - assert.equal(parseAgentMediatorDid(raw), AGENT); -}); - -test("backfill falls back to any agent that advertises one", () => { - // A single-agent wallet whose active pointer was never set still backfills. - const raw = envelope({ - activeVtaDid: null, - vtas: { "did:webvh:QmOnlyAgent:only.example:vta": { mediatorDid: OTHER } }, - }); - assert.equal(parseAgentMediatorDid(raw), OTHER); -}); - -test("backfill skips an active agent that advertises none", () => { - const raw = envelope({ - activeVtaDid: "did:webvh:QmRestOnly:rest.example:vta", - vtas: { - "did:webvh:QmRestOnly:rest.example:vta": {}, - "did:webvh:QmOtherAgent:other.example:vta": { mediatorDid: OTHER }, - }, - }); - assert.equal(parseAgentMediatorDid(raw), OTHER); -}); - -test("a REST-only wallet backfills nothing rather than guessing", () => { - const raw = envelope({ activeVtaDid: null, vtas: { "did:webvh:QmRestOnly:r.example:vta": {} } }); - assert.equal(parseAgentMediatorDid(raw), undefined); -}); - -test("unreadable or absent storage backfills nothing", () => { - assert.equal(parseAgentMediatorDid(undefined), undefined); - assert.equal(parseAgentMediatorDid("not json"), undefined); - assert.equal(parseAgentMediatorDid(envelope(undefined)), undefined); - // A non-string mediator (a half-written record) must read as absent. - assert.equal(parseAgentMediatorDid(envelope({ vtas: { a: { mediatorDid: 42 } } })), undefined); -});