Commit Graph
2938 Commits
Author SHA1 Message Date
liquidraver bbf54cfe1b fix(rx-coverage): open at the configured map default, with its own saved viewport (#2033)
The coverage page opened at a hardcoded [51.0, 4.8] zoom 8 regardless of deployment, ignoring /api/config/map (#2032). It now follows the same precedence as the main map (URL hash, then saved position, then /api/config/map, then [37.6, -122.1] zoom 9) and persists its own position across visits, syncing lat/lon/zoom into the hash so a view is shareable.

The saved position lives under its own key, rx-coverage-view, and the page never reads or writes the main map's map-view: sharing the configured default was the bug, sharing the session position was not. Both suites assert map-view stays untouched after a pan, so reintroducing a shared write fails instead of passing quietly.

Also fixed here: selectedRx is now percent-encoded into the hash, and a generation counter stops a late /api/config/map response or a stale 150ms layout timer from building a map for a page that was already left.

Reviewed twice. Verified by mutation rather than by reading: writing map-view too, ignoring the saved coverage position, and dropping the /api/config/map fetch each make the unit suite exit 1, so it covers the feature, the fix and the rejected alternative. The deploy.yml invocation was confirmed to land inside Run Playwright E2E tests (fail-fast) by parsing the workflow, and CI run 35261689694 is the E2E suite's first real execution: Go prints 'RX coverage viewport regressions OK' and Playwright prints 'RX coverage viewport browser regressions OK'.

Worth recording for the next reviewer: that E2E asserted localStorage.getItem('map-view'), so wiring it into deploy.yml without updating the assertion would have turned the job red on its first ever run. It was registered in scripts/non-unit-tests.json but invoked by nothing, the gap tracked as #2037.

Merged by the interim maintainer without a second human reviewer: CI and the mutation checks above are the independent checks.

Fixes #2032
2026-09-17 22:02:36 +02:00
efiten aabeda0f2c test(server): set the #1239 lock-hold threshold from measurement, 150µs to 5ms (#2039)
TestComputeAnalyticsDistanceLockHoldDuration failed on two consecutive master commits (5430bc79 at 222µs, 89377333 at 156µs), both passing on a re-run of the identical tree, neither touching cmd/server runtime code. The flat 150µs limit sat inside the healthy band.

Measured, not assumed:

  healthy    156µs, 222µs, 402µs   three commits, 402µs from run 35252186369
  regressed  201203µs              fork run 35252430017, RLock deliberately
                                   held across the whole compute

A factor of 500 apart, so the limit only had to stop sitting inside the healthy band. 5ms is 12x above the worst healthy reading and 40x below the measured regression. The four numbers and the run IDs are in the doc comment.

The first attempt (a1767c77) calibrated against a control where readers churned a second store the writer never locks, on the assumption that their CPU load was slowing the writer. CI measured that control at 0µs: the readers cost the writer nothing, the variance is lock handoff, and the control could not see what it was meant to subtract. 5e457961 replaces it. Both commits are kept in this branch's history, and issue #2038 is corrected where it argued against raising the limit.

Methodology untouched: same eight readers, same 200 writer cycles, same 20000 hops and 200 paths.

Merged by the interim maintainer without a second human reviewer. Not run locally: cmd/server needs cgo for the #1992 driver and this machine has no C toolchain, so CI (run 35253209412) is the check, and the mutation run above is what proves the assertion still fails on a real regression.

Fixes #2038
2026-09-17 21:58:34 +02:00
Alex B e6323ec587 fix(store): account path, decode-cache and dedup-key bytes so maxMemoryMB eviction triggers (#2035)
trackedBytes undercounted the packet store by about 2.5x, so packetStore.maxMemoryMB never triggered: a tx was charged at creation, before pickBestObservation set its path, so the byPathHop and spTxIndex costs were never added, and eviction then re-estimated with the path known and subtracted more than had been added, drifting the total downwards. The ParsedDecoded cache, the obsKeys dedup key and several per-observation strings were not estimated at all.

StoreTx.accountedBytes now records what was charged, rechargeTx returns the delta after every pickBestObservation, and eviction subtracts accountedBytes instead of re-estimating. Measured by the author on a production database copy: trackedMB 151 against 402 MB of heap in use before, 351 against 396 MB after, with GC cycles dropping from ~0.71/s to ~0.011/s over 12 h on their instance.

Reviewed by auditing the accounting rather than the arithmetic: all six production pickBestObservation sites recharge, all three subtraction sites read accountedBytes, every recharge site holds s.mu (Load from :857, the two ingest paths at :2778 and :3142 with deferred unlocks), observations are charged only after acceptance, and no charged tx is discarded during the chunk merge. That lock audit is the independent check, because CI's race job covers the ingestor only.

Operator impact, both from the estimate growing rather than any limit moving: where maxMemoryMB is set, eviction now caps the real store size, and the cold load clamp drops about a third of the boot walk (124420 to 84374 packets at 650 MB). Where it is unset, which is the default, the change is inert. docs/go-migration.md claimed the Go server ignored the setting, which was never true, and is corrected here.

Merged by the interim maintainer without a second human reviewer: CI (run 35258839322) plus the review above are the independent checks.
2026-09-17 21:58:16 +02:00
Alex B 893773338e chore(tests): move root test-*.js into tests/unit and tests/e2e (#2036)
Moves 290 root test-*.js into tests/unit (177, listed in test-all.sh) and tests/e2e (113, classified in scripts/non-unit-tests.json), per #1981 and PR-D of #1385. Root goes from 348 entries to 48. test-all.sh and test-fixtures/ stay put. The inventory guard now fails if a test reappears in the root or sits in the wrong folder.

Verified independently of the diff: the invoked sets are unchanged (test-all.sh 177 before and after, deploy.yml 96 before and after, both identical as sets), and a full local run of test-all.sh on master and on the branch produced 4702 output lines each whose only differences are absolute paths, stack-trace line numbers shifted by the REPO_ROOT line, the inventory wording and two perf ratios. The guard was mutation-checked: a test back in the root, a unit suite in tests/e2e, and a suite dropped from test-all.sh each make it exit 1. CI run 35246304316 ran 97 suites from tests/e2e and is green.

Follow-up 9335c51d finished the instruction files: no bare root test command is left in AGENTS.md, the squad charters, .github or docs, and every tests/ path they name resolves.

Merged by the interim maintainer without a second human reviewer: CI and the local runs above are the independent checks.

Known and deliberately out of scope: 18 of the 113 files in tests/e2e are invoked by no runner at all, and one of them cannot run anywhere because it requires jsdom, which is not a declared dependency. Tracked separately.
2026-09-17 19:06:07 +02:00
efiten 5430bc7923 test(ingestor): anchor the RF-sample fixtures to now, not to a calendar date (#2034)
The three ClientRfDeltas tests seeded 2026-08-17T10:00:00.000Z and queried that window back. resolveRxTimeCore (cmd/ingestor/main.go:1527) replaces timestamps older than 30 days with the ingest time, so from 2026-09-16T10:00Z the seeds landed at time.Now() and every delta fell outside the queried window. Master and every open PR went red on it.

Fixtures now derive from a package-level base two hours in the past, computed once per test binary so two seeds cannot straddle a second boundary and break the exact WallMillis assertion.

Merged by the interim maintainer without a second human reviewer: CI is the only independent check (run 35222316327, ingestor tests ok in 97.033s, race detector ok, no --- FAIL). Fixed dates elsewhere in the ingestor tests are untouched, they assert row counts rather than querying by the seeded date.
2026-09-17 16:52:20 +02:00
efitenandClaude Opus 5 b8c8d98e61 fix(release): keep every platform when re-tagging :edge as a release (#2031)
## Problem

`crane mutate` works on one image, not on an index. Pointed at the
multi-arch `:edge` tag it silently resolves the default platform, so the
fast path published v3.11.0 as a single amd64 OCI manifest, and `crane
tag` then pointed `v3.11`, `v3` and `latest` at that same manifest.
`docker pull` on arm64 against any of those four tags fails.

Verified in the registry:

| tag | shape | arch |
|---|---|---|
| `v3.9.2`, `v3.10`, `v3.10.1`, `edge` | index, 4 children | multi-arch
|
| `v3.11.0`, `v3.11`, `v3`, `latest` | `oci.image.manifest.v1`, 17
layers | amd64 only, revision `a2ea18f7` |

Earlier releases are indexes, so this only hit v3.11.0. The GitHub
release and both `corescope-decrypt` binaries are unaffected.

## Change

- The fast path now reads the `:edge` manifest, mutates each runnable
platform child by digest (`/app/.image-version` plus the version label,
as #1807 intended) and reassembles an index with `crane index append`. A
single-platform `:edge` still takes the old single mutate path.
- Attestation manifests (`platform.architecture == "unknown"`) are not
carried over: they reference the pre-mutation digests, so copying them
would attest the wrong images.
- A new verification step compares the platform set of `vX.Y.Z`, `vX.Y`,
`vX` and `latest` against `:edge` and fails the run if any of them
differs. A release tag that resolves to one platform is worse than a
slow release, so this should break the build rather than ship.
- Scratch tags (`tmp-vX.Y.Z-linux-amd64`, ...) are deleted best-effort
afterwards; the index references the manifests by digest, so leaving
them behind is only untidy.
- Added `workflow_dispatch` with a `tag` input to republish the images
for an existing release. A dispatched run resolves the tagged commit
itself, because `github.sha` is then the ref the workflow file came
from, and it skips the `deploy.yml` dispatch: that release already
exists and releases here are immutable (the trap from #1955/#1956).

## Tests

None: this repository has no harness that executes workflow files, and
the CI jobs cannot reach a step that pushes to GHCR. What the change is
verified against instead:

- `crane index append` accepts `-m/--manifest` repeated plus `-t/--tag`,
with the base index optional, so building an index from scratch is
supported (crane docs for `index append`).
- The YAML parses and every `run:` block passes `bash -n`.
- The platform comparison was run by hand against the live registry:
`:edge` reports `linux/amd64,linux/arm64` and `v3.11.0` reports
`single`, which is exactly the case the new step must fail on.
- The real test is the dispatch on `v3.11.0` right after merge, which is
also the repair. If the verification step fails there, nothing is
published and the tags stay as they are.

## Not verified

- The scratch-tag delete needs `delete:packages`; `GITHUB_TOKEN` may not
have it. It cannot fail the run.
- Whether GHCR keeps the attestation manifests attached to `:edge`
reachable after the index is rebuilt for a release tag (they stay on
`:edge` itself, which is untouched).

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 11:07:18 +02:00
Sylvain Rabot a2ea18f778 perf(sqlite): swap modernc.org/sqlite for mattn/go-sqlite3, cross-built with zig (#1992)
Swaps the SQLite driver from `modernc.org/sqlite` (pure Go, SQLite
3.46.0) to `github.com/mattn/go-sqlite3` (cgo, bundled SQLite 3.53.4),
and pays the resulting cross-compilation cost with `zig cc`.

Draft because the riskiest part of this deletes rows — see [Please
review this part first](#please-review-this-part-first) — and because
three things remain unverified at the bottom.

`modernc.org/sqlite` is a transpilation of the C amalgamation. This repo
is read-heavy: `cmd/server` chunk-loads a graph at startup and fans out
neighbour/topology/analytics queries per request, and it pays for that
transpilation on exactly those paths. Head-to-head on the same
120k-transmission / 240k-observation database, running our own hot-path
SQL under both drivers (Apple M4, `-count=5`, medians):

| workload | modernc | mattn | |
|---|---:|---:|---|
| chunk load (`chunked_load.go` v3 join, 20k tx) | 449ms | 196ms |
**2.3×** |
| aggregate scan (240k-row join + `GROUP BY`) | 276ms | 137ms | **2.0×**
|
| 1500 prepared-statement lookups | 512ms | 403ms | **1.3×** |

Allocations fall with it: 1.12M vs 1.64M allocs and 21MB vs 30MB on the
chunk load.

**Superseded by a production run.** @efiten measured both drivers on a
real instance — 11,077,038 observations, 9.7GB database, 4-core arm64 —
as server-only containers against the same live volume, one at a time,
with round 2 reversing the order so the page cache favours the old
driver:

| | audit 7d | audit 24h | background fill (13 chunks) | start →
/api/health |
|---|---:|---:|---:|---:|
| modernc, round 1 | 16.67s | 2.27s | 130.2s | 16.6s |
| mattn, round 1 | 7.87s | 1.34s | 93.8s | 13.5s |
| mattn, round 2 | 8.15s | 1.35s | 96.4s | 13.0s |
| modernc, round 2 | 13.46s | 2.29s | 137.8s | 15.5s |

Warm, the old driver improves to 13.46s on the 7d audit and still loses
by ~1.8×. Chunk load is ~1.4×. `/api/nodes?limit=500` is 0.039s against
0.037s — nothing.

**So the real gain is ~1.4–1.8× on the paths that matter, not 2–2.3×.**
The shape the harness predicted holds — scans and joins gain, small
lookups do not — which is more reassuring than the magnitude would have
been. Quote these numbers.

**The counterweight**, cold and native on that machine: a build goes
from **52s to 163s**. An instance that builds its own image pays that
per deploy.

## The build is cgo now, and one thing about that is a trap

**`CGO_ENABLED=0` still builds.** mattn links a stub, and the binary
dies on its first query with `go-sqlite3 requires cgo to work. This is a
stub`. A green build is not evidence of anything here, which is why
`AGENTS.md` now says so explicitly. `GOOS=linux go build` genuinely
cannot cross-compile any more.

A new root `Makefile` is the entry point. `make crossbuild` uses `zig cc
-target {x86_64,aarch64}-linux-musl` and links static, so each artifact
stays a single self-contained file and the `alpine:3.20` runtime no
longer depends on the base image's libc at all.

`-Wl,-s` is load-bearing: Go's own `-s -w` does not reach the musl
objects zig links in, and without it the server binary is 19.8MB instead
of 12.1MB.

The Dockerfile keeps its single `$BUILDPLATFORM` builder — still no QEMU
for compilation — and gains a checksum-pinned zig plus BuildKit cache
mounts. The mounts are not a nicety: without them an image build
recompiles the amalgamation from cold and takes over half an hour.

## Please review this part first

`internal/dbschema/dedup_index.go` **deletes observation rows**. It is
the one part of this change that can lose data, and it exists because
the migration exposed a real bug rather than causing one.

`stmtInsertObservation` resolves its `ON CONFLICT` against
`idx_observations_dedup`, which `cmd/ingestor/db.go` only ever created
inside the branch that creates the `observations` table for the first
time. Any database whose table predates that branch never got one, so
the UPSERT had no conflict target. modernc failed on the first insert;
mattn fails at `OpenStore`. Same bug, found earlier.

Creating the index unconditionally repairs it — but the index is what
was supposed to prevent duplicates, so a database that never had it can
already hold rows violating it. **`test-fixtures/e2e-fixture.db` in this
repo holds one.** So duplicates are collapsed first. Refusing is not the
safer option: without the index the ingestor cannot prepare its UPSERT,
so it cannot start at all.

Replaying that UPSERT faithfully is subtler than it looks, and a first
cut of this got it wrong twice:

- `COALESCE(excluded.x, x)` means the **incoming** value wins, so down a
group in id order the survivor keeps the **last** non-NULL value. Taking
the first silently discarded newer readings.
- The UPSERT names exactly five columns (`snr`, `rssi`, `score`,
`raw_hex`, `resolved_path`). Every other column must keep the surviving
row's own value; merging those too invents history the ingestor would
never have written.

Merge, delete and `CREATE UNIQUE INDEX` now share one transaction. Split
apart, a writer inserting a duplicate in the gap fails the index
creation while leaving the deletions committed — rows destroyed and no
index to show for it.

Cost, measured on 2.4M synthetic rows holding 5 duplicates: **4.1s**,
holding the write lock throughout, once, at ingestor startup before MQTT
subscribe. Materialising the duplicate-group scan once rather than per
column took that from 9.7s; the pathological case (400k of 600k rows
duplicated) is 5.7s, slightly worse than the 4.2s it was before that
change.

## Four more behavioural differences

Full detail in `docs/sqlite-driver-migration.md`. Briefly:

**Statement preparation is eager.** modernc's `newStmt` stored the SQL
and compiled lazily; mattn calls `sqlite3_prepare_v2` inside `Prepare`,
so SQL naming a missing table fails at *open*. 59 server tests failed on
this alone, all fixtures with partial schemas. `OpenDB` keeps failing
loudly (#1901; `main.go` gates on `dbschema.AssertReady` anyway) and the
fixtures now declare what they are prepared against via
`ensurePreparable`. This also exposed nine `nodes(pubkey …)`
declarations across seven files, where production has only ever had
`public_key` — lazy compilation had hidden the mismatch for as long as
it existed.

**`synchronous` silently dropped FULL → NORMAL.** mattn defaults it to
NORMAL and executes the pragma unconditionally, where SQLite's own
default (what modernc left alone) is FULL. In WAL mode that weakens
durability under power loss. Pinned in `dbschema.WriterDSN`, which both
writers now share — `cmd/migrate` kept a bare path at first and so
quietly wrote at NORMAL, which is what a second copy of a DSN buys you.

**The DSN dialects are mutually invisible.** modernc understood only
`_pragma=name(value)`, mattn only `_`-prefixed parameters, and neither
errors on the other's form — a driver-only rename would have dropped
every pragma in silence. `_journal_mode=WAL` is also gone from the
server's read handle: modernc ignored it, mattn honours it, and setting
`journal_mode` on a read-only connection is a write. Dropping
`_busy_timeout` with it costs nothing, since mattn already defaults to
5000ms — which means the read handle finally *gets* the busy timeout it
had silently lacked.

**`mode=ro` survives for a non-obvious reason.** mattn always passes
`READWRITE|CREATE` and its amalgamation has `SQLITE_USE_URI=0`; what
makes the URI work is its C wrapper ORing `SQLITE_OPEN_URI` in. So the
#1283/#1289 invariant holds with no build flags — but it depends on the
`file:` prefix. `cmd/decrypt` had been building its DSN without one, so
its `mode=ro` had never applied and a missing path was created
read-write. Fixed in passing; never a migration regression.

## What did not change

No modernc-specific API was in use: no `RegisterFunction`, no
`*sqlite.Conn`, no `sqlite/lib` error constants, no `sql.Register`. No
`time.Time` is ever bound as a query argument, so driver time handling
is not in play. Both drivers convert declared
`DATE`/`DATETIME`/`TIMESTAMP` columns to `time.Time`, so
`/api/dropped-packets` keeps emitting `dropped_at` as RFC3339 — an
earlier draft "fixed" that with a `CAST` and would have been the
regression.

## Tests and CI

New regression tests, each written because something got through without
it:

- `TestEnsureObservationsDedupIndexKeepsLatestValues` — the merge
ordering. The original test used complementary NULLs, which passes
whichever direction you pick, which is why the bug survived it.
- `TestCollapseDuplicatesAndIndexIsAtomic` — a failed index creation
must roll the deletions back.
- `TestOpenStorePragmas` / `TestWriterDSNPragmas` — every writer pragma,
read back through the store's own connection. A separate `sqlite3`
session or the startup log line would prove nothing.
- `TestOpenDBRefusesMissingDatabase` — the read-only invariant, which
now rests on a detail of the driver's C wrapper.
- `TestEnsurePreparableMatchesPrepareStatements` — fails when a new
prepared statement outgrows the fixture helper.

CI gains test execution for `cmd/migrate` and `internal/dbschema`, which
had none and both open the database. A PR-time two-arch build plus an
arm64 QEMU smoke gate is new: the GHCR push is push/tag-only, so without
it nothing on a PR would exercise zig, static musl linking or arm64, and
the first signal would arrive on master. `cache-dependency-path` widens
from 2 of the 5 tracked `go.sum` files to all of them.

`make test` passes across all 14 modules, `cmd/server` also under `-race
-count=2` with no failures and no races. `gofmt` and `go vet` clean.
Release-routing and Dockerfile COPY-invariant gates pass.

## Verified by running

- All 8 cross-builds static and correct-architecture; both arches of the
container image built, exported and run under QEMU, serving
`/api/health` and `/api/nodes` against a 2.9M-observation production
snapshot.
- The `migrate` binary repairing that snapshot's duplicate on bare
Alpine.
- `CGO_ENABLED=0` producing a binary that builds and then fails on first
query.

## Not verified

- ~~The 2–2.3× figures come from a standalone harness, not this load
under the old driver.~~ **Closed** by @efiten's production run above,
which also corrected the multiplier.
- SQLite 3.46.0 → 3.53.4 query-planner differences on queries with no
total `ORDER BY`.
- Sustained live ingest through the new writer DSN, and the duplicate
collapse against a database an ingestor is actively writing to. Verified
against a static snapshot only, and the collapse is measured at 4.1s on
2.4M synthetic rows with 5 duplicates — well short of an 11M-row
instance. @efiten has offered a staging instance taking real MQTT
traffic; **this is the item to close before the PR leaves draft.**

An earlier revision of this branch shipped the dedup merge in the wrong
direction with a green test suite, and review then found three more
things in the same file: the repair gated on an error string, a
non-atomic TEMP table drop aimed at the wrong connection, and a deletion
whose only record was a row count. All fixed in ac7e8d38. Passing tests
did not establish safety here, which is why the deletion path wanted a
second pair of eyes rather than a rubber stamp.
v3.11.0
2026-09-16 09:02:13 +02:00
Sylvain Rabot cb994d9f9a feat(home): link My Mesh node cards to the node page (#2027)
## What

The node cards in the **My Mesh** grid on the home page could open the
full
health panel or the node's packets, but there was no way to reach the
node
detail page from them — you had to go search for the node again.

Each card now leads with a **Node page →** button that navigates to
`#/nodes/<pubkey>`, the same route the channels, live and analytics
pages
already link to.

## Details

- Wired through the existing `.mnc-btn` click delegation, so it inherits
the
`stopPropagation()` that keeps the card's own click-to-health handler
from
  firing as well.
- The error-state card (health fetch failed, including the 404 *"waiting
for
first advert"* case) gets the button too, on its own actions row. *Full
health* and *View packets* stay off that card — the health fetch is
exactly
what failed, but the node page still resolves for a node that has so far
only
  been seen in channel messages.
- `.mnc-actions` now wraps, so three buttons don't overflow a narrow
card.

## Testing

`node --check public/home.js` passes. Lint and the Playwright suite were
not
run: the worktree this was written in has no `node_modules`. The
existing home
e2e test targets `.mnc-btn[data-action="health"]` specifically, so the
new
button does not disturb it.
2026-09-14 11:13:32 +02:00
efitenandClaude Opus 5 5d3af168b3 fix(live): keep the space the user is typing in the node filter (#2028)
## Problem

Typing a node name with a space slowly into the Live page node filter
glues the words together: "Dan's Local" ends up as `Dan'sLocal`, and
`/api/nodes/search` then returns no suggestions.

The debounced input handler commits the trimmed value (`public/live.js`
`applyFilterFromInput`, ~1791), so after "Dan's " the filter key is
`Dan's`. `setNodeFilter` calls `updateNodeFilterUI`, which wrote
`nodeFilterKeys.join(', ')` back into the input whenever it differed
from the raw input value (~2912). That dropped the trailing space the
user had just typed, and the next keystrokes were appended to `Dan's`.

Found while reviewing #2026.

## Change

`updateNodeFilterUI` no longer writes into the node filter field while
it has focus, and otherwise only when the trimmed input differs from the
keys. Besides the typing debounce, it runs for every matching live
packet, which could also eat a character typed inside the debounce, and
it replaced a picked suggestion's name with the pubkey. Restores from
`?node=` or localStorage (field not focused) still write the keys.

## Tests

- `test-live.js`: a trailing space typed into a focused field is kept
(fails on master); a focused field that differs from the keys is not
overwritten; an unfocused field with different text is; an unfocused
field that differs only by whitespace is not.
- Mutation: each of the three guards (focus, trimmed comparison, write
when different) fails one test on its own.
- `node test-live.js`: 100 passed. `sh test-all.sh`: all standalone
frontend suites pass. `npx eslint public/live.js`: 0 errors.

## Browser validation

Local server with the e2e fixture, headless Chromium, typing `Dan's `
(60 ms per key), a 500 ms pause, then `Local`:

| Build | Input | Stored filter | Suggestions |
|---|---|---|---|
| this branch | `Dan's Local` | `Dan's Local` | `Dan's Local Repeater` |
| master | `Dan'sLocal` | `Dan'sLocal` | none |

## Not verified

- Other browsers than Chromium, and mobile keyboards with autocorrect.
- A trailing space typed and left there: the filter key stays trimmed
while the input keeps the space, which is the intended difference.



## Review follow-up (commit `4e86171d`)

An independent review found the change correct but pointed at the same
bug on a second path, plus a weak test:

- **Packets during typing.** `updateNodeFilterUI` also runs for every
matching live packet (`public/live.js` ~3393). A packet arriving inside
the 200 ms debounce still rewrote the field: with filter `ab12` and the
user typing `3`, the `3` was lost. The write is now skipped while the
input has focus.
- **Picked suggestion.** The same write replaced the name
`selectSuggestion` had just put in the field with the node's
64-character pubkey. With the focus guard the field keeps the name; the
filter key is still the pubkey.
- **Tests.** The old "writes a different key" test started from an empty
input, so a check that only writes into an empty field passed too. The
tests now cover a focused input that differs (not overwritten), an
unfocused input with different text (overwritten) and an unfocused input
that differs only by whitespace (not overwritten). Each of the three
guards was mutated on its own and fails one test. `test-live.js`: 100
passed; `sh test-all.sh`: all standalone suites pass; eslint: 0 errors.

Browser, local server with the e2e fixture: typing `Dan's ` then `Local`
keeps `Dan's Local` with one suggestion; picking it shows `Dan's Local
Repeater` while the stored filter is the pubkey; reopening
`#/live?node=<pubkey>` shows the pubkey in the field, as before.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 23:13:27 +02:00
efitenandClaude Opus 5 52b9474d7a feat(map): filter repeaters by region name (#1862) (#2022)
Fixes #1862

## What

Adds a **Region Scope** picker to the map controls: pick `#be` and the
map keeps the nodes that declare `#be` or were seen carrying `#be`
traffic. It combines with the #2006 scope-state filter and persists in
localStorage the same way. While a region is picked, a small "Region:
#be · reset" chip sits on the map itself, so the filter stays visible
when the controls panel is collapsed (the default on phones) and can be
cleared from there.

## API

`/api/nodes` and `/api/nodes/{pubkey}` gain two fields on repeater/room
rows:

- `declared_regions`: named regions from the node's newest
declared-regions answer, split by the same function the Scope Audit now
uses for `declaredRegions` (`splitDeclaredRegions`,
`cmd/server/scope_config_state.go`), so both pages list a repeater under
the same names. `[]` means it answered and named no region. Absent means
it never answered, other roles, no declared-regions source, or the
declared-regions lookup failed.
- `declared_regions_truncated`: present, and `true`, only when that
answer was flagged as truncated, so the list is partial. Never `false`:
the `nodes.configured_scope` source does not record truncation, so
absence does not mean the list is complete.

The observed side reuses `transported_scopes`. Documented in
`docs/api-spec.md` and the served OpenAPI spec.

### Why no `?hashRegion=` query parameter
The observed side lives in the in-memory store. Filtering it after the
SQL `LIMIT`/`OFFSET` would corrupt `total` and paging, and the map pages
through `/api/nodes`. Same reasoning as the Data path section of #2001.

## Map behaviour

- Filtering is client-side over the nodes `fetchAllNodes` already
loaded: no new request. One pass over the loaded nodes to build the
picker counts, one Set lookup per node per render. The marker filter is
`nodePassesMapFilters` (`public/map.js:219`) and the observer stand-down
`observerLayerShown` (`:212`), both exported and tested.
- The picker and hint count only nodes with a map position, the same
test the marker filter applies first, so a count never promises markers
the map cannot draw.
- The observer layer stands down while a region is picked, for the same
reason it does for the scope-state filter.
- The popup lists declared and observed regions separately. A truncated
declared answer carries the same `truncated` badge the Scope Audit
shows.
- Absence is not read as a finding: the hint under the picker says a
node left off the map is not proof it lacks the region.

## Tests

- Go: `node_declared_regions_api_test.go` covers `declared_regions` on
list and detail endpoints, the no-source case, agreement with
`/api/scope-audit`, `declared_regions_truncated` (truncated,
truncated-empty, complete, configured_scope-only, newer untruncated
answer, companion) and the OpenAPI schema.
- JS: `test-issue-1862-map-region-filter.js` (30 tests) covers the pure
pieces (evidence, counts, options, hint, popup rows,
`nodePassesMapFilters`, `observerLayerShown`) and, at page level, runs
the registered map page through `init()` and `loadNodes()` in a vm
sandbox: picker built from loaded nodes, markers filtered by a stored
region, observer pins standing down, popup rows, the change handler
persisting, and the chip showing, resetting and rendering its text as
text.
- Mutation-checked: removing the region check, the observer stand-down,
the picker build in `loadNodes`, the popup rows, the persist on change,
the chip reset, or the truncated flag (Go or JS) each fails a test.
`test-issue-2001-map-scope-state.js` still passes.
- `go test ./...` in `cmd/server` passes; `check-css-vars` and
`check-xss-sinks --diff` are clean.

## Staging validation
Build `c646310f`, Chrome, no console errors:
- Picking `#be`: chip "Region: #be · reset" at the top of the map, hint
"221 nodes with a map position have evidence for #be: 129 declare it,
191 seen carrying its traffic. Absence here is not proof: ...".
- Clicking reset: picker back to "All regions", stored choice cleared,
chip hidden.
- First version, same instance: `declared_regions` on 136 of 500
`/api/nodes` rows; returning to "All regions" blocked the main thread
5.4 s against 3.2 s for the existing Status filter returning to "All".
The review measured the region filter's own added work at about 0.08 ms
per render plus popup rows that are already built for every marker, so
most of that time is the existing full re-render.

## Not verified
- The chip placement at phone width, and on narrow desktop widths where
it may sit under the expanded controls panel.
- The truncated badge with real truncated answers (staging has none
today).
- `test-map-clustering.js` has one failing test on `upstream/master`
too; untouched here.
- Marker badges from the original issue body are not implemented; the
popup rows are the per-node display.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 21:09:51 +02:00
efitenandClaude Opus 5 dda04ae918 fix(packets): empty the observer and type selections on Clear Filters (#2012) (#2015)
## What was wrong

On the Packets page, Clear Filters reset `filters.observer`,
`filters.type`, localStorage and every checkbox in both multi-select
menus, but not the Sets that hold the selection: `selectedObservers`
(`public/packets.js:1793`) and `selectedTypes`
(`public/packets.js:1845`). The next `change` event
(`public/packets.js:1826`, `:1876`) added to the stale Set, so select
observer A, Clear, select observer B wrote `A,B` to the URL and the
trigger read "2 Observers". Types behaved the same way. Clear also
unchecked the "All Observers" / "All Types" rows although no filter was
active.

## What changed

- `public/packets.js:1999-2006`: the Clear handler empties both Sets and
rebuilds both menus through `buildObserverMenu()` / `buildTypeMenu()`
plus `updateObsTrigger()` / `updateTypeTrigger()`, replacing the
hand-written checkbox and trigger resets. All four functions are in the
same scope as the handler.
- `test-issue-2012-clear-filters-selection.js` (new): runs the real
multi-select section and Clear handler from `packets.js` in one function
scope against a small fake DOM. For observers and types it drives select
A, Clear, select B and asserts that only B is selected (filters,
localStorage, trigger text) and that the All row is checked after Clear.
- `test-clear-filters.js`: the handler body now uses names this test did
not provide, so menu-agnostic cases get empty stand-ins. The old
"unchecks every checkbox" case expected the All row to be unchecked (the
bug), so it now asserts that the Sets are emptied and both menus
rebuilt.
- Registered the new test in `test-all.sh` and the unit-test step in
`.github/workflows/deploy.yml`.

## Tests

- `node test-issue-2012-clear-filters-selection.js`: 4 passed. With the
`packets.js` change reverted: 0 passed, 4 failed (`obsA,obsB`, `4,5`,
All row `false`).
- `node test-clear-filters.js`: 6 passed, 2 failed, the same counts as
on master. The two failing `updatePacketsUrl` cases fail on master with
`location is not defined`. This file is not run by `test-all.sh` or CI.
- Also green: `test-packets-local-channels.js`,
`test-issue-1415-packets-layout.js`, `test-frontend-helpers.js`,
`test-observer-iata-1188.js`, `test-packet-filter.js`,
`test-packet-filter-ux.js`, `test-xss-escape-sinks.js`,
`scripts/check-css-vars.js`.

## Browser validation

Deployed together with #2013's, #1851's and #1868's branches to a
staging instance with live traffic (build `e84d2da6`), in Chrome:

- Observers: select BE-BRU-Moris, Clear (URL back to `#/packets`,
trigger "All Observers", All row checked), select BE-BRU-Bécodok: URL
holds only Bécodok's key, trigger shows Bécodok.
- Types: select one type, Clear (All row checked, "All Types"), select
another: stored filter holds only the second.

## Not verified

- Other ways of resetting filters (navigating back to `#/packets`
without params) were not checked for the same stale Sets.
- The full `test-all.sh` run was not done locally.

Fixes #2012



## Review follow-up (commit `4b14f54e`)

An independent review of this PR reproduced the bug on master and
confirmed the fix in headless Chromium against a real server. It found
one related pre-existing problem, now fixed here:

- The type multi-select change handler never called
`updatePacketsUrl()`, which is what shows or hides the Clear button
(`public/packets.js` ~779-783). With only a type selected the button
stayed hidden, so the Types half of #2012 could not be reached by a
click. The handler now makes that call, like the observer handler does
(`public/packets.js:1887`). Type is not part of the URL, so the call
only toggles the button.
- The regression test now runs the real `updatePacketsUrl()` and adds
two cases: picking only a type, and only an observer, shows the Clear
button, and Clear hides it again. The type case fails without the new
call.
- The test's fake DOM also provides `#observerList` and
`#observerSearchInput`, so it keeps working if #1884 merges first.
Checked against a local merge of #1884: 6 of 6 pass. That merge has one
conflict in the Clear handler; whichever PR lands second must keep both
the Set clear and the search box reset.
- Not mentioned before: the fix also resets the type trigger's `title`
tooltip through `updateTypeTrigger()`, which the old handler left stale.

Validated on staging together with the other follow-ups (build
`c646310f`). Not verified: mobile viewport, full Playwright suite.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 21:09:31 +02:00
efitenandClaude Opus 5 efdb3ea0b3 feat(analytics): retransmission pressure over time (#1699) (#2023)
## Summary
Adds `GET /api/analytics/retransmissions` and a "Retransmission Pressure
(proxy)" chart on the Analytics Topology tab, implementing the metric
agreed in #1699: for each flood, the number of distinct repeaters in the
union of the paths of all its observations (`[A]`, `[A,B,C]`, `[A,D]`
gives 4), averaged per time bucket.

Topology is the tab that already shows hop counts and repeaters in
paths, so the chart sits there instead of in a new tab.

## Definition
- Flood routes only (`route_type` 0/1); TRACE excluded. Direct routes
carry the route still to travel (firmware
`src/Mesh.cpp:78-106,334-342`), zero-hop sends are direct
(`src/Mesh.cpp:717-737`), TRACE path bytes are SNR values
(`src/Mesh.cpp:59-61`, refused by `sendFlood` at
`src/Mesh.cpp:637-641`). Firmware commit 0679dbef.
- **Flood events, not hashes.** `transmissions.hash` is UNIQUE and the
packet hash excludes the path (`src/Packet.cpp:41-50`), so when the same
bytes flood again the observations land on the same transmission.
Observations are sorted by time and split into events wherever two
consecutive observations are more than 5 minutes apart. Each event is
counted on its own and bucketed by its first observation.
- Why 5 minutes: a node holds a flood for at most 32 s
(`src/Dispatcher.cpp:11,243-251`) plus a random retransmit delay. On
live over 7 days, 72,806 of 74,347 flood transmissions span 60 s or
less, and of 1,372,283 consecutive observation gaps, 52 fall between 60
s and 300 s against 1,823 above 300 s.
- Events that start before the store retention floor (now minus
`retentionHours`) are left out for every request shape. The store keeps
older observations only for hashes heard again recently, so they do not
represent that period. Eviction of those transmissions is tracked in
#2024.
- A flood event heard only with an empty path counts as 0 repeaters.
- **Prefixes are not resolved to nodes, and a prefix counts once per
event**, whether it repeats across observations or inside one path. On
live (7 days), a repeated 2-byte prefix inside one path occurs in 1.08%
of flood transmissions and 6,178 of 6,596 such repeats match exactly one
known node; for 3-byte it is 0.69% and 104 of 104. That is one node
forwarding again after its 160-slot cyclic duplicate filter dropped the
hash (`src/helpers/SimpleMeshTables.h:9,52-57`). A repeated 1-byte
prefix (44.9% of 1-byte transmissions) is mostly two nodes; counting it
once keeps the value a lower bound. `summary.one_byte_packets` reports
how many events that affects.
- Observations are stored once per observer and path per hash, so a
later event of the same hash only holds pairs not stored before; its
count is a lower bound too. On live these are 1,466 of 75,356 events
(1.9%), and they are kept in the average.
- Resolution was not used: on live, 1-byte observations nearly all have
`resolved_path` NULL, and cold load refuses context-based resolution of
history (`cmd/server/neighbor_persist.go:155-168`).
- Buckets `5m|15m|1h|6h|1d`. `region` filters on observers like
`/api/analytics/rf`, after the event split; a region with no known
observers is not filtered, the same as the other analytics endpoints.
`area` is not supported.

## Implementation
- `cmd/server/retransmission_pressure.go:255` `addPath`: scans path JSON
directly into a generation-stamped hash set, no allocation per
observation.
- `cmd/server/retransmission_pressure.go:367`
`computeRetransmissionPressure`: one pass under `s.mu.RLock`. Per flood
transmission it sorts the observations by cached parsed time into a
reused scratch slice, splits events and counts each in `addEvent`
(`:319`). O(T + O log k + H).
- `cmd/server/retransmission_pressure.go:470`
`GetRetransmissionPressure`: default shape from the recomputer (#1659
warm-up gate). Other shapes come from a typed TTL cache (max 64 entries)
cleared on new paths and eviction (`cmd/server/store.go:2289,2336`);
concurrent misses on one key share one compute through singleflight
(`store.go:204`).
- `cmd/server/retransmission_pressure.go:515` handler,
`cmd/server/routes.go:331`, `cmd/server/openapi.go:108`,
`docs/api-spec.md:1283`.
- `public/analytics.js:761` card, `:855` `renderRetransmissionChart`
(CSS variables only, lines break at missing buckets, caption states it
is a proxy, names the observer coverage bias, the once-per-flood prefix
rule and the 5 minute event split), `:829` loader with stale-response
guard.

## Performance
- `BenchmarkComputeRetransmissionPressure`, 50k transmissions x 20
observations, `-cpu 1`, i5-1335U: median 161 ms/op (132 ms/op before the
event split); first pass after startup with timestamps not yet parsed
192 ms/op. About 22 KB and 281 allocations per op.
- On staging the default shape is served from the recomputer in 0.3 s;
the post-load recompute of this recomputer took 994 ms on a
121k-transmission store (log line quoted in #2025). A 336h store would
be about twice that, every recompute interval, under the store read
lock.

## Tests
- `cmd/server/retransmission_pressure_test.go`: union counting (reporter
example, overlaps, once per event for 1/2/3-byte, width/case, growth);
route/TRACE/zero-hop filter, bucketing, window by event start; event
split and the 5 minute settle gap (boundary, chained steps, unsorted
input), retention floor; region filter, region applied after the split,
unknown region, 1-byte share; recomputer read, TTL cache invalidation on
new paths and on eviction, cache expiry, singleflight, recomputer gate
wiring, handler, warm-up gate.
- `test-issue-1699-retransmission-chart.js` (33 tests, registered in
`test-all.sh` and `deploy.yml`).
- Mutation-checked: 18 mutations of the event split, floor, prefix rule,
bucketing, region order, cache clears, expiry, gate wiring and
singleflight, all killed.
- `go test ./...` in cmd/server passes; `scripts/check-css-vars.js` OK.

## Staging validation
Build `c646310f` (this rework plus #2025 and the other review
follow-ups), after a container restart and full load: default shape
74,974 flood events, average 27.08 repeaters, 169 hourly buckets from
2026-09-06 16:00 (the 168h floor) to the current hour, highest hourly
average 53.3. Before the rework the same instance showed buckets back to
2026-07-18, averages up to 148, and for the first minutes after a
restart only 5,911 packets.

## Merge order with #2025
#2025 fixes the recomputer startup for all analytics endpoints (the
stale first snapshot seen here). Whichever of the two merges second has
to add `recompRetransmissions` to `analyticsRecomputersLocked`, wire it
to that PR's `loadedGate` instead of `LoadComplete`, bump the recomputer
count in `TestAnalyticsRecomputers_PostLoadOrder` from 9 to 10, and make
`TestStartAnalyticsRecomputers_RetransmissionsGatedOnLoadComplete` call
`signalStartupLoadDone()`. That resolution is what ran on staging.

## Not verified
- Recompute timing on a production-size (336h) store; only extrapolated.
- Phone-width layout and dark theme of the reworked chart.
- E2E Playwright suite.

Fixes #1699

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 20:37:14 +02:00
914bd4cf0e feat(channels): show each message's region (#1851) (#2018)
## Show each channel message's region scope

Each message in the Channels view now shows the region scope it was sent
with, as a small chip in the meta line: the region name (for example
`#be`), `unknown scope`, or nothing.

This is the channel-message part of #1852 by @dborup, extracted as a
focused change. #1852 was closed unmerged because it had grown to the
whole fork diff. The implementation follows dborup's commits c686ae3f,
350bf7ee and a94d57ed on dborup/CoreScope, adapted to current master.
dborup is co-author on the commit.

### What changed
- `cmd/server/db.go:2099,2195`: `GetChannelMessages` selects
`t.scope_name` when the column exists and returns it as `scope_name`.
- `cmd/server/store.go:5654`: the in-memory `GetChannelMessages` returns
`scope_name`, so the field does not depend on which path serves the
endpoint.
- `cmd/server/store.go:2966,3244`: both WebSocket broadcast builders
carry `scope_name`, so a live message shows its region immediately.
- `public/channels.js:342,2278`: `messageScopeChipHtml` renders the chip
with the existing `.sa-chip-declared` / `.sa-chip-unmatched` styles from
`scope-audit.css`. The name goes through `escapeHtml`. No new CSS.
- `public/channels.js:678,695,1435,1487`: the decrypt path and the
WebSocket path keep `scope_name` on the message.

### Differences from #1852
- The field is `scope_name`, the name `/api/packets` already uses.
- No `routeType` field. `transmissions.scope_name` already tells the
states apart: NULL means no transport code, an empty string means a
transport code that no configured region key matched. The frontend uses
`??`, not `||`, so the empty string is kept.
- A chip instead of `scope: <name>` text. The area label from later
#1852 commits is not included.

### Perf
One extra column per observation row in the page query (at most `limit`
transmissions), and one extra map entry per broadcast observation. No
new queries, loops or API calls.

### Tests
- `cmd/server/channel_message_scope_name_test.go`: the three states
through the DB query, the store, `/api/channels/{hash}/messages` over
both paths, a schema without the column, and both broadcast builders. 5
of its 6 tests fail without the change; the sixth guards the
missing-column case and passes either way.
- `test-issue-1851-channel-message-scope.js`: the REST, WebSocket and
client-side decrypt paths, escaping, and the name / unknown / none
render. 4/4 fail without the change. Registered in `test-all.sh` and the
unit step of `deploy.yml`.
- Mutation checks: returning `nil` for `scope_name` in the DB path fails
the DB and endpoint tests; `||` instead of `??` in the WebSocket path
fails the WebSocket test.
- `go test ./...` in `cmd/server`: ok. gofmt and go vet clean.

### Browser validation
On a staging instance with live traffic (build `e84d2da6`), in Chrome:
- `/api/channels/{hash}/messages` carries the `scope_name` key on every
message in the 19 channels whose results I read. `#hamradio`, latest 50:
34 named, 1 empty string, 15 NULL.
- Opening `#hamradio` renders 104 chips: `#nl` 53, `#be` 32, `#de` 11,
`#eu` 6, `#bx` 1 and `unknown scope` 1, and no chip on unscoped
messages. Chip text `rgb(26, 26, 46)` on `rgb(238, 242, 255)` in the
light theme.

### Not verified
- Dark theme not checked.
- The real-decrypt branch of `decryptCandidates` has no test and was not
exercised in the browser; the already-decrypted branch is tested.
- Messages already in the client decrypt cache show no chip until they
are decrypted again.
- `go test -race` and the Playwright E2E suite were not run locally.

Fixes #1851



## Review follow-up (commit `50346589`)

An independent review found no correctness or XSS problem and confirmed
DB, store and WebSocket agree on the value. Changed:

- **Real decrypt branch tested.** A new test runs the real AES+HMAC
decrypt branch in `decryptCandidates` with one packet per scope state;
deleting `scope_name` there now fails 2 of 7 tests.
- **Tooltip wording.** The unknown-scope tooltip now says the scope
"could not be matched to a single region on this instance"
(`public/channels.js:338-347`). The ingestor stores an empty name both
when no key matches and when several match without exactly one
operator-configured key (`cmd/ingestor/region_keys.go:364-393`), so
"matches none of the configured keys" was wrong for the second case.
- **Old decrypt cache.** Decrypted messages cached before this change
had no `scope_name` key and stayed chipless as long as the candidate
count did not change. A cached message missing the key now forces one
full decrypt; a cache that has it still takes the delta path. A test
covers each case.
- **Docs.** `docs/api-spec.md` documents `scope_name` on the channel
messages response, with the null / empty string / name semantics.

Corrections to the description:

- **Test counts.** With the `db.go` and `store.go` changes reverted, 4
of the 5 top-level Go tests fail (6 of 7 counting subtests); only the
missing-column test passes.
- **Broadcast payload.** `scope_name` is added to `pkt`, which is copied
into `broadcastMap` and also nested as `packet` (`store.go` ~2974-2980,
~3252-3257), so the key appears twice per observation: 36 bytes for
`null`, 48 bytes for `"#belgium"`.
- **Side effect on the Packets page.** The live table reads
`m.data.packet` (`packets.js` ~1316-1318), so flat rows and expanded
group children now show Scope for live packets. In grouped mode a new
group copies a fixed field list without `scope_name` (~1384-1395) and
shows the empty placeholder until reload. Before this PR every live row
showed that placeholder, so this is not a regression.

The three copies of the three-state scope rendering (`app.js`,
`packets.js`, `channels.js`) are left as they are.

---------

Co-authored-by: dborup <3627142+dborup@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 20:36:34 +02:00
2c6d7bb6ae feat(packets): add filter to All Observer dropdown (#1884)
Lets users type a prefix to filter the observer checkbox list, instead
of scrolling a long list.

---------

Co-authored-by: efiten <erwin.fiten@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 20:36:13 +02:00
efitenandClaude Opus 5 a059299588 feat(node-analytics): hop-count statistics per node (#1812) (#2021)
## Summary
Adds per-node hop-count statistics so repeater operators can choose
`flood.max`, `flood.max.unscoped` and `flood.max.advert` from what their
node actually sees.

- New endpoint `GET /api/nodes/{pubkey}/hop_analytics?days=N`
(`cmd/server/routes.go:299`, `cmd/server/node_hop_analytics.go:312`),
separate from `/analytics` as requested in the issue.
- New card "Hop Count at This Node" on the node analytics page
(`public/node-hop-analytics.js`, wired at
`public/node-analytics.js:130,174`): histogram of hop counts with a box
plot on the same x axis, filters `flood.max` (default),
`flood.max.advert`, `flood.max.unscoped`, driven by the existing range
picker.
- The existing "Hop Distribution" chart is unchanged: it shows path
length at the observer, a different quantity.
- No `direct` tag, although the issue lists one: for DIRECT packets the
path is the remaining route and no flood limit applies, so there is no
hop count to report.

## Hop count definition (firmware 0679dbef)
- `src/helpers/RoutingPolicy.h:15-21`: limits compare
`getPathHashCount()`; `.unscoped` applies to route type FLOOD, `.advert`
to adverts.
- `src/Mesh.cpp:344-350`: `routeRecvPacket` checks with n hashes in the
path, then writes its own hash at index n. So hops = the node's
zero-based index in the path, no +1.
- `src/Mesh.cpp:265-285`: a node forwards a flood once;
`src/Mesh.cpp:651,680`: an originator never forwards its own flood.
- DIRECT packets are excluded: their path is the remaining route
(`src/Mesh.cpp:78-103,334-341`).

Response: `{timeRange, packets: [{hash, timestamp, hops, tags}],
ambiguous}`. Tags: `flood`, `scoped` or `unscoped`, `advert`. Documented
in `docs/api-spec.md:679` and `cmd/server/openapi.go:90`.

## Attribution
`cmd/server/node_hop_analytics.go:198-309`. The result depends only on
the observed paths, the prefix map and the neighbor graph, so it is the
same after a restart as after live ingest.

- Every observation of every flood packet in the window is read.
`byNode` holds the server resolver's pick at ingest and other picks
after a cold load; `byPathHop` indexes only each packet's longest path,
which for a busy relay often runs through another branch of the flood.
- A packet counts when the node's prefix sits at exactly one index
across its observations, and either the node is the only relay candidate
for that prefix (`prefixMap.relayCandidates`,
`cmd/server/store.go:6795`), or the hop resolves to the node under the
ingestor's strict rule (`cmd/ingestor/path_resolver.go:143-214`) in at
least one observation and to another node in none. Strict rule: earlier
hops identified without a tiebreak, exactly one candidate adjacent in
`neighbor_edges` to the previous hop (the originator for hop 0 of an
advert), nodes already on the path excluded.
- The server resolver's tiebreaks (affinity, GPS distance, advert count,
pubkey order) are not used.
- Everything else with the node's prefix goes to `ambiguous`. In
practice that is most packets with a colliding 1-byte path hash.

On a read-only 7-day dump of a 1,669-node mesh DB, for one busy
repeater: 23,081 packets attributed, 11,437 ambiguous. Taking candidates
from `byPathHop` instead gave 9,995 attributed, with the histogram mode
moved from 2 to 3-5 hops.

## Performance
Scans `s.packets` under the read lock, no SQL per packet. Per
observation: one substring test for the node's first prefix byte; the
hop scan only for observations containing it; the strict walk only for
colliding prefixes, with per-request caches for candidates and
adjacency. `BenchmarkNodeHopPackets` models one 7-day request at that
scale (73,782 flood packets, 1,430,280 observations): 44-87 ms/op, 13.4
MB, 40 allocs on a throttling laptop.

Response size for that repeater over 7 days: about 23k entries, 2.3 MB
JSON, 375 KB gzipped. `hash` and `timestamp` are 61% of the raw and 91%
of the gzipped bytes; they stay because the issue asks for them so a
client can join entries to packets and bin by time.

## Tests
- Go: `cmd/server/node_hop_analytics_test.go`: 12 unit tests, a
live-ingest test through `IngestNewFromDB` (a colliding prefix without
independent attribution goes to `ambiguous`, not to the node the
resolver picked), live ingest versus cold load of the same DB, route
test, benchmark. 15 mutations of the attribution logic each fail a test.
- JS: `test-node-hop-analytics.js` (filters, histogram, quartiles and
whiskers with a fixture that separates 1.5 IQR from 3 IQR, render),
registered in `test-all.sh` and `.github/workflows/deploy.yml`.
- `gofmt`, `go vet ./...`, `go test ./...` in `cmd/server`,
`scripts/check-css-vars.js` pass.

## Staging validation
Build `c646310f` (this PR's review follow-up together with the other
open follow-ups), after a container restart and full load, on a busy
Belgian repeater:

- `hop_analytics?days=7`: 23,302 packets, 11,548 ambiguous, median 4,
adverts never above hop 7 (matching the firmware default
`flood_max_advert = 8`, `examples/simple_repeater/MyMesh.cpp:922`), 1.2
s. The first version reported 23,035 packets and 86 ambiguous in 534 ms,
because it trusted the resolver's pick for colliding prefixes.
- The card rendered on the first version with no console errors; the
rework does not touch the frontend beyond a test fixture.

## Not verified
- Response time and lock hold for 30 days on the busiest node on a
14-day store.
- Server relay candidates exclude companions and listeners while the
ingestor's prefix index does not, so a few strict attributions can
differ from the ingestor's persisted `resolved_path`.
- Identical numbers across a second container restart were shown in a Go
test, not repeated on staging.
- Dark theme, phone width, and switching the range picker in the
browser.
- Filter state is not reflected in the URL hash (the range picker is not
either).

Fixes #1812

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 19:59:56 +02:00
efitenandClaude Opus 5 2d4019f719 fix(analytics): recompute once the store has fully loaded (#2025)
Refs #2023, #1659, #1724

### Problem
`main.go:258` waits only for the first load chunk, then `main.go:402`
starts the analytics recomputers. `Start()` computes immediately on that
chunk (`analytics_recomputer.go:86` on master) and the next compute
waits a full interval (`:93`, 5 min default). The chunk loader walks by
ascending id, so that chunk holds the oldest transmissions.

- RF, topology, channels: the #1659 gate checked `LoadComplete()` after
the compute (`analytics_warmup_1659.go:122`). `LoadComplete` flips at
the end of the hot window (`chunked_load.go:489`), before the background
fill (`store.go:1455`), so the gate could open on a snapshot without the
background fill, and the 60 s force timeout (`:73`, `:162`) opened it on
the first-chunk snapshot. In an end-to-end test on master,
`/api/analytics/rf` returned 200 with 8 of 100 packets before the
background fill ran.
- Distance, hash-collisions, hash-sizes, roles, observers-clock-skew,
nodes-clock-skew: no gate, partial snapshot served from the start.
- Distance additionally served a snapshot from the previous index for up
to one interval after each lazy index build.

On a staging instance, a default analytics request returned 5,911
packets with hours-old last buckets until the next recompute (about
74k).

### Change
- `StartupLoadDone()` (`chunked_load.go:108`): closed when
`RunStartupLoad` returns, on every path (`chunked_load.go:202`). Closing
it drops the hash-size info cache (15 s TTL) and the clock-skew engine
throttle (30 s, `clock_skew.go:225`), both read by the post-load
computes.
- `recomputeWhenLoaded` (`analytics_recomputer.go:172`): on that signal,
recompute each recomputer once, sequentially, via `RecomputeNow`
(`:154`), which runs on the recomputer's own loop and restarts its
ticker (`:106`). Order (`:255`): rf, topology, channels, distance,
hash-collisions, hash-sizes, observers-clock-skew, nodes-clock-skew,
roles (roles reads the nodes-clock-skew snapshot). Logs one line with
per-recomputer durations.
- Warm-up gate: now the same signal (`:343-354`), sampled before the
compute starts (`:129`), so a pass that began on partial data never
opens it. 503 + `Retry-After: 5` and the force timeout are unchanged; a
forced-open snapshot is replaced by the post-load recompute.
- Ungated endpoints: no new 503s (their API has none); snapshot replaced
right after the load.
- Distance: the lazy index build refreshes the distance recomputer
before reporting built (`store.go:4476`).
- Recompute intervals and config unchanged.

### Performance
One extra compute per recomputer per process start, run sequentially so
they do not all hold the store read lock at once. Ticker phases
afterwards are offset by the cumulative post-load compute durations
instead of all starting within the first-chunk compute window (relevant
to #1724; the effect on lock waves is not measured).

### Tests
`analytics_recompute_after_load_test.go`: signal open during background
fill, closed after success and failure; cache drops; immediate and
ordered post-load recompute; gate not opened by a pass started before
the load; forced-open snapshot replaced on load; ticker restart;
distance refresh before 202 ends; end to end with recomputers started
before the background fill (RF 503 until load, then `totalTransmissions`
equals the full store; six ungated endpoints 200 during load; all nine
recomputed after load). 9 of these failed on master with stubs; 8
single-line mutations each caught. `go test ./...` in `cmd/server`
passes.

### Staging validation
Deployed together with the review follow-ups of #2015-#2023 (build
`c646310f`), container restart:

```
16:35:20 [store] first chunk ready (chunkSize=10000)
16:35:25 [store] LoadChunked complete ... starting background fill loader
16:36:58 [store] background load complete: 121120/121282 packets in memory (coverage=99.9%)
16:37:03 [analytics-recompute] startup load done: recomputed 10 snapshots in 5.155s (rf=955ms topology=1.684s channels=43ms distance=49ms hash-collisions=30ms hash-sizes=338ms observers-clock-skew=369ms nodes-clock-skew=692ms roles=2ms retransmissions=994ms)
```

Right after that line, `/api/analytics/rf` reported `totalTransmissions`
121,121 against 120,700 packets in memory, and the retransmissions
default shape from #2023 covered the full 7 days. Before this change
both waited for the next 5 minute tick.

### Merge order with #2023
#2023 adds a tenth recomputer. Whichever of the two merges second has to
add `recompRetransmissions` to `analyticsRecomputersLocked`, wire it to
`loadedGate` instead of `LoadComplete`, and change 9 to 10 in
`TestAnalyticsRecomputers_PostLoadOrder`; the retransmissions gate test
then calls `signalStartupLoadDone()` instead of setting `loadComplete`.
That resolution is what ran on staging above.

### Not verified
- Repeater-enrich recomputer and the region/window TTL caches
(hash-collisions region results have a 1 h TTL) may also keep partial
results after the load; not changed here.
- Recompute order is tested structurally, not with roles/clock-skew
data.
- Whether this reduces the #1724 stalls; not measured.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 19:59:37 +02:00
efitenandClaude Opus 5 7f8f71e773 fix(live): reconnect when the websocket goes silent (#1074) (#2020)
## Problem

#1074 reports that after a proxy dropped the WebSocket, live updates
only came back 8 to 10 minutes later.

The client only reconnects from `onclose` (`public/app.js:791` on
master). A half-open connection (a proxy or NAT dropping state without a
FIN reaching the browser, a laptop that slept) can keep a WebSocket OPEN
for minutes without `onclose`, and nothing retries in the meantime.

The server does ping every 30s (`cmd/server/websocket.go:252` on
master), but ping frames are answered by the browser below the page and
JS cannot observe them. The only app-level frames are packet broadcasts
(`websocket.go:337, 343, 366`), which stop on a quiet mesh. So the
client had no signal to tell a quiet mesh from a dead socket.

## Change

Server (`cmd/server/websocket.go`):
- On the existing ping tick, `writePump` also writes the text frame
`{"type":"heartbeat"}` (`:283`, bytes at `:80`). One 20-byte frame per
client per 30s, from the goroutine that already writes the ping, no hub
lock.
- The interval moves to `Hub.pingInterval` (default 30s, `:118`) so a
test can shorten it.

Client (`public/app.js`):
- Every frame refreshes `wsLastMessageAt` (`:841`). One timer
(`checkWSLiveness`, `:806`) fires at last message + `WS_STALE_MS` (75s,
one late or lost heartbeat of slack) and replaces the socket if it is
still silent. It is armed at socket creation, so a stuck handshake is
covered too.
- `dropWS` (`:796`) detaches the old socket's handlers before `close()`,
so a late close event cannot schedule a second connection.
- `connectWS` (`:818`) cancels a pending reconnect and drops the
previous socket, so the watchdog, resume checks, `onclose` and
pull-to-reconnect cannot stack sockets. Before this, `pullReconnect` on
a non-open socket left a third socket 3s later. The 3s `WS_RECONNECT_MS`
delay after `onclose` is unchanged (`:837`).
- `visibilitychange` (to visible) and `online` run the check immediately
(`:861`), because a hidden or sleeping tab's timers can run late.
- Heartbeat frames are matched by exact bytes (`:842`) and are not
pulsed or dispatched to `onWS` listeners.

Compatibility: tabs loaded before the deploy dispatch heartbeats to
their listeners until reloaded. Every current listener filters on
`msg.type`, so the visible effect is a logo pulse and a `/stats` cache
refresh every 30s.

Perf: one `Date.now()` and one string compare per WS message on the
client; one extra 20-byte write per client per 30s on the server.

## Tests

- `test-ws-stale-watchdog-1074.js`: real `app.js` in a vm with a fake
clock, timers and WebSocket. 12 tests: silence past the threshold
replaces the socket exactly once; a handshake that never opens is
replaced; heartbeats and packet traffic keep the socket; heartbeats are
not dispatched; resume and `online` after silence reconnect immediately,
with recent traffic they do not, and hiding does not trigger a check;
repeated resume events open one socket; after `onclose` only the
reconnect timer is pending; pull-to-reconnect leaves one socket. 9 of 12
fail on master. 12 of 12 source mutations (threshold, reconnect path,
detaching, timer cleanup, resume wiring, heartbeat filter) are caught.
Registered in `test-all.sh` and the deploy.yml unit step.
- `TestWritePumpSendsAppHeartbeat`: fails with a read timeout without
the heartbeat, even with pings every 20ms. `TestHubDefaultPingInterval`
pins the 30s interval that `WS_STALE_MS` assumes.
- `go test ./...` in `cmd/server` passes; gofmt and go vet are clean.

## Browser validation

On a staging instance (build `139e484e`, together with #1979's branch),
in Chrome, no console errors:

- A `{"type":"heartbeat"}` frame arrived on the open socket within the
observation window.
- Silent socket: after `ws.onmessage = null`, the page replaced the
socket after 76.2s (threshold 75s plus a 250ms poll); the old socket
ended in CLOSED, the new one OPEN.
- Normal close: `ws.close()` led to a new OPEN socket after 4.1s, and
exactly one new `WebSocket` was constructed.

## Not verified

- The reporter's proxy setup was not reproduced; that their delay was a
half-open socket is a hypothesis consistent with the symptom. Hence
`Refs`, not `Fixes`.
- Laptop sleep and the `visibilitychange` / `online` resume path were
only covered by the unit test, not in a browser.
- Behaviour under Chrome's intensive background-timer throttling and
mobile tab freezing was not measured; a frozen but healthy tab may do
one unnecessary reconnect on resume.
- Go tests were run without `-race`.

Refs #1074



## Review follow-up (commit `72e5e906`)

An independent review found no blocking bug: all data writes stay on the
write goroutine, pong-based dead-client detection still works, and no
ordering of onclose, watchdog, resume checks and pull ends with two live
sockets or none. It reproduced the silent-socket case in headless
Chromium through a blackholing TCP proxy (replacement 75.0 s after the
last frame). Changed:

1. **Startup wiring tested.** The first resume test now boots through
the page's real `DOMContentLoaded` listeners, so removing
`setupWSResumeCheck()` from startup makes it fail.
2. **Wall clock stepping back.** If the clock steps back after the last
message, the watchdog no longer re-arms for the size of the step (a 1 h
step used to delay detection by about an hour). A negative silence
reading is treated as stale, so the socket is replaced within
`WS_STALE_MS` of the step (`public/app.js:810-814`). A step in either
direction costs at most one extra reconnect on a healthy socket.
`Date.now()` stays the clock so a tab resumed after sleep is still
checked against real elapsed time.
3. **Pull-to-reconnect at once.** On an OPEN socket, pull-to-reconnect
now replaces it through `connectWS()` instead of closing it and waiting
for onclose, which took 63 s on a half-open connection in the review's
measurement (`public/app.js:927-934`). This was slow on master too; it
is safe now that `connectWS()` detaches the old socket.

Tests: 12 to 16 in `test-ws-stale-watchdog-1074.js`;
`test-pull-to-reconnect.js`, `test-pull-to-reconnect-1091.js` and
`test-live.js` pass.

Correction to the compatibility note: tabs opened before the deploy
treat the heartbeat like any other message. Besides the logo pulse,
`app.js` runs `updateNavStats` on every message and invalidates the
cached `/stats` and `/nodes` responses 5 s later; `packets.js` also
pushes every message into `pauseBuffer` unfiltered (~1310-1313), so an
old tab with Packets paused sees its counter rise by 2 per minute.
Cosmetic: heartbeats are filtered out on replay, and a reload ends it.

Not verified: real hidden-tab or mobile freeze behaviour, Firefox and
Safari, and the reporter's proxy setup.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 19:59:18 +02:00
efitenandClaude Opus 5 0fea3f2a75 feat(analytics): break scope adverts down by node role (#1979) (#2019)
## Summary

Adds a breakdown of flood adverts by sender role to `/api/scope-stats`
and the Scopes tab, in the descriptive shape agreed in #1979: per node
role, how many flood adverts were unscoped, scoped with an unnamed
region, or scoped with a named region. It reports what was sent, not
why.

## Changes

- `cmd/server/db.go:3114-3144`: one grouped query in `GetScopeStats`.
ADVERT packets on flood routes (TRANSPORT_FLOOD 0, FLOOD 1) in the
window, `LEFT JOIN nodes` on `from_pubkey`, split by the three
`scope_name` states (NULL, empty string, name). A missing or empty role
becomes `"unknown"`. Ordered by total descending, then role. Zero-hop
adverts are excluded because firmware sends them as
DIRECT/TRANSPORT_DIRECT (`src/Mesh.cpp:717-730`, `Mesh::sendZeroHop`),
so they would inflate "unscoped".
- `cmd/server/types.go:116-133`: `ScopeAdvertRoleCount` and
`ScopeStatsResponse.AdvertsByRole` (`advertsByRole`, always an array).
- `public/analytics.js:4760`: `scopeAdvertsByRoleHtml` renders a table
under the time-series chart with the count per state and its share of
the row. Role text is escaped. It reuses the existing `/scope-stats`
response, so there is no extra request.
- `docs/api-spec.md:1763-1786`: documents the new field.
`/api/scope-stats` is in `openapi_known_gaps.json`, so there is no
`openapi.go` entry to update.

API addition (existing fields unchanged):

    "advertsByRole": [
{ "role": "repeater", "unscoped": 7741, "unknownScope": 7, "named": 1562
}
    ]

## Performance

The query runs inside `GetScopeStats`, so it shares the existing 30s
cache per window. The unary `+` on `payload_type` keeps SQLite on the
`first_seen` range index. Without it the planner picked the
`payload_type` index and walked every stored advert whatever the window.
Read-only timing on a production DB (1,063,345 transmissions, 161,634
adverts, sqlite3 CLI 3.45.1):

| Window | payload_type index | first_seen index (this PR) |
|---|---|---|
| 7d | 0.231s | 0.059s |
| 24h | 0.214s | 0.008s |
| 1h | 0.213s | 0.001s |

## Tests

- `TestGetScopeStatsAdvertsByRole` (`cmd/server/db_test.go:2295`): the
three states, flood-only routes, non-advert and out-of-window exclusion,
`unknown` for a missing node row, an empty role and a NULL
`from_pubkey`, and ordering. Mutation checked: widening to routes 0-3
and dropping the empty-role fallback both fail it.
- `TestGetScopeStatsAdvertsByRoleEmpty` (`:2372`): empty result is `[]`,
not null.
- `test-issue-1979-scope-adverts-by-role.js`: renders the real
`analytics.js` helper in a vm sandbox. Covers row order, totals and
shares, columns, escaping (mutation checked), the empty state, and the
non-causal caption. Registered in `test-all.sh` and the deploy.yml unit
step.
- `go test ./...` in `cmd/server` passes, gofmt and go vet are clean,
`check-css-vars.js` OK, `check-xss-sinks.sh --diff` exits 0.

## Browser validation

On a staging instance with live traffic (build `139e484e`, together with
#1074's branch), in Chrome, no console errors: `/#/analytics?tab=scopes`
shows "Flood adverts by node role" under the time-series chart with its
caption, and a table of 6 roles for the default window, for example
`repeater 1.361 | 1.109 (81.5%) | 2 (0.1%) | 250 (18.4%)`. Shares in
each row add up to 100%. `/api/scope-stats?window=7d` returns
`advertsByRole` with the same six roles.

## Not verified

- Timings are from the sqlite3 CLI, not the modernc driver in the server
process.
- Role is the sender's current `nodes.role`. A node whose advert type
changed within the window is counted under its latest role. The data
also contains a raw `type-13` role, shown as is.
- Switching the window in the browser was not exercised; the API was
checked for 7d.
- No Playwright E2E added.

Fixes #1979



## Review follow-up (commit `43af46b8`)

An independent review found no correctness, security or performance
problem: counts are per transmission, the window matches the rest of the
Scopes tab, zero-hop adverts are DIRECT per firmware, and the
`+t.payload_type` hint holds on modernc SQLite 3.46.0. It found two test
gaps and a docs gap. Changed:

- **Ordering.** The Go fixture gave companion, repeater and unknown 3
adverts each, so ordering by total was never checked (`ORDER BY COUNT(*)
ASC` still passed). The fixture now has repeater 4, unknown 3, companion
2, sensor 2, so the expected order differs from alphabetical and the
companion/sensor tie checks the role-name tie-break. Reversing the count
order, dropping it, or reversing the tie-break now fails the test.
- **Column positions.** The JS test only checked that each cell string
appeared somewhere in the row, so swapping two columns passed. It now
compares each row's cells and the header cells by position; both swaps
fail.
- **Docs.** `docs/api-spec.md` and the `ScopeAdvertRoleCount` comment
now name every source of `unknown`: a NULL `from_pubkey` (legacy rows
the #1143 backfill has not reached), a sender with no `nodes` row,
including one moved to `inactive_nodes` by node retention (inside the 7d
window only with `retention.nodeDays` below 7), and an empty role.

Not added: a query-plan test pinning the `+t.payload_type` hint. The SQL
is inline in `GetScopeStats`, so the test would have to copy it or the
query would have to move into a constant; left for a follow-up if
wanted. The raw `type-13` role in the table is the ingestor's
placeholder for reserved advert types 5-15
(`cmd/ingestor/decoder.go:1229`, #1279), shown as is.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 19:59:00 +02:00
3ca176a676 fix(packets): decode CONTROL discover fields for humans (#1868) (#2017)
## What

CONTROL `DISCOVER_REQ` / `DISCOVER_RESP` packets on the Packets page now
show their fields in a readable form:

- Node type and the `DISCOVER_REQ` type filter bitmask render as
Companion / Repeater / Room Server / Sensor instead of a number or hex.
- SNR renders in dB (wire value / 4, sign kept: `-11` shows `-2.75 dB`)
instead of the raw wire byte.
- The responder pubkey renders as the node name (detail header, and the
row preview once the node index is loaded) or a link to the node (detail
field table) when the node is known, and as the first 8 hex chars when
it is not. The full key no longer appears in the row.
- The detail field table has Subtype / Type Filter / Tag / Since and
Node Type / SNR / Tag / Public Key rows with byte offsets, instead of
one generic `Raw` row. Unknown sub-types keep a `Raw` row.

## Credit

Port of @dborup's fix in dborup/CoreScope@ad1680ca (merged on their fork
as 601f1e4), adapted to current master. dborup is co-author on the
commit.

Differences from that commit:
- Name lookup uses the existing bulk node index (`HopResolver`) instead
of `/api/nodes/{pubkey}` on every opened packet, per AGENTS.md rule 10.
New `HopResolver.nodeForKey` (`public/hop-resolver.js:357`) does an O(1)
lookup by full key or 8-byte prefix; an ambiguous prefix returns null.
- `renderDetail` loads that index for CONTROL packets with a pubkey
(`public/packets.js:3293`), because zero-hop packets have no path to
trigger it.
- The ADVERT app-flags row and CONTROL share one type label map
(`advTypeLabel`, `public/packets.js:2992`).

## Firmware references (meshcore-dev/MeshCore @ 0679dbef)

- `docs/payloads.md:259-282`: field layout; DISCOVER_RESP `snr` is
"signed, SNR*4"
- `examples/simple_repeater/MyMesh.cpp:798-799`: sub-type values `0x80`
/ `0x90`
- `examples/simple_repeater/MyMesh.cpp:817`: `filter & (1 <<
ADV_TYPE_REPEATER)`
- `examples/simple_repeater/MyMesh.cpp:820-821`: node type in low
nibble, `data[1] = packet->_snr`
- `src/Dispatcher.cpp:206`: `_snr = getLastSNR() * 4.0f`
- `src/Packet.h:51,92`: `int8_t _snr`, `getSNR()` returns `_snr / 4.0f`
- `src/helpers/AdvertDataHelpers.h:7-11`: `ADV_TYPE_*` values

`cmd/ingestor/decoder.go:750-803` (`decodeControl`) already emits
`ctrlSNR` as `int(int8(buf[1]))` and `ctrlPubKey` as 64 or 16 hex chars,
so this is display-only. No backend change.

## Performance

Row preview adds one object lookup per CONTROL row (O(1)). The 8-byte
prefix index adds one entry per node at `HopResolver.init`. No new API
calls.

## Tests

- New `test-issue-1868-control-decode.js` (14 cases: type names, filter
bitmask, SNR 16 and -21, known full key, known 8-byte prefix, unknown
key, escaping of node names). Registered in `test-all.sh` and the unit
step of `.github/workflows/deploy.yml`.
- Written first: 14/14 failed before the change, 14/14 pass after.
- Mutation checks: removing `/ 4` fails 3 cases; treating the byte as
unsigned fails 2; disabling the prefix index fails 1.
- `test-packets.js`: CONTROL DISCOVER_RESP assertion updated from full
pubkey to 8-char prefix. Its other 13 failures are unchanged from
master.

## Browser validation

On a staging instance with live DISCOVER traffic (build `e84d2da6`), in
Chrome, no console errors:

- `4d67731357dac2ce`, raw payload `92 39 80 6C B8 41 CC 14 67 5D ...`:
the field table shows Subtype `DISCOVER_RESP` (flags `0x92`), Node Type
`Repeater`, SNR `14.25 dB` (wire `57 / 4`), Tag `0x41B86C80`, Public Key
linked as `BE-JBE-Permeke`.
- `99c6121e3fca3782`: SNR `-2.75 dB` (wire `-11 / 4`), Public Key
`BE-RIN-SPECTRUM-ESP-01`, link target `#/nodes/6b8c65b7...`, which
matches that node's key in `/api/nodes`.

## Not verified

- The row preview shows the 8-char prefix until the node index has
loaded, which a page listing only zero-hop packets does not trigger.
Seen on staging: the row kept `pubkey=cc14675d` while the detail panel
showed the name.
- `DISCOVER_REQ` packets were not opened in the browser; only the unit
test covers them.
- eslint and the full `test-all.sh` were not run locally.

Fixes #1868



## Review follow-up (commit `ee1a3887`)

An independent review re-derived every DISCOVER field and offset from
the firmware and found them correct, but found untested offsets and
three small display gaps. Changed:

- **Bytes past the last decoded field are kept.** The CONTROL field
table now adds a Raw row at the real offset for any payload bytes after
the last decoded field (`public/packets.js:3802`, `:3844-3846`). Before,
a truncated DISCOVER_REQ such as `80 04 12`, or a DISCOVER_RESP with a
partial or oversized key, dropped those bytes, while master showed them
in a generic Raw row. UNKNOWN subtypes use the same path now.
- **Unknown filter bits are visible.** Type filter bits outside ADV_TYPE
1..4 (ADV_TYPE_NONE and the reserved 5..15 range,
`src/helpers/AdvertDataHelpers.h:7-12`) are shown as hex next to the
names: `filter=Repeater+0x20` in the row, `Requesting: Repeater +0x21`
in the detail.
- **prefix_only and since=0.** The DISCOVER_REQ Subtype row decodes
`prefix_only` (flags bit 0, `docs/payloads.md:270`,
`examples/simple_repeater/MyMesh.cpp:818`), and `since=0` renders as "0
(no filter)" in the detail (`MyMesh.cpp:811-817`).
- **Unknown responder key in full.** A key with no known node now
appears in full (64 or 16 hex) in the field table; the row preview keeps
the 8-character prefix #1868 asked for.
- **Tests: 14 to 35.** They check the Offset column of every REQ and
RESP row, the since, prefix_only and key-length labels, the UNKNOWN and
truncated or oversized payload cases, an ambiguous 8-byte prefix in
`HopResolver.nodeForKey`, and that `renderDetail` loads the node index
for a zero-hop DISCOVER_RESP. Every mutation listed in the review
(offsets, Since row, Raw row, labels, resolver loading, ambiguous
prefix) now fails the suite, and so do 7 more on the new code.

Firmware references re-checked at `0679dbef`. Not verified: how the
wrapped 64-hex key looks in the detail pane.

---------

Co-authored-by: dborup <3627142+dborup@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 19:57:55 +02:00
efitenandClaude Opus 5 5efe61eef2 fix(ingestor): set an explicit MQTT ClientID per source (#2013) (#2016)
## What

`buildMQTTOpts` (`cmd/ingestor/main.go:591`) never called `SetClientID`,
so with paho.mqtt.golang v1.5.0 every ingestor connected with a
zero-length ClientID and `CleanSession=true`. The session identity then
depended on the broker.

This PR:

- adds an optional `clientId` per `mqttSources` entry
(`cmd/ingestor/config.go:29`)
- when unset, uses `corescope-<name>-<6 hex chars>`
(`cmd/ingestor/main.go:653`). The name is reduced to `[0-9A-Za-z-]`,
with the broker host as fallback when the name is empty. The suffix
comes from `crypto/rand` and changes on every ingestor start.
- sets the ID once per source in `buildMQTTOpts`
(`cmd/ingestor/main.go:616`). paho copies the options into the client
and reuses them for every reconnect, and the watchdog force-reconnect
reuses the same client, so the ID is stable for the life of the process.
- logs the ID on connect: `MQTT [tag] connected to <broker> as client
<id>` (`cmd/ingestor/main.go:150`)
- documents the key in `config.example.json:210` as a
`_comment_clientId` entry rather than a value, because
`docker/entrypoint.sh:6` copies that file as a live config and a literal
value would give every default deployment the same ID. Also listed in
`cmd/ingestor/README.md:94`.

## paho behaviour

- No client-side length limit. `SetClientID` only stores the value; the
65535 check in `packets/connect.go:156` is in `Validate()`, which the
client never calls.
- The default ID is longer than the MQTT 3.1 limit of 23 characters for
most source names. paho falls back to MQTT 3.1 after any refused CONNACK
when no protocol version is set (`client.go:412`), so on a broker that
refuses the first 3.1.1 attempt, the retry may hit that limit. I did not
cap the length because paho does not require it and the 3.1.1 path
accepts it (see below).

## Tests

`cmd/ingestor/mqtt_opts_test.go:53-107`:

- default ID is non-empty, has the sanitized name prefix, and contains
only `[0-9A-Za-z-]`
- broker host is used when the name is empty
- configured `clientId` is used verbatim
- two unconfigured sources with the same name get different IDs
- the client built from the options reports the same ID

Mutation checks: removing the random bytes fails the "different IDs"
test; removing sanitization fails the prefix and character-set tests.

`go test ./...` in `cmd/ingestor` passes except
`TestWriteStatsAtomic_SymlinkAtDestIsReplaced`, which fails locally on
Windows for a symlink privilege reason. `gofmt` and `go vet` are clean.

## Validation against a real broker

On a staging instance (build `e84d2da6`) connecting to a Mosquitto
bridge:

```
MQTT [lincomatic] connection attempt #1 to tcp://mosquitto-bridge:1883
MQTT [lincomatic] connected to tcp://mosquitto-bridge:1883 as client corescope-lincomatic-71a6eb
MQTT [lincomatic] subscribed to meshcore/#
```

The 27-character default was accepted on the first attempt and packets
kept arriving afterwards.

## Not verified

- Only one broker type (Mosquitto) was tried.
- `-race` was not run locally (no cgo toolchain on the test machine).
- The case where both the source name and the broker host are empty (ID
becomes `corescope-<hex>`) has no test.

Fixes #2013



## Review follow-up (commit `a1d6709e`)

An independent review found no bug in the ID handling, but the tests
covered less than their names said. Changed, tests only
(`cmd/ingestor/mqtt_opts_test.go`):

- `TestBuildMQTTOpts_ClientIDSurvivesReconnects` replaces the old
stability test, which only checked that paho copies the options. Against
a loopback fake broker built on paho's `packets` codec, the first
CONNECT, paho's auto-reconnect after the broker drops the socket, and
the watchdog force-reconnect (`buildForceReconnectFn`) must all carry
the same non-empty ID. It runs in about 0.01 s and passed `-count=30
-cpu 1,2,8`.
- `TestBuildMQTTOpts_ClientIDDefaultShape` asserts full IDs:
`^corescope-local-feed-1-[0-9a-f]{6}$`,
`^corescope-mqtt-example-com-[0-9a-f]{6}$` for the broker host fallback
(no port), and `^corescope-[0-9a-f]{6}$` with neither a name nor a host.
- Mutations now caught: `SetClientID` removed, `u.Host` instead of
`u.Hostname()`, a 1-byte suffix, the name guard dropped, sanitization
removed. The "as client" log line has no test because it is logged from
a closure inside `main()`.

Corrections to the description:

- **Fallback to MQTT 3.1.** paho falls back after any failed handshake
once the socket is open, not only after a refused CONNACK: also a read
error or timeout before any CONNACK, or a first packet that is not a
CONNACK (`client.go:401-416`, `net.go:83-97`). A failed dial does not
trigger it (`client.go:387-391`), and after the first successful connect
the protocol version is locked in (`client.go:422-424`). Without this PR
the 3.1 retry sent an empty ID, which MQTT 3.1 forbids as well, so
nothing gets worse.
- **Broker side.** On the EMQX broker we run, authorization has
per-username and all-client rules and no client-ID rules (checked
through its REST API). Two per-username rules use `${clientid}` in a
topic, but both are publish rules and the ingestor only subscribes, so
no rule can match it. A broker that caps IDs at 23 characters but
accepted the empty ID before would now reject the default ID for source
names of 7 or more characters; I have no evidence such a broker is in
use.
- The "Not verified" item about the empty name and host case no longer
applies.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 19:57:37 +02:00
n30nex cd9b4c04d0 test: unify frontend test runs and prevent inventory drift (#1965)
Make `test-all.sh` the authoritative standalone frontend runner for npm
and CI. Restore stale assertions and reject missing, duplicate, removed
or undocumented inventory entries.

Fixes #1858.

Rebased onto `a2f039d4`. Retains release-routing, map scope-state and
Scope Audit stylesheet tests, adds `test-packets-local-channels.js` to
the sorted runner, and classifies `test-neighbor-map-btn-clip-e2e.js`
under browser. Its separate CI browser step is preserved. Inventory: 280
root suites, 167 standalone, 113 requiring separate setup.

The icon repair fixes two suites red on master:
`test-issue-1648-m2-emoji-scan.js` and
`test-issue-1648-m6-final-sweep.js`. Node/live configured-scope
confirmations now use the existing accessible Phosphor check sprite.
Values, visibility conditions and scanner assertions are preserved.

- Red evidence: `89e45a9` inventory assertions; `4e255df` accessible
confirmation assertion. The latest rebase also reproduced both
unclassified-file failures before adding their entries. This follow-up
only changes runner/classification configuration and counts; no test
files modified.
- Local validation: all 167 standalone suites; 27 M2 browser checks and
16 neighbor geometry checks in Chromium. Syntax, whitespace, PII,
CSS-variable and XSS checks passed.
- Browser coverage includes populated/empty/null configured scopes in
node and live views.
- No new dependencies, requests, application settings or Go changes.
Workflow outside the unit step matches master.
- Windows validation uses process-local UTF-8 settings. Encoding and
node-reach confirmation follow-ups remain separate, as requested.

## Preflight override

External `run-all.sh` is unavailable; applicable repository checks were
run directly.
2026-09-13 19:19:39 +02:00
nullrouten0 a2f039d4c7 fix(packets): show browser-added channels in the channel filter (#2009)
Verified against production: ingestor writes enc_<HH> (db.go:2282), store.go:3536 filters on it, live values are uppercase two-digit hex with thousands of packets (enc_28: 2708), /api/channels omits them, and /api/packets?channel=enc_28 returns rows. New test passes at 14 and fails when the dedupe guard is disabled.
2026-09-12 12:40:44 +02:00
nullrouten0 6d7490e7da fix(tables): stop the trailing action column cropping its own button (#2010)
Reproduced on a live instance: 41px column for a 53px button at 1440px, 29px at 1024px, max-width:0 and overflow:hidden on the cell. Injecting the .col-action rule took it to 65px with nothing cropped. The new test fails 13 of 16 checks when only the CSS block is removed.
2026-09-12 12:38:58 +02:00
nullrouten0 c283f42c1f channels: 23 more names in the rainbow guess list (#2007)
Verified: every value is SHA-256(name)[:16] per internal/channel.DeriveKey, 319/320 exact (Public is the fixed firmware default), no duplicate hashes, file parses at 320 entries.
2026-09-12 11:49:27 +02:00
efiten 51a2a7dd2f fix(scope-audit): ship the stylesheet the page paints its badges with (#2005)
Fixes #2004. Two review rounds; findings and evidence on the PR. Verified by injecting the stylesheet into a running deployment and reading computed styles before and after: the badges gain background, size, uppercase and padding; at 430px the Config column stays visible; the sorted column keeps its accent. The new test fails on the eight unstyled classes against the pre-fix tree, on .ns-truncated against the first fix, and on a re-added column-hiding rule.
2026-09-11 15:35:52 +02:00
efiten 296456f9c1 feat(map): colour and filter repeaters by scope-configuration state (#2006)
Closes #2001. Two review rounds plus a re-review; findings and evidence on the PR. Verified on a deployment against live data: the field over 1653 nodes, marker tints per filter state, the marker title and popup Scope row reaching the DOM, and the colorblind-preset cascade. The audit and the map are held to the same classification by an end-to-end test that fails when either side's wildcard handling drifts.
2026-09-11 15:07:31 +02:00
liquidraver fe37f1060c fix(ingestor): delete aged packets in bounded batches so prune stops stalling ingest (#2000)
Reviewed at ecf0b371: query plans dumped and confirmed index-driven for all three statements, termination proven against concurrent ingest (first_seen is always time.Now()), FK child-first ordering required and correct, writer-stats assertions non-racy. Two low findings noted on the PR for follow-up: the dropped RowsAffected error now gates the loop, and ~0.53s batches will trip defaultSlowWriterMs=500.
2026-09-11 11:29:49 +02:00
efiten 02feb2a88e test(ingestor): join the watchdog loop goroutine instead of only asking it to stop (#2003)
Verified before merging: on upstream/master `go test ./cmd/ingestor -run TestMQTTStallWatchdog -count=20` fails; on this branch the same command passes. The flake blocked CI on #2000.
2026-09-11 10:58:56 +02:00
efiten 0c7f2306f6 feat(ingestor): keep the scope-match tally across restarts (#2002)
## What

`scopeMatchCounters` (unique / explicit-over-derived / ambiguous / none)
lives only in the ingestor process, and the only way to read it is the
periodic log line. A restart zeroes the counters, and recreating the
container removes that log with it, so a measurement in progress cannot
be recovered afterwards.

That happened here on 2026-09-10: a 24h ambiguity measurement completed,
two deploys followed before it was read, and nothing on disk held the
number. `/var/lib/docker/containers/*/*-json.log` had no earlier copy.

The tally gates a real decision (whether the collision tie-break from
the `autoRegionKeys` design is worth building), and that needs days of
traffic, so it has to survive the process counting it.

## How

A single-row table, `scope_match_totals`:

- `OpenStore` restores the counters from it, so counting continues
instead of restarting.
- The 5-minute stats ticker writes them back, and so does the shutdown
path, which is what a deploy triggers.
- `since_unix` carries the window start across restarts, so the ratio
keeps a denominator. The periodic log line now prints it.

Saving rides the **stats** ticker, not the region-refresh ticker:
matches are recorded for every transport-scoped packet, including on
instances that never enable `autoRegionKeys` and so never start the
refresh loop.

The table is **not** in `internal/dbschema` on purpose. The server
neither reads nor PRAGMA-detects it; putting it in `AssertReady` would
make an older DB fail the server's startup check over data the server
never looks at.

A failed restore is logged and ingestion continues. Losing an
observability counter is not a reason to stop ingesting.

## Tests

Four, all in `cmd/ingestor/scope_match_tally_test.go`:

- totals and `since_unix` restored across a close/reopen
- a fresh DB gets its anchor row immediately, so the first window has a
start time
- recording after a restore adds to the carried total instead of
counting from zero
- repeated saves keep exactly one row

Full `cmd/ingestor` suite green locally except
`TestWriteStatsAtomic_SymlinkAtDestIsReplaced`, which fails on Windows
only (`os.Symlink` needs a privilege this account lacks) and predates
this branch. `go vet` and `gofmt` clean. The race detector was not run
locally (no cgo toolchain here); the `race-test` job covers it, since
this branch touches `cmd/ingestor`.

## Not done

No UI or API surface for the tally. It is still read from the log line
or straight from the table.
2026-09-11 00:00:45 +02:00
efiten 6c92a8b612 test(ingestor): make the suite race-clean, and run the detector when it matters (#1994)
`go test -race ./...` on `cmd/ingestor` reports **six data races** on
master. None is in production logic. All six come from test helpers that
outlive the test that started them, which is why the detector blames
whichever test happens to be running: two different tests failed on two
consecutive runs of the same code.

## What was racing

**`StartStatsFileWriter` had no way to stop.** Two tests start it at a
50ms interval, and its goroutine then runs for the rest of the process.
It reads the package-level `readProcSelfIOFn` hook, which a later test
replaces to inject a fake, so the write and the read race. The same leak
explains the stray log lines about writing stats into temp directories
that were already cleaned up.

It now returns a stop function that closes the goroutine and waits for
it to exit. Production ignores the return value and runs for the process
lifetime exactly as before; the two tests call it through `t.Cleanup`.

**The migration test read a log buffer while a goroutine wrote to it.**
`log.Logger` serialises its own writes, but `logContains` read
`buf.String()` outside that lock while `RunAsyncMigration` kept logging
after the call that started it had returned. The capture helper now uses
a mutex-protected buffer.

That one is worth calling a real race rather than a test artefact: a
concurrent read during a buffer grow can panic outright with "concurrent
map read and map write"-class behaviour, not merely trip `-race`.

## Measured

`go test -race ./...` on linux/arm64 under go1.27.1: **exit 0, zero
races, 704s**.

## The CI job, and why it is shaped this way

The server has had `-race` since #1208. This closes the same gap for the
ingestor, which carries an `atomic.Pointer` snapshot (the region key set
from #1989) whose safety has been an argument rather than a measurement.

Two deliberate choices, because a check that costs too much gets
switched off:

- **Its own job, not a step inside "Go Build & Test".** Appending
`-race` there puts its ten-odd minutes on the critical path, taking the
pipeline from roughly 20 minutes to roughly 32. As a separate job it
runs beside the E2E job (15-17 minutes) and hides inside that window.
- **Only when `cmd/ingestor/**.go` changed**, decided by the existing
change-scope job, which already gates the heavy jobs on
documentation-only PRs. A frontend or docs PR cannot introduce a data
race in the ingestor. Pushes to master always run it, as they already do
for `code`.

Nothing `needs:` the new job. Adding it to `build-and-publish` would
mean a skipped race job skips everything downstream, which is the
opposite of what a conditional check should do. It reports as its own
check; whether that blocks a merge is a repository setting rather than
workflow logic.

## Scope

Test helpers, one production signature (`StartStatsFileWriter` now
returns a stop function), and the workflow. No change to what the
ingestor does at runtime.
2026-09-10 22:30:27 +02:00
efiten 675c576fea fix(scope-audit): bound the verifier's payload, and fix two tests that proved less than they claimed (#1993)
Three leftovers from reviewing the scope-audit series (#1986, #1987,
#1990). None is urgent; all three are the kind of thing that gets harder
to explain the longer it sits.

## `scopeHMACInputs` accepted a payload `DecodePacket` rejects

Its comment says it walks the same offsets as the decoder, and it does,
minus the `maxPacketPayload` bound the decoder enforces. Unreachable in
practice: such a packet never reaches the database with an empty
`scope_name` in the first place, so the verifier never sees one.

Worth closing anyway, because the comment claims the two agree. A
verifier that accepts what the decoder refuses is a small divergence
today and an hour of confusion on the day it matters.

## The corroboration test seeded the same packet twice

The threshold of two rests on `code1` being two bytes: one match is
1/65536 by chance, two on the same region is (1/65536)². That argument
needs two **independent** observations.

The test seeded `realTransportFloodPacket` twice. Identical payloads
derive identical codes, so it was one observation counted twice, and it
would have passed just as happily against an implementation that counted
rows rather than deriving anything.

It now seeds the real captured packet plus a second one built for a
different payload, both deriving to `#fm-112` on their own.

**The feature was never wrong here.** `transmissions.hash` is unique and
`ComputeContentHash` is path-independent, so two rows always mean two
distinct payloads in production. Only the test failed to demonstrate the
property it is named for.

## Naming at ingest and verifying at read time had no test together

They were built separately, in #1990 and #1989, and the interaction
between them is not exotic: with derived region keys enabled, a packet
that used to be stored unnameable now arrives with a name. The audit
must then report that region as observed by the ordinary route:

- present in `agg.scopes`
- absent from `notObserved`
- **not** claimed by `regionEvidence`, which exists to explain regions
that could only be established by verification

Getting that wrong is quiet. The chip stays green while the reason
underneath it is wrong, and a reader asking "how do we know this" gets
the wrong story.

## Verification

`cd cmd/server && go test ./...` passes (254s), `go vet` and `gofmt -l`
clean. No production behaviour changes beyond the payload bound, which
rejects input that cannot occur.
2026-09-10 22:30:24 +02:00
efiten b5c7612166 fix(live): use the shared WebSocket instead of opening a second one (#1991)
Closes #1980.

`app.js` opens a WebSocket on every page load and fans messages out
through `onWS()`/`offWS()`. `live.js` ignored that channel and opened
its own socket to the same endpoint. `Hub.Broadcast` does no per-client
filtering, so both carried the identical full packet stream and **every
viewer on the live map pulled it twice**. That page is the one people
leave open for hours, so it was a standing multiplier on origin
bandwidth rather than a burst.

## Measured, before and after

Against a running instance, leaving the live map and returning while
counting WebSocket constructions:

| | new sockets on re-entry | constructed by |
|---|---|---|
| before | 1 | `at connectWS (live.js:3315)` |
| after | 0 | nothing |

And with one viewer on the live map, the server now reports **one**
WebSocket client for that viewer, with the live feed counter climbing
normally (7 to 31 over one navigation cycle, 71 on a fresh load).

## The change

The live map subscribes to the shared channel like every other view, and
unsubscribes in `destroy()` rather than closing a socket the rest of the
app still needs.

`connectWS()` drops any existing registration before adding a fresh one.
An early return looks like the natural guard against double
subscription, but it keeps the previous visit's closure registered, and
re-registering without dropping the old one renders every packet twice.
Dropping first is idempotent either way and always binds the current
page.

Reconnection becomes `app.js`'s business, since it owns the socket. That
moved `WS_RECONNECT_MS` out of the only place that honoured it, so
`app.js` now uses it too. It comes from `roles.js` and operators set it
as `wsReconnectMs`; after this change it applies to the one socket
everyone shares, or to nothing at all.

## Tests

Three, in the sandbox that already loads `live.js` with a Leaflet mock:

- the page registers exactly one listener on the shared channel and
constructs no WebSocket of its own
- re-entering leaves exactly one listener, and it is the new one rather
than the previous visit's
- the handler ignores messages that carry no packet

`node test-frontend-helpers.js` passes (726 assertions), `node
test-packet-filter.js` passes.

## One note on the history

The second commit on this branch claims the early-return guard broke
rendering on re-entry, citing a real measurement. The measurement
happened, the attribution was wrong: the zero counter came from the
WebSocket constructor patch I had installed to count sockets, which
interfered with the page it was measuring. The third commit records that
rather than rewriting it away. The change is kept because dropping the
old registration first is the clearer contract, not because the guard
was broken.

## Rule 0

Strictly less work than before: one socket per viewer instead of two,
one JSON parse instead of two per packet, and no second reconnect loop.
Nothing is added to the hot path; a listener already existed for every
other view.
2026-09-09 23:16:13 +02:00
efiten 8c164c5315 feat(scope-audit): verify a declared region against the repeater's own traffic (#1990)
Follow-up to #1987, and the point of counting that traffic in the first
place.

A region this instance holds no `hashRegions` key for is **unnameable,
not absent**. #1987 says so with a caveat chip. This settles it wherever
the evidence allows: derive `SHA256("#region")[:16]` from the repeater's
own declaration and HMAC that repeater's own unmatched packets with it.
Same computation the ingestor performs at ingest, with the candidate set
narrowed from every configured key to this repeater's handful of
declarations.

Where it fires, a grey "declared but not observed" chip becomes a green
one and the caveat count shrinks by the packets it explained.

## Two packets, not one

`code1` is two bytes, so an unrelated name matches a given packet with
probability 1/65536. Across ~400 unmatched packets and ~124 declared
names, chance alone produces roughly one false match per refresh. Two
matches on the same region for the same repeater is (1/65536)², about
one in four billion.

Lowering the threshold to one would not make this noisy, it would make
it **unsound**, so the constant carries that arithmetic and a test
rather than a comment. A region with exactly one hit stays grey and
reports its single hit, so the page can say why it is still shown as not
observed instead of leaving the reader to wonder.

## What it deliberately does not do

**It writes nothing.** Read-time only. A wrong answer expires with the
window instead of sitting in `transmissions.scope_name` until someone
runs a repair, and `cmd/server` stays read-only per the invariant in
AGENTS.md.

**`notObserved` remains the single source of chip colour.**
`regionEvidence` says only HOW a region was established. Two fields that
can disagree about the same fact is how this column got confusing in the
first place.

## Rule 0, including the part that was wrong at first

The naive shape is `targets × names × packets` HMACs: 205 × 124 × 400 ≈
10M.

Caching per `(region, transmission)` pair cuts the HMACs to ~50k. **That
measured 501ms**, because the HMACs had become a rounding error while
the *iteration* stayed cubic at 10.2M map lookups. Re-keyed per region,
holding the set of matching transmissions, it is **36ms** at the same
worst-case shape: a region is HMACed over every packet once, and a
target then asks one question per declared region instead of one per
(region, packet). Most declared regions match nothing, so the common
case is a single map lookup and no packet loop at all.

`hmacCount` exists so a test can assert the first mistake cannot come
back; the benchmark exists because only it caught the second.

## Both axes are bounded, because neither is bounded by the data

The "~400 packets in a 7 day window" this was sized against is a
property of one instance's configuration, not of the feature: the
ingestor stores the unnameable state for every transport-scoped packet
no configured key names, so an instance with few or no `hashRegions`
entries — the stock state, and the one this helps most — has **every**
scoped packet in that set.

- the window query takes the 4096 most recent candidates and reports
truncation, which the handler logs, so a grey chip on a sampled refresh
is not read as "not forwarded"
- the declared list is capped at 32 names per repeater: it arrives from
a collector that validates each entry's shape but never how many entries
there are
- measured at the cap: **306ms** for 205 targets over 124 names, against
29ms for the shape a real network produces

Because both caps make the evidence a sample, the response carries
`observedUnmatchedSampled`. Without it a client subtracts a capped
numerator from an uncapped total and overstates the unexplained traffic
with no way to know it is doing so. The chip subtracts only evidence for
regions **absent** from `notObserved` — a single-hit region the server
refused to accept is not called explained either — and says "at most N"
when the count was sampled.

## Verified on live data

Six repeaters clear the threshold in a 7d window on a real instance. One
of them: `nl-nb` green with 3 corroborating packets and the tooltip
stating the count, `belml` still grey on 1, and the caveat chip reading
31 of 34 packets unexplained rather than 30.

## Tests

`scope_verify_test.go` covers the HMAC-input walk against a real
transport-flood packet captured from a live instance (a hand-built
fixture would only prove the parser agrees with itself), that
`regionCode` does not fold case, the threshold in both directions, the
memo's HMAC count, both bounds with their truncation flag, and the
benchmark at cap size. Handler-level tests cover a region verified into
green, a single hit left grey with its count reported, and the
sample-size field.

`cd cmd/server && go test ./...` passes (77s), frontend 723 assertions
pass, `go vet` and `gofmt -l` clean.
2026-09-09 17:58:09 +02:00
efiten 0e607c1d01 feat(ingestor): derive region keys from what nodes declare, opt-in (#1989)
Follow-up to #1988, which made an ambiguous match deterministic. This
adds the second tier of keys that ambiguity rule was needed for.

A transport-scoped packet can only be named by a region key this
instance holds. `hashRegions` is a hand-maintained list, so every region
a node forwards that nobody typed into the config is stored unmatched,
and everything downstream reports that region as **absent** rather than
as **unnameable**.

The instance already knows the names, though: `nodes.configured_scope`
holds what the observer `/neighbors` ingestion (#1865) confirmed each
node is configured for. This derives keys from those names, on top of
the explicit list rather than instead of it.

**Default off.** An absent config block leaves behaviour byte-for-byte
unchanged, asserted by tests rather than argued.

## Sources

Two, mirroring what the server's `AllCurrentDeclaredRegions` already
merges, so the derived tier sees exactly what the Scope Audit sees:

| source | availability |
|---|---|
| `nodes.configured_scope` | always — the column is part of the schema,
written by the `/neighbors` path |
| `node_declared_regions` | optional, where a deployment fills it by
other means |

The optional table is probed via `sqlite_master` before it is read. A
stock install does not have it, and its absence must not abort a refresh
the first source could answer on its own.

## The two spellings

The sources spell the same region differently, and both are accepted:

- `configured_scope` carries the leading `#` that `normalizeScopeList`
adds, because every other stored scope value has one
- an OTA answer in the optional table carries the bare name
- `loadRegionKeys` already prefixes a missing `#` before hashing

So `regionNameAcceptable` canonicalises before judging, and hands back
the bare name the caller re-prefixes. What it rejects is what cannot be
a region name at all: `*` (the flood wildcard, not a region), a comma
(it would split the name on the next round-trip through a
comma-separated column), a second `#`, whitespace, non-ASCII, NUL
padding from a stale client, and anything past 32 characters.

The rules are deliberately structural rather than about meaning. A real
declared set contains entries that look like junk, but a blocklist on
string values is unmaintainable, and the cost of one bad name is a
single slot out of the cap plus a 1-in-65536 collision chance.

`*` is skipped in **two** places on purpose: in the filter, so no key is
ever derived for it, and in the source count, because nearly every node
declares it and counting it would overstate both the cap arithmetic and
the refresh log on every deployment.

## Rule 0

Each derived key costs one HMAC per transport-scoped packet and raises
the random 2-byte collision rate by 1/65536. So:

- the tier is **capped** (default 256)
- over the cap, names are kept by how many distinct nodes declare them,
so a one-off local name is dropped before a region half the network uses
- benchmarked linear at ~0.65µs per key: at 314 keys that is 217µs per
packet, 0.0008% of one core at the 0.037 transport-scoped packets/s this
network produces

There is no indexable shortcut to reach for, for the same reason as in
#1988: `code1` is an HMAC over the payload, so nothing is
payload-independent.

The set is an `atomic.Pointer` to an immutable snapshot. A refresh
builds the replacement off to the side and swaps the pointer, so the
ingest path never blocks on a rebuild. `refreshDerived` is a
load-then-store rather than a CAS loop, which is safe only because
exactly one goroutine calls it: the refresh ticker, plus one synchronous
call at startup. The comment says so, rather than leaving the type
looking as though it tolerates concurrent refreshers.

## Tier 2, and the counters

When several keys match one packet and exactly one of them is explicit
operator config, the explicit one wins: an operator who typed a region
into `hashRegions` outranks a name overheard on the air. Ambiguity
between two equally-sourced keys still stores unmatched, unchanged from
#1988.

Counters tally how each packet was decided (unique /
explicit-over-derived / ambiguous / none) and are logged on the refresh
tick. They exist to answer one question with data rather than
estimation: whether a third tier that breaks ties on path evidence is
worth building at all. Measured on a live instance at 159 keys over 41.7
hours and 147,535 packets: **0.253% ambiguous**, with
explicit-over-derived at zero because that instance has exactly one
derived key.

## Rule 8

`maxDerived` and `refreshMinutes` are configurable values and belong in
the customizer eventually. Documented in `config.example.json` for now,
flagged here so it is tracked rather than forgotten.

## Tests

- default off, and a refresh that is a no-op while disabled
- the name filter across both spellings, the wildcard, and each
structural rejection
- ranking by declarer count, then recency, then name, so the result is
deterministic rather than churning between refreshes
- `configured_scope` as a source, including that a node counts once per
name and the newest answer wins
- both sources merged, neither dropped
- the explicit-over-derived tie-break
- the derived tier replaced rather than merged on refresh, so a region
that stops being declared leaves the key set and the cap keeps meaning
something

`cd cmd/ingestor && go test ./...` passes apart from
`TestWriteStatsAtomic_SymlinkAtDestIsReplaced`, which needs
`SeCreateSymbolicLinkPrivilege` and fails on Windows on master too. `go
vet` and `gofmt -l` clean, `config.example.json` still parses.
2026-09-09 17:58:04 +02:00
efiten fd825a779c fix(ingestor): name a region scope deterministically, or not at all (#1988)
`matchScope` returns the first configured region whose derived code
equals the packet's `code1` and stops there. Go randomises map iteration
order per `range`, so when two configured regions collide on a payload,
the stored region name depends on which key the runtime happened to
visit first. **The same packet can be named differently on two runs of
the same binary**, and neither answer is evidence of anything.

## How often this actually happens

`code1` is two bytes, so any two configured regions collide on a given
payload with probability 1/65536. That is a curiosity at 5 configured
regions and routine at 150.

Measured on a live instance carrying 159 configured regions, over 41.7
hours and 147,535 transport-scoped packets: **374 collisions, 0.253% of
decisions.** They concentrate on four key pairs rather than scattering,
because `code1` is an HMAC over the payload: a payload that collides
collides every time it is seen, and a flooded packet is seen by many
observers.

The existing comment sizes the function for "≤ 50 regions". A live BE/NL
instance declares 126 distinct region names across its repeaters, so
operators are already past that.

## The rule

`matchingRegions` returns every match; `matchScope` applies one rule:

- exactly one match names the packet
- several matches name nothing

The candidates are equally sourced, there is no principled winner
between them, and storing a wrong region name is worse than storing
none. `""` is already the ingestor's "transport-scoped but unnameable"
state (`scopeNameForDB`), so an ambiguous packet lands in a state the
rest of the system already understands rather than in a new one. Nothing
downstream needs to learn a new value.

The collision is logged, because it is otherwise invisible: such a
packet is stored exactly like one whose region this instance holds no
key for. An operator watching an unnameable count grow deserves to see
which of their own configured regions are colliding, since the fix is
theirs to make.

## Rule 0

Cost is unchanged: the same single pass over the same keys, it just no
longer stops early. The early exit was worth nothing on the common path,
where zero or one key matches and the loop runs to the end either way.
Worst case is unchanged at one HMAC per key per transport-scoped packet.

There is no indexable shortcut to reach for. `code1` is an HMAC over the
packet payload, so nothing is payload-independent to index on, and the
old comment suggesting a "pre-indexed lookup table" is removed rather
than left as a false lead for the next reader.

## Tests

Three, and the fixture matters:

- an unambiguous packet still gets its region name
- a genuinely colliding payload stores the unmatched state instead of a
coin flip
- the ambiguous case run 50 times, because a first-match matcher passes
a single iteration roughly half the time

The collision is **found by searching payloads** (~65k tries, fractions
of a second) rather than asserting on a hand-picked `code1`. The case
only exists when the matcher genuinely finds two names for one packet,
and a fabricated code would only prove the test agrees with itself.

`cd cmd/ingestor && go test ./...` passes apart from
`TestWriteStatsAtomic_SymlinkAtDestIsReplaced`, which needs
`SeCreateSymbolicLinkPrivilege` and fails on Windows on master too. `go
vet` and `gofmt -l` clean.
2026-09-09 17:18:54 +02:00
efiten 0605b1703a feat(scope-audit): count and surface the traffic this instance cannot name (#1987)
Follow-up to #1986, and the second half of the same problem.

`ScopeAuditForwarding` drops rows whose `scope_name` is the empty string
with a bare `continue`. That empty string is the ingestor's
"transport-scoped, but no configured region key matched `code1`" state
(`scopeNameForDB`), so those packets name no region and can never
satisfy a declared one. The consequence is on the page: **a repeater
forwarding a region this instance holds no `hashRegions` key for is
reported exactly like a repeater forwarding nothing at all.** The audit
presents a gap in the reader's own configuration as a finding about
someone else's hardware.

This counts them per target, exposes the count as
`observedUnmatchedPackets`, and renders it as a caveat chip beside the
scope chips.

## Why it is not a rare edge

Measured on a live instance before this landed: of 613 `notObserved`
entries across 205 repeaters, **260 named a region that never appeared
under any name in the whole 7-day window**. Two of them (`behss`,
`fm-112`) were hash-verified as genuinely forwarded traffic the instance
simply could not name: packet `0a065d41d51f1f77` decodes to
`code1=9209`, which is exactly the code `#fm-112` derives over that
packet's own payload.

That instance had 16 region keys configured against 124 distinct region
names its repeaters declare. A stock install has fewer.

## What the counter is not

It is deliberately **not** folded into `unscopedPackets`. The two are
opposites:

| | meaning | what governs it |
|---|---|---|
| `unscopedPackets` | the packet carried no scope at all (`scope_name`
SQL NULL) | the `*` wildcard |
| `observedUnmatchedPackets` | the packet IS scoped, this instance holds
no key for that region | nothing the repeater declares |

For the same reason the new count never feeds `wildcardContradiction`,
which counts only plain unscoped floods. `scopeNameForDB` in the
ingestor is the source of truth for that three-state encoding, and the
comments point there rather than restating it.

It is also distinct from `ambiguousHops`, and the distinction is the
point of the chip: that one is a pubkey-prefix collision between two
repeaters and is nobody's fault, this one is a missing entry in the
reader's own configuration and they can act on it. Saying which is which
is what stops someone investigating an innocent repeater.

## Frontend

The chip reuses the muted dashed treatment of `.sa-chip-ambiguous` on
purpose: both are caveats on the row's finding rather than findings
themselves, and neither may compete visually with the red/green scope
chips beside them.

It renders nothing for a non-numeric count. The value is
server-supplied, and a truthiness check would put the literal string
`NaN forwarded packets` on the page if that ever stopped holding.

## Docs

`docs/api-spec.md` had **no entry for `GET /api/scope-audit` at all**,
so this adds one: query parameter, full response shape, and the notes a
client needs (the three traps the per-node endpoint documents apply here
identically, `*` is never a scope, and "never asked" is not "declared
nothing"). The new field is documented there rather than in isolation.

## Tests

- the counter on a last-hop and on a mid-path hop
- an unmatched packet enters neither `agg.scopes` nor `unscopedPackets`,
which is the confusion this field exists to prevent
- the field on the API row
- six frontend cases: zero renders nothing, a missing field renders
nothing (older server), the chip carries its count and class, singular
and plural are both grammatical, the title names the cause and the fix,
and a non-numeric count renders nothing rather than `NaN`

`cd cmd/server && go test ./...` passes, frontend 712 assertions pass,
`go vet` and `gofmt -l` clean.

Rule 0: the counter is one increment on a branch that already existed as
a `continue`, inside a loop this PR does not change. No new query, no
new pass over the data.
2026-09-09 17:05:29 +02:00
efiten 079e73aa4c fix(scope-audit): attribute forwarding to every hop, and pay for the wider scan (#1986)
The Scope Audit credits a transmission to `path[last]` only. On a
flood-family route every forwarder appends its own hash to the END of
the path (`internal/packetpath/route.go`), so `path[last]` does not mean
"forwarded this packet", it means "was the transmission an uplinked
observer heard directly". Every earlier hop forwarded the same packet
and is discarded.

The last-hop rule is genuinely required for DIRECT routes, which consume
hops from the front, so their `path[last]` is the route’s far end rather
than the transmitter. But `scopeAuditForwarderScanQuery` already
restricts to `route_type IN (0, 1)` via
`scopeConformanceForwarderRouteTypesSQL`, where that hazard cannot
arise, so inside this query the restriction only throws evidence away.

## What it costs the page today

Measured on a live-shaped instance, 206 declared repeaters, 965k
transmissions, 7d window:

| | before | after |
|---|---|---|
| repeaters with no attributable evidence of any kind | 133 of 205 (65%)
| 30 of 206 (15%) |

On a 1000-packet flood sample the mean path length is 7.08 hops, so the
last-hop rule keeps 394 of 2789 hop observations (14%), and 85% of the
nodes seen forwarding never appear as a last hop at all. Those repeaters
have every region they declare reported as "declared, not observed",
which is the page presenting a gap in our own attribution as a finding
about someone else’s repeater.

## Rule 0: what widening it costs, and what pays for it

Reading every hop multiplies the rows the scan returns: a 7d window
yields **3,470,188 hop rows** from 1,368,761 observations carrying a
path. Cold cost before this change was 16.7s for 7d and 4.0s for 24h, of
which SQLite accounts for 2.7s. The rest was the Go side reading rows.

Three changes, in order of what they bought:

1. **The scan carried `scope_name` and `first_seen` on every hop row.**
Both are columns of `transmissions`, and at 43 hop rows per transmission
the same two values were re-read that many times. They now come from one
query over the same window keyed by transmission id, both inside one
read transaction so a transmission arriving between them cannot appear
in the hop scan with no metadata to attribute it by. The hop scan
carries two columns instead of four.
2. **The hop is lower-cased into a stack buffer** instead of through
`strings.ToLower`. 1,026,814 of the 1,284,897 hops in a 24h window are
stored uppercase, because `packetpath.DecodePathFromRawHex` writes them
that way, and the great majority match no declared target, so that
allocation was paid millions of times to answer "no". The `(target,
txID)` de-duplication key became a struct for the same reason.
3. **The compute ran outside the cache mutex**, so every request
arriving on a cold window ran its own full scan concurrently. It now
sits behind a singleflight, the same treatment `/api/observers` and
`/api/nodes/{pubkey}/reach` already have, and the 7d window gets a 5
minute TTL while 1h and 24h keep 30s. At 30s a single reader with 7d
open keeps the instance recomputing more than half the time, for an
aggregate that moves at the pace of a week of traffic.

Result, warm process:

| window | before | after |
|---|---|---|
| 1h | 0.155s | 0.140s |
| 24h | 4.04s | 2.79-2.89s across six samples |
| 7d | 16.7s | 11.6s |
| repeat inside TTL | ~1ms | ~1ms |

**Rejected alternatives, measured on the same database**, so the next
reader does not have to re-derive them:

| approach | rows returned | time in SQLite |
|---|---|---|
| the query as written | 3,470,188 | 2.7s |
| pre-filter on the declared targets’ first 4 hex chars | 1,971,126 |
20.9s |
| `GROUP BY t.id, hop` | 965,025 | 38.0s |
| `SELECT DISTINCT t.id, path_json` | 1,229,966 | 17.7s |

The query plan is already index-driven (`idx_transmissions_first_seen`,
then `idx_observations_tx_ts`), so there is no missing index behind
this: the rows are inherent to the data. Note for anyone attempting a
hop comparison in SQL: a case-sensitive comparison silently drops most
attributable hops, per the 80% figure above.

## Tests

- a mid-path hop is attributed (the case behind the 65% blind spot)
- a DIRECT transmission whose `path[last]` **is** the target is still
not attributed. With the last-hop rule gone this is the only thing
standing between the audit and misattribution, so it gets its own test
rather than relying on the route filter being obvious
- one transmission counted once per target even when it appears on
several hops of the same path, which the `(target, txID)` de-duplication
now carries alone
- a hop longer than the 4-char floor resolved by its own length, which
nothing pinned before: every other test seeds 4-char hops
- the per-window TTL, so collapsing it back to one constant has to
delete the reason
- a second request inside the TTL served from cache rather than
recomputed. The cache path had no test at all

`cd cmd/server && go test ./...` passes (168s), `go vet` and `gofmt -l`
clean. Server-side only, no API shape change, no frontend change.

Browser validation: run against a live instance carrying this change,
the Scope Audit renders 220 rows matching the API row for row, and the
per-node scopes page still answers with its route-type mix.
2026-09-09 16:00:15 +02:00
Anupam MedirattaandClaude Opus 5 de237fc29c fix(qa): bind TEST_PUBKEY as a SQLite parameter instead of interpolating it (#1982)
Closes #1977. Supersedes #1952. Follow-up filed as #1983.

## What §10.2 did

```bash
q="SELECT COUNT(*) FROM transmissions WHERE from_node = '$TEST_PUBKEY';"
qq=$(printf %q "$q")
if ! count=$(ssh_t "docker exec … sqlite3 … $qq" 2>/dev/null); then
  count=$(ssh_t "sqlite3 … $qq" 2>/dev/null || echo "")
fi
```

The injection is not reachable today — `TEST_PUBKEY` is hex-gated and
the
script `exit 2`s before the SQL is built. The problem is that the SQL
layer's
safety rests entirely on that outer gate rather than on the SQL layer
itself.
#1952 proposed doubling embedded quotes; that is string escaping, not
parameterisation, which is why it was withdrawn in favour of this.

## What this does

Per the four points in the sign-off on #1977:

**1. Bind the value.** A constant `SELECT` and a bound `:pubkey`, fed to
sqlite3 on stdin. The SQL no longer crosses the remote shell as a
command
word, so there is no `printf %q` on the query at all any more.

**Why hex rather than `.parameter set :pk '<value>'`.** Dot-command
arguments
are split on whitespace, so a payload containing a space produces too
many
arguments — and sqlite3 responds by printing the `.parameter` help to
**stdout**, exiting **0**, and leaving `:pk` **unbound**. `COUNT(*)`
then
returns 0, which reads exactly like a passing security fix. `-bail` does
not
catch it. Verified on 3.51.0:

```
$ printf ".parameter set :pk '' OR 1=1 --'\nSELECT COUNT(*) FROM transmissions WHERE from_node = :pk;\n" \
    | sqlite3 -bail ptest.db
.parameter CMD ...       Manage SQL parameter bindings     # <- help, on stdout
   …
0                                                          # <- :pk never bound
$ echo $?
0
```

`.parameter set :pk 1+1` also binds the integer `2` — the value is
evaluated
as an SQL expression and only falls back to a text literal when
evaluation
fails. So interpolating into the `.parameter set` line trades one hazard
for
another.

Hex-encoding removes the quoting layer instead of adding one: the value
is
bound as `cast(x'<hex>' as text)`, so its contribution to the SQL text
is
drawn from the alphabet `[0-9a-f]` only. Nothing to quote, no tokenizer
arity
hazard, and it holds for **arbitrary** input rather than only for
hex-gated
input — which is the point.

Verified against a fixture table holding two rows, one of them
`deadbeef`:

| value | result | exit |
|---|---|---|
| `deadbeef`, bound as `cast(x'6465616462656566' as text)` | `1` | 0 |
| `' OR 1=1 --`, bound the same way | `0` | 0 |
| `' OR 1=1 --`, interpolated the current way | `2` (whole table) | 0 |
| query against a DB with no `transmissions` table, `-bail` | `Parse
error … no such table` on **stderr** | **1** |

**2. Probe the capability, not a version.** `resolve_sqlite_runner`
binds
`corescope-probe-ok` and asserts it comes back — a round trip, not a
bare
`.parameter init`, so the positive control runs against the operator's
actual
binary rather than one we pin. If neither the container nor the host
qualifies,
it fails loudly and names what is needed:

```
  ❌ retain-failed: no sqlite3 able to bind a parameter on the target
     tried: docker exec -i corescope-prod sqlite3, then sqlite3 on runner@example
     need:  the sqlite3 CLI reachable over ssh, supporting '.parameter set'
OCI runtime exec failed: exec: "sqlite3": executable file not found in $PATH
bash: line 1: sqlite3: command not found
```

There is deliberately **no** interpolating fallback. That would leave
the
vulnerable path in place under a nicer name.

**3. The hex gate is kept**, with its comment updated to say why: for
the SQL
layer it is now defence in depth rather than the only guard. Redundant
is not
the same as wrong.

**4. The exit status and stderr survive.** `-batch -bail -init /dev/null
-noheader -list` (stop at the first SQL error; ignore the operator's
`~/.sqliterc`, where a stray `.mode` would make the count unparseable;
stdout
is exactly the number). Query stderr is captured and printed on failure
rather
than sent to `/dev/null`, so a broken query is distinguishable from a
legitimately empty result. Probe stderr is collected too, and printed
only if
*both* probes fail — the container miss is the known-normal case, so
surfacing
it on every run would be noise.

## Also fixed

An existing double-count in §10.2: the `TARGET_DB_PATH unset` branch
incremented `$fails` and then left `count=""`, so the generic branch
incremented it a **second** time for the same failure.
`read_retain_count` now
gives §10.2 exactly one increment point. Opportunistic cleanup in a file
already being touched (AGENTS.md line 318).

## Tests

New `qa/scripts/test-blacklist-sql.sh`, wired into the `go-test` job. 24
assertions, modelled on `scripts/staging/test-disk-monitor.sh`.

Both directions are asserted, because a zero from a command that failed
proves
nothing:

- **Positive control** — `deadbeef` still returns its row (`1`, exit 0),
and so
  does `cafebabe`; an absent pubkey returns `0`.
- **Negative** — `' OR 1=1 --` returns `0` while the table demonstrably
holds
2 rows, and the old interpolated form is asserted to leak all `2`. That
last
  assertion is what makes the `0` above worth something.
- **Error surfacing** — the same SQL against a DB with no
`transmissions` table
  exits non-zero with a message on stderr and nothing on stdout.
- **Alphabet** — `sql_hex_literal` output matches `^x'[0-9a-f]*'$` for
the SQL
payloads, a backslash, `$(id)` / backticks, an embedded newline,
`héllo`, and
a 4096-byte repetitive string. That last one is a regression guard for
`od
  -v`: without the flag `od` collapses repeated identical lines to `*`.
- `run_sqlite` with no resolved runner refuses rather than guessing.

Group 2 skips loudly (rather than silently) if `sqlite3` is not on PATH;
group
1 needs no sqlite3 and always runs.

**Mutation-tested** — each of these breaks the suite, so the assertions
have
teeth:

| mutation | caught by |
|---|---|
| restore full interpolation | `injection payload → 0 rows — expected
'0' got '2'` |
| naive `.parameter set '%s'` | `expected '0' got '.parameter CMD ...'`
|
| drop `od -v` | alphabet failure on `*`, plus `expected '8192' got
'33'` |

Commit 1 is a behaviour-neutral refactor that moves the imperative body
into
`main()` behind a `BASH_SOURCE` guard, so the test can source the script
and
exercise individual helpers. Same idiom as
`scripts/staging/disk-monitor.sh:99`.

## Verification

- `bash qa/scripts/test-blacklist-sql.sh` → 24 passed, 0 failed
- `bash -n` on both scripts
- All three runtime paths exercised end to end with PATH shims for
  `ssh`/`docker`/`sqlite3` against a real fixture DB: success
  (`sqlite3 runner: host`, count 2), query failure (classified message +
`Parse error … no such table`, `fails=1`), and no-capability (the loud
block
  above, both probe stderrs, `fails=1` — not 2)
- The new step lands inside `go-test`, which runs when
`changes.outputs.code ==
  'true'`; `qa/scripts/*.sh` does not match that job's
  `^docs/|[.]md$|^LICENSE$` documentation filter, so it is not skipped

## Deliberately out of scope

- **The `docker exec` branch is dead on current images** → filed as
#1983. The
app container has no `sqlite3` at all: `Dockerfile:15` is pure-Go SQLite
with
  no CGO, and the `apk add` installs only `mosquitto mosquitto-clients
supervisor caddy wget`. So the host "fallback" is the only path that has
ever
executed, silently, because both branches discarded stderr. This change
keeps
both branches and merely makes the outcome visible (`sqlite3 runner: …`
on
  every run).
- **`-readonly` on the target DB.** Tempting, and verified compatible
with
  `.parameter` (the binding table lives in the TEMP database), but a WAL
database needing journal recovery can refuse a read-only open. Adding it
here
risks exactly the "trades an unreachable injection for a script that
does not
  run" outcome flagged in the #1952 thread. Worth its own issue.
- **The other `2>/dev/null` sites** in this file, which also sit
awkwardly with
`qa/README.md`'s "Don't silence stderr". Only the §10.2 lines named in
the
  sign-off are touched.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 15:37:40 +02:00
Sylvain Rabot 3fbff01f64 build: upgrade Go toolchain to 1.27 (#1946)
## Summary
- Bumps the Go toolchain used to build/test to 1.27:
`golang:1.27-alpine` in `Dockerfile` and `Dockerfile.go`, and
`go-version: '1.27'` in the three `actions/setup-go` steps in
`.github/workflows/deploy.yml`.
- Each module's `go.mod` `go` directive is intentionally left at `1.22`
— no 1.27-only language features are being adopted, and a 1.27 toolchain
builds a `go 1.22`-declared module without issue.

## Test plan
- [x] `go build ./...` + `go vet ./...` for all 13 modules
(`cmd/server`, `cmd/ingestor`, `cmd/migrate`, `cmd/decrypt`, 10
`internal/*` packages) under Go 1.27.0
- [x] `go test ./...` passes for `cmd/server`, `cmd/ingestor`,
`cmd/migrate`, `cmd/decrypt`
- [ ] `docker build` against the new `golang:1.27-alpine` base (Docker
wasn't available in the sandbox this change was prepared in — needs a
check in CI or locally)
2026-09-09 11:49:33 +02:00
efiten 2c8c1161b5 feat(#1975): network-wide Scope Audit page, fed by confirmed scopes (#1976)
One row per repeater whose configured region list is known, answering a question
no other view answers: you declare these regions, but were you seen forwarding
them? default_scope says what a node's adverts were observed under and
transported_scopes (#1751) says what it carried, but nothing lined the declared
list up against observed forwarding.

The declared side merges every confirmed-scope source the database carries,
newest answer per node wins, rather than naming one. On a stock install only
nodes.configured_scope (#1865/#1971) exists and it degrades to the one-source
case; deployments that collect the same fact another way keep working. Reading a
single hard-coded source would have rendered an empty page on the very instance
the evidence came from.

Declared and observed are compared through normScope, so a leading "#" and a
bare region name are one region. Unobserved regions render neutral, not red:
absence over a short window is weak evidence, which the page header already
states in words.

Ported from a long-running fork deployment with its 17 server tests, rewired to
the upstream data source, plus 14 frontend cases asserting rendered markup.
2026-09-06 23:04:42 +02:00
efitenandClaude Opus 5 5b689f75fe feat(#1845): filter nodes by how long they have been silent (#1973)
Closes #1845 for the question in its title. The alerting ask that came
later in the thread is deliberately **not** in here; see the bottom.

### The gap

@Jonher937 asked to flag repeaters that stopped communicating for x
days, to find remote gear that has died. Today the Nodes page has
Active/Stale with thresholds fixed at 72h for infra and 24h for
everything else, plus a Last Heard filter that selects nodes heard
**within** a window. Neither answers "show me what has been quiet for
over a week".

### What this adds

A `Silent for` select beside Last Heard: 1d, 3d, 7d, 14d, 30d, each
labelled with the number of nodes it would select.

```
[ All ] [ Active ] [ Stale ]   Last Heard: Any v    Silent for: over 7d (23) v
```

Counts are computed **before** the silence filter is applied, so the
dropdown keeps showing what the other windows would select instead of
collapsing to the one already chosen. The choice persists in
`localStorage` like the neighbouring filters and is mirrored into the
URL as `?silent=7d`, so the view can be pasted to whoever owns the
silent gear. The URL sync is wrapped in try/catch, because it is a
convenience and must never stop the filter working.

### The part worth reviewing: one definition of freshness, not two

`getNodeStatus` has been relay-aware since #1598, while `nodes.js`
separately computed `statusAge` from the ADVERT timestamp alone.
Filtering on the latter would have listed a repeater as silent for ten
days while its own badge on the same row said active, and it would have
done so for **exactly** the nodes #1598 exists to protect.

So the freshness rule is extracted into `window.getEffectiveHeardMs` in
`roles.js`, and `getNodeStatus` now calls it. Behaviour is unchanged,
there is now one source. Reviewers should look hardest at that refactor
rather than at the select.

A node never heard from at all scores `Infinity`, so it matches every
window instead of silently dropping out of the filter.

### Why the thresholds are fixed values and not derived

I measured the alternative before writing this, on a 1179-repeater mesh,
and posted it on #1611: replacing a fixed threshold with `3 x per-node
advert median` fixes 8 false "silent" flags and newly mis-flags **28
currently-active nodes**, because a 3h-median node gets a 9h threshold.
Raising the global default to 144h rescues 7 and hides 27 genuinely dead
repeaters. Both are net-negative. A user-chosen window sidesteps the
whole question: the operator picks what "too long" means for their mesh,
which is what @Jonher937 asked for in the first place.

### Verification

- `test-frontend-helpers.js`: **666 → 680 passed, 0 failed**. Fourteen
cases covering `NaN` rather than `0` when nothing is known (0 is a real
timestamp and would sort as very old rather than unknown), the full
`_liveSeen > _lastHeard > last_heard > last_seen` precedence, a recent
relay beating a stale advert, a stale relay **not** dragging a fresh
advert backwards, relay alone sufficing, `room` counting as infra while
`companion` does not (a `last_relayed` on a companion is meaningless and
must not rescue it), case-insensitive roles, the legacy `(role, ms)`
call shape, and the 72h boundary asserted at 71h and 73h.
- One of those failed on first run and **the test was wrong, not the
code**: `9e7` ms is 25h, which is correctly active for infra. Fixed, and
the boundary is now asserted explicitly so nobody repeats it.
- `eslint` on the changed files: 0 errors. The warnings present are the
same ones master already reports.
- No server change, no new API, no new column. No cache-buster bump
needed: `__BUST__` is substituted at startup in
`cmd/server/main.go:570`.

### Not done

**No alerting.** @fokcuk asked on the thread for notification when a
repeater they look after goes silent, which is a different product:
subscriber identity, an evaluation loop and delivery, none of which
exist today. That deserves its own issue and a design call rather than
being shimmed into `nodes.js`, which is also what the triage concluded.
This PR gives the operator the view; it does not push to them.

Sizes and boundaries (1d/3d/7d/14d/30d) are a judgement call. Say the
word if a different set fits real operator habits better.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 21:43:20 +02:00
n30nex 2288e28d4e fix: publish release artifacts after successful image retagging (#1964)
Successful release fast paths publish image tags but never dispatch the
job that creates the GitHub release and decrypt binaries. Dispatch
`deploy.yml` from both image routes. A default-off `images_published`
input skips E2E and image rebuilding only for an already-published tag;
missing or mismatched images keep the complete fallback without
requiring new inputs on older workflow definitions.

Go validation still gates the release binaries, checkout and version
flags retain the tagged source, and the existing release action uploads
both architectures before publication. Missing binary files now fail
publication.

Fixes #1956.

Validation:

- `node test-issue-1956-release-routing.js` executes the actual workflow
shell steps with registry and dispatch commands stubbed. Covers
matching, missing and mismatched images; failed retag and Go validation;
branch/PR boundaries; and both tagged binary commands.
- The original test commit fails because a matching image dispatches
zero artifact workflows; the fix passes the same assertion.
- Existing release workflow Go checks, decrypt/channel tests, YAML
parsing and actionlint pass.
- Both static Linux amd64 and arm64 binaries cross-build with verified
architecture and version metadata.

Actual registry publication and GitHub release creation were not
exercised. Existing immutable releases and old tags that contain older
workflow definitions are outside this fix.

Following #1922, this is a focused release-routing PR. A separate repair
for #1858 rewrites the shared frontend test runner; merging this first
lets that repair retain this regression in its authoritative list.
Please assess current Go and E2E job results separately from
workflow-approval or staging-runner state.
2026-09-06 21:20:16 +02:00
efitenandClaude Opus 5 1ffaad8eb1 feat(#1794): per-IP limits and a deny list on the /ws upgrade (#1974)
Closes #1794. Follow-up to #1793, decided **before** the upgrade because
the handshake is the resource being protected.

- Deny list of addresses and CIDRs → 403
- Per-IP concurrent connection cap → 403
- Per-IP upgrade rate limit over a rolling minute → **429**, not 403: a
temporary refusal should not read as "never come back"
- Rejection counters split by cause in `/api/stats` under `websocket`

### The decision this feature lives or dies on

Most CoreScope installs sit behind nginx, Caddy, Traefik or an ingress.
`cdn_detection.go` says so in as many words: it deliberately excludes
`X-Forwarded-For` from its CDN signals precisely because *every*
reverse-proxied install sets it. For those deployments `r.RemoteAddr` is
the proxy, `127.0.0.1` for every visitor on earth. A per-IP cap keyed on
that address protects nobody and hands the sixth legitimate browser tab
a 403. That is a self-inflicted outage wearing the costume of hardening.

So:

- **`X-Forwarded-For` is believed only from an address listed in
`webSocket.trustedProxies`.** From anywhere else it is
attacker-supplied, and trusting it would let anyone mint a fresh source
IP per connection, which is strictly worse than having no limit at all.
- **When the peer looks like a local reverse proxy and no
`trustedProxies` is set, the per-IP limits are skipped**, and one
warning names the setting that fixes it. Silently refusing real users is
the worse failure.
- **The deny list still applies there**, because it is the operator's
explicit instruction rather than an inference.

That is the answer to @mcode6726's question on the thread: it is neither
"always the socket address" nor "always the header", and the operator
decides which by naming their proxy.

### Two deliberate departures from the issue body

**`maxConnsPerIP` ships as 0 (off), not 5.** Carrier-grade NAT puts
thousands of unrelated mobile subscribers behind a single public IPv4. A
cap of 5 refuses real visitors on phones while a scraper simply rents
more addresses: all of the cost, none of the benefit.
`upgradesPerMinPerIP` ships at **30 and on**, because that one *is* safe
under CGNAT: a real client upgrades a handful of times per minute even
while reconnecting, so 30 leaves ordinary traffic untouched while
flattening a reconnect loop. A pointer type distinguishes "unset" from
an explicit `0` that turns it off.

**The default deny list is not shipped.** The thread proposed seeding 44
CIDRs for one VPS provider after a single scraper was seen at
`23.111.177.6`. I have left it out: blanket-blocking a hosting provider
by default breaks legitimate operators who host there, is undiscoverable
by the person locked out (they see a bare 403), and ages badly as ranges
get reassigned. The mechanism is here and `config.example.json` shows
exactly how to configure it, so any operator who wants that list can
have it in one line. If you want it shipped as a default anyway, that is
your call as maintainer and it is a one-line change.

### Verification

19 tests, including all five the issue specifies as TDD requirements,
each marked with the issue's own wording. Beyond those five:

- a **bare address** in the deny list works, not just CIDR form.
Operators write `1.2.3.4`, and silently ignoring that would be the worst
possible failure for a deny list: it looks configured and blocks nothing
- an unparseable deny entry is skipped and logged, not fatal. One typo
must not take the server down
- one client behind a trusted proxy does **not** exhaust another
client's budget behind the same proxy, which is the entire point of
honouring XFF
- changing a forged XFF from an untrusted peer buys no fresh budget
- `release` frees a slot and is **idempotent**, because `Unregister` can
run twice for one client and double-crediting would leak slots
- a **rejected** upgrade does not consume rate budget, or a retrying
client could never recover once its window cleared
- limits skipped for loopback and private peers; deny list applies
anyway
- a nil limiter allows everything, so a `Hub` built without
`ConfigureLimits` behaves exactly as before
- idle per-IP state is collected, while a record with a live connection
never is

Full `cmd/server` suite green, `gofmt` clean.

### Not done

- No runtime config reload; restart required. Listed as optional in the
issue.
- No `WS_DENY_IPS` env override. Also listed as optional.
- From the OWASP expansion in the first comment: `maxPayload` and the
idle/read timeout are **already in master** (`SetReadLimit`,
`SetReadDeadline`). The ping/pong heartbeat is not, and is not in this
PR either; it is a separate change to the read/write pumps and belongs
in its own review.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 21:11:29 +02:00
efitenandSaarMesh-Bot 9d6f08c144 feat(#1865): ingest observer /neighbors as confirmed scope evidence (#1971)
Carries SaarMesh-Bot's implementation from the closed #1867 forward onto current
master, 67 commits later, and surfaces the result on the per-node Reach report.

The declared region list a repeater answers with is now stored on the node as
configured_scope, normalised to the same leading-# syntax default_scope already
uses so the two are directly comparable. That normalisation is the point
@cwichura raised on #1865 and @dborup agreed with before the original PR closed.

Co-authored-by: SaarMesh-Bot <300107934+SaarMesh-Bot@users.noreply.github.com>
2026-09-06 21:06:12 +02:00
n30nex 43d83ee8ca fix(nodes): dispose map timers with their owning view (#1970)
Red commit: `2f1a50e` (local Chromium: 2 passed, 7 assertion failures).
Ownership regression: `059ab49` (9 passed, 4 assertion failures). CI:
[run](https://github.com/Kpa-clawbot/CoreScope/actions/runs/33988139479)
awaits maintainer approval (`action_required`); 0 jobs started.

Rapid navigation or closing node detail could leave a delayed resize
targeting a removed or replacement map. Disposal now cancels its timer,
each resize captures its own map, and delayed responses respect the view
owning the current map. Stale side-pane responses are ignored before
rendering.

Fixes #1972.

- E2E assertion added: `test-issue-1206-resize-observer-leak-e2e.js:216`
and `:292`. This existing CI-selected suite covers navigation,
replacement deadlines, close/Escape, no-location rendering, and late
error/success responses. Existing observer-growth assertions remain
intact; readiness waits replace fixed sleeps.
- Browser verified: local fixture with real Chromium and Leaflet; 13
browser checks passed after push. Evidence:
`data/node-map-validation/post-push-browser.log` and
`data/node-map-validation/evidence.md`.
- Validation: frontend unit suites 99/18/666 passed; JavaScript syntax,
CSS variables, whitespace, PII and XSS checks passed. Backend unchanged;
Go suites not rerun.
- Independent adversarial, expert and TDD reviews found no required
changes. Two original navigation checks initially timed out; the
unchanged parent rerun passed 13/13 (`parent-browser-confirm.log`).
- Performance/config: one timer handle, no packet/node loops or new
requests. Tests enforce zero stale invalidations and one resize at the
surviving map's deadline. Existing 100ms delay retained; no new settings
or throughput claim.

Fix commits: `2492a65`, `1dc090d`.

## Preflight overrides

- External `run-all.sh` is unavailable. Scoped branch, red/green, PII,
CSS, XSS and whitespace checks were run directly; no migrations, SQL
attribution or image markup are added.
2026-09-06 21:04:09 +02:00
n30nex a938176f83 fix(nodes): clearly mark the selected node in path chains (#1968)
Red commit: `0988bc0` (local browser: 3 passed, 8 behavior assertion
failures before the fix).

The selected node now has a compact outline in long “Paths Through This
Node” chains, in the side panel and full detail page. Matching uses
complete public keys without case sensitivity; same-prefix siblings and
unresolved hops stay unmarked. Existing links, escaped names, warnings
and ambiguity underlines are preserved.

Fixes #1153. Its prerequisite #1144 is already merged.

- E2E assertion added: `test-issue-1146-path-link-contrast-e2e.js:220`.
The existing CI-selected harness passes 11 checks across 18-hop paths,
both themes, desktop/mobile, and the renderer fallback. Review follow-up
`bd8118f` verifies the marked ambiguous hop's dashed underline.
- Browser verified: `http://127.0.0.1:55635`; desktop/mobile path
screenshots were inspected. The broader smoke runner exited successfully
with fixture-dependent skips.
- Required frontend checks pass: 99 filter, 18 aging, 666 helpers. CSS
variables, seven CSS self-tests, 31 XSS sink checks, 17 XSS gate
self-tests and XSS diff preflight pass.
- Three independent reviews found no blocking issues. Traversal remains
linear with no new requests, settings, dependencies or cache
invalidation; styling uses the existing customizer token.

## Preflight overrides

- The external preflight runner is absent; corresponding scoped gates
passed. Red browser evidence is local, with upstream CI approval tracked
separately under the process in #1922.
- Existing rapid-navigation map resize timer errors remain visible in
browser logs and are outside this change.
2026-09-06 21:04:03 +02:00
n30nex 108ea020f7 fix(nodes): remove misleading aggregate SNR headlines (#1969)
Red commit: `2bf9be8` (local Chromium: 3 passed, 3 intended assertion
failures). CI:
[run](https://github.com/Kpa-clawbot/CoreScope/actions/runs/33988112224)
awaits maintainer approval (`action_required`); 0 jobs started.

Remove the unqualified aggregate Avg SNR row from node side-panel
Overview and full-detail stats, following option 3 in #1149. Heard By
retains each observer's SNR reading.

Fixes #1149.

- E2E assertion added: `test-issue-1281-location-row-e2e.js:224`. Three
new browser cases cover desktop side/full and mobile full views with a
numeric aggregate and distinct positive/negative observer readings.
Existing packet-location assertions remain intact.
- Browser verified: local Chromium; 6 cases passed after push.
Screenshots: `coverage/issue-1149/issue-1149-desktop-side-panel.png`,
`coverage/issue-1149/issue-1149-desktop-full-detail.png`, and
`coverage/issue-1149/issue-1149-mobile-full-detail.png`.
- Validation: packet filter 99/99, aging 18/18, frontend helpers
666/666; XSS, CSS-variable, syntax, whitespace and PII checks passed.
- Independent reviews: adversarial, lifecycle expert and TDD reviewers
found no required changes. One initial browser navigation timed out; the
unchanged parent rerun passed 6/6.
- Performance/config: two production row deletions; no new requests,
loops, timers, settings or customizer implications. Backend unchanged;
Go suites were not rerun.

Fix commit: `d7c68f3`.

## Preflight overrides

- External `run-all.sh` is unavailable on this host. Scoped branch,
red/green, PII, CSS, XSS and whitespace checks were run directly. The
diff adds no migrations, SQL attribution or image markup.
2026-09-06 21:03:58 +02:00
n30nex eb1d733998 fix(analytics): preserve selected hash size in links (#1967)
Red commit: `5a5ecb6` (local browser: 14 passed, 8 behavior assertion
failures before the fix).

Hash Issues links now restore `bytes=1|2|3` for the selected control and
its matrix/collision data. Missing or malformed values default to one
byte. Selector clicks, section/top links, tab-bar changes, filters and
theme refreshes retain the chosen view through the existing URL helper.

Fixes #1914.

- E2E assertion added:
`test-issue-1306-collisions-terminology-e2e.js:242`. The existing
CI-selected harness passes 23 checks, including distinct nonempty
collision rows for each byte size. Its original assertions remain.
- Browser verified: `http://127.0.0.1:55634` with the local fixture API,
plus reviewed matrix/risk screenshots. Region refresh passed; area
coverage skips because the fixture has no areas.
- Required frontend checks pass: 99 filter, 18 aging, 666 helpers; URL
helpers pass 18. Three independent reviews found no blocking issues;
their coverage suggestion is included in `fb482fe`.
- Added work parses URL state and updates six links. Rendering and bulk
requests are reused; no backend, configuration, dependency or CI-list
changes.
- A broader smoke run timed out at Live autocomplete (#1110); full-suite
success is not established.

## Preflight overrides

- The external `run-all.sh` is absent. Corresponding scope, PII, syntax,
whitespace and CSS checks passed; no SQL, migration or image changes
require those gates.
- Red browser evidence is local. Upstream CI execution remains a
separate approval gate, as discussed in #1922.
2026-09-06 21:02:14 +02:00
n30nex cf67a5e5ec fix: remove evicted resolved path hops and preserve relay snapshots (#1966)
Eviction removes raw wire hops but leaves resolved full-key entries in
`byPathHop`, retaining expired transmissions and stale relay
counts/scopes. Filter every hop bucket once per eviction batch using the
existing evicted-ID set, remove duplicate references and empty buckets,
and clear discarded pointer slots.

Bulk relay aggregation now owns its bucket snapshots before releasing
the read lock, so eviction and raw-path updates cannot mutate an
in-flight reader. Three existing handler test fixtures also wait for
index readiness or explicitly simulate not-ready state, preserving their
original 200/503 assertions.

Fixes #1908.

Validation:

- Regression commits fail before their corresponding fixes: resolved
keys/counts/scopes remain after eviction, and saved relay snapshots
change during eviction.
- Targeted eviction, relay, scope, cache and concurrent-reader checks
pass under `-race`; coverage includes time/cap eviction, missing
resolved-path prefetch, disabled membership indexing, duplicate
references, retained backing arrays and surviving entries.
- Local browser smoke: nodes, node details/path attribution and
analytics render using the fixture-backed Go server.
- The last full Windows server race run, before the final snapshot-copy
correction, had one remaining DB-only timing failure
(`TestGetChannelMessagesPerfLargeChannel`: 2.198s against a 1.5s
budget). The final correction was checked with focused race tests. The
unchanged ingestor suite also cannot create one symlink without Windows
privileges. These thresholds/assertions were preserved; full Linux
Go/E2E results still require upstream CI approval.

Performance tradeoff: cleanup is O(total indexed pointers) per nonempty
eviction batch, under the existing write lock. The minute-based ticker
pays for one sweep instead of repeated scans of shared raw buckets. No
per-transmission string index or dependency is added. Synthetic
benchmark medians (three single-iteration runs, shared Windows host):

| Transmissions | Evicted | Before | After |
|---:|---:|---:|---:|
| 30,000 | 1 | 1.07 ms | 14.19 ms |
| 30,000 | 3,000 | 56.27 ms | 61.68 ms |
| 30,000 | 7,500 | 83.58 ms | 65.54 ms |
| 100,000 | 1 | 0.30 ms | 56.95 ms |
| 100,000 | 10,000 | 949.93 ms | 190.71 ms |
| 100,000 | 25,000 | 1,834.48 ms | 320.38 ms |

Fixture: eight raw plus eight resolved hops per transmission, two
observations, 2,048 relays; 480,000/1,600,000 hop entries. Timing
includes acquiring the store lock and omits unrelated secondary indexes.
Small batches now pay for the complete sweep; shared-host timing is
noisy.

Owning the bulk reader's arrays also has a measured cost on cold/bulk
recomputation, rather than cached hits. Snapshot medians from three
samples of ten iterations:

| Transmissions / relay nodes | Before time / bytes per operation |
After time / bytes per operation |
|---|---:|---:|
| 30,000 / 50 | 0.0068 ms / 5,416 B | 23.65 ms / 4,101,435 B |
| 30,000 / 2,000 | 0.1374 ms / 196,768 B | 26.77 ms / 4,274,336 B |
| 100,000 / 2,000 | 0.1376 ms / 196,768 B | 27.89 ms / 13,959,337 B |

These are total snapshot costs, comparing the unsafe header-only
snapshot with owned pointer arrays. Cleanup guarantees here apply to
`byPathHop`; other indexes and existing periodic bulk-cache freshness
are outside this change.

Following #1922, this runtime fix is separate from the release-routing
and frontend-runner PRs. Current Go and E2E job results should be
assessed separately from workflow-approval or staging-runner state.
2026-09-06 21:00:44 +02:00
TeTeHacko 6ae7971da0 test(#1356): assert the rendered label, not where identifiers sit in map.js (#1933)
Follow-up to the review on #1912, where this assertion cost a round
trip. Independent of that PR — this branch is off current `master` and
touches no code path it changes.

## The problem

`#1356 V3.e`, `V3.f` and `V3.g` all describe what
`makeRepeaterLabelIcon` **produces**, but all three assert it by
grepping `public/map.js`. V3.e also bounds the distance between two
identifiers:

```js
assert(/MB_GLYPHS\[[^\]]+\][\s\S]{0,200}shortHash|shortHash[\s\S]{0,200}MB_GLYPHS\[/.test(mapSrc),
  'makeRepeaterLabelIcon prepends MB_GLYPHS glyph to the hash text');
```

Three separate failure modes, all observed:

**1. It fails on edits that change nothing.** #1912 inserts one variable
declaration in that function; the markup is byte-identical and the build
went red.

**2. It cannot tell code from prose about code.** My first attempt at
fixing #1912 added a comment explaining the constraint — and the comment
mentioned both identifiers, so it satisfied the grep by itself. With
that comment present I moved the hash assignment away from the glyph,
reintroducing the exact defect, and the test still reported green. A
check that a comment can satisfy is worse than one that is merely
brittle.

**3. It does not assert the thing it is named after.** On `master` the
match is not the declaration order at all. It is `MB_GLYPHS[...]`
reaching the *later* `shortHash` inside `ariaStatus`, 212 characters
downstream. Whether the glyph is actually prepended to the hash is
incidental to whether this passes.

That third point also corrects something I said on #1912, and it
corrects it against myself: both the 312 you quoted and the 299 I
"corrected" it to are the distance between the two **declarations**,
which is not the distance the regex uses. Measuring the one it does use:

| tree | `MB_GLYPHS[` → next `shortHash` | assertion |
|---|---|---|
| `master` | 212 | pass |
| #1912 before the fix | 277 | fail |
| moving `unknownWidth` below the glyph | **343** | fail |
| moving `shortHash` below the glyph | 54 | pass |

So moving `unknownWidth` down does not merely fall short — it makes the
gap *worse*, because it lands between the glyph and `ariaStatus`. My
earlier "233, still 33 over" was the wrong metric on the wrong pair.
Apologies; the conclusion happened to hold but the reasoning did not.

## What this does

Loads `map.js` in the same DOM-less `vm` sandbox
`test-map-clustering.js` already uses, exposes `makeRepeaterLabelIcon`
through the existing `window.__meshcoreMapInternals` hook, and asserts
the emitted markup:

- glyph, `U+2009` thin space, hash — in that order and adjacent;
- no glyph and no thin space when there is no multi-byte status;
- `aria-label` exactly `multi-byte <status>, hash <ID>`, and `repeater
hash <ID>` without one;
- the visible span carries `aria-hidden`.

No browser, so it stays in the JS-unit-tests step rather than moving to
Playwright.

## Mutation-tested, not eyeballed

| mutation | old V3.e/f/g | new |
|---|---|---|
| glyph moved after the hash | **all silent** | caught |
| plain space instead of `U+2009` | **all silent** | caught |
| span loses `aria-hidden` | V3.g caught | caught |
| `aria-label` loses its comma | **all silent** | caught |
| 200 chars inserted between the identifiers (no behaviour change) |
V3.e **fails** | passes |

Full JS unit list from `.github/workflows/deploy.yml`: 65/65.

## Notes for review

- V3.a–V3.d (MB_GLYPHS definitions, CSS variables, the border rule) are
left as source/CSS greps. The glyph values are now covered implicitly by
the rendered-output assertions, but converting the CSS ones needs a
different approach and did not belong here.
- The sandbox loader has no `try`/`catch` that warns and continues. If
`map.js` stops loading, the suite must fail rather than silently skip
every assertion below it.
- If this lands, the ordering comment in #1912 becomes obsolete and I
will drop it there. I deliberately did not touch it from this branch so
the two do not conflict textually.
2026-09-05 15:05:43 +02:00