From 0eacbf5cfd49d63be1e9efbd4cb629e951f9960e Mon Sep 17 00:00:00 2001 From: Afri <220195275+l5yth@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:55:59 +0200 Subject: [PATCH] web: honor the node opt-out marker on /api/destinations (#943) --- CHANGELOG.md | 2 +- data/mesh_ingestor/CONTRACTS.md | 4 ++ .../application/queries/node_queries.rb | 22 ++++++++--- web/spec/reticulum_spec.rb | 38 +++++++++++++++++++ 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aeadd0d6..d66271ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ * Docs: document the Reticulum config directory, its Docker volume, and how to add interfaces to it * Data: `ALLOWED_CHANNELS`, `HIDDEN_CHANNELS`, and `MAP_ZOOM` no longer break on the packaged Compose default's quoted-empty value (ACCEPTANCE CH-A1-CH-A3) * Data/Web: one Reticulum node row per announced destination, grouped by `nodes.identity_hash` (SPEC RE1) -* Data/Web: `user.publicKey` carries the identity's real public key; destinations get their own table, served by `GET /api/destinations` (SPEC RE2) +* Data/Web: `user.publicKey` carries the identity's real public key; destinations get their own table, served by `GET /api/destinations`, which honors the node opt-out marker (SPEC RE2) * Data: new `RETICULUM_INTERFACES` allowlist scopes announce ingestion by interface, applied from one hop out; the operator's own nodes are always ingested (SPEC RN4/RE4) * Web: the `/charts` mesh-activity figure draws the Reticulum series (SPEC RN2) * Web: `instances.reticulum_nodes_count` gets its own migration (SPEC RN7) diff --git a/data/mesh_ingestor/CONTRACTS.md b/data/mesh_ingestor/CONTRACTS.md index 0a4f5775..4c2765fe 100644 --- a/data/mesh_ingestor/CONTRACTS.md +++ b/data/mesh_ingestor/CONTRACTS.md @@ -455,6 +455,10 @@ One row per announced destination, newest `last_heard` first. - No retention floor. A destination is a relationship, not an event, and the nodes it belongs to already clamp their own window - a floor here would hide the addresses of a node the table is still showing. +- Privacy. Honors the node opt-out marker: rows whose `node_id` names an + opted-out node are omitted, so `?node_id=` for that node returns `[]`. The + opt-out is node-level: a marker in a non-headline destination's own name hides + nothing, so the operator puts the marker in the node's headline name. - This route holds no response cache, so `since`/`before` have no cached path to bypass; the weak ETag varies with the cursor because it is hashed from the body the cursor produced. diff --git a/web/lib/potato_mesh/application/queries/node_queries.rb b/web/lib/potato_mesh/application/queries/node_queries.rb index 6fb69114..45f010fd 100644 --- a/web/lib/potato_mesh/application/queries/node_queries.rb +++ b/web/lib/potato_mesh/application/queries/node_queries.rb @@ -316,13 +316,18 @@ module PotatoMesh # bind parameters. STATS_WINDOWS = %w[hour day week month].freeze - # Per-protocol scopes counted alongside the unfiltered +total+, paired with - # the short column-alias prefix used in the generated SQL. # Fetch the destinations a node has announced on (SPEC RE-A6). # - # A Reticulum identity announces on several destinations -- one per aspect - # -- and each is its own +nodes+ row, so this table is what lets a reader - # group those rows back into one peer via +identity_hash+. + # A Reticulum identity is one +nodes+ row (SPEC RE7) that announces on + # several destinations -- one per aspect -- so each destination is a row + # here, linked to that node by +node_id+ and to its identity by + # +identity_hash+. + # + # Honors the node opt-out marker (Invariant II): rows whose +node_id+ + # names an opted-out node are never returned, so +node_id:+ set to such a + # node yields an empty list. The opt-out is node-level, decided on the + # +nodes+ row: a marker only in a non-headline destination's own name + # hides nothing. # # @param limit [Integer] maximum rows to return. # @param node_id [String, nil] restrict to one node's destinations. @@ -351,6 +356,11 @@ module PotatoMesh params << threshold end append_before_filter(clauses, params, before, column: "last_heard") + # Node-level opt-out (Invariant II): the +node_id+ fragment the positions, + # telemetry and waypoints reads already apply. Filtering in SQL keeps + # +LIMIT+ counting visible rows only, so a short page still means the + # window is exhausted for a backward pager (SPEC RA8). + append_opt_out_filter(clauses, params, opt_out_node_id_filter("node_id")) sql = +"SELECT id, node_id, identity_hash, name, aspect, role, interface, " \ "first_heard, last_heard, protocol FROM destinations" sql << " WHERE #{clauses.join(" AND ")}" if clauses.any? @@ -361,6 +371,8 @@ module PotatoMesh handle&.close unless db end + # Per-protocol scopes counted alongside the unfiltered +total+, paired with + # the short column-alias prefix used in the generated SQL. STATS_PROTOCOL_SCOPES = [["meshcore", "mc"], ["meshtastic", "mt"], ["reticulum", "rt"]].freeze # Return exact activity counts for /api/stats as a scope → metric → window diff --git a/web/spec/reticulum_spec.rb b/web/spec/reticulum_spec.rb index 275a4556..e2d02a7b 100644 --- a/web/spec/reticulum_spec.rb +++ b/web/spec/reticulum_spec.rb @@ -340,6 +340,44 @@ RSpec.describe "Reticulum protocol support" do "aspect" => "lxmf.delivery", ) end + + it "omits the destinations of an opted-out node (Invariant II)" do + register_reticulum_ingestor + post_reticulum_nodes + marker = PotatoMesh::Config.node_opt_out_marker + opted_identity = "0badcafe#{"00" * 12}" + opted = reticulum_node_fixture(last_heard: now - 20, dest_id: RETICULUM_DEST_HASH2) + opted["user"]["longName"] = "Quiet #{marker} Station" + opted["identityHash"] = opted_identity + payload = { + RETICULUM_NODE_ID2 => opted, + "ingestor" => RETICULUM_INGESTOR_ID, + "protocol" => "reticulum", + } + post "/api/nodes", payload.to_json, auth_headers + expect(last_response.status).to eq(201) + + # Preconditions: ingest is not refused, so the destination row is stored, + # and the node row itself is already hidden (A2c). Only the destinations + # read path is left to leak it. + with_db(readonly: true) do |db| + stored = db.execute("SELECT id FROM destinations WHERE node_id = ?", [RETICULUM_NODE_ID2]) + expect(stored.map { |r| r["id"] }).to eq([RETICULUM_DEST_HASH2]) + end + get "/api/nodes" + expect(JSON.parse(last_response.body).map { |n| n["node_id"] }).not_to include(RETICULUM_NODE_ID2) + + get "/api/destinations" + expect(last_response.status).to eq(200) + rows = JSON.parse(last_response.body) + expect(rows.map { |r| r["id"] }).to eq([RETICULUM_DEST_HASH]) + expect(rows.map { |r| r["identity_hash"] }).not_to include(opted_identity) + expect(rows.map { |r| r["name"] }).not_to include("Quiet #{marker} Station") + + get "/api/destinations?node_id=#{RETICULUM_NODE_ID2}" + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body)).to eq([]) + end end describe "GET /api/destinations?node_id=" do