morphit/docs/AUDIT-2026-06-DEEPDEEP.md

1397 lines
217 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
---
## cp454cp456 — 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 on `MORPHIT_CHAT_DEBUG=1` env. 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_tag` is
truncated to 10 chars via `tagPreview`. 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, so `deps.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 `$derived` reads
`?order=` synchronously). Tamper-tested `chat-thread-remount-smoke` (5).
- **#1 profile-field clear (data-integrity / the indexer merge contract).** `buildProfileBody`
now 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) + updated `ops.redaction.test.ts` (empty⇒'' clear, absent⇒omit distinction).
- **Null-`pathname` layout crash fix (error-handling / robustness).** `afterNavigate` now
reads `nav.from.url?.pathname === nav.to?.url?.pathname` and the layout's other three
`$page.url?.pathname ?? ''` reads are guarded. A null `nav.to.url` no 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.hidden` off 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-tested `chat-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) + updated `chat-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)** — `probeOne` fetches ONLY
`DEFAULT_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-IP `resource`
rate-limit. Response exposes only coarse public health. The browser never fetches a
node (indexer-only) — pinned by `endpoint-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 on `o.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 renders `order_permlink` + the summary
through auto-escaped Svelte + i18n values; amounts are `Number`. Native `<dialog>`
closed on destroy (no leak).
- **#3 fiat pricing** — `usdToFiat`/`formatFiat` on the public `/v1/fx` table; FX fetch
is try/catch with a USD fallback; no injection surface.
- **#4 power-down floor** — `floorToBlurtPrecision` guarantees fill ≤ available (fixes
the `toFixed(3)` round-UP that exceeded available); tamper-pinned 25/25.
- **#5 footer flash** — the `highlight` param 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-ops` usage (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 AM, 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_shares` prune candidate + any others)
- ⬜ Draft finalization (any half-built features / TODO drafts)
- ⬜ FAQ accuracy (138 entries × facts vs code/economics; new `why_agpl` consistency)
- ⬜ Mobile responsiveness (every route at narrow width; the `dev/responsive` harness)
- ⬜ 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 AM + 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):**
1. NEW same-origin `POST /v1/chain/key-references` proxy in `apps/indexer/src/api/chainExplorer.ts` — validates the body (`keys` is a non-empty `string[]`, each BLT-shape `^BLT[1-9A-HJ-NP-Za-km-z]{40,60}$`, cap 8), forwards `get_key_references` server-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 cp347 `resource` per-IP rate-limit via the existing `chainApp.use('*', rateLimit(...))` mount.
2. Rewrote `accountByKey.ts` to `fetchWithTimeout` the 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 at `onboarding/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.
3. Extended `rpc-privacy-routing-smoke` +4: accountByKey-is-same-origin / accountByKey-makes-no-direct-call / a GENERAL sweep that no web source calls `condenser_api.get_key_references` directly (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 UPDATE`s 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) seedaccount-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 cp338cp348 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 cp338cp348 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` 2220: 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 85568552 (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 (cp338cp346: the cp344 same-origin broadcast proxy + the cp346 settings scoping / endpoint-error / pairing work). Builds on the prior comprehensive AO passes (cp208 + cp252276) 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 cp338cp346 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 cp338cp346.
**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 browserthird-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.** cp338cp346 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: cp338cp346 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 cp338cp346 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 browserthird-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 25,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 truefalse 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 `setUserBlurtAccount`s 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 `$effect`s (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)** NEW `SignupProgress.svelte`
("Step X of Y", `role=progressbar`) wired through onboarding steps 13 + register-name 4/4 (importers excluded);
`onboarding.progress.step_label` ×10. **(D) ENS `.eth` feature 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 to
`https://{name}.eth.limo`, no in-app resolution, pragmatic ASCII `.eth` regex). Touch-points: ops-cli
`altAddressValidate.ts` (`ENS_RE`/`isValidEnsName`/`validateEnsName`/`ENS_ENV_KEY`) + `altAddress.ts` managed slot
(`collectEns`, menu, dispatch) + init Step-10 `wantsEns` prompt + `render.ts` + `edit.ts` keep-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
unused `footer.eth`); a per-locale ENS bullet in the `help_make_unstoppable` FAQ ×10. **Launch-blocker caught +
fixed:** `MORPHIT_INSTANCE_ENS_NAME` was MISSING from the `@morphit/operator-config` ALLOWLIST — a
`morphit.config.env` with it would HARD-ERROR at boot; now allowlisted. Smokes: alt-address-wizard +ENS cases
(57/57), api-response-shape `ens` (76), completeness allowlists `footer.ens` ×9, indexer
`federationProbeSelfBranding.test` mock+assertion `ens: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.svg` is all-white-fill, rendered via `<img>` like every alt-network icon; the app is dark-only (footer
`bg-ink-950`) so the white icon is visible, and it matches sibling `icon-i2p.svg` (also pure `#fff`) — optional aesthetic only. Gates: svelte-check 0/0; `tsc` clean 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-smoke` can't run
under bare tsx (`$config` path-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 89 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:**
1. 🟢 `href-xss-smoke` — the explorer-link lang-prefix fix added 3 `href={url ? lp(url) : '#'}` bindings
the XSS smoke didn't allowlist (block page prevUrl+txUrl, tx page blockUrl). VERIFIED safe (all
`{@const}` from validated `morphitExplorer{Block,Tx}Url()` — block validates finite positive int, tx
validates BLURT_TRXID_RE — wrapped in `lp()` SAFE_BUILDER, '#' fallback; identical profile to the
existing allowlisted account-page entry). Added 2 new `ALLOWLIST_HREF_EXPR` entries with full safety
rationale. 1/1.
2. 🟢 `init-smoke` — the i2p two-slot refactor (`AltNetworkResult.i2p``i2pB32`+`i2pName`) updated the
WIZARD smoke but left init-smoke on the old `i2p` shape (runtime assertion failed: expected legacy
`MORPHIT_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 the `alt-address`
command's lookup maps (with documented legacy `_I2P_ADDRESS` read-fallback), NOT the dropped field.
PLUS fixed 2 more latent stale fields in the same fixture (caught via the typecheck survey below):
`chatLinkExplorers` was missing `dcr/sol/eth/xrp` (vs `ChatLinkExplorersResult`); `listingFee` was
missing `denominationFiat` (vs `ListingFeeResult`). 51/51.
3. 🟢 `cross-tab-signout-propagation-smoke` — scenario #8 enforced the explicit Sign Out button calls
`broadcastSignOut()` (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 calls
`broadcastSignOut()`, and login's confirmSwitch too — propagation intact, only the site moved
settings→avatar. Repointed the smoke (constant/read/scenario #8/header doc) to `AvatarMenu.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.
---
## cp357cp358 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): `/login` welcome-back now shows the colorful
"Welcome back @username" heading and a right-aligned red Sign out button. Sign out →
the avatar menu's exact destructive `ConfirmModal``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_ACCOUNT` is 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 via `paired_readonly.welcome_back_heading`. No missed sibling.
- Sign-out reuse is exact: same red classes + log-out icon + `avatar_menu.sign_out`
label + `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 two `main.ts` help blocks, and OPERATIONS.md were updated to note the interactive
form; menu-annotations-smoke pins the `positional:['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, NO `single` prop — 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 `/post` and `/post/edit`.
Re-read `/post/edit` with 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 the `post_order.form.fiat_label` value change.
- Bug 1 (Ken-reported) — draft-restore banner on a pristine first-trade form.
`draftHasContent` counted 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. `priceModelError` is a
`$derived` returning '' for an empty/zero spread, and each error StatusLine renders
INSIDE its own `priceModelKind === …` 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) for
`Intl.ListFormat` and resolves method names via the SAME `displayNamesForMethods`
the 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):
1. Price feed ON by default for all operators. `MORPHIT_INDEXER_PRICE_FEED_ENABLED`
default 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.
2. Price-feed status on `morphit-ops health` (main-menu #13). Added a compact, non-
sensitive `price_feed` summary 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.
3. Walkthrough-link hover. FirstPostStarterPack faq_link ("Read the full first-trade
walkthrough ⇨") gained `dark:hover:text-morphit-emerald` so the TEXT turns emerald with
the arrow in dark mode (the arrow already did via .nav-arrow; `dark:text-white` was
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_feed` field 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 health`
shows "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 ≈ <px> <DENOM> (<source>)" / "on but stale …" / "off …". `--json` includes 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 the `enableKlingex` option field,
the `createKlingexFetcher` import, the klingex upstream push block, and the 3 asset-
default `enableKlingex` entries. 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`: removed `klingexBaseUrl` field, `MORPHIT_INDEXER_KLINGEX_BASE_URL`
env (+ 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 the `klingex` search synonyms.
- `apps/matrix-bot/src/classifier.ts`: removed the dead `price-klingex` feed_stale rule
(module deleted; `price-coingecko` rule 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-smoke` 19/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-smoke` 11/11 (dropped the deleted klingexFetcher from the call-site
sentinel set).
- `price-source-hardening-smoke` 28/28 (fixture source labels klingex→coingecko).
- `classifier-smoke` 100/100 (dropped the price-klingex scenario).
- `persona-walkthrough-smoke` 182/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 `source` values. RUN-A-MORPHIT-NODE.md: all-assets-3-tier. API.md: the
`price_feed.source` value 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_cache` path) — 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().stale` flips true → `/v1/listing-fee` omits `blurt_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 a `controllerchange` listener (once) when a waiting worker
exists and reloads via a single `reloadOnce` guard the instant the new worker takes
control, with a 3s `setTimeout(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_fiat` whenever a fiat is
selected (the normal path — the form requires one), but `tier_*_with_fiat` keys didn't
exist, and svelte-i18n renders the literal key path rather than degrading (the stale
comment claimed it degrades). FIX: added `tier_500/2000/10000/50000_with_fiat` to all 10
locales (derived from the existing `tier_*` 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`/`priceModelError` validators with no
"touched" gate, so a pristine form (waiver active + empty amountMin → `waiver_min_required`)
and a just-revealed flat-price field (`fixed_price_required` when empty) showed red before
any input; the green focus ring stacked outside the red = the "double border". FIX: added
`amountTouched`/`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 shared `amountError` reddened 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 `oninput` that strips via `keepDecimal`/`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: a `syncCleaned`
helper force-writes `el.value = clean` when they differ, wired through four named handlers
(`handleAmountMinInput`/`handleAmountMaxInput`/`handleSpreadInput`/`handleFixedPriceInput`).
The box now stays numeric and the validator sees the truth.
- BUG 7 (missing nav button): Step 3 (payment/region/terms/expiry) AND the Continue button
are gated behind `{#if step1Done && step2Done}`, and `step2Done` requires `amountError === ''`.
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 neutral
`continue_locked_hint` ("Finish the fields above to continue.") renders when
`step1Done && !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 = 500` in 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:53` confirms the intended "$0.125 listing fee".
- It also created a UNIT BUG: `amount_min`/`amount_max` are 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_DEFAULT` 2000 (BLURT) → 4 (≈$4 fiat). The benefits ladder breakpoints
500/2000/10000/50000 BLURT → 1/4/20/100 (fiat USD-equiv); `waiverBenefitRows` rewritten to be
fiat-first (shows the threshold via `formatFiat(tier.at, denominationFiat)`, `unlocked` compares
the fiat amountMin to the fiat tier) — dropped the BLURT-quantity formatting and the `_with_fiat`
suffix 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} — <desc>", dropping
"{amount} BLURT"); the 4 `tier_*_with_fiat` keys (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_min` exactly). 🟢 **Finding (fixed):**
`lastSeededFiat` was 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 — added `lastSeededFiat = ''` 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_BLURT` documented as the Plan-B fallback; she inherits the chain-pinned
BTC/XMR/BLURT treasury and does nothing; `/v1/fx` on by default. operator-doc-env-var-parity 113,
fenced-path-existence + section-ref green.
- ✅ **Josie** (sysadmin, morphit-ops) — `/v1/health` wires `fxSource` → the `fx` feed-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.ts` loads); `/v1/fx` serves only the public USD→fiat table (no per-user data), so no
fee-mechanics privacy regression.
### Deep-deep findings (comprehensive, one pass) — all 🟢 fixed
- 🟢 **`lastSeededFiat` reset gap** (Sally-user, above) — fixed in both fresh-listing resets.
- 🟢 **Stale `economics.ts` comments in `economics-canonical-smoke.ts`** (header + inline) — the
canonical economics was inlined into `index.ts` at 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`** — the `fxRoute` import insert landed on
the same line as the `chatRoute` import (`…'$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/fx` undocumented 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_notes` and `post_order.form.terms_placeholder` are 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-smoke` 1/1 + `i18n-hardcoded-english-smoke` 1/1;
the new OM/Haveno text carries no HTML and no English leaked into non-EN locales.
- **FiatCurrencySelect label `for=` duplicate-id** — the static id `fiat-currency-search` is
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 tag `morphit-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 by `chat-folders-onchain-smoke` + a runtime no-leak test). Verified:
fresh `randombytes_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 (`list` bucket) 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 the
`schema.sql` baseline (fresh DBs) with identical columns; `SCHEMA_HEAD_VERSION` +
`MIGRATIONS_COVERAGE_HIGH` bumped 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 `enc` the 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:
1. `schema-migration-coverage-smoke` — my v42 banner exceeded the pinned
`SCHEMA_HEAD_VERSION=41`. Bumped the pin (+ `MIGRATIONS_COVERAGE_HIGH`) to 42.
2. `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.
3. `cancel-redirect-and-relist-smoke` — a stale pin still expected the old 1.5s
pre-navigation indexer wait; #6/#7 replaced that with the optimistic `recordCancel`
→ `applyRecentCancels` override. 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).