@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
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),
|
|
3
|
+
A vanilla TypeScript Nostr signer with an optional unstyled login UI. Supports NIP-07 (browser extension), NIP-46 (bunker URI + nostrconnect QR), NIP-49 (ncryptsec at rest), NIP-55 (Android external signer apps, via a Capacitor plugin **or** a plain browser), and a pure-web signer-app flow.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -28,6 +28,7 @@ await signer.loginWithExtension();
|
|
|
28
28
|
await signer.loginWithBunkerUri('bunker://...');
|
|
29
29
|
await signer.loginWithNostrConnect({ relays: ['wss://relay.example'], onUri: (uri) => /* show QR */ });
|
|
30
30
|
await signer.loginWithAndroidSigner({ packageName: 'com.greenart7c3.nostrsigner' });
|
|
31
|
+
await signer.loginWithNip55Web(); // Android browser, no Capacitor shell — see below
|
|
31
32
|
|
|
32
33
|
// Sign events — the active signer never exposes the privkey
|
|
33
34
|
const active = signer.getActiveSigner()!;
|
|
@@ -79,6 +80,7 @@ Per-method behavior:
|
|
|
79
80
|
| `extension` | constructs `ExtensionSigner` (stateless wrapper around `window.nostr`) | no |
|
|
80
81
|
| `nip46` | reuses persisted `clientSecretKey` + `remoteSignerPubkey` + `relays` to attach a `BunkerSigner` — **skips the `connect` request**, which is what triggers a fresh approval prompt every reload | no |
|
|
81
82
|
| `android` | builds the `AndroidSigner` directly from cached `pubkey` + `npub` + `androidPackageName`, **bypassing the plugin's `getPublicKey` content-provider call** | no |
|
|
83
|
+
| `nip55-web` | builds a `Nip55WebSigner` from the cached `pubkey`; it does not open the signer app until the next sign/encrypt call | no |
|
|
82
84
|
| `ncryptsec` | returns `null` — the passphrase is not (and must not be) persisted; caller drives the prompt and calls `loginWithNcryptsec(account.ncryptsec, passphrase)` | n/a (by design) |
|
|
83
85
|
|
|
84
86
|
`unlock()` returns `null` (without emitting an event) when there is no active account, when the account is missing fields it needs to resume, when method is `nip46` but no `pool` was supplied, or when method is `android` but no plugin is configured. On success it emits the same `login`/`switch` event the corresponding `loginWith*` would.
|
|
@@ -133,7 +135,105 @@ const myPlugin: AndroidSignerPlugin = {
|
|
|
133
135
|
|
|
134
136
|
The interface signatures intentionally mirror `nostr-signer-capacitor-plugin`'s exported `NostrSignerPlugin` so the real wrapper is directly assignable. If you write a custom plugin, the package's test suite includes a compile-time conformance guard (`tests/helpers/mockAndroidPlugin.ts`) you can model your own check on — wire it up in your CI and you'll catch any drift the moment the upstream wrapper changes shape.
|
|
135
137
|
|
|
136
|
-
**Identifier shape.** The `npub` field returned by `getPublicKey` is permissive: the package accepts either a bech32 `npub1…` string (the NIP-55 spec shape) or a 32-byte hex pubkey (what current Amber builds actually return). Whichever you hand back, the package normalizes internally — `StoredAccount.npub` is always bech32 and `StoredAccount.pubkey` is always lowercase hex. Anything else surfaces as a debuggable error including a preview of what was received.
|
|
138
|
+
**Identifier shape.** The `npub` field returned by `getPublicKey` is permissive: the package accepts either a bech32 `npub1…` string (the NIP-55 spec shape) or a 32-byte hex pubkey (what current Amber builds actually return). Whichever you hand back, the package normalizes internally — `StoredAccount.npub` is always bech32 and `StoredAccount.pubkey` is always lowercase hex. Anything else surfaces as a debuggable error including a preview of what was received. The same normalization (plus `nprofile1…`) is shared with the browser flow below.
|
|
139
|
+
|
|
140
|
+
## NIP-55 in a plain browser (`loginWithNip55Web`)
|
|
141
|
+
|
|
142
|
+
The Capacitor plugin above only works inside a native Android shell. `loginWithNip55Web()` covers the other case: a **plain browser on Android** (mobile web, a PWA) talking to any installed NIP-55 signer app with no native bridge.
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
await signer.loginWithNip55Web();
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The mechanism:
|
|
149
|
+
|
|
150
|
+
1. **Plant a sentinel.** The package overwrites the clipboard with a random `__formstr_nip55_sentinel_…__` string. This is what makes the result identifiable — otherwise a clipboard left over from an earlier approval would look like a fresh answer and resolve immediately.
|
|
151
|
+
2. **Open the intent.** It opens `intent:#Intent;scheme=nostrsigner;S.type=…;end` via `window.open`. No package is named, so Android resolves it against every app that registered the scheme — one signer opens directly, several show the standard "Open with" chooser. This is not Amber-specific.
|
|
152
|
+
3. **Poll the clipboard.** The signer app signs and copies the result to the clipboard. The package polls `navigator.clipboard.readText()` every `pollIntervalMs` (default 500ms) until the value differs from the sentinel, then resolves.
|
|
153
|
+
|
|
154
|
+
### Why polling, not `visibilitychange`
|
|
155
|
+
|
|
156
|
+
The obvious design — read the clipboard when the user returns to the tab — **does not work on Android Chrome**, verified on a Pixel emulator running Amber 6.6.4:
|
|
157
|
+
|
|
158
|
+
- Returning from the signer app fires **no** `visibilitychange` or `focus` event. `window.open` produces a brief hide/show blip *before* the signer is even open (a `blur`, two `visibilitychange`s and a `focus` within ~400ms), and then nothing on the actual return. An event-driven read therefore never runs.
|
|
159
|
+
- Even when a read is attempted right after returning, Chrome rejects it with `NotAllowedError: Document is not focused` — the page is visible but not focused, and it does not regain focus on its own.
|
|
160
|
+
- `setInterval`, by contrast, keeps ticking while the tab is backgrounded, so polling observes the result regardless.
|
|
161
|
+
|
|
162
|
+
This is why the transport interface has `readClipboard`/`writeClipboard` but no foreground callback: the browser simply does not provide a reliable return signal.
|
|
163
|
+
|
|
164
|
+
### Browser only — not for native builds
|
|
165
|
+
|
|
166
|
+
This flow is for a plain browser. Inside a Capacitor native shell the same
|
|
167
|
+
device already has the real NIP-55 plugin, which supports every method and
|
|
168
|
+
needs neither the clipboard nor a per-operation approval — so this path is
|
|
169
|
+
hidden there (Capacitor injects a `Capacitor.isNativePlatform()` global,
|
|
170
|
+
which the package checks without depending on `@capacitor/core`).
|
|
171
|
+
|
|
172
|
+
Gate your UI on `signer.nip55WebSupport()`:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
container.innerHTML = renderLoginHtml({
|
|
176
|
+
nip55Web: signer.nip55WebSupport(),
|
|
177
|
+
});
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
That returns `{ visible, warning?, reason? }`:
|
|
181
|
+
|
|
182
|
+
| Environment | `visible` | `warning` |
|
|
183
|
+
| --- | --- | --- |
|
|
184
|
+
| Android Chrome / Brave | `true` | — |
|
|
185
|
+
| Android Firefox / Focus / Klar | `true` | "May not work in Firefox for Android…" |
|
|
186
|
+
| Capacitor native shell | `false` | — |
|
|
187
|
+
| Desktop / iOS | `false` | — |
|
|
188
|
+
|
|
189
|
+
Nothing is hard-blocked: Firefox is shown **with a warning** so users can
|
|
190
|
+
still try, and so they can see why their browser differs from Chrome on the
|
|
191
|
+
same device. `loginWithNip55Web()` in a native build throws a message
|
|
192
|
+
pointing at `loginWithAndroidSigner()`.
|
|
193
|
+
|
|
194
|
+
`supportsNip55Web()` remains as a boolean shorthand for `.visible`.
|
|
195
|
+
Both are **capability** checks, not availability ones: there is no web API
|
|
196
|
+
to detect an installed Android app, so they cannot tell you whether a signer
|
|
197
|
+
app is actually present. They answer "could this flow possibly work here",
|
|
198
|
+
not "will it succeed".
|
|
199
|
+
|
|
200
|
+
### Firefox for Android
|
|
201
|
+
|
|
202
|
+
Firefox (and Focus/Klar) is the one browser that advertises
|
|
203
|
+
`navigator.clipboard.readText` yet cannot run this flow. It never grants a
|
|
204
|
+
persistent `clipboard-read` permission, so **every** read requires transient
|
|
205
|
+
user activation and is answered with an ephemeral "Paste" menu. A background
|
|
206
|
+
poll therefore cannot succeed.
|
|
207
|
+
|
|
208
|
+
Verified on an emulator (Firefox 125): calling `readText()` directly inside a
|
|
209
|
+
click handler — `hasFocus=true`, `visibilityState=visible`,
|
|
210
|
+
`userActivation.isActive=true` — still rejects with
|
|
211
|
+
`NotAllowedError: Clipboard read operation is not allowed`.
|
|
212
|
+
|
|
213
|
+
Because browser behaviour changes and blocking a whole browser is a worse
|
|
214
|
+
failure than a warning, the option stays visible and attemptable there.
|
|
215
|
+
|
|
216
|
+
### What to know
|
|
217
|
+
|
|
218
|
+
- **Android + secure context only.** `nip55WebSupport().visible` requires an Android user agent, the async clipboard API, and not being in a native shell. `loginWithNip55Web()` throws before persisting anything when not visible. `http://localhost` counts as a secure context, which is handy for local testing.
|
|
219
|
+
- **Chrome will ask to read the clipboard** the first time; approve it, or every read fails.
|
|
220
|
+
- **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.
|
|
221
|
+
- **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.
|
|
222
|
+
- **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.
|
|
223
|
+
- **The clipboard is clobbered** by the sentinel write. This is inherent to the transport; warn users if your app cares about clipboard contents.
|
|
224
|
+
- **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.
|
|
225
|
+
|
|
226
|
+
The environment bridge is pluggable for tests and unusual hosts:
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
import type { Nip55WebTransport } from '@formstr/signer';
|
|
230
|
+
|
|
231
|
+
const s = createSigner({ nip55WebTransport: myTransport });
|
|
232
|
+
// or per call:
|
|
233
|
+
await s.loginWithNip55Web({ transport: myTransport });
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
`browserNip55Transport()` is exported as the default implementation. `pollIntervalMs` (default 500) trades latency against how often the clipboard is read. `Nip55WebSigner` also exposes a `close()` that cancels an in-flight request and stops its poll; `Signer` calls it automatically when the active signer is replaced (a new login/unlock, `switchAccount`, or `logout`), so a pending request never leaks past its session.
|
|
137
237
|
|
|
138
238
|
## NIP-46 app identity (required for nostrconnect)
|
|
139
239
|
|
|
@@ -191,7 +291,7 @@ const detach = attachLoginListeners(container, signer, {
|
|
|
191
291
|
// later: detach();
|
|
192
292
|
```
|
|
193
293
|
|
|
194
|
-
The login modal renders one tab per method (Create, Existing key, Extension, Bunker URI, Remote QR, Android). The Android tab is always rendered but its list of installed signers is fetched lazily on activation via `signer.listAndroidSignerApps()` — it errors clearly if no Android plugin is configured (e.g. when running on web).
|
|
294
|
+
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({ nip55Web: signer.nip55WebSupport() })` to hide it in native builds and to show Firefox's warning.
|
|
195
295
|
|
|
196
296
|
## Errors
|
|
197
297
|
|
|
@@ -199,11 +299,12 @@ All `loginWith*` methods reject with bare `Error` instances. Categories you can
|
|
|
199
299
|
|
|
200
300
|
- **Validation** — empty passphrase, empty relays, malformed bunker URI.
|
|
201
301
|
- **Wrong credential** — `loginWithNcryptsec` with a bad passphrase throws synchronously after decrypt.
|
|
202
|
-
- **External denial** — extension/bunker/Android signer rejects the request.
|
|
203
|
-
- **Transport** — NIP-46 relay unreachable, pairing timeout, abort.
|
|
302
|
+
- **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.
|
|
303
|
+
- **Transport** — NIP-46 relay unreachable, pairing timeout, abort; browser NIP-55 empty/inaccessible clipboard, or an unverifiable signature.
|
|
204
304
|
- **Configuration** —
|
|
205
305
|
- `loginWithNostrConnect` throws if neither `appName` (in `createSigner`) nor `metadata.name` (per call) is set. See "NIP-46 app identity" above.
|
|
206
306
|
- `loginWithAndroidSigner` / `listAndroidSignerApps` throws if no plugin is configured.
|
|
307
|
+
- `loginWithNip55Web` throws if the environment is not an Android browser with clipboard access.
|
|
207
308
|
|
|
208
309
|
Error messages are prefixed with `@formstr/signer:` for messages the package generates itself. Errors from `nostr-tools` or the Capacitor plugin propagate unchanged. There is currently no typed `code` field — discriminate by string match or by which method threw.
|
|
209
310
|
|
|
@@ -246,6 +347,7 @@ The UI ships with these class names. Override in your own CSS.
|
|
|
246
347
|
| `.nostr-signer__tab--extension` | NIP-07 tab |
|
|
247
348
|
| `.nostr-signer__tab--bunker` | NIP-46 bunker URI tab |
|
|
248
349
|
| `.nostr-signer__tab--nostrconnect` | NIP-46 nostrconnect (QR) tab |
|
|
350
|
+
| `.nostr-signer__tab--nip55web` | NIP-55 browser/`nostrsigner` tab |
|
|
249
351
|
| `.nostr-signer__tab--android` | NIP-55 Android tab |
|
|
250
352
|
|
|
251
353
|
### Panels
|
|
@@ -258,6 +360,7 @@ The UI ships with these class names. Override in your own CSS.
|
|
|
258
360
|
| `.nostr-signer__panel--extension` | extension panel |
|
|
259
361
|
| `.nostr-signer__panel--bunker` | bunker URI panel |
|
|
260
362
|
| `.nostr-signer__panel--nostrconnect` | nostrconnect panel |
|
|
363
|
+
| `.nostr-signer__panel--nip55web` | NIP-55 browser/`nostrsigner` panel |
|
|
261
364
|
| `.nostr-signer__panel--android` | Android signer panel |
|
|
262
365
|
| `.nostr-signer__panel--created` | post-creation backup-the-ncryptsec panel |
|
|
263
366
|
|
|
@@ -297,5 +400,6 @@ The UI ships with these class names. Override in your own CSS.
|
|
|
297
400
|
| `.nostr-signer__button--primary` | primary button |
|
|
298
401
|
| `.nostr-signer__button--secondary` | secondary button |
|
|
299
402
|
| `.nostr-signer__error` | error message |
|
|
403
|
+
| `.nostr-signer__warn` | advisory (non-blocking) warning |
|
|
300
404
|
| `.nostr-signer__status` | status / loading message |
|
|
301
405
|
| `.nostr-signer__hint` | hint text |
|