fix(#1800): add routed_through filter, clarify path, fix hex lexer error (#1801)

Fixes #1800.

## Three changes

1. **`routed_through` field** — new `FIELDS` entry. Resolves against
`packet.resolved_path` (handles both the JSON-string form from
`/api/packets/by-id` and the already-parsed-array form from
`/api/packets`). Returns a space-joined lower-case hex string so
`contains` / `starts_with` / `==` work the same way they already do for
`hash`.
2. **`path` desc clarified + `path_prefixes` alias** — `path` desc now
reads `Hop path as 1-byte prefixes joined (e.g. a3→7f). For pubkey
search use routed_through.` `path_prefixes` is added as a
discoverability alias and resolves to the same value.
3. **Lexer hex-token error** — when the number/duration tokenizer hits
an unknown unit AND the slice (extended forward through any remaining
`[0-9a-fA-F]`) is pure hex of length ≥ 4, the lexer now returns:

   ```
Hex value must be quoted: try 'field == "<hex>"' or use the
starts_with/contains operator
   ```

instead of `Invalid duration unit 'f' at position N (expected
s/m/h/d/w)`. The duration-unit error is preserved for non-hex cases
(`age < 5x` still errors with the original message).

## TDD

Red commit `e44ac00a` adds 7 assertions that fail with the unmodified
code (proven by stashing the impl and re-running — output: `7 failed`).
Green commit `7b623721` makes them pass.

Tests added in `test-packet-filter.js` (`#1800: …`):
- `routed_through starts_with "2f0b00"` matches packet with JSON-string
`resolved_path`
- same, against array-form `resolved_path` (handles real `/api/packets`
shape)
- `routed_through contains "<full-pubkey>"` matches
- `routed_through contains "2f0b"` matches
- `routed_through starts_with "deadbe"` does NOT match
- `path 2f0b001247a047ca` → error contains `Hex value must be quoted`
- regression: `path contains "a3"` still matches `path_json=["a3","7f"]`
- `routed_through` listed in `FIELDS`
- `path_prefixes` alias resolves like `path`

`node test-packet-filter.js` → `=== Results: 92 passed, 0 failed ===`
Sibling JS tests (`test-packet-filter-ux.js`,
`test-packet-filter-time.js`, in-file self-tests) all green.

## Browser verification

Browser tool was unavailable this session, so I executed
`public/packet-filter.js` in a Node VM context (identical execution) and
exercised it against a live `/api/packets?limit=200` response from
staging:

- `routed_through starts_with "41b1"` returned 1 matching packet (whose
`resolved_path[0]` is
`41b1eabc3c6e88997242051ee53fa5840761dff02ac5f6d9904f23985395ec31`)
- `routed_through starts_with "bccf91"` matched a packet with that hop
- `routed_through starts_with "deadbe"` matched nothing (correct)
- `PF.suggest('routed_t', 8)` returned `['routed_through']`
- `PF.suggest('route', 5)` returned `['route', 'routed_through']`
- `PF.compile('path 2f0b001247a047ca').error` is verbatim: `Hex value
must be quoted: try 'field == "<hex>"' or use the starts_with/contains
operator`
- `PF.compile('age < 5x').error` is still `Invalid duration unit 'x' at
position 7 (expected s/m/h/d/w)` — duration-unit message preserved for
non-hex cases.

## Out of scope (per issue)

- No server-side filter pushdown.
- No operator-list changes.
- No `resolved_path` changes — it already ships on `/api/packets`,
`/api/packets/by-id`, `/api/live`.

---------

Co-authored-by: meshcore-bot <meshcore-bot@users.noreply.github.com>
This commit is contained in:
Kpa-clawbot
2026-06-27 21:02:37 -07:00
committed by GitHub
co-authored by meshcore-bot
parent d5ceb27334
commit 5e096147e5
2 changed files with 76 additions and 2 deletions
+33 -2
View File
@@ -78,6 +78,18 @@
while (i < len && /[a-zA-Z]/.test(input[i])) i++;
var unit = input.slice(unitStart, i);
if (!DURATION_UNITS[unit]) {
// Issue #1800: when the user typed a bare hex token (e.g.
// `path 2f0b001247a047ca`), the number/duration tokenizer eats
// only the leading "2f". Extend the scan to capture any further
// [0-9a-fA-F] chars; if the combined slice is pure hex of
// length >= 4, emit a targeted error rather than the cryptic
// duration-unit message.
var probe = i;
while (probe < len && /[0-9a-fA-F]/.test(input[probe])) probe++;
var fullSlice = input.slice(start, probe);
if (/^[0-9a-fA-F]+$/.test(fullSlice) && fullSlice.length >= 4) {
return { tokens: null, error: "Hex value must be quoted: try 'field == \"<hex>\"' or use the starts_with/contains operator" };
}
return { tokens: null, error: "Invalid duration unit '" + unit + "' at position " + unitStart + " (expected s/m/h/d/w)" };
}
tokens.push({ type: TK.DURATION, value: parseFloat(numStr) * DURATION_UNITS[unit], raw: numStr + unit });
@@ -274,9 +286,26 @@
if (isNaN(ms2)) return null;
return Math.max(0, (Date.now() - ms2) / 1000);
}
if (field === 'path') {
if (field === 'path' || field === 'path_prefixes') {
try { return JSON.parse(packet.path_json || '[]').join(' → '); } catch(e) { return ''; }
}
// Issue #1800: routed_through matches against resolved_path full pubkeys.
// resolved_path may arrive as a JSON string OR as an already-parsed array
// (depends on which API surface produced the packet). Handles both.
if (field === 'routed_through') {
var rp = packet.resolved_path;
if (rp == null) return '';
var arr;
if (typeof rp === 'string') {
try { arr = JSON.parse(rp); } catch (e) { return ''; }
} else if (Array.isArray(rp)) {
arr = rp;
} else {
return '';
}
if (!Array.isArray(arr)) return '';
return arr.map(function(h) { return String(h || '').toLowerCase(); }).join(' ');
}
if (field === 'payload_bytes') {
return packet.raw_hex ? Math.max(0, packet.raw_hex.length / 2 - 2) : 0;
}
@@ -444,7 +473,9 @@
{ name: 'observer_iata', desc: 'Observer IATA region code (e.g. SJC, SFO)' },
{ name: 'iata', desc: 'Alias of observer_iata' },
{ name: 'observations', desc: 'Number of observations of this packet' },
{ name: 'path', desc: 'Hop path (joined with arrows)' },
{ name: 'path', desc: 'Hop path as 1-byte prefixes joined (e.g. a3→7f). For pubkey search use routed_through.' },
{ name: 'path_prefixes', desc: 'Alias of path (1-byte hop prefixes joined with arrows)' },
{ name: 'routed_through', desc: 'Match packets routed through a node by pubkey (full or prefix)' },
{ name: 'payload_bytes', desc: 'Payload size in bytes (size - 2 header bytes)' },
{ name: 'payload_hex', desc: 'Payload bytes as hex (raw without header)' },
{ name: 'time', desc: 'Packet timestamp (epoch ms)' },
+43
View File
@@ -255,5 +255,48 @@ test('#1774: payload.srcHash listed in FIELDS suggestions', () => {
assert(names.indexOf('payload.srcHash') !== -1, 'payload.srcHash missing from FIELDS');
});
// --- Issue #1800: routed_through field + path desc + hex lexer error ---
const routedPkt = {
...pkt,
resolved_path: '["2f0b001247a047cabcdef0123456789a","aabbccddeeff00112233445566778899"]',
};
const routedArrPkt = {
...pkt,
resolved_path: ['2f0b001247a047cabcdef0123456789a', 'aabbccddeeff00112233445566778899'],
};
test('#1800: routed_through starts_with prefix matches', () => {
assert(PF.compile('routed_through starts_with "2f0b00"').filter(routedPkt));
});
test('#1800: routed_through starts_with prefix matches (array form)', () => {
assert(PF.compile('routed_through starts_with "2f0b00"').filter(routedArrPkt));
});
test('#1800: routed_through == full pubkey matches', () => {
assert(PF.compile('routed_through contains "2f0b001247a047cabcdef0123456789a"').filter(routedPkt));
});
test('#1800: routed_through contains "2f0b" matches', () => {
assert(PF.compile('routed_through contains "2f0b"').filter(routedPkt));
});
test('#1800: routed_through does not match unrelated prefix', () => {
assert(!PF.compile('routed_through starts_with "deadbe"').filter(routedPkt));
});
test('#1800: bare hex value after path → "Hex value must be quoted" error', () => {
const c = PF.compile('path 2f0b001247a047ca');
assert(c.error !== null, 'should have parse/lex error');
assert(c.error.indexOf('Hex value must be quoted') !== -1,
'error must mention Hex value must be quoted, got: ' + c.error);
});
test('#1800 regression: path contains "a3" still matches existing prefix list', () => {
const prefixPkt = { ...pkt, path_json: '["a3","7f"]' };
assert(PF.compile('path contains "a3"').filter(prefixPkt));
});
test('#1800: routed_through listed in FIELDS suggestions', () => {
const names = PF.FIELDS.map(f => f.name);
assert(names.indexOf('routed_through') !== -1, 'routed_through missing from FIELDS');
});
test('#1800: path_prefixes alias resolves like path', () => {
const prefixPkt = { ...pkt, path_json: '["a3","7f"]' };
assert(PF.compile('path_prefixes contains "a3"').filter(prefixPkt));
});
console.log(`\n=== Results: ${pass} passed, ${fail} failed ===`);
process.exit(fail > 0 ? 1 : 0);