@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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @formstr/signer
2
2
 
3
- A vanilla TypeScript Nostr signer with an optional unstyled login UI. Supports NIP-07 (browser extension), NIP-46 (bunker URI + nostrconnect QR), NIP-49 (ncryptsec at rest), and NIP-55 (Android external signer apps).
3
+ A vanilla TypeScript Nostr signer with an optional unstyled login UI. Supports NIP-07 (browser extension), NIP-46 (bunker URI + nostrconnect QR), NIP-49 (ncryptsec at rest), NIP-55 (Android external signer apps, via a Capacitor plugin **or** a plain browser), and a pure-web signer-app flow.
4
4
 
5
5
  ## Install
6
6
 
@@ -28,6 +28,7 @@ await signer.loginWithExtension();
28
28
  await signer.loginWithBunkerUri('bunker://...');
29
29
  await signer.loginWithNostrConnect({ relays: ['wss://relay.example'], onUri: (uri) => /* show QR */ });
30
30
  await signer.loginWithAndroidSigner({ packageName: 'com.greenart7c3.nostrsigner' });
31
+ await signer.loginWithNip55Web(); // Android browser, no Capacitor shell — see below
31
32
 
32
33
  // Sign events — the active signer never exposes the privkey
33
34
  const active = signer.getActiveSigner()!;
@@ -79,6 +80,7 @@ Per-method behavior:
79
80
  | `extension` | constructs `ExtensionSigner` (stateless wrapper around `window.nostr`) | no |
80
81
  | `nip46` | reuses persisted `clientSecretKey` + `remoteSignerPubkey` + `relays` to attach a `BunkerSigner` — **skips the `connect` request**, which is what triggers a fresh approval prompt every reload | no |
81
82
  | `android` | builds the `AndroidSigner` directly from cached `pubkey` + `npub` + `androidPackageName`, **bypassing the plugin's `getPublicKey` content-provider call** | no |
83
+ | `nip55-web` | builds a `Nip55WebSigner` from the cached `pubkey`; it does not open the signer app until the next sign/encrypt call | no |
82
84
  | `ncryptsec` | returns `null` — the passphrase is not (and must not be) persisted; caller drives the prompt and calls `loginWithNcryptsec(account.ncryptsec, passphrase)` | n/a (by design) |
83
85
 
84
86
  `unlock()` returns `null` (without emitting an event) when there is no active account, when the account is missing fields it needs to resume, when method is `nip46` but no `pool` was supplied, or when method is `android` but no plugin is configured. On success it emits the same `login`/`switch` event the corresponding `loginWith*` would.
