From db71e6d72614587f89f4074d5fae2fd2e506b8f1 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 30 Aug 2026 22:49:22 +0200 Subject: [PATCH] =?UTF-8?q?feat(rp-login):=20add=20walletProfile=20?= =?UTF-8?q?=E2=80=94=20ask=20who=20this=20site=20knows=20you=20as?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #144 made `proxyLogin({})` resolve-or-bind, and that is enough for an RP that only needs a token. It is not enough for the two RPs we actually have. `vtc-service/admin-ui` and `did-hosting-ui` both bind their `/auth/challenge` to the persona DID: POST /auth/challenge { did: entry.principalDid } proxyLogin({ entryId, nonce: challenge }) The DID is needed *before* the nonce, so it cannot come out of `proxyLogin` — and both pages get it today from `vaultList()`, which enumerates the user's whole vault to answer a question about one entry. On a site with nothing bound that returns empty and the page gives up, which is exactly what #144 was meant to stop and could not reach. `walletProfile({ target })` answers that question and only that one. It resolves the entry for the browser-attested origin, or raises the first-use picker and binds the answer, and returns `{ did, entryId, bound }`. It mints nothing and issues no session. **`entryId` comes back deliberately.** The objection to a page holding one was never possession; it was that *learning* one cost a vault-enumerating prompt. An id handed back for the site's own entry costs nothing, and passing it to `proxyLogin` saves the lookup this call just did — so the two-call flow is the same number of VTA round trips as the `vaultList` + `proxyLogin` it replaces, with one fewer prompt and no disclosure of the rest of the vault. **Two prompts on a first sign-in, one after.** Binding raises the picker; the sign-in raises its own consent. Folding the second into the first would mean a call that mints nothing silently pre-authorizing one that does. First contact with a site is the place to ask twice. An already-bound lookup does not prompt at all: it discloses one DID, to the site that DID exists for, which is about to receive it inside an id_token anyway. `principalDid` is read back from the VTA rather than remembered, since it is maintainer-derived and an entry whose secret was rotated there would otherwise report a DID it no longer signs as. Two drift traps found on the way, both now guarded: - `provider.ts` inlined a second copy of the `BridgeMethod` union, so adding a method failed at its own call site with an error naming every method but the new one. It uses the type now. - `content.ts` cannot `import` (classic script), so it inlines the protocol constants under a "keep these in sync" comment with nothing enforcing it. Two invariants ride on that hand-sync: a method routed to a type absent from `PAGE_FACING_RUNTIME_TYPES` takes its origin from the page's own message body rather than the browser — and every vault entry, trust record and pin here is keyed on origin — while a mistyped constant routes to no handler and surfaces as a shapeless failure. `tests/page-facing-surface.test.mts` reads both sources and checks them against each other; its first assertions guard against the regexes silently matching nothing. Requires the RP-side change in OpenVTC/verifiable-trust-infrastructure and OpenVTC/affinidi-webvh-service; neither wallet call is removed, so the repos can land in either order. Signed-off-by: Glenn Gore --- packages/demo-rp/login-harness.mjs | 20 +++ packages/extension/src/background.ts | 121 ++++++++++++++++-- packages/extension/src/bridge-protocol.ts | 48 +++++++ packages/extension/src/content.ts | 2 + packages/extension/src/provider.ts | 40 ++++-- .../tests/page-facing-surface.test.mts | 111 ++++++++++++++++ 6 files changed, 321 insertions(+), 21 deletions(-) create mode 100644 packages/extension/tests/page-facing-surface.test.mts diff --git a/packages/demo-rp/login-harness.mjs b/packages/demo-rp/login-harness.mjs index 958a16d..7dd8708 100644 --- a/packages/demo-rp/login-harness.mjs +++ b/packages/demo-rp/login-harness.mjs @@ -65,6 +65,7 @@ way a relying party would. Keep the wallet's offscreen console open beside this. +

