signet-login 0.10.2 → 0.10.4

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
@@ -164,9 +164,22 @@ await fetch('/api/login', {
164
164
  });
165
165
  ```
166
166
 
167
- Headless exports include `hasNip07`, `createNip07Signer`, `createBunkerSigner`, `createBunkerSignerFromNostrConnect`, `buildNostrConnectUri`, `createLocalSignerFromNsec`, `createLoginAuthEvent`, `createSessionFromSigner`, and `generateSecretKey`.
167
+ Headless exports include `hasNip07`, `createNip07Signer`, `createBunkerSigner`, `createBunkerSignerFromNostrConnect`, `buildNostrConnectUri`, `buildBunkerUriFromNostrConnectUri`, `isBunkerUri`, `isNostrConnectUri`, `isSupportedPairingUri`, `createLocalSignerFromNsec`, `createLoginAuthEvent`, `createSessionFromSigner`, and `generateSecretKey`.
168
168
  The IIFE bundle attaches the same helpers to `window.Signet`.
169
169
 
170
+ ### NostrConnect and bunker roles
171
+
172
+ NIP-46 has two URI directions:
173
+
174
+ | URI | Producer | Consumer | Use |
175
+ |---|---|---|---|
176
+ | `nostrconnect://...` | The app / Signet Access client | Signer app scans or opens it | First pairing, where the app advertises its client pubkey, relays, requested permissions, and one-time secret |
177
+ | `bunker://...` | The signer / bunker | App connects to it | Reconnect, paste/scan bunker flows, native clients, and persisted sessions |
178
+
179
+ `createBunkerSignerFromNostrConnect()` waits for the signer response and then stores the equivalent `bunker://` reconnect URI internally, preserving the relay list and secret. This matters for apps such as Canary, Pallasite, and Axenstax: the user can pair once with NostrConnect, then `restoreSession()` can reconnect with the same stable client key instead of showing a fresh pairing request.
180
+
181
+ Signet Access is the app-side session broker. Identity creation, recovery, and derived personas belong in Signet, Heartwood, and `nsec-tree`; app integrations should consume the returned pubkey and capability flags instead of deriving identities inside the login SDK.
182
+
170
183
  ### Custom storage
171
184
 
172
185
  By default, Signet Access stores session state in localStorage under `signet:login.*`. Pass `storage` when you need encrypted, async, IndexedDB, server-backed, or test storage:
