signet-login 0.9.15 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,14 +1,19 @@
1
- # signet-login
1
+ # Signet Access
2
2
 
3
3
  [![GitHub Sponsors](https://img.shields.io/github/sponsors/TheCryptoDonkey?logo=githubsponsors&color=ea4aaa&label=Sponsor)](https://github.com/sponsors/TheCryptoDonkey)
4
4
 
5
- **Sign in with Signet** for Nostr-aware websites. One picker, three backends:
5
+ Published as `signet-login`.
6
6
 
7
- - **Browser extension** (NIP-07 bark, Alby, nos2x, Flamingo, …)
8
- - **Sign in with Signet** (cross-device QR via NIP-17 gift-wrap)
9
- - **Paste bunker URI** (NIP-46 remote signer — Heartwood, nsecBunker, Amber)
7
+ **Signet Access** is a drop-in auth and signer-access SDK for Nostr-aware websites. One picker, one session shape, multiple ways to prove identity and, when available, keep a live signer:
10
8
 
11
- Returns a unified `SignetSigner` your code can use to sign Nostr events going forward.
9
+ - **Sign in with Signet** on this device or by cross-device QR
10
+ - **Browser extension** via NIP-07 (bark, Alby, nos2x, Flamingo, ...)
11
+ - **Connect a Nostr signer** via app-initiated NIP-46 / NostrConnect
12
+ - **Paste or scan bunker URI** for Heartwood, nsecBunker, Amber, or compatible signers
13
+ - **Sign in with Amber** via Android NIP-55
14
+ - **Paste private key** as an in-memory, advanced fallback only
15
+
16
+ Returns a unified `SignetSigner` plus a signed kind-21236 auth proof your server can verify before granting privileges.
12
17
 
13
18
  ## Install
14
19
 
@@ -58,20 +63,34 @@ Show the picker, return a `SignetSession` on success or `null` on cancel/timeout
58
63
 
59
64
  ```ts
60
65
  interface LoginOptions {
61
- appName: string; // shown in modal
62
- challenge?: string; // 64 hex; auto if omitted
63
- preferredMethod?: 'nip07' | 'redirect' | 'bunker'; // skip the picker
64
- relayUrl?: string; // default wss://relay.damus.io
65
- theme?: 'light' | 'dark' | 'auto'; // default 'auto'
66
- timeout?: number; // default 120_000ms; clamped to [5k, 600k]
67
- signetAppOrigin?: string; // default https://mysignet.app
68
- redirectCallback?: string; // for same-device redirect (future)
69
- persist?: boolean; // default true (localStorage)
66
+ appName: string; // shown in modal
67
+ challenge?: string; // 64 hex; auto if omitted
68
+ preferredMethod?: LoginPickerMethod; // skip the picker
69
+ methods?: LoginPickerMethod[]; // picker methods, in order
70
+ advancedMethods?: LoginPickerMethod[]; // grouped behind Advanced; [] = flat list
71
+ relayUrl?: string; // default wss://relay.damus.io
72
+ relayUrls?: string[]; // repeated relay= params for NostrConnect
73
+ nostrConnectPerms?: string[]; // default sign_event + NIP-44
74
+ theme?: 'light' | 'dark' | 'auto'; // default 'auto'
75
+ timeout?: number; // default 120_000ms; clamped to [5k, 600k]
76
+ signetAppOrigin?: string; // default https://mysignet.app
77
+ redirectCallback?: string; // for same-device redirect / Amber return
78
+ mode?: 'relay' | 'redirect'; // Signet delivery mode
79
+ persist?: boolean; // default true (localStorage)
70
80
  }
71
81
 
82
+ type LoginPickerMethod =
83
+ | 'nip07'
84
+ | 'redirect' // same-device Signet, relay delivery
85
+ | 'qr' // cross-device Signet QR
86
+ | 'bunker' // paste bunker://
87
+ | 'nostrconnect' // show nostrconnect:// QR
88
+ | 'amber' // Android NIP-55
89
+ | 'nsec'; // in-memory private key fallback
90
+
72
91
  interface SignetSession {
73
92
  pubkey: string; // hex
74
- method: 'nip07' | 'redirect' | 'bunker';
93
+ method: 'nip07' | 'redirect' | 'bunker' | 'nsec' | 'amber';
75
94
  signer: SignetSigner;
76
95
  authEvent: SignetAuthEvent; // signed kind-21236 challenge proof
77
96
  expiresAt?: number;
@@ -79,6 +98,56 @@ interface SignetSession {
79
98
  }
80
99
  ```
81
100
 
101
+ By default, the picker shows ordinary user-facing methods first and groups `bunker`, `nostrconnect`, and `nsec` behind **Advanced**. Control the surface per app:
102
+
103
+ ```js
104
+ await Signet.login({
105
+ appName: 'My Game',
106
+ methods: ['redirect', 'qr', 'nip07'],
107
+ });
108
+
109
+ await Signet.login({
110
+ appName: 'Power User Tool',
111
+ methods: ['nip07', 'bunker', 'nostrconnect', 'nsec'],
112
+ advancedMethods: [], // flat picker
113
+ relayUrls: ['wss://relay.nsec.app', 'wss://relay.damus.io'],
114
+ });
115
+ ```
116
+
117
+ When camera APIs are available, the bunker URI screen can scan `bunker://` QR codes directly. Paste remains the fallback.
118
+
119
+ ### Headless/custom UI
120
+
121
+ Use the exported signer constructors and proof helpers when your app owns the UI:
122
+
123
+ ```ts
124
+ import {
125
+ createBunkerSigner,
126
+ createLoginAuthEvent,
127
+ createSessionFromSigner,
128
+ createLocalSignerFromNsec,
129
+ } from 'signet-login';
130
+
131
+ const signer = await createBunkerSigner({
132
+ uri: bunkerUri,
133
+ timeoutMs: 30_000,
134
+ });
135
+
136
+ const session = await createSessionFromSigner(signer, {
137
+ appName: 'My App',
138
+ challenge: challengeFromServer,
139
+ origin: 'https://my-app.example',
140
+ });
141
+
142
+ await fetch('/api/login', {
143
+ method: 'POST',
144
+ body: JSON.stringify({ authEvent: session.authEvent }),
145
+ });
146
+ ```
147
+
148
+ Headless exports include `hasNip07`, `createNip07Signer`, `createBunkerSigner`, `createBunkerSignerFromNostrConnect`, `buildNostrConnectUri`, `createLocalSignerFromNsec`, `createLoginAuthEvent`, `createSessionFromSigner`, and `generateSecretKey`.
149
+ The IIFE bundle attaches the same helpers to `window.Signet`.
150
+
82
151
  ### `Signet.restoreSession(opts?)`
83
152
 
84
153
  Restore a session from localStorage. For bunker sessions this attempts to reconnect to the stored bunker. Returns `null` if no session is stored, the session is malformed, or reconnection fails.
@@ -98,14 +167,14 @@ Clear stored session and close the active signer.
98
167
 
99
168
  Run on your callback page when using the same-device redirect flow. Parses URL params and posts them to `window.opener` (if popup-opened), then closes the popup.
100
169
 
101
- ## The three signers
170
+ ## Signers and capabilities
102
171
 
103
- All three implement `SignetSigner`:
172
+ All session signers implement `SignetSigner`:
104
173
 
105
174
  ```ts
106
175
  interface SignetSigner {
107
176
  readonly pubkey: string;
108
- readonly method: 'nip07' | 'redirect' | 'bunker';
177
+ readonly method: 'nip07' | 'redirect' | 'bunker' | 'nsec' | 'amber';
109
178
  readonly capabilities: { canSignEvents: boolean; hasNip44: boolean };
110
179
  signEvent(template: EventTemplate): Promise<NostrEvent>;
111
180
  nip44?: { encrypt, decrypt };
@@ -117,9 +186,10 @@ interface SignetSigner {
117
186
  |---|---|---|
118
187
  | `Nip07Signer` | true | `window.nostr` (any NIP-07 extension) |
119
188
  | `BunkerSignerImpl` | true | `nostr-tools` BunkerSigner over NIP-46 relay |
120
- | `EphemeralSigner` | **false** | Auth-only redirect returned only `authEvent` |
189
+ | `LocalSigner` | true | In-memory nsec fallback; never persisted |
190
+ | `EphemeralSigner` | **false** | Auth-only Signet redirect / QR / Amber callback |
121
191
 
122
- `EphemeralSigner` exists because the v0.1 redirect flow returns a single signed challenge but no ongoing-signing channel. Use `signer.capabilities.canSignEvents` to gate UI:
192
+ `EphemeralSigner` exists because some redirect-style flows return a signed challenge but no ongoing signing channel. Use `signer.capabilities.canSignEvents` to gate UI:
123
193
 
124
194
  ```js
125
195
  if (session.signer.capabilities.canSignEvents) {
@@ -129,7 +199,7 @@ if (session.signer.capabilities.canSignEvents) {
129
199
  }
130
200
  ```
131
201
 
132
- A future Option-B upgrade to signet-app will spawn a session-bunker per origin during the redirect approval, at which point redirect sessions will be full signers transparently. The SDK API does not change.
202
+ When Signet or a signer app returns a `bunker://` handoff, the SDK upgrades the auth-only proof into a live `BunkerSignerImpl` if the handoff connects and matches the authenticated pubkey.
133
203
 
134
204
  ## Server-side verification
135
205
 
@@ -161,7 +231,7 @@ Session data is stored in localStorage under `signet:login.*`:
161
231
  | Key | Purpose |
162
232
  |---|---|
163
233
  | `signet:login.pubkey` | Authenticated pubkey |
164
- | `signet:login.method` | `nip07` / `redirect` / `bunker` |
234
+ | `signet:login.method` | `nip07` / `redirect` / `bunker` / `amber` |
165
235
  | `signet:login.authEvent` | Serialised kind-21236 auth event |
166
236
  | `signet:login.bunkerUri` | Bunker URI for reconnect (bunker only) |
167
237
  | `signet:login.bunkerClientSk` | Client secret key hex (bunker only) |
@@ -190,7 +260,7 @@ Each SDK manages its own slice of `window.Signet` and `localStorage` namespaces.
190
260
 
191
261
  ## Bundle size
192
262
 
193
- Approx **48.5 KB gzipped** (135 KB unminified). The bulk is `nostr-tools` `BunkerSigner` for NIP-46 + `signet-verify` for the cross-device QR primitive. A future split-bundle could lazy-load the bunker path to halve the initial size.
263
+ The ESM entry is approx **5.9 KB gzipped** before bundling dependencies. The standalone IIFE is approx **114.7 KB gzipped** because it includes NIP-46, Signet QR/relay support, and camera QR decoding. A future split-bundle could lazy-load advanced signer paths for smaller first-load pages.
194
264
 
195
265
  ## Browser support
196
266
 
@@ -207,9 +277,12 @@ npm test # vitest in jsdom
207
277
 
208
278
  Examples in `examples/`:
209
279
  - `basic.html` — full demo with login / sign / logout / restore
280
+ - `headless.html` — custom UI demo using signer constructors and proof helpers
210
281
  - `callback.html` — redirect-back receiver page
211
282
 
212
- Build the IIFE bundle first, then serve the repo root with any static server and open `examples/basic.html`.
283
+ Build the IIFE bundle first, then serve the repo root with any static server and open `examples/basic.html` or `examples/headless.html`.
284
+
285
+ See [docs/competitive-audit.md](docs/competitive-audit.md) for the current competitor comparison and roadmap priorities.
213
286
 
214
287
  ## Out of scope
215
288
 
package/dist/modal.js CHANGED
@@ -12,7 +12,11 @@ import { waitForAuthResponse } from 'signet-verify';
12
12
  import { schnorr } from '@noble/curves/secp256k1';
13
13
  import { bytesToHex } from '@noble/hashes/utils';
14
14
  import QRCode from 'qrcode';
15
+ import jsQR from 'jsqr';
15
16
  const QR_BUNKER_CONNECT_TIMEOUT_MS = 8000;
17
+ const DEFAULT_PICKER_METHODS = ['nip07', 'amber', 'redirect', 'qr', 'bunker', 'nostrconnect', 'nsec'];
18
+ const DEFAULT_ADVANCED_METHODS = ['bunker', 'nostrconnect', 'nsec'];
19
+ const DEFAULT_NOSTR_CONNECT_PERMS = ['sign_event', 'nip44_encrypt', 'nip44_decrypt'];
16
20
  function escapeHtml(str) {
17
21
  return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
18
22
  }
@@ -144,33 +148,176 @@ function buttonStyle(dark, primary = false) {
144
148
  const fg = dark ? '#e0e0e0' : '#1a1a2e';
145
149
  return `background:transparent;color:${fg};border:1px solid ${border};padding:12px 16px;border-radius:8px;cursor:pointer;font-size:0.95rem;width:100%;margin-bottom:8px;text-align:left;display:flex;align-items:center;gap:12px;`;
146
150
  }
151
+ function canUseCameraQrScanner() {
152
+ return typeof navigator !== 'undefined'
153
+ && !!navigator.mediaDevices
154
+ && typeof navigator.mediaDevices.getUserMedia === 'function'
155
+ && typeof document !== 'undefined';
156
+ }
157
+ function isAcceptedPairingQr(value, acceptedPrefixes) {
158
+ const lower = value.trim().toLowerCase();
159
+ return acceptedPrefixes.some(prefix => lower.startsWith(prefix));
160
+ }
161
+ async function startCameraQrScanner(input) {
162
+ const { container, status, acceptedPrefixes, onValue } = input;
163
+ if (!canUseCameraQrScanner())
164
+ throw new Error('camera-unavailable');
165
+ let stopped = false;
166
+ let frame = 0;
167
+ let stream = null;
168
+ const video = document.createElement('video');
169
+ const canvas = document.createElement('canvas');
170
+ const stopBtn = document.createElement('button');
171
+ video.muted = true;
172
+ video.playsInline = true;
173
+ video.style.cssText = 'display:block;width:100%;max-height:240px;object-fit:cover;border-radius:8px;background:#000;margin:0 0 8px;';
174
+ canvas.style.display = 'none';
175
+ stopBtn.type = 'button';
176
+ stopBtn.dataset.action = 'stop-scan';
177
+ stopBtn.textContent = 'Stop scan';
178
+ stopBtn.style.cssText = 'display:block;margin:0 auto 8px;background:transparent;border:1px solid currentColor;border-radius:8px;padding:8px 12px;cursor:pointer;color:inherit;';
179
+ const stop = () => {
180
+ if (stopped)
181
+ return;
182
+ stopped = true;
183
+ if (frame)
184
+ cancelAnimationFrame(frame);
185
+ if (stream) {
186
+ for (const track of stream.getTracks())
187
+ track.stop();
188
+ }
189
+ container.hidden = true;
190
+ container.replaceChildren();
191
+ };
192
+ stopBtn.addEventListener('click', () => {
193
+ stop();
194
+ if (status) {
195
+ status.textContent = 'QR scan stopped.';
196
+ status.style.color = '';
197
+ }
198
+ });
199
+ container.hidden = false;
200
+ container.replaceChildren(video, canvas, stopBtn);
201
+ if (status) {
202
+ status.textContent = 'Point your camera at a QR code...';
203
+ status.style.color = '';
204
+ }
205
+ try {
206
+ stream = await navigator.mediaDevices.getUserMedia({
207
+ audio: false,
208
+ video: { facingMode: { ideal: 'environment' } },
209
+ });
210
+ video.srcObject = stream;
211
+ await video.play();
212
+ }
213
+ catch (err) {
214
+ stop();
215
+ throw err;
216
+ }
217
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
218
+ if (!ctx) {
219
+ stop();
220
+ throw new Error('canvas-unavailable');
221
+ }
222
+ const tick = () => {
223
+ if (stopped)
224
+ return;
225
+ if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && video.videoWidth > 0 && video.videoHeight > 0) {
226
+ canvas.width = video.videoWidth;
227
+ canvas.height = video.videoHeight;
228
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
229
+ const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
230
+ const code = jsQR(image.data, image.width, image.height, { inversionAttempts: 'attemptBoth' });
231
+ const scanned = code?.data?.trim();
232
+ if (scanned) {
233
+ if (isAcceptedPairingQr(scanned, acceptedPrefixes)) {
234
+ onValue(scanned);
235
+ if (status) {
236
+ status.textContent = 'QR code scanned.';
237
+ status.style.color = '';
238
+ }
239
+ stop();
240
+ return;
241
+ }
242
+ if (status) {
243
+ status.textContent = 'That QR is not a supported pairing URI.';
244
+ status.style.color = '#d04848';
245
+ }
246
+ }
247
+ }
248
+ frame = requestAnimationFrame(tick);
249
+ };
250
+ frame = requestAnimationFrame(tick);
251
+ return { stop };
252
+ }
147
253
  // ── Picker ────────────────────────────────────────────────────────────────────
148
- function renderPicker(refs, appName, theme) {
149
- const dark = isDarkMode(theme);
254
+ const METHOD_META = {
255
+ nip07: { icon: '🌐', title: 'Browser extension', hint: 'bark, Alby, nos2x' },
256
+ amber: { icon: '🤖', title: 'Sign in with Amber', hint: 'Android signer (NIP-55)' },
257
+ redirect: { icon: '🪪', title: 'Sign in with Signet', hint: 'Open Signet on this device' },
258
+ qr: { icon: '📱', title: 'Signet on another device', hint: 'Scan QR with your phone' },
259
+ bunker: { icon: '🔑', title: 'Paste bunker URI', hint: 'For NIP-46 power users' },
260
+ nostrconnect: { icon: '📡', title: 'Connect a Nostr signer', hint: 'Scan with nsec.app, Amber, Keychat...' },
261
+ nsec: { icon: '⚠️', title: 'Paste private key', hint: 'In-memory only - risky, last resort' },
262
+ };
263
+ function isMethodAvailable(method) {
264
+ if (method === 'nip07')
265
+ return hasNip07();
266
+ if (method === 'amber')
267
+ return isAndroid();
268
+ return true;
269
+ }
270
+ function methodButtonHtml(method, dark, muted, primary) {
271
+ const meta = METHOD_META[method];
272
+ return `<button data-choice="${method}" style="${buttonStyle(dark, primary)}"><span style="font-size:1.2rem;">${meta.icon}</span><span><strong>${meta.title}</strong><br><span style="font-size:0.8rem;color:${primary ? 'rgba(255,255,255,0.8)' : muted};">${meta.hint}</span></span></button>`;
273
+ }
274
+ function renderPicker(refs, opts) {
275
+ const dark = isDarkMode(opts.theme);
150
276
  const muted = dark ? '#888' : '#666';
151
- const showNip07 = hasNip07();
152
- const showAmber = isAndroid();
153
- refs.dialog.innerHTML = `
154
- <h2 style="margin:0 0 8px;font-size:1.3rem;">Sign in to ${escapeHtml(appName)}</h2>
155
- <p style="margin:0 0 24px;color:${muted};font-size:0.9rem;">Choose how you want to sign in. Your keys never leave your control.</p>
156
- <div style="display:flex;flex-direction:column;">
157
- ${showNip07 ? `<button data-choice="nip07" style="${buttonStyle(dark, true)}"><span style="font-size:1.2rem;">🌐</span><span><strong>Browser extension</strong><br><span style="font-size:0.8rem;opacity:0.8;">bark, Alby, nos2x</span></span></button>` : ''}
158
- ${showAmber ? `<button data-choice="amber" style="${buttonStyle(dark)}"><span style="font-size:1.2rem;">🤖</span><span><strong>Sign in with Amber</strong><br><span style="font-size:0.8rem;color:${muted};">Android signer (NIP-55)</span></span></button>` : ''}
159
- <button data-choice="redirect" style="${buttonStyle(dark)}"><span style="font-size:1.2rem;">🪪</span><span><strong>Sign in with Signet</strong><br><span style="font-size:0.8rem;color:${muted};">Open Signet on this device</span></span></button>
160
- <button data-choice="qr" style="${buttonStyle(dark)}"><span style="font-size:1.2rem;">📱</span><span><strong>Signet on another device</strong><br><span style="font-size:0.8rem;color:${muted};">Scan QR with your phone</span></span></button>
161
- <button data-choice="bunker" style="${buttonStyle(dark)}"><span style="font-size:1.2rem;">🔑</span><span><strong>Paste bunker URI</strong><br><span style="font-size:0.8rem;color:${muted};">For NIP-46 power users</span></span></button>
162
- <button data-choice="nostrconnect" style="${buttonStyle(dark)}"><span style="font-size:1.2rem;">📡</span><span><strong>Connect a Nostr signer</strong><br><span style="font-size:0.8rem;color:${muted};">Scan with nsec.app, Amber, Keychat…</span></span></button>
163
- <button data-choice="nsec" style="${buttonStyle(dark)}"><span style="font-size:1.2rem;">⚠️</span><span><strong>Paste private key</strong><br><span style="font-size:0.8rem;color:${muted};">In-memory only — risky, last resort</span></span></button>
164
- </div>
165
- <button data-choice="cancel" style="background:transparent;color:${dark ? '#e0e0e0' : '#1a1a2e'};border:1px solid ${dark ? '#3a3a4e' : '#d0d0d0'};border-radius:8px;padding:12px;cursor:pointer;font-size:0.95rem;width:100%;margin-top:12px;text-align:center;">Cancel</button>
166
- `;
167
277
  return new Promise(resolve => {
168
- refs.dialog.querySelectorAll('button[data-choice]').forEach(btn => {
169
- btn.addEventListener('click', () => {
170
- const choice = btn.dataset.choice;
171
- resolve(choice);
278
+ let advancedOpen = false;
279
+ const availableMethods = opts.methods.filter(isMethodAvailable);
280
+ const advancedSet = new Set(opts.advancedMethods);
281
+ const primaryMethods = availableMethods.filter(method => !advancedSet.has(method));
282
+ const advancedMethods = availableMethods.filter(method => advancedSet.has(method));
283
+ const attachChoiceHandlers = () => {
284
+ refs.dialog.querySelectorAll('button[data-choice]').forEach(btn => {
285
+ btn.addEventListener('click', () => {
286
+ const choice = btn.dataset.choice;
287
+ resolve(choice);
288
+ });
172
289
  });
173
- });
290
+ refs.dialog.querySelector('[data-action="advanced"]')?.addEventListener('click', () => {
291
+ advancedOpen = true;
292
+ paint();
293
+ });
294
+ };
295
+ const paint = () => {
296
+ const showAdvanced = advancedOpen || primaryMethods.length === 0;
297
+ const primaryHtml = primaryMethods.map((method, index) => methodButtonHtml(method, dark, muted, index === 0)).join('');
298
+ const advancedHtml = showAdvanced
299
+ ? advancedMethods.map((method, index) => methodButtonHtml(method, dark, muted, primaryMethods.length === 0 && index === 0)).join('')
300
+ : '';
301
+ const advancedToggle = advancedMethods.length > 0 && !showAdvanced
302
+ ? `<button data-action="advanced" style="${buttonStyle(dark)}justify-content:center;text-align:center;">Advanced</button>`
303
+ : '';
304
+ const empty = availableMethods.length === 0
305
+ ? `<p style="margin:0 0 12px;color:${muted};font-size:0.85rem;">No configured sign-in methods are available on this device.</p>`
306
+ : '';
307
+ refs.dialog.innerHTML = `
308
+ <h2 style="margin:0 0 8px;font-size:1.3rem;">Sign in to ${escapeHtml(opts.appName)}</h2>
309
+ <p style="margin:0 0 24px;color:${muted};font-size:0.9rem;">Choose how you want to sign in. Your keys never leave your control.</p>
310
+ <div style="display:flex;flex-direction:column;">
311
+ ${empty}
312
+ ${primaryHtml}
313
+ ${advancedToggle}
314
+ ${advancedHtml}
315
+ </div>
316
+ <button data-choice="cancel" style="background:transparent;color:${dark ? '#e0e0e0' : '#1a1a2e'};border:1px solid ${dark ? '#3a3a4e' : '#d0d0d0'};border-radius:8px;padding:12px;cursor:pointer;font-size:0.95rem;width:100%;margin-top:12px;text-align:center;">Cancel</button>
317
+ `;
318
+ attachChoiceHandlers();
319
+ };
320
+ paint();
174
321
  });
175
322
  }
176
323
  /**
@@ -424,30 +571,71 @@ async function runBunkerFlow(refs, opts) {
424
571
  const muted = dark ? '#888' : '#666';
425
572
  const inputBg = dark ? '#0f0f1f' : '#f5f5f8';
426
573
  const inputFg = dark ? '#e0e0e0' : '#1a1a2e';
574
+ const scanButton = canUseCameraQrScanner()
575
+ ? `<button data-action="scan" style="${buttonStyle(dark)}width:auto;flex:0 0 auto;padding:8px 16px;">Scan QR</button>`
576
+ : '';
427
577
  refs.dialog.innerHTML = `
428
578
  <h2 style="margin:0 0 8px;font-size:1.2rem;">Paste bunker URI</h2>
429
- <p style="margin:0 0 16px;color:${muted};font-size:0.85rem;">Connect to your NIP-46 bunker (Heartwood, nsecBunker, or any compatible signer).</p>
579
+ <p style="margin:0 0 16px;color:${muted};font-size:0.85rem;">Connect to your NIP-46 bunker (Heartwood, nsecBunker, Amber, or any compatible signer).</p>
430
580
  <textarea id="signet-login-bunker-input" placeholder="bunker://..." rows="3" style="width:100%;background:${inputBg};color:${inputFg};border:1px solid ${dark ? '#3a3a4e' : '#d0d0d0'};border-radius:8px;padding:10px;font-size:0.85rem;font-family:ui-monospace,monospace;box-sizing:border-box;resize:vertical;margin-bottom:12px;"></textarea>
581
+ <div id="signet-login-bunker-scan" hidden style="margin:0 0 12px;"></div>
431
582
  <p id="signet-login-bunker-status" style="margin:0 0 12px;color:${muted};font-size:0.85rem;min-height:1.2em;"></p>
432
583
  <div style="display:flex;gap:8px;justify-content:space-between;">
433
584
  <button data-action="back" style="${buttonStyle(dark)}width:auto;flex:0 0 auto;padding:8px 16px;">← Back</button>
585
+ ${scanButton}
434
586
  <button data-action="connect" style="${buttonStyle(dark, true)}width:auto;flex:1;padding:8px 16px;text-align:center;">Connect</button>
435
587
  </div>
436
588
  `;
437
589
  return new Promise(resolve => {
438
590
  let settled = false;
591
+ let scanGeneration = 0;
439
592
  const settle = (v) => {
440
593
  if (settled)
441
594
  return;
442
595
  settled = true;
596
+ scanGeneration++;
597
+ scanner?.stop();
443
598
  resolve(v);
444
599
  };
445
600
  const input = refs.dialog.querySelector('#signet-login-bunker-input');
446
601
  const status = refs.dialog.querySelector('#signet-login-bunker-status');
602
+ const scanContainer = refs.dialog.querySelector('#signet-login-bunker-scan');
447
603
  const connectBtn = refs.dialog.querySelector('[data-action="connect"]');
604
+ const scanBtn = refs.dialog.querySelector('[data-action="scan"]');
605
+ let scanner = null;
448
606
  refs.dialog.querySelector('[data-action="back"]')?.addEventListener('click', () => {
607
+ scanner?.stop();
449
608
  settle(null);
450
609
  });
610
+ scanBtn?.addEventListener('click', () => {
611
+ if (!input || !scanContainer)
612
+ return;
613
+ scanner?.stop();
614
+ scanner = null;
615
+ const generation = ++scanGeneration;
616
+ void startCameraQrScanner({
617
+ container: scanContainer,
618
+ status,
619
+ acceptedPrefixes: ['bunker://'],
620
+ onValue: value => {
621
+ input.value = value;
622
+ input.focus();
623
+ },
624
+ }).then(handle => {
625
+ if (settled || generation !== scanGeneration) {
626
+ handle.stop();
627
+ return;
628
+ }
629
+ scanner = handle;
630
+ }).catch(err => {
631
+ if (settled || generation !== scanGeneration)
632
+ return;
633
+ if (status) {
634
+ status.textContent = `✗ ${err instanceof Error ? err.message : String(err)}`;
635
+ status.style.color = '#d04848';
636
+ }
637
+ });
638
+ });
451
639
  connectBtn?.addEventListener('click', async () => {
452
640
  const uri = input?.value.trim() ?? '';
453
641
  if (!uri) {
@@ -492,15 +680,15 @@ async function runNostrConnectFlow(refs, opts) {
492
680
  const secret = bytesToHex(schnorr.utils.randomPrivateKey()).slice(0, 32);
493
681
  const uri = buildNostrConnectUri({
494
682
  clientPubkeyHex: clientPubkey,
495
- relayUrl: opts.relayUrl,
683
+ relayUrls: opts.relayUrls,
496
684
  secret,
497
- perms: ['sign_event', 'nip44_encrypt', 'nip44_decrypt'],
685
+ perms: opts.nostrConnectPerms,
498
686
  appName: opts.appName,
499
687
  appUrl: opts.origin,
500
688
  });
501
689
  refs.dialog.innerHTML = `
502
690
  <h2 style="margin:0 0 8px;font-size:1.2rem;">Connect a Nostr signer</h2>
503
- <p style="margin:0 0 16px;color:${muted};font-size:0.85rem;">Scan or paste this into your signer (nsec.app, Amber, Keychat). The connection happens over your relay.</p>
691
+ <p style="margin:0 0 16px;color:${muted};font-size:0.85rem;">Scan or paste this into your signer (nsec.app, Amber, Keychat...). The connection happens over your configured relay${opts.relayUrls.length > 1 ? 's' : ''}.</p>
504
692
  <div style="background:${dark ? '#0f0f1f' : '#f5f5f8'};border-radius:8px;padding:16px;margin-bottom:16px;">
505
693
  <canvas id="signet-login-nc-qr" width="200" height="200" style="display:block;width:200px;height:200px;margin:0 auto 12px;background:#ffffff;border-radius:6px;box-sizing:border-box;"></canvas>
506
694
  <button data-action="copy" style="${buttonStyle(dark)}width:auto;font-size:0.75rem;padding:6px 10px;margin:0 auto;display:block;">Copy URI</button>
@@ -613,17 +801,46 @@ async function runNsecFlow(refs, opts) {
613
801
  });
614
802
  });
615
803
  }
804
+ function uniquePickerMethods(input, fallback) {
805
+ const source = input ?? fallback;
806
+ const allowed = new Set(DEFAULT_PICKER_METHODS);
807
+ const out = [];
808
+ for (const method of source) {
809
+ if (!allowed.has(method))
810
+ continue;
811
+ if (!out.includes(method))
812
+ out.push(method);
813
+ }
814
+ return input === undefined && out.length === 0 ? [...fallback] : out;
815
+ }
816
+ function resolveMethodConfig(opts) {
817
+ const methods = uniquePickerMethods(opts.methods, DEFAULT_PICKER_METHODS);
818
+ const advancedMethods = uniquePickerMethods(opts.advancedMethods, DEFAULT_ADVANCED_METHODS)
819
+ .filter(method => methods.includes(method));
820
+ return { methods, advancedMethods };
821
+ }
822
+ function resolveRelayUrls(opts) {
823
+ const relayUrls = opts.relayUrls ?? (opts.relayUrl ? [opts.relayUrl] : [DEFAULTS.relayUrl]);
824
+ const cleanRelayUrls = relayUrls.map(relay => relay.trim()).filter(Boolean);
825
+ return cleanRelayUrls.length > 0 ? cleanRelayUrls : [DEFAULTS.relayUrl];
826
+ }
616
827
  function resolveOptions(opts) {
617
828
  const challenge = opts.challenge ?? generateChallenge();
618
829
  if (!/^[0-9a-f]{64}$/i.test(challenge))
619
830
  throw new Error('challenge-must-be-64-hex');
620
831
  const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost';
621
832
  const timeout = Math.max(5000, Math.min(opts.timeout ?? DEFAULTS.timeout, 600000));
833
+ const relayUrls = resolveRelayUrls(opts);
834
+ const methodConfig = resolveMethodConfig(opts);
622
835
  const result = {
623
836
  appName: opts.appName,
624
837
  challenge: challenge.toLowerCase(),
625
838
  origin,
626
- relayUrl: opts.relayUrl ?? DEFAULTS.relayUrl,
839
+ methods: methodConfig.methods,
840
+ advancedMethods: methodConfig.advancedMethods,
841
+ relayUrl: relayUrls[0],
842
+ relayUrls,
843
+ nostrConnectPerms: opts.nostrConnectPerms ?? DEFAULT_NOSTR_CONNECT_PERMS,
627
844
  theme: opts.theme ?? DEFAULTS.theme,
628
845
  timeout,
629
846
  signetAppOrigin: opts.signetAppOrigin ?? DEFAULTS.signetAppOrigin,
@@ -672,7 +889,7 @@ async function runLoginModal(opts) {
672
889
  while (true) {
673
890
  const choice = resolved.preferredMethod
674
891
  ? resolved.preferredMethod
675
- : await Promise.race([renderPicker(refs, resolved.appName, resolved.theme), aborted]);
892
+ : await Promise.race([renderPicker(refs, resolved), aborted]);
676
893
  if (userAborted)
677
894
  return null;
678
895
  if (choice === null || choice === 'cancel')
package/dist/signers.d.ts CHANGED
@@ -77,7 +77,8 @@ export declare function createBunkerSignerFromNostrConnect(input: {
77
77
  */
78
78
  export declare function buildNostrConnectUri(input: {
79
79
  clientPubkeyHex: string;
80
- relayUrl: string;
80
+ relayUrl?: string;
81
+ relayUrls?: string[];
81
82
  secret: string;
82
83
  perms?: string[];
83
84
  appName?: string;
package/dist/signers.js CHANGED
@@ -126,12 +126,21 @@ export async function createBunkerSignerFromNostrConnect(input) {
126
126
  * it's talking to the right peer; it must be unguessable.
127
127
  */
128
128
  export function buildNostrConnectUri(input) {
129
- const { clientPubkeyHex, relayUrl, secret } = input;
129
+ const { clientPubkeyHex, secret } = input;
130
130
  if (!/^[0-9a-f]{64}$/i.test(clientPubkeyHex))
131
131
  throw new Error('invalid-client-pubkey');
132
- if (!/^wss?:\/\//.test(relayUrl))
133
- throw new Error('invalid-relay-url');
134
- const params = new URLSearchParams({ relay: relayUrl, secret });
132
+ const relayUrls = input.relayUrls ?? (input.relayUrl ? [input.relayUrl] : []);
133
+ const cleanRelayUrls = relayUrls.map(relay => relay.trim()).filter(Boolean);
134
+ if (cleanRelayUrls.length === 0)
135
+ throw new Error('relay-url-required');
136
+ for (const relayUrl of cleanRelayUrls) {
137
+ if (!/^wss?:\/\//.test(relayUrl))
138
+ throw new Error('invalid-relay-url');
139
+ }
140
+ const params = new URLSearchParams();
141
+ for (const relayUrl of cleanRelayUrls)
142
+ params.append('relay', relayUrl);
143
+ params.set('secret', secret);
135
144
  if (input.perms && input.perms.length > 0)
136
145
  params.set('perms', input.perms.join(','));
137
146
  if (input.appName)