signet-login 0.5.0 β†’ 0.6.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/dist/modal.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * top-layer placement, theme-aware colours, no third-party UI deps.
6
6
  */
7
7
  import { DEFAULTS } from './types.js';
8
- import { hasNip07, createNip07Signer, createBunkerSigner, EphemeralSigner, createLocalSignerFromNsec } from './signers.js';
8
+ import { hasNip07, createNip07Signer, createBunkerSigner, createBunkerSignerFromNostrConnect, buildNostrConnectUri, EphemeralSigner, createLocalSignerFromNsec } from './signers.js';
9
9
  import { waitForAuthResponse } from 'signet-verify';
10
10
  import { schnorr } from '@noble/curves/secp256k1';
11
11
  import { bytesToHex } from '@noble/hashes/utils';
@@ -69,6 +69,7 @@ function renderPicker(refs, appName, theme) {
69
69
  <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>
70
70
  <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>
71
71
  <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>
72
+ <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>
72
73
  <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>
73
74
  </div>
74
75
  <button data-choice="cancel" style="background:none;border:0;color:${muted};padding:12px;cursor:pointer;font-size:0.85rem;margin-top:8px;">Cancel</button>
@@ -309,6 +310,85 @@ async function runBunkerFlow(refs, opts) {
309
310
  });
310
311
  });
311
312
  }
313
+ // ── Connect a Nostr signer (NostrConnect URI, app-initiated NIP-46) ──────────
314
+ /**
315
+ * App-initiated NIP-46. Mirror image of bunker URI: instead of the user
316
+ * pasting a bunker URI from their signer, we generate a `nostrconnect://`
317
+ * URI and the user scans it with their signer (nsec.app, Amber, Keychat…).
318
+ * The signer connects to our chosen relay and signs ad-hoc from there.
319
+ */
320
+ async function runNostrConnectFlow(refs, opts) {
321
+ const dark = isDarkMode(opts.theme);
322
+ const muted = dark ? '#888' : '#666';
323
+ const sk = schnorr.utils.randomPrivateKey();
324
+ const clientPubkey = bytesToHex(schnorr.getPublicKey(sk));
325
+ const secret = bytesToHex(schnorr.utils.randomPrivateKey()).slice(0, 32);
326
+ const uri = buildNostrConnectUri({
327
+ clientPubkeyHex: clientPubkey,
328
+ relayUrl: opts.relayUrl,
329
+ secret,
330
+ perms: ['sign_event', 'nip44_encrypt', 'nip44_decrypt'],
331
+ appName: opts.appName,
332
+ appUrl: opts.origin,
333
+ });
334
+ refs.dialog.innerHTML = `
335
+ <h2 style="margin:0 0 8px;font-size:1.2rem;">Connect a Nostr signer</h2>
336
+ <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>
337
+ <div style="background:${dark ? '#0f0f1f' : '#f5f5f8'};border-radius:8px;padding:16px;margin-bottom:16px;">
338
+ <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>
339
+ <button data-action="copy" style="${buttonStyle(dark)}width:auto;font-size:0.75rem;padding:6px 10px;margin:0 auto;display:block;">Copy URI</button>
340
+ </div>
341
+ <p id="signet-login-nc-status" style="margin:0 0 12px;color:${muted};font-size:0.85rem;">Waiting for signer to connect…</p>
342
+ <div style="display:flex;gap:8px;justify-content:space-between;">
343
+ <button data-action="back" style="${buttonStyle(dark)}width:auto;flex:0 0 auto;padding:8px 16px;">← Back</button>
344
+ <button data-action="cancel" style="${buttonStyle(dark)}width:auto;flex:0 0 auto;padding:8px 16px;">Cancel</button>
345
+ </div>
346
+ `;
347
+ const qrCanvas = refs.dialog.querySelector('#signet-login-nc-qr');
348
+ if (qrCanvas) {
349
+ void QRCode.toCanvas(qrCanvas, uri, {
350
+ width: 200, margin: 1, errorCorrectionLevel: 'M',
351
+ color: { dark: '#0a0418', light: '#ffffff' },
352
+ }).catch(() => { });
353
+ }
354
+ const copyBtn = refs.dialog.querySelector('[data-action="copy"]');
355
+ copyBtn?.addEventListener('click', () => {
356
+ void navigator.clipboard?.writeText(uri).then(() => {
357
+ copyBtn.textContent = 'Copied βœ“';
358
+ window.setTimeout(() => { copyBtn.textContent = 'Copy URI'; }, 1500);
359
+ });
360
+ });
361
+ const ac = new AbortController();
362
+ const status = refs.dialog.querySelector('#signet-login-nc-status');
363
+ return new Promise(resolve => {
364
+ let settled = false;
365
+ const settle = (v) => {
366
+ if (settled)
367
+ return;
368
+ settled = true;
369
+ resolve(v);
370
+ };
371
+ refs.dialog.querySelector('[data-action="back"]')?.addEventListener('click', () => {
372
+ ac.abort();
373
+ settle(null);
374
+ });
375
+ refs.dialog.querySelector('[data-action="cancel"]')?.addEventListener('click', () => {
376
+ ac.abort();
377
+ settle(null);
378
+ });
379
+ createBunkerSignerFromNostrConnect({ uri, clientSecretKey: sk, abortSignal: ac.signal })
380
+ .then(signer => settle(signer))
381
+ .catch(err => {
382
+ if (settled)
383
+ return; // already cancelled
384
+ if (status) {
385
+ status.textContent = `βœ— ${err instanceof Error ? err.message : String(err)}`;
386
+ status.style.color = '#d04848';
387
+ }
388
+ // Don't auto-settle on error β€” user clicks Back/Cancel.
389
+ });
390
+ });
391
+ }
312
392
  // ── Paste nsec (in-memory only) ───────────────────────────────────────────────