@@ -133,7 +135,77 @@ const myPlugin: AndroidSignerPlugin = {
133
135
 
134
136
  The interface signatures intentionally mirror `nostr-signer-capacitor-plugin`'s exported `NostrSignerPlugin` so the real wrapper is directly assignable. If you write a custom plugin, the package's test suite includes a compile-time conformance guard (`tests/helpers/mockAndroidPlugin.ts`) you can model your own check on — wire it up in your CI and you'll catch any drift the moment the upstream wrapper changes shape.
135
137
 
136
- **Identifier shape.** The `npub` field returned by `getPublicKey` is permissive: the package accepts either a bech32 `npub1…` string (the NIP-55 spec shape) or a 32-byte hex pubkey (what current Amber builds actually return). Whichever you hand back, the package normalizes internally — `StoredAccount.npub` is always bech32 and `StoredAccount.pubkey` is always lowercase hex. Anything else surfaces as a debuggable error including a preview of what was received.
138
+ **Identifier shape.** The `npub` field returned by `getPublicKey` is permissive: the package accepts either a bech32 `npub1…` string (the NIP-55 spec shape) or a 32-byte hex pubkey (what current Amber builds actually return). Whichever you hand back, the package normalizes internally — `StoredAccount.npub` is always bech32 and `StoredAccount.pubkey` is always lowercase hex. Anything else surfaces as a debuggable error including a preview of what was received. The same normalization (plus `nprofile1…`) is shared with the browser flow below.
139
+
140
+ ## NIP-55 in a plain browser (`loginWithNip55Web`)
141
+
142
+ The Capacitor plugin above only works inside a native Android shell. `loginWithNip55Web()` covers the other case: a **plain browser on Android** (mobile web, a PWA) talking to any installed NIP-55 signer app with no native bridge.
143
+
144
+ ```ts
145
+ await signer.loginWithNip55Web();
146
+ ```
147
+
148
+ The mechanism:
149
+
150
+ 1. **Plant a sentinel.** The package overwrites the clipboard with a random `__formstr_nip55_sentinel_…__` string. This is what makes the result identifiable — otherwise a clipboard left over from an earlier approval would look like a fresh answer and resolve immediately.
151
+ 2. **Open the intent.** It opens `intent:#Intent;scheme=nostrsigner;S.type=…;end` via `window.open`. No package is named, so Android resolves it against every app that registered the scheme — one signer opens directly, several show the standard "Open with" chooser. This is not Amber-specific.
152
+ 3. **Poll the clipboard.** The signer app signs and copies the result to the clipboard. The package polls `navigator.clipboard.readText()` every `pollIntervalMs` (default 500ms) until the value differs from the sentinel, then resolves.
153
+
154
+ ### Why polling, not `visibilitychange`
155
+
156
+ The obvious design — read the clipboard when the user returns to the tab — **does not work on Android Chrome**, verified on a Pixel emulator running Amber 6.6.4:
157
+
158
+ - Returning from the signer app fires **no** `visibilitychange` or `focus` event. `window.open` produces a brief hide/show blip *before* the signer is even open (a `blur`, two `visibilitychange`s and a `focus` within ~400ms), and then nothing on the actual return. An event-driven read therefore never runs.
159
+ - Even when a read is attempted right after returning, Chrome rejects it with `NotAllowedError: Document is not focused` — the page is visible but not focused, and it does not regain focus on its own.
160
+ - `setInterval`, by contrast, keeps ticking while the tab is backgrounded, so polling observes the result regardless.
161
+
162
+ This is why the transport interface has `readClipboard`/`writeClipboard` but no foreground callback: the browser simply does not provide a reliable return signal.
163
+
164
+ ### Browser only — not for native builds
165
+
166
+ This flow is for a plain browser. Inside a Capacitor native shell the same
167
+ device already has the real NIP-55 plugin, which supports every method and
168
+ needs neither the clipboard nor a per-operation approval — so this path is
169
+ disabled there (Capacitor injects a `Capacitor.isNativePlatform()` global,
170
+ which the package checks without depending on `@capacitor/core`).
171
+
172
+ Gate your UI on `signer.supportsNip55Web()`:
173
+
174
+ ```ts
175
+ container.innerHTML = renderLoginHtml({
176
+ includeNip55Web: signer.supportsNip55Web(),
177
+ });
178
+ ```
179
+
180
+ In a native build that omits the tab and `loginWithNip55Web()` throws a
181
+ message pointing at `loginWithAndroidSigner()`.
182
+
183
+ `supportsNip55Web()` is a **capability** check, not an availability one:
184
+ there is no web API to detect an installed Android app, so it cannot tell
185
+ you whether a signer app is actually present. It answers "could this flow
186
+ possibly work here", not "will it succeed".
187
+
188
+ ### What to know
189
+
190
+ - **Android + secure context only.** `supportsNip55Web()` requires an Android user agent, the async clipboard API, and not being in a native shell. `loginWithNip55Web()` throws before persisting anything when unsupported. `http://localhost` counts as a secure context, which is handy for local testing.
191
+ - **Chrome will ask to read the clipboard** the first time; approve it, or every read fails.
192
+ - **One approval per operation.** There is no background channel, so `getPublicKey`, every `signEvent`, and every `nip04`/`nip44` call re-opens the app. `unlock()` resumes the account from its cached pubkey without opening the app; the first real signing call prompts.
193
+ - **No rejection signal, but there is a timeout.** NIP-55's reject path is an Android intent extra a browser never sees, so a denial is indistinguishable from the user never returning. The package therefore times requests out (`timeoutMs`, default 120s; `0` disables) and rejects with a clear message. Pass a `signal` to cancel yourself — aborting rejects with `name === 'AbortError'`, matching the NIP-46 flow.
194
+ - **Signatures are verified.** `signEvent` computes the event id, sends the complete unsigned event, then checks the returned 128-char hex signature with `verifyEvent` before returning it.
195
+ - **The clipboard is clobbered** by the sentinel write. This is inherent to the transport; warn users if your app cares about clipboard contents.
196
+ - **Prefer NIP-46 when you can.** The NIP-55 spec itself recommends NIP-46 for web clients precisely because this flow can't run in the background. Keep the browser NIP-55 path for users who want their existing signer app without a pairing step.
197
+
198
+ The environment bridge is pluggable for tests and unusual hosts:
199
+
200
+ ```ts
201
+ import type { Nip55WebTransport } from '@formstr/signer';
202
+
203
+ const s = createSigner({ nip55WebTransport: myTransport });
204
+ // or per call:
205
+ await s.loginWithNip55Web({ transport: myTransport });
206
+ ```
207
+
208
+ `browserNip55Transport()` is exported as the default implementation. `pollIntervalMs` (default 500) trades latency against how often the clipboard is read. `Nip55WebSigner` also exposes a `close()` that cancels an in-flight request and stops its poll; `Signer` calls it automatically when the active signer is replaced (a new login/unlock, `switchAccount`, or `logout`), so a pending request never leaks past its session.
137
209
 
138
210
  ## NIP-46 app identity (required for nostrconnect)
139
211
 
@@ -191,7 +263,7 @@ const detach = attachLoginListeners(container, signer, {
191
263
  // later: detach();
192
264
  ```
193
265
 
194
- The login modal renders one tab per method (Create, Existing key, Extension, Bunker URI, Remote QR, Android). The Android tab is always rendered but its list of installed signers is fetched lazily on activation via `signer.listAndroidSignerApps()` — it errors clearly if no Android plugin is configured (e.g. when running on web).
266
+ The login modal renders one tab per method (Create, Existing key, Extension, Bunker URI, Remote QR, Signer app, Android). The Android tab is always rendered but its list of installed signers is fetched lazily on activation via `signer.listAndroidSignerApps()` — it errors clearly if no Android plugin is configured (e.g. when running on web). The Signer app tab drives `loginWithNip55Web()` and needs no plugin; pass `renderLoginHtml({ includeNip55Web: signer.supportsNip55Web() })` to hide it in native builds.
195
267
 
196
268
  ## Errors
197
269
 
@@ -199,11 +271,12 @@ All `loginWith*` methods reject with bare `Error` instances. Categories you can
199
271
 
200
272
  - **Validation** — empty passphrase, empty relays, malformed bunker URI.
201
273
  - **Wrong credential** — `loginWithNcryptsec` with a bad passphrase throws synchronously after decrypt.
202
- - **External denial** — extension/bunker/Android signer rejects the request.
203
- - **Transport** — NIP-46 relay unreachable, pairing timeout, abort.
274
+ - **External denial** — extension/bunker/Android signer rejects the request. The browser NIP-55 flow has no denial signal (see its section), so pair it with a timeout.
275
+ - **Transport** — NIP-46 relay unreachable, pairing timeout, abort; browser NIP-55 empty/inaccessible clipboard, or an unverifiable signature.
204
276
  - **Configuration** —
205
277
  - `loginWithNostrConnect` throws if neither `appName` (in `createSigner`) nor `metadata.name` (per call) is set. See "NIP-46 app identity" above.
206
278
  - `loginWithAndroidSigner` / `listAndroidSignerApps` throws if no plugin is configured.
279
+ - `loginWithNip55Web` throws if the environment is not an Android browser with clipboard access.
207
280
 
208
281
  Error messages are prefixed with `@formstr/signer:` for messages the package generates itself. Errors from `nostr-tools` or the Capacitor plugin propagate unchanged. There is currently no typed `code` field — discriminate by string match or by which method threw.
209
282
 
@@ -246,6 +319,7 @@ The UI ships with these class names. Override in your own CSS.
246
319
  | `.nostr-signer__tab--extension` | NIP-07 tab |
247
320
  | `.nostr-signer__tab--bunker` | NIP-46 bunker URI tab |
248
321
  | `.nostr-signer__tab--nostrconnect` | NIP-46 nostrconnect (QR) tab |
322
+ | `.nostr-signer__tab--nip55web` | NIP-55 browser/`nostrsigner` tab |
249
323
  | `.nostr-signer__tab--android` | NIP-55 Android tab |
250
324
 
251
325
  ### Panels
@@ -258,6 +332,7 @@ The UI ships with these class names. Override in your own CSS.
258
332
  | `.nostr-signer__panel--extension` | extension panel |
259
333
  | `.nostr-signer__panel--bunker` | bunker URI panel |
260
334
  | `.nostr-signer__panel--nostrconnect` | nostrconnect panel |
335
+ | `.nostr-signer__panel--nip55web` | NIP-55 browser/`nostrsigner` panel |
261
336
  | `.nostr-signer__panel--android` | Android signer panel |
262
337
  | `.nostr-signer__panel--created` | post-creation backup-the-ncryptsec panel |
263
338
 
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,264 @@ 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 browserNip55Transport() {
458
+ return {
459
+ isSupported() {
460
+ return typeof navigator !== "undefined" && !isNativeShell() && /Android/i.test(navigator.userAgent) && typeof navigator.clipboard?.readText === "function";
461
+ },
462
+ open(intent) {
463
+ window.open(intent, "_blank");
464
+ },
465
+ readClipboard() {
466
+ return navigator.clipboard.readText();
467
+ },
468
+ writeClipboard(text) {
469
+ return navigator.clipboard.writeText(text);
470
+ }
471
+ };
472
+ }
473
+ var Nip55WebSigner = class _Nip55WebSigner {
474
+ #transport;
475
+ #pollIntervalMs;
476
+ #timeoutMs;
477
+ #signal;
478
+ #debug;
479
+ #pending = null;
480
+ #pubkey = null;
481
+ constructor(options = {}) {
482
+ this.#transport = options.transport ?? browserNip55Transport();
483
+ this.#pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
484
+ this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
485
+ this.#signal = options.signal;
486
+ this.#debug = options.debug;
487
+ this.#pubkey = options.pubkey ?? null;
488
+ }
489
+ #log(message) {
490
+ this.#debug?.(message);
491
+ }
492
+ /** True when the configured transport can actually open a signer app. */
493
+ isSupported() {
494
+ return this.#transport.isSupported();
495
+ }
496
+ async getPublicKey() {
497
+ if (this.#pubkey !== null) return this.#pubkey;
498
+ this.#checkSupport();
499
+ const raw = await this.#request(_Nip55WebSigner.getPublicKeyIntent());
500
+ const { pubkey } = normalizeNip55Identifier(raw);
501
+ this.#pubkey = pubkey;
502
+ return pubkey;
503
+ }
504
+ async signEvent(event) {
505
+ this.#checkSupport();
506
+ const pubkey = this.#pubkey ?? await this.getPublicKey();
507
+ const unsigned = { ...event, pubkey };
508
+ const draftWithId = { ...unsigned, id: (0, import_nostr_tools5.getEventHash)(unsigned) };
509
+ const sig = (await this.#request(_Nip55WebSigner.signEventIntent(draftWithId))).trim();
510
+ if (!/^[0-9a-f]{128}$/i.test(sig)) {
511
+ throw new Error(
512
+ "@formstr/signer: NIP-55 signer did not return a hex signature"
513
+ );
514
+ }
515
+ const signed = { ...draftWithId, sig: sig.toLowerCase() };
516
+ if (!(0, import_nostr_tools5.verifyEvent)(signed)) {
517
+ throw new Error("@formstr/signer: NIP-55 signer returned an invalid signature");
518
+ }
519
+ return signed;
520
+ }
521
+ async nip04Encrypt(peerPubkey, plaintext) {
522
+ this.#checkSupport();
523
+ return this.#request(_Nip55WebSigner.nip04EncryptIntent(peerPubkey, plaintext));
524
+ }
525
+ async nip04Decrypt(peerPubkey, ciphertext) {
526
+ this.#checkSupport();
527
+ return this.#request(_Nip55WebSigner.nip04DecryptIntent(peerPubkey, ciphertext));
528
+ }
529
+ async nip44Encrypt(peerPubkey, plaintext) {
530
+ this.#checkSupport();
531
+ return this.#request(_Nip55WebSigner.nip44EncryptIntent(peerPubkey, plaintext));
532
+ }
533
+ async nip44Decrypt(peerPubkey, ciphertext) {
534
+ this.#checkSupport();
535
+ return this.#request(_Nip55WebSigner.nip44DecryptIntent(peerPubkey, ciphertext));
536
+ }
537
+ /**
538
+ * Cancel any in-flight request and stop its clipboard poll. Subsequent
539
+ * operations still work — this is a teardown of live resources, not a
540
+ * permanent disable (the {@link Signer} calls it when replacing the
541
+ * active signer).
542
+ */
543
+ close() {
544
+ if (this.#pending) {
545
+ const pending = this.#pending;
546
+ this.#settle();
547
+ pending.reject(abortError());
548
+ }
549
+ }
550
+ #checkSupport() {
551
+ if (this.#transport.isSupported()) return;
552
+ if (isNativeShell()) {
553
+ throw new Error(
554
+ "@formstr/signer: the browser NIP-55 flow is not for native builds \u2014 use loginWithAndroidSigner() with the Capacitor plugin instead"
555
+ );
556
+ }
425
557
  throw new Error(
426
- `@formstr/signer: android signer returned a non-npub identifier (type=${decoded.type}, got ${describeIdentifier(rawIdentifier)})`
558
+ "@formstr/signer: NIP-55 web signing requires an Android browser with clipboard access (a signer app registering the `nostrsigner` scheme must be installed)"
427
559
  );
428
560
  }
429
- return { pubkey: decoded.data, npub: rawIdentifier };
430
- }
561
+ /** One poll tick: read the clipboard and settle if the signer answered. */
562
+ #poll = async (pending) => {
563
+ let text;
564
+ try {
565
+ text = await this.#transport.readClipboard();
566
+ } catch (error) {
567
+ this.#log(`clipboard read failed: ${error.message}`);
568
+ return;
569
+ }
570
+ if (this.#pending !== pending) return;
571
+ const trimmed = text.trim();
572
+ if (trimmed.length === 0) return;
573
+ if (pending.sentinel !== null && trimmed === pending.sentinel) return;
574
+ this.#log(`clipboard result (${trimmed.length} chars)`);
575
+ this.#settle();
576
+ pending.resolve(trimmed);
577
+ };
578
+ #request(intent) {
579
+ this.#checkAborted();
580
+ this.#cancelPending();
581
+ return new Promise((resolve, reject) => {
582
+ const pending = {
583
+ resolve,
584
+ reject,
585
+ sentinel: null,
586
+ poll: null,
587
+ timer: null,
588
+ onAbort: null
589
+ };
590
+ this.#pending = pending;
591
+ if (this.#signal) {
592
+ const onAbort = () => this.#fail(pending, abortError());
593
+ this.#signal.addEventListener("abort", onAbort);
594
+ pending.onAbort = onAbort;
595
+ }
596
+ if (this.#timeoutMs > 0) {
597
+ pending.timer = setTimeout(() => {
598
+ this.#fail(
599
+ pending,
600
+ new Error(
601
+ `@formstr/signer: NIP-55 request timed out after ${this.#timeoutMs}ms (the signer app never returned a result)`
602
+ )
603
+ );
604
+ }, this.#timeoutMs);
605
+ }
606
+ void (async () => {
607
+ try {
608
+ const sentinel = makeSentinel();
609
+ await this.#transport.writeClipboard(sentinel);
610
+ if (this.#pending === pending) pending.sentinel = sentinel;
611
+ this.#log("planted clipboard sentinel");
612
+ } catch (error) {
613
+ this.#log(`sentinel write failed: ${error.message}`);
614
+ }
615
+ if (this.#pending !== pending) return;
616
+ try {
617
+ this.#log(`opening signer app: ${intent.slice(0, 80)}\u2026`);
618
+ this.#transport.open(intent);
619
+ } catch (error) {
620
+ this.#fail(pending, error);
621
+ return;
622
+ }
623
+ pending.poll = setInterval(() => {
624
+ void this.#poll(pending);
625
+ }, this.#pollIntervalMs);
626
+ })();
627
+ });
628
+ }
629
+ /**
630
+ * Reject the current request. Every caller is cleared on settle — the
631
+ * timeout timer and abort listener are removed, and `open()` is
632
+ * synchronous — so `pending` is always the active request here.
633
+ */
634
+ #fail(pending, error) {
635
+ this.#settle();
636
+ pending.reject(error);
637
+ }
638
+ #settle() {
639
+ const pending = this.#pending;
640
+ this.#pending = null;
641
+ if (pending?.poll) clearInterval(pending.poll);
642
+ if (pending?.timer) clearTimeout(pending.timer);
643
+ if (pending?.onAbort && this.#signal) {
644
+ this.#signal.removeEventListener("abort", pending.onAbort);
645
+ }
646
+ }
647
+ #cancelPending() {
648
+ if (!this.#pending) return;
649
+ const pending = this.#pending;
650
+ this.#settle();
651
+ pending.reject(new Error("@formstr/signer: NIP-55 request superseded"));
652
+ }
653
+ #checkAborted() {
654
+ if (this.#signal?.aborted) throw abortError();
655
+ }
656
+ static getPublicKeyIntent() {
657
+ return "intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;end";
658
+ }
659
+ static signEventIntent(draft) {
660
+ return `intent:${encodeURIComponent(
661
+ JSON.stringify(draft)
662
+ )}#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=sign_event;end`;
663
+ }
664
+ static nip04EncryptIntent(peerPubkey, plaintext) {
665
+ return _Nip55WebSigner.#cryptoIntent("nip04_encrypt", peerPubkey, plaintext);
666
+ }
667
+ static nip04DecryptIntent(peerPubkey, ciphertext) {
668
+ return _Nip55WebSigner.#cryptoIntent("nip04_decrypt", peerPubkey, ciphertext);
669
+ }
670
+ static nip44EncryptIntent(peerPubkey, plaintext) {
671
+ return _Nip55WebSigner.#cryptoIntent("nip44_encrypt", peerPubkey, plaintext);
672
+ }
673
+ static nip44DecryptIntent(peerPubkey, ciphertext) {
674
+ return _Nip55WebSigner.#cryptoIntent("nip44_decrypt", peerPubkey, ciphertext);
675
+ }
676
+ static #cryptoIntent(type, peerPubkey, payload) {
677
+ return `intent:${encodeURIComponent(
678
+ payload
679
+ )}#Intent;scheme=nostrsigner;S.pubKey=${peerPubkey};S.compressionType=none;S.returnType=signature;S.type=${type};end`;
680
+ }
681
+ };
431
682
 
