@formstr/signer 0.2.1 → 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,6 +135,78 @@ 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
 
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.
209
+
136
210
  ## NIP-46 app identity (required for nostrconnect)
137
211
 
138
212
  The nostrconnect URI you generate must include a `name` (and ideally `url`/`image`) so remote signer apps can show the user *which app* is asking to pair. Without it:
@@ -189,7 +263,7 @@ const detach = attachLoginListeners(container, signer, {
189
263
  // later: detach();
190
264
  ```
191
265
 
192
- 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.
193
267
 
194
268
  ## Errors
195
269
 
@@ -197,11 +271,12 @@ All `loginWith*` methods reject with bare `Error` instances. Categories you can
197
271
 
198
272
  - **Validation** — empty passphrase, empty relays, malformed bunker URI.
199
273
  - **Wrong credential** — `loginWithNcryptsec` with a bad passphrase throws synchronously after decrypt.
200
- - **External denial** — extension/bunker/Android signer rejects the request.
201
- - **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.
202
276
  - **Configuration** —
203
277
  - `loginWithNostrConnect` throws if neither `appName` (in `createSigner`) nor `metadata.name` (per call) is set. See "NIP-46 app identity" above.
204
278
  - `loginWithAndroidSigner` / `listAndroidSignerApps` throws if no plugin is configured.
279
+ - `loginWithNip55Web` throws if the environment is not an Android browser with clipboard access.
205
280
 
206
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.
207
282
 
@@ -244,6 +319,7 @@ The UI ships with these class names. Override in your own CSS.
244
319
  | `.nostr-signer__tab--extension` | NIP-07 tab |
245
320
  | `.nostr-signer__tab--bunker` | NIP-46 bunker URI tab |
246
321
  | `.nostr-signer__tab--nostrconnect` | NIP-46 nostrconnect (QR) tab |
322
+ | `.nostr-signer__tab--nip55web` | NIP-55 browser/`nostrsigner` tab |
247
323
  | `.nostr-signer__tab--android` | NIP-55 Android tab |
248
324
 
249
325
  ### Panels
@@ -256,6 +332,7 @@ The UI ships with these class names. Override in your own CSS.
256
332
  | `.nostr-signer__panel--extension` | extension panel |
257
333
  | `.nostr-signer__panel--bunker` | bunker URI panel |
258
334
  | `.nostr-signer__panel--nostrconnect` | nostrconnect panel |
335
+ | `.nostr-signer__panel--nip55web` | NIP-55 browser/`nostrsigner` panel |
259
336
  | `.nostr-signer__panel--android` | Android signer panel |
260
337
  | `.nostr-signer__panel--created` | post-creation backup-the-ncryptsec panel |
261
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
@@ -388,37 +391,294 @@ function describeIdentifier(value) {
388
391
  const suffix = value.length > 12 ? "\u2026" : "";
389
392
  return `"${prefix}${suffix}" (length=${value.length})`;
390
393
  }
394
+ var HEX_PUBKEY_RE = /^[0-9a-f]{64}$/i;
391
395
  async function loginWithAndroidSigner(plugin, packageName) {
392
396
  if (packageName) {
393
397
  await plugin.setPackageName(packageName);
394
398
  }
395
- const { npub, package: pluginPackage } = await plugin.getPublicKey(packageName);
399
+ const { npub: rawIdentifier, package: pluginPackage } = await plugin.getPublicKey(packageName);
396
400
  const resolvedPackage = pluginPackage || packageName;
397
401
  if (!resolvedPackage) {
398
402
  throw new Error(
399
403
  "@formstr/signer: android signer did not return a package name and none was supplied"
400
404
  );
401
405
  }
406
+ const { pubkey, npub } = normalizeNip55Identifier(rawIdentifier);
407
+ return {
408
+ signer: new AndroidSigner(plugin, resolvedPackage, npub, pubkey),
409
+ pubkey,
410
+ npub,
411
+ packageName: resolvedPackage
412
+ };
413
+ }
414
+ function normalizeNip55Identifier(rawIdentifier) {
415
+ if (typeof rawIdentifier === "string" && HEX_PUBKEY_RE.test(rawIdentifier)) {
416
+ const pubkey = rawIdentifier.toLowerCase();
417
+ return { pubkey, npub: import_nostr_tools4.nip19.npubEncode(pubkey) };
418
+ }
402
419
  let decoded;
403
420
  try {
404
- decoded = import_nostr_tools4.nip19.decode(npub);
421
+ decoded = import_nostr_tools4.nip19.decode(rawIdentifier);
405
422
  } catch (e) {
406
423
  throw new Error(
407
- `@formstr/signer: android signer returned an undecodable identifier (got ${describeIdentifier(npub)}): ${e.message}`
424
+ `@formstr/signer: signer returned an undecodable identifier (got ${describeIdentifier(rawIdentifier)}): ${e.message}`
408
425
  );
409
426
  }
410
- if (decoded.type !== "npub") {
411
- throw new Error(
412
- `@formstr/signer: android signer returned a non-npub identifier (type=${decoded.type}, got ${describeIdentifier(npub)})`
413
- );
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) };
414
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() {
415
458
  return {
416
- signer: new AndroidSigner(plugin, resolvedPackage, npub, decoded.data),
417
- pubkey: decoded.data,
418
- npub,
419
- packageName: resolvedPackage
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
+ }
420
471
  };
421
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
+ }
557
+ throw new Error(
558
+ "@formstr/signer: NIP-55 web signing requires an Android browser with clipboard access (a signer app registering the `nostrsigner` scheme must be installed)"
559
+ );
560
+ }
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
+ };
422
682
 
423
683
  // src/core/signer.ts
424
684
  var ACCOUNTS_KEY = "accounts";
@@ -426,6 +686,7 @@ var ACTIVE_KEY = "active-pubkey";
426
686
  var Signer = class {
427
687
  #storage;
428
688
  #defaultAndroidPlugin;
689
+ #nip55WebTransport;
429
690
  #appMetadata;
430
691
  #accounts = [];
431
692
  #activePubkey = null;
@@ -434,6 +695,7 @@ var Signer = class {
434
695
  constructor(config = {}) {
435
696
  this.#storage = config.storage ?? localStorageAdapter(config.storageKeyPrefix);
436
697
  this.#defaultAndroidPlugin = config.androidSignerPlugin;
698
+ this.#nip55WebTransport = config.nip55WebTransport;
437
699
  this.#appMetadata = {
438
700
  name: config.appName,
439
701
  url: config.appUrl,
@@ -467,8 +729,28 @@ var Signer = class {
467
729
  else this.#accounts.push(account);
468
730
  this.#persistAccounts();
469
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
+ }
470
751
  #setActive(account, signer) {
471
752
  const wasDifferent = this.#activePubkey !== null && this.#activePubkey !== account.pubkey;
753
+ if (this.#activeSigner !== signer) this.#closeActiveSigner();
472
754
  this.#activePubkey = account.pubkey;
473
755
  this.#activeSigner = signer;
474
756
  this.#persistActive();
@@ -510,8 +792,8 @@ var Signer = class {
510
792
  if (!ncryptsec) throw new Error("loginWithNcryptsec: ncryptsec required");
511
793
  if (!passphrase) throw new Error("loginWithNcryptsec: passphrase required");
512
794
  const secretKey = decryptNcryptsec(ncryptsec, passphrase);
513
- const pubkey = (0, import_nostr_tools5.getPublicKey)(secretKey);
514
- 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);
515
797
  const account = { npub, pubkey, method: "ncryptsec", ncryptsec };
516
798
  this.#upsertAccount(account);
517
799
  this.#setActive(account, new LocalSigner(secretKey));
@@ -526,7 +808,7 @@ var Signer = class {
526
808
  async loginWithExtension() {
527
809
  const extension = new ExtensionSigner();
528
810
  const pubkey = await extension.getPublicKey();
529
- const npub = import_nostr_tools5.nip19.npubEncode(pubkey);
811
+ const npub = import_nostr_tools6.nip19.npubEncode(pubkey);
530
812
  const account = { npub, pubkey, method: "extension" };
531
813
  this.#upsertAccount(account);
532
814
  this.#setActive(account, extension);
@@ -543,7 +825,7 @@ var Signer = class {
543
825
  */
544
826
  async loginWithBunkerUri(uri, options = {}) {
545
827
  const result = await connectWithBunkerUri(uri, options);
546
- const npub = import_nostr_tools5.nip19.npubEncode(result.pubkey);
828
+ const npub = import_nostr_tools6.nip19.npubEncode(result.pubkey);
547
829
  const account = {
548
830
  npub,
549
831
  pubkey: result.pubkey,
@@ -595,7 +877,7 @@ var Signer = class {
595
877
  });
596
878
  options.onUri(init.uri);
597
879
  const result = await init.complete;
598
- const npub = import_nostr_tools5.nip19.npubEncode(result.pubkey);
880
+ const npub = import_nostr_tools6.nip19.npubEncode(result.pubkey);
599
881
  const account = {
600
882
  npub,
601
883
  pubkey: result.pubkey,
@@ -660,6 +942,69 @@ var Signer = class {
660
942
  this.#setActive(account, result.signer);
661
943
  return account;
662
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
+ }
663
1008
  /** Snapshot of every persisted account, in insertion order. */
664
1009
  listAccounts() {
665
1010
  return [...this.#accounts];
@@ -713,6 +1058,10 @@ var Signer = class {
713
1058
  * {@link loginWithAndroidSigner} performs and that — on Amber —
714
1059
  * surfaces as a permission prompt every cold start.
715
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
+ *
716
1065
  * - `ncryptsec`: returns `null`. There is no silent path — the user's
717
1066
  * passphrase isn't (and shouldn't be) persisted. The caller must
718
1067
  * drive the passphrase prompt and call {@link loginWithNcryptsec}.
@@ -761,6 +1110,14 @@ var Signer = class {
761
1110
  this.#setActive(account, signer);
762
1111
  return signer;
763
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
+ }
764
1121
  case "ncryptsec":
765
1122
  return null;
766
1123
  }
@@ -775,6 +1132,7 @@ var Signer = class {
775
1132
  async switchAccount(pubkey) {
776
1133
  const account = this.#accounts.find((a) => a.pubkey === pubkey);
777
1134
  if (!account) throw new Error(`switchAccount: no account for pubkey ${pubkey}`);
1135
+ this.#closeActiveSigner();
778
1136
  this.#activePubkey = pubkey;
779
1137
  this.#activeSigner = null;
780
1138
  this.#persistActive();
@@ -791,6 +1149,7 @@ var Signer = class {
791
1149
  this.#accounts = this.#accounts.filter((a) => a.pubkey !== target);
792
1150
  this.#persistAccounts();
793
1151
  if (this.#activePubkey === target) {
1152
+ this.#closeActiveSigner();
794
1153
  this.#activePubkey = null;
795
1154
  this.#activeSigner = null;
796
1155
  this.#persistActive();
@@ -818,7 +1177,9 @@ function createSigner(config = {}) {
818
1177
  BunkerSigner,
819
1178
  ExtensionSigner,
820
1179
  LocalSigner,
1180
+ Nip55WebSigner,
821
1181
  Signer,
1182
+ browserNip55Transport,
822
1183
  bytesToHex,
823
1184
  connectWithBunkerUri,
824
1185
  createSigner,
@@ -829,6 +1190,7 @@ function createSigner(config = {}) {
829
1190
  hexToBytes,
830
1191
  initiateNostrConnect,
831
1192
  localStorageAdapter,
832
- loginWithAndroidSigner
1193
+ loginWithAndroidSigner,
1194
+ normalizeNip55Identifier
833
1195
  });
834
1196
  //# sourceMappingURL=index.cjs.map