@cef-ai/wallet 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -57,17 +57,112 @@ const sig = await signer.signMessage(new TextEncoder().encode('Hello'));
57
57
  console.log('Signature bytes:', sig);
58
58
  ```
59
59
 
60
+ ## Transport: iframe (default) vs popup
61
+
62
+ ```ts
63
+ new EmbedWallet({ appId, walletOrigin, transport: 'iframe' | 'popup' });
64
+ ```
65
+
66
+ `transport` defaults to `'iframe'`. This controls how the SDK talks to the
67
+ wallet UI at `${walletOrigin}/embed/wallet`:
68
+
69
+ - **`'iframe'` (default, `IframeTransport`).** The SDK injects a hidden,
70
+ zero-size `<iframe>` pinned to `walletOrigin` on first use. It stays
71
+ invisible until the wallet needs to show UI (a consent prompt or the
72
+ login "Continue" gesture — see below), at which point it grows into a
73
+ centered modal with a backdrop, then shrinks back to hidden. No
74
+ popup-blocker exposure, no separate window for the user to lose track of.
75
+ - **`'popup'` (`PopupTransport`, the original transport).** Opens
76
+ `${walletOrigin}/embed/wallet` in a real popup window via `window.open`.
77
+ Still fully supported — pass `transport: 'popup'` to opt back into it (e.g.
78
+ if your host page can't grant the iframe permission below, or you prefer
79
+ the popup UX).
80
+
81
+ Both implement the same `WalletTransport` interface, so `EmbedWallet`'s
82
+ public methods behave identically regardless of which one is active.
83
+
84
+ ### `allow` / `Permissions-Policy` requirement (iframe transport only)
85
+
86
+ The injected iframe is created with:
87
+
88
+ ```html
89
+ <iframe
90
+ src="https://wallet.example.com/embed/wallet?origin=..."
91
+ allow="publickey-credentials-get https://wallet.example.com"
92
+ />
93
+ ```
94
+
95
+ This delegates the WebAuthn **`get()`** (i.e. `login()`) ceremony into the
96
+ cross-origin iframe. Two things follow for host apps:
97
+
98
+ 1. If your page sends a restrictive `Permissions-Policy` HTTP response
99
+ header, it must explicitly allow `publickey-credentials-get` for your
100
+ wallet origin (e.g. `Permissions-Policy: publickey-credentials-get=(self "https://wallet.example.com")`),
101
+ or the browser will block WebAuthn `get()` inside the iframe regardless of
102
+ the `allow` attribute. Pages with no `Permissions-Policy` header (the
103
+ common case) need no action — the `allow` attribute alone is sufficient.
104
+ 2. **Only `-get` is delegated, not `-create`.** Safari blocks WebAuthn
105
+ `create()` (passkey registration) in cross-origin iframes outright, so
106
+ `create` is deliberately not requested in `allow`. See "Register uses a
107
+ synchronous popup" below.
108
+
109
+ ### Register uses a synchronous popup
110
+
111
+ `EmbedWallet.register()` always performs its WebAuthn ceremony over a
112
+ `PopupTransport`, even when the SDK is configured with the (default) iframe
113
+ transport:
114
+
115
+ - If the configured transport is already `PopupTransport` (`transport: 'popup'`),
116
+ `register()` reuses it directly.
117
+ - Otherwise (iframe transport), `register()` opens a **transient popup
118
+ synchronously** — inside the same call stack as the consumer's click
119
+ handler — performs the `wallet:hello` (`intent: 'register'`) ceremony over
120
+ it, and closes it when done. "Synchronous" matters: Safari requires
121
+ `window.open` to happen within a real user-activation event, so `register()`
122
+ cannot defer opening the popup until after an `await`.
123
+ - `login()` and every other method (`getSigner`, `asSigner`,
124
+ `requestDelegation`, `saveApplication`, `findApplications`, `logout`) always
125
+ use the configured transport (iframe by default) unchanged — only
126
+ `register()` special-cases the popup punch-out.
127
+ - After the register popup completes, the persistent iframe adopts the new
128
+ session automatically via a same-origin `BroadcastChannel('scp-wallet-v2')`
129
+ handoff (`BroadcastSessionShare`) — no explicit `login()` call is needed
130
+ after `register()` resolves.
131
+
60
132
  ## Architecture
61
133
 
62
- - A popup-window-based transport (`PopupTransport`) opens `wallet.example.com/embed/wallet?origin=...`
63
- - The popup runs the wallet UI, performs the WebAuthn ceremony, and posts results back
64
- - Cross-tab session sharing via `BroadcastChannel('scp-wallet-v2')` so a second host tab discovers an existing session
134
+ - Default transport (`IframeTransport`) injects a hidden, permission-scoped
135
+ iframe at `wallet.example.com/embed/wallet?origin=...` and shows/hides a
136
+ modal overlay on request from the wallet document (see "Transport" above).
137
+ - Opt-in transport (`PopupTransport`) opens `wallet.example.com/embed/wallet?origin=...`
138
+ in a separate popup window instead.
139
+ - Either way, the wallet surface runs the WebAuthn ceremony, signing, and
140
+ consent UI, and posts id-correlated results back over `postMessage`.
141
+ - Cross-tab session sharing via `BroadcastChannel('scp-wallet-v2')` so a second
142
+ host tab (or the register popup handing off to the iframe) discovers an
143
+ existing session.
65
144
 
66
145
  Design specs and ADRs are maintained in the company memory bank.
67
146
 
147
+ ## Public exports
148
+
149
+ - `EmbedWallet` — the SDK class (see Quick start).
150
+ - `WalletTransport` (type) — the transport interface both transports
151
+ implement; only relevant if you're injecting a custom transport via the
152
+ `__internal__` test seam.
153
+ - `PopupTransport` / `PopupTransportOptions` — the popup-window transport.
154
+ - `IframeTransport` / `IframeTransportOptions` — the default iframe transport.
155
+ Options: `walletOrigin`, `hostWindow?`, `document?`, `defaultTimeoutMs?`,
156
+ plus two test seams (`mountIframe?`, `onOverlay?`) not needed in production
157
+ use.
158
+ - `PopupHostBridge` / `PopupHostBridgeOptions`, `BroadcastSessionShare` /
159
+ `BroadcastSessionShareOptions` / `BroadcastSession` — wallet-side building
160
+ blocks (used by the wallet SPA itself, exported for advanced/embedding
161
+ scenarios); most host-app integrations never need these directly.
162
+
68
163
  ## Capabilities
69
164
 
70
- - `register({ label? })` / `login()` — opens the popup, performs the WebAuthn ceremony, returns the session
165
+ - `register({ name?, email?, label? })` / `login()` — opens the configured transport surface (iframe by default; `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`, `email` → `user.name` (what OS/browser passkey managers show); `label` is a legacy single-field fallback for both.
71
166
  - `getSigner({ chain })` — returns an `EthersSigner` (`'evm'`), `PolkadotSigner` (`'cere'`), or `SolanaSigner` (`'solana'`); each exposes `signMessage(bytes: Uint8Array): Promise<Uint8Array>`
72
167
  - `requestDelegation({ capabilities, ttl, appId?, agentPubkey?, constraints? })` — requests a scoped delegation token; the popup shows a consent UI
73
168
  - `saveApplication(record)` / `findApplications(filter)` — manage app context records
package/dist/index.cjs CHANGED
@@ -107,8 +107,15 @@ var PopupTransport = class {
107
107
  });
108
108
  });
109
109
  }
110
- /** Close the popup if open. Pending requests will time out normally. */
110
+ /**
111
+ * Close the popup if open. Immediately rejects any in-flight `request()`
112
+ * calls (mirrors `IframeTransport.close()`) rather than leaving them to
113
+ * time out — a caller that closes the popup mid-request (e.g.
114
+ * `EmbedWallet.register()`'s `finally` block) gets a prompt rejection
115
+ * instead of waiting out the full default 60s timeout.
116
+ */
111
117
  close() {
118
+ this.rejectAllPending("transport closed");
112
119
  if (this.popup && !this.popup.closed) {
113
120
  this.popup.close();
114
121
  }
@@ -132,6 +139,14 @@ var PopupTransport = class {
132
139
  }
133
140
  }
134
141
  // ---- internal -------------------------------------------------------------
142
+ /** Reject every in-flight request with a `WalletError('internal', reason)`. Idempotent. */
143
+ rejectAllPending(reason) {
144
+ for (const [id, pending] of this.pending) {
145
+ clearTimeout(pending.timer);
146
+ this.pending.delete(id);
147
+ pending.reject(new walletApiClient.WalletError("internal", reason));
148
+ }
149
+ }
135
150
  ensureListening() {
136
151
  if (this.listener) return;
137
152
  this.listener = (event) => this.onMessage(event);
@@ -189,6 +204,173 @@ function defaultOpener(url, features) {
189
204
  const opened = w == null ? void 0 : w.open(url, "_blank", features);
190
205
  return opened;
191
206
  }
207
+ var IframeTransport = class {
208
+ constructor(options) {
209
+ this.iframe = null;
210
+ this.pending = /* @__PURE__ */ new Map();
211
+ this.listener = null;
212
+ this.ready = false;
213
+ this.outbox = [];
214
+ this.backdrop = null;
215
+ this.hiddenCssText = "";
216
+ this.overlayVisible = false;
217
+ this.opts = __spreadValues({
218
+ hostWindow: globalThis.window,
219
+ document: globalThis.document,
220
+ defaultTimeoutMs: 6e4,
221
+ mountIframe: options.mountIframe,
222
+ onOverlay: options.onOverlay
223
+ }, options);
224
+ }
225
+ ensureIframe() {
226
+ var _a, _b, _c;
227
+ if (this.iframe) return;
228
+ const hostOrigin = (_b = (_a = globalThis.location) == null ? void 0 : _a.origin) != null ? _b : "";
229
+ const url = `${this.opts.walletOrigin}/embed/wallet?origin=${encodeURIComponent(hostOrigin)}`;
230
+ const allow = `publickey-credentials-get ${this.opts.walletOrigin}`;
231
+ if (this.opts.mountIframe) {
232
+ this.iframe = this.opts.mountIframe(url, allow);
233
+ } else {
234
+ const el = this.opts.document.createElement("iframe");
235
+ el.src = url;
236
+ el.setAttribute("allow", allow);
237
+ el.style.cssText = "position:fixed;border:0;width:0;height:0;left:-9999px;";
238
+ this.opts.document.body.appendChild(el);
239
+ this.iframe = el;
240
+ }
241
+ this.hiddenCssText = (_c = this.iframe.style.cssText) != null ? _c : "";
242
+ this.ready = false;
243
+ this.outbox = [];
244
+ this.overlayVisible = false;
245
+ this.ensureListening();
246
+ }
247
+ showOverlay() {
248
+ var _a, _b;
249
+ if (this.overlayVisible) return;
250
+ this.overlayVisible = true;
251
+ if (this.iframe) {
252
+ const backdrop = this.opts.document.createElement("div");
253
+ backdrop.setAttribute("data-wallet-overlay-backdrop", "");
254
+ backdrop.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:2147483646;";
255
+ this.opts.document.body.appendChild(backdrop);
256
+ this.backdrop = backdrop;
257
+ this.iframe.style.cssText = "position:fixed;border:0;z-index:2147483647;width:min(420px,100vw);height:min(640px,100vh);left:50%;top:50%;transform:translate(-50%,-50%);";
258
+ }
259
+ (_b = (_a = this.opts).onOverlay) == null ? void 0 : _b.call(_a, true);
260
+ }
261
+ hideOverlay() {
262
+ var _a, _b, _c;
263
+ if (!this.overlayVisible) return;
264
+ this.overlayVisible = false;
265
+ (_a = this.backdrop) == null ? void 0 : _a.remove();
266
+ this.backdrop = null;
267
+ if (this.iframe) {
268
+ this.iframe.style.cssText = this.hiddenCssText;
269
+ }
270
+ (_c = (_b = this.opts).onOverlay) == null ? void 0 : _c.call(_b, false);
271
+ }
272
+ ensureListening() {
273
+ if (this.listener) return;
274
+ this.listener = (e) => this.onMessage(e);
275
+ this.opts.hostWindow.addEventListener("message", this.listener);
276
+ }
277
+ request(_0, _1) {
278
+ return __async(this, arguments, function* (message, expectedTypes, init = {}) {
279
+ var _a;
280
+ this.ensureIframe();
281
+ const id = message.id;
282
+ const timeoutMs = (_a = init.timeoutMs) != null ? _a : this.opts.defaultTimeoutMs;
283
+ return new Promise((resolve, reject) => {
284
+ const timer = setTimeout(() => {
285
+ if (this.pending.has(id)) {
286
+ this.pending.delete(id);
287
+ this.outbox = this.outbox.filter((m) => m.id !== id);
288
+ reject(new walletApiClient.WalletError("internal", `request "${message.type}" (id=${id}) timed out after ${timeoutMs}ms`));
289
+ }
290
+ }, timeoutMs);
291
+ this.pending.set(id, { expectedTypes, resolve: (m) => resolve(m), reject, timer });
292
+ if (this.ready) this.postToIframe(message);
293
+ else this.outbox.push(message);
294
+ });
295
+ });
296
+ }
297
+ postToIframe(message) {
298
+ var _a, _b;
299
+ (_b = (_a = this.iframe) == null ? void 0 : _a.contentWindow) == null ? void 0 : _b.postMessage(message, this.opts.walletOrigin);
300
+ }
301
+ close() {
302
+ var _a, _b;
303
+ this.rejectAllPending("transport closed");
304
+ (_a = this.backdrop) == null ? void 0 : _a.remove();
305
+ this.backdrop = null;
306
+ this.overlayVisible = false;
307
+ (_b = this.iframe) == null ? void 0 : _b.remove();
308
+ this.iframe = null;
309
+ this.ready = false;
310
+ this.outbox = [];
311
+ }
312
+ destroy() {
313
+ this.close();
314
+ if (this.listener) {
315
+ this.opts.hostWindow.removeEventListener("message", this.listener);
316
+ this.listener = null;
317
+ }
318
+ }
319
+ rejectAllPending(reason) {
320
+ for (const [id, p] of this.pending) {
321
+ clearTimeout(p.timer);
322
+ this.pending.delete(id);
323
+ p.reject(new walletApiClient.WalletError("internal", reason));
324
+ }
325
+ }
326
+ onMessage(event) {
327
+ const data = event.data;
328
+ if (!isMatchingOrigin(event.origin, this.opts.walletOrigin)) {
329
+ if (data && typeof data.id === "string" && this.pending.has(data.id)) {
330
+ const p2 = this.pending.get(data.id);
331
+ this.pending.delete(data.id);
332
+ clearTimeout(p2.timer);
333
+ p2.reject(
334
+ new walletApiClient.WalletError(
335
+ "origin-mismatch",
336
+ `message from "${event.origin}" rejected (expected "${this.opts.walletOrigin}")`
337
+ )
338
+ );
339
+ }
340
+ return;
341
+ }
342
+ if (!data || typeof data.type !== "string" || typeof data.id !== "string") return;
343
+ if (data.type === "wallet:ready") {
344
+ if (this.ready) return;
345
+ this.ready = true;
346
+ const flush = this.outbox;
347
+ this.outbox = [];
348
+ for (const m of flush) this.postToIframe(m);
349
+ return;
350
+ }
351
+ if (data.type === "wallet:ui:show") {
352
+ this.showOverlay();
353
+ return;
354
+ }
355
+ if (data.type === "wallet:ui:hide") {
356
+ this.hideOverlay();
357
+ return;
358
+ }
359
+ const p = this.pending.get(data.id);
360
+ if (!p) return;
361
+ clearTimeout(p.timer);
362
+ this.pending.delete(data.id);
363
+ if (data.type === "wallet:error") {
364
+ p.reject(new walletApiClient.WalletError(data.code, data.message, data.traceId));
365
+ return;
366
+ }
367
+ if (!p.expectedTypes.includes(data.type)) {
368
+ p.reject(new walletApiClient.WalletError("internal", `received unexpected response type "${data.type}" for id "${data.id}"`));
369
+ return;
370
+ }
371
+ p.resolve(data);
372
+ }
373
+ };
192
374
 
193
375
  // src/EmbedWallet.ts
194
376
  var EmbedWallet = class {
@@ -196,7 +378,7 @@ var EmbedWallet = class {
196
378
  this._addresses = null;
197
379
  this._credentialId = null;
198
380
  this._listeners = {};
199
- var _a, _b, _c, _d, _e, _f, _g;
381
+ var _a, _b, _c, _d, _e, _f, _g, _h;
200
382
  const injectedTransport = (_a = opts.__internal__) == null ? void 0 : _a.transport;
201
383
  if (!injectedTransport && !opts.walletOrigin) {
202
384
  throw new Error("EmbedWallet: `walletOrigin` is required (e.g. https://wallet.example.com).");
@@ -205,9 +387,8 @@ var EmbedWallet = class {
205
387
  this.walletOrigin = (_b = opts.walletOrigin) != null ? _b : "";
206
388
  this.popup = { width: (_d = (_c = opts.popup) == null ? void 0 : _c.width) != null ? _d : 420, height: (_f = (_e = opts.popup) == null ? void 0 : _e.height) != null ? _f : 640 };
207
389
  this.appName = (_g = opts.appName) != null ? _g : "";
208
- this.transport = injectedTransport != null ? injectedTransport : new PopupTransport({
209
- walletOrigin: this.walletOrigin
210
- });
390
+ this.regPopupOpener = (_h = opts.__internal__) == null ? void 0 : _h.registerPopupOpener;
391
+ this.transport = injectedTransport != null ? injectedTransport : opts.transport === "popup" ? new PopupTransport({ walletOrigin: this.walletOrigin }) : new IframeTransport({ walletOrigin: this.walletOrigin });
211
392
  }
212
393
  get isLoggedIn() {
213
394
  return this._addresses !== null;
@@ -218,9 +399,27 @@ var EmbedWallet = class {
218
399
  get credentialId() {
219
400
  return this._credentialId;
220
401
  }
402
+ /**
403
+ * Safari blocks WebAuthn `create()` in cross-origin iframes, so `register()`
404
+ * always runs its `wallet:hello` ceremony over a `PopupTransport` — even
405
+ * when the configured transport is the iframe. When the main transport is
406
+ * already a `PopupTransport` it's reused; otherwise a transient one is
407
+ * opened synchronously (within the caller's click) and closed afterward.
408
+ * `login()` and all other ops use the configured transport unchanged.
409
+ */
221
410
  register() {
222
411
  return __async(this, arguments, function* (opts = {}) {
223
- return this.helloAndAwaitLogin("register", opts);
412
+ if (this.transport instanceof PopupTransport) {
413
+ return this.helloAndAwaitLogin("register", opts);
414
+ }
415
+ const popup = new PopupTransport(__spreadValues({
416
+ walletOrigin: this.walletOrigin
417
+ }, this.regPopupOpener ? { openPopup: this.regPopupOpener } : {}));
418
+ try {
419
+ return yield this.helloAndAwaitLogin("register", opts, popup);
420
+ } finally {
421
+ popup.close();
422
+ }
224
423
  });
225
424
  }
226
425
  login() {
@@ -379,16 +578,18 @@ var EmbedWallet = class {
379
578
  return { appId: this.appId, name: this.appName, origin };
380
579
  }
381
580
  helloAndAwaitLogin(_0) {
382
- return __async(this, arguments, function* (intent, opts = {}) {
581
+ return __async(this, arguments, function* (intent, opts = {}, transport = this.transport) {
383
582
  const id = crypto.randomUUID();
384
583
  const helloMessage = {
385
584
  type: "wallet:hello",
386
585
  id,
387
586
  appContext: this.appContext(),
388
587
  intent,
389
- label: opts.label
588
+ label: opts.label,
589
+ name: opts.name,
590
+ email: opts.email
390
591
  };
391
- const result = yield this.transport.request(helloMessage, [
592
+ const result = yield transport.request(helloMessage, [
392
593
  "wallet:login:ok"
393
594
  ]);
394
595
  this._addresses = result.addresses;
@@ -421,7 +622,8 @@ var PopupHostBridge = class {
421
622
  this.handlers = {};
422
623
  this.listener = null;
423
624
  this.opts = __spreadValues({
424
- popupWindow: globalThis.window
625
+ popupWindow: globalThis.window,
626
+ replyTo: "opener"
425
627
  }, options);
426
628
  }
427
629
  /** Register a handler for a specific message type. Replaces any prior handler. */
@@ -430,16 +632,16 @@ var PopupHostBridge = class {
430
632
  return this;
431
633
  }
432
634
  /**
433
- * Send a `PopupToHost` message back to the opener. The bridge does not
434
- * remember the host-window reference itself — it reads `globalThis.opener`
435
- * each call, so it survives popup re-opens.
635
+ * Send a `PopupToHost` message back to the opener or parent, depending on
636
+ * `replyTo`. The bridge does not remember the host-window reference itself —
637
+ * it reads the target window each call, so it survives popup re-opens.
436
638
  */
437
639
  send(message) {
438
- const opener = globalThis.opener;
439
- if (!opener || typeof opener.postMessage !== "function") {
640
+ const target = this.opts.replyTo === "parent" ? globalThis.parent : globalThis.opener;
641
+ if (!target || typeof target.postMessage !== "function") {
440
642
  return;
441
643
  }
442
- opener.postMessage(message, this.opts.hostOrigin);
644
+ target.postMessage(message, this.opts.hostOrigin);
443
645
  }
444
646
  /**
445
647
  * Start listening for inbound messages.
@@ -525,8 +727,8 @@ var BroadcastSessionShare = class {
525
727
  });
526
728
  return;
527
729
  }
528
- if (msg.type === "logout") {
529
- (_b = (_a = this.opts).onLogout) == null ? void 0 : _b.call(_a);
730
+ if (msg.type === "session-available") {
731
+ (_b = (_a = this.opts).onAvailable) == null ? void 0 : _b.call(_a);
530
732
  return;
531
733
  }
532
734
  };
@@ -565,7 +767,13 @@ var BroadcastSessionShare = class {
565
767
  });
566
768
  });
567
769
  }
568
- /** Begin offering this tab's session in response to requests, and handle logouts. */
770
+ /**
771
+ * Begin offering this tab's session in response to requests, and invoke
772
+ * `onAvailable` on incoming `session-available` pushes. Callers that only
773
+ * care about `onAvailable` (no session to offer) still call `start()` —
774
+ * `getSession()` returning `null` simply means `request-session` replies
775
+ * are skipped.
776
+ */
569
777
  start() {
570
778
  if (this.listening) return;
571
779
  this.listening = true;
@@ -575,10 +783,24 @@ var BroadcastSessionShare = class {
575
783
  this.channel.removeEventListener("message", this.onMessage);
576
784
  this.listening = false;
577
785
  }
578
- /** Notify peers that the user logged out. Peers wipe their sessions. */
786
+ /**
787
+ * Notify peers that the user logged out. Kept for public-API compatibility;
788
+ * no `BroadcastSessionShare` instance currently handles the receive side
789
+ * (see class doc) — cross-tab logout is `CrossTabSync`'s responsibility.
790
+ */
579
791
  broadcastLogout() {
580
792
  this.channel.postMessage({ type: "logout" });
581
793
  }
794
+ /**
795
+ * Notify peers that this tab just established a session (register/login
796
+ * completed). Carries NO key material — it is purely a push trigger that
797
+ * tells session-less peers to issue their own `request()`. Key material
798
+ * still only ever moves as an `offer-session` reply to an explicit
799
+ * `request-session`; `announce()` does not bypass that gate.
800
+ */
801
+ announce() {
802
+ this.channel.postMessage({ type: "session-available" });
803
+ }
582
804
  /** Free the underlying channel handle. Call from unload handlers. */
583
805
  close() {
584
806
  this.stop();
@@ -590,6 +812,7 @@ exports.ALLOWED_ORIGIN_SCHEMES = ALLOWED_ORIGIN_SCHEMES;
590
812
  exports.BROADCAST_CHANNEL_NAME = BROADCAST_CHANNEL_NAME;
591
813
  exports.BroadcastSessionShare = BroadcastSessionShare;
592
814
  exports.EmbedWallet = EmbedWallet;
815
+ exports.IframeTransport = IframeTransport;
593
816
  exports.PopupHostBridge = PopupHostBridge;
594
817
  exports.PopupTransport = PopupTransport;
595
818
  exports.isMatchingOrigin = isMatchingOrigin;