Skip to content

Add simplified interact() API.#57

Open
djscruggs wants to merge 10 commits into
mainfrom
add-interact-api-spec
Open

Add simplified interact() API.#57
djscruggs wants to merge 10 commits into
mainfrom
add-interact-api-spec

Conversation

@djscruggs

@djscruggs djscruggs commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

What

Adds the simplified, single-call CHAPI interact() API — both the spec (docs/specs/interact-api.md) and an initial implementation.

await navigator.chapi.interact({interactionUrl, signal, recommendedHandlerOrigins});
  • Always generates a request (no type param); translates into the existing get() flow, carrying interactionUrl in the protocols map under the well-known interact meta-protocol key: web: {protocols: {interact: interactionUrl}}. No mediator/RPC changes.
  • interactionUrl is treated as opaque — the polyfill does not fetch, parse, or encode it. The full protocols object is fetched from that URL over TLS by the selected handler, giving source authentication and disconnected-system (QR) support.
  • Resolves to an empty object {} on completion; rejects AbortError on user cancel or signal abort; otherwise surfaces the same errors as get().
  • Exposed two ways, sharing one implementation: navigator.chapi.interact() and the chapi object on the value returned by load()/loadOnce().
  • Purely additive — no behavior change to get()/store()/load()/loadOnce().

Structure (functional core / imperative shell)

  • lib/interactRequest.js — pure createInteractRequest() builder (validates https:, builds the request); no navigator/network, independently unit-tested.
  • lib/interact.jsinteract() shell built around an injected credentials container + secure-context assertion; maps result/abort to the resolution contract.
  • lib/index.js — wires chapi onto the returned polyfill and navigator.chapi.

Why

Addresses #50. The current API requires relying parties to compose a full web/VerifiablePresentation request object. A single interactionUrl-based entry point is much simpler for coordinator websites, and the URL indirection authenticates the protocols source over TLS.

Testing

  • npm run test:nodenode --test unit tests for the pure builder (9) and the shell (9): request shape, opaque pass-through, validation, abort/cancel mapping, secure-context.
  • npm test runs the Node tests and the cross-browser Playwright suite (Chromium/Firefox/WebKit). Added test/interact.spec.js (wiring smoke tests) and a navigator.chapi assertion to the existing load smoke test.
  • Full suite green locally (17 passed, 1 pre-existing WebKit skip); lint clean.

Open questions (for review)

Left open in the spec and called out inline: final method name; {} vs undefined resolution payload; first-call performance; whether abort must actively tear down the in-flight RPC vs. abandon it; and whether a null get() result (no credential selected) should map to AbortError (current behavior) or resolve empty. The interact protocols-key and dropping type were resolved during review.

Addresses #50.

🤖 Generated with Claude Code

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.
@dlongley

Copy link
Copy Markdown
Contributor

Let's leave type off for now and just generate a request. That might actually cover all the use cases.

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.
@djscruggs

djscruggs commented Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

Done — pushed a commit dropping type; interact() now always generates a request. Updated the summary, the API shape, the translation section, and removed the type: 'store' open question.

Follow-up that this raises (open question 3 in the spec): which protocols key should interactionUrl ride under? Since protocols is a protocolName → URL map and a URL-type handler only receives a protocol it advertised in acceptedProtocols, the choice affects which handlers interact() can reach. Two paths:

  1. Hardcode a key (e.g. vcapi or OID4VCI) — simplest, but locks interact() to one exchange protocol.
  2. Pass the protocol name through as an optional param (interact({interactionUrl, protocol})) — keeps the polyfill protocol-agnostic and lets the caller match their handler, at the cost of one more param.

Either way this is purely additive to the polyfill — interact() is new, so there's no back-compat risk to get()/store(). The compatibility that does matter is with deployed URL-type handlers: only a handler that advertised the chosen protocol in acceptedProtocols will receive the URL. An optional protocol param is also safe to add in a later release (it defaults), so hardcoding now doesn't paint us into a corner — though changing a hardcoded default later would be breaking for anyone relying on it.

I lean toward (2) to keep the polyfill a dumb broker, but do you have a target protocol in mind? That decides the request shape before I implement.

@dlongley

Copy link
Copy Markdown
Contributor

It needs to use interact and just put the interactionUrl there as the value. The rest of the protocols are all hidden behind the interaction URL and not a concern for CHAPI. The interact protocol is a sort of "meta" protocol that allows for chaining together protocols objects.

@djscruggs

Copy link
Copy Markdown
Contributor Author

Got it — using interact as the protocols key, with interactionUrl as the value: web: { protocols: { interact: interactionUrl } }. That makes the meta-protocol explicit and keeps CHAPI out of the underlying-protocol business — the polyfill just hands over one opaque URL under one well-known key, no protocol param needed. Will update the spec to set the key, close open question 3, and note that the polyfill treats interactionUrl as opaque (encoding is the caller's concern), then start the implementation.

djscruggs and others added 4 commits June 24, 2026 12:02
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.
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.
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@djscruggs djscruggs marked this pull request as ready for review June 24, 2026 22:19
@djscruggs djscruggs changed the title Add spec for simplified interact() API. Add simplified interact() API. Jun 24, 2026
@djscruggs djscruggs requested a review from dlongley June 24, 2026 22:30
Comment thread lib/index.js Outdated
Comment thread lib/index.js Outdated
Comment thread lib/interact.js
Comment thread lib/interactRequest.js Outdated
Comment thread lib/interactRequest.js Outdated
Comment thread lib/interactRequest.js Outdated
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) <noreply@anthropic.com>
@djscruggs

Copy link
Copy Markdown
Contributor Author

Pushed a2917bc addressing all six review comments:

  • Off navigator, onto globalThis. Removed the navigator.chapi assignment. Added an install option to load()/loadOnce() (default true, preserving the current global-install behavior): true installs globally and also sets globalThis.chapi; false attaches nothing and just returns the polyfill. README now recommends globalThis.chapi = (await loadOnce()).chapi.
  • signal?.throwIfAborted() for the already-aborted pre-check; kept the mid-flight abort listener.
  • Validation: error.cause + clearer message on URL parse failure, a distinct wrong-protocol message, and recommendedHandlerOrigins validated as an array of URL strings (URL.parse, verified available in all CI engines).

Spec, README, and CHANGELOG updated to match. Tests: 21 node + 20 browser (1 pre-existing WebKit skip), lint clean. Added coverage for install: false, the distinct error messages, and the non-URL-origin rejection.

Ready for another look.

@djscruggs djscruggs requested a review from dlongley June 25, 2026 17:25
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) <noreply@anthropic.com>
@dlongley dlongley requested a review from BigBlueHat June 25, 2026 19:06
Comment thread lib/index.js
Comment thread lib/index.js Outdated
Comment thread CHANGELOG.md Outdated
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) <noreply@anthropic.com>
@djscruggs

Copy link
Copy Markdown
Contributor Author

Pushed 1d313e6 for the latest round:

  • load() option parsing restructured so each option is validated independently: a string is still the legacy mediator-URL form; otherwise options must be an object, mediatorOrigin is validated (throws if not a string), and install is read independently. Fixes the fall-through where a non-string mediatorOrigin could skip the throw. install: true now shown in the default parameter.
  • CHANGELOG Oxford comma applied.

All tests green (21 node + 20 browser, 1 pre-existing WebKit skip), lint clean.

@djscruggs djscruggs requested review from TallTed and dlongley June 25, 2026 20:29
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) <noreply@anthropic.com>

@TallTed TallTed left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

English looks good. I cannot judge the code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants