web: honor the node opt-out marker on /api/destinations (#943)

This commit is contained in:
Afri authored and GitHub committed 2026-10-05 12:55:59 +02:00
1 parent ce0fdd4391
commit 0eacbf5cfd
4 files changed
+60 -6

No files matched your search

+1 -1
View File
@@ -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)
+4
View File
@@ -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.
@@ -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
+38
View File
@@ -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