217 KiB
Morphit — June 2026 DEEP-DEEP audit campaign
Status legend: ⬜ not started · 🔄 in progress · ✅ done · ⚠️ finding open · 🟢 finding fixed
Scope (Ken's directive): all five persona walkthroughs touching every interactive surface; a 94+ task full security + code audit of EVERY file/script in the repo; a "what if every op was hostile?" sweep per handler + a consolidated chain-direct attack re-pass; DB dead fields, draft finalization, FAQ accuracy, mobile-responsiveness, UI/UX, README + OPERATIONS + RUN-A-NODE + every docs/*.md accuracy, broken references, efficiency / page-load, wiring, keys, drift, memory leaks, fallbacks/failovers, "never leave a user hanging"; any audit type not yet performed; recommendations; grandma-friendliness. Fix as we go. No tarball until Ken says go.
Repo size at campaign start: 6 apps + 7 packages; 45 routes, 77 components, 197 web lib .ts, 190 smoke scripts, 70 docs/*.md.
cp454–cp456 — DELTA deep-deep (v1.4.7 → v1.4.8) ✅ CLEAR
Scope: only the v1.4.8 surface — the 8 t.txt tasks (#1 profile-field clear, #2 mark-all-read
under all tabs, #3 chatroom loading-vs-empty, #5 remove ✓Sent, #6 background notifications,
#7 green toolbar buttons, #8 contact-flash), the null-pathname layout crash fix, the
(peer, order) chat-thread remount fix (the #4 root cause), and the opt-in chat debug
instrumentation (client + server). Black-hatted across injection, authz, privacy, input
validation, DoS/rate-limit, data-integrity, error-handling, races, resource-leaks, i18n,
backward-compat, a11y. Every functional change carries a tamper-tested registered smoke.
No findings.
CLEAR:
- Chat debug instrumentation (privacy / info-leak — the highest-risk addition). OFF by
default at every layer: client gated on
chatDebugEnabled()(localStorage/?chatdebug=1), server gated onMORPHIT_CHAT_DEBUG=1env. Verified by grep that NO log call anywhere (chatDebug/chatDbg/tailerDbg/streamDbg) passes ciphertext, plaintext, or decrypted text — metadata only (sender/recipient/order_permlink/id/decrypt-ok flag).client_tagis truncated to 10 chars viatagPreview. No behavior change when disabled (early-return no-ops). Privacy-is-priority-#1 preserved. Remove after Ken confirms the #4 fix live. - #4
(peer, order)remount fix (correctness / data-integrity). The{#key}forces a clean ConversationView remount when thread identity changes, sodeps.orderPermlink(an onMount snapshot) can never go stale. Remount re-fetches history + reconnects the SSE, both per-(me,peer) and idempotent — no dup/leak. Closes BOTH the mis-tag (sender) and the mis-filter (receiver, line-668) halves. Fresh loads unaffected (the$derivedreads?order=synchronously). Tamper-testedchat-thread-remount-smoke(5). - #1 profile-field clear (data-integrity / the indexer merge contract).
buildProfileBodynow sends a PRESENT-but-empty text field as''(the clear signal the indexer merge honors:''⇒clear, absent⇒keep), aligned to the avatar convention. Only op with a mergeable metadata blob; localStorage prefs are wholesale-written so unaffected. redactPrivateKeys still runs on the non-empty branch → no key-leak regression.profile.clear.test.ts(6, tamper-tested) + updatedops.redaction.test.ts(empty⇒'' clear, absent⇒omit distinction). - Null-
pathnamelayout crash fix (error-handling / robustness).afterNavigatenow readsnav.from.url?.pathname === nav.to?.url?.pathnameand the layout's other three$page.url?.pathname ?? ''reads are guarded. A nullnav.to.urlno longer throws through SvelteKit's afterNavigate callback Set (which aborted later callbacks). No behavior change on the happy path; strictly removes a crash. - #6 background notifications (DoS / correctness). Lifting
!document.hiddenoff the global-chat-activity ping handler makes a backgrounded tab refresh its favicon/title badge. The ping is event-driven and cheap; the idle interval backstop stays gated → no polling-storm regression. Tamper-testedchat-bg-notify-v148-smoke(4). - #2/#3/#5/#7/#8 (UI, low-risk). Pure client render/formatting; no authz/data surface.
chat-ui-v148-smoke(6) + updatedchat-sent-state-smoke(6) +footer-contact-flash-smoke(7). i18n parity held (10/10) after #5's key removal + #3's key addition; snapshot regenerated.
Verification totals: web vitest 982 (2 stale redaction tests corrected: empty⇒'' clear), indexer vitest 610, svelte-check 0/0, indexer tsc 0, vite build ✓, i18n parity 10/10, native-floor 11/11, dead-key clean, persona-walkthrough 185/185, full smoke battery 14117 scenarios (4 runner shape-pins updated for the null-safety guard + debug-log block form; no behavior pins moved), version-consistency 19/19 @ 1.4.8. No DB migration ships; on-chain release-payload format unchanged (backward-compatible; MORPHIT IS LIVE).
cp453 — DELTA deep-deep (v1.4.5 → v1.4.7) ✅ CLEAR
Scope: only the cp453 surface (the 8 t.txt tasks + chat-notifications-default + the ~60s sender-UX fix). Black-hatted across injection, authz, privacy, input validation, DoS/rate-limit, data-integrity, error-handling, races, resource-leaks, i18n, backward-compat, a11y. Every functional change carries a tamper-tested registered smoke (11 guards). No findings.
CLEAR:
- #1 active RPC probe (SSRF / DoS / privacy) —
probeOnefetches ONLYDEFAULT_BLURT_RPC_ENDPOINTS(a fixed canonical/public list); no user input reaches the URL → no SSRF. The 5s guard is a single GLOBAL cache keyed on nothing, so varying query params (?probe=1&x=…) cannot bypass it; concurrent bursts coalesce onto one in-flight probe; per-node 6s timeout; the route also carries the per-IPresourcerate-limit. Response exposes only coarse public health. The browser never fetches a node (indexer-only) — pinned byendpoint-list-throttle+rpc-endpoints-probe. - #2 featured-bids query + modal (SQLi / XSS / cross-account leak) — the added
columns are plain
o.*references on the already-parameterized query ($1/$2/$3); the LEFT JOIN keys ono.account = b.bidder, so a query for account X returns only X's OWN featured orders (which are public promotions) — no cross-account leak, no new exposure beyond the pre-existing endpoint. The modal rendersorder_permlink+ the summary through auto-escaped Svelte + i18n values; amounts areNumber. Native<dialog>closed on destroy (no leak). - #3 fiat pricing —
usdToFiat/formatFiaton the public/v1/fxtable; FX fetch is try/catch with a USD fallback; no injection surface. - #4 power-down floor —
floorToBlurtPrecisionguarantees fill ≤ available (fixes thetoFixed(3)round-UP that exceeded available); tamper-pinned 25/25. - #5 footer flash — the
highlightparam is ONLY ever compared to the literal'current'; no reflection, no injection. - #6 support Matrix card removal — pure deletion;
operator_matrix_room(unrelated about-instance schema field) verified untouched. - #7 FAQ AND-search — terms flow through normalize +
includes; no regex/HTML injection; i18n parity 10/10, completeness 4/4. - #8 hide-confirm + chat-default migration + ~60s sent-state — hide gated behind a destructive ConfirmModal; the chat-default migration is one-time (done-flag) and never overrides a later explicit opt-out; the sent-state is a pure client render split (pending→broadcast→confirmed unchanged on the wire). On-chain payloads untouched (backward-compatible — MORPHIT IS LIVE).
Backward-compat: FeaturedBidHistoryEntry gained only NULLABLE fields (older
consumers unaffected); no on-chain op or release-payload shape changed.
Guards (all registered + tamper-tested): endpoint-list-throttle 10, rpc-endpoints-probe 6, featured-bid-history-modal 8, feature-pills-fiat 4, wallet-power-modal 25, footer-contact-flash 7, support-operator-matrix-removed 3, orderbook-hide-confirm 6, chat-notif-default-on 4, chat-sent-state 6, + faqIndex vitest 29 & grandma-coverage 14. Persona walkthrough 185/185. svelte-check 0/0, indexer tsc clean, parity 10/10, completeness 4/4.
PHASE 1 — Persona walkthroughs (every button / link / field / select)
- ✅ Bob — Blurt multi-login (login, onboarding/import posting-key + keyfile + master-password, account switching, authenticated surfaces) — persona-walkthrough 183/183; cp346 account-switch leak closed
- ✅ Sally-user — no crypto (new-account onboarding, orderbook browse, order detail, chat, feedback) — sally-walkthrough 22/22 + persona pins
- ✅ Sally-operator — stands up a node from the .md (RUN-A-NODE + OPERATIONS walked as written) — persona-walkthrough Sally-operator pins; cp344 broadcast proxy + cp346 endpoint errors folded in
- ✅ Josie — sysadmin, daily
morphit-opsusage (every command + menu + prompt + select) — 46 ops-cli smokes green + Jo-1..Jo-9b pins - ✅ Charlie — MCP agent, read-only (every MCP tool, deeplink-handoff posture) — mcp-server suite green (9+9+23+4) + MCP pins (Re-walked cp347; see the cp347 progress-log entry. All five green; the cp338→cp346 changes improve rather than break these flows.)
Surface checklist per persona: every <button>, <a>, on:click/onclick,
<input>/<textarea>, every <select>/option, every form submit, every error/empty/
loading state ("never leave a user hanging"), keyboard + RTL + dark-mode, mobile width.
PHASE 2 — 94+ task deep-deep (categories A–M, black-hat)
- ⬜ A static code (dead code, unreachable, TODO/FIXME, console.* leaks)
- ⬜ B deps / supply-chain (lockfile integrity, advisory check w/o
audit fix, license parity) - ⬜ C SQL / DB (injection, dead fields, migrations, indexes, schema drift)
- ⬜ D HTTP / API (validation, authz, rate-limit, error leakage, CORS, headers)
- ⬜ E crypto (key handling, signature paths, RNG, envelope/2FA, byte-identity proofs)
- ⬜ F privacy (no-IP, no-telemetry, no-CDN, fresh-address, jitter, ciphertext-only)
- ⬜ G operator-trust (untrusted-by-default, federation, rogue-operator containment)
- ⬜ H frontend (XSS, injection, @html, unsafe href, state leaks)
- ⬜ I contracts / op schemas (narrow unions, validation, version frozen enums)
- ⬜ J build / CI (gates green, version-consistency, smoke registration, reproducibility)
- ⬜ K threat modeling (STRIDE per changed subsystem, attack trees)
- ⬜ L per-subsystem walk (every sibling file/route/dispatch/docblock/i18n consumer)
- ⬜ M i18n integrity (parity, coverage, completeness, native-floor, hardcoded-english, drift)
PHASE 3 — Hostile-op sweep (every handler) + chain-direct attack re-pass
Handlers (17): ⬜ block ⬜ chat ⬜ chatIdentity ⬜ chatRead ⬜ featureBid ⬜ feeAttest ⬜ feedback ⬜ feedbackResponse ⬜ operatorBlock ⬜ operatorPaymentMethod ⬜ operatorRegister ⬜ order ⬜ orderCancel ⬜ orderReplace ⬜ profile ⬜ release ⬜ strangerFee Op types (18): block, chat_identity, chat_read, chat, feature_bid, fee_attest, feedback_response, feedback, operator_block, operator_register, operator_update, order_cancel, order_replace, order, payment_method_addition, profile, release, stranger_fee.
- ⬜ Per-handler: "what if this op was hostile?" (malformed/oversized/spoofed/replayed/out-of-order/unicode/injection)
- ⬜ Consolidated chain-direct attack pass (bypass the frontend, post raw ops)
PHASE 4 — Cross-cutting
- ⬜ DB dead fields (the known
voting_manabar/received_vesting_shares/delegated_vesting_sharesprune candidate + any others) - ⬜ Draft finalization (any half-built features / TODO drafts)
- ⬜ FAQ accuracy (138 entries × facts vs code/economics; new
why_agplconsistency) - ⬜ Mobile responsiveness (every route at narrow width; the
dev/responsiveharness) - ⬜ UI/UX oddities; grandma-friendliness
- ⬜ README accuracy
- ⬜ OPERATIONS.md + RUN-A-MORPHIT-NODE.md accuracy (walked as written)
- ⬜ Every other docs/*.md accuracy (70 files)
- ⬜ Broken references (links, paths, anchors, cross-doc)
- ⬜ Efficiency / slow page loads (bundle, lazy-load, N+1, waterfalls)
- ⬜ Wiring (call-site + registered + tested for every feature)
- ⬜ Keys / vals (missing, dead, drifted, bad)
- ⬜ Memory leaks (timers, listeners, subscriptions, SW caches)
- ⬜ Fallbacks / failovers everywhere they should be
PHASE 5 — Audit types not yet performed (identify + run)
- ⬜ Enumerate prior audit types (from docs/AUDIT-*.md) → find the gaps → run them
PHASE 6 — Recommendations
- ⬜ Change / remove / simplify list; grandma-friendliness verdict
PROGRESS LOG
(newest first)
Session (cp452, 2026-07-11) — v1.4.5 pre-release deep-deep of the v1.4.0→v1.4.5 delta (cp451 + cp452 chat/profile stack). ZERO new findings; release gate GREEN except the full battery (needs a longer window).
Release deep-deep scoped to everything NEW since v1.4.0 (which was itself deep-deep-clean at its cut): cp451 canary RPC failover (laptop/VPS tooling) + the cp452 chat+profile stack. Black-hatted across categories A–M + the Phase-3 hostile-op lens. Git is not in this tarball tree, so the surface was walked from the TARBALL ledger: profileCache.ts (+primeProfile/prime-hold), settings + orderbook (Tasks 2/3), broadcast.ts+blurt/client.ts (hedge-off, D/H), net/config.ts (RPC re-partition), chatUnread.ts+AvatarMenu.svelte (badge/mark-all-read), ChatMessage.svelte+chat/+page.svelte (bubble/slide/inbox-card), login +10 locales (emoji), + the canary scripts.
A static — clean: every console.* in the touched files is a console.warn/error in a catch (error logging, none added this session); no console.log/debugger/TODO/FIXME in the delta. B deps — no new deps, no package.json/lockfile change, no audit fix. C SQL/DB — no migration / schema / query change in the delta (primeProfile is a client cache; hedge is transport; RPC partition is client config) → N/A. D HTTP/API — the one-line broadcast_transaction_synchronous {hedge:false} leaves the op-type + custom_json-id allowlists intact (broadcast-op-allowlist 5/5), error bodies stay generic, and user-facing READS still hedge (getAccount defaults userFacing→hedge); resolveHedge = options.hedge ?? options.userFacing===true is the correct precedence. E crypto — hedge is redundancy/latency, not an auth/validation or signature property; disabling it on a signed write weakens nothing (active-owner-key-invariants 13/13, broadcast-same-origin 20/20); a primed avatar_svg is re-run through sanitizeSvg on READ by extractLabelPropsFromProfile, so an optimistic avatar can't introduce script. F privacy — the RPC re-partition added NO browser→third-party privacy leak (rpc-privacy-routing 16/16; profile fetches go same-origin to the indexer, key-references stays on the cp308 same-origin proxy, the browser rotator is only the inherently-multi-node chat-quorum verify); primeProfile is purely local (no network); rehydrateSelf reads self via the same-origin indexer. G operator-trust — canonical DEFAULT_BLURT_RPC_ENDPOINTS (operator config) unchanged; the browser subset is client-only. H frontend — the delta added zero @html/innerHTML/href/eval; every touched href uses the internal lp() helper; the pre-existing {@html} avatar sites read re-sanitized SVG. I contracts — no op-schema / frozen-enum change; fee_method untouched; badgeEligible gate sound (self / hidden / blocked / archived excluded; case normalized — Blurt names are lowercase). J build/CI — smoke-registration-integrity 4/4 (all 469 smoke files registered — the 3 new cp452 smokes wired), version-consistency 19/19 @ 1.4.0 (correct: the bump happens at the ceremony), the delta added no new dep/version touchpoint. K STRIDE (primeProfile + orderbook subscription) — Spoofing: primeProfile is called only with getUserBlurtAccount() (self); absent XSS (game-over anyway) no path primes another account, and any wrong prime self-heals in ≤PRIME_HOLD_MS (12s) as the indexer reconciles. Tampering: writes only cache[self], hold keyed by account, no cross-account reach. Info-disclosure: local write, no network. DoS: one bounded entry per self; primedAt cleared by clearProfileCache. Elevation: display-only, no authz. The selfProfile.subscribe → rehydrateSelf → getProfilesBatch loop can't storm (in-flight-deduped, no write back to selfProfile). L per-subsystem walk — all profileCache consumers compile (svelte-check 0/0); isPrimeHeld is inert for every non-self / non-primed read, so common-case behaviour is byte-identical (confirmed by persona-walkthrough 185, sally-walkthrough 21, order-card 51, identity-label-policy 6, settings-profile-keys-account-scoped 14, href-xss 1, +10 more file-adjacent smokes green); the fix is SYSTEMIC (every cache reader benefits on its next read) and the orderbook is the unique live-self surface needing the extra subscription (AvatarMenu already uses the store directly, the chat inbox shows peers); the synthetic source_block_num:0 is inert — no profile consumer reads it (only chat-identity responses do). M i18n — login.register_cta emoji-slot change holds parity (i18n-locale-parity 10/10). Phase 3 hostile-op — cp452 touched NO indexer op HANDLER (hedge is in the broadcast transport/api, not a handler; primeProfile is client-only); the op allowlists are unchanged → no new chain-direct surface.
VERIFIED THIS PASS: profileCache vitest 23/23 (+4 prime/hold behavioural) + selfProfile 8 = 31/31; profile-freshness 28/28 (tamper-tested two ways); broadcast-hedge-off 10/10; rpc-endpoint-canon 13/13; rpc-privacy-routing 16/16; broadcast-op-allowlist 5/5; broadcast-same-origin 20/20; active-owner-key-invariants 13/13; broadcast-chain-proxy-rate-limit 5/5; chat-blocks-race-guard 9/9; i18n-locale-parity 10/10; version-consistency 19/19; smoke-registration-integrity 4/4; persona-walkthrough 185 + sally-walkthrough 21; svelte-check 0/0. NOT run in-sandbox: the FULL 476-smoke battery (exceeds a single command's time budget here — a targeted subset covering every delta file ran green; the whole battery + vitest-must-pass + vite build are the remaining release gates, to run in a longer window / CI) and the real-browser eyeball (settings edit → instant everywhere; "Load it now" SW-upgrade → self's orderbook cards keep avatar+name). Conclusion: the v1.4.0→v1.4.5 delta is deep-deep clean — no new findings. Remaining v1.4.5 gates: full battery, real-browser pass, then the eli5 release ceremony (run bash scripts/eli5-release.sh 1.4.5 "…").
Session (cp371, 2026-06-27) — form id/name a11y completion + /post/edit grandma-friendly consistency pass (post-cp370-tarball; NOT in any tarball)
Ken: "finish up everything you can, including deferred tasks that you can do." Two bounded backlog items, both verified end-to-end. (a) Form id/name (the cp369 remainder): PaymentMethodsPicker search <input> → name="payment-methods-search"; the 3 decorative selected-state checkboxes (inside toggle buttons, pointer-events-none/tabindex=-1/readonly) → name={pm-${entry.key}}; ProtectedTextarea gained an optional name?: string prop forwarded to its <textarea>, with all 5 call sites passing a name (chat-message / feedback-comment / feedback-response / order-terms ×2). Clears the "a form field should have an id or name" DevTools warnings Ken flagged in cp369. a11y-patterns 36→41 (5 new guard scenarios + PROTECTED_TEXTAREA/CHAT_COMPOSER reads); TAMPER-TESTED (stripping {name} fails it 1/41). (b) /post/edit consistency pass (type=number → cleaned inputmode="decimal" + dynamic-fiat labels): /post got the grandma-friendly cleaned inputs in cp360; /post/edit had lagged on type="number" + the generic amount_{min,max}_label. Converted all 4 /post/edit number inputs (amountMin / amountMax / spreadPercent / fixedPrice — all already string $state, so state-compatible) to /post's type="text" inputmode="decimal" maxlength value oninput pattern with id/name, and swapped the two amount labels to the dynamic amount_{min,max}_label_in_fiat form. No validation gap from dropping the browser min/max/step: the existing $derived validators enforce all ranges (amounts ≥0/≤MAX_AMOUNT=1e12, spread finite ∈[-50,50], fixed >0/≤MAX_AMOUNT) — the same JS validation /post relies on. The cleaner helpers were DUPLICATED from /post (to avoid touching the critical /post form mid-pass); they're stable input-hygiene utilities, NOT money-value logic (centralized in the canonical economics) — a shared-util extraction is filed in REVISIT (low priority). Verified GREEN: svelte-check 0/0; a11y-patterns 41; post-edit-multi-network-wired 29; price-model-picker-parity 13; price-model-display 21; paired-readonly 13; require-live-session 14; post-form 20 (/post untouched bar one name="order-terms"); persona-walkthrough 182; wiring-completeness 56; active-owner-key-invariants 13; first-trade-buy-blurt-lock 11; i18n key-coverage 2265 + completeness 4. Human-gated: real-browser eyeball of /post/edit's converted inputs (mobile decimal keypad + fa RTL) — folds into the standing /post + /post/edit check.
Session (cp370, 2026-06-27) — canonical hardcoded ECONOMICS source of truth + every FAQ/doc/locale cost corrected + black-hat helper hardening (post-beta.35 working tree, NO bump; NO TARBALL — Ken: "no tarball until i say so")
Ken: the fee + first-order economics "need to be hardcoded somewhere so you never screw this up again — it's people's money — get this perfectly"; then "do not rush the live price tracking, DO IT RIGHT THE FIRST TIME, think like a black hat, think like grandma." Created packages/asset-registry/src/economics.ts — the single source of truth the frontend (quote) + indexer (validation) both import so they cannot drift: FIRST_ORDER_MIN_USD=1.0; LISTING_FEE_USD={blurt:0.125,btc:0.25,xmr:0.25} (frozen — the only fee numbers); FEE_REFERENCE_PRICE_USD (frozen, seeds fallback); FEE_PRICE_TOLERANCE=0.15 (price-drift band for the future quote→pay window); derivation helpers listingFeeBlurtBase/Satoshis/Piconero; FEE_FALLBACK={62.5,417,781250000n}; isFeeCapableAsset. Black-hat (FAQ/category-J + money-math): the helpers divide a fixed USD target by a feed price — hardened so a garbage price → null (tiny→would-be ∞ → BigInt(∞) THROW averted + ∞-amount DoS averted; huge→would-be 0-satoshi free-listing averted) via safeAmount/safeUnitCount (finite + positive; smallest-unit counts must be positive safe integers); caller falls back to FEE_FALLBACK. Floor wired to FIRST_ORDER_MIN_USD (client + 2 indexer). FAQ accuracy (category, all 10 locales, fiat-first): most FAQ already correct ($0.25/$0.125); fixed cheat_sheet…listing_fee_body, first_order_free.a (full re-translation, "500 Blurt"→"$1 worth"/"60 Blurt each"→"~12.5¢"), where_does_blurt_price_come_from.a ($0.12→$0.125), welcome_first_buy.bullet_starter. Only the loyalty-milestone "500 Blurt" (cumulative spend) kept — legitimately Blurt-denominated. Living docs (FEES-AND-REWARDS, OPERATIONS) point at the canonical module; history docs immutable. CLI + wizard default target → LISTING_FEE_USD.btc. New smoke economics-canonical 63/63 (USD targets + frozen + 50%-discount invariant + derivation + FEE_FALLBACK + black-hat garbage-price cases + a registry cross-check that isFeeCapableAsset/the fee-capable set/LISTING_FEE_USD keys/decimals all agree with ASSETS), registered. Verified GREEN: asset-registry+indexer+ops-cli tsc 0; svelte-check 0/0; economics-canonical 63/63; post-form-grandma-regression 20/20; i18n parity 10/10 + key-coverage 2/2 + completeness 4/4; registration-integrity 4/4 (382 files); forgejo 3/3. DEEP-DEEP thorough sweep: (test/category-J) fixed a test-mock/tolerance bug — testutils/context.ts mocked feeBaseBlurt=60 while order.test + the integration test send 62.5 transfers with "1% tolerance" comments (tests passed by luck; the "0.5%-below" case was wrong at the real 0.1% tolerance) → anchored the mock to FEE_FALLBACK.blurtBase=62.5 + fixed the within-tolerance amount to 62.45 + corrected the tolerance comments (full indexer unit vitest 495+1skip); renamed stale BLURT-era waiver_benefits tier keys (tier_500/…→tier_1/4/20/100) across 10 locales+code+smoke + rebuilt the native-translations snapshot (floor 11/11); caught + fixed a LATENT cp368 a11y-patterns regression (3/36 stale aria-invalid matchers not re-synced after the per-field/touch-gated split — a11y intact, now 36/36) + swept all 13 /post-source smokes green; ADR-0011 forward-note added; confirmed listingFee.test.ts is a documented skip for a removed module (not a live second fee path) and the MCP server exposes no fee-cost figure. OPEN (Ken agreed NOT to rush): the fee AMOUNTS still don't track the live price — full live-tracking needs a BTC/XMR USD price subsystem built in the indexer FIRST (verified: poller has only the BLURT priceSource; no BTC/XMR feed). Design ready (centralize derivation in poller feeAmounts, both quote+validation read it, FEE_PRICE_TOLERANCE absorbs the quote→pay drift, FEE_FALLBACK on outage); the canonical module is its foundation. See TARBALL/REVISIT cp370. NOT in-sandbox: vitest-must-pass (better-sqlite3) + vite build → CI; native QA on fa/ru/zh first_order_free. FULL-BATTERY ROUND (Ken: "make it ALL perfect") — RAN the entire smoke battery in 6 chunks, 387/387 runnable smokes GREEN (only vitest-must-pass + workspace-typecheck excluded as CI/sandbox gates), fixing 4 failures it surfaced. (1) A REGRESSION I introduced this session (category-J / build-resolution): the new standalone economics.ts broke the BUILT mcp-server — @morphit/asset-registry is consumed as RAW src/index.ts (no build) and index.ts was self-contained; plain Node ESM (the mcp-server's node dist/main.js bin, which value-imports ASSET_TICKERS at runtime) resolves the new relative ./economics.js LITERALLY (no .js→.ts remap like tsx/Vite) → ERR_MODULE_NOT_FOUND → startup crash → would have broken production. FIX: inlined the canonical economics INTO index.ts (deleted economics.ts; removed the re-export) with a DO-NOT-RE-EXTRACT comment, restoring the self-contained invariant (works under Vite + tsx + plain node). Re-verified: mcp-server-smoke 8/8, node dist/main.js JSON-RPC OK, package tsc 0, svelte-check 0/0, economics-canonical 63/63, order.test 38/38, the 22 package smokes; the 7 source comments naming "economics.ts" repointed to "@morphit/asset-registry". (2) order-handler-smoke 4/42→42/42 — stale 60/75-BLURT fee amounts (after the 62.5 testutils anchor) + the removed 500-BLURT floor (cp369→$1) → re-anchored to 62.5-derived amounts + the $1 fiat floor. (3) indexer-result-shape-smoke →27/27 — cp368's el.value (HTMLInputElement) flagged as a Result misuse → added a token-level DOM-binding allowlist (no masking of real model.value misuses). (4) llms-full-freshness-smoke →6/6 — 90+ drifted FAQ sections (mostly pre-existing) → regenerated apps/web/static/llms-full.txt via the sanctioned node scripts/build-llms-full.mjs. Tally: chunks 65/65/70/65/65/57. 3 of the 4 were pre-existing latent failures (cp368/cp369), 1 was mine this session — all the kind only a full battery surfaces.
Session (cp356, 2026-06-26) — three UI fixes (post-beta.32 working tree, NO bump; NO TARBALL — Ken deferred)
Ken-reported. (1) Tooltip "Learn more ⇨" underline-on-hover: removed hover:underline from Tooltip.svelte's learn-more <button> (the nav-arrow slide IS the hover affordance) and extended the app.css no-underline rule from :where(a):has(.nav-arrow) to also cover button/[role] so arrow-buttons can't reintroduce it. (2) Security-page bounty link spacing ("…rules⇨"): the inline-flex items-center link collapsed the whitespace text node before the arrow span — added gap-1 to the flex container. (3) Locked → welcome-back → intended destination: RequireLiveSession.svelte now captures the current path and routes locked visitors to /login?next=… (was gotoLocale('/')); the login page's new postUnlockDestination() reads next and forwards there after BOTH unlock paths (password + YubiKey). Open-redirect guard: new URL(raw, $page.url.origin) + same-origin check (defeats //evil//\evil/scheme:); off-origin/malformed → home. Helps all RequireLiveSession-guarded pages (post, post/edit, chat, chat/[peer], settings, 2fa, backup-keys), not just post/chat. Smokes: updated locked-session-ux-smoke + require-live-session-smoke (retargeted off the stale "homepage" assertion); new unlock-redirect-next-smoke.ts (8 checks, registered → 387, tamper-tested both ways). No i18n changes. Verified GREEN: svelte-check 0/0; locked-session-ux 13/13; require-live-session 14/14; registration-integrity 4/4 (387/380); web chunk 321-387 = 823/0. Battery 8590 across 387. NO tarball cut — Ken said "no tarball until I say so"; cp356 is working-tree-only, latest cut tarball remains cp355. NOT in-sandbox: real-browser pass on the three fixes after the next deploy.
Session (cp355, 2026-06-26) — accountless-but-unlocked state made loud/clear (post-beta.32 working tree, NO bump)
Follow-up to the cp354 discussion (Ken: "make the accountless state louder/clearer so it never feels like a bug"). Verified the signup flow: the session BOOTS at the end of step 3 (seed-confirm quiz in onboarding/+page.svelte), THEN navigates to step 4 (register-name), which is deliberately SKIPPABLE (relay-out-of-BLURT resilience + look-around-first). The resulting accountless-but-unlocked state was already HANDLED per-page (place-order Gate 1 {#if !blurtAccount} blocks trading, orderbook "finding H4" banner, /my/orders no_account, chat null-safe, balance card only on a profile) but had no GLOBAL signal, so it could read as a bug on an arbitrary page. Fix: new global NeedsAccountNameBanner.svelte (mirrors PairedReadOnlyBanner; gated $isUnlocked && $blurtAccountName === null; suppressed on /onboarding/* + /settings; CTA → register-name), wired into [lang]/+layout.svelte. Reciprocal setup cross-links so neither population dead-ends: register-name (claim-new) → Settings verify card; Settings account-name card (verify-existing) → register-name. i18n: needs_account_name.{heading,body,cta} + register_name.have_account_link + account_name.no_account_link × 10 (informal register to match these sections; fa/ru/zh are Claude's → native-QA flag). Kept the orderbook inline banner (smoke-pinned "Sally finding H4"). New regression smoke accountless-banner-smoke.ts (8 checks, registered → 386, tamper-tested). Verified GREEN: svelte-check 0/0; i18n parity 10/10 @ 3218 + completeness 4/4 + key-coverage 2/2; native-translations-floor 11/11; sally-walkthrough 22/22; heading-hierarchy 4/4; registration-integrity 4/4 (386/379); web chunk 321-386 = 815/0. Battery 8582 across 386. FULL tarball morphit-cp355-beta32-FULL-STATE.tar.gz (adds a component + a smoke → FULL). Design note (Ken-decided): signup step 4 stays skippable; this banner is the chosen alternative to forcing it. NOT in-sandbox: web vite build → CI; real-browser pass on the banner + cross-links after the next deploy.
Session (cp354, 2026-06-26) — account-name auto-resolve extended to keyfile + posting-key imports (post-beta.32 working tree, NO bump)
Ken: after a keyfile or posting-key import he shouldn't have to type his Blurt username by hand either. cp351 added seed→account reverse-resolution but gated the posting-pubkey capture on the seed-only full FullIdentity, so keyfile (envelope decrypts inside bootFromEnvelope without surfacing a FullIdentity) fell through to manual /settings entry, and posting-only required typing the account up front. Fixes (all in apps/web/src/routes/[lang]/onboarding/import/+page.svelte): (1) keyfile/seed now capture the posting pubkey uniformly post-boot from get(liveIdentity)?.posting.publicKey (added liveIdentity + get imports) → both feed the existing same-origin resolveAccountsByPublicKeys; (2) posting-only account field made OPTIONAL — format-checked only when typed, master-password detector guarded on a typed account, blank ⇒ resolveAccountsByPublicKeys([derivedPub]) (unique match becomes the account, then existing fetch+verify runs; else posting_only.error.could_not_resolve → manual entry), !postingAccount.trim() dropped from the submit gate. Lookup stays SAME-ORIGIN (/v1/chain/key-references). i18n: account_label/account_hint updated + new error.could_not_resolve across all 10 locales (register matched to the existing informal posting_only block; fa/ru/zh are Claude's → native-QA flag). New regression smoke import-account-auto-resolve-smoke.ts (8 checks, registered → 385, tamper-tested both ways). Verified GREEN: svelte-check 0/0 (fixed one string|undefined from matches[0] under noUncheckedIndexedAccess); i18n parity 10/10 + completeness 4/4 + key-coverage 2/2; active-owner-key-invariants 13/13 (auto-detect stays posting-only); native-translations-floor 11/11; import-remember-me 5/5; login-key-verify-via-indexer 10/10; sally-walkthrough 22/22; registration-integrity 4/4 (385/378); web chunk 321-385 = 807/0. Battery 8574 across 385. FULL tarball morphit-cp354-beta32-FULL-STATE.tar.gz (adds a smoke file → FULL). NOT in-sandbox: web vite build → CI; real-browser pass on all three import flows; needs a live get_key_references-capable RPC behind the indexer for keyfile/posting-only auto-lookup to fire.
Session (cp353, 2026-06-26) — beta.32 RELEASE CUT (beta.31 → beta.32; Ken said go)
Bumped all 19 version touchpoints beta.31 → beta.32 (14 package.json + relay/indexer/mcp version constants + docs/API.md + indexer README) via per-line sed (each file held exactly one beta.31 string — verified before replacing, and no package.json carried it off a "version" line); synced package-lock.json (15 → 15, --package-lock-only --ignore-scripts; npm audit fix BANNED); wrote RELEASE-NOTES-v1.0.0-beta.32.md (user-facing, no asset-count claims). The release bundles the post-beta.31 working tree cp350 (text-input maxlength security audit) + cp351 (profile/avatar/broadcast 17-item batch incl. the indexer json_metadata MERGE + the clearSelfProfile deep-deep fix) + cp352 (the same-origin /v1/chain/key-references privacy fix). No src/ logic change beyond the three runtime version string-constants. FULL tarball morphit-cp353-beta32-FULL-STATE.tar.gz (adds a RELEASE-NOTES file → FULL). Verified GREEN @ beta.32: version-consistency 19/19 + RELEASE-NOTES present; asset-count-parity 3/3; lockfile-sync 3/3; mediakit/llms/comparison-image freshness 7/7 + 6/6 + 15/15; svelte-check 0/0; typecheck-sweep 14/14 @ 0; i18n 10/10 @ 3213 + completeness 4/4; registration-integrity 4/4 (384/377); vitest 1484 (742/5-skip + 492/1-skip + 250); FULL smoke battery 8566 across all 384, 0 failed (3277 + 1341 + 1291 + 1858 + 799). Still a BETA → Forgejo only; Basic-Auth gate stays up; nothing mirrored/broadcast — the stable-public-release ceremony is unchanged + pending. NOT in-sandbox: indexer better-sqlite3 native build + web vite build → CI; a real-browser eyeball of the cp338→cp352 UI/flows after the VPS deploys beta.32.
Session (cp352, 2026-06-26) — fresh-session deep review of the cp351 tarball + 1 PRIVACY finding fixed (post-beta.31, no bump; WORKING TREE ONLY)
Ken's standing "deeply review the tarball, recommend where to go, fix what should be fixed" ask, from a clean session.
(A) Independent full re-verification — ALL GREEN, matched the cp351 handoff (NOT trusting the numbers): svelte-check 0/0; typecheck-sweep 14/14 @ 0; indexer tsc 0 + profile.test 21/21; i18n-locale-parity 10/10 @ 3213; active-owner-key-invariants 13/13; cross-tab-signout 10/10; text-input-maxlength 3/3; smoke-registration-integrity 4/4 (384 entries).
(B) Black-hat re-read of the cp351 highest-risk deltas — the rest SOUND. The indexer json_metadata MERGE (handlers/profile.ts): closed 5-key whitelist, SELECT … FOR UPDATE, omit=keep / empty-string=clear / non-empty=set, and a merged-size re-check vs MAX_JSONB_BYTES_PROFILE (8 KB) that bounds accumulation across repeated partial merges. The chain-DIRECT profile-metadata READ path (profileProps.ts) is genuinely layered against a hostile on-chain morphit_profile_v1 op (the frontend write-path sanitizer is NOT a defense for a chain-direct op): str() returns only non-empty strings (a non-string avatar_svg/etc → null), avatar_svg is re-run through the allowlist sanitizeSvg on receive, avatar_data_uri is matched against a strict ^data:image/(webp|png|jpeg|gif);base64,…$ regex with a 16 KB cap (SVG deliberately excluded so it can't bypass the SVG sanitizer — the O3.2 IP-leak/SSRF-via-<img src> defense), and nostr_url/blurt_media_url are rendered as href ONLY through validateNostrUrlForRender/validateBlurtMediaUrlForRender, which reject javascript:/data:/file:/vbscript: (and blurt.media pins host-exact, blocking open-redirect/subdomain confusion). The cp351 selfProfile store is in-memory only (resets on reload), guarded by a monotonic latest token against the account-switch race, and clearSelfProfile() increments it; broadcastSignOut() clears it on EXPLICIT sign-out only. All sound.
(C) THE FINDING (MEDIUM — privacy priority #1, 🟢 FIXED): the cp351 seed→account AUTO-LOOKUP leaked the importing user's IP to a third-party RPC node. apps/web/src/lib/blurt/accountByKey.ts documented itself as sending the derived public keys "through the same-origin /v1/chain proxy (so the user's IP never reaches a third-party node)", but the implementation called getBlurtClient().call('condenser_api.get_key_references', …) — the DIRECT third-party rotator. Verified three ways: getBlurtClient() is explicitly the "Legacy fallback: broadcast straight to a Blurt RPC node from the browser" in broadcastTransport.ts; DEFAULT_RPC_ENDPOINTS are third-party absolute URLs (rpc.drakernoise.com/rpc.blurt.blog/blurt-rpc.saboin.com); and /v1/chain only proxied block/tx/properties — no key-references endpoint existed, so the lookup could NOT have been same-origin. Impact: on every seed import the browser sent the user's IP + their derived keys + the exact moment of account restore to a node Morphit doesn't control — a high-value deanonymization point (ties an IP to a specific account AT LOGIN) and a regression of the very direction cp344/cp346 hardened. Why the cp346 rpc-privacy-routing-smoke didn't catch it: it's an enumerated-allowlist smoke (only checks named files), so a NEW file going direct slips past — the same coverage-gap class as the cp177 parity guard.
FIX (3 parts, wired end-to-end):
- NEW same-origin
POST /v1/chain/key-referencesproxy inapps/indexer/src/api/chainExplorer.ts— validates the body (keysis a non-emptystring[], each BLT-shape^BLT[1-9A-HJ-NP-Za-km-z]{40,60}$, cap 8), forwardsget_key_referencesserver-side via the rpc-pool, returns the deduped account-name UNION only (never the raw per-key nested arrays),Cache-Control: no-store, and auto-inherits the cp347resourceper-IP rate-limit via the existingchainApp.use('*', rateLimit(...))mount. - Rewrote
accountByKey.tstofetchWithTimeoutthe proxy — proxy-only, NO direct-RPC fallback. This is the privacy-correct choice: the import flow already falls back to MANUAL account-name entry on an empty result (verified atonboarding/import/+page.svelte:502), so a proxy failure costs one manual step, never an IP leak (unlike a broadcast, which MUST succeed and so keeps its documented direct fallback). Comment is now TRUE. - Extended
rpc-privacy-routing-smoke+4: accountByKey-is-same-origin / accountByKey-makes-no-direct-call / a GENERAL sweep that no web source callscondenser_api.get_key_referencesdirectly (quoted-method-string match, so a comment mention doesn't false-positive — closes the allowlist gap for the NEXT instance of the class) / the indexer exposes the proxy route. Tamper-tested: injecting a direct call into accountByKey turns BOTH the specific check AND the general sweep red; reverting → 12/12.
LIVE-RETEST now SERVER-SIDE. The lookup depends on the operator's rpc-pool supporting get_key_references; because the pool tries the full canonical set, a single node lacking the method no longer breaks the lookup (a reliability bonus, like the get_transaction note in chainExplorer).
FILES: EDITED code (2): apps/indexer/src/api/chainExplorer.ts, apps/web/src/lib/blurt/accountByKey.ts. EDITED smoke (1): apps/web/scripts/rpc-privacy-routing-smoke.ts. EDITED handoff docs: TARBALL.md, REVISIT-LIST.md, this file. NO new/deleted/moved files, NO version touchpoints, NO new deps, NO locale change, NO brag/mediakit change.
VERIFIED GREEN post-fix: svelte-check 0/0; indexer tsc 0; rpc-privacy-routing 12/12 (tamper-tested); chain-explorer-via-indexer 8/8; broadcast-same-origin 19/19; broadcast-chain-proxy-rate-limit 5/5; profile.test 21/21; smoke-registration-integrity 4/4 (384 — extension, no new file); indexer chunk [1..40] 1324 scenarios / 0 failed; web chunk [345..384] 419 / 0. Battery delta +4 (8562 → 8566 across all 384 smokes, 0 failed) = the 4 new rpc-privacy-routing scenarios; no other count moved, no runner red. NOT run in-sandbox: indexer better-sqlite3 native build + web vite build → CI; a real-browser seed-import against a live get_key_references-capable RPC node (now via the indexer). Advances PHASE 2-F (no new direct browser→3rd-party RPC) + PHASE 2-J (gates/coverage) + PHASE 6 (recommendation). Post-beta.31 working tree — not committed/released.
Session (cp351, 2026-06-26) — profile/avatar/broadcast/UI batch (17 items) + full five-persona walkthrough + black-hat deep-deep (post-beta.31, no bump; WORKING TREE ONLY)
Ken's large itemized batch across the profile/settings/avatar/import surface, then the standing five-persona walkthroughs + a deep-deep that surfaced one real bug.
HEADLINE FIX — indexer json_metadata MERGE (avatar-orphan). Ken's block-explorer screenshot disproved the earlier "never persisted" theory: the avatar WAS on-chain. The disappearance was the indexer's FULL-REPLACE upsert (json_metadata = EXCLUDED.json_metadata) orphaning the avatar from the materialized profile row whenever a later bio-only morphit_profile_v1 op carried only short_bio. apps/indexer/src/indexer/handlers/profile.ts now SELECT … FOR UPDATEs the prior json_metadata, merges per a closed 5-key whitelist (short_bio/nostr_url/blurt_media_url/avatar_svg/avatar_data_uri — exactly the frontend profileProps readers; app/tags are post/comment metadata, not profile — verified no reader expects any other key), re-checks merged size vs the 8 KB cap, and upserts the merge with the handler's documented omit=keep / empty-string=clear semantic. Handler tests +2 MERGE regressions + optional-display_name → 21/21.
THE 17 ITEMS: (1) signup_dust 1→2 BLURT (relay create.ts + health.ts liquid margin 2→3 + ADR-0010 + OPERATIONS; the separate low-balance refill correctly stays ~1 BLURT). (2) avatar cap 3K→6K (avatar/index.ts; merged worst case ~7.6 KB < 8 KB profile cap). (3) broadcast status → "Broadcasted" ×10 locales. (4) display-name made OPTIONAL end-to-end (settings gates removed, ProfilePayload.display_name? optional + coerce, indexer allows empty via keep-prior CASE-WHEN; the frontend entered-name validator + operatorRegister min-1 remain — operators still require a name). (5) auto-save-on-blur removed (settings). (6) broadcast pre-flight authority guard — broadcastProfile derives the live posting pubkey, fetches the account's keys, throws a localized key_mismatch on a definitive mismatch (network failure → best-effort proceed). (7) avatar EVERYWHERE — new selfProfile store + AvatarMenu/IdentityLabel render the uploaded avatar, gated by selfProfile.account === activeAccount to prevent a wrong-account window. (8) the MERGE. (9) explorer Home-card removal + canonical hover-raise. (10) every slide-arrow CTA → canonical .nav-arrow affordance (9 sites; RTL handled by CSS, no hand-rolled rtl:-scale-x-100). (11) FAQ hover-border brightness halved. (12) login lock emoji 🔐 (the locked-session-ux smoke was REVERSED to enforce the emoji — Ken: don't let it regress). (13) import remember-me continue-gating + password-mismatch red border. (14) settings account-name @-strip + invalid red border. (15) settings avatar card — current-avatar thumbnail + ConfirmModal-gated removal. (16) profile page — standalone @handle removed (glyphs-only via new hideHandle prop), balance-card title "@{account} balance", others' profiles "Message @username" (new chat.message_button_label_named ×10). (17) seed→account-name AUTO-LOOKUP — new apps/web/src/lib/blurt/accountByKey.ts calling condenser_api.get_key_references via the same-origin /v1/chain proxy; the import flow captures the POSTING public key ONLY before wiping the FullIdentity (reaching into keys.owner/keys.active tripped the active/owner-key-invariants smoke — posting-only respects it and covers any standard seed-derived account), resolves on the remember-me continue → a unique on-chain match auto-sets the account + routes home, ambiguous/none/error → the existing manual /settings fallback. NEEDS A LIVE RETEST (depends on the RPC node supporting get_key_references).
PHASE 1 — all five personas GREEN: persona-walkthrough 183, sally-walkthrough 22, Charlie's MCP suite (8+3+12+8+22). Bob (seed import → auto-lookup → multi-account selfProfile → sign-out clear), Sally-user (explorer/FAQ/nav-arrow/profile/settings-avatar/🔐), Sally-operator + Josie (signup-dust 2 BLURT docs; ops-cli byte-unchanged this session → Josie's runtime identical), Charlie (MCP profile reads return the merged 5-key blob whole; no MCP field expects a dropped key).
DEEP-DEEP — 1 finding fixed + dead code removed + all else clean: 🟢 BUG (privacy/correctness) — clearSelfProfile() was defined but NEVER called, so the prior user's avatar could linger in the store across sign-out (the AvatarMenu's reactive clear on activeAccount → null races the menu's own unmount when hasAnySession → false). FIX: broadcastSignOut() now clears it via a dynamic import (keeps selfProfile's profileCache/profileProps deps out of identity.ts's static graph; explicit-signout-only, NOT reset()/lockSession where the avatar is public + re-shown on unlock). REGRESSION GUARD: cross-tab-signout-propagation-smoke +2 scenarios (sign-out clears it / reset must NOT), tamper-tested (deleting the call → 9/1 red; restored → 10/10). 🟢 DEAD CODE — removed the now-unused json_metadata_serialized from the indexer ValidatedPayload interface/local/return (the merge handler computes its own merged serialization; the op-size check is retained). VERIFIED CLEAN: orphan identifiers all 0 in code (need_display_name/home_*/persist*OnBlur/rtl:-scale-x-100/lock-SVG path); DISPLAY_NAME_MIN's 2 remaining refs both legitimate (frontend entered-name validator + operatorRegister, NOT the removed profile-handler const); run_a_node.register.err_display_name_too_short ("Display name is required.") is a false positive — it belongs to the run_a_node.register.err_${reason} dynamic-key family (operator registration, which DOES require a name) and is correctly unchanged; merge whitelist complete; avatar headroom math accurate; signup-dust docs correct (current-state docs = 2 BLURT, refill = 1 BLURT distinct, the two REVISIT-LIST "1 BLURT signup dust" lines are immutable historical ledger entries left as history); no stale "3 KB avatar"/"display-name-required" mentions anywhere; brag list carries no claim touched by cp351 → untouched, no mediakit rebuild.
FILES: NEW (2 → tarball FULL): apps/web/src/lib/blurt/accountByKey.ts, apps/web/src/lib/stores/selfProfile.ts. EDITED: relay (3) create.ts/health.ts/test; indexer (4) handlers/profile.ts + test + profile-handler-smoke + config/index.ts; web src (16) avatar/index.ts, ops/profile.ts, stores/identity.ts, 7 components (AvatarMenu/FaqSearch/FirstPostStarterPack/FirstTradeHelper/IdentityLabel/MyBalanceCard/SeedBackupNudge), 9 routes ([x+40][account]/explorer/instances/login/onboarding-import/orderbook/post/security/settings); web smokes (3) cross-tab-signout/locked-session-ux/native-translations-snapshot; locales (10); docs (2) OPERATIONS.md + adr/0010; handoff (TARBALL/REVISIT/this AUDIT). NO version touchpoints, NO new deps, NO deleted/moved files.
VERIFIED GREEN: svelte-check 0/0; indexer tsc 0 + profile.test 21/21; active-owner-key-invariants 13/13; identity unit 7 pass/5 env-skip; i18n-locale-parity 10/10 @ 3213 + completeness/key-coverage/native-floor green; native-translations snapshot rebuilt (28017 pairs); cross-tab-signout 10/10 (tamper-tested); all five persona suites green; FULL battery 8562 across all 384 smokes, 0 runners failed (3277 + 1341 + 1291 + 1858 + 795). NOT in-sandbox: indexer better-sqlite3 native build + web vite build → CI; the cp351 real-browser eyeball (esp. the auto-lookup against a live get_key_references-capable RPC node). Advances PHASE 1 (re-walked) + PHASE 2-H (frontend) + PHASE 3 (handler — profile merge) + PHASE 4 (drift/dead-code/leak). Post-beta.31 working tree — not committed/released.
Session (cp350, 2026-06-25) — site-wide text-field / textarea / chip security audit + maxlength backstops + new regression smoke (post-beta.31, no bump; WORKING TREE ONLY)
Triggered by Ken's settings-page question: after folding maxlength into the 3 settings fields, audit EVERY text field/textarea/chip site-wide and "make sure those cannot be messed with by a black hat." (A) Inventory: parsed all 139 <input>/<textarea> across 34 .svelte files (81 text-entry; the rest checkbox/radio/file/number/time where maxlength is a no-op). (B) The decisive black-hat check — stored XSS, not maxlength: enumerated EVERY {@html} in the app. Only TWO render end-user free-text, both SAFE: ProtectedTextarea's private-key-highlight overlay builds HTML via escapeHtml() (escapes & < > " ') on every slice of user text before wrapping matches in <mark>, with the sole interpolated attribute (data-kind) a fixed enum; and the user-uploaded avatar SVG runs through sanitizeSvg ($lib/avatar) — an allowlist sanitizer (allowed tags/attrs only; strips <script>, on*=, javascript: hrefs, <foreignObject>; handles the historical root-<svg onload> bypass) applied on BOTH the read path (profileProps.ts, so a hostile on-chain SVG is cleaned before render) and the write path. Every other {@html} renders generated SVG (QR/identicon), project-controlled i18n/FAQ, or quote-escaped operator config — never end-user free-text. No stored-XSS hole. (C) Chips: the three chip controls (FiatCurrencySelect, PaymentFilterSelect, PaymentMethodsPicker) are SELECTION chips — chip values come from a fixed registry (ISO codes / payment-method keys), never raw text, so a chip cannot carry markup; their query boxes are ephemeral filters. EndpointList is a URL-validated, client-side-only RPC list. No free-text tag chips exist. (D) Fix: added maxlength to all 23 text-entry controls that lacked it, each sized ≥ its validator cap so valid input is never truncated — the JS validators (display-name/short-bio/URL/account-regex/op-Zod) plus the indexer's server-side mirror remain the authoritative enforcement; maxlength is a cheap first-line backstop against pathological pastes. Number fields keep min="0" step="0.01" + range validation (maxlength N/A on type=number). No user-facing text changed → no locale edits. (E) Regression smoke text-input-maxlength-coverage-smoke (comment-stripping so doc-block tag mentions aren't counted; brace/quote-aware tag scan; asserts maxlength on every text-entry control; empty ALLOW_LIST) — registered in run-smokes.sh, 3 scenarios. Verified GREEN: new smoke 3/3 (126 files, 81 controls); svelte-check 0/0; typecheck-sweep 14/14 @ 0; vitest 1481 (742/5-skip + 489/1-skip + 250); smoke-registration-integrity 4/4 (384 entries / 377 files, 0 orphans); FULL battery 8555 across all 384, 0 failed (+3 = the new smoke; no other count moved). NOT in-sandbox: indexer native build + web vite build → CI; a real-browser check that the caps don't truncate legitimate input. Advances PHASE 2-H (frontend) + PHASE 2-J (CI/gates) for the input-hardening surface. Post-beta.31 working tree — not committed/released.
Session (cp349, 2026-06-25) — beta.31 RELEASE CUT (beta.30 → beta.31; Ken said go)
Bumped all 19 version touchpoints beta.30 → beta.31 (14 package.json + relay/indexer/mcp version constants + docs/API.md + indexer README) via per-line sed (each file held exactly one beta.30 string — verified before replacing); synced package-lock.json (15 → 15, --package-lock-only --ignore-scripts; npm audit fix BANNED); wrote RELEASE-NOTES-v1.0.0-beta.31.md (user-facing, no asset-count claims). The release bundles the cp338→cp348 working tree (same-origin broadcast proxy, per-account profile-leak fix, proxy rate-limit, locked-session UX, unified nav arrows, update-banner fix, mint-acts cleanup). No src/ logic change beyond the three runtime version string-constants. FULL tarball morphit-cp349-beta31-FULL-STATE.tar.gz (cp348 deleted 2 files + this adds a RELEASE-NOTES file → FULL mandatory). Verified GREEN @ beta.31: version-consistency 19/19 + RELEASE-NOTES present, asset-count-parity 3/3, lockfile-sync 3/3, mediakit/llms freshness 7/7 + 6/6, svelte-check 0/0, typecheck-sweep 14/14 @ 0, i18n 10/10 @ 3214 + 4/4, registration-integrity 4/4 (383/376), FULL battery 8552 across all 383, 0 failed, vitest 1481 (742/5-skip + 489/1-skip + 250); no test pins the version string. Still a BETA → Forgejo only; Basic-Auth gate stays up; nothing mirrored/broadcast — the stable-public-release ceremony is unchanged + pending. NOT in-sandbox: indexer native build + web vite build → CI; a real-browser eyeball of the cp338→cp348 UI/flows after the VPS deploys beta.31.
Session (cp348, 2026-06-25) — finish cp329's incomplete ACT-minting removal: delete dead unit templates + dead npm script, correct every stale operator doc + the Ansible deploy role (post-beta.30, no bump; WORKING TREE ONLY; ⚠ NO TARBALL CUT — Ken deferred; FULL cut PENDING — 2 files deleted)
Fresh-session deep review of the cp347 tarball (Ken's standard "deeply review, recommend, fix what should be fixed"). (A) Independent full re-verification — ALL GREEN, matched the handoff numbers exactly (NOT trusting them): svelte-check 0/0; typecheck-sweep 14/14 @ 0; vitest 1481 (web 742/5-skip, indexer 489/1-skip, relay 250); FULL smoke battery 8556 across all 383, 0 failed; version-consistency 19/19 @ beta.30; i18n-locale-parity 10/10 @ 3214 + completeness 4/4; registration-integrity 4/4 (383/376); mediakit/llms freshness 7/7 + 6/6; Forgejo-naming guard 3/3.
(B) Black-hat re-read of the two highest-risk post-beta.30 deltas — both SOUND. /v1/broadcast (cp344 write proxy): op-whitelist {custom_json, transfer, comment, comment_options, vote} with custom_json id forced ^morphit_, structural Zod (ops ≤10, sigs ≤8 / ≤200-hex), non-custodial (tx arrives pre-signed; the chain charges the SIGNER's RC), and the chain-rejection 400 path surfaces only err.message — verified safe via isTransportError (network errors + retryable 5xx/429 → generic 502; only 4xx / genuine chain-reject → 400, where the message is public chain semantics or HTTP <status>, never an internal host/URL), and cp347's rateLimit('resource', resourceRatePerMin) + the global bodyCap are both wired (main.ts L298/L421/L425). cp346 per-account profile-draft scoping: the legacy-GLOBAL-key purge is guarded by if (acct) so it can't delete the current account's scoped (.${acct}) draft, and on-chain hydration fills only fields with no local draft (local wins).
(C) THE FINDING — cp329's ACT-minting removal was incomplete (much bigger than cp339's one-line backlog flag). cp329 (beta.28, the account_create op-5 migration) removed ACT minting from the CODE — deleted mint-acts.ts, the auto-minter, MORPHIT_RELAY_AUTOMINT_* / WEEKLY_ACT_COUNT — and rewrote OPERATIONS §2 to "REMOVED", but left a contradictory trail of dead artifacts + stale operator docs that OPERATIONS §2 itself directly contradicts: two dead systemd unit templates (ExecStart → the deleted script), a dead apps/relay/package.json mint-acts npm script, LAUNCH-DAY + PRE-LAUNCH-CHECKLIST literally instructing npm run mint-acts -- 25, AUTOMATION-AUDIT §1.1 calling minting "✅ AUTOMATED", a SECURITY "Mint-acts unattended timer" section, and — critically — an Ansible deploy role that COPIED + ENABLED the two unit templates (so the canonical bare-metal playbook would break). The smoke battery stayed green only because the smokes checked the unit file (still present) and the env var was already pulled — nothing tripped at runtime, but it's a real launch-day footgun + a "stale docs trailing live code" violation. Fixed comprehensively in one pass — deleted the 2 unit templates (→ eventual tarball is FULL) + the dead npm script; edited 3 smokes (systemd-unit-install 22→20: dropped mint-acts from ISOLATED_UNITS + its isolation check; operator-doc-section-length: removed the dead allowList entry; env-example-schema-parity: reframed the stale header example); corrected 4 operator docs (AUTOMATION-AUDIT, LAUNCH-DAY, PRE-LAUNCH-CHECKLIST, SECURITY) to the account_create-inline reality — the ~100 BLURT fee is now paid inline per signup, so the sizing numbers are unchanged, only the minting framing is gone; and fixed the Ansible role (copy loop, enable-timer task, 4 comments reattributing the passphrase-unlock path from the deleted mint-acts LoadCredential= to the relay's own LoadCredentialEncrypted=, sysadmin-handoff verify step, README). Left correct historical/superseded records untouched (ADR-0010's superseded banners, RELEASE-NOTES-beta.13/14/28, handoff logs, a dated changelog row, the af-unix smoke's accurate beta.13-history comment).
Verified GREEN post-cleanup: relay tsc clean; Ansible YAML valid (3 files); install-systemd-units.sh bash -n clean; the 3 edited smokes 20/4/6; af-unix 2/2 (now globs 4 JS-runtime units, not 5); operator-doc gates fenced-path-existence 285/285 + env-var-parity 114/114 + section-ref 4/4; FULL battery 8552 across all 383, 0 runners failed (2562+2053+2163+1774). Delta 8556→8552 (−4) fully reconciled: systemd-unit-install −2, ansible-systemd-user-consistency −1 (one fewer User= unit after the delete), −1 from a doc smoke no longer enumerating the removed npm run mint-acts command — all the "removed dead artifact" direction; no runner went red. NOT in-sandbox (standing limits): indexer better-sqlite3 native build (matrix-bot only) + web vite build → CI; a live Ansible deploy to an operator box. This advances PHASE 2-A (dead code) + PHASE 2-J (CI/gates) + PHASE 4 (OPERATIONS/docs accuracy, broken references, wiring) for the ACT-minting surface specifically.
Session (cp347, 2026-06-25) — full persona walkthroughs + black-hat deep-deep of the cp338→cp346 surface; 1 finding (rate-limit gap) FIXED (post-beta.30, no bump; WORKING TREE ONLY; ⚠ NO TARBALL CUT — Ken deferred)
Scope. Ken: "complete walkthroughs and deep deep now." All five persona walkthroughs re-run + manual black-hat reasoning on the recently-changed surface; a full-repo regression battery; and a black-hat static pass focused on the delta since the last comprehensive deep-deep (cp338→cp346: the cp344 same-origin broadcast proxy + the cp346 settings scoping / endpoint-error / pairing work). Builds on the prior comprehensive A–O passes (cp≤208 + cp252–276) recorded below, which remain valid for the unchanged surface.
PHASE 1 — persona walkthroughs (all five GREEN). persona-walkthrough 183/183 (Bob multi-login, Sally-user, Sally-operator, + Charlie/MCP pins + Josie Jo-1..Jo-9b pins); sally-walkthrough 22/22 (Sally-user deep); Charlie — the mcp-server suite (mcp-server 9, agent-field-allowlist 9, private-instance-policy 23, fetchjson-body-cap 4) all green; Josie — the 46 ops-cli smokes all green within the battery. Manual reasoning: the cp338→cp346 changes IMPROVE rather than break these flows — Sally-user's /my/orders now shows its own copy instead of the borrowed "post an order" wording (cp345); Bob's account-switch no longer leaks another account's profile drafts and his QR pairing now fetches keys via the same-origin indexer (cp346); Sally-operator gains the broadcast proxy + per-endpoint RPC error reasons (cp344/cp346); Josie's ops-cli + Charlie's MCP surface were untouched by cp338→cp346.
PHASE 2 — full regression battery + black-hat static pass. FULL battery = 8556 scenarios across all 383 registered smokes, 0 runners failed (chunks 3274 + 1344 + 1289 + 1870 + 779), which codifies categories C/D/E/H/I/J/M across the whole repo (SQL/handler/contract/XSS/i18n/wiring sentinels). Fresh black-hat reads of the delta: (A) no dead code / TODO / FIXME / debugger in the changed files; the console.warn calls in settings + pairing are intentional, browser-local error diagnostics that log messages (never keys — the redactPrivateKeys discipline holds). (F) no NEW direct browser→third-party RPC path — the only condenser_* reference in changed browser code is broadcastTransport's documented cp344 fallback. (H) no @html / innerHTML / eval / unsafe sink in the cp345 cards or cp346 settings hydration (the on-chain fields land in escaped text inputs, never an HTML sink; the avatar SVG is still re-sanitized in extractLabelPropsFromProfile). (M) the 9 new i18n keys (cp345 ×7, cp346 ×2) are each referenced in source — no orphans — and parity is 10/10 @ 3214.
FINDING (cp347, MEDIUM — FIXED + pinned). The cp344 forwarding proxies /v1/chain (block explorer + ref-block properties) and /v1/broadcast (the write proxy) each forward ONE upstream Blurt RPC call per request, yet were mounted WITHOUT the per-IP rate-limit tier that every other upstream-touching proxy carries (/v1/account, /v1/profiles, /v1/release, …) — so an unauthenticated flood of well-formed-but-bogus requests could amplify load onto the operator's RPC pool. (The global body cap + edge nginx client_max_body_size already bound size; this was specifically the request-rate gap.) FIX: wrapped both in a Hono sub-app carrying rateLimit('resource', config.resourceRatePerMin) (600/min default — far above any legitimate broadcast or explorer rate, so real writes never trip it into the direct-RPC fallback; a 429 is intentionally NOT in broadcastTransport's fallback set, so a throttled op surfaces "try again" rather than silently leaking to direct RPC). NEW smoke broadcast-chain-proxy-rate-limit-smoke (5) pins both routes' coverage so the gap can't reopen; updated two dependent smokes (broadcast-same-origin, chain-explorer-via-indexer — assertion + tamper) for the new sub-app mount form. Registered run-smokes.sh (382 → 383).
PHASE 3 — hostile-op + chain-direct re-pass. cp338→cp346 added NO new ingest handlers (broadcast.ts is a forwarding proxy, not a handler), so the 17-handler trust model from the prior comprehensive sweep is unchanged and its handler smokes (order-handler 42, chat, feedback, stranger-fee, operator-*, fee-attest, feature-bid, block, orderbook-block-enforcement) are green in the battery. The NEW chain-direct surface — an attacker POSTing raw ops via /v1/broadcast — was black-hatted in full: the op WHITELIST ({custom_json[^morphit_], transfer, comment, comment_options, vote}) refuses everything else; the tx arrives ALREADY SIGNED by the client so the relay is non-custodial and the chain charges the signer's resource credits (so even within the whitelist it can't be used to spam at the operator's expense); structural Zod (≤10 ops, ≤8 sigs, hex-only sig regex) + the now-added rate limit bound abuse; a chain rejection surfaces its real reason (400) while a transport failure (502) lets the client fall back. The indexer never acts on a forwarded op — its ingest handlers re-validate everything when they read it back from chain — so a morphit_fake_v1 forwarded here is inert.
PHASE 4 — cross-cutting (clean for the delta). DB dead fields: cp338→cp346 added NO schema columns (broadcast forwards to chain; profile scoping is localStorage), so the prior finding (only the two *_detected_at forensic timestamps, both written + the tables actively read) stands. Memory leaks: the cp346 EndpointList poll setInterval clears on teardown (return () => clearInterval); the settings rehydrate $effect adds no timer; endpoints.ts adds no module-level timer. Wiring: every cp344/cp345/cp346 change is wired + smoked (broadcast proxy, /my/orders copy, profile-key scoping, endpoint-error classify, pairing reroute). Doc accuracy / broken refs: the battery's cross-document-value-invariants + operator-doc-fenced-path-existence + Forgejo-naming guards are green, and this session's doc edits passed them.
PHASE 5 — audit type not previously run. Rate-limit COVERAGE across the proxy-route surface (which surfaced the finding above) — now codified by the new smoke.
PHASE 6 — recommendation / verdict. One real finding (rate-limit gap), fixed + pinned. The rest of the cp338→cp346 surface is clean: non-custodial + same-origin privacy intact, the trust-minimized verification reads (release / payment / chat-identity) correctly stay multi-node-direct with documented rationale, the cross-account profile leak is closed, and no new XSS / dead code / dead keys / leaks / schema drift. Grandma-friendliness: the /my/orders copy + the endpoint error reasons are clearer than before. Consistent with the clean bills from the prior comprehensive sessions.
VERIFICATION (all GREEN). web svelte-check 0/0; indexer tsc --noEmit clean; profileProps.test.ts 27/27; the cp346 smokes (14/10/8) + the new cp347 smoke 5/5 + the two updated dependents (broadcast-same-origin 19/19, chain-explorer-via-indexer 8/8); all five persona suites green; i18n-locale-parity 10/10 @ 3214, completeness 4/4; the Forgejo-naming doc guard 3/3; FULL battery 8556 across all 383 smokes, 0 failed. NOT verifiable in-sandbox (no browser, no Blurt RPC): the live rate-limit behavior under a real flood, and everything the prior sessions flagged for a post-deploy eyeball. ⚠ NO TARBALL CUT — Ken deferred; WORKING-TREE ONLY. The pending FULL cut (cp339 deleted ensUrl.ts) now bundles cp339 → cp347.
Session (cp346, 2026-06-25) — settings: per-account profile-draft scoping (BUG) + RPC endpoint error reasons + tighten direct-RPC to the indexer (post-beta.30, no bump; WORKING TREE ONLY; ⚠ NO TARBALL CUT — Ken deferred)
Context. Ken's three-part request, all on the settings / RPC surface: (1) surface WHY an RPC node is failing on the settings endpoint panel; (2) keep the user anonymous — no direct RPC connections; route through the indexer + the best-node rotator wherever we must reach a node; (3) a concrete bug — after signing out and back in as a DIFFERENT account (kentest2), /settings showed the PREVIOUS account's (kentest3's) profile field values.
PART 3 — the cached-fields bug (FIXED + verified). ROOT CAUSE: the four settings draft keys (morphit.displayName, morphit.nostrUrl, morphit.blurtMediaUrl, morphit.shortBio) were GLOBAL localStorage keys, not scoped by account, so every account read whatever the last account wrote — a cross-account correctness + privacy leak. A SECOND latent defect: getProfile(acct) was fetched only to decide whether to show the "Remove avatar" button; it never populated the editable display-name / bio / blurt.media / nostr fields, so the form was driven PURELY by the local cache and never reflected the current account's on-chain profile on a fresh device. FIX: (a) scoped all four keys per account — PROFILE_KEY_SUFFIX = .${getUserBlurtAccount()}, resolved once at component init (stable for the page's life; sign-out navigates away → the component remounts on the next login), so the ~17 existing read/write sites pick up the scoped key with no other change; (b) on mount (when an account is present), purge the pre-cp346 GLOBAL keys so a previous account's leaked drafts don't linger as orphans (they are never read again under scoping); (c) extended extractLabelPropsFromProfile (profileProps.ts) to also return shortBio (json_metadata.short_bio), and in the getProfile success handler fill display-name / blurt.media / bio / nostr from the on-chain profile ONLY where there is no local draft for this account (a local draft is a pending edit and wins). The four keys are referenced only in settings (the header and public profile render from the indexer, not localStorage), so the leak is fully contained there. kentest2 now sees its own empty-or-on-chain values, never kentest3's.
PART 1 — RPC node error reasons (DONE). The EndpointList panel already showed cooldown, an HTTP status ("Error: 429"), and latency, but a non-HTTP failure (timeout / network / CORS) showed only "Failing (N)" — and warmup(), the panel's own probe path, SWALLOWED the error in a bare catch {}, so even the HTTP code was not captured there. FIX (endpoints.ts): added EndpointStat.lastErrorKind: 'http' | 'timeout' | 'network' | null and an exported classifyEndpointError(err) that maps an ^HTTP \d{3} message to { http, code }, an AbortError / timeout to { timeout }, and everything else to { network } — the browser deliberately collapses DNS / offline / TLS / CORS into one opaque TypeError, so we do NOT claim a specific one. Wired it into all three failure sites (call, callMany, warmup) and cleared it on every success / RpcError branch. EndpointList.statusLabel now renders the reason: "Error: 429" / "Timed out" / "Unreachable". Added two i18n keys settings.endpoints.{timed_out,unreachable} across all ten locales (terse status labels, register-matched).
PART 2 — no direct RPC (pairing rerouted; verification stays direct BY DESIGN — honest pushback). First, getBlurtClient() is already backed by the rotator (blurt/client.ts returns getRotator()), so EVERY remaining direct path already uses the node-hopping best-node rotator — Ken's "always use the best rpc node" requirement is already met. On the "use the indexer" half, the paths split cleanly: (a) DONE — the pairing posting-key lookup. pairingClient.ts and pairingPhoneSigner.ts now fetch the account's PUBLIC posting authority through the SAME-ORIGIN indexer (fetchAccountKeys → /v1/account/:name/keys) instead of a direct condenser_api.get_accounts. This is a clean privacy win (third-party RPC nodes no longer see which account is pairing) with no trust loss: the keys are public, the signature check stays client-side, and a malicious operator serving fake keys could only make a legitimate pairing FAIL — a denial it already has by serving the app. (b) DELIBERATELY KEPT DIRECT, because routing them through the operator's own indexer would reintroduce the exact forgery they defend against: release verification (releaseFetch — trust-anchor: a malicious indexer could serve a forged release; its own header comment already spells this out), payment verification (blurtVerify — a MULTI-NODE quorum via callMany, 2-of-3, so no single party including the operator's indexer can fabricate a "payment received" and trick a seller), op / chat-identity verification (chainOpVerify / chainVerify — same single-hostile-RPC forgery rationale), and the cp344 broadcast direct-RPC FALLBACK (used only when the same-origin proxy is unreachable). All four are already documented in-code; the new routing smoke pins these boundaries so a future "route everything through the indexer" pass cannot silently turn them into a vulnerability.
FILES. EDITED code (6): routes/[lang]/settings/+page.svelte, lib/indexer/profileProps.ts, lib/net/endpoints.ts, lib/components/EndpointList.svelte, lib/auth/pairingClient.ts, lib/auth/pairingPhoneSigner.ts. EDITED test (1): lib/indexer/profileProps.test.ts (added shortBio to the all-null and keys-list assertions + a short_bio extraction case → 27/27). EDITED locales (10): two settings.endpoints keys. NEW smokes (3) + registration: settings-profile-keys-account-scoped-smoke.ts (14), endpoint-error-classify-smoke.ts (10), rpc-privacy-routing-smoke.ts (8) → scripts/run-smokes.sh (379 → 382). EDITED docs: TARBALL.md, docs/REVISIT-LIST.md, docs/AUDIT-2026-06-DEEPDEEP.md. No version touchpoints, no new deps, no mediakit change, no deleted or moved files.
VERIFICATION (all GREEN). web svelte-check 0/0; profileProps.test.ts 27/27; the three new smokes 14/14 + 10/10 + 8/8; i18n-locale-parity 10/10 @ 3214 keys (+2 vs cp345's 3212); i18n-translation-completeness 4/4; the Forgejo-naming doc guard 3/3; FULL smoke battery = 8550 scenarios across ALL 382 registered smokes, 0 runners failed (chunks 3281 + 1333 + 1305 + 1860 + 771 = cp345's 8518 + the 32 new). NOT verifiable in-sandbox (no browser, no Blurt RPC): the actual account-switch behavior (Part 3), the rendered endpoint reasons (Part 1), and a live pairing through the indexer keys proxy (Part 2) — all want a human eyeball after deploy; the wiring is pinned by svelte-check + the new smokes. ⚠ NO TARBALL CUT — Ken deferred; WORKING-TREE ONLY. The pending FULL cut (cp339 deleted ensUrl.ts) now bundles cp339 → cp346.
Session (cp345, 2026-06-25) — /my/orders unauthenticated states get their own copy + a privacy decision (post-beta.30, no bump; WORKING TREE ONLY; ⚠ NO TARBALL CUT — Ken deferred)
Context. Ken's standing open item "decide /my/orders never-logged-in handling." Investigated in code before recommending.
Findings. /my/orders already branched three ways — never-onboarded (!blurtAccount, where blurtAccount = getUserBlurtAccount() is the CACHED account name and survives a reload/lock), onboarded-but-locked (!isUnlocked && !isPairedReadOnly), and unlocked/paired (full render) — so the never-logged-in case was functionally handled. Two defects: (1) the never-onboarded and locked branches BORROWED post_order.no_account.* / post_order.locked.* copy, so a first-time visitor to a bookmarked /my/orders was told they needed an account "to post an order"; (2) a doc/code contradiction — the RequireLiveSession docstring (cp342) asserted /my/orders shows on-chain history from the cached name when locked, but the code gates history behind unlock.
Decision (Ken approved the recommendation): keep the unlock-gate; do NOT open read-only history when locked. Order history is a row-by-row record of counterparties, amounts, and timestamps; rendering it on a locked, walked-away device is a real exposure beyond the account name already shown in the header CTA — the privacy-first choice (priority #1). It is also the smaller, fully-in-sandbox-verifiable change: a locked fall-through (like the paired-readonly path) would require new per-row write-blocked affordances AND an on-chain history render that can't be tested here.
Done. (a) never-onboarded card → its own my_orders.no_account.* copy plus a SECOND CTA ("I already have an account" → /onboarding/import, secondary variant) beside "Create an account", because a fresh visitor to /my/orders could be brand-new OR on a new device with existing keys; (b) locked card → its own my_orders.locked.* copy that states the privacy rationale ("a locked device never reveals your trades"); (c) corrected the RequireLiveSession docstring so it no longer claims read-only-history-when-locked (the page presents its own locked UI rather than redirecting). The post_order.* keys are untouched and still consumed by /post and /post/edit (verified — not orphaned). 7 new i18n keys (my_orders.no_account.{title,body,cta_register,cta_unlock} + my_orders.locked.{title,body,unlock}) added to all 10 locales, register-matched per language, each locale's established terms reused, "Morphit" kept as a proper noun (fa transliterates مورفیت per its existing copy). No new smoke (i18n-locale-parity already pins every key in all 10 locales; there is no new runtime behavior to regression-guard). No version bump, no new deps, no mediakit change.
VERIFICATION (all GREEN): svelte-check (apps/web) 0/0; i18n-locale-parity 10/10 @ 3212 keys (+7 vs cp344's 3205); i18n-translation-completeness 4/4; the Forgejo-naming doc guard 3/3; FULL smoke battery = 8518 scenarios across ALL 379 registered smokes, 0 runners failed (chunks 3281 + 1333 + 1305 + 1860 + 739 — unchanged from cp344; no smokes added or removed). NOT verifiable in-sandbox (no browser): the two rendered cards across the 10 locales want a human eyeball after deploy; the copy + wiring are pinned by svelte-check + i18n parity. ⚠ NO TARBALL CUT — Ken deferred; WORKING-TREE ONLY. The pending FULL cut (cp339 deleted ensUrl.ts) now bundles cp339 → cp345.
Session (cp344, 2026-06-25) — broadcasts go same-origin (privacy + reliability) + 6 settings/profile/orders UX fixes (post-beta.30, no bump; WORKING TREE ONLY → beta.31 candidate; ⚠ NO TARBALL CUT — Ken deferred)
Ken's 7-item report: "Save & broadcast" fails with "Couldn't broadcast. Try again." for display name AND avatar/short-bio/blurt.media/nostr (all tested, all identical); plus a double border, icon over-spacing, missing Save-locally buttons, a profile refresh-button lag, a missing-icons question, a keyfile-login username prompt question, and a wrong my/orders arrow.
ITEM 1 — broadcasts (CRITICAL). Diagnosis (verified in code, NEVER ASSUME). Traced every chain WRITE: profile (broadcastProfile→broadcastCustomJson), orders (order.ts:101,124→same), chat (chatService.ts→same), feedback/blocks/feedbackResponse/chatIdentity/strangerFee/featureBid/operatorRegister — ALL funnel through broadcastCustomJson/broadcastSignedTransaction (sign.ts) → a DIRECT browser→third-party-RPC condenser_api.broadcast_transaction_synchronous against the 3 browser-"CORS-clean" nodes in DEFAULT_RPC_ENDPOINTS (net/config.ts, last verified cp268). The signing + ref-block derivation are well-tested and correct; the failure is REACHING the chain. This is two problems at once: (a) the likely break — if any of those 3 nodes changed its CORS header or went down since cp268, every browser broadcast fails while reads keep working (reads were moved to the indexer in cp295/296/298), which exactly matches "saves locally, broadcast fails"; and (b) a priority-#1 PRIVACY hole — the browser opens a cross-origin connection to a third-party RPC operator on every order/chat/profile op, leaking the user's IP + their exact on-chain action, the same deanonymizing leak the cp298 account-keys proxy closed for READS but on WRITES (more sensitive). The ref-block read (getDynamicGlobalProperties) that builds the tx also went direct to RPC, so under the same conditions it fails FIRST, before signing.
Fix — mirror the proven cp295/296/298 read-proxy on the WRITE side. NEW apps/indexer/src/api/broadcast.ts — POST /v1/broadcast: Zod-validates the signed-tx shape, enforces an operation WHITELIST (custom_json with id forced to ^morphit_[a-z0-9_]+$, plus transfer/comment/comment_options/vote — refuses account_update/witness/etc so it can't be repurposed as an open relay; the chain charges the SIGNER's resource credits regardless), forwards broadcast_transaction_synchronous SERVER-side via blurt.callCondenser, maps a transport error → 502 (so the client can fall back) and a chain rejection → 400 carrying the chain's own message, and normalizes condenser's id → trx_id. Mounted /v1/broadcast in main.ts next to /v1/chain. Extended chainExplorer.ts with GET /v1/chain/properties (a get_dynamic_global_properties proxy, 2 s cache) so the ref-block read is same-origin too. NEW web apps/web/src/lib/blurt/broadcastTransport.ts — submitSignedTransaction (POST /v1/broadcast) + fetchDynamicGlobalProperties (GET /v1/chain/properties), each FALLING BACK to the legacy direct-RPC path on proxy-unreachable (network error / 5xx / a stale indexer's 404), so the change CANNOT regress below the pre-cp344 behavior; a proxy 400 is a chain rejection and is surfaced as a ChainRejectedError (NOT fallen back — direct RPC would only refuse it again). Rerouted sign.ts (getRefBlockInfo→fetchDynamicGlobalProperties; both broadcast fns→submitSignedTransaction; dropped the now-unused getBlurtClient import + the direct condenser call; refreshed the stale header comment). Also rerouted apps/web/src/lib/blurt/ops/comment.ts (blog-post syndication, called by syndication/publish.ts) — it had its OWN duplicated getRefBlockInfo + a direct condenser_api.broadcast_transaction_synchronous; the prior cp344 handoff claimed this path was already done, but the working tree still had it broadcasting direct, so it was genuinely closed here (DGP read → fetchDynamicGlobalProperties, broadcast → submitSignedTransaction, getBlurtClient import dropped, docstring refreshed). Swept the rest: pairing / releaseFetch / chainVerify use the rotator for READS only (get_accounts, release/identity verification), so comment.ts was the LAST direct broadcast path. Updated the net/config.ts endpoint comment — those CORS-clean RPC nodes are now the write FALLBACK plus the transport for the browser reads not yet proxied. Error surfacing: a shared broadcastErrCopy(err) now backs all 6 settings broadcast catch blocks and shows the chain's REAL reason (e.g. "missing required posting authority", "insufficient mana") via a NEW settings.display_name.broadcast_err.rejected key added to all 10 locales — replacing the opaque generic, so the next failure (if any) is diagnosable. CANNOT live-test here (the sandbox reaches only npm/pypi/github/ubuntu, no Blurt RPC) — needs a real-browser broadcast after deploy: if the cause was CORS/dead-nodes the same-origin proxy fixes it; if it's an account issue the proxy now surfaces the chain's real reason. ITEM 6's verification rules a wrong account out as the cause.
ITEMS 2–5,7 (UI/UX). (2) blurt.media + nostr inputs showed a red border AND a green focus ring at once (plus a stacked border-ink-300 under the conditional red) — restructured both so border + ring track validity together (red/red invalid, grey/emerald valid). (3) the blurt/nostr glyphs in IdentityLabel.svelte carried ms-1 on TOP of the container gap-1.5 (~10 px ≈ the "2-3 spaces") while every other child used only the 6 px gap — removed the redundant ms-1. (4) added a "Save locally only" BusyButton left of "Save & broadcast" on the blurt.media + nostr cards, mirroring short-bio (reuses the existing local-save fns + state + the shared display_name.save/saved_toast/save_pending labels — no new keys), and updated the two now-stale "no longer needs a Save-locally button" comments. (5a) MyBalanceCard.manualRefresh set manualRefreshing=true then await refresh(), but refresh() early-returns under the refreshInFlight guard while the silent 5 s auto-refresh is mid-flight — that resolves so fast the true→false flip coalesces into one reactive flush and the icon never visibly spins, so a click in that window looks dead and only "works" seconds later; added a 600 ms minimum-spin floor (Promise.all([refresh(), minSpin])). (5b — NO code) the profile hero already renders the display name, IdentityLabel nostr/blurt.media glyphs (validated via validate*ForRender), and the short bio; they're blank because those fields were never written on-chain (= ITEM 1) and the public profile is sourced from the indexer, so it resolves once broadcasts work. (7) the my/orders "View my account on the block explorer →" link used a literal arrow + hover:underline + no slide — replaced with the canonical .nav-arrow nav-arrow-right ⇨ span and dropped the underline classes, so app.css gives the hover slide, no-underline, and RTL flip for free.
ITEM 6 (NO code — current behavior is correct and safe). A JSON keyfile/keystore stores encrypted KEYS but not the Blurt account NAME, so the keyfile import flags needs_account_name and routes to the /settings account-name card, which VERIFIES the entered name (format check → fetchAccountKeys → verifyPostingKey) and only setUserBlurtAccounts on a confirmed match — bogus → error_bad_format, nonexistent → error_not_found, valid-but-key-mismatch → error_key_mismatch, empty blocked. So a wrong account can't be saved (and a wrong account is therefore not the ITEM 1 cause). Auto-detecting the name from the unlocked posting key via a get_key_references reverse-lookup is viable but a follow-up feature (new same-origin lookup endpoint + import-flow change + multi-account-key/lookup-failure fallbacks), untestable here — recommended, not built blind.
SMOKE. NEW apps/web/scripts/broadcast-same-origin-smoke.ts (19 — static, cross-workspace): asserts sign.ts AND comment.ts (blog-post syndication) route through the transport and that NO direct condenser broadcast / DGP read remains in either; that the transport POSTs /v1/broadcast, reads /v1/chain/properties, exports ChainRejectedError, falls back to direct RPC, and surfaces a 400; and that the indexer route exists, forwards server-side, enforces the op whitelist + ^morphit_ id guard + the 502/400 error map, that chainExplorer exposes the properties proxy, and that the route is mounted at /v1/broadcast. Registered in scripts/run-smokes.sh (378 → 379). Its bring-up caught one false positive (the sign.ts header still named the old condenser call — the comment was updated). BRAG: added entry #15 to MORPHIT-BRAG-LIST.md §2 ("Your broadcasts go through your operator's node, not a stranger's." — honestly disclosing the direct-RPC fallback so the claim stays verifiable), ran renumber-brag-list.py (333 entries), shifted the brag-list-kiss-budget-smoke staccato allowlist +1 for the new entry, and regenerated morphit-mediakit.zip (it bundles the brag list). Re-greened: kiss-budget 2/2, trailer-invariants 5/5, claim-parity 82/82, mediakit-freshness 7/7.
VERIFICATION (all GREEN): svelte-check (apps/web) 0/0; indexer tsc --noEmit clean; broadcast-same-origin-smoke 19/19; i18n-locale-parity 10/10 @ 3205 keys (the one new broadcast_err.rejected key ×10), i18n-translation-completeness 4/4; the Forgejo-naming doc guard 3/3; FULL smoke battery = 8518 scenarios across ALL 379 registered smokes, 0 runners failed (chunks 3281 + 1333 + 1305 + 1860 + 739). NOT verifiable in-sandbox (no browser, no Blurt RPC): the live broadcast round-trip (ITEM 1) and the rendered border/spacing/buttons/spin/arrow (ITEMS 2-5,7) — the code paths + the broadcast wiring are pinned by the smoke + typecheck, but the pixels and the chain round-trip want a human eyeball after deploy. ⚠ NO TARBALL CUT — Ken deferred; WORKING-TREE ONLY. The pending cut is FULL (cp339 deleted ensUrl.ts) and bundles cp339 → cp344.
Session (cp343b, 2026-06-25) — FAQ deep-link scroll fix: footer "API" link + every other deep link (post-beta.30, no bump; WORKING TREE ONLY → beta.31 candidate; ⚠ NO TARBALL CUT — Ken deferred)
Ken reported the footer "API" link lands on /faq but doesn't smooth-scroll to the expanded article, and asked to fix it plus any other links meant to land on a specific expanded FAQ article.
Diagnosis (verified in code, NEVER ASSUME). The link itself is fine and the article DOES expand. The footer API link is {lp('/faq')}?q=wallet_developer_api&lang=… (+layout.svelte:577) — byte-for-byte the same shape as the AGPL link {lp('/faq')}?q=why_agpl&lang=… (:629) that scrolls correctly. Confirmed wallet_developer_api is a real FAQ key (faqIndex.ts:166 in FAQ_KEYS), that $faqEntries is built by mapping ALL of FAQ_KEYS (buildEntries, faqIndex.ts:1035), and that the template renders a FLAT {#each $faqEntries as entry} with <li id="faq-{entry.key}"> (FaqSearch.svelte:421-424) — so #faq-wallet_developer_api exists. Cross-checked all 16 in-app + footer deep-link keys against FAQ_KEYS via a one-off Node script: every one resolves to a real article with non-empty q+a. The afterNavigate deep-link handler (FaqSearch.svelte:86-129) finds the entry, expanded.add()s it (the article expands), and calls the shared scrollToEntry; the three $effects (242/254/314) don't scroll (the highlight one uses the paint-only CSS Custom Highlight API — no layout mutation). So the failure is NOT a missing key, a missing entry, or a non-rendered element.
Root cause = the smooth scroll lands SHORT. wallet_developer_api sits in section 10 with a long answer (bullet lists + links). On a freshly-mounted, still-laying-out FAQ page (139 articles), when the single scrollIntoView({behavior:'smooth'}) fires (a tick() + double-rAF after the expand), the entry's FINAL Y isn't settled yet — articles above it are still being laid out and images are still resolving — so the page height grows under the smooth animation and it lands above the entry. why_agpl is higher up, so its position settles before the scroll fires → it works. The lower and taller the target article, the more fragile the single-shot scroll. This is a layout-timing race, not a logic bug, which is why both links use the same code yet only the lower one fails.
Fix — hardened the SHARED scrollToEntry (FaqSearch.svelte). Kept the await tick() + double-rAF initial smooth scroll, then added a settle-and-correct pass: after 400 ms, re-measure the entry's getBoundingClientRect().top and re-align ONLY if it's still >8 px off the viewport top. So a first scroll that already landed isn't re-animated (no double-jump), a near-bottom article that physically can't reach the top isn't fought (re-scroll to max is a visual no-op), and a user who's already where they want isn't yanked. Refactored the body to a single align() helper invoked twice → there is still exactly ONE scrollIntoView({block:'start'}) literal, so faq-scroll-block-start-smoke stays 4/4. Because scrollToEntry is the shared scroll for the deep-link handler, the related-entry chips, AND search-result clicks, this one change fixes the footer API/AGPL/no-JS links and every in-app # deep link and chip/result scroll at once. The footer link itself was NOT changed — its key and URL form are correct.
SMOKE. NEW apps/web/scripts/faq-deeplink-smoke.ts (6): parses FAQ_KEYS; walks every .svelte under src/routes + src/lib/components; validates that EVERY FAQ deep-link key resolves to a real article — matching BOTH the direct /faq#KEY · /faq?q=KEY form AND the footer {lp('/faq')}?q=KEY form (the lp() wrapper splits /faq from ?q=, so a naive single pattern misses it); spot-checks the footer API → wallet_developer_api; and pins the corrective re-scroll (setTimeout + getBoundingClientRect().top re-align) plus block:'start'. Registered in scripts/run-smokes.sh (377 → 378). It validated 30 deep links across the app, all resolving to real keys. Its own bring-up surfaced two would-be smoke bugs (both fixed): (1) a /faq#unknown false positive lifted from a code COMMENT (the "/faq#unknown-key is intentionally a no-op" note) — added unknown to the structural-anchor ignore set alongside faqpage/faq; (2) the naive /faq(?:\?q=|#) regex MISSED the footer lp('/faq')}?q= form, i.e. the smoke would have falsely passed on exactly the link Ken reported — added the explicit \/faq'\)\}(?:\?q=|#) second pattern so the footer API + AGPL links are actually covered.
VERIFICATION (all GREEN): svelte-check (apps/web) 0/0; faq-deeplink-smoke 6/6 (30 deep links validated); faq-scroll-block-start-smoke 4/4; i18n-locale-parity 10/10 @ 3204 keys (NO text change this unit — scroll fix + smoke only), i18n-translation-completeness 4/4; the Forgejo-naming doc guard 3/3; FULL smoke battery = 8496 scenarios across ALL 378 registered smokes, 0 runners failed (chunks 3279 + 1333 + 1304 + 1860 + 720). NOT verifiable in-sandbox (no browser): the actual rendered smooth-scroll landing — the code path + the corrective are pinned by the smoke, but only a real browser confirms the pixels, so this wants a human eyeball after deploy. ⚠ NO TARBALL CUT — Ken deferred; WORKING-TREE ONLY. The pending cut is FULL (cp339 deleted ensUrl.ts) and bundles cp339 → cp343b.
Session (cp343, 2026-06-25) — welcome-back password autofocus + login-required redirect extended to /chat + auto-lock selector confirmed & guarded (post-beta.30, no bump; WORKING TREE ONLY → beta.31 candidates; ⚠ NO TARBALL CUT — Ken deferred)
Ken's 4-item follow-up to cp342.
(1) Auto-focus the welcome-back password field. Ken wants to land on the welcome-back screen and immediately type his password + hit Enter (or click Unlock) with no extra click. Verified in code (NEVER ASSUME): Enter ALREADY worked — the form is <form … onsubmit={(e) => { e.preventDefault(); void handleUnlock(); }}> — and the Unlock BusyButton already had onclick={handleUnlock}. The only missing piece was initial focus. Added a small focusOnMount(node: HTMLInputElement) action in login/+page.svelte that does requestAnimationFrame(() => node.focus()) — rAF-deferred so SvelteKit's post-navigation focus handling (which can reset focus on client-side nav) doesn't steal it back — and applied use:focusOnMount to BOTH the #unlock-password input AND the #unlock-totp input. The TOTP one matters because the password field carries disabled={needTotp}: once a first unlock attempt reveals a 2FA requirement, the password disables and the freshly-mounted TOTP input takes focus, so the flow stays keyboard-only end to end.
(2) Auto-lock selector — CONFIRMED present & intact; no code change; added a regression smoke. Ken asked whether the Settings auto-lock <select> "disappeared." Read the code: it's at settings/+page.svelte ~line 2255 — <select id="autolock-select"> with options 15 min / 30 min / 1 h / 4 h / 9 h / 24 h / Never (default 9 h per Q5.2). It AUTO-SAVES on change: onchange={setAutoLock} calls writeTimeoutMinutes(NEVER_LOCK) for "never" else writeTimeoutMinutes(parseInt(v,10)) immediately — there is no submit button — and sets a transient autoLockChanged = $_('settings.session.autolock_changed', …) confirmation rendered under the select. It's gated on canConfigureAutoLock = $derived(hasPersistedKeystore()) — only password-mode logins have a persisted envelope; seed-only users don't see it because Lock and Sign Out are the same action for them (documented in the code). Confirmed cp342's settings refactor did NOT touch any of this (it only removed the inline redirect + onMount/gotoLocale/get; svelte-check 0/0). Diagnosed for Ken why he might not see it: (a) he's testing the DEPLOYED beta.30, which predates everything since cp338 — the selector itself is older and IS in beta.30, but if he expected the auto-save behaviour as "new" it's the established design; (b) his current session may not be password-mode (hasPersistedKeystore() false); or (c) — the most likely interaction — now that cp342 redirects a LOCKED visit to /settings straight to the homepage, he can only reach Settings (and therefore the selector) while UNLOCKED. The feature had ZERO smoke coverage (a gap, given the repo's smoke culture and that a refactor could silently drop it), so added one.
(3) Extend the login-required redirect to /chat + /chat/[peer]. Ken: "if a user Locked their session, signed out, or never logged in, and then tries to reach a page that requires login (profile edit, place a trade, view settings, etc), send them to the homepage." cp342 covered settings, 2fa, backup-keys, post, post/edit. Audited the remaining routes for "requires login": the chat inbox (chat/+page.svelte) and conversation (chat/[peer=account]/+page.svelte) both REQUIRE a live chat identity — their onMount derives chat keys / publishes a chat identity, and the bootstrap bails with if (!me) return, so a locked / signed-out / never-logged-in visitor just landed on an empty page — and neither was guarded (no isUnlocked/redirect in either). Added <RequireLiveSession /> to both (inbox: first child of the root <section>; conversation: right after <Head>, before the {#if bootError} chain). The guard redirects exactly Ken's three states (!isUnlocked && !isPairedReadOnly) and leaves paired-readonly through (consistent with the other pages; a paired device's chat affordance is a separate matter). /my/orders REMAINS excluded — re-confirmed it loads your on-chain order history from the cached account name (onMount: if (blurtAccount) void load(), and the name survives a refresh), so it stays genuinely useful read-only when locked; it doesn't share the "useless when locked" property of Ken's examples. Flagged to Ken that the never-logged-in (no cached account) case is the one weak spot, offering to either guard it too or add an explicit empty state — his call. Login-FLOW pages (login, login/qr-pair, scan-login) and /admin/setup-wizard (operator auth) correctly stay unguarded. Coverage is now 7 login-required pages.
SMOKES. NEW apps/web/scripts/autolock-settings-smoke.ts (8 — store helpers imported; <select id="autolock-select"> exists; onchange={setAutoLock} auto-save; setAutoLock persists via writeTimeoutMinutes/NEVER_LOCK; canConfigureAutoLock = hasPersistedKeystore() gate + {#if canConfigureAutoLock}; "Changed to …" confirmation wired + rendered; all 7 <option>s; 10 autolock_* en keys), registered in scripts/run-smokes.sh (376 → 377). UPDATED require-live-session-smoke (12 → 14 — added chat + chat/[peer] to the "renders + imports the guard" set; header notes cp343 coverage). UPDATED locked-session-ux-smoke (12 → 13 — new section 5: the welcome-back #unlock-password carries use:focusOnMount and focusOnMount is defined). VERIFICATION (all GREEN): svelte-check (apps/web) 0/0; autolock-settings-smoke 8/8; require-live-session-smoke 14/14; locked-session-ux-smoke 13/13; i18n-locale-parity 10/10 @ 3204 keys (NO locale change this unit — autofocus + chat guard add no text; the auto-lock feature pre-exists with its locales), i18n-translation-completeness 4/4; the Forgejo-naming doc guard 3/3; FULL smoke battery = 8490 scenarios across ALL 377 registered smokes, 0 runners failed (chunks 3279 + 1333 + 1304 + 1860 + 714). NOT run in-sandbox (standing limits): indexer native build + web vite build → CI; the rendered autofocus + the chat redirect's timing/feel are a human eyeball after deploy. ⚠ NO TARBALL CUT — Ken deferred; WORKING-TREE ONLY. The pending cut is FULL (cp339 deleted ensUrl.ts) and bundles cp339 + cp340 + cp341 + cp342 + cp343.
Session (cp342, 2026-06-25) — locked→home redirect generalized to every session-required page + FAQ hover borders + QR-cache confirmation (post-beta.30, no bump; WORKING TREE ONLY → beta.31 candidates; ⚠ NO TARBALL CUT — Ken deferred)
Ken's 4-item follow-up to cp340/cp341.
(1) Generalize the locked→homepage redirect. Ken: "I was on /settings when I refreshed — what if I was on a different page? Regardless, the avatar should read 'Unlock' and redirect to the homepage; clicking Unlock takes me to welcome-back." The avatar "Unlock" label was ALREADY global (cp340 put it in the layout AvatarMenu, so it flips on every page once hasAnySession goes false on reload — verified, no change needed). The REDIRECT, though, was inline in settings/+page.svelte only. Extracted it verbatim into a shared render-nothing guard apps/web/src/lib/components/RequireLiveSession.svelte: onMount (runs ONCE — not an $effect, so a later idle auto-lock while actively on the page does NOT yank the user mid-task) → 250ms setTimeout grace (lets a multi-tab cross-tab session handoff restore the live session first) → if (!get(isUnlocked) && !get(isPairedReadOnly)) void gotoLocale('/'), returning a clearTimeout cleanup. Dropped <RequireLiveSession /> as the first template child of every session-required landing page: settings (REFACTORED — removed the inline onMount redirect block AND its now-unused imports onMount/gotoLocale/get; confirmed by grep that get( and gotoLocale appeared ONLY in that block, while isPairedReadOnly is still used at the template {#if $isPairedReadOnly} ~line 1062 so its import stayed — svelte-check 0/0 confirms the cleanup), settings/security/2fa, backup-keys, post, post/edit/[permlink]. Deliberately EXCLUDED /my/orders (NEVER ASSUME — read its onMount + template): it calls load() keyed off the cached blurtAccount name, which SURVIVES a refresh, so it renders your on-chain order HISTORY read-only when locked, with a per-order "unlock to leave feedback" affordance for the write path — genuinely useful, not stranded; redirecting would REMOVE value. Also excluded all public/browseable pages (orderbook, faq, account/permlink, …). The guard fires ONLY for fully-locked visitors (!isUnlocked && !isPairedReadOnly); a paired-readonly session is a LIVE read-only session (keys on the phone) and KEEPS access on every page (it sees the page's own WriteBlockedReadOnly affordance instead). Verified each target's fully-locked branch in code first: post shows a post_order.locked.* "unlock to post" card, 2fa sets phase='locked', backup-keys can't export (no in-memory keys) — all pure session-required, so a uniform redirect is correct. Net behaviour Ken asked for: refresh while locked on ANY session-gated page → homepage; header reads "Unlock" → welcome-back.
(2) FAQ hover borders (FaqSearch.svelte). The .card base class (app.css) is @apply rounded-2xl bg-white p-6 shadow-morphit-card dark:bg-ink-900 — NO border. So the article cards <li id="faq-{entry.key}" class="card p-0"> had no border to highlight. Added border border-transparent transition-colors hover:border-ink-300 dark:hover:border-white/70 to the <li> — the always-present transparent 1px reserves the space (NO layout shift on hover), and on hover it colours to a light gray (light mode) / 70%-white "not-quite-white" (dark mode, Ken's view). Gave the search <input> the same hover treatment (hover:border-ink-300 dark:hover:border-white/70; it already had border-2 border-ink-200 dark:border-ink-700 + transition, so its existing border simply brightens on hover; left the width at 2px to avoid a reflow). CSS-only — no locale change.
(3) QR caching — confirmed already optimal, NOTHING TO BUILD. Ken worried the QR "image" on the welcome-back "use phone instead" button costs a server round-trip on every view ("every byte counts… 3G phones… efficiency is priority #4"). VERIFIED in code: the only icon-qr occurrence in the web app is a CODE COMMENT in login/+page.svelte; the QR itself is an INLINE <svg><path> baked into the component markup, NOT an <img src> or any fetch — so it costs ZERO network requests, ever (it ships inside the JS chunk). That chunk is precached by the service worker (service-worker.ts PRECACHE_ASSETS = [...build, ...files, ...prerendered], cached-on-install, served cache-first), so on a later welcome-back view it's served from local cache with no server trip. Inline SVG is exactly the byte-frugal choice for a small icon (no extra HTTP request, no separate cache entry, gzips with the bundle); the cp341 lock svg is inline for the same reason. Explained to Ken; no code.
SMOKES. NEW apps/web/scripts/require-live-session-smoke.ts (12 — the guard exists + redirects via gotoLocale('/') + the fully-locked-only !isUnlocked && !isPairedReadOnly guard + onMount-not-$effect; the 5 session-required pages each render AND import <RequireLiveSession />; my/orders + orderbook + faq each do NOT reference it), registered in scripts/run-smokes.sh (375 → 376). UPDATED locked-session-ux-smoke.ts section 2 (still 12 total): the cp340 assertions pinned the settings INLINE redirect (gotoLocale('/')/guard/onMount in settings), which cp342 removed — re-pointed to read RequireLiveSession.svelte and assert (a) settings renders <RequireLiveSession />, (b) the component redirects to '/', (c) the component's guard + onMount-once logic. Swept the other web smokes: no other smoke asserted the old settings inline redirect (no collateral). VERIFICATION (all GREEN): svelte-check (apps/web) 0/0 (the settings import cleanup is clean); require-live-session-smoke 12/12; locked-session-ux-smoke 12/12; i18n-locale-parity 10/10 @ 3204 keys, i18n-translation-completeness 4/4 (no locale change this unit); the Forgejo-naming doc guard 3/3; FULL smoke battery = 8479 scenarios across ALL 376 registered smokes, 0 runners failed (chunks 3279 + 1333 + 1304 + 1860 + 703; +12 from the new smoke, +1 from a component-count smoke registering the new RequireLiveSession.svelte). NOT run in-sandbox (standing limits): indexer native build + web vite build → CI; the rendered hover border + the redirect timing/feel are a human eyeball after deploy. ⚠ NO TARBALL CUT — Ken deferred; WORKING-TREE ONLY. The pending cut is FULL (cp339 deleted ensUrl.ts) and bundles cp339 + cp340 + cp341 + cp342.
Session (cp340 + cp341, 2026-06-25) — locked-session UX (Unlock CTA + Settings→home redirect) + welcome-back/login icon polish (post-beta.30, no bump; WORKING TREE ONLY → beta.31 candidates; ⚠ NO TARBALL CUT — Ken deferred)
Ken reported (again) being "logged out" after refreshing while logged in via keyfile + password + "Remember me". Root-caused: NOT a bug. Traced the entire path in code (NEVER ASSUME): the keyfile + Remember-me persist runs correctly (onboarding/import/+page.svelte finalizeImportChoice → passwordAlreadyChosen branch → writeEnvelope(pendingEnvelope) + writeKeystoreMode('password')); pagehide calls a bare reset() which (cp334) leaves disk intact — the encrypted keystore SURVIVES a refresh; clearUserBlurtAccount() only fires on an explicit sign-out (and Ken's @kentest3 name survived → he did NOT sign out); there is NO sessionStorage session-survival in stores/crypto, by design (decrypted keys never persist across a reload — the security posture); and a keyfile envelope that imported successfully passes the same readEnvelope validation on the next load (validateSimpleEnvelope/validateLayeredEnvelope, the same validators blobToEnvelope already ran). Confirmed with Ken: after refresh /login shows "Welcome back — unlock with your password," not import-needed. So the keystore is intact; the "logged-out" FEELING was (a) the header CTA reading "Start" (looks account-less while the live in-memory session is gone) + (b) being left on /settings. Flagged that a true stay-logged-in-across-refresh would require persisting decrypted keys/password where a reload (and thus XSS) can read them — against priority #1; Ken chose to keep security strong and fix the UX only.
cp340 — three UX changes. (1) Header CTA Start→Unlock when a keystore is remembered: AvatarMenu.svelte signedOutCtaLabel = $derived(!$hasAnySession && hasPersistedKeystore() ? $_('nav.unlock') : $_('nav.start')); the signed-out button renders {signedOutCtaLabel}; fresh devices (no keystore) still say "Start"; hasAnySession = state==='unlocked'||'paired-readonly', so it correctly goes false after a refresh and flips the label. New nav.unlock ×10 locales (reused login.welcome_back.unlock). (2) Refresh while locked on /settings → homepage: settings/+page.svelte onMount (ONCE — not $effect, so a later idle auto-lock while actively on the page doesn't yank the user away) with a 250ms grace (lets a multi-tab cross-tab session handoff restore first) then if (!get(isUnlocked) && !get(isPairedReadOnly)) gotoLocale('/'); paired-readonly keeps read access. The header "Unlock" CTA then routes to welcome-back — exactly Ken's requested flow (refresh → home, click Unlock → welcome-back). (3) Welcome-back escape-hatch buttons given icons (originally a 🔐 emoji on the seed button + the inline QR svg on the phone button).
cp341 — icon polish (supersedes cp340 item-3's emoji choice). The "sign in with your keys" buttons carried a 🔐 emoji (colourful glyph) while the "use phone instead" buttons carry a monochrome QR <svg> — in the same row they don't actually look alike, which was Ken's real point ("so those buttons look more like the ones on the login page"). Replaced the emoji with a matching monochrome inline closed-padlock lock <svg> (Heroicons lock-closed solid, viewBox="0 0 24 24", fill=currentColor, aria-hidden, h-5 w-5 flex-none — identical wrapper attrs to the QR svg) on BOTH the welcome-back seed button (use_seed_instead) AND the import-needed import button (login.import_existing, the cross-page "login page" reference Ken named — kept consistent so the lock glyph doesn't diverge by state). Stripped the 🔐 prefix from login.welcome_back.use_seed_instead AND login.import_existing in all 10 locales (verified none remain). Net: every "sign in with your keys" button renders the lock svg; every "use phone instead" button renders the QR svg — consistent within each state and across both. No security surface touched (pure icon/markup + label de-emoji). Also backfilled the cp340 REVISIT-LIST + AUDIT entries (cp340's TARBALL note had claimed doc edits that weren't actually present — fixed).
SMOKE: apps/web/scripts/locked-session-ux-smoke.ts (12, registered, run-smokes.sh 374→375). cp341 rewrote section 3: asserts use_seed_instead + import_existing no longer contain \u{1F510}, the lock-svg path renders on BOTH key buttons (≥2 matches), the QR viewBox renders on BOTH phone buttons (≥2); header comment + title updated. VERIFICATION (all GREEN): svelte-check 0/0; locked-session-ux-smoke 12/12; i18n-locale-parity 10/10 @ 3204 keys (label de-emoji only — no key add/remove), i18n-translation-completeness 4/4, i18n-key-coverage 2238, no hardcoded English; no OTHER smoke references the removed emoji/labels; FULL smoke battery = 8466 scenarios across ALL 375 registered smokes, 0 runners failed (chunks 3278 + 1333 + 1304 + 1860 + 691). NOT run in-sandbox (standing limits): indexer native build + web vite build → CI; the rendered button alignment/visual is a human eyeball after deploy. ⚠ NO TARBALL CUT — Ken deferred; WORKING-TREE ONLY. The pending cut is FULL (cp339 deleted ensUrl.ts) and bundles cp339 + cp340 + cp341.
Session (cp339, 2026-06-25) — ten-item user-reported UI/UX batch + ENS-gateway revert (post-beta.30, no bump; WORKING TREE ONLY → beta.31 candidates; ⚠ NO TARBALL CUT — Ken deferred)
Ken's batch of UI fixes + one behaviour rework; all apps/web + locales + handoff docs. (1) Signup intro gated to step 1. onboarding.intro ("Your Morphit account is a pair of cryptographic keys…") rendered in the always-on <header> of routes/[lang]/onboarding/+page.svelte (line 482), so it showed on every step. Verified the step model in code (NEVER ASSUME): signupStep = $derived(stage === 'review' ? 2 : stage === 'confirm' ? 3 : 1), and stages choose/generating/done all map to step 1. Wrapped the intro <p> in {#if signupStep === 1 && stage !== 'done'} → shows only on step 1 (path-choice), hidden on review (2)/confirm (3)/the done screen; register-name (step 4) never rendered it. No locale change (the string is unchanged, just conditionally rendered). (2)+(3)+(7) Arrows unified site-wide with the homepage "Learn more" hover. Ken loved the homepage card hover (arrow slides in its pointing direction + turns emerald) and asked for it on EVERY arrow, no underline. The gold standard was PrioritiesSection.svelte's .priorities-card-cta-arrow (transition: transform 180ms; on .priorities-card:hover → translateX(3px) + CTA text → rgb(0 218 105); text-decoration:none). Generalized into ONE global affordance in app.css: .nav-arrow (display:inline-block, transition transform+color) + .nav-arrow-right/-left; on hover/focus-visible of the enclosing :where(a,button,[role=link],[role=button]) the glyph slides translateX(±3px) and turns var(--morphit-emerald); :where(a):has(.nav-arrow){text-decoration:none} removes underlines; [dir=rtl] .nav-arrow{transform:scaleX(-1)} mirrors the glyph and the RTL hover rules restate scaleX(-1) translateX(±3px) to compose; prefers-reduced-motion drops the slide (keeps colour + RTL mirror). Migrated EVERY arrow: the ~18 cp335 ⇦/⇨ glyph sites via perl (raw-byte mode — -CSD decoded the file but left the pattern glyphs as bytes, so the first pass matched only the ASCII-only chat substitution; re-ran without -CSD) swapping rtl:inline-block rtl:-scale-x-100 → nav-arrow nav-arrow-{dir} (RTL now in CSS, not per-element Tailwind), incl. the onboarding path-card variant, the privacy always-emerald arrow, and the chat row arrow (kept its ml-auto text-xs text-ink-* layout classes); the homepage 7 cards' bespoke inline SVG → <span class="nav-arrow nav-arrow-right">⇨</span> (removed the dead .priorities-card-cta-arrow size/hover-slide rules + the reduced-motion selector; kept the CTA-text colour shift); the onboarding "I already have keys" tiny → → glyph + removed its hover:underline; Tooltip's learn_more → → glyph. Verified 0 rtl:-scale-x-100 left in markup. (4) Update snackbar (UpdateBanner.svelte) reworked — fixes "broke on PC" + makes reload strictly user-consent. Two root causes in code: (a) the $effect registered a controllerchange listener that location.reload()ed whenever the SW became controller — an auto-refresh behind the user's back; (b) the applying flag (gates the snackbar via !applying) was PERSISTED in sessionStorage (APPLYING_KEY) and restored on mount, so on PC — where SW activation lags (a proxy can serve /service-worker.js stale) — a "Load it now" click that reloaded WITHOUT the update fully landing left applying stuck true, suppressing the snackbar for minutes (the exact symptom). The SW (service-worker.ts) deliberately never skipWaiting()s on its own — it waits for the APPLY_UPDATE user-consent message — so the design already wanted user-driven updates; the page-side auto-reload was the rogue piece. FIX (script rewrite, 333 → 239 lines): removed the controllerchange listener + armActivation/armedWorker/refreshing + the clearApplying/APPLYING_KEY persistence; applying is now IN-MEMORY only (a reload resets it → can never wedge; if the update didn't land the snackbar correctly reappears). applyUpdate() is the SINGLE reload site: applying=true (hide instantly) + waitingWorker?.postMessage({type:'APPLY_UPDATE'}) (skipWaiting) + setTimeout(()=>location.reload(),250) — navigations are network-first so the fresh shell loads regardless of SW state. dismiss() ("Later") only closes the snackbar (no reload) and is now VERSION-AWARE: stores the deployed version (or a __sw__ sentinel); a derived dismissedForCurrent keeps it hidden only while that version is still deployed, so an even newer deploy re-shows it. Kept check(), pollDeployedVersion() (verify.json, the desktop-reliable path), the 60s/5min timers + visibility/online re-checks. Answer to Ken's explicit question ("how much later until it comes back after Later?"): session-scoped, NOT timer-based — hidden until the tab is closed & reopened, or immediately re-shown if an even newer version deploys. (5) Footer alt-network pills only show CONFIGURED networks. In routes/[lang]/+layout.svelte, tor/lokinet/i2p_b32/nostr used <li>{#if X}<a/>{:else}<span class="chip cursor-not-allowed opacity-50" aria-disabled>…</span>{/if}</li> — a greyed disabled placeholder when unconfigured (Ken has no lokinet/nostr but the pills showed); i2p_name/ens were already pure {#if}-gated. Converted all four to pure {#if $instance.alt_networks.X}<li>…</li>{/if} via a Python regex matching ONLY pills carrying an {:else} placeholder; footer.alt_network_disabled no longer rendered. (6) ENS pill → bare morphit.eth, gateway removed. The pill (footer + /instances) linked to https://{ens}.eth.limo. cp339 FIRST fixed a real bug — the ENS validator requires a .eth suffix, so morphit.eth produced morphit.eth.eth.limo — via a new ensEthLimoUrl() helper + 5-case vitest. Ken then pushed back: he registered morphit.eth, not anything .limo, and routing through the centralized eth.limo gateway cuts against Morphit's no-SPOF / privacy ethos (the FAQ says reach it "through an ENS-aware browser or gateway" — the user's choice, not hardcoded). REVERTED the gateway: deleted ensUrl.ts + ensUrl.test.ts; both pills now link to the bare href="https://{ens}" (= https://morphit.eth), which ENS-aware browsers (Brave/MetaMask) resolve directly. Honest tradeoff flagged: a bare .eth link does NOT resolve in a vanilla non-ENS browser — a deliberate decentralization-over-reach choice. Swept .limo from docs/OPERATIONS.md + docs/RUN-A-MORPHIT-NODE.md + RELEASE-NOTES-v1.0.0-beta.30.md (left historical cp334/cp336 audit mentions as record). (8) footer.i2p_b32 "I2P (.b32.i2p)" → "B32 I2P" ×10 locales (icon kept; already identical across locales + allow-listed — updated the allow-list reason). SMOKES (HIGH regressions — WIRED + REGISTERED + run e2e): NEW footer-alt-network-pills-gated-smoke (14), update-banner-user-consent-smoke (8), nav-arrow-consistency-smoke (9); scripts/run-smokes.sh 371 → 374. UPDATED service-worker-single-registration-smoke #10/#12/#13 — they pinned the OLD design (reloads-on-controllerchange / refreshing guarding ≥2 reload sites / APPLYING_KEY persist+restore+clear + reload-on-'activated'); rewrote them to the NEW invariants (no controllerchange auto-reload + reload only inside applyUpdate; exactly one reload site; applying in-memory only). VERIFICATION (all GREEN): svelte-check (apps/web) 0/0; web vitest 741 / 5-skip (the ensUrl test was added then removed with the helper revert → baseline); i18n-locale-parity 10/10, i18n-translation-completeness 4/4; the FULL smoke battery via the smoke-tsconfig chunk runner = ~8458 scenarios across ALL 374 registered smokes, 0 runners failed (chunks 3179 + 2588 + 2691). NOT run in-sandbox (standing limits): indexer better-sqlite3 native build (matrix-bot only, 0 tests) + web vite build → Forgejo CI. ⚠ NO TARBALL CUT — Ken said "no tarball until I say so"; WORKING-TREE ONLY, and a FULL tarball (1 file deleted) is PENDING to persist it across sessions.
Session (cp338, 2026-06-25) — 2 user-reported bug fixes + ENS icon swap (post-beta.30, no bump; WORKING TREE ONLY → beta.31 candidates)
Three localized apps/web changes from Ken's reports, all verified; no new user-facing text → no locale work. (1) Seed-import "Unlock my account" button gated on exactly 12 words. The seed-mode submitDisabled branch was !seed.trim(), so any non-empty text enabled the button — a single pasted garbage token (Ken's screenshot, agrrtwreterwt…) lit it. Verified Ken's recollection in code (NEVER ASSUME): normalizeSeedPhrase() does comma→space + collapse-whitespace + trim + lowercase, applied on blur. Added dependency-free seedWordCount(raw): number to apps/web/src/lib/crypto/seedNormalize.ts (counts on the normalized form so comma-separated input counts even before the on-blur tidy; '' → 0; kept dependency-free so the gate doesn't drag the heavy bip39/secp256k1 graph from keygen.ts into the import route's first-paint work) and changed the gate to seedWordCount(seed) !== 12. Deliberately STRUCTURAL (exactly 12 words), NOT validateMnemonic() (full BIP-39 + checksum) — matching the field contract and the submit path's "Seed must be 12 words" throw, while leaving checksum validity as the submit-time error so a one-word transcription typo surfaces a clear "invalid seed phrase" message rather than a silently-disabled button with 12 words visibly typed. Extended seed-normalize-smoke 9 → 17 (incl. the exact regression: single token → 1 not 12; 11/13 → disabled; comma-no-space → 12). (2) Footer AGPL-3.0 link → FAQ why_agpl now smooth-scrolls to the expanded article. The footer AGPL-3.0 link targets /faq?q=why_agpl&lang=… (query param, not hash; why_agpl IS a real FaqKey at faqIndex.ts:178; the accordion <li id="faq-{key}"> is always in the DOM at FaqSearch.svelte:418). Root cause: the deep-link handler (a $effect) did expanded.add() then scrolled inside a queueMicrotask — but the footer link is a cross-page NAVIGATION, and SvelteKit resets scroll to the top AFTER that microtask runs, so the top won (the related-entry + search-dropdown scrolls worked only because they fire on the same already-loaded page, no nav reset). Secondary: the $effect re-fired on locale change (would re-yank an open entry). FIX in apps/web/src/lib/components/FaqSearch.svelte: converted the deep-link $effect → afterNavigate (runs AFTER SvelteKit's post-navigation scroll handling; does not re-fire on a pure locale switch) and added a shared scrollToEntry(key) helper — await tick() (so the expanded answer body has rendered and the target sits at its final height) + double requestAnimationFrame (layout settles + clears the nav scroll reset) → scrollIntoView({behavior:'smooth',block:'start'}). Routed goToRelated and the search-result-dropdown onclick through the same helper (removed their queueMicrotask scrolls — net effect on faq-scroll-block-start-smoke: three scrollIntoView calls → one, so its scenario count drops 6 → 4, which it tolerates since it asserts one check per call). Imported afterNavigate ($app/navigation) + tick (svelte); browser import retained (used at 4 other sites); toggle() still uses history.replaceState (no $page update) so manual expand does NOT trigger afterNavigate. (3) ENS icon swap (Ken's new SVG). Replaced apps/web/static/icons/icon-ens.svg with Ken's mark — the clean 4-path ENS "eternal" logo; the old file was the same mark PLUS embedded "ENS" wordmark letters (8 paths). Both pure #ffffff on a 450×450 viewBox → still consistent with the white sibling alt-net icons (icon-i2p/tor/lokinet/nostr); stripped the XML prolog + <!DOCTYPE> to match the siblings (none carry a DOCTYPE), artwork byte-identical to the upload (1205 B, was 2764). Wiring intact — AltNetworkIcon.svelte:52 templates /icons/icon-${network}.svg so ens → /icons/icon-ens.svg (filename unchanged; the literal "icon-ens" doesn't appear in code, which is why a code grep is empty). NOT bundled in the mediakit (build-mediakit.sh doesn't copy the icons dir) and NOT referenced in llms-full.txt (0 refs) → no regeneration. Valid XML + color-contrast 5/5. VERIFICATION (all GREEN, post-beta.30 tree, no bump): svelte-check (apps/web) 0/0; web vitest 741 passing / 5 skipped (no regression); the FULL smoke battery via the smoke-tsconfig chunk runner = 8423 scenarios across ALL 371 registered smokes, 0 runners failed (chunks 2436 + 1667 + 2342 + 1978) — the count moved 8417 → 8423 exactly as the edits predict (+8 seed-normalize, −2 faq-scroll-block-start); no smoke files added/removed → registration-integrity 4/4 (371); version-consistency 19/19 @ beta.30 (no bump). NOT run in-sandbox (standing limits): indexer better-sqlite3 native build (matrix-bot only, 0 tests) + web vite build → Forgejo CI. FULL tarball morphit-cp338-beta30-FULL-STATE.tar.gz cut (DELTA-eligible — nothing moved/deleted — but FULL per handoff-safety convention). Backlog flagged: ops/systemd/morphit-relay-mint-acts.service/.timer are stale (ExecStart runs scripts/mint-acts.ts, removed cp329); prune the inert templates.
Session (cp337, 2026-06-24) — beta.30 RELEASE CUT (beta.29 → beta.30; Ken said go)
Cut the v1.0.0-beta.30 release, bundling the entire post-beta.29 working tree (cp333 settings/Short-Bio + cp334 login-race-fix/2FA-polish/SignupProgress/ENS + cp335 arrows/RTL/breadcrumbs/uv_cwd-fix + cp336 the fresh-review + 3 stale-comment fixes). Bumped all 19 version touchpoints beta.29 → beta.30 via surgical per-line edits (each file verified to hold exactly ONE 1.0.0-beta.29 string first, so a per-file sed of the version literal is surgical, no reformat): 14 package.json (root + the 13 workspaces, discovered dynamically by version-consistency from the root workspaces array), apps/relay/src/api/health.ts VERSION, apps/indexer/src/api/health.ts INDEXER_VERSION, apps/mcp-server/src/main.ts MCP_VERSION, docs/API.md (health example), apps/indexer/README.md (health example). Synced package-lock.json (npm install --package-lock-only --ignore-scripts; 15 beta.29 → 15 beta.30 entries; npm audit fix/--force NOT run — permanently banned; the 23 advisories are the documented dev-only vitest-UI + matrix-bot-sdk transitives with no prod exposure). Wrote RELEASE-NOTES-v1.0.0-beta.30.md — user-facing plain-language prose matching the beta.29 format, organized by Staying-signed-in / Profile-and-settings / Signing-up / Getting-around / Two-factor / For-operators / Under-the-hood; deliberately NO asset-count claims so release-notes-asset-count-parity stays 3/3; headline item is the cp334 sign-in reliability fix (a normal refresh no longer drops a "Remember Me" user to the import screen), confirmed in-tree before writing. Verified no test pins the literal version string (so vitest is unaffected by the bump) + no generated asset (llms-full.txt, llms.txt, mediakit) embeds the version. No code change beyond the 3 version-constant strings + the new RELEASE-NOTES — every functional change was already in the tree at cp336. FULL VERIFICATION @ beta.30 (all GREEN): version-consistency 19/19 @ beta.30 + RELEASE-NOTES present; release-notes-asset-count-parity 3/3; mediakit-freshness 7/7; llms-full-freshness 6/6; svelte-check 0 errors / 0 warnings / 698 files; typecheck-sweep 14/14 (0 errors) (indexer src+test, relay src+test, ops-cli, matrix-bot, mcp-server + the 7 packages); i18n-locale-parity 10/10, translation-completeness 4/4, key-coverage 2/2, html-injection 1/1; smoke-registration-integrity 4/4 (371 entries / 364 files); the FULL smoke battery via the smoke-tsconfig chunk runner = 8417 scenarios across ALL 371 registered smokes, 0 runners failed (chunks 2436 + 1667 + 2334 + 1980); vitest 1480 passing / 0 failing (web 741/5-skip, indexer 489/1-skip, relay 250/0). NOT run in-sandbox (explicit, standing limits): indexer better-sqlite3 native build (needed only by matrix-bot, which has 0 tests) + web vite build → Forgejo CI on push. FULL tarball morphit-cp337-beta30-FULL-STATE.tar.gz cut (a doc FILE — the release notes — was added, so FULL not delta). This is a BETA → Forgejo only: the beta Basic-Auth gate STAYS up, nothing is mirrored to Codeberg/IPFS, no morphit_release_v1 is broadcast — those are the separate stable-public-release ceremony. Handed Ken the single-paste git block (add / commit / signed tag v1.0.0-beta.30 / push main + tag) plus the verified VPS sudo morphit-ops upgrade flow for the live deployment.
Session (cp336, 2026-06-24) — fresh-session deep re-verification of the cp335 tarball + 3 stale-comment fixes (post-beta.29, no bump; WORKING TREE ONLY → folded into the beta.30 release, cp337 below)
Ken's standard "deeply review the tarball, recommend where to go, fix what should be fixed" ask, from a CLEAN session (no trust in the inbound handoff numbers). (A) Independent full-gate re-verification — ALL GREEN, and STRONGER than the cp335 handoff claimed. npm install --ignore-scripts → 684 pkgs (23 advisories = the documented dev-only vitest-UI + matrix-bot-sdk transitives, NO prod exposure; audit fix BANNED). svelte-check (apps/web) 0 errors / 0 warnings / 698 files; typecheck-sweep all 14 targets 0 errors; vitest 1480 passing / 0 failing (indexer 489 / relay 250 / web 741 — better-sqlite3 not built but only matrix-bot needs it and it has 0 tests; indexer/relay run on pg + fakes); the FULL smoke battery via the smoke-tsconfig runner (tsconfig.smoke.json, which resolves the $lib/$config path aliases the bare runner cannot) = 8417 scenarios across ALL 371 registered smokes, 0 runners failed — strictly better than the cp335 handoff's "≈327 pass / 3 env-only", because those 3 indexer non-passes were only the bare-runner alias gap, not real defects. Gates: version-consistency 19/19 @ beta.29; i18n-locale-parity 10/10; translation-completeness 4/4; key-coverage 2/2; html-injection 1/1; mediakit-freshness 7/7; llms-full-freshness 6/6; smoke-registration-integrity 4/4 (364 files). (B) Black-hat audit of the freshest cp333/334/335 code — all substantive work SOUND. ENS .eth: validator ENS_RE = safe strict-lowercase-ASCII label(.label)*\.eth; indexer zod MORPHIT_INSTANCE_ENS_NAME mirrors the TOR/LOKINET/I2P siblings; footer + /instances pills build https://{ens}.eth.limo via a Svelte-escaped attribute + rel="noopener noreferrer" (no new vector beyond the existing operator-self-declared alt-net pills); the MORPHIT_INSTANCE_ENS_NAME allowlist entry is genuinely present at packages/operator-config/src/index.ts:325 → the claimed boot-error launch-blocker IS fixed. cp334 CRITICAL login-race fix (identity.ts reset(opts?)): in-memory wipe always, disk-clear ONLY on explicit {clearDisk:true}; all callers verified (pagehide→bare; cross-tab storage-mirror→bare; broadcastSignOut + cross-tab 'signout'→clearDisk:true); regression test pins both contracts incl. the refresh-race — sound. cp335 uv_cwd fix (repoRoot.ts safeCwd()): all 6 sites route through safeCwd() ?? defaultRepoRoot(), render.ts passes an explicit base — comprehensive. cp335 RTL arrows: all 18 markup ⇦/⇨ carry rtl:inline-block rtl:-scale-x-100; no ← residue outside ToastRegion. cp333 Short Bio: rendered via escaped {shortBio} (NOT @html) → XSS-safe even with hostile chain content. HMAC-secrets schema secure-by-default. No functional bug found. (C) The one finding — 3 stale comments from the cp334 reset()-contract change (LOW, comment-only → no smoke). cp334 made disk-clear opt-in but left 3 cross-reference comments describing the OLD unconditional-disk-clear behaviour, which contradicted the new contract and obscured the exact refresh-logout bug cp334 fixed. Verified in code (NEVER ASSUME): the idle auto-lock calls lockSession() (NOT bare reset()), and lockSession() on a paired-readonly session DELIBERATELY calls clearPairedSession() (correct — a QR-pair carries no password, so a meaningful lock must drop the marker rather than silently auto-restore it). Behaviour is correct; only docs drifted. 🟢 Fixed: (1a/1b) identity.ts reset() doc paragraph + body bullet — removed the false "idle auto-lock" attribution, clarified idle-lock is a separate lockSession() path; (2) identity.ts storage-event mirror comment (~line 550) — "tries to clear the persisted envelope (already gone — clearKeystore is idempotent)" → a bare reset() never touches disk, which is exactly right here since the other tab already removed the envelope; (3) apps/web/src/lib/crypto/pairedSession.ts lifecycle header (~line 28) — "cleared … by any reset() call" → cleared by signOut-from-paired / keystore-unlock-switch / lockSession() on a paired session / explicit reset({clearDisk:true}); a BARE reset() (pagehide / cross-tab mirror) PRESERVES the marker for auto-restore. profile.ts:111's reset() comment is about the account-name cache → accurate, left alone. 5 personas traced (Bob / Sally-user / Sally-operator / Josie / Charlie) → all reach the feedback path (/my/orders → PendingFeedbackReminderBanner → LeaveFeedbackForm → morphit_feedback_v1 → indexer → profile → feedbackResponse_v1); no recursive bug-finding. Post-edit re-verify: svelte-check 0/0; targeted vitest on the three identity/paired suites (identityPaired.test.ts + identity.test.ts + pairedSession.test.ts) = 46 passed / 5 skipped (the contract the edited comments describe is pinned green); comment block-delimiters balanced (identity.ts 22/22, pairedSession.ts 9/9). Recommendation to Ken — the real deliverable: the tree is in excellent release-ready-for-beta.30 shape; every substantive next step is HARDWARE or CEREMONY, not code — (1) a YubiKey bench session to fix the 5 transport.ts WebHID framing defects with a physical device (the single remaining pre-stable human gate); (2) the stable-public-release ceremony (build → SRI manifest → morphit_release_v1 on-chain broadcast → remove the beta Basic-Auth gate → mirror Codeberg + IPFS); (3) set the two launch-blocking relay HMAC secrets; (4) a locale-QA eyeball of the 9 non-English why_agpl translations + the ENS FAQ bullet. The cp336 comment-only fixes were folded directly into the beta.30 release cut (cp337) — no separate cp336 tarball was cut.
Session (cp335, 2026-06-24) — UI arrows + breadcrumb/back-link standardization + morphit-ops uv_cwd crash fix (post-beta.29, no bump; WORKING TREE ONLY)
Ken's 4-item batch, no version bump; captured in the morphit-cp335-beta29-FULL-STATE.tar.gz handoff tarball (not released). (1) Bigger arrows site-wide: swapped thin →/← for the larger ⇦/⇨ (U+21E6/U+21E8) — text glyphs, so they inherit the adjacent text's colour + size with zero extra CSS (satisfies "same colour/height"). All back-nav ←→⇦ in 9 route files (13 sites; ToastRegion swipe ← left), trailing link-label →→⇨ across all 10 locales (value-based — only strings ending in →), and forward-nav →→⇨ in 4 components (settings TOTP CTA, chat row affordance, onboarding path CTA hints ×2, privacy asset-link). Prose-internal flow arrows ("Settings → Session", "first trade → 10 Blurt") deliberately left as-is (mid-sentence ⇨ hurts readability) — flagged to Ken. (3) Breadcrumb / back-link standardization: top-of-page breadcrumb back-links were in inconsistent "weird colours" (explorer grey text-ink-500, privacy always-green, 2FA blue var(--accent,#4a9eff)) → all text-white hover:text-morphit-emerald; 2FA scoped .back → color:#fff + :hover{color:var(--morphit-emerald)}. App is dark-only (<html class="dark">, bg-ink-950 text-ink-100) so plain white is correct/visible. Bottom de-emphasized cancel/back: new BusyButton link variant (grey text-ink-300 + emerald-text hover, no button chrome); the 5 ghost back/cancel controls converted to it + larger ⇦; the 2 prominent secondary recovery buttons (post/edit back_to_orderbook) left as outlined buttons. (2) "Sign in with YubiKey" — FINDING, no code change (honest pushback): YubiKey login is ALREADY built/wired on the login welcome-back (unlock) state (handleUnlockYubikey()→requestYubikey(slot)→bootFromEnvelopeWithYubikey, both as the sole path for a YubiKey-only keystore and as a secondary option beside the password form, already using /icons/icon-yubikey.svg). It is an UNLOCK factor for the local encrypted keystore (YubiKey HMAC-SHA1 challenge-response + the enroll-time passphrase), NOT a portable/fresh-device credential — so it cannot sit next to the QR "use my phone" button, which lives on the separate import-needed (fresh-device) state with no local keystore to unwrap. NO button added next to QR; QR label NOT shortened (that shortening was explicitly conditional on adding the button). Caveat unchanged: the 5 WebHID transport.ts framing defects (the remaining pre-stable hardware-gate) mean it won't function against real hardware until fixed with a device; the enroll-verify gate currently blocks enrolling one. (4) 🔴 morphit-ops ✗ ENOENT … uv_cwd on menu choices 3/4/etc — FIXED + proven: process.cwd() throws uv_cwd ENOENT when the shell's cwd was removed out from under the process (classic post-upgrade install-dir rename). The interactive menu RENDERS without cwd, but dispatching edit(3)/alt-address(4)/status/etc → defaultRepoRoot() → process.cwd() → crash. Fix (apps/ops-cli/src/lib/repoRoot.ts): new exported safeCwd() (try/catch→null); defaultRepoRoot() skips the cwd-walk on null cwd and falls through to module-relative resolution (fileURLToPath(import.meta.url), always inside the install tree); cwdStrandedInUpgradeBackup() returns false on null cwd. The 5 other direct process.cwd() sites (doctor/ssl/install/editActiveKey/init render resolveOutputPath) hardened to safeCwd() ?? defaultRepoRoot() (identical when cwd valid). PROVEN via a deleted-cwd runtime repro: safeCwd()=null, defaultRepoRoot()=<repo root> (no throw), cwdStranded()=false. NOT introduced by the ENS work — root cause is the operator's shell sitting in a removed dir. Gates (green @ beta.29): ops-cli tsc clean; deleted-cwd repro passes; svelte-check 0/0; i18n-locale-parity 10/10, i18n-translation-completeness 4/4, i18n-key-coverage 2/2, i18n-html-injection 1/1; all 10 locale JSONs valid; ← now only in ToastRegion swipe CSS. Not in-sandbox: indexer vitest + web vite build → CI.
cp335 DEEP-DEEP (full smoke battery + targeted audit + 5 personas). Ran the ENTIRE smoke battery (all 330 scripts) in-sandbox: ops-cli 46/46 (incl. repo-root-bak-recovery, doctor, ssl, install-invariants, edit-active-key — the cwd-hardened files), web 144/144, packages 26/26 (asset-registry 22, operator-config 1 incl. ENS allowlist, net-defense 1, relay-client 1, rpc-pool 1), relay 11/11, matrix-bot 11/11 (incl. cp334 ENS API-shape fixture), mcp-server 5/5, indexer 84/87 (the 3 are $lib-alias resolution under bare tsx — CI-only, not regressions). Total ≈327 pass / 3 env-only. The deep-deep CAUGHT + FIXED 3 real regressions left by the prior (cp334) session, each traced to legitimate cp334 code that the last session didn't re-verify: (i) llms-full-freshness — en.json changed (ENS FAQ bullet + trailing arrows) so the generated apps/web/static/llms-full.txt was stale → regenerated via node scripts/build-llms-full.mjs (139 entries, now 6/6); (ii) 2fa-no-google-recommendation — cp334's non-mutating alphabetical sort made the #each iterate recommendedAppsSorted/notRecommendedAppsSorted (sorted copies) so the smoke's literal each RECOMMENDED_AUTHENTICATOR_APPS regex missed; the code is correct (all apps still rendered, source arrays untouched) → relaxed both assertions to also accept the [...RECOMMENDED_AUTHENTICATOR_APPS].sort(…) full-spread-copy pattern (completeness preserved); (iii) cross-tab-signout-propagation — cp334's critical reset() signature change to reset(opts?: { clearDisk?: boolean }) broke the smoke's empty-parens locator /export\s+function\s+reset\s*\(\s*\)\s*:/; the code is correct → made the locator param-tolerant /…reset\s*\([^)]*\)\s*:/ (the actual safety assertion — no signout broadcast inside reset() — is preserved and passes). Targeted audit: ops-cli has NO bare-relative readFileSync/existsSync that could still throw on a dead cwd (all paths absolute/resolved via defaultRepoRoot()) → the cwd fix is complete. RTL ARROW DIRECTION — FIXED (follow-up to the initial flag): the app activates dir="rtl" for Farsi (app.html + hooks.client.ts, fa = rtl:true). All 18 markup nav arrows (13 back + 5 forward) now carry Tailwind rtl:inline-block rtl:-scale-x-100, which flips the glyph horizontally ONLY under [dir="rtl"] and composes with existing hover-transforms (verified via a standalone tailwindcss compile — generates .rtl\:-scale-x-100:where([dir="rtl"], [dir="rtl"] *) with scaleX(var(--tw-scale-x)), --tw-scale-x:-1; LTR unaffected). The 4 fa-embedded i18n trailing forward arrows were flipped ⇨→⇦ directly (CSS cannot target text inside a translated string; in RTL the trailing ⇦ resolves to the visual-left pointing left = correct forward affordance). Back arrows also gained aria-hidden="true" (decorative — improves the link's accessible name; matches the forward arrows). Re-verified: svelte-check 0/0, web battery 144/144, i18n parity 10/10 + completeness 4/4, a11y 36/36, fa now 4 ⇦/0 ⇨, en still ⇨. The ToastRegion swipe-to-dismiss arrow was also verified and is ALREADY RTL-aware (.toast-arrow:dir(rtl)::after swaps →→←), so it needs no change. 5 PERSONA WALKTHROUGHS traced (Bob multi-login unlock incl. YubiKey factor; Sally-user fresh onboarding incl. signup progress + the link-variant back buttons; Sally-operator first node setup incl. the now-FIXED morphit-ops cwd crash; Josie ongoing morphit-ops incl. edit/alt-address/status/ENS no longer crashing on stale cwd; Charlie MCP — 5 read-only-by-construction tools) — all reach the feedback path /my/orders → PendingFeedbackReminderBanner → LeaveFeedbackForm → lib/blurt/ops/feedback.ts (morphit_feedback_v1) → indexer handlers/feedback.ts → profile → feedbackResponse_v1. No new defects surfaced beyond the 3 fixed above; no recursive bug-finding. Still working-tree only; NO tarball.
Session (cp334, 2026-06-24) — UI/feature batch + ENS .eth alt-DNS (post-beta.29, no bump; WORKING TREE ONLY)
Four items, no version bump, no tarball cut (awaiting Ken's go). (F) 🔴 CRITICAL login/lock-session race in
apps/web/src/lib/stores/identity.ts: reset() did an UNCONDITIONAL fire-and-forget disk-clear
(clearKeystore()+clearPairedSession()) that — because $crypto/persistentKeystore is loaded on every page —
actually ran on a normal REFRESH, wiping a "Remember Me" user to the import screen. Fixed with
reset(opts?: { clearDisk?: boolean }) defaulting to in-memory-only; pagehide + idle-lock + the cross-tab
storage-event mirror call bare reset() (disk survives), only genuine sign-out paths pass { clearDisk: true }.
Regression test added (identity 23 passed / 5 skipped). (E) 2FA settings page polish (discoverable <details>
summaries w/ hover+pointer, gradient h1, alphabetized recommended/not-recommended authenticator lists, link hover)
- " (2FA)" on
settings.totp.heading×10 (WebAuthn copy untouched per Ken). (C) NEWSignupProgress.svelte("Step X of Y",role=progressbar) wired through onboarding steps 1–3 + register-name 4/4 (importers excluded);onboarding.progress.step_label×10. (D) ENS.ethfeature wired end-to-end — a registered, display-only decentralized NAME (modeled on the I2P vanity name, NOT a hidden-service transport; footer/instances pills link tohttps://{name}.eth.limo, no in-app resolution, pragmatic ASCII.ethregex). Touch-points: ops-clialtAddressValidate.ts(ENS_RE/isValidEnsName/validateEnsName/ENS_ENV_KEY) +altAddress.tsmanaged slot (collectEns, menu, dispatch) + init Step-10wantsEnsprompt +render.ts+edit.tskeep-current field; indexer config zod (MORPHIT_INSTANCE_ENS_NAME→instanceEnsName) +/v1/instance+ poller + federationProbe + instancesStreamHelpers;@morphit/indexer-client(both alt_networks blocks); web instance store +AltNetworkIcon(+'ens'→/icons/icon-ens.svg) + footer/instances ENS pills ({#if ens}-gated);footer.ens×10 (dropped the unusedfooter.eth); a per-locale ENS bullet in thehelp_make_unstoppableFAQ ×10. Launch-blocker caught + fixed:MORPHIT_INSTANCE_ENS_NAMEwas MISSING from the@morphit/operator-configALLOWLIST — amorphit.config.envwith it would HARD-ERROR at boot; now allowlisted. Smokes: alt-address-wizard +ENS cases (57/57), api-response-shapeens(76), completeness allowlistsfooter.ens×9, indexerfederationProbeSelfBranding.testmock+assertionens:null. Env/docs: indexer.env.example, API.md, OPERATIONS.md - RUN-A-MORPHIT-NODE.md (alt-address ENS row/bullet, headers, init step-10). ENS icon RESOLVED: supplied
icon-ens.svgis all-white-fill, rendered via<img>like every alt-network icon; the app is dark-only (footerbg-ink-950) so the white icon is visible, and it matches siblingicon-i2p.svg(also pure#fff) — optional aesthetic only. Gates: svelte-check 0/0;tscclean for ops-cli/indexer/indexer-client/matrix-bot; alt-address-wizard 57/57; i18n parity 10/10 @ 3203 + completeness 4/4 + key-coverage 2/2 + hardcoded-english 1/1; api-response-shape 76; edit-smoke 18/18. NOT in-sandbox: indexer vitest + web vite build → CI;indexer-config-boot-smokecan't run under bare tsx ($configpath-alias unresolved — pre-existing, unrelated; indexer tsc covers the new zod field). No release; working tree only.
Session (cp333, 2026-06-24) — settings-screen UI + profile batch (post-beta.29, no bump)
Seven items against the settings screen plus one new profile field. (1) Avatar card moved up to sit directly
under the Blurt-account-name card (above Display name) — splice on the section comment markers. (2) Avatar
explainer trimmed (dropped the on-chain-storage sentence). (3) Display-name explainer trimmed (dropped
"You can change this at any time"). (4) Display-name "not unique" reminder: 💡 prefix on the title, removed the
(BLT7gHu8mn…A9bb) example from the body. (5) New two-line button legend above that reminder, and the two
buttons renamed to "Save locally only" / "Save & broadcast" (legend reuses the button-label keys so they can't
drift). (6) Auto-lock select already applied on change (no submit); added a transient green-check
"Changed to {label}" confirmation that clears on page unmount. (7) NEW optional Short Bio field (≤128 codepoints):
validateShortBio + SHORT_BIO_MAX_LENGTH in $crypto/profile; short_bio on ProfilePayload + buildProfileBody
(json_metadata, WIF-redacted); a Short Bio settings card on the local-save/broadcast model; threaded into all 5
broadcastProfile call sites; rendered on the account profile page under the hero. All copy in 10 locales
(+11 keys each, 6 edits each). Tests: +7 validateShortBio cases (crypto.test), +3 short_bio redaction cases
(ops.redaction.test). Gates: svelte-check 0/0; apps/web vitest 740/5-skip; full non-indexer battery ~6118
scenarios / 0 genuine failures (workspace-typecheck 13/13 + relay vitest 250/0 standalone; the two battery
timeouts are the aggregate meta-smokes at the 70s cap); i18n locale-parity 10/10 + completeness 4/4 + 2fa 9/9;
version-consistency 19/19 @ beta.29; registration-integrity 4/4. NOT in-sandbox: indexer vitest + web vite build
→ CI. No release; beta.30 candidate. FULL tarball morphit-cp333-beta29-FULL-STATE.tar.gz.
Session (cp332, 2026-06-24) — beta.29 RELEASE CUT
Ken said go. Bumped all 19 version touchpoints beta.28→beta.29 (14 package.json + relay/indexer/mcp runtime
constants + docs/API.md + apps/indexer/README.md) via surgical per-line edits; synced package-lock.json
(npm install --package-lock-only --ignore-scripts; 15→beta.29; no audit). Wrote
RELEASE-NOTES-v1.0.0-beta.29.md (user-facing; no asset-count claims). The release bundles cp330 (keystore
hardening + CEK nonce fix + first 2FA round-trip coverage), cp331 (YubiKey transport 5-defect diagnosis +
fail-closed enroll-verify gate), and the post-beta.28 working-tree batch (14-task UI, AGPL FAQ, two-slot i2p,
voting power, RPC list UX, SW cache + snackbar fixes, ops-cli Tor-wipe fix). No code change beyond the bump +
notes. Gates green: version-consistency 19/19 @ beta.29 + RELEASE-NOTES present; release-notes-asset-count-parity
3/3; mediakit-freshness 7/7; llms-full-freshness 6/6; svelte-check 0/0; full non-indexer battery 6118 scenarios /
0 genuine failures (the two timeouts are workspace-typecheck + vitest-must-pass at the 70s runner cap, both green
standalone: workspace-typecheck 13/13, web vitest 730/5-skip, relay vitest 250/0); registration-integrity 4/4
(371/364). Still a BETA → Forgejo only; Basic-Auth gate stays up; nothing mirrored to Codeberg/IPFS; no
morphit_release_v1 broadcast (the stable-public ceremony is separate and still pending). FULL tarball cut
(morphit-cp332-beta29-FULL-STATE.tar.gz).
Session (cp331, 2026-06-24) — YubiKey transport re-diagnosis (FIVE defects, corrects cp330) + fail-closed enroll-verify gate
A fresh DEEP review of the cp330 handoff re-read yubikey/transport.ts against Yubico's yubikey-personalization
C source (ykcore.c yk_write_to_key / yk_read_response_from_key / yk_wait_for_key_status, ykdef.h).
cp330's "two framing bugs" count was incomplete — there are FIVE defects, and cp330 missed the most
dangerous one. SEND: (1) no 70-byte YK_FRAME (challenge [0..63], slot cmd [64], CRC16 of [0..63]
[65..66] LE, filler [67..69]) — raw chunks with a misplaced command byte and NO CRC16 → frame-CRC reject;
(2) wrong per-report seq/flag byte (must be SLOT_WRITE_FLAG 0x80 | seq). READ: (3) RESP_PENDING_FLAG (0x40)
polarity INVERTED — the key SETS 0x40 when data is READY and the host CLEARS it while draining; this code
waits-while-set / reads-when-clear (backwards) → reads status, not the HMAC. This is the one cp330 missed,
and the one most likely to yield challenge-INDEPENDENT output. (4) no response-sequence de-dup; (5) no device
reset (0x8f) after read. The transport is browser-only WebHID — cannot be run or safely rewritten in-sandbox,
so the full five-defect diagnosis + interim-gate rationale is now written into transport.ts (replacing the
misleading "narrow surface" comment) to set up the bench session.
FIX — fail-closed enrollment-verification gate (all in apps/web, ZERO transport code changed). Because
defect (3) most likely produces challenge-INDEPENDENT output, a naive single-tap enroll could silently commit a
wrap around a CONSTANT / zero-entropy response — a "2FA factor" unlockable by a known constant (security
theatre). wrap.ts: extracted a private wrapCekWithResponse build-core; buildYubikeyWrap delegates to it
(unchanged, 1 tap); ADDED verifyYubikeyChallengeResponse(hmacFn) (two DISTINCT challenges must yield DISTINCT
20-byte responses via constant-time sodium.memcmp; equal → throws 'YubiKey verification failed: challenge-independent response') and buildVerifiedYubikeyWrap (verify → build-from-verified, 2 taps).
keystoreYubikey.ts: enrollYubikey calls the verified builder at BOTH enroll sites. yubikeyErrors.ts: new
enroll_verify_failed kind + classifier rule, routed to localized copy (10 locales × both error blocks). A
transport that can't prove real challenge-response simply can't enroll; the passphrase wrap is the escape hatch
so a user can never be locked out. Design: the 2-tap independence check catches the dangerous
constant/zero-entropy case; the residual (inconsistent-but-varying garbage → dead-but-not-hollow factor) is
bounded by the passphrase fallback and deferred to the hardware session.
Verification. NEW yubikey-enroll-verify-smoke 15/15 (correct stub passes + exactly 2 taps + round-trips;
constant / zero / dead / wrong-length all rejected with the right kinds; legacy single-tap buildYubikeyWrap
WOULD have accepted the constant — proving the closed gap; enrollYubikey end-to-end rejects a constant device).
yubikey-error-classifier-smoke 17 → 19. 10 locale files gained enroll_verify_failed in both
settings.hardware_key.error and login.unlock.yubikey.error (genuine translations; locale-parity 10/10,
translation-completeness 4/4). Battery 370 → 371. Gates: full non-indexer battery 4-chunk = 6118 scenarios /
0 genuine failures (only workspace-typecheck + vitest-must-pass timed out at the 70s runner cap — both
confirmed green standalone: workspace-typecheck 13/13, apps/web vitest 730/5-skip, relay vitest 250/0);
svelte-check 0/0; version-consistency 19/19 @ beta.28 (NO bump); registration-integrity 4/4 (371/364).
NOT runnable in-sandbox: indexer vitest + web vite build + vitest-must-pass (runs indexer vitest) —
Forgejo CI on push. Remaining human gate (better set up now): real-hardware enroll → reload → unlock +
fixing the five transport defects with the device in hand.
Session 1 (cont., 2026-06-23) — #11a fixed + a CRITICAL latent keystore bug found & fixed
Phase 1 (continued) — Bob's authenticated trading surfaces + chat/settings: all CLEAN.
orderbook (1436): clean 3-phase machine (loading/ready/error) + retry + handled empty; filters
(side/asset/fiat/region/payment/minTrades/sort) all wired. post (2751): mature 6-phase machine
(editing→reviewing→awaiting_password→broadcasting→success/error) + draft persistence + per-field fee
validation + degraded-broadcast handling; feeMethodChoice matches the frozen enum. my/orders (896):
loading/ready/error + retry + empty/no-account CTAs. chat (529): corrupt-localStorage try/catch,
profile-fetch fallbacks, empty/error/load-error states. settings (2083): every handler has a catch.
Drift check: the fee_method enum shows ONLY the 4 frozen values (blurt/waived_first_buy/btc/xmr)
across the entire stack — no drift, no typos.
#11a (/settings change-password) — FIXED; the prior diagnosis was partly wrong.
The earlier REVISIT note claimed decryptIdentity throws totp_required for a 2FA envelope and that the
re-encrypt "drops 2FA". Reading the code: the TOTP gate is in bootFromEnvelope (unlock), NOT
decryptIdentity; and totpSecret/backup codes ride INSIDE the encrypted identity blob, so re-encrypting
preserves TOTP on either scheme — TOTP-only 2FA survived a password change all along (Ken's planned 2FA
test would have shown "works", masking the real bug). The real bug: a layered-cek envelope (YubiKey
enrolled + passphrase wrap) got re-encrypted via encryptIdentity() → simple-passphrase (no wraps[]),
silently dropping the YubiKey unlock path. FIX: new rewrapLayeredPassphrase(env, oldPw, newPw) in
keystore.ts rotates ONLY the passphrase wrap — recovers the CEK via the old passphrase, rebuilds the
passphrase wrap from the new one, carries the CEK + ciphertext + every yubikey wrap over byte-for-byte;
changePassword branches on scheme === 'layered-cek' to use it. Also bumped MIN_NEW_PASSWORD_LENGTH
8 → 10 to match the keystore floor (encryptIdentity/buildPassphraseWrap throw under 10; an 8–9 char pw
previously surfaced as a confusing generic 'internal'). New regression smoke
change-password-layered-rewrap-smoke (8/8), registered in run-smokes.sh. svelte-check 0/0.
🔴 CRITICAL — entire YubiKey / layered-cek keystore WRITE path was broken (CEK_NONCE_BYTES = 12). FIXED.
Found while building the #11a smoke — the first code in the repo to exercise encryptIdentityToCek /
buildPassphraseWrap at runtime. They call libsodium crypto_secretbox_easy (XSalsa20-Poly1305, 24-byte
nonce) but generated a 12-byte nonce from CEK_NONCE_BYTES (mislabeled in yubikey/protocol.ts as a
ChaCha20-Poly1305-IETF size). libsodium throws "invalid nonce length" → every YubiKey enrollment / layered
write threw at runtime. Unnoticed because that path needs a physical YubiKey and had ZERO automated coverage.
FIX: CEK_NONCE_BYTES 12 → 24 + corrected the comment. No migration concern (the write path always threw,
so no 12-byte envelope exists; readers use the stored nonce length). Verified by the #11a smoke's full
encrypt→decrypt round-trip and the whole crypto/keystore smoke cluster (all green). Follow-up (same
session): added two smokes exercising the full crypto round-trips these 2FA mechanisms previously had ZERO
coverage for — yubikey-enroll-unlock-smoke (7/7: real enrollYubikey→unlockWithYubikey with a
deterministic simulated HMAC-SHA1 device; identity recovered & matched across all four key roles, wrong key
rejected, passphrase still works) and totp-2fa-enroll-verify-smoke (7/7: real
enrollTotp→verifyTotpOrBackup, RFC-6238 codegen, backup-code redemption, and proves TOTP survives a
password change). Both registered. The only leg now untested in-sandbox is the literal physical HID
transport + touch (yubikey/transport.ts); the cryptographic core of both 2FA mechanisms is verified
end-to-end.
Session 1 (2026-06-23) — battery baseline + 3 stale-smoke fixes + smoke type-health survey
Phase 1 (personas) — started. Bob (Blurt multi-login): /login (651 lines) read in full — CLEAN
(4 form modes, every button/field/state wired; typed KeystoreError dispatch, password cleared on every
branch, TOTP session-local rate-limit, YubiKey phase indicators, sign-out-before-switch ConfirmModal,
RTL/dark-safe). /onboarding/import (1205 lines): interactive elements + error surfacing scanned —
CLEAN (3 mode tabs seed/keyfile/posting-only, every error path maps to an i18n key — no user left
hanging; line 392 raw-err is an explicit smoke-ok-raw-local for regex-classification + console.warn,
NOT echoed to UI). Remaining Bob surfaces (authenticated: orderbook/post/my-orders/chat/settings) + the
other 4 personas: TODO.
Cross-cutting anti-pattern sweep (whole tree). Zero real TODO/FIXME (the 4 hits are XXXX-XXXX/
\uXXXX format-string comments). Zero raw err.message/String(err) echoed to UI state in web. The
1366 console.* hits are CLI tools (ops-cli/matrix-bot/indexer terminal output = their UI) + comments;
the 73 web-frontend console.warn/error are bracketed dev diagnostics (not telemetry, not user-data) —
acceptable.
Full smoke battery (367 runners / ~8,364 scenarios) — run in 3 chunks; found + FIXED 3 stale smokes, all from THIS session's earlier work not propagating:
- 🟢
href-xss-smoke— the explorer-link lang-prefix fix added 3href={url ? lp(url) : '#'}bindings the XSS smoke didn't allowlist (block page prevUrl+txUrl, tx page blockUrl). VERIFIED safe (all{@const}from validatedmorphitExplorer{Block,Tx}Url()— block validates finite positive int, tx validates BLURT_TRXID_RE — wrapped inlp()SAFE_BUILDER, '#' fallback; identical profile to the existing allowlisted account-page entry). Added 2 newALLOWLIST_HREF_EXPRentries with full safety rationale. 1/1. - 🟢
init-smoke— the i2p two-slot refactor (AltNetworkResult.i2p→i2pB32+i2pName) updated the WIZARD smoke but left init-smoke on the oldi2pshape (runtime assertion failed: expected legacyMORPHIT_INSTANCE_I2P_ADDRESS). Fixed the fixture + strengthened the scenario to exercise BOTH slots (asserts_I2P_B32_ADDRESS+_I2P_NAME_ADDRESS, and NO legacy_I2P_ADDRESS). Drift-swept ops-cli for other stale.i2p— remaining hits are intentional network-name keys in thealt-addresscommand's lookup maps (with documented legacy_I2P_ADDRESSread-fallback), NOT the dropped field. PLUS fixed 2 more latent stale fields in the same fixture (caught via the typecheck survey below):chatLinkExplorerswas missingdcr/sol/eth/xrp(vsChatLinkExplorersResult);listingFeewas missingdenominationFiat(vsListingFeeResult). 51/51. - 🟢
cross-tab-signout-propagation-smoke— scenario #8 enforced the explicit Sign Out button callsbroadcastSignOut()(not bare reset, for cross-tab propagation) but inspected the SETTINGS page whose button #11b removed. VERIFIED not a real bug:AvatarMenu.confirmSignOut(line 191) correctly callsbroadcastSignOut(), and login's confirmSwitch too — propagation intact, only the site moved settings→avatar. Repointed the smoke (constant/read/scenario #8/header doc) toAvatarMenu.svelte. 8/8. Post-fix chunk results: [1-130] 3270 scenarios / 0 fail; [131-260] 2673 / 0; [261-367] 2421 / 0. Full battery GREEN. All 3 were exactly the "updated/outdated smokes + drift" class.
Phase 2/J — GATE GAP found + analyzed (recommendation, NOT fixed): smokes are never typechecked.
ops-cli tsconfig.json include is ["src/**/*.ts","test/**/*.ts"] (excludes scripts/); web
tsconfig.json include is src/**+tests/** (excludes scripts/); tsconfig.smoke.json is a RUN-only
config for tsx (esbuild, no typecheck — no include/allowImportingTsExtensions/noEmit). So NO gate
typechecks any smoke — which is how init-smoke's 3 stale fields all slipped. Built a temp
tsconfig.smoke-check.json (extends smoke + allowImportingTsExtensions + noEmit + all */scripts/**)
and ran tsc: 253 errors across ~30 smokes — BUT after isolating them, the overwhelming majority are
(a) harness-batch artifacts (cross-file scenarios/failed "cannot redeclare" + .push/.ok/.detail
on Scenario — each smoke runs in ISOLATION under tsx, so invalid when batched into one tsc program),
and (b) discriminated-union access without narrowing (e.g. r.reason on ReleaseValidateResult /
HandlerResult / WifDecodeVerdict / rate-limit unions before checking !r.ok/!r.allowed) — the
property exists on the right branch, so every one of these smokes PASSES at runtime. Spot-confirmed
EligibilityResult's missingLoyaltyBlurt/daysUntilEligible live on the Fail branch (real, not
stale). VERDICT: a smoke-typecheck gate would mean ~253 low-value narrowing edits to passing smokes; not
worth the churn. The runtime battery is the real gate and it catches the drift that matters (init-smoke
went red because its drift caused a runtime failure; these don't). Temp config deleted; tree clean. If
ever desired, the cheap version is per-file tsc (matches tsx isolation, kills the harness-batch false
positives) — logged for a future J pass.
Campaign setup
Created this tracker. Surveyed scope (6 apps + 7 packages; 45 routes, 77 components, 197 web lib .ts, 367 smoke runners, 70 docs). Inventoried 17 indexer handlers + 18 on-chain op types for the Phase-3 hostile-op sweep.
cp357–cp358 focused deep-deep + five-persona walkthrough (beta.33 cut) 🟢
Scope: the surfaces changed this session — the balance card (cp357), the welcome-back sign-in screen + the morphit-ops payment-method menu (cp358).
Five-persona walkthrough — no issues found.
- Bob / Sally-user (web return):
/loginwelcome-back now shows the colorful "Welcome back @username" heading and a right-aligned red Sign out button. Sign out → the avatar menu's exact destructiveConfirmModal→broadcastSignOut()+ home, propagating across tabs. Both welcome-back variants (password-form + yubikey-only) carry the heading and the button. - Sally-operator / Josie (morphit-ops): main-menu #5 now opens the interactive
List/Add/Remove/Back menu instead of printing the empty list and dropping to the
shell. The instance env loads at command entry, so
MORPHIT_RELAY_ACCOUNTis present for the DB/broadcast actions; add/remove reuse the existing validated, confirmed, on-chain paths. - Charlie (MCP read-only): untouched — no web-login, no ops-cli. No break.
Deep-deep findings — clean.
- The login page (
[lang]/login/+page.svelte) is the ONLY welcome-back unlock surface; the other keystore-referencing routes (onboarding/import, 2fa, backup-keys, settings) are not "Welcome back" screens, and the paired-readonly welcome already shows the account viapaired_readonly.welcome_back_heading. No missed sibling. - Sign-out reuse is exact: same red classes + log-out icon +
avatar_menu.sign_outlabel +avatar_menu.sign_out_modal.*modal (confirmed present in all 10 locales), zero new i18n keys. - payment-method has one dispatch site (
main.ts→runPaymentMethod); the docblock, the twomain.tshelp blocks, and OPERATIONS.md were updated to note the interactive form; menu-annotations-smoke pins thepositional:['menu']wiring. - Treasury-address CRUD (Ken's question): no menu item edits the BTC/XMR/BLURT receive
addresses today —
edit.ts(Edit settings, #3) edits the fee AMOUNTS (MORPHIT_INDEXER_BTC_FEE_SATOSHIS/XMR_FEE_PICONERO) only. Address CRUD is the deferred post-beta.33 fee-treasury feature.
Regressions added (tamper-verified): web accountBalance.test.ts (cache-buster
contract), indexer accountBalance.test.ts (no stale-while-revalidate + max-age ≤ 5),
menu-annotations-smoke (payment-method menu wiring).
cp359 — beta.33 CI fix: identity-label-policy (honest miss) 🟢
Both CI runners (release + smoke-suite) failed the cp358 beta.33 push on
identity-label-policy-smoke. The cp358 welcome-back heading rendered the account
as RAW @{lockedAccount} markup (login/+page.svelte), which the policy forbids
outside the IdentityLabel allow-list. The sanctioned pattern for a heading (vs an
identity row) is the one paired_readonly.welcome_back_heading already uses: the
@{account} lives inside the i18n string and is interpolated by svelte-i18n, so it
never appears as raw @{var} markup (the smoke only scans .svelte source, not the
locale JSON). Fixed with a new login.welcome_back.title_named key across all 10
locales; identity-label-policy back to 6/6. This was a genuine deep-deep miss — the
policy has a dedicated smoke and the compliant pattern was visible in the very
paired-readonly heading I'd read; it slipped because the full 387-smoke battery can't
run in-sandbox. Lesson for future welcome-back/handle edits: render handles via
IdentityLabel or an i18n-string placeholder, never raw @{var} markup.
cp360 — grandma-friendly /post overhaul: deep-deep + five-persona walkthrough
Scope: the new-order page (/post) made first-time-trader-friendly + a live
order-summary card, plus two Ken-reported bug fixes. No schema/payload change
(verified: OrderPayload/OrderFormInput already carry side/asset/fiat/min-max/
price-model/payment-methods/terms — orderbook, RSS, API and MCP already consume the
full shape, so this was pure form-UX + copy + bug fixes).
DEEP-DEEP findings (all resolved/clean):
- FiatCurrencySelect consumers. Two:
/post(single mode — changed to inline plain-text display) and/orderbook(multi mode, NOsingleprop — UNCHANGED, keeps removable chips). PaymentFilterSelect/AssetFilterSelect only reference it in comments. No multi-mode regression. - Shared i18n keys.
post_order.form.{fiat_label,amount_min_label,amount_max_label, price_model_hint,terms_label}are consumed by BOTH/postand/post/edit. Re-read/post/editwith the new wording — all render sensibly in the edit context (the bare amount labels are the no-fiat fallback the post page also uses). The orderbook + settings "fiat_label" hits are SEPARATE namespaces (orderbook.filters.fiat_label,settings.preferences.fiat_label) — unaffected by thepost_order.form.fiat_labelvalue change. - Bug 1 (Ken-reported) — draft-restore banner on a pristine first-trade form.
draftHasContentcounted d.side/d.asset, but the first-trade lock force-sets side='buy'/asset='BLURT' on a brand-new account → the snapshot read non-empty with nothing typed → spurious "restored draft" banner. FIX: dropped side/asset from the heuristic (still snapshotted + restored; just one-tap re-selections, not content "worth announcing"). Locked by the new regression smoke. - Bug 2 (Ken-reported) — "Enter a flat price." leaking into market mode.
Investigated: the CURRENT code is already correct.
priceModelErroris a$derivedreturning '' for an empty/zero spread, and each error StatusLine renders INSIDE its ownpriceModelKind === …block, so fixed→market recomputes to '' and the fixed-error StatusLine unmounts. The stale error Ken saw was on deployed beta.32; the current beta.33+ tree clears it. Locked structurally by the new smoke rather than a no-op code change. - Summary card. Uses the route locale (
currentLang, valid BCP-47) forIntl.ListFormatand resolves method names via the SAMEdisplayNamesForMethodsthe picker uses, so the recap matches the picker exactly. Per-locale fragment templates control slot order so the assembled sentence reads naturally per language.
NEW REGRESSION SMOKE: post-form-grandma-regression-smoke (9 source-structural
scenarios, registered, canonical pass line) locks both bug fixes plus the
summary/Oxford-or, numeric sanitisers, dynamic fiat labels, and first-trade
subtitle/heading conditionals.
FIVE-PERSONA WALKTHROUGH: Sally-user (first-trade lock → "Let's trade!" heading, buy/BLURT forced, waiver auto-applied → new waiver hints + benefits ladder, dynamic fiat labels, summary "buy … BLURT … pay with …", Terms/Details/Notes textarea, canReview submit; subtitle hidden) — the target persona, fully exercised. Bob (experienced → subtitle "90 days", normal heading, full picker, summary "sell … accept …"; no waiver hints). Sally-operator / Josie / Charlie unaffected — this turn touched only the web post page; no ops-cli, MCP, indexer, relay, operator-doc or schema change.
NOT run in-sandbox (deferred to deploy/CI): FULL 388-smoke battery + full vitest; web
vite build; a real-browser eyeball of the first-time /post flow + the summary
card in all 10 locales (esp. fa RTL + the two Chinese variants — Claude-authored,
native QA still owed).
cp361 — beta.34 release cut (mechanical)
Pure release cut of the cp360 working tree: all 19 version touchpoints bumped
beta.33 → beta.34 (root + 13 workspace package.json, the relay/indexer/mcp VERSION
constants, docs/API.md, apps/indexer/README.md), package-lock synced (15 → 15;
npm audit fix not run — banned), and RELEASE-NOTES-v1.0.0-beta.34.md written (no
asset-count claims). No code change beyond the bump + RELEASE-NOTES — the cp360
deep-deep + five-persona walkthrough above already cover every functional change in
this release. Verified: version-consistency 19/19 + RELEASE-NOTES present;
lockfile-sync 3/3; asset-count-parity 3/3; svelte-check 0/0; indexer tsc 0; i18n
10/10 @ 3239; post-form-grandma-regression 9/9; maxlength 3/3; price-model-picker
13/13. BETA → Forgejo only; the full 388-smoke battery + vitest + typecheck-sweep
run at CI on push.
cp362 — beta.34 CI fix: a11y-patterns-smoke stale bind:value anchor
The cp361 beta.34 tag push failed BOTH CI runners on a11y-patterns-smoke: 4 of 36
scenarios — "/post amountMin/amountMax/spread/fixed input has aria-invalid". Root
cause: cp360 deliberately switched those four inputs from bind:value={…} to one-way
value={…} + oninput so the decimal sanitiser (keepDecimal/keepSignedDecimal) can
run — a two-way bind would fight the sanitiser. The a11y smoke matched
bind:value=\{X\}[\s\S]{0,300}aria-invalid=…, which no longer matched once the
bind: prefix was gone. The a11y itself was intact the whole time — all four inputs
still carry aria-invalid={!!amountError|priceModelError} + aria-describedby; only
the smoke's binding-syntax anchor was stale. Fixed by anchoring the 4 /post
matchers on value=\{X\} (which also matches bind:value={X}, a superstring); the 2
/post/edit matchers keep bind:value because the edit route was untouched in cp360.
Honest miss: same class as the cp359 identity-label-policy miss — a11y-patterns-smoke
runs only in the full battery (not the in-sandbox subset). The discipline going
forward: when restructuring an input/markup pattern, grep the smoke suite for
anchors on that exact pattern (bind:value, type=, id=, the i18n key) BEFORE cutting a
release. This turn I swept all 12 smokes that read the /post source; only
a11y-patterns anchored on the changed binding, and it is now fixed. beta.34 is
unchanged (no version bump); the beta.34 tag moves to the cp362 commit.
cp363 — sign-out "Unlock"-label fix (reactivity/timing) + BLURT info-text rewrite
Working-tree (post-beta.34, no tarball cut — Ken deferred).
BLURT asset-explainer copy (post_order.form.asset_explainer.blurt) rewritten across
all 10 locales per Ken (new framing emphasising zero-fee transfers, the built-in
rewards system, reputation-building, trade posterity, and connecting with
like-minded people; corrected the "pople" typo to "people"; value-only change, parity
3239 unchanged).
Sign-out "Unlock"-label bug (Ken-reported with screenshot). After an EXPLICIT sign-out
the avatar-menu/header CTA still read "Unlock" — implying a remembered locked account
— but clicking it landed on the FULL sign-in page (the persisted account name was
already cleared by clearUserBlurtAccount), an inconsistency. Root cause is a
reactivity/timing gap: AvatarMenu's signedOutCtaLabel is
$derived(!$hasAnySession && hasPersistedKeystore() ? nav.unlock : nav.start).
hasPersistedKeystore() is a plain localStorage read, not a reactive dependency; the
$derived's only reactive trigger is $hasAnySession, which flips synchronously inside
reset(). But broadcastSignOut cleared the keystore through
reset({clearDisk:true})'s ASYNC dynamic-import path, which resolves a microtask AFTER
that single re-run. AvatarMenu lives in the root layout, so the post-sign-out
navigation home does not remount it to re-read — the label is captured once (keystore
still present → "Unlock") and never refreshed.
Fix: broadcastSignOut clears the keystore + paired marker SYNCHRONOUSLY
(clearKeystore(); clearPairedSession(); reset();) before the $hasAnySession flip, so
hasPersistedKeystore() is already false when the $derived re-runs → the CTA reverts
to "Sign in", consistent with /login then rendering the full sign-in. clearKeystore
was added to the existing $crypto/persistentKeystore import; clearPairedSession was
already imported. No import cycle (persistentKeystore/pairedSession don't import the
identity store — reset used a dynamic import only for static-graph trimming), and the
crypto-blurt + libsodium baseline-closure byte-budget smokes stay 7/7 + 6/6, confirming
the static import didn't enlarge the every-page closure. The reset() clearDisk path is
left intact for its other caller (the paired→keystore upgrade).
Regression: cross-tab-signout-propagation #11 asserts broadcastSignOut clears the keystore synchronously (a revert to the async-only path turns it red). 11/11.
Verified GREEN: svelte-check 0/0; cross-tab-signout 11/11; autolock-settings 8/8; locked-session-ux 13/13; paired-readonly lifecycle 18/18 + affordance-surfaces 13/13; crypto-blurt/libsodium baseline-closure 7/7 + 6/6; i18n parity 10/10 @ 3239 + completeness 4/4 + key-coverage 2/2 + native-floor 11/11 + hardcoded-english 1/1; a11y-patterns 36/36. NOT run in-sandbox: FULL battery + vitest → CI; a real-browser confirm of the CTA flip + the new BLURT copy in all 10 locales.
cp364 — three live-beta.34 bug fixes (Ken's /post screenshot) + investigation notes
Ken logged in (keyfile+password) on the live beta.34 and reported five things. Three addressed here; two stay open.
(1) 🔴 First-trade /post form vanished below the asset card. Step 2 is gated
{#if step1Done} and step1Done needs side!==null && asset!==null. For a first-trade,
side/asset are FORCED to buy/BLURT — but the only forcing mechanism was a post-render
$effect (the lock effect), which lands a flush AFTER the template recomputes
step1Done off the just-resolved isFirstTrade. The selected BLURT chip in the screenshot
is the tell: a SEPARATE waiver $effect sets asset='BLURT' (when asset===null) WITHOUT
side, so asset reads selected while side can stay null → step1Done false → Step 2 +
step nav stay hidden. Static analysis says the lock effect should converge (waiverOffered
=== isFirstTrade are identical expressions; waiverEligibility is a one-shot onMount fetch,
not a reactive churn), and I could NOT reproduce the precise runtime trigger in-sandbox
(no browser). The cp360 walkthrough only REASONED "step1Done auto-satisfies" and never
browser-tested it — recorded as the miss. FIX (deterministic): in onMount's
checkWaiverEligibility(...).then((r) => {...}), right after waiverEligibility = r, force
side='buy'/asset='BLURT'/expiresDays=7 when r.kind is eligible/eligible_unknown_account.
Runs in the SAME tick isFirstTrade flips, so step1Done is consistent within the flush and
the submitted order carries the right shape — not merely the gate. Runs after the
synchronous onMount draft-restore (wins over a null-side restored draft); only fires when
eligible (non-first-trade drafts untouched); lock effect kept as backstop; guards make it
idempotent.
(2) Starter-pack card re-appears on return visits. FirstPostStarterPack persisted its dismissal in sessionStorage (morphit.firstPostStarterPack.dismissedThisSession) and stayed hidden on remount within the session. Ken wants it back on a later /post visit until the first order is placed. FIX: removed the persistence entirely — dismiss() is in-memory only (visible=false); dropped readDismissed/writeDismissed/DISMISSED_KEY + the onMount dismissed-gate. The getOrdersByAccount zero-orders check is now the sole stop-showing signal; the X is a per-VIEW "not now".
(3) Balance-card USD line — SERVER-SIDE, no frontend change. Frontend verified correct: loadPrice() is wired in MyBalanceCard onMount; fetchListingFee returns {kind:'ok',quote} and the card's r.kind==='ok' matches; usdLabel gates only on blurtPriceFiat!==null && Number.isFinite(blurtBalance). The indexer (api/listingFeeBody.ts) echoes blurt_price_fiat ONLY when priceSource!==null && !detail.stale && detail.price>0 — a disabled native feed OR a stale cached price omits the field (used as oracle input only when stale; staleness = age > refreshIntervalMs×2 in compositeSource.ts). Actionable: curl /v1/listing-fee; if blurt_price_fiat absent, check MORPHIT_INDEXER_PRICE_FEED_ENABLED + MORPHIT_INDEXER_PRICE_FEED_NATIVE_ENABLED + the indexer log for a stale-price warning.
(4) OPEN — "snackbar shows up twice." ToastRegion is mounted exactly once ([lang]/+layout.svelte:649; confirmed by service-worker-single-registration 13/13), and /post fires no showToast (no showToast caller matches post/draft/order). So it is neither a double-mounted toast region nor a /post toast. Could not identify which snackbar from the description → asked Ken which page + text. (5) Sign-out CTA — already fixed in cp363; Ken's homepage cold-refresh test (Start replaced Unlock) confirms the diagnosis; cp363 makes the flip happen without a refresh. Acknowledged.
REGRESSION SMOKE: post-form-grandma-regression-smoke 9 → 11. +1 asserts the checkWaiverEligibility(...).then handler force-sets side='buy'/asset='BLURT' on the eligible kinds (first-trade form never gated solely on the post-render lock effect). +1 reads FirstPostStarterPack.svelte and asserts no sessionStorage/localStorage, no readDismissed/writeDismissed/DISMISSED_KEY, dismiss() sets visible=false, and the getOrdersByAccount zero-orders gate remains.
NOT run in-sandbox (deferred to deploy/CI): FULL 388-smoke battery + vitest; web vite build; a real-browser confirm that the first-trade /post form renders Step 2 + step nav (the #1 fix's exact trigger could not be reproduced in-sandbox) and that the starter-pack card re-appears on a return visit.
cp364 UPDATE — real root cause of the vanishing form (Ken's DevTools console)
Ken's console settled #4 definitively. The MaxListenersExceededWarning + ObjectMultiplex
("orphaned data … / malformed chunk") lines are all from contentscript.js:14083 = a browser
WALLET extension (MetaMask-class @metamask/object-multiplex), not Morphit — ignorable. The
real one is Uncaught TypeError: e(...).trim is not a function in the hashed bundle.
So the form was NOT a timing problem (step1Done WAS becoming true — the BLURT chip rendered
selected). The crash is a .trim() on a NON-string from the STALE 3h draft: applyDraft did
fiatArr = d.fiat ? [d.fiat] : [] and fiat = $derived(fiatArr[0] ?? ''), so an
old-/changed-schema draft storing fiat (or an amount) as an array/number/object put a
non-string into fiat. The instant step1Done flipped true, the {#if step1Done && step2Done}
gate evaluated step2Done → fiat.trim() (line ~1234) → uncaught throw aborted the render
flush → everything below Step 1 stayed blank. (Last turn's eligibility-force was real but
addressed a non-problem; kept as harmless defense.)
FIX: hardened applyDraft to type-coerce every restored field — local str() helper for the 9
string fields (fiat/amountMin/amountMax/spreadPercent/fixedPrice/pmDraft/region/terms/
externalTxId/txProof), enum guards for side/asset/priceModelKind/feeMethodChoice,
Array.isArray+filter for paymentMethods, finite clamp (1..90) for expiresDays, ===true for
syndicateToBlog. A well-formed current-schema draft passes through byte-identically; a stale
one degrades gracefully. PLUS a single-read-point backstop fiat = $derived(typeof fiatArr[0] === 'string' ? fiatArr[0] : ''), and a typeof==='string' guard on the getPreferencesSnapshot
fiat/region injection (the other unguarded path). Proven in a standalone node sim: old path
throws exactly fiat.trim is not a function on array/number/object fiat; new path returns '';
valid "MXN" unchanged.
#3 USD line — CONFIRMED server-side: Ken ran curl -s https://morphit.io/v1/listing-fee | grep -o 'blurt_price_fiat…' on the VPS → empty. The indexer is not echoing blurt_price_fiat,
so the native price feed is disabled or stale (listingFeeBody.ts only echoes when
priceSource!==null && !detail.stale && detail.price>0). Frontend correct; Ken to enable/repair
the feed (MORPHIT_INDEXER_PRICE_FEED_ENABLED + …_NATIVE_ENABLED + check the stale-price log).
Regression: post-form-grandma-regression-smoke 9 → 12 (eligibility-force, draft-coercion, starter-pack-no-persist). #1 snackbar: Ken will note which page it doubles on after the next release. Verified GREEN: svelte-check 0/0; grandma 12/12; node sim; cross-tab-signout 11/11; meta-smokes; service-worker-single-registration 13/13.
cp365 — three Ken-reported fixes + deep-deep + five-persona walkthroughs
WORK (all wired + tested):
- Price feed ON by default for all operators.
MORPHIT_INDEXER_PRICE_FEED_ENABLEDdefault flipped false→true in apps/indexer/src/config/index.ts + ops/env/indexer.env.example. Powers the UI's USD echoes (balance card + listing-fee fiat); source is the layered external chain (Klingex → CoinGecko) with a static-floor fallback, server-side, never user-facing; toggleable off for a fully self-contained instance. - Price-feed status on
morphit-ops health(main-menu #13). Added a compact, non- sensitiveprice_feedsummary to the NON-verbose /v1/health body (the price is already public via /v1/listing-fee), rendered as a "Price feed:" line in the Node-health Indexer block (on+live with price/source, on-but-stale, or off). JSON mode picks it up via indexer.summary automatically. Documented in docs/API.md. - Walkthrough-link hover. FirstPostStarterPack faq_link ("Read the full first-trade
walkthrough ⇨") gained
dark:hover:text-morphit-emeraldso the TEXT turns emerald with the arrow in dark mode (the arrow already did via .nav-arrow;dark:text-whitewas out-specifying the plain hover variant). Sibling FirstTradeHelper already did this right.
DEEP-DEEP FINDINGS (scope: the cp363/364/365 delta + the price-feed-default ripples + release-readiness; not a from-scratch full-repo re-audit — cp308/cp276 covered that):
- F1 (fixed): my first-pass comment said the price source was "CoinGecko"; the chain is actually Klingex → CoinGecko → static_floor (health.ts:151, FAQ price_source_chain). Corrected the config comment + env.example wording.
- F2 (resolved by the flip): the detailed FAQ already describes the "~$0.12" USD subtext as PRESENT, but with the old default-off a fresh instance produced no subtext at all — a latent copy/behavior mismatch. Defaulting the feed on makes the shipped FAQ accurate.
- F3 (fixed): the new /v1/health
price_feedfield was undocumented; added it to the docs/API.md /v1/health JSON example + a field-by-field description. - F4 (no change): MyBalanceCard degrades gracefully when blurt_price_fiat is absent (blurtPriceFiat null → usdLabel omitted → BLURT-only) — verified in code.
- F5 (no break): operator-doc-env-var-parity is presence-based, not value-based → the env.example value change keeps it green (114/114).
- F6 (safe flip): no test asserts the schema default false; the indexer testutils context sets priceFeedEnabled:false as an explicit mock (not a default assertion), unaffected.
FIVE-PERSONA WALKTHROUGHS:
- Sally-operator (fresh node): now gets USD on by default — the init wizard copies
env.example (=true) and no init step writes PRICE_FEED_ENABLED=false. If the box can't
reach CoinGecko/Klingex, the feed degrades to the static floor and
morphit-ops healthshows "on but stale" (the new line aids diagnosis). Static floor remains wizard-editable. - Josie (sysadmin):
sudo morphit-ops→ #13 Node health now shows "Price feed: on — 1 BLURT ≈ ()" / "on but stale …" / "off …".--jsonincludes it. - Sally-user (no crypto, first trade): /post first-trade renders Step 2 + nav (cp364 draft coercion — no .trim() throw), starter-pack tips show, walkthrough link hovers emerald (text + arrow, cp365).
- Bob (returning Blurt trader): the eligibility-force is gated to kind eligible/eligible_unknown_account, so it does NOT fire for a returning trader; his draft is restored + cp364-coerced with no first-trade shape forcing.
- Charlie (MCP read-only): no MCP code touched this session; the price is already public via /v1/listing-fee, so the new health field is no new exposure; the 5 read-only tools are unchanged.
VERIFICATION (all GREEN): svelte-check 0/0; post-form-grandma-regression 13/13; indexer health.test 30/30 (incl. 3 new price_feed cases); ops-cli health-view-smoke 50/50 (incl. parsePriceFeed cases); indexer + ops-cli tsc --noEmit clean; operator-doc-env-var- parity 114/114; smoke-registration-integrity 4/4 (381 files — no new file); smoke-pass-line- canonical 10/10 (388 registered). NOT run in-sandbox: full 388-smoke battery + vitest suite
- vite build → CI; real-browser eyeball of the hover + first-trade flow → post-deploy.
cp367 — Klingex removal (out of business) + price-feed questions answered
CONTEXT: Ken reported Klingex (the Blurt-community CEX that was BLURT's primary external price upstream) went out of business and directed that all mentions + use of their data be eliminated. CoinGecko is now the sole external BLURT/USD source.
CODE (Klingex no longer fetched or used anywhere — indexer tsc 0, matrix-bot tsc 0):
apps/indexer/src/indexer/price/factory.ts: removed theenableKlingexoption field, thecreateKlingexFetcherimport, the klingex upstream push block, and the 3 asset- defaultenableKlingexentries. BLURT now builds the SAME chain as BTC/XMR: Coingecko → morphit_native → static floor. Header chain diagram updated; one historical "went out of business" note kept.- DELETED
apps/indexer/src/indexer/price/klingexFetcher.ts. config/index.ts: removedklingexBaseUrlfield,MORPHIT_INDEXER_KLINGEX_BASE_URLenv (+ default), and the env→config mapping. Fixed 3 comments (incl. reverting the cp365 "Klingex, then CoinGecko" wording to "CoinGecko").disagreementMonitor.ts:EXTERNAL_MARKET_SOURCES{'klingex','coingecko'}→{'coingecko'}; black-hat comment rewritten for the Coingecko-only chain.- Comment-only:
coingeckoFetcher.ts(now documented as the sole external upstream),compositeSource.ts,priceFetchUtil.ts,source.ts,morphitNativeFetcher.ts,main.ts,api/health.ts. apps/web/src/lib/prices/providers/coingecko.ts+fallback.ts: comment updates.apps/web/src/lib/utils/faqIndex.ts: dropped theklingexsearch synonyms.apps/matrix-bot/src/classifier.ts: removed the deadprice-klingexfeed_stale rule (module deleted;price-coingeckorule still covers external-feed staleness).
TESTS / SMOKES (all GREEN, tamper-safe):
test/api/health.test.ts(klingex→coingecko source labels) 30/30;test/testutils/context.ts(dropped the klingexBaseUrl mock).multi-asset-factory-smoke19/19 (removed the enableKlingex shape-check + the 3 "BLURT enables Klingex" scenarios; added "no enableKlingex field" + "factory imports no Klingex fetcher" assertions; doc-marker updated).price-fetch-util-smoke11/11 (dropped the deleted klingexFetcher from the call-site sentinel set).price-source-hardening-smoke28/28 (fixture source labels klingex→coingecko).classifier-smoke100/100 (dropped the price-klingex scenario).persona-walkthrough-smoke182/182 (removed the obsolete D-6 "Klingex curl URL" scenario that pinned a now-defunct klingex.io URL).
LOCALES (all 10, value-only — parity unchanged; faq render smokes green):
where_to_buy_blurt.a: dropped the "Last-resort: Klingex.io" paragraph (it carried a now-dead klingex.io trade link) in all 10 (4→3 paras each).where_does_blurt_price_come_from.a: removed Klingex as price-source #1, renumbered the chain to 1. Coingecko / 2. morphit_native / 3. Static fallback, and scrubbed the in-prose Klingex mentions, in all 10 locales. NOTE: fa used a Persian digit (۱.) for the Klingex bullet, which escaped the first ASCII-digit pass and briefly left a mangled bullet + unbalanced bold — caught by faq-jsonld-no-markdown + faq-inline-render and fixed. i18n parity 10/10; faq-jsonld-no-markdown 7/7; faq-inline-render 13/13; faq-search-grandma-coverage 14/14. The 9 mechanically-edited non-English answers (esp. fa/ru/zh, already on the native-QA list) want a native-speaker polish pass on the slightly-redundant "Coingecko could be the same" sentence.
DOCS (live operator/marketing surface fixed; historical records left as immutable history):
- env.example: removed the dead
MORPHIT_INDEXER_KLINGEX_BASE_URL, reverted the cp365 "Klingex, then CoinGecko" wording, fixed the per-asset-chain + morphit_native comments. - OPERATIONS.md §13: rewrote the chain description (all assets 3-tier), removed the dead
"Is Klingex reachable… curl $MORPHIT_INDEXER_KLINGEX_BASE_URL" troubleshooting step
(renumbered), reframed the "klingex is down" example workflow around Coingecko, fixed
the example JSON
sourcevalues. RUN-A-MORPHIT-NODE.md: all-assets-3-tier. API.md: theprice_feed.sourcevalue list (dropped klingex). SECURITY.md: price-feed posture rewritten to Coingecko-only. ADR-0004: added a 2026 forward-note (Klingex removed, CoinGecko sole external source). - Brag list items 96 + 100: "instead of asking Klingex or Coingecko" → "Coingecko"; the multi-asset chain claim → all-assets-3-tier. MEDIAKIT regenerated (brag changed); mediakit-freshness 7/7, brag-claim-parity 82/82, trailer 5/5, kiss 2/2.
- LEFT AS HISTORY (flagged in REVISIT): ADR-0011/0039/0042, PRICE-SOURCES-RESEARCH.md, POST-LAUNCH-WEEK-ONE.md, and the dated AUDIT/PLAN/PHASE-*/LAUNCH-DAY docs still mention Klingex as part of their point-in-time decision/research record. These are immutable history; ADR-0004's forward-note is the canonical "Klingex is gone" pointer.
VERIFICATION SUMMARY (all GREEN): indexer tsc 0; matrix-bot tsc 0; indexer health.test 30/30; multi-asset-factory 19/19; price-fetch-util 11/11; price-source-hardening 28/28; classifier 100/100; persona-walkthrough 182/182; i18n parity 10/10; faq-jsonld-no-markdown 7/7; faq-inline-render 13/13; faq-search-grandma-coverage 14/14; brag-claim-parity 82/82; mediakit-freshness 7/7; operator-doc-env-var-parity 113/113 (was 114 — the dead KLINGEX_BASE_URL var removed from both env.example and config); the Forgejo-vs-others guard 3/3. NOT run in-sandbox: full 388-battery + vitest + vite build → CI. NO TARBALL CUT (Ken: "no tarball until i say so").
ANSWERS recorded for Ken (no code change — these were questions):
- Listing fee is a FIXED 60 BLURT (
MORPHIT_INDEXER_FEE_BASE_BLURT); the USD figure is DERIVED (base_fee_fiat = feeBaseBlurt × price). The fee is NOT USD-targeted (ADR-0009's $0.25 USD-target was replaced by the BLURT-native refactor, ADR-0011). So changing the static floor $0.002→$0.001 changes ONLY the displayed USD echo (when the floor is the active source), NOT any BLURT amount anyone pays. - The static floor is a config constant; it does NOT auto-update from the live price. But
the composite source CACHES the last successful CoinGecko price and serves it on
temporary upstream failure (the
all_upstreams_failed_serving_cachepath) — the floor is only used if NOTHING ever succeeded since boot. - If CoinGecko rate-limits/blocks: serve last cached price; after staleThresholdMs (2×
refresh)
currentDetailed().staleflips true →/v1/listing-feeomitsblurt_price_fiat→ UI shows BLURT only. The fixed BLURT fee is unaffected. - CoinMarketCap: feasible as a 2nd external upstream (now that Klingex is gone, redundancy has value); needs a CMC API key + BLURT being listed on CMC. Offered, not built.
cp368 — UpdateBanner one-tap mobile fix + first-trade /post bug batch (from 2 screenshots)
CONTEXT: Ken approved the controllerchange one-tap fix, then reported a batch of bugs on the first-trade /post screen (signed in via keyfile, landed on the post page): (1) red- bordered Min/Max on a pristine form + a yellow warning; (2) double border on focus (red error + green focus ring) on Min/Max and flat-price; (3) the market-spread % field accepts letters with no red; (4) the flat-price field is red before any input; (5) flat-price also accepts letters; (6) the "What your buy unlocks" box shows raw i18n key paths; (7) "where is the nav button?".
UpdateBanner (apps/web/src/lib/components/UpdateBanner.svelte):
applyUpdate()now registers acontrollerchangelistener (once) when a waiting worker exists and reloads via a singlereloadOnceguard the instant the new worker takes control, with a 3ssetTimeout(reloadOnce)fallback if the handoff stalls; the no- waiting-worker (version-poll-only) path still reloads after 250ms. One tap now lands the new bundle on mobile (the old fixed 250ms reload could beat SW activation, leaving the tab on the stale bundle so the verify.json poll re-offered → the double-tap Ken saw). The listener lives ONLY inside applyUpdate, so it's still consent-gated (no autonomous controllerchange auto-reload). Comments updated. svelte-check 0/0.
/post page (apps/web/src/routes/[lang]/post/+page.svelte) — all root causes verified in code:
- BUG 6 (raw keys): the waiver-benefits rows append
_with_fiatwhenever a fiat is selected (the normal path — the form requires one), buttier_*_with_fiatkeys didn't exist, and svelte-i18n renders the literal key path rather than degrading (the stale comment claimed it degrades). FIX: addedtier_500/2000/10000/50000_with_fiatto all 10 locales (derived from the existingtier_*by inserting "(~{fiat} {denomination_fiat})" after "{amount} BLURT"; the form requires a fiat so this is the primary path and also turns on the intended fiat-equivalent display). Stale comment corrected. - BUGS 1/2/4 (premature red + double border): the borders + the bottom amount-error
StatusLine keyed purely off the
amountError/priceModelErrorvalidators with no "touched" gate, so a pristine form (waiver active + empty amountMin →waiver_min_required) and a just-revealed flat-price field (fixed_price_requiredwhen empty) showed red before any input; the green focus ring stacked outside the red = the "double border". FIX: addedamountTouched/fixedPriceTouched$state set on first input; the red border + inline StatusLine now gate on touched. Removing the premature red removes the double-border on an untouched field (the focus ring alone shows). Also added per-field attribution (amountMinHasError/amountMaxHasError) so a min-only fault (incl. the waiver floor) reddens only the min field — previously the sharedamountErrorreddened BOTH inputs. Reset flags wired: clearDraft + postAnother set both false (pristine); applyDraft sets them true when a loaded draft carries non-empty values (so an invalid saved value surfaces). - BUGS 3/5 (number fields accept letters): all four number inputs use one-way
value={…}- an
oninputthat strips viakeepDecimal/keepSignedDecimal. When the stripped result equals the current state (typing letters into an empty field → both ''), Svelte sees no state change and skips re-rendering the input, so the typed letters linger on screen while the bound value stays empty → the validator sees empty → no red. FIX: asyncCleanedhelper force-writesel.value = cleanwhen they differ, wired through four named handlers (handleAmountMinInput/handleAmountMaxInput/handleSpreadInput/handleFixedPriceInput). The box now stays numeric and the validator sees the truth.
- an
- BUG 7 (missing nav button): Step 3 (payment/region/terms/expiry) AND the Continue button
are gated behind
{#if step1Done && step2Done}, andstep2DonerequiresamountError === ''. With Ken's amountMin=1 below the waiver floor (and on a fiat-less first load), step2Done is false → the whole block, button included, never renders → a silent dead-end. NOT a bug in the button; it's progressive disclosure with no cue. FIX (cue, not ungating): a neutralcontinue_locked_hint("Finish the fields above to continue.") renders whenstep1Done && !step2Done— added to all 10 locales (register-aware), never red.
REGRESSION SMOKE (apps/web/scripts/post-form-grandma-regression-smoke.ts — now 19 scenarios,
was 13): added 6 tamper-tested cp368 scenarios — (a) the four tier_*_with_fiat keys exist
and interpolate {amount}/{fiat}/{denomination_fiat} + the code builds the suffix; (b) the
amount borders + bottom error gate on amountTouched and no ungated {amountError ? border
remains; (c) amountMinHasError/amountMaxHasError per-field deriveds exist; (d) the flat-
price border + StatusLine gate on fixedPriceTouched; (e) syncCleaned rewrites the DOM
value + all four handlers are wired and the old inline strip-without-resync handlers are
gone; (f) the continue_locked_hint exists and renders under step1Done && !step2Done.
Reads en.json for the key-existence checks.
UPDATED SMOKES for the controllerchange design change (the old rule forbade ANY controllerchange listener; the new one is consent-gated inside applyUpdate):
- update-banner-user-consent-smoke: replaced "no controllerchange listener" with "controllerchange listener lives only inside applyUpdate (>=1, none outside)"; header updated. 8/8.
- service-worker-single-registration-smoke §10: same consent-gated check (controllerchange only inside applyUpdate); §12 comment refreshed (reload is still a single reloadOnce site). 13/13.
VERIFICATION (all GREEN): svelte-check 0 errors / 0 warnings; post-form-grandma-regression 19/19; update-banner-user-consent 8/8; update-banner-deployed-version-poll 8/8; service- worker-single-registration 13/13; update-surface-nocache-config 6/6; i18n-locale-parity 10/10; i18n-key-coverage 2/2; i18n-translation-completeness 4/4; split-on-placeholder 19/19; i18n-hardcoded-english 1/1; i18n-html-injection 1/1. NOT run in-sandbox: full 388-battery + vitest + vite build → CI; real-browser eyeball of the /post screen (pristine load, focus states, typing letters, flat-price reveal, the unlocked-tier fiat figures, the locked hint) in all 10 locales after deploy. NO TARBALL CUT (Ken: "no tarball until i say so").
FLAGGED TO KEN (NOT fixed — needs design intent): UNIT MISMATCH on the Min/Max field. When a
fiat is selected the label is "Minimum value in {fiat}" (e.g. "in USD"), but amountMin is
compared directly to WAIVER_MIN_BLURT = 500 BLURT (and the order carries amountMin as the
asset amount). So "$1" (≈500 BLURT at $0.002 — exactly the floor) is read as "1 BLURT" < 500
and rejected. Either amountMin IS the BLURT/asset amount and the "in {fiat}" label is wrong,
or it's a fiat value and the waiver check needs conversion to a fiat floor. The cp368 display
fixes are correct regardless; this is a separate semantic decision.
cp369 — FIAT-FIRST reversal of the §F.11 regression (first-order $1 floor) + form id/name a11y
CONTEXT: Ken (frustrated — he's said this many times) restated the core design: users think in their LOCAL FIAT, never in BLURT. (a) The first-order minimum is $1 USD-equivalent of BLURT (the user picks their currency; the system figures out how much of it equals $1 worth) — never "buy 500 BLURT" (sounds like a fortune, scares newcomers). (b) Listing fees are 25¢ USD worth of XMR/BTC, or ~12.5¢ USD-equiv if paid in BLURT — USD-targeted, tracking the live price. Recorded as a memory edit so it stops getting lost. He also attached a DevTools screenshot of 11 "form field should have an id or name" warnings.
ROOT-CAUSE (verified in code — and I own it): a past §F.11 "BLURT-denomination refactor" (which I drove, and wrongly defended last turn) abandoned Ken's USD-targeted/fiat-first design and replaced it with hardcoded BLURT constants:
- The waiver floor became a flat
WAIVER_MIN_BLURT = 500in BOTH the client (+page.svelte) and the indexer (order.ts,orderReplace.ts), justified by "no price feed in the critical path." - The fees became fixed amounts targeting USD at a reference price:
feeBaseBlurt = 60(≈$0.12), a fixed satoshi amount "targets ~$0.25 at [ref]" (config:954), a fixed piconero amount "targets ~$0.25 USD at $320 XMR" (config:990).loyalty.ts:53confirms the intended "$0.125 listing fee". - It also created a UNIT BUG:
amount_min/amount_maxare FIAT values (the orderbook RSS renders${amount_min} – ${amount_max} ${fiat_currency}, e.g. "1 – 50 USD"), but the floor compared the fiat value to a 500-BLURT constant. So "$1" (≈500 BLURT, exactly the floor) read as "1 BLURT < 500" and was rejected — the bug Ken hit, and the reason my last-turn answer was wrong.
FIX — FLOOR reversal (this is clean: amount_min is already a fiat value everywhere except the buggy checks, so the floor is a fiat-to-fiat comparison — NO price feed needed, which moots §F.11's only stated reason):
- Client
+page.svelte:WAIVER_MIN_BLURT = 500→WAIVER_MIN_FIAT_USD = 1(the $1 floor, a fiat value); both floor checks (amountError,amountMinHasError) updated; comments rewritten.WAIVER_SUGGESTED_DEFAULT2000 (BLURT) → 4 (≈$4 fiat). The benefits ladder breakpoints 500/2000/10000/50000 BLURT → 1/4/20/100 (fiat USD-equiv);waiverBenefitRowsrewritten to be fiat-first (shows the threshold viaformatFiat(tier.at, denominationFiat),unlockedcompares the fiat amountMin to the fiat tier) — dropped the BLURT-quantity formatting and the_with_fiatsuffix logic entirely. Three stale "500 floor" comments (prefill rationale, the amountError block, the waiver-box markup) corrected to "$1 USD-equivalent". - Indexer
order.ts+orderReplace.ts:WAIVER_MIN_BLURT = 500→WAIVER_MIN_FIAT_USD = 1; the §F.11 comment blocks rewritten to explain the fiat floor + the reversal. - Locales (all 10): the 4
tier_*keys rewritten fiat-primary ("{amount} — ", dropping "{amount} BLURT"); the 4tier_*_with_fiatkeys (added cp368) DELETED. The descriptions still hold ($1 ÷ ~$0.125 per listing ≈ 8 listings). - NON-USD NUANCE (documented, not yet solved): "$1 USD-equivalent" is exact when the order's fiat is USD (the default denomination). A non-USD instance needs a per-currency $1 conversion, which the single-denomination price feed (BLURT priced in one currency) doesn't carry — a multi- currency-pricing enhancement, flagged in code + REVISIT.
FIX — a11y id/name (Ken's DevTools screenshot): added unique id + name to the 7 inline
composing-phase inputs (amount min/max, spread %, flat price, region, expires select, syndicate
checkbox) and to the FiatCurrencySelect search/combobox input. The radios already carry name=
(price-model-kind, fee-method). REMAINING (minor, deferred): the PaymentMethodsPicker checkbox
inputs and the ProtectedTextarea (the latter needs a name prop since it's a reusable component
— a hardcoded name would be wrong for other consumers).
REGRESSION SMOKE: post-form-grandma-regression-smoke 19 → 20 — the cp368 _with_fiat
scenario was replaced by two cp369 scenarios: (a) the floor is WAIVER_MIN_FIAT_USD = 1 with no
stale WAIVER_MIN_BLURT; (b) the ladder is fiat-first ($1/$4/$20/$100 breakpoints, tier keys
interpolate {amount} with no "BLURT" prefix, no _with_fiat keys, label via formatFiat).
Indexer order.test.ts + orderReplace.test.ts: the below-floor rejection case now uses
amount_min=$0.50 (below $1), the at-floor case uses $1, the non-waived case uses $0.50 (proving
the floor doesn't apply), and the waivedPayload() helper uses realistic fiat amounts; stale
"500 BLURT / WAIVER_MIN_BLURT" comments fixed.
NOT FIXED THIS TURN — the FEE MODEL (flagged, with the real reason): the fees are the same §F.11
regression (fixed BLURT/sat/piconero amounts that only hit their USD targets at a reference price,
not tracking the live price). Reversing them to true USD-targeting (12.5¢ BLURT, 25¢ BTC/XMR) is
NOT a one-line change like the floor, because the listing fee is a paid on-chain amount: the
client quotes it, the user pays it on-chain, and the indexer validates the paid amount. A price-
tracking fee reintroduces a client↔indexer price-agreement problem (a price move between quote and
payment could reject a good-faith payment) — almost certainly why §F.11 went fixed-BLURT. The
correct shape is a quote-and-validate window (the response already carries quote_ttl_seconds: 300
as the basis): the indexer quotes fee + validity window, the client pays that, the indexer
validates against the quote it issued (with tolerance). Flagged for Ken — do NOT slam this in blind
(real-money on-chain risk).
VERIFICATION (all GREEN): svelte-check 0 errors / 0 warnings; indexer tsc 0; post-form-grandma- regression 20/20; i18n-locale-parity 10/10; i18n-key-coverage 2/2; i18n-translation-completeness 4/4; split-on-placeholder 19/19; i18n-hardcoded-english 1/1; indexer order.test 38/38 + orderReplace.test 29/29 (vitest). NOT in-sandbox: full 388-battery + full vitest + vite build → CI; real-browser eyeball of the /post screen in all 10 locales (pristine load, focus states, typing letters, flat-price reveal, the fiat-tier figures "$1 — ~8 future listings…", the locked hint, and that a $1 first order is now ACCEPTED). NO TARBALL CUT (Ken: "no tarball until i say so").
cp372 — treasury auto-re-pin + FX subsystem + /post grandma batch: deep-deep + five-persona walkthrough 🟢
Scope this pass: everything built in the cp372 session — the chain-pinned BLURT base + automated
auto-re-pin (decision core, read-only check + opt-in key-gated broadcast actuators, systemd timer +
units + env, manual Plan B), the FX subsystem (/v1/fx endpoint, fx source, client fetcher + pure
helpers, the FX-aware /post first-order floor + live $1-equivalent default + hint), and the rest of
the /post grandma batch (dark per-method box removal, redundant terms-line removal, FAQ
scroll-margin + border tone-down, "Step n of 3" badges, multilingual typewriter placeholder,
fiat-required hint, the one shared .hover-subtle standard).
Five-persona walkthroughs (verified in code, not assumed) — all ✅
- ✅ Bob (Blurt multi-login, paid order) — the FX work is gated to the waiver path AND
isFirstTrade, so his non-first paid flow is untouched (no seed, no FX floor); the listing fee he pays uses the chain-pinned BLURT base (order-handler-smoke 51 + order.test 38). Sees the cosmetic wins (step badges, typewriter, cleaner picker, hover standard). - ✅ Sally-user (no crypto, first trade) — the target persona. Picks her local fiat → the guarded
seed effect fills Min-value with the $1-equivalent in HER currency → the FX-aware floor accepts it
(client mirrors the indexer's
fiatToUsd(...) ?? amount_minexactly). 🟢 Finding (fixed):lastSeededFiatwas not reset in the two fresh-listing reset blocks (postAnother+ the start-over reset), so a reset-then-same-fiat could skip a re-seed. Moot for Sally (one first-trade per account) but a latent robustness gap — addedlastSeededFiat = ''to both resets. - ✅ Sally-operator (node setup from the .md) — env-example-schema-parity 6/6 (the 9 new
FX/crypto vars present + all defaulted, so a fresh node boots without setting them);
MORPHIT_INDEXER_FEE_BASE_BLURTdocumented as the Plan-B fallback; she inherits the chain-pinned BTC/XMR/BLURT treasury and does nothing;/v1/fxon by default. operator-doc-env-var-parity 113, fenced-path-existence + section-ref green. - ✅ Josie (sysadmin, morphit-ops) —
/v1/healthwiresfxSource→ thefxfeed-health block (price-feeds-health 16 + health-view 61); the maintainer-only treasury-repin timer/units are documented + User=-consistent (ansible-systemd-user-consistency 19). - ✅ Charlie (MCP read-only) — mcp-server-read-only-invariant 3/3 (the inlined economics in
index.tsloads);/v1/fxserves only the public USD→fiat table (no per-user data), so no fee-mechanics privacy regression.
Deep-deep findings (comprehensive, one pass) — all 🟢 fixed
- 🟢
lastSeededFiatreset gap (Sally-user, above) — fixed in both fresh-listing resets. - 🟢 Stale
economics.tscomments ineconomics-canonical-smoke.ts(header + inline) — the canonical economics was inlined intoindex.tsat cp370; corrected both comments. (Earlier this session the same stale path was fixed in FEES-AND-REWARDS.md + ADR-0011's forward-note.) - 🟢 Concatenated import in
apps/indexer/src/main.ts— thefxRouteimport insert landed on the same line as thechatRouteimport (…'$api/fx';import { chatRoute }…). Valid JS (tsc was clean) but malformed; split onto its own line. No other concatenated imports in the session's touched files. - 🟢
/v1/fxundocumented in API.md — added the endpoint (whole-table privacy posture, the 404-when-disabled behavior, the response shape).
Notes (no action — harmless)
post_order.summary.see_notesandpost_order.form.terms_placeholderare now orphaned locale keys (their renders were removed/replaced this session). Harmless: still present in all 10 locales so i18n-locale-parity holds, and the native-translations-floor smoke treats the snapshot as a FLOOR (unused keys don't break it; no locale keys were removed). Left in place rather than churn 10 locales + the native-QA snapshot for two cosmetic strings.
VERIFICATION (all GREEN): full smoke battery 395/395 (354 regex-matched in chunks +41 non-regex);
full indexer unit vitest 504 passed / 1 skipped; svelte-check 0/0; i18n-locale-parity 10/10 (3237
keys); post-form-grandma-regression 22/22 (added FX-floor, seed-safety, typewriter scenarios);
economics-canonical 63; a11y-patterns 41; persona-walkthrough 182; faq 4; forgejo guard 3;
operator-doc-env-var-parity 113; ansible-systemd-user-consistency 19; full web production build
compiles (Tailwind @apply hover-subtle resolves). indexer + indexer-client + release-schema tsc 0.
NOT in-sandbox (→ deploy/CI + human): real-browser eyeball of the /post screen in all 10 locales
(typewriter animation, step badges, $1-equivalent seed per fiat, hover standard), and the live
auto-re-pin broadcast (sandbox can't reach coingecko/RPC; the pure decision/build/parse core is
22/22). NO TARBALL CUT (Ken: "no tarball until i say so").
cp382 (beta.38 batch) — focused deep-deep on the changed surface
Scope: this session changed only UI (FiatCurrencySelect.svelte, a global app.css hover
rule), content (FAQ text in 10 locale JSONs, brag list, comparison generator), and docs
(OPERATIONS/RUN-A AMD-SEV note). No crypto, key-handling, indexer, relay, or auth code-path
was touched, so the 94-task static-audit baseline is unchanged for the untouched surface;
this is a black-hat sweep of the delta only.
FINDING (fixed) — hover rule vs. red invalid borders. The new global hover-brighten rule
excluded [aria-invalid='true'], but several fields signal invalid by RED CLASS without
setting aria-invalid: ProtectedTextarea.svelte (textarea, border-red-500), FocusedField.svelte
(field wrapper, border-red-500), and the onboarding/import + settings forms (border-red-400/500).
On those, hovering an invalid field would have washed the red border to neutral ink-300.
FIX: added :not([class*='border-red']) to all three selector branches — the substring guard
keeps every red-bordered field intact on hover in one place, no per-field aria-invalid retrofit.
svelte-check 0/0 after.
CLEAR:
- FAQ XSS / injection —
i18n-html-injection-smoke1/1 +i18n-hardcoded-english-smoke1/1; the new OM/Haveno text carries no HTML and no English leaked into non-EN locales. - FiatCurrencySelect label
for=duplicate-id — the static idfiat-currency-searchis safe because only ONE single-mode instance renders per page (post / post-edit on separate pages; the orderbook fiat filter is multi-mode and renders no label). Noted as a minor: a future page stacking two single-mode instances would need a derived id. - Competitor-claim accuracy — the Haveno June-16 dispute-resolution exploit and OpenMonero's return were web-verified before publishing; the ~1,500 XMR figure is attributed to orangefren as an early-tracking estimate (mainstream put June losses "still being assessed"), and the unverifiable ~40 XMR May-21 OM figure was softened to the confirmed halt-payments alert.
- AMD SEV doc — makes no TEE-attestation claim; frames SEV/TDX as opt-in and notes it roots trust in the CPU vendor (priority-#2 tension), consistent with the prior TEE-NO decision.
- Comparison OM
-on obfuscation — defensible: OM brags V8 bytecode, which is a source-protection/obfuscation technique; Bisq/Haveno/BasicSwap stay Y (open, unobfuscated).
Walkthroughs: persona 182/182, sally 22/22 green. Charlie (MCP) and Josie (ops-cli) unaffected — no MCP or ops-cli runtime touched (docs only).
cp466 — v1.4.8 → v1.4.9 delta deep-deep (focused on the changed surface) 🟢
Scope: everything that changed since the v1.4.8 cut. The ONLY meaningful new attack
surface is t.txt #5 (on-chain chat folders): a new posting-signed custom_json op
(morphit_chat_folders_v1), a new crypto module, a new indexer handler + table +
public endpoint, and a new client-side sync. Everything else (#1 contact-flash
by-origin fix, #2 Terms markdown-guide modal, #3/#4 my-orders defaults + button
size prop, #6/#7 optimistic cancel via recentCancels, #8 empty-category note, #9
orderbook slide-in) is UI/logic with no new privileged path.
#5 on-chain chat folders — audited across the crypto/op/indexer/endpoint/client layers
- ✅ Confidentiality (priority #1). The folder state (which peers/orders a user
keeps in Starred/Archived) is encrypted before it ever leaves the client:
folderCrypto.encryptFolderState— BLAKE2b-256-keyed derivation from the POSTING key (domain-separated tagmorphit-chat-folders-v1/state/<account>, so it can never collide with the chat-identity key), ChaCha20-Poly1305 IETF, wire = base64(nonce‖ct). The op body is exactly{ v: 1, enc }— no thread keys in the clear (pinned bychat-folders-onchain-smoke+ a runtime no-leak test). Verified: freshrandombytes_buf(12)nonce per encryption (no reuse); derived key wiped (memzero) on both encrypt and decrypt. - ✅ Integrity / no forgery. AEAD tag rejects any tampered blob (decrypt → null, no throw, no partial-plaintext leak). KAT (known-answer test) freezes the wire format so a future change to derivation/nonce/AAD/cipher/base64 fails the test instead of silently orphaning every on-chain blob.
- ✅ No impersonation. The indexer handler keys the row on
ctx.signer(the op's cryptographic signer), NOT any payload field — a user can only write their OWN account's folder state. SQL fully parameterized ($1..$5, zero interpolation). - ✅ DoS / bloat bounded. Handler rejects non-object payload, v≠1, non-string /
empty / >96 KB / non-base64
enc. Upsert is latest-by-block (source_block_num < EXCLUDED.source_block_num), so a replayed OLD op can't overwrite newer state. Endpoint is rate-limited (listbucket) and validates the account name. - ✅ Public endpoint leaks nothing. GET /v1/chat-folders/:account returns only the
opaque
enc+ a timestamp. Reading another account's blob yields ciphertext that is undecryptable without THAT account's posting key — so the endpoint being public is safe (the block timestamp is already public on chain anyway). - ✅ Migration hygiene. v42 lands in BOTH
migrations.ts(existing DBs) and theschema.sqlbaseline (fresh DBs) with identical columns;SCHEMA_HEAD_VERSION+MIGRATIONS_COVERAGE_HIGHbumped to 42 (found by the battery — see below). Column parity pinned by the smoke. - ✅ Posting-only users supported; memo key never touched (pinned).
Note (no fix — accepted within the trust model)
- Rollback-on-read. The client adopts whatever
encthe indexer serves for its account. A malicious or buggy indexer could serve a STALE (but genuine) blob, rolling a user's folder organization back to an earlier state. Accepted because: (a) the indexer is the user's OWN trusted instance (a hostile one has far graver levers); (b) the AEAD still prevents FORGERY — only real past blobs can be served, never fabricated ones; (c) impact is minor and self-healing — the next folder action re-broadcasts current state. Not worth a monotonic-version gate on read.
Battery (full ~492-smoke run, in chunks) — 3 real regressions found + fixed 🟢
Running the full battery in-sandbox (not just the changed-file smokes) caught three issues the representative subset would have missed — all fixed, all re-verified:
schema-migration-coverage-smoke— my v42 banner exceeded the pinnedSCHEMA_HEAD_VERSION=41. Bumped the pin (+MIGRATIONS_COVERAGE_HIGH) to 42.i18n-translation-completeness-smoke— two #2 Terms-guide keys are byte-identical to English in German (col_element="Element",el_link="Link"). Both are correct German (cognate + hyperlink loanword), added to the allow-list with (a)/(b) reasons.cancel-redirect-and-relist-smoke— a stale pin still expected the old 1.5s pre-navigation indexer wait; #6/#7 replaced that with the optimisticrecordCancel→applyRecentCancelsoverride. Updated the pin to the new mechanism.
CLEAR. In-sandbox green: full battery 489/489 · full vitest (via
vitest-must-pass-smoke) · 5-persona walkthroughs (via persona-walkthrough-smoke)
· vite build ✓ · web svelte-check 0/0 · indexer tsc 0. #5 guarded by 28 unit tests +
20 tamper-tested shape-pin checks (KAT backward-compat + no-leak privacy included).