signet-login 0.10.1 → 0.10.3

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
@@ -6,7 +6,8 @@ Published as `signet-login`.
6
6
 
7
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:
8
8
 
9
- - **Sign in with Signet** on this device or by cross-device QR
9
+ - **Local Signet** on this device, against hosted or local-dev Signet
10
+ - **Remote Signet** by cross-device QR, so a phone or second machine can approve
10
11
  - **Browser extension** via NIP-07 (bark, Alby, nos2x, Flamingo, ...)
11
12
  - **Connect a Nostr signer** via app-initiated NIP-46 / NostrConnect
12
13
  - **Paste or scan bunker URI** for Heartwood, nsecBunker, Amber, or compatible signers
@@ -88,8 +89,10 @@ interface SignetStorage {
88
89
 
89
90
  type LoginPickerMethod =
90
91
  | 'nip07'
91
- | 'redirect' // same-device Signet, relay delivery
92
- | 'qr' // cross-device Signet QR
92
+ | 'local-signet' // same-device Signet, relay delivery
93
+ | 'remote-signet' // cross-device Signet QR
94
+ | 'redirect' // legacy alias for local-signet
95
+ | 'qr' // legacy alias for remote-signet
93
96
  | 'bunker' // paste bunker://
94
97
  | 'nostrconnect' // show nostrconnect:// QR
95
98
  | 'amber' // Android NIP-55
@@ -110,7 +113,14 @@ By default, the picker shows ordinary user-facing methods first and groups `bunk
110
113
  ```js
111
114
  await Signet.login({
112
115
  appName: 'My Game',
113
- methods: ['redirect', 'qr', 'nip07'],
116
+ methods: ['local-signet', 'remote-signet', 'nip07'],
117
+ });
118
+
119
+ await Signet.login({
120
+ appName: 'My Local Dev Game',
121
+ preferredMethod: 'local-signet',
122
+ signetAppOrigin: 'http://localhost:5174',
123
+ relayUrl: 'ws://localhost:7777',
114
124
  });
115
125
 
116
126
  await Signet.login({
@@ -121,6 +131,8 @@ await Signet.login({
121
131
  });
122
132
  ```
123
133
 
134
+ `redirect` and `qr` remain supported picker aliases for existing apps, but new integrations should use `local-signet` and `remote-signet`.
135
+
124
136
  When camera APIs are available, the bunker URI screen can scan `bunker://` QR codes directly. Paste remains the fallback.
125
137
 
126
138
  ### Headless/custom UI
@@ -152,9 +164,22 @@ await fetch('/api/login', {
152
164
  });
153
165
  ```
154
166
 
155
- 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`.
156
168
  The IIFE bundle attaches the same helpers to `window.Signet`.
157
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
+
158
183
  ### Custom storage
159
184
 
160
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
@@ -14,7 +14,8 @@ import { bytesToHex } from '@noble/hashes/utils';
14
14
  import QRCode from 'qrcode';
15
15
  import jsQR from 'jsqr';
16
16
  const QR_BUNKER_CONNECT_TIMEOUT_MS = 8000;
17
- const DEFAULT_PICKER_METHODS = ['nip07', 'amber', 'redirect', 'qr', 'bunker', 'nostrconnect', 'nsec'];
17
+ const DEFAULT_PICKER_METHODS = ['nip07', 'amber', 'local-signet', 'remote-signet', 'bunker', 'nostrconnect', 'nsec'];
18
+ const ALL_PICKER_METHODS = [...DEFAULT_PICKER_METHODS, 'redirect', 'qr'];
18
19
  const DEFAULT_ADVANCED_METHODS = ['bunker', 'nostrconnect', 'nsec'];
19
20
  const DEFAULT_NOSTR_CONNECT_PERMS = ['sign_event', 'nip44_encrypt', 'nip44_decrypt'];
20
21
  function escapeHtml(str) {
@@ -254,12 +255,28 @@ async function startCameraQrScanner(input) {
254
255
  const METHOD_META = {
255
256
  nip07: { icon: '🌐', title: 'Browser extension', hint: 'bark, Alby, nos2x' },
256
257
  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' },
258
+ 'local-signet': { icon: '🪪', title: 'Local Signet', hint: 'Open Signet on this device' },
259
+ 'remote-signet': { icon: '📱', title: 'Remote Signet', hint: 'Scan with Signet on another device' },
260
+ redirect: { icon: '🪪', title: 'Local Signet', hint: 'Open Signet on this device' },
261
+ qr: { icon: '📱', title: 'Remote Signet', hint: 'Scan with Signet on another device' },
259
262
  bunker: { icon: '🔑', title: 'Paste bunker URI', hint: 'For NIP-46 power users' },
260
263
  nostrconnect: { icon: '📡', title: 'Connect a Nostr signer', hint: 'Scan with nsec.app, Amber, Keychat...' },
261
264
  nsec: { icon: '⚠️', title: 'Paste private key', hint: 'In-memory only - risky, last resort' },
262
265
  };
266
+ function pickerMethodKey(method) {
267
+ if (method === 'local-signet' || method === 'redirect')
268
+ return 'local-signet';
269
+ if (method === 'remote-signet' || method === 'qr')
270
+ return 'remote-signet';
271
+ return method;
272
+ }
273
+ function routePickerChoice(choice) {
274
+ if (choice === 'local-signet')
275
+ return 'redirect';
276
+ if (choice === 'remote-signet')
277
+ return 'qr';
278
+ return choice;
279
+ }
263
280
  function isMethodAvailable(method) {
264
281
  if (method === 'nip07')
265
282
  return hasNip07();
@@ -277,9 +294,9 @@ function renderPicker(refs, opts) {
277
294
  return new Promise(resolve => {
278
295
  let advancedOpen = false;
279
296
  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));
297
+ const advancedSet = new Set(opts.advancedMethods.map(pickerMethodKey));
298
+ const primaryMethods = availableMethods.filter(method => !advancedSet.has(pickerMethodKey(method)));
299
+ const advancedMethods = availableMethods.filter(method => advancedSet.has(pickerMethodKey(method)));
283
300
  const attachChoiceHandlers = () => {
284
301
  refs.dialog.querySelectorAll('button[data-choice]').forEach(btn => {
285
302
  btn.addEventListener('click', () => {
@@ -806,20 +823,25 @@ async function runNsecFlow(refs, opts) {
806
823
  }
807
824
  function uniquePickerMethods(input, fallback) {
808
825
  const source = input ?? fallback;
809
- const allowed = new Set(DEFAULT_PICKER_METHODS);
826
+ const allowed = new Set(ALL_PICKER_METHODS);
827
+ const seen = new Set();
810
828
  const out = [];
811
829
  for (const method of source) {
812
830
  if (!allowed.has(method))
813
831
  continue;
814
- if (!out.includes(method))
815
- out.push(method);
832
+ const key = pickerMethodKey(method);
833
+ if (seen.has(key))
834
+ continue;
835
+ seen.add(key);
836
+ out.push(method);
816
837
  }
817
838
  return input === undefined && out.length === 0 ? [...fallback] : out;
818
839
  }
819
840
  function resolveMethodConfig(opts) {
820
841
  const methods = uniquePickerMethods(opts.methods, DEFAULT_PICKER_METHODS);
842
+ const methodKeys = new Set(methods.map(pickerMethodKey));
821
843
  const advancedMethods = uniquePickerMethods(opts.advancedMethods, DEFAULT_ADVANCED_METHODS)
822
- .filter(method => methods.includes(method));
844
+ .filter(method => methodKeys.has(pickerMethodKey(method)));
823
845
  return { methods, advancedMethods };
824
846
  }
825
847
  function resolveRelayUrls(opts) {
@@ -895,11 +917,12 @@ async function runLoginModal(opts) {
895
917
  const choice = resolved.preferredMethod
896
918
  ? resolved.preferredMethod
897
919
  : await Promise.race([renderPicker(refs, resolved), aborted]);
920
+ const routeChoice = choice === null ? null : routePickerChoice(choice);
898
921
  if (userAborted)
899
922
  return null;
900
- if (choice === null || choice === 'cancel')
923
+ if (routeChoice === null || routeChoice === 'cancel')
901
924
  return null;
902
- if (choice === 'nip07') {
925
+ if (routeChoice === 'nip07') {
903
926
  const result = await Promise.race([runNip07Flow(refs, resolved), aborted]);
904
927
  if (userAborted)
905
928
  return null;
@@ -919,7 +942,7 @@ async function runLoginModal(opts) {
919
942
  authEvent: result.authEvent,
920
943
  };
921
944
  }
922
- if (choice === 'redirect') {
945
+ if (routeChoice === 'redirect') {
923
946
  // Same-device Signet in the modal must keep this app tab alive and keep
924
947
  // the My Signet tab alive as the ongoing bunker. Use the relay-backed
925
948
  // auth response path here; explicit `login({ mode: 'redirect' })`
@@ -942,7 +965,7 @@ async function runLoginModal(opts) {
942
965
  }
943
966
  return session;
944
967
  }
945
- if (choice === 'amber') {
968
+ if (routeChoice === 'amber') {
946
969
  // Same-tab navigation to a `nostrsigner:` URL. Android dispatches
947
970
  // it to Amber; the page comes back via callbackUrl with the signed
948
971
  // event in `?event=`. Picked up on next boot by handleRedirectCallback.
@@ -955,7 +978,7 @@ async function runLoginModal(opts) {
955
978
  });
956
979
  return null; // unreachable
957
980
  }
958
- if (choice === 'qr') {
981
+ if (routeChoice === 'qr') {
959
982
  const result = await Promise.race([runRedirectFlow(refs, resolved), aborted]);
960
983
  if (userAborted)
961
984
  return null;
@@ -974,7 +997,7 @@ async function runLoginModal(opts) {
974
997
  }
975
998
  return session;
976
999
  }
977
- if (choice === 'bunker') {
1000
+ if (routeChoice === 'bunker') {
978
1001
  const signer = await Promise.race([runBunkerFlow(refs, resolved), aborted]);
979
1002
  if (userAborted)
980
1003
  return null;
@@ -1000,7 +1023,7 @@ async function runLoginModal(opts) {
1000
1023
  authEvent,
1001
1024
  };
1002
1025
  }
1003
- if (choice === 'nostrconnect') {
1026
+ if (routeChoice === 'nostrconnect') {
1004
1027
  const signer = await Promise.race([runNostrConnectFlow(refs, resolved), aborted]);
1005
1028
  if (userAborted)
1006
1029
  return null;
@@ -1029,7 +1052,7 @@ async function runLoginModal(opts) {
1029
1052
  authEvent,
1030
1053
  };
1031
1054
  }
1032
- if (choice === 'nsec') {
1055
+ if (routeChoice === 'nsec') {
1033
1056
  const signer = await Promise.race([runNsecFlow(refs, resolved), aborted]);
1034
1057
  if (userAborted)
1035
1058
  return null;
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.