Commit Graph
3011 Commits
Author SHA1 Message Date
efitenandClaude Opus 5.5 9897d52cf6 feat(users): companion linking store (schema v6, device sessions, link challenges) (#2174)
Part 1 of 3 for #2173 (companion linking). This PR adds only the
`users.db` side: the schema and store methods the server (part 2) and
ingestor (part 3) build on. No route, no UI and no behaviour change for
a running server.

## Changes in this PR

- **Design spec** `docs/specs/2026-10-08-companion-linking-design.md`,
including the optional linked-only ingest.
- **Schema v6** (`internal/users/schema.go`):
- `sessions` gains `kind` (`'web'` or `'device'`), `label` and `scopes`
(`''` = full, for web sessions).
- `companion_links (pubkey PRIMARY KEY, user_id, name, linked_at)`,
cascading on user delete.
- `link_challenges`: single-use challenges bound to a user and a pubkey,
with an expiry.
- **Device sessions** (`sessions.go`): create a device session with a
label and scopes, list sessions with kind and label.
- **Challenges** (`challenges.go`): issue and consume a challenge
exactly once; consumed, expired, or bound to another user or pubkey all
fail.
- **Companion links** (`companions.go`): link, list, unlink. Linking a
pubkey that belongs to another user moves it and reports the previous
owner (transfer detection), so the server can audit and mail.
- **Janitor**: the existing janitor also prunes expired challenges
(`cmd/server/auth_service.go`, 3 lines).

## Upgrade note

The store migrates `users.db` to v6 on start. A binary that only knows
v5 refuses to open a v6 file (`users: database schema version 6 is newer
than this binary supports (5)`), so rolling back after this lands needs
the pre-upgrade `users.db` backup.

## Verification

- `internal/users`: new tests for the v6 migration from v5, device
sessions, challenges (single use, expiry, binding) and companion links
(transfer, cascade on user delete). `go vet` and `go test ./...` green.
- `cmd/server` full suite green against the v6 store.

## Not in this PR

- Routes, bearer auth and the link handshake: part 2.
- Ingestor filter, account page and "My coverage": part 3.

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 18:08:51 +02:00
efitenandClaude Opus 5.5 f3004d977e feat(rx-coverage): show where a companion drove and received nothing (#2172)
The Mobile RX coverage page (`#/rx-coverage`) shows only cells where a
companion received something. Where a companion drove and received
nothing is just as useful for operators, and the data is already there:
the RF sample track (`client_rf_samples`, #1905/#1906) and the
receptions (`client_receptions`) use the same hex grid (`hexCellAt`,
`zoomToHexRes`). This PR adds those cells as a hatched layer under the
reception cells.

## Changes in this PR

**Server**
- `GET /api/rx-coverage?...&gaps=1` adds a `gaps` member to the
FeatureCollection: cells with moving (non-stationary) track samples and
no reception in the same `days` window and `bbox`, each with `samples`.
`gaps_truncated` is set when more than 5000 cells qualify (the
most-sampled are kept, same pattern as `coverageFeatureCap` and
`rfNoiseFeatureCap`).
- Gaps are only returned when `clientRfSamples` is on and there is no
`node` filter: with `?node=` the question would be "this node was not
heard", which these cells do not answer. `?rx=` limits both the track
and the receptions to that companion.
- Without `gaps=1` the payload is unchanged: the `gaps` member is
omitted.
- New code lives in `cmd/server/rx_coverage_gaps.go` (pure
`aggregateGaps` plus `queryTrackPoints`); the handler change in
`rx_dashboard.go` is 14 lines.
- `/api/rx-coverage` is now documented in `openapi.go` and removed from
`openapi_known_gaps.json` (#1670).

**Frontend** (`public/rx-coverage.js`, `public/node-reach-coverage.css`)
- A "Nothing received" toggle on the signal layer, on by default, only
rendered with `MC_CLIENT_RF_SAMPLES`. Hidden on the noise layer.
- Gap cells use a 45-degree hatch (an SVG pattern in a dedicated
renderer, so they sit under the reception cells). The hatch is denser
for cells driven through more often (1 / 2-3 / 4+ samples). Tooltip:
sample count. Legend entry: "driven, nothing received".
- Off state is deep-linkable as `gaps=0`.
- New token `--nq-cov-gap` for light and dark. Plain grey
(`--nq-cov-grey`) already means "received, no SNR", so the hatch keeps
the two apart.

## Threshold: one sample

Measured on a live network (analyzer.on8ar.eu, Belgium, September to
October 2026), with cells of about 250 m: a cell with track samples and
no reception in one half of a month, driven again in the other half. How
often did it receive something the second time?

| Minimum samples | Cells "nothing received" | Driven again | Received
something later |
|---|---|---|---|
| 1 | 3971 | 1263 | 29% |
| 2 | 1565 | 592 | 32% |
| 3 | 619 | 243 | 35% |
| 5 | 142 | 63 | 40% |

A higher threshold hides cells without making the rest more reliable, so
the threshold is 1 and the wording is "nothing received in this period",
never "no coverage". Using every decoded packet as reception evidence
(including 1-byte path hashes) gave 25% to 32%, so the capture rule for
1-byte hashes is not what drives this. The median cell has 2 samples.

## Verification

- Go: 7 new tests in `rx_coverage_gaps_test.go` (gap/no gap, stationary
excluded, single sample, cap keeps the most-sampled, endpoint, `?rx=`
filter, no `gaps` member when not asked / with `?node=` / with RF
samples off). The endpoint tests fail without the handler change. Full
`cmd/server` suite green locally, `go vet` and `gofmt` clean.
- E2E: `tests/e2e/test-rx-coverage-gaps-e2e.js` (4 steps: no toggle with
RF samples off, on by default with hatched cells under the reception
cells, toggle off with `gaps=0` and deep link, noise layer hides it). 2
steps fail without the frontend change. Existing
`test-rx-coverage-noise-e2e.js` and `test-rx-coverage-viewport-e2e.js`
still pass. Listed in `scripts/non-unit-tests.json`.
- Frontend unit suite (`test-all.sh`) green.
- Real data on a staging instance (7 days, bbox 50.8,4.9,51.3,5.8,
z=12): 478 reception cells and 379 gap cells, no cell in both, reception
features identical with and without `gaps=1`, 0.48 s to 0.52 s per
request both ways.
- Browser: checked on staging with real data (light theme), and light,
dark and 390 px wide with mocked data.

## Not in this PR

- A per-node variant on the Reach page ("this repeater transmitted while
a companion was nearby, and was not heard"). That needs the transmit
moments from `observations` and is a heavier query.
- `deploy.yml` does not get a line for the new E2E file, like the other
entries that are only in `non-unit-tests.json` (#2037).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:39:33 +02:00
efitenandClaude Opus 5.5 673442ccb9 perf(ingestor): refresh planner statistics 24h after the last refresh, not after every start (#2158)
Refs #2146.

## Situation

The daily planner statistics refresh (#2058) runs its first `ANALYZE` 2
minutes after every ingestor start. On a cold page cache that bounded
`ANALYZE` (analysis_limit 10000) takes minutes on a 10-11 GB database,
and it holds the ingestor's single write connection the whole time:

| Where | Logged after a restart |
|---|---|
| production instance (11.5 GB) | `component=analyze
duration=469855.2ms`, plus `[neighbor-build] SLOW tick: 7m49.98s` behind
it |
| staging instance, 12 restarts logged on 2026-10-09 | 4m11s to 9m11s
each; 7 of the 12 took over 7 minutes |

`EnsurePlannerStats` already documents what that costs: ingest stalls
and buffers for those minutes. The server's reads compete with the scan
for the disk during that window.

Every restart pays this for statistics that are at most a day old.

## My change in this PR

- `RefreshPlannerStats` records when it succeeded, in a file next to the
database (`<db>.planner-stats-refreshed`, RFC 3339).
- The refresh loop waits until that time is 24 hours old: at least the
existing 2-minute stagger, at most 24 hours (`nextPlannerStatsRefresh`).
A restart within a day of the last refresh runs no `ANALYZE`.
- No recorded time (the first start after upgrading, or a new database)
refreshes after 2 minutes as before, and records it.
`EnsurePlannerStats` records its build too, so a new database no longer
runs a second `ANALYZE` 2 minutes after the first.
- A failed refresh keeps the daily rhythm instead of retrying every 2
minutes, the same as the old ticker.

## Verification

On staging (`/var/lib/docker/volumes/.../meshcore.db`, 168 h window):

```
first start:
  [analyze] next planner statistics refresh in 2m0s
  [analyze] planner statistics refreshed in 7m26.086s (analysis_limit=10000)
  [analyze] next planner statistics refresh in 24h0m0s
  meshcore.db.planner-stats-refreshed: 2026-10-09T21:08:34Z
docker restart, then:
  [analyze] next planner statistics refresh in 24h0m0s
  (no ANALYZE and no db-slow-writer line in the following 6 minutes)
```

Tests:

- `TestNextPlannerStatsRefresh`: never refreshed, 1 hour ago, 23h59m
ago, two days ago, a recorded time in the future.
- `TestRefreshPlannerStatsRecordsWhen`: a refresh records about now, and
a reopened store reads it back as a wait of about 24 hours.
- `TestRefreshPlannerStatsDisabledRecordsNothing`.
- The existing #2058 planner-statistics tests pass unchanged.
`cmd/ingestor`: `go test ./...` green.

## Not in this PR

- The time lives in a file, not in the database: the schema has no
metadata table, and a new table would need a migration for one
timestamp. If the file is lost, the only cost is one refresh 2 minutes
after the next start, as today.
- The cold `ANALYZE` itself is unchanged; it now runs once a day instead
of after every restart. Its time of day is wherever the last refresh
landed.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:04:08 +02:00
efitenandClaude Opus 5.5 a2b5a4931e perf(server): battery history via the primary key, cached /api/perf row counts (#2156)
Refs #2146 (items: `/api/nodes/:pubkey/battery`, `/api/perf`).

## Situation

Two endpoints ran SQL that reads more than it needs, on every request.
Timings are read-only on a production database (18.3M observations, 283k
`observer_metrics` rows), first run / second run:

| Query | Now | With this PR |
|---|---|---|
| battery history, `WHERE LOWER(observer_id) = ?` | 174 / 29 ms (plan:
`idx_observer_metrics_timestamp`) | 5.9 / 5.5 ms (plan: primary key
`(observer_id, timestamp)`) |
| `/api/perf`: `SELECT COUNT(*) FROM observations` | 118 / 116 ms on
every call | once per 60 s |

## My changes in this PR

1. **Battery history** (`5bdd3c7f`). `LOWER(observer_id) = ?` keeps
SQLite off the primary-key index, so it walked the timestamp index over
every observer's metrics in the window. The query now matches
`observer_id IN (?, ?)` with the pubkey lowercased and uppercased, the
two casings observer IDs are stored in.
2. **`/api/perf` row counts** (`e24c24cf`). The four `COUNT(*)`
(transmissions, observations, nodes, observers) are cached for 60 s.
File, WAL and freelist sizes are still read on every call.

## Tests

- `TestNodeBatteryQueryUsesObserverIndex` loads the `sqlite_stat1`
values of that database and checks the plan uses
`sqlite_autoindex_observer_metrics_1`. With the `LOWER()` form it fails
with `SEARCH observer_metrics USING INDEX idx_observer_metrics_timestamp
(timestamp>?)`.
- `TestGetNodeBatteryHistory_LowercaseStoredID`: a lowercase-stored
observer ID is still found.
- `TestGetDBSizeStatsTypedCachesRowCounts`: the cached count is served
within the TTL and refreshed after it.
- `cmd/server`: `go test ./...` green.

## Not in this PR

Other per-request queries listed in #2146 measured too small to change
on the same database: `COUNT(*) FROM dropped_packets` 0.0 ms, `SELECT
DISTINCT iata FROM observers` 0.2 ms, the observers packet-count query
1.3 ms, the 7-day active node count 0.1 ms. An observer ID stored in
mixed case would no longer match the battery query; in that database all
41 distinct `observer_metrics.observer_id` values are uppercase.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:03:56 +02:00
efitenandClaude Opus 5.5 be9f382526 perf(server): /paths reads canonical paths in batches and skips redundant collision checks (#2157)
Refs #2146 (item: `/api/nodes/:pubkey/paths`).

## Situation

Per-step timing in a staging build (3 most-connected nodes, 2 calls
each) showed where a `/paths` request spends its time:

| Step | Time |
|---|---|
| build candidates under the read lock | 73-115 ms |
| collision check: one `INSTR(LOWER(resolved_path), ?)` query per index
hit, 13,367-16,790 per call | 1.3-2.7 s (first call after start: 13.5 s)
|
| canonical resolved_path per survivor (`bestResolvedPath`, one or two
queries each) | 1.7-2.9 s |
| aggregation under the read lock | 82-120 ms |

So 3.2-4.1 s of a 3.4-5.1 s request was SQL.

On a production database (busiest node, 27k transmissions, read-only):
the collision check costs 1.85 s one by one and still 1.69 s batched,
because SQLite reads and lowercases every stored path either way.
Reading the canonical path (the longest observation's stored path) costs
0.18 s one by one and 0.09 s batched by id.

## My changes in this PR

- **The collision check runs only where it decides something.** The
aggregation keeps a transmission with a canonical path only if that path
names the node (`if !containsTarget { continue }`). If no stored path
names the node, the canonical one does not either. So for index hits
with a canonical path the check changes nothing, and it now runs only
for index hits without one, as before.
- **Canonical paths are read in batches.** `loadCanonicalResolvedPaths`
reads the stored path of each candidate's longest observation, one query
per 500 transmissions. When that path is missing, empty or unparseable
it calls `bestResolvedPath` for that transmission, so the result is the
same as before.
- The snapshot of observation IDs and `path_json` strings is taken in
the existing candidate read lock (string copies, no parsing).

The PR has two commits: a first version that batched both queries and
did not get faster on staging, and the change described above, whose
message has the measurements. Squash-merging is fine.

## Measurements

Staging, after the ingestor's startup `ANALYZE`, same probe for both
builds (ms):

| | without this PR | with this PR |
|---|---|---|
| node 1 (degree 55), call 1 / call 2 | 22,237 / 3,364 | 1,793 / 1,686 |
| node 2 (degree 47) | 4,427 / 3,615 | 1,828 / 1,809 |
| node 3 (degree 46) | 5,171 / 4,035 | 3,050 / 2,408 |
| 4 concurrent clients over 40 nodes, 120 s: calls / p50 / p95 | 73 /
6,717 / 15,160 | 125 / 2,861 / 10,630 |

Both staging builds also contain #2147 and #2155.

## Tests

- `TestLoadCanonicalResolvedPathsMatchesBestResolvedPath` compares the
batched result with `bestResolvedPath` on seven cases: the #810 case
(longest observation without a stored path), equal lengths, an empty
stored path, case, an observation missing from the snapshot, longest
wins, nothing stored.
- `TestLoadCanonicalResolvedPathsBatchesQueries`: 1,001 transmissions
take 3 batched queries and no per-transmission lookups.
- `TestHandleNodePaths_CanonicalPathDecidesIndexHits`: the longest
observation names A, a shorter one names B; both are index hits. A gets
the transmission, B does not, with no per-transmission query. Run
against master, the A/B results are the same and only the query-count
check fails (1 there, 0 here).
- The existing `/paths` tests pass unchanged (#810, #1278, #1352
collision and fallback cases, anchor bias).
- `cmd/server`: `go test ./...` and `go test -race` green.

## Not in this PR

- `nodeInResolvedPathViaIndex` still calls the per-transmission check;
it has no production caller, only tests.
- The concurrent p95 is still 10.6 s on staging. What is left there is
the 4-connection read pool shared with other endpoints; a staging A/B
with 8 connections gave no gain (`/paths` throughput dropped from
133-146 to 81-103 calls per 120 s), so the pool stays at 4.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:58:33 +02:00
efitenandClaude Opus 5.5 3e21b179ec fix(server): drive the reach scan from the observations time window (#2155)
Refs #2146 (item: `/api/nodes/:pubkey/reach`).

## Situation

The reach scan joins `observations` to `transmissions` and filters on
`o.timestamp >= ?` plus one `o.path_json LIKE '%"XX"%'` per token. With
the planner statistics `ANALYZE` produces on a large database, SQLite
runs it as `SCAN t`: every transmission, with an index probe into
`observations` (`idx_observations_tx_ts`) per row. The cost is then the
size of `transmissions`, whatever node is asked for and whatever the
window.

## My change in this PR

`scanReachRows` uses `CROSS JOIN` for the transmissions join. In SQLite
that fixes the join order: `observations` is the outer loop, read
through `idx_observations_timestamp` for the window, and each row looks
up its transmission by primary key. Results are the same rows; it is
still an inner join.

The query text moves into `reachScanSQL(nTokens)`, so a test can inspect
the plan.

## Measurements

Read-only on a database with 18.3M observations and 1.5M transmissions
(Python `sqlite3` 3.45.1 on the host), 7-day window:

| node (rows returned) | `JOIN`, first run | `JOIN`, warm | `CROSS
JOIN`, both runs |
|---|---|---|---|
| busiest relay (54,387) | 10.29 s | 3.27 s | 1.95 s |
| quartile (3,913) | 3.16 s | 3.15 s | 1.81 s |
| median (1,310) | 3.17 s | 3.18 s | 1.82 s |
| a repeater reported as timing out in the browser (34,209) | 14.11 s |
3.25 s | 1.93 s |

On that instance `/api/nodes/:pubkey/reach` reports p95 188.6 s and max
192.6 s over 34 calls in `/api/perf`; a single uncached call from the
host took 3.4 to 3.6 s, so most of that is waiting for the 2 build slots
and the read pool.

On a staging instance (smaller database), with unique cache keys and
`days` 6 or 7, one cycle per build after the ingestor's startup
`ANALYZE` had finished:

| | without this PR | with this PR |
|---|---|---|
| sequential, 12 calls, p50 | 3402-3801 ms | 2078-2467 ms |
| 2 concurrent clients (= `reachMaxConcurrentBuilds`), p50 / p95 |
3575-4087 / 4049-5291 ms | 2271-2462 / 2814-2928 ms |
| calls completed in 120 s | 58-68 | 96-101 |

The staging builds also contain #2147.

## Verification

- `TestReachScanStartsFromObservationsTimeIndex` creates the two
production indexes, loads the `sqlite_stat1` values from the
18.3M-observation database, and checks that the plan starts with `SEARCH
o USING INDEX idx_observations_timestamp`. With the old `JOIN` it fails
with the production plan (`SCAN t`, then `SEARCH o USING INDEX
idx_observations_tx_ts`).
- The existing reach tests (`TestScanReachRows_DecodesRows`,
`TestScanReachRows_CapTruncates`, attribution tests) pass unchanged.
- `cmd/server`: `go test ./...` green.

## Not in this PR

- The scan still reads every observation in the window and applies the
`LIKE` per row, so its cost grows with the window and with traffic.
Removing the scan needs an index on hop tokens (for example a table the
ingestor maintains). I have not measured what that would cost in
storage.
- The build-slot limit (2) and the 5-minute cache are unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:20:34 +02:00
efitenandClaude Opus 5.5 f50312767f fix(server): no SQL while holding PacketStore.mu (#2147)
Refs #2146 (first item: "Stop issuing SQL while holding `store.mu`").

## Situation

Several store functions ran SQLite queries while holding
`PacketStore.mu`. The server's read pool has 4 connections
(`db.go:153`), so a store-lock holder could wait for a pool connection.
`mu` is a `sync.RWMutex`: once the ingest poller asks for the write
lock, every new reader queues behind it, including `/api/healthz`. A
test that scans the package (below) finds 16 such call sites on `master`
at 9dbc2875.

## My changes in this PR

One commit per part:

1. **Caches serve stale while one refresh runs** (`4d9a818e`).
`getCachedNodesAndPM`, `resolveRegionObservers` and `resolveAreaNodes`
ran their SQL inline on a 30 s TTL miss, also from ingest under the
write lock. Past the TTL they now return the cached value and start one
background refresh per key (`cache_refresh.go`).
- A cache that was never built, or was invalidated with
`InvalidateNodeCache`, still loads inline. A generation counter stops a
refresh that started before an invalidation from marking the cache
fresh.
- The region cache no longer holds `regionObsMu` across its query. Its
refresh re-queries only the regions looked up since the previous
refresh. That keeps it bounded, because the region comes from the query
string.
2. **resolved_path after the lock** (`750dd951`). `QueryPackets`,
`QueryMultiNodePackets`, `GetTransmissionByID`, `GetPacketByHash`,
`GetPacketByID`, `GetObservationsForHash` and `GetNodeHealth` record the
lookups under the lock (`rpJob`, with a snapshot of observation IDs and
path JSON) and run them after releasing it. The number of queries per
row is unchanged.
3. **Single queries before the lock** (`bf85e63e`): the `node=` filter
(`resolveQueryNode`), multi-node pubkey resolution, the can_relay sets
in node health, the observers read for hash-size home regions
(`observerIATAs`), and the stats read in topology.
4. **Bulk health** (`93b7e8ec`): the nodes-table read runs before the
lock. The region filter, which needs the in-memory indexes, is applied
to those rows under the lock.
5. **`/paths` observations race** (`c30e9b43`, `fbf0d7ef`).
`handleNodePaths` read each candidate's canonical resolved_path after
releasing the lock, and that lookup walked `tx.Observations`, which
ingest appends to under the write lock. `-race` reports it in the new
test. The handler now copies the survivors' observation IDs and path
JSON in a short read lock, and parses after releasing it.
`fetchResolvedPathForTxBest` had no caller left and is replaced by
`bestResolvedPath`.
6. **Topology node count** (`4094d215`). Topology used `db.GetStats()`
for one field, the 7-day active node count. `GetStats` also runs
`COUNT(*)` over transmissions and observations. That count is now
`CountActiveNodes`, one query on `nodes`, and `GetStats` uses it too.

## Tests

- `TestNoSQLUnderStoreLock` parses the package and fails on any call
inside a `store.mu` section that reaches SQL, directly or through other
package functions. It allows the three caches above as callees (they
only query when cold) and the startup loaders as holders.
`TestNoSQLUnderStoreLockDetects` runs the scan on a small synthetic
source, so a scan that stops matching cannot pass silently. On master:
16 call sites. On this branch: 0.
- `TestSlowResolvedPathSQLDoesNotStallReaders` reproduces the convoy. A
test hook makes the resolved_path query take 300 ms; `/api/packets` sits
in it, the ingest writer queues, then a reader takes `RLock`. With the
lookup back under the lock (as on master) the reader waited 590 ms. On
this branch it did not wait measurably.
- `TestResolvedPathSQLRunsWithoutStoreLock` checks all seven functions
from part 2. `cache_swr_test.go` covers stale serving, one refresh per
key, inline rebuild after invalidation, and a cold region not blocking a
cached one. `TestNodePathsDoesNotReadObservationsUnlocked` fails under
`-race` with the old read. `TestCountActiveNodes` pins the 7-day count
against `GetStats`.

## Verification

- `cmd/server`: `go test ./...` green, and `go test -race` on the full
package green with 0 races, both on the final commit.
- E2E against a local server on the CI fixture: `test-e2e-playwright.js`
148/151 (3 skipped by the suite itself), plus
`test-issue-1486-collapse-reopens-detail-e2e.js`,
`test-issue-1692-packets-init-parallel-e2e.js` and
`test-channel-issue-1087-e2e.js`, all passing. The other E2E suites were
not run locally.
- Browser: packets, node detail, analytics hash sizes and topology
render. In the API responses, resolved_path is present on 44 of 50
packets, on packet detail, and on health `recentPackets`. No console
errors.

### Staging measurement

A staging instance with 129k transmissions in memory. Load ran on the
same host: 4 threads looping `/paths`,
`/api/packets?limit=200&expand=observations`, node health,
`hash-sizes?region=`, `topology?region=` and `/api/packets?node=`, while
one thread sampled `/api/healthz` every 200 ms. Each build had one
cycle: 360 s during the ingestor's startup `ANALYZE` (heavy disk reads
for 7 to 9 minutes after every restart), then 2 runs of 120 s after it
finished. Values in ms.

| | without this PR | with this PR |
|---|---|---|
| **During ANALYZE** | | |
| `healthz` p95 / p99 / max | 156 / 1105 / 5681 | 42 / 905 / 4280 |
| `topology?region` p99 / max | 2537 / 3061 | 1942 / 4159 |
| `hash-sizes?region` max | 5809 | 2866 |
| node health max | 5719 | 1110 |
| `packets?expand` max | 3912 | 2570 |
| calls over 5 s, excluding `/paths` | 2 | 0 |
| **After ANALYZE** (two runs) | | |
| `healthz` p95 / max | 444-667 / 2139-4356 | 189-335 / 1377-3238 |
| `topology?region` max | 3401-5363 | 1440-1927 |
| `/paths` p50 | 1466-4736 | 1125-2648 |
| calls over 5 s | 3 | 0 |

The two builds were measured about 50 minutes apart, so the background
traffic was not identical.

An earlier version of this branch, without `fbf0d7ef`, raised `healthz`
p95 to 2.7-2.9 s in the same setup, because it parsed every `/paths`
candidate's path JSON under the read lock. Without `4094d215`,
`topology?region` reached 8-17 s during `ANALYZE` in three cycles. The
table is for the final branch.

## Not in this PR

- The query count of `/paths` (1 to 3 per candidate,
`resolved_index.go:160`). It still dominates that endpoint, at p95 8 to
15 s on staging. It is its own item in #2146.
- Batching the resolved_path queries.
- The other per-endpoint items in #2146 (reach, channels, perf,
observers, config/regions, battery, neighbors).
- A cache's first lookup still queries inline, under the lock where the
caller holds it. That happens once per key per process, by design, and
the invariant test allows it explicitly.
- `cmd/ingestor` is unchanged. No measurement on an instance the size of
the ones in #2146.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:58:42 +02:00
efitenandClaude Opus 5.5 9dbc287579 docs: release notes for v3.14.0 (#2143)
Release notes and the CHANGELOG entry for v3.14.0. The release workflow
reads `docs/release-notes/v3.14.0.md` from the tagged commit as the
release body, so this lands before the tag.

23 commits since v3.13.1: 13 fix, 8 feat, 1 ci, and this docs commit.
The headline is optional user accounts (#2128). "Read this before
upgrading" covers the new limits on unauthenticated endpoints, the
`traffic_share_score` drop (#2117), the region filter window (#2114) and
the config file mode (#2126).

After merge: tag this commit as `v3.14.0` once its `:edge` image is
built, then dispatch the CI/CD pipeline on the tag.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
v3.14.0
2026-10-08 23:19:04 +02:00
nullrouten0andClaude Mythos 5.1 57395e2b28 fix(ui): escape observer iata, name and id, and node names, in eight render sinks (#2118)
Anyone who can publish to an MQTT broker that CoreScope reads controls
three strings: the observer `iata` (a topic segment), the observer
`name` (the JSON `origin` field) and the observer `id` (a topic
segment). Anyone with a radio controls the node `name` in an ADVERT.

Eight places wrote these into the page with `innerHTML` without
escaping, so a crafted value runs as script in every visitor's browser:

| file | what |
|---|---|
| `packets.js` | Region badge in the group, child and single packet rows
|
| `observers.js` | Region badge in the observers table and in the
slide-over |
| `live.js` | "Heard By — Regions" header in the node detail |
| `nodes.js` | observer name in the clock-skew evidence panel |
| `analytics.js` | `observer_id` inside `data-observer=` and `id=`
attributes |
| `app.js` | node name in the favorites dropdown |
| `customize-v2.js` | node name in the geofilter prune preview (admin
page) |

**Fix:** each value now goes through the existing `escapeHtml()` /
`esc()` helper. No behaviour change for normal values.

**Tests:** one test per sink added to
`tests/unit/test-xss-escape-sinks.js`, following the file's existing
pattern (pull the template out of the source, render it with a hostile
value, check the output is escaped). Reverting any single fix makes its
test fail. 41/41 pass.

**Notes for reviewers:**
- The ingestor uppercases `iata` before storing it. That is not a
defence: tag and attribute names are case-insensitive, and script can be
written with numeric character references.
- An operator who has set an `observerIATAWhitelist` is protected from
the `iata` sinks but not from the name/id sinks.
- Follow-ups worth a separate PR: validate `iata` and observer `id` to a
safe character set at ingest, cap the `origin` length, and add a
`Content-Security-Policy` header. This PR is escaping only so it is easy
to review and safe to ship.

Running in production on our instance since 2026-10-07.

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

Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
2026-10-08 22:59:25 +02:00
n30nex 29095658e9 fix(nodes): explain packet and observation counts (#2132)
Red commit: `d8a6d42` ([CI
assertion](https://github.com/Kpa-clawbot/CoreScope/actions/runs/37695889461)).
Review-regression red: `f73643b` ([CI
assertion](https://github.com/Kpa-clawbot/CoreScope/actions/runs/37703995494)).

Fixes #2131. Partial fix for #1154: U2 only; other audit items remain
open.

Both node views explain distinct transmissions versus recorded
observations. The shared help is reachable by keyboard and touch, stays
readable at either scroll edge, and remains open when the pointer enters
its text. Escape dismisses help while preserving node context and focus;
a second Escape keeps the existing navigation behavior.

Count expressions and API requests are preserved. One controller per
nodes page handles one active help element and one queued animation
frame. Scroll/resize work is coalesced; listeners are removed on page
teardown. Work is constant relative to packet/node counts, with no
dataset scans or new settings/dependencies.

E2E assertion added: `tests/e2e/test-e2e-playwright.js:114`. Thirteen
cases cover exact counts, accessible description, keyboard/touch, hover
transfer, Escape, and viewport/scroll bounds. Existing metric assertions
are unchanged.

Browser verified: http://localhost:63508 using Chromium, controlled
fixtures and actual API data. Desktop/mobile screenshots retained
locally; additional checks exercised resizing and repeated page cleanup.

Local validation: 189 standalone suites; core browser 148 passed, 3
fixture-dependent skips (existing manual skips unchanged);
location/metric suite 6 passed; syntax, theme variables and lint passed
(0 errors, 92 existing warnings).

## Preflight overrides
- OpenClaw preflight/profile unavailable; repository checks and local
Chromium used.
- Windows ingestor symlink fixture lacks permission. Backend files are
unchanged; full Linux Go CI is required.
2026-10-08 22:37:15 +02:00
efitenandClaude Opus 5.5 2456762179 feat: account data export and users.db backup (#2141)
A small follow-up to parts A to E of #2128: users can download all data
the instance holds about them, and the server backs up `users.db`.

Stacked on #2140. Review the commits after that branch.

## The situation

- An instance with accounts holds personal data, but a user has no way
to get a copy (GDPR articles 15 and 20). Self-delete already exists.
- `users.db` is the only file the server writes, and nothing backs it
up: `GET /api/backup` snapshots the analyzer database only.

## What this PR adds

**Data export.** `GET /api/account/export` and a "Download my data"
button on the account page give one JSON file with:
- the profile, including activation and a pending email change;
- sessions (times and user agent);
- synced settings, own proposals, notification prefs and watches;
- audit rows where the user is actor or target, with other accounts as
an id only;
- the mail log.

Password hash, session and link tokens and the unsubscribe token are
left out: they are credentials. Each export is audited as `user.export`.

**users.db backup**
- Daily `VACUUM INTO` snapshots through `internal/users`, on by default
when user management is on. They go to `backups/` next to `users.db`,
the 7 newest are kept, the directory is 0700 and the files 0600.
Rotation only touches `users-<timestamp>.db` files and never the
snapshot it just wrote. Config: `userManagement.backup {enabled, dir,
keep}`.
- `GET /api/admin/users-backup`: an admin downloads a fresh snapshot to
keep off the server; audited as `user.backup`.
- `docs/user-guide/accounts.md` gains a restore procedure. It covers
what a restore brings back (accounts deleted after the snapshot, old
passwords, revoked sessions) and how to clean that up afterwards.

Spec:
[`docs/specs/2026-10-08-account-export-and-users-backup-design.md`](https://github.com/efiten/CoreScope/blob/feat/account-export-backup/docs/specs/2026-10-08-account-export-and-users-backup-design.md).

## Verification

- `internal/users` with `-race` and the `cmd/server` suite pass locally,
30 new Go tests. The file-mode tests skip on Windows.
- `sh test-all.sh` exits 0; the XSS gate in diff mode passes.
- User-management E2E: 27 of 27 steps locally.
- On our production instance since 8 October 2026; the first snapshot
there was 143 kB.

## Not in this PR

- Importing an export into another instance.
- Off-site upload of the snapshots. The admin download covers that by
hand.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:16:49 +02:00
efitenandClaude Opus 5.5 de364a8bb6 feat: mail notifications for watched nodes (user management part E) (#2140)
Part E of #2128: a logged-in user watches nodes and gets one mail when a
watched node goes offline, comes back, or reports a low battery. Admins
can add instance events: a new foreign node, and an observer going
offline or back. This covers the per-user mail part of #775.

Stacked on #2139 (part D, which builds on #2138). Review the commits
after that branch.

## The situation

A sysop learns that a repeater went silent, or that its battery is
running down, only by opening CoreScope. #775 asks for notifications.
Accounts (part A) now give a verified address to mail and a mailer with
delivery status.

## What this PR adds

**Events.** They use the instance thresholds, so a mail does not
disagree with the node page:
- `node.offline`: silent for the role's `healthThresholds` window. Last
heard comes from the packet store, and repeaters and rooms count relayed
traffic (#1598).
- `node.battery`: advert telemetry below `batteryThresholds.lowMv`,
recovered at `lowMv + 100`.
- Admins only: `foreign.new` (once per node) and `observer.offline`.

**Store.** Schema v5 with `notification_prefs`, `notification_watches`
and `notification_state`.

**Server** (only with `userManagement.notifications.enabled`)
- A notifier next to the janitor, every `intervalMinutes` (5). The
evaluator is a pure function of watches, prefs, stored states and node
snapshots.
- No burst after a restart or a new watch: a subject's first evaluation
stores its state without mailing. The loop waits for the startup load.
While ingest is stale (newest packet older than 30 minutes) the offline
checks pause, and after recovery they wait one silent window.
- One mail per user per check. Limits: 20 per user and 100 per instance
per rolling 24 hours, 50 watches per user. A change over a limit is
recorded and never mailed later.
- Every mail has a one-click unsubscribe (`List-Unsubscribe` and
`List-Unsubscribe-Post`). The GET only redirects to a confirm page, so
link scanners cannot unsubscribe anyone. Node names are cleaned of
control and bidi characters before they go into a mail.
- Routes: `GET`/`PUT /api/account/notifications`, `PUT`/`DELETE
/api/account/notifications/watches/{pubkey}`, `POST
/api/account/notifications/watch-my-nodes` (copies the synced "my nodes"
list), `GET`/`POST /api/notifications/unsubscribe`. `mailer.Message`
gains `Headers`.

**Frontend.** A "Notify me" toggle on the node pages, a Notifications
section on the account page (`#/account?section=notifications`), an
unsubscribe page, and the notification mail count on the admin overview.

Spec:
[`docs/specs/2026-10-07-node-notifications-design.md`](https://github.com/efiten/CoreScope/blob/feat/notifications/docs/specs/2026-10-07-node-notifications-design.md).

## Performance

Each check reads watches, prefs and states from `users.db` (one query
each), the watched nodes from the analyzer DB in chunks of 500, and
last-heard times from the packet store.

| Measurement | Result |
|---|---|
| One check on our staging instance, 130,000 packets in memory, 1
watcher | 6.9 ms, of which 0.93 ms holding the store read lock |
| Evaluator benchmark, 100 users with 50 watches each, 2,000 nodes |
1.75 ms |

No work is added to ingest, broadcast or any request path.

## Verification

- `internal/users` and `internal/mailer` with `-race`; the `cmd/server`
suite plus `-race` on the notifier tests; 74 new Go tests. The same
Windows-only failure as noted in #2138 applies.
- `sh test-all.sh` exits 0; the XSS gate in diff mode passes.
- User-management E2E: 26 of 26 steps locally. With notifications off
the new steps fail, so they do test the flag.
- On our staging and production instance since 7 October 2026.

## Not in this PR

- Other channels (Discord, Telegram, webhooks). Detection is separate
from delivery, so one can be added.
- The topology, RF and anomaly alerts of #775.
- If the whole server starts after a feed outage that already ended, the
grace window is unknown and a watcher can get one wrong offline mail.
The user guide says so.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:02:39 +02:00
efitenandClaude Opus 5.5 abbe6846ff feat: propose and approve hashtag channels (user management part D) (#2139)
Part D of #2128: logged-in users propose a hashtag channel, an admin
approves, rejects or later revokes it, and the ingestor decrypts
approved channels without a restart. It is the first consumer of a
generic proposal table, and answers the request in #2092.

Stacked on #2138 (part C). Review the commits after that branch; GitHub
shows C's commits here until it is merged.

## The situation

- An instance decrypts only the hashtag channels listed in
`hashChannels`. Users who want a city or club channel shown have to ask
the operator, who edits `config.json` and restarts the ingestor.
- #2092 asked for a suggest-and-approve flow; a downstream fork
(dborup/CoreScope#99) runs one with anonymous suggestions and a file
queue.

## What this PR adds

**Name rules (`internal/channel`).** `ValidateHashtagName`, mirrored in
the browser:
- at most 31 UTF-8 bytes including `#` (firmware
`ChannelDetails.name[32]`), case preserved, `Public` refused in any
case;
- control, bidi, separator and format characters refused except ZWJ,
plus invisible fillers (`Other_Default_Ignorable_Code_Point`, U+2800,
non-ASCII spaces).

Go and JS give the same verdict for every code point.

**Store.** Schema v4, one generic `proposals` table (kind, subject,
status pending/approved/rejected/revoked, proposer, reviewer, note). One
row per (kind, subject); limits are checked in the same transaction as
the change.

**Server** (only with `userManagement.channelProposals.enabled`)
- `POST /api/proposals`, `GET /api/account/proposals`, `GET
/api/admin/proposals`, `POST
/api/admin/proposals/{id}/approve|reject|revoke`.
- 400 for an invalid name. 409 for a duplicate, a rejected name, or a
name already in `hashChannels`/`channelKeys` ("this channel is already
decrypted on this instance"). 429 over a limit (`maxPending` 100,
`maxApproved` 128, `perUserPerDay` 5).
- `GET /api/channels` gains `approvedChannels`, so approved channels are
listed before they have traffic. With the feature off the response is
byte-identical.

**Ingestor.** Opens `users.db` with `mode=ro` every 60 seconds,
re-validates the names, and swaps its key map through an
`atomic.Pointer`. Configured channels always win, and a failed read
keeps the last good set. The ingestor never writes `users.db`; AGENTS.md
now says so.

**Frontend.** "Propose for everyone" in the add-channel dialog, an
"approved" marker in the channel list, "My proposals" on the account
page, and an admin "Proposals" tab with a warning on approve. The packet
detail pane now escapes the channel name: until now only the operator
chose those names.

Spec:
[`docs/specs/2026-10-07-channel-proposals-design.md`](https://github.com/efiten/CoreScope/blob/feat/channel-proposals/docs/specs/2026-10-07-channel-proposals-design.md).

## Performance

Every key is tried on each GRP_TXT that no key opens (`decodeGrpTxt`).
Benchmark of that worst case:

| Keys | Time per packet |
|---|---|
| 320 configured | 204 to 216 µs |
| 320 configured + 128 approved | 272 to 320 µs |

That is about +33%, linear in the key count and capped by `maxApproved`.
With the feature off the decoder gets the identical map and no ticker
runs.

## Verification

- `internal/channel`, `internal/users` (with `-race`), `cmd/server` and
`cmd/ingestor` pass locally, 53 new Go tests. The ingestor's
`TestWriteStatsAtomic_SymlinkAtDestIsReplaced` needs symlink rights on
Windows and fails on `master` too.
- `sh test-all.sh` exits 0; the XSS gate in diff mode passes.
- User-management E2E: 21 of 21 steps locally (propose, approve, the
channel appears, revoke removes it).
- On our staging and production instance since 7 October 2026.

## Not in this PR

- Private (PSK) channels: their keys stay in the browser (#725).
- Anonymous proposals.
- Names that need ZWNJ (some Persian and Urdu spellings) and subdivision
flags are refused, because ZWNJ and tag characters are format
characters. Allowing them later only widens the rule, so no approved
name gets stranded.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:51:42 +02:00
efitenandClaude Opus 5.5 18a634c115 feat: admin dashboard with overview and audit log (user management part C) (#2138)
Part C of #2128: an admin area with an overview, the user table and a
global audit log. Off by default like the rest of user management: with
`userManagement` off nothing changes, and non-admins get no new UI.

This is the first of 4 stacked PRs (C, D, E, then a small export/backup
follow-up). Each later one contains this branch; review them in order.

## The situation

- An admin could manage users one by one in `#/admin/users`, but nothing
showed whether the instance needs attention: accounts stuck in
activation, bouncing mail, someone guessing a password, an MQTT source
that dropped.
- The audit log existed (`internal/users/audit.go`) but could only be
read per user, and logins were not recorded.

## What this PR adds

**Store (`internal/users`)**
- Schema v3: an index on `audit_log(at)`.
- `AuditList` with filters (action or group prefix like `user.login.*`,
user as actor or target, period) and keyset pagination; `PruneAudit`;
`Stats` for the user figures.

**Server**
- `GET /api/admin/stats` (typed struct): accounts by status, admins,
registrations and active users over 7 and 30 days, logins and failed
logins in 24 hours, mail by final status, and the attention items
computed server-side.
- `GET /api/admin/audit`: filtered, newest first, `next` cursor.
- `GET /api/admin/users` gains `bouncing=1`.
- Logins are audited as `user.login` and `user.login.failed` (reason
`wrong_password`, `pending` or `disabled`). An unknown address writes no
row. The writes are asynchronous, so the login response does not wait on
`users.db`. Login rows are pruned after 90 days.

**Frontend**
- `#/admin?tab=overview|users|audit`: `admin.js` (tab shell),
`admin-overview.js` ("Needs attention", Users card, System card from the
existing health, MQTT and observer endpoints), `admin-audit.js` (filters
in the URL, "Load more"). `admin-users.js` becomes the Users tab; the
old `#/admin/users` link rewrites to it.
- `/api/healthz` is read on open and on Refresh only, not on the
60-second timer, because it walks every packet under a read lock.

Spec:
[`docs/specs/2026-10-07-admin-dashboard-design.md`](https://github.com/efiten/CoreScope/blob/feat/admin-dashboard/docs/specs/2026-10-07-admin-dashboard-design.md).

## Verification

- `internal/users` and `cmd/server`: `go vet` and `go test` pass
locally, 26 new Go tests. One upstream test,
`TestSaveGeoFilterPreservesFileMode`, also fails on Windows on plain
`master` (file modes) and is unrelated.
- `sh test-all.sh` exits 0; the XSS gate in diff mode passes.
- User-management E2E with the `e2etest` build: 13 of 13 steps locally.
- Running on our staging and production instance since 7 October 2026.

## Not in this PR

- Restricting existing pages (perf, MQTT status) to logged-in users or
admins. The admin area adds no access mechanism of its own, so that
stays a later router rule plus endpoint check.
- Server-enforced customizer tab restrictions (#1508).
- The 24-hour, 5-attempt and 10-minute attention thresholds are
constants for now (AGENTS.md rule 8: customizer later).

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:38:30 +02:00
efitenandClaude Opus 5.5 a4445b4985 ci: run E2E, Go tests and the image build side by side (#2135)
Closes #2134.

## What changes

The test jobs and the image build now run side by side. Only the GHCR
push waits for all of them.

```
changes ─┬─ go-test ─────────────────────────┐
         ├─ race-test (ingestor changes only)  │
         ├─ e2e-shard ×3 ── e2e-test (gate) ───┼─ build-and-publish → deploy, publish
         └─ image-check (two-arch build) ──────┘
```

- **`e2e-shard` (new, matrix of 3).** It needs only `changes`. The suite
list stays in the workflow: each line now reads `suite <shard> <file>
[VAR=value ...]`, with the same environment variables as before.
- Shards are balanced on the measured suite times, about 6 minutes each.
- A shard number outside 1..3 fails the step, so a typo cannot drop a
suite silently.
- `test-issue-1648-m4-icons-e2e.js` stays in the same shard as, and
after, the a11y suite. Its distance-tab check only counts once the lazy
distance index (#1011) has been built, and the a11y suite is what first
requests it.
- **`e2e-test`** is now a gate under the old check name. It runs only if
all three shards passed, checks that three results arrived, and builds
the same `e2e-badges` artifact as before.
- **`image-check` (new).** It runs the two-arch build (no push) and the
arm64 QEMU smoke on every event, beside the tests. It also computes the
build metadata once, which the push reuses.
- **`build-and-publish`** now needs `go-test`, `e2e-test` and
`image-check`. On a PR every step is skipped. On push and tag refs it
only pushes, with the same build args as `image-check`, so every layer
should be a cache hit.
- **The unused `docker compose build` of the staging image is gone.**
`docker compose config --quiet` still validates the file.
- `AXE_SCREENSHOT_DIR` now actually reaches the a11y suite.
- `tests/unit/test-issue-1956-release-routing.js` follows the new jobs,
and checks more than before:
- a failure in `go-test`, `e2e-shard`, `e2e-test` or `image-check` skips
`build-and-publish`;
- the only non-publishing build lives in `image-check` and runs on every
event;
  - the push takes its build args from `image-check`.

## Measurements

The old numbers are from upstream; the new ones are from PR runs on my
fork (a branch on top of this commit, plus one temporary commit that
also triggers the workflow for PRs into a test base branch).

| | Before (PR run 37766553643) | After (fork runs 37812660655 /
37814554422) |
|---|---|---|
| Go Build & Test | 4.9 min | 7.2 / 7.5 min, in parallel |
| E2E | 18.4 min | 3 shards of 7.1–9.0 min, in parallel |
| Image build | 6.9 min, after E2E | 5.9 / 5.4 min, in parallel |
| **Total** | **30.5 min** | **9.6 / 10.6 min** |

**Same tests:** I compared the second fork run with the E2E log of the
upstream run, suite by suite. All 118 suites ran, and each one passed
the same number of checks (1161 in total).

**Cost:** more runner minutes, because each shard repeats about 2.5
minutes of setup. Standard runners are free for a public repository.

## Not verified here

- **A master push.** On a fork the GHCR push cannot run. The expectation
is that `Build and push to GHCR` gets cache hits from `image-check` and
finishes in a minute or two instead of 4–4.5. The first master run after
merge will show whether it does.
- **Branch protection.** If it requires other job names than `🎭
Playwright E2E Tests` and `🏗️ Build & Publish Docker Image`, those may
need adding. I cannot read the protection settings, so I could not
check.

## Tests run locally

- `sh test-all.sh` passes (on Windows with `PYTHONUTF8=1`).
- `test-issue-1956-release-routing.js` passes, and fails as expected
when `go-test` is removed from the `needs` of `build-and-publish`.
- actionlint 1.7.7 reports only the existing self-hosted runner label.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 20:13:09 +02:00
nullrouten0andClaude Mythos 5.1 caa66356bd fix(api): cap concurrent cold reach scans at 2, answer 429 beyond that (#2127)
`GET /api/nodes/{pubkey}/reach` is unauthenticated and a cold-cache
request runs a full scan. `singleflight` only collapses identical keys,
so distinct `(pubkey, days)` requests each start a scan, and they share
the 4-connection SQLite pool with every other handler. A handful of
requests for different nodes can hold every reader and stall the whole
API.

**Fix:** a small semaphore allows two cold scans at once. A cold request
that finds both slots busy gets `429` with `Retry-After: 5` instead of
queueing. Cached answers are unaffected, and the two in-flight scans
still complete normally.

**Tests:** `node_reach_concurrency_test.go` — with both slots held a
cold request returns 429 with `Retry-After`; after release the same
request runs normally. Full server suite passes.

**Note:** our instance runs a fork that already bounds reach work
differently (an async job queue), so this exact change is not what we
run in production. The test suite is the verification here.

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

Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
2026-10-08 16:26:15 +02:00
nullrouten0andClaude Mythos 5.1 0a6870c508 fix(api): cap request bodies on /api/decode and /api/packets/observations (#2119)
`POST /api/decode` and `POST /api/packets/observations` are
unauthenticated and decoded the request body with no size limit.
`json.Decoder` buffers the whole value in memory, and `/api/decode`
hex-decodes the string before the 184-byte payload check runs. On a test
instance one 100 MB request raised RSS by about 180 MB. Many requests in
parallel can push a container past its memory limit, and a restart
reloads the dataset for several minutes.

**Fix:** `http.MaxBytesReader` on both handlers, the same way the
geo-filter PUT and `/api/paths/inspect` already do it.

| endpoint | cap | why |
|---|---|---|
| `/api/decode` | 4 KB | a packet is at most ~512 hex chars |
| `/api/packets/observations` | 64 KB | 200 hashes of 64 chars is ~14 KB
|

Oversized bodies get HTTP 413 instead of a generic 400.

**Tests:** `body_limits_test.go` — normal bodies still return 200,
oversized bodies return 413, for both endpoints. Full `go test ./...` in
`cmd/server` passes.

**Note:** deployments behind a reverse proxy with its own body limit
were already safe. The shipped Caddy config has no body limit, so
default installs were not.

Running in production on our instance since 2026-10-07.

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

Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
2026-10-08 16:26:10 +02:00
nullrouten0andClaude Mythos 5.1 2e7a4ebcfe fix(ingestor): bound the unauthenticated /neighbors report (#2122)
`handleNeighborsReport` trusted whatever an observer published on the
`/neighbors` topic. The sender chose `origin_id` (whose "self" it is)
and could list any pubkey as a responded neighbor; each got its
`configured_scope` written with any scope string, stamped with the
sender's own timestamp. The store is last-write-wins on that timestamp
and `normalizeReportTS` accepted any RFC3339 time, so one report dated
years ahead was written once and then blocked every genuine later report
for that node until someone edited the database. The value is shown on
the reach page as the confirmed scope and feeds `/api/scope-audit`.

**Fix (three guards):**
- pubkeys must be 64 hex chars — anything else cannot match a node
anyway, so it is dropped instead of running UPDATEs that never match
- the normalised scope list is capped at 256 bytes
- a report stamped more than 5 minutes ahead of our clock is dropped, so
a far-future timestamp can no longer lock the node

**Tests:** `neighbors_guard_test.go` covers each guard, including that a
genuine report still lands after a future-stamped one was rejected and
that ordinary clock skew is still accepted. Full ingestor suite passes.

Running in production on our instance since 2026-10-07.

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

Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
2026-10-08 15:58:45 +02:00
nullrouten0andClaude Mythos 5.1 a981420d21 fix(ingestor): cap observer-supplied string lengths (#2123)
Observer `id` and `iata` come from the MQTT topic; `origin` (name),
`model`, `firmware`, `client_version` and `radio` come from the status
JSON. Any publisher controls them and nothing bounded their length, so
one message could store a 64 KB observer id or name. Each new id is also
a new `observers` row, and that table is joined by most packet queries.

**Fix:** a small `clampObserverField` helper strips control characters
and truncates: ids to 128 runes, text fields to 128, IATA to 16. Applied
on the status path, the packet path and in `extractObserverMeta`. Values
are truncated rather than rejected, so a legitimate observer with a long
name still appears.

**Tests:** `observer_fields_test.go` — short values untouched, long
values cut at 128 runes (not bytes, so multi-byte names are not split),
control characters removed, `extractObserverMeta` caps all string
fields. Full ingestor suite passes.

Running in production on our instance since 2026-10-07.

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

Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
2026-10-08 15:58:13 +02:00
nullrouten0andClaude Mythos 5.1 548e4cd4a4 fix(api): cap the nodes= list on /api/packets at 50 entries (#2120)
Each entry in the comma-separated `nodes=` list on `GET /api/packets`
costs one SQLite lookup (`resolveNodePubkey`) while the packet store's
read lock is held. A 1 MB URL fits about 15,000 entries. On a test
instance 12,000 entries took 1.3 s per request, against 0.9 ms for one
entry, and the lock stalls the poller's writes for that long. A few
parallel clients can keep the site busy and the live feed stale.

**Fix:** lists longer than 50 entries get HTTP 400 with a clear message.
No UI page sends more than a handful.

**Tests:** `multi_node_cap_test.go` — 50 entries return 200, 51 return
400. Full `go test ./...` in `cmd/server` passes.

Running in production on our instance since 2026-10-07.

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

Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
2026-10-08 15:58:07 +02:00
nullrouten0andClaude Mythos 5.1 4ce6d9f6d4 fix(ui): pin CDN script versions and add integrity hashes (#2121)
`leaflet.heat` and `chart.js` in `index.html`, and swagger-ui on
`/api/docs`, were loaded from unpkg with no `integrity` attribute.
`chart.js@4` and `swagger-ui-dist@5` also floated on a major version, so
a new release would load unreviewed. A compromised CDN or package would
run as our own code on every page. Leaflet itself already had a hash.

**Fix:** pin `chart.js@4.5.1`, `leaflet.heat@0.2.0`,
`swagger-ui-dist@5.33.1`, each with a sha384 hash and
`crossorigin="anonymous"`.

**How the hashes were made:** download each pinned file, `openssl dgst
-sha384 -binary | base64`, then download again and check the hash
matches.

**Note:** bumping chart.js or swagger-ui now means updating the hash
too. Vendoring them into `public/vendor/` (as markercluster already is)
would remove the CDN dependency entirely; happy to do that instead if
preferred.

Running in production on our instance since 2026-10-07.

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

Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
2026-10-08 15:56:48 +02:00
nullrouten0andClaude Mythos 5.1 8b64439635 fix(server): keep config.json's file mode when saving the geo-filter (#2126)
`SaveGeoFilter` rewrote `config.json` through a temp file created with
mode 0644, so a config an operator had made 0600 (it holds the API key
and broker passwords) became world-readable after the first geo-filter
save.

**Fix:** stat the original and reuse its mode for the temp file. Falls
back to 0644 when the stat fails.

**Tests:** `config_mode_test.go` — a 0600 config stays 0600 after a
save; a 0644 config stays 0644.

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

Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
2026-10-08 15:56:42 +02:00
nullrouten0andClaude Mythos 5.1 523a5edfa1 fix(ui): escape the route string on the unknown-route page (#2124)
`app.js` wrote the URL fragment into `innerHTML` as the heading of the
"Page not yet implemented" view for routes it does not know. Browsers
percent-encode `<`, `>` and `"` in fragments, so this is unlikely to be
exploitable today, but it is a plain `innerHTML` sink on a
URL-controlled string and costs one `escapeHtml()` to close.

**Tests:** one test added to `tests/unit/test-xss-escape-sinks.js` in
the file's existing style.

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

Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
2026-10-08 15:56:30 +02:00
dborupandClaude Opus 5.5 7309bdb5a9 fix(store): index a transmission once per relay key in byPathHop (#2117)
Relates to #2108

## Problem

`traffic_share_score` grows with server uptime until it is far above
reality, and many relays end up clamped at 1.0.

The score is the number of non-advert entries in `byPathHop[pubkey]`
divided by the number of non-advert transmissions.
`indexResolvedPathHops` runs once per **observation**, on the
live-ingest path (`IngestNewFromDB`) and on the late-observation path
(`IngestNewObservations`). `addResolvedPubkeysToPathHopIndex` only
de-duplicates within one call, so every further observation through the
same relay appends the transmission to that relay's bucket again. The
denominator counts each transmission once.

After a restart the values look right, because `buildPathHopIndex` →
`retainResolvedPathHops` de-duplicates by `*StoreTx`. They then drift
upwards as live observations arrive.

None of the affected functions has changed on `master` since the
diagnosis against `415362c`. This branch is based on `000d9ab`.

## Fix

1. **Idempotent insert, per (transmission, relay key).**
`addResolvedPubkeysToPathHopIndex` keeps a side map `pathHopResolved
map[*StoreTx][]string`. It holds the resolved keys each transmission is
already indexed under and skips those.
- Keys are compared **exactly**, so no collision can ever drop an entry.
- A later observation through a **new** relay still adds that relay,
once.
   - `StoreTx` is unchanged.
2. **Interned keys.** `pathHopKeys map[string]string` keeps one shared
copy of each resolved key. The record then holds a 16-byte string header
per entry, and not the string each observation's resolve allocated: a
fresh `json.Unmarshal` string per persisted observation on `Load`, and a
`strings.ToLower` result on the live paths. The `byPathHop` key is the
same shared copy.
3. **Eviction and rebuild.**
- `evictStaleInternal` drops the record of evicted transmissions, plus
any interned key whose bucket it deletes.
- `retainResolvedPathHops` keeps the records of live transmissions only
and drops interned keys whose bucket was not carried over. When a
rebuild starts from an empty index, it clears both.
4. **Defence in depth.** The single (`GetRepeaterUsefulnessScore`),
batch (`GetRepeaterNodeStatsBatch`) and bulk
(`computeRepeaterUsefulnessScoreMap`) scores count **distinct**
non-advert transmissions per bucket, via `countDistinctNonAdvert`.
   - IDs are collected in a reused slice.
- A bucket in ascending ID order is already distinct; only out-of-order
buckets are sorted.
- The bulk pass does this for full-pubkey keys only. Raw-hop buckets get
one entry per transmission from `addTxToPathHopIndex`.
5. **Cache side effect.** A repeated observation through known relays no
longer mutates `byPathHop`, so it no longer drops the batch relay-stats
cache, as the cache contract from `1164` intends. An observation through
a new relay still drops it.

The other `byPathHop` consumers already de-duplicate by `tx.ID` or read
only raw prefix keys: `GetNodeHopAnalytics`,
`computeMultiByteCapability`, `handleNodePaths`,
`computeRepeaterRelayInfoMap` and the relay info in
`GetRepeaterNodeStatsBatch`. Two tests pin that they ignore duplicate
entries.

## Exact keys vs. a 64-bit fingerprint

The first version of this fix stored a 64-bit FNV-1a hash per key. As
requested, I measured both, plus exact keys without interning, on the
real `addResolvedPubkeysToPathHopIndex`.

**Keys per transmission.** In the e2e fixture, the union of resolved
relay keys over all observations of one transmission has a mean of 5.4
and a maximum of 17. The protocol limit is 64 path bytes
(`MAX_PATH_SIZE`), i.e. 64 / hash size hops.

**Memory** retained by the record, per transmission.
`BenchmarkPathHopRecordMemory_2108`: 100K transmissions, keys from 4,000
relays, each transmission heard twice with freshly allocated keys. The
record map and the interned keys are included.

| keys per tx | exact, interned (this PR) | 64-bit fingerprint | exact,
not interned |
|---|---|---|---|
| 2 | 88 B | 68 B | 207 B |
| 5 | 136 B | 101 B | 447 B |
| 17 | 344 B | 197 B | 1,423 B |

- At realistic key counts, exact keys cost 20–35 B per transmission more
than the fingerprint. That is about 3.5 MB per 100K transmissions, and
small next to what the store already charges per transmission
(`storeTxBaseBytes` alone is 384 B).
- Without interning, exact keys would cost 3–4× more, and most of that
would be held during every `Load`. Interning is what makes exact keys
affordable.

**Time.** I ran the variants interleaved, 3 rounds each, median ns/op on
an Apple M4. The record step is not measurable inside the full call. The
isolated record step was faster with exact keys in a separate
micro-benchmark, because comparing a handful of 64-char strings is
cheaper than hashing each one.

| benchmark | exact, interned | fingerprint | exact, not interned |
|---|---|---|---|
| late observation, 20K txs | 312 | 358 | 331 |
| late observation, 100K txs | 760 | 836 | 758 |
| `IngestNewObservations` (SQL + resolve + index) | 531 µs | 497 µs |
527 µs |

**Collision probability of the fingerprint.** For k distinct keys in one
transmission it is about k(k−1)/2 / 2^64:

| k | per transmission | per 10^9 transmissions |
|---|---|---|
| 2 | 5.4e-20 | 5.4e-11 |
| 5 | 5.4e-19 | 5.4e-10 |
| 17 | 7.4e-18 | 7.4e-9 |
| 64 | 1.1e-16 | 1.1e-7 |

For random keys this is negligible. FNV-1a is unkeyed, though, so two
relay keys that collide could be found deliberately (a birthday search
over about 2^32 key pairs). A collision would leave a transmission out
of one relay's bucket.

**Decision.** Exact keys are cheap enough once interned: the same speed
within noise, and a few tens of bytes per transmission. They remove the
question of collision-driven omissions entirely, so this PR uses exact
keys and drops the fingerprint.

The two new maps (`pathHopResolved`, `pathHopKeys`) are not added to
`trackedBytes`, so the `maxMemoryMB` trigger undercounts by roughly the
per-transmission figures above. Both are bounded by live state (eviction
and rebuild prune them), so this is a steady proportional undercount
rather than a leak.

## Tests

**New: `cmd/server/pathhop_dedupe_2108_test.go`.** Behaviour tests that
use only existing API. Each one runs against the real SQLite schema
through `Load`, `IngestNewFromDB` and `IngestNewObservations` where
noted.

| test | covers | on `master` |
|---|---|---|
| `TestPathHopIndexOncePerTx_LateObservations_2108` | 1 + 10 late
observations through the same relays: one entry per relay | **fails**
(11 entries) |
| `TestPathHopIndexOncePerTx_LiveIngestBatch_2108` | 11 observations in
one `IngestNewFromDB` batch | **fails** (11) |
| `TestPathHopIndexOncePerTx_AfterLoad_2108` | `Load` + rebuild, then
one live observation | **fails** (2) |
| `TestPathHopIndexAddsNewRelayFromLaterObservation_2108` | a later
observation through a **new** relay adds it exactly once, and its share
becomes correct | **fails** (2) |
| `TestTrafficShareStableAcrossLateObservations_2108` | single, batch
and bulk scores agree, equal the definition, stay put over rounds of
late observations, and equal a fresh `Load` of the same data | **fails**
(all four relays at 1.0, want 0.4–0.5) |
| `TestPathHopIndexSizeBoundedByTransmissions_2108` | the index size
does not grow with observations | **fails** |
| `TestRelayStatsCacheAcrossRepeatedObservations_2108` | a repeated
observation keeps the relay-stats cache, and the kept cache equals a
fresh compute; a new relay drops it, and the next read sees the relay |
**fails** |
| `TestAddResolvedPubkeysToPathHopIndex_PerRelayIdempotent_2108` | the
helper's return value and cache invalidation per (tx, relay), in any key
order | **fails** |
| `TestTrafficShareCountsDistinctTransmissions_2108` | all three scores
count distinct transmissions in a duplicated bucket | **fails** |
| `TestPathHopConsumersIgnoreDuplicateEntries_2108` | pin of the
consumer audit | passes (by design) |
| `TestMultiByteCapabilityIgnoresDuplicateEntries_2108` | pin of the
consumer audit | passes (by design) |

**New: `cmd/server/pathhop_record_2108_test.go`.** These tests use the
new symbols, so they do not compile against `master`.
- `TestCountDistinctNonAdvert_2108`: ascending, descending, interleaved
duplicates, adverts, nils, untyped.
- `TestPathHopResolvedRecordBoundedAndEvicted_2108`: the record stays at
2 keys after 11 observations. Eviction removes the record and every
entry. A survivor's next observation adds nothing. Evicting everything
leaves no record, no interned key and no bucket.
- `TestPathHopResolvedRecordAcrossRebuild_2108`: a rebuild keeps the
records of live transmissions, so the next observation adds nothing. It
drops the record of a removed transmission and the interned key only
that transmission used.
- `TestPathHopResolvedRecordClearedWithEmptyIndex_2108`: a rebuild from
an empty index clears the record and the interned keys, and the next
observation puts the transmission back.
- `TestPathHopResolvedRecordInternsKeys_2108`: record entries and the
`byPathHop` key share one copy, even though every observation passes
freshly allocated keys.

**Changed: `pathhop_eviction_1908_test.go`.** It built its duplicate
entries by repeating `indexResolvedPathHops`, which no longer
duplicates. It now seeds the duplicates directly, so the `1908` sweep is
still tested against buckets that hold one transmission several times.
The expected buckets are unchanged.

**Changed: `db_test.go`.** `setupTestDB` takes `testing.TB`, so the SQL
benchmark can use it.

**Mutants.** I applied each mutant on its own to this branch. All 14 are
killed:

| mutant | killed by |
|---|---|
| record never consulted | the OncePerTx, stable-share, size, cache and
record tests |
| dedupe per transmission instead of per relay |
`AddsNewRelayFromLaterObservation`,
`RelayStatsCacheAcrossRepeatedObservations`, `PerRelayIdempotent` |
| eviction keeps the record | `RecordBoundedAndEvicted` |
| rebuild keeps records of removed transmissions | `RecordAcrossRebuild`
|
| rebuild from an empty index keeps the record |
`RecordClearedWithEmptyIndex` |
| cache dropped on every call |
`RelayStatsCacheAcrossRepeatedObservations`, `PerRelayIdempotent`,
existing `NoMutation_PreservesCache` |
| cache kept although `byPathHop` changed |
`RelayStatsCacheAcrossRepeatedObservations`, `PerRelayIdempotent`,
existing `InvalidatesRelayStatsCache` |
| single score counts entries |
`TrafficShareCountsDistinctTransmissions` |
| batch score counts entries | `TrafficShareCountsDistinctTransmissions`
|
| bulk score counts entries | `TrafficShareCountsDistinctTransmissions`
|
| distinct count trusts any bucket order |
`TrafficShareCountsDistinctTransmissions`, `CountDistinctNonAdvert` |
| keys not interned | `RecordInternsKeys` |
| eviction keeps interned keys | `RecordBoundedAndEvicted` |
| rebuild keeps interned keys of dropped buckets | `RecordAcrossRebuild`
|

**Commands run:**
- `gofmt -l` on all tracked Go files: clean.
- `go vet ./...` in all 14 modules: clean.
- `cd cmd/server && go test -race ./...`: pass.
- `cd cmd/ingestor && go test ./...`: pass.
- `sh test-all.sh`: 184 of 186 suites pass locally. The other two,
`test-issue-1956-release-routing.js` and `test-preflight-xss-gate.js`,
shell out to scripts that need bash ≥ 4 (`mapfile`). They fail under
macOS's bash 3.2 regardless of this change. This PR touches no frontend
or script files.

## Benchmark: `master` vs. this branch

I ran `master`'s sources (`000d9ab`) and this branch interleaved, 5
rounds, with the same benchmark files. Medians on an Apple M4.

| benchmark | `master` | this PR | change |
|---|---|---|---|
| late observation through known relays, 20K txs | 446 ns, 38 B/op | 441
ns, 0 B/op | within noise |
| late observation through known relays, 100K txs | 710 ns, 69 B/op |
755 ns, 0 B/op | within noise (runs overlap) |
| … index size afterwards, entries/tx (20K / 100K) | 24.99 / 8.99, still
growing | 4.99 / 4.99 | bounded |
| `IngestNewObservations`, one observation for each of 20 txs | 532 µs |
513 µs | within noise |
| bulk score pass, clean index, 20K, ingest order | 246 µs | 316 µs |
+28 % |
| bulk score pass, clean index, 100K, ingest order | 3.03 ms | 4.30 ms |
+42 % |
| bulk score pass, clean index, 20K, reversed buckets | 257 µs | 467 µs
| +82 % |
| bulk score pass, clean index, 100K, reversed buckets | 3.38 ms | 4.79
ms | +42 % |
| bulk score pass after 11 observations per tx, 20K | 663 µs | 284 µs |
−57 % |
| bulk score pass after 11 observations per tx, 100K | 4.72 ms | 3.19 ms
| −32 % |

- On an index that is clean on both builds, the distinct count makes the
bulk pass slower. That pass runs on the cache-miss path, which the
background recomputer refreshes every 5 minutes by default.
- On the index a live server actually holds without this fix, `master`
walks every accumulated duplicate, so the real-world pass is faster
after the fix. The gap grows with uptime.
- The late-observation step no longer allocates. On `master` its index
grows with every observation.

Benchmarks: `BenchmarkLateObservationIndex_2108`,
`BenchmarkIngestNewObservations_2108`,
`BenchmarkTrafficShareScoreMap_2108` and
`BenchmarkPathHopRecordMemory_2108`.

## Production motivation

We have run this fix on two production instances. Before the fix, the
summed `traffic_share_score` grew past 180 and many relays sat at the
1.0 clamp; with the fix no node reaches `≥ 0.999`.

A controlled 12 h A/B run makes the drift explicit. Two servers read one
identical database — one on this `master` base, one with the fix —
alongside a reference server that freshly loads the same database (a
fresh load is correct, because the rebuild dedupes). The unfixed server
drifted to 9 relays at the 1.0 clamp and up to 0.95 absolute error per
node against the reference; the fixed server stayed within 0.05 of the
reference for every node, with no relay at the clamp.

A smaller residual rise remains and is a separate cause (startup-vs-live
hop resolution); it is deliberately left for a follow-up so its effect
stays measurable.

## Out of scope

That remaining slow rise comes from a separate cause: live ingest
resolves some hops that the startup load does not. This PR deliberately
leaves that drift alone, so its effect stays measurable. The fix will
follow in a separate PR.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:34:31 +02:00
efitenandClaude Opus 5.5 f3ae8ac25a feat: sync a logged-in user's settings across devices (part B) (#2130)
Part B of #2128: a logged-in user's settings follow them across devices.
Log in on a phone and your own nodes, favorites, customizer and filters
are there; a change on one device reaches the others within a minute or
when you return to the tab.

**This PR builds on #2129.** Until that one is merged, the diff here
includes it. The commits for this part start at `docs(specs): settings
sync for optional user management (sub-project B)`.

## The situation

Everything a visitor sets up lives in one browser's `localStorage`
(about 100 keys in `public/`). A second device or a cleared cache starts
from zero (#895).

## What this PR adds

**Storage.** `users.db` schema v2: one JSON document per user in
`user_settings`, with a revision number and a generation id. A write
succeeds only when the client's revision and generation match the stored
ones, so two devices cannot overwrite each other silently.

**Server.** `GET`, `PUT` and `DELETE /api/account/settings`, behind the
same session and CSRF checks as the account routes.
- The server owns the list of synced keys (61 keys,
[`settings_allowlist.go`](https://github.com/efiten/CoreScope/blob/feat/settings-sync/cmd/server/settings_allowlist.go))
and sends it to the client, so the two cannot drift.
- A hard denylist, checked first, refuses `meshcore-api-key`, every
`corescope_channel_*` key and `live-channel-colors` (#725). The colour
map is keyed by channel hash, and for a user-added channel that hash is
`user:<name>`, which would expose hashtag channel names.
- Documents are capped at 256 KiB, measured like `JSON.stringify`. PUT
is limited to 60 requests per hour per user. A stale revision gets 409
with the current document.

**Client**
([`settings-sync.js`](https://github.com/efiten/CoreScope/blob/feat/settings-sync/public/settings-sync.js)).
Inert unless the feature is on and someone is logged in.
- It wraps `localStorage.setItem` and `removeItem` for allowlisted keys
only and pushes 2 seconds after the last change.
- It pulls on login, page load, tab focus and every 60 seconds while the
tab is visible.
- **Merge:** three-way, against a per-device baseline that belongs to
one user and one document generation. Lists (own nodes, favorites, saved
filters) merge per item, so an item added anywhere is kept and an item
removed on one device does not come back from another. Single values:
the profile wins unless only this device changed it.
- Remote changes are written without a push, theme and colour-blind
preset are re-applied, and the current page re-renders (skipped on
account pages and while the geofilter editor is open).

**UI.**
- Logout asks: keep my settings on this device (default), remove them
from this device, or cancel. Channel keys are never removed: no copy
exists anywhere else.
- The account page gets a "Settings sync" section: last synced time,
"Sync now", what is and is not synced, and "Delete synced settings from
my account".

## Not synced

Layout and device state (panel and column widths, collapsed panels, map
positions, geofilter drafts), channel data (#725), the API key, and all
`sessionStorage`. The full list is in the
[spec](https://github.com/efiten/CoreScope/blob/feat/settings-sync/docs/specs/2026-10-06-user-settings-sync-design.md).

## Performance

- One GET per page load, tab focus and minute while visible; one
debounced PUT per burst of changes.
- The `setItem` wrapper costs one Set lookup per write for non-synced
keys. A synced write reads one small revision key, not the stored
document.
- The server reads or writes one row per request.

## Verification

- `internal/users` and `cmd/server`: `go vet` and `go test` pass locally
(22 new Go tests), including a test that every allowlisted key still
occurs in `public/`, and denylist tests.
- `tests/unit/test-settings-sync.js`: 79 passing (vm, real module). The
cases cover the merge table, two tabs sharing one storage, stale answers
after a push, delete while a push is in flight, and logout while the
final push fails.
- `sh test-all.sh` exits 0.
- `tests/e2e/test-user-management-e2e.js` (10 steps, 4 of them new)
passed locally with two browser contexts as two devices: a favorite and
the packet time window travel from device 1 to device 2, a removal does
not come back, and "remove from this device" clears the synced keys
while a channel key stays.
- Checked by hand on a staging instance with a desktop and a phone on
one account.

## Not in this PR

- On a shared browser where the previous user chose "keep", the next
user's first login merges those settings into their own account. The
user guide says to choose "remove" on shared computers.
- Saved filter expressions are synced as typed, including any channel
names written in them. The guide says so.
- Realtime push between devices; the minute pull is the sync interval.

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 11:20:44 +02:00
efitenandClaude Opus 5.5 d232072f85 feat: optional user accounts (part A: foundation) (#2129)
Part A of #2128: optional, off-by-default user accounts. With the
feature off nothing changes; with it on, visitors can register and log
in, and admins manage users and use the operator actions without the API
key.

PR #2130 (settings sync) builds on this one. The two are meant to be
merged together.

## The situation

- Operator actions (geofilter save and prune, backup, perf reset) need
the shared `apiKey`. There is no per-person right.
- Nothing in CoreScope knows who a visitor is, so the requests in #2128
that need that (#1835, #2092, #1508, #730) have nothing to build on.

## What this PR adds

**Two new Go modules**
- `internal/users`: a separate `users.db` (SQLite through
`modernc.org/sqlite`) with users, sessions, single-use tokens, an audit
log and a mail log. Passwords use argon2id.
- `internal/mailer`: a `Mailer` interface with a Brevo client (send,
delivery events, webhook parsing) and an in-memory fake for tests.

**Server (`cmd/server`)**, active only with `userManagement.enabled`
- 24 routes, all documented in OpenAPI under the `users` tag
([`auth_routes.go`](https://github.com/efiten/CoreScope/blob/feat/user-management/cmd/server/auth_routes.go)):
  - auth: register, activate, login, logout, me, forgot, reset;
- account: profile, password, email change with confirmation, sessions,
self-delete;
- admin: list, detail, disable, enable, delete, role, resend activation,
manual activation, mail status refresh;
  - a Brevo webhook, registered only when `mail.webhookSecret` is set.
- `requireAdmin` replaces `requireAPIKey` at the 7 operator call sites:
the API key **or** an admin session. With the feature off it is the old
API-key gate (`TestRequireAdminWithoutUserManagementIsAPIKeyGate`).
- `/api/config/client` gets `userManagement: {enabled: true}` only when
the service started; with the feature off the response is
byte-identical.

**Frontend**
- `auth.js` (header account control, request helper that adds the CSRF
header), `account.js` (login, register, activate, forgot, reset, confirm
email, my account), `admin-users.js` (`#/admin/users`, deep-linked
filters), `account.css` (theme tokens only).
- On phones the top-bar control is hidden, so a conditional entry goes
into the bottom-nav "More" sheet and the nav drawer.
- The customizer geofilter tab and the Perf "Reset stats" button use the
admin session when there is one.

**Config.** A `userManagement` block (`config.example.json`,
[`docs/user-guide/accounts.md`](https://github.com/efiten/CoreScope/blob/feat/user-management/docs/user-guide/accounts.md)).
The Brevo key can come from `CORESCOPE_BREVO_API_KEY`. The server
refuses to start when the block is enabled but incomplete.

## Security choices

- Session cookie `cs_session`: HttpOnly, SameSite=Lax, Secure when
`publicBaseUrl` is https. Every cookie-authenticated state change needs
the `X-CS-CSRF` header and a matching Origin.
- Activation needs the token **and** the account password. Without the
password, an attacker who keeps re-registering a known address could get
the owner to activate an account that carries the attacker's password.
- Register, forgot and email change answer identically for known and
unknown addresses. A password reset ends all sessions, a password change
ends all other sessions, and both end outstanding email-change links.
- Rate limits: login 10 per 15 minutes, register and forgot 5 per hour,
per IP and per address. The bucket count is capped. `trustedProxies`
makes the per-IP limits see real client IPs behind a proxy.
- Server logs carry `#<user id>`, never addresses, tokens or passwords;
mail-provider error texts are redacted before logging.

## Performance

No change to an existing hot path with the feature off. With it on:
- One `users.db` lookup per authenticated request (session by token
hash).
- The admin user table rebuilds its `tbody` on each filter change.
`users.List` caps the result at 1000 rows (`internal/users/users.go`),
which bounds the rebuild.
- `map[string]interface{}` in `openapi.go`: 79 before, 78 after.

## Verification

- `internal/users`, `internal/mailer` and `cmd/server`: `go vet` and `go
test -race` pass locally. 121 new Go tests.
- `cmd/server` with `-tags e2etest`: vet and the e2e hook tests pass.
- `sh test-all.sh` exits 0. `tests/unit/test-user-management-ui.js`: 67
passing (vm, real modules).
- `tests/e2e/test-user-management-e2e.js` (6 steps) passed locally
against an `e2etest` build with the fake mailer and against a
feature-off build. CI builds the `e2etest` binary and runs the suite on
a second server (`deploy.yml`).
- On a staging instance with a real Brevo key: register, activation mail
delivered, activate, admin table, "Refresh status" showing sent,
deferred, delivered, opened and clicked.

## Not in this PR

- Settings sync (#2130), the admin dashboard, approval flows and
notifications (parts B to E of #2128).
- A `requireReadAuth` mode (#1835). Sessions from this PR are what such
a mode would accept.
- Binary size and build time with `modernc.org/sqlite` linked next to
`mattn/go-sqlite3` were not measured. Their driver names do not collide.
#1992 discusses the driver choice.
- No Brevo webhook was configured on staging; delivery status there came
from "Refresh status".

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 09:25:29 +02:00
efitenandClaude Opus 5.5 4363403495 fix(nodes): match the region node filter on observer ID, not ingest-time IATA (#2115)
Follow-up to #2114, from its review.

`RegionNodePubkeys` compared the IATA copied onto each observation at
ingest (`StoreObs.ObserverIATA`). When an operator changes an observer's
code, those copies stay as they were until a restart or until the
observations age out. `/api/nodes?region=` then kept matching the old
region, while the other store region filters already used the new one.
Those filters resolve observers by ID through `resolveRegionObservers`,
for example the packets query and `computeNodeHomeRegions`. The SQL
subquery that #2114 replaced also read `observers.iata` at query time,
so this restores that behaviour.

## Also fixes a regression from #2114

Found in review: `loadChunk`, the background history loader
(`cmd/server/store.go`, the chunk SELECT and the `StoreObs` it builds),
sets `ObserverID` on each observation but never `ObserverIATA`. With
#2114 matching on that IATA, **every node whose adverts came only from
background-loaded history dropped out of `/api/nodes?region=`** on
current master. Matching on observer ID fixes it.
`TestRegionNodePubkeysMatchesChunkLoadedObservations` pins it (an
observation with the observer ID and an empty IATA) and fails on
master's `region_nodes.go`.

## Change

- Resolve the region to observer IDs with `resolveRegionObservers` (own
mutex, 30 s cache) and match observations by `ObserverID`. Lock order:
`regionNodesMu` is released before it, and `s.mu` is taken after it;
none of the three is held together.
- Without a database there is nothing to resolve, so `RegionNodePubkeys`
reports no set and the handler keeps the SQL path.

## Tests

- The region tests now seed an observers table and leave each
observation's IATA at a stale value, so they can only pass through the
table.
- New `TestRegionNodePubkeysFollowsObserverIATAChange`: an observer that
moved from SJC to SFO matches SFO and not SJC. It fails on the previous
code (`got [pk_moved]` for SJC).
- `TestRegionNodePubkeysMatchesChunkLoadedObservations` (second commit),
see above.
- `setupTestDB` takes `testing.TB` so the benchmark can use it; every
existing caller passes `*testing.T` unchanged.
- Full `cmd/server` suite passes locally, the region tests also with
`-race`.

## Performance

`BenchmarkRegionNodePubkeys` (220k adverts × 8 observations): 37 ms to
30 ms per uncached scan, a map lookup per observation instead of a
string normalisation. The observer lookup is one query on the small
`observers` table, cached for 30 s.

## Not done

- No singleflight on a cold cache, and the 64-entry cache still resets
when full. Both were non-blocking in the #2114 review.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:42:07 +02:00
efitenandClaude Opus 5.5 d5e6d1b2a6 fix(nodes): resolve the /api/nodes region filter from the packet store (#2114)
Fixes #2101.

`/api/nodes?region=` filtered nodes with a subquery that joins every
advert to all of its observations and observers, with no time bound
(`cmd/server/db.go`, `GetNodes`). It ran twice per request, once for
`COUNT(*)` and once for the page. A client paging through nodes repeats
both per page.

## Reproduced

On our staging database (10.3 GB, 16.5M observations, 219,750 adverts),
read-only `sqlite3`, region `BRU`:

| | real | user | sys |
|---|---|---|---|
| one regional `COUNT(*)`, as `GetNodes` builds it | **154.8 s** | 1.9 s
| 5.8 s |

Almost all of it is waiting on disk. The plan walks
`idx_transmissions_payload_type` for every advert and then
`idx_observations_dedup` for each one's observations. Two of those per
request, a few requests at once, and the reader pool is gone, which
matches the report of all four database workers sitting in `GetNodes`.

## Fix

- **`PacketStore.RegionNodePubkeys(region)`** (new
`cmd/server/region_nodes.go`) walks the store's in-memory adverts
(`byPayloadType[ADVERT]`) once. It keeps the pubkey of every node with
an advert heard by an observer in the region, using the `ObserverIATA`
each observation already carries. It is cached for 30 s per region, and
the cache is bounded to 64 entries because its keys come from the
client's parameter. Lock discipline follows `resolveAreaNodes`: the
cache mutex and `s.mu` are never held together, and the ordering note in
`store.go` lists it.
- **`GetNodes` takes a `NodeQuery` struct** with a `RegionPubkeys`
field, passed as one `json_each` parameter: `public_key IN (SELECT value
FROM json_each(?))`, a primary-key lookup. An empty set matches nothing.
The SQL subquery stays for a server without a store (tests, tooling).
- The advert-pubkey lookup that `trackAdvertPubkey`,
`untrackAdvertPubkey` and `computeNodeHomeRegions` each copied is now
one helper, `advertPubkey`.

The struct instead of a second `GetNodes` variant keeps the
`map[string]interface{}` count unchanged in `db.go` (75) and `routes.go`
(59).

## Behaviour change

The region filter now covers the adverts the store holds
(`packetStore.retentionHours`), which is the window the rest of the UI
shows, instead of all database history. A node heard in a region only
before that window no longer matches the filter. While the store is
still loading after a restart, the set grows as history loads.

## Performance

| | before | after |
|---|---|---|
| region set, uncached | 154.8 s (SQL count, staging) | 37 ms
(`BenchmarkRegionNodePubkeys`: 220k adverts × 8 observations, 4,000
nodes) |
| region set, within 30 s | same again | cache hit |
| node count over the set | (included above) | 2 ms on staging (1,200
keys) |
| 500-row page over the set | same scan again | 3 ms on staging |

The scan holds `s.mu` for reading for those 37 ms, at most once per
region every 30 s.

## Tests

- `TestRegionNodePubkeys`: the in-region advert, an advert heard in two
regions, case and whitespace in codes, a comma list, an unknown region
giving an empty set, a non-advert never counting, a blank region giving
no filter.
- `TestRegionNodePubkeysCached`, `TestRegionNodePubkeysCacheIsBounded`
(1,000 distinct regions stay within 64 entries).
- `TestGetNodesRegionPubkeys`: the set combines with the role filter and
counts correctly, and an empty set returns nothing even with `Region`
set.
- `TestHandleNodesRegionUsesStore`: an advert that only the store knows
about shows up through `/api/nodes?region=`, so the handler is proven
not to ask SQL.
- Existing region tests (`TestGetNodesRegionFilterV2` and the
`db_test.go` region cases) pass unchanged through the SQL fallback. Full
`cmd/server` suite passes locally; the new tests also pass with `-race`.

## Not done

- No request context on these queries, also raised in the issue. With
the scan gone they take milliseconds, so I left that out of this change.
- The region semantics stay "heard by an observer in the region". #1879
argues for the node's home region instead; that is a separate decision.
- No frontend change. I did not check this in a browser; the nodes page
and map call the same endpoint with the same parameters.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:45:02 +02:00
Sylvain Rabot b5b230e884 feat(channels): show the sender's path hash size on each message (#2089)
Each channel message now shows the hash size its sender's path uses, read
from bits 7-6 of the path byte that the originator writes and repeaters keep.
The server sends it as path_hash_size (packetpath.HashSize); the frontend
helper pathHashSize() applies the same rule and returns 0 for unknown.

One rule on both pages: the packet detail Hash Size row and the hex
breakdown call pathHashSize() too, so a 0-hop flood message reports the
same size on Channels and on its packet page. A direct packet with no hops
left reports no size, matching cmd/server/decoder.go. The cases live in
test-fixtures/path-hash-size-cases.json, read by the Go and JS tests.
2026-10-05 20:32:40 +02:00
efitenandClaude Opus 5.5 000d9ab030 feat(coverage): RF noise-floor layer on the Mobile RX coverage page (#2113)
The ingestor already stores the noise floor that CoreDrive RX companions
report with each GPS fix (`client_rf_samples`, opt-in through
`clientRfSamples`, `cmd/ingestor/client_rf_sample.go:62`), but nothing
reads it back. An operator who enables it collects the data and cannot
see it. This adds the read side: `GET /api/rf-noise` and a Signal/Noise
toggle on the Mobile RX coverage page.

## What it does

- **`GET /api/rf-noise?bbox=&z=&days=`** returns a GeoJSON hex grid with
the median, quietest and noisiest noise floor per cell, in the same
shape as `/api/rx-coverage`. It is registered always and 404s unless
`clientRfSamples.enabled` is true, the same pattern as the coverage
routes.
- **Stationary samples are excluded.** A parked companion logs hundreds
of readings at one point, which would otherwise define its cell.
- **Coverage page:** a Signal/Noise toggle, rendered only when
`/api/config/client` reports `clientRfSamples: true`. The noise layer
reuses the coverage colour tokens with the axis inverted, because a
lower dBm is quieter. Tiers are at -115 and -108 dBm.
- **Deep link:** `#/rx-coverage?layer=noise` opens on the noise layer.
- **Empty and failed answers** are labelled on the map ("No RF samples
in this view yet", or a retry hint), so a blank map never reads as
"feature off".

No new configuration key: it reads the existing `clientRfSamples`
section that the ingestor already uses. Default off, so nothing changes
for an instance that has not opted in.

## Where it comes from

Ported from the efiten/CoreScope fork, where it has run on
analyzer.on8ar.eu since September (fork commits `42d09f0e`, `80581c43`,
`cde95078`). The cherry-picks conflicted with upstream's newer
`routes.go`, `types.go` and `rx-coverage.js`, so the final state was
ported by hand. The fork-only `/scopes` route that sat next to it in the
same hunk is deliberately left out.

## Performance

The query is bounded by `sampled_at` (indexed, `idx_crf_prune`) and the
bbox, aggregation is one pass plus a per-cell sort, and the response is
capped at 5000 cells. Measured on analyzer.on8ar.eu, all of Belgium
(`bbox=49.4,2.4,51.6,6.5&z=9`), from a client in Belgium, so network
time included:

| window | samples | cells | response time |
|---|---|---|---|
| 7 days | 10,835 | 241 | 0.37 s |
| 30 days | 43,303 | 429 | 0.39 s |

That table holds 45,270 rows in total. It is only read when someone
opens the noise layer, never on ingest or WebSocket paths.

## Tests

- `cmd/server/rf_noise_test.go`: 8 tests for the aggregation (median and
extremes, stationary exclusion, the cell cap, empty input) and the gate
(404 when off, even with data present). All pass, and the full
`cmd/server` suite passes locally.
- `tests/unit/test-rx-coverage-noise.js` (new, in `test-all.sh`): the
colour axis runs the right way, including both tier bounds and a string
median from the API. Slices the real function out of `rx-coverage.js`
and fails on master's copy, which has no thresholds.
- `tests/e2e/test-rx-coverage-noise-e2e.js` (new, wired in `deploy.yml`
and `scripts/non-unit-tests.json`): no toggle when the flag is off;
Noise fetches `/api/rf-noise`, draws the cells, swaps legend and
subtitle, puts `layer=noise` in the hash; a deep link opens on the noise
layer and an empty answer shows the message. 3/3 locally; against
master's `rx-coverage.js` and `roles.js`, 1/3 (only the "flag off" case
passes, as it should).
- `test-rx-coverage-viewport-e2e.js` still passes. ESLint 8 reports 0
errors on the changed files.

## Not done

- The thresholds (-115 / -108 dBm) are fitted to the fork's own data
(1,241 moving samples at the time) and are constants in
`rx-coverage.js`. Per AGENTS.md rule 8 they belong in the customizer
eventually; not in this PR.
- The E2E suite mocks the API. The real endpoint is covered by the Go
tests and by the measurements above, not by a fixture with seeded
samples.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:11:34 +02:00
efitenandClaude Opus 5.5 9c3d76e14f docs(release): notes and changelog for v3.13.1 (#2112)
Release notes and CHANGELOG entry for v3.13.1, a patch release that
ships #2111 (node-discover replies count as coverage).

Once merged, the tag goes on the merge commit so `deploy.yml` picks up
`docs/release-notes/v3.13.1.md` as the release body.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
v3.13.1
2026-10-04 17:22:56 +02:00
efitenandClaude Opus 5 00ee4f9e23 fix(coverage): attribute node-discover replies to the responder (#2111)
## Problem

A CoreDrive RX companion that runs node-discover gets no coverage on an
upstream instance. Reported today by an operator on v3.13.0: the app
logged 32 discover replies from one repeater in 16 minutes (`heard
751a49f0c5dadc70 (8B, discover)`), all published, and
`client_receptions` stayed at 0 rows for that companion while
`client_rx_observations` held 93.

Two gaps, both on the upstream side:

1. **Ingestor.** `deriveHeardKey` attributes a FLOOD `path[last]` and a
0-hop advert, and drops a 0-hop `CONTROL/DISCOVER_RESP` (firmware
`CTL_TYPE_NODE_DISCOVER_RESP`). The reply carries the responder's own
pubkey at offset 6 of the payload, which `decoder.go` already parses
into `CtrlPubKey` (#1802), but the coverage path never used it.
2. **Server.** CoreDrive RX asks for `DISCOVER_PREFIX_ONLY`, so the
firmware answers with an 8-byte prefix
(`simple_repeater/MyMesh.cpp:799-805`). `coverageHeardKeyCandidates`
only built the 64, 6 and 4 hex candidates, so a 16-hex `heard_key` would
be stored and then matched by no per-node coverage query.

## Change

- `cmd/ingestor/client_reception.go`: a third branch in `deriveHeardKey`
for a discover response with no hops. Accepts exactly 8 or 32 bytes,
nothing truncated, stored with `src='discover'`.
- `cmd/server/rx_coverage.go`: adds the 16-hex prefix to
`coverageHeardKeyCandidates`.
- `docs/client-rx-coverage.md`: documents the `discover` source, the
8-byte keylen and the four-candidate lookup.

The leaderboard and `/api/rx-coverage` read `client_receptions` without
a key filter, so they pick the rows up without a change. Name resolution
goes through `batchResolveHeardKeys`, which is a prefix lookup and
handles 16 hex as is.

## Evidence from a deployment that has had this since 2026-08-19

On analyzer.on8ar.eu, `client_receptions` over the last 7 days by `src`:
discover 4232, rxlog 4909, geo 548, advert 34. Discover replies are 44%
of all coverage rows there (4232 of 9723); on an upstream instance those
rows are not written. They cannot be backfilled afterwards either:
`client_rx_observations` keeps no raw bytes, so the responder pubkey is
gone.

## Tests

- `TestDeriveHeardKey` and `TestBuildClientReception` gain discover
cases: 8-byte and 32-byte keys accepted (32-byte uppercase input
lowercased), a 3-byte and an empty key rejected, a non-discover CONTROL
rejected, a discover response with hops not attributed.
- `TestHandleClientPacketDiscoverRespWritesReception`: end to end, a raw
0-hop DISCOVER_RESP on the client topic writes one `client_receptions`
row with `src='discover'`.
- `TestCoverageHeardKeyCandidatesIncludesDiscoverPrefix`: the 16-hex
prefix is among the per-node candidates.
- Ran locally on Windows: `go test ./...` in `cmd/server` passes; in
`cmd/ingestor` everything passes except
`TestWriteStatsAtomic_SymlinkAtDestIsReplaced`, which needs the symlink
privilege on Windows and fails on clean master too.

Not done: no browser validation, as the change is ingestor and server
only and the frontend reads the same endpoints. Not included: geographic
resolution of 1-byte hops (`src='geo'`) and the RF noise layer, which
are separate changes.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 17:15:50 +02:00
efitenandClaude Opus 5.5 c2a9500060 docs(release): notes and changelog for v3.13.0 (#2110)
Release notes and CHANGELOG entry for v3.13.0, in the shape used for
v3.12.0. Once merged, the tag goes on the merge commit so `deploy.yml`
picks up `docs/release-notes/v3.13.0.md` as the release body (#2076).

- 16 commits since v3.12.0: 10 fix, 4 test, 2 feat, so a minor bump.
- "Read this before upgrading" covers the one-off advert route evidence
backfill from #2088. Durations are measured on two instances (live 24m
1s on 16.8M observations, staging 36m 23s on 16.5M), and ingest during
the backfill window is measured as unaffected on live.
- Issues closed come from each PR's closing references: 14 from 16 PRs.

Docs only. #2089 is deliberately not in this release.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
v3.13.0
2026-10-04 13:23:25 +02:00
efitenandClaude Opus 5.5 ef12535364 test(map): wait for the new route sidebar before measuring it (#2109)
Step (9) of `tests/e2e/test-path-inspector-e2e.js` fails intermittently
on master with `(3-9) fixture route coverage: 0 !== 700`: 3 of the 18
runs that executed it since 2026-09-30, including master runs
[37120155356](https://github.com/Kpa-clawbot/CoreScope/actions/runs/37120155356)
and
[37156362836](https://github.com/Kpa-clawbot/CoreScope/actions/runs/37156362836).
A red master run skips the image build, so `:edge` is not rebuilt.

## Cause

The step clicks a candidate and then waits for any `.mc-rt-sidebar`. The
previous route's sidebar survives the hash-only `goto` to `#/map`, and
`drawPacketRoute` (`public/map.js:1080`) replaces it only after
`/api/resolve-hops` answers. So the wait matched the old sidebar, and
the checks that follow measured either the old sidebar or, if the
replacement landed between the locator resolving and the evaluation, a
detached element, whose `getBoundingClientRect()` width is 0.

This is a test defect. The app keeps the previous route on screen while
the next one loads, which is intended.

## Fix

Remember the sidebar before the click and wait until a different one is
in the DOM. Test-only, one file.

## Evidence

Locally against the fixture, with `/api/resolve-hops` delayed through
`page.route` in a throwaway copy of the test, measuring which sidebar
the step-9 assertion sees:

| Delay | Before | After |
|---|---|---|
| 0 ms | new sidebar, pass | new sidebar, pass |
| 80 to 100 ms | old sidebar, `isConnected: false`, width 0 (3 of 5
runs) | new sidebar, pass (5 of 5) |
| 1500 ms | old sidebar, pass: the step checked the wrong page | new
sidebar, pass |

The unmodified fixed test then passed 3 of 3 runs.

## Not done

- I did not look for the same wait pattern in other suites.
- The delay probe is not committed. It needs a timing window to be
useful, and that window would be a fixed sleep in CI.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:27:02 +02:00
n30nex 9e49db9725 feat(nodes): group adverts by observed route evidence (#2085)
Red commit: `ac60248` ([two intended label
failures](https://github.com/Kpa-clawbot/CoreScope/actions/runs/37137429333)).
Original grouping red: `767da9b`
([CI](https://github.com/Kpa-clawbot/CoreScope/actions/runs/36353796690)).

Fixes #2073.

#2088 is merged. Rebased onto master `415362c`; all eight patches are
unchanged, including the test-first history.

Recent Adverts groups the bounded sample by authoritative `advert_kind`:
Flood, Mixed flood / direct (empty path), Direct (empty path), and Other
/ unknown. Missing evidence stays unknown. Each advert appears once,
preserving reception metadata, order, packet links, counts and
origin/history explanations. Wording describes observed remaining paths
without inferring original send mode or RF distance.

Validation at `6317baf`:
- All 185 standalone suites passed; syntax, XSS and lint passed (zero
errors, 92 existing warnings).
- Browser verified: `http://127.0.0.1:51827` (temporary Go fixture
server, now stopped), desktop pane/full view and mobile. Mixed evidence,
exact links and caveats checked again after pushing.
- The expanded fixture exposes the existing #2104 failure in the
unmodified core harness. Supplemental integration with #2105's exact
test correction passes 135 checks, with three existing skips. Both PRs
remain separate.
- [Fresh green
CI](https://github.com/Kpa-clawbot/CoreScope/actions/runs/37138366726)
passed; three independent automated reviews found no must-fix issues.

E2E assertion added: `tests/e2e/test-e2e-playwright.js:142`.

Grouping is O(n) over the existing 20-advert sample. No new requests,
settings, colors or dependencies.

## Preflight overrides
External OpenClaw runner/profile unavailable; repository checks and
actual local Chromium were used.
2026-10-03 23:49:08 +02:00
n30nex 99a326a6b3 test(packets): verify observation hex after rerenders (#2105)
Red commit: `ca553e4` ([expected assertion
failure](https://github.com/Kpa-clawbot/CoreScope/actions/runs/37137426691)).

Fixes #2104.

The observation hex test now opens the existing grouped fixture
directly, selects A → B → A with stable-ID locators, and asserts the
selected ID and exact raw bytes after each render. Missing fixture data
fails explicitly. Distinct observation frames are seeded after schema
migration; this test's fixed sleeps and silent skips are removed.

Rebased onto master `415362c` after #2090 merged. Both patches are
unchanged. The previous #1122 layout blocker now passes against the
actual current server and assets.

Validation at `5db8017`:
- All 185 standalone frontend suites passed.
- Core browser: 133 passed, three existing skips; #1122 layout 6/6,
filter 11/11, grouped-collapse 4/4.
- Syntax, YAML, fixture SQL, XSS and lint passed (zero errors, 92
existing warnings).
- Fresh red CI reached the intended detached-observation assertion.
[Green
CI](https://github.com/Kpa-clawbot/CoreScope/actions/runs/37138187496)
passed; three independent automated reviews found no must-fix issues.

Only test code and fixture setup change; no configuration/customizer
implications.

## Preflight overrides
External OpenClaw runner/profile unavailable; repository checks and
actual local Chromium were used.
2026-10-03 23:48:32 +02:00
efitenandClaude Opus 5.5 415362c5af fix(packets): resolve path hops with the observer that heard them (#2097) (#2099)
Fixes #2097.

Every 1-byte path prefix on this network is shared. Measured on the live
deployment: 254 prefixes cover all 2043 nodes, nine of them on a single
prefix, and not one node has a prefix to itself. The packets page
printed one of those nine as a certainty.

It could already have said otherwise. `hop-resolver.js` marks such a hop
`ambiguous` and returns its full candidate list; `hop-display.js`
renders a warning badge with a count and a candidate popover;
`renderHop(h, observerId)` reads a per-observer cache key. None of it
fired, because `HopResolver.resolve()` takes six parameters and
`packets.js` passed one.

Without `observerId`, `packetIata` is null, `nodeInRegion()` never runs,
no candidate is flagged `regional`, `globalFallback` stays false, and
`hop-display.js` computes a badge count of 0. The whole chain stayed
silent while the data said it was a guess.

## What this changes

**Resolution carries the observer.** `resolveHops()` takes it and writes
the per-observer cache key that `renderHop()` has always read and
nothing ever wrote. `resolveHopsForPackets()` groups a multi-packet call
by observer so each group is filtered on its own. One site was the bug
in miniature: it wrote its result under `hopNameCache[k + ':' +
pkt.observer_id]` after resolving without that observer, so the key
promised something the value was not.

**The observer's position is the anchor.** `resolve()` has always
accepted `observerLat`/`observerLon` as the anchor at the receiving end:
when nothing later in the path is resolved, the observer is the next
known position, which is what `pickByAffinity` needs.
`observerPosition()` is the single lookup, used by both resolve paths.

This matters more than the IATA route, which turns out to be dead on
this network. `nodeInRegion()` looks the observer's code up in
`/api/iata-coords`; **0 of 42 observers have their code in that table**
(`ANR BRU GNE HEP KJK LGG MST NRW OBL OST` are all missing, while its 54
entries are `AMS`, `APC`, `ATL` and the like). So the 300 km filter has
never excluded anything here. 27 of 42 observers report lat/lon
directly, and that works. Filed separately as #2098, since it is a
network-wide lookup that resolves nothing and hop resolution may not be
its only consumer.

**Each badge belongs to its pill.** The badge is a sibling after the
pill, so a path read `A [8] → B [6] → C` with nothing to say which name
the 8 qualified. Pill and badge now render inside one `white-space:
nowrap` `.hop-group`, leaving the separator outside the pair. Only a hop
that carries a badge is wrapped; wrapping every hop would change the
layout of every path for nothing. That markup predates this work, but
these commits are what made it visible — before them the badge
essentially never appeared on this page.

**The list summarises, the detail pane does not.** Every 1-byte hop
getting its own badge turned a table row into a line of warning
triangles. `renderPath()` takes `{ summary: true }` for the three list
call sites: names with no per-hop badge, and one indicator reading "N of
M hops have more than one candidate". The two detail-pane sites are
unchanged and keep a badge per hop, because that is where the candidates
actually get read. `HopDisplay` gains `opts.badge === false` for it, and
the hop keeps its `hop-ambiguous` class either way so CSS can still mark
it.

## Scope

Deliberately not included: chaining the next resolved hop to narrow a
candidate set to one. `pickByAffinity` already scores on graph edges and
would do it; it needs neighbouring context this call does not yet
supply, and that belongs in its own change to `hop-resolver.js`. What is
here makes the uncertainty visible and picks a defensible candidate
rather than the first in index order.

## Verification

30 unit tests in `tests/unit/test-issue-2097-hop-ambiguity-badge.js`,
registered in `test-all.sh`. Full frontend suite exits 0.

Four of them are structural guards over `packets.js`, and each was
mutation-checked by reverting the change it protects:

- no `HopResolver.resolve()` call passes the hops alone
- no call passes an observer id and drops its position
- one helper does the observer lookup
- the detail pane calls `renderPath` without `summary`

The behavioural test for the anchor runs with `iataCoords` deliberately
empty, which is the live state, and lists the distant candidate first:
without an anchor the resolver keeps candidate order and picks it, with
one it does not, and the hop stays reported as ambiguous with all
candidates listed.

**Browser validated** on a staging deployment of this branch, against
live data:

| | list | detail pane |
|---|---|---|
| per-hop badges | 0 | 24 |
| path indicators | 1 | 0 |
| badges outside a `.hop-group` | — | 0 |

On the packet that prompted the issue, the first hop now resolves to a
repeater 10.6 km from the transmitter instead of one 126 km away, and
the second hop resolves to the only candidate that has a recorded
neighbour edge matching the next hop in the path.

## Note for reviewers

The last commit exists because staging disagreed with the tests. After
the anchor landed, the detail pane still picked the distant candidate:
it resolves through its own `HopResolver.resolve` call, which had the
observer id but a null position. The guard that now fails on exactly
that shape is what would have caught it before the deploy.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:35:40 +02:00
efitenandClaude Opus 5.5 8933e2c703 fix(channels): keep client-only state across a channel-list refresh (#2095) (#2096)
Fixes #2095.

`loadChannels()` replaced the `channels` array with the server snapshot
and carried nothing across, and `mergeUserChannels()` only ever ran from
`init()`. So any refresh destroyed every field that exists only in the
tab.

**No race needed for the worst of it.** Change the region filter or
toggle show-encrypted with a PSK channel open, and:

- the **My Channels** section disappears — `renderChannelList()` derives
it as `channels.filter(c => c.userAdded === true)`, and the server
returns neither `userAdded` nor a `user:`-prefixed hash
- every unread badge resets to 0
- the user's own labels vanish
- `reconcileSelectionAfterChannelRefresh()` cannot find the `user:*`
hash in the snapshot, so it nulls `selectedHash`, sets `messages = []`,
rewrites the URL to `#/channels` and replaces the open conversation with
"Choose a channel from the sidebar"

## What this does

`mergeClientChannelState(fresh, prev)` mirrors
`mergeWsAppendedIntoRest()`, which already does exactly this job for
`messages` (#1498). Same shape: pure, takes both arrays as parameters,
returns a fresh array, never aliases or mutates an input.

It carries `unread`, `userAdded` and `userLabel` by hash, and keeps
`lastActivityMs` / `lastSender` / `lastMessage` / `messageCount` when a
WebSocket batch landed while the request was in flight and is therefore
newer than the snapshot. Those four move together: a sender without its
message reads as a different message.

`mergeUserChannels()` now runs inside `loadChannels()`, before the
render and before the reconcile, so all three call sites get it instead
of `init()` alone.

## What it deliberately does not do

**It does not resurrect a channel the snapshot left out.** A
region-filter change legitimately narrows the list, so carrying
survivors over would defeat the filter, which is a worse bug than the
one being fixed. The helper only enriches rows already present in the
fresh snapshot.

That leaves half of finding 2 in the issue unfixed: a channel pushed by
the WebSocket handler during the initial in-flight window is still
dropped. That one self-heals on the channel's next packet, and the
reverted preview line is overwritten by the next WS batch. Fixing it
properly needs a way to tell "dropped because the snapshot is stale"
from "dropped because the filter excludes it", which is a larger change
than this.

## Tests

`tests/unit/test-issue-2095-channels-client-state.js`, 11 cases,
registered in `test-all.sh`.

Part 1 exercises the helper directly. Part 2 drives the real
`loadChannels()` through the existing `_channelsLoadChannelsForTest`
hook with a stubbed `api()`, which is what proves the helper is wired in
rather than merely defined.

**Verified by mutation.** With the wiring removed from `loadChannels()`
but the helper left in place, the three reproduction cases fail with
exactly the reported symptoms:

```
FAIL  a refresh keeps the My Channels rows
      My Channels lost on refresh (got ["public1"])
FAIL  a refresh keeps unread badges
      the unread badge reset to 0 on refresh   undefined !== 7
FAIL  a refresh does not close an open PSK conversation
      the open PSK channel was deselected      + null  - 'user:MyPSK'
```

The fourth case, "a refresh still drops a channel the server filtered
out", stays green throughout, so the fix cannot be defeating the region
filter.

Full frontend suite green: `sh test-all.sh` exits 0.

## Not done

**No browser validation.** This is frontend JS covered by unit tests
that call the production function through its own hook, but I did not
run Playwright against a server, so I am not claiming a browser check I
did not do.

`init()` still calls `mergeUserChannels()` and `renderChannelList()`
after `loadChannels()` resolves, which is now redundant and costs one
extra full sidebar render per page load. Left alone deliberately: it is
idempotent, and the comment there records the regression it was added to
fix (`test-channel-issue-1111-e2e.js`, case 2). Worth removing
separately with that e2e test watched.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:55:39 +02:00
n30nex 3b415fb007 fix(live): enable node filtering after its handlers are ready (#2103)
Red commit: `8974616` ([CI assertion
failures](https://github.com/Kpa-clawbot/CoreScope/actions/runs/37064461392)).

Fixes #2094.

The Live node-filter input now starts natively disabled and becomes
editable after its input, keyboard and blur handlers attach. Previously
it accepted text while initial node loading was pending, then never
processed that text without another keystroke.

The change adds one HTML attribute and one initialization assignment.
Failed initial loading still reaches the existing error handler and
installs the controls. Saved filters, URL precedence, autocomplete and
keyboard selection keep their existing behavior. No new settings,
styling, dependencies, requests or packet-processing work.

## Validation

- The existing CI-wired Live browser suite delays or aborts the real
initial node request. It verifies rejected premature typing, later
editability, actual suggestions, keyboard selection and URL state. Both
new cases failed on the red commit; all six cases pass locally on
`11fab46`.
- All 183 standalone frontend suites passed, plus lint and XSS gates.
Separate browser checks passed saved-filter restoration and URL
override; desktop/mobile loading/ready screenshots were inspected.
- A broader local core run exposed the unchanged stale
observation-handle test tracked in #2104. It is outside this Live fix.
Full
[CI](https://github.com/Kpa-clawbot/CoreScope/actions/runs/37067337557)
passed at `11fab46`, including Go, browsers and both container
architectures; three independent reviews are clear.

E2E assertion added: `tests/e2e/test-1110-live-filter.js:69`.

Preflight overrides: external script/OpenClaw profile unavailable;
repository checks and local Chromium used.
2026-10-03 12:18:33 +02:00
n30nex b9fb3d244c feat(analytics): split adverts using recorded route evidence (#2088)
Red commits: `dc7dcbe`, `76e3217` ([assertion
failures](https://github.com/Kpa-clawbot/CoreScope/actions/runs/37059977385)).

Fixes #2041; supplies #2085's backend contract.

Records at most two route bits per retained advert. Node flood counts
now use the same evidence, including mixed and transport-flood adverts.
Analytics failures log/count errors while core observation, path and
liveness writes continue. The unrelated recent-advert limit clamp is
removed.

Direct adverts (empty path) describes the observed remaining path across
valid hash widths. Firmware can forward an advert down to an empty path;
this cannot establish original send mode or RF distance. The `zero_hop`
API spelling remains compatible.

Successful post-upgrade evidence writes survive observation replacement
and restart. Older overwritten frames and failed writes can leave gaps.
The UI explains automatic background backfill.

## Measured validation

Synthetic Windows workloads; timings are not production guarantees:

- 300K retained adverts, one-hour window: median 64.35→39.84 ms; 13.98
MB→145 KB allocated. Seven-day allocations unchanged; five paired
samples.
- 1M transmissions/11M observations: backfill 5m43s. Simple 10 Hz
writer: all 3,423 observations persisted; p99 28 ms, maximum 323 ms.
- Simulated pre-backfill cursor: retained evidence ready 4m21s; full
catch-up 6m41s, 122 cache invalidations. Normal completed-migration
startup bulk-loads current evidence.

Portable opt-in scale tests and window benchmarks are included. All 183
frontend suites, full server tests, targeted races, lint and real-API
desktop/mobile checks passed. Full [Linux
CI](https://github.com/Kpa-clawbot/CoreScope/actions/runs/37063210229)
passed at `36def9c`; three independent reviews are clear. Local Windows
testing hit the unchanged symlink-privilege test. No configuration
changes.

Preflight overrides: external script/OpenClaw profile unavailable;
repository checks and Chromium used.
2026-10-03 12:16:55 +02:00
Sylvain Rabot a4a447acea fix(packets): pack short columns, one expand arrow, Full Names toggle (#2090)
The packets table wastes most of its width on short columns, draws two
arrows on grouped rows, and cuts every observer and hop name short. This
fixes all three and adds a way to read the full path when it does not
fit.

## Why the columns were wide

`makeColumnsResizable` measured each column once on load and stored the
widths as **percentages** of the table. Percentages grow with the
screen: on a 2000px display "17s ago" sat in a 180px column. The
measurement also read the virtual-scroll spacer row — a single `colspan`
cell as wide as the table — as column 0, so the expand column came out
160px wide locally and about 950px on a live instance with grouped rows.

Measured on the fixture at 2000px:

| Column | Before | After |
|---|---|---|
| Expand | 162–230px | 32px |
| Time | 111px | 67px |
| Size | 64px | 46px |
| HB | 44px | 29px |
| Scope | 80px | 48px |
| Path / Details | ~180px each | ~650px each |

## The change

**Column packing.** A new `fitColumnsToContent` (`app.js`) sizes every
column except Path and Details to its widest cell, in px; those two
split what is left. Every visible column gets an explicit width and the
table gets their sum: a spacer row keeps a slot for each hidden column,
and leaving any column `auto` hands those slots a share of the slack as
a blank strip at the right edge.

When space is short (detail panel open, narrow window) the widest fixed
columns give way first, then Path and Details shrink to 60px, and only
then does the wrapper scroll. The split itself is a pure function,
`distributeColumnWidths`, with unit tests.

It re-lays out when the container resizes (detail panel), when
TableResponsive hides or reveals columns (a new `table-columns-changed`
event), and after sorting or a column toggle. After each render `grow()`
widens columns for the rows just inserted and never narrows them, so
nothing jitters while scrolling. Drag handles store px; double-clicking
one resets it. Path and Details have no handle.

`makeColumnsResizable`, still used by the nodes, observers and analytics
tables, now skips `colspan` rows when measuring; other rows are measured
as before.

**One expand arrow.** A CSS `::before` "▶" sat next to the SVG caret,
which pointed up when collapsed. The glyph is gone and the caret points
right, then down.

**Full Names.** A toolbar toggle next to Hex Paths shows observer and
hop names untruncated. It is saved per browser; a link can carry
`fullNames=1`, which applies to that page without overwriting the
visitor's saved preference. Paths stay on one line: the virtual scroller
positions rows at one fixed height, so wrapping would make scrolling
jump.

**The "+N" path pill, and a full-path popover.** Hops past the column
edge were meant to go behind a "+N" pill (#1124), but the pill was
appended after the overflowing chips and so sat past the clipped edge:
it never showed. It is now sticky at the right edge, opaque on hover,
and counts hop chips only (it used to count warning icons too). Hovering
or focusing it shows the full path, one hop per line and numbered;
click, Enter or Space pins it until an outside click or Escape, and it
closes when the page changes or its row scrolls away. Clicking the pill
no longer selects the row underneath.

**Two related fixes.**
- Rows drawn before `/api/observers` resolves show raw 64-char pubkeys.
`loadObservers` now re-renders and re-measures, instead of leaving
Observer at the pubkey's width (~600px under Full Names).
- Group child rows indented every cell by 20px, which misaligned them
with the headers and widened every column on expand. Only the Time cell
is indented now.

Anyone who had dragged packet columns gets the new defaults once: the
old percentage key is dropped.

## Performance

`grow()` runs after every `renderVisibleRows`, so it is on the hot path.
Measured in Chromium on the fixture, 300 iterations, three runs:

| Case | Cost |
|---|---|
| Full render, 63 rows measured | +0.88–0.93ms over the forced reflow
alone |
| Scroll step (incremental render) | ~73 `Range` rect reads, only the
inserted rows |
| Large jump / re-render | ~569 rect reads |

Before narrowing `grow()` to inserted rows, one scroll step with 92
rendered rows made 5,472 rect reads. The DOM row count is bounded by the
virtual-scroll window, so the cost does not grow with the packet count.
`distributeColumnWidths` is a binary search over at most ~10 columns.

## Tests

- `tests/e2e/test-packets-compact-columns-e2e.js`, 16 cases: short
columns stay under fixed px bounds at 1920px; Path and Details take the
freed width; hiding both still fills the wrapper; no blank strip and no
horizontal scroll, with and without the detail panel, at 1920, 1100,
1000 and 375px; responsive reveal and re-hide refit; stale percentage
widths dropped; drag follows the pointer and double-click resets;
expanding a group leaves widths alone; Full Names untruncates, keeps
paths on one line, persists and round-trips through the URL; the
Observer column shrinks back when observer names arrive late; the pill
popover lists every hop vertically, closes when the pointer leaves, pins
on click without selecting the row, closes on Escape and on navigation,
and opens on keyboard focus. Registered in `scripts/non-unit-tests.json`
and `deploy.yml`.
- On `master` the suite fails where it should, and restoring each fix's
old behaviour (child-row indent, the late-observer refit) turns its case
red.
- `tests/unit/test-packets.js`: `distributeColumnWidths` (room to spare,
rounding, widest-first capping, floors, dragged columns, the 60px
minimum, overflow), the single chevron and no `::before` glyph, Full
Names on flat and grouped observer cells, and the Full Names CSS.
- `tests/unit/test-xss-escape-sinks.js` now builds the real
`obsCellName` from source: escaping moved into it. Removing its
`escapeHtml` turns both observer-cell tests red.
- `test-issue-1189-composed-cell.js` and
`test-issue-2012-clear-filters-selection.js` evaluate packets.js
fragments with injected helpers and needed `obsCellName` /
`showFullNames` added.
- Existing packets E2E suites pass on this branch and on master against
a CI-prepared fixture: scope column, #1128 (layout and multi-viewport),
#1122, #1188, #1648, table sort, #1692, #1486.
- Unit: 182 of 183 suites pass locally;
`test-issue-1956-release-routing.js` could not create a temp directory
in my sandbox and is unrelated.

## Browser validation

Checked in Chromium against the fixture at 2000, 1920, 1280 (panel open
and closed), 1100 and 375px, and with the seeded grouped row expanded
and collapsed. Screenshots taken at each step; the table fills its
container exactly, grouped rows show one arrow, and the pill popover
lists full names.

## Not done, deliberately

- **No width cap on Observer under Full Names.** A cap brings back the
truncation the toggle removes, and Observer is already the first column
to give way when space is short.
- **Customizer.** The floors (60px flex minimum, 64px shrink floor, 32px
expand, 58px Rpt) are hardcoded, per AGENTS rule 8; exposing them in the
customizer can follow.
- **Wrapping paths.** Rejected because of the fixed row height above;
the pill popover is the way to read a long path.
2026-10-03 12:15:52 +02:00
n30nex 093e320c2b fix(map): keep Path Inspector candidates accessible and cover route replacement (#2082)
## Fix

Fixes #2060. Fixes #2081.

Path Inspector route drawing and candidate replacement now have
deterministic browser coverage. An idempotent SQL seed adds four
synthetic repeaters and four edges after fixture migration, without
changing packet ordering. The test uses the real API, normal clicks,
visible Leaflet geometry, and removal of every previous route object.

Restoring coverage exposed a desktop layout bug: drawing the first route
moved the map over the next candidate button. The route sidebar now
participates in the existing flex layout, including resizing and
collapse, while preserving the mobile bottom sheet.

The historical zero-candidate result did not reproduce on the current
baseline. No search thresholds or API behavior changed. No new
dependencies or customizer settings.

## Validation

- Local assertion-red history: `042db2b` requires seeded candidates;
`ca1d22a` exposes the blocked second click. Subsequent commits repair
layout and narrow-window behavior.
- All 183 standalone suites passed. Unchanged-base Go failures: #2083
readiness race (fixed in #2084) and Windows symlink privilege.
- Real Chromium: 11 Path Inspector/layout checks, 33 related map checks,
and core E2E (131 passed, 3 existing fixture skips).
- CSS-variable checks, XSS diff check, inventory, whitespace and PII
checks passed. Seed remains valid after periodic graph refresh.

Local browser validation used Chromium with a 60-second navigation
budget; the OpenClaw profile and external preflight script were
unavailable. Repository checks ran directly. [CI run
36343343569](https://github.com/Kpa-clawbot/CoreScope/actions/runs/36343343569)
records the initial assertion-red commit. Final CI must pass before
merge.
2026-09-30 11:40:49 +02:00
n30nex ded27a8312 fix(packets): stabilize fixture tests and preserve mobile empty states (#2080)
Fixes #2054 and #2079.

Packet-row tests could outlive the fixture's default 15-minute window.
An audit of 115 wired suites identified four needing explicit fixture
windows: 180 minutes on mobile, 1440 on desktop. Mobile rejects larger
saved windows, so a desktop-only preference was insufficient.
Packet-icon checks now require a real path-bearing packet and both
detail controls.

Default-window coverage exposed #2079: responsive column hiding also hid
the spanning empty-message cell. Skip structural single-cell spanning
rows when hiding individual columns, preserving status messages and
virtual-scroll spacer height. Ordinary and partial-span data cells
retain their behavior; application defaults stay unchanged.

- Red evidence: `bae5e78` fails the aged-fixture row assertion;
`9b75cf6` fails spanning-row visibility. Guards/setup make them pass.
- Validation: 183 standalone suites; 82 targeted browser cases on a
fixture aged over 30 minutes (one pre-existing node-gesture skip).
Broader browser run: 131 passed, 3 fixture-dependent skips.
- Browser verified: fixture-backed Go server and Chromium;
`coverage/2079-mobile-empty.png`. Real mobile scrolling retains spacer
height and has no horizontal overflow.
- E2E assertions: `test-observer-iata-1188-e2e.js` default-window cases
and `test-table-fluid-e2e.js:58` shared-helper regression, both under
`tests/e2e/`.
- Three independent reviews completed; packet-icon coverage feedback
addressed in `760057d`.
- Populated mobile axe coverage remains part of #1995.
- One constant-time guard per already-visited row; no new requests or
collections.

## Preflight overrides

- External `run-all.sh` unavailable; repository checks run directly.
Local runs use UTF-8, the release harness's `GITHUB_REF_NAME`, and a 60s
navigation budget; repository timeouts are unchanged.
2026-09-30 11:40:36 +02:00
n30nex 410c82c02c fix(ui): use consistent 48px navigation and channel buttons (#2078)
Fixes #2052.

Navigation (`.nav-btn`) and channel icon (`.ch-icon-btn`) buttons now
use the shared 48px house minimum. Remove competing 44px
component/coarse-pointer rules and the legacy 32px mobile rule, and
correct comments that confused the house preference with accessibility
requirements.

At 768px, user-added channel rows already clipped Remove; the larger
Share target exposed the same constraint. Allow only those rows'
controls to wrap so both actions remain within the sidebar. Navigation
height and normal network-channel rows are unchanged.

- Red `7787d9b`: rendered target assertions fail at 375/390/768/1280px.
Green `e6ed9c8` changes CSS only.
- Validation: all 183 standalone suites; 33 computed touch-target
checks; 15 actual-page layout/click checks. Broader browser suite: 131
passed, 3 fixture-dependent skips. Three independent reviews found no
required changes.
- Browser verified: local Chromium and fixture-backed Go server;
screenshots `coverage/2052-targets-375.png` and
`coverage/2052-targets-768.png`.
- E2E assertion added: `tests/e2e/test-channel-fluid-e2e.js:112`,
covering dimensions, containment, overflow and normal Filter/Share
clicks. Existing CI selection is retained; Chromium-required runs cannot
silently skip the touch suite.
- Test-only follow-up `5144503` waits for packet readiness before
measuring navbar controls. Five fresh mobile contexts passed; size and
click assertions remain intact.
- No new settings, requests, dependencies or runtime JavaScript.

## Preflight overrides

- External `run-all.sh` unavailable; repository checks run directly.
- Local unit runner uses UTF-8 and `GITHUB_REF_NAME=local-validation`
for the existing release harness. Browser navigation uses a 60s local
budget for slow assets; repository assertions/timeouts are unchanged.
2026-09-30 11:40:22 +02:00
n30nex f1edbbef3f fix(packets): preserve observation selection in detail URLs (#2093)
Red commit: `c165087` ([CI assertion
failure](https://github.com/Kpa-clawbot/CoreScope/actions/runs/36644030012)).

Fixes #2091.

Packet detail links now retain the selected observation when page
initialization or filter changes rebuild the URL. Initialization also
restores `obs` from the complete hash after the router strips its query.
Explicit ID links render the requested observation and retain it in Copy
Link state.

The shared updater reads selection from the current route, so returning
to the list or selecting another packet cannot resurrect an old
observation. Existing filter serialization and Clear Filters behavior
remain intact. No new requests, configuration, dependencies or layout
changes.

## Validation

- Real fixture browser checks use nondefault observation 502: hash/ID
load, type/observer/time-window changes, refresh, Clear, and another
refresh. Both URL and selected row are asserted.
- All 183 standalone frontend suites passed; focused filter browser
suite 11/11.
- Broader local core run reached the unrelated Live input readiness bug
#2094, reproduced on unchanged master. The complete CI browser suite
passed.
- ESLint 8: zero errors; 91 existing warnings. XSS diff, syntax and
whitespace checks passed.
- Three independent reviews found no production defects; assertions
additionally prove filter controls changed before checking selection.

E2E assertion added: `tests/e2e/test-filter-ux-e2e.js:179`.

OpenClaw profile/external preflight were unavailable; Chromium and
repository checks ran directly. Local navigation used a 60-second
budget. Final CI passed at `280ced5`, including Go, browsers, coverage
and both container architectures
([run](https://github.com/Kpa-clawbot/CoreScope/actions/runs/36646175352)).
2026-09-30 11:40:00 +02:00
n30nex dc4db17c48 fix(nodes): remove unsupported region fields from Heard By (#2077)
Fixes #2062.

The node health API does not emit observer region data, but Heard By
rendered a Region column containing only dashes and a Regions summary
that could never appear. Remove that column, its sort control, and the
unsupported region displays from the full node page and side panel.

Keep Observer, Packets, Avg SNR and Avg RSSI, along with existing
escaping, signal placeholders, relay counts and badges. No API,
dependency or configuration changes.

- Red commit `4396492` fails on the unwanted Region header and summary;
`a360711` removes the unsupported fields.
- Validation: 183 standalone suites and 7 focused browser checks passed.
Broader browser run: 131 passed, 3 fixture-dependent skips. Independent
reviews completed; the sorting-test finding was addressed in `1452981`.
- Unit coverage evaluates the real templates. The existing CI-selected
browser suite checks four-column alignment and sorting on desktop/mobile
using the actual API field names.
- Browser verified: local Chromium against a fixture-backed Go server;
screenshots recorded in `coverage/issue-2062-heard-by-1400.png` and
`coverage/issue-2062-heard-by-390.png`.
- E2E assertion added:
`tests/e2e/test-issue-1151-orphan-separators-e2e.js:125`, the two #2062
desktop/mobile cases.
- No added requests, loops or data structures.

## Preflight overrides

- External `run-all.sh` unavailable; repository syntax, whitespace,
CSS-variable, XSS and PII checks run directly.
- Local unit execution supplies UTF-8 settings and
`GITHUB_REF_NAME=local-validation`, required by the existing
release-routing test harness.


- Local browser navigation budget raised to 60s for slow local asset
responses; repository assertions and timeout settings unchanged.
2026-09-30 11:39:51 +02:00
n30nex 31744c6da2 test(channels): isolate WS assertions from initial loading (#2087)
Fixes #2086.

The #1468 WebSocket browser checks could fail when initial channel
loading completed between their separate before/after evaluations. An
orphan message added no channel, yet unrelated loading changed the count
from 0 to 5; the positive control could also lose its sentinel when
loading replaced the list.

Each check now captures before state, processes its packet, and captures
after state in one synchronous browser evaluation. All four original
assertions and both message payloads are unchanged. This updates one
test file only, with no production, dependency or configuration changes.

## Validation

- Delayed real API loading reproduces both failures on unchanged master.
- Both corrected checks pass with loading held and with loading
completed; each uses one evaluation.
- Parent independently ran the delayed-loading harness and full browser
suite: 131 passed, three existing fixture skips.
- All 183 standalone frontend suites passed. Syntax, inventory,
whitespace and privacy checks passed.

TDD justification: test synchronization repair only. Existing assertions
demonstrate the baseline failure; no production behavior or manufactured
failing test was added.

Browser checks used Chromium against unchanged production source.
OpenClaw profile/external preflight were unavailable; repository checks
ran directly, with a 60-second local navigation budget. Final CI must
pass before merge.
2026-09-30 11:39:34 +02:00
n30nex 248d2045fd test(server): await indexes before node-path regression requests (#2084)
Fixes #2083.

Node-path regression tests could request `/paths` while background
indexes were still loading, intermittently receiving HTTP 503 instead of
exercising hop resolution or sorting. Seven fixture loads now await the
existing bounded `WaitIndexesReady` signal. The anchor-bias test uses
the same signal instead of polling.

This changes five test files only. Production readiness behavior and
every HTTP/content assertion are preserved. No dependencies,
configuration or customizer changes.

## Validation

- Unchanged baseline: 94 passed / 6 failed across 100 targeted
executions; failures were actual HTTP 503 assertions.
- Fixed setup: 200/200 targeted executions passed.
- Parent independently ran the entire server suite: exit 0, 83.4
seconds.
- All 183 standalone frontend suites and 20 real Chromium route-map
checks passed.
- Formatting, whitespace and PII checks passed.

TDD justification: test-fixture synchronization repair, with no
production logic change. The existing unchanged behavioral assertions
supplied the before/after failure proof; no fabricated failing test was
added.

Local browser checks used Chromium against the unchanged production
source; OpenClaw profile/external preflight were unavailable. The
unrelated ingestor symlink test requires a Windows privilege absent
locally; Linux CI validates that suite. Final CI must pass before merge.
2026-09-30 11:39:25 +02:00
efitenandClaude Opus 5 9eb3098867 fix(release): publish the release notes as the release body (#2076)
Three releases went out with an empty release body. Checked with `gh
release view --json body`:

| release | body | assets |
|---|---|---|
| v3.9.1 | 1206 bytes | 2 |
| v3.9.2 | 2768 bytes | 0 |
| v3.10.1 | **0** | 2 |
| v3.11.0 | **0** | 2 |
| v3.12.0 | **0** | 2 |

v3.9.1 and v3.9.2 were written by hand, which this repository then
stopped allowing because published releases are immutable. Nothing
replaced them, so the `docs/release-notes/` convention was never wired
to the release page and three releases shipped with a description of
nothing.

Reported by the fork operator, who went looking on the v3.12.0 page for
the list of fixed issues that older releases carried.

## The cause

`action-gh-release` in this workflow was given `files` and
`fail_on_unmatched_files` and nothing else. No `body`, no `body_path`,
no `generate_release_notes`.

## The change

A step resolves `docs/release-notes/${GITHUB_REF_NAME}.md` and passes it
as `body_path`, with `generate_release_notes: true` so GitHub's pull
request list lands underneath the hand-written notes. A missing notes
file is a warning rather than a failure: the release then still gets the
generated list, which is more than an empty body.

## Also in this PR

The v3.12.0 notes file gains the two sections it should have had:

- **Issues closed**, 13 of them. Collected from each pull request's
`closingIssuesReferences`, not from commit text, which is why 30 pull
requests yield 13 issues.
- **Contributors**, separating pull request authors (@efiten 22,
@liquidraver 3, @A13xB0 2, @n30nex 1, @dborup 1, @anieto 1) from commit
co-authors (@nullrouten, @anieto, SaarMesh-Bot, Openclaw) from issue
reporters (@efiten 5, @n30nex 4, @anieto 2, @liquidraver 1,
@damn-simple-scripts 1). It says outright that 22 of the 30 pull
requests are the interim maintainer's own, which is the shape of a
release cut while the owner is unreachable, not a healthy ratio.

v3.12.0's published body has already been set to exactly this content by
hand, so the release page and the file agree.

## Worth recording

`gh release edit <tag> --notes-file <f>` updates a published release
body even though `action-gh-release` cannot. The immutability that
permanently burns a tag name does not extend to the description, so a
thin release body is recoverable. That was not obvious from the existing
comments, which describe releases as immutable without qualifying which
parts.

## Verification, and its limit

`yaml.safe_load` parses the file, but that proves little: it silently
accepts duplicate mapping keys that GitHub rejects outright, which is
how a previous workflow edit passed a local check and produced no runs
at all. The real validator is this PR's own pipeline, and the change
cannot be exercised end to end until the next tag is pushed.

## Not done

No backfill for v3.10.1 or v3.11.0. Neither has a notes file, and
reconstructing them is the same retrospective work deliberately skipped
for the 3.11.0 changelog entry.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-26 18:45:50 +02:00
efitenandClaude Opus 5 6d5686dbb4 docs: release notes for v3.12.0, and the 3.11.0 entry that was never written (#2075)
Prepares the v3.12.0 release. No tag is pushed by this PR: the release
procedure requires the tagged commit to carry an `:edge` image whose
revision label matches it, so that check belongs after this merges,
against the merge commit.

## Why minor, not patch

29 commits since v3.11.0: 15 fix, 5 test, 3 perf, **3 feat**, 2 ci, 1
chore. The feats are #2047, #2067 and #2068.

## What the notes lead with

Two changes alter what a running instance does without anyone asking it
to, so they are at the top rather than in a list:

- **#2058**: the first start against a database that has never been
`ANALYZE`d builds planner statistics and stalls ingest while it does.
Measured at 3m43.9s on 9.4 GB, once per database, buffered with nothing
dropped. `db.analysisLimit` set negative skips it.
- **#2035**: `maxMemoryMB` eviction now fires where it previously did
not, because the footprint compared against the limit was undercounted.
An instance that set the limit and never saw eviction will start seeing
it.

## The 3.11.0 gap

`CHANGELOG.md` had no entry for 3.11.0 and `[Unreleased]` was empty, so
52 shipped commits were undocumented. Added as a short entry that says
outright it was written after the fact and has no notes file. The
alternative of reconstructing 52 commits for a superseded version is
error-prone work with little value, and leaving the gap silent is worse
than naming it.

## One fix beyond documentation

`deploy.yml` carried a comment that would mislead the next person
cutting a release. It still said documentation-only commits skip the
workflow "(see the `paths-ignore` above)", which is exactly how v3.10.0
lost its `:edge` image and then its tag name permanently. That filter no
longer exists: the `changes` job forces `code=true` for anything that is
not a pull request (lines 61-73), so every master commit gets an image
and a documentation commit is safe to tag. The history stays in the
comment; the false present tense does not.

## Verified rather than asserted

Both went into the notes as upgrade advice, so both were checked in the
tree:

- `node_declared_regions` is created at boot with `CREATE TABLE IF NOT
EXISTS` (`cmd/ingestor/db.go:435`), added by #2047, which found the
table was read by `region_keys.go` and `config.go` and created by
nothing. So "no manual migration step" is accurate.
- The cgo dependency attributed to #1992 in the 3.11.0 entry is the one
that stops this repo building with `CGO_ENABLED=0` today.

## Not done

- No tag, no release. Next steps, after this merges: confirm the merge
commit's pipeline is green, confirm `:edge`'s revision label is that
commit, then tag `v3.12.0` annotated, push the tag only, dispatch `CI/CD
Pipeline` on the tag ref, and verify the published digests in the
registry rather than in a green job.
- No `docs/release-notes/v3.11.0.md`, deliberately, as above.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
v3.12.0
2026-09-26 12:03:31 +02:00