diff options
| -rw-r--r-- | src/app.js | 208 | ||||
| -rw-r--r-- | src/features/nostrlogin.js | 128 | ||||
| -rw-r--r-- | src/i18n.js | 23 | ||||
| -rw-r--r-- | src/nostr-login.js | 12 | ||||
| -rw-r--r-- | src/passkey.js | 6 | ||||
| -rw-r--r-- | src/style.css | 4 | ||||
| -rw-r--r-- | tools/balance-settings-row-test.js | 13 | ||||
| -rw-r--r-- | tools/onboard-backup-test.js | 4 | ||||
| -rw-r--r-- | tools/send-lnaddr-advance-test.js | 3 | ||||
| -rw-r--r-- | tools/signup-quick-test.js | 167 |
10 files changed, 536 insertions, 32 deletions
@@ -16,7 +16,7 @@ import { nip98Header } from './nip98.js'; import { uploadPublicMedia, uploadStream, mediaUploadSigner, MediaUploadError } from './media-upload.js'; import { NOSTR_MARK } from './features/nostrlogin.js'; import { scanQr } from './scan.js'; -import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.js'; +import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS, getArkConfig } from './api.js'; import { STAGING } from './build-flags.js'; import { buildFeatures, loadDeferredFeatures } from './features/index.js'; import { cacheReady, cacheStore } from './cache-store.js'; @@ -1883,7 +1883,16 @@ async function enterWallet(mnemonic, passphrase, opts = {}) { // it — Google, a passkey and a nostr key all end up at a seed the user has // never seen, and which nobody can reissue for them. That is the one case // worth asking about, so it's recorded once and never guessed at again. - if (acc.seedSeen === undefined) acc.seedSeen = !opts.nostrPubkey; + // A sign-up (Create an account) shows no phrase either: it stays one tap + // away in Settings, next to the other ways back in (passkey, Google, nostr). + if (acc.seedSeen === undefined) acc.seedSeen = !opts.nostrPubkey && !opts.unseen; + if (opts.signup && !acc.signupAt) acc.signupAt = Date.now(); + // Spending first: an account made by signing up has no Savings side until + // its owner adds one (or on-chain money turns up — see savingsEvident). A + // recovery sign-in carries the same shape over from the account's backup. + if (opts.spendingOnly && !acc.kind && !acc.deriveIndex && !acc.savingsAdded && acc.type === 'full') { + acc.kind = 'spending'; acc.savingsLater = true; + } else if (opts.signup && acc.kind === 'spending') acc.savingsLater = true; await activateAccount(acc, { ...opts, fresh: true }); } @@ -2193,7 +2202,11 @@ function defaultLabel(type) { function loadWatchAccounts() { try { const dir = JSON.parse(localStorage.getItem(WATCH_KEY) || '[]'); - return dir.filter((d) => d.xpub).map((d) => ({ id: d.id, label: d.label, type: 'watch', xpub: d.xpub, autoLock: d.autoLock || 0, network: d.network, nostrPk: d.nostrPk })); + return dir.filter((d) => d.xpub).map((d) => ({ id: d.id, label: d.label, type: 'watch', xpub: d.xpub, autoLock: d.autoLock || 0, network: d.network, nostrPk: d.nostrPk, + // a sign-up's Spending-only shape and its ways back in outlive the session + ...(d.spendingOnly ? { kind: 'spending', savingsLater: true } : {}), + ...(d.signupAt ? { signupAt: d.signupAt } : {}), + ...(Array.isArray(d.recovery) && d.recovery.length ? { recovery: d.recovery } : {}) })); } catch { return []; } } function saveDirectory() { @@ -2210,7 +2223,10 @@ function saveDirectory() { || (wallet.nostrPubkey && wallet.nostrPubkey()) || a.nostrPk; } catch {} } - return { id: a.id, label: a.label, xpub: a.xpub, autoLock: a.autoLock || 0, network: a.network, nostrPk: a.nostrPk, seedSeen: !!a.seedSeen }; + return { id: a.id, label: a.label, xpub: a.xpub, autoLock: a.autoLock || 0, network: a.network, nostrPk: a.nostrPk, seedSeen: !!a.seedSeen, + ...(a.kind === 'spending' && a.savingsLater ? { spendingOnly: true } : {}), + ...(a.signupAt ? { signupAt: a.signupAt } : {}), + ...(a.recovery && a.recovery.length ? { recovery: a.recovery } : {}) }; }); localStorage.setItem(WATCH_KEY, JSON.stringify(dir)); } catch {} @@ -3231,7 +3247,7 @@ function settingsTab() { // matters if the phone is lost tonight case 'wallet': if (ui.delAcct && a) return deleteAccountView(a); - return page(a ? [recoveryCard(a), balanceSettingsCard(a), walletNameCard(a), pubkeyCard(a), historyExportCard(), deleteAccountCard(a)] : []); + return page(a ? [featureHook('waysBackCard'), recoveryCard(a), balanceSettingsCard(a), walletNameCard(a), pubkeyCard(a), historyExportCard(), deleteAccountCard(a)] : []); case 'payments': return page(featureAll('settingsCards').reverse()); case 'pos': return page(featureAll('posSettingsCards').reverse()); // the till's own card first, then the terminal's case 'network': return page([networkCard(), explorerCard()]); // an old bookmark of the tile; the cards live under Advanced now @@ -3939,7 +3955,21 @@ function balanceSettingsCard(a) { h('span', { style: 'font-weight:600' }, viewLabel(a, view)), h('span', { class: 'small muted' }, t(view === 'spending' ? 'spendingSettingsDesc' : 'savingsSettingsDesc'))), h('span', { class: 'muted' }, '›')); - return h('div', { class: 'card col', style: 'gap:4px' }, h('h3', {}, t('balanceSettingsTitle')), ...views.map(row)); + // a sign-up's Spending-only account: Savings is one tap away + const addRow = a.kind === 'spending' && a.savingsLater && !a.deriveIndex + ? h('button', { + id: 'add-savings', + class: 'btn-ghost btn-block balance-settings-row', + style: 'display:flex;align-items:center;gap:10px;text-align:left;padding:10px 4px', + onClick: () => { addSavings(a); toast(t('savingsAddedToast')); }, + }, + h('span', { style: 'display:flex;color:var(--muted)', html: BITCOIN_ICON(18) }), + h('span', { class: 'col', style: 'flex:1;gap:2px' }, + h('span', { style: 'font-weight:600' }, t('addSavings')), + h('span', { class: 'small muted' }, t('addSavingsDesc'))), + h('span', { class: 'muted' }, '+')) + : null; + return h('div', { class: 'card col', style: 'gap:4px' }, h('h3', {}, t('balanceSettingsTitle')), ...views.map(row), addRow); } function walletNameCard(a) { const saveName = () => { @@ -4274,6 +4304,21 @@ async function publicMediaUpload(file) { // Whether the wizard can offer Spending at all: an Ark-capable build on a // network with the names registrar, for a full (signing) account. Elsewhere // the offer would be a spinner that never resolves. +// Signing up (Adam, 2026-11-15): "we should just have them claim a name and +// pick their avatar/cover and they should be in." Where a name can be +// claimed — an Ark server and the names registrar on this network — Create +// makes the account straight away: no twelve words to copy or check, no +// Savings side. The phrase waits in Settings with the other ways back in +// (passkey, Google, nostr), and a quiet line on the wallet asks for one. +// Elsewhere (regtest, a build without Ark) there's no name to claim, the +// wallet IS its seed, and Create keeps the classic phrase flow. +function quickSignupOk() { + if (!['mainnet', 'mutinynet'].includes(getNetwork())) return false; + try { + if (!FEATURES.some((f) => f.id === 'ark') || !FEATURES.some((f) => f.id === 'names')) return false; + return !!getArkConfig(); + } catch { return false; } +} function canOfferSpending() { const acc = activeAccount(); if (!acc || acc.type !== 'full' || wallet.watchOnly) return false; @@ -4310,7 +4355,8 @@ function onboardScreen() { // they lose the Google account and find out it was never a backup. // Handled further down, where page()/title() exist. const arrived = activeAccount(); - if (arrived && arrived.mnemonic && !arrived.seedSeen) o.step = 'backup'; + // (a sign-up was never meant to see it: straight on to the name) + if (arrived && arrived.mnemonic && !arrived.seedSeen && !arrived.signupAt) o.step = 'backup'; else if (canOfferSpending()) { // Before the home screen: one offer to set up Spending (a name and a // payment address), with Not now a single tap away. @@ -4318,10 +4364,11 @@ function onboardScreen() { } else { ui.onb = null; try { localStorage.removeItem(ONB_STEP_KEY); } catch {} - // On-chain first: the wizard's promise is "your keys, your coins", and - // Spending stays out of sight until the user sets it up. - ui.account = 'savings'; - try { localStorage.setItem(ACCOUNT_KEY, 'savings'); } catch {} + // On-chain first for a seed wallet: the wizard's promise is "your + // keys, your coins". A sign-up is Spending alone. + const home = arrived && arrived.kind ? arrived.kind : 'savings'; + ui.account = home; + try { localStorage.setItem(ACCOUNT_KEY, home); } catch {} ui.tab = 'receive'; return null; // the caller falls through to the wallet itself } @@ -4415,6 +4462,24 @@ function onboardScreen() { } // Making your own keys: the words are the wallet, so show them at once, // no Generate to tap. + // Create an account: a fresh seed opens at once as a Spending-only + // account; the wizard carries on to the name and the look. + const startQuick = async () => { + if (o.creating) return; + o.creating = true; ui.onbError = ''; render(); + try { + clearSeedDrafts(); + ui.passphrase = ''; ui.confirmPass = ''; ui.draftMnemonic = ''; + ui.newWalletKind = 'spending'; + await enterWallet(newMnemonic(), '', { generated: true, unseen: true, signup: true }); + } catch (e) { + ui.onbError = e.message || String(e); + } finally { + ui.newWalletKind = null; + o.creating = false; + render(); + } + }; const startCreate = () => { ui.unlockTab = 'create'; ui.unlockError = ''; @@ -4443,11 +4508,16 @@ function onboardScreen() { title(t('onbWelcomeTitle')), // spaced as the sign-in list is (10px) h('div', { class: 'col', style: 'gap:10px' }, - h('button', { - // a stable hook for tests, so this button survives its own copy - id: 'onb-create', - class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', onClick: startCreate, - }, t('onbStart')), + quickSignupOk() + ? h('button', { + // a stable hook for tests, so this button survives its own copy + id: 'onb-create', 'data-quick': '1', + class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', disabled: !!o.creating, onClick: startQuick, + }, o.creating ? h('span', { class: 'spinner sm' }) : t('onbCreateAccount')) + : h('button', { + id: 'onb-create', + class: 'btn-primary btn-block', style: 'font-size:17px;padding:14px', onClick: startCreate, + }, t('onbStart')), h('button', { id: 'onb-signin', class: 'btn-block', style: 'font-size:17px;padding:14px', @@ -4572,14 +4642,18 @@ function onboardScreen() { ]); } if (o.step === 'spend') { + // A sign-up's name is the account itself — what people pay, follow and + // message — so it's claimed here, not skipped. + const signupAcc = activeAccount(); + const signup = !o.fromGift && !!(signupAcc && signupAcc.signupAt && signupAcc.kind === 'spending'); return page([ - title(t(o.fromGift ? 'onbGiftNameTitle' : 'onbSpendTitle')), - h('p', { class: 'muted', style: 'margin:0' }, t(o.fromGift ? 'onbGiftNameBody' : 'onbSpendBody')), + title(t(o.fromGift ? 'onbGiftNameTitle' : signup ? 'onbNameTitle' : 'onbSpendTitle')), + h('p', { class: 'muted', style: 'margin:0' }, t(o.fromGift ? 'onbGiftNameBody' : signup ? 'onbNameBody' : 'onbSpendBody')), featureHook('namesClaimForm') || h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('onbNameWait'))), // Optional means optional: one tap to the wallet, no questions — on // Savings, the account that exists without any setup (or, after a // gift, the account the gift arrived in). - h('button', { class: 'btn-ghost btn-block', onClick: () => { + signup ? null : h('button', { class: 'btn-ghost btn-block', onClick: () => { const home = o.home || 'savings'; ui.onb = null; try { localStorage.removeItem(ONB_STEP_KEY); } catch {} @@ -4915,22 +4989,107 @@ wallet.registerCacheExtension({ }, }); +// ---- Savings, later -------------------------------------------------------- +// An account made by signing up is Spending alone (kind 'spending', marked +// savingsLater): no on-chain face, no carousel, no Move money. Its owner adds +// Savings from Settings → Wallet whenever they like — the same seed, account +// 0, so it is the on-chain wallet that was always underneath. A balance must +// never hide, so on-chain money or history arriving adds it by itself. +function addSavings(acc, { quiet = false } = {}) { + if (!acc || acc.kind !== 'spending' || !acc.savingsLater) return false; + delete acc.kind; delete acc.savingsLater; + if (!acc.spendingSetup) acc.spendingSetup = Date.now(); + acc.savingsAdded = Date.now(); + persistAccounts(); + if (acc.persisted) writeVault(); + if (acc.id === activeId) { try { wallet.saveCache(); } catch {} } + if (!quiet) render(); + return true; +} +function savingsEvident() { + const a = activeAccount(); + if (!a || a.kind !== 'spending' || !a.savingsLater || !wallet.loaded) return; + if ((wallet.spendable || 0) > 0 || (wallet.pendingIncoming || 0) > 0 || (wallet.txs || []).length) addSavings(a, { quiet: true }); +} +// The shape rides the synced snapshot, so the phone opens the account the +// way the laptop has it: Spending-only until Savings is added on either. +// Commutative: savingsAdded only ever turns on and wins over spendingOnly. +wallet.registerCacheExtension({ + mergeAlways: true, + save: () => { + const a = activeAccount(); + if (!a) return {}; + if (a.savingsAdded) return { savingsAdded: a.savingsAdded }; + return a.kind === 'spending' && a.savingsLater ? { spendingOnly: true } : {}; + }, + load: (d) => { + const a = activeAccount(); + if (!d || !a || a.type !== 'full' || a.deriveIndex) return; + if (d.savingsAdded) { if (addSavings(a, { quiet: true }) === false && !a.savingsAdded) { a.savingsAdded = d.savingsAdded; persistAccounts(); } return; } + if (d.spendingOnly && !a.kind && !a.savingsAdded && !(wallet.txs || []).length && !(wallet.spendable || 0)) { + a.kind = 'spending'; a.savingsLater = true; + persistAccounts(); + } + }, +}); + +// ---- ways back in ---------------------------------------------------------- +// The sign-in methods that can open this account somewhere else, besides its +// recovery phrase: a passkey, a Google account, a nostr identity — each holds +// a copy of the seed encrypted to its own key on the relays (see the +// nostrlogin feature's addRecovery). Listed on the account so Settings can +// say what's set up; the list rides the synced snapshot, union by key. +function recoveryMethods(a = activeAccount()) { + return (a && Array.isArray(a.recovery)) ? a.recovery : []; +} +function sameSeed(a, x) { + return x.mnemonic && x.mnemonic === a.mnemonic && (x.passphrase || '') === (a.passphrase || ''); +} +function mergeRecovery(list) { + const a = activeAccount(); + if (!a || !a.mnemonic || !Array.isArray(list) || !list.length) return false; + let changed = false; + for (const x of accounts) { + if (x !== a && !sameSeed(a, x)) continue; + const have = new Map(recoveryMethods(x).map((m) => [m.id, m])); + for (const m of list) if (m && m.id && !have.has(m.id)) { have.set(m.id, m); changed = true; } + x.recovery = [...have.values()].sort((p, q) => (p.at || 0) - (q.at || 0)); + } + if (changed) { persistAccounts(); if (a.persisted) writeVault(); } + return changed; +} +function addRecoveryMethod(m) { + if (!mergeRecovery([{ ...m, at: m.at || Date.now() }])) return; + try { wallet.saveCache(); } catch {} + render(); +} +wallet.registerCacheExtension({ + mergeAlways: true, + save: () => { const r = recoveryMethods(); return r.length ? { recovery: r } : {}; }, + load: (d) => { if (d && d.recovery) mergeRecovery(d.recovery); }, +}); + // A wallet whose recovery phrase has never been on screen keeps one quiet // line about it. "Later" is a real answer, so this is a row and not a modal — // but it doesn't dismiss, because the risk doesn't go away on its own. It // disappears the moment the phrase is actually seen (here or in settings). function backupNotice() { + savingsEvident(); const a = activeAccount(); if (!a || !a.mnemonic || a.seedSeen || wallet.watchOnly) return null; + // A sign-up never saw a phrase and wasn't asked to: what it needs is A way + // back in, any one — and once it has one, nothing to nag about. + const signup = !!a.signupAt; + if (signup && recoveryMethods(a).length) return null; return h('div', { class: 'small muted', style: 'margin:10px 0 0;text-align:center' }, - t('backupReminder'), + t(signup ? 'waysBackReminder' : 'backupReminder'), ' ', h('button', { class: 'linklike small', onClick: () => { ui.tab = 'settings'; ui.settingsPage = 'wallet'; - ui.revealShown = 'masked'; + if (!signup) ui.revealShown = 'masked'; render(); - } }, t('backupReminderGo'))); + } }, t(signup ? 'waysBackReminderGo' : 'backupReminderGo'))); } // Safe mode (see BOOT_GUARD in build.js): the app crashed on open twice in a @@ -6857,6 +7016,11 @@ const ctx = { getAccount: () => accountSel(), setAccount: (a, dir) => setAccountSel(a, dir), offerProfileSetup, pickConfirm, + // the ways back in (Settings → Wallet): what's set up, and adding one + recoveryMethods: () => recoveryMethods(), addRecoveryMethod, + spendingOnlyAccount: () => { const a = activeAccount(); return !!(a && a.kind === 'spending' && a.savingsLater); }, + seedSeen: () => !!(activeAccount() || {}).seedSeen, + showRecoveryPhrase: () => { ui.tab = 'settings'; ui.settingsPage = 'wallet'; ui.revealShown = 'masked'; render(); }, // Open (or create) a wallet from a mnemonic — used by nostr login. openMnemonic: async (mnemonic, passphrase, opts) => enterWallet(mnemonic, passphrase, opts), // Profile pictures retain the metadata-scrubbing image uploader. Public diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js index 4a811fc..e48e9a2 100644 --- a/src/features/nostrlogin.js +++ b/src/features/nostrlogin.js @@ -158,6 +158,17 @@ export function nostrLoginFeature(ctx) { // 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); + // A way back in (added from Settings): the key holds a copy of an + // account's seed, and opens that account as ITSELF — its own name, + // profile and identity. Nothing about the key is adopted or kept. + if (res.mode === 'restored' && res.recovery) { + try { if (signer.kind === 'bunker' && signer.close) signer.close(); } catch {} + ctx.onbNostrLogin(true); + ui.navAnimSkip = true; + await ctx.openMnemonic(res.mnemonic, res.passphrase || '', { spendingHint: !!res.spending, spendingOnly: !!res.spendingOnly }); + busy(false); + return; + } 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 @@ -681,6 +692,122 @@ export function nostrLoginFeature(ctx) { resumeLogin().then((s) => { if (s) render(); }).catch(() => {}); } + // ---- ways back in ------------------------------------------------------ + // Settings → Wallet: the sign-in methods that can open THIS account on + // another device, added after the fact — a passkey, Google, a nostr key — + // next to the recovery phrase. Each one gets a copy of the seed encrypted + // to its own key (kind 30078, marked `recovery`), so signing in with it + // anywhere opens this account as itself. Unlike linking a login, the + // account's identity doesn't change. + async function addRecovery(makeSigner, via, label = '') { + ui.waysBackError = ''; ui.waysBackBusy = via; ui.nostrLoginBusy = true; render(); + let signer = null; + try { + if (!wallet.mnemonic) throw new Error(t('nlNeedSeed')); + signer = await makeSigner(); + const st = load(); + if (st.pubkey && st.pubkey === signer.pubkey) throw new Error(t('waysBackIsLogin')); + // a key that already opens a DIFFERENT wallet keeps it: never overwrite + const existing = await fetchWalletBackup(signer); + if (existing && existing.mnemonic + && (existing.mnemonic !== wallet.mnemonic || (existing.passphrase || '') !== (wallet.passphrase || ''))) { + throw new Error(t('waysBackTaken')); + } + await publishWalletBackup(signer, { + mnemonic: wallet.mnemonic, passphrase: wallet.passphrase || '', + spending: true, recovery: true, spendingOnly: !!ctx.spendingOnlyAccount(), + }); + ctx.addRecoveryMethod({ via, id: signer.pubkey, label }); + ui.waysBackNostr = false; + toast(t('waysBackAdded')); + } catch (e) { + const msg = String((e && e.message) || e || ''); + // a dismissed passkey or Google dialog is a plain cancel + if (!/NotAllowed|abort/i.test(String((e && e.name) || '') + ' ' + msg)) ui.waysBackError = msg; + } finally { + try { if (signer && signer.kind === 'bunker' && signer.close) signer.close(); } catch {} + ui.waysBackBusy = null; ui.nostrLoginBusy = false; + render(); + } + } + async function passkeyRecovery() { + const pass = await import('../passkey.js'); + const name = ctx.hook('namesAddress') || 'coinos'; + await addRecovery(async () => keySigner(await pass.passkeyCreate({ name, displayName: name })), 'passkey'); + } + async function googleRecovery() { + // opened in the click itself (user activation) — see googleLogin + const popup = window.open('about:blank', 'OAuth', 'width=600,height=600'); + ui.waysBackError = ''; ui.waysBackBusy = 'google'; render(); + try { + const pome = await import('../pomegranate.js'); + const { uri, email } = await pome.loginWithGoogle(popup, {}); + await addRecovery(() => bunkerSigner(uri, { onAuth: (url) => { ui.nostrLoginAuthUrl = url; render(); } }), 'google', email || ''); + } catch (e) { + try { popup && popup.close(); } catch {} + ui.waysBackError = (e && e.message) || String(e); + ui.waysBackBusy = null; + render(); + } + } + const fmtDay = (ms) => { try { return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); } catch { return ''; } }; + function waysBackCard() { + if (wallet.watchOnly || !wallet.mnemonic) return null; + const methods = ctx.recoveryMethods() || []; + const st = load(); + const busyVia = ui.waysBackBusy; + const hasPasskey = typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials; + const done = (mark, title, detail, key) => h('div', { class: 'row gap6 ways-back-row', 'data-way': key, style: 'align-items:center;padding:8px 2px' }, + h('span', { style: 'display:flex;flex-shrink:0;width:23px;justify-content:center', html: mark }), + h('span', { class: 'col', style: 'flex:1;gap:1px;min-width:0' }, + h('span', { style: 'font-weight:600' }, title), + detail ? h('span', { class: 'small muted break' }, detail) : null), + h('span', { class: 'ways-back-check', 'aria-label': t('waysBackOn') }, '✓')); + const add = (mark, label, via, onClick) => h('button', { + class: 'btn-block ways-back-add', 'data-way': via, disabled: !!busyVia, onClick, + }, markedLabel(mark, label, busyVia === via)); + const rows = []; + // the sign-in this account came through is a way back in already + if (st.pubkey) { + const via = st.via || 'nostr'; + rows.push(done(via === 'google' ? GOOGLE_MARK : via === 'passkey' ? PASSKEY_MARK : OSTRICH, + t('waysBackLogin', { via: via === 'google' ? 'Google' : via === 'passkey' ? t('doorPasskey') : 'Nostr' }), + via === 'google' || via === 'passkey' ? '' : (npubOf(st.pubkey) || '').slice(0, 20) + '…', 'login')); + } + for (const m of methods) { + if (m.via === 'passkey') rows.push(done(PASSKEY_MARK, t('doorPasskey'), t('waysBackAddedOn', { date: fmtDay(m.at) }), 'passkey')); + else if (m.via === 'google') rows.push(done(GOOGLE_MARK, 'Google', m.label || t('waysBackAddedOn', { date: fmtDay(m.at) }), 'google')); + else rows.push(done(OSTRICH, 'Nostr', (npubOf(m.id) || '').slice(0, 20) + '…', 'nostr')); + } + const seen = ctx.seedSeen(); + rows.push(h('div', { class: 'row gap6 ways-back-row', 'data-way': 'phrase', style: 'align-items:center;padding:8px 2px' }, + h('span', { style: 'display:flex;flex-shrink:0;width:23px;justify-content:center;font-size:17px' }, '🔑'), + h('span', { class: 'col', style: 'flex:1;gap:1px' }, + h('span', { style: 'font-weight:600' }, t('recoveryPhrase')), + h('span', { class: 'small muted' }, t(seen ? 'waysBackPhraseSeen' : 'waysBackPhraseUnseen'))), + seen ? h('span', { class: 'ways-back-check' }, '✓') + : h('button', { class: 'btn-sm', onClick: () => ctx.showRecoveryPhrase() }, t('waysBackShow')))); + const have = (via) => methods.some((m) => m.via === via) || (via !== 'nostr' && st.via === via); + const adds = [ + hasPasskey && !have('passkey') ? add(PASSKEY_MARK, t('waysBackAddPasskey'), 'passkey', () => passkeyRecovery()) : null, + !have('google') ? add(GOOGLE_MARK, t('waysBackAddGoogle'), 'google', () => googleRecovery()) : null, + ui.waysBackNostr + ? h('div', { class: 'col', style: 'gap:8px' }, + h('div', { class: 'row between' }, + h('strong', { class: 'small' }, t('waysBackAddNostr')), + h('span', { class: 'linklike small', onClick: () => { ui.waysBackNostr = false; ui.waysBackError = ''; render(); } }, t('cancel'))), + signerButtons((make) => addRecovery(make, 'nostr'), { nostrOnly: true })) + : add(OSTRICH, t('waysBackAddNostr'), 'nostr', () => { ui.waysBackNostr = true; ui.waysBackError = ''; ui.nostrLoginError = ''; render(); }), + ].filter(Boolean); + return h('div', { class: 'card col ways-back', style: 'gap:6px' }, + h('h3', {}, t('waysBackTitle')), + h('p', { class: 'small muted', style: 'margin:0 0 4px' }, t('waysBackDesc')), + h('div', { class: 'col', style: 'gap:0' }, ...rows), + adds.length ? h('div', { class: 'col', style: 'gap:8px;margin-top:6px' }, ...adds) : null, + ui.waysBackError ? h('div', { class: 'notice err' }, ui.waysBackError) : null, + h('p', { class: 'small faint', style: 'margin:4px 0 0' }, t('waysBackNote'))); + } + function settingsCard() { if (wallet.watchOnly || !wallet.mnemonic) return null; const st = load(); @@ -879,5 +1006,6 @@ export function nostrLoginFeature(ctx) { ].filter(Boolean); }, nostrSettingsCards() { return [settingsCard()]; }, + waysBackCard() { return waysBackCard(); }, }; } diff --git a/src/i18n.js b/src/i18n.js index 3e42daa..9b59710 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -1498,6 +1498,29 @@ const STR = { onbContinue: 'Continue', onbSkipAvatar: 'Continue', onbDoneTitle: "You're all set!", + onbCreateAccount: 'Create an account', + onbNameTitle: 'Claim your name', + onbNameBody: "It's your username and your payment address: people pay you, follow you and message you with just your name.", + waysBackReminder: 'Add a way back into your account, in case you lose this device.', + waysBackReminderGo: 'Add one', + addSavings: 'Add a savings account', + addSavingsDesc: 'On-chain bitcoin, for keeping. Same account, a second balance.', + savingsAddedToast: 'Savings added', + waysBackTitle: 'Ways back in', + waysBackDesc: 'Any one of these opens this account on a new device, or if this one is lost.', + waysBackNote: 'Each one can open your account and spend its money, so only add ones you control.', + waysBackOn: 'Set up', + waysBackLogin: 'Signed in with {via}', + waysBackAddedOn: 'Added {date}', + waysBackPhraseSeen: 'Seen. Keep it written down somewhere safe', + waysBackPhraseUnseen: 'Twelve words that open this account in any wallet', + waysBackShow: 'Show', + waysBackAddPasskey: 'Add a passkey', + waysBackAddGoogle: 'Add Google', + waysBackAddNostr: 'Add a Nostr account', + waysBackAdded: 'Added. You can sign in with it now', + waysBackIsLogin: 'That is the account you signed in with; it already opens this one.', + waysBackTaken: 'That sign-in already opens a different coinos account, so it can’t open this one too.', onbDoneBody: 'Your spending account is set up. Your address is live and the coinos community chat is waiting for you.', onbEnter: 'Enter coinos', spendSetup: 'Set up a spending account', diff --git a/src/nostr-login.js b/src/nostr-login.js index 3357f61..562a5d4 100644 --- a/src/nostr-login.js +++ b/src/nostr-login.js @@ -514,8 +514,14 @@ export async function fetchWalletBackup(signer, relays = BACKUP_RELAYS) { // `spending` rides along as a hint: a restored wallet paints its Spending // side from the first frame instead of reflowing when the name lookup proves // it seconds later. -export async function publishWalletBackup(signer, { mnemonic, passphrase, spending }, relays = BACKUP_RELAYS) { - const content = await signer.encryptSelf(JSON.stringify({ mnemonic, passphrase: passphrase || '', ...(spending ? { spending: true } : {}) })); +// `recovery` marks a copy kept by a WAY BACK IN (a passkey, Google or nostr +// key added from Settings): signing in with that key opens the account as +// itself — its own identity, name and profile — rather than adopting the +// key as the account's nostr identity the way a login does. `spendingOnly` +// carries a sign-up's shape (no Savings side yet) to the next device. +export async function publishWalletBackup(signer, { mnemonic, passphrase, spending, recovery, spendingOnly }, relays = BACKUP_RELAYS) { + const content = await signer.encryptSelf(JSON.stringify({ mnemonic, passphrase: passphrase || '', + ...(spending ? { spending: true } : {}), ...(recovery ? { recovery: true } : {}), ...(spendingOnly ? { spendingOnly: true } : {}) })); const evt = await signer.signEvent({ kind: BACKUP_KIND, created_at: Math.floor(Date.now() / 1000), tags: [['d', SEED_DTAG]], content, @@ -537,7 +543,7 @@ export async function publishWalletBackup(signer, { mnemonic, passphrase, spendi // deriving (key in hand) or generating (signer only). export async function walletForSigner(signer) { const found = await fetchWalletBackup(signer); - if (found) return { mnemonic: found.mnemonic, passphrase: found.passphrase, spending: !!found.spending, mode: 'restored' }; + if (found) return { mnemonic: found.mnemonic, passphrase: found.passphrase, spending: !!found.spending, recovery: !!found.recovery, spendingOnly: !!found.spendingOnly, mode: 'restored' }; if (signer.secret) { // Publishing the derived seed costs nothing: the derivation is public, // so anyone holding this key could compute it anyway. It buys the thing diff --git a/src/passkey.js b/src/passkey.js index 248e1e6..7d5e6e5 100644 --- a/src/passkey.js +++ b/src/passkey.js @@ -54,14 +54,16 @@ export async function passkeySignIn() { // Create a passkey, then derive the key from it. Some platforms evaluate PRF // during creation; the rest need a follow-up assertion against the new // credential — both paths end at the same bytes. -export async function passkeyCreate() { +// `name` labels the credential in the platform's passkey picker (a way back +// into a named account reads "[email protected]", not just "coinos"). +export async function passkeyCreate({ name = 'coinos', displayName = 'Coinos wallet' } = {}) { const cred = await navigator.credentials.create({ publicKey: { challenge: crypto.getRandomValues(new Uint8Array(32)), rp: { name: 'Coinos', id: location.hostname }, user: { id: crypto.getRandomValues(new Uint8Array(16)), - name: 'coinos', displayName: 'Coinos wallet', + name, displayName, }, pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 }], authenticatorSelection: { residentKey: 'required', userVerification: 'required' }, diff --git a/src/style.css b/src/style.css index 1db0984..8206000 100644 --- a/src/style.css +++ b/src/style.css @@ -2179,3 +2179,7 @@ html:has(.call-overlay:not(.call-min)) { overflow: hidden; scrollbar-gutter: aut /* on: the accent with its own text colour (accent-fg). accent-ink, used before, is light on dark mode's white accent: unreadable (Adam, 2026-11-15) */ .moment-toggle.on { color: var(--accent-fg); background: var(--accent); border: 1px solid var(--accent); } + +/* Settings → Wallet: the ways back into an account */ +.ways-back .ways-back-row + .ways-back-row { border-top: 1px solid var(--line); } +.ways-back .ways-back-check { color: var(--green, #2e9d5b); font-weight: 700; font-size: 16px; padding: 0 4px; } diff --git a/tools/balance-settings-row-test.js b/tools/balance-settings-row-test.js index 89e68d9..38d34b3 100644 --- a/tools/balance-settings-row-test.js +++ b/tools/balance-settings-row-test.js @@ -2,7 +2,9 @@ // balance card's gear opens. "I simply cannot find this option" (Nils, // 2026-11-12) — the Spending gear was the only way in. // Run: bun tools/balance-settings-row-test.js (NO_BUILD=1 to reuse dist/) -import puppeteer, { seedWords, skipSeedCheck } from './puppeteer.js'; +import puppeteer from './puppeteer.js'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let ok = true; const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; @@ -32,13 +34,16 @@ try { await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'mainnet')); await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); - await click('create a new wallet'); await sleep(600); - await seedWords(page); - await skipSeedCheck(page); + // on mainnet Create makes a Spending-only account (signup-quick-test); a + // seed wallet with Savings alone comes in through the seed-phrase door + await page.waitForSelector('#onb-signin'); await page.click('#onb-signin'); await sleep(400); + await page.click('#onb-restore'); await sleep(400); + await page.waitForSelector('textarea'); await page.type('textarea', generateMnemonic(wordlist)); await click('open wallet'); if (await waitText('claim a username', 8000)) { await click('not now'); await sleep(800); } await waitText('receive'); await sleep(1500); check('savings-only wallet lists Savings alone', await (async () => { await openWalletPage(); const v = await page.$$eval('.balance-settings-row', (l) => l.map((e) => e.dataset.view)); await page.goBack(); await page.goBack(); await sleep(500); return v.join() === 'savings'; })()); + await waitText('set up a spending account', 30000); await click('set up a spending account'); await sleep(800); if (await waitText('claim a username', 5000)) { await click('not now'); await sleep(800); } await waitText('receive'); await sleep(800); diff --git a/tools/onboard-backup-test.js b/tools/onboard-backup-test.js index 8fc1071..946f342 100644 --- a/tools/onboard-backup-test.js +++ b/tools/onboard-backup-test.js @@ -64,6 +64,10 @@ try { const page = await browser.newPage(); await page.setViewport({ width: 390, height: 844 }); await page.goto('http://localhost:5291/', { waitUntil: 'domcontentloaded' }); + // regtest: the classic phrase flow. Where a name can be claimed (mainnet, + // mutinynet) Create is the quick sign-up instead — signup-quick-test.js. + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(1500); // Two choices and nothing else (2026-11-06): a new wallet, or one you // already have — the sign-in methods wait behind the second. diff --git a/tools/send-lnaddr-advance-test.js b/tools/send-lnaddr-advance-test.js index fd6c1a1..14640ca 100644 --- a/tools/send-lnaddr-advance-test.js +++ b/tools/send-lnaddr-advance-test.js @@ -43,7 +43,8 @@ try { await page.setViewport({ width: 390, height: 844 }); await page.goto(server.url.origin, { waitUntil: 'domcontentloaded' }); await sleep(400); - await click('create a new wallet'); await sleep(500); await click('to import'); await sleep(400); + await page.waitForSelector('#onb-signin'); await page.click('#onb-signin'); await sleep(400); + await page.click('#onb-restore'); await sleep(400); await page.waitForSelector('textarea'); await page.type('textarea', generateMnemonic(wordlist)); await click('open wallet'); // mainnet onboarding: skip the username step (and anything after it) for (let i = 0; i < 80 && !(await page.evaluate(() => [...document.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Send'))); i++) { diff --git a/tools/signup-quick-test.js b/tools/signup-quick-test.js new file mode 100644 index 0000000..c80547d --- /dev/null +++ b/tools/signup-quick-test.js @@ -0,0 +1,167 @@ +// Signing up is a name and a look, nothing else (Adam, 2026-11-15): "i'd like +// to move away from requiring people to backup and verify a seed phrase to +// sign up. we should just have them claim a name and pick their avatar/cover +// and they should be in. we should also not create a savings account by +// default but let them add it later … adding additional auth methods like +// passkey, nostr, or google should be possible once they've created their +// account." +// +// Mainnet, with the Ark server's info read for real and the names registrar +// mocked (nothing is claimed): Create an account → no twelve words, no check +// → Claim your name (no "Not now") → Choose your look → in, on a Spending-only +// wallet (no carousel, no Savings face); the wallet line asks for a way back +// in; Settings → Wallet shows "Ways back in" (passkey via a CDP virtual +// authenticator → listed, the line goes away) and "Add a savings account" +// (→ the Savings face appears); a reload keeps both. +// Run: bun tools/signup-quick-test.js (NO_BUILD=1 to reuse dist/) +import puppeteer from './puppeteer.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +if (!process.env.NO_BUILD && Bun.spawnSync(['bun', 'build.js']).exitCode !== 0) throw new Error('build failed'); +const server = Bun.serve({ port: 0, fetch: async (req) => { + const path = new URL(req.url).pathname; + const f = Bun.file('dist' + path); + return path.includes('.') && await f.exists() ? new Response(f) : new Response(Bun.file('dist/index.html')); +} }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errs = []; +const registered = []; +const cors = { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type,authorization', 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS' }; +await page.setRequestInterception(true); +page.on('request', (r) => { + const u = new URL(r.url()); + if (u.origin === server.url.origin || u.hostname === 'ark.coinos.io') return r.continue(); + if (u.hostname !== 'names.coinos.io') return r.abort(); + if (r.method() === 'OPTIONS') return r.respond({ status: 200, headers: cors, body: '{}' }); + const reply = (j, status = 200) => r.respond({ status, headers: { ...cors, 'content-type': 'application/json' }, body: JSON.stringify(j) }); + if (u.pathname === '/register' && r.method() === 'POST') { const j = JSON.parse(r.postData() || '{}'); registered.push(j.name); return reply({ ok: true, name: j.name }); } + if (u.pathname.startsWith('/name/')) return reply({ taken: false }); + if (u.pathname.startsWith('/pubkey/')) return reply({}); + return reply({}); +}); +page.on('pageerror', (e) => errs.push(String(e))); +const click = (x) => page.evaluate((s) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(s)); if (e) e.click(); return !!e; }, x); +const text = () => page.evaluate(() => document.body.innerText); +const waitText = async (x, ms = 20000) => { for (let i = 0; i < ms / 250; i++) { if ((await text()).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const openWalletPage = async () => { + await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings').click()); await sleep(500); + await page.evaluate(() => [...document.querySelectorAll('.settings-tile')].find((e) => /^Wallet/.test(e.textContent.trim())).click()); await sleep(600); +}; +try { + await page.setViewport({ width: 390, height: 844 }); + const cdp = await page.createCDPSession(); + await cdp.send('WebAuthn.enable'); + await cdp.send('WebAuthn.addVirtualAuthenticator', { options: { + protocol: 'ctap2', transport: 'internal', hasResidentKey: true, hasUserVerification: true, isUserVerified: true, hasPrf: true, automaticPresenceSimulation: true } }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'mainnet')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#onb-create', { timeout: 15000 }); + check('the front door says Create an account', (await page.$eval('#onb-create', (e) => e.textContent.trim())) === 'Create an account'); + await page.click('#onb-create'); + check('no seed screens: straight to Claim your name', await waitText('claim your name', 20000)); + const t1 = await text(); + check('no twelve words or check on the way', !(await page.$('.words')) && !/which word/i.test(t1)); + check('no "Not now" on a sign-up\'s name step', !/not now/i.test(t1)); + await page.waitForSelector('.onb input[type=text]', { timeout: 20000 }); + await page.type('.onb input[type=text]', 'quicktester'); + await click('claim'); + check('the name goes to the registrar', await (async () => { for (let i = 0; i < 80 && !registered.length; i++) await sleep(250); return registered.includes('quicktester'); })(), registered.join()); + check('then the look: avatar and cover', await (async () => { for (let i = 0; i < 80; i++) { if (await page.$('.onb-cover')) return true; await sleep(250); } return false; })()); + await click('continue'); await sleep(800); + if (await waitText("you're all set", 8000)) await click('enter'); + await page.waitForSelector('.balance-face', { timeout: 20000 }); + await sleep(1500); + const shape = async () => page.evaluate(() => ({ + faces: document.querySelectorAll('.balance-face').length, + carousel: !!document.querySelector('.bal-carousel'), + label: document.querySelector('.balance-face .small.faint')?.textContent.trim(), + body: document.body.innerText, + })); + let s = await shape(); + check('in, on Spending alone (no Savings face)', s.faces === 1 && !s.carousel && /spending/i.test(s.label || ''), JSON.stringify({ faces: s.faces, carousel: s.carousel, label: s.label })); + check('a quiet line asks for a way back in', /add a way back into your account/i.test(s.body)); + const acct = await page.evaluate(() => JSON.parse(sessionStorage.getItem('btc-wallet-accounts') || '{}')); + const a0 = (acct.accounts || [])[0] || {}; + check('the account: spending-only, phrase unseen, saved to the device', a0.kind === 'spending' && a0.savingsLater && a0.seedSeen === false && a0.persisted, JSON.stringify({ kind: a0.kind, later: a0.savingsLater, seen: a0.seedSeen, persisted: a0.persisted })); + + await openWalletPage(); + check('Settings → Wallet opens with Ways back in', !!(await page.$('.ways-back')) && /ways back in/i.test(await text())); + const views = await page.$$eval('.balance-settings-row[data-view]', (l) => l.map((e) => e.dataset.view)); + check('only Spending is listed, with Add a savings account', views.join() === 'spending' && !!(await page.$('#add-savings')), views.join()); + if (process.env.SHOT) { await page.evaluate(() => document.querySelector('.ways-back').scrollIntoView()); await page.screenshot({ path: process.env.SHOT }); } + // a passkey: the relays are stubbed, so the publish fails offline — the + // error says so and nothing is listed + await page.evaluate(() => document.querySelector('.ways-back-add[data-way="passkey"]')?.click()); + await sleep(4000); + const pk = await page.evaluate(() => ({ rows: [...document.querySelectorAll('.ways-back-row')].map((e) => e.dataset.way), err: document.querySelector('.ways-back .notice.err')?.textContent || '' })); + check('adding a passkey lists it', pk.rows.includes('passkey') && !pk.err, JSON.stringify(pk)); + const backups = await page.evaluate(() => (window.__relayGuard.events || []).filter((e) => e.kind === 30078 && e.tags.some((t) => t[0] === 'd' && t[1] === 'coinos:wallet:v1'))); + check('the reminder line is gone once there is a way back in', await (async () => { await page.goBack(); await page.goBack(); await sleep(600); const gone = !/add a way back into your account/i.test(await text()); await openWalletPage(); return gone; })()); + // the list and the reminder, as the app sees them (record a method directly) + await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.textContent.trim() === 'Show')?.click()); + await sleep(500); + check('Show opens the recovery phrase card', /recovery phrase/i.test(await text())); + + await page.evaluate(() => document.getElementById('add-savings')?.click()); await sleep(800); + const views2 = await page.$$eval('.balance-settings-row[data-view]', (l) => l.map((e) => e.dataset.view)); + check('Add a savings account adds Savings beside Spending', views2.join() === 'spending,savings' && !(await page.$('#add-savings')), views2.join()); + await page.goBack(); await page.goBack(); await sleep(800); + s = await shape(); + check('the wallet now has both faces', s.carousel && s.faces === 2, JSON.stringify({ faces: s.faces, carousel: s.carousel })); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.balance-face', { timeout: 20000 }); await sleep(1500); + s = await shape(); + check('a reload keeps Savings', s.carousel && s.faces === 2); + + // A new device: everything here wiped, the relays holding only what the + // passkey published. Sign in → Passkey opens THIS account, as itself. + const before = await page.evaluate(() => { + const st = JSON.parse(sessionStorage.getItem('btc-wallet-accounts') || '{}'); + const a = (st.accounts || [])[0] || {}; + return { mnemonic: a.mnemonic, nostrPk: a.nostrPk }; + }); + before.backups = backups; + check('the passkey published one encrypted seed copy', before.backups.length >= 1 && !before.backups[0].content.includes(before.mnemonic.split(' ')[0] + ' '), String(before.backups.length)); + const backup = before.backups[before.backups.length - 1]; + await page.evaluateOnNewDocument((ev) => { + const send = WebSocket.prototype.send; + WebSocket.prototype.send = function (d) { + try { + const m = JSON.parse(d); + if (m[0] === 'REQ' && m.slice(2).some((f) => (f.kinds || []).includes(30078) && (f.authors || []).includes(ev.pubkey))) { + const ws = this; + setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', m[1], ev]) })), 1); + } + } catch {} + return send.call(this, d); + }; + }, backup); + await page.evaluate(async () => { + localStorage.clear(); sessionStorage.clear(); + for (const d of (await indexedDB.databases?.() || [])) if (d.name) indexedDB.deleteDatabase(d.name); + localStorage.setItem('btc-wallet-network', 'mainnet'); + }); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#onb-signin', { timeout: 15000 }); + await page.click('#onb-signin'); await sleep(500); + await click('passkey'); + for (let i = 0; i < 120 && !(await page.$('.balance-face')); i++) await sleep(250); + if (!(await page.$('.balance-face'))) console.log((await text()).slice(0, 800), errs); + const after = await page.evaluate(() => { + const st = JSON.parse(sessionStorage.getItem('btc-wallet-accounts') || '{}'); + const a = (st.accounts || [])[0] || {}; + const login = Object.keys(localStorage).concat([]).filter((k) => /:nostrlogin$/.test(k)).map((k) => localStorage.getItem(k)); + return { mnemonic: a.mnemonic, nostrPk: a.nostrPk, kind: a.kind, login, onb: localStorage.getItem('btc-wallet-onb-step') }; + }); + check('the passkey opened the same account', after.mnemonic === before.mnemonic); + check('as itself: same identity, the passkey key not adopted', after.nostrPk === before.nostrPk && !after.login.some((v) => v && v.includes(backup.pubkey)), JSON.stringify({ was: before.nostrPk?.slice(0, 8), now: after.nostrPk?.slice(0, 8) })); + check('no wizard on the way back in', !after.onb && !(await page.$('.onb'))); + check('no page errors', errs.length === 0, errs.join(' | ')); +} finally { + await browser.close(); server.stop(true); +} +console.log(ok ? 'PASS' : 'FAIL'); +process.exit(ok ? 0 : 1); |
