@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: trueinpackage.json— not yet published to npm.
Install
npm install @cef-ai/wallet
TODO(publish) — must be addressed before flipping private: false
Workspace dep versions.
@cef-ai/wallet-api-client,@cef-ai/wallet-identity,@cef-ai/wallet-signersare pinned at0.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/]fromtsup.config.tsso the SDK bundle inlines them. Smaller dep tree for consumers; larger SDK.
Downstream type resolution. Even after Option A above, the workspace packages must publish their own
dist/with.d.tsfiles. The current workspacepackage.jsonfiles pointtypesat./src(source). For publish they'll need their own tsup configs and matching publishConfig.Pack validation. Add a
pack-dry-runCI step that runsnpm pack --dry-run --jsonand assertsdist/index.js,dist/index.cjs,dist/index.d.ts,dist/index.d.ctsare all present and no source.tsfiles 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 towalletOriginon 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/walletin a real popup window viawindow.open. Still fully supported — passtransport: '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:
- If your page sends a restrictive
Permissions-PolicyHTTP response header, it must explicitly allowpublickey-credentials-getfor your wallet origin (e.g.Permissions-Policy: publickey-credentials-get=(self "https://wallet.example.com")), or the browser will block WebAuthnget()inside the iframe regardless of theallowattribute. Pages with noPermissions-Policyheader (the common case) need no action — theallowattribute alone is sufficient. - Only
-getis delegated, not-create. Safari blocks WebAuthncreate()(passkey registration) in cross-origin iframes outright, socreateis deliberately not requested inallow. 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:
- It first sends a
wallet:session:statusrequest 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. - If the answer is
authenticated: true(and carriesaddresses+credentialId),login()resolves immediately with that session — no ceremony, no popup. Theloginevent still fires andisLoggedInstill flips true, so callers can't tell a silent resume from a ceremony-based login by observing the public API. - 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-outregister()uses (see below): the configured transport is reused if it's already aPopupTransport, 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 thewallet:hello(intent: 'register') ceremony over it, and closes it when done. "Synchronous" matters: Safari requireswindow.opento happen within a real user-activation event, soregister()cannot defer opening the popup until after anawait. login()'s ceremony fallback (see above) uses the same popup punch-out but is not synchronous — it only opens after thewallet:session:statusround trip resolvesauthenticated: false, so it does not need to happen inside the original click's call stack the wayregister()'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 (pollingwallet:session:status), keeping the ceremony popup open just long enough to serve that handoff, so a subsequentsign()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 nextsign()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:signrequest 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-outregister()/login()'s ceremony fallback use (reuse the configured transport if it's already aPopupTransport, otherwise open a transient one and close it infinally). The popup runs a WebAuthn+PRF ceremony against the user's existing passkey (no new registration), derives the signing keys, and completes the samewallet:signrequest over itself. The persistent iframe adopts the freshly-derived keys via the sameBroadcastSessionSharehandoff described above, so a subsequentsign()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 onwallet:helloand the popup constrains WebAuthnallowCredentialsto 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 atwallet.example.com/embed/wallet?origin=...and shows/hides a modal overlay on request from the wallet document (see "Transport" above). - Opt-in transport (
PopupTransport) openswallet.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 aMessagePorttransfer (WalletTransport.postTransfer(msg, [port])) to broker the directMessageChannelbetween 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 — buthasKeysreflects the sink's REAL post-adopt state: it can honestly befalseif 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 aHostToPopupmessage together with aTransferable[](theMessagePort). Unlikerequest(), it isn't queued in the pre-handshake outbox — aTransferableis neutered at post time, so it's only called afterwallet:ready.onKeyed?: (hasKeys?: boolean) => void— an optional, mutable hook the SDK assigns once at construction; a transport invokes it whenever an inboundwallet:session:keyedack arrives, out-of-band fromrequest()'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 requiredpostTransfer(message, transfer)method and the optionalonKeyed?hook used by the keyedMessagePorthandoff (see "Keyed handoff over aMessagePort" below) — a custom transport must implementpostTransferto 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'suser.displayName,email→user.name(what OS/browser passkey managers show);labelis a legacy single-field fallback for both.resume()— silent, popup-free boot reconnect: adopts and returns an existing live session, ornullif 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"). ReturnsLoginResult | null.login()— resumes an existing session silently when one is live (viaresume()); otherwise runs the WebAuthn ceremony over a popup, same asregister(). Returns the session. Useresume()instead when you must NOT prompt (e.g. on boot).getSigner({ chain })— returns anEthersSigner('evm'),PolkadotSigner('cere'), orSolanaSigner('solana'); each exposessignMessage(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"). SeeARCHITECTURE.md→ Key invariants.saveApplication(record)/findApplications(filter)— manage app context recordslogout()— always sendswallet:logoutto 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 calledlogin(), 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.