313
393
  async function runNsecFlow(refs, opts) {
314
394
  const dark = isDarkMode(opts.theme);
@@ -483,6 +563,33 @@ export async function showLoginModal(opts) {
483
563
  authEvent,
484
564
  };
485
565
  }
566
+ if (choice === 'nostrconnect') {
567
+ const signer = await runNostrConnectFlow(refs, resolved);
568
+ if (!signer) {
569
+ if (resolved.preferredMethod)
570
+ return null;
571
+ continue;
572
+ }
573
+ const authEvent = await signer.signEvent({
574
+ kind: 21236,
575
+ content: '',
576
+ tags: [
577
+ ['challenge', resolved.challenge],
578
+ ['origin', resolved.origin],
579
+ ['app', resolved.appName],
580
+ ],
581
+ });
582
+ // Surfaces as 'bunker' since the session shape is identical to a
583
+ // bunker URI session β€” same signer, same persistence path, same
584
+ // capabilities. The picker choice routed us here; from this point
585
+ // on the rest of the SDK doesn't care about the initiation direction.
586
+ return {
587
+ pubkey: signer.pubkey,
588
+ method: 'bunker',
589
+ signer,
590
+ authEvent,
591
+ };
592
+ }
486
593
  if (choice === 'nsec') {
487
594
  const signer = await runNsecFlow(refs, resolved);
488
595
  if (!signer) {
package/dist/signers.d.ts CHANGED
@@ -54,6 +54,35 @@ export declare class BunkerSignerImpl implements SignetSigner {
54
54
  signEvent(template: EventTemplate): Promise<NostrEvent>;
55
55
  close(): Promise<void>;
56
56
  }
57
+ /**
58
+ * App-initiated NIP-46: we generate a `nostrconnect://` URI containing our
59
+ * client pubkey, relay, secret, and requested perms. The user pastes/scans
60
+ * it into their signer, which then connects to the relay and acks. Returns
61
+ * a BunkerSignerImpl once the handshake completes (or rejects on abort).
62
+ *
63
+ * uri β€” the nostrconnect:// URI shown to the user (built by
64
+ * the caller via buildNostrConnectUri)
65
+ * clientSecretKey β€” the 32-byte session key the URI was built with
66
+ * abortSignal β€” cancel a long-running wait when the modal closes
67
+ */
68
+ export declare function createBunkerSignerFromNostrConnect(input: {
69
+ uri: string;
70
+ clientSecretKey: Uint8Array;
71
+ abortSignal?: AbortSignal;
72
+ }): Promise<BunkerSignerImpl>;
73
+ /**
74
+ * Build a NIP-46 `nostrconnect://` URI for the app-initiated flow. The
75
+ * `secret` is echoed back by the bunker on connect so the app can verify
76
+ * it's talking to the right peer; it must be unguessable.
77
+ */
78
+ export declare function buildNostrConnectUri(input: {
79
+ clientPubkeyHex: string;
80
+ relayUrl: string;
81
+ secret: string;
82
+ perms?: string[];
83
+ appName?: string;
84
+ appUrl?: string;
85
+ }): string;
57
86
  /**
58
87
  * Connect a bunker session from a `bunker://` or `nostr+connect://` URI (or a
59
88
  * NIP-05 identifier). Generates a fresh client secret key for the session.
package/dist/signers.js CHANGED
@@ -80,6 +80,51 @@ export class BunkerSignerImpl {
80
80
  await this.bunker.close();
81
81
  }
82
82
  }
83
+ /**
84
+ * App-initiated NIP-46: we generate a `nostrconnect://` URI containing our
85
+ * client pubkey, relay, secret, and requested perms. The user pastes/scans
86
+ * it into their signer, which then connects to the relay and acks. Returns
87
+ * a BunkerSignerImpl once the handshake completes (or rejects on abort).
88
+ *
89
+ * uri β€” the nostrconnect:// URI shown to the user (built by
90
+ * the caller via buildNostrConnectUri)
91
+ * clientSecretKey β€” the 32-byte session key the URI was built with
92
+ * abortSignal β€” cancel a long-running wait when the modal closes
93
+ */
94
+ export async function createBunkerSignerFromNostrConnect(input) {
95
+ const { uri, clientSecretKey, abortSignal } = input;
96
+ if (clientSecretKey.length !== 32)
97
+ throw new Error('invalid-client-secret-key');
98
+ const bunker = abortSignal
99
+ ? await BunkerSigner.fromURI(clientSecretKey, uri, undefined, abortSignal)
100
+ : await BunkerSigner.fromURI(clientSecretKey, uri);
101
+ const pubkey = await bunker.getPublicKey();
102
+ if (!/^[0-9a-f]{64}$/i.test(pubkey)) {
103
+ await bunker.close().catch(() => { });
104
+ throw new Error('invalid-pubkey-from-bunker');
105
+ }
106
+ return new BunkerSignerImpl(pubkey.toLowerCase(), bunker, uri, clientSecretKey);
107
+ }
108
+ /**
109
+ * Build a NIP-46 `nostrconnect://` URI for the app-initiated flow. The
110
+ * `secret` is echoed back by the bunker on connect so the app can verify
111
+ * it's talking to the right peer; it must be unguessable.
112
+ */
113
+ export function buildNostrConnectUri(input) {
114
+ const { clientPubkeyHex, relayUrl, secret } = input;
115
+ if (!/^[0-9a-f]{64}$/i.test(clientPubkeyHex))
116
+ throw new Error('invalid-client-pubkey');
117
+ if (!/^wss?:\/\//.test(relayUrl))
118
+ throw new Error('invalid-relay-url');
119
+ const params = new URLSearchParams({ relay: relayUrl, secret });
120
+ if (input.perms && input.perms.length > 0)
121
+ params.set('perms', input.perms.join(','));
122
+ if (input.appName)
123
+ params.set('name', input.appName);
124
+ if (input.appUrl)
125
+ params.set('url', input.appUrl);
126
+ return `nostrconnect://${clientPubkeyHex}?${params.toString()}`;
127
+ }
83
128
  /**
84
129
  * Connect a bunker session from a `bunker://` or `nostr+connect://` URI (or a
85
130
  * NIP-05 identifier). Generates a fresh client secret key for the session.