1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
|
// "Log in with Nostr" — open (or create) a wallet from a nostr identity, and
// link an existing wallet to one after the fact.
//
// The mechanics and their tradeoffs live in ../nostr-login.js. In short: a
// pasted key DERIVES its wallet deterministically; an extension or remote
// signer ASSOCIATES one via a seed published encrypted to the user's own key.
// The second is what makes "log in on a new device" work without the key
// ever reaching us, and it is spelled out in the UI before anything is
// published, because it means the nostr key can recover the money.
import { newMnemonic } from '../wallet.js';
import { npubOf, nsecOf } from '../nostr.js';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
import { t } from '../i18n.js';
import { qrSvg } from '../qr.js';
import {
extensionSigner, bunkerSigner, keySigner, parseNostrSecret,
walletForSigner, publishWalletBackup, fetchWalletBackup, nostrConnect, nostrConnectPending, resumeBunker,
amberAvailable, amberSigner, amberPending, amberDeliver, amberCancel, onAmberChange } from '../nostr-login.js';
// The nostr ostrich, in the brand's monochrome.
const OSTRICH = '<svg width="23" height="23" style="display:block" viewBox="0 0 2000 2000" fill="none" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="ng" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#B84FE8"/><stop offset="100%" stop-color="#6D28D9"/></linearGradient></defs><circle cx="1000" cy="1000" r="1000" fill="url(#ng)"/><g transform="translate(1000 1000) scale(0.82) translate(-1000 -1000)"><path d="M1574.29 770.408C1532.04 840.612 1445.92 888.367 1437.96 892.449C1421.84 901.02 1415.92 913.469 1416.94 932.041C1417.96 950.612 1413.47 1042.65 1357.35 1083.27C1328.57 1104.08 1212.45 1131.02 1159.18 1140C1131.84 1144.69 1125.51 1153.06 1110.41 1162.04C1090 1174.69 1017.55 1275.1 1005.31 1297.35C1006.94 1297.14 1233.67 1226.73 1243.88 1225.1C1270.82 1221.02 1291.22 1228.78 1304.49 1248.37C1317.14 1267.14 1329.59 1286.12 1342.04 1305.1C1346.53 1311.84 1350.82 1318.78 1355.1 1325.71L1359.59 1332.86C1361.22 1335.51 1363.47 1338.57 1364.49 1342.65C1365.31 1345.51 1367.14 1355.1 1361.63 1360.82C1356.53 1366.12 1347.76 1366.12 1343.06 1364.9C1338.57 1363.67 1331.84 1361.02 1326.33 1356.53L1318.78 1350.2C1312.86 1345.1 1301.63 1342.65 1292.86 1343.06C1282.65 1343.47 1269.18 1340.2 1262.65 1336.12C1250 1327.96 1246.73 1307.55 1246.73 1307.35C1245.1 1300.61 1240.2 1298.78 1235.71 1298.78C1227.55 1298.57 1221.22 1301.02 1214.69 1302.86C1184.9 1311.84 1155.31 1321.02 1125.51 1330.41L1082.86 1343.67C1061.84 1350.2 1040.82 1356.73 1020 1363.67C1014.29 1365.51 1008.78 1368.37 1002.86 1371.43C1000.41 1372.65 997.959 1373.88 995.51 1375.1C993.061 1376.33 990.408 1377.76 987.959 1379.18C982.449 1382.24 976.531 1385.51 970 1387.35C948.776 1393.67 930.204 1384.49 921.429 1363.27C915.714 1349.59 920.816 1322.45 923.878 1314.49C943.061 1264.69 1005.51 1170.82 1005.51 1170.2C1004.49 1169.8 936.327 1197.14 925.102 1205.71C894.286 1229.18 822.041 1284.69 821.02 1290C816.327 1314.69 801.02 1333.27 777.551 1342.45C760.612 1348.98 750.408 1362.45 740.408 1375.51C740.408 1375.51 559.388 1624.9 547.143 1638.37C543.469 1642.45 509.592 1677.96 500.612 1694.49C498.571 1698.57 490.816 1714.9 488.776 1718.98C487.143 1722.24 483.469 1731.02 469.592 1730.41C455.714 1729.8 455.306 1710.41 454.694 1702.04C453.265 1681.43 457.755 1661.22 468.571 1640C469.592 1637.96 481.837 1619.39 477.347 1604.49C473.265 1591.43 469.388 1571.22 475.51 1561.02C485.102 1544.9 502.041 1544.29 524.694 1540.61C540.408 1537.96 551.837 1530.2 562.857 1514.08C586.122 1480.2 673.674 1355.92 693.469 1327.14C698.776 1319.59 702.857 1309.8 704.694 1300.2C710.408 1271.22 727.959 1251.02 758.571 1238.57C765.102 1235.92 886.939 1134.08 886.939 1121.02C886.939 1111.63 873.674 1106.53 863.469 1103.67C862.041 1103.27 785.51 1083.47 750.612 1067.55C731.633 1058.98 717.347 1051.43 700.408 1039.18C677.347 1022.24 631.225 1030.41 624.49 1031.43C591.837 1036.12 567.551 1050.82 540 1069.39C535.102 1072.86 510 1081.84 497.347 1074.69C472.653 1060.61 418.367 1035.31 398.367 1004.9C389.184 990.816 390.408 967.959 396.939 942.653C417.959 883.878 448.98 857.551 506.327 841.633C547.143 827.551 629.388 824.49 668.98 821.429C671.633 820.612 759.388 818.571 821.02 786.939C862.041 765.918 983.878 684.898 1159.18 690.408C1230 692.653 1363.88 761.837 1415.51 763.878C1468.16 766.122 1494.49 760.204 1524.9 728.776C1533.47 719.796 1570 641.02 1535.51 597.959C1522.65 582.041 1509.39 567.755 1494.08 554.694C1473.47 537.143 1452.04 520.408 1433.47 500.612C1392.45 457.143 1379.8 397.755 1399.18 351.429C1411.63 318.98 1437.76 304.898 1475.51 312.245C1502.04 317.347 1521.43 341.225 1540.41 353.878C1551.02 361.02 1568.78 365.102 1580.41 367.959C1595.1 371.429 1606.12 382.653 1605.51 390.204C1604.9 397.755 1585.51 404.286 1566.94 402.857C1544.9 401.429 1507.14 401.225 1484.49 408.776C1469.39 414.286 1462.65 431.02 1467.14 447.551C1470.82 461.02 1499.59 485.102 1510.41 494.082C1532.04 511.837 1555.1 527.959 1573.06 550.204C1593.27 575.51 1603.88 604.286 1607.14 636.122C1612.04 684.898 1599.18 728.776 1574.29 770.408Z" fill="#fff"/></g></svg>';
export const NOSTR_MARK = OSTRICH;
export function nostrLoginFeature(ctx) {
const { h, ui, render, wallet, toast, copyBtn } = ctx;
const load = () => wallet.loadFeatureState('nostrlogin', {});
const save = (st) => { idMemo = null; wallet.saveFeatureState('nostrlogin', st); };
// nostrLoginIdentity() is asked per feed row per moderation check (isMe,
// mePk, trusted…): thousands of times per scroll frame. Each answer parsed
// the stored state twice and bech32-encoded the npub. Keep the last answer
// briefly; a save, a signer change or another wallet's key drops it.
let idMemo = null;
const ID_MEMO_MS = 250;
// The signer that opened this session, kept so the names feature can claim
// the user's real npub as their payment address (and nominate the wallet
// key as manager, so later updates work without the signer).
let live = null;
// true while a sign-in is mid-flight: the signer is live but its link to
// the wallet hasn't been persisted yet, so init() must not sever it
let attaching = false;
let resuming = null;
let _spendingHinted = false; // relay backup already carries the spending hint
let resumeFailedAt = 0;
// The signer, but only if it still belongs to the wallet that's open.
// Checked on every READ rather than at init() alone, because a wallet
// switch has a gap: the new account is loaded and its directory entry
// written (stamping the identity it shows) BEFORE features re-init, and the
// previous account's signer was still answering in that gap — which is how
// two different wallets ended up wearing the same face in the account
// switcher. (Mid-sign-in the link isn't saved yet; `attaching` keeps this
// from severing it.)
const liveSigner = () => {
if (live && !attaching && load().pubkey !== live.pubkey) live = null;
return live;
};
// A remote signer's connection can die quietly — the app is left holding a
// signer object that will never answer again, and every send after that
// fails until the page is reloaded. Notice the first failure and drop it, so
// the next attempt resumes a fresh connection instead.
//
// But only a CONNECTION-shaped failure. A remote signer answering "can't
// decrypt that" is a healthy connection doing its job — the DM inbox probes
// it with wraps that turn out to be someone else's, and treating each
// refusal as a dead signer disconnected Amethyst users over and over.
const SIGNER_DEAD = /did not answer|timed? ?out|connect|closed|socket|relay|network/i;
const selfHealing = (signer) => {
if (!signer || signer.kind !== 'bunker') return signer;
const guard = (fn) => async (...args) => {
try { return await fn(...args); } catch (e) {
if (live === wrapped && SIGNER_DEAD.test(e?.message || '')) {
// drop it AND dial again, silently: the notice (and its Reconnect
// button) is for a resume that failed, not for every hiccup
live = null;
try { signer.close && signer.close(); } catch {}
resumeQuietly();
}
throw e;
}
};
const wrapped = {
...signer,
signEvent: guard(signer.signEvent.bind(signer)),
encryptSelf: guard(signer.encryptSelf.bind(signer)),
decryptSelf: guard(signer.decryptSelf.bind(signer)),
encryptTo: guard(signer.encryptTo.bind(signer)),
decryptFrom: guard(signer.decryptFrom.bind(signer)),
};
return wrapped;
};
// What it takes to be this identity again after a reload. Without it the app
// shows your face in the header but signs everything else with the wallet's
// own key — a different pubkey, which is exactly the mismatch people notice
// when their chat messages come out under a stranger's name.
//
// A remote signer stores its NIP-46 client key: the signer authorised that
// key for this app, and it cannot sign anything on its own.
//
// A pasted key stores NOTHING. The UI promises it is used once and never
// kept, and that promise is worth more than saving someone a paste after a
// reload — they reconnect from the nostr settings card instead.
//
// A PASSKEY-derived key is the exception among keys: no such promise was
// made, the key exists only through this app's PRF derivation, and
// re-deriving it takes a WebAuthn ceremony — a system dialog plus a
// biometric on every open, which is why passkey users kept meeting "your
// signer isn't connected" in chat. The same storage already holds the
// wallet seed, so keeping the key is the same exposure class — and it's
// what makes a passkey identity always-available the way its owner expects.
const sessionOf = (signer) => {
if (!signer) return {};
const out = { session: signer.session || null };
if (viaOf(signer) === 'passkey' && signer.kind === 'key' && signer.secret) {
out.sk = bytesToHex(signer.secret);
}
return out;
};
// HOW this account was connected — so the reconnect surfaces can offer the
// matching door instead of every door. A Google or passkey flow is running
// when ui.nostrLoginVia says so (Google hands us a bunker, passkey a raw
// key, so the signer kind alone can't tell); otherwise the signer speaks
// for itself.
const viaOf = (signer) =>
ui.nostrLoginVia === 'google' || ui.nostrLoginVia === 'passkey'
? ui.nostrLoginVia
: signer && signer.kind === 'bunker' ? 'bunker'
: signer && signer.kind === 'extension' ? 'extension'
: signer && signer.kind === 'amber' ? 'amber'
: 'key';
const busy = (v) => { ui.nostrLoginBusy = v; render(); };
const fail = (e) => { attaching = false; ui.nostrLoginError = e.message || String(e); busy(false); };
// A signer just went live: features holding work only a signer can do
// (the DM inbox's undecrypted wraps) get their cue. Deferred a tick so
// the `live` assignment has settled wherever this is called from.
const announceLive = () => { setTimeout(() => { try { ctx.hook('nostrSignerLive'); } catch {} }, 0); };
// Open the wallet a signer identifies. New accounts confirm first, because
// publishing an encrypted seed to relays deserves an explicit yes.
async function loginWith(makeSigner) {
ui.nostrLoginError = '';
busy(true);
let signer;
try {
signer = await makeSigner();
// warm the identity's profile + avatar in parallel with the seed work,
// so the home screen's first paint already has the picture
ctx.hook('warmProfile', signer.pubkey);
// and the payment name too — the Receive pane should open with its QR
const nameHintP = ctx.hook('namesLookupByPk', signer.pubkey);
const res = await walletForSigner(signer);
if (res.mode === 'new') {
// No wallet on this identity yet — make one and walk straight in.
// The interstitial that used to ask first read as an error screen to
// Google/passkey users (a fresh identity is the NORMAL first run for
// them); anyone signing in wants a wallet, and the backup mechanics
// stay spelled out in the nostr settings card.
await createForSigner({ signer });
ctx.hook('identitySignedInNew');
return;
}
// Same wallet however you log in: a key-derived seed is published too,
// so an extension login later finds this wallet instead of making a new
// one. Costs nothing — anyone with the key could derive it anyway.
if (res.publish) await publishWalletBackup(signer, { mnemonic: res.mnemonic }).catch(() => {});
const spendingHint = !!res.spending;
attaching = true;
live = selfHealing(signer);
announceLive();
// hand the wizard over BEFORE the screen flips: walletScreen routes
// back into onboarding while ui.onb is set, so clearing it afterwards
// re-animated the welcome screen for a beat before the wallet appeared
ctx.onbNostrLogin(res.mode === 'restored');
ui.navAnimSkip = true; // the handover swaps instantly, no fade
// usually resolved long ago; the race caps a slow registrar at 1.5s
const nameHint = await Promise.race([
Promise.resolve(nameHintP).catch(() => null),
new Promise((r) => setTimeout(() => r(null), 1500)),
]);
await ctx.openMnemonic(res.mnemonic, res.passphrase || '', { nostrPubkey: signer.pubkey, spendingHint, nameHint });
save({ ...load(), pubkey: signer.pubkey, linked: Date.now(), via: viaOf(signer), ...sessionOf(signer) });
attaching = false;
// claim the real npub as the payment address while this signer is live
ctx.hook('namesAdoptIdentity', signer, npubOf(signer.pubkey))?.catch?.(() => {});
// no success toast: the wallet appearing IS the message
busy(false);
} catch (e) { fail(e); }
}
async function createForSigner(st) {
if (!st) return;
busy(true);
try {
// One last look before publishing a fresh seed: if a backup surfaces
// now (a relay that was unreachable a moment ago), open that wallet
// instead of replacing it on the relays that can see it.
const existing = await fetchWalletBackup(st.signer).catch(() => null);
const mnemonic = existing ? existing.mnemonic : newMnemonic();
if (!existing) await publishWalletBackup(st.signer, { mnemonic });
attaching = true;
live = selfHealing(st.signer);
announceLive();
ctx.onbNostrLogin(!!existing); // before the screen flips — see loginWith
ui.navAnimSkip = true; // the handover swaps instantly, no fade
await ctx.openMnemonic(mnemonic, (existing && existing.passphrase) || '', { nostrPubkey: st.signer.pubkey, spendingHint: !!(existing && existing.spending) });
save({ ...load(), pubkey: st.signer.pubkey, linked: Date.now(), via: viaOf(st.signer), ...sessionOf(st.signer) });
attaching = false;
ctx.hook('namesAdoptIdentity', st.signer, npubOf(st.signer.pubkey))?.catch?.(() => {});
busy(false);
} catch (e) { fail(e); }
}
// Link the wallet that's already open to a nostr identity, so logging in
// with it on another device opens this same wallet.
async function linkOpenWallet(makeSigner) {
ui.nostrLoginError = '';
busy(true);
let signer;
try {
signer = await makeSigner();
if (!wallet.mnemonic) throw new Error(t('nlNeedSeed'));
const existing = await walletForSigner(signer);
if (existing.mnemonic && existing.mnemonic !== wallet.mnemonic) {
throw new Error(t('nlAlreadyLinked'));
}
await publishWalletBackup(signer, { mnemonic: wallet.mnemonic, passphrase: wallet.passphrase || '' });
live = selfHealing(signer);
announceLive();
save({ ...load(), pubkey: signer.pubkey, linked: Date.now(), via: viaOf(signer), ...sessionOf(signer) });
ctx.hook('namesAdoptIdentity', signer, npubOf(signer.pubkey))?.catch?.(() => {});
toast(t('nlLinked'));
busy(false);
} catch (e) { fail(e); }
}
// ---- UI ---------------------------------------------------------------
// Client-initiated remote signing: one live connect attempt at a time. The
// URI renders as a deep link (tap launches the signer app) and a QR (scan
// from another device); when the app connects, login continues by itself.
let nc = null;
let ncRun = null;
let ncToken = null; // guards the async gap while the nip46 module loads
async function startNostrConnect(run, resume = null) {
if (nc) nc.cancel();
ncRun = run;
const token = (ncToken = {});
let mine;
try { mine = await nostrConnect({ resume }); } catch {
if (token === ncToken && ui.nostrConnectOpen) { ui.nostrConnectOpen = false; render(); }
return;
}
if (token !== ncToken || !ncRun) { mine.cancel(); return; }
nc = mine;
render();
mine.ready.then((signer) => {
if (nc !== mine || !ncRun) return;
ui.nostrConnectOpen = false;
ncRun(() => Promise.resolve(signer));
nc = null;
}).catch(() => {
if (nc === mine) { nc = null; if (ui.nostrConnectOpen) { ui.nostrConnectOpen = false; render(); } }
});
}
function stopNostrConnect() {
if (nc) { nc.cancel(); nc = null; }
ncRun = null;
ncToken = null;
}
// A reload mid-nostrconnect (the signer app's callbackUrl navigating the
// tab home, or Android discarding the frozen tab) used to strand the
// login: the approval lands on the relays and nobody is waiting. On boot,
// pick the persisted handshake back up — the poller finds the stored
// approval and the login finishes on its own. Deferred a beat so the boot
// restore has decided whether this tab is even logged out.
setTimeout(() => {
// the same, for a wallet that was reconnecting its own account (Amber
// moving to NIP-46 so messages can be read): back to that screen
const again = !nc && wallet.mnemonic && load().pubkey && nostrConnectPending();
if (again) {
ui.nostrReconnect = 'upgrade';
ui.nostrConnectOpen = true;
startNostrConnect(reconnectRun, again);
render();
return;
}
const pending = !nc && !wallet.mnemonic && ui.screen === 'unlock' && nostrConnectPending();
if (!pending) return;
if (ui.onb && ['welcome', 'signin'].includes(ui.onb.step)) ui.onb = { step: 'signin' };
ui.nostrLoginOpen = true;
ui.nostrConnectOpen = true;
startNostrConnect(loginWith, pending);
render();
}, 400);
function nostrConnectSection(run) {
if (!ui.nostrConnectOpen) {
return h('button', {
class: 'btn-block', disabled: ui.nostrLoginBusy,
onClick: () => { ui.nostrConnectOpen = true; startNostrConnect(run); render(); },
}, t('nlConnectApp'));
}
// the nip46 module is still fetching — hold the spot with a spinner
if (!nc) return h('div', { class: 'row gap6', style: 'align-items:center;justify-content:center;padding:8px' },
h('span', { class: 'spinner sm' }));
return h('div', { class: 'col', style: 'gap:10px;align-items:center' },
h('a', {
class: 'btn btn-block btn-primary', href: nc.uri,
style: 'text-align:center;display:block;text-decoration:none',
}, t('nlOpenSigner')),
h('div', { html: qrSvg(nc.uri) }),
h('div', { class: 'row gap6', style: 'align-items:center' },
h('span', { class: 'spinner sm' }),
h('span', { class: 'small muted' }, t('nlWaitingSigner'))),
h('div', { class: 'row gap6' },
copyBtn(nc.uri, t('copy')),
h('button', { class: 'btn-sm', onClick: () => { stopNostrConnect(); ui.nostrConnectOpen = false; render(); } }, t('cancel'))));
}
// "Sign in with Google" — pomegranate: the code (and its FROST dealer)
// rides in a lazy chunk, so the popup must open HERE, inside the click's
// user activation, and the module navigates it once loaded.
async function googleLogin(run, central = null) {
const popup = window.open('about:blank', 'OAuth', 'width=600,height=600');
ui.nostrLoginError = '';
// no status narration — a quiet spinner on the button itself is the whole
// story (the popup is where anything needing the user's eyes happens)
ui.nostrLoginVia = 'google';
busy(true);
try {
const pome = await import('../pomegranate.js');
const { uri } = await pome.loginWithGoogle(popup, { central });
await run(() => bunkerSigner(uri, { onAuth: (url) => { ui.nostrLoginAuthUrl = url; render(); } }));
} catch (e) {
try { popup && popup.close(); } catch {}
ui.nostrLoginError = e.message;
} finally {
ui.nostrLoginVia = null;
busy(false);
}
}
// Passkey sign-in: try an existing credential first; when the device has
// none (or the picker is dismissed), roll straight into creating one — the
// browser's own passkey dialog IS the confirmation, so an interstitial of
// ours would just be a second question.
async function passkeyLogin(run) {
ui.nostrLoginError = '';
ui.nostrLoginVia = 'passkey';
render();
const pass = await import('../passkey.js');
try {
const sk = await pass.passkeySignIn();
await run(() => keySigner(sk));
} catch (e) {
if (/derive a wallet key/.test(e.message) || e.message === pass.LOCK_PASSKEY) { ui.nostrLoginError = e.message; render(); return; }
try {
const sk = await pass.passkeyCreate();
await run(() => keySigner(sk));
} catch (e2) {
// dismissing the create dialog is a plain cancel, not an error
if (!/NotAllowed|abort/i.test(String(e2.name || '') + e2.message)) ui.nostrLoginError = e2.message;
render();
}
} finally {
ui.nostrLoginVia = null;
render();
}
}
// The official four-colour "G" (brand-fixed colours, not themed).
const GOOGLE_MARK = '<svg width="19" height="19" style="display:block" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg"><path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/><path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/><path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/><path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/></svg>';
// Person + key, stroke style matching the app's other line icons.
const PASSKEY_MARK = '<svg width="19" height="19" style="display:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"><circle cx="10" cy="7" r="4"/><path d="M3 21v-1a5 5 0 0 1 5-5h4"/><circle cx="17.5" cy="14.5" r="2.5"/><path d="M17.5 17v5l2-1.7"/></svg>';
// `spinning` swaps the ICON for a same-sized spinner and keeps the label —
// the button must not change size mid-login, or the whole column shifts.
const markedLabel = (mark, label, spinning) =>
h('span', { style: 'display:flex;align-items:center;justify-content:center;gap:8px' },
spinning
? h('span', { class: 'spinner sm', style: 'flex-shrink:0' })
: h('span', { style: 'display:flex;flex-shrink:0', html: mark }),
label);
// Google + passkey, with their transient status rows — shared between the
// collapsed front-door card and the expanded signer list.
// Google accounts live per pomegranate central. coinos runs its own
// (auth.coinos.io); whoever signed in while auth.njump.me was the default
// still has a door to it. Mirrors pomegranate.js's CENTRALS (that module
// is a lazy chunk, so the two strings live here too).
const GOOGLE_CENTRAL = 'https://auth.njump.me'; // flip to auth.coinos.io with pomegranate.js DEFAULT_CENTRAL
const GOOGLE_LEGACY = 'https://auth.njump.me';
// While a request is out with the signer app: what to do, and a way in for
// the answer when the browser won't read the clipboard for us.
onAmberChange(() => render());
function amberWaitCard() {
const p = amberPending();
if (!p) return null;
const e = ui.amberPaste || (ui.amberPaste = { text: '' });
return h('div', { class: 'card col amber-wait', style: 'gap:8px' },
h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small' }, t('nlAmberWait'))),
h('div', { class: 'small muted' }, t('nlAmberPasteHint')),
h('div', { class: 'row gap6' },
h('input', { type: 'text', class: 'grow amber-paste', placeholder: t('nlAmberPastePh'), value: e.text, autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false',
onInput: (ev) => { e.text = ev.target.value; } }),
h('button', { class: 'btn-sm', type: 'button', onClick: () => { if (amberDeliver(e.text)) e.text = ''; else { ui.nostrLoginError = t('nlAmberBadPaste'); render(); } } }, t('nlAmberPasteBtn'))),
h('button', { class: 'btn-ghost btn-sm', type: 'button', onClick: () => amberCancel() }, t('cancel')));
}
function externalButtons(run, big = false) {
const hasPasskey = typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials;
const pad = big ? 'padding:14px' : '';
return [
h('button', { class: 'btn-block', style: pad, disabled: ui.nostrLoginBusy,
onClick: () => googleLogin(run) },
markedLabel(GOOGLE_MARK, t('nlGoogle'), ui.nostrLoginVia === 'google')),
GOOGLE_CENTRAL !== GOOGLE_LEGACY
? h('button', { class: 'linklike small', style: 'align-self:center', disabled: ui.nostrLoginBusy, onClick: () => googleLogin(run, GOOGLE_LEGACY) }, t('nlGooglePrev'))
: null,
hasPasskey
? h('button', { class: 'btn-block', style: pad, disabled: ui.nostrLoginBusy,
onClick: () => passkeyLogin(run) }, markedLabel(PASSKEY_MARK, t('nlPasskey'), ui.nostrLoginVia === 'passkey'))
: null,
];
}
// The ways in, as buttons. `run` receives a signer factory. The expanded
// "Log in with Nostr" card passes nostrOnly — Google and passkey already
// stand on the front door, and repeating them under a Nostr heading reads
// as noise. Linking keeps them: a different account may live behind either.
//
// RECONNECTING is narrower: the account is known, so only the door it came
// through belongs. `reconnectVia` says which — Google/passkey show their
// button, everything else (bunker, extension, key) gets the nostr surfaces
// only; an Amethyst user staring at a Google button was signing into the
// wrong identity at worst and noise at best. 'legacy' (pre-`via` state with
// no stored session) can't be told apart from a passkey account, so it
// keeps every door.
function signerButtons(run, { nostrOnly = false, reconnectVia = null } = {}) {
const hasExt = typeof window !== 'undefined' && !!window.nostr;
const externals = nostrOnly ? []
: reconnectVia && !['google', 'passkey', 'legacy'].includes(reconnectVia) ? []
: externalButtons(run);
return h('div', { class: 'col', style: 'gap:8px' },
...externals,
hasExt
? h('button', { class: 'btn-block', disabled: ui.nostrLoginBusy,
onClick: () => run(() => extensionSigner()) }, t('nlExtension'))
: null,
nostrConnectSection(run),
// On Android, the signer app straight through an intent (NIP-55): works
// with Amber offline, no bunker, no relay — one trip through the app
// per signature.
amberAvailable()
? h('button', { class: 'btn-block', disabled: ui.nostrLoginBusy, onClick: () => run(() => amberSigner()) }, t('nlAmber'))
: null,
amberWaitCard(),
// These are alternatives, not steps: without the divider the field under
// the button reads as something you also have to fill in.
h('div', { class: 'or-split' }, h('span', {}, t('or'))),
h('div', { class: 'row gap6' },
h('input', {
type: 'password', placeholder: t('nlKeyOrBunker'), style: 'flex:1',
autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false',
value: ui.nostrLoginInput || '',
onInput: (e) => { ui.nostrLoginInput = e.target.value.trim(); },
}),
h('button', { class: 'btn-sm', disabled: ui.nostrLoginBusy, onClick: () => {
const v = (ui.nostrLoginInput || '').trim();
const sk = parseNostrSecret(v);
if (sk) return run(() => keySigner(sk));
if (/^bunker:\/\//i.test(v)) {
return run(() => bunkerSigner(v, { onAuth: (url) => { ui.nostrLoginAuthUrl = url; render(); } }));
}
ui.nostrLoginError = t('nlUnrecognized');
render();
} }, ui.nostrLoginBusy ? h('span', { class: 'spinner' }) : t('nlGo'))),
h('div', { class: 'small faint' }, t('nlHint')),
ui.nostrLoginAuthUrl
? h('a', { href: ui.nostrLoginAuthUrl, target: '_blank', class: 'small' }, t('nlApprove'))
: null,
ui.nostrLoginError ? h('div', { class: 'notice err' }, ui.nostrLoginError) : null);
}
// Shown under the create/import tabs on the unlock screen.
function unlockExtra() {
// Collapsed by default: Google, passkey, and Nostr right on the front
// door; the Nostr button expands into the full signer list.
if (!ui.nostrLoginOpen) {
return h('div', { class: 'col', style: 'gap:8px' },
...externalButtons(loginWith),
// NB .btn-block forces display:block, so the flex centering lives on
// an inner wrapper rather than the button itself.
h('button', {
class: 'btn-block',
onClick: () => { ui.nostrLoginOpen = true; ui.nostrLoginError = ''; render(); },
}, h('span', { style: 'display:flex;align-items:center;justify-content:center;gap:8px' },
h('span', { style: 'display:flex;flex-shrink:0', html: OSTRICH }),
t('nlSignInNostr'))),
ui.nostrLoginError ? h('div', { class: 'notice err' }, ui.nostrLoginError) : null);
}
return h('div', { class: 'card col', style: 'gap:10px' },
h('div', { class: 'row between' },
h('h3', { style: 'margin:0' }, t('nlTitle')),
h('span', { class: 'linklike small', onClick: () => { ui.nostrLoginOpen = false; render(); } }, t('cancel'))),
h('p', { class: 'small muted', style: 'margin:0' }, t('nlDesc')),
signerButtons(loginWith, { nostrOnly: true }));
}
// Re-attach a signer for the account already linked here. Deliberately not
// linkOpenWallet: this must not become a way to quietly swap identities, so
// a different account is refused rather than linked.
async function reconnectSigner(makeSigner) {
ui.nostrLoginError = '';
busy(true);
try {
const st = load();
const signer = await makeSigner();
if (signer.pubkey !== st.pubkey) {
if (signer.close) signer.close();
throw new Error(t('nlWrongAccount', { npub: npubOf(st.pubkey) || '' }));
}
live = selfHealing(signer);
announceLive();
save({ ...load(), via: viaOf(signer), ...sessionOf(signer) });
// No toast: the badge flips to "connected" and the reconnect controls
// disappear, which says it better than a pill floating over the footer.
busy(false);
} catch (e) { fail(e); }
}
// Reveal the seed-derived nostr key. A self-backup is published FIRST so
// the promise on the reveal screen is true: pasting this nsec into any
// coinos login finds the backup and opens THIS wallet, instead of
// key-deriving a fresh empty one (walletForSigner checks relays before
// deriving).
async function exportWalletKey() {
ui.nostrLoginError = '';
busy(true);
try {
const sk = wallet.nostr && wallet.nostr.sk;
if (!sk || !wallet.mnemonic) throw new Error(t('nlNeedSeed'));
await publishWalletBackup(keySigner(sk), { mnemonic: wallet.mnemonic, passphrase: wallet.passphrase || '' });
ui.nostrExportStep = 'shown';
busy(false);
} catch (e) { fail(e); }
}
// The built-in key, behind the same two-step gate as the recovery phrase:
// a warning you have to act on before anything secret is in the DOM.
function exportSection() {
const sk = wallet.nostr && wallet.nostr.sk;
if (!sk) return null;
if (ui.nostrExportStep === 'shown') {
const nsec = nsecOf(sk);
return h('div', { class: 'col', style: 'gap:8px' },
h('div', { class: 'addr-box break' }, nsec),
h('p', { class: 'small faint', style: 'margin:0' }, t('nlExportedNote')),
h('div', { class: 'row gap6' },
copyBtn(nsec, t('copy')),
h('button', { class: 'btn-sm grow', onClick: () => { ui.nostrExportStep = false; render(); } }, t('hide'))));
}
if (ui.nostrExportStep === 'warn') {
return h('div', { class: 'col', style: 'gap:8px' },
h('div', { class: 'warn-box' }, t('nlExportWarn')),
ui.nostrLoginError ? h('div', { class: 'notice err' }, ui.nostrLoginError) : null,
h('div', { class: 'row gap6' },
h('button', { class: 'btn-primary grow', disabled: ui.nostrLoginBusy, onClick: exportWalletKey },
ui.nostrLoginBusy ? h('span', { class: 'spinner' }) : t('nlExportReveal')),
h('button', { class: 'btn-sm', onClick: () => { ui.nostrExportStep = false; ui.nostrLoginError = ''; render(); } }, t('cancel'))));
}
return h('button', {
class: 'btn-block', disabled: ui.nostrLoginBusy,
onClick: () => { ui.nostrExportStep = 'warn'; ui.nostrLoginError = ''; render(); },
}, t('nlExportBtn'));
}
function linkSection() {
if (!ui.nostrLinkOpen) {
return h('button', {
class: 'btn-block', disabled: ui.nostrLoginBusy,
onClick: () => { ui.nostrLinkOpen = true; ui.nostrLoginError = ''; render(); },
}, t('nlChangeBtn'));
}
return h('div', { class: 'col', style: 'gap:10px' },
h('div', { class: 'row between' },
h('strong', { class: 'small' }, t('nlChangeBtn')),
h('span', { class: 'linklike small', onClick: () => { ui.nostrLinkOpen = false; ui.nostrLoginError = ''; render(); } }, t('cancel'))),
h('p', { class: 'small muted', style: 'margin:0' }, t('nlLinkDesc')),
h('div', { class: 'notice info small' }, t('nlBackupWarning')),
signerButtons(linkOpenWallet));
}
// The silent reattach (extension first, then a stored bunker session) —
// callable from anywhere that notices the signer missing. One attempt at a
// time, and a pause after a failure: identity() asks on every send, and a
// signer that isn't answering shouldn't mean a new relay connection per
// keystroke.
async function resumeLogin() {
if (liveSigner()) return live;
const st = load();
if (!st.pubkey) return null;
if (resuming) return resuming;
if (Date.now() - resumeFailedAt < 20_000) return null;
resuming = (async () => {
// a stored passkey-derived key reattaches instantly — no network, no
// dialog; see sessionOf for why this key (alone among keys) is kept
if (st.sk) {
try {
const s = keySigner(hexToBytes(st.sk));
if (s.pubkey === st.pubkey) return (live = s);
} catch {}
}
if (typeof window !== 'undefined' && window.nostr) {
try {
const s = await extensionSigner();
if (s.pubkey === st.pubkey) return (live = s);
} catch {}
}
// an Android signer app: the stored pubkey is the whole session, and
// coming back needs no round trip (the first signature will be one)
if (st.session && st.session.amber === st.pubkey) return (live = await amberSigner({ pubkey: st.pubkey }));
if (st.session) {
try {
const s = await resumeBunker(st.session, { onAuth: (url) => { ui.nostrLoginAuthUrl = url; render(); } });
if (s && s.pubkey === st.pubkey) return (live = selfHealing(s));
} catch {}
}
return null;
})();
try {
const s = await resuming;
if (!s) resumeFailedAt = Date.now();
else announceLive();
return s;
} finally { resuming = null; }
}
// Which door this account came through, for the reconnect surfaces. State
// saved before `via` existed: a stored session means a bunker; without one
// it could be extension, key, or passkey — offer everything.
const reconnectViaOf = (st) => st.via || (st.session && st.session.amber ? 'amber' : st.session ? 'bunker' : 'legacy');
// Fire-and-forget resume for render paths: heal the badge without anyone
// tapping, repaint when it lands. resumeLogin's own gating keeps this from
// dialing on every render.
function resumeQuietly() {
resumeLogin().then((s) => { if (s) render(); }).catch(() => {});
}
function settingsCard() {
if (wallet.watchOnly || !wallet.mnemonic) return null;
const st = load();
const lv = liveSigner();
if (st.pubkey) {
return h('div', { class: 'card col' },
h('div', { class: 'row between' },
h('h3', {}, t('nlLinkTitle')),
h('span', { class: 'badge dot' + (lv ? ' live' : ' off') }, lv ? t('nlConnected') : t('nlDisconnected'))),
h('div', { class: 'small muted break' }, npubOf(st.pubkey) || st.pubkey),
h('p', { class: 'small faint', style: 'margin:0' }, t('nlLinkedDesc')),
// Signing needs a live signer, and a reload always drops a remote one.
// Without this the app can tell you it isn't connected and offer you
// nowhere to fix it. A stored session or an installed extension can
// usually reattach without the user doing anything — try that quietly
// whenever the card paints disconnected (the resume path rate-limits
// itself), and only the doors this account came through are offered.
lv ? null : (resumeQuietly(), h('div', { class: 'col', style: 'gap:8px' },
h('p', { class: 'small muted', style: 'margin:0' }, t('nlReconnectDesc')),
signerButtons(reconnectSigner, { reconnectVia: reconnectViaOf(st) }))));
}
// No external account linked — but the wallet still HAS a nostr account:
// the one derived from its seed, already signing chat and holding the
// payment address. A bare login form here read as "no account yet", so
// the built-in identity comes first and both exporting its key and
// switching to another account are explicit steps behind buttons.
const npub = wallet.nostrNpub && wallet.nostrNpub();
return h('div', { class: 'card col' },
h('h3', {}, t('nlLinkTitle')),
npub ? h('div', { class: 'small muted break' }, npub) : null,
h('p', { class: 'small muted', style: 'margin:0' }, t('nlBuiltinDesc')),
exportSection(),
linkSection());
}
const reconnectRun = (makeSigner) => reconnectSigner(makeSigner).then(() => {
// an upgrade is done only once the new signer can decrypt
if (liveSigner() && !(ui.nostrReconnect === 'upgrade' && live.interactive)) { ui.nostrReconnect = null; ui.nostrConnectOpen = false; render(); }
});
// The reconnect takeover: opened by any feature whose action just found
// the signer missing (a reload dropped it; Amethyst and friends can't
// reattach silently). Same guarded buttons as the settings card — an
// extension, the signer-app deeplink/QR, or a pasted key — and the run
// refuses a different account.
function reconnectScreen() {
const st = load();
// 'upgrade': Amber by intent is live but can't read messages — offer
// only its NIP-46 door (nostrconnect), same account enforced
const upgrade = ui.nostrReconnect === 'upgrade';
const run = reconnectRun;
// straight to the link: the screen exists for this one door
if (upgrade && ui.nostrUpgradeStart) { ui.nostrUpgradeStart = false; ui.nostrConnectOpen = true; startNostrConnect(run); }
return h('div', { class: 'col', style: 'gap:16px' },
ctx.brandHeader(false),
h('div', { class: 'card col', style: 'gap:12px' },
h('h3', { style: 'margin:0' }, t(upgrade ? 'nlAmberUpgradeTitle' : 'nlReconnectTitle')),
h('p', { class: 'small muted', style: 'margin:0' }, upgrade ? t('nlAmberUpgradeBody') : t('nlReconnectBody', { npub: (npubOf(st.pubkey) || '').slice(0, 12) })),
upgrade ? nostrConnectSection(run) : signerButtons(run, { reconnectVia: reconnectViaOf(st) }),
ui.nostrLoginError ? h('div', { class: 'notice err' }, ui.nostrLoginError) : null),
h('button', { class: 'btn-ghost btn-block', onClick: () => {
stopNostrConnect();
ui.nostrReconnect = null; ui.nostrConnectOpen = false; ui.nostrLoginError = '';
render();
} }, t('back')));
}
return {
id: 'nostrlogin',
stop() { stopNostrConnect(); },
screenView() {
if (ui.screen !== 'wallet' || !ui.nostrReconnect) return null;
return reconnectScreen();
},
// An action needed the login signer and it isn't there: open the
// reconnect screen (and try the silent extension reattach in the
// background — it closes the screen by itself when it lands).
// Messages found an Amber-by-intent login that can't decrypt: offer to
// reconnect the same account over NIP-46.
nostrSignerUpgrade() {
const st = load();
if (!st.pubkey) return false;
ui.nostrReconnect = 'upgrade';
ui.nostrUpgradeStart = true;
ui.nostrLoginError = '';
render();
return true;
},
nostrReconnectPrompt() {
const st = load();
if (!st.pubkey || liveSigner()) return false;
ui.nostrReconnect = true;
ui.nostrLoginError = '';
this.nostrLoginResume().then((sg) => {
if (sg && ui.nostrReconnect) { ui.nostrReconnect = null; render(); }
}).catch(() => {});
render();
return true;
},
init() {
// A live signer is bound to the wallet that linked it. Switching to an
// account that never did must not inherit the login identity — it used
// to, and the fresh wallet then claimed names under the login npub,
// showed its profile, and even wore its hats.
liveSigner();
},
// The nostr identity this session is logged in as, for features that
// should speak as the user (the payment address defaults to this npub).
nostrLoginIdentity() {
// A just-connected signer counts immediately — the persisted link lands
// only after the wallet opens, and the header avatar shouldn't show the
// wallet key's face in the gap.
// `via`: how the account came in, known before its signer wakes (an
// Amber account is one whether or not Amber has been asked anything yet)
const key = wallet.featureStateKey('nostrlogin');
if (idMemo && idMemo.live === live && idMemo.key === key && Date.now() - idMemo.at < ID_MEMO_MS) return idMemo.value;
const st = load();
let value = null;
if (liveSigner()) value = { pubkey: live.pubkey, npub: npubOf(live.pubkey), signer: live, via: st.pubkey === live.pubkey ? reconnectViaOf(st) : viaOf(live) };
else if (st.pubkey) value = { pubkey: st.pubkey, npub: npubOf(st.pubkey), signer: live, via: reconnectViaOf(st) };
idMemo = { at: Date.now(), live, key, value };
return value;
},
// A page reload loses the signer. A stored passkey key or an installed
// extension reattaches without prompting, which is what lets the payment
// address stay tied to the user's real identity across reloads. Returns
// null for signers we cannot silently reattach (pasted keys, bunkers
// without a stored session).
nostrLoginResume() { return resumeLogin(); },
// Back from the background: a remote signer's socket may be gone even if
// the pool will bring it back — ask it something small now, and if the
// answer never comes, resume a fresh session before anyone notices.
resumed(awayMs) {
const s = liveSigner();
if (!s || s.kind !== 'bunker' || !s.ping) return;
if (awayMs && awayMs < 5_000) return;
s.ping().catch(() => {
if (live !== s) return;
live = null;
try { s.close && s.close(); } catch {}
resumeQuietly();
});
return true;
},
unlockExtra() { return unlockExtra(); },
// The welcome screen's sign-in block: Google and passkey act right there;
// the Nostr button steps into the wizard's signer list.
// Spending just became real (name claimed / balance appeared): stamp the
// hint into the relay backup while a signer is live, so the NEXT restore
// paints Spending from its first frame. Once per session is plenty.
spendingEvident() {
if (_spendingHinted || !liveSigner() || !wallet.mnemonic) return null;
_spendingHinted = true;
publishWalletBackup(live, { mnemonic: wallet.mnemonic, passphrase: wallet.passphrase || '', spending: true })
.catch(() => { _spendingHinted = false; });
return true;
},
frontDoorSignin() {
return h('div', { class: 'col', style: 'gap:8px' },
...externalButtons(loginWith, true),
h('button', { class: 'btn-block', style: 'padding:14px', onClick: () => {
if (ui.onb) ui.onb.step = 'signin';
ui.nostrLoginOpen = true;
ui.nostrLoginError = '';
render();
} }, h('span', { style: 'display:flex;align-items:center;justify-content:center;gap:8px' },
h('span', { style: 'display:flex;flex-shrink:0', html: OSTRICH }),
t('nlSignInNostr'))),
ui.nostrLoginError ? h('div', { class: 'notice err' }, ui.nostrLoginError) : null);
},
nostrSettingsCards() { return [settingsCard()]; },
};
}
|