From 90884e6259081ba85a93919e2069e399dbf7b6a1 Mon Sep 17 00:00:00 2001 From: archipelago Date: Sat, 1 Aug 2026 06:37:00 -0400 Subject: [PATCH] feat(01-02): chat mutations mutate demo state instead of acking (FED-04) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reactions, replies, read-receipts, edits, deletes, forwards and channel sends shared one bare `{ ok: true, sent: true }` case, so none of them rendered on the demo — the UI derives reaction chips and reply quotes from the message store, and there was nothing in it to derive from. Each now mirrors its daemon counterpart. Reactions/replies/receipts push typed messages carrying the { sender_pubkey, sender_seq } target key Mesh.vue's reactionIndex and replyTargetPreview read. Edits rewrite the text and set edited_at; deletes tombstone IN PLACE (plaintext, typed_payload.deleted, message_type 'delete') because that is what mesh/mod.rs apply_local_delete does — it does not remove the row. Edits and deletes go through a per-session overrides overlay keyed by sender_seq, because mesh.messages rebuilds its seed array on every read, so in-place mutation would only ever work for messages sent this session. mesh.refresh and mesh.reboot-radio stay acknowledgements on purpose — the daemon's handlers have no message-store effect either — with a comment saying so, so a later reader does not "fix" them into divergence. Also completes the phase bookkeeping for 01-02/03/11/12/13/14/15 and lands the orphaned 01-12/01-14 SUMMARYs. Verified: parity harness 17/17 live assertions; full frontend suite 102 files / 822 tests green. Co-Authored-By: Claude Opus 5 (1M context) --- .planning/REQUIREMENTS.md | 24 +-- .planning/ROADMAP.md | 28 ++- .planning/STATE.md | 20 +- .../01-02-SUMMARY.md | 150 ++++++++++++++ .../01-12-SUMMARY.md | 172 ++++++++++++++++ .../01-14-SUMMARY.md | 188 ++++++++++++++++++ neode-ui/mock-backend.js | 181 ++++++++++++++++- neode-ui/scripts/mock-rpc-parity.mjs | 64 +++++- 8 files changed, 786 insertions(+), 41 deletions(-) create mode 100644 .planning/phases/01-federation-mesh-hardening/01-02-SUMMARY.md create mode 100644 .planning/phases/01-federation-mesh-hardening/01-12-SUMMARY.md create mode 100644 .planning/phases/01-federation-mesh-hardening/01-14-SUMMARY.md diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 43b15b44..90e7e41c 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -15,20 +15,20 @@ declared exit criteria (multinode pass + workstreams B/C/F), `.planning/codebase - [ ] **FED-01**: Removing a federation node sticks — it disappears from every UI surface, tombstones propagate, it never reappears via later sync cycles, and a failed removal surfaces an error (never a silent no-op) - [ ] **FED-02**: Federation sync converges and is observable — after sync settles, fleet nodes agree on the node list with fresh status; stale entries, duplicates, and silent sync failures are eliminated and sync errors are operator-visible - [ ] **FED-03**: A structured code review of the federation/fleet area (`core/archipelago/src/federation`, node sync, FIPS/transport dial layer) and mesh area (`core/archipelago/src/mesh`, mesh RPC surface) is completed, with every finding fixed or explicitly deferred with a reason -- [ ] **FED-04**: Mesh messaging parity — attachment send (and the rest of the mesh chat surface) behaves identically on the demo and on real nodes: the demo backend implements the same RPC surface the UI calls, transport decisions mirror the real size-based tier logic, and no demo-only modals exist +- [x] **FED-04**: Mesh messaging parity — attachment send (and the rest of the mesh chat surface) behaves identically on the demo and on real nodes: the demo backend implements the same RPC surface the UI calls, transport decisions mirror the real size-based tier logic, and no demo-only modals exist - [ ] **FED-05**: Inter-node Lightning channel opening UX — the UI shows the node's shareable Lightning URI; lists trusted (federated) nodes by hostname for one-click channel opening; and lets the user browse/request channels with public nodes — using the existing design system and components, verified on the :8100 dev preview against archi-dev before deploy -- [ ] **FED-06**: On-brand payment success animation — the invoice "paid" tick's circle uses the screensaver-style ring with outer EQ-segment lines (reuse `ScreensaverRing.vue`'s compact size) in place of the current success burst, applied consistently everywhere the paid tick shows +- [x] **FED-06**: On-brand payment success animation — the invoice "paid" tick's circle uses the screensaver-style ring with outer EQ-segment lines (reuse `ScreensaverRing.vue`'s compact size) in place of the current success burst, applied consistently everywhere the paid tick shows - [ ] **FED-08**: Lightning invoices created by the wallet embed route hints (LND `private` flag) so nodes whose channels are unannounced can actually receive payments — diagnosed on archy-x250-mad2 2026-07-31, where every wallet-UI invoice had `route_hints: []` and was unroutable; the bug is unconditional and affects any node without a public channel - [ ] **FED-09**: The container doctor does not restart Tor on every run — it recognises Tor's own setgid `2700` hidden-service directory mode as correct rather than "fixing" it to `700` and restarting, a loop that reset Tor every ~5 minutes, starved it of its consensus/HSDir cache (`No more HSDir available to query`), and broke the mesh's Tor fallback entirely; genuinely permissive modes are still corrected, and a restart backoff makes the failure class non-recurring -- [ ] **FED-07**: Fedimint gateway never installs with a pre-set password — gateway credentials are generated per-install via manifest-declared `generated_secrets` (or explicitly set by the user), never baked into the image/manifest; existing installs with the default password get a migration path (BLOCKER — default credentials are a security hole) +- [x] **FED-07**: Fedimint gateway never installs with a pre-set password — gateway credentials are generated per-install via manifest-declared `generated_secrets` (or explicitly set by the user), never baked into the image/manifest; existing installs with the default password get a migration path (BLOCKER — default credentials are a security hole) ### UI Fixes (UIFIX) — user-reported blockers, added 2026-07-30 - [ ] **UIFIX-01**: The FIPS/Tor pills on cloud files are kept (never removed by cleanups) and render at mobile widths — on mobile, users can see each file's security/transport state (BLOCKER) -- [ ] **UIFIX-02**: The connected-nodes list scrolls at row-matched height — its height tracks the taller right-hand sibling in the row and the inner list scrolls within it, never growing to fit all rows scroll-free (BLOCKER) -- [ ] **UIFIX-03**: On short viewports the onboarding confirmation tickbox is discoverably visible — an on-brand affordance (scroll cue, sticky footer, or equivalent) makes it obvious without altering tall-screen appearance (BLOCKER) +- [x] **UIFIX-02**: The connected-nodes list scrolls at row-matched height — its height tracks the taller right-hand sibling in the row and the inner list scrolls within it, never growing to fit all rows scroll-free (BLOCKER) +- [x] **UIFIX-03**: On short viewports the onboarding confirmation tickbox is discoverably visible — an on-brand affordance (scroll cue, sticky footer, or equivalent) makes it obvious without altering tall-screen appearance (BLOCKER) - [ ] **UIFIX-04**: Paid Files pictures open in the app's lightbox, not a browser tab — consistent with the rest of the app's media UX -- [ ] **UIFIX-05**: Picture-in-picture is robust — entering PiP closes the lightbox with a fluid on-brand animation, and an active PiP session survives main-tab changes and video buffering pauses (only an explicit user stop ends it) +- [x] **UIFIX-05**: Picture-in-picture is robust — entering PiP closes the lightbox with a fluid on-brand animation, and an active PiP session survives main-tab changes and video buffering pauses (only an explicit user stop ends it) - [ ] **UIFIX-06**: Surfaces with genuinely slow opens show house-style loader states — no dead-feeling clicks (cached revisits stay spinner-free per PERF-02) ### UI Performance (PERF) @@ -109,17 +109,17 @@ Which phases cover which requirements. Updated during roadmap creation. | FED-01 | Phase 1 | Pending | | FED-02 | Phase 1 | Pending | | FED-03 | Phase 1 | Pending | -| FED-04 | Phase 1 | Pending | +| FED-04 | Phase 1 | Complete | | FED-05 | Phase 1 | Pending | -| FED-06 | Phase 1 | Pending | -| FED-07 | Phase 1 | Pending | +| FED-06 | Phase 1 | Complete | +| FED-07 | Phase 1 | Complete | | FED-08 | Phase 1 | Pending | | FED-09 | Phase 1 | Pending | | UIFIX-01 | Phase 1 | Pending | -| UIFIX-02 | Phase 1 | Pending | -| UIFIX-03 | Phase 1 | Pending | +| UIFIX-02 | Phase 1 | Complete | +| UIFIX-03 | Phase 1 | Complete | | UIFIX-04 | Phase 1 | Pending | -| UIFIX-05 | Phase 1 | Pending | +| UIFIX-05 | Phase 1 | Complete | | UIFIX-06 | Phase 1 | Pending | | PERF-01 | Phase 2 | Complete | | PERF-02 | Phase 2 | Complete. 02-11 (`02-FINDINGS.md` § Client-Side Render Cost Root Cause + § Task 3) named and fixed the real cause of Web5/Server's revisit-ms regressions — three leaked background pollers (`useFleetData.ts`, `FipsNetworkCard.vue`, `Web5Monitoring.vue`) armed in `onMounted` and never disarmed once their owning views joined `KEEP_ALIVE_PATHS`, gated to activate/deactivate. Web5 now fixed (275ms, below both its 566ms pre-phase-2 baseline and the 300ms pass bar); Server's regression is closed (574ms, below its 738ms baseline) though not yet under the 300ms stretch target — residual named as real, un-eliminated per-resource reactivation cost, not a new defect | diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index dda2ca2c..48356b63 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -53,15 +53,15 @@ signed/decentralized registry and a user installs it on their node. 13. Genuinely slow opens show loader states (UIFIX-06, added 2026-07-30 — see `.planning/todos/pending/2026-07-30-missing-loader-states-on-slow-opens.md`; 02-08's flagged timing regressions are the starting inventory) NOTE for criteria 7–13: all were added after phase 1's 10 plans were written — before phase 1 execution completes, create gap plan(s) covering FED-07 + UIFIX-01..06 (existing desktop visuals must remain untouched per the standing visual-invisibility rule; UIFIX items themselves are user-approved visual changes) -**Plans**: 2/20 plans executed +**Plans**: 8/20 plans executed Plans: - [ ] 01-20-PLAN.md — URGENT wave 1: doctor stops restarting Tor every 5min (mesh Tor fallback) (FED-09) - [ ] 01-19-PLAN.md — URGENT wave 1: wallet invoices embed route hints so private-channel nodes can receive (FED-08) - [x] 01-01-PLAN.md — Serialize the federation node store and make removal stick (FED-01) -- [ ] 01-02-PLAN.md — Demo mesh/federation RPC parity + automated parity harness (FED-04) -- [ ] 01-03-PLAN.md — On-brand paid tick: ScreensaverRing badge variant on both success surfaces (FED-06) +- [x] 01-02-PLAN.md — Demo mesh/federation RPC parity + automated parity harness (FED-04) +- [x] 01-03-PLAN.md — On-brand paid tick: ScreensaverRing badge variant on both success surfaces (FED-06) - [ ] 01-04-PLAN.md — Lightning identity: own-node URI + meshed Lightning peer discovery (FED-05) - [ ] 01-05-PLAN.md — Federation sync convergence and operator-visible sync errors (FED-02) - [ ] 01-06-PLAN.md — Lightning URI on the federation sync payload, sharing default decided (FED-05) @@ -72,11 +72,11 @@ Plans: **Wave 7** *(gap closure — criteria 7–13, added 2026-07-30 after the original 10 plans were written)* -- [ ] 01-11-PLAN.md — No baked-in Fedimint gateway credential: per-install secret on every path (FED-07) +- [x] 01-11-PLAN.md — No baked-in Fedimint gateway credential: per-install secret on every path (FED-07) - [x] 01-12-PLAN.md — Connected-nodes list scrolls at row-matched height instead of growing (UIFIX-02) -- [ ] 01-13-PLAN.md — On-brand scroll cue makes the onboarding tickbox findable on short screens (UIFIX-03) -- [ ] 01-14-PLAN.md — Paid Files open in the app lightbox, with a visible wait and a real error path (UIFIX-04/06) -- [ ] 01-15-PLAN.md — PiP hands off from the lightbox and survives tab changes and buffering (UIFIX-05) +- [x] 01-13-PLAN.md — On-brand scroll cue makes the onboarding tickbox findable on short screens (UIFIX-03) +- [x] 01-14-PLAN.md — Paid Files open in the app lightbox, with a visible wait and a real error path (UIFIX-04/06) +- [x] 01-15-PLAN.md — PiP hands off from the lightbox and survives tab changes and buffering (UIFIX-05) **Wave 8** *(blocked on Wave 7 completion)* @@ -235,7 +235,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 | Phase | Plans Complete | Status | Completed | |-------|----------------|--------|-----------| -| 1. Federation & Mesh Hardening | 2/20 | In Progress| | +| 1. Federation & Mesh Hardening | 8/20 | In Progress| | | 2. UI Performance | 11/12 | Complete | 2026-07-31 | | 3. Multinode Verification Pass | 0/TBD | Not started | - | | 4. Lifecycle Perfection & Quadlet Default | 0/TBD | Not started | - | @@ -244,6 +244,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 | 7. Developer Tooling CLI | 0/TBD | Not started | - | | 8. Decentralized Marketplace | 0/TBD | Not started | - | | 9. BotFights Platform Upgrade | 7/7 | Executed — awaiting human demo verification | 2026-07-31 | +| 10. Key-Material Hardening | 0/TBD | Not started — **priority override, see phase note** | - | ### Phase 9: BotFights Platform Upgrade @@ -261,3 +262,14 @@ Plans: - [x] 09-05-PLAN.md — Build+push botfights:1.2.0, roll the arena, prove cross-instance visibility (BOT-03/BOT-04) - [x] 09-06-PLAN.md — Manifest 1.2.0 with generated JWT secret + signed catalog republished (BOT-04) - [x] 09-07-PLAN.md — archi-dev-box deploy + demo rehearsal: real signer login, cloud bot from the prompt (BOT-01/02/03/04) + +### Phase 10: Key-Material Hardening + +**Goal:** Every path that creates, restores, or persists node key material proves the caller is authorized and the material is per-node — closing the three exploitable findings from `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`. A node that is already onboarded must refuse to have its identity replaced; a node flashed from the shared rootfs must never share another node's host keys; and the wallet spending key must not exist in cleartext outside the encrypted envelope. +**Requirements**: KEY-01 (F-01, **Critical**) `seed.generate`/`seed.restore` are unauthenticated (`api/rpc/middleware.rs:25`) and `NodeIdentity::from_seed` (`identity.rs:79`) overwrites `node_key`/`nostr_secret`/FIPS key unconditionally — one unauthenticated POST with an attacker-chosen mnemonic hijacks a live node; gate on onboarding-incomplete (the unused `identity.rs:117` `key_exists` guard) + rate-limit; KEY-02 (F-03, **High**) first-boot per-device secret regeneration is fail-open and its completion marker is set even on failure (`image-recipe/_archived/build-auto-installer-iso.sh:1647,:1659,:1663`), over a fleet-shared cached rootfs that bakes SSH host keys + the TLS key — make it fail-closed and retried; KEY-03 (F-13, **High**) the BIP-84 account **private** key is imported into Bitcoin Core's wallet (`api/rpc/bitcoin.rs:203,:229-231`), duplicating the spending key outside the encrypted envelope — move to watch-only descriptors per `docs/security/PSBT-SIGNING-ARCHITECTURE.md`; KEY-04 on-node verification of C-3/C-4/C-6 from the audit's UNVERIFIED checklist (host-key uniqueness across two real nodes, rootfs tar contents on the build host, unauthenticated LAN reachability of the RPC endpoint) +**Depends on:** Nothing (independent security work; parallelizable with Phases 1–8). **Priority override: F-01 is Critical and live on every fleet node — this phase should be planned and executed ahead of its numeric position, which reflects append order in a shared roadmap, not sequencing.** +**Plans:** 0 plans + +Plans: + +- [ ] TBD (run /gsd-plan-phase 10 to break down) diff --git a/.planning/STATE.md b/.planning/STATE.md index ebfdc11a..9c99c1eb 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -5,16 +5,16 @@ milestone_name: milestone current_phase: 09 current_phase_name: BotFights Platform Upgrade status: planning -stopped_at: Completed 01-01-PLAN.md (federation store lock, FED-01) -last_updated: "2026-08-01T02:29:19.595Z" +stopped_at: Phase 10 context gathered +last_updated: "2026-08-01T10:32:34.902Z" last_activity: 2026-07-31 last_activity_desc: Phase 02 complete, transitioned to Phase 09 progress: - total_phases: 9 + total_phases: 10 completed_phases: 1 total_plans: 39 - completed_plans: 20 - percent: 11 + completed_plans: 25 + percent: 10 --- # Project State @@ -33,7 +33,7 @@ Plan: Not started Status: Ready to plan Last activity: 2026-07-31 — Phase 02 complete, transitioned to Phase 09 -Progress: [█████░░░░░] 51% +Progress: [█████░░░░░] 54% ## Performance Metrics @@ -74,6 +74,7 @@ Progress: [█████░░░░░] 51% - FED-05 added to Phase 1 (2026-07-29): inter-node Lightning channel-opening UX (share node URI, pick trusted/federated nodes by hostname, request channels with public nodes); UI tested on :8100 dev preview against archi-dev before deploy - FED-06 added to Phase 1 (2026-07-29): on-brand paid-tick animation — screensaver ring + EQ segments (reuse ScreensaverRing.vue compact) replacing the success burst in SendBitcoinModal.vue - Phase 9 added (2026-07-30): BotFights Platform Upgrade — native nostr signer login, unified AI bot-setup prompt replacing docs page, shared public match endpoint on VPS2 (all nodes see all fighters), registry/manifest update. Independent of Phases 1–8. +- Phase 10 added (2026-08-01): Key-Material Hardening — KEY-01/F-01 (Critical: unauthenticated `seed.generate`/`seed.restore` overwrite a live node's identity keys), KEY-02/F-03 (fail-open first-boot secret regeneration over a fleet-shared rootfs), KEY-03/F-13 (BIP-84 private key imported into Bitcoin Core), KEY-04 (on-node verification of the audit's UNVERIFIED checklist). Sourced from `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` (quick task 260731-upz). Appended rather than inserted to avoid renumbering a roadmap with concurrent uncommitted edits — **numeric position is append order, not priority; F-01 is Critical and live on the fleet.** ### Decisions @@ -120,6 +121,7 @@ Decisions are logged in PROJECT.md (10 locked ADRs in the `` block + - [Phase 2, gap closure 02-11]: Discover (1389ms, worst remaining) has a SECOND, distinct cause: card-stagger/showStagger entrance-animation classes are baked into the DOM at first mount and never programmatically removed, so every KeepAlive detach/reattach cycle restarts the CSS animation on reactivation — replaying the full entrance cascade on every revisit. Confirmed via a diagnostic (DOM card count doubling transiently on every revisit) and an extended animation-event log. NOT fixed — blast radius spans 5+ files outside 02-11's scope (Apps.vue, Marketplace.vue, Home.vue, several Web5 sub-cards), needs its own real-device verification budget; recommended as a dedicated follow-up - [Phase 2, gap closure 02-11]: openwrt-gateway unmeasurable in the final re-measure (Chromium "Target crashed" cascading from an unrelated surface, cloud-folder, earlier in the same harness run) — recorded as not-measurable, not written in as data. Separately confirmed the prior baseline/after/remeasure numbers were measuring a real, substantive disconnected-state UI (OpenWrtGateway.vue's h1 is unconditional; a "No router configured" RPC error deterministically renders a real Connect-to-Router form, not a blank/error page) — the six-surface regression count is not retracted, but the numbers reflect one specific code branch (no OpenWrt device has ever been connected to archi-dev-box) - [Phase ?]: 01-01: record_peer_transport and update_node routed through FEDERATION_STORE_LOCK via *_inner; tombstone-write-failure test added; full-suite verify blocked by a concurrent agent's uncommitted install.rs edit (unrelated file, not fixed per scope boundary) +- [Phase ?]: UIFIX-02: connected-nodes card height tracks row sibling via xl:flex-1 xl:basis-0 (zero-basis flex-grow) instead of flex-auto, with an xl:min-h-[40rem] floor for a short sibling (discovery disabled), tuned from an initial 20rem guess per Dorian's live feedback ### Pending Todos @@ -163,6 +165,6 @@ Decisions are logged in PROJECT.md (10 locked ADRs in the `` block + ## Session Continuity -Last session: 2026-08-01T02:28:42.155Z -Stopped at: Completed 01-01-PLAN.md (federation store lock, FED-01) -Resume file: None +Last session: 2026-08-01T10:32:34.860Z +Stopped at: Phase 10 context gathered +Resume file: .planning/phases/10-key-material-hardening/10-CONTEXT.md diff --git a/.planning/phases/01-federation-mesh-hardening/01-02-SUMMARY.md b/.planning/phases/01-federation-mesh-hardening/01-02-SUMMARY.md new file mode 100644 index 00000000..e025b4ab --- /dev/null +++ b/.planning/phases/01-federation-mesh-hardening/01-02-SUMMARY.md @@ -0,0 +1,150 @@ +--- +phase: 01-federation-mesh-hardening +plan: 02 +subsystem: demo +tags: [mock-backend, rpc, parity, mesh, federation, harness] + +requires: + - phase: 01-federation-mesh-hardening + provides: "mock-backend.js's per-session store (mesh.dynamic/blobs) and the attachment-parity handlers landed in c2ce71c6 (send-content-inline / send-content / fetch-content / transport-advice)" +provides: + - "Ten previously-missing mesh/federation RPC handlers, each mirroring and citing its daemon counterpart" + - "Stateful chat mutations (reaction, reply, read-receipt, edit, delete, forward, channel) visible on the next mesh.messages read" + - "scripts/mock-rpc-parity.mjs + npm run test:mock-parity — a single command that fails when the demo drifts from the UI's call surface" +affects: [demo, mesh, federation] + +tech-stack: + added: [] + patterns: + - "Parity harnesses must match call syntax (`method: ''`), not bare string literals. The same dotted names are used as resource-cache keys in the UI (`key: 'federation.nodes'`), so a literal scan reports phantom gaps that can never be closed — which would make the harness permanently red and therefore ignored." + - "Mirror-don't-invent: every mock handler carries a comment naming the Rust file and function it mirrors, so a future reader can re-check parity instead of guessing what the demo is 'supposed' to do." + +key-files: + created: + - neode-ui/scripts/mock-rpc-parity.mjs + modified: + - neode-ui/mock-backend.js + - neode-ui/package.json + +key-decisions: + - "The STATIC stage matches `method: ''` rather than every `'mesh.*'`/`'federation.*'` string literal as the plan specified — see Deviations. This is what let KNOWN_GAPS actually reach zero." + - "Edits and deletes are applied through a per-session `overrides` overlay keyed by sender_seq rather than by mutating a message array, because the demo's seed message list is rebuilt on every mesh.messages read. The overlay makes edit/delete observable on seeded messages too, not just ones sent this session." + - "Delete tombstones in place (plaintext '🗑 message deleted', typed_payload { deleted: true }, message_type 'delete') because that is precisely what mesh/mod.rs apply_local_delete does — it does not remove the row. A comment says so, since 'simplifying' it to a splice would be a silent divergence." + - "mesh.refresh and mesh.reboot-radio were deliberately LEFT as bare acknowledgements: the daemon's handlers have no message-store effect either, so giving them demo state would be divergence rather than parity. The comment records this so a later reader does not 'fix' them." + - "mesh.peers and mesh.contacts-list now read from one shared DEMO_MESH_PEERS constant, so the two can never disagree about who is on the mesh — the daemon merges contacts over the same peer map for the same reason." + - "An outbound/'sent' pending request was added to the demo seed. federation.cancel-request faithfully rejects anything that is not outbound-and-sent, so without such a request the demo's cancel button could only ever produce an error." + +requirements-completed: [FED-04] + +coverage: + - id: D1 + description: "Every mesh.*/federation.* RPC the UI calls has a mock handler — the demo never answers a UI call with 'Method not found'" + requirement: "FED-04" + verification: + - kind: integration + ref: "neode-ui/scripts/mock-rpc-parity.mjs STATIC stage — 57 UI-called methods, 0 unhandled" + status: pass + human_judgment: false + - id: D2 + description: "Renaming a mesh peer on the demo persists across a contacts-list read" + requirement: "FED-04" + verification: + - kind: integration + ref: "…LIVE stage — contacts-save then contacts-list round-trips the alias" + status: pass + human_judgment: false + - id: D3 + description: "Reaction, reply, edit, delete and forward mutate demo state and are visible on the next mesh.messages read" + requirement: "FED-04" + verification: + - kind: integration + ref: "…LIVE stage — six mutation assertions, each re-reading mesh.messages and checking the UI-expected shape" + status: pass + human_judgment: false + - id: D4 + description: "The parity check fails when a UI-called method has no mock handler" + requirement: "FED-04" + verification: + - kind: integration + ref: "Fail-first proof: disabling the mesh.clear-all case → exit 1, 'no mock handler for mesh.clear-all'; restored → exit 0 twice consecutively" + status: pass + human_judgment: false + - id: D5 + description: "The harness leaves no stray listener behind" + requirement: "FED-04" + verification: + - kind: integration + ref: "Two consecutive runs both exit 0 (child killed in a finally block; ephemeral port via MOCK_BACKEND_PORT)" + status: pass + human_judgment: false + +duration: 75min +completed: 2026-08-01 +status: complete +--- + +# Phase 1 Plan 2: Demo/Real Mesh RPC Parity (FED-04) Summary + +**Closed the ten mesh/federation methods the demo answered with "Method not found", made the six ack-only chat mutations actually mutate demo state, and replaced "squint at the browser console" with a single command that fails when the demo drifts.** + +## Performance + +- **Duration:** ~75 min +- **Completed:** 2026-08-01 +- **Tasks:** 3/3 +- **Files modified:** 3 (mock backend, new harness, package.json) + +## Accomplishments + +- Ten new handlers, each citing the Rust it mirrors: `mesh.contacts-list`/`-save` (typed_messages.rs), `mesh.clear-all` (status.rs), `mesh.schedule-message`/`list-scheduled`/`cancel-scheduled` (assistant.rs + scheduler.rs), `mesh.assistant-status`/`-configure` (assistant.rs), `federation.cancel-request` and `federation.notify-did-change` (federation/handlers.rs). +- The chat mutations are no longer bare acknowledgements. Reactions, replies and read-receipts push typed messages carrying the `{ sender_pubkey, sender_seq }` target key the UI's `reactionIndex`/`replyTargetPreview` read; edits rewrite the text and set `edited_at`; deletes tombstone in place; forwards copy to the destination peer; channel sends are channel-addressed. +- `mesh.peers` and `mesh.contacts-list` share one `DEMO_MESH_PEERS` list, and the peer with no `pubkey_hex` is omitted from contacts exactly as the daemon's `if let Some(pk)` guard omits it. +- `scripts/mock-rpc-parity.mjs` runs a static cross-reference then boots the mock on an ephemeral port and drives 17 live assertions. No `|| fallback` escapes anywhere in it — a failed spawn or fetch fails the run rather than producing a green run that measured nothing. + +## Task Commits + +1. **Tasks 1 + 2: contacts round-trip, harness, and the ten missing methods** — `b8979f36` (feat) +2. **Task 3: make the message-mutation stubs mutate demo state** — committed with this SUMMARY + +## Deviations from Plan + +### The STATIC scan matches call syntax, not every string literal — and the plan's gap list had two false positives + +**Found during:** Task 1, building the static cross-reference +**Issue:** The plan specifies scanning `src/**` for "every `'mesh.'` / `'federation.'` string literal". That over-reports badly: `Mesh.vue` and `Federation.vue` use the same dotted names as **resource-cache keys** (`key: 'mesh.self-did'`, `key: 'mesh.transport-status'`, `key: 'federation.nodes'`, `key: 'federation.dwn-status'`), and `stores/sync.ts` invalidates by the same strings. None are RPC methods. A literal scan reports 18 gaps where 10 exist. + +Two of those phantoms — **`federation.nodes` and `federation.dwn-status`** — are named in the plan's own Task 2 gap list. They are not RPC methods and the daemon's dispatcher has no such routes; `Federation.vue` uses them purely as cache keys. Implementing them would have added demo-only endpoints the real node does not serve, which the plan's own transparency prohibition forbids. +**Resolution:** The scan matches `method:\s*['"]…['"]`, i.e. an actual call site. The gap list becomes exactly the 10 real methods, `KNOWN_GAPS` is empty, and the harness is genuinely failable — proven by disabling a case and watching it exit 1. +**Files modified:** `neode-ui/scripts/mock-rpc-parity.mjs` + +### Added an outbound pending request to the demo seed + +**Found during:** Task 2, implementing `federation.cancel-request` +**Issue:** The daemon only permits cancelling an **outbound** request in **sent** state. The demo seed contained a single inbound/pending request, so a faithful handler could only ever return an error and the cancel path was unexercisable. +**Resolution:** Added `preq-demo-out-1` (outbound, state `sent`) to `pendingPeerRequests`. The handler stays faithful — it still rejects non-outbound and non-sent requests with the daemon's own message. +**Files modified:** `neode-ui/mock-backend.js` + +### Edits/deletes use an overlay rather than in-place mutation + +**Found during:** Task 3 +**Issue:** `mesh.messages` rebuilds its seeded message array on every read, so mutating "the message" in place would be lost immediately for any seeded message and only work for messages sent in this session. +**Resolution:** A per-session `overrides` map keyed by `sender_seq`, applied over the merged list on read. This matches the daemon's matching rule (own-Sent message by `sender_seq`) and makes edit/delete observable for seeded messages too. +**Files modified:** `neode-ui/mock-backend.js` + +## Known Stubs + +`mesh.assistant-status` reports `ollama_detected: false`, `claude_available: false` and an empty model list. That is the honest answer for a browser demo with no local model — the UI's "not detected" path is what a visitor should see, and fabricating a model list would be exactly the demo-only divergence this plan's prohibition forbids. + +## Threat Flags + +None. This plan touches only the demo/mock backend and a dev-time harness; no production code path, endpoint or trust boundary is involved. The `T-01-SC` package-install threat does not apply — no dependencies were added (the harness uses only `node:` builtins and global `fetch`). + +## Self-Check: PASSED + +- CONFIRMED: `node --check mock-backend.js` exits 0 +- CONFIRMED: `node scripts/mock-rpc-parity.mjs` exits 0 — 17 live assertions, 0 unhandled methods +- CONFIRMED: fail-first proof (disable a case → exit 1 naming it; restore → exit 0 twice in a row) +- CONFIRMED: each of the ten methods appears exactly once as a `case` +- CONFIRMED: `KNOWN_GAPS` is present and empty; `MOCK_BACKEND_PORT` honoured; `typed_messages.rs` cited twice +- CONFIRMED: `npm run test:mock-parity` registered in package.json + diff --git a/.planning/phases/01-federation-mesh-hardening/01-12-SUMMARY.md b/.planning/phases/01-federation-mesh-hardening/01-12-SUMMARY.md new file mode 100644 index 00000000..ba1b0557 --- /dev/null +++ b/.planning/phases/01-federation-mesh-hardening/01-12-SUMMARY.md @@ -0,0 +1,172 @@ +--- +phase: 01-federation-mesh-hardening +plan: 12 +subsystem: ui +tags: [vue, tailwind, flexbox, css-grid, web5, scroll] + +requires: + - phase: 01-federation-mesh-hardening + provides: "Web5.vue's existing xl:grid-cols-2 row layout and Web5ConnectedNodes.vue's tabbed card (quick task 260729-je5 made the list fill the card's height; this plan adds the missing other half)" +provides: + - "A bounded, sibling-matched scroll contract on Web5ConnectedNodes.vue's three tab panes (trusted/observers/requests), pinned by a structural vitest suite" + - "Settled verdict on the second 'connected nodes' surface flagged by the originating todo: AccountInfoSection.vue's hits are changelog prose describing the feature, not a second scrolling list — no fix needed there" +affects: [web5, federation-mesh-hardening] + +tech-stack: + added: [] + patterns: + - "Sibling-matched equal-height row + inner-scroll: give the growing child `xl:flex-1 xl:basis-0` (zero flex-basis so it contributes no intrinsic height) instead of `flex-auto`/no-basis, so a CSS Grid row's default `align-items: stretch` sizes the card by its sibling, and the pane's own overflow-y-auto scrolls inside the leftover height. Card root needs `min-h-0` for the flex column to be allowed to shrink below content height, plus a row-breakpoint `xl:min-h-[Nrem]` floor for the case where the sibling itself is short." + +key-files: + created: + - neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts + modified: + - neode-ui/src/views/web5/Web5ConnectedNodes.vue + +key-decisions: + - "Row-breakpoint height floor set to xl:min-h-[40rem] (not the planner's initial 20rem guess) — confirmed via Dorian's live-browser check: with node discovery disabled, Web5NodeVisibility (the row sibling) renders short, the floor takes over, and 20rem looked stunted; he asked for double, i.e. 40rem." + - "Settings 'connected nodes' surface (AccountInfoSection.vue) is unrelated to this defect — its 10 'connected' hits are all changelog/what's-new prose describing the Connected Nodes feature by name, not a second scrolling list component. No code change made there." + - "Live-browser verification for wide (>=1280px xl breakpoint, sibling-height match + forced-overflow scroll) and narrow (<1280px, stacked layout unchanged) viewports was performed by Dorian directly on his own already-running dev session, not by the executor — the executor does not touch :8100/:5173/:5175/:5959/:3141 (a hard constraint clarified mid-execution to mean 'never kill/restart/disturb', not 'never read from')." + +requirements-completed: [UIFIX-02] + +coverage: + - id: D1 + description: "The connected-nodes card's height at the xl (row) breakpoint tracks its Web5NodeVisibility sibling via CSS Grid stretch + a zero-basis flex child, instead of growing to fit every row" + requirement: "UIFIX-02" + verification: + - kind: unit + ref: "neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts#gives all three tab panes the bounded, sibling-matched scroll contract" + status: pass + - kind: manual_procedural + ref: "Dorian's direct visual check on his running :8100 session, wide viewport — reported 'we are good' for height match, scroll, and stacked layout" + status: pass + human_judgment: false + - id: D2 + description: "The inner list scrolls within the matched height rather than growing scroll-free, for all three tabs (trusted/observers/requests)" + requirement: "UIFIX-02" + verification: + - kind: unit + ref: "neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts#gives all three tab panes the bounded, sibling-matched scroll contract" + status: pass + - kind: manual_procedural + ref: "Dorian's direct visual check — scroll behaviour confirmed correct" + status: pass + human_judgment: false + - id: D3 + description: "A short sibling (discovery disabled) still leaves a usable list height via the xl:min-h-[40rem] floor, rather than collapsing to header+tabs" + requirement: "UIFIX-02" + verification: + - kind: unit + ref: "neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts#gives the card root a min-h-0 flex column with a row-breakpoint height floor" + status: pass + - kind: manual_procedural + ref: "Dorian's direct feedback ('too short when discovery is disabled, should be twice as tall') drove the 20rem -> 40rem correction, applied and re-verified" + status: pass + human_judgment: false + - id: D4 + description: "The stacked (below-xl) single-column layout is byte-identical to before: same capped max-h-72 height, same scroll" + requirement: "UIFIX-02" + verification: + - kind: unit + ref: "neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts (max-h-72 asserted on all three panes; git diff confirms Web5.vue untouched and no script-block change)" + status: pass + - kind: manual_procedural + ref: "Dorian's direct visual check, narrow viewport — stacked layout confirmed unchanged" + status: pass + human_judgment: false + - id: D5 + description: "The zero-node/empty-list edge case still renders the existing empty/loading row and the pane stays in the tree (does not collapse)" + requirement: "UIFIX-02" + verification: + - kind: unit + ref: "neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts#still renders the empty-state row for each pane when the node list is empty" + status: pass + human_judgment: false + - id: D6 + description: "The second 'connected nodes' surface flagged by the originating todo (settings AccountInfoSection.vue) is investigated and given an explicit verdict rather than silently skipped" + verification: + - kind: other + ref: "grep -n -i connected neode-ui/src/views/settings/AccountInfoSection.vue — all 10 hits are changelog prose, not a scrolling list" + status: pass + human_judgment: false + +duration: 105min +completed: 2026-07-31 +status: complete +--- + +# Phase 1 Plan 12: Connected-Nodes Row-Matched Scroll (UIFIX-02) Summary + +**Gave the connected-nodes card's three tab panes a zero-basis flex-grow contract so the card's height at the row breakpoint comes from its `Web5NodeVisibility` sibling via Grid's default stretch, with the list scrolling inside that height instead of growing to fit every row — floor tuned to 40rem per Dorian's live-browser feedback.** + +## Performance + +- **Duration:** ~105 min (across two work sessions, separated by a live-browser verification checkpoint) +- **Completed:** 2026-07-31 +- **Tasks:** 2/2 (Task 2's dev-preview portion completed by Dorian directly, not the executor — see Deviations) +- **Files modified:** 2 (1 component, 1 new test file) + +## Accomplishments + +- All three tab panes (trusted/observers/requests) in `Web5ConnectedNodes.vue` now carry `min-h-0 overflow-y-auto max-h-72 xl:flex-1 xl:basis-0 xl:max-h-none` — below the `xl` breakpoint nothing changed (same cap, same scroll); at `xl` the pane contributes zero intrinsic height, so the grid row is sized by the sibling alone and the pane scrolls inside the leftover height. +- Card root gained `min-h-0 xl:min-h-[40rem]` so a short sibling (e.g. discovery disabled, `Web5NodeVisibility` renders small) still leaves a full, usable list area instead of collapsing to the header+tabs strip. +- New structural test `Web5ConnectedNodesScroll.test.ts` pins this contract (3 tests) so a future cleanup cannot regress it a third time (the todo notes this was already a regression of an earlier fix). +- Settled the ambiguity the originating todo explicitly flagged: the "connected nodes" hits in `settings/AccountInfoSection.vue` are changelog prose describing the feature by name, not a second scrolling list — confirmed by direct grep of all 10 hits, no code change needed there. +- Live-browser verification (sibling-height match at wide viewport, forced-overflow internal scroll, unchanged stacked layout at narrow viewport) was performed by Dorian on his own running `:8100` dev session rather than by the executor spinning up a competing instance. + +## Task Commits + +Each task was committed atomically, across two rounds (the second correcting the height floor per live feedback): + +1. **Task 1: End-to-end — the trusted pane scrolls at a sibling-matched height** - `ceafbcb5` (fix) — added the scroll contract classes + wrote the pinning test (initial floor: `xl:min-h-[20rem]`, the planner's flagged judgement call) +2. **Task 2 correction: raise the height floor to 40rem** - `b5628d96` (fix) — Dorian verified height-match/scroll/stacked-layout were all correct on his own running session but reported the floor was too short with discovery disabled ("should be twice as tall"); floor changed `20rem` → `40rem`, test's expected value updated to match + +Both commits pushed to `gitea-ai main`. No separate plan-metadata commit was needed beyond this SUMMARY's own final commit (below). + +_Note: this plan's Task 1 was `type="tracer" tdd="true"` — test file written first (RED verified analytically via `git diff` showing the exact classes the test's `toContain`/`not.toContain` assertions depend on), then the fix applied and the test confirmed green (GREEN)._ + +## Files Created/Modified + +- `neode-ui/src/views/web5/Web5ConnectedNodes.vue` - card root: `min-h-0 xl:min-h-[40rem]`; all three tab panes: `xl:flex-1 xl:basis-0 xl:max-h-none` replacing `flex-auto`/no-basis, keeping `min-h-0 overflow-y-auto max-h-72` unchanged. No script-block, header, tab-strip, row-markup, or `Web5.vue` changes (verified via `git diff --stat` showing zero change to `Web5.vue`, and zero ` [p.contact_id, p.advert_name]), +) +// This node's own key on the demo. Reactions/replies the visitor sends are +// direction:'sent', which is what the UI keys '__self__' off — but the field +// still has to be present and stable for the message key to work. +const DEMO_SELF_PUBKEY = 'demo02abababababababababababababababababababababababababababab' + +// Append a message to this session's mesh store, matching the field set of +// mesh/types.rs MeshMessage (id, direction, peer_contact_id, peer_name, +// plaintext, timestamp, delivered, encrypted, transport, message_type, +// typed_payload, sender_pubkey, sender_seq). +function pushMeshMessage({ + contact_id = null, + plaintext = '', + message_type = 'text', + typed_payload = null, + transport = 'meshcore', + channel = null, +}) { + const meshStore = currentStore().mesh + const id = 100 + meshStore.dynamic.length + const msg = { + id, + direction: 'sent', + peer_contact_id: contact_id, + peer_name: contact_id === null ? (channel === null ? null : `channel-${channel}`) : DEMO_PEER_NAMES[contact_id] || `peer-${contact_id}`, + plaintext, + timestamp: new Date().toISOString(), + delivered: true, + // mountain-node (3) has no pubkey, so traffic to it is unencrypted — the + // same asymmetry the rest of the demo already models. + encrypted: contact_id !== 3, + transport, + sender_pubkey: DEMO_SELF_PUBKEY, + sender_seq: id, + message_type, + typed_payload, + } + if (channel !== null) msg.channel = channel + meshStore.dynamic.push(msg) + return msg +} + +// Apply this session's edit/delete overrides over a message list. Keyed by +// sender_seq, mirroring mesh/mod.rs apply_local_edit / apply_local_delete, +// which match an own-Sent message by sender_seq. Kept as an overlay rather +// than mutating the seed array, because the seed is rebuilt on every read. +function applyMeshOverrides(messages) { + const overrides = currentStore().mesh.overrides + if (!overrides || Object.keys(overrides).length === 0) return messages + return messages.map((m) => { + const o = m.direction === 'sent' && m.sender_seq != null ? overrides[m.sender_seq] : null + return o ? { ...m, ...o } : m + }) +} + // Boot mode: simulate server startup delay let BOOT_START_TIME = Date.now() const BOOT_DELAY_MS = 25000 // 25 seconds of simulated startup (slower for analysis) @@ -3265,7 +3322,7 @@ app.post('/rpc/v1', (req, res) => { ] // Messages sent this session (texts + attachments) ride after the // static seed so refresh-after-send shows them, same as a real node. - const withDynamic = [...allMessages, ...currentStore().mesh.dynamic] + const withDynamic = applyMeshOverrides([...allMessages, ...currentStore().mesh.dynamic]) return res.json({ result: { messages: withDynamic.slice(0, limit), @@ -4844,19 +4901,121 @@ app.post('/rpc/v1', (req, res) => { }) } - // Chat actions the demo only needs to acknowledge. - // + // ── Chat mutations ────────────────────────────────────────────────── + // Reactions, replies, receipts and forwards are new typed messages + // carrying a target key (typed_messages.rs send_typed_wire); edits and + // deletes mutate the target in place (mesh/mod.rs apply_local_edit / + // apply_local_delete). The UI derives reaction chips and reply quotes + // from exactly these shapes (Mesh.vue reactionIndex / + // replyTargetPreview), so a bare ack renders as nothing at all. + case 'mesh.send-reaction': { + const emoji = params?.emoji ?? '' + pushMeshMessage({ + contact_id: params?.contact_id ?? null, + plaintext: emoji, + message_type: 'reaction', + typed_payload: { + target: { sender_pubkey: params?.target_pubkey, sender_seq: params?.target_seq }, + // An empty emoji clears this reactor's reaction, per the UI's + // reactionIndex (freshest per reactor wins; '' deletes). + emoji, + }, + }) + return res.json({ result: { ok: true, sent: true } }) + } + + case 'mesh.send-reply': { + const text = params?.text ?? '' + pushMeshMessage({ + contact_id: params?.contact_id ?? null, + plaintext: text, + message_type: 'reply', + typed_payload: { + target: { sender_pubkey: params?.target_pubkey, sender_seq: params?.target_seq }, + text, + }, + }) + return res.json({ result: { ok: true, sent: true } }) + } + + case 'mesh.send-read-receipt': { + pushMeshMessage({ + contact_id: params?.contact_id ?? null, + plaintext: '', + message_type: 'read_receipt', + typed_payload: { + target: { sender_pubkey: params?.target_pubkey, sender_seq: params?.target_seq }, + }, + }) + return res.json({ result: { ok: true, sent: true } }) + } + + // apply_local_edit: replaces plaintext and merges { edited_at, text } + // into the existing typed_payload of the own-Sent message with that seq. + case 'mesh.edit-message': { + const seq = params?.target_seq + const newText = params?.new_text ?? '' + if (seq == null) { + return res.json({ error: { code: -32602, message: 'target_seq is required' } }) + } + const overrides = currentStore().mesh.overrides + const prior = overrides[seq] || {} + overrides[seq] = { + ...prior, + plaintext: newText, + typed_payload: { + ...(prior.typed_payload || {}), + edited_at: Math.floor(Date.now() / 1000), + text: newText, + }, + } + return res.json({ result: { ok: true, sent: true } }) + } + + // apply_local_delete: tombstone in place — the daemon does NOT remove + // the row, it rewrites plaintext, sets typed_payload { deleted: true } + // and message_type 'delete'. Mirrored exactly; do not "simplify" this + // into a splice, the UI renders the tombstone bubble. + case 'mesh.delete-message': { + const seq = params?.target_seq + if (seq == null) { + return res.json({ error: { code: -32602, message: 'target_seq is required' } }) + } + currentStore().mesh.overrides[seq] = { + plaintext: '🗑 message deleted', + typed_payload: { deleted: true }, + message_type: 'delete', + } + return res.json({ result: { ok: true, sent: true } }) + } + + case 'mesh.forward-message': { + const sourceId = params?.source_message_id + const all = applyMeshOverrides(currentStore().mesh.dynamic) + const source = all.find((m) => m.id === sourceId) + pushMeshMessage({ + contact_id: params?.contact_id ?? null, + plaintext: source ? source.plaintext : '(forwarded)', + message_type: source?.message_type === 'content_ref' ? 'content_ref' : 'text', + typed_payload: source?.typed_payload ?? null, + }) + return res.json({ result: { ok: true, sent: true } }) + } + + case 'mesh.send-channel': { + pushMeshMessage({ + contact_id: null, + channel: params?.channel ?? 0, + plaintext: params?.message ?? '', + message_type: 'text', + }) + return res.json({ result: { ok: true, sent: true } }) + } + // mesh.refresh and mesh.reboot-radio stay bare acknowledgements ON // PURPOSE: the daemon's handlers have no message-store effect either // (refresh re-polls the radio, reboot-radio power-cycles it), so giving // them demo-side state would be divergence, not parity. Do not "fix". - case 'mesh.send-reaction': - case 'mesh.send-reply': - case 'mesh.send-read-receipt': - case 'mesh.edit-message': - case 'mesh.delete-message': - case 'mesh.forward-message': - case 'mesh.send-channel': case 'mesh.refresh': case 'mesh.reboot-radio': { return res.json({ result: { ok: true, sent: true } }) @@ -5877,7 +6036,7 @@ function makeSessionStore() { // contacts: pubkey_hex → { alias, notes, pinned, blocked }, mirroring the // daemon's state.contacts map. scheduled: queued messages awaiting their // fire_at, mirroring svc.scheduler's list. - mesh: { dynamic: [], blobs: {}, contacts: {}, scheduled: [], nextScheduledId: 1 }, + mesh: { dynamic: [], blobs: {}, contacts: {}, scheduled: [], nextScheduledId: 1, overrides: {} }, sockets: new Set(), lastSeen: Date.now(), } diff --git a/neode-ui/scripts/mock-rpc-parity.mjs b/neode-ui/scripts/mock-rpc-parity.mjs index 2dac8eb9..d09b47bc 100644 --- a/neode-ui/scripts/mock-rpc-parity.mjs +++ b/neode-ui/scripts/mock-rpc-parity.mjs @@ -214,7 +214,69 @@ try { else fail('cancelled request still listed as pending') } - // 6. federation.notify-did-change reports per-peer results. + // 6. Chat mutations are observable on the next mesh.messages read — the + // whole point of FED-04's "not a bare ok acknowledgement" requirement. + const sent = await rpc('mesh.send-content-inline', { + contact_id: 1, + mime: 'text/plain', + filename: 'mutate.txt', + bytes_b64: Buffer.from('mutate me').toString('base64'), + }) + const targetSeq = sent.message_id + + await rpc('mesh.send-reaction', { + contact_id: 1, + target_pubkey: 'demo02abababababababababababababababababababababababababababab', + target_seq: targetSeq, + emoji: '🔥', + }) + let msgs = (await rpc('mesh.messages', { limit: 500 })).messages + const reaction = msgs.find( + (m) => m.message_type === 'reaction' && m.typed_payload?.target?.sender_seq === targetSeq, + ) + if (reaction?.typed_payload?.emoji === '🔥') pass('mesh.send-reaction is visible as a reaction message with a target key') + else fail('reaction did not appear in mesh.messages with the UI-expected shape') + + await rpc('mesh.send-reply', { + contact_id: 1, + target_pubkey: 'demo02abababababababababababababababababababababababababababab', + target_seq: targetSeq, + text: 'replying to that', + }) + msgs = (await rpc('mesh.messages', { limit: 500 })).messages + const reply = msgs.find((m) => m.message_type === 'reply' && m.typed_payload?.target?.sender_seq === targetSeq) + if (reply?.plaintext === 'replying to that') pass('mesh.send-reply is visible as a reply carrying its target') + else fail('reply did not appear with a target key') + + await rpc('mesh.edit-message', { contact_id: 1, target_seq: targetSeq, new_text: 'edited text' }) + msgs = (await rpc('mesh.messages', { limit: 500 })).messages + const edited = msgs.find((m) => m.sender_seq === targetSeq && m.direction === 'sent') + if (edited?.plaintext === 'edited text' && edited?.typed_payload?.edited_at) + pass('mesh.edit-message rewrote the text and set the edited marker') + else fail(`edit not applied: ${JSON.stringify(edited?.plaintext)}`) + + await rpc('mesh.delete-message', { contact_id: 1, target_seq: targetSeq }) + msgs = (await rpc('mesh.messages', { limit: 500 })).messages + const deleted = msgs.find((m) => m.sender_seq === targetSeq && m.direction === 'sent') + // The daemon tombstones in place rather than removing the row. + if (deleted && deleted.message_type === 'delete' && deleted.typed_payload?.deleted === true) + pass('mesh.delete-message tombstoned in place, as apply_local_delete does') + else fail(`delete representation wrong: ${JSON.stringify(deleted)}`) + + const beforeForward = (await rpc('mesh.messages', { limit: 500 })).count + await rpc('mesh.forward-message', { contact_id: 2, source_message_id: targetSeq }) + msgs = (await rpc('mesh.messages', { limit: 500 })).messages + const forwarded = msgs.filter((m) => m.peer_contact_id === 2 && m.direction === 'sent') + if (msgs.length > beforeForward && forwarded.length > 0) pass('mesh.forward-message pushed a copy for the destination peer') + else fail('forward did not create a message for the destination peer') + + await rpc('mesh.send-channel', { channel: 3, message: 'channel broadcast' }) + msgs = (await rpc('mesh.messages', { limit: 500 })).messages + if (msgs.some((m) => m.channel === 3 && m.plaintext === 'channel broadcast')) + pass('mesh.send-channel pushed a channel-addressed message') + else fail('channel message not visible in mesh.messages') + + // 7. federation.notify-did-change reports per-peer results. const notified = await rpc('federation.notify-did-change', { old_did: 'did:key:zOld', new_did: 'did:key:zNew',