@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.
@@ -68,6 +68,197 @@ interface AndroidLoginResult {
68
68
  packageName: string;
69
69
  }
70
70
  declare function loginWithAndroidSigner(plugin: AndroidSignerPlugin, packageName?: string): Promise<AndroidLoginResult>;
71
+ /**
72
+ * Normalize whatever a NIP-55 transport handed us in the pubkey slot into a
73
+ * `{ pubkey, npub }` pair. Accepts:
74
+ * - a 32-byte lowercase hex pubkey (newer Amber builds return this),
75
+ * - a bech32 `npub1…` (the original NIP-55 spec shape),
76
+ * - a bech32 `nprofile1…`, which the web/clipboard transport may return.
77
+ * Throws a debuggable error for anything else, including the preview of
78
+ * what was actually received so the caller can triage.
79
+ *
80
+ * Shared by the Capacitor (`loginWithAndroidSigner`) and pure-web
81
+ * (`nip55Web.ts`) flows — both transports are NIP-55 and must agree on
82
+ * how identifiers normalize.
83
+ */
84
+ declare function normalizeNip55Identifier(rawIdentifier: unknown): {
85
+ pubkey: string;
86
+ npub: string;
87
+ };
88
+
89
+ /**
90
+ * NIP-55 over the web, without a Capacitor/native bridge.
91
+ *
92
+ * A plain browser page cannot call an Android signer in the background, so
93
+ * this transport hands the OS a `nostrsigner` intent and then reads the
94
+ * result back from the **clipboard** once the user returns to the tab. It
95
+ * is the mechanism used by applesauce's `AmberClipboardSigner` and
96
+ * gitworkshop's "Use Amber" button; no package is named in the intent, so
97
+ * Android resolves it against whatever app registered the scheme — one
98
+ * installed signer opens directly, several show the "Open with" chooser.
99
+ *
100
+ * The alternative NIP-55 web path — `?callbackUrl=` — is not used here: it
101
+ * navigates the page away and back, which cannot be hidden behind a
102
+ * promise-returning {@link ActiveSigner}. Use NIP-46 when a persistent
103
+ * session is wanted; the spec recommends it for web clients.
104
+ *
105
+ * @see https://github.com/nostr-protocol/nips/blob/master/55.md
106
+ */
107
+ /** Why the browser NIP-55 flow cannot run here. */
108
+ type Nip55WebSupportReason = 'native' | 'not-android' | 'no-clipboard' | 'firefox';
109
+ /**
110
+ * Whether the browser NIP-55 option should be shown, plus an advisory
111
+ * warning where it is known to be flaky.
112
+ *
113
+ * Visibility and warnings are deliberately separate, and nothing is
114
+ * hard-blocked. The option is hidden only where the mechanism cannot exist
115
+ * at all — a Capacitor native shell (the plugin path is strictly better)
116
+ * and non-Android platforms (the `nostrsigner` intent cannot resolve).
117
+ *
118
+ * Firefox for Android is **shown with a warning** rather than blocked. It
119
+ * advertises `readText` but never grants a persistent permission, so every
120
+ * read needs transient activation and the poll loop cannot complete —
121
+ * verified on an emulator (Firefox 125). We still let the user try, because
122
+ * blocking a browser outright is a worse failure than a warning, and
123
+ * browser behaviour changes.
124
+ */
125
+ interface Nip55WebSupport {
126
+ /** Render the option at all. False in native shells and on non-Android. */
127
+ visible: boolean;
128
+ /**
129
+ * Advisory, non-blocking message to show beside the option. The flow
130
+ * remains attemptable; this only sets expectations.
131
+ */
132
+ warning?: string;
133
+ /** Machine-readable reason for the warning / hidden state. */
134
+ reason?: Nip55WebSupportReason;
135
+ }
136
+ interface Nip55WebTransport {
137
+ /**
138
+ * Whether this environment can run the intent + clipboard flow at all.
139
+ * The package's browser transport requires an Android user agent and the
140
+ * async clipboard API; anything else must fail loudly rather than open a
141
+ * dead intent.
142
+ */
143
+ isSupported(): boolean;
144
+ /**
145
+ * Richer form of {@link isSupported}: whether to *offer* the option and
146
+ * whether it actually works. Hosts should prefer this so they can show an
147
+ * explanatory hint (e.g. "does not work on Firefox") rather than hide a
148
+ * capability that other browsers on the same device do have.
149
+ */
150
+ supportStatus?(): Nip55WebSupport;
151
+ /** Hand the intent URI to the OS (a browser typically `window.open`s it). */
152
+ open(intent: string): void;
153
+ /** Read the current clipboard text. */
154
+ readClipboard(): Promise<string>;
155
+ /**
156
+ * Overwrite the clipboard. Used to plant a sentinel before opening the
157
+ * signer, so the result can be told apart from whatever the user had
158
+ * copied earlier. Best-effort — a failure just means we fall back to
159
+ * accepting the first non-empty read.
160
+ */
161
+ writeClipboard(text: string): Promise<void>;
162
+ }
163
+ interface Nip55WebOptions {
164
+ /**
165
+ * Environment bridge. Defaults to a browser implementation
166
+ * ({@link browserNip55Transport}). Tests inject a fake.
167
+ */
168
+ transport?: Nip55WebTransport;
169
+ /**
170
+ * Cached user pubkey. When supplied, {@link Nip55WebSigner.getPublicKey}
171
+ * returns it without a signer roundtrip — used on cold start to avoid a
172
+ * fresh `get_public_key` approval prompt for an already-paired account.
173
+ */
174
+ pubkey?: string;
175
+ /**
176
+ * How often to poll the clipboard while waiting for the signer app to
177
+ * return a result. Default 500ms.
178
+ *
179
+ * Polling, not events: on Android Chrome the return from the signer app
180
+ * fires **no** `visibilitychange`/`focus` event at all (the tab was never
181
+ * reported hidden), so an event-driven read simply never happens.
182
+ * `setInterval` keeps running while the tab is backgrounded, so polling
183
+ * is what actually observes the result.
184
+ */
185
+ pollIntervalMs?: number;
186
+ /**
187
+ * Max time to wait for the signer app to return a result. A rejection is
188
+ * invisible over NIP-55 web (the app just never calls back), so without
189
+ * this a denied or abandoned request hangs forever. Default 120000ms;
190
+ * `0` disables the timeout.
191
+ */
192
+ timeoutMs?: number;
193
+ /**
194
+ * Lifetime abort. Aborts the in-flight request and makes every future
195
+ * request reject immediately (rejections carry `name === 'AbortError'`,
196
+ * matching the NIP-46 flow).
197
+ *
198
+ * This is a property of the *signer*, not of one attempt. Do **not**
199
+ * pass a controller that is aborted once a login modal closes — that
200
+ * would leave the signer permanently unusable. To cancel a single
201
+ * pairing attempt, use {@link Signer.loginWithNip55Web}'s `signal`.
202
+ */
203
+ signal?: AbortSignal;
204
+ /**
205
+ * Diagnostic sink for the intent/clipboard round-trip. The flow is hard
206
+ * to observe (it leaves the page and returns), so a host can pass a
207
+ * logger — e.g. to an on-screen list — to see what actually happened.
208
+ */
209
+ debug?: (message: string) => void;
210
+ }
211
+ /**
212
+ * Browser transport: `window.open` for the intent, `navigator.clipboard`
213
+ * for the result. Globals are read lazily so importing this module stays
214
+ * safe in Node.
215
+ *
216
+ * This transport is for a **plain browser only**. Inside a Capacitor
217
+ * native shell the same device has the real NIP-55 plugin, which supports
218
+ * every method and does not need the clipboard or a per-operation
219
+ * approval, so this flow is not offered there — a native host should use
220
+ * `loginWithAndroidSigner` instead.
221
+ */
222
+ declare function browserNip55Transport(): Nip55WebTransport;
223
+ /**
224
+ * {@link ActiveSigner} backed by any installed NIP-55 signer app, used from
225
+ * a plain (Android) browser via intents + clipboard.
226
+ *
227
+ * Every operation is a separate user approval — there is no background
228
+ * channel and no way to learn that the user rejected a request, so callers
229
+ * must impose their own timeout (the flow simply never resolves otherwise).
230
+ */
231
+ declare class Nip55WebSigner implements ActiveSigner {
232
+ #private;
233
+ constructor(options?: Nip55WebOptions);
234
+ /** True when the configured transport can actually open a signer app. */
235
+ isSupported(): boolean;
236
+ /**
237
+ * Whether to offer the option and whether it works. Prefer this over
238
+ * {@link isSupported} so a host can surface `message` — notably the
239
+ * Firefox case, which is shown but cannot complete.
240
+ */
241
+ supportStatus(): Nip55WebSupport;
242
+ getPublicKey(): Promise<string>;
243
+ signEvent(event: EventTemplate): Promise<Event>;
244
+ nip04Encrypt(peerPubkey: string, plaintext: string): Promise<string>;
245
+ nip04Decrypt(peerPubkey: string, ciphertext: string): Promise<string>;
246
+ nip44Encrypt(peerPubkey: string, plaintext: string): Promise<string>;
247
+ nip44Decrypt(peerPubkey: string, ciphertext: string): Promise<string>;
248
+ /**
249
+ * Cancel any in-flight request and stop its clipboard poll. Subsequent
250
+ * operations still work — this is a teardown of live resources, not a
251
+ * permanent disable (the {@link Signer} calls it when replacing the
252
+ * active signer).
253
+ */
254
+ close(): void;
255
+ static getPublicKeyIntent(): string;
256
+ static signEventIntent(draft: object): string;
257
+ static nip04EncryptIntent(peerPubkey: string, plaintext: string): string;
258
+ static nip04DecryptIntent(peerPubkey: string, ciphertext: string): string;
259
+ static nip44EncryptIntent(peerPubkey: string, plaintext: string): string;
260
+ static nip44DecryptIntent(peerPubkey: string, ciphertext: string): string;
261
+ }
71
262
 
