signet-login 0.9.11 → 0.9.14

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
@@ -11,7 +11,6 @@ import { loadOrCreatePersistentClientSk } 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
- import { startRedirect } from './redirect.js';
15
14
  import QRCode from 'qrcode';
16
15
  const QR_BUNKER_CONNECT_TIMEOUT_MS = 8000;
17
16
  function escapeHtml(str) {
@@ -256,9 +255,10 @@ async function runNip07Flow(refs, opts) {
256
255
  window.clearInterval(ticker);
257
256
  }
258
257
  }
259
- async function runRedirectFlow(refs, opts) {
258
+ async function runRedirectFlow(refs, opts, flowOpts = {}) {
260
259
  const dark = isDarkMode(opts.theme);
261
260
  const muted = dark ? '#888' : '#666';
261
+ const sameDevice = flowOpts.sameDevice === true;
262
262
  // Generate session keypair for cross-device gift-wrap
263
263
  const sessionPrivKey = schnorr.utils.randomPrivateKey();
264
264
  const sessionPubkey = bytesToHex(schnorr.getPublicKey(sessionPrivKey));
@@ -274,13 +274,13 @@ async function runRedirectFlow(refs, opts) {
274
274
  });
275
275
  const authUrl = `${opts.signetAppOrigin}/?${params.toString()}`;
276
276
  refs.dialog.innerHTML = `
277
- <h2 style="margin:0 0 8px;font-size:1.2rem;">Sign in with Signet</h2>
278
- <p style="margin:0 0 16px;color:${muted};font-size:0.85rem;">Open the link on your phone, or scan the QR if rendered.</p>
277
+ <h2 style="margin:0 0 8px;font-size:1.2rem;">${sameDevice ? 'Open My Signet' : 'Sign in with Signet'}</h2>
278
+ <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>
279
279
  <div style="background:${dark ? '#0f0f1f' : '#f5f5f8'};border-radius:8px;padding:16px;margin-bottom:16px;">
280
280
  <canvas id="signet-login-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>
281
- <a href="${escapeHtml(authUrl)}" target="_blank" rel="noopener" style="display:block;color:#5b6dff;font-size:0.75rem;word-break:break-all;text-decoration:none;">${escapeHtml(authUrl.slice(0, 80))}…</a>
281
+ <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>
282
282
  </div>
283
- <p id="signet-login-status" style="margin:0 0 12px;color:${muted};font-size:0.85rem;">Waiting for approval…</p>
283
+ <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>
284
284
  <div style="display:flex;gap:8px;justify-content:space-between;">
285
285
  <button data-action="back" style="${buttonStyle(dark)}width:auto;flex:0 0 auto;padding:8px 16px;">← Back</button>
286
286
  <button data-action="cancel" style="${buttonStyle(dark)}width:auto;flex:0 0 auto;padding:8px 16px;">Cancel</button>
@@ -301,6 +301,14 @@ async function runRedirectFlow(refs, opts) {
301
301
  // — the visible link below the canvas still gets the user across.
302
302
  });
303
303
  }
304
+ if (sameDevice && typeof window !== 'undefined') {
305
+ try {
306
+ window.open(authUrl, '_blank', 'noopener,noreferrer');
307
+ }
308
+ catch {
309
+ // Popup blocked or unavailable — the explicit link remains visible.
310
+ }
311
+ }
304
312
  return new Promise(resolve => {
305
313
  let settled = false;
306
314
  const settle = (v) => {
@@ -348,6 +356,65 @@ async function runRedirectFlow(refs, opts) {
348
356
  });
349
357
  });
350
358
  }
359
+ async function buildSessionFromRedirectFlowResult(refs, result, _aborted) {
360
+ // Default: auth-only ephemeral signer (identity proof, no live signing).
361
+ let signer = new EphemeralSigner(result.pubkey, result.authEvent);
362
+ let method = 'redirect';
363
+ // Cross-device / same-device bunker passthrough: when the signer device hands
364
+ // back a `bunker://` URI (its own NIP-46 server, or an upstream hardware
365
+ // bunker), connect before resolving the flow. Consumers such as Pallasite
366
+ // reject auth-only at their auth boundary; returning a cold
367
+ // DeferredBunkerSigner makes them classify the session as non-signing before
368
+ // the relay handshake can finish.
369
+ if (result.bunkerUri) {
370
+ const clientSecretKey = loadOrCreatePersistentClientSk();
371
+ const expected = result.pubkey;
372
+ const status = refs.dialog.querySelector('#signet-login-status');
373
+ if (status)
374
+ status.textContent = 'Connecting signer...';
375
+ try {
376
+ const bunkerSigner = await createBunkerSigner({
377
+ uri: result.bunkerUri,
378
+ clientSecretKey,
379
+ timeoutMs: QR_BUNKER_CONNECT_TIMEOUT_MS,
380
+ });
381
+ if (bunkerSigner.pubkey.toLowerCase() !== expected.toLowerCase()) {
382
+ // Bunker came back as a different key than the identity we proved.
383
+ // Discard it and keep the auth-only identity (result.pubkey) rather than
384
+ // signing as the wrong key — the consumer can prompt for a proper signer
385
+ // if it needs one.
386
+ console.warn('[signet-login] Signet relay upgrade: bunker pubkey mismatch — continuing identity-only', { connected: bunkerSigner.pubkey, expected });
387
+ void bunkerSigner.close().catch(() => { });
388
+ }
389
+ else {
390
+ signer = bunkerSigner;
391
+ method = 'bunker';
392
+ }
393
+ }
394
+ catch (err) {
395
+ // Bunker connect failed or timed out — signer offline, or a stale handoff
396
+ // URI (the common cross-device failure: the producer re-handed a dead
397
+ // connect string). Do NOT fail the whole sign-in: fall back to the
398
+ // auth-only identity we already hold (the kind-21236 authEvent proves
399
+ // result.pubkey). The consumer decides whether identity-only is enough —
400
+ // one that needs a live signer can prompt for an upgrade rather than being
401
+ // handed null and stranding the user at "couldn't sign in".
402
+ console.warn('[signet-login] Signet relay upgrade: createBunkerSigner failed — continuing identity-only (auth-only).', err);
403
+ }
404
+ }
405
+ else {
406
+ console.warn('[signet-login] Signet relay login carried no bunkerUri — auth-only ephemeral (cannot sign). The signer device must have its NIP-46 server enabled to hand back a bunker:// URI.');
407
+ }
408
+ const session = {
409
+ pubkey: result.pubkey,
410
+ method,
411
+ signer,
412
+ authEvent: result.authEvent,
413
+ };
414
+ if (result.displayName)
415
+ session.displayName = result.displayName;
416
+ return session;
417
+ }
351
418
  // ── Paste bunker URI ──────────────────────────────────────────────────────────
