@formstr/signer 0.2.2 → 0.3.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/dist/index.js CHANGED
@@ -368,7 +368,7 @@ async function loginWithAndroidSigner(plugin, packageName) {
368
368
  "@formstr/signer: android signer did not return a package name and none was supplied"
369
369
  );
370
370
  }
371
- const { pubkey, npub } = normalizeAndroidIdentifier(rawIdentifier);
371
+ const { pubkey, npub } = normalizeNip55Identifier(rawIdentifier);
372
372
  return {
373
373
  signer: new AndroidSigner(plugin, resolvedPackage, npub, pubkey),
374
374
  pubkey,
@@ -376,7 +376,7 @@ async function loginWithAndroidSigner(plugin, packageName) {
376
376
  packageName: resolvedPackage
377
377
  };
378
378
  }
379
- function normalizeAndroidIdentifier(rawIdentifier) {
379
+ function normalizeNip55Identifier(rawIdentifier) {
380
380
  if (typeof rawIdentifier === "string" && HEX_PUBKEY_RE.test(rawIdentifier)) {
381
381
  const pubkey = rawIdentifier.toLowerCase();
382
382
  return { pubkey, npub: nip192.npubEncode(pubkey) };
@@ -386,16 +386,267 @@ function normalizeAndroidIdentifier(rawIdentifier) {
386
386
  decoded = nip192.decode(rawIdentifier);
387
387
  } catch (e) {
388
388
  throw new Error(
389
- `@formstr/signer: android signer returned an undecodable identifier (got ${describeIdentifier(rawIdentifier)}): ${e.message}`
389
+ `@formstr/signer: signer returned an undecodable identifier (got ${describeIdentifier(rawIdentifier)}): ${e.message}`
390
390
  );
391
391
  }
392
- if (decoded.type !== "npub") {
392
+ if (decoded.type === "npub") {
393
+ return { pubkey: decoded.data, npub: rawIdentifier };
394
+ }
395
+ if (decoded.type === "nprofile") {
396
+ const pubkey = decoded.data.pubkey;
397
+ return { pubkey, npub: nip192.npubEncode(pubkey) };
398
+ }
399
+ throw new Error(
400
+ `@formstr/signer: signer returned a non-pubkey identifier (type=${decoded.type}, got ${describeIdentifier(rawIdentifier)})`
401
+ );
402
+ }
403
+
404
+ // src/nip55Web.ts
405
+ import {
406
+ getEventHash as getEventHash2,
407
+ verifyEvent
408
+ } from "nostr-tools";
409
+ var DEFAULT_POLL_INTERVAL_MS = 500;
410
+ var DEFAULT_TIMEOUT_MS = 12e4;
411
+ var sentinelCounter = 0;
412
+ function makeSentinel() {
413
+ sentinelCounter += 1;
414
+ return `__formstr_nip55_sentinel_${Date.now()}_${sentinelCounter}__`;
415
+ }
416
+ function abortError() {
417
+ const error = new Error("@formstr/signer: NIP-55 request aborted");
418
+ error.name = "AbortError";
419
+ return error;
420
+ }
421
+ function isNativeShell() {
422
+ const cap = globalThis.Capacitor;
423
+ return typeof cap?.isNativePlatform === "function" && cap.isNativePlatform();
424
+ }
425
+ function browserNip55Transport() {
426
+ return {
427
+ isSupported() {
428
+ return typeof navigator !== "undefined" && !isNativeShell() && /Android/i.test(navigator.userAgent) && typeof navigator.clipboard?.readText === "function";
429
+ },
430
+ open(intent) {
431
+ window.open(intent, "_blank");
432
+ },
433
+ readClipboard() {
434
+ return navigator.clipboard.readText();
435
+ },
436
+ writeClipboard(text) {
437
+ return navigator.clipboard.writeText(text);
438
+ }
439
+ };
440
+ }
441
+ var Nip55WebSigner = class _Nip55WebSigner {
442
+ #transport;
443
+ #pollIntervalMs;
444
+ #timeoutMs;
445
+ #signal;
446
+ #debug;
447
+ #pending = null;
448
+ #pubkey = null;
449
+ constructor(options = {}) {
450
+ this.#transport = options.transport ?? browserNip55Transport();
451
+ this.#pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
452
+ this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
453
+ this.#signal = options.signal;
454
+ this.#debug = options.debug;
455
+ this.#pubkey = options.pubkey ?? null;
456
+ }
457
+ #log(message) {
458
+ this.#debug?.(message);
459
+ }
460
+ /** True when the configured transport can actually open a signer app. */
461
+ isSupported() {
462
+ return this.#transport.isSupported();
463
+ }
464
+ async getPublicKey() {
465
+ if (this.#pubkey !== null) return this.#pubkey;
466
+ this.#checkSupport();
467
+ const raw = await this.#request(_Nip55WebSigner.getPublicKeyIntent());
468
+ const { pubkey } = normalizeNip55Identifier(raw);
469
+ this.#pubkey = pubkey;
470
+ return pubkey;
471
+ }
472
+ async signEvent(event) {
473
+ this.#checkSupport();
474
+ const pubkey = this.#pubkey ?? await this.getPublicKey();
475
+ const unsigned = { ...event, pubkey };
476
+ const draftWithId = { ...unsigned, id: getEventHash2(unsigned) };
477
+ const sig = (await this.#request(_Nip55WebSigner.signEventIntent(draftWithId))).trim();
478
+ if (!/^[0-9a-f]{128}$/i.test(sig)) {
479
+ throw new Error(
480
+ "@formstr/signer: NIP-55 signer did not return a hex signature"
481
+ );
482
+ }
483
+ const signed = { ...draftWithId, sig: sig.toLowerCase() };
484
+ if (!verifyEvent(signed)) {
485
+ throw new Error("@formstr/signer: NIP-55 signer returned an invalid signature");
486
+ }
487
+ return signed;
488
+ }
489
+ async nip04Encrypt(peerPubkey, plaintext) {
490
+ this.#checkSupport();
491
+ return this.#request(_Nip55WebSigner.nip04EncryptIntent(peerPubkey, plaintext));
492
+ }
493
+ async nip04Decrypt(peerPubkey, ciphertext) {
494
+ this.#checkSupport();
495
+ return this.#request(_Nip55WebSigner.nip04DecryptIntent(peerPubkey, ciphertext));
496
+ }
497
+ async nip44Encrypt(peerPubkey, plaintext) {
498
+ this.#checkSupport();
499
+ return this.#request(_Nip55WebSigner.nip44EncryptIntent(peerPubkey, plaintext));
500
+ }
501
+ async nip44Decrypt(peerPubkey, ciphertext) {
502
+ this.#checkSupport();
503
+ return this.#request(_Nip55WebSigner.nip44DecryptIntent(peerPubkey, ciphertext));
504
+ }
505
+ /**
506
+ * Cancel any in-flight request and stop its clipboard poll. Subsequent
507
+ * operations still work — this is a teardown of live resources, not a
508
+ * permanent disable (the {@link Signer} calls it when replacing the
509
+ * active signer).
510
+ */
511
+ close() {
512
+ if (this.#pending) {
513
+ const pending = this.#pending;
514
+ this.#settle();
515
+ pending.reject(abortError());
516
+ }
517
+ }
518
+ #checkSupport() {
519
+ if (this.#transport.isSupported()) return;
520
+ if (isNativeShell()) {
521
+ throw new Error(
522
+ "@formstr/signer: the browser NIP-55 flow is not for native builds \u2014 use loginWithAndroidSigner() with the Capacitor plugin instead"
523
+ );
524
+ }
393
525
  throw new Error(
394
- `@formstr/signer: android signer returned a non-npub identifier (type=${decoded.type}, got ${describeIdentifier(rawIdentifier)})`
526
+ "@formstr/signer: NIP-55 web signing requires an Android browser with clipboard access (a signer app registering the `nostrsigner` scheme must be installed)"
395
527
  );
396
528
  }
397
- return { pubkey: decoded.data, npub: rawIdentifier };
398
- }
529
+ /** One poll tick: read the clipboard and settle if the signer answered. */
530
+ #poll = async (pending) => {
531
+ let text;
532
+ try {
533
+ text = await this.#transport.readClipboard();
534
+ } catch (error) {
535
+ this.#log(`clipboard read failed: ${error.message}`);
536
+ return;
537
+ }
538
+ if (this.#pending !== pending) return;
539
+ const trimmed = text.trim();
540
+ if (trimmed.length === 0) return;
541
+ if (pending.sentinel !== null && trimmed === pending.sentinel) return;
542
+ this.#log(`clipboard result (${trimmed.length} chars)`);
543
+ this.#settle();
544
+ pending.resolve(trimmed);
545
+ };
546
+ #request(intent) {
547
+ this.#checkAborted();
548
+ this.#cancelPending();
549
+ return new Promise((resolve, reject) => {
550
+ const pending = {
551
+ resolve,
552
+ reject,
553
+ sentinel: null,
554
+ poll: null,
555
+ timer: null,
556
+ onAbort: null
557
+ };
558
+ this.#pending = pending;
559
+ if (this.#signal) {
560
+ const onAbort = () => this.#fail(pending, abortError());
561
+ this.#signal.addEventListener("abort", onAbort);
562
+ pending.onAbort = onAbort;
563
+ }
564
+ if (this.#timeoutMs > 0) {
565
+ pending.timer = setTimeout(() => {
566
+ this.#fail(
567
+ pending,
568
+ new Error(
569
+ `@formstr/signer: NIP-55 request timed out after ${this.#timeoutMs}ms (the signer app never returned a result)`
570
+ )
571
+ );
572
+ }, this.#timeoutMs);
573
+ }
574
+ void (async () => {
575
+ try {
576
+ const sentinel = makeSentinel();
577
+ await this.#transport.writeClipboard(sentinel);
578
+ if (this.#pending === pending) pending.sentinel = sentinel;
579
+ this.#log("planted clipboard sentinel");
580
+ } catch (error) {
581
+ this.#log(`sentinel write failed: ${error.message}`);
582
+ }
583
+ if (this.#pending !== pending) return;
584
+ try {
585
+ this.#log(`opening signer app: ${intent.slice(0, 80)}\u2026`);
586
+ this.#transport.open(intent);
587
+ } catch (error) {
588
+ this.#fail(pending, error);
589
+ return;
590
+ }
591
+ pending.poll = setInterval(() => {
592
+ void this.#poll(pending);
593
+ }, this.#pollIntervalMs);
594
+ })();
595
+ });
596
+ }
597
+ /**
598
+ * Reject the current request. Every caller is cleared on settle — the
599
+ * timeout timer and abort listener are removed, and `open()` is
600
+ * synchronous — so `pending` is always the active request here.
601
+ */
602
+ #fail(pending, error) {
603
+ this.#settle();
604
+ pending.reject(error);
605
+ }
606
+ #settle() {
607
+ const pending = this.#pending;
608
+ this.#pending = null;
609
+ if (pending?.poll) clearInterval(pending.poll);
610
+ if (pending?.timer) clearTimeout(pending.timer);
611
+ if (pending?.onAbort && this.#signal) {
612
+ this.#signal.removeEventListener("abort", pending.onAbort);
613
+ }
614
+ }
615
+ #cancelPending() {
616
+ if (!this.#pending) return;
617
+ const pending = this.#pending;
618
+ this.#settle();
619
+ pending.reject(new Error("@formstr/signer: NIP-55 request superseded"));
620
+ }
621
+ #checkAborted() {
622
+ if (this.#signal?.aborted) throw abortError();
623
+ }
624
+ static getPublicKeyIntent() {
625
+ return "intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;end";
626
+ }
627
+ static signEventIntent(draft) {
628
+ return `intent:${encodeURIComponent(
629
+ JSON.stringify(draft)
630
+ )}#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=sign_event;end`;
631
+ }
632
+ static nip04EncryptIntent(peerPubkey, plaintext) {
633
+ return _Nip55WebSigner.#cryptoIntent("nip04_encrypt", peerPubkey, plaintext);
634
+ }
635
+ static nip04DecryptIntent(peerPubkey, ciphertext) {
636
+ return _Nip55WebSigner.#cryptoIntent("nip04_decrypt", peerPubkey, ciphertext);
637
+ }
638
+ static nip44EncryptIntent(peerPubkey, plaintext) {
639
+ return _Nip55WebSigner.#cryptoIntent("nip44_encrypt", peerPubkey, plaintext);
640
+ }
641
+ static nip44DecryptIntent(peerPubkey, ciphertext) {
642
+ return _Nip55WebSigner.#cryptoIntent("nip44_decrypt", peerPubkey, ciphertext);
643
+ }
644
+ static #cryptoIntent(type, peerPubkey, payload) {
645
+ return `intent:${encodeURIComponent(
646
+ payload
647
+ )}#Intent;scheme=nostrsigner;S.pubKey=${peerPubkey};S.compressionType=none;S.returnType=signature;S.type=${type};end`;
648
+ }
649
+ };
399
650
 
400
651
  // src/core/signer.ts
401
652
  var ACCOUNTS_KEY = "accounts";
@@ -403,6 +654,7 @@ var ACTIVE_KEY = "active-pubkey";
403
654
  var Signer = class {
404
655
  #storage;
405
656
  #defaultAndroidPlugin;
657
+ #nip55WebTransport;
406
658
  #appMetadata;
407
659
  #accounts = [];
408
660
  #activePubkey = null;
@@ -411,6 +663,7 @@ var Signer = class {
411
663
  constructor(config = {}) {
412
664
  this.#storage = config.storage ?? localStorageAdapter(config.storageKeyPrefix);
413
665
  this.#defaultAndroidPlugin = config.androidSignerPlugin;
666
+ this.#nip55WebTransport = config.nip55WebTransport;
414
667
  this.#appMetadata = {
415
668
  name: config.appName,
416
669
  url: config.appUrl,
@@ -444,8 +697,28 @@ var Signer = class {
444
697
  else this.#accounts.push(account);
445
698
  this.#persistAccounts();
446
699
  }
700
+ /**
701
+ * Release the currently-held signer, if it has a `close()`. Called
702
+ * whenever the active signer is replaced or cleared so live resources
703
+ * (bunker subscriptions, `visibilitychange` listeners, in-flight NIP-55
704
+ * requests) don't outlive the session. Errors are swallowed — teardown
705
+ * must never block a login/switch/logout.
706
+ */
707
+ #closeActiveSigner() {
708
+ const signer = this.#activeSigner;
709
+ if (!signer?.close) return;
710
+ try {
711
+ const result = signer.close();
712
+ if (result && typeof result.catch === "function") {
713
+ result.catch(() => {
714
+ });
715
+ }
716
+ } catch {
717
+ }
718
+ }
447
719
  #setActive(account, signer) {
448
720
  const wasDifferent = this.#activePubkey !== null && this.#activePubkey !== account.pubkey;
721
+ if (this.#activeSigner !== signer) this.#closeActiveSigner();
449
722
  this.#activePubkey = account.pubkey;
450
723
  this.#activeSigner = signer;
451
724
  this.#persistActive();
@@ -637,6 +910,69 @@ var Signer = class {
637
910
  this.#setActive(account, result.signer);
638
911
  return account;
639
912
  }
913
+ /**
914
+ * Whether `loginWithNip55Web` can run in this environment — a plain
915
+ * Android browser with async clipboard access. False in a Capacitor
916
+ * native shell (use {@link loginWithAndroidSigner} there), and on
917
+ * desktop/iOS/SSR.
918
+ *
919
+ * A **capability** check, not an availability one: there is no web API
920
+ * to detect an installed Android app, so this says nothing about
921
+ * whether a signer app is actually installed. Use it to hide the
922
+ * browser flow where it cannot work, not to promise that it will.
923
+ */
924
+ supportsNip55Web(transport) {
925
+ const t = transport ?? this.#nip55WebTransport ?? browserNip55Transport();
926
+ return t.isSupported();
927
+ }
928
+ /**
929
+ * Sign in via a NIP-55 Android external signer (Amber, etc) **from a
930
+ * plain browser**, with no Capacitor/native bridge. Opens the installed
931
+ * signer app through a `nostrsigner` intent and reads the result back
932
+ * from the clipboard once the user returns to the tab. Because the
933
+ * intent names no package, this works with any app that registered the
934
+ * `nostrsigner` scheme — one opens directly, several show the Android
935
+ * "Open with" chooser.
936
+ *
937
+ * Not for native builds — inside a Capacitor shell use
938
+ * {@link loginWithAndroidSigner}, which needs no clipboard and no
939
+ * per-operation approval.
940
+ *
941
+ * Every operation is a separate approval, and a rejection is
942
+ * indistinguishable from the user simply not returning, so callers must
943
+ * impose their own timeout. Prefer NIP-46 when a persistent session is
944
+ * acceptable — the NIP-55 spec recommends it for web clients.
945
+ *
946
+ * @throws if the environment cannot run the flow (not Android, no async
947
+ * clipboard) or the signer returns an unexpected value.
948
+ */
949
+ async loginWithNip55Web(options = {}) {
950
+ const transport = options.transport ?? this.#nip55WebTransport;
951
+ const pairing = new Nip55WebSigner({
952
+ transport,
953
+ pollIntervalMs: options.pollIntervalMs,
954
+ timeoutMs: options.timeoutMs,
955
+ signal: options.signal,
956
+ debug: options.debug
957
+ });
958
+ const pubkey = await pairing.getPublicKey();
959
+ const signer = new Nip55WebSigner({
960
+ transport,
961
+ pollIntervalMs: options.pollIntervalMs,
962
+ timeoutMs: options.timeoutMs,
963
+ pubkey,
964
+ debug: options.debug
965
+ });
966
+ const npub = nip193.npubEncode(pubkey);
967
+ const account = {
968
+ npub,
969
+ pubkey,
970
+ method: "nip55-web"
971
+ };
972
+ this.#upsertAccount(account);
973
+ this.#setActive(account, signer);
974
+ return account;
975
+ }
640
976
  /** Snapshot of every persisted account, in insertion order. */
641
977
  listAccounts() {
642
978
  return [...this.#accounts];
@@ -690,6 +1026,10 @@ var Signer = class {
690
1026
  * {@link loginWithAndroidSigner} performs and that — on Amber —
691
1027
  * surfaces as a permission prompt every cold start.
692
1028
  *
1029
+ * - `nip55-web`: constructs a {@link Nip55WebSigner} with the stored
1030
+ * `pubkey` cached. Like `android`, this opens no signer app during
1031
+ * unlock; the first sign/encrypt call is what prompts.
1032
+ *
693
1033
  * - `ncryptsec`: returns `null`. There is no silent path — the user's
694
1034
  * passphrase isn't (and shouldn't be) persisted. The caller must
695
1035
  * drive the passphrase prompt and call {@link loginWithNcryptsec}.
@@ -738,6 +1078,14 @@ var Signer = class {
738
1078
  this.#setActive(account, signer);
739
1079
  return signer;
740
1080
  }
1081
+ case "nip55-web": {
1082
+ const signer = new Nip55WebSigner({
1083
+ transport: this.#nip55WebTransport,
1084
+ pubkey: account.pubkey
1085
+ });
1086
+ this.#setActive(account, signer);
1087
+ return signer;
1088
+ }
741
1089
  case "ncryptsec":
742
1090
  return null;
743
1091
  }
@@ -752,6 +1100,7 @@ var Signer = class {
752
1100
  async switchAccount(pubkey) {
753
1101
  const account = this.#accounts.find((a) => a.pubkey === pubkey);
754
1102
  if (!account) throw new Error(`switchAccount: no account for pubkey ${pubkey}`);
1103
+ this.#closeActiveSigner();
755
1104
  this.#activePubkey = pubkey;
756
1105
  this.#activeSigner = null;
757
1106
  this.#persistActive();
@@ -768,6 +1117,7 @@ var Signer = class {
768
1117
  this.#accounts = this.#accounts.filter((a) => a.pubkey !== target);
769
1118
  this.#persistAccounts();
770
1119
  if (this.#activePubkey === target) {
1120
+ this.#closeActiveSigner();
771
1121
  this.#activePubkey = null;
772
1122
  this.#activeSigner = null;
773
1123
  this.#persistActive();
@@ -794,7 +1144,9 @@ export {
794
1144
  BunkerSigner,
795
1145
  ExtensionSigner,
796
1146
  LocalSigner,
1147
+ Nip55WebSigner,
797
1148
  Signer,
1149
+ browserNip55Transport,
798
1150
  bytesToHex,
799
1151
  connectWithBunkerUri,
800
1152
  createSigner,
@@ -805,6 +1157,7 @@ export {
805
1157
  hexToBytes,
806
1158
  initiateNostrConnect,
807
1159
  localStorageAdapter,
808
- loginWithAndroidSigner
1160
+ loginWithAndroidSigner,
1161
+ normalizeNip55Identifier
809
1162
  };
810
1163
  //# sourceMappingURL=index.js.map