From 73f950f84aa6a3ca8387fd5de6da2f55717559a3 Mon Sep 17 00:00:00 2001 From: Derek Scruggs Date: Tue, 23 Jun 2026 13:03:18 -0500 Subject: [PATCH 01/10] Add spec for simplified interact() API. Draft technical spec for a single-call CHAPI entry point that lets relying parties start an interaction from an interactionUrl, without composing the full web request object. Translates into the existing get() flow via the protocols mechanism, so no mediator changes are required for v1. Exposes the API both at navigator.chapi and via a standalone factory export. Open questions are left open to be resolved during draft-PR review. Addresses #50. --- docs/specs/interact-api.md | 136 +++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 docs/specs/interact-api.md diff --git a/docs/specs/interact-api.md b/docs/specs/interact-api.md new file mode 100644 index 0000000..e397ca9 --- /dev/null +++ b/docs/specs/interact-api.md @@ -0,0 +1,136 @@ +# Spec: Simplified `interact()` API for interaction URLs +> Status: Draft — pending review by Engineering, DevOps, CTO, and Privacy Officer. Addresses [#50](https://github.com/credential-handler/credential-handler-polyfill/issues/50). +## Summary +Add a drastically simplified, single-call CHAPI entry point that lets issuer/verifier coordinator websites (relying parties) start a credential interaction by handing the polyfill an `interactionUrl`, without composing the full `web`/`VerifiablePresentation` request object themselves. The new `interact({interactionUrl, signal, recommendedHandlerOrigins, type})` method resolves when the interaction completes (to an empty object/`undefined` for now) or rejects with an `AbortError` when the user cancels or the caller aborts via an `AbortSignal`. Under the hood it translates into the existing `credentialrequest` (`get()`) flow — reusing the established `protocols` mechanism to carry the URL — so it is compatible with today's mediator and deployed credential handlers. The method is reachable two ways: attached at `navigator.chapi` after `loadOnce()`, and returned from a standalone factory export so callers can attach the API wherever they like (addressing the "avoid `navigator`" request in the issue thread). +## Implementation details & assumptions +### New public API +```js +// Shape (param names subject to bikeshedding per the issue): +await chapi.interact({ + interactionUrl, // required string (https: URL) + signal, // optional AbortSignal + recommendedHandlerOrigins, // optional string[] + type // optional 'request' | 'store', default 'request' +}); +``` + +Resolution contract: + +- **Resolves** to an empty object (or `undefined`) when the interaction completes for now. The return shape is intentionally minimal and reserved for future expansion; callers must not depend on a credential being returned yet. + +- **Rejects** with a `DOMException` of name `AbortError` when the user cancels in the mediator UI, or when the caller aborts via `signal`. + +- Other failures reject with the existing error types surfaced by `get()` (e.g. `NotSupportedError`, `SecurityError` from the secure-context assertion). + +### Two ways to reach it (issue asks for both) +1. `navigator.chapi` — `load()`/`loadOnce()` attach a `chapi` object (containing `interact`) to `navigator` alongside the existing `navigator.credentialsPolyfill`, for parity with the current global pattern. + +2. **Standalone factory** — a new export (working name `loadOnce`-style factory; final name TBD per the issue's bikeshedding note) returns the `chapi` object so callers can assign it wherever they want, e.g. `globalThis.chapi = await createChapi({...})`. This avoids forcing the `navigator` namespace, which the thread flags as prone to clobbering by password-manager extensions and browser security changes. + + Both paths share one implementation; `navigator.chapi` is just the factory result assigned to `navigator`. + +### Translation to existing flow +- `type: 'request'` (default) → translate to a `navigator.credentials.get()` call with a `web` request. + +- `type: 'store'` → reserved in the API now; wiring to `store()` is deferred to a follow-up (see Open questions). `interact()` validates and accepts the param but the spec does not commit to `store` behavior in this change. + +- `interactionUrl` is carried via the **existing** `protocols` **map** (the query-param mechanism already used for URL-type credential handlers), not a new mediator field. This keeps the change client-side only — no mediator or RPC contract changes required for the initial release. + +- `recommendedHandlerOrigins` passes straight through to the underlying request options, identical to current `get()`/`store()` semantics. + +- `signal` is wired to the abort path: if already aborted, reject immediately; otherwise reject with `AbortError` when it fires. + +### Functional-core / imperative-shell split +Per house practice, the request-construction logic is a **pure function** — `(interactionUrl, type, recommendedHandlerOrigins) → CredentialRequestOptions` — independently testable with no mediator, no `navigator`, no network. The imperative shell (`interact()`) does the secure-context check, awaits the RPC, and maps the result/abort to the resolution contract. +### Assumptions +- The deployed mediator and at least one URL-type credential handler already honor the `protocols` query-param mechanism (it is documented and shipped). + +- `interactionUrl` is an `https:` URL the coordinator already trusts; the polyfill validates the scheme but does not fetch or interpret it. + +- No top-level-await requirement is introduced beyond what `loadOnce()` already implies. + +## Data flows +``` +Coordinator page + → chapi.interact({interactionUrl, ...}) + → pure builder → CredentialRequestOptions (interactionUrl placed in protocols) + → CredentialsContainer.get(options) [existing RPC] + → web-request-rpc → Credential Mediator (authn.io) in cross-origin iframe + → mediator → user selects handler / handler receives interactionUrl as + query param on its registered URL + ← resolve (empty) on completion | reject AbortError on cancel/abort +``` + +Trust boundaries (unchanged from existing `get()`): + +- The coordinator page cannot enumerate the user's wallets/handlers (anti- fingerprinting); `recommendedHandlerOrigins` are _suggestions_ surfaced by the mediator UI, not a query of installed handlers. + +- The mediator runs cross-origin and mediates all handler communication. + +- `interactionUrl` crosses to the selected handler only after user selection. + +## DB schema changes +None. This is a browser polyfill with no datastore. +## API endpoints and scope +No new server endpoints. Surface area is the client API only: + +- New method `interact()` on the `chapi` object. + +- New factory export (name TBD) returning the `chapi` object. + +- New `navigator.chapi` global set during `load()`/`loadOnce()`. + + +No existing public methods change behavior; this is purely additive (no breaking change to `get()`, `store()`, `load()`, or `loadOnce()`). +## Personal information impact +The polyfill itself collects, stores, and persists **no** personal data. It is a message broker between the coordinator page and a cross-origin mediator. + +- **Categories touched (in transit only):** `interactionUrl` and `recommendedHandlerOrigins` — neither is personal data by itself; they are endpoint/origin references chosen by the coordinator. Any personal data exchanged (e.g. a Verifiable Presentation) flows between the user-selected handler and the interaction endpoint, **not** through new code added here, and is not returned to the coordinator by `interact()` in this release. + +- **Purpose:** initiate a user-consented credential interaction. + +- **Storage:** none added. No new persistence, cookies, or localStorage. + +- **Transmission:** over the existing cross-origin RPC channel; the secure- context assertion (`window.isSecureContext`) is retained, so HTTPS/TLS is required. + +- **Data minimization:** `interact()` deliberately returns an empty result for now, returning _less_ to the coordinator than `get()` does, which reduces the data exposed to the relying party. + +## Security considerations +- **How could this be misused?** A malicious coordinator could pass a hostile `interactionUrl`. Mitigation: the polyfill validates the scheme is `https:`, does not fetch or execute the URL, and the URL only reaches a credential handler _after explicit user selection_ in the trusted mediator UI. User consent remains the gate, unchanged from `get()`. + +- **Attack surface / unnecessary data:** additive method reusing the existing RPC path; no new cross-origin channel, no new global beyond `navigator.chapi`. The empty-result contract avoids handing credential data to the relying party. + +- **Trusted vs untrusted sources:** `interactionUrl` and `recommendedHandlerOrigins` are **untrusted** caller input — validated (scheme, type) before use, never interpolated into executable contexts. Results from the mediator are treated as today (validation TODOs in `CredentialsContainer` apply equally). + +- **Anti-fingerprinting:** preserved — `interact()` gives the caller no way to learn which handlers the user has, consistent with the existing privacy model. + +- `navigator` **clobbering:** the standalone factory export lets security- conscious callers avoid the `navigator` namespace entirely, reducing exposure to extension/browser interference noted in the issue. + +## Open questions + +These remain **open by design** and are to be resolved during the initial +review on the draft PR — they are not blockers to opening that draft. + +1. **Final names.** `interact`, the factory export name, and the `type` values + (`'request'`/`'store'`) are all flagged for bikeshedding in the issue. Names + above are placeholders. +2. **`type: 'store'` wiring.** Should `store` translate to a `credentialstore` + event in this change, or land as a documented-but-unimplemented param with a + follow-up issue? Spec currently defers `store` behavior. +3. **Resolution payload.** Confirm `interact()` resolves to `{}` vs `undefined`, + and whether a future version returns interaction results (and if so, what the + minimal shape is). +4. **`interactionUrl` mapping key.** Under which `protocols` key should + `interactionUrl` be placed for the mediator/handler to recognize it, and does + any deployed handler need a manifest update to accept it? +5. **Performance for first-time calls.** The issue notes a no-`loadOnce()` path + may incur first-call setup cost (injecting the mediator iframe/styles). Do we + need a documented "warm-up" call, or is lazy initialization on first + `interact()` acceptable? +6. **`signal` interaction with the RPC.** The current RPC `get` uses an + indefinite timeout; confirm aborting `signal` cleanly tears down or abandons + the in-flight RPC rather than leaking it. + +--- + +_Next step: distribute this spec to Engineering, DevOps, CTO, and the Privacy Officer for review before any implementation begins._ From 05ace7f2d25787c7bdf1cc7df29f7d88db561a63 Mon Sep 17 00:00:00 2001 From: Derek Scruggs Date: Wed, 24 Jun 2026 08:50:51 -0500 Subject: [PATCH 02/10] Drop type param from interact() spec. Per review feedback on the draft PR, interact() always generates a credential request; the type parameter (and the deferred 'store' wiring) is removed. A store-style flow can be added later without changing the method signature. Renumbers the open questions left behind. Addresses #50. --- docs/specs/interact-api.md | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/docs/specs/interact-api.md b/docs/specs/interact-api.md index e397ca9..6908d7f 100644 --- a/docs/specs/interact-api.md +++ b/docs/specs/interact-api.md @@ -1,7 +1,7 @@ # Spec: Simplified `interact()` API for interaction URLs > Status: Draft — pending review by Engineering, DevOps, CTO, and Privacy Officer. Addresses [#50](https://github.com/credential-handler/credential-handler-polyfill/issues/50). ## Summary -Add a drastically simplified, single-call CHAPI entry point that lets issuer/verifier coordinator websites (relying parties) start a credential interaction by handing the polyfill an `interactionUrl`, without composing the full `web`/`VerifiablePresentation` request object themselves. The new `interact({interactionUrl, signal, recommendedHandlerOrigins, type})` method resolves when the interaction completes (to an empty object/`undefined` for now) or rejects with an `AbortError` when the user cancels or the caller aborts via an `AbortSignal`. Under the hood it translates into the existing `credentialrequest` (`get()`) flow — reusing the established `protocols` mechanism to carry the URL — so it is compatible with today's mediator and deployed credential handlers. The method is reachable two ways: attached at `navigator.chapi` after `loadOnce()`, and returned from a standalone factory export so callers can attach the API wherever they like (addressing the "avoid `navigator`" request in the issue thread). +Add a drastically simplified, single-call CHAPI entry point that lets issuer/verifier coordinator websites (relying parties) start a credential interaction by handing the polyfill an `interactionUrl`, without composing the full `web`/`VerifiablePresentation` request object themselves. The new `interact({interactionUrl, signal, recommendedHandlerOrigins})` method always generates a credential _request_ and resolves when the interaction completes (to an empty object/`undefined` for now) or rejects with an `AbortError` when the user cancels or the caller aborts via an `AbortSignal`. Under the hood it translates into the existing `credentialrequest` (`get()`) flow — reusing the established `protocols` mechanism to carry the URL — so it is compatible with today's mediator and deployed credential handlers. The method is reachable two ways: attached at `navigator.chapi` after `loadOnce()`, and returned from a standalone factory export so callers can attach the API wherever they like (addressing the "avoid `navigator`" request in the issue thread). ## Implementation details & assumptions ### New public API ```js @@ -9,8 +9,7 @@ Add a drastically simplified, single-call CHAPI entry point that lets issuer/ver await chapi.interact({ interactionUrl, // required string (https: URL) signal, // optional AbortSignal - recommendedHandlerOrigins, // optional string[] - type // optional 'request' | 'store', default 'request' + recommendedHandlerOrigins // optional string[] }); ``` @@ -30,9 +29,7 @@ Resolution contract: Both paths share one implementation; `navigator.chapi` is just the factory result assigned to `navigator`. ### Translation to existing flow -- `type: 'request'` (default) → translate to a `navigator.credentials.get()` call with a `web` request. - -- `type: 'store'` → reserved in the API now; wiring to `store()` is deferred to a follow-up (see Open questions). `interact()` validates and accepts the param but the spec does not commit to `store` behavior in this change. +- `interact()` always translates to a `navigator.credentials.get()` call with a `web` request. There is no `type` parameter: generating a request is expected to cover all current use cases (per review feedback on the draft PR), and a `store`-style flow can be added later without changing this signature if a concrete need emerges. - `interactionUrl` is carried via the **existing** `protocols` **map** (the query-param mechanism already used for URL-type credential handlers), not a new mediator field. This keeps the change client-side only — no mediator or RPC contract changes required for the initial release. @@ -41,7 +38,7 @@ Resolution contract: - `signal` is wired to the abort path: if already aborted, reject immediately; otherwise reject with `AbortError` when it fires. ### Functional-core / imperative-shell split -Per house practice, the request-construction logic is a **pure function** — `(interactionUrl, type, recommendedHandlerOrigins) → CredentialRequestOptions` — independently testable with no mediator, no `navigator`, no network. The imperative shell (`interact()`) does the secure-context check, awaits the RPC, and maps the result/abort to the resolution contract. +Per house practice, the request-construction logic is a **pure function** — `(interactionUrl, recommendedHandlerOrigins) → CredentialRequestOptions` — independently testable with no mediator, no `navigator`, no network. The imperative shell (`interact()`) does the secure-context check, awaits the RPC, and maps the result/abort to the resolution contract. ### Assumptions - The deployed mediator and at least one URL-type credential handler already honor the `protocols` query-param mechanism (it is documented and shipped). @@ -111,23 +108,19 @@ The polyfill itself collects, stores, and persists **no** personal data. It is a These remain **open by design** and are to be resolved during the initial review on the draft PR — they are not blockers to opening that draft. -1. **Final names.** `interact`, the factory export name, and the `type` values - (`'request'`/`'store'`) are all flagged for bikeshedding in the issue. Names - above are placeholders. -2. **`type: 'store'` wiring.** Should `store` translate to a `credentialstore` - event in this change, or land as a documented-but-unimplemented param with a - follow-up issue? Spec currently defers `store` behavior. -3. **Resolution payload.** Confirm `interact()` resolves to `{}` vs `undefined`, +1. **Final names.** `interact` and the factory export name are flagged for + bikeshedding in the issue. Names above are placeholders. +2. **Resolution payload.** Confirm `interact()` resolves to `{}` vs `undefined`, and whether a future version returns interaction results (and if so, what the minimal shape is). -4. **`interactionUrl` mapping key.** Under which `protocols` key should +3. **`interactionUrl` mapping key.** Under which `protocols` key should `interactionUrl` be placed for the mediator/handler to recognize it, and does any deployed handler need a manifest update to accept it? -5. **Performance for first-time calls.** The issue notes a no-`loadOnce()` path +4. **Performance for first-time calls.** The issue notes a no-`loadOnce()` path may incur first-call setup cost (injecting the mediator iframe/styles). Do we need a documented "warm-up" call, or is lazy initialization on first `interact()` acceptable? -6. **`signal` interaction with the RPC.** The current RPC `get` uses an +5. **`signal` interaction with the RPC.** The current RPC `get` uses an indefinite timeout; confirm aborting `signal` cleanly tears down or abandons the in-flight RPC rather than leaking it. From 6ae8eafe07f81b94d50b250dcd141233bc82c0ca Mon Sep 17 00:00:00 2001 From: Derek Scruggs Date: Wed, 24 Jun 2026 12:02:56 -0500 Subject: [PATCH 03/10] Resolve interactionUrl mapping key in interact() spec. Per review on the draft PR, carry interactionUrl in the protocols map under the well-known `interact` meta-protocol key: `{web: {protocols: {interact: interactionUrl}}}`. Any underlying exchange protocol stays hidden behind the URL, so no protocol param is needed; the polyfill treats interactionUrl as opaque (encoding is the caller's concern). Closes the mapping-key open question and renumbers the rest. Addresses #50. --- docs/specs/interact-api.md | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/docs/specs/interact-api.md b/docs/specs/interact-api.md index 6908d7f..073e4d3 100644 --- a/docs/specs/interact-api.md +++ b/docs/specs/interact-api.md @@ -31,7 +31,15 @@ Resolution contract: ### Translation to existing flow - `interact()` always translates to a `navigator.credentials.get()` call with a `web` request. There is no `type` parameter: generating a request is expected to cover all current use cases (per review feedback on the draft PR), and a `store`-style flow can be added later without changing this signature if a concrete need emerges. -- `interactionUrl` is carried via the **existing** `protocols` **map** (the query-param mechanism already used for URL-type credential handlers), not a new mediator field. This keeps the change client-side only — no mediator or RPC contract changes required for the initial release. +- `interactionUrl` is carried via the **existing** `protocols` **map** (the query-param mechanism already used for URL-type credential handlers), not a new mediator field, under the well-known key **`interact`**: + + ```js + web: { + protocols: {interact: interactionUrl} + } + ``` + + `interact` is a "meta" protocol: any underlying exchange protocol (e.g. `vcapi`, `OID4VCI`) is negotiated at the interaction endpoint and stays hidden behind the URL, so CHAPI never needs to know or carry it. The polyfill treats `interactionUrl` as an **opaque** string — it does not parse, encode, or decode it (any obfuscation such as base64url-encoding is the caller's concern). This keeps the change client-side only — no mediator or RPC contract changes required for the initial release. - `recommendedHandlerOrigins` passes straight through to the underlying request options, identical to current `get()`/`store()` semantics. @@ -50,7 +58,8 @@ Per house practice, the request-construction logic is a **pure function** — `( ``` Coordinator page → chapi.interact({interactionUrl, ...}) - → pure builder → CredentialRequestOptions (interactionUrl placed in protocols) + → pure builder → CredentialRequestOptions (interactionUrl placed in + protocols under the `interact` key) → CredentialsContainer.get(options) [existing RPC] → web-request-rpc → Credential Mediator (authn.io) in cross-origin iframe → mediator → user selects handler / handler receives interactionUrl as @@ -113,17 +122,24 @@ review on the draft PR — they are not blockers to opening that draft. 2. **Resolution payload.** Confirm `interact()` resolves to `{}` vs `undefined`, and whether a future version returns interaction results (and if so, what the minimal shape is). -3. **`interactionUrl` mapping key.** Under which `protocols` key should - `interactionUrl` be placed for the mediator/handler to recognize it, and does - any deployed handler need a manifest update to accept it? -4. **Performance for first-time calls.** The issue notes a no-`loadOnce()` path +3. **Performance for first-time calls.** The issue notes a no-`loadOnce()` path may incur first-call setup cost (injecting the mediator iframe/styles). Do we need a documented "warm-up" call, or is lazy initialization on first `interact()` acceptable? -5. **`signal` interaction with the RPC.** The current RPC `get` uses an +4. **`signal` interaction with the RPC.** The current RPC `get` uses an indefinite timeout; confirm aborting `signal` cleanly tears down or abandons the in-flight RPC rather than leaking it. +## Resolved during review + +- **`interactionUrl` mapping key** → use the well-known `protocols` key + **`interact`** (a "meta" protocol), with `interactionUrl` as the value. Any + underlying exchange protocol stays hidden behind the URL, so no `protocol` + param is needed on `interact()`. *Caveat:* a URL-type credential handler only + receives this value if it advertised `acceptedProtocols: ["interact"]` in its + manifest — deployed handlers may need that entry to participate. +- **`type` param** → dropped; `interact()` always generates a request. + --- _Next step: distribute this spec to Engineering, DevOps, CTO, and the Privacy Officer for review before any implementation begins._ From 33a89c9818925f8a3cd5157447cb332726412362 Mon Sep 17 00:00:00 2001 From: Derek Scruggs Date: Wed, 24 Jun 2026 12:04:14 -0500 Subject: [PATCH 04/10] Document URL-indirection rationale in interact() spec. Explain why interact() sends a single-key `interact` protocols object instead of an inline multi-key protocols object: the interaction URL is a layer of indirection that lets the consuming side fetch the full protocols object over TLS, providing source authentication and support for disconnected systems (e.g. QR-code readers) by reusing existing TLS infrastructure. Note that the single-key form is expected to supersede the multi-key form. Addresses #50. --- docs/specs/interact-api.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/specs/interact-api.md b/docs/specs/interact-api.md index 073e4d3..e0a9322 100644 --- a/docs/specs/interact-api.md +++ b/docs/specs/interact-api.md @@ -39,7 +39,7 @@ Resolution contract: } ``` - `interact` is a "meta" protocol: any underlying exchange protocol (e.g. `vcapi`, `OID4VCI`) is negotiated at the interaction endpoint and stays hidden behind the URL, so CHAPI never needs to know or carry it. The polyfill treats `interactionUrl` as an **opaque** string — it does not parse, encode, or decode it (any obfuscation such as base64url-encoding is the caller's concern). This keeps the change client-side only — no mediator or RPC contract changes required for the initial release. + `interact` is a "meta" protocol: any underlying exchange protocol (e.g. `vcapi`, `OID4VCI`) is negotiated at the interaction endpoint and stays hidden behind the URL, so CHAPI never needs to know or carry it. Where `get()`/`store()` historically accepted a multi-key `protocols` object, `interact()` always sends a **single-key** object — just `interact` — which is expected to supersede the multi-key form going forward (see Security considerations for why the URL indirection is preferred). The polyfill treats `interactionUrl` as an **opaque** string — it does not parse, encode, or decode it (any obfuscation such as base64url-encoding is the caller's concern). This keeps the change client-side only — no mediator or RPC contract changes required for the initial release. - `recommendedHandlerOrigins` passes straight through to the underlying request options, identical to current `get()`/`store()` semantics. @@ -112,6 +112,8 @@ The polyfill itself collects, stores, and persists **no** personal data. It is a - `navigator` **clobbering:** the standalone factory export lets security- conscious callers avoid the `navigator` namespace entirely, reducing exposure to extension/browser interference noted in the issue. +- **Why a URL instead of an inline `protocols` object (design rationale):** the single `interact` URL is a layer of indirection over the "full" `protocols` object. Rather than embedding a multi-key protocols object directly in the initial channel (e.g. a QR code), the relying party hands over one URL; the consuming side fetches the full protocols object from it **over TLS**. This yields two properties an inline blob cannot: (1) **source authentication** — TLS authenticates the origin of the protocols object, so the recipient can verify who issued it; and (2) **support for disconnected systems** — a reader with no back-channel (e.g. a QR-code scanner) can still authenticate the source by reusing existing TLS infrastructure, without new key-distribution or crypto. The expectation is that the single-key `interact` object supersedes multi-key `protocols` objects going forward. + ## Open questions These remain **open by design** and are to be resolved during the initial From cbb92af87a64bfb5da67c867fe812d34450f9f08 Mon Sep 17 00:00:00 2001 From: Derek Scruggs Date: Wed, 24 Jun 2026 17:14:39 -0500 Subject: [PATCH 05/10] Implement simplified interact() API. Add a single-call CHAPI entry point that starts a credential interaction from an opaque interaction URL, without the caller composing a full web request. interact() always generates a request, translating to the existing get() flow with the URL carried in the protocols map under the well-known `interact` meta-protocol key. Split into a pure functional core (createInteractRequest, independently unit-tested) and an imperative shell (interact(), built around an injected credentials container and secure-context assertion). The shell resolves to an empty object on completion and rejects with AbortError on user cancel or signal abort. Expose interact() both on the object returned by load()/loadOnce() and as navigator.chapi. Add node:test unit tests for the core and shell, a Playwright smoke test for the wiring, a navigator.chapi assertion to the existing load smoke test, and a README usage section. Addresses #50. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 39 +++++++ lib/index.js | 11 ++ lib/interact.js | 104 +++++++++++++++++++ lib/interactRequest.js | 52 ++++++++++ package.json | 4 +- playwright.config.js | 3 + test/interact.spec.js | 70 +++++++++++++ test/node/10-interactRequest.test.js | 77 ++++++++++++++ test/node/20-interact.test.js | 145 +++++++++++++++++++++++++++ test/smoke.spec.js | 5 +- 10 files changed, 508 insertions(+), 2 deletions(-) create mode 100644 lib/interact.js create mode 100644 lib/interactRequest.js create mode 100644 test/interact.spec.js create mode 100644 test/node/10-interactRequest.test.js create mode 100644 test/node/20-interact.test.js diff --git a/README.md b/README.md index fa92ad0..879e790 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,45 @@ if(!result) { } ``` +#### `interact()` + +> **Note:** `interact()` is a new, simplified entry point. See the +> [design spec](docs/specs/interact-api.md). The method name and return shape +> may still change. + +A coordinator website (a Relying Party such as an issuer or verifier) can start +a credential interaction by handing the polyfill a single **interaction URL**, +without composing a full `web` request object itself. `interact()` always +generates a credential _request_; under the hood it translates into the same +`navigator.credentials.get()` flow, carrying the URL in the `protocols` map +under the well-known `interact` key. + +```js +// reachable as `navigator.chapi` after load(), or from the object returned +// by load()/loadOnce() +await navigator.chapi.interact({ + // required: an `https:` URL the coordinator already trusts; the polyfill + // treats it as opaque (it does not fetch, parse, or encode it). The full + // "protocols" object is fetched from this URL over TLS by the selected + // handler, which authenticates its source and supports disconnected + // systems (e.g. QR-code readers). + interactionUrl: 'https://coordinator.example/exchanges/z1A2b3C4', + // optional: an AbortSignal to cancel the interaction + signal, + // optional: credential handler origins to recommend to the user + recommendedHandlerOrigins: ['https://wallet.example.chapi.io'] +}); +``` + +The returned promise: + +- **resolves** to an empty object (`{}`) when the interaction completes; no + credential data is returned to the coordinator (data minimization), +- **rejects** with a `DOMException` named `AbortError` when the user cancels or + the caller aborts via `signal`, +- otherwise rejects with the same errors surfaced by + [`get()`](#get) (e.g. `SecurityError` outside a secure context). + #### WebCredential TODO: Discuss creating and receiving WebCredential instances diff --git a/lib/index.js b/lib/index.js index eb0662d..f826baa 100644 --- a/lib/index.js +++ b/lib/index.js @@ -3,6 +3,7 @@ */ import {WebAppContext} from 'web-request-rpc'; +import {_createInteract} from './interact.js'; import {CredentialHandler} from './CredentialHandler.js'; import {CredentialHandlers} from './CredentialHandlers.js'; import {CredentialManager} from './CredentialManager.js'; @@ -75,8 +76,18 @@ export async function load(options = { polyfill.WebCredential = WebCredential; + // the simplified `interact()` API, bound to this polyfill's credentials + // container; exposed both on the returned polyfill (`polyfill.chapi`) and + // as a global (`navigator.chapi`) so callers can use either without + // composing a full request object themselves + const chapi = { + interact: _createInteract({credentials: polyfill.credentials}) + }; + polyfill.chapi = chapi; + // expose polyfill navigator.credentialsPolyfill = polyfill; + navigator.chapi = chapi; // polyfill if('credentials' in navigator) { diff --git a/lib/interact.js b/lib/interact.js new file mode 100644 index 0000000..160dea8 --- /dev/null +++ b/lib/interact.js @@ -0,0 +1,104 @@ +/*! + * The `interact()` API: a simplified, single-call CHAPI entry point that + * starts a credential interaction from an interaction URL. The imperative + * shell here wires the pure request builder to the existing `get()` RPC and + * maps the result/abort to the resolution contract. See + * docs/specs/interact-api.md. + * + * Copyright (c) 2026 Digital Bazaar, Inc. + */ +import {createInteractRequest} from './interactRequest.js'; + +/** + * Asserts the current context is secure (HTTPS/TLS), matching the existing + * polyfill behavior. + */ +export function assertSecureContext() { + if(!globalThis.isSecureContext) { + throw new DOMException('The operation is insecure.', 'SecurityError'); + } +} + +/** + * Builds an `interact()` function bound to a given credentials container. + * Exported (with a leading underscore) for testing the imperative shell + * without a browser; production code reaches `interact()` via the chapi + * object returned from `load()`/`loadOnce()` or the standalone factory. + * + * @param {object} options - The options to use. + * @param {object} options.credentials - A credentials container exposing + * `get(CredentialRequestOptions)` (the RPC seam). + * @param {Function} [options.assertSecureContext] - Secure-context assertion; + * defaults to the real check. + * + * @returns {Function} The `interact()` function. + */ +export function _createInteract({ + credentials, assertSecureContext: assertSecure = assertSecureContext +} = {}) { + /** + * Starts a credential interaction. + * + * @param {object} options - The options to use. + * @param {string} options.interactionUrl - An `https:` interaction URL. + * @param {AbortSignal} [options.signal] - Aborts the interaction. + * @param {string[]} [options.recommendedHandlerOrigins] - Optional handler + * origins to recommend to the user. + * + * @returns {Promise} Resolves to an empty object on completion; + * rejects with an `AbortError` on cancel/abort. + */ + return async function interact({ + interactionUrl, signal, recommendedHandlerOrigins + } = {}) { + assertSecure(); + + // build the request before touching the signal so invalid input fails + // fast with a synchronous validation error + const request = createInteractRequest({ + interactionUrl, recommendedHandlerOrigins + }); + + if(signal?.aborted) { + throw _abortError(); + } + + // race the in-flight RPC against the abort signal so an abort rejects + // promptly rather than waiting on the indefinite-timeout `get()` + const credential = await _withAbort(credentials.get(request), signal); + + if(!credential) { + // no credential selected: treat as a user cancel + throw _abortError(); + } + + // minimal resolution contract: resolve to an empty object for now; the + // shape is reserved for future expansion and intentionally returns no + // credential data to the relying party + return {}; + }; +} + +function _abortError() { + return new DOMException('The operation was aborted.', 'AbortError'); +} + +/** + * Rejects as soon as `signal` fires, otherwise settles with `promise`. + * + * @param {Promise} promise - The in-flight operation. + * @param {AbortSignal} [signal] - Optional abort signal. + * + * @returns {Promise} The race result. + */ +function _withAbort(promise, signal) { + if(!signal) { + return promise; + } + return new Promise((resolve, reject) => { + const onAbort = () => reject(_abortError()); + signal.addEventListener('abort', onAbort, {once: true}); + promise.then(resolve, reject).finally( + () => signal.removeEventListener('abort', onAbort)); + }); +} diff --git a/lib/interactRequest.js b/lib/interactRequest.js new file mode 100644 index 0000000..4016775 --- /dev/null +++ b/lib/interactRequest.js @@ -0,0 +1,52 @@ +/*! + * Pure request-construction for the `interact()` API. Builds a + * `CredentialRequestOptions` for `navigator.credentials.get()` from an + * interaction URL, with no mediator, no `navigator`, and no network -- so it + * is independently testable. See docs/specs/interact-api.md. + * + * Copyright (c) 2026 Digital Bazaar, Inc. + */ + +/** + * Builds the `get()` request that carries an interaction URL. + * + * The URL is placed in the `protocols` map under the well-known `interact` + * key (a "meta" protocol); the polyfill treats it as opaque and does not + * parse, encode, or decode it. Any underlying exchange protocol stays hidden + * behind the URL. + * + * @param {object} options - The options to use. + * @param {string} options.interactionUrl - An `https:` interaction URL. + * @param {string[]} [options.recommendedHandlerOrigins] - Optional handler + * origins to recommend to the user. + * + * @returns {object} A `CredentialRequestOptions` for `credentials.get()`. + */ +export function createInteractRequest({ + interactionUrl, recommendedHandlerOrigins +} = {}) { + if(typeof interactionUrl !== 'string') { + throw new TypeError('"interactionUrl" must be a string.'); + } + // validate the scheme only; the value is otherwise treated as opaque and + // stored byte-for-byte (the original string, not a re-serialized URL) + let parsed; + try { + parsed = new URL(interactionUrl); + } catch { + throw new Error('"interactionUrl" must be a valid "https:" URL.'); + } + if(parsed.protocol !== 'https:') { + throw new Error('"interactionUrl" must be an "https:" URL.'); + } + if(recommendedHandlerOrigins !== undefined && + !Array.isArray(recommendedHandlerOrigins)) { + throw new TypeError('"recommendedHandlerOrigins" must be an array.'); + } + + const web = {protocols: {interact: interactionUrl}}; + if(recommendedHandlerOrigins !== undefined) { + web.recommendedHandlerOrigins = recommendedHandlerOrigins; + } + return {web}; +} diff --git a/package.json b/package.json index 45f517b..7281a2b 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "prepublish": "npm run build", "build": "webpack", "lint": "eslint --no-warn-ignored .", - "test": "playwright test" + "test": "npm run test:node && npm run test:browser", + "test:node": "node --test 'test/node/**/*.test.js'", + "test:browser": "playwright test" }, "repository": { "type": "git", diff --git a/playwright.config.js b/playwright.config.js index e18bb61..95910bb 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -10,6 +10,9 @@ const PORT = 9876; export default defineConfig({ testDir: './test', + // node:test unit tests for the pure functional core live in test/node/ and + // are run via `npm run test:node`, not Playwright + testIgnore: '**/node/**', fullyParallel: true, forbidOnly: !!process.env.CI, retries: 0, diff --git a/test/interact.spec.js b/test/interact.spec.js new file mode 100644 index 0000000..e218f7a --- /dev/null +++ b/test/interact.spec.js @@ -0,0 +1,70 @@ +/*! + * Copyright (c) 2026 Digital Bazaar, Inc. + */ +import {expect, test} from '@playwright/test'; + +// Smoke test for the simplified `interact()` API wiring (PR #57). Verifies +// that `loadOnce()` exposes `interact` both on the returned polyfill object +// and as `navigator.chapi`, and that input validation reaches through the +// built bundle. The pure builder and shell logic are covered exhaustively by +// the node:test unit tests in test/node/; this only checks the wiring. + +test('loadOnce() exposes interact() on the polyfill and navigator.chapi', + async ({page}) => { + await page.goto('/test/fixtures/index.html'); + + const result = await page.evaluate(async () => { + const polyfill = await window.credentialHandlerPolyfill.loadOnce(); + return { + returnedInteractIsFn: typeof polyfill.chapi?.interact === 'function', + navigatorInteractIsFn: typeof navigator.chapi?.interact === 'function', + // both paths share one implementation + sameChapi: polyfill.chapi === navigator.chapi + }; + }); + + expect(result.returnedInteractIsFn).toBe(true); + expect(result.navigatorInteractIsFn).toBe(true); + expect(result.sameChapi).toBe(true); + }); + +test('interact() rejects a non-https interactionUrl through the bundle', + async ({page}) => { + await page.goto('/test/fixtures/index.html'); + + const error = await page.evaluate(async () => { + await window.credentialHandlerPolyfill.loadOnce(); + try { + await navigator.chapi.interact({ + interactionUrl: 'http://insecure.example/abc' + }); + return null; + } catch(e) { + return e.message; + } + }); + + expect(error).toContain('https:'); + }); + +test('interact() rejects immediately when signal is already aborted', + async ({page}) => { + await page.goto('/test/fixtures/index.html'); + + const name = await page.evaluate(async () => { + await window.credentialHandlerPolyfill.loadOnce(); + const controller = new AbortController(); + controller.abort(); + try { + await navigator.chapi.interact({ + interactionUrl: 'https://exchange.example/abc', + signal: controller.signal + }); + return null; + } catch(e) { + return e.name; + } + }); + + expect(name).toBe('AbortError'); + }); diff --git a/test/node/10-interactRequest.test.js b/test/node/10-interactRequest.test.js new file mode 100644 index 0000000..a7456b0 --- /dev/null +++ b/test/node/10-interactRequest.test.js @@ -0,0 +1,77 @@ +/*! + * Copyright (c) 2026 Digital Bazaar, Inc. + */ +import {strict as assert} from 'node:assert'; +import {createInteractRequest} from '../../lib/interactRequest.js'; +import test from 'node:test'; + +// The pure functional core: it builds a `CredentialRequestOptions` for +// `navigator.credentials.get()` from an interaction URL, with no mediator, +// no `navigator`, and no network. See docs/specs/interact-api.md. + +test('places interactionUrl under the `interact` protocols key', () => { + const options = createInteractRequest({ + interactionUrl: 'https://exchange.example/abc' + }); + assert.deepEqual(options, { + web: { + protocols: {interact: 'https://exchange.example/abc'} + } + }); +}); + +test('includes recommendedHandlerOrigins when provided', () => { + const options = createInteractRequest({ + interactionUrl: 'https://exchange.example/abc', + recommendedHandlerOrigins: ['https://wallet.example'] + }); + assert.deepEqual(options, { + web: { + protocols: {interact: 'https://exchange.example/abc'}, + recommendedHandlerOrigins: ['https://wallet.example'] + } + }); +}); + +test('omits recommendedHandlerOrigins when not provided', () => { + const options = createInteractRequest({ + interactionUrl: 'https://exchange.example/abc' + }); + assert.equal('recommendedHandlerOrigins' in options.web, false); +}); + +test('produces only the single-key `interact` protocols object', () => { + const {protocols} = createInteractRequest({ + interactionUrl: 'https://exchange.example/abc' + }).web; + assert.deepEqual(Object.keys(protocols), ['interact']); +}); + +test('treats interactionUrl as opaque (does not parse or re-encode)', () => { + // a URL with query/fragment must pass through byte-for-byte + const url = 'https://exchange.example/abc?x=1&y=2#frag'; + const options = createInteractRequest({interactionUrl: url}); + assert.equal(options.web.protocols.interact, url); +}); + +test('throws TypeError when interactionUrl is missing', () => { + assert.throws(() => createInteractRequest({}), TypeError); +}); + +test('throws TypeError when interactionUrl is not a string', () => { + assert.throws( + () => createInteractRequest({interactionUrl: 42}), TypeError); +}); + +test('throws when interactionUrl is not an https: URL', () => { + assert.throws( + () => createInteractRequest({interactionUrl: 'http://exchange.example'}), + /https:/); +}); + +test('throws when recommendedHandlerOrigins is not an array', () => { + assert.throws(() => createInteractRequest({ + interactionUrl: 'https://exchange.example/abc', + recommendedHandlerOrigins: 'https://wallet.example' + }), TypeError); +}); diff --git a/test/node/20-interact.test.js b/test/node/20-interact.test.js new file mode 100644 index 0000000..0dd4e8b --- /dev/null +++ b/test/node/20-interact.test.js @@ -0,0 +1,145 @@ +/*! + * Copyright (c) 2026 Digital Bazaar, Inc. + */ +import {_createInteract} from '../../lib/interact.js'; +import {strict as assert} from 'node:assert'; +import test from 'node:test'; + +// The imperative shell. It is built around an injected `credentials` +// container (the RPC seam) and an injected `assertSecureContext` so it can be +// exercised without a browser. See docs/specs/interact-api.md. + +const URL_OK = 'https://exchange.example/abc'; + +function makeCredentials(impl) { + return {get: impl}; +} + +// a secure-context assertion that passes, for the happy paths +const secure = () => {}; + +test('translates to credentials.get() with the interact request', async () => { + let received; + const interact = _createInteract({ + credentials: makeCredentials(async options => { + received = options; + return {selected: true}; + }), + assertSecureContext: secure + }); + await interact({interactionUrl: URL_OK}); + assert.deepEqual(received, { + web: {protocols: {interact: URL_OK}} + }); +}); + +test('passes recommendedHandlerOrigins through to get()', async () => { + let received; + const interact = _createInteract({ + credentials: makeCredentials(async options => { + received = options; + return {selected: true}; + }), + assertSecureContext: secure + }); + await interact({ + interactionUrl: URL_OK, + recommendedHandlerOrigins: ['https://wallet.example'] + }); + assert.deepEqual( + received.web.recommendedHandlerOrigins, ['https://wallet.example']); +}); + +test('resolves to an empty object on completion', async () => { + const interact = _createInteract({ + credentials: makeCredentials(async () => ({some: 'credential'})), + assertSecureContext: secure + }); + const result = await interact({interactionUrl: URL_OK}); + // minimal contract: resolves to {} regardless of what get() returned + assert.deepEqual(result, {}); +}); + +test('rejects with AbortError when signal is already aborted', async () => { + let called = false; + const interact = _createInteract({ + credentials: makeCredentials(async () => { + called = true; + return null; + }), + assertSecureContext: secure + }); + const controller = new AbortController(); + controller.abort(); + await assert.rejects( + () => interact({interactionUrl: URL_OK, signal: controller.signal}), + e => e.name === 'AbortError'); + // must not even call get() if already aborted + assert.equal(called, false); +}); + +test('rejects with AbortError when signal fires mid-flight', async () => { + const controller = new AbortController(); + const interact = _createInteract({ + // a get() that never settles on its own + credentials: makeCredentials(() => new Promise(() => {})), + assertSecureContext: secure + }); + const promise = interact({ + interactionUrl: URL_OK, signal: controller.signal + }); + controller.abort(); + await assert.rejects(promise, e => e.name === 'AbortError'); +}); + +test('rejects with AbortError when get() resolves null', async () => { + // when no credential is selected, get() resolves null; interact() treats + // that as a user cancel and rejects with AbortError + const interact = _createInteract({ + credentials: makeCredentials(async () => null), + assertSecureContext: secure + }); + await assert.rejects( + () => interact({interactionUrl: URL_OK}), + e => e.name === 'AbortError'); +}); + +test('propagates other get() errors unchanged', async () => { + const err = new DOMException('Not implemented.', 'NotSupportedError'); + const interact = _createInteract({ + credentials: makeCredentials(async () => { + throw err; + }), + assertSecureContext: secure + }); + await assert.rejects( + () => interact({interactionUrl: URL_OK}), + e => e.name === 'NotSupportedError'); +}); + +test('asserts secure context before doing anything', async () => { + let getCalled = false; + const interact = _createInteract({ + credentials: makeCredentials(async () => { + getCalled = true; + return null; + }), + assertSecureContext: () => { + throw new DOMException('The operation is insecure.', 'SecurityError'); + } + }); + await assert.rejects( + () => interact({interactionUrl: URL_OK}), + e => e.name === 'SecurityError'); + assert.equal(getCalled, false); +}); + +test('rejects invalid interactionUrl via the builder', async () => { + const interact = _createInteract({ + credentials: makeCredentials(async () => null), + assertSecureContext: secure + }); + await assert.rejects( + () => interact({interactionUrl: 'http://insecure.example'}), + /https:/); +}); diff --git a/test/smoke.spec.js b/test/smoke.spec.js index 555b7ca..85a6363 100644 --- a/test/smoke.spec.js +++ b/test/smoke.spec.js @@ -22,7 +22,8 @@ test('loadOnce() resolves and patches navigator.credentials', async ({ return { hasWebCredential: typeof window.WebCredential === 'function', getIsFn: typeof navigator.credentials.get === 'function', - storeIsFn: typeof navigator.credentials.store === 'function' + storeIsFn: typeof navigator.credentials.store === 'function', + interactIsFn: typeof navigator.chapi?.interact === 'function' }; }); @@ -31,6 +32,8 @@ test('loadOnce() resolves and patches navigator.credentials', async ({ expect(result.hasWebCredential).toBe(true); expect(result.getIsFn).toBe(true); expect(result.storeIsFn).toBe(true); + // the simplified interact() API is installed on navigator.chapi by load() + expect(result.interactIsFn).toBe(true); }); test('loadOnce() resolves when navigator.credentials is non-configurable', From 3b24f06812509d9855e4a40d4fc9a4fc72928af1 Mon Sep 17 00:00:00 2001 From: Derek Scruggs Date: Wed, 24 Jun 2026 17:19:01 -0500 Subject: [PATCH 06/10] Add changelog and reconcile interact() spec with implementation. Add a CHANGELOG entry for the interact() API and its Node unit tests. Update the spec status from Draft to In review and annotate the open questions with what the initial implementation currently does: resolves to {}, abandons (does not tear down) the in-flight RPC on signal abort, and maps a null get() result (no credential selected) to AbortError. These decisions remain open for review. Addresses #50. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 11 +++++++++++ docs/specs/interact-api.md | 20 +++++++++++++------- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b368e79..8558547 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ ## 4.0.4 - 2026-06-dd ### Added +- Simplified `interact()` API: a single-call entry point that starts a + credential interaction from an opaque `https:` interaction URL, without the + caller composing a full `web` request. It always generates a request, + translating to the existing `get()` flow with the URL carried in the + `protocols` map under the well-known `interact` meta-protocol key. Reachable + as `navigator.chapi.interact()` and on the object returned by + `load()`/`loadOnce()`. Resolves to an empty object on completion; rejects + with `AbortError` on user cancel or `signal` abort. See + `docs/specs/interact-api.md` and #50. +- Node.js unit tests (`node --test`) for the `interact()` functional core and + imperative shell, run via `npm run test:node` (and as part of `npm test`). - Cross-browser smoke test suite (Playwright) covering Chromium, Firefox, and WebKit. Verifies `loadOnce()` resolves and patches `navigator.credentials`, and includes a regression guard for the non-configurable diff --git a/docs/specs/interact-api.md b/docs/specs/interact-api.md index e0a9322..b993972 100644 --- a/docs/specs/interact-api.md +++ b/docs/specs/interact-api.md @@ -1,5 +1,5 @@ # Spec: Simplified `interact()` API for interaction URLs -> Status: Draft — pending review by Engineering, DevOps, CTO, and Privacy Officer. Addresses [#50](https://github.com/credential-handler/credential-handler-polyfill/issues/50). +> Status: In review — an initial implementation has landed in this PR; some open questions below remain to be settled during review (final method name, resolution payload, in-flight `signal` teardown). Addresses [#50](https://github.com/credential-handler/credential-handler-polyfill/issues/50). ## Summary Add a drastically simplified, single-call CHAPI entry point that lets issuer/verifier coordinator websites (relying parties) start a credential interaction by handing the polyfill an `interactionUrl`, without composing the full `web`/`VerifiablePresentation` request object themselves. The new `interact({interactionUrl, signal, recommendedHandlerOrigins})` method always generates a credential _request_ and resolves when the interaction completes (to an empty object/`undefined` for now) or rejects with an `AbortError` when the user cancels or the caller aborts via an `AbortSignal`. Under the hood it translates into the existing `credentialrequest` (`get()`) flow — reusing the established `protocols` mechanism to carry the URL — so it is compatible with today's mediator and deployed credential handlers. The method is reachable two ways: attached at `navigator.chapi` after `loadOnce()`, and returned from a standalone factory export so callers can attach the API wherever they like (addressing the "avoid `navigator`" request in the issue thread). ## Implementation details & assumptions @@ -121,16 +121,22 @@ review on the draft PR — they are not blockers to opening that draft. 1. **Final names.** `interact` and the factory export name are flagged for bikeshedding in the issue. Names above are placeholders. -2. **Resolution payload.** Confirm `interact()` resolves to `{}` vs `undefined`, - and whether a future version returns interaction results (and if so, what the - minimal shape is). +2. **Resolution payload.** Confirm `interact()` resolves to `{}` vs `undefined`. + *Current implementation resolves to `{}`.* Open: whether a future version + returns interaction results (and if so, what the minimal shape is). 3. **Performance for first-time calls.** The issue notes a no-`loadOnce()` path may incur first-call setup cost (injecting the mediator iframe/styles). Do we need a documented "warm-up" call, or is lazy initialization on first `interact()` acceptable? -4. **`signal` interaction with the RPC.** The current RPC `get` uses an - indefinite timeout; confirm aborting `signal` cleanly tears down or abandons - the in-flight RPC rather than leaking it. +4. **`signal` interaction with the RPC.** *Current implementation* races the + in-flight `get()` against the signal and rejects with `AbortError` when the + signal fires, but it **abandons** the underlying RPC rather than tearing it + down (`get()` uses an indefinite timeout). Open: confirm whether abandoning + is acceptable or whether the RPC must be actively cancelled to avoid a leak. +5. **User-cancel mapping.** *Current implementation* treats `get()` resolving + `null` (no credential selected) as a user cancel and rejects with + `AbortError`. Open: confirm this is the desired contract vs. resolving empty + on a non-selection. ## Resolved during review From a2917bc65cc18075ee40cee261f5143038c94b65 Mon Sep 17 00:00:00 2001 From: Derek Scruggs Date: Thu, 25 Jun 2026 12:07:42 -0500 Subject: [PATCH 07/10] Address review: install option, throwIfAborted, validation. Stop attaching the interact() API to navigator. Add an `install` option to load()/loadOnce() (default true, preserving global-install behavior): when true it now also sets globalThis.chapi; when false the polyfill attaches nothing globally and only returns the polyfill, letting callers place the API themselves. Recommend `globalThis.chapi = (await loadOnce()).chapi` in the docs. Use signal.throwIfAborted() for the already-aborted pre-check so the signal's own reason surfaces; keep the mid-flight abort race. Improve interactRequest validation: attach error.cause and a clearer message on URL parse failure, distinguish the wrong-protocol message, and require recommendedHandlerOrigins to be an array of URL strings. Update the spec, README, CHANGELOG, and tests accordingly. Addresses #50. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 12 ++++--- README.md | 27 +++++++++++++-- docs/specs/interact-api.md | 49 +++++++++++++++++++++------- lib/index.js | 21 +++++++++--- lib/interact.js | 6 ++-- lib/interactRequest.js | 15 ++++++--- test/interact.spec.js | 42 ++++++++++++++++++------ test/node/10-interactRequest.test.js | 27 +++++++++++++-- test/smoke.spec.js | 4 +-- 9 files changed, 159 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8558547..a7b806d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,14 @@ caller composing a full `web` request. It always generates a request, translating to the existing `get()` flow with the URL carried in the `protocols` map under the well-known `interact` meta-protocol key. Reachable - as `navigator.chapi.interact()` and on the object returned by - `load()`/`loadOnce()`. Resolves to an empty object on completion; rejects - with `AbortError` on user cancel or `signal` abort. See - `docs/specs/interact-api.md` and #50. + on the `chapi` object returned by `load()`/`loadOnce()` and, when installing, + at `globalThis.chapi` (the polyfill never attaches to `navigator`). Resolves + to an empty object on completion; rejects with `AbortError` on user cancel or + `signal` abort. See `docs/specs/interact-api.md` and #50. +- `install` option on `load()`/`loadOnce()` (default `true`, preserving the + existing global-install behavior). When `false`, the polyfill attaches + nothing to the global environment and only returns the polyfill, letting + callers place the API (e.g. `globalThis.chapi`) themselves. - Node.js unit tests (`node --test`) for the `interact()` functional core and imperative shell, run via `npm run test:node` (and as part of `npm test`). - Cross-browser smoke test suite (Playwright) covering Chromium, Firefox, and diff --git a/README.md b/README.md index 879e790..8c9294a 100644 --- a/README.md +++ b/README.md @@ -173,10 +173,31 @@ generates a credential _request_; under the hood it translates into the same `navigator.credentials.get()` flow, carrying the URL in the `protocols` map under the well-known `interact` key. +`interact()` lives on the `chapi` object. The polyfill does **not** attach it to +`navigator`. By default `load()`/`loadOnce()` set `globalThis.chapi` for you, +but the recommended pattern is to attach it explicitly so your app controls +where the API lives: + +```js +try { + globalThis.chapi = (await loadOnce()).chapi; +} catch(e) { + console.log('CHAPI failed to load.'); +} +``` + +To suppress all automatic global installation and place the API entirely +yourself, pass `install: false`: + +```js +const {chapi} = await loadOnce({install: false}); +// nothing was attached to navigator or globalThis; attach it however you like +``` + +Then call it: + ```js -// reachable as `navigator.chapi` after load(), or from the object returned -// by load()/loadOnce() -await navigator.chapi.interact({ +await chapi.interact({ // required: an `https:` URL the coordinator already trusts; the polyfill // treats it as opaque (it does not fetch, parse, or encode it). The full // "protocols" object is fetched from this URL over TLS by the selected diff --git a/docs/specs/interact-api.md b/docs/specs/interact-api.md index b993972..7921b6b 100644 --- a/docs/specs/interact-api.md +++ b/docs/specs/interact-api.md @@ -1,7 +1,7 @@ # Spec: Simplified `interact()` API for interaction URLs > Status: In review — an initial implementation has landed in this PR; some open questions below remain to be settled during review (final method name, resolution payload, in-flight `signal` teardown). Addresses [#50](https://github.com/credential-handler/credential-handler-polyfill/issues/50). ## Summary -Add a drastically simplified, single-call CHAPI entry point that lets issuer/verifier coordinator websites (relying parties) start a credential interaction by handing the polyfill an `interactionUrl`, without composing the full `web`/`VerifiablePresentation` request object themselves. The new `interact({interactionUrl, signal, recommendedHandlerOrigins})` method always generates a credential _request_ and resolves when the interaction completes (to an empty object/`undefined` for now) or rejects with an `AbortError` when the user cancels or the caller aborts via an `AbortSignal`. Under the hood it translates into the existing `credentialrequest` (`get()`) flow — reusing the established `protocols` mechanism to carry the URL — so it is compatible with today's mediator and deployed credential handlers. The method is reachable two ways: attached at `navigator.chapi` after `loadOnce()`, and returned from a standalone factory export so callers can attach the API wherever they like (addressing the "avoid `navigator`" request in the issue thread). +Add a drastically simplified, single-call CHAPI entry point that lets issuer/verifier coordinator websites (relying parties) start a credential interaction by handing the polyfill an `interactionUrl`, without composing the full `web`/`VerifiablePresentation` request object themselves. The new `interact({interactionUrl, signal, recommendedHandlerOrigins})` method always generates a credential _request_ and resolves when the interaction completes (to an empty object/`undefined` for now) or rejects with an `AbortError` when the user cancels or the caller aborts via an `AbortSignal`. Under the hood it translates into the existing `credentialrequest` (`get()`) flow — reusing the established `protocols` mechanism to carry the URL — so it is compatible with today's mediator and deployed credential handlers. The method is reachable on the object returned by `load()`/`loadOnce()` and, when installing, at the global `globalThis.chapi`; the polyfill never attaches to `navigator` (addressing the "avoid `navigator`" request in the issue thread). An `install: false` option lets callers suppress all global attachment and place the API wherever they like. ## Implementation details & assumptions ### New public API ```js @@ -22,11 +22,31 @@ Resolution contract: - Other failures reject with the existing error types surfaced by `get()` (e.g. `NotSupportedError`, `SecurityError` from the secure-context assertion). ### Two ways to reach it (issue asks for both) -1. `navigator.chapi` — `load()`/`loadOnce()` attach a `chapi` object (containing `interact`) to `navigator` alongside the existing `navigator.credentialsPolyfill`, for parity with the current global pattern. - -2. **Standalone factory** — a new export (working name `loadOnce`-style factory; final name TBD per the issue's bikeshedding note) returns the `chapi` object so callers can assign it wherever they want, e.g. `globalThis.chapi = await createChapi({...})`. This avoids forcing the `navigator` namespace, which the thread flags as prone to clobbering by password-manager extensions and browser security changes. - - Both paths share one implementation; `navigator.chapi` is just the factory result assigned to `navigator`. +The polyfill deliberately **never** attaches `chapi` to `navigator` — the +thread flags `navigator` as prone to clobbering by password-manager extensions +and browser security changes. Instead: + +1. **Returned object** — `load()`/`loadOnce()` return the polyfill with a + `chapi` property (containing `interact`), so callers can wire it up however + they like. The recommended pattern attaches it to `globalThis` explicitly: + + ```js + try { + globalThis.chapi = (await loadOnce()).chapi; + } catch(e) { + console.log('CHAPI failed to load.'); + } + ``` + +2. **`install` option** — `load({install})`/`loadOnce({install})` controls + automatic global installation. `install: true` (the default, for backwards + compatibility) installs the polyfill globally as today **and** sets + `globalThis.chapi = chapi`. `install: false` attaches nothing to the global + environment and only returns the polyfill, leaving placement entirely to the + caller. + + Both paths share one implementation; `globalThis.chapi` (when installed) is + just the same `chapi` object that is also returned. ### Translation to existing flow - `interact()` always translates to a `navigator.credentials.get()` call with a `web` request. There is no `type` parameter: generating a request is expected to cover all current use cases (per review feedback on the draft PR), and a `store`-style flow can be added later without changing this signature if a concrete need emerges. @@ -82,12 +102,19 @@ No new server endpoints. Surface area is the client API only: - New method `interact()` on the `chapi` object. -- New factory export (name TBD) returning the `chapi` object. +- New `chapi` property on the object returned by `load()`/`loadOnce()`. + +- New `globalThis.chapi` global set during `load()`/`loadOnce()` when + `install` is `true` (the default). -- New `navigator.chapi` global set during `load()`/`loadOnce()`. +- New `install` option on `load()`/`loadOnce()` (default `true`) that, when + `false`, suppresses all global installation. -No existing public methods change behavior; this is purely additive (no breaking change to `get()`, `store()`, `load()`, or `loadOnce()`). +No existing public methods change behavior; this is purely additive. The new +`install` option defaults to `true`, preserving the current global-install +behavior of `load()`/`loadOnce()` (no breaking change to `get()`, `store()`, +`load()`, or `loadOnce()`). ## Personal information impact The polyfill itself collects, stores, and persists **no** personal data. It is a message broker between the coordinator page and a cross-origin mediator. @@ -104,13 +131,13 @@ The polyfill itself collects, stores, and persists **no** personal data. It is a ## Security considerations - **How could this be misused?** A malicious coordinator could pass a hostile `interactionUrl`. Mitigation: the polyfill validates the scheme is `https:`, does not fetch or execute the URL, and the URL only reaches a credential handler _after explicit user selection_ in the trusted mediator UI. User consent remains the gate, unchanged from `get()`. -- **Attack surface / unnecessary data:** additive method reusing the existing RPC path; no new cross-origin channel, no new global beyond `navigator.chapi`. The empty-result contract avoids handing credential data to the relying party. +- **Attack surface / unnecessary data:** additive method reusing the existing RPC path; no new cross-origin channel, and no new global beyond `globalThis.chapi` (which `install: false` suppresses entirely). The empty-result contract avoids handing credential data to the relying party. - **Trusted vs untrusted sources:** `interactionUrl` and `recommendedHandlerOrigins` are **untrusted** caller input — validated (scheme, type) before use, never interpolated into executable contexts. Results from the mediator are treated as today (validation TODOs in `CredentialsContainer` apply equally). - **Anti-fingerprinting:** preserved — `interact()` gives the caller no way to learn which handlers the user has, consistent with the existing privacy model. -- `navigator` **clobbering:** the standalone factory export lets security- conscious callers avoid the `navigator` namespace entirely, reducing exposure to extension/browser interference noted in the issue. +- `navigator` **clobbering:** the polyfill never attaches `chapi` to `navigator`, and `install: false` lets security-conscious callers suppress all automatic global attachment and place the API themselves, reducing exposure to extension/browser interference noted in the issue. - **Why a URL instead of an inline `protocols` object (design rationale):** the single `interact` URL is a layer of indirection over the "full" `protocols` object. Rather than embedding a multi-key protocols object directly in the initial channel (e.g. a QR code), the relying party hands over one URL; the consuming side fetches the full protocols object from it **over TLS**. This yields two properties an inline blob cannot: (1) **source authentication** — TLS authenticates the origin of the protocols object, so the recipient can verify who issued it; and (2) **support for disconnected systems** — a reader with no back-channel (e.g. a QR-code scanner) can still authenticate the source by reusing existing TLS infrastructure, without new key-distribution or crypto. The expectation is that the single-key `interact` object supersedes multi-key `protocols` objects going forward. diff --git a/lib/index.js b/lib/index.js index f826baa..157abfc 100644 --- a/lib/index.js +++ b/lib/index.js @@ -37,11 +37,19 @@ export async function load(options = { // backwards compatibility (`options` used to be a string for expressing // the full mediator URL) let mediatorUrl; + // when `true` (the default), the polyfill installs itself on the global + // environment (`navigator.credentials`, `navigator.credentialsPolyfill`, + // and `globalThis.chapi`); when `false`, it attaches nothing and only + // returns the polyfill so callers can wire it up however they like + let install = true; if(typeof options === 'string') { mediatorUrl = options; } else if(options && typeof options === 'object' && typeof options.mediatorOrigin === 'string') { mediatorUrl = `${options.mediatorOrigin}/mediator`; + if(options.install !== undefined) { + install = options.install; + } } else { throw new Error( '"options.mediatorOrigin" must be a string expressing the ' + @@ -77,17 +85,22 @@ export async function load(options = { polyfill.WebCredential = WebCredential; // the simplified `interact()` API, bound to this polyfill's credentials - // container; exposed both on the returned polyfill (`polyfill.chapi`) and - // as a global (`navigator.chapi`) so callers can use either without - // composing a full request object themselves + // container; always available on the returned polyfill (`polyfill.chapi`) + // and, when installing, recommended as the global `globalThis.chapi` so + // callers can use either without composing a full request object themselves const chapi = { interact: _createInteract({credentials: polyfill.credentials}) }; polyfill.chapi = chapi; + if(!install) { + // do not attach anything globally; just return the polyfill + return polyfill; + } + // expose polyfill navigator.credentialsPolyfill = polyfill; - navigator.chapi = chapi; + globalThis.chapi = chapi; // polyfill if('credentials' in navigator) { diff --git a/lib/interact.js b/lib/interact.js index 160dea8..f85c5e6 100644 --- a/lib/interact.js +++ b/lib/interact.js @@ -59,9 +59,9 @@ export function _createInteract({ interactionUrl, recommendedHandlerOrigins }); - if(signal?.aborted) { - throw _abortError(); - } + // reject immediately if already aborted; surfaces the signal's own + // `reason` (a `DOMException` named `AbortError` by default) + signal?.throwIfAborted(); // race the in-flight RPC against the abort signal so an abort rejects // promptly rather than waiting on the indefinite-timeout `get()` diff --git a/lib/interactRequest.js b/lib/interactRequest.js index 4016775..7bad7b2 100644 --- a/lib/interactRequest.js +++ b/lib/interactRequest.js @@ -33,15 +33,20 @@ export function createInteractRequest({ let parsed; try { parsed = new URL(interactionUrl); - } catch { - throw new Error('"interactionUrl" must be a valid "https:" URL.'); + } catch(cause) { + const error = new Error( + '"interactionUrl" string must be a valid "https:" URL.'); + error.cause = cause; + throw error; } if(parsed.protocol !== 'https:') { - throw new Error('"interactionUrl" must be an "https:" URL.'); + throw new Error('"interactionUrl" protocol must be "https:".'); } if(recommendedHandlerOrigins !== undefined && - !Array.isArray(recommendedHandlerOrigins)) { - throw new TypeError('"recommendedHandlerOrigins" must be an array.'); + !(Array.isArray(recommendedHandlerOrigins) && + recommendedHandlerOrigins.every(s => URL.parse(s) !== null))) { + throw new TypeError( + '"recommendedHandlerOrigins" must be an array of URL strings.'); } const web = {protocols: {interact: interactionUrl}}; diff --git a/test/interact.spec.js b/test/interact.spec.js index e218f7a..69abba3 100644 --- a/test/interact.spec.js +++ b/test/interact.spec.js @@ -4,12 +4,13 @@ import {expect, test} from '@playwright/test'; // Smoke test for the simplified `interact()` API wiring (PR #57). Verifies -// that `loadOnce()` exposes `interact` both on the returned polyfill object -// and as `navigator.chapi`, and that input validation reaches through the -// built bundle. The pure builder and shell logic are covered exhaustively by -// the node:test unit tests in test/node/; this only checks the wiring. +// that loading exposes `interact` on the returned polyfill object and, when +// installing, at `globalThis.chapi`; that `install: false` attaches nothing; +// and that input validation reaches through the built bundle. The pure +// builder and shell logic are covered exhaustively by the node:test unit +// tests in test/node/; this only checks the wiring. -test('loadOnce() exposes interact() on the polyfill and navigator.chapi', +test('loadOnce() exposes interact() on the polyfill and globalThis.chapi', async ({page}) => { await page.goto('/test/fixtures/index.html'); @@ -17,17 +18,38 @@ test('loadOnce() exposes interact() on the polyfill and navigator.chapi', const polyfill = await window.credentialHandlerPolyfill.loadOnce(); return { returnedInteractIsFn: typeof polyfill.chapi?.interact === 'function', - navigatorInteractIsFn: typeof navigator.chapi?.interact === 'function', + globalInteractIsFn: typeof globalThis.chapi?.interact === 'function', // both paths share one implementation - sameChapi: polyfill.chapi === navigator.chapi + sameChapi: polyfill.chapi === globalThis.chapi }; }); expect(result.returnedInteractIsFn).toBe(true); - expect(result.navigatorInteractIsFn).toBe(true); + expect(result.globalInteractIsFn).toBe(true); expect(result.sameChapi).toBe(true); }); +test('load({install: false}) attaches nothing globally', async ({page}) => { + await page.goto('/test/fixtures/index.html'); + + const result = await page.evaluate(async () => { + const polyfill = await window.credentialHandlerPolyfill.load({ + mediatorOrigin: 'https://authn.io', + install: false + }); + return { + returnedInteractIsFn: typeof polyfill.chapi?.interact === 'function', + // nothing should have been attached to the global environment + noGlobalChapi: globalThis.chapi === undefined, + noPolyfillGlobal: navigator.credentialsPolyfill === undefined + }; + }); + + expect(result.returnedInteractIsFn).toBe(true); + expect(result.noGlobalChapi).toBe(true); + expect(result.noPolyfillGlobal).toBe(true); +}); + test('interact() rejects a non-https interactionUrl through the bundle', async ({page}) => { await page.goto('/test/fixtures/index.html'); @@ -35,7 +57,7 @@ test('interact() rejects a non-https interactionUrl through the bundle', const error = await page.evaluate(async () => { await window.credentialHandlerPolyfill.loadOnce(); try { - await navigator.chapi.interact({ + await globalThis.chapi.interact({ interactionUrl: 'http://insecure.example/abc' }); return null; @@ -56,7 +78,7 @@ test('interact() rejects immediately when signal is already aborted', const controller = new AbortController(); controller.abort(); try { - await navigator.chapi.interact({ + await globalThis.chapi.interact({ interactionUrl: 'https://exchange.example/abc', signal: controller.signal }); diff --git a/test/node/10-interactRequest.test.js b/test/node/10-interactRequest.test.js index a7456b0..ffc6cdd 100644 --- a/test/node/10-interactRequest.test.js +++ b/test/node/10-interactRequest.test.js @@ -63,10 +63,17 @@ test('throws TypeError when interactionUrl is not a string', () => { () => createInteractRequest({interactionUrl: 42}), TypeError); }); -test('throws when interactionUrl is not an https: URL', () => { +test('throws a protocol error when interactionUrl is not https:', () => { assert.throws( () => createInteractRequest({interactionUrl: 'http://exchange.example'}), - /https:/); + /protocol must be "https:"/); +}); + +test('throws a parse error with a cause for a malformed URL', () => { + assert.throws( + () => createInteractRequest({interactionUrl: 'not a url'}), + e => /must be a valid "https:" URL/.test(e.message) && + e.cause !== undefined); }); test('throws when recommendedHandlerOrigins is not an array', () => { @@ -75,3 +82,19 @@ test('throws when recommendedHandlerOrigins is not an array', () => { recommendedHandlerOrigins: 'https://wallet.example' }), TypeError); }); + +test('throws when recommendedHandlerOrigins has a non-URL entry', () => { + assert.throws(() => createInteractRequest({ + interactionUrl: 'https://exchange.example/abc', + recommendedHandlerOrigins: ['https://wallet.example', 'not a url'] + }), /array of URL strings/); +}); + +test('accepts an array of valid URL-string origins', () => { + const options = createInteractRequest({ + interactionUrl: 'https://exchange.example/abc', + recommendedHandlerOrigins: ['https://wallet.example'] + }); + assert.deepEqual( + options.web.recommendedHandlerOrigins, ['https://wallet.example']); +}); diff --git a/test/smoke.spec.js b/test/smoke.spec.js index 85a6363..b0412c8 100644 --- a/test/smoke.spec.js +++ b/test/smoke.spec.js @@ -23,7 +23,7 @@ test('loadOnce() resolves and patches navigator.credentials', async ({ hasWebCredential: typeof window.WebCredential === 'function', getIsFn: typeof navigator.credentials.get === 'function', storeIsFn: typeof navigator.credentials.store === 'function', - interactIsFn: typeof navigator.chapi?.interact === 'function' + interactIsFn: typeof globalThis.chapi?.interact === 'function' }; }); @@ -32,7 +32,7 @@ test('loadOnce() resolves and patches navigator.credentials', async ({ expect(result.hasWebCredential).toBe(true); expect(result.getIsFn).toBe(true); expect(result.storeIsFn).toBe(true); - // the simplified interact() API is installed on navigator.chapi by load() + // the simplified interact() API is installed at globalThis.chapi by load() expect(result.interactIsFn).toBe(true); }); From 4f55663c719824b6fea5252ac52f9e393a5878cc Mon Sep 17 00:00:00 2001 From: Derek Scruggs Date: Thu, 25 Jun 2026 13:33:17 -0500 Subject: [PATCH 08/10] Fix contradictory interact() install guidance in README. The recommended snippet reassigned globalThis.chapi to the value loadOnce() had already set under the default install:true, which was a no-op that contradicted the surrounding "control where it lives" prose. Show the default (zero-config global) and the explicit install:false path as the two real choices, and move the try/catch attach example under install:false where the caller's assignment is what places chapi. Addresses #50. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 8c9294a..36be910 100644 --- a/README.md +++ b/README.md @@ -174,26 +174,22 @@ generates a credential _request_; under the hood it translates into the same under the well-known `interact` key. `interact()` lives on the `chapi` object. The polyfill does **not** attach it to -`navigator`. By default `load()`/`loadOnce()` set `globalThis.chapi` for you, -but the recommended pattern is to attach it explicitly so your app controls -where the API lives: +`navigator`. By default (`install: true`), `load()`/`loadOnce()` set +`globalThis.chapi` for you, so you can just call `globalThis.chapi.interact(...)` +after loading. + +If you'd rather control where the API lives, pass `install: false` — the +polyfill then attaches nothing to the global environment and only returns the +polyfill, so you place `chapi` yourself: ```js try { - globalThis.chapi = (await loadOnce()).chapi; + globalThis.chapi = (await loadOnce({install: false})).chapi; } catch(e) { console.log('CHAPI failed to load.'); } ``` -To suppress all automatic global installation and place the API entirely -yourself, pass `install: false`: - -```js -const {chapi} = await loadOnce({install: false}); -// nothing was attached to navigator or globalThis; attach it however you like -``` - Then call it: ```js From 1d313e6cbccb48b9f626fb7e9187d482f42907d1 Mon Sep 17 00:00:00 2001 From: Derek Scruggs Date: Thu, 25 Jun 2026 15:26:10 -0500 Subject: [PATCH 09/10] Validate load() options independently; fix install parsing. Restructure load() option handling per review: a string remains the legacy mediator-URL form; otherwise options must be an object, and each option (mediatorOrigin, install) is validated independently. This fixes a case where a non-string mediatorOrigin could fall through without throwing. Surface install: true in the default parameter for clarity. Also add the Oxford comma to the CHANGELOG install entry. Addresses #50. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- lib/index.js | 23 +++++++++++++++-------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7b806d..f58cc31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ - `install` option on `load()`/`loadOnce()` (default `true`, preserving the existing global-install behavior). When `false`, the polyfill attaches nothing to the global environment and only returns the polyfill, letting - callers place the API (e.g. `globalThis.chapi`) themselves. + callers place the API (e.g., `globalThis.chapi`) themselves. - Node.js unit tests (`node --test`) for the `interact()` functional core and imperative shell, run via `npm run test:node` (and as part of `npm test`). - Cross-browser smoke test suite (Playwright) covering Chromium, Firefox, and diff --git a/lib/index.js b/lib/index.js index 157abfc..443f657 100644 --- a/lib/index.js +++ b/lib/index.js @@ -31,11 +31,11 @@ export async function loadOnce(options) { } export async function load(options = { + install: true, mediatorOrigin: DEFAULT_MEDIATOR_ORIGIN }) { _assertSecureContext(); - // backwards compatibility (`options` used to be a string for expressing - // the full mediator URL) + let mediatorUrl; // when `true` (the default), the polyfill installs itself on the global // environment (`navigator.credentials`, `navigator.credentialsPolyfill`, @@ -43,17 +43,24 @@ export async function load(options = { // returns the polyfill so callers can wire it up however they like let install = true; if(typeof options === 'string') { + // backwards compatibility (`options` used to be a string expressing the + // full mediator URL) mediatorUrl = options; - } else if(options && typeof options === 'object' && - typeof options.mediatorOrigin === 'string') { + } else { + // otherwise `options` must be an object; validate each option + // independently + if(!(options && typeof options === 'object')) { + throw new Error('"options" must be an object or a string.'); + } + if(typeof options.mediatorOrigin !== 'string') { + throw new Error( + '"options.mediatorOrigin" must be a string expressing the ' + + 'origin of the mediator.'); + } mediatorUrl = `${options.mediatorOrigin}/mediator`; if(options.install !== undefined) { install = options.install; } - } else { - throw new Error( - '"options.mediatorOrigin" must be a string expressing the ' + - 'origin of the mediator.'); } // temporarily still using this for setting permissions and other From ea70221de9ebe7b1ebd99922eb59f55099b0c402 Mon Sep 17 00:00:00 2001 From: Derek Scruggs Date: Thu, 25 Jun 2026 15:40:37 -0500 Subject: [PATCH 10/10] Rename install option to setGlobal. Per review, "setGlobal" reads more clearly than "install" for the load()/loadOnce() option that controls whether the polyfill attaches to the global environment (and sets globalThis.chapi). Rename the option, the internal variable, and all references in the spec, README, and CHANGELOG. Behavior is unchanged: default true preserves the existing global-install behavior; false attaches nothing and returns the polyfill. Addresses #50. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- README.md | 6 +++--- docs/specs/interact-api.md | 20 ++++++++++---------- lib/index.js | 10 +++++----- test/interact.spec.js | 6 +++--- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f58cc31..0ac8341 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ at `globalThis.chapi` (the polyfill never attaches to `navigator`). Resolves to an empty object on completion; rejects with `AbortError` on user cancel or `signal` abort. See `docs/specs/interact-api.md` and #50. -- `install` option on `load()`/`loadOnce()` (default `true`, preserving the +- `setGlobal` option on `load()`/`loadOnce()` (default `true`, preserving the existing global-install behavior). When `false`, the polyfill attaches nothing to the global environment and only returns the polyfill, letting callers place the API (e.g., `globalThis.chapi`) themselves. diff --git a/README.md b/README.md index 36be910..392ba10 100644 --- a/README.md +++ b/README.md @@ -174,17 +174,17 @@ generates a credential _request_; under the hood it translates into the same under the well-known `interact` key. `interact()` lives on the `chapi` object. The polyfill does **not** attach it to -`navigator`. By default (`install: true`), `load()`/`loadOnce()` set +`navigator`. By default (`setGlobal: true`), `load()`/`loadOnce()` set `globalThis.chapi` for you, so you can just call `globalThis.chapi.interact(...)` after loading. -If you'd rather control where the API lives, pass `install: false` — the +If you'd rather control where the API lives, pass `setGlobal: false` — the polyfill then attaches nothing to the global environment and only returns the polyfill, so you place `chapi` yourself: ```js try { - globalThis.chapi = (await loadOnce({install: false})).chapi; + globalThis.chapi = (await loadOnce({setGlobal: false})).chapi; } catch(e) { console.log('CHAPI failed to load.'); } diff --git a/docs/specs/interact-api.md b/docs/specs/interact-api.md index 7921b6b..498576b 100644 --- a/docs/specs/interact-api.md +++ b/docs/specs/interact-api.md @@ -1,7 +1,7 @@ # Spec: Simplified `interact()` API for interaction URLs > Status: In review — an initial implementation has landed in this PR; some open questions below remain to be settled during review (final method name, resolution payload, in-flight `signal` teardown). Addresses [#50](https://github.com/credential-handler/credential-handler-polyfill/issues/50). ## Summary -Add a drastically simplified, single-call CHAPI entry point that lets issuer/verifier coordinator websites (relying parties) start a credential interaction by handing the polyfill an `interactionUrl`, without composing the full `web`/`VerifiablePresentation` request object themselves. The new `interact({interactionUrl, signal, recommendedHandlerOrigins})` method always generates a credential _request_ and resolves when the interaction completes (to an empty object/`undefined` for now) or rejects with an `AbortError` when the user cancels or the caller aborts via an `AbortSignal`. Under the hood it translates into the existing `credentialrequest` (`get()`) flow — reusing the established `protocols` mechanism to carry the URL — so it is compatible with today's mediator and deployed credential handlers. The method is reachable on the object returned by `load()`/`loadOnce()` and, when installing, at the global `globalThis.chapi`; the polyfill never attaches to `navigator` (addressing the "avoid `navigator`" request in the issue thread). An `install: false` option lets callers suppress all global attachment and place the API wherever they like. +Add a drastically simplified, single-call CHAPI entry point that lets issuer/verifier coordinator websites (relying parties) start a credential interaction by handing the polyfill an `interactionUrl`, without composing the full `web`/`VerifiablePresentation` request object themselves. The new `interact({interactionUrl, signal, recommendedHandlerOrigins})` method always generates a credential _request_ and resolves when the interaction completes (to an empty object/`undefined` for now) or rejects with an `AbortError` when the user cancels or the caller aborts via an `AbortSignal`. Under the hood it translates into the existing `credentialrequest` (`get()`) flow — reusing the established `protocols` mechanism to carry the URL — so it is compatible with today's mediator and deployed credential handlers. The method is reachable on the object returned by `load()`/`loadOnce()` and, when installing, at the global `globalThis.chapi`; the polyfill never attaches to `navigator` (addressing the "avoid `navigator`" request in the issue thread). A `setGlobal: false` option lets callers suppress all global attachment and place the API wherever they like. ## Implementation details & assumptions ### New public API ```js @@ -38,10 +38,10 @@ and browser security changes. Instead: } ``` -2. **`install` option** — `load({install})`/`loadOnce({install})` controls - automatic global installation. `install: true` (the default, for backwards - compatibility) installs the polyfill globally as today **and** sets - `globalThis.chapi = chapi`. `install: false` attaches nothing to the global +2. **`setGlobal` option** — `load({setGlobal})`/`loadOnce({setGlobal})` + controls automatic global installation. `setGlobal: true` (the default, for + backwards compatibility) installs the polyfill globally as today **and** sets + `globalThis.chapi = chapi`. `setGlobal: false` attaches nothing to the global environment and only returns the polyfill, leaving placement entirely to the caller. @@ -105,14 +105,14 @@ No new server endpoints. Surface area is the client API only: - New `chapi` property on the object returned by `load()`/`loadOnce()`. - New `globalThis.chapi` global set during `load()`/`loadOnce()` when - `install` is `true` (the default). + `setGlobal` is `true` (the default). -- New `install` option on `load()`/`loadOnce()` (default `true`) that, when +- New `setGlobal` option on `load()`/`loadOnce()` (default `true`) that, when `false`, suppresses all global installation. No existing public methods change behavior; this is purely additive. The new -`install` option defaults to `true`, preserving the current global-install +`setGlobal` option defaults to `true`, preserving the current global-install behavior of `load()`/`loadOnce()` (no breaking change to `get()`, `store()`, `load()`, or `loadOnce()`). ## Personal information impact @@ -131,13 +131,13 @@ The polyfill itself collects, stores, and persists **no** personal data. It is a ## Security considerations - **How could this be misused?** A malicious coordinator could pass a hostile `interactionUrl`. Mitigation: the polyfill validates the scheme is `https:`, does not fetch or execute the URL, and the URL only reaches a credential handler _after explicit user selection_ in the trusted mediator UI. User consent remains the gate, unchanged from `get()`. -- **Attack surface / unnecessary data:** additive method reusing the existing RPC path; no new cross-origin channel, and no new global beyond `globalThis.chapi` (which `install: false` suppresses entirely). The empty-result contract avoids handing credential data to the relying party. +- **Attack surface / unnecessary data:** additive method reusing the existing RPC path; no new cross-origin channel, and no new global beyond `globalThis.chapi` (which `setGlobal: false` suppresses entirely). The empty-result contract avoids handing credential data to the relying party. - **Trusted vs untrusted sources:** `interactionUrl` and `recommendedHandlerOrigins` are **untrusted** caller input — validated (scheme, type) before use, never interpolated into executable contexts. Results from the mediator are treated as today (validation TODOs in `CredentialsContainer` apply equally). - **Anti-fingerprinting:** preserved — `interact()` gives the caller no way to learn which handlers the user has, consistent with the existing privacy model. -- `navigator` **clobbering:** the polyfill never attaches `chapi` to `navigator`, and `install: false` lets security-conscious callers suppress all automatic global attachment and place the API themselves, reducing exposure to extension/browser interference noted in the issue. +- `navigator` **clobbering:** the polyfill never attaches `chapi` to `navigator`, and `setGlobal: false` lets security-conscious callers suppress all automatic global attachment and place the API themselves, reducing exposure to extension/browser interference noted in the issue. - **Why a URL instead of an inline `protocols` object (design rationale):** the single `interact` URL is a layer of indirection over the "full" `protocols` object. Rather than embedding a multi-key protocols object directly in the initial channel (e.g. a QR code), the relying party hands over one URL; the consuming side fetches the full protocols object from it **over TLS**. This yields two properties an inline blob cannot: (1) **source authentication** — TLS authenticates the origin of the protocols object, so the recipient can verify who issued it; and (2) **support for disconnected systems** — a reader with no back-channel (e.g. a QR-code scanner) can still authenticate the source by reusing existing TLS infrastructure, without new key-distribution or crypto. The expectation is that the single-key `interact` object supersedes multi-key `protocols` objects going forward. diff --git a/lib/index.js b/lib/index.js index 443f657..8a3e188 100644 --- a/lib/index.js +++ b/lib/index.js @@ -31,7 +31,7 @@ export async function loadOnce(options) { } export async function load(options = { - install: true, + setGlobal: true, mediatorOrigin: DEFAULT_MEDIATOR_ORIGIN }) { _assertSecureContext(); @@ -41,7 +41,7 @@ export async function load(options = { // environment (`navigator.credentials`, `navigator.credentialsPolyfill`, // and `globalThis.chapi`); when `false`, it attaches nothing and only // returns the polyfill so callers can wire it up however they like - let install = true; + let setGlobal = true; if(typeof options === 'string') { // backwards compatibility (`options` used to be a string expressing the // full mediator URL) @@ -58,8 +58,8 @@ export async function load(options = { 'origin of the mediator.'); } mediatorUrl = `${options.mediatorOrigin}/mediator`; - if(options.install !== undefined) { - install = options.install; + if(options.setGlobal !== undefined) { + setGlobal = options.setGlobal; } } @@ -100,7 +100,7 @@ export async function load(options = { }; polyfill.chapi = chapi; - if(!install) { + if(!setGlobal) { // do not attach anything globally; just return the polyfill return polyfill; } diff --git a/test/interact.spec.js b/test/interact.spec.js index 69abba3..40a566e 100644 --- a/test/interact.spec.js +++ b/test/interact.spec.js @@ -5,7 +5,7 @@ import {expect, test} from '@playwright/test'; // Smoke test for the simplified `interact()` API wiring (PR #57). Verifies // that loading exposes `interact` on the returned polyfill object and, when -// installing, at `globalThis.chapi`; that `install: false` attaches nothing; +// installing, at `globalThis.chapi`; that `setGlobal: false` attaches nothing; // and that input validation reaches through the built bundle. The pure // builder and shell logic are covered exhaustively by the node:test unit // tests in test/node/; this only checks the wiring. @@ -29,13 +29,13 @@ test('loadOnce() exposes interact() on the polyfill and globalThis.chapi', expect(result.sameChapi).toBe(true); }); -test('load({install: false}) attaches nothing globally', async ({page}) => { +test('load({setGlobal: false}) attaches nothing globally', async ({page}) => { await page.goto('/test/fixtures/index.html'); const result = await page.evaluate(async () => { const polyfill = await window.credentialHandlerPolyfill.load({ mediatorOrigin: 'https://authn.io', - install: false + setGlobal: false }); return { returnedInteractIsFn: typeof polyfill.chapi?.interact === 'function',