352
419
  async function runBunkerFlow(refs, opts) {
353
420
  const dark = isDarkMode(opts.theme);
@@ -564,12 +631,25 @@ function resolveOptions(opts) {
564
631
  result.redirectCallback = opts.redirectCallback;
565
632
  return result;
566
633
  }
634
+ let modalQueue = Promise.resolve();
567
635
  /**
568
636
  * Entry point — show the modal, route to the chosen method, return a session.
569
637
  *
570
638
  * Returns null when the user cancels or the flow times out.
571
639
  */
572
640
  export async function showLoginModal(opts) {
641
+ const previous = modalQueue;
642
+ let release;
643
+ modalQueue = new Promise(resolve => { release = resolve; });
644
+ await previous;
645
+ try {
646
+ return await runLoginModal(opts);
647
+ }
648
+ finally {
649
+ release();
650
+ }
651
+ }
652
+ async function runLoginModal(opts) {
573
653
  if (!opts.appName || opts.appName.length === 0)
574
654
  throw new Error('appName-required');
575
655
  if (opts.appName.length > 64)
@@ -615,21 +695,27 @@ export async function showLoginModal(opts) {
615
695
  };
616
696
  }
617
697
  if (choice === 'redirect') {
618
- // Same-tab navigation. Reuses the same pending-state and callback
619
- // machinery as `Signet.login({ mode: 'redirect' })`, so this picker
620
- // path lands the user on signet-app and the consumer's next page
621
- // load picks up the round-trip via `Signet.handleRedirectCallback`.
622
- // The promise from `startRedirect` never resolves the page is gone
623
- // before the await completes — so the dialog teardown in the finally
624
- // block is also a no-op for this branch.
625
- await startRedirect({
626
- appName: resolved.appName,
627
- challenge: resolved.challenge,
628
- origin: resolved.origin,
629
- signetAppOrigin: resolved.signetAppOrigin,
630
- ...(resolved.redirectCallback !== undefined ? { redirectCallback: resolved.redirectCallback } : {}),
631
- });
632
- return null; // unreachable
698
+ // Same-device Signet in the modal must keep this app tab alive and keep
699
+ // the My Signet tab alive as the ongoing bunker. Use the relay-backed
700
+ // auth response path here; explicit `login({ mode: 'redirect' })`
701
+ // remains the same-tab redirect API for mobile/single-device callers.
702
+ const result = await Promise.race([runRedirectFlow(refs, resolved, { sameDevice: true }), aborted]);
703
+ if (userAborted)
704
+ return null;
705
+ if (!result) {
706
+ if (resolved.preferredMethod)
707
+ return null;
708
+ continue;
709
+ }
710
+ const session = await buildSessionFromRedirectFlowResult(refs, result, aborted);
711
+ if (userAborted)
712
+ return null;
713
+ if (!session) {
714
+ if (resolved.preferredMethod)
715
+ return null;
716
+ continue;
717
+ }
718
+ return session;
633
719
  }
634
720
  if (choice === 'amber') {
635
721
  // Same-tab navigation to a `nostrsigner:` URL. Android dispatches
@@ -652,66 +738,14 @@ export async function showLoginModal(opts) {
652
738
  return null;
653
739
  continue;
654
740
  }
655
- // Default: auth-only ephemeral signer (identity proof, no live signing).
656
- let signer = new EphemeralSigner(result.pubkey, result.authEvent);
657
- let method = 'redirect';
658
- // Cross-device bunker passthrough: when the signer device hands back a
659
- // `bunker://` URI (its own NIP-46 server, or an upstream hardware
660
- // bunker), connect before resolving the QR flow. QR is already an
661
- // in-place modal, and consumers such as Pallasite reject auth-only at
662
- // their auth boundary; returning a cold DeferredBunkerSigner makes them
663
- // classify the session as non-signing before the relay handshake can
664
- // finish.
665
- if (result.bunkerUri) {
666
- const clientSecretKey = loadOrCreatePersistentClientSk();
667
- const expected = result.pubkey;
668
- const status = refs.dialog.querySelector('#signet-login-status');
669
- if (status)
670
- status.textContent = 'Connecting signer...';
671
- try {
672
- const bunkerSigner = await createBunkerSigner({
673
- uri: result.bunkerUri,
674
- clientSecretKey,
675
- timeoutMs: QR_BUNKER_CONNECT_TIMEOUT_MS,
676
- });
677
- if (bunkerSigner.pubkey.toLowerCase() !== expected.toLowerCase()) {
678
- console.warn('[signet-login] QR upgrade: bunker pubkey mismatch — cannot sign', { connected: bunkerSigner.pubkey, expected });
679
- void bunkerSigner.close().catch(() => { });
680
- if (status) {
681
- status.textContent = 'Signer connected with the wrong public key.';
682
- status.style.color = '#d04848';
683
- }
684
- await Promise.race([new Promise(resolve => setTimeout(resolve, 2500)), aborted]);
685
- if (userAborted || resolved.preferredMethod)
686
- return null;
687
- continue;
688
- }
689
- signer = bunkerSigner;
690
- }
691
- catch (err) {
692
- console.warn('[signet-login] QR upgrade: createBunkerSigner failed — signer did not become live.', err);
693
- if (status) {
694
- status.textContent = `Signer connection failed: ${err instanceof Error ? err.message : String(err)}`;
695
- status.style.color = '#d04848';
696
- }
697
- await Promise.race([new Promise(resolve => setTimeout(resolve, 2500)), aborted]);
698
- if (userAborted || resolved.preferredMethod)
699
- return null;
700
- continue;
701
- }
702
- method = 'bunker';
703
- }
704
- else {
705
- console.warn('[signet-login] QR login carried no bunkerUri — auth-only ephemeral (cannot sign). The signer device must have its NIP-46 server enabled to hand back a bunker:// URI.');
741
+ const session = await buildSessionFromRedirectFlowResult(refs, result, aborted);
742
+ if (userAborted)
743
+ return null;
744
+ if (!session) {
745
+ if (resolved.preferredMethod)
746
+ return null;
747
+ continue;
706
748
  }
707
- const session = {
708
- pubkey: result.pubkey,
709
- method,
710
- signer,
711
- authEvent: result.authEvent,
712
- };
713
- if (result.displayName)
714
- session.displayName = result.displayName;
715
749
  return session;
716
750
  }
717
751
  if (choice === 'bunker') {