aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--build.js82
-rw-r--r--src/app.js25
-rw-r--r--src/features/hats.js2
-rw-r--r--src/features/index.js35
-rw-r--r--src/features/names.js2
-rw-r--r--src/nip98.js15
-rw-r--r--src/nostr-login.js8
-rw-r--r--tools/feature-registry-check.js51
-rw-r--r--tools/minimal-smoke.js7
-rw-r--r--tools/nostr-login-test.js2
10 files changed, 151 insertions, 78 deletions
diff --git a/build.js b/build.js
index 06dbe6f..e566dc6 100644
--- a/build.js
+++ b/build.js
@@ -42,7 +42,7 @@ const SW = `const CACHE = 'cold-{{VERSION}}';
// module are available offline too. app-{{VERSION}}.js is the main bundle —
// content-addressed, so a new deploy precaches a new name and the old cache
// is dropped wholesale.
-const SHELL = ['./', 'manifest.webmanifest', 'icon-192.png', 'icon-512.png', 'jsqr.js', 'nip46.js', 'app-{{VERSION}}.js'];
+const SHELL = ['./', 'manifest.webmanifest', 'icon-192.png', 'icon-512.png', 'jsqr.js', 'nip46.js', 'app-{{VERSION}}.js'{{EXTRA_SHELL}}];
self.addEventListener('install', (e) => {
self.skipWaiting();
@@ -146,6 +146,12 @@ const SW_REGISTER =
`navigator.serviceWorker.addEventListener('controllerchange',function(){_ask(navigator.serviceWorker.controller)});` +
`addEventListener('load',function(){navigator.serviceWorker.register('sw.js',{updateViaCache:'none'}).catch(function(){})})}</script>`;
+// Lazy bundles load as classic scripts into the page's global scope, where
+// the main bundle's minified top-level names already live — two Bun outputs
+// can mint the same identifier and die with "already been declared". An IIFE
+// wrapper gives each its own scope; they publish through window.* on purpose.
+const iife = (js) => `(function(){${js}\n})();`;
+
// Bundle the standalone jsQR decoder (sets window.jsQR) for lazy loading.
export async function buildJsQr({ minify = true } = {}) {
const result = await Bun.build({
@@ -157,7 +163,7 @@ export async function buildJsQr({ minify = true } = {}) {
for (const log of result.logs) console.error(log);
throw new Error('jsqr bundle failed');
}
- return result.outputs[0].text();
+ return iife(await result.outputs[0].text());
}
// Standalone bundle for the NIP-46 remote-signer client — loaded on demand by
@@ -172,7 +178,7 @@ export async function buildNip46({ minify = true } = {}) {
for (const log of result.logs) console.error(log);
throw new Error('nip46 bundle failed');
}
- return result.outputs[0].text();
+ return iife(await result.outputs[0].text());
}
// The notify-only push handler for builds without the ark+nwc features (the
@@ -228,14 +234,36 @@ export async function buildSwNwc({ minify = true } = {}) {
// because it drives ark's headless pay/balance seam.
const ALL_FEATURES = { gifts: 'giftsFeature', ark: 'arkFeature', nostrlogin: 'nostrLoginFeature', names: 'namesFeature', zaps: 'zapsFeature', nwc: 'nwcFeature', hats: 'hatsFeature', sync: 'syncFeature', messages: 'messagesFeature' };
+// Features with no first-frame surface load as separate chunks right after
+// boot (dynamic import; real files in the split build, inlined in the
+// single-file build). Hook precedence is POSITION in the features array, so
+// each deferred feature holds its slot with an empty placeholder object —
+// every `f[hook]` probe skips it — and is filled in place when it arrives.
+// NOT deferrable: ark (balance on the home screen), messages (deep-linked
+// profiles + the boot shell), gifts is deferrable because app.js awaits the
+// deferred load before the bootUrl check when the path looks like a gift.
+const DEFERRED_FEATURES = ['gifts', 'nwc', 'hats'];
+
export function enabledFeatures(spec = process.env.HAL_FEATURES) {
if (spec == null) return Object.keys(ALL_FEATURES);
return spec.split(',').map((x) => x.trim().toLowerCase()).filter((x) => x in ALL_FEATURES);
}
-function featureIndexSource(enabled) {
- return enabled.map((f) => `import { ${ALL_FEATURES[f]} } from './${f}.js';`).join('\n')
- + `\nexport function buildFeatures(ctx) { return [${enabled.map((f) => `${ALL_FEATURES[f]}(ctx)`).join(', ')}]; }\n`;
+export function featureIndexSource(enabled) {
+ const lazy = enabled.filter((f) => DEFERRED_FEATURES.includes(f));
+ const eager = enabled.filter((f) => !lazy.includes(f));
+ return eager.map((f) => `import { ${ALL_FEATURES[f]} } from './${f}.js';`).join('\n') + `
+export function buildFeatures(ctx) {
+ return [${enabled.map((f) => (lazy.includes(f) ? '{}' : `${ALL_FEATURES[f]}(ctx)`)).join(', ')}];
+}
+export async function loadDeferredFeatures(ctx, features) {
+ const late = [];
+ await Promise.all([${lazy.map((f) => `
+ import('./${f}.js').then((m) => { late.push(features[${enabled.indexOf(f)}] = m.${ALL_FEATURES[f]}(ctx)); })`).join(',')}
+ ]);
+ return late;
+}
+`;
}
const featurePlugin = (enabled) => ({
@@ -274,18 +302,21 @@ function gitCommit() {
return 'dev';
}
-async function bundleAppJs({ minify, features, staging }) {
+async function bundleApp({ minify, features, staging, splitting = false }) {
const result = await Bun.build({
entrypoints: ['./src/app.js'],
target: 'browser',
minify,
+ // The split build emits real ESM chunks (deferred features arrive as their
+ // own files); the single-file build inlines the dynamic imports instead.
+ ...(splitting ? { splitting: true, format: 'esm', naming: { chunk: 'chunk-[hash].[ext]' } } : {}),
plugins: [featurePlugin(enabledFeatures(features)), flagsPlugin(staging)],
});
if (!result.success) {
for (const log of result.logs) console.error(log);
throw new Error('bundle failed');
}
- return result.outputs[0].text();
+ return result.outputs;
}
// The brand mark for the static boot shell, lifted from brandHeader in
@@ -328,7 +359,8 @@ ${pwa ? SW_REGISTER + '\n' : ''}</body>
// Self-contained single file (dev server, test tools, save-and-open-offline).
export async function buildHtml({ minify = true, pwa = minify, features = process.env.HAL_FEATURES, staging = isStaging() } = {}) {
- let js = await bundleAppJs({ minify, features, staging });
+ const outputs = await bundleApp({ minify, features, staging });
+ let js = await outputs[0].text();
// Guard against a literal </script> inside the bundle closing our tag early.
js = js.replaceAll('</script', '<\\/script');
const css = await Bun.file('./src/style.css').text();
@@ -337,15 +369,23 @@ export async function buildHtml({ minify = true, pwa = minify, features = proces
}
// The hosted split build: a small no-cache index.html (CSS + boot shell) that
-// loads the bundle from a content-addressed app-<version>.js. The page stays
-// tiny on every load while the heavy bundle is cached by name — a repeat
-// visit re-downloads kilobytes, not the whole app.
+// loads the app as a content-addressed ESM entry plus chunks — the deferred
+// features ride in their own files, fetched right after boot. The page stays
+// tiny on every load while the heavy bundles are cached by name — a repeat
+// visit re-downloads kilobytes, not the app.
export async function buildSplit({ minify = true, features = process.env.HAL_FEATURES, staging = isStaging() } = {}) {
- const js = (await bundleAppJs({ minify, features, staging })).replaceAll('{{COMMIT}}', gitCommit());
+ const outputs = await bundleApp({ minify, features, staging, splitting: true });
+ const entry = outputs.find((o) => o.kind === 'entry-point') || outputs[0];
+ const js = (await entry.text()).replaceAll('{{COMMIT}}', gitCommit());
+ const chunks = await Promise.all(outputs.filter((o) => o !== entry).map(async (o) => ({
+ name: o.path.split('/').pop(),
+ text: (await o.text()).replaceAll('{{COMMIT}}', gitCommit()),
+ })));
const css = await Bun.file('./src/style.css').text();
- const html = (await pageHtml({ css, staging, pwa: true, scriptHtml: '<script src="app-{{VERSION}}.js" defer></script>' }))
+ // module scripts defer by default; the boot shell paints while this loads
+ const html = (await pageHtml({ css, staging, pwa: true, scriptHtml: '<script type="module" src="app-{{VERSION}}.js"></script>' }))
.replaceAll('{{COMMIT}}', gitCommit());
- return { html, js };
+ return { html, js, chunks };
}
if (import.meta.main) {
@@ -353,15 +393,17 @@ if (import.meta.main) {
// Hashed with {{VERSION}} still a placeholder, so the page, the bundle
// filename and the worker are all stamped with the same token — the page
// cannot contain a hash of itself.
- const { html: rawHtml, js: rawJs } = await buildSplit({ minify: true });
- const version = Bun.hash(rawHtml + rawJs).toString(36);
+ const { html: rawHtml, js: rawJs, chunks } = await buildSplit({ minify: true });
+ const version = Bun.hash(rawHtml + rawJs + chunks.map((c) => c.text).join('')).toString(36);
const html = rawHtml.replaceAll('{{VERSION}}', version);
await Bun.write('dist/index.html', html);
await Bun.write(`dist/app-${version}.js`, rawJs.replaceAll('{{VERSION}}', version));
+ for (const c of chunks) await Bun.write('dist/' + c.name, c.text);
// Drop stale content-addressed bundles from earlier local builds so dist/
// mirrors a deploy (the deploy hook rsyncs dist/ wholesale).
+ const keep = new Set([`app-${version}.js`, ...chunks.map((c) => c.name)]);
for (const f of await readdir('dist')) {
- if (/^app-[a-z0-9]+\.js$/.test(f) && f !== `app-${version}.js`) await Bun.file('dist/' + f).delete();
+ if (/^(app|chunk)-[A-Za-z0-9]+\.js$/.test(f) && !keep.has(f)) await Bun.file('dist/' + f).delete();
}
// Lazy-loaded QR decoder — kept out of index.html, fetched only when a
@@ -387,6 +429,7 @@ if (import.meta.main) {
const nwcSw = feats.includes('nwc') && feats.includes('ark');
const swBody = SW
.replaceAll('{{VERSION}}', version)
+ .replaceAll('{{EXTRA_SHELL}}', chunks.map((c) => `, '${c.name}'`).join(''))
.replaceAll('{{NWC_WAKE_FALLBACK}}', nwcSw ? '' : SW_WAKE_ONLY);
await Bun.write('dist/sw.js', nwcSw ? swBody + '\n' + await buildSwNwc() : swBody);
for (const f of STATIC) await Bun.write('dist/' + f, Bun.file('static/' + f));
@@ -396,6 +439,7 @@ if (import.meta.main) {
const kb = (Buffer.byteLength(html) / 1024).toFixed(0);
const jkb = (Buffer.byteLength(rawJs) / 1024).toFixed(0);
- console.log(`✓ dist/index.html written (${kb} KB) + app-${version}.js (${jkb} KB) — offline: keep the two side by side (or install the PWA)`);
+ const ckb = (chunks.reduce((n, c) => n + Buffer.byteLength(c.text), 0) / 1024).toFixed(0);
+ console.log(`✓ dist/index.html (${kb} KB) + app-${version}.js (${jkb} KB) + ${chunks.length} deferred chunk(s) (${ckb} KB) — offline use: install the PWA`);
console.log(`✓ PWA: manifest.webmanifest, sw.js (cold-${version}), ${STATIC.length} icons, jsqr.js, nip46.js, ${locales.length} locales`);
}
diff --git a/src/app.js b/src/app.js
index 237808e..2e52e56 100644
--- a/src/app.js
+++ b/src/app.js
@@ -8,12 +8,12 @@ import { Wallet, newMnemonic, isValidMnemonic, accountXpubFor, cacheKeyFor, utxo
import { qrSvg } from './qr.js';
import { makeSearcher, resultRows, searchable, punkUrl } from './recipient-search.js';
import { npubOf } from './nostr.js';
-import { nip98Header } from './nostr-login.js';
+import { nip98Header } from './nip98.js';
import { NOSTR_MARK } from './features/nostrlogin.js';
import { scanQr } from './scan.js';
import { dataSources, getSource, setSource, getNetwork, setNetwork, NETWORKS } from './api.js';
import { STAGING } from './build-flags.js';
-import { buildFeatures } from './features/index.js';
+import { buildFeatures, loadDeferredFeatures } from './features/index.js';
import { t, LANGS, getLang, setLang, isRTL, loadLocale } from './i18n.js';
import {
fmtBtc,
@@ -1093,6 +1093,7 @@ async function activateAccount(acc, opts = {}) {
// threads, decrypted DMs) belong to the old keys, and surviving into this
// account they'd both leak the old account's messages on screen and block
// the new account's own subscriptions from ever starting.
+ _featuresInited = false;
for (const f of FEATURES) { try { f.stop && f.stop(); } catch {} }
activeId = acc.id;
// A gift link generates this wallet only to claim into. Keep it provisional
@@ -1119,6 +1120,7 @@ async function activateAccount(acc, opts = {}) {
persistAccounts();
autoSave(acc);
for (const f of FEATURES) { try { f.init && f.init(); } catch {} } // per-feature wallet lifecycle
+ _featuresInited = true; // a deferred feature landing after this point gets its init() on arrival
// A freshly generated identity provably has no profile yet — let features
// skip the "is there a kind-0?" wait (avatar would sit blank meanwhile).
if (opts.generated) featureHook('identityGenerated');
@@ -1783,6 +1785,7 @@ function lock({ offerPassword = false } = {}) {
wallet.stopRealtime();
// A locked session must not keep listening as the account that just left —
// and whatever logs in next must not inherit its threads or subscriptions.
+ _featuresInited = false;
for (const f of FEATURES) { try { f.stop && f.stop(); } catch {} }
clearAccounts();
vaultPassword = null;
@@ -1909,6 +1912,7 @@ function softLock() {
}
}
const keepId = activeId;
+ _featuresInited = false;
for (const f of FEATURES) { try { f.stop && f.stop(); } catch {} }
vaultPassword = null;
accounts = watch.slice();
@@ -4398,6 +4402,18 @@ const ctx = {
},
};
const FEATURES = buildFeatures(ctx);
+// Deferred features (gifts, NWC, hats) hold placeholder slots in FEATURES —
+// hook precedence is position — and arrive as separate chunks right after
+// boot, filled in place. If a wallet opened before they landed, each newcomer
+// gets the init() it missed; if the wallet locked again, it doesn't.
+let _featuresInited = false;
+const _deferredReady = (async () => {
+ try {
+ const late = await loadDeferredFeatures(ctx, FEATURES);
+ if (_featuresInited) for (const f of late) { try { f.init && f.init(); } catch {} }
+ if (late.length) render();
+ } catch (e) { console.error('deferred features failed to load', e); }
+})();
// apply text direction, then restore a wallet left open in this tab — otherwise
// show the unlock screen.
@@ -4467,8 +4483,11 @@ document.addEventListener('visibilitychange', () => {
else onAppVisible();
});
window.addEventListener('load', () => setTimeout(() => { if (activeAccount()) claimMigratedName(); }, 1500));
-loadLocale(getLang()).finally(() => {
+loadLocale(getLang()).finally(async () => {
applyBootAutoLogout(); // clear an overdue session before we read it for claim targets
+ // A gift link's feature is deferred — wait for it before asking who owns
+ // the URL, or the claim would fall through to a normal boot and be lost.
+ if (/^\/(g|ag|lg)\//.test(location.pathname)) await _deferredReady;
if (featureHook('bootUrl')) return; // a feature consumed the URL (e.g. a gift claim)
if (!restoreAccountsState()) render();
});
diff --git a/src/features/hats.js b/src/features/hats.js
index bb9b8b0..43151ad 100644
--- a/src/features/hats.js
+++ b/src/features/hats.js
@@ -13,7 +13,7 @@
import { t } from '../i18n.js';
import { fmtSats } from '../format.js';
-import { nip98Header } from '../nostr-login.js';
+import { nip98Header } from '../nip98.js';
import { getNetwork } from '../api.js';
const REGISTRAR = 'https://names.coinos.io';
diff --git a/src/features/index.js b/src/features/index.js
index 5ecb7c0..a32bced 100644
--- a/src/features/index.js
+++ b/src/features/index.js
@@ -3,24 +3,33 @@
// build.js replaces this module's contents based on HAL_FEATURES, so a
// disabled feature's code — and its network endpoints — never enters the
// bundle. See featureIndexSource() in build.js.
-
-import { giftsFeature } from './gifts.js';
-import { nostrLoginFeature } from './nostrlogin.js';
+//
+// This checked-in copy must stay byte-equivalent (comments aside) to
+// featureIndexSource(all features) — tools/feature-registry-check.js enforces
+// it. Deferred features (gifts, nwc, hats) hold their array position with an
+// empty placeholder — hook precedence is position — and are filled in place
+// by loadDeferredFeatures right after boot, as separate chunks in the split
+// build. NB order is meaningful where hooks stack: gifts' receive takeover
+// and balance line come before ark's; zaps sits after ark so an npub prefers
+// an instant Ark zap; nwc sits after ark because it drives ark's headless
+// pay/balance seam; names sits between ark and zaps so a pasted user@domain
+// resolves over DNS (BIP-353) before zaps treats it as a Lightning address.
import { arkFeature } from './ark.js';
+import { nostrLoginFeature } from './nostrlogin.js';
import { namesFeature } from './names.js';
import { zapsFeature } from './zaps.js';
import { syncFeature } from './sync.js';
-import { nwcFeature } from './nwc.js';
-import { hatsFeature } from './hats.js';
import { messagesFeature } from './messages.js';
-// NB order is meaningful where hooks stack: gifts' receive takeover and
-// balance line come before ark's, matching the pre-plugin layout. zaps sits
-// after ark so an npub prefers an instant Ark zap and only falls through to a
-// Lightning zap when Ark can't serve it. nwc sits after ark because it
-// drives ark's headless pay/balance seam.
-// names sits between ark and zaps: a pasted user@domain resolves over DNS
-// (BIP-353) before the zaps feature treats it as a plain Lightning address.
export function buildFeatures(ctx) {
- return [giftsFeature(ctx), arkFeature(ctx), nostrLoginFeature(ctx), namesFeature(ctx), zapsFeature(ctx), nwcFeature(ctx), hatsFeature(ctx), syncFeature(ctx), messagesFeature(ctx)];
+ return [{}, arkFeature(ctx), nostrLoginFeature(ctx), namesFeature(ctx), zapsFeature(ctx), {}, {}, syncFeature(ctx), messagesFeature(ctx)];
+}
+export async function loadDeferredFeatures(ctx, features) {
+ const late = [];
+ await Promise.all([
+ import('./gifts.js').then((m) => { late.push(features[0] = m.giftsFeature(ctx)); }),
+ import('./nwc.js').then((m) => { late.push(features[5] = m.nwcFeature(ctx)); }),
+ import('./hats.js').then((m) => { late.push(features[6] = m.hatsFeature(ctx)); })
+ ]);
+ return late;
}
diff --git a/src/features/names.js b/src/features/names.js
index cf2aa3c..56fc77d 100644
--- a/src/features/names.js
+++ b/src/features/names.js
@@ -12,7 +12,7 @@
// record falls back to the Lightning-address flow (zaps feature).
import { resolveBip353, parsePaymentName, parseBip21 } from '../bip353.js';
-import { nip98Header } from '../nostr-login.js';
+import { nip98Header } from '../nip98.js';
import { qrSvg } from '../qr.js';
import { isArkAddress } from './ark.js';
import { getNetwork } from '../api.js';
diff --git a/src/nip98.js b/src/nip98.js
new file mode 100644
index 0000000..aa0343e
--- /dev/null
+++ b/src/nip98.js
@@ -0,0 +1,15 @@
+// NIP-98 HTTP Auth header (kind 27235): the signature covers the exact URL,
+// method and body hash, so a captured header is useless anywhere else. Its
+// own module — every build needs it (registrar calls), but only some builds
+// need the login/backup machinery around it in nostr-login.js.
+import { hex } from '@scure/base';
+import { sha256 } from '@noble/hashes/sha256';
+
+export async function nip98Header(signer, url, method, bodyText) {
+ const tags = [['u', url], ['method', method.toUpperCase()]];
+ if (bodyText) tags.push(['payload', hex.encode(sha256(new TextEncoder().encode(bodyText)))]);
+ const evt = await signer.signEvent({
+ kind: 27235, created_at: Math.floor(Date.now() / 1000), tags, content: '',
+ });
+ return 'Nostr ' + btoa(JSON.stringify(evt));
+}
diff --git a/src/nostr-login.js b/src/nostr-login.js
index 6958da0..b032c73 100644
--- a/src/nostr-login.js
+++ b/src/nostr-login.js
@@ -270,11 +270,3 @@ export async function walletForSigner(signer) {
// request with a nostr key. `u` and `method` bind the signature to this exact
// request and `payload` to its body, so a captured header can't be replayed
// against a different endpoint or a tampered body.
-export async function nip98Header(signer, url, method, bodyText) {
- const tags = [['u', url], ['method', method.toUpperCase()]];
- if (bodyText) tags.push(['payload', hex.encode(sha256(new TextEncoder().encode(bodyText)))]);
- const evt = await signer.signEvent({
- kind: 27235, created_at: Math.floor(Date.now() / 1000), tags, content: '',
- });
- return 'Nostr ' + btoa(JSON.stringify(evt));
-}
diff --git a/tools/feature-registry-check.js b/tools/feature-registry-check.js
index 9b7ac5e..0931156 100644
--- a/tools/feature-registry-check.js
+++ b/tools/feature-registry-check.js
@@ -1,40 +1,29 @@
-// Guard: src/features/index.js and build.js's ALL_FEATURES must agree.
+// Guard: src/features/index.js and build.js's featureIndexSource must agree.
// They drifted once (nwc was added to index.js only, so its i18n strings
// shipped but its code never did — the settings card silently never rendered,
// because build.js SWAPS OUT features/index.js for a generated module).
+//
+// The strongest form of agreement is equality: the checked-in module must be
+// code-identical (comments and whitespace aside) to what featureIndexSource
+// generates for the full feature set — imports, construction order, deferred
+// placeholder slots, and the deferred loader all included.
// bun tools/feature-registry-check.js
import { readFileSync } from 'node:fs';
+import { featureIndexSource, enabledFeatures } from '../build.js';
-const idx = readFileSync(new URL('../src/features/index.js', import.meta.url), 'utf8');
-const build = readFileSync(new URL('../build.js', import.meta.url), 'utf8');
+const strip = (s) => s
+ .replace(/\/\/[^\n]*/g, '') // line comments
+ .replace(/\s+/g, ' ') // whitespace runs
+ .replace(/, /g, ',').replace(/ ,/g, ',')
+ .trim();
-const inIndex = [...idx.matchAll(/import \{ (\w+Feature) \} from '\.\/([\w-]+)\.js'/g)]
- .map((m) => ({ fn: m[1], name: m[2] }));
-const registry = build.match(/const ALL_FEATURES = \{([^}]*)\}/s)?.[1] || '';
-const inBuild = [...registry.matchAll(/(\w+):\s*'(\w+Feature)'/g)].map((m) => ({ name: m[1], fn: m[2] }));
+const idx = readFileSync(new URL('../src/features/index.js', import.meta.url), 'utf8');
+const gen = featureIndexSource(enabledFeatures(undefined));
-let ok = true;
-for (const f of inIndex) {
- if (!inBuild.find((b) => b.fn === f.fn)) {
- console.log(`x ${f.fn} (${f.name}.js) is in features/index.js but NOT in build.js ALL_FEATURES — it will not ship`);
- ok = false;
- }
-}
-for (const b of inBuild) {
- if (!inIndex.find((f) => f.fn === b.fn)) {
- console.log(`x ${b.fn} is in build.js ALL_FEATURES but not imported in features/index.js`);
- ok = false;
- }
-}
-// Compare the ORDER FEATURES ARE CONSTRUCTED IN (hook precedence depends on
-// it), not the order they happen to be imported in.
-const built = [...(idx.match(/return \[([^\]]*)\]/s)?.[1] || '')
- .matchAll(/(\w+Feature)\(ctx\)/g)].map((m) => m[1]);
-const orderIdx = built.join(',');
-const orderBuild = inBuild.map((b) => b.fn).join(',');
-if (ok && orderIdx !== orderBuild) {
- console.log(`x feature ORDER differs (hook precedence depends on it)\n index.js: ${orderIdx}\n build.js: ${orderBuild}`);
- ok = false;
+if (strip(idx) !== strip(gen)) {
+ console.log('x src/features/index.js does not match build.js featureIndexSource (full set)');
+ console.log('\n--- generated (normalized) ---\n' + strip(gen));
+ console.log('\n--- checked in (normalized) ---\n' + strip(idx));
+ process.exit(1);
}
-if (ok) console.log(`ok ${inIndex.length} features registered consistently`);
-process.exit(ok ? 0 : 1);
+console.log(`ok features/index.js matches the generator (${enabledFeatures(undefined).length} features, deferred slots included)`);
diff --git a/tools/minimal-smoke.js b/tools/minimal-smoke.js
index bd0d588..f3f896c 100644
--- a/tools/minimal-smoke.js
+++ b/tools/minimal-smoke.js
@@ -29,7 +29,10 @@ check('no ark protocol code', !html.includes('Ark VTXO mailbox authorization'));
check('no swap protocol code', !html.includes('swap/reverse'));
check('no musig code', !html.includes('MuSig/noncecoef'));
check('no silent-payment engine', !html.includes('BIP0352') && !html.includes('__SP_WORKER__'));
-check('no nostr sync code', !html.includes('30078') && !html.includes('relay.coinos.io'));
+// The sync EVENT KIND must be gone; the coinos relay hostname alone is not a
+// sync marker anymore — recipient search (send-to-npub, core UX) carries
+// PROFILE_RELAYS for profile lookups even in the minimal build.
+check('no nostr sync code', !html.includes('30078'));
const server = Bun.serve({ port: 5198, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
@@ -62,6 +65,8 @@ try {
await sleep(400);
console.log('\n[1] import + core wallet');
+ await clickText('button', 'Get started'); // the front door gained a step
+ await sleep(300);
await clickText('button', 'Import existing');
await sleep(200);
await page.type('textarea', generateMnemonic(wordlist));
diff --git a/tools/nostr-login-test.js b/tools/nostr-login-test.js
index 468ffb4..25983a4 100644
--- a/tools/nostr-login-test.js
+++ b/tools/nostr-login-test.js
@@ -58,7 +58,7 @@ check('another account cannot decrypt it', denied);
console.log('\n[NIP-98 auth]');
{
- const { nip98Header } = await import('../src/nostr-login.js');
+ const { nip98Header } = await import('../src/nip98.js');
const { verifyEvent } = await import('nostr-tools/pure');
const { sha256: sha } = await import('@noble/hashes/sha256');
const body = JSON.stringify({ name: 'x' });