72
263
  interface StorageAdapter {
73
264
  get(key: string): string | null;
@@ -82,8 +273,10 @@ declare function localStorageAdapter(prefix?: string): StorageAdapter;
82
273
  * - `nip46`: NIP-46 remote signer (bunker URI or nostrconnect QR).
83
274
  * - `ncryptsec`: NIP-49 encrypted nsec — decrypted into memory on unlock.
84
275
  * - `android`: NIP-55 Android external signer app via a Capacitor plugin.
276
+ * - `nip55-web`: NIP-55 Android external signer app from a plain browser,
277
+ * via `nostrsigner` intents + clipboard (no native bridge).
85
278
  */
86
- type LoginMethod = 'extension' | 'nip46' | 'ncryptsec' | 'android';
279
+ type LoginMethod = 'extension' | 'nip46' | 'ncryptsec' | 'android' | 'nip55-web';
87
280
  /**
88
281
  * Serialized account record persisted by the {@link StorageAdapter}.
89
282
  *
@@ -142,6 +335,14 @@ interface ActiveSigner {
142
335
  nip04Decrypt(peerPubkey: string, ciphertext: string): Promise<string>;
143
336
  nip44Encrypt(peerPubkey: string, plaintext: string): Promise<string>;
144
337
  nip44Decrypt(peerPubkey: string, ciphertext: string): Promise<string>;
338
+ /**
339
+ * Optional teardown, called by {@link Signer} when this signer is
340
+ * replaced — on a subsequent login/unlock, `switchAccount`, or
341
+ * `logout`. Implementations with live resources (an open bunker relay
342
+ * subscription, a clipboard poll, an in-flight request) should release
343
+ * them here. Omitted by stateless signers.
344
+ */
345
+ close?(): void | Promise<void>;
145
346
  }