The proxy button names no vault entry, which is the point: the wallet resolves one from this origin, and on a first visit asks which identity to use and binds the answer. A @@ -112,6 +113,25 @@ second click should not prompt for an identity again.

} }); + // The shape an RP uses when its challenge is bound to the persona DID: ask + // the wallet who this site knows you as, THEN fetch a challenge for that DID, + // then mint. Here it just reports the answer — the harness has no challenge + // endpoint of its own — which is enough to see the first-use prompt fire and + // to confirm a second click does not prompt again. + document.getElementById("profile").addEventListener("click", async () => { + if (!probe()) return; + show( + "Asking the wallet…", + "On a first visit it should ask which identity to bind to this site.", + ); + try { + const r = await window.vtaWallet.walletProfile({}); + show(r.bound ? "walletProfile bound a new identity" : "walletProfile resolved", r); + } catch (e) { + show("walletProfile rejected", String(e && e.message ? e.message : e)); + } + }); + // Deliberately passes NO entryId. A page that supplies one has had to call // vaultList() to learn it — a second consent prompt that enumerates the // user's vault to this site. Omitting it is the shape a relying party should diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index eec06a1..d26e605 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -67,6 +67,7 @@ import { RUNTIME_VAULT_LIST_PAGE, RUNTIME_VAULT_PROXY_LOGIN, RUNTIME_VAULT_PROXY_LOGIN_PAGE, + RUNTIME_WALLET_PROFILE, RUNTIME_VAULT_RELEASE, RUNTIME_VAULT_UPSERT, OFFSCREEN_LOCK_WALLET, @@ -159,6 +160,8 @@ import { type RuntimeVaultReleaseRequest, type RuntimeVaultReleaseResponse, type ProxyLoginParams, + type RuntimeWalletProfileRequest, + type RuntimeWalletProfileResponse, type RuntimeVaultUpsertRequest, type RuntimeVaultUpsertResponse, type RuntimeApproverStateResponse, @@ -1839,7 +1842,7 @@ async function handleVaultProxyLoginPage( // Resolve the entry BEFORE prompting. Which prompt to raise depends on // whether this site already has a persona bound, and a page that calls this // with no VTA connected should fail without raising one at all. - const resolved = await resolveProfileEntry(req); + const resolved = await resolveProfileEntry(req.origin, req.params.entryId); if (!resolved.ok) return { ok: false, error: resolved.error }; if (resolved.entryId) { @@ -1898,6 +1901,95 @@ async function handleVaultProxyLoginPage( return result; } +/** + * Which persona this site knows the user as — resolve, or bind one. + * + * Split out of the sign-in because an RP whose `/auth/challenge` is bound to + * the persona DID needs that DID *before* it can ask for a nonce, and so cannot + * reach it through `proxyLogin` at all. The route it had was `vaultList()`, + * which discloses every entry to answer a question about one. + * + * ## Two prompts on a first sign-in, and why that is the right number + * + * A page that binds and then signs in raises the picker here and the sign-in + * consent in `handleVaultProxyLoginPage` — two decisions the first time, one + * every time after. Folding the second into the first would mean this call, + * which mints nothing and issues no session, silently pre-authorizing one that + * does. First contact with a site is the place to ask twice; every later + * sign-in is a single prompt, and the operator can still tick "remember". + * + * Nothing is minted here, and no session is issued. The result is a DID the + * site is about to be told anyway, and the id of the entry holding it. + */ +async function handleWalletProfile( + req: RuntimeWalletProfileRequest, +): Promise { + const target = req.params.target as { kind?: string; did?: string } | undefined; + const targetDid = target?.kind === "did" ? target.did : undefined; + + const resolved = await resolveProfileEntry(req.origin); + if (!resolved.ok) return { ok: false, error: resolved.error }; + + if (resolved.entryId) { + // Already bound. No prompt: this discloses one DID, to the site that DID + // exists for, which is about to receive it inside an id_token anyway. A + // prompt here would be asking the operator to re-approve a decision they + // already made, which is how prompts stop being read. + const did = await principalDidFor(req.origin, resolved.entryId); + if (!did.ok) return { ok: false, error: did.error }; + return { ok: true, result: { did: did.did, entryId: resolved.entryId, bound: false } }; + } + + // `requestConsent`, not `gatedConsent` — see handleVaultProxyLoginPage. A + // remembered origin has consented to being signed in as an identity already + // chosen, never to a new one being chosen for it. + const decision = await requestConsent({ + origin: req.origin, + action: "Choose the identity this site knows you as", + chooseProfile: true, + ...(targetDid ? { rpDid: targetDid } : {}), + }); + if (!decision.approved || !decision.selectedDid) { + return { ok: false, error: "identity selection denied by user" }; + } + + const bound = await bindProfileEntry(req.origin, decision.selectedDid, targetDid); + if (!bound.ok) return { ok: false, error: bound.error }; + if (decision.remember) await trustOrigin(req.origin, targetDid); + + return { + ok: true, + result: { did: decision.selectedDid, entryId: bound.entryId, bound: true }, + }; +} + +/** + * The persona DID an already-bound entry acts as. + * + * `principalDid` is maintainer-derived, so it is read back from the VTA rather + * than reconstructed here: the wallet seals the secret and never sees it again, + * and an entry whose secret was rotated at the VTA would otherwise report a DID + * it no longer signs as. + */ +async function principalDidFor( + origin: string, + entryId: string, +): Promise<{ ok: true; did: string } | { ok: false; error: string }> { + const listed = await handleVaultList({ + type: RUNTIME_VAULT_LIST, + filter: { secretKind: PROFILE_SECRET_KIND, targetOriginPrefix: origin }, + }); + if (!listed.ok) return { ok: false, error: listed.error }; + const entry = listed.result.entries.find((e) => e.id === entryId); + if (!entry?.principalDid) { + // An entry with no principalDid cannot mint an id_token, so returning it + // would hand the page a DID-shaped hole that fails at `/auth/challenge` + // with nothing pointing back here. + return { ok: false, error: `vault entry ${entryId} names no persona DID` }; + } + return { ok: true, did: entry.principalDid }; +} + /** Send a resolved proxy-login to the offscreen document, where the holder * identity and the DIDComm unpacking live. */ async function dispatchProxyLogin( @@ -1918,18 +2010,20 @@ async function dispatchProxyLogin( /** * Which vault entry a page-initiated proxy login should use. * - * `entryId` supplied by the page is honoured as-is — that is the pre-existing - * contract, and a page that discovered an id through `vaultList()` has already - * had its own consent prompt for it. Otherwise the entry comes from the origin - * the *browser* attested, never from anything the page said about itself. + * `suppliedEntryId` is honoured as-is — that is the pre-existing contract, and + * an id the page holds came either from `vaultList()` (which had its own + * consent prompt) or from `walletProfile()` (which handed back this site's own + * entry). Otherwise the entry comes from the origin the *browser* attested, + * never from anything the page said about itself. * * `entryId: undefined` with `ok: true` means "this site has no persona yet", * which is a first-use prompt, not an error. */ async function resolveProfileEntry( - req: RuntimeVaultProxyLoginPageRequest, + origin: string, + suppliedEntryId?: string, ): Promise<{ ok: true; entryId?: string } | { ok: false; error: string }> { - if (req.params.entryId) return { ok: true, entryId: req.params.entryId }; + if (suppliedEntryId) return { ok: true, entryId: suppliedEntryId }; const listed = await handleVaultList({ type: RUNTIME_VAULT_LIST, @@ -1937,11 +2031,11 @@ async function resolveProfileEntry( // decide the answer. A prefix is not an origin — `https://example.com` is a // prefix of `https://example.com.evil.test` — so `matchProfileEntry` does // the actual match locally, with `===`, on the attested origin. - filter: { secretKind: PROFILE_SECRET_KIND, targetOriginPrefix: req.origin }, + filter: { secretKind: PROFILE_SECRET_KIND, targetOriginPrefix: origin }, }); if (!listed.ok) return { ok: false, error: listed.error }; - const match = matchProfileEntry(listed.result.entries, req.origin); + const match = matchProfileEntry(listed.result.entries, origin); return { ok: true, ...(match ? { entryId: match.id } : {}) }; } @@ -2329,6 +2423,15 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return true; } + if ((message as { type?: string })?.type === RUNTIME_WALLET_PROFILE) { + handleWalletProfile(message as RuntimeWalletProfileRequest) + .then(sendResponse) + .catch((e: unknown) => + sendResponse({ ok: false, error: e instanceof Error ? e.message : String(e) }), + ); + return true; // async sendResponse + } + if ((message as { type?: string })?.type === RUNTIME_VAULT_PROXY_LOGIN_PAGE) { handleVaultProxyLoginPage(message as RuntimeVaultProxyLoginPageRequest) .then(sendResponse) diff --git a/packages/extension/src/bridge-protocol.ts b/packages/extension/src/bridge-protocol.ts index e4ae453..98a935e 100644 --- a/packages/extension/src/bridge-protocol.ts +++ b/packages/extension/src/bridge-protocol.ts @@ -30,6 +30,7 @@ export type BridgeMethod = | "walletDefaults" | "signTrustTask" | "proxyLogin" + | "walletProfile" | "vaultList" | "requestTask"; @@ -266,6 +267,16 @@ export const RUNTIME_SIGN_TRUST_TASK = "vta-wallet/sign-trust-task" as const; * attested origin, so the wallet never attests to a document the page wrote. */ export const RUNTIME_REQUEST_TASK = "vta-wallet/request-task" as const; +/** page → background: which persona this site knows the user as. + * + * Resolve-or-bind, and it mints nothing. An RP whose challenge is bound to the + * persona DID — the shape both `did-hosting` and `vtc-service` use — needs the + * DID *before* it can ask for a nonce, so it cannot get there through + * `proxyLogin` alone. The alternative it had was `vaultList()`, which + * enumerates the user's whole vault to the site to answer a question about one + * entry. This answers that question and only that one. */ +export const RUNTIME_WALLET_PROFILE = "vta-wallet/wallet-profile" as const; + export const RUNTIME_CONSENT_RESULT = "vta-wallet/consent-result" as const; /** offscreen → background: an inbound, executor-signed `task-consent/request` * needs a human. Unlike the generic login consent prompt, the surface renders @@ -1246,6 +1257,42 @@ export interface ProxyLoginParams { ttlSecondsHint?: number; } +/** Page-world params for `window.vtaWallet.walletProfile(...)`. */ +export interface WalletProfileParams { + /** The relying party this is for, when the page has a DID for itself. Bound + * as a second target on a newly created entry so the RP's own + * `vaultList({ targetDid })` finds it; never used to *match* an entry, since + * only the origin is browser-attested. */ + target?: VaultEntryView["targets"][number]; +} + +export interface WalletProfileResult { + /** The persona DID this site knows the user as — the `iss`/`sub` of any SIOP + * id_token minted for it, and the DID an RP binds its challenge to. */ + did: string; + /** The vault entry backing it. Pass straight to `proxyLogin` so it does not + * repeat the lookup this call just did. Naming an entry the wallet handed + * back for this site costs nothing — the disclosure this avoids was + * `vaultList()` returning every *other* entry too. */ + entryId: string; + /** True when this call bound the persona rather than finding one already + * bound, i.e. the operator was prompted. A page can use it to explain why + * the sign-in that follows may be refused until the DID is on its ACL. */ + bound: boolean; +} + +export type RuntimeWalletProfileResponse = + | { ok: true; result: WalletProfileResult } + | { ok: false; error: string }; + +export interface RuntimeWalletProfileRequest { + type: typeof RUNTIME_WALLET_PROFILE; + params: WalletProfileParams; + /** Origin of the calling page, captured by the content script. The entry is + * resolved and bound against this, never against anything the page says. */ + origin: string; +} + export interface RuntimeVaultProxyLoginPageRequest { type: typeof RUNTIME_VAULT_PROXY_LOGIN_PAGE; params: ProxyLoginParams; @@ -1685,6 +1732,7 @@ export const PAGE_FACING_RUNTIME_TYPES = [ RUNTIME_WALLET_DEFAULTS, RUNTIME_SIGN_TRUST_TASK, RUNTIME_VAULT_PROXY_LOGIN_PAGE, + RUNTIME_WALLET_PROFILE, RUNTIME_VAULT_LIST_PAGE, RUNTIME_REQUEST_TASK, ] as const; diff --git a/packages/extension/src/content.ts b/packages/extension/src/content.ts index d13c4a7..af09e1a 100644 --- a/packages/extension/src/content.ts +++ b/packages/extension/src/content.ts @@ -28,6 +28,7 @@ const RUNTIME_MEDIATOR_STATUS = "vta-wallet/mediator-status"; const RUNTIME_WALLET_DEFAULTS = "vta-wallet/wallet-defaults"; const RUNTIME_SIGN_TRUST_TASK = "vta-wallet/sign-trust-task"; const RUNTIME_VAULT_PROXY_LOGIN_PAGE = "vta-wallet/vault-proxy-login-page"; +const RUNTIME_WALLET_PROFILE = "vta-wallet/wallet-profile"; const RUNTIME_VAULT_LIST_PAGE = "vta-wallet/vault-list-page"; const RUNTIME_REQUEST_TASK = "vta-wallet/request-task"; const RUNTIME_BROADCAST_EVENT = "vta-wallet/broadcast-event"; @@ -61,6 +62,7 @@ const RUNTIME_TYPE_BY_METHOD: Record = { walletDefaults: RUNTIME_WALLET_DEFAULTS, signTrustTask: RUNTIME_SIGN_TRUST_TASK, proxyLogin: RUNTIME_VAULT_PROXY_LOGIN_PAGE, + walletProfile: RUNTIME_WALLET_PROFILE, vaultList: RUNTIME_VAULT_LIST_PAGE, requestTask: RUNTIME_REQUEST_TASK, }; diff --git a/packages/extension/src/provider.ts b/packages/extension/src/provider.ts index c98e484..a757aa8 100644 --- a/packages/extension/src/provider.ts +++ b/packages/extension/src/provider.ts @@ -4,6 +4,7 @@ // `window.postMessage` using the bridge protocol. import type { + BridgeMethod, RequestTaskParams, ApiGetParams, ApiGetResult, @@ -21,6 +22,8 @@ import type { VaultListResultView, VaultProxyLoginResultView, WalletDefaultsResult, + WalletProfileParams, + WalletProfileResult, } from "./bridge-protocol.js"; // Bundled as a standalone page-world script, so it must be self-contained @@ -87,6 +90,24 @@ interface VtaWallet { * to learn one, which costs a second consent prompt and shows this site the * rest of the user's vault. */ proxyLogin(params: ProxyLoginParams): Promise; + /** Which persona this site knows the user as, resolving or binding one. + * + * Mints nothing and issues no session — it answers a question. On a site + * with no persona bound the user is asked to pick one and it is remembered; + * after that this is a lookup. + * + * For an RP whose `/auth/challenge` is bound to the persona DID, this is the + * first call: the DID it returns is what the challenge is requested for, and + * the `entryId` goes straight into `proxyLogin` so the wallet does not + * repeat the lookup. + * + * const { did, entryId } = await wallet.walletProfile({ target }); + * const { challenge } = await postChallenge(did); + * await wallet.proxyLogin({ entryId, nonce: challenge, target }); + * + * `bound: true` means the persona was just created, so the relying party has + * never seen it and may refuse the sign-in until it is on its access list. */ + walletProfile(params: WalletProfileParams): Promise; /** Enumerate vault entries (metadata only, no secret material) via * vault/list/0.1. Each returned entry's `principalDid` is the DID the entry * would act AS when used in a proxy-login call. @@ -140,19 +161,12 @@ window.addEventListener("message", (event: MessageEvent) => { else entry.reject(new Error(data.error)); }); +// `BridgeMethod`, not a copy of it. This used to inline the same union, and the +// copy silently went stale the moment a method was added — the union grew, this +// did not, and the new method failed to typecheck at its own call site with an +// error naming every method *but* the one being added. function call( - method: - | "login" - | "loginDidcomm" - | "stepUpVta" - | "apiGet" - | "apiPost" - | "mediatorStatus" - | "walletDefaults" - | "signTrustTask" - | "proxyLogin" - | "vaultList" - | "requestTask", + method: BridgeMethod, params: | LoginParams | DidcommLoginParams @@ -161,6 +175,7 @@ function call( | ApiPostParams | SignTrustTaskParams | ProxyLoginParams + | WalletProfileParams | VaultListParams | RequestTaskParams | Record, @@ -185,6 +200,7 @@ if (!window.vtaWallet) { walletDefaults: () => call("walletDefaults", {}), signTrustTask: (params) => call("signTrustTask", params), proxyLogin: (params) => call("proxyLogin", params), + walletProfile: (params) => call("walletProfile", params), vaultList: (params) => call("vaultList", params), requestTask: (params) => call>("requestTask", params), }; diff --git a/packages/extension/tests/page-facing-surface.test.mts b/packages/extension/tests/page-facing-surface.test.mts new file mode 100644 index 0000000..11d6bcc --- /dev/null +++ b/packages/extension/tests/page-facing-surface.test.mts @@ -0,0 +1,111 @@ +// The content script's copy of the protocol, checked against the protocol. +// +// `content.ts` is injected as a *classic* script and cannot `import`, so it +// inlines the runtime-type strings with a "keep these in sync" comment and no +// guard. Two things ride on that hand-sync, and neither fails loudly: +// +// 1. **Origin attestation.** `background.ts` overrides the body's origin with +// the browser's `sender` origin for types in `PAGE_FACING_RUNTIME_TYPES`, +// and only those. A page-facing method routed to a type missing from that +// list would let the calling page name its own origin — and every vault +// entry, trust record and pin in this wallet is keyed on origin. +// +// 2. **A typo routes nowhere.** A mistyped constant produces a message the +// background has no branch for, so `sendMessage` resolves `undefined` and +// the page sees a shapeless failure with nothing pointing here. +// +// Neither is hypothetical: `provider.ts` inlined a second copy of the +// `BridgeMethod` union, and adding a method broke at its call site with an +// error naming every method but the new one. This reads the sources rather +// than importing them, because `content.ts` touches `chrome` at module scope. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const src = (name: string) => + readFileSync(fileURLToPath(new URL(`../src/${name}`, import.meta.url)), "utf8"); + +const content = src("content.ts"); +const protocol = src("bridge-protocol.ts"); + +/** `const NAME = "value"` / `export const NAME = "value" as const` → a map. + * + * The value may sit on the following line: a long declaration is wrapped by + * the formatter, and a same-line-only pattern reported the first such constant + * as missing from bridge-protocol.ts entirely. A parse gap that reads as a + * drift is worse than no test, so `\s*` spans the break. */ +function constants(source: string): Map { + const out = new Map(); + const re = /^(?:export )?const (RUNTIME_[A-Z0-9_]+) =\s*"([^"]+)"/gm; + for (const m of source.matchAll(re)) out.set(m[1]!, m[2]!); + return out; +} + +/** The identifiers listed in a `const NAME = [ … ] as const` array. */ +function arrayMembers(source: string, name: string): string[] { + const m = new RegExp(`const ${name} = \\[([^\\]]*)\\]`).exec(source); + assert.ok(m, `${name} not found — this test is reading the wrong shape`); + return m[1]! + .split(",") + .map((s) => s.replace(/\/\/.*$/gm, "").trim()) + .filter(Boolean); +} + +/** `method: RUNTIME_CONST,` pairs from the content script's routing table. */ +function routingTable(): Map { + const m = /RUNTIME_TYPE_BY_METHOD: Record = \{([^}]*)\}/.exec(content); + assert.ok(m, "RUNTIME_TYPE_BY_METHOD not found — this test is reading the wrong shape"); + const out = new Map(); + for (const line of m[1]!.split("\n")) { + const pair = /^\s*(\w+):\s*(RUNTIME_[A-Z0-9_]+),/.exec(line); + if (pair) out.set(pair[1]!, pair[2]!); + } + return out; +} + +const contentConsts = constants(content); +const protocolConsts = constants(protocol); +const table = routingTable(); + +test("the routing table is not empty", () => { + // Every assertion below is vacuously true against an empty table, which is + // exactly how a regex that stopped matching would pass silently. + assert.ok(table.size >= 10, `routing table has only ${table.size} entries`); + assert.ok(contentConsts.size >= 10, `parsed only ${contentConsts.size} constants`); +}); + +test("every constant the content script inlines matches bridge-protocol", () => { + for (const [name, value] of contentConsts) { + const canonical = protocolConsts.get(name); + assert.ok(canonical, `content.ts declares ${name}, which bridge-protocol.ts does not`); + assert.equal( + value, + canonical, + `${name} has drifted: content.ts says "${value}", bridge-protocol.ts says "${canonical}"`, + ); + } +}); + +test("every page-facing method routes to an origin-attested type", () => { + const pageFacing = new Set(arrayMembers(protocol, "PAGE_FACING_RUNTIME_TYPES")); + for (const [method, constName] of table) { + assert.ok( + pageFacing.has(constName), + `window.vtaWallet.${method}() routes to ${constName}, which is absent from ` + + `PAGE_FACING_RUNTIME_TYPES — the background would take that call's origin ` + + `from the page's own message body instead of from the browser`, + ); + } +}); + +test("every method in the BridgeMethod union has a route", () => { + const union = /export type BridgeMethod =([\s\S]*?);/.exec(protocol); + assert.ok(union, "BridgeMethod union not found"); + const methods = [...union[1]!.matchAll(/"(\w+)"/g)].map((m) => m[1]!); + assert.ok(methods.length >= 10, `parsed only ${methods.length} methods`); + for (const method of methods) { + assert.ok(table.has(method), `BridgeMethod "${method}" has no entry in RUNTIME_TYPE_BY_METHOD`); + } +});