432
683
  // src/core/signer.ts
433
684
  var ACCOUNTS_KEY = "accounts";
@@ -435,6 +686,7 @@ var ACTIVE_KEY = "active-pubkey";
435
686
  var Signer = class {
436
687
  #storage;
437
688
  #defaultAndroidPlugin;
689
+ #nip55WebTransport;
438
690
  #appMetadata;
439
691
  #accounts = [];
440
692
  #activePubkey = null;
@@ -443,6 +695,7 @@ var Signer = class {
443
695
  constructor(config = {}) {
444
696
  this.#storage = config.storage ?? localStorageAdapter(config.storageKeyPrefix);
445
697
  this.#defaultAndroidPlugin = config.androidSignerPlugin;
698
+ this.#nip55WebTransport = config.nip55WebTransport;
446
699
  this.#appMetadata = {
447
700
  name: config.appName,
448
701
  url: config.appUrl,
@@ -476,8 +729,28 @@ var Signer = class {
476
729
  else this.#accounts.push(account);
477
730
  this.#persistAccounts();
478
731
  }
732
+ /**
733
+ * Release the currently-held signer, if it has a `close()`. Called
734
+ * whenever the active signer is replaced or cleared so live resources
735
+ * (bunker subscriptions, `visibilitychange` listeners, in-flight NIP-55
736
+ * requests) don't outlive the session. Errors are swallowed — teardown
737
+ * must never block a login/switch/logout.
738
+ */
739
+ #closeActiveSigner() {
740
+ const signer = this.#activeSigner;
741
+ if (!signer?.close) return;
742
+ try {
743
+ const result = signer.close();
744
+ if (result && typeof result.catch === "function") {
745
+ result.catch(() => {
746
+ });
747
+ }
748
+ } catch {
749
+ }
750
+ }
479
751
  #setActive(account, signer) {
480
752
  const wasDifferent = this.#activePubkey !== null && this.#activePubkey !== account.pubkey;
753
+ if (this.#activeSigner !== signer) this.#closeActiveSigner();
481
754
  this.#activePubkey = account.pubkey;
482
755
  this.#activeSigner = signer;
483
756
  this.#persistActive();
@@ -519,8 +792,8 @@ var Signer = class {
519
792
  if (!ncryptsec) throw new Error("loginWithNcryptsec: ncryptsec required");
520
793
  if (!passphrase) throw new Error("loginWithNcryptsec: passphrase required");
521
794
  const secretKey = decryptNcryptsec(ncryptsec, passphrase);
522
- const pubkey = (0, import_nostr_tools5.getPublicKey)(secretKey);
523
- const npub = import_nostr_tools5.nip19.npubEncode(pubkey);
795
+ const pubkey = (0, import_nostr_tools6.getPublicKey)(secretKey);
796
+ const npub = import_nostr_tools6.nip19.npubEncode(pubkey);
524
797
  const account = { npub, pubkey, method: "ncryptsec", ncryptsec };
525
798
  this.#upsertAccount(account);
526
799
  this.#setActive(account, new LocalSigner(secretKey));
@@ -535,7 +808,7 @@ var Signer = class {
535
808
  async loginWithExtension() {
536
809
  const extension = new ExtensionSigner();
537
810
  const pubkey = await extension.getPublicKey();
538
- const npub = import_nostr_tools5.nip19.npubEncode(pubkey);
811
+ const npub = import_nostr_tools6.nip19.npubEncode(pubkey);
539
812
  const account = { npub, pubkey, method: "extension" };
540
813
  this.#upsertAccount(account);
541
814
  this.#setActive(account, extension);
@@ -552,7 +825,7 @@ var Signer = class {
552
825
  */
553
826
  async loginWithBunkerUri(uri, options = {}) {
554
827
  const result = await connectWithBunkerUri(uri, options);
555
- const npub = import_nostr_tools5.nip19.npubEncode(result.pubkey);
828
+ const npub = import_nostr_tools6.nip19.npubEncode(result.pubkey);
556
829
  const account = {
557
830
  npub,
558
831
  pubkey: result.pubkey,
@@ -604,7 +877,7 @@ var Signer = class {
604
877
  });
605
878
  options.onUri(init.uri);
606
879
  const result = await init.complete;
607
- const npub = import_nostr_tools5.nip19.npubEncode(result.pubkey);
880
+ const npub = import_nostr_tools6.nip19.npubEncode(result.pubkey);
608
881
  const account = {
609
882
  npub,
610
883
  pubkey: result.pubkey,
@@ -669,6 +942,69 @@ var Signer = class {
669
942
  this.#setActive(account, result.signer);
670
943
  return account;
671
944
  }
945
+ /**
946
+ * Whether `loginWithNip55Web` can run in this environment — a plain
947
+ * Android browser with async clipboard access. False in a Capacitor
948
+ * native shell (use {@link loginWithAndroidSigner} there), and on
949
+ * desktop/iOS/SSR.
950
+ *
951
+ * A **capability** check, not an availability one: there is no web API
952
+ * to detect an installed Android app, so this says nothing about
953
+ * whether a signer app is actually installed. Use it to hide the
954
+ * browser flow where it cannot work, not to promise that it will.
955
+ */
956
+ supportsNip55Web(transport) {
957
+ const t = transport ?? this.#nip55WebTransport ?? browserNip55Transport();
958
+ return t.isSupported();
959
+ }
960
+ /**
961
+ * Sign in via a NIP-55 Android external signer (Amber, etc) **from a
962
+ * plain browser**, with no Capacitor/native bridge. Opens the installed
963
+ * signer app through a `nostrsigner` intent and reads the result back
964
+ * from the clipboard once the user returns to the tab. Because the
965
+ * intent names no package, this works with any app that registered the
966
+ * `nostrsigner` scheme — one opens directly, several show the Android
967
+ * "Open with" chooser.
968
+ *
969
+ * Not for native builds — inside a Capacitor shell use
970
+ * {@link loginWithAndroidSigner}, which needs no clipboard and no
971
+ * per-operation approval.
972
+ *
973
+ * Every operation is a separate approval, and a rejection is
974
+ * indistinguishable from the user simply not returning, so callers must
975
+ * impose their own timeout. Prefer NIP-46 when a persistent session is
976
+ * acceptable — the NIP-55 spec recommends it for web clients.
977
+ *
978
+ * @throws if the environment cannot run the flow (not Android, no async
979
+ * clipboard) or the signer returns an unexpected value.
980
+ */
981
+ async loginWithNip55Web(options = {}) {
982
+ const transport = options.transport ?? this.#nip55WebTransport;
983
+ const pairing = new Nip55WebSigner({
984
+ transport,
985
+ pollIntervalMs: options.pollIntervalMs,
986
+ timeoutMs: options.timeoutMs,
987
+ signal: options.signal,
988
+ debug: options.debug
989
+ });
990
+ const pubkey = await pairing.getPublicKey();
991
+ const signer = new Nip55WebSigner({
992
+ transport,
993
+ pollIntervalMs: options.pollIntervalMs,
994
+ timeoutMs: options.timeoutMs,
995
+ pubkey,
996
+ debug: options.debug
997
+ });
998
+ const npub = import_nostr_tools6.nip19.npubEncode(pubkey);
999
+ const account = {
1000
+ npub,
1001
+ pubkey,
1002
+ method: "nip55-web"
1003
+ };
1004
+ this.#upsertAccount(account);
1005
+ this.#setActive(account, signer);
1006
+ return account;
1007
+ }
672
1008
  /** Snapshot of every persisted account, in insertion order. */
673
1009
  listAccounts() {
674
1010
  return [...this.#accounts];
@@ -722,6 +1058,10 @@ var Signer = class {
722
1058
  * {@link loginWithAndroidSigner} performs and that — on Amber —
723
1059
  * surfaces as a permission prompt every cold start.
724
1060
  *
1061
+ * - `nip55-web`: constructs a {@link Nip55WebSigner} with the stored
1062
+ * `pubkey` cached. Like `android`, this opens no signer app during
1063
+ * unlock; the first sign/encrypt call is what prompts.
1064
+ *
725
1065
  * - `ncryptsec`: returns `null`. There is no silent path — the user's
726
1066
  * passphrase isn't (and shouldn't be) persisted. The caller must
727
1067
  * drive the passphrase prompt and call {@link loginWithNcryptsec}.
@@ -770,6 +1110,14 @@ var Signer = class {
770
1110
  this.#setActive(account, signer);
771
1111
  return signer;
772
1112
  }
1113
+ case "nip55-web": {
1114
+ const signer = new Nip55WebSigner({
1115
+ transport: this.#nip55WebTransport,
1116
+ pubkey: account.pubkey
1117
+ });
1118
+ this.#setActive(account, signer);
1119
+ return signer;
1120
+ }
773
1121
  case "ncryptsec":
774
1122
  return null;
775
1123
  }
@@ -784,6 +1132,7 @@ var Signer = class {
784
1132
  async switchAccount(pubkey) {
785
1133
  const account = this.#accounts.find((a) => a.pubkey === pubkey);
786
1134
  if (!account) throw new Error(`switchAccount: no account for pubkey ${pubkey}`);
1135
+ this.#closeActiveSigner();
787
1136
  this.#activePubkey = pubkey;
788
1137
  this.#activeSigner = null;
789
1138
  this.#persistActive();
@@ -800,6 +1149,7 @@ var Signer = class {
800
1149
  this.#accounts = this.#accounts.filter((a) => a.pubkey !== target);
801
1150
  this.#persistAccounts();
802
1151
  if (this.#activePubkey === target) {
1152
+ this.#closeActiveSigner();
803
1153
  this.#activePubkey = null;
804
1154
  this.#activeSigner = null;
805
1155
  this.#persistActive();
@@ -827,7 +1177,9 @@ function createSigner(config = {}) {
827
1177
  BunkerSigner,
828
1178
  ExtensionSigner,
829
1179
  LocalSigner,
1180
+ Nip55WebSigner,
830
1181
  Signer,
1182
+ browserNip55Transport,
831
1183
  bytesToHex,
832
1184
  connectWithBunkerUri,
833
1185
  createSigner,
@@ -838,6 +1190,7 @@ function createSigner(config = {}) {
838
1190
  hexToBytes,
839
1191
  initiateNostrConnect,
840
1192
  localStorageAdapter,
841
- loginWithAndroidSigner
1193
+ loginWithAndroidSigner,
1194
+ normalizeNip55Identifier
842
1195
  });
843
1196
  //# sourceMappingURL=index.cjs.map