web,data: stop Reticulum destination placeholders from renaming (#968)

This commit is contained in:
Afri authored and GitHub committed 2026-10-06 13:06:43 +02:00
1 parent 7536dd5f50
commit 27eac3d4e5
7 files changed
+331 -43

No files matched your search

+1
View File
@@ -63,6 +63,7 @@
* CI: the Python workflow tests Python 3.13 and 3.14; neither CI matrix fails fast (SPEC DP5)
* Web: a MeshCore channel message heard by two ingestors that resolved its sender to different node ids is stored once (#880)
* Web: the first boot after upgrading deletes duplicate MeshCore messages once; on instances with several MeshCore ingestors this can remove about a fifth of the stored MeshCore messages (#880)
* Data/Web: a Reticulum destination's placeholder name never replaces its real name or names its node; a node with no announced name reads its own placeholder; a nameless destination is named from its own hash (SPEC RA10, RE10)
* Web, bridge, app: Reticulum is named as its own protocol in the bridge tag (`[RT]`), the footer join strip (`RETICULUM_PRESET` and `RETICULUM_FREQ`), the `/nodes` page description and the client fallback label; the site description names no protocol; the app README states the app is Meshtastic only (SPEC RL8)
* Docs: Reticulum is marked experimental and announce-only; the README says what the RNS stack transmits and that the channel filters ignore it (SPEC RD7/RN5)
* Nix: the flake ingestor includes `rns`, so `PROTOCOL=reticulum` runs on NixOS; `flake.lock` moves nixpkgs to nixos-unstable of 2026-10-03, which brings Python 3.14 and Ruby 3.4.9 (SPEC DP8)
+7
View File
@@ -484,6 +484,13 @@ One row per announced destination, newest `last_heard` first.
body the cursor produced.
- Fields: `id` (destination hash, hex), `node_id`, `identity_hash`, `name`,
`aspect`, `role`, `interface`, `first_heard`, `last_heard`, `protocol`.
- `name` is the destination's announced display name, else `Reticulum` plus the
upper-cased first four hex of its own `id`, the placeholder the ingestor sends
for a nameless destination on the announce and host paths alike (SPEC RA10).
A destination's placeholder is stored on a first sighting, never replaces a
stored name and never becomes the node's `long_name`, which is the
highest-ranked announced name, else a stored name that is not a placeholder,
else the node's own placeholder (SPEC RE10).
- `identity_hash` groups rows belonging to one peer; several rows share it when
an identity announces on several aspects.
- Written only by the node ingest route, from each node record's `destination`
+8 -1
View File
@@ -600,9 +600,16 @@ def _announce_to_node_dict(
return None
hash_hex = _reticulum_hash_hex(dest_hash)
display_name = _decode_display_name(app_data)
# The web stores this name on the destination row, so a placeholder is
# built from the destination's own hash, as host discovery builds one
# (SPEC RA10(b)). The node id stands in only for an unusable hash, which
# writes no destination row; the node's headline comes from RE10.
placeholder_id = _reticulum_node_id(dest_hash) or node_id
user: dict = {
"longName": (
display_name if display_name else _reticulum_placeholder_name(node_id)
display_name
if display_name
else _reticulum_placeholder_name(placeholder_id)
),
"shortName": _reticulum_short_name(node_id),
"publicKey": _identity_public_key_hex(identity),
+54 -19
View File
@@ -460,22 +460,24 @@ def test_fallback_name_matches_the_web_placeholder_for_every_id_shape():
mismatch that breaks ~85% of the id space — which is exactly how the first
attempt at this fix passed its own test while still clobbering names.
"""
for node_id, expected in (
identity = _FakeIdentity(bytes.fromhex(_FIELD_PRIMARY))
for dest_ref, expected in (
("!0001beef", "Reticulum 0001"), # digits only - matches even lower-cased
("!c0ffee00", "Reticulum C0FF"),
("!beefcafe", "Reticulum BEEF"),
("!deadbeef", "Reticulum DEAD"),
("!0000000a", "Reticulum 0000"),
):
# The placeholder names the row, and the row is keyed on the
# identity — so walk identity hashes, not destinations. The expected
# The placeholder names the row it lands on, the destination (SPEC
# RA10(b)), so walk destination hashes under one identity. The expected
# strings below are the *web tier's* rule (placeholder_short_id in
# identity.rb): first four hex, upper-cased, for reticulum. If the two
# sides drift, the web upsert stops recognising these as placeholders
# and they start overwriting real display names.
idn = _FakeIdentity(bytes.fromhex(node_id[1:] + "11" * 12))
node = _announce_to_node_dict(_DEST_HASH, None, identity=idn, last_heard=1)
assert node["user"]["longName"] == expected, node_id
# identity.rb, which upsert_destination applies to "!" plus the first
# eight hex of the destination): first four hex, upper-cased, for
# reticulum. If the two sides drift, the web upsert stops recognising
# these as placeholders and they start overwriting real display names.
dest = bytes.fromhex(dest_ref[1:] + "11" * 12)
node = _announce_to_node_dict(dest, None, identity=identity, last_heard=1)
assert node["user"]["longName"] == expected, dest_ref
# ---------------------------------------------------------------------------
@@ -742,8 +744,11 @@ def test_host_destinations_carry_their_interface_and_announced_name(monkeypatch)
def test_host_destination_falls_back_to_the_placeholder_without_a_name(monkeypatch):
"""A destination the stack has never heard a name for keeps the placeholder.
The generic name is wanted -- it is the web upsert's recognised fallback
form -- but only where there is no real one to prefer.
The generic name is wanted, but only where there is no real one to prefer.
The web upsert recognises both placeholder forms, the node id's head and the
destination's own head, so it stores this one on a first sighting, never
lets it replace a real name, and never lets it head the node, which keeps
its own ``Reticulum 2771`` (web/spec/reticulum_spec.rb pins the same string).
"""
_local_stack(monkeypatch, {_FIELD_LXMF: _FIELD_PRIMARY})
record = ReticulumProvider().host_destination_nodes()[0]
@@ -754,6 +759,28 @@ def test_host_destination_falls_back_to_the_placeholder_without_a_name(monkeypat
assert "interface" not in record
def test_peer_destination_placeholder_names_its_own_hash():
"""A nameless peer announce names its destination from that destination.
SPEC RA10(b): each row is named from its own hash. The host path above
does this; the announce path built the fallback from the identity's node
id, so the field peer's ``lxmf.delivery`` destination ``4cf985bf...`` was
stored as ``Reticulum 2771``, the node's name, instead of its own
``Reticulum 4CF9``. The node keeps its own short name (RA10(a)).
"""
node = _announce_to_node_dict(
bytes.fromhex(_FIELD_LXMF),
None,
identity=_FakeIdentity(bytes.fromhex(_FIELD_PRIMARY)),
aspect="lxmf.delivery",
last_heard=1,
)
assert node["nodeId"] == "!27716218"
assert node["user"]["shortName"] == "2771"
assert node["destination"]["id"] == _FIELD_LXMF
assert node["user"]["longName"] == "Reticulum 4CF9"
def test_transport_aspect_is_gated_on_transport_enabled(monkeypatch):
"""TRANSPORT is reported only when the stack actually relays traffic.
@@ -886,16 +913,23 @@ def test_announce_to_node_dict_public_key_none_when_unreadable():
assert node["user"]["publicKey"] is None
def test_announce_to_node_dict_long_name_falls_back_to_the_node_placeholder():
"""Undecodable app_data falls back to a placeholder naming the *node*.
def test_announce_to_node_dict_long_name_falls_back_to_its_destination_placeholder():
"""Undecodable app_data falls back to a placeholder naming the destination.
Not the destination: the row is keyed on the identity (SPEC RE7), and the
web upsert only yields to the "<Label> <short id>" form it recognises.
The web stores this name on the destination row, so it is built from the
destination's own hash (SPEC RA10(b)); the node keeps its own headline
(RE10). Only an unusable destination hash, which writes no destination
row, falls back to the node id's placeholder.
"""
node = _announce_to_node_dict(
_DEST_HASH, b"\xff\xfe", identity=_FakeIdentity(), last_heard=1
)
assert node["user"]["longName"] == "Reticulum BEEF"
assert node["user"]["longName"] == "Reticulum AABB"
salvaged = _announce_to_node_dict(
b"\xaa", b"\xff\xfe", identity=_FakeIdentity(), last_heard=1
)
assert "destination" not in salvaged
assert salvaged["user"]["longName"] == "Reticulum BEEF"
def test_announce_to_node_dict_omits_hops_when_unknown():
@@ -1407,9 +1441,10 @@ def test_node_snapshot_items_returns_heard_announces(monkeypatch):
as_dict = dict(items)
assert set(as_dict) == {"!beef0001", "!c0ffee00"}
assert as_dict["!beef0001"]["user"]["longName"] == "Alice"
# Name-less announce falls back to a placeholder built from its own node
# id, so it can never carry another destination's hex.
assert as_dict["!c0ffee00"]["user"]["longName"] == "Reticulum C0FF"
# Name-less announce falls back to a placeholder built from the
# destination it arrived on (SPEC RA10(b)), never from another
# destination's hex.
assert as_dict["!c0ffee00"]["user"]["longName"] == "Reticulum 1122"
def test_update_node_ignores_a_falsy_node_id():
@@ -183,7 +183,10 @@ module PotatoMesh
#
# Must stay in lockstep with the ingestor's own placeholder: this method
# is what recognises a placeholder so a real name is never overwritten by
# one, and a mismatch would silently break that guard.
# one, and a mismatch would silently break that guard. The ingestor
# builds a nameless Reticulum destination's placeholder from the head of
# the destination's own hash, and older ingestors built a peer's from the
# head of the node id, so {#reticulum_placeholder_name?} checks both forms.
#
# @param node_id [String, nil] canonical node identifier.
# @param protocol [String, nil] protocol the placeholder belongs to.
@@ -198,6 +201,59 @@ module PotatoMesh
hex.length >= 4 ? hex[0, 4].upcase : parts[2]
end
# Whether +name+ is a generic Reticulum placeholder for a destination of
# a node (SPEC RA10).
#
# Two forms are generic: the head of the destination's own hash,
# canonicalised like a node id (+!+ plus its first eight hex digits),
# which the ingestor builds for every nameless destination, and the head
# of the node id, which older ingestors built for a peer's. Either one is
# a stand-in, not an announced name: a destination's placeholder never
# replaces its stored name and never names its node, which with no
# announced name and no stored real name reads its own placeholder
# ({#reticulum_headline_name}).
#
# @param name [String, nil] candidate name.
# @param node_id [String] canonical id of the node owning the destination.
# @param destination_id [String, nil] destination hash, hex; +nil+ (no
# usable destination) checks the node id's form only.
# @return [Boolean] true when +name+ is either placeholder.
def reticulum_placeholder_name?(name, node_id, destination_id)
generic_fallback_name?(name, node_id, "reticulum") ||
generic_fallback_name?(name, "!#{destination_id.to_s[0, 8]}", "reticulum")
end
# The headline name of a Reticulum node (SPEC RE10 as amended).
#
# The first announced name among the node's destinations, best ranked
# first. A placeholder is not a name, so a nameless +NODE+ aspect cannot
# rename a peer whose +PEER+ aspect announced one. With no announced name
# a stored headline that is not a placeholder stands; otherwise the node
# takes its own placeholder (SPEC RA10(a)): +!27716218+ badges +2771+ and
# reads +Reticulum 2771+, never a destination's +Reticulum 9C59+.
#
# @param node_id [String] canonical node id.
# @param destinations [Array<Array(String, String)>] +[id, name]+ pairs of
# the node's destinations, best ranked first.
# @param stored [String, nil] the node's current +long_name+.
# @return [String, nil] the headline, or nil to leave +long_name+ as it is.
def reticulum_headline_name(node_id, destinations, stored)
announced = destinations.find do |id, name|
string_or_nil(name) && !reticulum_placeholder_name?(name, node_id, id)
end
return announced.last if announced
stored = string_or_nil(stored)
# A placeholder of the node or of any of its destinations yields; a
# real name kept from a record with no usable destination stands.
generic = stored.nil? || generic_fallback_name?(stored, node_id, "reticulum") ||
destinations.any? { |id, _name| reticulum_placeholder_name?(stored, node_id, id) }
return stored unless generic
short_id = placeholder_short_id(node_id, "reticulum")
short_id ? "#{protocol_display_label("reticulum")} #{short_id}" : stored
end
# Resolve a raw node reference to its canonical row in the +nodes+ table.
#
# @param db [SQLite3::Database] open database handle.
@@ -264,8 +264,12 @@ module PotatoMesh
# @param destination [Object] +destination+ block from the payload.
# @param identity_hash [String, nil] identity the destination belongs to.
# @param name [String, nil] display name announced on this destination;
# a generic +Reticulum <SHORT>+ placeholder is stored as +nil+ so it
# cannot displace a real one.
# a generic +Reticulum <SHORT>+ placeholder, built from this
# destination's own hash or, by an older ingestor, from the node id, is
# stored on a first sighting but never replaces a stored name and never
# names the node; with no announced name the node keeps a stored name
# that is not a placeholder, else reads its own placeholder
# ({#reticulum_placeholder_name?}, {#reticulum_headline_name}).
# @param interface [String, nil] interface the announce was heard on.
# @param heard [Integer] unix seconds of receipt.
# @return [void]
@@ -277,13 +281,14 @@ module PotatoMesh
# A generic "Reticulum <SHORT>" placeholder is still worth storing on a
# first sighting -- it is what the reader sees until a real name turns
# up -- but it must never *replace* one a real announce supplied. The
# host's own destinations are discovered rather than announced, so they
# fall back to the placeholder whenever the stack remembers no app_data,
# and since RE10 derives the node headline from these rows, letting that
# land on top of a real name would rename the node too.
# up -- but it must never *replace* one a real announce supplied. Every
# nameless destination falls back to one, the host's own whenever the
# stack remembers no app_data, and letting that land on top of a real
# name would erase it; if RE10 had picked that name and another aspect
# announced one, the headline would move to that one. Both placeholder
# forms count (SPEC RA10).
stored_name = string_or_nil(name)
update_name = generic_fallback_name?(stored_name, node_id, "reticulum") ? nil : stored_name
update_name = reticulum_placeholder_name?(stored_name, node_id, id) ? nil : stored_name
params = [
id,
@@ -350,8 +355,11 @@ module PotatoMesh
#
# +name+ and +role+ are resolved independently: an aspect can carry a role
# while announcing no display name, and taking both from one row would let
# a nameless top-ranked aspect blank the headline. Ties break on the more
# recently heard destination.
# a nameless top-ranked aspect blank the headline. A placeholder is not a
# name either (SPEC RE10 as amended): the name is the best-ranked
# *announced* one, else a stored name that is not a placeholder, else the
# node's own placeholder ({#reticulum_headline_name}). Ties break on the
# more recently heard destination.
#
# @param db [SQLite3::Database] open database handle.
# @param node_id [String] canonical id of the node to refresh.
@@ -359,13 +367,15 @@ module PotatoMesh
def refresh_node_identity_from_destinations(db, node_id)
rank = DESTINATION_ROLE_RANK_SQL
with_busy_retry do
db.execute(<<~SQL, [node_id, node_id, node_id])
destinations = db.execute(<<~SQL, [node_id]).map { |row| row.is_a?(Hash) ? row.values_at("id", "name") : row }
SELECT id, name FROM destinations WHERE node_id = ?
ORDER BY #{rank}, COALESCE(last_heard, 0) DESC
SQL
stored = db.get_first_value("SELECT long_name FROM nodes WHERE node_id = ?", [node_id])
headline = reticulum_headline_name(node_id, destinations, stored)
db.execute(<<~SQL, [headline, node_id, node_id])
UPDATE nodes SET
long_name = COALESCE((
SELECT name FROM destinations
WHERE node_id = ? AND name IS NOT NULL AND TRIM(name) <> ''
ORDER BY #{rank}, COALESCE(last_heard, 0) DESC LIMIT 1
), long_name),
long_name = COALESCE(?, long_name),
role = COALESCE((
SELECT role FROM destinations
WHERE node_id = ? AND role IS NOT NULL
@@ -488,8 +498,17 @@ module PotatoMesh
# If the incoming long name is a generic placeholder, prefer any real
# name already on record so we never stomp known data with fallback
# text. For new nodes there is nothing to preserve, so the generic
# name is still written via the INSERT VALUES path.
long_name_conflict_sql = if generic_fallback_name?(long_name, node_id, protocol)
# name is still written via the INSERT VALUES path. A Reticulum
# record's name lands on its destination, so its placeholder may be
# built from the destination's own hash rather than the node id
# (SPEC RA10); both forms yield.
destination = n["destination"]
generic_long_name = if protocol == "reticulum"
reticulum_placeholder_name?(long_name, node_id, destination.is_a?(Hash) ? destination["id"] : nil)
else
generic_fallback_name?(long_name, node_id, protocol)
end
long_name_conflict_sql = if generic_long_name
# Generic placeholder: keep any real name already on record.
# COALESCE returns nodes.long_name when non-null, otherwise falls
# back to the incoming generic — so brand-new nodes still get it.
@@ -619,8 +638,9 @@ module PotatoMesh
heard: lh,
)
# The headline fields follow the ranked aspect, not the announce
# that happened to arrive last (SPEC RE10).
refresh_node_identity_from_destinations(db, node_id)
# that happened to arrive last (SPEC RE10). Only a Reticulum node
# has destinations, so no other protocol pays for the lookups.
refresh_node_identity_from_destinations(db, node_id) if protocol == "reticulum"
end
# Keyed-evidence stamp (SPEC MR1). Deliberately a separate,
+164 -2
View File
@@ -278,8 +278,9 @@ RSpec.describe "Reticulum protocol support" do
it "keeps a real destination name when a placeholder arrives later" do
# Discovery re-emits the host's aspects on every snapshot, falling back to
# "Reticulum <SHORT>" when the stack remembers no app_data. Arriving after
# a real announce, that must not overwrite the stored name -- which would
# rename the node too, since RE10 derives the headline from these rows.
# a real announce, that must not overwrite the stored name: the
# destination row would lose it, though the node keeps its stored
# headline (RE10).
row = post_two_aspects(
{ aspect: "nomadnetwork.node", dest: RETICULUM_DEST_HASH,
role: "NODE", name: "Department of Decentralization" },
@@ -323,6 +324,167 @@ RSpec.describe "Reticulum protocol support" do
expect(row["role"]).to eq("NODE")
expect(row["long_name"]).to eq("Afri Nomad Orion")
end
it "does not let a placeholder on a higher aspect rename the node" do
# The example above sends name: nil, which no ingestor path sends: a
# nameless aspect arrives as a placeholder, built from its own hash
# (Reticulum 00FF) or, by an older ingestor, from the node id
# (Reticulum A1B2). Neither is a name (SPEC RE10 as amended).
observed = ["Reticulum 00FF", "Reticulum A1B2"].to_h do |placeholder|
clear_tables
row = post_two_aspects(
{ aspect: "lxmf.delivery", dest: RETICULUM_DEST_HASH,
role: "PEER", name: "Afri Nomad Orion" },
{ aspect: "nomadnetwork.node", dest: RETICULUM_DEST_HASH2,
role: "NODE", name: placeholder },
)
[placeholder, [row["long_name"], row["role"]]]
end
expect(observed).to eq(
"Reticulum 00FF" => ["Afri Nomad Orion", "NODE"],
"Reticulum A1B2" => ["Afri Nomad Orion", "NODE"],
)
end
it "names a node with no announced name from its own id, not a destination's" do
# SPEC RA10(a): the node badges a1b2, so its headline reads Reticulum A1B2
# while the destination row keeps its own Reticulum 00FF (RA10(b)). The
# aspect is posted twice, as a later snapshot would repeat it.
nameless = { aspect: "nomadnetwork.node", dest: RETICULUM_DEST_HASH2,
role: "NODE", name: "Reticulum 00FF" }
row = post_two_aspects(nameless, nameless)
with_db(readonly: true) do |db|
destination = db.get_first_value(
"SELECT name FROM destinations WHERE id = ?", [RETICULUM_DEST_HASH2]
)
expect([destination, row["long_name"]]).to eq(["Reticulum 00FF", "Reticulum A1B2"])
end
end
end
describe "headline name fallback (SPEC RE10, RA10)" do
it "keeps a real name the node carries when a nameless destination arrives" do
# A record with an unusable destination hash names the node but writes no
# destination row; a later nameless destination must not erase that name.
register_reticulum_ingestor
named = reticulum_node_fixture(last_heard: now - 30).except("destination")
nameless = reticulum_node_fixture(last_heard: now - 29, aspect: "nomadnetwork.node",
dest_id: RETICULUM_DEST_HASH2, role: "NODE")
nameless["user"]["longName"] = "Reticulum 00FF"
[named, nameless].each do |record|
payload = { RETICULUM_NODE_ID => record, "ingestor" => RETICULUM_INGESTOR_ID, "protocol" => "reticulum" }
post "/api/nodes", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
end
with_db(readonly: true) do |db|
expect(
db.get_first_value("SELECT long_name FROM nodes WHERE node_id = ?", [RETICULUM_NODE_ID]),
).to eq("Argos Station")
end
end
it "gives a node with no name at all its own placeholder" do
expect(reticulum_headline_name(RETICULUM_NODE_ID, [], nil)).to eq("Reticulum A1B2")
end
it "leaves the name alone for an id it cannot read" do
expect(reticulum_headline_name("not-a-node-id", [], nil)).to be_nil
end
it "reads destination rows from a hash-results handle" do
register_reticulum_ingestor
post_reticulum_nodes
with_db do |db|
db.execute("UPDATE nodes SET long_name = 'Reticulum A1B2' WHERE node_id = ?", [RETICULUM_NODE_ID])
refresh_node_identity_from_destinations(db, RETICULUM_NODE_ID)
expect(
db.get_first_value("SELECT long_name FROM nodes WHERE node_id = ?", [RETICULUM_NODE_ID]),
).to eq("Argos Station")
end
end
end
describe "host destination placeholders (SPEC RA10)" do
# The field host: node !27716218 (RETICULUM_IDENTITY_HASH) and two of the
# destinations RNS derives from that identity, so neither shares the node's
# head. The ingestor names a nameless destination from its own hash
# (RA10): "Reticulum 4CF9" and "Reticulum 9C59", not the node's
# "Reticulum 2771". tests/test_reticulum_unit.py pins the same string for
# the same pair. RETICULUM_DEST_HASH starts like RETICULUM_NODE_ID and
# cannot tell the two forms apart.
RETICULUM_HOST_NODE_ID = "!27716218".freeze
RETICULUM_HOST_LXMF = {
"id" => "4cf985bf933c21b1aa8dabd407d4ef69", "aspect" => "lxmf.delivery", "role" => "PEER",
}.freeze
RETICULUM_HOST_NOMADNET = {
"id" => "9c59da5e1516745d74cc908243e0ba2b", "aspect" => "nomadnetwork.node", "role" => "NODE",
}.freeze
# POST host-destination records one at a time, each shaped as
# _host_destination_nodes builds it (no publicKey, no hopsAway), and read
# back what they left behind.
#
# @param records [Array<Array(Hash, String)>] destination block and
# user.longName pairs, oldest first.
# @return [Array(Hash{String => String}, String)] the stored destination
# names keyed by destination id, and the node's long_name.
def post_host_destinations(*records)
register_reticulum_ingestor
records.each_with_index do |(destination, name), index|
payload = {
RETICULUM_HOST_NODE_ID => {
"nodeId" => RETICULUM_HOST_NODE_ID,
"lastHeard" => now - 30 + index,
"protocol" => "reticulum",
"identityHash" => RETICULUM_IDENTITY_HASH,
"destination" => destination,
"user" => { "shortName" => "2771", "longName" => name, "role" => destination["role"] },
},
"ingestor" => RETICULUM_INGESTOR_ID,
"protocol" => "reticulum",
}
post "/api/nodes", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
end
with_db(readonly: true) do |db|
names = db.execute("SELECT id, name FROM destinations").to_h { |r| [r["id"], r["name"]] }
headline = db.get_first_value(
"SELECT long_name FROM nodes WHERE node_id = ?", [RETICULUM_HOST_NODE_ID]
)
[names, headline]
end
end
it "never lets a destination-derived placeholder replace a real name" do
# The second record is a later snapshot whose stack remembers no app_data
# for the destination.
names, headline = post_host_destinations(
[RETICULUM_HOST_LXMF, "Afri Nomad Orion"],
[RETICULUM_HOST_LXMF, "Reticulum 4CF9"],
)
expect([names[RETICULUM_HOST_LXMF["id"]], headline]).to eq(["Afri Nomad Orion", "Afri Nomad Orion"])
end
it "keeps the ranked headline when its aspect falls back to the placeholder" do
# NODE outranks PEER (RE10); without the guard a placeholder landing on
# the NODE row erases its real name and the headline moves to PEER's.
names, headline = post_host_destinations(
[RETICULUM_HOST_NOMADNET, "Department of Decentralization"],
[RETICULUM_HOST_LXMF, "Afri Nomad Orion"],
[RETICULUM_HOST_NOMADNET, "Reticulum 9C59"],
)
expect([names[RETICULUM_HOST_NOMADNET["id"]], headline]).to eq(
["Department of Decentralization", "Department of Decentralization"],
)
end
it "stores a destination-derived placeholder on a first sighting" do
# The generic name is wanted where there is no real one to prefer, on the
# destination row only: the node keeps its own placeholder, which its
# badge 2771 matches (RA10(a), RE10).
names, headline = post_host_destinations([RETICULUM_HOST_LXMF, "Reticulum 4CF9"])
expect([names[RETICULUM_HOST_LXMF["id"]], headline]).to eq(["Reticulum 4CF9", "Reticulum 2771"])
end
end
describe "GET /api/destinations" do