fix(meshcore): anon auth banner, channel-sync data loss, and reply/trigger ordering - #4491
Conversation
… report
syncChannelsFromDevice() reconciles the DB against the device by deleting
rows whose idx is absent from the scan. `getChannels()` enumerates until the
firmware errors, so a partial scan returns success with a SHORT list rather
than failing — and every unlisted slot was then treated as deleted.
Absent is unknown, not empty. The reconcile now deletes only slots the
firmware positively reported as unconfigured (present in the response,
failing isConfiguredMeshCoreChannel). Rows for slots the scan never reached
are preserved and logged at warn.
This mirrors the decision refreshContacts already makes right next to the
call site ("deliberately won't wipe on empty") — the channel sync simply
never got the same guard.
Scope note: this is hardening, NOT the fix for the report that prompted it.
Debug logging showed the device in that case returned its full 40-slot table
with 39 empty ("Synced 1 configured channel(s) ... filtered out 39 empty
slot(s)"), i.e. a complete scan of a device that genuinely no longer had the
channel — the reconcile was correct. The partial-scan hazard is real though:
the sibling source was simultaneously failing get_channels with a native
command timeout.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB
…seconds A remote auto-responder's reply rendered ABOVE the message that triggered it. Captured from the running app: trigger (ours) id sent-1785604213050-… timestamp 1785604213050 reply (remote) id 1785604214478-… timestamp 1785604213000 The reply was observed 1.4s AFTER the trigger but carried a timestamp 50ms EARLIER, because the two directions share neither a clock nor a resolution: received: timestamp = sender_timestamp * 1000 ← remote, WHOLE SECONDS sent: timestamp = Date.now() ← ours, milliseconds Any remote reply landing in the same second as our send could therefore sort ahead of it. Sub-second precision on our own sends is precision we can never have for the other side, and trusting it is what produced the inversion. Adds `receivedAt` — MeshMonitor's own wall clock, stamped identically at all six message-creation sites (3 received, 3 sent) — and a shared comparator in messageOrder.ts: compare `timestamp` at second granularity (the wire's real resolution), break ties on `receivedAt`, fall back to `id` so the order is total and never depends on input sequence. Display still uses `timestamp`. No migration: meshcore_messages already persists `createdAt` with exactly this meaning, so DB rehydration maps createdAt → receivedAt and EXISTING history reorders correctly too. Legacy rows without it fall back to `timestamp`. The DM view is unchanged — it does not sort, inheriting pool arrival order, which is already causally correct. Tests use the real captured values, and one asserts the premise (that a naive timestamp sort genuinely inverts these two) so the fix cannot be quietly neutered later. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB
…ead-only viewers
An anonymous viewer with read grants on a MeshCore source got
"Authentication required" splashed over the channel view, which reads as
"you can't view this channel" even though the page was otherwise working.
Reproduced in an anonymous browser session. Two of the four failing calls
were 401s:
config/default-scope 401
saved-regions 401
Both are `requireAuth()` + `configuration: read`, and both exist ONLY to
populate the scope-override datalist — a send-side control a read-only
viewer cannot use. `fetchSavedRegions` then surfaced the raw 401 body via
setError, so the server's "Authentication required" became a page banner.
Two changes, either of which would fix the symptom; both are correct:
- Gate both lookups on `canSend`, so the requests are never made for a
viewer who cannot send rather than made and ignored.
- Drop the setError from fetchSavedRegions, matching getDefaultScope
directly above it. Optional autocomplete suggestions must not be able to
raise a page-level error.
Also moves `connection` to the top of the RESOURCES list and rewords it to
"Required to open a source." MeshCoreSourcePage gates its ENTIRE surface on
connection:read, so without it every other grant on a MeshCore source is
inert and the operator only sees "You do not have permission to view this
MeshCore source" — with no hint which permission is missing. Note the gate is
MeshCore-only; no Meshtastic path checks it, despite the comment there
claiming parity.
Verified anonymously on the running container: banner gone, and neither
auth-only endpoint is requested at all.
Not changed: channel CONTENT still needs `messages: read`, which the server
states explicitly in its 403 body
(required: {resource: "messages", action: "read"}). `channel_0..7` are inert
on MeshCore sources — no MeshCore code path consults them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB
|
Claude finished @Yeraze's task —— View job PR Review: fix(meshcore): anon auth banner, channel-sync data loss, and reply/trigger ordering
Overall this is a high-quality, well-reasoned set of fixes. The PR description is unusually thorough, tests directly encode the real captured failure values, and the behavioral scope is tightly limited. A few observations below. Fix 1 — Spurious auth banner (
|
Twelve changes since rc4, across all five version files (package.json, package-lock.json, helm Chart.yaml, desktop/package.json, tauri.conf.json). Highlights: - Remote admin no longer holds an HTTP request across mesh round-trips (#4482 → #4485, #4486): commands, session-passkey acquisition, and remote config import all return 202 + an operation id and complete in the background, fixing upstream 502s behind a reverse proxy. Remote ignore/unignore gained routing-ACK confirmation. - Admin messages now use the node's configured LoRa hop limit instead of a hardcoded 3 (#4479), so nodes further than 3 hops away are administrable. - GET /stats no longer 500s when sourceId is omitted (#4470). - MeshCore fixes: anon auth banner, channel-sync data loss, reply/trigger ordering (#4491), channel view scroll/delete (#4488), hop/route/scope preserved in channel history (#4475). - Navigation unified across Meshtastic/MeshCore per-source views (#4481, #4484). Carries the system-test label: this is the first release build to exercise the async remote-admin paths against real hardware — the Configuration Import leg covers /import-config, which has not run on a device until now. Claude-Session: https://claude.ai/code/session_015cgD5kSurLjdeVkZ6PcQD9 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Three MeshCore fixes found by driving the running app. All pre-existing — none introduced by the #4473 nav work (verified against both of its merge commits).
Supersedes #4489, whose commit is included here (it was stacked on the pre-squash version of #4488 and had gone conflicting).
1. Spurious "Authentication required" over the channel view
An anonymous viewer with read grants got a page-level banner that reads as "you can't view this channel", on a page that was otherwise working.
Reproduced in an anonymous session — two of four failing calls were the cause:
config/default-scopesaved-regionsBoth are
requireAuth()+configuration: readand exist only to populate the scope-override datalist — a send-side control a read-only viewer cannot use.fetchSavedRegionssurfaced the raw 401 body throughsetError, turning{"error":"Authentication required"}into a banner.canSend— never requested for a viewer who cannot send.setError, matchinggetDefaultScopedirectly above it. Optional autocomplete suggestions must not raise a page-level error.Permissions UI:
connectionmoves to the top ofRESOURCES, reworded to "Required to open a source."MeshCoreSourcePagegates its entire surface onconnection: read, so without it every other grant is inert and the operator sees only "You do not have permission to view this MeshCore source". Note that gate is MeshCore-only — no Meshtastic path checks it, despite the comment there claiming parity.2. Channel rows deleted for slots the device never reported
syncChannelsFromDevice()reconciled by deleting rows absent from the scan.getChannels()enumerates until the firmware errors, so a partial scan returns success with a short list rather than failing — and every unlisted slot was treated as deleted.Absent is unknown, not empty. Deletion now requires the firmware to have positively reported the slot as unconfigured; unreached slots are preserved and logged at warn. This mirrors the decision
refreshContactsalready makes at the adjacent call site ("deliberately won't wipe on empty").Scope note, stated plainly: this is hardening, not the fix for the report that prompted it. Debug logging showed that device returned its full 40-slot table with 39 empty (
Synced 1 configured channel(s) ... filtered out 39 empty slot(s)) — a complete scan of a device that genuinely no longer had the channel, so the reconcile was correct. The partial-scan hazard is real regardless: the sibling source was simultaneously failingget_channelswith a native command timeout.3. Auto-responder replies sorted above their own trigger
Captured from the running app:
The reply was observed 1.4 s after the trigger but carried a timestamp 50 ms earlier, because the two directions share neither a clock nor a resolution:
Sub-second precision on our own sends is precision we can never have for the other side, and trusting it is what produced the inversion.
Adds
receivedAt— MeshMonitor's own clock, stamped identically at all six message-creation sites (3 received, 3 sent) — plus a shared comparator inmessageOrder.ts: comparetimestampat second granularity (the wire's real resolution), tie-break onreceivedAt, fall back toidso the order is total and never depends on input sequence. Display still usestimestamp.No migration:
meshcore_messagesalready persistscreatedAtwith exactly this meaning, so rehydration mapscreatedAt → receivedAtand existing history reorders too. Legacy rows without it fall back totimestamp.The DM view is unchanged — it doesn't sort, inheriting pool arrival order, which is already causally correct.
Verified on the running container
1:10:13 PM. A second pair sent later has byte-identical timestamps across three messages; onlyreceivedAtseparates them, and they order correctly.createdAtmapping.Test plan
timestampsort genuinely inverts them, so the fix can't be quietly neutered.success: true, 12966 passed, 0 failed.npx tsc --noEmitclean;npm run lint:cino FAIL lines.Follow-ups not addressed here
channel_0..7are offered on MeshCore sources but are inert — no MeshCore code path consultschannel_N. Grantingchannel_0there looks like it should grant channel access and does nothing; channel content needsmessages: read(the server names it in its 403 body). Hiding them for MeshCore sources would be its own change.get_channelsintermittently times out on these companions, so a source's channel list can silently never sync. Worth its own issue.Generated by Claude Code