npm.io
1.5.0 • Published 1 week ago

@cef-ai/wallet

Licence
MIT
Version
1.5.0
Deps
3
Size
430 kB
Vulns
0
Weekly
0

@cef-ai/wallet

Embed SCP Wallet into a host application. Passkey-native authentication via WebAuthn + PRF. No MetaMask, no seed phrases, no popup-blocker headaches.

For runtime topology and the popup/cross-tab channels this SDK speaks, see ARCHITECTURE.md in the repo root.

Status: v2.0.0-alpha. Public API surface is stable but pre-1.0. private: true in package.json — not yet published to npm.

Install

npm install @cef-ai/wallet

TODO(publish) — must be addressed before flipping private: false

  1. Workspace dep versions. @cef-ai/wallet-api-client, @cef-ai/wallet-identity, @cef-ai/wallet-signers are pinned at 0.0.0 (workspace placeholder). Choose:

    • Option A: publish those three packages with real semver versions first, then bump this package's dependencies to match.
    • Option B: drop external: [/^@cef-ai\/wallet/] from tsup.config.ts so the SDK bundle inlines them. Smaller dep tree for consumers; larger SDK.
  2. Downstream type resolution. Even after Option A above, the workspace packages must publish their own dist/ with .d.ts files. The current workspace package.json files point types at ./src (source). For publish they'll need their own tsup configs and matching publishConfig.

  3. Pack validation. Add a pack-dry-run CI step that runs npm pack --dry-run --json and asserts dist/index.js, dist/index.cjs, dist/index.d.ts, dist/index.d.cts are all present and no source .ts files leak through.

Quick start

import { EmbedWallet } from '@cef-ai/wallet';

const wallet = new EmbedWallet({
  appId: 'my-app',
  appName: 'My App',
  walletOrigin: 'https://wallet.example.com', // required: your wallet deployment origin
});

// First-time users register a passkey; returning users call login().
const session = await wallet.login();
console.log('Cere address:', session.addresses.cere);

// Signing is per-chain. Pick a signer for the chain you need, then call its
// `signMessage(bytes)` (returns raw signature bytes).
const signer = wallet.getSigner({ chain: 'cere' });
const sig = await signer.signMessage(new TextEncoder().encode('Hello'));
console.log('Signature bytes:', sig);

Transport: iframe (default) vs popup

new EmbedWallet({ appId, walletOrigin, transport: 'iframe' | 'popup' });

