feat(meshcore): delete/purge messages — per-message, per-conversation, purge-all (#3981)#3984
Conversation
…urge-all (#3981) MeshCore messages (channel + DM) previously had no way to be deleted from the UI, unlike Meshtastic sources. This adds the full stack, mirroring the Meshtastic purge patterns and honouring per-source scoping throughout. Backend - Repository (`meshcore.ts`): source-scoped deletes — `deleteMessageForSource`, `deleteMessagesByIds` (chunked for SQLite's bind ceiling), `deleteChannelMessagesForSource` (reuses `channelWhereClause`), `deleteAllMessagesForSource`, plus `getMessageEndpointsForSource` used to resolve DM conversations. Every method requires a sourceId. - Manager (`meshcoreManager.ts`): `deleteStoredMessage`, `purgeConversation` (prefix-matches inbound-vs-outbound peer keys like the frontend `keysMatch`), `purgeChannelMessages`, `purgeAllMessages`. Each prunes the in-memory pool and broadcasts a `meshcore:messages:deleted` event. - Routes (`meshcoreRoutes.ts`): DELETE `/messages`, `/messages/channel/:idx`, `/messages/conversation/:publicKey`, `/messages/:messageId` — all `requireAuth()` + `requirePermission('messages','write',{sourceIdFrom:'params.id'})` and audit-logged. The single-message route uses `:messageId` (not `:id`) to avoid shadowing the mount's source `:id`. Frontend - `useMeshCore` hook: `deleteMessage`/`clearConversation`/`clearChannelMessages`/ `purgeAllMessages` actions + a `meshcore:messages:deleted` socket handler that prunes local state. - Per-message 🗑️ in the shared `MeshCoreMessageStream` (hover-revealed), wired into both the DM and channel views; a "Clear conversation"/"Clear channel" toolbar button; and a "Purge all messages" danger action in the settings view. Tests: repo per-source isolation, manager purge logic (prefix matching, pool prune, event emit), and route auth/permission/validation/404 coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015AFBA76hsjqhsXe1BdnYub
|
Claude finished @Yeraze's task —— View job PR Review: feat(meshcore): delete/purge messages (#3981)
Overall AssessmentThis is a well-structured, thorough implementation that follows existing project patterns carefully. The per-source scoping is correct throughout, the select-then-delete pattern ensures accurate counts, and the layered test coverage is solid. A few items worth addressing: Bugs / Correctness Issues1. The method does a // Current (two queries):
const existing = await this.db.select({...}).where(condition).limit(1);
if (existing.length === 0) return false;
await this.db.delete(meshcoreMessages).where(condition);
return true;
// Preferred (one query, matches deleteNode pattern):
const result = await this.db.delete(meshcoreMessages).where(condition);
return this.getAffectedRows(result) > 0;2. Same issue — SELECT before DELETE to count rows removed. The 3. Both pre-select to count, then delete. 4. const selfKey = this.localNode?.publicKey;If Security5. Route registration order is correct but relies on implicit Express ordering ( The comment on line 1462 ("Registered before /messages/:id so the two-segment path wins") is accurate and Express does what you expect here, but this is fragile — a future reorder of routes in this file could silently break the routing. Consider a comment in a test to assert which route handles 6. In const matches = (a?: string) => !!a && (a === pk || a.startsWith(pk) || pk.startsWith(a));The Code Quality / Minor Issues7. Duplicated channel-filter logic in three places The same channel filter appears in:
The 8. The comment at line 1360-1362 acknowledges the double-apply is "idempotent" and "harmless" — that's correct. Minor cosmetic note only. 9. Clear channel button visibility condition ( {canSend && filtered.length > 0 && (
<div className="meshcore-conversation-toolbar">The "Clear channel messages" button only appears when there are visible messages ( 10. if (!/^[0-9a-f]{2,64}$/.test(publicKey)) {The PR description mentions "pubkey prefix" semantics, so a 2-char minimum makes sense. However, a 2-char prefix would match an extremely large number of conversations (most contacts share the first byte). Consider documenting the minimum practical prefix length or enforcing a higher floor (e.g. 8 chars = 4 bytes for reasonable uniqueness). Test CoverageThe test coverage is genuinely good:
Summary
The core implementation is sound, permissions are correctly scoped, the event pipeline is clean, and the existing tests are comprehensive. The most actionable fixes are the double-query patterns in the repository (#1–3) which can be made atomic with |
# Conflicts: # src/server/services/dataEventEmitter.ts
|
Claude finished @Yeraze's task —— View job PR Review: feat(meshcore): delete/purge messages (#3981)
Overall AssessmentThis is a solid, well-structured implementation that closely mirrors the existing Meshtastic purge patterns. Per-source scoping is correctly enforced end-to-end, auth + permission middleware is consistently applied, and the layered tests provide good coverage. A few areas worth addressing before merge: Bugs / Correctness1. Double-query pattern in all four delete methods ( Every new delete method does a // Current (two queries — racy + slow):
const existing = await this.db.select({id: ...}).from(...).where(condition).limit(1);
if (existing.length === 0) return false;
await this.db.delete(meshcoreMessages).where(condition);
return true;
// Preferred (one atomic query):
const result = await this.db.delete(meshcoreMessages).where(condition);
return this.getAffectedRows(result) > 0;This affects 2. const selfKey = this.localNode?.publicKey;
// ...
const fromPeer = !MeshCoreManager.keysMatch(r.fromPublicKey, selfKey)
&& MeshCoreManager.keysMatch(r.fromPublicKey, publicKey);If if (!selfKey) {
logger.warn(`[MeshCore:${this.sourceId}] purgeConversation: localNode has no publicKey; self-exclusion skipped`);
}Security3. Unvalidated socket payload const matches = (a?: string) => !!a && (a === pk || a.startsWith(pk) || pk.startsWith(a));The 4. Route registration relies on implicit Express ordering ( The comment correctly notes that 5. if (!/^[0-9a-f]{2,64}$/.test(publicKey)) {A 2-hex-character prefix matches ~1/256 of all contacts — far too broad for practical use. The PR description mentions prefix semantics, so document the minimum useful prefix length (8+ chars = 4 bytes for reasonable uniqueness) or enforce a higher floor. At minimum, a comment explaining why 2 is acceptable would prevent future confusion. Code Quality6. Channel-filter logic duplicated in three places The same filter expression appears in:
The double-apply (optimistic + server broadcast) is intentional and documented. However, the filter logic itself appears in three independent locations — if Test CoverageThe test coverage is genuinely comprehensive:
Summary
The core implementation is sound, permissions are correctly scoped, the event pipeline is clean, and the test suite is thorough. The most impactful fix is the double-query pattern (#1) — it's both a correctness issue (TOCTOU) and a performance issue for bulk operations. |
Closes #3981.
Summary
MeshCore messages (channel and DM) had no way to be deleted or purged from the UI, unlike Meshtastic sources. This PR adds the full stack — per-message delete, per-conversation/channel clear, and "purge all MeshCore messages for this source" — mirroring the existing Meshtastic purge patterns and honouring per-source scoping end to end.
Storage model note
All MeshCore messages live in one
meshcore_messagestable; channel identity is synthesised intochannel-Npubkey columns and DMs use real pubkeys. Inbound DMs store the sender as a pubkey prefix while outbound store the full key, so DM conversation matching needs the same prefix semantics the frontendkeysMatchuses — resolved in the manager, then deleted by id.Backend
src/db/repositories/meshcore.ts): source-scoped deletes —deleteMessageForSource,deleteMessagesByIds(chunked to stay under SQLite's bind-variable ceiling),deleteChannelMessagesForSource(reuses the existingchannelWhereClause),deleteAllMessagesForSource, plus a lightweightgetMessageEndpointsForSource. Every method requires asourceId.src/server/meshcoreManager.ts):deleteStoredMessage,purgeConversation,purgeChannelMessages,purgeAllMessages. Each deletes scoped to its own source, prunes the in-memory message pool, and broadcasts a newmeshcore:messages:deletedevent.src/server/routes/meshcoreRoutes.ts):DELETE /messages,/messages/channel/:idx,/messages/conversation/:publicKey,/messages/:messageId— allrequireAuth()+requirePermission('messages','write',{ sourceIdFrom: 'params.id' })and audit-logged. (The single-message route param is:messageId, not:id, so it doesn't shadow the mount's source id.)Frontend / UI approach
onDeleteMessagecallback on the sharedMeshCoreMessageStream, rendering a hover-revealed 🗑️ next to the existing reply affordance — wired into both the DM view and the channel view (one component, both surfaces).MeshCoreSettingsViewwith a "Purge all messages" button. Chosen because the settings view is the natural home for a source-wide destructive action and already receivesactions(no sourceId threading needed).useMeshCorehook gains the four actions and ameshcore:messages:deletedsocket handler that prunes local state; all destructive UI pathswindow.confirmfirst, matching the Meshtastic idiom.Tests
meshcore.messagePurge.perSource.test.ts— repository source-isolation (incl. chunking + refusing another source's id).meshcoreManager.messagePurge.test.ts— DM prefix matching, pool prune, event emit.meshcoreRoutes.test.ts— auth (401), permission-scoped routing, validation (400), 404s, and no cross-source leak.Verification
tsc --noEmit: clean.NodeJS/no-undefnoise fromeslint .is unrelated to this change).🤖 Generated with Claude Code
https://claude.ai/code/session_015AFBA76hsjqhsXe1BdnYub