signet-login 0.10.0 → 0.10.2

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
@@ -76,13 +77,22 @@ interface LoginOptions {
76
77
  signetAppOrigin?: string; // default https://mysignet.app
77
78
  redirectCallback?: string; // for same-device redirect / Amber return
78
79
  mode?: 'relay' | 'redirect'; // Signet delivery mode
79
- persist?: boolean; // default true (localStorage)
80
+ storage?: SignetStorage; // default localStorage
81
+ persist?: boolean; // default true
82
+ }
83
+
84
+ interface SignetStorage {
85
+ getItem(key: string): string | null | Promise<string | null>;
86
+ setItem(key: string, value: string): void | Promise<void>;
87
+ removeItem(key: string): void | Promise<void>;
80
88
  }
81
89
 
82
90
  type LoginPickerMethod =
83
91
  | 'nip07'
84
- | 'redirect' // same-device Signet, relay delivery
85
- | '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
86
96
  | 'bunker' // paste bunker://
87
97
  | 'nostrconnect' // show nostrconnect:// QR
88
98
  | 'amber' // Android NIP-55
@@ -103,7 +113,14 @@ By default, the picker shows ordinary user-facing methods first and groups `bunk
103
113
  ```js
104
114
  await Signet.login({
105
115
  appName: 'My Game',
106
- 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',
107
124
  });
108
125
 
