@formstr/signer 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -5
- package/dist/index.cjs +420 -15
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +413 -8
- package/dist/index.js.map +1 -1
- package/dist/{signer-BepGdtJs.d.cts → signer-CQyqBYxq.d.cts} +286 -2
- package/dist/{signer-BepGdtJs.d.ts → signer-CQyqBYxq.d.ts} +286 -2
- package/dist/ui/index.cjs +36 -2
- package/dist/ui/index.cjs.map +1 -1
- package/dist/ui/index.d.cts +30 -4
- package/dist/ui/index.d.ts +30 -4
- package/dist/ui/index.js +36 -2
- package/dist/ui/index.js.map +1 -1
- package/package.json +3 -2
- package/styles/signer.css +10 -0
|
@@ -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
|
|
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
|
|
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 };
|