146
347
  interface RelayMismatchInfo {
147
348
  userRelays: string[];
@@ -182,6 +383,33 @@ interface UnlockOptions {
182
383
  */
183
384
  pool?: AbstractSimplePool;
184
385
  }
386
+ /**
387
+ * Options for {@link Signer.loginWithNip55Web} — the pure-browser NIP-55
388
+ * flow (Android intents + clipboard, no Capacitor bridge).
389
+ */
390
+ interface Nip55WebLoginOptions {
391
+ /**
392
+ * Environment bridge for this call. Falls back to
393
+ * {@link SignerConfig.nip55WebTransport}, then to the browser default.
394
+ */
395
+ transport?: Nip55WebTransport;
396
+ /**
397
+ * How often to poll the clipboard while waiting for the signer app.
398
+ * Default 500ms. Polling is required because returning from the signer
399
+ * fires no foreground event on Android Chrome.
400
+ */
401
+ pollIntervalMs?: number;
402
+ /**
403
+ * Max time to wait for the signer app. NIP-55 web has no rejection
404
+ * signal, so a denied/abandoned request never resolves without this.
405
+ * Default 120000ms; `0` disables it.
406
+ */
407
+ timeoutMs?: number;
408
+ /** Abort the pairing request (rejects with `name === 'AbortError'`). */
409
+ signal?: AbortSignal;
410
+ /** Diagnostic sink for the intent/clipboard round-trip. */
411
+ debug?: (message: string) => void;
412
+ }
185
413
  interface NostrConnectOptions {
186
414
  relays: string[];
187
415
  metadata?: {
@@ -251,6 +479,12 @@ interface SignerConfig {
251
479
  * or `listAndroidSignerApps(plugin)`.
252
480
  */
253
481
  androidSignerPlugin?: AndroidSignerPlugin;
482
+ /**
483
+ * Environment bridge for the pure-web NIP-55 flow (Android browser,
484
+ * intents + clipboard). Defaults to a browser implementation; supply a
485
+ * stub in tests or non-browser hosts.
486
+ */
487
+ nip55WebTransport?: Nip55WebTransport;
254
488
  }
255
489
 
256
490
  /**
@@ -346,6 +580,52 @@ declare class Signer {
346
580
  * resolved to a package name, or the user denies the request.
347
581
  */
348
582
  loginWithAndroidSigner(options?: AndroidLoginOptions): Promise<StoredAccount>;
583
+ /**
584
+ * Whether `loginWithNip55Web` can run in this environment — a plain
585
+ * Android browser with async clipboard access. False in a Capacitor
586
+ * native shell (use {@link loginWithAndroidSigner} there), and on
587
+ * desktop/iOS/SSR.
588
+ *
589
+ * A **capability** check, not an availability one: there is no web API
590
+ * to detect an installed Android app, so this says nothing about
591
+ * whether a signer app is actually installed. Use it to hide the
592
+ * browser flow where it cannot work, not to promise that it will.
593
+ */
594
+ supportsNip55Web(transport?: Nip55WebTransport): boolean;
595
+ /**
596
+ * Whether to offer the browser NIP-55 option, plus an advisory `warning`
597
+ * where it is known to be flaky. Prefer this in UI code.
598
+ *
599
+ * Nothing here is a hard block. `visible: false` covers only environments
600
+ * where the mechanism cannot exist — a Capacitor native shell (the plugin
601
+ * path is better) and non-Android platforms (no `nostrsigner` handler).
602
+ * Firefox for Android is `visible: true` **with a warning**, because it
603
+ * advertises `readText` but never grants a persistent permission, so the
604
+ * poll loop usually cannot complete. Users are still allowed to try.
605
+ */
606
+ nip55WebSupport(transport?: Nip55WebTransport): Nip55WebSupport;
607
+ /**
608
+ * Sign in via a NIP-55 Android external signer (Amber, etc) **from a
609
+ * plain browser**, with no Capacitor/native bridge. Opens the installed
610
+ * signer app through a `nostrsigner` intent and reads the result back
611
+ * from the clipboard once the user returns to the tab. Because the
612
+ * intent names no package, this works with any app that registered the
613
+ * `nostrsigner` scheme — one opens directly, several show the Android
614
+ * "Open with" chooser.
615
+ *
616
+ * Not for native builds — inside a Capacitor shell use
617
+ * {@link loginWithAndroidSigner}, which needs no clipboard and no
618
+ * per-operation approval.
619
+ *
620
+ * Every operation is a separate approval, and a rejection is
621
+ * indistinguishable from the user simply not returning, so callers must
622
+ * impose their own timeout. Prefer NIP-46 when a persistent session is
623
+ * acceptable — the NIP-55 spec recommends it for web clients.
624
+ *
625
+ * @throws if the environment cannot run the flow (not Android, no async
626
+ * clipboard) or the signer returns an unexpected value.
627
+ */
628
+ loginWithNip55Web(options?: Nip55WebLoginOptions): Promise<StoredAccount>;
349
629
  /** Snapshot of every persisted account, in insertion order. */
350
630
  listAccounts(): StoredAccount[];
351
631
  /**
@@ -392,6 +672,10 @@ declare class Signer {
392
672
  * {@link loginWithAndroidSigner} performs and that — on Amber —
393
673
  * surfaces as a permission prompt every cold start.
394
674
  *
675
+ * - `nip55-web`: constructs a {@link Nip55WebSigner} with the stored
676
+ * `pubkey` cached. Like `android`, this opens no signer app during
677
+ * unlock; the first sign/encrypt call is what prompts.
678
+ *
395
679
  * - `ncryptsec`: returns `null`. There is no silent path — the user's
396
680
  * passphrase isn't (and shouldn't be) persisted. The caller must
397
681
  * drive the passphrase prompt and call {@link loginWithNcryptsec}.
@@ -428,4 +712,4 @@ declare class Signer {
428
712
  /** Convenience wrapper around `new Signer(config)`. */
429
713
  declare function createSigner(config?: SignerConfig): Signer;
430
714
 
431
- export { type ActiveSigner as A, type BunkerLoginOptions as B, type LoginMethod as L, type NostrConnectOptions as N, type RelayMismatchHandler as R, Signer as S, type UnlockOptions as U, type AndroidLoginOptions as a, type AndroidLoginResult as b, AndroidSigner as c, type AndroidSignerAppInfo as d, type AndroidSignerPlugin as e, type RelayMismatchInfo as f, type SignerConfig as g, type SignerEvent as h, type StorageAdapter as i, type StoredAccount as j, createSigner as k, localStorageAdapter as l, loginWithAndroidSigner as m };
715
+ export { type ActiveSigner as A, type BunkerLoginOptions as B, type LoginMethod as L, type Nip55WebLoginOptions as N, type RelayMismatchHandler as R, Signer as S, type UnlockOptions as U, type AndroidLoginOptions as a, type AndroidLoginResult as b, AndroidSigner as c, type AndroidSignerAppInfo as d, type AndroidSignerPlugin as e, type Nip55WebOptions as f, Nip55WebSigner as g, type Nip55WebSupport as h, type Nip55WebSupportReason as i, type Nip55WebTransport as j, type NostrConnectOptions as k, type RelayMismatchInfo as l, type SignerConfig as m, type SignerEvent as n, type StorageAdapter as o, type StoredAccount as p, browserNip55Transport as q, createSigner as r, localStorageAdapter as s, loginWithAndroidSigner as t, normalizeNip55Identifier as u };
@@ -68,6 +68,197 @@ interface AndroidLoginResult {
68
68
  packageName: string;
69
69
  }
70
70
  declare function loginWithAndroidSigner(plugin: AndroidSignerPlugin, packageName?: string): Promise<AndroidLoginResult>;
71
+ /**
72
+ * Normalize whatever a NIP-55 transport handed us in the pubkey slot into a
73
+ * `{ pubkey, npub }` pair. Accepts:
74
+ * - a 32-byte lowercase hex pubkey (newer Amber builds return this),
75
+ * - a bech32 `npub1…` (the original NIP-55 spec shape),
76
+ * - a bech32 `nprofile1…`, which the web/clipboard transport may return.
77
+ * Throws a debuggable error for anything else, including the preview of
78
+ * what was actually received so the caller can triage.
79
+ *
80
+ * Shared by the Capacitor (`loginWithAndroidSigner`) and pure-web
81
+ * (`nip55Web.ts`) flows — both transports are NIP-55 and must agree on
82
+ * how identifiers normalize.
83
+ */
84
+ declare function normalizeNip55Identifier(rawIdentifier: unknown): {
85
+ pubkey: string;
86
+ npub: string;
87
+ };
88
+
89
+ /**
90
+ * NIP-55 over the web, without a Capacitor/native bridge.
91
+ *
92
+ * A plain browser page cannot call an Android signer in the background, so
93
+ * this transport hands the OS a `nostrsigner` intent and then reads the
94
+ * result back from the **clipboard** once the user returns to the tab. It
95
+ * is the mechanism used by applesauce's `AmberClipboardSigner` and
96
+ * gitworkshop's "Use Amber" button; no package is named in the intent, so
97
+ * Android resolves it against whatever app registered the scheme — one
98
+ * installed signer opens directly, several show the "Open with" chooser.
99
+ *
100
+ * The alternative NIP-55 web path — `?callbackUrl=` — is not used here: it
101
+ * navigates the page away and back, which cannot be hidden behind a
102
+ * promise-returning {@link ActiveSigner}. Use NIP-46 when a persistent
103
+ * session is wanted; the spec recommends it for web clients.
104
+ *
105
+ * @see https://github.com/nostr-protocol/nips/blob/master/55.md
106
+ */
107
+ /** Why the browser NIP-55 flow cannot run here. */
108
+ type Nip55WebSupportReason = 'native' | 'not-android' | 'no-clipboard' | 'firefox';
109
+ /**
110
+ * Whether the browser NIP-55 option should be shown, plus an advisory
111
+ * warning where it is known to be flaky.
112
+ *
113
+ * Visibility and warnings are deliberately separate, and nothing is
114
+ * hard-blocked. The option is hidden only where the mechanism cannot exist
115
+ * at all — a Capacitor native shell (the plugin path is strictly better)
116
+ * and non-Android platforms (the `nostrsigner` intent cannot resolve).
117
+ *
118
+ * Firefox for Android is **shown with a warning** rather than blocked. It
119
+ * advertises `readText` but never grants a persistent permission, so every
120
+ * read needs transient activation and the poll loop cannot complete —
121
+ * verified on an emulator (Firefox 125). We still let the user try, because
122
+ * blocking a browser outright is a worse failure than a warning, and
123
+ * browser behaviour changes.
124
+ */
125
+ interface Nip55WebSupport {
126
+ /** Render the option at all. False in native shells and on non-Android. */
127
+ visible: boolean;
128
+ /**
129
+ * Advisory, non-blocking message to show beside the option. The flow
130
+ * remains attemptable; this only sets expectations.
131
+ */
132
+ warning?: string;
133
+ /** Machine-readable reason for the warning / hidden state. */
134
+ reason?: Nip55WebSupportReason;
135
+ }
136
+ interface Nip55WebTransport {
137
+ /**
138
+ * Whether this environment can run the intent + clipboard flow at all.
139
+ * The package's browser transport requires an Android user agent and the
140
+ * async clipboard API; anything else must fail loudly rather than open a
141
+ * dead intent.
142
+ */
143
+ isSupported(): boolean;
144
+ /**
145
+ * Richer form of {@link isSupported}: whether to *offer* the option and
146
+ * whether it actually works. Hosts should prefer this so they can show an
147
+ * explanatory hint (e.g. "does not work on Firefox") rather than hide a
148
+ * capability that other browsers on the same device do have.
149
+ */
150
+ supportStatus?(): Nip55WebSupport;
151
+ /** Hand the intent URI to the OS (a browser typically `window.open`s it). */
152
+ open(intent: string): void;
153
+ /** Read the current clipboard text. */
154
+ readClipboard(): Promise<string>;
155
+ /**
156
+ * Overwrite the clipboard. Used to plant a sentinel before opening the
157
+ * signer, so the result can be told apart from whatever the user had
158
+ * copied earlier. Best-effort — a failure just means we fall back to
159
+ * accepting the first non-empty read.
160
+ */
161
+ writeClipboard(text: string): Promise<void>;
162
+ }
163
+ interface Nip55WebOptions {
164
+ /**
165
+ * Environment bridge. Defaults to a browser implementation
166
+ * ({@link browserNip55Transport}). Tests inject a fake.
167
+ */
168
+ transport?: Nip55WebTransport;
169
+ /**
170
+ * Cached user pubkey. When supplied, {@link Nip55WebSigner.getPublicKey}
171
+ * returns it without a signer roundtrip — used on cold start to avoid a
172
+ * fresh `get_public_key` approval prompt for an already-paired account.
173
+ */
174
+ pubkey?: string;
175
+ /**
176
+ * How often to poll the clipboard while waiting for the signer app to
177
+ * return a result. Default 500ms.
178
+ *
179
+ * Polling, not events: on Android Chrome the return from the signer app
180
+ * fires **no** `visibilitychange`/`focus` event at all (the tab was never
181
+ * reported hidden), so an event-driven read simply never happens.
182
+ * `setInterval` keeps running while the tab is backgrounded, so polling
183
+ * is what actually observes the result.
184
+ */
185
+ pollIntervalMs?: number;
186
+ /**
187
+ * Max time to wait for the signer app to return a result. A rejection is
188
+ * invisible over NIP-55 web (the app just never calls back), so without
189
+ * this a denied or abandoned request hangs forever. Default 120000ms;
190
+ * `0` disables the timeout.
191
+ */
192
+ timeoutMs?: number;
193
+ /**
194
+ * Lifetime abort. Aborts the in-flight request and makes every future
195
+ * request reject immediately (rejections carry `name === 'AbortError'`,
196
+ * matching the NIP-46 flow).
197
+ *
198
+ * This is a property of the *signer*, not of one attempt. Do **not**
199
+ * pass a controller that is aborted once a login modal closes — that
200
+ * would leave the signer permanently unusable. To cancel a single
201
+ * pairing attempt, use {@link Signer.loginWithNip55Web}'s `signal`.
202
+ */
203
+ signal?: AbortSignal;
204
+ /**
205
+ * Diagnostic sink for the intent/clipboard round-trip. The flow is hard
206
+ * to observe (it leaves the page and returns), so a host can pass a
207
+ * logger — e.g. to an on-screen list — to see what actually happened.
208
+ */
209
+ debug?: (message: string) => void;
210
+ }
211
+ /**
212
+ * Browser transport: `window.open` for the intent, `navigator.clipboard`
213
+ * for the result. Globals are read lazily so importing this module stays
214
+ * safe in Node.
215
+ *
216
+ * This transport is for a **plain browser only**. Inside a Capacitor
217
+ * native shell the same device has the real NIP-55 plugin, which supports
218
+ * every method and does not need the clipboard or a per-operation
219
+ * approval, so this flow is not offered there — a native host should use
220
+ * `loginWithAndroidSigner` instead.
221
+ */
222
+ declare function browserNip55Transport(): Nip55WebTransport;
223
+ /**
224
+ * {@link ActiveSigner} backed by any installed NIP-55 signer app, used from
225
+ * a plain (Android) browser via intents + clipboard.
226
+ *
227
+ * Every operation is a separate user approval — there is no background
228
+ * channel and no way to learn that the user rejected a request, so callers
229
+ * must impose their own timeout (the flow simply never resolves otherwise).
230
+ */
231
+ declare class Nip55WebSigner implements ActiveSigner {
232
+ #private;
233
+ constructor(options?: Nip55WebOptions);
234
+ /** True when the configured transport can actually open a signer app. */
235
+ isSupported(): boolean;
236
+ /**
237
+ * Whether to offer the option and whether it works. Prefer this over
238
+ * {@link isSupported} so a host can surface `message` — notably the
239
+ * Firefox case, which is shown but cannot complete.
240
+ */
241
+ supportStatus(): Nip55WebSupport;
242
+ getPublicKey(): Promise<string>;
243
+ signEvent(event: EventTemplate): Promise<Event>;
244
+ nip04Encrypt(peerPubkey: string, plaintext: string): Promise<string>;
245
+ nip04Decrypt(peerPubkey: string, ciphertext: string): Promise<string>;
246
+ nip44Encrypt(peerPubkey: string, plaintext: string): Promise<string>;
247
+ nip44Decrypt(peerPubkey: string, ciphertext: string): Promise<string>;
248
+ /**
249
+ * Cancel any in-flight request and stop its clipboard poll. Subsequent
250
+ * operations still work — this is a teardown of live resources, not a
251
+ * permanent disable (the {@link Signer} calls it when replacing the
252
+ * active signer).
253
+ */
254
+ close(): void;
255
+ static getPublicKeyIntent(): string;
256
+ static signEventIntent(draft: object): string;
257
+ static nip04EncryptIntent(peerPubkey: string, plaintext: string): string;
258
+ static nip04DecryptIntent(peerPubkey: string, ciphertext: string): string;
259
+ static nip44EncryptIntent(peerPubkey: string, plaintext: string): string;
260
+ static nip44DecryptIntent(peerPubkey: string, ciphertext: string): string;
261
+ }
71
262
 
72
263
  interface StorageAdapter {
73
264
  get(key: string): string | null;
@@ -82,8 +273,10 @@ declare function localStorageAdapter(prefix?: string): StorageAdapter;
82
273
  * - `nip46`: NIP-46 remote signer (bunker URI or nostrconnect QR).
83
274
  * - `ncryptsec`: NIP-49 encrypted nsec — decrypted into memory on unlock.
84
275
  * - `android`: NIP-55 Android external signer app via a Capacitor plugin.
276
+ * - `nip55-web`: NIP-55 Android external signer app from a plain browser,
277
+ * via `nostrsigner` intents + clipboard (no native bridge).
85
278
  */
86
- type LoginMethod = 'extension' | 'nip46' | 'ncryptsec' | 'android';
279
+ type LoginMethod = 'extension' | 'nip46' | 'ncryptsec' | 'android' | 'nip55-web';
87
280
  /**
88
281
  * Serialized account record persisted by the {@link StorageAdapter}.
89
282
  *
@@ -142,6 +335,14 @@ interface ActiveSigner {
142
335
  nip04Decrypt(peerPubkey: string, ciphertext: string): Promise<string>;
143
336
  nip44Encrypt(peerPubkey: string, plaintext: string): Promise<string>;
144
337
  nip44Decrypt(peerPubkey: string, ciphertext: string): Promise<string>;
338
+ /**
339
+ * Optional teardown, called by {@link Signer} when this signer is
340
+ * replaced — on a subsequent login/unlock, `switchAccount`, or
341
+ * `logout`. Implementations with live resources (an open bunker relay
342
+ * subscription, a clipboard poll, an in-flight request) should release
343
+ * them here. Omitted by stateless signers.
344
+ */
345
+ close?(): void | Promise<void>;
145
346
  }
146
347
  interface RelayMismatchInfo {
147
348
  userRelays: string[];
@@ -182,6 +383,33 @@ interface UnlockOptions {
182
383
  */
183
384
  pool?: AbstractSimplePool;
184
385
  }
386
+ /**
387
+ * Options for {@link Signer.loginWithNip55Web} — the pure-browser NIP-55
388
+ * flow (Android intents + clipboard, no Capacitor bridge).
389
+ */
390
+ interface Nip55WebLoginOptions {
391
+ /**
392
+ * Environment bridge for this call. Falls back to
393
+ * {@link SignerConfig.nip55WebTransport}, then to the browser default.
394
+ */
395
+ transport?: Nip55WebTransport;
396
+ /**
397
+ * How often to poll the clipboard while waiting for the signer app.
398
+ * Default 500ms. Polling is required because returning from the signer
399
+ * fires no foreground event on Android Chrome.
400
+ */
401
+ pollIntervalMs?: number;
402
+ /**
403
+ * Max time to wait for the signer app. NIP-55 web has no rejection
404
+ * signal, so a denied/abandoned request never resolves without this.
405
+ * Default 120000ms; `0` disables it.
406
+ */
407
+ timeoutMs?: number;
408
+ /** Abort the pairing request (rejects with `name === 'AbortError'`). */
409
+ signal?: AbortSignal;
410
+ /** Diagnostic sink for the intent/clipboard round-trip. */
411
+ debug?: (message: string) => void;
412
+ }
185
413
  interface NostrConnectOptions {
186
414
  relays: string[];
187
415
  metadata?: {
@@ -251,6 +479,12 @@ interface SignerConfig {
251
479
  * or `listAndroidSignerApps(plugin)`.
252
480
  */
253
481
  androidSignerPlugin?: AndroidSignerPlugin;
482
+ /**
483
+ * Environment bridge for the pure-web NIP-55 flow (Android browser,
484
+ * intents + clipboard). Defaults to a browser implementation; supply a
485
+ * stub in tests or non-browser hosts.
486
+ */
487
+ nip55WebTransport?: Nip55WebTransport;
254
488
  }
255
489
 
256
490
  /**
@@ -346,6 +580,52 @@ declare class Signer {
346
580
  * resolved to a package name, or the user denies the request.
347
581
  */
348
582
  loginWithAndroidSigner(options?: AndroidLoginOptions): Promise<StoredAccount>;
583
+ /**
584
+ * Whether `loginWithNip55Web` can run in this environment — a plain
585
+ * Android browser with async clipboard access. False in a Capacitor
586
+ * native shell (use {@link loginWithAndroidSigner} there), and on
587
+ * desktop/iOS/SSR.
588
+ *
589
+ * A **capability** check, not an availability one: there is no web API
590
+ * to detect an installed Android app, so this says nothing about
591
+ * whether a signer app is actually installed. Use it to hide the
592
+ * browser flow where it cannot work, not to promise that it will.
593
+ */
594
+ supportsNip55Web(transport?: Nip55WebTransport): boolean;
595
+ /**
596
+ * Whether to offer the browser NIP-55 option, plus an advisory `warning`
597
+ * where it is known to be flaky. Prefer this in UI code.
598
+ *
599
+ * Nothing here is a hard block. `visible: false` covers only environments
600
+ * where the mechanism cannot exist — a Capacitor native shell (the plugin
601
+ * path is better) and non-Android platforms (no `nostrsigner` handler).
602
+ * Firefox for Android is `visible: true` **with a warning**, because it
603
+ * advertises `readText` but never grants a persistent permission, so the
604
+ * poll loop usually cannot complete. Users are still allowed to try.
605
+ */
606
+ nip55WebSupport(transport?: Nip55WebTransport): Nip55WebSupport;
607
+ /**
608
+ * Sign in via a NIP-55 Android external signer (Amber, etc) **from a
609
+ * plain browser**, with no Capacitor/native bridge. Opens the installed
610
+ * signer app through a `nostrsigner` intent and reads the result back
611
+ * from the clipboard once the user returns to the tab. Because the
612
+ * intent names no package, this works with any app that registered the
613
+ * `nostrsigner` scheme — one opens directly, several show the Android
614
+ * "Open with" chooser.
615
+ *
616
+ * Not for native builds — inside a Capacitor shell use
617
+ * {@link loginWithAndroidSigner}, which needs no clipboard and no
618
+ * per-operation approval.
619
+ *
620
+ * Every operation is a separate approval, and a rejection is
621
+ * indistinguishable from the user simply not returning, so callers must
622
+ * impose their own timeout. Prefer NIP-46 when a persistent session is
623
+ * acceptable — the NIP-55 spec recommends it for web clients.
624
+ *
625
+ * @throws if the environment cannot run the flow (not Android, no async
626
+ * clipboard) or the signer returns an unexpected value.
627
+ */
628
+ loginWithNip55Web(options?: Nip55WebLoginOptions): Promise<StoredAccount>;
349
629
  /** Snapshot of every persisted account, in insertion order. */
350
630
  listAccounts(): StoredAccount[];
351
631
  /**
@@ -392,6 +672,10 @@ declare class Signer {
392
672
  * {@link loginWithAndroidSigner} performs and that — on Amber —
393
673
  * surfaces as a permission prompt every cold start.
394
674
  *
675
+ * - `nip55-web`: constructs a {@link Nip55WebSigner} with the stored
676
+ * `pubkey` cached. Like `android`, this opens no signer app during
677
+ * unlock; the first sign/encrypt call is what prompts.
678
+ *
395
679
  * - `ncryptsec`: returns `null`. There is no silent path — the user's
396
680
  * passphrase isn't (and shouldn't be) persisted. The caller must
397
681
  * drive the passphrase prompt and call {@link loginWithNcryptsec}.
@@ -428,4 +712,4 @@ declare class Signer {
428
712
  /** Convenience wrapper around `new Signer(config)`. */
429
713
  declare function createSigner(config?: SignerConfig): Signer;
430
714
 
431
- export { type ActiveSigner as A, type BunkerLoginOptions as B, type LoginMethod as L, type NostrConnectOptions as N, type RelayMismatchHandler as R, Signer as S, type UnlockOptions as U, type AndroidLoginOptions as a, type AndroidLoginResult as b, AndroidSigner as c, type AndroidSignerAppInfo as d, type AndroidSignerPlugin as e, type RelayMismatchInfo as f, type SignerConfig as g, type SignerEvent as h, type StorageAdapter as i, type StoredAccount as j, createSigner as k, localStorageAdapter as l, loginWithAndroidSigner as m };
715
+ export { type ActiveSigner as A, type BunkerLoginOptions as B, type LoginMethod as L, type Nip55WebLoginOptions as N, type RelayMismatchHandler as R, Signer as S, type UnlockOptions as U, type AndroidLoginOptions as a, type AndroidLoginResult as b, AndroidSigner as c, type AndroidSignerAppInfo as d, type AndroidSignerPlugin as e, type Nip55WebOptions as f, Nip55WebSigner as g, type Nip55WebSupport as h, type Nip55WebSupportReason as i, type Nip55WebTransport as j, type NostrConnectOptions as k, type RelayMismatchInfo as l, type SignerConfig as m, type SignerEvent as n, type StorageAdapter as o, type StoredAccount as p, browserNip55Transport as q, createSigner as r, localStorageAdapter as s, loginWithAndroidSigner as t, normalizeNip55Identifier as u };