109
126
  await Signet.login({
@@ -114,6 +131,8 @@ await Signet.login({
114
131
  });
115
132
  ```
116
133
 
134
+ `redirect` and `qr` remain supported picker aliases for existing apps, but new integrations should use `local-signet` and `remote-signet`.
135
+
117
136
  When camera APIs are available, the bunker URI screen can scan `bunker://` QR codes directly. Paste remains the fallback.
118
137
 
119
138
  ### Headless/custom UI
@@ -148,9 +167,41 @@ await fetch('/api/login', {
148
167
  Headless exports include `hasNip07`, `createNip07Signer`, `createBunkerSigner`, `createBunkerSignerFromNostrConnect`, `buildNostrConnectUri`, `createLocalSignerFromNsec`, `createLoginAuthEvent`, `createSessionFromSigner`, and `generateSecretKey`.
149
168
  The IIFE bundle attaches the same helpers to `window.Signet`.
150
169
 
170
+ ### Custom storage
171
+
172
+ 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:
173
+
174
+ ```js
175
+ const encryptedStorage = {
176
+ async getItem(key) {
177
+ const value = localStorage.getItem(key);
178
+ return value ? await decrypt(value) : null;
179
+ },
180
+ async setItem(key, value) {
181
+ localStorage.setItem(key, await encrypt(value));
182
+ },
183
+ async removeItem(key) {
184
+ localStorage.removeItem(key);
185
+ },
186
+ };
187
+
188
+ const session = await Signet.login({
189
+ appName: 'My Game',
190
+ storage: encryptedStorage,
191
+ });
192
+
193
+ await Signet.restoreSession({ storage: encryptedStorage });
194
+ await Signet.handleRedirectCallback({ storage: encryptedStorage });
195
+ await Signet.logout(session, { storage: encryptedStorage });
196
+ ```
197
+
198
+ Use the same storage adapter for `login`, `restoreSession`, `handleRedirectCallback`, and `logout`.
199
+
200
+ This adapter is deliberately not called "Stash". `@forgesworn/stash` is the separate encrypted cloud-save vault for app data; Signet Access storage is local session/reconnect state needed before a signer is available.
201
+
151
202
  ### `Signet.restoreSession(opts?)`
152
203
 
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.
204
+ Restore a session from configured storage. 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.
154
205
 
155
206
  ```js
156
207
  const session = await Signet.restoreSession();
@@ -159,7 +210,7 @@ if (session?.signer.capabilities.canSignEvents) {
159
210
  }
160
211
  ```
161
212
 
162
- ### `Signet.logout(currentSession?)`
213
+ ### `Signet.logout(currentSession?, opts?)`
163
214
 
164
215
  Clear stored session and close the active signer.
165
216
 
@@ -226,7 +277,7 @@ The verifier checks: schnorr signature, canonical event ID, kind=21236, challeng
226
277
 
227
278
  ## Storage
228
279
 
229
- Session data is stored in localStorage under `signet:login.*`:
280
+ By default, session data is stored in localStorage under `signet:login.*`:
230
281
 
231
282
  | Key | Purpose |
232
283
  |---|---|
@@ -264,7 +315,7 @@ The ESM entry is approx **5.9 KB gzipped** before bundling dependencies. The sta
264
315
 
265
316
  ## Browser support
266
317
 
267
- ES2020 baseline. Tested on modern Chrome / Firefox / Safari. Requires `localStorage`, `crypto.subtle`, `WebSocket`, and the native `<dialog>` element.
318
+ ES2020 baseline. Tested on modern Chrome / Firefox / Safari. Requires `crypto.subtle`, `WebSocket`, and the native `<dialog>` element. Session persistence defaults to `localStorage`, but apps can provide a custom storage adapter.
268
319
 
269
320
  ## Development
270
321
 
@@ -282,8 +333,6 @@ Examples in `examples/`:
282
333
 
283
334
  Build the IIFE bundle first, then serve the repo root with any static server and open `examples/basic.html` or `examples/headless.html`.
284
335
 
285
- See [docs/competitive-audit.md](docs/competitive-audit.md) for the current competitor comparison and roadmap priorities.
286
-
287
336
  ## Out of scope
288
337
 
289
338
  | Excluded | Where it lives |
package/dist/amber.d.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  * environment. Smoke test on a real Android device with Amber installed
18
18
  * before promoting to production.
19
19
  */
20
- import type { SignetSession } from './types.js';
20
+ import type { SignetStorage, SignetSession } from './types.js';
21
21
  /** True when running on a likely-Android browser. Lets the picker hide the
22
22
  * Amber option on iOS/desktop where the `nostrsigner:` scheme is unhandled. */
23
23
  export declare function isAndroid(): boolean;
@@ -27,6 +27,7 @@ export interface AmberStartOptions {
27
27
  origin: string;
28
28
  /** Optional override for the callback URL. Defaults to current page origin. */
29
29
  redirectCallback?: string;
30
+ storage?: SignetStorage;
30
31
  }
31
32
  /**
32
33
  * Build the `nostrsigner:` URL that Android dispatches to Amber. The auth
@@ -52,10 +53,5 @@ export type ConsumeAmberResult = {
52
53
  kind: 'invalid';
53
54
  reason: string;
54
55
  };
55
- /**
56
- * Consume an Amber callback. Detects `?event=<base64-or-json>` (or the
57
- * `signet_amber=1` flag) on the URL and reconstructs a session. Idempotent:
58
- * a second call after a successful consume returns 'no-callback' because
59
- * the params have been stripped.
60
- */
61
56
  export declare function consumeAmberCallback(): ConsumeAmberResult;
57
+ export declare function consumeAmberCallbackFromStorage(storage?: SignetStorage): Promise<ConsumeAmberResult>;
package/dist/amber.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { PENDING_REDIRECT_TTL_MS } from './types.js';
2
- import { clearPendingRedirect, loadPendingRedirect, savePendingRedirect, } from './storage.js';
2
+ import { clearPendingRedirect, clearPendingRedirectFromStorage, loadPendingRedirect, loadPendingRedirectFromStorage, savePendingRedirectToStorage, } from './storage.js';
3
3
  import { EphemeralSigner } from './signers.js';
4
4
  /** True when running on a likely-Android browser. Lets the picker hide the
5
5
  * Amber option on iOS/desktop where the `nostrsigner:` scheme is unhandled. */
@@ -55,7 +55,7 @@ export function buildAmberSignerUrl(opts) {
55
55
  * web URL handled by signet-app. The promise never resolves — the page is
56
56
  * gone before it can.
57
57
  */
58
- export function startAmberSignIn(opts) {
58
+ export async function startAmberSignIn(opts) {
59
59
  if (typeof window === 'undefined') {
60
60
  throw new Error('signet-login: amber mode requires a browser environment');
61
61
  }
@@ -65,7 +65,7 @@ export function startAmberSignIn(opts) {
65
65
  appName: opts.appName,
66
66
  createdAt: Date.now(),
67
67
  };
68
- savePendingRedirect(pending);
68
+ await savePendingRedirectToStorage(pending, opts.storage);
69
69
  window.location.href = buildAmberSignerUrl(opts);
70
70
  return new Promise(() => { });
71
71
  }
@@ -96,22 +96,16 @@ function cleanupAmberCallbackUrl() {
96
96
  * a second call after a successful consume returns 'no-callback' because
97
97
  * the params have been stripped.
98
98
  */
99
- export function consumeAmberCallback() {
99
+ function consumeAmberCallbackWithPending(pending, finalize) {
100
100
  if (typeof window === 'undefined')
101
101
  return { kind: 'no-callback' };
102
102
  const params = new URLSearchParams(window.location.search);
103
103
  const flagged = params.has('signet_amber') || params.has('event');
104
104
  if (!flagged)
105
105
  return { kind: 'no-callback' };
106
- const finalize = (result) => {
107
- clearPendingRedirect();
108
- cleanupAmberCallbackUrl();
109
- return result;
110
- };
111
106
  if (params.get('error') === 'denied') {
112
107
  return finalize({ kind: 'denied' });
113
108
  }
114
- const pending = loadPendingRedirect();
115
109
  if (!pending) {
116
110
  return finalize({ kind: 'invalid', reason: 'no-pending-state' });
117
111
  }
@@ -179,3 +173,19 @@ export function consumeAmberCallback() {
179
173
  };
180
174
  return finalize({ kind: 'session', session });
181
175
  }
176
+ export function consumeAmberCallback() {
177
+ const finalize = (result) => {
178
+ clearPendingRedirect();
179
+ cleanupAmberCallbackUrl();
180
+ return result;
181
+ };
182
+ return consumeAmberCallbackWithPending(loadPendingRedirect(), finalize);
183
+ }
184
+ export async function consumeAmberCallbackFromStorage(storage) {
185
+ const finalize = async (result) => {
186
+ await clearPendingRedirectFromStorage(storage);
187
+ cleanupAmberCallbackUrl();
188
+ return result;
189
+ };
190
+ return await consumeAmberCallbackWithPending(await loadPendingRedirectFromStorage(storage), finalize);
191
+ }
package/dist/modal.js CHANGED
@@ -7,14 +7,15 @@
7
7
  import { DEFAULTS } from './types.js';
8
8
  import { hasNip07, createNip07Signer, createBunkerSigner, createBunkerSignerFromNostrConnect, buildNostrConnectUri, EphemeralSigner, createLocalSignerFromNsec } from './signers.js';
9
9
  import { isAndroid, startAmberSignIn } from './amber.js';
10
- import { loadOrCreatePersistentClientSk } from './storage.js';
10
+ import { loadOrCreatePersistentClientSkFromStorage } from './storage.js';
11
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
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', () => {
@@ -506,7 +523,7 @@ async function runRedirectFlow(refs, opts, flowOpts = {}) {
506
523
  });
507
524
  });
508
525
  }
509
- async function buildSessionFromRedirectFlowResult(refs, result, _aborted) {
526
+ async function buildSessionFromRedirectFlowResult(refs, result, opts, _aborted) {
510
527
  // Default: auth-only ephemeral signer (identity proof, no live signing).
511
528
  let signer = new EphemeralSigner(result.pubkey, result.authEvent);
512
529
  let method = 'redirect';
@@ -517,7 +534,7 @@ async function buildSessionFromRedirectFlowResult(refs, result, _aborted) {
517
534
  // DeferredBunkerSigner makes them classify the session as non-signing before
518
535
  // the relay handshake can finish.
519
536
  if (result.bunkerUri) {
520
- const clientSecretKey = loadOrCreatePersistentClientSk();
537
+ const clientSecretKey = await loadOrCreatePersistentClientSkFromStorage(opts.storage);
521
538
  const expected = result.pubkey;
522
539
  const status = refs.dialog.querySelector('#signet-login-status');
523
540
  if (status)
@@ -649,7 +666,10 @@ async function runBunkerFlow(refs, opts) {
649
666
  }
650
667
  connectBtn.disabled = true;
651
668
  try {
652
- const signer = await createBunkerSigner({ uri, clientSecretKey: loadOrCreatePersistentClientSk() });
669
+ const signer = await createBunkerSigner({
670
+ uri,
671
+ clientSecretKey: await loadOrCreatePersistentClientSkFromStorage(opts.storage),
672
+ });
653
673
  settle(signer);
654
674
  }
655
675
  catch (err) {
@@ -675,7 +695,7 @@ async function runNostrConnectFlow(refs, opts) {
675
695
  // Persistent client key so the advertised client pubkey is stable across
676
696
  // logins (bunkers auto-approve a bound client pubkey). The connect `secret`
677
697
  // stays fresh per handshake — it's a one-time challenge, not an identity.
678
- const sk = loadOrCreatePersistentClientSk();
698
+ const sk = await loadOrCreatePersistentClientSkFromStorage(opts.storage);
679
699
  const clientPubkey = bytesToHex(schnorr.getPublicKey(sk));
680
700
  const secret = bytesToHex(schnorr.utils.randomPrivateKey()).slice(0, 32);
681
701
  const uri = buildNostrConnectUri({
@@ -803,20 +823,25 @@ async function runNsecFlow(refs, opts) {
803
823
  }
804
824
  function uniquePickerMethods(input, fallback) {
805
825
  const source = input ?? fallback;
806
- const allowed = new Set(DEFAULT_PICKER_METHODS);
826
+ const allowed = new Set(ALL_PICKER_METHODS);
827
+ const seen = new Set();
807
828
  const out = [];
808
829
  for (const method of source) {
809
830
  if (!allowed.has(method))
810
831
  continue;
811
- if (!out.includes(method))
812
- out.push(method);
832
+ const key = pickerMethodKey(method);
833
+ if (seen.has(key))
834
+ continue;
835
+ seen.add(key);
836
+ out.push(method);
813
837
  }
814
838
  return input === undefined && out.length === 0 ? [...fallback] : out;
815
839
  }
816
840
  function resolveMethodConfig(opts) {
817
841
  const methods = uniquePickerMethods(opts.methods, DEFAULT_PICKER_METHODS);
842
+ const methodKeys = new Set(methods.map(pickerMethodKey));
818
843
  const advancedMethods = uniquePickerMethods(opts.advancedMethods, DEFAULT_ADVANCED_METHODS)
819
- .filter(method => methods.includes(method));
844
+ .filter(method => methodKeys.has(pickerMethodKey(method)));
820
845
  return { methods, advancedMethods };
821
846
  }
822
847
  function resolveRelayUrls(opts) {
@@ -849,6 +874,8 @@ function resolveOptions(opts) {
849
874
  result.preferredMethod = opts.preferredMethod;
850
875
  if (opts.redirectCallback !== undefined)
851
876
  result.redirectCallback = opts.redirectCallback;
877
+ if (opts.storage !== undefined)
878
+ result.storage = opts.storage;
852
879
  return result;
853
880
  }
854
881
  let modalQueue = Promise.resolve();
@@ -890,11 +917,12 @@ async function runLoginModal(opts) {
890
917
  const choice = resolved.preferredMethod
891
918
  ? resolved.preferredMethod
892
919
  : await Promise.race([renderPicker(refs, resolved), aborted]);
920
+ const routeChoice = choice === null ? null : routePickerChoice(choice);
893
921
  if (userAborted)
894
922
  return null;
895
- if (choice === null || choice === 'cancel')
923
+ if (routeChoice === null || routeChoice === 'cancel')
896
924
  return null;
897
- if (choice === 'nip07') {
925
+ if (routeChoice === 'nip07') {
898
926
  const result = await Promise.race([runNip07Flow(refs, resolved), aborted]);
899
927
  if (userAborted)
900
928
  return null;
@@ -914,7 +942,7 @@ async function runLoginModal(opts) {
914
942
  authEvent: result.authEvent,
915
943
  };
916
944
  }
917
- if (choice === 'redirect') {
945
+ if (routeChoice === 'redirect') {
918
946
  // Same-device Signet in the modal must keep this app tab alive and keep
919
947
  // the My Signet tab alive as the ongoing bunker. Use the relay-backed
920
948
  // auth response path here; explicit `login({ mode: 'redirect' })`
@@ -927,7 +955,7 @@ async function runLoginModal(opts) {
927
955
  return null;
928
956
  continue;
929
957
  }
930
- const session = await buildSessionFromRedirectFlowResult(refs, result, aborted);
958
+ const session = await buildSessionFromRedirectFlowResult(refs, result, resolved, aborted);
931
959
  if (userAborted)
932
960
  return null;
933
961
  if (!session) {
@@ -937,7 +965,7 @@ async function runLoginModal(opts) {
937
965
  }
938
966
  return session;
939
967
  }
940
- if (choice === 'amber') {
968
+ if (routeChoice === 'amber') {
941
969
  // Same-tab navigation to a `nostrsigner:` URL. Android dispatches
942
970
  // it to Amber; the page comes back via callbackUrl with the signed
943
971
  // event in `?event=`. Picked up on next boot by handleRedirectCallback.
@@ -946,10 +974,11 @@ async function runLoginModal(opts) {
946
974
  challenge: resolved.challenge,
947
975
  origin: resolved.origin,
948
976
  ...(resolved.redirectCallback !== undefined ? { redirectCallback: resolved.redirectCallback } : {}),
977
+ ...(resolved.storage !== undefined ? { storage: resolved.storage } : {}),
949
978
  });
950
979
  return null; // unreachable
951
980
  }
952
- if (choice === 'qr') {
981
+ if (routeChoice === 'qr') {
953
982
  const result = await Promise.race([runRedirectFlow(refs, resolved), aborted]);
954
983
  if (userAborted)
955
984
  return null;
@@ -958,7 +987,7 @@ async function runLoginModal(opts) {
958
987
  return null;
959
988
  continue;
960
989
  }
961
- const session = await buildSessionFromRedirectFlowResult(refs, result, aborted);
990
+ const session = await buildSessionFromRedirectFlowResult(refs, result, resolved, aborted);
962
991
  if (userAborted)
963
992
  return null;
964
993
  if (!session) {
@@ -968,7 +997,7 @@ async function runLoginModal(opts) {
968
997
  }
969
998
  return session;
970
999
  }
971
- if (choice === 'bunker') {
1000
+ if (routeChoice === 'bunker') {
972
1001
  const signer = await Promise.race([runBunkerFlow(refs, resolved), aborted]);
973
1002
  if (userAborted)
974
1003
  return null;
@@ -994,7 +1023,7 @@ async function runLoginModal(opts) {
994
1023
  authEvent,
995
1024
  };
996
1025
  }
997
- if (choice === 'nostrconnect') {
1026
+ if (routeChoice === 'nostrconnect') {
998
1027
  const signer = await Promise.race([runNostrConnectFlow(refs, resolved), aborted]);
999
1028
  if (userAborted)
1000
1029
  return null;
@@ -1023,7 +1052,7 @@ async function runLoginModal(opts) {
1023
1052
  authEvent,
1024
1053
  };
1025
1054
  }
1026
- if (choice === 'nsec') {
1055
+ if (routeChoice === 'nsec') {
1027
1056
  const signer = await Promise.race([runNsecFlow(refs, resolved), aborted]);
1028
1057
  if (userAborted)
1029
1058
  return null;
@@ -26,7 +26,7 @@
26
26
  * logs a warning — server-side strict verification will fail until the
27
27
  * issuer is upgraded.
28
28
  */
29
- import type { SignetSession } from './types.js';
29
+ import type { SignetStorage, SignetSession } from './types.js';
30
30
  import { DEFAULTS } from './types.js';
31
31
  /** Subset of resolved options used by the redirect path. */
32
32
  export interface RedirectStartOptions {
@@ -35,6 +35,7 @@ export interface RedirectStartOptions {
35
35
  origin: string;
36
36
  signetAppOrigin: string;
37
37
  redirectCallback?: string;
38
+ storage?: SignetStorage;
38
39
  }
39
40
  /**
40
41
  * Build the signet-app auth URL for redirect mode. Deliberately omits `relay`
@@ -74,22 +75,6 @@ export type ConsumeCallbackResult = {
74
75
  kind: 'invalid';
75
76
  reason: string;
76
77
  };
77
- /**
78
- * Detect and consume a redirect-back callback. Returns:
79
- *
80
- * - { kind: 'session', session } — round-trip valid; clears pending state
81
- * and strips auth params from the URL
82
- * - { kind: 'denied' } — signet-app sent `error=denied`
83
- * - { kind: 'no-callback' } — no auth params in the URL; do nothing
84
- * - { kind: 'invalid', reason } — params present but failed validation
85
- * (pending state mismatch, stale, hex
86
- * malformed, …). Pending state is cleared
87
- * in this case too — a stale or attacker-
88
- * supplied URL shouldn't poison the next
89
- * login attempt.
90
- *
91
- * Idempotent: calling it twice on the same loaded page returns 'no-callback'
92
- * the second time because the URL params have been stripped.
93
- */
94
78
  export declare function consumeCallback(): ConsumeCallbackResult;
79
+ export declare function consumeCallbackFromStorage(storage?: SignetStorage): Promise<ConsumeCallbackResult>;
95
80
  export { DEFAULTS };
package/dist/redirect.js CHANGED
@@ -27,7 +27,7 @@
27
27
  * issuer is upgraded.
28
28
  */
29
29
  import { DEFAULTS, PENDING_REDIRECT_TTL_MS } from './types.js';
30
- import { clearPendingRedirect, loadPendingRedirect, savePendingRedirect, } from './storage.js';
30
+ import { clearPendingRedirect, clearPendingRedirectFromStorage, loadPendingRedirect, loadPendingRedirectFromStorage, savePendingRedirectToStorage, } from './storage.js';
31
31
  import { EphemeralSigner } from './signers.js';
32
32
  /** Hex regexes — kept local to avoid pulling in @noble for two patterns. */
33
33
  const HEX_64 = /^[0-9a-f]{64}$/i;
@@ -58,7 +58,7 @@ export function buildRedirectAuthUrl(opts) {
58
58
  * mode in non-browser code is a programming error, not something to silently
59
59
  * swallow.
60
60
  */
61
- export function startRedirect(opts) {
61
+ export async function startRedirect(opts) {
62
62
  if (typeof window === 'undefined') {
63
63
  throw new Error('signet-login: redirect mode requires a browser environment');
64
64
  }
@@ -68,7 +68,7 @@ export function startRedirect(opts) {
68
68
  appName: opts.appName,
69
69
  createdAt: Date.now(),
70
70
  };
71
- savePendingRedirect(pending);
71
+ await savePendingRedirectToStorage(pending, opts.storage);
72
72
  const url = buildRedirectAuthUrl(opts);
73
73
  // Use assignment (not replace) so the user can hit back to abort. The
74
74
  // pending record stays put; consumeCallback will GC it via the freshness
@@ -106,24 +106,7 @@ function cleanupCallbackUrl() {
106
106
  // history API blocked (file:// origin, sandboxed iframe, …) — leave URL alone
107
107
  }
108
108
  }
109
- /**
110
- * Detect and consume a redirect-back callback. Returns:
111
- *
112
- * - { kind: 'session', session } — round-trip valid; clears pending state
113
- * and strips auth params from the URL
114
- * - { kind: 'denied' } — signet-app sent `error=denied`
115
- * - { kind: 'no-callback' } — no auth params in the URL; do nothing
116
- * - { kind: 'invalid', reason } — params present but failed validation
117
- * (pending state mismatch, stale, hex
118
- * malformed, …). Pending state is cleared
119
- * in this case too — a stale or attacker-
120
- * supplied URL shouldn't poison the next
121
- * login attempt.
122
- *
123
- * Idempotent: calling it twice on the same loaded page returns 'no-callback'
124
- * the second time because the URL params have been stripped.
125
- */
126
- export function consumeCallback() {
109
+ function consumeCallbackWithPending(pending, finalize) {
127
110
  if (typeof window === 'undefined')
128
111
  return { kind: 'no-callback' };
129
112
  const params = new URLSearchParams(window.location.search);
@@ -135,14 +118,6 @@ export function consumeCallback() {
135
118
  if (!error && !pubkey && !signature && !eventId) {
136
119
  return { kind: 'no-callback' };
137
120
  }
138
- const pending = loadPendingRedirect();
139
- // From here on we're handling a callback — pending state must always be
140
- // cleared on exit so a stale record can't be reused.
141
- const finalize = (result) => {
142
- clearPendingRedirect();
143
- cleanupCallbackUrl();
144
- return result;
145
- };
146
121
  if (error === 'denied') {
147
122
  return finalize({ kind: 'denied' });
148
123
  }
@@ -254,6 +229,24 @@ export function consumeCallback() {
254
229
  }
255
230
  return finalize(bunkerUri ? { kind: 'session', session, bunkerUri } : { kind: 'session', session });
256
231
  }
232
+ export function consumeCallback() {
233
+ // From here on we're handling a callback — pending state must always be
234
+ // cleared on exit so a stale record can't be reused.
235
+ const finalize = (result) => {
236
+ clearPendingRedirect();
237
+ cleanupCallbackUrl();
238
+ return result;
239
+ };
240
+ return consumeCallbackWithPending(loadPendingRedirect(), finalize);
241
+ }
242
+ export async function consumeCallbackFromStorage(storage) {
243
+ const finalize = async (result) => {
244
+ await clearPendingRedirectFromStorage(storage);
245
+ cleanupCallbackUrl();
246
+ return result;
247
+ };
248
+ return await consumeCallbackWithPending(await loadPendingRedirectFromStorage(storage), finalize);
249
+ }
257
250
  // Re-export DEFAULTS for tree-shaking-friendly callers that want to avoid
258
251
  // importing the full types module just for one constant.
259
252
  export { DEFAULTS };
@@ -14,8 +14,8 @@
14
14
  * already there (so `signet-verify.iife.js` and `signet-login.iife.js` coexist
15
15
  * in either load order on the same page).
16
16
  */
17
- export type { NostrEvent, EventTemplate, LoginMethod, LoginPickerMethod, SignerCapabilities, SignetSigner, SignetAuthEvent, SignetSession, LoginOptions, RestoreOptions, } from './types.js';
18
- import type { SignetSigner, LoginOptions, RestoreOptions, SignetSession, SignetAuthEvent } from './types.js';
17
+ export type { NostrEvent, EventTemplate, LoginMethod, LoginPickerMethod, SignerCapabilities, SignetSigner, SignetAuthEvent, SignetSession, LoginOptions, RestoreOptions, SignetStorage, } from './types.js';
18
+ import type { SignetSigner, LoginOptions, RestoreOptions, SignetSession, SignetAuthEvent, SignetStorage } from './types.js';
19
19
  import { hasNip07, createNip07Signer, createBunkerSigner, createBunkerSignerFromNostrConnect, buildNostrConnectUri, 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';
@@ -34,6 +34,11 @@ export interface HandleRedirectCallbackOptions {
34
34
  * should set this true and reject auth-only returns at their boundary.
35
35
  */
36
36
  waitForBunker?: boolean;
37
+ /**
38
+ * Storage backend for pending redirect consumption and session persistence.
39
+ * Must match the backend passed to `login({ mode: 'redirect', storage })`.
40
+ */
41
+ storage?: SignetStorage;
37
42
  }
38
43
  export interface CreateLoginAuthEventOptions {
39
44
  /** Required. Bound into the auth event's `app` tag. */
@@ -43,6 +48,10 @@ export interface CreateLoginAuthEventOptions {
43
48
  /** Origin to bind into the proof. Defaults to `window.location.origin`. */
44
49
  origin?: string;
45
50
  }
51
+ export interface LogoutOptions {
52
+ /** Storage backend to clear. Defaults to localStorage. */
53
+ storage?: SignetStorage;
54
+ }
46
55
  /**
47
56
  * Show the login picker and resolve to a SignetSession on success, or null on
48
57
  * cancel / timeout.
@@ -117,4 +126,4 @@ export declare function handleRedirectCallback(options?: HandleRedirectCallbackO
117
126
  /**
118
127
  * Clear the stored session and close the active signer.
119
128
  */
120
- export declare function logout(currentSession?: SignetSession): Promise<void>;
129
+ export declare function logout(currentSession?: SignetSession, opts?: LogoutOptions): Promise<void>;