mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-13 19:17:29 +00:00
Part 3 of 3 for #2173 (companion linking): the optional linked-only ingest filter, the account page and the "My coverage" toggle, plus the docs. Builds on #2174 and #2175 (both merged); `upstream/master` is merged in, so the diff shows only this part. ## Changes in this PR **Ingestor: linked-only ingest** (`cmd/ingestor/linked_companions.go`) - With `clientRxCoverage.requireLinkedCompanion` on (default off) and user management enabled, every `meshcore/client/<pubkey>/...` message whose pubkey is not in `companion_links` is dropped before any client handler runs, and counted (`client_unlinked_dropped` in the stats file). - The ingestor opens `users.db` read-only (`mode=ro`, raw SQL, no `internal/users` import), like the approved-channels reader, so the #1283 write invariant holds. `TestLinkedCompanionSetIsReadOnly` pins it. - The set refreshes every 60 s. A miss re-reads at most once per 5 s, so a companion linked a moment ago is accepted within seconds while a flood of unknown pubkeys costs one read per 5 s. - It passes everything until its first successful read, and when `users.db` has no `companion_links` table yet (a server older than this). After the first read it keeps the last set on a read error. It is a filter, not a security boundary: anyone with the shared broker credential can still publish under a linked pubkey. **Frontend** - Account page: device tokens show under Devices as "CoreDrive RX" with their label; a new Companions section lists linked companions (name, short key, linked since, last seen) with an Unlink button. - Coverage page (`#/rx-coverage`): a "My coverage" toggle for logged-in users (`mine=1`). Only the newest coverage reply is drawn, and a refused `mine=1` turns the toggle off. Under "My coverage" the page does not ask for the "nothing received" cells from #2172, and their legend entry is hidden. **Docs**: user guide (accounts: devices, companions), `docs/client-rx-coverage.md` (linking, linked-only ingest, the CORS exception), `config.example.json`, and the spec status. ## Performance The filter runs on every client message. Benchmarks with 1000 linked companions (12-core dev box, `go test -bench`, 3 runs each): | Path | Time | Allocations | |---|---|---| | linked pubkey (lock-free map lookup) | 54 ns/op | 0 | | linked pubkey, parallel | 11-14 ns/op | 0 | | unlinked pubkey inside the 5 s miss gap | 85-95 ns/op | 0 | | one `users.db` re-read | 0.38 ms | 3030 | The unlinked benchmark asserts that no extra `users.db` read happens. A re-read happens at most once per 5 s on a miss and once a minute from the refresh loop. ## Verification - `cmd/ingestor`: 15 new tests (filter off changes nothing, linked passes, unlinked dropped before every handler, miss refresh accepts a new link, the 5 s cap holds under a flood, unlink honoured after the periodic refresh, missing table and missing file, fail-closed only after the first read, read-only open, startup, stats counter) and 4 benchmarks. Full ingestor suite green. - Frontend unit tests for the account page (device tokens, companions list escaping, empty state, Unlink confirm and cancel, load errors) and `tests/unit/test-rx-coverage-mine.js`. - E2E `tests/e2e/test-user-management-e2e.js` gains a companion step (device token under Devices, a companion linked with a real Ed25519 signature under Companions, Unlink). Run locally against the e2etest build: 28/28. The coverage suites (`test-rx-coverage-gaps-e2e.js`, `-noise-`, `-viewport-`) still pass. - Frontend unit suite (`test-all.sh`) green. ## Not in this PR - Narrowing the "nothing received" cells to the caller's companions under "My coverage" (the track query takes one companion today). - Per-user broker credentials or ACLs, which would turn the filter into an access control (non-goal in the spec). --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
69 lines
4.0 KiB
JavaScript
69 lines
4.0 KiB
JavaScript
'use strict';
|
|
const REPO_ROOT = require('path').resolve(__dirname, '..', '..');
|
|
// "My coverage" toggle (docs/specs/2026-10-08-companion-linking-design.md,
|
|
// Coverage attribution): shown to logged-in users only, it adds mine=1 to the
|
|
// signal-layer request. The real public/rx-coverage.js is loaded in a vm, with
|
|
// a window that has no addEventListener, like test-rx-coverage-config-race.js.
|
|
const assert = require('assert');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const vm = require('vm');
|
|
|
|
const code = fs.readFileSync(path.join(REPO_ROOT, 'public', 'rx-coverage.js'), 'utf8');
|
|
const sandbox = {
|
|
window: {},
|
|
document: { getElementById: function () { return null; } },
|
|
registerPage: function () {},
|
|
console: { warn: function () {} },
|
|
Promise: Promise,
|
|
setTimeout: function () {}, clearTimeout: function () {},
|
|
fetch: function () { throw new Error('no fetch expected'); },
|
|
L: {}, getComputedStyle: function () { return { getPropertyValue: function () { return ''; } }; },
|
|
};
|
|
vm.createContext(sandbox);
|
|
vm.runInContext(code, sandbox);
|
|
const t = sandbox.window.CSRxCoverage && sandbox.window.CSRxCoverage._test;
|
|
assert.ok(t, 'rx-coverage.js should expose window.CSRxCoverage._test');
|
|
|
|
// The request URL: mine=1 only with the toggle on, after the rx filter.
|
|
assert.strictEqual(t.coverageUrl('1,2,3,4', 9, 7, '', false), '/api/rx-coverage?bbox=1,2,3,4&z=9&days=7');
|
|
assert.strictEqual(t.coverageUrl('1,2,3,4', 9, 7, '', true), '/api/rx-coverage?bbox=1,2,3,4&z=9&days=7&mine=1');
|
|
assert.strictEqual(t.coverageUrl('1,2,3,4', 9, 30, 'ab cd', true), '/api/rx-coverage?bbox=1,2,3,4&z=9&days=30&rx=ab%20cd&mine=1');
|
|
|
|
// The toggle: nothing for a logged-out visitor, a pressed/unpressed button otherwise.
|
|
assert.strictEqual(t.mineBtnHtml(null, false), '', 'no toggle for a logged-out visitor');
|
|
const off = t.mineBtnHtml({ id: 1 }, false);
|
|
assert.ok(/data-mine="1"/.test(off) && /aria-pressed="false"/.test(off) && />My coverage</.test(off) && !/class="active"/.test(off), off);
|
|
const on = t.mineBtnHtml({ id: 1 }, true);
|
|
assert.ok(/aria-pressed="true"/.test(on) && /class="active"/.test(on), on);
|
|
|
|
// Only a logged-in user of an instance with accounts enabled counts.
|
|
const u = { id: 1 };
|
|
assert.strictEqual(t.authUser(undefined), null);
|
|
assert.strictEqual(t.authUser({ isEnabled: function () { return false; }, user: function () { return u; } }), null);
|
|
assert.strictEqual(t.authUser({ isEnabled: function () { return true; }, user: function () { return null; } }), null);
|
|
assert.strictEqual(t.authUser({ isEnabled: function () { return true; }, user: function () { return u; } }), u);
|
|
|
|
// Wiring: the signal layer builds its URL with the toggle, and the bar starts hidden.
|
|
assert.ok(code.indexOf('var mineSent = mine && !!authUser(window.CSAuth);') !== -1 &&
|
|
code.indexOf('coverageUrl(bbox, map.getZoom(), days, selectedRx, mineSent)') !== -1,
|
|
'drawSignalLayer must build its URL with coverageUrl and the toggle');
|
|
assert.ok(/id="rxMineBar"[^>]*hidden/.test(code), 'the toggle bar must start hidden');
|
|
|
|
// A reply only counts when it answers the newest signal-layer request: an
|
|
// older, unfiltered reply must not overwrite "My coverage" (or the reverse).
|
|
assert.strictEqual(t.coverageReply(1, 2, 200, false), 'ignore');
|
|
assert.strictEqual(t.coverageReply(2, 2, 200, true), 'draw');
|
|
// mine=1 refused (session gone, or a proxy's bearer header): leave the
|
|
// toggle instead of showing an empty map that reads as "heard nothing".
|
|
assert.strictEqual(t.coverageReply(2, 2, 401, true), 'mine-refused');
|
|
assert.strictEqual(t.coverageReply(2, 2, 403, true), 'mine-refused');
|
|
assert.strictEqual(t.coverageReply(1, 2, 401, true), 'ignore');
|
|
// Any other failure is an error, never parsed as a FeatureCollection.
|
|
assert.strictEqual(t.coverageReply(2, 2, 401, false), 'error');
|
|
assert.strictEqual(t.coverageReply(2, 2, 500, true), 'error');
|
|
assert.ok(/coverageReply\(seq, signalSeq, r\.status, mineSent\)/.test(code),
|
|
'drawSignalLayer must judge each reply with coverageReply');
|
|
|
|
console.log('rx-coverage My coverage OK');
|