@formstr/signer 0.2.2 → 0.3.1

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/dist/index.cjs CHANGED
@@ -24,7 +24,9 @@ __export(src_exports, {
24
24
  BunkerSigner: () => BunkerSigner,
25
25
  ExtensionSigner: () => ExtensionSigner,
26
26
  LocalSigner: () => LocalSigner,
27
+ Nip55WebSigner: () => Nip55WebSigner,
27
28
  Signer: () => Signer,
29
+ browserNip55Transport: () => browserNip55Transport,
28
30
  bytesToHex: () => bytesToHex,
29
31
  connectWithBunkerUri: () => connectWithBunkerUri,
30
32
  createSigner: () => createSigner,
@@ -35,12 +37,13 @@ __export(src_exports, {
35
37
  hexToBytes: () => hexToBytes,
36
38
  initiateNostrConnect: () => initiateNostrConnect,
37
39
  localStorageAdapter: () => localStorageAdapter,
38
- loginWithAndroidSigner: () => loginWithAndroidSigner
40
+ loginWithAndroidSigner: () => loginWithAndroidSigner,
41
+ normalizeNip55Identifier: () => normalizeNip55Identifier
39
42
  });
40
43
  module.exports = __toCommonJS(src_exports);
41
44
 
42
45
  // src/core/signer.ts
43
- var import_nostr_tools5 = require("nostr-tools");
46
+ var import_nostr_tools6 = require("nostr-tools");
44
47
  var import_nip462 = require("nostr-tools/nip46");
45
48
 
46
49
  // src/core/storage.ts
@@ -400,7 +403,7 @@ async function loginWithAndroidSigner(plugin, packageName) {
400
403
  "@formstr/signer: android signer did not return a package name and none was supplied"
401
404
  );
402
405
  }
403
- const { pubkey, npub } = normalizeAndroidIdentifier(rawIdentifier);
406
+ const { pubkey, npub } = normalizeNip55Identifier(rawIdentifier);
404
407
  return {
405
408
  signer: new AndroidSigner(plugin, resolvedPackage, npub, pubkey),
406
409
  pubkey,
@@ -408,7 +411,7 @@ async function loginWithAndroidSigner(plugin, packageName) {
408
411
  packageName: resolvedPackage
409
412
  };
410
413
  }
411
- function normalizeAndroidIdentifier(rawIdentifier) {
414
+ function normalizeNip55Identifier(rawIdentifier) {
412
415
  if (typeof rawIdentifier === "string" && HEX_PUBKEY_RE.test(rawIdentifier)) {
413
416
  const pubkey = rawIdentifier.toLowerCase();
414
417
  return { pubkey, npub: import_nostr_tools4.nip19.npubEncode(pubkey) };
@@ -418,16 +421,301 @@ function normalizeAndroidIdentifier(rawIdentifier) {
418
421
  decoded = import_nostr_tools4.nip19.decode(rawIdentifier);
419
422
  } catch (e) {
420
423
  throw new Error(
421
- `@formstr/signer: android signer returned an undecodable identifier (got ${describeIdentifier(rawIdentifier)}): ${e.message}`
424
+ `@formstr/signer: signer returned an undecodable identifier (got ${describeIdentifier(rawIdentifier)}): ${e.message}`
422
425
  );
423
426
  }
424
- if (decoded.type !== "npub") {
427
+ if (decoded.type === "npub") {
428
+ return { pubkey: decoded.data, npub: rawIdentifier };
429
+ }
430
+ if (decoded.type === "nprofile") {
431
+ const pubkey = decoded.data.pubkey;
432
+ return { pubkey, npub: import_nostr_tools4.nip19.npubEncode(pubkey) };
433
+ }
434
+ throw new Error(
435
+ `@formstr/signer: signer returned a non-pubkey identifier (type=${decoded.type}, got ${describeIdentifier(rawIdentifier)})`
436
+ );
437
+ }
438
+
439
+ // src/nip55Web.ts
440
+ var import_nostr_tools5 = require("nostr-tools");
441
+ var DEFAULT_POLL_INTERVAL_MS = 500;
442
+ var DEFAULT_TIMEOUT_MS = 12e4;
443
+ var sentinelCounter = 0;
444
+ function makeSentinel() {
445
+ sentinelCounter += 1;
446
+ return `__formstr_nip55_sentinel_${Date.now()}_${sentinelCounter}__`;
447
+ }
448
+ function abortError() {
449
+ const error = new Error("@formstr/signer: NIP-55 request aborted");
450
+ error.name = "AbortError";
451
+ return error;
452
+ }
453
+ function isNativeShell() {
454
+ const cap = globalThis.Capacitor;
455
+ return typeof cap?.isNativePlatform === "function" && cap.isNativePlatform();
456
+ }
457
+ function isGeckoBrowser() {
458
+ const ua = navigator.userAgent;
459
+ return /Firefox\/|FxiOS\/|Focus\/|Gecko\//.test(ua);
460
+ }
461
+ function browserNip55Transport() {
462
+ return {
463
+ supportStatus() {
464
+ if (typeof navigator === "undefined") {
465
+ return { visible: false, reason: "not-android" };
466
+ }
467
+ if (isNativeShell()) {
468
+ return { visible: false, reason: "native" };
469
+ }
470
+ if (!/Android/i.test(navigator.userAgent)) {
471
+ return { visible: false, reason: "not-android" };
472
+ }
473
+ if (typeof navigator.clipboard?.readText !== "function") {
474
+ return { visible: false, reason: "no-clipboard" };
475
+ }
476
+ if (isGeckoBrowser()) {
477
+ return {
478
+ visible: true,
479
+ reason: "firefox",
480
+ warning: "May not work in Firefox for Android \u2014 the clipboard usually cannot be read automatically. If it hangs, try Chrome, Brave, or the Android app."
481
+ };
482
+ }
483
+ return { visible: true };
484
+ },
485
+ isSupported() {
486
+ return this.supportStatus().visible;
487
+ },
488
+ open(intent) {
489
+ window.open(intent, "_blank");
490
+ },
491
+ readClipboard() {
492
+ return navigator.clipboard.readText();
493
+ },
494
+ writeClipboard(text) {
495
+ return navigator.clipboard.writeText(text);
496
+ }
497
+ };
498
+ }
499
+ var Nip55WebSigner = class _Nip55WebSigner {
500
+ #transport;
501
+ #pollIntervalMs;
502
+ #timeoutMs;
503
+ #signal;
504
+ #debug;
505
+ #pending = null;
506
+ #pubkey = null;
507
+ constructor(options = {}) {
508
+ this.#transport = options.transport ?? browserNip55Transport();
509
+ this.#pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
510
+ this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
511
+ this.#signal = options.signal;
512
+ this.#debug = options.debug;
513
+ this.#pubkey = options.pubkey ?? null;
514
+ }
515
+ #log(message) {
516
+ this.#debug?.(message);
517
+ }
518
+ /** True when the configured transport can actually open a signer app. */
519
+ isSupported() {
520
+ return this.#transport.isSupported();
521
+ }
522
+ /**
523
+ * Whether to offer the option and whether it works. Prefer this over
524
+ * {@link isSupported} so a host can surface `message` — notably the
525
+ * Firefox case, which is shown but cannot complete.
526
+ */
527
+ supportStatus() {
528
+ const status = this.#transport.supportStatus;
529
+ if (status) return status.call(this.#transport);
530
+ return { visible: this.#transport.isSupported() };
531
+ }
532
+ async getPublicKey() {
533
+ if (this.#pubkey !== null) return this.#pubkey;
534
+ this.#checkSupport();
535
+ const raw = await this.#request(_Nip55WebSigner.getPublicKeyIntent());
536
+ const { pubkey } = normalizeNip55Identifier(raw);
537
+ this.#pubkey = pubkey;
538
+ return pubkey;
539
+ }
540
+ async signEvent(event) {
541
+ this.#checkSupport();
542
+ const pubkey = this.#pubkey ?? await this.getPublicKey();
543
+ const unsigned = { ...event, pubkey };
544
+ const draftWithId = { ...unsigned, id: (0, import_nostr_tools5.getEventHash)(unsigned) };
545
+ const sig = (await this.#request(_Nip55WebSigner.signEventIntent(draftWithId))).trim();
546
+ if (!/^[0-9a-f]{128}$/i.test(sig)) {
547
+ throw new Error(
548
+ "@formstr/signer: NIP-55 signer did not return a hex signature"
549
+ );
550
+ }
551
+ const signed = { ...draftWithId, sig: sig.toLowerCase() };
552
+ if (!(0, import_nostr_tools5.verifyEvent)(signed)) {
553
+ throw new Error("@formstr/signer: NIP-55 signer returned an invalid signature");
554
+ }
555
+ return signed;
556
+ }
557
+ async nip04Encrypt(peerPubkey, plaintext) {
558
+ this.#checkSupport();
559
+ return this.#request(_Nip55WebSigner.nip04EncryptIntent(peerPubkey, plaintext));
560
+ }
561
+ async nip04Decrypt(peerPubkey, ciphertext) {
562
+ this.#checkSupport();
563
+ return this.#request(_Nip55WebSigner.nip04DecryptIntent(peerPubkey, ciphertext));
564
+ }
565
+ async nip44Encrypt(peerPubkey, plaintext) {
566
+ this.#checkSupport();
567
+ return this.#request(_Nip55WebSigner.nip44EncryptIntent(peerPubkey, plaintext));
568
+ }
569
+ async nip44Decrypt(peerPubkey, ciphertext) {
570
+ this.#checkSupport();
571
+ return this.#request(_Nip55WebSigner.nip44DecryptIntent(peerPubkey, ciphertext));
572
+ }
573
+ /**
574
+ * Cancel any in-flight request and stop its clipboard poll. Subsequent
575
+ * operations still work — this is a teardown of live resources, not a
576
+ * permanent disable (the {@link Signer} calls it when replacing the
577
+ * active signer).
578
+ */
579
+ close() {
580
+ if (this.#pending) {
581
+ const pending = this.#pending;
582
+ this.#settle();
583
+ pending.reject(abortError());
584
+ }
585
+ }
586
+ #checkSupport() {
587
+ const { visible, reason } = this.supportStatus();
588
+ if (visible) return;
589
+ if (reason === "native") {
590
+ throw new Error(
591
+ "@formstr/signer: the browser NIP-55 flow is not for native builds \u2014 use loginWithAndroidSigner() with the Capacitor plugin instead"
592
+ );
593
+ }
425
594
  throw new Error(
426
- `@formstr/signer: android signer returned a non-npub identifier (type=${decoded.type}, got ${describeIdentifier(rawIdentifier)})`
595
+ "@formstr/signer: NIP-55 web signing requires an Android browser with clipboard access (a signer app registering the `nostrsigner` scheme must be installed)"
427
596
  );
428
597
  }
429
- return { pubkey: decoded.data, npub: rawIdentifier };
430
- }
598
+ /** One poll tick: read the clipboard and settle if the signer answered. */
599
+ #poll = async (pending) => {
600
+ let text;
601
+ try {
602
+ text = await this.#transport.readClipboard();
603
+ } catch (error) {
604
+ this.#log(`clipboard read failed: ${error.message}`);
605
+ return;
606
+ }
607
+ if (this.#pending !== pending) return;
608
+ const trimmed = text.trim();
609
+ if (trimmed.length === 0) return;
610
+ if (pending.sentinel !== null && trimmed === pending.sentinel) return;
611
+ this.#log(`clipboard result (${trimmed.length} chars)`);
612
+ this.#settle();
613
+ pending.resolve(trimmed);
614
+ };
615
+ #request(intent) {
616
+ this.#checkAborted();
617
+ this.#cancelPending();
618
+ return new Promise((resolve, reject) => {
619
+ const pending = {
620
+ resolve,
621
+ reject,
622
+ sentinel: null,
623
+ poll: null,
624
+ timer: null,
625
+ onAbort: null
626
+ };
627
+ this.#pending = pending;
628
+ if (this.#signal) {
629
+ const onAbort = () => this.#fail(pending, abortError());
630
+ this.#signal.addEventListener("abort", onAbort);
631
+ pending.onAbort = onAbort;
632
+ }
633
+ if (this.#timeoutMs > 0) {
634
+ pending.timer = setTimeout(() => {
635
+ this.#fail(
636
+ pending,
637
+ new Error(
638
+ `@formstr/signer: NIP-55 request timed out after ${this.#timeoutMs}ms (the signer app never returned a result)`
639
+ )
640
+ );
641
+ }, this.#timeoutMs);
642
+ }
643
+ void (async () => {
644
+ try {
645
+ const sentinel = makeSentinel();
646
+ await this.#transport.writeClipboard(sentinel);
647
+ if (this.#pending === pending) pending.sentinel = sentinel;
648
+ this.#log("planted clipboard sentinel");
649
+ } catch (error) {
650
+ this.#log(`sentinel write failed: ${error.message}`);
651
+ }
652
+ if (this.#pending !== pending) return;
653
+ try {
654
+ this.#log(`opening signer app: ${intent.slice(0, 80)}\u2026`);
655
+ this.#transport.open(intent);
656
+ } catch (error) {
657
+ this.#fail(pending, error);
658
+ return;
659
+ }
660
+ pending.poll = setInterval(() => {
661
+ void this.#poll(pending);
662
+ }, this.#pollIntervalMs);
663
+ })();
664
+ });
665
+ }
666
+ /**
667
+ * Reject the current request. Every caller is cleared on settle — the
668
+ * timeout timer and abort listener are removed, and `open()` is
669
+ * synchronous — so `pending` is always the active request here.
670
+ */
671
+ #fail(pending, error) {
672
+ this.#settle();
673
+ pending.reject(error);
674
+ }
675
+ #settle() {
676
+ const pending = this.#pending;
677
+ this.#pending = null;
678
+ if (pending?.poll) clearInterval(pending.poll);
679
+ if (pending?.timer) clearTimeout(pending.timer);
680
+ if (pending?.onAbort && this.#signal) {
681
+ this.#signal.removeEventListener("abort", pending.onAbort);
682
+ }
683
+ }
684
+ #cancelPending() {
685
+ if (!this.#pending) return;
686
+ const pending = this.#pending;
687
+ this.#settle();
688
+ pending.reject(new Error("@formstr/signer: NIP-55 request superseded"));
689
+ }
690
+ #checkAborted() {
691
+ if (this.#signal?.aborted) throw abortError();
692
+ }
693
+ static getPublicKeyIntent() {
694
+ return "intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;end";
695
+ }
696
+ static signEventIntent(draft) {
697
+ return `intent:${encodeURIComponent(
698
+ JSON.stringify(draft)
699
+ )}#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=sign_event;end`;
700
+ }
701
+ static nip04EncryptIntent(peerPubkey, plaintext) {
702
+ return _Nip55WebSigner.#cryptoIntent("nip04_encrypt", peerPubkey, plaintext);
703
+ }
704
+ static nip04DecryptIntent(peerPubkey, ciphertext) {
705
+ return _Nip55WebSigner.#cryptoIntent("nip04_decrypt", peerPubkey, ciphertext);
706
+ }
707
+ static nip44EncryptIntent(peerPubkey, plaintext) {
708
+ return _Nip55WebSigner.#cryptoIntent("nip44_encrypt", peerPubkey, plaintext);
709
+ }
710
+ static nip44DecryptIntent(peerPubkey, ciphertext) {
711
+ return _Nip55WebSigner.#cryptoIntent("nip44_decrypt", peerPubkey, ciphertext);
712
+ }
713
+ static #cryptoIntent(type, peerPubkey, payload) {
714
+ return `intent:${encodeURIComponent(
715
+ payload
716
+ )}#Intent;scheme=nostrsigner;S.pubKey=${peerPubkey};S.compressionType=none;S.returnType=signature;S.type=${type};end`;
717
+ }
718
+ };
431
719
 
432
720
  // src/core/signer.ts
433
721
  var ACCOUNTS_KEY = "accounts";
@@ -435,6 +723,7 @@ var ACTIVE_KEY = "active-pubkey";
435
723
  var Signer = class {
436
724
  #storage;
437
725
  #defaultAndroidPlugin;
726
+ #nip55WebTransport;
438
727
  #appMetadata;
439
728
  #accounts = [];
440
729
  #activePubkey = null;
@@ -443,6 +732,7 @@ var Signer = class {
443
732
  constructor(config = {}) {
444
733
  this.#storage = config.storage ?? localStorageAdapter(config.storageKeyPrefix);
445
734
  this.#defaultAndroidPlugin = config.androidSignerPlugin;
735
+ this.#nip55WebTransport = config.nip55WebTransport;
446
736
  this.#appMetadata = {
447
737
  name: config.appName,
448
738
  url: config.appUrl,
@@ -476,8 +766,28 @@ var Signer = class {
476
766
  else this.#accounts.push(account);
477
767
  this.#persistAccounts();
478
768
  }
769
+ /**
770
+ * Release the currently-held signer, if it has a `close()`. Called
771
+ * whenever the active signer is replaced or cleared so live resources
772
+ * (bunker subscriptions, `visibilitychange` listeners, in-flight NIP-55
773
+ * requests) don't outlive the session. Errors are swallowed — teardown
774
+ * must never block a login/switch/logout.
775
+ */
776
+ #closeActiveSigner() {
777
+ const signer = this.#activeSigner;
778
+ if (!signer?.close) return;
779
+ try {
780
+ const result = signer.close();
781
+ if (result && typeof result.catch === "function") {
782
+ result.catch(() => {
783
+ });
784
+ }
785
+ } catch {
786
+ }
787
+ }
479
788
  #setActive(account, signer) {
480
789
  const wasDifferent = this.#activePubkey !== null && this.#activePubkey !== account.pubkey;
790
+ if (this.#activeSigner !== signer) this.#closeActiveSigner();
481
791
  this.#activePubkey = account.pubkey;
482
792
  this.#activeSigner = signer;
483
793
  this.#persistActive();
@@ -519,8 +829,8 @@ var Signer = class {
519
829
  if (!ncryptsec) throw new Error("loginWithNcryptsec: ncryptsec required");
520
830
  if (!passphrase) throw new Error("loginWithNcryptsec: passphrase required");
521
831
  const secretKey = decryptNcryptsec(ncryptsec, passphrase);
522
- const pubkey = (0, import_nostr_tools5.getPublicKey)(secretKey);
523
- const npub = import_nostr_tools5.nip19.npubEncode(pubkey);
832
+ const pubkey = (0, import_nostr_tools6.getPublicKey)(secretKey);
833
+ const npub = import_nostr_tools6.nip19.npubEncode(pubkey);
524
834
  const account = { npub, pubkey, method: "ncryptsec", ncryptsec };
525
835
  this.#upsertAccount(account);
526
836
  this.#setActive(account, new LocalSigner(secretKey));
@@ -535,7 +845,7 @@ var Signer = class {
535
845
  async loginWithExtension() {
536
846
  const extension = new ExtensionSigner();
537
847
  const pubkey = await extension.getPublicKey();
538
- const npub = import_nostr_tools5.nip19.npubEncode(pubkey);
848
+ const npub = import_nostr_tools6.nip19.npubEncode(pubkey);
539
849
  const account = { npub, pubkey, method: "extension" };
540
850
  this.#upsertAccount(account);
541
851
  this.#setActive(account, extension);
@@ -552,7 +862,7 @@ var Signer = class {
552
862
  */
553
863
  async loginWithBunkerUri(uri, options = {}) {
554
864
  const result = await connectWithBunkerUri(uri, options);
555
- const npub = import_nostr_tools5.nip19.npubEncode(result.pubkey);
865
+ const npub = import_nostr_tools6.nip19.npubEncode(result.pubkey);
556
866
  const account = {
557
867
  npub,
558
868
  pubkey: result.pubkey,
@@ -604,7 +914,7 @@ var Signer = class {
604
914
  });
605
915
  options.onUri(init.uri);
606
916
  const result = await init.complete;
607
- const npub = import_nostr_tools5.nip19.npubEncode(result.pubkey);
917
+ const npub = import_nostr_tools6.nip19.npubEncode(result.pubkey);
608
918
  const account = {
609
919
  npub,
610
920
  pubkey: result.pubkey,
@@ -669,6 +979,84 @@ var Signer = class {
669
979
  this.#setActive(account, result.signer);
670
980
  return account;
671
981
  }
982
+ /**
983
+ * Whether `loginWithNip55Web` can run in this environment — a plain
984
+ * Android browser with async clipboard access. False in a Capacitor
985
+ * native shell (use {@link loginWithAndroidSigner} there), and on
986
+ * desktop/iOS/SSR.
987
+ *
988
+ * A **capability** check, not an availability one: there is no web API
989
+ * to detect an installed Android app, so this says nothing about
990
+ * whether a signer app is actually installed. Use it to hide the
991
+ * browser flow where it cannot work, not to promise that it will.
992
+ */
993
+ supportsNip55Web(transport) {
994
+ return this.nip55WebSupport(transport).visible;
995
+ }
996
+ /**
997
+ * Whether to offer the browser NIP-55 option, plus an advisory `warning`
998
+ * where it is known to be flaky. Prefer this in UI code.
999
+ *
1000
+ * Nothing here is a hard block. `visible: false` covers only environments
1001
+ * where the mechanism cannot exist — a Capacitor native shell (the plugin
1002
+ * path is better) and non-Android platforms (no `nostrsigner` handler).
1003
+ * Firefox for Android is `visible: true` **with a warning**, because it
1004
+ * advertises `readText` but never grants a persistent permission, so the
1005
+ * poll loop usually cannot complete. Users are still allowed to try.
1006
+ */
1007
+ nip55WebSupport(transport) {
1008
+ const t = transport ?? this.#nip55WebTransport ?? browserNip55Transport();
1009
+ if (t.supportStatus) return t.supportStatus();
1010
+ return { visible: t.isSupported() };
1011
+ }
1012
+ /**
1013
+ * Sign in via a NIP-55 Android external signer (Amber, etc) **from a
1014
+ * plain browser**, with no Capacitor/native bridge. Opens the installed
1015
+ * signer app through a `nostrsigner` intent and reads the result back
1016
+ * from the clipboard once the user returns to the tab. Because the
1017
+ * intent names no package, this works with any app that registered the
1018
+ * `nostrsigner` scheme — one opens directly, several show the Android
1019
+ * "Open with" chooser.
1020
+ *
1021
+ * Not for native builds — inside a Capacitor shell use
1022
+ * {@link loginWithAndroidSigner}, which needs no clipboard and no
1023
+ * per-operation approval.
1024
+ *
1025
+ * Every operation is a separate approval, and a rejection is
1026
+ * indistinguishable from the user simply not returning, so callers must
1027
+ * impose their own timeout. Prefer NIP-46 when a persistent session is
1028
+ * acceptable — the NIP-55 spec recommends it for web clients.
1029
+ *
1030
+ * @throws if the environment cannot run the flow (not Android, no async
1031
+ * clipboard) or the signer returns an unexpected value.
1032
+ */
1033
+ async loginWithNip55Web(options = {}) {
1034
+ const transport = options.transport ?? this.#nip55WebTransport;
1035
+ const pairing = new Nip55WebSigner({
1036
+ transport,
1037
+ pollIntervalMs: options.pollIntervalMs,
1038
+ timeoutMs: options.timeoutMs,
1039
+ signal: options.signal,
1040
+ debug: options.debug
1041
+ });
1042
+ const pubkey = await pairing.getPublicKey();
1043
+ const signer = new Nip55WebSigner({
1044
+ transport,
1045
+ pollIntervalMs: options.pollIntervalMs,
1046
+ timeoutMs: options.timeoutMs,
1047
+ pubkey,
1048
+ debug: options.debug
1049
+ });
1050
+ const npub = import_nostr_tools6.nip19.npubEncode(pubkey);
1051
+ const account = {
1052
+ npub,
1053
+ pubkey,
1054
+ method: "nip55-web"
1055
+ };
1056
+ this.#upsertAccount(account);
1057
+ this.#setActive(account, signer);
1058
+ return account;
1059
+ }
672
1060
  /** Snapshot of every persisted account, in insertion order. */
673
1061
  listAccounts() {
674
1062
  return [...this.#accounts];
@@ -722,6 +1110,10 @@ var Signer = class {
722
1110
  * {@link loginWithAndroidSigner} performs and that — on Amber —
723
1111
  * surfaces as a permission prompt every cold start.
724
1112
  *
1113
+ * - `nip55-web`: constructs a {@link Nip55WebSigner} with the stored
1114
+ * `pubkey` cached. Like `android`, this opens no signer app during
1115
+ * unlock; the first sign/encrypt call is what prompts.
1116
+ *
725
1117
  * - `ncryptsec`: returns `null`. There is no silent path — the user's
726
1118
  * passphrase isn't (and shouldn't be) persisted. The caller must
727
1119
  * drive the passphrase prompt and call {@link loginWithNcryptsec}.
@@ -770,6 +1162,14 @@ var Signer = class {
770
1162
  this.#setActive(account, signer);
771
1163
  return signer;
772
1164
  }
1165
+ case "nip55-web": {
1166
+ const signer = new Nip55WebSigner({
1167
+ transport: this.#nip55WebTransport,
1168
+ pubkey: account.pubkey
1169
+ });
1170
+ this.#setActive(account, signer);
1171
+ return signer;
1172
+ }
773
1173
  case "ncryptsec":
774
1174
  return null;
775
1175
  }
@@ -784,6 +1184,7 @@ var Signer = class {
784
1184
  async switchAccount(pubkey) {
785
1185
  const account = this.#accounts.find((a) => a.pubkey === pubkey);
786
1186
  if (!account) throw new Error(`switchAccount: no account for pubkey ${pubkey}`);
1187
+ this.#closeActiveSigner();
787
1188
  this.#activePubkey = pubkey;
788
1189
  this.#activeSigner = null;
789
1190
  this.#persistActive();
@@ -800,6 +1201,7 @@ var Signer = class {
800
1201
  this.#accounts = this.#accounts.filter((a) => a.pubkey !== target);
801
1202
  this.#persistAccounts();
802
1203
  if (this.#activePubkey === target) {
1204
+ this.#closeActiveSigner();
803
1205
  this.#activePubkey = null;
804
1206
  this.#activeSigner = null;
805
1207
  this.#persistActive();
@@ -827,7 +1229,9 @@ function createSigner(config = {}) {
827
1229
  BunkerSigner,
828
1230
  ExtensionSigner,
829
1231
  LocalSigner,
1232
+ Nip55WebSigner,
830
1233
  Signer,
1234
+ browserNip55Transport,
831
1235
  bytesToHex,
832
1236
  connectWithBunkerUri,
833
1237
  createSigner,
@@ -838,6 +1242,7 @@ function createSigner(config = {}) {
838
1242
  hexToBytes,
839
1243
  initiateNostrConnect,
840
1244
  localStorageAdapter,
841
- loginWithAndroidSigner
1245
+ loginWithAndroidSigner,
1246
+ normalizeNip55Identifier
842
1247
  });
843
1248
  //# sourceMappingURL=index.cjs.map