package/dist/modal.js CHANGED
@@ -18,6 +18,7 @@ const DEFAULT_PICKER_METHODS = ['nip07', 'amber', 'local-signet', 'remote-signet
18
18
  const ALL_PICKER_METHODS = [...DEFAULT_PICKER_METHODS, 'redirect', 'qr'];
19
19
  const DEFAULT_ADVANCED_METHODS = ['bunker', 'nostrconnect', 'nsec'];
20
20
  const DEFAULT_NOSTR_CONNECT_PERMS = ['sign_event', 'nip44_encrypt', 'nip44_decrypt'];
21
+ const LARGE_QR_SIZE_PX = 360;
21
22
  function escapeHtml(str) {
22
23
  return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
23
24
  }
@@ -26,6 +27,13 @@ function generateChallenge() {
26
27
  crypto.getRandomValues(bytes);
27
28
  return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
28
29
  }
30
+ function keepQrCanvasResponsive(canvas, sizePx = LARGE_QR_SIZE_PX) {
31
+ // qrcode mutates canvas.style.width/height after rendering. Reset height so
32
+ // max-width scaling on narrow phones preserves the square QR aspect ratio.
33
+ canvas.style.width = `${sizePx}px`;
34
+ canvas.style.height = 'auto';
35
+ canvas.style.maxWidth = '100%';
36
+ }
29
37
  function isDarkMode(theme) {
30
38
  if (theme === 'dark')
31
39
  return true;
@@ -441,7 +449,7 @@ async function runRedirectFlow(refs, opts, flowOpts = {}) {
441
449
  <h2 style="margin:0 0 8px;font-size:1.2rem;">${sameDevice ? 'Open My Signet' : 'Sign in with Signet'}</h2>
442
450
  <p style="margin:0 0 16px;color:${muted};font-size:0.85rem;">${sameDevice ? 'Approve in My Signet and keep that tab open so it can sign for this app.' : 'Open the link on your phone, or scan the QR if rendered.'}</p>
443
451
  <div style="background:${dark ? '#0f0f1f' : '#f5f5f8'};border-radius:8px;padding:16px;margin-bottom:16px;">
444
- <canvas id="signet-login-qr" width="360" height="360" style="display:block;width:360px;height:360px;max-width:100%;margin:0 auto 12px;background:#ffffff;border-radius:6px;box-sizing:border-box;"></canvas>
452
+ <canvas id="signet-login-qr" width="${LARGE_QR_SIZE_PX}" height="${LARGE_QR_SIZE_PX}" style="display:block;width:${LARGE_QR_SIZE_PX}px;height:auto;max-width:100%;margin:0 auto 12px;background:#ffffff;border-radius:6px;box-sizing:border-box;"></canvas>
445
453
  <a id="signet-login-open-signet" href="${escapeHtml(authUrl)}" target="_blank" rel="noopener" style="${sameDevice ? buttonStyle(dark, true) + 'justify-content:center;text-align:center;text-decoration:none;' : 'display:block;color:#5b6dff;font-size:0.75rem;word-break:break-all;text-decoration:none;'}">${sameDevice ? 'Open My Signet' : `${escapeHtml(authUrl.slice(0, 80))}…`}</a>
446
454
  </div>
447
455
  <p id="signet-login-status" style="margin:0 0 12px;color:${muted};font-size:0.85rem;">${sameDevice ? 'Waiting for My Signet approval…' : 'Waiting for approval…'}</p>
@@ -459,11 +467,11 @@ async function runRedirectFlow(refs, opts, flowOpts = {}) {
459
467
  const qrCanvas = refs.dialog.querySelector('#signet-login-qr');
460
468
  if (qrCanvas) {
461
469
  void QRCode.toCanvas(qrCanvas, authUrl, {
462
- width: 360,
470
+ width: LARGE_QR_SIZE_PX,
463
471
  margin: 1,
464
472
  errorCorrectionLevel: 'H',
465
473
  color: { dark: '#0a0418', light: '#ffffff' },
466
- }).catch(() => {
474
+ }).then(() => { keepQrCanvasResponsive(qrCanvas); }).catch(() => {
467
475
  // Encoding failure (URL too long for QR L-Q levels, canvas inaccessible)
468
476
  // — the visible link below the canvas still gets the user across.
469
477
  });
@@ -708,9 +716,10 @@ async function runNostrConnectFlow(refs, opts) {
708
716
  });
709
717
  refs.dialog.innerHTML = `
710
718
  <h2 style="margin:0 0 8px;font-size:1.2rem;">Connect a Nostr signer</h2>
711
- <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>
719
+ <p style="margin:0 0 16px;color:${muted};font-size:0.85rem;">Scan this with your signer (nsec.app, Amber, Keychat...), or copy/paste the URI below. The connection happens over your configured relay${opts.relayUrls.length > 1 ? 's' : ''}.</p>
712
720
  <div style="background:${dark ? '#0f0f1f' : '#f5f5f8'};border-radius:8px;padding:16px;margin-bottom:16px;">
713
- <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>
721
+ <canvas id="signet-login-nc-qr" width="${LARGE_QR_SIZE_PX}" height="${LARGE_QR_SIZE_PX}" style="display:block;width:${LARGE_QR_SIZE_PX}px;height:auto;max-width:100%;margin:0 auto 12px;background:#ffffff;border-radius:6px;box-sizing:border-box;"></canvas>
722
+ <textarea id="signet-login-nc-uri" readonly rows="4" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" aria-label="NostrConnect URI" style="width:100%;background:${dark ? '#050510' : '#ffffff'};color:${dark ? '#e7e7f0' : '#141427'};border:1px solid ${dark ? '#34344d' : '#d8dae3'};border-radius:6px;padding:8px;font-size:0.7rem;line-height:1.35;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;box-sizing:border-box;resize:vertical;margin:0 0 10px;overflow-wrap:anywhere;">${escapeHtml(uri)}</textarea>
714
723
  <button data-action="copy" style="${buttonStyle(dark)}width:auto;font-size:0.75rem;padding:6px 10px;margin:0 auto;display:block;">Copy URI</button>
715
724
  </div>
716
725
  <p id="signet-login-nc-status" style="margin:0 0 12px;color:${muted};font-size:0.85rem;">Waiting for signer to connect…</p>
@@ -722,19 +731,49 @@ async function runNostrConnectFlow(refs, opts) {
722
731
  const qrCanvas = refs.dialog.querySelector('#signet-login-nc-qr');
723
732
  if (qrCanvas) {
724
733
  void QRCode.toCanvas(qrCanvas, uri, {
725
- width: 200, margin: 1, errorCorrectionLevel: 'M',
734
+ width: LARGE_QR_SIZE_PX, margin: 2, errorCorrectionLevel: 'L',
726
735
  color: { dark: '#0a0418', light: '#ffffff' },
727
- }).catch(() => { });
736
+ }).then(() => { keepQrCanvasResponsive(qrCanvas); }).catch(() => { });
728
737
  }
738
+ const status = refs.dialog.querySelector('#signet-login-nc-status');
739
+ const uriText = refs.dialog.querySelector('#signet-login-nc-uri');
740
+ const selectUriText = () => {
741
+ if (!uriText)
742
+ return;
743
+ uriText.focus();
744
+ uriText.select();
745
+ uriText.setSelectionRange(0, uriText.value.length);
746
+ };
747
+ uriText?.addEventListener('focus', selectUriText);
748
+ uriText?.addEventListener('click', selectUriText);
729
749
  const copyBtn = refs.dialog.querySelector('[data-action="copy"]');
730
750
  copyBtn?.addEventListener('click', () => {
731
- void navigator.clipboard?.writeText(uri).then(() => {
732
- copyBtn.textContent = 'Copied ✓';
733
- window.setTimeout(() => { copyBtn.textContent = 'Copy URI'; }, 1500);
734
- });
751
+ void (async () => {
752
+ try {
753
+ if (navigator.clipboard?.writeText) {
754
+ await navigator.clipboard.writeText(uri);
755
+ }
756
+ else {
757
+ selectUriText();
758
+ if (typeof document.execCommand !== 'function' || !document.execCommand('copy')) {
759
+ throw new Error('clipboard-unavailable');
760
+ }
761
+ }
762
+ copyBtn.textContent = 'Copied ✓';
763
+ window.setTimeout(() => { copyBtn.textContent = 'Copy URI'; }, 1500);
764
+ }
765
+ catch {
766
+ selectUriText();
767
+ copyBtn.textContent = 'URI selected';
768
+ if (status) {
769
+ status.textContent = 'URI selected. Copy it manually if needed.';
770
+ status.style.color = '';
771
+ }
772
+ window.setTimeout(() => { copyBtn.textContent = 'Copy URI'; }, 2000);
773
+ }
774
+ })();
735
775
  });
736
776
  const ac = new AbortController();
737
- const status = refs.dialog.querySelector('#signet-login-nc-status');
738
777
  return new Promise(resolve => {
739
778
  let settled = false;
740
779
  const settle = (v) => {
package/dist/signers.d.ts CHANGED
@@ -85,11 +85,23 @@ export declare function buildNostrConnectUri(input: {
85
85
  appUrl?: string;
86
86
  }): string;
87
87
  /**
88
- * Connect a bunker session from a `bunker://` or `nostr+connect://` URI (or a
89
- * NIP-05 identifier). Pass `clientSecretKey` to bind a stable client pubkey the
90
- * signer can auto-approve (see `loadOrCreatePersistentClientSk`); when omitted a
91
- * fresh ephemeral key is generated, which a per-pubkey-approving bunker will
92
- * treat as a new, unapproved client.
88
+ * Convert an app-generated `nostrconnect://` pairing URI into the equivalent
89
+ * signer-published `bunker://` reconnect URI once the signer pubkey is known.
90
+ *
91
+ * `nostrconnect://` is a one-time app-to-signer invitation; it only contains
92
+ * the client pubkey. After the signer responds, future restores should use
93
+ * `bunker://signerPubkey?...` with the same relays and secret.
94
+ */
95
+ export declare function buildBunkerUriFromNostrConnectUri(nostrConnectUri: string, signerPubkeyHex: string): string;
96
+ export declare function isBunkerUri(value: string): boolean;
97
+ export declare function isNostrConnectUri(value: string): boolean;
98
+ export declare function isSupportedPairingUri(value: string): boolean;
99
+ /**
100
+ * Connect a bunker session from a `bunker://` URI or NIP-05 identifier. Pass
101
+ * `clientSecretKey` to bind a stable client pubkey the signer can auto-approve
102
+ * (see `loadOrCreatePersistentClientSk`); when omitted a fresh ephemeral key is
103
+ * generated, which a per-pubkey-approving bunker will treat as a new,
104
+ * unapproved client.
93
105
  */
94
106
  export declare function createBunkerSigner(input: {
95
107
  uri: string;
package/dist/signers.js CHANGED
@@ -118,7 +118,8 @@ export async function createBunkerSignerFromNostrConnect(input) {
118
118
  await bunker.close().catch(() => { });
119
119
  throw new Error('invalid-pubkey-from-bunker');
120
120
  }
121
- return new BunkerSignerImpl(pubkey.toLowerCase(), bunker, uri, clientSecretKey);
121
+ const normalizedBunkerUri = buildBunkerUriFromNostrConnectUri(uri, pubkey);
122
+ return new BunkerSignerImpl(pubkey.toLowerCase(), bunker, normalizedBunkerUri, clientSecretKey);
122
123
  }
123
124
  /**
124
125
  * Build a NIP-46 `nostrconnect://` URI for the app-initiated flow. The
@@ -149,6 +150,50 @@ export function buildNostrConnectUri(input) {
149
150
  params.set('url', input.appUrl);
150
151
  return `nostrconnect://${clientPubkeyHex}?${params.toString()}`;
151
152
  }
153
+ /**
154
+ * Convert an app-generated `nostrconnect://` pairing URI into the equivalent
155
+ * signer-published `bunker://` reconnect URI once the signer pubkey is known.
156
+ *
157
+ * `nostrconnect://` is a one-time app-to-signer invitation; it only contains
158
+ * the client pubkey. After the signer responds, future restores should use
159
+ * `bunker://signerPubkey?...` with the same relays and secret.
160
+ */
161
+ export function buildBunkerUriFromNostrConnectUri(nostrConnectUri, signerPubkeyHex) {
162
+ if (!/^[0-9a-f]{64}$/i.test(signerPubkeyHex))
163
+ throw new Error('invalid-signer-pubkey');
164
+ let parsed;
165
+ try {
166
+ parsed = new URL(nostrConnectUri);
167
+ }
168
+ catch {
169
+ throw new Error('invalid-nostrconnect-uri');
170
+ }
171
+ if (parsed.protocol !== 'nostrconnect:')
172
+ throw new Error('invalid-nostrconnect-uri');
173
+ const relays = parsed.searchParams.getAll('relay').map(relay => relay.trim()).filter(Boolean);
174
+ if (relays.length === 0)
175
+ throw new Error('relay-url-required');
176
+ for (const relayUrl of relays) {
177
+ if (!/^wss?:\/\//.test(relayUrl))
178
+ throw new Error('invalid-relay-url');
179
+ }
180
+ const secret = parsed.searchParams.get('secret');
181
+ const params = new URLSearchParams();
182
+ for (const relayUrl of relays)
183
+ params.append('relay', relayUrl);
184
+ if (secret)
185
+ params.set('secret', secret);
186
+ return `bunker://${signerPubkeyHex.toLowerCase()}?${params.toString()}`;
187
+ }
188
+ export function isBunkerUri(value) {
189
+ return value.trim().toLowerCase().startsWith('bunker://');
190
+ }
191
+ export function isNostrConnectUri(value) {
192
+ return value.trim().toLowerCase().startsWith('nostrconnect://');
193
+ }
194
+ export function isSupportedPairingUri(value) {
195
+ return isBunkerUri(value) || isNostrConnectUri(value);
196
+ }
152
197
  /**
153
198
  * Race a bunker handshake against a deadline. nostr-tools' `BunkerSigner`
154
199
  * `sendRequest` has no per-request timeout — it publishes the request and only
@@ -177,11 +222,11 @@ async function raceBunkerHandshake(p, ms, bunker) {
177
222
  }
178
223
  }
179
224
  /**
180
- * Connect a bunker session from a `bunker://` or `nostr+connect://` URI (or a
181
- * NIP-05 identifier). Pass `clientSecretKey` to bind a stable client pubkey the
182
- * signer can auto-approve (see `loadOrCreatePersistentClientSk`); when omitted a
183
- * fresh ephemeral key is generated, which a per-pubkey-approving bunker will
184
- * treat as a new, unapproved client.
225
+ * Connect a bunker session from a `bunker://` URI or NIP-05 identifier. Pass
226
+ * `clientSecretKey` to bind a stable client pubkey the signer can auto-approve
227
+ * (see `loadOrCreatePersistentClientSk`); when omitted a fresh ephemeral key is
228
+ * generated, which a per-pubkey-approving bunker will treat as a new,
229
+ * unapproved client.
185
230
  */
186
231
  export async function createBunkerSigner(input) {
187
232
  const trimmed = input.uri.trim();
@@ -16,7 +16,7 @@
16
16
  */
17
17
  export type { NostrEvent, EventTemplate, LoginMethod, LoginPickerMethod, SignerCapabilities, SignetSigner, SignetAuthEvent, SignetSession, LoginOptions, RestoreOptions, SignetStorage, } from './types.js';
18
18
  import type { SignetSigner, LoginOptions, RestoreOptions, SignetSession, SignetAuthEvent, SignetStorage } from './types.js';
19
- import { hasNip07, createNip07Signer, createBunkerSigner, createBunkerSignerFromNostrConnect, buildNostrConnectUri, createLocalSignerFromNsec, generateSecretKey, Nip07Signer, BunkerSignerImpl, LocalSigner } from './signers.js';
19
+ import { hasNip07, createNip07Signer, createBunkerSigner, createBunkerSignerFromNostrConnect, buildNostrConnectUri, buildBunkerUriFromNostrConnectUri, isBunkerUri, isNostrConnectUri, isSupportedPairingUri, createLocalSignerFromNsec, generateSecretKey, Nip07Signer, BunkerSignerImpl, LocalSigner } from './signers.js';
20
20
  import { type ConsumeAmberResult } from './amber.js';
21
21
  import { handleCallback as handlePopupCallback } from './callback.js';
22
22
  import type { ConsumeCallbackResult } from './redirect.js';
@@ -24,7 +24,7 @@ export type { CallbackResult } from './callback.js';
24
24
  export type { ConsumeCallbackResult } from './redirect.js';
25
25
  export type { ConsumeAmberResult } from './amber.js';
26
26
  export { isAndroid } from './amber.js';
27
- export { hasNip07, createNip07Signer, createBunkerSigner, createBunkerSignerFromNostrConnect, buildNostrConnectUri, createLocalSignerFromNsec, generateSecretKey, Nip07Signer, BunkerSignerImpl, LocalSigner, };
27
+ export { hasNip07, createNip07Signer, createBunkerSigner, createBunkerSignerFromNostrConnect, buildNostrConnectUri, buildBunkerUriFromNostrConnectUri, isBunkerUri, isNostrConnectUri, isSupportedPairingUri, createLocalSignerFromNsec, generateSecretKey, Nip07Signer, BunkerSignerImpl, LocalSigner, };
28
28
  export interface HandleRedirectCallbackOptions {
29
29
  /**
30
30
  * Await the returned `bunker://` handoff before resolving the callback.