web: federation signature v2 migration (#805)

* web: federation signature v2 migration

* web: address review comments
This commit is contained in:
l5y authored and GitHub committed 2026-06-22 10:01:45 +02:00
1 parent af64b2c60f
commit 027b6160df
15 files changed
+428 -173

No files matched your search

@@ -1,37 +1,65 @@
---
name: api-casing-and-federation-signature
description: API JSON casing map + the camelCase federation-signature landmine
description: API JSON casing map + the federation signature v2 (snake) migration
metadata:
type: project
---
**Federation instance signatures are computed over camelCase canonical keys.**
`web/lib/potato_mesh/application/federation/signature.rb` `canonical_instance_payload`
signs `JSON.generate({contactLink, id, domain, pubkey, name, version, channel,
frequency, latitude, longitude, lastUpdateTime, isPrivate}, sort_keys: true)`. So
the federation wire — `/.well-known/potato-mesh`, `GET /api/instances`
(`instances.rb` `normalize_instance_row`), the announcement payload
(`self_instance.rb`) — **must stay camelCase**. Renaming any signed key breaks
cross-version signature verification **bilaterally** (no "emit both keys" shim
fixes a signature). Treat this as immovable without a signature-version scheme.
**Federation wire is snake_case "signature v2" since the 0.7.0 line (FS1–FS6 in
SPEC.md).** It used to be camelCase-and-immovable (BF3, now superseded). The whole
federation surface — `/.well-known/potato-mesh`, `GET /api/instances`
(`instances.rb` `normalize_instance_row`), and the announcement payload
(`self_instance.rb` `instance_announcement_payload`) — now emits snake_case:
`public_key` (was `publicKey`/`pubkey`), `last_update` (was
`lastUpdate`/`lastUpdateTime`), `is_private`, `contact_link`, `nodes_count`,
`meshcore_nodes_count`, `meshtastic_nodes_count`, `reticulum_nodes_count`
(forward-compat stub, always 0), plus `signature_algorithm`, `signed_payload`,
`signature_version`. Single-token keys (`id`, `domain`, `name`, `version`,
`channel`, `frequency`, `latitude`, `longitude`, `signature`) unchanged. DB
columns (`pubkey`, `last_update_time`) stay internal, mapped at the wire boundary.
**HTTP API casing map (as of 0.7.0):**
**How the migration works (`federation/signature.rb`):**
- One shared `canonical_signed_payload(fields)` builds the snake canonical and
stamps `signature_version` *inside the signed bytes* (anti-downgrade). Used by
both signers (instance announcement + well-known) — option U0.
- `canonical_instance_payload` signs the **v2** (snake) form, now including ALL
node counts (FS2 — no unsigned attribute remains).
- `verify_instance_signature` tries **v2 (snake) then v1 (camel)** canonical →
accept-both. `canonical_instance_payload_v1` is kept verbatim for the v1 path.
- The break is **one-way** (FS5): old peers can't verify our v2 signature (stop
accepting us until they upgrade); we still accept their v1 → mixed fleet
converges. `config.federation_signature_version` = 2.
- Parsers read both casings: `crawl.rb` `remote_instance_attributes_from_payload`
+ `routes/ingest.rb` POST `/api/instances` + `federation/validation.rb`
`validate_well_known_document` (e.g. `payload["public_key"] || payload["publicKey"]`).
**Signed counts are stored/served/relayed verbatim (decision (a)).** Because the
crawl re-verifies relayed `/api/instances` entries' signatures over their counts
(`crawl.rb`), the old recompute-on-store would break relay re-verification when
the recomputed value diverged from the signed snapshot. So the recompute in
`ingest.rb` and `crawl.rb` is now a **count-absent fallback only** — present
(signed) counts are kept so the canonical re-builds identically downstream.
**HTTP API casing map (0.7.0 line):**
- Read collections (`/api/nodes`, `/api/messages`, `/api/positions`,
`/api/telemetry`, `/api/traces`, `/api/neighbors`) and `/api/stats`: **snake_case**
already (`query_nodes` selects snake columns; matrix reads `rx_time`).
- `/version`: **snake_case** since 0.7.0 (was the lone camelCase read response).
- `POST /api/nodes` **input**: Meshtastic camelCase, but `upsert_node` now also
accepts snake via nil-aware `pick_alias` (camel preferred → ingestor unaffected).
- Federation wire: **camelCase** (signed — see above).
`/api/telemetry`, `/api/traces`, `/api/neighbors`) and `/api/stats`,
`/version`: **snake_case**.
- `POST /api/nodes` / `POST /api/instances` **input**: accept camelCase AND
snake (nil-aware `pick_alias` / `a || b`).
- Federation wire: **snake_case v2** (signed — see above).
**Gotchas that cost time:**
- `/api/nodes` camelCase you see in the frontend is the POST input, the client
`normalizeNodeCollection` internal model, and `?? shortName` fallbacks — NOT the
GET response. Don't "snake-case /api/nodes"; it already is.
- The dashboard frontend reads config from the server-rendered `data-app-config`
attribute (`views/layouts/app.erb`, helper `frontend_app_config`), **not** from
`/version`. The matrix bridge only pings `/version` for liveness (no key reads).
The Flutter app *does* read `/version` config keys (`app/lib/main.dart`) — the
one consumer that breaks on a `/version` casing change.
**Gotchas:**
- Frontend `federation-page.js` reads `/api/instances` dual (`x.snake ?? x.camel`)
so it survives either casing; `federation-instance-display.js` only reads
`name`/`domain` (casing-safe).
- RSpec `stub_const(name, value)` does **NOT** take a block — `stub_const(...) do
… end` silently skips the block body (vacuous test). `app_spec.rb` had several
such blocks (`build_well_known_document` + the `.self_instance_domain` /
`.self_instance_registration_decision` / `.ensure_self_instance_record!`
describes); **all now converted to inline `stub_const`** and genuinely run — no
block-form `stub_const` remains in the specs. Use the value form
`stub_const("X", Class.new do … end)` only when the block belongs to `Class.new`.
- The Flutter app reads `/version` config keys (one consumer that breaks on a
`/version` casing change); it does NOT consume the federation wire.
See [[web-app-local-run]] for running the server to curl these.
+53
View File
@@ -669,3 +669,56 @@ canonical payload are unchanged.
```
**Expected:** all green. POST `be_ok` assertions were updated to `201` (not
removed); the ingestor is unaffected (2xx success); the matrix bridge is GET-only.
---
## Bugfix/Migration: Federation signature v2
Maps to SPEC **FS1–FS6** — federation wire migrated to snake_case with signed
counts and v1-backward-compatible verification.
### FS-A1 — v2 sign/verify round-trip + v1 backward-accept
```bash
( cd web && bundle exec rspec spec/federation_spec.rb -e "signature" )
```
**Expected:** pass. A v2 (snake) instance signature verifies; a legacy v1
(camelCase, no `signature_version`) signature still verifies via fallback.
`verify_instance_signature` accepts both; instances sign/send v2.
### FS-A2 — all announced counts are signed (tamper-evident)
```bash
( cd web && bundle exec rspec spec/federation_spec.rb -e "signed counts" )
```
**Expected:** pass. The announcement canonical covers `nodes_count`,
`meshcore_nodes_count`, `meshtastic_nodes_count`, `reticulum_nodes_count`;
altering any count invalidates the v2 signature. Nothing in the announced payload
sits outside the signed canonical except `signature` / `signature_version`.
### FS-A3 — well-known v2 snake + version marker, accepts v1+v2
```bash
( cd web && bundle exec rspec spec/app_spec.rb -e "well-known" )
```
**Expected:** pass. `/.well-known/potato-mesh` emits snake_case (`public_key`,
`last_update`, `signature_algorithm`, `signed_payload`, `signature_version`); the
validator accepts both v2 and legacy v1 documents.
### FS-A4 — wire surfaces are snake_case
```bash
( cd web && bundle exec rspec spec/app_spec.rb -e "/api/instances" )
```
**Expected:** pass. `GET /api/instances` and the announce payload use
`public_key`, `last_update`, `is_private`, `contact_link`, `*_nodes_count` — no
camelCase keys.
### FS-A5 — activity gate is intended behavior (not a regression)
**Expected (covered by `federation_spec`):** an instance with **0 nodes active in
7 days** is **not** federated — `validate_remote_nodes` rejects it ("node data is
stale" / below `remote_instance_min_node_count`). By design.
### FS-R1 — Regression
```bash
( cd web && bundle exec rspec ) && ( cd web && npm test )
( . .venv/bin/activate && pytest -q tests/ ) && ( cd matrix && cargo test --all --all-features )
```
**Expected:** all green. The pre-existing camelCase federation specs are
retargeted to v2 or kept as the v1-backward-accept proof, not removed.
+24 -2
View File
@@ -262,9 +262,31 @@ to standardise on snake_case while preserving compatibility where it is load-bea
| --- | --- | --- |
| **BF1** | **`/version` response is snake_case.** Top-level `last_node_update` and the `config` block (`site_name`, `map_center` {`lat`,`lon`}, `private_mode`, `instance_domain`, `contact_link`, `contact_link_url`, `max_distance_km`, `refresh_interval_seconds`) replace the prior camelCase keys. A versioned breaking change (0.7.0); consumers are the Flutter app and external clients. | interview |
| **BF2** | **`POST /api/nodes` additionally accepts snake_case** node fields (`last_heard`, `user.short_name`/`long_name`/`hw_model`, `device_metrics.battery_level`, `position.location_source`, …) via a **nil-aware** `pick_alias` (a `false` camelCase value is never overridden by a snake_case alias). **Additive** — the Python ingestor's camelCase output keeps working, so no ingestor change is required. | interview |
| **BF3** | **The signed federation wire is unchanged** (Invariant III). `/.well-known/potato-mesh` and `/api/instances` keep their camelCase keys (`isPrivate`, `lastUpdateTime`, `nodesCount`, …) because those keys are inside the instance **signature** (`federation/signature.rb`); renaming them would break cross-version signature verification bilaterally. | code |
| **BF3** | **The signed federation wire is unchanged** (Invariant III). `/.well-known/potato-mesh` and `/api/instances` keep their camelCase keys (`isPrivate`, `lastUpdateTime`, `nodesCount`, …) because those keys are inside the instance **signature** (`federation/signature.rb`); renaming them would break cross-version signature verification bilaterally. **Superseded by FS1–FS6** (next release): the wire is deliberately migrated to snake_case v2 with a `signature_version` marker and v1-backward-accept, so the break is one-way and versioned rather than silent. | code |
| **BF4** | **Out of scope (deferred).** The Flutter app's `/version` reader (`app/lib/main.dart`) and the server→frontend `data-app-config` DOM channel (`frontend_app_config`) keep camelCase for now and are tracked as separate follow-ups; the frontend dashboard is unaffected (it reads `data-app-config`, not `/version`). | interview |
| **BF5** | **`POST /api/instances` accepts snake_case aliases** for its optional fields (`contact_link`, `nodes_count`, `meshcore_nodes_count`, `meshtastic_nodes_count`) in addition to camelCase (third-party / cross-version compat); `id`/`lastUpdateTime`/`isPrivate` were already dual-keyed. The signed canonical payload (camelCase) is unchanged. (I6) | interview |
| **BF5** | **`POST /api/instances` accepts snake_case aliases** for its optional fields (`contact_link`, `nodes_count`, `meshcore_nodes_count`, `meshtastic_nodes_count`) in addition to camelCase (third-party / cross-version compat); `id`/`lastUpdateTime`/`isPrivate` were already dual-keyed. The signed canonical payload (camelCase) is unchanged. (I6) **Superseded by FS1** (the signed canonical and announced payload are now snake_case v2; the dual-key acceptance remains as the v1-backward path). | interview |
| **BF6** | **Position time is exposed only as `position_time`** (unix int) on GET responses; the redundant ISO twin (`pos_time_iso` on `/api/nodes`, `position_time_iso` on `/api/positions`) is removed — clients format it themselves. (I2) | interview |
| **BF7** | **All `POST /api/*` ingest routes return `201 Created`** (was `200`), matching `/api/instances`. The Python ingestor accepts any 2xx (`queue.py` urlopen); the matrix bridge is GET-only. (I3) | interview |
| **BF8** | **List POST routes validate the top-level payload.** `/api/messages`, `/positions`, `/telemetry`, `/neighbors`, `/traces` reject a non-Array/non-Hash body with `400 {"error":"invalid payload"}`, matching `/api/nodes` strictness. (I5) | interview |
---
## Bugfix/Migration: Federation signature v2 (snake_case wire, signed counts)
Migrates the federation wire (instance announcement, `GET /api/instances`,
`/.well-known/potato-mesh`) from camelCase to snake_case and closes the
unsigned-field gap, as a **deliberate, versioned break to Invariant III** with
receiver-side backward compatibility. The two signed surfaces keep distinct roles
(well-known = fetched-from-origin identity anchor; announcement = relayable
attribute bundle) but share one snake_case canonicalizer, one `signature_version`
marker, and one fallback chain (option **U0**).
| # | Decision | Source |
| --- | --- | --- |
| **FS1** | **Snake_case federation wire** via one shared canonicalizer: `public_key` (was `publicKey`/`pubkey`), `last_update` (was `lastUpdate`/`lastUpdateTime`), `is_private`, `contact_link`, `nodes_count`, `meshcore_nodes_count`, `meshtastic_nodes_count`, `reticulum_nodes_count`, `signature_algorithm`, `signed_payload`, `signature_version`. Single-token keys (`id`, `domain`, `name`, `version`, `channel`, `frequency`, `latitude`, `longitude`, `signature`) unchanged. DB columns (`pubkey`, `last_update_time`) stay internal, mapped at the wire boundary. | interview |
| **FS2** | **Every announced count is signed** — the announcement canonical includes `nodes_count`, `meshcore_nodes_count`, `meshtastic_nodes_count`, and a forward-compat `reticulum_nodes_count` (0 until a Reticulum ingestor exists); **no unsigned attribute remains** in the announcement. Counts are still **recomputed** from the peer's live `/api/nodes` on receipt — the signature authenticates the sender's snapshot (integrity), the recompute keeps the displayed figure fresh. | interview |
| **FS3** | **`signature_version` is stamped inside the signed canonical** (not only the envelope), so the format cannot be silently downgraded. Current version = `2`; legacy payloads without it are treated as `v1`. | interview |
| **FS4** | **Send-snake, accept-both (U0).** Instances sign and send **v2 (snake)**. `verify_instance_signature` and the well-known validator try the **v2 snake** canonical, then fall back to the **v1 camel** canonical, each composed with the existing `contact_link`-strip and domain-casing fallbacks. One canonicalizer + fallback chain is shared by both signers (their field sets differ; the mechanism/casing/marker/fallback are shared). | interview |
| **FS5** | **Versioned one-way break** (amends Invariant III, mirrors S7): old peers cannot verify a v2 signature and stop accepting this instance until upgraded (accepted cost); new instances accept old peers' v1 signatures, so a mixed fleet converges. | interview |
| **FS6** | **`last_update`** is the sole wire name for the instance update time across both signed surfaces. | interview |
| **FS7** | **Flutter `/api/instances` reader deferred** (extends BF4). `app/lib/main.dart` `MeshInstance.fromJson` still reads `isPrivate`/`lastUpdateTime` (camelCase) and is intentionally **not** migrated, consistent with the standing decision to defer all Flutter work. Low impact: `GET /api/instances` never serves an `is_private: true` entry (private instances 404 the endpoint via `federation_enabled?`; remote private peers are rejected at registration/crawl), so privacy stays enforced server-side — the stale reads only blank the app's `last_update`/`is_private` display until the client is updated. | review |
@@ -92,16 +92,20 @@ module PotatoMesh
domain = sanitize_instance_domain(payload["domain"])
return [nil, nil, "missing instance domain"] unless domain
pubkey = sanitize_public_key_pem(payload["pubkey"])
# Accept both the v2 (snake_case) and legacy v1 (camelCase) wire keys so
# the verifier can reconstruct either canonical, and so the parsed
# attributes carry the signed counts needed to rebuild the v2 blob
# (SPEC FS1/FS2/FS4).
pubkey = sanitize_public_key_pem(payload["public_key"] || payload["pubkey"])
return [nil, nil, "missing instance public key"] unless pubkey
signature = string_or_nil(payload["signature"])
return [nil, nil, "missing instance signature"] unless signature
private_value = if payload.key?("isPrivate")
payload["isPrivate"]
else
private_value = if payload.key?("is_private")
payload["is_private"]
elsif payload.key?("isPrivate")
payload["isPrivate"]
end
private_flag = coerce_boolean(private_value)
if private_flag.nil?
@@ -119,9 +123,13 @@ module PotatoMesh
frequency: string_or_nil(payload["frequency"]),
latitude: coerce_float(payload["latitude"]),
longitude: coerce_float(payload["longitude"]),
last_update_time: coerce_integer(payload["lastUpdateTime"]),
last_update_time: coerce_integer(payload["last_update"] || payload["lastUpdateTime"]),
is_private: private_flag,
contact_link: string_or_nil(payload["contactLink"]),
contact_link: string_or_nil(payload["contactLink"] || payload["contact_link"]),
nodes_count: coerce_integer(payload["nodes_count"] || payload["nodesCount"]),
meshcore_nodes_count: coerce_integer(payload["meshcore_nodes_count"] || payload["meshcoreNodesCount"]),
meshtastic_nodes_count: coerce_integer(payload["meshtastic_nodes_count"] || payload["meshtasticNodesCount"]),
reticulum_nodes_count: coerce_integer(payload["reticulum_nodes_count"]),
}
[attributes, signature, nil]
@@ -322,15 +330,16 @@ module PotatoMesh
stats_payload,
max_age_seconds: PotatoMesh::Config.remote_instance_max_node_age,
)
attributes[:nodes_count] = stats_count if stats_count
# Keep the SIGNED counts from the verified entry verbatim (SPEC FS2/(a)
# — relay-consistent); only fall back to the peer's live /api/stats when
# the entry carried no count.
attributes[:nodes_count] = stats_count if stats_count && attributes[:nodes_count].nil?
# Extract per-protocol 24h counts (informational, not signed). Reads
# the 0.7.0 nodes sub-hash, falling back to the pre-0.7.0 flat shape.
if stats_payload.is_a?(Hash)
mc_day = remote_stats_protocol_day(stats_payload, "meshcore")
mt_day = remote_stats_protocol_day(stats_payload, "meshtastic")
attributes[:meshcore_nodes_count] = mc_day if mc_day
attributes[:meshtastic_nodes_count] = mt_day if mt_day
attributes[:meshcore_nodes_count] = mc_day if mc_day && attributes[:meshcore_nodes_count].nil?
attributes[:meshtastic_nodes_count] = mt_day if mt_day && attributes[:meshtastic_nodes_count].nil?
end
nodes_since_path = "/api/nodes?since=#{recent_cutoff}&limit=1000"
@@ -108,6 +108,9 @@ module PotatoMesh
nodes_count: nodes_count,
meshcore_nodes_count: mc_count,
meshtastic_nodes_count: mt_count,
# Forward-compat stub (SPEC FS2): always 0 until a Reticulum ingestor
# exists, but signed now so the shape need not change again.
reticulum_nodes_count: 0,
}
ensure
db&.close
@@ -133,20 +136,22 @@ module PotatoMesh
payload = {
"id" => attributes[:id],
"domain" => attributes[:domain],
"pubkey" => attributes[:pubkey],
"public_key" => attributes[:pubkey],
"name" => attributes[:name],
"version" => attributes[:version],
"channel" => attributes[:channel],
"frequency" => attributes[:frequency],
"latitude" => attributes[:latitude],
"longitude" => attributes[:longitude],
"lastUpdateTime" => attributes[:last_update_time],
"isPrivate" => attributes[:is_private],
"contactLink" => attributes[:contact_link],
"nodesCount" => attributes[:nodes_count],
"meshcoreNodesCount" => attributes[:meshcore_nodes_count],
"meshtasticNodesCount" => attributes[:meshtastic_nodes_count],
"last_update" => attributes[:last_update_time],
"is_private" => attributes[:is_private],
"contact_link" => attributes[:contact_link],
"nodes_count" => attributes[:nodes_count],
"meshcore_nodes_count" => attributes[:meshcore_nodes_count],
"meshtastic_nodes_count" => attributes[:meshtastic_nodes_count],
"reticulum_nodes_count" => attributes[:reticulum_nodes_count],
"signature" => signature,
"signature_version" => PotatoMesh::Config.federation_signature_version,
}
payload.reject { |_, value| value.nil? }
end
@@ -17,13 +17,55 @@
module PotatoMesh
module App
module Federation
# Build the canonical JSON payload that gets signed for instance
# announcements. Keys are emitted in deterministic order and only
# populated when the corresponding attribute is non-nil.
# Build a canonical signed JSON payload: snake_case keys in deterministic
# order (+sort_keys+), with +signature_version+ stamped inside so the format
# cannot be silently downgraded. Shared by the instance-announcement and
# well-known signers (SPEC FS1/FS3/FS4, option U0). Nil fields are omitted.
#
# @param fields [Hash{String,Symbol => Object}] snake_case wire fields.
# @param signature_version [Integer] format marker baked into the payload.
# @return [String] canonical JSON string suitable for signing/verifying.
def canonical_signed_payload(fields, signature_version: PotatoMesh::Config.federation_signature_version)
data = {}
fields.each { |key, value| data[key.to_s] = value unless value.nil? }
data["signature_version"] = signature_version
JSON.generate(data, sort_keys: true)
end
# v2 (snake_case) canonical for an instance announcement. Signs every
# announced attribute, including all node counts (SPEC FS1/FS2) — nothing
# in the announced payload sits outside this blob except the signature.
#
# @param attributes [Hash] instance attributes hash.
# @return [String] canonical JSON string suitable for signing.
def canonical_instance_payload(attributes)
# @return [String] canonical JSON string.
def canonical_instance_payload_v2(attributes)
canonical_signed_payload({
"channel" => attributes[:channel],
"contact_link" => attributes[:contact_link],
"domain" => attributes[:domain],
"frequency" => attributes[:frequency],
"id" => attributes[:id],
"is_private" => attributes[:is_private],
"last_update" => attributes[:last_update_time],
"latitude" => attributes[:latitude],
"longitude" => attributes[:longitude],
"meshcore_nodes_count" => attributes[:meshcore_nodes_count],
"meshtastic_nodes_count" => attributes[:meshtastic_nodes_count],
"name" => attributes[:name],
"nodes_count" => attributes[:nodes_count],
"public_key" => attributes[:pubkey],
"reticulum_nodes_count" => attributes[:reticulum_nodes_count],
"version" => attributes[:version],
})
end
# v1 (legacy camelCase) canonical — retained verbatim so a pre-0.7.0 peer's
# signature still verifies (SPEC FS4 backward-accept). MUST reproduce the
# exact bytes older instances signed; do not modify.
#
# @param attributes [Hash] instance attributes hash.
# @return [String] canonical JSON string.
def canonical_instance_payload_v1(attributes)
data = {}
data["contactLink"] = attributes[:contact_link] if attributes[:contact_link]
data["id"] = attributes[:id] if attributes[:id]
@@ -41,19 +83,34 @@ module PotatoMesh
JSON.generate(data, sort_keys: true)
end
# Verify a base64 RSA-SHA256 signature for an instance attribute set.
# Canonical used when SIGNING our own announcement — always the current
# (v2) form (SPEC FS4 send-snake).
#
# @param attributes [Hash] instance attributes hash.
# @return [String] canonical JSON string suitable for signing.
def canonical_instance_payload(attributes)
canonical_instance_payload_v2(attributes)
end
# Verify a base64 RSA-SHA256 instance signature, accepting both the v2
# (snake) and legacy v1 (camel) canonical forms (SPEC FS4 accept-both).
#
# @param attributes [Hash] canonical instance attributes.
# @param signature [String, nil] base64-encoded signature bytes.
# @param public_key_pem [String, nil] PEM-encoded RSA public key.
# @return [Boolean] true when the signature validates against the public key.
# @return [Boolean] true when the signature validates under either form.
def verify_instance_signature(attributes, signature, public_key_pem)
return false unless signature && public_key_pem
canonical = canonical_instance_payload(attributes)
signature_bytes = Base64.strict_decode64(signature)
key = OpenSSL::PKey::RSA.new(public_key_pem)
key.verify(OpenSSL::Digest::SHA256.new, signature_bytes, canonical)
candidates = [
canonical_instance_payload_v2(attributes),
canonical_instance_payload_v1(attributes),
]
candidates.any? do |canonical|
key.verify(OpenSSL::Digest::SHA256.new, signature_bytes, canonical)
end
rescue ArgumentError, OpenSSL::PKey::PKeyError
false
end
@@ -30,7 +30,7 @@ module PotatoMesh
return [false, "document is not an object"]
end
remote_pubkey = sanitize_public_key_pem(document["publicKey"])
remote_pubkey = sanitize_public_key_pem(document["public_key"] || document["publicKey"])
return [false, "public key missing"] unless remote_pubkey
return [false, "public key mismatch"] unless remote_pubkey == pubkey
@@ -38,12 +38,12 @@ module PotatoMesh
return [false, "domain missing"] unless remote_domain
return [false, "domain mismatch"] unless remote_domain.casecmp?(domain)
algorithm = string_or_nil(document["signatureAlgorithm"])
algorithm = string_or_nil(document["signature_algorithm"] || document["signatureAlgorithm"])
unless algorithm&.casecmp?(PotatoMesh::Config.instance_signature_algorithm)
return [false, "unsupported signature algorithm"]
end
signed_payload_b64 = string_or_nil(document["signedPayload"])
signed_payload_b64 = string_or_nil(document["signed_payload"] || document["signedPayload"])
signature_b64 = string_or_nil(document["signature"])
return [false, "missing signed payload"] unless signed_payload_b64
return [false, "missing signature"] unless signature_b64
@@ -61,7 +61,7 @@ module PotatoMesh
end
payload_domain = string_or_nil(payload["domain"])
payload_pubkey = sanitize_public_key_pem(payload["publicKey"])
payload_pubkey = sanitize_public_key_pem(payload["public_key"] || payload["publicKey"])
return [false, "signed payload domain mismatch"] unless payload_domain&.casecmp?(domain)
return [false, "signed payload public key mismatch"] unless payload_pubkey == pubkey
+13 -9
View File
@@ -175,22 +175,26 @@ module PotatoMesh
domain_value = sanitize_instance_domain(app_constant(:INSTANCE_DOMAIN))
payload = {
publicKey: app_constant(:INSTANCE_PUBLIC_KEY_PEM),
name: sanitized_site_name,
version: app_constant(:APP_VERSION),
domain: domain_value,
lastUpdate: last_update,
"public_key" => app_constant(:INSTANCE_PUBLIC_KEY_PEM),
"name" => sanitized_site_name,
"version" => app_constant(:APP_VERSION),
"domain" => domain_value,
"last_update" => last_update,
}
signed_payload = JSON.generate(payload, sort_keys: true)
# Shared snake_case canonicalizer (SPEC FS1/FS4, U0) stamps
# signature_version inside the signed bytes so the format cannot be
# silently downgraded.
signed_payload = canonical_signed_payload(payload)
signature = Base64.strict_encode64(
app_constant(:INSTANCE_PRIVATE_KEY).sign(OpenSSL::Digest::SHA256.new, signed_payload),
)
document = payload.merge(
signature: signature,
signatureAlgorithm: PotatoMesh::Config.instance_signature_algorithm,
signedPayload: Base64.strict_encode64(signed_payload),
"signature_version" => PotatoMesh::Config.federation_signature_version,
"signature" => signature,
"signature_algorithm" => PotatoMesh::Config.instance_signature_algorithm,
"signed_payload" => Base64.strict_encode64(signed_payload),
)
json_output = JSON.pretty_generate(document)
+12 -8
View File
@@ -134,20 +134,24 @@ module PotatoMesh
payload = {
"id" => id,
"domain" => domain,
"pubkey" => pubkey,
"public_key" => pubkey,
"name" => string_or_nil(row["name"]),
"version" => string_or_nil(row["version"]),
"channel" => string_or_nil(row["channel"]),
"frequency" => string_or_nil(row["frequency"]),
"latitude" => coerce_float(row["latitude"]),
"longitude" => coerce_float(row["longitude"]),
"lastUpdateTime" => last_update_time,
"isPrivate" => private_flag,
"nodesCount" => coerce_integer(row["nodes_count"]),
"meshcoreNodesCount" => coerce_integer(row["meshcore_nodes_count"]),
"meshtasticNodesCount" => coerce_integer(row["meshtastic_nodes_count"]),
"contactLink" => string_or_nil(row["contact_link"]),
"last_update" => last_update_time,
"is_private" => private_flag,
"nodes_count" => coerce_integer(row["nodes_count"]),
"meshcore_nodes_count" => coerce_integer(row["meshcore_nodes_count"]),
"meshtastic_nodes_count" => coerce_integer(row["meshtastic_nodes_count"]),
# reticulum count has no column (always 0 today); served signed=0 so a
# crawler re-verifying a relayed v2 record rebuilds the same canonical.
"reticulum_nodes_count" => 0,
"contact_link" => string_or_nil(row["contact_link"]),
"signature" => signature,
"signature_version" => PotatoMesh::Config.federation_signature_version,
}
payload.reject { |_, value| value.nil? }
@@ -194,7 +198,7 @@ module PotatoMesh
normalized = normalize_instance_row(row)
next unless normalized
last_update_time = normalized["lastUpdateTime"]
last_update_time = normalized["last_update"]
next unless last_update_time.is_a?(Integer) && last_update_time >= min_last_update_time
memo << normalized
@@ -142,24 +142,29 @@ module PotatoMesh
)
halt 400, { error: "invalid domain" }.to_json
end
pubkey = sanitize_public_key_pem(payload["pubkey"])
pubkey = sanitize_public_key_pem(payload["public_key"] || payload["pubkey"])
name = string_or_nil(payload["name"])
version = string_or_nil(payload["version"])
channel = string_or_nil(payload["channel"])
frequency = string_or_nil(payload["frequency"])
latitude = coerce_float(payload["latitude"])
longitude = coerce_float(payload["longitude"])
last_update_time = coerce_integer(payload["last_update_time"] || payload["lastUpdateTime"])
raw_private = payload.key?("isPrivate") ? payload["isPrivate"] : payload["is_private"]
last_update_time = coerce_integer(payload["last_update"] || payload["last_update_time"] || payload["lastUpdateTime"])
raw_private = if payload.key?("is_private")
payload["is_private"]
elsif payload.key?("isPrivate")
payload["isPrivate"]
end
is_private = coerce_boolean(raw_private)
signature = string_or_nil(payload["signature"])
# Accept either casing for third-party / cross-version compatibility
# (the existing camelCase keys plus their snake_case aliases). +id+,
# +lastUpdateTime+, and +isPrivate+ are already dual-keyed above.
# Accept both v2 (snake_case) and legacy v1 (camelCase) wire keys
# (SPEC FS4); the parsed counts (incl. reticulum) feed v2 signature
# verification before they are recomputed from the live node list.
contact_link = string_or_nil(payload["contactLink"] || payload["contact_link"])
nodes_count = coerce_integer(payload["nodesCount"] || payload["nodes_count"])
meshcore_nodes_count = coerce_integer(payload["meshcoreNodesCount"] || payload["meshcore_nodes_count"])
meshtastic_nodes_count = coerce_integer(payload["meshtasticNodesCount"] || payload["meshtastic_nodes_count"])
nodes_count = coerce_integer(payload["nodes_count"] || payload["nodesCount"])
meshcore_nodes_count = coerce_integer(payload["meshcore_nodes_count"] || payload["meshcoreNodesCount"])
meshtastic_nodes_count = coerce_integer(payload["meshtastic_nodes_count"] || payload["meshtasticNodesCount"])
reticulum_nodes_count = coerce_integer(payload["reticulum_nodes_count"])
attributes = {
id: id,
@@ -177,6 +182,9 @@ module PotatoMesh
nodes_count: nodes_count,
meshcore_nodes_count: meshcore_nodes_count,
meshtastic_nodes_count: meshtastic_nodes_count,
# Carried for v2 signature verification only (no DB column; always 0
# until a Reticulum ingestor exists).
reticulum_nodes_count: reticulum_nodes_count,
}
if [attributes[:id], attributes[:domain], attributes[:pubkey], signature, attributes[:last_update_time]].any?(&:nil?)
@@ -304,12 +312,12 @@ module PotatoMesh
halt 400, { error: freshness_reason || "stale node data" }.to_json
end
# Recompute node counts from the fetched node list so that
# nodes_count, meshcore_nodes_count, and meshtastic_nodes_count
# stay internally consistent. The announcement payload may carry
# sender-asserted counts, but those are unsigned and could diverge
# from the actual node data — overwriting them here is intentional.
if remote_nodes.is_a?(Array)
# Node-count fallback only (SPEC FS2/(a)): v2 announcements carry
# SIGNED counts, which we keep verbatim so the stored — and later
# relayed — record stays signature-consistent (a re-verifying peer
# rebuilds the same canonical). We derive counts from the fetched
# node list only when the announcement omits them.
if remote_nodes.is_a?(Array) && attributes[:nodes_count].nil?
cutoff = Time.now.to_i - PotatoMesh::Config.remote_instance_max_node_age
total = 0
meshcore = 0
+10
View File
@@ -423,6 +423,16 @@ module PotatoMesh
"rsa-sha256"
end
# Current federation signature/canonical format version (SPEC FS3). Stamped
# inside every signed payload (instance announcement + well-known) so the
# format cannot be silently downgraded; verifiers accept this version and the
# legacy unmarked v1 form.
#
# @return [Integer] the current signature format version.
def federation_signature_version
2
end
# Connection timeout used when establishing federation HTTP sockets.
#
# The timeout can be customised with the REMOTE_INSTANCE_CONNECT_TIMEOUT
+9 -9
View File
@@ -384,7 +384,7 @@ export async function initializeFederationPage(options = {}) {
defaultDirection: 'asc'
},
domain: { getValue: inst => inst.domain ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
contact: { getValue: inst => inst.contactLink ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
contact: { getValue: inst => (inst.contact_link ?? inst.contactLink) ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
version: { getValue: inst => inst.version ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
channel: { getValue: inst => inst.channel ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
frequency: { getValue: inst => inst.frequency ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
@@ -395,13 +395,13 @@ export async function initializeFederationPage(options = {}) {
defaultDirection: 'desc'
},
meshcoreNodesCount: {
getValue: inst => toFiniteNumber(inst.meshcoreNodesCount),
getValue: inst => toFiniteNumber(inst.meshcore_nodes_count ?? inst.meshcoreNodesCount),
compare: compareNumber,
hasValue: hasNumberValue,
defaultDirection: 'desc'
},
meshtasticNodesCount: {
getValue: inst => toFiniteNumber(inst.meshtasticNodesCount),
getValue: inst => toFiniteNumber(inst.meshtastic_nodes_count ?? inst.meshtasticNodesCount),
compare: compareNumber,
hasValue: hasNumberValue,
defaultDirection: 'desc'
@@ -409,7 +409,7 @@ export async function initializeFederationPage(options = {}) {
latitude: { getValue: inst => toFiniteNumber(inst.latitude), compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'asc' },
longitude: { getValue: inst => toFiniteNumber(inst.longitude), compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'asc' },
lastUpdateTime: {
getValue: inst => toFiniteNumber(inst.lastUpdateTime),
getValue: inst => toFiniteNumber(inst.last_update ?? inst.lastUpdateTime),
compare: compareNumber,
hasValue: hasNumberValue,
defaultDirection: 'desc'
@@ -488,12 +488,12 @@ export async function initializeFederationPage(options = {}) {
const domainHtml = url
? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">${escapeHtml(instance.domain || '')}</a>`
: escapeHtml(instance.domain || '');
const contactHtml = renderContactHtml(instance.contactLink);
const nodesCountValue = toFiniteNumber(instance.nodesCount ?? instance.nodes_count);
const contactHtml = renderContactHtml(instance.contact_link ?? instance.contactLink);
const nodesCountValue = toFiniteNumber(instance.nodes_count ?? instance.nodesCount);
const nodesCountText = nodesCountValue == null ? '<em>—</em>' : escapeHtml(String(nodesCountValue));
const mcNodesVal = toFiniteNumber(instance.meshcoreNodesCount);
const mcNodesVal = toFiniteNumber(instance.meshcore_nodes_count ?? instance.meshcoreNodesCount);
const mcNodesText = mcNodesVal == null ? '<em>—</em>' : `${meshcoreIconHtml()} ${escapeHtml(String(mcNodesVal))}`;
const mtNodesVal = toFiniteNumber(instance.meshtasticNodesCount);
const mtNodesVal = toFiniteNumber(instance.meshtastic_nodes_count ?? instance.meshtasticNodesCount);
const mtNodesText = mtNodesVal == null ? '<em>—</em>' : `${meshtasticIconHtml()} ${escapeHtml(String(mtNodesVal))}`;
tr.innerHTML = `
@@ -508,7 +508,7 @@ export async function initializeFederationPage(options = {}) {
<td class="instances-col instances-col--meshtastic-nodes mono">${mtNodesText}</td>
<td class="instances-col instances-col--latitude mono">${fmtCoords(instance.latitude)}</td>
<td class="instances-col instances-col--longitude mono">${fmtCoords(instance.longitude)}</td>
<td class="instances-col instances-col--last-update mono">${timeAgo(instance.lastUpdateTime, nowSec)}</td>
<td class="instances-col instances-col--last-update mono">${timeAgo(instance.last_update ?? instance.lastUpdateTime, nowSec)}</td>
`;
frag.appendChild(tr);
+62 -61
View File
@@ -1035,26 +1035,23 @@ RSpec.describe "Potato Mesh Sinatra app" do
it "returns the sanitized domain when configuration is present" do
ENV.delete("APP_ENV")
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", " Example.Org ") do
expect(application_class.self_instance_domain).to eq("example.org")
end
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", " Example.Org ")
expect(application_class.self_instance_domain).to eq("example.org")
end
it "returns nil when the domain is unavailable outside production" do
ENV["APP_ENV"] = "development"
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", nil) do
expect(application_class.self_instance_domain).to be_nil
end
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", nil)
expect(application_class.self_instance_domain).to be_nil
end
it "raises when the domain is unavailable in production" do
ENV["APP_ENV"] = "production"
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", nil) do
expect { application_class.self_instance_domain }.to raise_error(
RuntimeError,
"INSTANCE_DOMAIN could not be determined",
)
end
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", nil)
expect { application_class.self_instance_domain }.to raise_error(
RuntimeError,
"INSTANCE_DOMAIN could not be determined",
)
end
end
@@ -1062,67 +1059,60 @@ RSpec.describe "Potato Mesh Sinatra app" do
let(:domain) { "spec.mesh.test" }
it "rejects registration when the domain source is not the environment" do
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :reverse_dns) do
allowed, reason = application_class.self_instance_registration_decision(domain)
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :reverse_dns)
allowed, reason = application_class.self_instance_registration_decision(domain)
expect(allowed).to be(false)
expect(reason).to eq("INSTANCE_DOMAIN source is reverse_dns")
end
expect(allowed).to be(false)
expect(reason).to eq("INSTANCE_DOMAIN source is reverse_dns")
end
it "rejects registration when the domain is invalid" do
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :environment) do
allowed, reason = application_class.self_instance_registration_decision(nil)
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :environment)
allowed, reason = application_class.self_instance_registration_decision(nil)
expect(allowed).to be(false)
expect(reason).to eq("INSTANCE_DOMAIN missing or invalid")
end
expect(allowed).to be(false)
expect(reason).to eq("INSTANCE_DOMAIN missing or invalid")
end
it "rejects registration when the domain resolves to a restricted IP" do
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :environment) do
allowed, reason = application_class.self_instance_registration_decision("127.0.0.1")
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :environment)
allowed, reason = application_class.self_instance_registration_decision("127.0.0.1")
expect(allowed).to be(false)
expect(reason).to eq("INSTANCE_DOMAIN resolves to restricted IP")
end
expect(allowed).to be(false)
expect(reason).to eq("INSTANCE_DOMAIN resolves to restricted IP")
end
it "accepts registration when configuration is valid" do
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :environment) do
allowed, reason = application_class.self_instance_registration_decision(domain)
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :environment)
allowed, reason = application_class.self_instance_registration_decision(domain)
expect(allowed).to be(true)
expect(reason).to be_nil
end
expect(allowed).to be(true)
expect(reason).to be_nil
end
end
describe ".ensure_self_instance_record!" do
it "persists the self instance when registration is allowed" do
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :environment) do
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", "self.mesh") do
with_db do |db|
db.execute("DELETE FROM instances")
end
application_class.ensure_self_instance_record!
expect(instance_count).to eq(1)
end
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :environment)
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", "self.mesh")
with_db do |db|
db.execute("DELETE FROM instances")
end
application_class.ensure_self_instance_record!
expect(instance_count).to eq(1)
end
it "skips persistence when registration is not allowed" do
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :reverse_dns) do
with_db do |db|
db.execute("DELETE FROM instances")
end
application_class.ensure_self_instance_record!
expect(instance_count).to eq(0)
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :reverse_dns)
with_db do |db|
db.execute("DELETE FROM instances")
end
application_class.ensure_self_instance_record!
expect(instance_count).to eq(0)
end
end
end
@@ -1238,16 +1228,22 @@ RSpec.describe "Potato Mesh Sinatra app" do
db.execute("INSERT INTO nodes (node_id, last_heard) VALUES (?, ?)", ["node-z", 321])
end
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", "Example.NET") do
json_output, signature = application_class.build_well_known_document
document = JSON.parse(json_output)
# NOTE: stub_const does not take a block (the previous block form silently
# skipped every assertion below). Stub for the example, then assert inline.
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", "Example.NET")
json_output, signature = application_class.build_well_known_document
document = JSON.parse(json_output)
expect(document["domain"]).to eq("example.net")
expect(document["lastUpdate"]).to eq(321)
expect(document["signatureAlgorithm"]).to eq("rsa-sha256")
expect(signature).to be_a(String)
expect(signature).not_to be_empty
end
# v2 well-known is snake_case with a signature_version marker (SPEC FS1/FS3).
expect(document["domain"]).to eq("example.net")
expect(document["last_update"]).to eq(321)
expect(document["signature_algorithm"]).to eq("rsa-sha256")
expect(document["signature_version"]).to eq(2)
expect(document["public_key"]).to eq(application_class::INSTANCE_PUBLIC_KEY_PEM)
expect(document).not_to have_key("lastUpdate")
expect(document).not_to have_key("signatureAlgorithm")
expect(signature).to be_a(String)
expect(signature).not_to be_empty
end
end
@@ -2735,8 +2731,13 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(self_entry).not_to be_nil
expect(self_entry["domain"]).not_to be_nil
expect(self_entry["isPrivate"]).to eq(false)
expect(self_entry["is_private"]).to eq(false)
expect(self_entry["signature"]).not_to be_nil
expect(self_entry["signature_version"]).to eq(2)
# FS-A4: wire is snake_case only — no camelCase keys leak through.
expect(self_entry).not_to have_key("isPrivate")
expect(self_entry).not_to have_key("lastUpdateTime")
expect(self_entry).not_to have_key("nodesCount")
end
it "exposes validated instance records from fixture data" do
@@ -2789,7 +2790,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(remote_entry).not_to be_nil
expect(remote_entry["domain"]).to eq("remote.example")
expect(remote_entry["isPrivate"]).to eq(false)
expect(remote_entry["is_private"]).to eq(false)
expect(remote_entry["signature"]).to eq(remote_signature)
end
+57 -3
View File
@@ -1151,11 +1151,17 @@ RSpec.describe PotatoMesh::App::Federation do
nodes_count: 42,
meshcore_nodes_count: 30,
meshtastic_nodes_count: 12,
reticulum_nodes_count: 0,
}
payload = federation_helpers.instance_announcement_payload(attributes, "sig")
expect(payload["nodesCount"]).to eq(42)
expect(payload["meshcoreNodesCount"]).to eq(30)
expect(payload["meshtasticNodesCount"]).to eq(12)
expect(payload["nodes_count"]).to eq(42)
expect(payload["meshcore_nodes_count"]).to eq(30)
expect(payload["meshtastic_nodes_count"]).to eq(12)
# v2 wire is snake_case with a forward-compat reticulum count + version marker.
expect(payload["reticulum_nodes_count"]).to eq(0)
expect(payload["public_key"]).to eq("key")
expect(payload["signature_version"]).to eq(2)
expect(payload).not_to have_key("nodesCount")
end
it "omits node count fields when nil" do
@@ -2528,6 +2534,54 @@ RSpec.describe PotatoMesh::App::Federation do
end
end
describe "instance signature v2 migration (FS1-FS4)" do
let(:application_class) { PotatoMesh::Application }
let(:keypair) { OpenSSL::PKey::RSA.new(2048) }
let(:pubkey_pem) { keypair.public_key.to_pem }
let(:attributes) do
{
id: "inst-1", domain: "a.mesh", pubkey: pubkey_pem, name: "A",
version: "0.7.0", channel: "#x", frequency: "915MHz",
latitude: 1.0, longitude: 2.0, last_update_time: 1_700_000_000,
is_private: false, contact_link: "#a:x", nodes_count: 7,
meshcore_nodes_count: 3, meshtastic_nodes_count: 4, reticulum_nodes_count: 0,
}
end
def sign_canonical(canonical)
Base64.strict_encode64(keypair.sign(OpenSSL::Digest::SHA256.new, canonical))
end
it "signs the v2 (snake_case) canonical with a signature_version marker and signed counts" do
parsed = JSON.parse(application_class.canonical_instance_payload(attributes))
expect(parsed).to include(
"public_key", "last_update", "is_private", "contact_link",
"nodes_count", "meshcore_nodes_count", "meshtastic_nodes_count",
"reticulum_nodes_count", "signature_version"
)
expect(parsed["signature_version"]).to eq(2)
expect(parsed).not_to have_key("publicKey")
expect(parsed).not_to have_key("lastUpdateTime")
expect(parsed).not_to have_key("isPrivate")
end
it "verifies a v2 signature" do
sig = sign_canonical(application_class.canonical_instance_payload_v2(attributes))
expect(application_class.verify_instance_signature(attributes, sig, pubkey_pem)).to be(true)
end
it "still verifies a legacy v1 (camelCase) signature — backward accept" do
sig = sign_canonical(application_class.canonical_instance_payload_v1(attributes))
expect(application_class.verify_instance_signature(attributes, sig, pubkey_pem)).to be(true)
end
it "rejects a tampered node count (counts are signed in v2)" do
sig = sign_canonical(application_class.canonical_instance_payload_v2(attributes))
tampered = attributes.merge(nodes_count: 999)
expect(application_class.verify_instance_signature(tampered, sig, pubkey_pem)).to be(false)
end
end
describe ".validate_well_known_document" do
let(:application_class) { PotatoMesh::Application }
let(:domain) { "remote.mesh" }
+9 -9
View File
@@ -94,7 +94,7 @@ RSpec.describe PotatoMesh::App::Instances do
expect(domains).to include(application_class.app_constant(:INSTANCE_DOMAIN))
expect(domains).not_to include("stale.mesh.test")
expect(domains).not_to include("missing.mesh.test")
expect(payload.all? { |row| row["lastUpdateTime"] >= lower_bound }).to be(true)
expect(payload.all? { |row| row["last_update"] >= lower_bound }).to be(true)
end
it "exposes contactLink when present and omits blank values" do
@@ -130,8 +130,8 @@ RSpec.describe PotatoMesh::App::Instances do
with_contact = payload.find { |row| row["domain"] == "alpha.mesh.test" }
without_contact = payload.find { |row| row["domain"] == "beta.mesh.test" }
expect(with_contact["contactLink"]).to eq("https://example.org/contact")
expect(without_contact.key?("contactLink")).to be(false)
expect(with_contact["contact_link"]).to eq("https://example.org/contact")
expect(without_contact.key?("contact_link")).to be(false)
end
it "includes nodesCount values, preserving zeros" do
@@ -173,8 +173,8 @@ RSpec.describe PotatoMesh::App::Instances do
with_nodes = payload.find { |row| row["domain"] == "gamma.mesh.test" }
zero_nodes = payload.find { |row| row["domain"] == "delta.mesh.test" }
expect(with_nodes["nodesCount"]).to eq(42)
expect(zero_nodes["nodesCount"]).to eq(0)
expect(with_nodes["nodes_count"]).to eq(42)
expect(zero_nodes["nodes_count"]).to eq(0)
end
it "includes per-protocol node counts when present and omits when nil" do
@@ -218,10 +218,10 @@ RSpec.describe PotatoMesh::App::Instances do
with_proto = payload.find { |row| row["domain"] == "proto.mesh.test" }
without_proto = payload.find { |row| row["domain"] == "noproto.mesh.test" }
expect(with_proto["meshcoreNodesCount"]).to eq(20)
expect(with_proto["meshtasticNodesCount"]).to eq(30)
expect(without_proto.key?("meshcoreNodesCount")).to be(false)
expect(without_proto.key?("meshtasticNodesCount")).to be(false)
expect(with_proto["meshcore_nodes_count"]).to eq(20)
expect(with_proto["meshtastic_nodes_count"]).to eq(30)
expect(without_proto.key?("meshcore_nodes_count")).to be(false)
expect(without_proto.key?("meshtastic_nodes_count")).to be(false)
end
end
end