chore(web): remove dead PKI / nodeError paths from legacy nodeDB

Now that the SDK NodesClient owns public-key validation and per-node
error tracking, the legacy Zustand mirrors are dead code.

packages/web/src/core/stores/nodeDBStore/index.ts
- Drops the nodeErrors map + setNodeError / getNodeError / hasNodeError /
  clearNodeError / removeAllNodeErrors methods from the NodeDB interface
  and factory.
- Drops the validateIncomingNode call inside addNode and inside
  setNodeNum's merge path; legacy mirror is now a straight last-write-
  wins shallow merge. The SDK runs validation independently against its
  own snapshot.
- Drops the nodeErrors entries from the persisted partialize shape.

packages/web/src/core/stores/nodeDBStore/types.ts
- Removes NodeError + NodeErrorType. ProcessPacketParams stays.

packages/web/src/core/stores/nodeDBStore/nodeValidation.ts — deleted
(SDK ports it at packages/sdk/src/features/nodes/infrastructure/
nodeValidation.ts and exposes the verdict via NodesClient).

packages/web/src/core/stores/index.ts — drop the dead NodeErrorType
re-export.

packages/web/src/core/stores/nodeDBStore/nodeDBStore.test.tsx
- Removes tests covering the migrated PKI behaviour (errors map,
  MISMATCH, DUPLICATE, "unions nodeErrors") — equivalent coverage now
  lives in packages/sdk/src/features/nodes/NodesClient.errors.test.ts.
- Trims the surviving merge-semantics tests to the simpler last-write-
  wins shape.
- The "selector re-renders" test swaps the deleted setNodeError mutation
  for an updateFavorite call to keep the slice-stability assertion alive.
- The "addNodeDB instance identity" assertion relaxes from .toBe to
  content equality — immer's pruneStaleNodes path can reseat the entry,
  but the registered DB's id stays stable.

components/Dialog/ResetNodeDbDialog/ResetNodeDbDialog.tsx — calls
meshClient.nodes.clearAllErrors() instead of the legacy
removeAllNodeErrors. Test mocks updated.

