31 KiB
Morphit cp308+ repo-wide deep-deep + five-persona walkthrough campaign
Mandate (Ken, cp308): exhaustive five-persona walkthroughs (every button, link, field, select option — soup to nuts), then a 94+-task deep-deep across EVERY file/script (.md/.ts/.svelte/ops): drift, regex accuracy, type errors, test-coverage gaps, stale/outdated smokes + gates + parities, bad keys/vals, unwired code, non-functioning paths, staleness/orphans, hostile-op sweep across all handlers, chain-direct attack patterns, DB dead fields, unfinished drafts, FAQ accuracy, mobile-responsiveness, UX papercuts, README + OPERATIONS + RUN-A-MORPHIT-NODE + all docs/*.md accuracy, broken refs, efficiency / slow page loads, memory leaks, fallbacks/failovers, "never leave a user hanging," and "is it grandma-friendly?". Fix as we go. NO tarball until Ken says so. Tree stays v1.0.0-beta.23, Forgejo only.
This file is the campaign spine — updated every turn so the effort survives across sessions. Status legend: ☐ not started · ◐ in progress · ☑ done.
Persona walkthroughs (trace every interactive element through real code)
- ☑ Bob — Blurt multi-login. COMPLETE. Login unlock (full error matrix incl. yubikey/TOTP/paired, busy guards); sign-in cards (cp305) + import (cp306); account-switch via guardSwitch sign-out modal (cp305); QR-pair BOTH sides clean (ScanLoginQr phase machine handles camera-denied/no-camera/invalid-QR/not-unlocked terminal states; LoginQrInitiator one-tap regenerate on expiry, "no scary error", paired-readonly boot; /pair protocol handler falls back to home on malformed payload); post/create (6-phase, frozen fee enum, proof validation, never-hang — F-005 fixed); post/edit (separate impl, not_found/not_yours/not_live/window_expired/save_error states, idempotent same-permlink so no F-005 risk); my/orders (loading/ready/error, two-step cancel confirm, per-order error + finally un-stick, idempotent cancel). One finding (F-005) fixed; rest clean.
- ☑ Sally-user — no crypto. COMPLETE. Onboarding (generate path) CLEAN; register-name CLEAN (F-004 fixed). backup-keys CLEAN (locked/posting-only/paired each handled, seed-decrypt wipes the copy, download try/catch/finally). orderbook browse CLEAN (phase loading/ready/error with retry; side/min_trades/sort selects all wired + correctly mapped to the indexer enum incl. the goods/barter variants; moderation transparency line; read-only register banner grandma-friendly). Feedback path WIRED end-to-end (banner→LeaveFeedbackForm→
morphit_feedback_v1→dispatcher→feedbackHandler / feedbackResponseHandler→RespondToFeedbackForm + explorer decoration). The post/create-order placement flow is the same impl Bob traced (6-phase, never-hang, F-005 fixed); feedback round-trip reads through the wired handler chain above. - ☑ Sally-operator — node setup purely from the .md docs. COMPLETE. Doc-accuracy audit of RUN-A-MORPHIT-NODE.md: all referenced
MORPHIT_*env vars are real (now gated by F-006); referenced files exist;npm run migrateexists; "23 steps" ==TOTAL_STEPS=23; everymorphit-ops <cmd>reference is a real subcommand. Install-sequence ordering traced + sound: nginx+certbot installed but cert deferred until DNS + nginx config exist (the doc explicitly states the prerequisite);npm run build --workspacesbefore deploy; DB setup beforenpm run migrate; SSL viamorphit-ops ssl setupafter the domain resolves. BunkerWeb path coherent (frontend container serves whole site, SvelteKit build mounted read-only not copied, relay/indexer bind for the 172.20.0.0/16 Docker bridge with matching UFW rules). Home-hosting/CGNAT path documented via Dynu DDNS + VPS→Pi migration note. Found + fixed F-007 (compose relay-key var names) + built F-006 gate. Host-gated remainder: the live on-host install itself. - ☑ Josie — sysadmin, morphit-ops daily usage (every menu item). COMPLETE. Smoke battery green; color + #16 fixed; graceful-degradation traced: status.ts wraps each probe (DB / relay / indexer / systemd) in try/catch (lines 292/305/314/329) so a down service or unreachable DB degrades to a reported-unavailable line, never a crash; #16 DB-URL fallback removes the prior "No database URL" dead-end; health-view + status-backups smokes gate it. Host-gated remainder: #15 (Matrix-alerts editing) needs Ken's host.
- ☑ Charlie — MCP agent, read-only (every tool + every parameter). Complete: 5 tools bounded + SSRF-guarded, 53 smoke scenarios, version drift fixed.
Feedback path to verify in each: /my/orders → banner → form → morphit_feedback_v1 → indexer → profile → feedbackResponse_v1.
Deep-deep task categories (A–L, ≥94 tasks)
- ☑ A. Static code (dead code, unwired, orphans, TODO/FIXME/draft markers) — no markers; smoke-registration clean
- ☑ B. Deps / supply-chain (pins, no audit-fix, lockfile integrity) — ranges+lock+sync/satisfaction/audit gates
- ☑ C. SQL / DB (dead fields, migrations, injection, indexes) — both-direction drift clean; injection clean
- ☑ D. HTTP / API (handler hostile-op sweep, error shapes, never-hang) — 17 handlers + relay HTTP + MCP
- ☑ E. Crypto / keys (zeroing, KDF floors, WIF/base58, no key leak) — argon2id + memzero; no leak
- ☑ F. Privacy (no IP leak, no external fetch, CSP, view-key env-only) — IP/CSP/TX-proof-not-viewkey
- ☑ G. Operator-trust (fee math, treasury, block enforcement) — 90/10 exact, 100/0 BTC/XMR, frozen enum
- ☑ H. Frontend (a11y, mobile-responsive, UX, never-hang, grandma-friendly) — viewport/responsive/a11y-gated
- ☑ I. Contracts / parities (smoke ↔ code, env ↔ schema, locale parity) — smoke-reg + FAQ-locale + env clean; F-006 gate BUILT + registered (battery 358)
- ☑ J. Build / CI (tsc clean, smokes wired, gates current) — FULL pre-tarball battery GREEN: tsc 11/11 workspaces, svelte-check 0/0, vitest (web 730 / ops-cli 24 / indexer 486 / relay 268), full 358 smoke battery all pass (chunked + 2 meta-runners covered directly); version held at beta.23 (consistency 19/19); F-010 (stale llms-full.txt) found + fixed.
- ☑ K. Threat modeling (chain-direct attack patterns across handlers) — covered by D hostile-op sweep
- ☑ L. Per-subsystem deep reads (relay, indexer, web, ops-cli, mcp, matrix-bot, packages) — all swept
- ☑ M. Docs accuracy (README, OPERATIONS, RUN-A-MORPHIT-NODE, FAQ, all docs/*.md, broken refs) — F-007/F-009 fixed, gates present
Findings log
F-004 — register-name "register from Settings" is a broken promise · UX/COPY · FIXED (cp308)
The relay_out_of_funds registration error (all 10 locales) told a generate-and-
skip Sally to "skip for now and register from Settings." But Settings has NO
account-CREATION path — only an on-chain name VERIFICATION section
(needsAccountNameBanner, for seed/keyfile imports whose keys are already on
chain). A Sally who generated keys and skipped has no on-chain account, so
Settings is a dead end → exactly the "left wondering wtf" failure. The real
re-registration path is the orderbook (and post / my-orders) banner — skipForNow
even navigates to /orderbook, whose $isUnlocked && viewerAccount === null
banner shows a clear 👋 register CTA. Fix: corrected the copy in all 10 locales
to "…register when you're ready to trade" (parity 10/10, completeness 4/4), and
fixed the inaccurate skipForNow comment that claimed "Settings will nudge them."
RECOMMENDATION — DECLINED by Ken (cp308). Do NOT add a Settings register
affordance. The corrected copy + the orderbook register banner are the path;
leave Settings as verification-only. (Keeping this noted so no future session
re-proposes or builds it.)
F-001 — mcp-server version string not drift-gated · DRIFT · FIXED (cp308)
apps/mcp-server/src/main.ts advertised version: '1.0.0-beta.23' as an inline
literal in the SDK server-info handshake. The version-consistency smoke gated the
relay/indexer health.ts constants and every package.json, but NOT this string —
so a release bump could silently leave the MCP server reporting a stale version.
Fix: hoisted to const MCP_VERSION and added it as a Category-B touchpoint in
apps/web/scripts/version-consistency-smoke.ts. Smoke 18→19, mcp-server tsc 0.
F-005 — post/create-order had no mid-broadcast navigation guard · UX/SAFETY · FIXED (cp308)
register-name cancels navigation while its account-creation op is broadcasting,
but the post (create-order) flow did not — its broadcasting phase was just a
spinner with the global nav still live. Order permlinks are RANDOM per attempt
(makeOrderPermlink), so a user who navigates away mid-broadcast (unsure whether
it landed) and re-posts creates a DUPLICATE on-chain order. Fix: added a
beforeNavigate guard that cancels navigation while phase === 'broadcasting',
mirroring register-name. svelte-check 0/0. (Pre-broadcast awaiting_password is
intentionally NOT guarded — nothing has hit the chain yet, and the draft system
preserves the in-progress order.)
F-006 — no doc↔code parity gate for env vars · GATE GAP · FIXED/BUILT (cp308)
The operator docs (RUN-A-MORPHIT-NODE.md, OPERATIONS.md) prescribe ~118 MORPHIT_*
env vars in fenced examples. operator-doc-fenced-path-existence-smoke gates fenced
PATHS, but NOTHING gated doc-referenced env-var NAMES — so the F-007 drift class
(doc prescribes a var the code never reads → relay won't boot) had no gate. Manual
cross-check was CLEAN; BUILT the gate this turn.
BUILT: scripts/operator-doc-env-var-parity-smoke.ts (registered .:operator-doc-env-var-parity-smoke,
battery 357→358). Design — every false-positive class mapped before writing:
- Extracts
MORPHIT_*only from FENCED code blocks (…), not prose. This is what makes it robust: the F-007 caveat NAMES the wrong vars in prose precisely to warn against them, andMORPHIT_RELAY_KEYSTORE_PATHsurvives only there now — fenced-only extraction correctly ignores it. Prose design/roadmap vars (DAILY_RECIPIENT_CAP_USD, GLOBAL_TPM_CEILING) are likewise ignored. - Known universe =
MORPHIT_*across apps/ packages/ ops/ scripts/ (305 vars). - DYNAMIC_PATTERNS =
^MORPHIT_FAIL2BAN_[A-Z0-9]+_(CRITICAL|WARN)$— the fail2ban monitor builds per-jail names at runtime (MORPHIT_FAIL2BAN_<JAIL>_CRITICAL), so e.g.…_SSHD_CRITICALis a valid prescribed override a static grep can't see. - DOCUMENTED_BUT_UNIMPLEMENTED allowlist (2, each with rationale): the compose
*_FILEDB-secret pattern (INDEXER/RELAY_DB_PASSWORD_FILE), documented with an explicit not-yet-implemented caveat. TESTED: passes (✓ all 118); tamper-test (inject fake var into a fenced block → ✗ 1/119; revert → ✓ 118, no residual diff); registration-integrity green (358/358, 351 smoke files all registered). The command-name half was intentionally NOT built —morphit-opssubcommand drift is already covered by the ops-cli dispatcher tests, and a doc-command extractor risks prose false-positives for little marginal gain.
F-007 — docker-compose example named relay key vars the relay never reads · DOC/OPS · FIXED (cp308)
OPERATIONS.md "Compose example" (relay service env block, ~line 6031) set
MORPHIT_RELAY_KEYSTORE_PATH: /run/secrets/relay_keystore and
MORPHIT_RELAY_PASSPHRASE_FILE: /run/secrets/relay_passphrase — NEITHER name is
read anywhere in the relay. The relay reads its key from MORPHIT_RELAY_ACTIVE_KEY_FILE
(REQUIRED, z.string().min(1), apps/relay/src/config/index.ts:65) and the unlock
passphrase from MORPHIT_RELAY_ACTIVE_KEY_PASSPHRASE_FILE (apps/relay/src/config/unlock.ts:77).
An operator copying the compose verbatim would set two ignored vars, omit the REQUIRED
…ACTIVE_KEY_FILE, and the relay would FAIL TO BOOT (config validation). Worse, the
pre-existing caveat block wrongly lumped both into the "not yet implemented *_FILE
secret pattern" — but the relay key+passphrase-from-file ARE implemented; only the
DB-password *_FILE vars (MORPHIT_INDEXER_DB_PASSWORD_FILE, MORPHIT_RELAY_DB_PASSWORD_FILE)
are unimplemented (confirmed: no consumer anywhere). FIX: renamed the two compose
vars to the real implemented names (…ACTIVE_KEY_FILE / …ACTIVE_KEY_PASSPHRASE_FILE,
pointing at the same mounted secret paths; docker secrets: labels unchanged) and
rewrote the caveat to split the two classes — DB-password *_FILE NOT implemented
(inline creds in DATABASE_URL meanwhile) vs. relay key+passphrase-from-file IS
implemented and works as shown — plus a parenthetical recording the old wrong names
and that copying them bricks the relay. Verified post-fix: wrong names survive only in
the explanatory parenthetical; compose block uses the real required names.
F-008 — loyalty.ts comment calls the first-trade bonus a "10 BP delegation" · DRIFT (comment) · FIXED (cp308)
apps/indexer/src/indexer/loyalty.ts docstring (above the first-fee welcome)
described the first-COMPLETED-trade reward as "the existing 10 BP delegation that
fires on first COMPLETED trade … both add to the cumulative delegation target."
But feedback.ts:435-436 enqueues that reward as liquid 10 + vesting 10 — a
10 BLURT liquid transfer + 10 BLURT Power (vesting) the user OWNS, NOT a delegation
and NOT "10 BP", and it does NOT feed the cumulative DELEGATION target (only the
first-fee 1 BP welcome + the loyalty milestones are delegations, which replace-not-add
per client.ts:595 and so accumulate via the queue). Same delegation/vesting
confusion the prior campaign (beta.19) fixed in the user-facing FAQ but missed in
this internal comment. Comment-only (no behavior change, no smoke needed). The FAQ
itself is verified ACCURATE. FIX: rewrote the comment to describe the first-trade
reward as owned liquid+vesting and clarify only the delegations accumulate.
F-009 — README "~320 runners" self-check count stale · DRIFT (doc) · FIXED (cp308)
README.md quick-start step 6 cited "320 runners" but ") and
NOT gated by any smoke (cross-document-value-invariants doesn't cover it), so it
drifted as the battery grew (cp183 had corrected it to ~266). FIX: updated to
"~357 runners". Doc-only, no behavior change.run-smokes.sh registers
357 entries (the documented battery). The count is an approximation ("
F-010 — committed llms-full.txt stale vs current FAQ · DRIFT (generated artifact) · FIXED (cp308)
The pre-tarball full-battery run surfaced llms-full-freshness-smoke failing: the
committed apps/web/static/llms-full.txt (the AI-readable whole-site FAQ dump) had
drifted from en.json — ~137 sections changed + footer "136 entries" vs en.json's
138 (the cp303 FAQ work added/edited entries; the committed dump was never rebuilt).
Production is unaffected (the web prebuild regenerates it via build:llms-full), but
the committed copy was stale. FIX: ran node scripts/build-llms-full.mjs → 138
entries / 225583 chars; freshness smoke now ✓ all 6. Generated artifact, no hand-edit.
Verified CLEAN this chunk (cp308) — no findings
-
G operator-trust (fee math/treasury/enum) — CLEAN.
OPERATOR_BLURT_SPLIT_PERCENT=90, treasury = exact remainder via integer milliBLURT math (always sums to 100%, no float drift),computeOperatorShareBlurtvalidates finite-and-positive, per-eventsplit_percent_at_eventpreserves history; BTC/XMR → 100% treasury; treasury =morphit-fees; fee_method enum frozen (asset-registry comments + the frozen-enum smoke). -
A static code — CLEAN. No TODO/FIXME/HACK/XXX/unimplemented/stub markers in shipping src across all workspaces (all "placeholder" hits are legitimate txid templates / password sentinels / default placeholders).
-
B deps/supply-chain — CLEAN. Standard posture:
^/~ranges in manifests + exact pins inpackage-lock.json;deps-pin-checkverifies installed satisfies declared;lockfile-sync-smokeenforcesnpm cisync;npm-audit-gate-smoke; standingaudit fix --forceban. -
E crypto — CLEAN. KDF = argon2id (modern; INTERACTIVE tier is the right client/mobile choice — SENSITIVE's ~1GB is infeasible on phones); comprehensive
sodium.memzero()of keys/plaintext/CEK/wrapKey after use; WIF/base58 verified earlier (Bob/Charlie); no key leak in logs. -
F privacy — CLEAN. IP never logged/persisted (ip.ts, brag #14 verified); Monero verification uses per-payment TX-PROOFS not a wallet view key (no persistent view-key config anywhere; code notes proofs "strictly less leaky"; not logged); CSP gated (
csp-header-consistency-smoke). -
H frontend a11y/mobile/grandma — CLEAN. Correct mobile viewport (
width=device-width…viewport-fit=cover); responsive breakpoints, no fixed-width layout traps; touch-sized padding across ~44 components; a11y gated by a11y-patterns + color-contrast + heading-hierarchy smokes; persona-verified never-hang + grandma copy. -
L matrix-bot + packages — CLEAN. matrix-bot is send-only (no inbound Matrix command handler; tails journalctl + pushes alerts over a loopback sidecar with dedicated input-hardening/json-str-injection/sidecar-envelope smokes); packages carry no unfinished markers.
-
M docs broken-ref gates — present + README spot-check. fenced-path-existence
- section-ref + external-link-hygiene gates cover doc refs; README falsifiable claims accurate post-F-009 (version series, apps/locale descriptions); OPERATIONS (F-007) + RUN-A + FAQ + brag-list verified earlier.
-
Stale/orphaned-gates check (smoke registration integrity) — CLEAN. All 350 smoke files (324 in scripts/apps + 26 in packages/*/scripts) are registered in
run-smokes.sh's SMOKES array; all 357 registered entries (350 smoke + 7 meta-runners: 2 lints, xml-validate, live-fire, 2 deps-pin, 2 noble-signer proofs) resolve to existing files; 0 orphans, 0 dangling, 0 duplicates; count (357) matches the documented battery; a dedicatedsmoke-registration-integrity-smokegates this with an explicit deliberate-exclusions allowlist. (Two false leads from my own tooling were caught + reconciled before recording — a grep that kept.tsvs the runner's extensionless names, and afindthat skippedpackages/— not code defects.) -
Efficiency / query sweep + INTERVAL-injection check — CLEAN. No problematic N+1: the per-row
INSERT INTO fee_transfers/account-create loops in dispatcher.ts are bounded by block size (small on Blurt), are writes (prepared-stmt amortized), and carry a documented clarity-vs-multirow tradeoff. Signal/anomaly + native-price queries are WINDOWED (WHERE created_at >= NOW() - INTERVAL 'N ...'), not full-table; orderbook hot path uses theorders_live_idxpartial index; chat/order existence checks areSELECT 1 … WHERE-indexed. Bonus SQL-injection surface: everyINTERVAL '${…}'interpolation uses a trusted numeric —SIGNAL_*_WINDOW_DAYSconsts (=7 etc.),Math.floor(...)integers (federationProbe), and TS-typednumberconfig defaults (windowHours/graceMinutes) — so no injection (extends the parameterized-query result: the ONLY SQL interpolations anywhere are constant savepoint names + numeric intervals). -
Brag-list claim spot-check (concrete/high-stakes claims) — CLEAN. Verified against code: #14 (IP never logged/persisted/transmitted — ip.ts only transforms the IP into a /24 or /64 rate-limit bucket key, no console/fs/db/fetch; bucket evicted after the window per the mem-leak sweep) ✓; #32 jitter bounds (≤999 satoshis UTXO =
jitterUtxoAmount, ≤99 milliblurt Blurt =jitterBlurtAmount, plus the XMR/stablecoin/SOL/ETH family) ✓; #33 address-reuse buffer (MAX_ENTRIES=200rolling, localStorage-only, never transmitted = addressHistory.ts) ✓. Concrete numbers match the implementation; the "claims verifiable in code" rule holds for the sampled claims. -
Delegation/vesting/transfer drift class — fully swept (docs + code comments). Repo-wide grep of
delegat*/ welcome-bonus / "10 BP" against the ground-truth enqueue kinds (feedback.ts first-trade = liquid+vesting; loyalty.ts first-fee + milestones = delegation): the user-facing FAQ + operator docs are accurate (the AUDIT-2026-05.md hits are the prior fix's audit log, not live drift); F-008 (the loyalty.ts comment) was the only live instance, now fixed. -
FAQ accuracy spot-check (welcome_bonus deep, kyc/safety) — CLEAN; one code-comment drift fixed (F-008). Reconciled the
welcome_bonusFAQ (the highest-factual-risk entry, and the one a prior campaign fixed for delegation/vesting confusion) against the code: all three rewards are accurately characterized — reward #1 (first-fee 1 BP) IS adelegation(loyalty.ts:206), reward #2 (first trade) isliquid10 +vesting10 = "10 Blurt liquid + 10 Blurt Power" (vesting = owned, correctly NOT called a delegation; feedback.ts:435-436), reward #3 (loyalty milestones) isdelegation. 10-locale parity holds (entry present + non-empty in all; zh shorter is normal density).kyc_requirement+is_it_safeaccurate. The FAQ is correct — the only drift was a code comment (F-008, fixed). -
Fallbacks / graceful-degradation sweep — CLEAN. Price feed (
CompositeCachedPriceSource):current()always returns a positive number (validatedstaticFloor > 0floor), upstream chain tried in priority order, all-fail → last-good served withstale=true, never-succeeded → static_floor withsource='static_floor'+stale=true(staleness EXPOSED, not hidden), refresh failures logged not propagated, Defense-B drift bound caps a compromised oracle. Chain poller:cooling_downbackoff on transient RPC errors,last_applied_blockadvanced in the SAME DB tx as block effects (mid-block failure rolls back both — no skipped/partial blocks; resumes from the DB cursor on restart),tick_failedlogged+backed-off not crashed, fatal/transient split (chain_id mismatch → fatal throw, won't index the wrong fork), abort signal threaded through every sleep. Joins the already-verified relaychain_unavailable503 + timeout-guarded monero/bitcoin fee verifiers. -
DB dead-field + schema-drift sweep (schema.sql ↔ code) — CLEAN both directions. (1) Extracted all 169 distinct column names across ~40 tables and grepped every non-test
.ts/.svelteacross all 6 workspaces: 0 columns never referenced by name — no vestigial/abandoned columns. (2) Parsed everyINSERT INTO <table> (cols)in indexer code and checked each column against the table's schema columns (incl.ALTER TABLE ADD COLUMN): 0 mismatches — no code writes to a nonexistent column (no broken-write drift). (Scope note: finer write-only vs read-only sub-classification isn't statically separable here — some reads useSELECT *, some column lists are built dynamically; the two checks above are the reliable ones and both are clean. Schema mixes upper/lower-case type keywords across tables — a parser-case footgun, not a defect.) -
Memory-leak / unbounded-growth sweep (relay + indexer + matrix-bot) — CLEAN. Every PERSISTENT in-memory structure is explicitly bounded: relay rate-limiter
bucketsMap (janitor sweep deletes empty keys + per-key in-place eviction +stop()); create-handlerdedupe[](evictStaleDedupe()compacting filter, 60s window);sequentialDetector.records[](prune()by age +MAX_RECENT_SIGNUPS=5000cap); inviteconsumedNoncesMap (janitor-swept); indexer rate-limiterbucketsMap (janitordelete);loginPairing.entriesMap (30s janitor + 5-minPID_TTL_MAX_MS- delete on expiry/success/sweep,
.unref()+stop());operatorAccountBalanceScanner.state blurt clientCachebounded by the finite operator/endpoint sets. Per-requestsetTimeoutabort timers fire once (not leaks); daemonsetIntervals (price monitors, janitors) are intended loops carrying.unref()/stop hooks. Function-scope arrays are GC'd on return.
- delete on expiry/success/sweep,
-
Category-B relay HTTP attack surface (anonymous network-direct) — CLEAN.
/v1/account/create(spends an ACT → the Sybil/drain target) is defended in depth: bounded Zod body, per-IP burst limiter + per-IP daily limiter + global daily ceiling with ATOMICtryReserve()andreleaseReservation()infinally(author reasons explicitly about the count=ceiling-1 race), signed-invite gate, and real pubkey validation (isValidPublicKey=BLTprefix +PublicKey.fromStringbase58/checksum, not a stub) — ordered so policy-rejected inputs never reach the signing path./v1/account/invite(the Sybil entry point) carries a tighter per-IP issuance limiter + an ALTCHA proof-of-work bond triggered past a per-IP daily threshold. Invite tokens: HMAC-SHA256 signed,timingSafeEqual-compared, single-use via a janitor-swept (bounded) nonce map, expiry-checked, verify/consume separated so a failed create doesn't burn the invite. ALTCHA: challenge HMAC authenticated (timing-safe), PoW recomputed server-side, salt-replay protected. All cryptographically real, not theater. -
Category-A hostile-op handler sweep (all 17 indexer handlers) — CLEAN. Trust boundary
extractSigner(src/blurt/verify.ts) yields a single chain-authenticated signer: rejects non-array auths, active-auth ops (required_auths.length>0— no leveraging a hotter key), zero posting-auths, and >1 posting-auths (no ambiguous attribution).parseJsonPayloadrejects non-string / over-MAX_RAW_JSON_LENGTH/ unparseable JSON. Dispatcher coerces auths→[] and json→'' defensively, skips unknown op-ids, swallows handler rejections (only unexpected throws roll back). Cross-handler greps: every query parameterized (the only interpolation is the hardcoded'welcome_bonus_sp'savepoint name — identifiers can't be bound, not attacker-derived); authz is alwaysctx.signer(theb.from !== signerchecks in featureBid/strangerFee/order are correct guards skipping transfers/ops not from the authenticated signer); chatIdentity regex-validates base64 before the lenientBuffer.fromdecode. Deep-read of the highest-risk handler (order.ts, 996L):MAX_AMOUNT=1e12+MAX_EXPIRES_AT_DAYS=365caps,Number.isFiniterejects NaN/Infinity, negative/min>max/oversize all rejected,price_modelsize-bounded + shape-validated against "chain-direct abuse" (author's own term). -
Bob QR-pair + my/orders + post/edit: ScanLoginQr + LoginQrInitiator + /pair all terminal-state-complete and grandma-friendly (one-tap QR regenerate, no scary errors, camera-permission states handled); my/orders cancel has a generic fallback +
finallyun-stick and is idempotent; post/edit has a full invalid-state phase machine and is idempotent (same permlink). -
backup-keys route: locked → clear error (no hang); posting-only and paired-readonly each get a handled section + deep-link; seed reveal decrypts a fresh FullIdentity and wipes it after use; keyfile download try/catch/finally.
-
orderbook browse + filters: phase machine (loading/ready/error+retry), debounced + cancellable fetches; side/min_trades/sort
<select>options all wired and mapped to the indexer enum (goods/barter variants translate to buy/sell before query — no drift); moderation transparency line; read-only register banner is grandma-friendly. -
feedback path: fully wired — banner + form lazy-loaded in /my/orders →
morphit_feedback_v1(config.ts + feedback.ts, signed) → indexer dispatcher → feedbackHandler + feedbackResponseHandler → RespondToFeedbackForm + explorer decoration. No unwired links.
Tool-dispatch envelope (main.ts CallToolRequestSchema) verified never-hang:
unknown tool → isError; Zod parse failure → caught → isError with message;
handler throw → caught → isError: Tool error: <msg>. Stateless HTTP rebuild
per request (no session leak). Remaining: per-tool input-schema + handler reads
(searchOrders, listInstances, listPaymentMethods, getListing, describeMorphit) —
validation bounds, SSRF guard in indexerClient, output shape stability.
Charlie (MCP) walkthrough — ☑ COMPLETE (cp308)
All 5 tools have bounded Zod schemas (asset/side/sort enums, min/max strings,
limit 1–100, min_trades ≤100) — every field/option Charlie can try is validated;
invalid input → isError, never a hang. indexerClient.ts SSRF guard verified:
https/http-only scheme check, private-address denylist (cp154 F-mcp-1, opt-out
via env), credential-stripping in error messages, body-size cap. Stateless HTTP,
loopback bind, rate limit, slowloris timeouts, path allowlist (§45). Coverage: 5
smokes all green — private-instance-policy 22, mcp-server 8, fetchjson-body-cap 3,
mcp-http-transport 12, agent-field-allowlist 8 (= 53 scenarios). tsc 0. One
finding fixed (F-001 version drift). NO issues left on Charlie's surface.
F-002 — morphit-ops Status dashboard "No database URL configured" (#16) · FIXED (cp308)
Root cause: defaultRepoRoot() (apps/ops-cli/src/lib/repoRoot.ts) derived the
install root by walking UP from process.cwd() only. Run morphit-ops from
anywhere outside the install tree (e.g. operator's home dir) → the walk finds no
workspaces package.json, falls back to the cwd → loadInstanceEnv() looks for
morphit.config.env/morphit.env in the wrong place → DB URL (and other infra
env) never loaded → Status dashboard throws. Fix: when the cwd-walk doesn't land
on a workspaces root, fall back to walking up from THIS module's own location
(import.meta.url), which is always inside the installed tree (dist or src),
with the same stale-.bak recovery applied. Verified: repo-root-bak-recovery 6/6
(all .bak cases preserved), instance-env-loader 14/14, ops-cli tsc 0.
RESIDUAL (deployment data, not code): <install>/morphit.env must contain
MORPHIT_INDEXER_DATABASE_URL (or DATABASE_URL) for the loader to apply it;
the infra-env path is loaded unfiltered, the allowlist applies only to the
operator-tunable morphit.config.env.
F-003 — morphit-ops color stripped on the interactive menu · FIXED (cp307, re-confirmed cp308)
(See cp307.) The menu rendered before initColor ran; fixed with
initColorMode(readColorMode()) before the menu. The "update available"
bright-yellow marker + relay-balance warnings now colorize. ops-cli vitest 24.
#15 Matrix alerts editing — DIAGNOSED (cp307/cp308), implementation deferred
apps/ops-cli/src/commands/matrix.ts edits ONLY the alert-MXID line in
/etc/morphit/matrix-bot.env and requires that file to already exist (else
no-env-file). It does NOT edit the public chatroom alias (separate indexer
config / /v1/instance). Ken wants: edit + clear MXID AND chatroom, and set the
MXID even before the bot is installed. Needs careful work on host file layout +
live verification (interactive CLI not runnable in-sandbox) — tackle on Ken's box.
Josie (ops-cli) walkthrough — ◐ in progress (cp308)
Full smoke battery GREEN (~47 smokes: install/upgrade/edit/matrix-lifecycle/ status-backups/health-view/doctor/moderation/ansible/term-sanitize/menu- annotations/…). Color fix (F-003) verified. #16 (F-002) FIXED. #15 diagnosed, deferred. Remaining for full completion: per-command graceful-degradation trace (every menu item when the node is half-installed / DB down / chain unreachable — confirm none leave Josie hanging), and the #15 implementation.