From 56212e524cff174c5394598416e557accdecfe65 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Mon, 31 Aug 2026 08:41:46 +0200 Subject: [PATCH] fix(inbox): one inbox per agent, so every agent can reach the wallet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wallet had a single `mediatorDid` for its whole inbox. Whichever agent that value happened to name could push to it; every other onboarded agent's consent requests and approvals went to a relay the wallet was not listening on, and were lost without a trace. Glenn runs multiple VTAs, so this was live. Why it has to be per agent: a v4 holder is a `did:key`, which carries no service endpoint, and the wallet publishes its relay to nobody — there is no discovery path at all. An executor can therefore only hand a message to a mediator it already knows, its own, and the wallet hears it only if it is listening there. An inbox is not one address the wallet owns; it is "wherever that agent's relay is", once per agent. `settings.inboxes` is now `Record`. Onboarding writes the agent's entry; `reconcileInbound` opens one session per (agent, that agent's relay); `isInbox`, the close-extras sweep and the transport-health snapshot all match on the PAIR, because with one relay per agent the same mediator can be one agent's inbox and another's outbound hop. `followAgentInbox` sweeps every agent, not just the active one. The approver inbox — the session that carries task-consent requests — was keyed on the wallet-wide value too, so it had the same defect. Migration for wallets on the old single setting. An `operator` one was a person's choice and carries 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 the old `setSettings` persisted as though it had been chosen. The per-agent adopt then fills each entry from that agent's persisted connection, so no re-onboarding is needed. `setInbox` / `forgetInbox` own the read-modify-write of the map, because handing `setSettings` a whole `inboxes` object drops every agent absent from the caller's copy — and the symptom of that is another agent's pushes going quietly nowhere, which is the failure this map exists to end. An agent's entry is forgotten inside the reconcile, right after its session is closed: deleting it where the operator forgets the agent runs BEFORE the reconcile and leaves the session unrecognisable as an inbox, and so open forever. Setup's routing field now edits the active agent's relay and says so. The self-test reports a missing relay per agent, and warns when a pinned one is not what the agent advertises — a wallet listening somewhere the agent does not push is listening where nothing arrives. `getWalletMediatorDid` is deleted rather than left reading the deprecated field. Signed-off-by: Glenn Gore --- packages/extension/src/active-vta.ts | 56 ++-- packages/extension/src/background.ts | 195 ++++++++------ packages/extension/src/bridge-protocol.ts | 15 +- packages/extension/src/config.ts | 122 +++++++-- packages/extension/src/holder.ts | 20 -- packages/extension/src/network-pane.tsx | 2 +- packages/extension/src/offscreen.ts | 253 ++++++++++-------- packages/extension/src/setup-pane.tsx | 44 +-- .../extension/tests/wallet-inbox.test.mts | 154 +++++------ 9 files changed, 486 insertions(+), 375 deletions(-) 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); -});