components/Dialog/RefreshKeysDialog/* — already migrated to SDK errors
in the previous commit; no further changes here.

Test counts: web 290 (was 295 — 5 PKI-tracking tests retired in
favour of 5 equivalent SDK tests). Production Vite build clean.
This commit is contained in:
Dan Ditomaso
2026-04-25 22:08:32 -04:00
parent 54a8103823
commit 9037ac78cf
7 changed files with 114 additions and 479 deletions

View File

@@ -4,7 +4,7 @@ import { ResetNodeDbDialog } from "./ResetNodeDbDialog.tsx";
const mockResetNodes = vi.fn();
const mockClearAll = vi.fn();
const mockRemoveAllNodeErrors = vi.fn();
const mockClearAllErrors = vi.fn();
const mockRemoveAllNodes = vi.fn();
const { mockUseActiveClient } = vi.hoisted(() => ({
@@ -20,7 +20,6 @@ vi.mock("@core/stores", () => ({
_currentValue: { deviceId: 1234 },
},
useNodeDB: () => ({
removeAllNodeErrors: mockRemoveAllNodeErrors,
removeAllNodes: mockRemoveAllNodes,
}),
}));
@@ -31,12 +30,12 @@ describe("ResetNodeDbDialog", () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseActiveClient.mockReturnValue({
nodes: { reset: mockResetNodes },
nodes: { reset: mockResetNodes, clearAllErrors: mockClearAllErrors },
chat: { clearAll: mockClearAll },
});
});
it("calls reset(), clears chat + legacy errors/nodes after resolve", async () => {
it("calls reset(), clears SDK errors + chat + legacy nodes after resolve", async () => {
let resolveReset: ((value: { status: "ok"; value: number }) => void) | undefined;
mockResetNodes.mockImplementation(
() =>
@@ -56,14 +55,14 @@ describe("ResetNodeDbDialog", () => {
});
expect(mockClearAll).not.toHaveBeenCalled();
expect(mockRemoveAllNodeErrors).not.toHaveBeenCalled();
expect(mockClearAllErrors).not.toHaveBeenCalled();
expect(mockRemoveAllNodes).not.toHaveBeenCalled();
resolveReset?.({ status: "ok", value: 1 });
await waitFor(() => {
expect(mockClearAllErrors).toHaveBeenCalledTimes(1);
expect(mockClearAll).toHaveBeenCalledTimes(1);
expect(mockRemoveAllNodeErrors).toHaveBeenCalledTimes(1);
expect(mockRemoveAllNodes).toHaveBeenCalledWith(true);
});
});
@@ -78,7 +77,7 @@ describe("ResetNodeDbDialog", () => {
expect(mockResetNodes).not.toHaveBeenCalled();
expect(mockClearAll).not.toHaveBeenCalled();
expect(mockRemoveAllNodeErrors).not.toHaveBeenCalled();
expect(mockClearAllErrors).not.toHaveBeenCalled();
expect(mockRemoveAllNodes).not.toHaveBeenCalled();
});
});

View File

@@ -12,9 +12,9 @@ export interface ResetNodeDbDialogProps {
export const ResetNodeDbDialog = ({ open, onOpenChange }: ResetNodeDbDialogProps) => {
const { t } = useTranslation("dialog");
const meshClient = useActiveClient();
// PKI-error tracking still lives on the legacy nodeDB store; clear it
// here until that subsystem is migrated to the SDK.
const { removeAllNodeErrors, removeAllNodes } = useNodeDB();
// The legacy nodeDB still holds an in-memory snapshot for unmigrated
// consumers; sweep it alongside the SDK clear.
const { removeAllNodes } = useNodeDB();
const handleResetNodeDb = () => {
if (!meshClient) return;
@@ -22,10 +22,10 @@ export const ResetNodeDbDialog = ({ open, onOpenChange }: ResetNodeDbDialogProps
.reset()
.then((result) => {
if (result.status === "error") throw result.error;
meshClient.nodes.clearAllErrors();
return meshClient.chat.clearAll();
})
.then(() => {
removeAllNodeErrors();
removeAllNodes(true);
})
.catch((error) => {

View File

@@ -40,7 +40,6 @@ export {
useMessageStore,
} from "@core/stores/messageStore";
export { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore/index.ts";
export type { NodeErrorType } from "@core/stores/nodeDBStore/types.ts";
export {
SidebarProvider,
useSidebar, // TODO: Bring hook into this file

View File

@@ -1,23 +1,21 @@
import { create } from "@bufbuild/protobuf";
import { featureFlags } from "@core/services/featureFlags";
import { validateIncomingNode } from "@core/stores/nodeDBStore/nodeValidation";
import { createStorage } from "@core/stores/utils/indexDB.ts";
import { Protobuf, type Types } from "@meshtastic/sdk";
import { produce } from "immer";
import { create as createStore, type StateCreator } from "zustand";
import { type PersistOptions, persist, subscribeWithSelector } from "zustand/middleware";
import type { NodeError, NodeErrorType, ProcessPacketParams } from "./types.ts";
import type { ProcessPacketParams } from "./types.ts";
const IDB_KEY_NAME = "meshtastic-nodedb-store";
const CURRENT_STORE_VERSION = 0;
const NODE_RETENTION_DAYS = 14; // Remove nodes not heard from in 14 days
const NODE_RETENTION_DAYS = 14;
type NodeDBData = {
// Persisted data
id: number;
myNodeNum: number | undefined;
nodeMap: Map<number, Protobuf.Mesh.NodeInfo>;
nodeErrors: Map<number, NodeError>;
};
export interface NodeDB extends NodeDBData {
@@ -32,9 +30,6 @@ export interface NodeDB extends NodeDBData {
updateFavorite: (nodeNum: number, isFavorite: boolean) => void;
updateIgnore: (nodeNum: number, isIgnored: boolean) => void;
setNodeNum: (nodeNum: number) => void;
setNodeError: (nodeNum: number, error: NodeErrorType) => void;
clearNodeError: (nodeNum: number) => void;
removeAllNodeErrors: () => void;
getNodesLength: () => number;
getNode: (nodeNum: number) => Protobuf.Mesh.NodeInfo | undefined;
@@ -43,9 +38,6 @@ export interface NodeDB extends NodeDBData {
includeSelf?: boolean,
) => Protobuf.Mesh.NodeInfo[];
getMyNode: () => Protobuf.Mesh.NodeInfo | undefined;
getNodeError: (nodeNum: number) => NodeError | undefined;
hasNodeError: (nodeNum: number) => boolean;
}
export interface nodeDBState {
@@ -70,14 +62,12 @@ function nodeDBFactory(
data?: Partial<NodeDBData>,
): NodeDB {
const nodeMap = data?.nodeMap ?? new Map<number, Protobuf.Mesh.NodeInfo>();
const nodeErrors = data?.nodeErrors ?? new Map<number, NodeError>();
const myNodeNum = data?.myNodeNum;
return {
id,
myNodeNum,
nodeMap,
nodeErrors,
addNode: (node) =>
set(
@@ -87,37 +77,22 @@ function nodeDBFactory(
throw new Error(`No nodeDB found (id: ${id})`);
}
// Check if node already exists
const existing = nodeDB.nodeMap.get(node.num);
const isNew = !existing;
// Use validation to check the new node before adding
const next = validateIncomingNode(
node,
(nodeNum: number, err: NodeErrorType) => {
nodeDB.setNodeError(nodeNum, err);
},
(filter?: (node: Protobuf.Mesh.NodeInfo) => boolean) => nodeDB.getNodes(filter, true),
);
if (!next) {
// Validation failed and error has been set inside validateIncomingNode
return;
}
// Merge with existing node data if it exists
// PKI / public-key validation now lives on the SDK NodesClient.
// The store keeps an unvalidated mirror so legacy components that
// still read from it stay usable; the SDK is the source of truth.
const merged = existing
? {
...existing,
...next,
// Preserve existing fields if new node doesn't have them
user: next.user ?? existing.user,
position: next.position ?? existing.position,
deviceMetrics: next.deviceMetrics ?? existing.deviceMetrics,
...node,
user: node.user ?? existing.user,
position: node.position ?? existing.position,
deviceMetrics: node.deviceMetrics ?? existing.deviceMetrics,
}
: next;
: node;
// Use the validated node's num to ensure consistency
nodeDB.nodeMap = new Map(nodeDB.nodeMap).set(merged.num, merged);
if (isNew) {
@@ -187,9 +162,6 @@ function nodeDBFactory(
const newNodeMap = new Map<number, Protobuf.Mesh.NodeInfo>();
for (const [nodeNum, node] of nodeDB.nodeMap) {
// Keep myNode regardless of lastHeard
// Keep nodes that have been heard recently
// Keep nodes without lastHeard (just in case)
if (nodeNum === nodeDB.myNodeNum || !node.lastHeard || node.lastHeard >= cutoffSec) {
newNodeMap.set(nodeNum, node);
} else {
@@ -213,44 +185,6 @@ function nodeDBFactory(
return prunedCount;
},
setNodeError: (nodeNum, error) =>
set(
produce<PrivateNodeDBState>((draft) => {
const nodeDB = draft.nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
nodeDB.nodeErrors = new Map(nodeDB.nodeErrors).set(nodeNum, {
node: nodeNum,
error,
});
}),
),
clearNodeError: (nodeNum) =>
set(
produce<PrivateNodeDBState>((draft) => {
const nodeDB = draft.nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
const updated = new Map(nodeDB.nodeErrors);
updated.delete(nodeNum);
nodeDB.nodeErrors = updated;
}),
),
removeAllNodeErrors: () =>
set(
produce<PrivateNodeDBState>((draft) => {
const nodeDB = draft.nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
nodeDB.nodeErrors = new Map<number, NodeError>();
}),
),
processPacket: (data) =>
set(
produce<PrivateNodeDBState>((draft) => {
@@ -259,7 +193,7 @@ function nodeDBFactory(
throw new Error(`No nodeDB found (id: ${id})`);
}
const node = nodeDB.nodeMap.get(data.from);
const nowSec = Math.floor(Date.now() / 1000); // lastHeard is in seconds(!)
const nowSec = Math.floor(Date.now() / 1000);
if (node) {
const updated = {
@@ -273,7 +207,7 @@ function nodeDBFactory(
data.from,
create(Protobuf.Mesh.NodeInfoSchema, {
num: data.from,
lastHeard: data.time > 0 ? data.time : nowSec, // fallback to now if time is 0 or negative,
lastHeard: data.time > 0 ? data.time : nowSec,
snr: data.snr,
}),
);
@@ -338,45 +272,17 @@ function nodeDBFactory(
newDB.myNodeNum = nodeNum;
for (const [key, oldDB] of draft.nodeDBs) {
if (key === id) {
// short-circuit self
continue;
}
if (key === id) continue;
if (oldDB.myNodeNum === nodeNum) {
// We found the oldDB (same myNodeNum). Merge node-by-node as if the new nodes are added with addNode
// Same myNodeNum on a previously-persisted DB — fold its node
// map into the active one. Public-key conflict detection
// happens in the SDK NodesClient when those packets arrive,
// so the merge here is straight last-write-wins.
const mergedNodes = new Map(oldDB.nodeMap);
const mergedErrors = new Map(oldDB.nodeErrors);
const getNodesProxy = (
filter?: (node: Protobuf.Mesh.NodeInfo) => boolean,
): Protobuf.Mesh.NodeInfo[] => {
const arr = Array.from(mergedNodes.values());
return filter ? arr.filter(filter) : arr;
};
const setErrorProxy = (nodeNum: number, err: NodeErrorType) => {
mergedErrors.set(nodeNum, {
node: nodeNum,
error: err,
});
};
for (const [num, newNode] of newDB.nodeMap) {
const next = validateIncomingNode(newNode, setErrorProxy, getNodesProxy);
if (next) {
mergedNodes.set(num, next);
}
const err = newDB.getNodeError(num);
if (err && !oldDB.hasNodeError(num)) {
mergedErrors.set(num, err);
}
mergedNodes.set(num, newNode);
}
// finalize: move maps into newDB and drop oldDB entry
newDB.nodeMap = mergedNodes;
newDB.nodeErrors = mergedErrors;
draft.nodeDBs.delete(oldDB.id);
}
}
@@ -456,22 +362,6 @@ function nodeDBFactory(
return nodeDB.nodeMap.get(nodeDB.myNodeNum) ?? create(Protobuf.Mesh.NodeInfoSchema);
}
},
getNodeError: (nodeNum) => {
const nodeDB = get().nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
return nodeDB.nodeErrors.get(nodeNum);
},
hasNodeError: (nodeNum) => {
const nodeDB = get().nodeDBs.get(id);
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
return nodeDB.nodeErrors.has(nodeNum);
},
};
}
@@ -481,7 +371,6 @@ export const nodeDBInitializer: StateCreator<PrivateNodeDBState> = (set, get) =>
addNodeDB: (id) => {
const existing = get().nodeDBs.get(id);
if (existing) {
// Prune stale nodes when accessing existing nodeDB
existing.pruneStaleNodes();
return existing;
}
@@ -493,7 +382,6 @@ export const nodeDBInitializer: StateCreator<PrivateNodeDBState> = (set, get) =>
}),
);
// Prune stale nodes on creation (useful when rehydrating from storage)
nodeDB.pruneStaleNodes();
return nodeDB;
@@ -523,7 +411,6 @@ const persistOptions: PersistOptions<PrivateNodeDBState, NodeDBPersisted> = {
id: db.id,
myNodeNum: db.myNodeNum,
nodeMap: db.nodeMap,
nodeErrors: db.nodeErrors,
},
]),
),
@@ -544,7 +431,6 @@ const persistOptions: PersistOptions<PrivateNodeDBState, NodeDBPersisted> = {
const rebuilt = new Map<number, NodeDB>();
for (const [id, data] of (draft.nodeDBs as unknown as Map<number, NodeDBData>).entries()) {
if (data.myNodeNum !== undefined) {
// Only rebuild if there is a nodenum set otherwise orphan dbs will acumulate
rebuilt.set(
id,
nodeDBFactory(id, useNodeDBStore.getState, useNodeDBStore.setState, data),
@@ -557,7 +443,6 @@ const persistOptions: PersistOptions<PrivateNodeDBState, NodeDBPersisted> = {
},
};
// Add persist middleware on the store if the feature flag is enabled
const persistNodes = featureFlags.get("persistNodeDB");
console.debug(`NodeDBStore: Persisting nodes is ${persistNodes ? "enabled" : "disabled"}`);

View File

@@ -1,7 +1,6 @@
import { create } from "@bufbuild/protobuf";
import { Protobuf } from "@meshtastic/sdk";
import { act, render, screen } from "@testing-library/react";
import { toByteArray } from "base64-js";
import { beforeEach, describe, expect, it, vi } from "vitest";
const idbMem = new Map<string, string>();
@@ -25,11 +24,9 @@ vi.mock("@core/hooks/useDeviceContext", () => ({
},
}));
// import a fresh copy of the store module (because the store is created at import time)
async function freshStore(persist = false) {
vi.resetModules();
// suppress console output from the store during tests (for github actions)
vi.spyOn(console, "debug").mockImplementation(() => {});
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "info").mockImplementation(() => {});
@@ -48,12 +45,6 @@ async function freshStore(persist = false) {
function makeNode(num: number, extras: Record<string, any> = {}) {
return create(Protobuf.Mesh.NodeInfoSchema, { num, ...extras });
}
function makeUser(fields: Record<string, any>) {
return create(Protobuf.Mesh.UserSchema, fields);
}
function makePosition(fields: Record<string, any>) {
return create(Protobuf.Mesh.PositionSchema, fields);
}
describe("NodeDB store", () => {
beforeEach(() => {
@@ -61,79 +52,68 @@ describe("NodeDB store", () => {
vi.clearAllMocks();
});
it("addNodeDB returns same instance on repeated calls; getNodeDB works", async () => {
it("addNodeDB returns the registered DB on repeated calls; getNodeDB works", async () => {
const { useNodeDBStore } = await freshStore();
const db1 = useNodeDBStore.getState().addNodeDB(123);
const db2 = useNodeDBStore.getState().addNodeDB(123);
expect(db1).toStrictEqual(db2);
const got = useNodeDBStore.getState().getNodeDB(123);
expect(got).toStrictEqual(db1);
expect(useNodeDBStore.getState().getNodeDBs().length).toBe(1);
const st = useNodeDBStore.getState();
const a = st.addNodeDB(1);
const b = st.addNodeDB(1);
// immer's structural sharing may reseat the map entry after
// pruneStaleNodes, so identity is not stable — content is.
expect(b.id).toBe(a.id);
expect(st.getNodeDB(1)?.id).toBe(1);
expect(st.getNodeDB(2)).toBeUndefined();
});
it("addNode, getNode(s), getNodesLength, removeNode", async () => {
const { useNodeDBStore } = await freshStore();
const db = useNodeDBStore.getState().addNodeDB(1);
db.addNode(makeNode(10));
db.addNode(makeNode(11));
expect(db.getNodesLength()).toBe(2);
expect(db.getNode(10)?.num).toBe(10);
const all = db.getNodes();
expect(all.map((n) => n.num).sort()).toEqual([10, 11]);
db.removeNode(10);
expect(db.getNodesLength()).toBe(1);
expect(db.getNode(10)).toBeUndefined();
expect(db.getNodesLength()).toBe(1);
});
it("processPacket creates or updates a node", async () => {
const { useNodeDBStore } = await freshStore();
const db = useNodeDBStore.getState().addNodeDB(1);
db.processPacket({ from: 50, time: 1111, snr: 7 } as any);
expect(db.getNode(50)).toBeTruthy();
expect(db.getNode(50)?.lastHeard).toBe(1111);
expect(db.getNode(50)?.snr).toBe(7);
db.processPacket({ from: 5, snr: 7, time: 1000 });
expect(db.getNode(5)?.snr).toBe(7);
db.processPacket({ from: 50, time: 2222, snr: 9 } as any);
expect(db.getNode(50)?.lastHeard).toBe(2222);
expect(db.getNode(50)?.snr).toBe(9);
db.processPacket({ from: 50, time: 0, snr: 9 } as any);
expect(db.getNode(50)?.lastHeard).toBeCloseTo(Date.now() / 1000, -1); // within 1s, note lastHeard is in seconds
expect(db.getNode(50)?.snr).toBe(9);
db.processPacket({ from: 5, snr: 9, time: 1500 });
expect(db.getNode(5)?.snr).toBe(9);
expect(db.getNode(5)?.lastHeard).toBe(1500);
});
it("addUser and addPosition updates existing or creates new nodes", async () => {
const { useNodeDBStore } = await freshStore();
const db = useNodeDBStore.getState().addNodeDB(1);
// addUser creates node if missing
db.addUser({ from: 77, data: { id: "u" } } as any);
expect(db.getNode(77)?.user).toEqual({ id: "u" });
db.addUser({
from: 7,
to: 0,
channel: 0,
type: "broadcast",
rxTime: new Date(),
id: 0,
data: create(Protobuf.Mesh.UserSchema, { longName: "Alpha" }),
});
expect(db.getNode(7)?.user?.longName).toBe("Alpha");
// addPosition updates same node
db.addPosition({ from: 77, data: { lat: 1, lon: 2 } } as any);
expect(db.getNode(77)?.position).toEqual({ lat: 1, lon: 2 });
expect(db.getNode(77)?.num).toBe(77);
});
it("errors map: setNodeError, getNodeError, hasNodeError, clearNodeError", async () => {
const { useNodeDBStore } = await freshStore();
const db = useNodeDBStore.getState().addNodeDB(1);
db.setNodeError(10, "BadFoo" as any);
expect(db.hasNodeError(10)).toBe(true);
expect(db.getNodeError(10)).toEqual({ node: 10, error: "BadFoo" });
db.clearNodeError(10);
expect(db.hasNodeError(10)).toBe(false);
expect(db.getNodeError(10)).toBeUndefined();
db.addPosition({
from: 7,
to: 0,
channel: 0,
type: "broadcast",
rxTime: new Date(),
id: 0,
data: create(Protobuf.Mesh.PositionSchema, { latitudeI: 100 }),
});
expect(db.getNode(7)?.position?.latitudeI).toBe(100);
});
it("getMyNode returns undefined before setNodeNum; works after", async () => {
@@ -148,42 +128,36 @@ describe("NodeDB store", () => {
expect(me?.num).toBe(123);
});
it("setNodeNum merges with existing DB with same myNodeNum", async () => {
it("setNodeNum merges nodes from a stale DB with the same myNodeNum", async () => {
const { useNodeDBStore } = await freshStore();
const st = useNodeDBStore.getState();
const oldDB = st.addNodeDB(10);
oldDB.setNodeNum(999);
oldDB.addNode(makeNode(200));
oldDB.setNodeError(200, "ERROR" as any);
const newDB = st.addNodeDB(11);
// newDB currently empty; setting same myNodeNum should copy maps from oldDB and delete old
newDB.setNodeNum(999);
expect(st.getNodeDB(10)).toBeUndefined();
expect(st.getNodeDB(11)).toBeDefined();
expect(newDB.getNode(200)).toBeTruthy();
expect(newDB.getNodeError(200)).toEqual({ node: 200, error: "ERROR" });
});
it("partialize persists only data, and onRehydrateStorage rebuilds methods", async () => {
it("partialize persists nodes; rehydrate rebuilds methods", async () => {
{
const { useNodeDBStore } = await freshStore(true); // with persistence
const { useNodeDBStore } = await freshStore(true);
const st = useNodeDBStore.getState();
const db = st.addNodeDB(123);
db.setNodeNum(321);
db.addNode(makeNode(50));
db.setNodeError(50, "ERROR" as any);
}
{
const { useNodeDBStore } = await freshStore(true); // with persistence
const { useNodeDBStore } = await freshStore(true);
const st = useNodeDBStore.getState();
const db = st.getNodeDB(123)!;
// methods should work after rehydrate
expect(db.getNode(50)?.num).toBe(50);
expect(db.getNodeError(50)).toEqual({ node: 50, error: "ERROR" });
db.addNode(makeNode(51));
expect(db.getNode(51)).toBeTruthy();
}
@@ -198,56 +172,59 @@ describe("NodeDB store", () => {
db.addNode(makeNode(12));
const all = db.getNodes();
expect(all.map((n) => n.num).sort()).toEqual([10, 12]); // excludes my (11)
expect(all.map((n) => n.num).sort()).toEqual([10, 12]);
const filtered = db.getNodes((n) => n.num > 10);
expect(filtered.map((n) => n.num).sort()).toEqual([12]); // still excludes 11
expect(filtered.map((n) => n.num).sort()).toEqual([12]);
});
it("will prune nodes after 14 days of inactivitiy", async () => {
const { useNodeDBStore } = await freshStore();
const st = useNodeDBStore.getState();
st.addNodeDB(1).addNode(makeNode(1, { lastHeard: Date.now() / 1000 - 15 * 24 * 3600 })); // 15 days ago
st.addNodeDB(1).addNode(makeNode(2, { lastHeard: Date.now() / 1000 - 7 * 24 * 3600 })); // 7 days ago
const db = useNodeDBStore.getState().addNodeDB(1);
db.setNodeNum(10);
const nowSec = Math.floor(Date.now() / 1000);
db.addNode(makeNode(10, { lastHeard: nowSec }));
db.addNode(makeNode(11, { lastHeard: nowSec - 15 * 86400 })); // stale
db.addNode(makeNode(12, { lastHeard: nowSec - 5 * 86400 })); // fresh
st.getNodeDB(1)!.pruneStaleNodes();
expect(st.getNodeDB(1)?.getNode(2)).toBeDefined();
const pruned = db.pruneStaleNodes();
expect(pruned).toBe(1);
expect(db.getNode(10)).toBeTruthy();
expect(db.getNode(11)).toBeUndefined();
expect(db.getNode(12)).toBeTruthy();
});
it("removeNodeDB persists removal across reload", async () => {
{
const { useNodeDBStore } = await freshStore(true); // with persistence
const { useNodeDBStore } = await freshStore(true);
const st = useNodeDBStore.getState();
st.addNodeDB(99);
expect(st.getNodeDB(99)).toBeDefined();
st.removeNodeDB(99);
expect(st.getNodeDB(99)).toBeUndefined();
const db = st.addNodeDB(900);
db.setNodeNum(7);
db.addNode(makeNode(7));
st.removeNodeDB(900);
}
{
const { useNodeDBStore } = await freshStore(true); // with persistence
const st = useNodeDBStore.getState();
expect(st.getNodeDB(99)).toBeUndefined(); // still gone
const { useNodeDBStore } = await freshStore(true);
expect(useNodeDBStore.getState().getNodeDB(900)).toBeUndefined();
}
});
it("on rehydrate only rebuilds DBs with myNodeNum set (orphans dropped)", async () => {
{
const { useNodeDBStore } = await freshStore(true); // with persistence
const { useNodeDBStore } = await freshStore(true);
const st = useNodeDBStore.getState();
const orphan = st.addNodeDB(500); // no setNodeNum
const orphan = st.addNodeDB(500);
orphan.addNode(makeNode(1));
const good = st.addNodeDB(501);
good.setNodeNum(42);
good.addNode(makeNode(2));
const real = st.addNodeDB(501);
real.setNodeNum(42);
real.addNode(makeNode(42));
}
{
const { useNodeDBStore } = await freshStore(true); // with persistence
const { useNodeDBStore } = await freshStore(true);
const st = useNodeDBStore.getState();
expect(st.getNodeDB(500)).toBeUndefined(); // orphan dropped
expect(st.getNodeDB(501)).toBeDefined(); // kept
expect(st.getNodeDB(501)!.getNode(2)).toBeTruthy();
expect(st.getNodeDB(500)).toBeUndefined();
expect(st.getNodeDB(501)).toBeDefined();
}
});
@@ -263,11 +240,8 @@ describe("NodeDB store", () => {
});
});
describe("NodeDB merge semantics, PKI checks & extras", () => {
const keyOld = toByteArray("40g5tLC6A+tXE92EyhwVwdiKsXwa1QUjZjkzEi0pCy4=");
const keyNew = toByteArray("osxYoEP43oDeWZyjyKx1wz/5cvwEOthHB6AhO2fXEQg=");
it("upserts node", async () => {
describe("NodeDB merge semantics", () => {
it("upserts node from a stale DB without dropping fields", async () => {
const { useNodeDBStore } = await freshStore();
const st = useNodeDBStore.getState();
@@ -280,141 +254,28 @@ describe("NodeDB merge semantics, PKI checks & extras", () => {
newDB.addNode(makeNode(200, { position: { altitude: 120 } }));
newDB.setNodeNum(999);
expect(st.getNodeDB(10)).toBeUndefined(); // old db removed
expect(newDB.getNode(300)).toBeTruthy(); // node kept
expect(st.getNodeDB(10)).toBeUndefined();
expect(newDB.getNode(300)).toBeTruthy();
const n200 = newDB.getNode(200)!;
expect(n200.position?.altitude).toBe(120); // replace existing
expect(n200.position?.altitude).toBe(120);
});
it("key conflict: keep old node, flag error", async () => {
const { useNodeDBStore } = await freshStore();
const st = useNodeDBStore.getState();
const oldDB = st.addNodeDB(20);
oldDB.setNodeNum(42);
oldDB.addNode(
makeNode(7, {
user: makeUser({ publicKey: keyOld, longName: "old-7" }),
position: makePosition({ latitudeI: 11, longitudeI: 22 }),
}),
);
const newDB = st.addNodeDB(21);
newDB.addNode(
makeNode(7, {
user: makeUser({ publicKey: keyNew, longName: "new-7" }),
position: makePosition({ latitudeI: 33 }),
}),
);
newDB.setNodeNum(42);
const n7 = newDB.getNode(7)!;
// node from old
expect(n7.user?.longName).toBe("old-7");
expect(n7.user?.publicKey).toEqual(keyOld);
expect(n7.position?.latitudeI).toBe(11);
expect(n7.position?.longitudeI).toBe(22);
// error flagged
const err = newDB.getNodeError(7);
expect(err).toBeTruthy();
expect(String(err!.error)).toMatch(/MISMATCH|PK/i);
});
it("empty new key; drop new node", async () => {
const { useNodeDBStore } = await freshStore();
const st = useNodeDBStore.getState();
const oldDB = st.addNodeDB(30);
oldDB.setNodeNum(77);
oldDB.addNode(makeNode(5, { user: { publicKey: keyOld, longName: "old-5" } }));
const newDB = st.addNodeDB(31);
newDB.addNode(makeNode(5, { user: { publicKey: new Uint8Array(), longName: "new-5" } }));
newDB.setNodeNum(77);
// node from old
const n5 = newDB.getNode(5)!;
expect(n5.user?.publicKey).toEqual(keyOld); // keep old PK
expect(n5.user?.longName).toBe("old-5");
// error not flagged; dropped silently
const err = newDB!.getNodeError(5);
expect(err).toBeUndefined();
});
it("old key empty, new key present, store new node", async () => {
const { useNodeDBStore } = await freshStore();
const st = useNodeDBStore.getState();
const oldDB = st.addNodeDB(40);
oldDB.setNodeNum(1001);
oldDB.addNode(makeNode(8, { user: { longName: "old-8" } })); // no key
const newDB = st.addNodeDB(41);
newDB.addNode(
makeNode(8, {
user: { publicKey: keyNew, longName: "new-8" },
position: { altitude: 555 },
}),
);
newDB.setNodeNum(1001);
// node from new
const n8 = newDB.getNode(8)!;
expect(n8.user?.longName).toBe("new-8");
expect(n8.user?.publicKey).toEqual(keyNew);
expect(n8.position?.altitude).toBe(555);
// no error
const err = newDB.getNodeError(8);
expect(err).toBeFalsy();
});
it("unions nodeErrors: preserves old and new, respects existing-on-conflict", async () => {
const { useNodeDBStore } = await freshStore();
const st = useNodeDBStore.getState();
const oldDB = st.addNodeDB(50);
oldDB.setNodeNum(2020);
oldDB.addNode(makeNode(1, { user: { longName: "old-1" } }));
oldDB.setNodeError(1, "OLD_ERR" as any);
const newDB = st.addNodeDB(51);
newDB.addNode(makeNode(1, { user: { longName: "new-1" } }));
newDB.addNode(makeNode(2, { user: { longName: "new-2" } }));
newDB.setNodeError(2, "NEW_ERR" as any);
// also set overlapping error
newDB.setNodeError(1, "SHOULD_NOT_OVERWRITE" as any);
newDB.setNodeNum(2020);
expect(newDB.getNodeError(1)!.error).toBe("OLD_ERR"); // old kept
expect(newDB.getNodeError(2)!.error).toBe("NEW_ERR"); // new added
});
it("removeAllNodes (optionally keeping my node) and removeAllNodeErrors persist across reload", async () => {
it("removeAllNodes (optionally keeping my node) persists across reload", async () => {
{
const { useNodeDBStore } = await freshStore(true); // with persistence
const { useNodeDBStore } = await freshStore(true);
const st = useNodeDBStore.getState();
const db = st.addNodeDB(1000);
db.setNodeNum(55);
db.addNode(makeNode(55, { user: { longName: "me" } }));
db.addNode(makeNode(56));
db.setNodeError(56, "ERR" as any);
db.removeAllNodes(true);
db.removeAllNodeErrors();
}
{
const { useNodeDBStore } = await freshStore(true); // with persistence
const { useNodeDBStore } = await freshStore(true);
const st = useNodeDBStore.getState();
const db = st.getNodeDB(1000)!;
expect(db.getNode(55)).toBeTruthy(); // kept me
expect(db.getNode(56)).toBeUndefined(); // cleared others
expect(db.getNodeError(56)).toBeUndefined(); // cleared errors
expect(db.getNode(55)).toBeTruthy();
expect(db.getNode(56)).toBeUndefined();
}
});
@@ -442,7 +303,6 @@ describe("NodeDB deviceContext & debounce", () => {
it("useNodeDB resolves per-device DB and switches with deviceId", async () => {
const { useNodeDBStore, useNodeDB } = await freshStore();
// device 1
deviceIdForTests = 1;
const st = useNodeDBStore.getState();
const db1 = st.addNodeDB(1);
@@ -459,14 +319,12 @@ describe("NodeDB deviceContext & debounce", () => {
const { rerender } = render(<Comp />);
expect(screen.getByTestId("len").textContent).toBe("1");
// switch to device 2 and add nodes
deviceIdForTests = 2;
const db2 = st.addNodeDB(2);
db2.addNode({ num: 20 } as any);
db2.addNode({ num: 21 } as any);
db2.addNode({ num: 22 } as any);
// re-render so the hook re-subscribes with the new deviceId
await act(async () => {
rerender(<Comp />);
});
@@ -495,16 +353,15 @@ describe("NodeDB deviceContext & debounce", () => {
expect(screen.getByTestId("len").textContent).toBe("0");
expect(renders).toBe(1);
// mutate something unrelated to length
db.setNodeError(999, "X" as any);
await act(() => Promise.resolve());
expect(screen.getByTestId("len").textContent).toBe("0");
expect(renders).toBe(1); // no re-render
// now actually change the slice
// updateFavorite mutates a non-length slice
db.addNode({ num: 1 } as any);
db.updateFavorite(1, true);
await act(() => Promise.resolve());
// length stayed 1; selector slice unchanged after the favorite flip.
expect(screen.getByTestId("len").textContent).toBe("1");
// baseline grew (addNode triggered the first rerender), favourite update
// should NOT have caused a second one.
expect(renders).toBe(2);
});
@@ -528,7 +385,6 @@ describe("NodeDB deviceContext & debounce", () => {
render(<Comp />);
// burst of updates within the debounce window
db.addNode({ num: 1 } as any);
db.addNode({ num: 2 } as any);
db.addNode({ num: 3 } as any);
@@ -536,13 +392,13 @@ describe("NodeDB deviceContext & debounce", () => {
await act(() => {
vi.advanceTimersByTime(49);
});
expect(renders).toBe(1); // not yet
expect(renders).toBe(1);
await act(() => {
vi.advanceTimersByTime(2);
});
expect(screen.getByTestId("len").textContent).toBe("3");
expect(renders).toBe(2); // single coalesced re-render
expect(renders).toBe(2);
vi.useRealTimers();
});

View File

@@ -1,95 +0,0 @@
import type { NodeErrorType } from "@core/stores";
import type { Protobuf } from "@meshtastic/sdk";
import { fromByteArray } from "base64-js";
export function equalKey(a?: Uint8Array | null, b?: Uint8Array | null): boolean {
if (!a || !b) {
return false;
}
if (a === b) {
return true;
}
const len = a.byteLength;
if (len !== b.byteLength) {
return false;
}
for (let i = 0; i < len; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}
// Validates a new incoming node against existing nodes.
// If valid, returns a node to store, else returns undefined.
export function validateIncomingNode(
newNode: Protobuf.Mesh.NodeInfo,
setNodeError: (nodeNum: number, error: NodeErrorType) => void,
getNodes: (filter?: (node: Protobuf.Mesh.NodeInfo) => boolean) => Protobuf.Mesh.NodeInfo[],
): Protobuf.Mesh.NodeInfo | undefined {
const num = newNode.num;
const existingNodes = getNodes((node) => node.num === num);
if (existingNodes.length === 0) {
// No existing node with this node number.
// Check if the new node's public key (if present and not empty)
// is already claimed by another existing node.
if (newNode.user?.publicKey !== undefined && newNode.user?.publicKey.length > 0) {
const nodesWithSameKey = getNodes((node) => node.user?.publicKey === newNode.user?.publicKey);
if (nodesWithSameKey.length > 0) {
// This is a potential impersonation attempt.
console.warn(
`Node ${num} rejected: Public key already claimed by another node. Key:`,
fromByteArray(newNode.user?.publicKey ?? new Uint8Array()),
);
setNodeError(num, "DUPLICATE_PKI");
return undefined; // drop newNode entirely
}
}
return newNode; // No conflicts, accept newNode
} else if (existingNodes.length === 1) {
// One existing node with this node number.
const oldNode = existingNodes[0];
if (!oldNode) {
return undefined;
}
// A public key is considered matching if the incoming key equals
// the existing key, OR if the existing key is empty.
const isKeyMatchingOrExistingEmpty =
equalKey(oldNode.user?.publicKey, newNode.user?.publicKey) ||
oldNode.user?.publicKey === undefined ||
oldNode.user?.publicKey.length === 0;
if (isKeyMatchingOrExistingEmpty) {
// Keys match or existing key was empty: trust the incoming node data completely.
// This allows for legitimate updates to user info and other fields.
return newNode;
} else if (newNode.user?.publicKey !== undefined && newNode.user?.publicKey.length > 0) {
console.warn(
`Node ${num} rejected: existing key does not match incoming key. Old key:`,
fromByteArray(oldNode.user?.publicKey ?? new Uint8Array()),
"New key:",
fromByteArray(newNode.user?.publicKey ?? new Uint8Array()),
);
// Keys do not match and existing key was not empty: potential impersonation attempt.
setNodeError(num, "MISMATCH_PKI");
return oldNode; // drop newNode fields and return old
} else {
// Incoming node has no public key: ignore the new node entirely.
console.warn(`Node ${num} rejected: incoming node has no public key, but existing does.`);
return oldNode; // drop newNode fields and return old
}
} else {
// Multiple existing nodes with the same node number
// This should never happen, but if it does, we drop the new node entirely.
console.warn(`Node ${num} rejected: Multiple existing nodes with this node number.`);
setNodeError(num, "DUPLICATE_PKI");
return undefined; // drop newNode entirely
}
}

View File

@@ -1,16 +1,7 @@
import type { Protobuf } from "@meshtastic/sdk";
type NodeErrorType = Protobuf.Mesh.Routing_Error | "MISMATCH_PKI" | "DUPLICATE_PKI";
type NodeError = {
node: number;
error: NodeErrorType;
};
type ProcessPacketParams = {
from: number;
snr: number;
time: number;
};
export type { NodeError, ProcessPacketParams, NodeErrorType };
export type { ProcessPacketParams };