transport defaults to 'iframe'. This controls how the SDK talks to the wallet UI at ${walletOrigin}/embed/wallet:

  • 'iframe' (default, IframeTransport). The SDK injects a hidden, zero-size <iframe> pinned to walletOrigin on first use. It stays invisible until the wallet needs to show UI (a consent prompt or the login "Continue" gesture — see below), at which point it grows into a centered modal with a backdrop, then shrinks back to hidden. No popup-blocker exposure, no separate window for the user to lose track of.
  • 'popup' (PopupTransport, the original transport). Opens ${walletOrigin}/embed/wallet in a real popup window via window.open. Still fully supported — pass transport: 'popup' to opt back into it (e.g. if your host page can't grant the iframe permission below, or you prefer the popup UX).

Both implement the same WalletTransport interface, so EmbedWallet's public methods behave identically regardless of which one is active.

allow / Permissions-Policy requirement (iframe transport only)

The injected iframe is created with:

<iframe
  src="https://wallet.example.com/embed/wallet?origin=..."
  allow="publickey-credentials-get https://wallet.example.com"
/>

The allow attribute permits WebAuthn publickey-credentials-get inside the iframe, but as of the resume-first login() behavior below, the iframe itself never actually runs a get() ceremony — see "login() resumes silently, then falls back to a popup" for why the allowance still matters (older wallet deployments, and defense in depth). Two things follow for host apps:

  1. If your page sends a restrictive Permissions-Policy HTTP response header, it must explicitly allow publickey-credentials-get for your wallet origin (e.g. Permissions-Policy: publickey-credentials-get=(self "https://wallet.example.com")), or the browser will block WebAuthn get() inside the iframe regardless of the allow attribute. Pages with no Permissions-Policy header (the common case) need no action — the allow attribute alone is sufficient.
  2. Only -get is delegated, not -create. Safari blocks WebAuthn create() (passkey registration) in cross-origin iframes outright, so create is deliberately not requested in allow. See "Register uses a synchronous popup" below.
login() resumes silently, then falls back to a popup

EmbedWallet.login() never runs a WebAuthn ceremony over the iframe. Instead:

  1. It first sends a wallet:session:status request over the configured transport (the iframe, by default) asking whether that surface already holds a live session — bearer-active (token, no signing keys yet) or fully keyed. This never triggers WebAuthn.
  2. If the answer is authenticated: true (and carries addresses + credentialId), login() resolves immediately with that session — no ceremony, no popup. The login event still fires and isLoggedIn still flips true, so callers can't tell a silent resume from a ceremony-based login by observing the public API.
  3. Otherwise (authenticated: false, or the status check itself fails — e.g. an older wallet deployment that doesn't recognize the message), login() falls back to a real ceremony run over a popup, using exactly the same punch-out register() uses (see below): the configured transport is reused if it's already a PopupTransport, otherwise a transient popup is opened for the ceremony and closed afterward.
resume() — silent, popup-free boot reconnect

EmbedWallet.resume(): Promise<LoginResult | null> is steps 1–2 of login() without the ceremony fallback. It sends wallet:session:status, adopts and returns the session if one exists (firing login and flipping isLoggedIn, exactly like a silent login()), and returns null otherwise — it never opens a popup or runs a ceremony.

Use it on app boot to reconnect returning users without prompting logged-out ones. Calling login() unconditionally on boot would open (or, without a user gesture, get popup-blocked on) a ceremony for logged-out users; resume() avoids that:

// On app shell mount:
const session = await wallet.resume(); // null if not signed in — no popup
if (session) {
  // returning user: reconnected silently
} else {
  // show a "Connect" CTA; call wallet.login() from the click handler
}
Register uses a synchronous popup

EmbedWallet.register() always performs its WebAuthn ceremony over a PopupTransport, even when the SDK is configured with the (default) iframe transport:

  • If the configured transport is already PopupTransport (transport: 'popup'), register() reuses it directly.
  • Otherwise (iframe transport), register() opens a transient popup synchronously — inside the same call stack as the consumer's click handler — performs the wallet:hello (intent: 'register') ceremony over it, and closes it when done. "Synchronous" matters: Safari requires window.open to happen within a real user-activation event, so register() cannot defer opening the popup until after an await.
  • login()'s ceremony fallback (see above) uses the same popup punch-out but is not synchronous — it only opens after the wallet:session:status round trip resolves authenticated: false, so it does not need to happen inside the original click's call stack the way register()'s does.
  • Every other method except signing (requestDelegation, saveApplication, findApplications, logout) always uses the configured transport (iframe by default) unchanged. Signing (getSigner(...).sign*, asSigner().sign) has its own popup step-up rule — see below.
  • After a register or login-ceremony popup completes, the persistent iframe adopts the new session automatically via a same-origin BroadcastChannel('scp-wallet-v2') handoff (BroadcastSessionShare) — no explicit follow-up call is needed after either resolves.
  • login()/register() settle only once the session is usable: after a successful ceremony they wait briefly for the hidden iframe to adopt the signing keys (polling wallet:session:status), keeping the ceremony popup open just long enough to serve that handoff, so a subsequent sign() runs warm instead of triggering a step-up. If the handoff can't complete in time, login()/register() still resolve — the session is bearer-active — and the next sign() performs a one-time step-up.
Signing steps up in a popup when keys are absent

A bearer-active session (a silent login() resume that returned hasKeys: false) has a JWT and addresses but no in-memory signing keys — token-authorized ops (like requestDelegation) work, but a raw signature does not exist yet. getSigner({ chain }).sign* and asSigner().sign both route through the same warm/cold check:

  • Keys present (warm): the wallet:sign request goes straight to the configured transport (the iframe by default) and resolves silently — no popup, no prompt.
  • Keys absent (cold): the SDK opens a step-up popup synchronously within the sign()/signMessage() call's gesture — the same punch-out register()/login()'s ceremony fallback use (reuse the configured transport if it's already a PopupTransport, otherwise open a transient one and close it in finally). The popup runs a WebAuthn+PRF ceremony against the user's existing passkey (no new registration), derives the signing keys, and completes the same wallet:sign request over itself. The persistent iframe adopts the freshly-derived keys via the same BroadcastSessionShare handoff described above, so a subsequent sign() call is warm (no second popup). The iframe never runs WebAuthn, even for this step-up. The ceremony is pinned to the active session's credential: the step-up popup's vault is closed, so without a pin the browser would show the discoverable-credential picker and a user with more than one passkey could authenticate as a different identity than the one this SDK instance already knows. When the SDK holds a known session credential, it's sent on wallet:hello and the popup constrains WebAuthn allowCredentials to it; the ceremony's returned credential is also cross-checked afterward — a mismatch aborts the sign (see below) rather than signing under, or handing the iframe, the wrong identity.

A cancelled step-up rejects with WalletError('user-cancelled'); a blocked popup rejects with WalletError('popup-blocked'); a step-up that authenticates as a different credential than the active session rejects with WalletError('unauthorized') and restores the pre-step-up session state. Either way, no partial session state is created — signing keys are only considered present after a full, successful ceremony + sign round trip that also matches the pinned identity.

sign()/signMessage() may also reject with WalletError('needs-step-up'). This SDK caches a private hasKeys mirror and only opens the step-up popup above when that mirror reads false; if the mirror is stale-true (e.g. a keyed session handoff lost a race after boot) the "warm" attempt is sent to the transport as-is and can come back needs-step-up instead of a signature. When that happens the SDK corrects its mirror internally but does not retry on your behalf — it cannot synchronously open a popup outside your original call's user gesture. Catch needs-step-up and have the caller retry sign()/signMessage() from a fresh, gesture-driven call; the SDK's mirror is correct by then, so that retry runs the normal cold-path step-up popup described above and completes with exactly one additional prompt.

Architecture

  • Default transport (IframeTransport) injects a hidden, permission-scoped iframe at wallet.example.com/embed/wallet?origin=... and shows/hides a modal overlay on request from the wallet document (see "Transport" above).
  • Opt-in transport (PopupTransport) opens wallet.example.com/embed/wallet?origin=... in a separate popup window instead.
  • Either way, the wallet surface runs the WebAuthn ceremony, signing, and consent UI, and posts id-correlated results back over postMessage.
  • Cross-tab session sharing via BroadcastChannel('scp-wallet-v2') so a second host tab (or the register popup handing off to the iframe) discovers an existing session.

Design specs and ADRs are maintained in the company memory bank.

Diagnostics (opt-in, dev-only)

wallet.on('diag', (e) => ...) subscribes to an opt-in diagnostic event stream — dev/debug signal for building tooling around the SDK (e.g. a harness that visualizes the popup→iframe key handoff), never part of production behavior. It is default-inert: if you never call wallet.on('diag', ...), nothing changes — emission reuses the same no-op-when-unsubscribed emitter as login/logout/error.

Each event is { type: 'diag', name: WalletDiagName, at: number, detail? }, where name is one of 'announce' | 'request-session' | 'offer-session-sent' | 'offer-session-received' | 'adopt-session' | 'keyed-poll' | 'session-state' | 'popup-open' | 'popup-close' and at is Date.now() at emission time — except for the handoff names (announce/request-session/ offer-session-sent/offer-session-received), which are forwarded from the wallet SPA and carry the SPA's own timestamp (see below).

Payload-safety guarantee: detail may contain only addresses, pubkeys, booleans, counts, and timestamps — never a Uint8Array or any key/seed material. This is a hard invariant, not a best-effort convention.

BroadcastSessionShareOptions also gains an optional onDiag?: (name, detail?) => void for the same event names, so advanced callers constructing their own BroadcastSessionShare (as the wallet SPA does) can feed its local announce/request-session/offer-session-* events into their own diagnostics. It defaults to undefined (no-op) and does not change the BroadcastChannel('scp-wallet-v2') wire protocol in any way.

The wallet SPA wires this onDiag hook at both BroadcastSessionShare construction sites (the iframe requester and the popup responder) to forward those events across the wallet→host postMessage bridge as a new, purely additive wallet:diag envelope on the PopupToHost union. Both IframeTransport and PopupTransport accept a matching onDiag?: (name, at, detail?) => void option; EmbedWallet wires it into the SAME emit('diag', ...) path as its own emissions, so wallet.on('diag', ...) receives ONE unified timeline — the SDK's own host-side events interleaved with the SPA's forwarded handoff events — rather than two disjoint streams.

Keyed handoff over a MessagePort (shipped)

EmbedWallet replaces the fixed-timeout wallet:session:status poll (see "Ceremony paths wait for the iframe to confirm it's keyed" in ARCHITECTURE.md) with a deterministic, SDK-brokered MessageChannel handoff. After a ceremony completes over the popup, the SDK opens a fresh MessageChannel and transfers one port to the ceremony popup (role: 'source') and the other to the persistent iframe (role: 'sink'); the popup posts the exported session directly over its entangled port, the iframe adopts it and posts back a keyless ack, and the SDK closes the popup once that ack (or a timeout) settles. This is carried by two protocol.ts envelopes:

  • wallet:handoff-port (HostToPopup) — { type: 'wallet:handoff-port', id: string, role: 'source' | 'sink' }. The host sends this alongside a MessagePort transfer (WalletTransport.postTransfer(msg, [port])) to broker the direct MessageChannel between the ceremony popup (role: 'source') and the persistent iframe (role: 'sink').
  • wallet:session:keyed (PopupToHost) — { type: 'wallet:session:keyed', id: string, hasKeys: boolean }. Sent by the sink over that same port once it has attempted to adopt the handed-off session. It is a keyless ack by construction — the envelope never carries key material — but hasKeys reflects the sink's REAL post-adopt state: it can honestly be false if the transferred session had already expired in transit, in which case the SDK does not treat the ack as success and falls through to the fallback poll below instead.

Transport surface additions. Both WalletTransport implementations (IframeTransport, PopupTransport) implement two new members added to the WalletTransport interface:

  • postTransfer(message, transfer) — fire-and-forget send of a HostToPopup message together with a Transferable[] (the MessagePort). Unlike request(), it isn't queued in the pre-handshake outbox — a Transferable is neutered at post time, so it's only called after wallet:ready.
  • onKeyed?: (hasKeys?: boolean) => void — an optional, mutable hook the SDK assigns once at construction; a transport invokes it whenever an inbound wallet:session:keyed ack arrives, out-of-band from request()'s id-correlated replies.

Fallback, unchanged wire-compatible. If no ack arrives within the SDK's internal ack timeout — an older wallet deployment that doesn't yet speak this protocol, or a genuinely lost message — the SDK falls back to the original wallet:session:status poll. Overall behavior is never worse than the poll-only path this replaces; the common case just resolves deterministically instead of on a fixed timeout.

Public exports

  • EmbedWallet — the SDK class (see Quick start).
  • WalletTransport (type) — the transport interface both transports implement; only relevant if you're injecting a custom transport via the __internal__ test seam. Includes the required postTransfer(message, transfer) method and the optional onKeyed? hook used by the keyed MessagePort handoff (see "Keyed handoff over a MessagePort" below) — a custom transport must implement postTransfer to satisfy the type.
  • PopupTransport / PopupTransportOptions — the popup-window transport.
  • IframeTransport / IframeTransportOptions — the default iframe transport. Options: walletOrigin, hostWindow?, document?, defaultTimeoutMs?, onDiag? (see Diagnostics above), plus two test seams (mountIframe?, onOverlay?) not needed in production use.
  • PopupHostBridge / PopupHostBridgeOptions, BroadcastSessionShare / BroadcastSessionShareOptions / BroadcastSession — wallet-side building blocks (used by the wallet SPA itself, exported for advanced/embedding scenarios); most host-app integrations never need these directly.

Capabilities

  • register({ name?, email?, label? }) — opens the configured transport surface (register() always punches out to a popup — see "Register uses a synchronous popup"), performs the WebAuthn ceremony, returns the session. name → the passkey's user.displayName, emailuser.name (what OS/browser passkey managers show); label is a legacy single-field fallback for both.
  • resume() — silent, popup-free boot reconnect: adopts and returns an existing live session, or null if there is none. Never opens a popup or runs a ceremony, so it's safe to call unconditionally on app boot (see "resume() — silent, popup-free boot reconnect"). Returns LoginResult | null.
  • login() — resumes an existing session silently when one is live (via resume()); otherwise runs the WebAuthn ceremony over a popup, same as register(). Returns the session. Use resume() instead when you must NOT prompt (e.g. on boot).
  • getSigner({ chain }) — returns an EthersSigner ('evm'), PolkadotSigner ('cere'), or SolanaSigner ('solana'); each exposes signMessage(bytes: Uint8Array): Promise<Uint8Array>. Steps up via a popup on first use after a bearer-active resume — see "Signing steps up in a popup when keys are absent".
  • requestDelegation({ capabilities, ttl, appId?, agentPubkey?, constraints? }) — requests a scoped delegation token. First-party (same-site with the wallet) callers mint silently; third-party callers see a one-time consent UI (then silent for the same origin + capabilities if they chose "always"). See ARCHITECTURE.md → Key invariants.
  • saveApplication(record) / findApplications(filter) — manage app context records
  • logout() — always sends wallet:logout to revoke the server-side refresh session (so a later page reload can't silently resume) — even if this SDK instance's own local state is still null (e.g. the host never called login(), but the iframe silently resumed a session at boot) — then closes the session locally and broadcasts so other tabs do the same. Idempotent; a network failure revoking the server-side session does not block local logout.
  • dispose() — tear-down hook; closes any open popup and removes listeners

License

MIT.

Keywords