web: fix apis to use consistently use camel case (#802)

This commit is contained in:
l5y
2026-06-21 20:17:40 +02:00
committed by GitHub
parent 5e0363a0ec
commit af64b2c60f
13 changed files with 436 additions and 136 deletions
@@ -0,0 +1,4 @@
# Memory index
- [Web app local run](web-app-local-run.md) — rackup fails (no config.ru); use `ruby app.rb`, binds 41447
- [API casing & federation signature](api-casing-and-federation-signature.md) — federation wire is camelCase because it's signed; read API is snake_case; don't rename signed keys
@@ -0,0 +1,37 @@
---
name: api-casing-and-federation-signature
description: API JSON casing map + the camelCase federation-signature landmine
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.
**HTTP API casing map (as of 0.7.0):**
- 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).
**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.
See [[web-app-local-run]] for running the server to curl these.
+102 -5
View File
@@ -99,9 +99,10 @@ private mode — `web/lib/potato_mesh/application/routes/api.rb:49`).
**A2b. Private flag is advertised (the client uses it to hide chat).**
```bash
curl -s http://127.0.0.1:41447/version | grep -o '"privateMode":true'
curl -s http://127.0.0.1:41447/version | grep -o '"private_mode":true'
```
**Expected:** prints `"privateMode":true`.
**Expected:** prints `"private_mode":true` (snake_case as of 0.7.0 — see
[§ Bugfix: API casing consistency](#bugfix-api-casing-consistency)).
**A2c. Node opt-out marker is honored wherever data is listed/exported.**
```bash
@@ -339,9 +340,11 @@ Maps to decisions **D10, D11** and the README. *Server env per check.*
```bash
curl -s http://127.0.0.1:41447/version
```
**Expected:** a JSON `config` block exposing `siteName`, `channel`, `frequency`,
`contactLink`, `mapCenter` (`lat`/`lon`), `maxDistanceKm`, `instanceDomain`, and
`privateMode`, reflecting the env vars set at boot (README "Web App" table).
**Expected:** a JSON `config` block exposing `site_name`, `channel`, `frequency`,
`contact_link`, `map_center` (`lat`/`lon`), `max_distance_km`, `instance_domain`,
and `private_mode`, reflecting the env vars set at boot (README "Web App" table).
Keys are snake_case as of 0.7.0 (see
[§ Bugfix: API casing consistency](#bugfix-api-casing-consistency)).
### D2 — `ALLOWED_CHANNELS` / `HIDDEN_CHANNELS` enforced (ingestor)
```bash
@@ -572,3 +575,97 @@ still 404s in private mode **and** message counts are now zeroed, S-A4); and
and the dashboard consumer (`stats.js`) are **updated** to read `total.nodes` from
the new shape, not removed. No POST/event contract changes, so **C2** and the
Python suite are unaffected.
---
## Bugfix: API casing consistency
Two casing inconsistencies on the HTTP API, fixed as a versioned breaking change
(0.7.0). The `/version` JSON response moves to snake_case (matching every other
read response and `/api/stats`); `POST /api/nodes` **additionally** accepts
snake_case node fields so the ingest contract is no longer Meshtastic-camelCase
only. The **signed federation wire** (`/.well-known`, `/api/instances`) is
deliberately **unchanged** (camelCase — its keys are part of the instance
signature, `federation/signature.rb`).
*Run the server in public mode (`API_TOKEN=acctest PRIVATE=0 FEDERATION=0 bundle exec ruby app.rb`).*
### BF-A1 — `/version` response is snake_case
```bash
( cd web && bundle exec rspec spec/app_spec.rb -e "exposes the /version config block in snake_case" )
```
**Expected:** pass. `GET /version` returns a `config` block keyed in snake_case
(`site_name`, `map_center` `{lat,lon}`, `private_mode`, `instance_domain`,
`contact_link`, `contact_link_url`, `max_distance_km`, `refresh_interval_seconds`)
plus a top-level `last_node_update`. The pre-0.7.0 camelCase keys (`siteName`,
`mapCenter`, `privateMode`, …, `lastNodeUpdate`) are **gone**. The federation wire
(`/.well-known`, `/api/instances`) stays camelCase (signed).
### BF-A2 — `POST /api/nodes` accepts snake_case node fields
```bash
( cd web && bundle exec rspec spec/app_spec.rb -e "accepts snake_case node fields on POST /api/nodes" )
```
**Expected:** pass. A node POSTed with snake_case fields (`last_heard`,
`user.short_name`/`long_name`/`hw_model`, `device_metrics.battery_level`,
`position.latitude`/`longitude`) is stored and surfaces on `GET /api/nodes`.
camelCase Meshtastic input (`lastHeard`, `user.shortName`, …) continues to work
unchanged — acceptance is **additive**, so the existing Python ingestor is
unaffected.
### BF-R1 — Regression: prior acceptance still holds
```bash
( cd web && npm test ) && ( cd web && bundle exec rspec )
( . .venv/bin/activate && pytest -q tests/ )
```
**Expected:** every prior check still passes. Updated for the `/version` break:
**A2b** now asserts `"private_mode":true` (was `"privateMode":true`) and **D1**
lists the snake_case config keys. The deployed Flutter app reads the new
`/version` keys (`app/lib/main.dart`); older app builds break until updated (the
accepted one-way cost of the clean break). `data-app-config` (the server→frontend
DOM channel) is intentionally **out of scope** and stays camelCase.
---
## Bugfix: API consistency cleanups (I2/I3/I5/I6)
Four small API consistency fixes shipped in 0.7.0 alongside the casing change
above. The signed federation wire (`/.well-known`, `/api/instances` output, the
canonical signed payload) stays untouched throughout.
### IC-A1 — `POST /api/instances` accepts both key casings (I6)
```bash
( cd web && bundle exec rspec spec/app_spec.rb -e "accepts snake_case optional fields on POST /api/instances" )
```
**Expected:** pass. Optional fields (`contact_link`, `nodes_count`, …) accept
snake_case in addition to camelCase; the camelCase keys and the camelCase signed
canonical payload are unchanged.
### IC-A2 — Only `position_time`, no ISO twin (I2)
```bash
( cd web && bundle exec rspec spec/app_spec.rb -e "/api/nodes" -e "/api/positions" )
```
**Expected:** pass. `GET /api/nodes` and `/api/positions` emit `position_time`
(unix int) and **no** `pos_time_iso` / `position_time_iso`.
### IC-A3 — POST ingest routes return 201 (I3)
```bash
( cd web && bundle exec rspec spec/app_spec.rb -e "POST ingest status codes" )
```
**Expected:** pass. Every `POST /api/*` ingest route returns `201 Created`
(matching `/api/instances`). The ingestor treats any 2xx as success.
### IC-A4 — List POST routes reject malformed payloads (I5)
```bash
( cd web && bundle exec rspec spec/app_spec.rb -e "POST payload validation" )
```
**Expected:** pass. `/api/messages|positions|telemetry|neighbors|traces` return
`400 {"error":"invalid payload"}` for a non-array/non-object body, matching the
`/api/nodes` Hash check.
### IC-R1 — Regression
```bash
( cd web && npm test ) && ( cd web && bundle exec rspec )
( . .venv/bin/activate && pytest -q tests/ ) && ( cd matrix && cargo test --all --all-features )
```
**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.
+23
View File
@@ -245,3 +245,26 @@ degrade gracefully to their existing node-list fallback). Integrates with
| **S5** | **Privacy: messages zeroed in private mode** (Invariant II). When `private_mode?`, every `messages` count (in `total` and all protocol scopes) is **0**, mirroring the `PRIVATE=1` message-API 404 (A2a) so stats never leak message volume that privacy hides. Node counts keep the `CLIENT_HIDDEN` exclusion; **all** metrics honor the node opt-out marker via the per-table opt-out filter (`opt_out_self_filter` for `nodes`; `opt_out_node_id_filter` / `opt_out_node_num_filter` for the message and telemetry-umbrella tables, matching the existing list endpoints). Telemetry/positions/neighbors/traces are not gated by `PRIVATE`, so those counts remain reported. | interview |
| **S6** | **`reticulum` is a forward-looking zero stub.** A `reticulum` scope is always emitted with all-zero counts and an in-code `# stub` comment, so the shape extends to future protocols without another break. It adds **no** ingest path (Invariant I), privileges no protocol (Invariant IV), and does **not** enter `KNOWN_PROTOCOLS` (which still gates the `?protocol=` query param at `meshcore` + `meshtastic`). | interview |
| **S7** | **One-way federation compatibility (new reads old).** Federation consumers (`crawl.rb`) try the new shape first (`total.nodes[window]`, `meshcore.nodes.day`, `meshtastic.nodes.day`) then fall back to the old shape (`active_nodes[window]`, `meshcore.day`, `meshtastic.day`), then to the existing node-list fallback. Detection is **structural** (key presence/shape) — no in-band version field. New instances read both old and new peers; old instances reading a new peer degrade gracefully (the accepted one-way limit). | interview |
---
## Bugfix: API casing consistency
Removes two casing inconsistencies on the HTTP API, shipped within the same
versioned 0.7.0 break. Background: every read collection (`/api/nodes`,
`/api/messages`, `/api/positions`, …) and `/api/stats` already emit snake_case;
the lone camelCase **read response** was `/version`, and `POST /api/nodes` was the
lone camelCase **ingest input** (Meshtastic-shaped). PotatoMesh is multi-protocol
and no longer bound to the Meshtastic JSON convention, so the contract is amended
to standardise on snake_case while preserving compatibility where it is load-bearing.
| # | Decision | Source |
| --- | --- | --- |
| **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 |
| **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 |
| **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 |
+10 -2
View File
@@ -33,7 +33,13 @@ Payload is a mapping keyed by canonical node id, with optional top-level `”ing
Protocol resolution per-row honours, in order: (1) an explicit per-node `”protocol”` field inside the node entry; (2) the wrapper-level top-level `”protocol”` key; (3) the registered ingestor's protocol (see `POST /api/ingestors`); (4) `”meshtastic”` as the final default. Valid values are `”meshtastic”` and `”meshcore”` — values outside this set fall through to the next source. The wrapper stamp is what the Python ingestor emits unconditionally so the web app classifies records correctly even before the ingestor heartbeat is processed (closes the startup race that misclassified MeshCore placeholders as Meshtastic).
Node entry fields are “Meshtastic-ish” (camelCase) and may include:
Node entry fields are “Meshtastic-ish” (camelCase) and may include the following.
**As of 0.7.0 each field is additionally accepted in snake_case** (e.g.
`last_heard`, `user.short_name`, `user.hw_model`, `device_metrics.battery_level`,
`position.location_source`) so the node ingest contract is no longer
Meshtastic-camelCase-only; the existing collector keeps emitting camelCase, which
remains accepted. Per-field acceptance is nil-aware, so a camelCase value of
`false` is never overridden by a snake_case alias. Fields:
- `num` (int node number)
- `lastHeard` (int unix seconds)
@@ -54,7 +60,7 @@ Node entry fields are “Meshtastic-ish” (camelCase) and may include:
The web application applies the same normalisation as a safety net so legacy ingestors and replayed payloads cannot reintroduce the sentinels, but new ingestors should strip them at the source so the cross-network contract stays clean.
**Wire-format note for federation peers (issue #782).** GET responses (`/api/nodes`, `/api/positions`) compact sentinel rows by **omitting** the `position_time` and `pos_time_iso` / `position_time_iso` keys rather than emitting them as `0` or `"1970-01-01T00:00:00Z"`. Federation peers consuming this API and any third-party clients SHOULD treat an *absent* `position_time` as "no GPS lock recorded" and not synthesise a zero or epoch value when re-serialising. Older peers that key on `position_time == 0` may need a small adjustment.
**Wire-format note for federation peers (issue #782).** Position time is exposed **only** as `position_time` (unix seconds) on GET responses (`/api/nodes`, `/api/positions`); the redundant ISO twin (`pos_time_iso` on `/api/nodes`, `position_time_iso` on `/api/positions`) was **removed in 0.7.0** — clients format `position_time` themselves. Sentinel rows are compacted by **omitting** `position_time` rather than emitting `0` or `"1970-01-01T00:00:00Z"`. Federation peers consuming this API and any third-party clients SHOULD treat an *absent* `position_time` as "no GPS lock recorded" and not synthesise a zero or epoch value when re-serialising. Older peers that key on `position_time == 0` may need a small adjustment.
#### `POST /api/messages`
@@ -163,6 +169,8 @@ Heartbeat payload:
**Protocol propagation**: all event records (`messages`, `positions`, `telemetry`, `traces`, `neighbors`) that reference this ingestor via their `ingestor` field inherit its `protocol` value at write time when no explicit per-record `protocol` stamp is present. Per-record stamps take precedence — the ingestor heartbeat default only kicks in when the per-record field is absent or malformed.
**POST response & validation (0.7.0).** Every `POST /api/*` ingest route returns `201 Created` with `{"status":"ok"}` on success (`POST /api/instances` returns `{"status":"registered"}`). A batch route (`messages` / `positions` / `telemetry` / `neighbors` / `traces`) accepts either a single record object or an array of them; any other top-level JSON type is rejected with `400 {"error":"invalid payload"}`, matching the `/api/nodes` and `/api/ingestors` object check. Clients should treat any `2xx` as success.
### GET endpoint filtering
All collection GET endpoints (`/api/nodes`, `/api/messages`, `/api/positions`, `/api/telemetry`, `/api/traces`, `/api/neighbors`, `/api/ingestors`) accept an optional `?protocol=<value>` query parameter. When present, only records whose `protocol` column matches the given value are returned. The `protocol` field is included in all GET responses.
@@ -155,14 +155,30 @@ module PotatoMesh
# @param n [Hash] node payload extracted from the ingestor.
# @param protocol [String] protocol identifier (default +meshtastic+).
# @return [void]
# Read +hash[primary]+, falling back to the first present alias key. Lets the
# node ingest contract accept snake_case fields in addition to the Meshtastic
# camelCase the collector emits today; nil-aware so a boolean +false+ from the
# primary key is never discarded in favour of an alias.
#
# @param hash [Object] candidate mapping (ignored unless a Hash).
# @param primary [String] preferred key.
# @param aliases [Array<String>] fallback keys, tried in order.
# @return [Object, nil] first non-nil value, or nil.
def pick_alias(hash, primary, *aliases)
return nil unless hash.is_a?(Hash)
return hash[primary] unless hash[primary].nil?
aliases.each { |key| return hash[key] unless hash[key].nil? }
nil
end
def upsert_node(db, node_id, n, protocol: "meshtastic")
user = n["user"] || {}
met = n["deviceMetrics"] || {}
met = pick_alias(n, "deviceMetrics", "device_metrics") || {}
pos = n["position"] || {}
# nil when user info absent; COALESCE in the conflict clause preserves
# the stored role rather than overwriting with a default.
role = user["role"]
lh = coerce_integer(n["lastHeard"])
lh = coerce_integer(pick_alias(n, "lastHeard", "last_heard"))
now = Time.now.to_i
# Issue #782: drop Meshtastic "no GPS lock" sentinels at the write
# boundary so neither the nodes row nor downstream readers ever see
@@ -187,7 +203,7 @@ module PotatoMesh
loc_source = nil
else
alt = pos["altitude"]
loc_source = pos["locationSource"]
loc_source = pick_alias(pos, "locationSource", "location_source")
end
node_num = resolve_node_num(node_id, n)
@@ -202,7 +218,7 @@ module PotatoMesh
# Synthetic flag: true for placeholder nodes created from channel message
# sender names before the real contact advertisement is received.
synthetic = user["synthetic"] ? 1 : 0
long_name = user["longName"]
long_name = pick_alias(user, "longName", "long_name")
# If the incoming long name is a generic placeholder, prefer any real
# name already on record so we never stomp known data with fallback
@@ -224,23 +240,23 @@ module PotatoMesh
row = [
node_id,
node_num,
user["shortName"],
pick_alias(user, "shortName", "short_name"),
long_name,
user["macaddr"],
user["hwModel"] || n["hwModel"],
pick_alias(user, "hwModel", "hw_model") || pick_alias(n, "hwModel", "hw_model"),
role,
user["publicKey"],
coerce_bool(user["isUnmessagable"]),
coerce_bool(n["isFavorite"]),
n["hopsAway"],
pick_alias(user, "publicKey", "public_key"),
coerce_bool(pick_alias(user, "isUnmessagable", "is_unmessagable")),
coerce_bool(pick_alias(n, "isFavorite", "is_favorite")),
pick_alias(n, "hopsAway", "hops_away"),
n["snr"],
lh,
lh,
met["batteryLevel"],
pick_alias(met, "batteryLevel", "battery_level"),
met["voltage"],
met["channelUtilization"],
met["airUtilTx"],
met["uptimeSeconds"],
pick_alias(met, "channelUtilization", "channel_utilization"),
pick_alias(met, "airUtilTx", "air_util_tx"),
pick_alias(met, "uptimeSeconds", "uptime_seconds"),
pt,
loc_source,
coerce_integer(
@@ -67,7 +67,7 @@ module PotatoMesh
position_time = coerce_positive_or_nil(r["position_time"], ceiling: now)
r["position_time"] = position_time
r["position_time_iso"] = Time.at(position_time).utc.iso8601 if position_time
# I2: only position_time (unix int) is emitted; no ISO twin.
r["precision_bits"] = coerce_integer(r["precision_bits"])
r["sats_in_view"] = coerce_integer(r["sats_in_view"])
@@ -210,7 +210,8 @@ module PotatoMesh
r["last_heard"] = lh
r["position_time"] = pt
r["last_seen_iso"] = Time.at(lh).utc.iso8601 if lh
r["pos_time_iso"] = Time.at(pt).utc.iso8601 if pt
# I2: position_time (unix int) is the sole position-time key; the
# redundant ISO twin (pos_time_iso / position_time_iso) is not emitted.
pb = r["precision_bits"]
r["precision_bits"] = pb.to_i if pb
end
@@ -56,21 +56,21 @@ module PotatoMesh
payload = {
name: sanitized_site_name,
version: app_constant(:APP_VERSION),
lastNodeUpdate: last_update,
last_node_update: last_update,
config: {
siteName: sanitized_site_name,
site_name: sanitized_site_name,
channel: sanitized_channel,
frequency: sanitized_frequency,
contactLink: sanitized_contact_link,
contactLinkUrl: sanitized_contact_link_url,
refreshIntervalSeconds: PotatoMesh::Config.refresh_interval_seconds,
mapCenter: {
contact_link: sanitized_contact_link,
contact_link_url: sanitized_contact_link_url,
refresh_interval_seconds: PotatoMesh::Config.refresh_interval_seconds,
map_center: {
lat: PotatoMesh::Config.map_center_lat,
lon: PotatoMesh::Config.map_center_lon,
},
maxDistanceKm: PotatoMesh::Config.max_distance_km,
instanceDomain: app_constant(:INSTANCE_DOMAIN),
privateMode: private_mode?,
max_distance_km: PotatoMesh::Config.max_distance_km,
instance_domain: app_constant(:INSTANCE_DOMAIN),
private_mode: private_mode?,
},
}
payload.to_json
@@ -53,6 +53,7 @@ module PotatoMesh
end
PotatoMesh::App::Prometheus::NODES_GAUGE.set(query_nodes(1000).length)
PotatoMesh::App::ApiCache.invalidate_prefix("api:nodes:", "api:stats:")
status 201
{ status: "ok" }.to_json
ensure
db&.close
@@ -66,6 +67,9 @@ module PotatoMesh
rescue JSON::ParserError
halt 400, { error: "invalid JSON" }.to_json
end
unless data.is_a?(Array) || data.is_a?(Hash)
halt 400, { error: "invalid payload" }.to_json
end
messages = data.is_a?(Array) ? data : [data]
halt 400, { error: "too many messages" }.to_json if messages.size > 1000
db = open_database
@@ -74,6 +78,7 @@ module PotatoMesh
insert_message(db, msg, protocol_cache: protocol_cache)
end
PotatoMesh::App::ApiCache.invalidate_prefix("api:messages:", "api:stats:")
status 201
{ status: "ok" }.to_json
ensure
db&.close
@@ -94,6 +99,7 @@ module PotatoMesh
stored = upsert_ingestor(db, payload)
halt 400, { error: "invalid payload" }.to_json unless stored
PotatoMesh::App::ApiCache.invalidate_prefix("api:ingestors:")
status 201
{ status: "ok" }.to_json
ensure
db&.close
@@ -147,10 +153,13 @@ module PotatoMesh
raw_private = payload.key?("isPrivate") ? payload["isPrivate"] : payload["is_private"]
is_private = coerce_boolean(raw_private)
signature = string_or_nil(payload["signature"])
contact_link = string_or_nil(payload["contactLink"])
nodes_count = coerce_integer(payload["nodesCount"])
meshcore_nodes_count = coerce_integer(payload["meshcoreNodesCount"])
meshtastic_nodes_count = coerce_integer(payload["meshtasticNodesCount"])
# 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.
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"])
attributes = {
id: id,
@@ -352,6 +361,9 @@ module PotatoMesh
rescue JSON::ParserError
halt 400, { error: "invalid JSON" }.to_json
end
unless data.is_a?(Array) || data.is_a?(Hash)
halt 400, { error: "invalid payload" }.to_json
end
positions = data.is_a?(Array) ? data : [data]
halt 400, { error: "too many positions" }.to_json if positions.size > 1000
db = open_database
@@ -360,6 +372,7 @@ module PotatoMesh
insert_position(db, pos, protocol_cache: protocol_cache)
end
PotatoMesh::App::ApiCache.invalidate_prefix("api:positions:", "api:nodes:", "api:stats:")
status 201
{ status: "ok" }.to_json
ensure
db&.close
@@ -373,6 +386,9 @@ module PotatoMesh
rescue JSON::ParserError
halt 400, { error: "invalid JSON" }.to_json
end
unless data.is_a?(Array) || data.is_a?(Hash)
halt 400, { error: "invalid payload" }.to_json
end
neighbor_payloads = data.is_a?(Array) ? data : [data]
halt 400, { error: "too many neighbor packets" }.to_json if neighbor_payloads.size > 1000
db = open_database
@@ -381,6 +397,7 @@ module PotatoMesh
insert_neighbors(db, packet, protocol_cache: protocol_cache)
end
PotatoMesh::App::ApiCache.invalidate_prefix("api:neighbors:", "api:stats:")
status 201
{ status: "ok" }.to_json
ensure
db&.close
@@ -394,6 +411,9 @@ module PotatoMesh
rescue JSON::ParserError
halt 400, { error: "invalid JSON" }.to_json
end
unless data.is_a?(Array) || data.is_a?(Hash)
halt 400, { error: "invalid payload" }.to_json
end
telemetry_packets = data.is_a?(Array) ? data : [data]
halt 400, { error: "too many telemetry packets" }.to_json if telemetry_packets.size > 1000
db = open_database
@@ -402,6 +422,7 @@ module PotatoMesh
insert_telemetry(db, packet, protocol_cache: protocol_cache)
end
PotatoMesh::App::ApiCache.invalidate_prefix("api:telemetry:", "api:stats:")
status 201
{ status: "ok" }.to_json
ensure
db&.close
@@ -415,6 +436,9 @@ module PotatoMesh
rescue JSON::ParserError
halt 400, { error: "invalid JSON" }.to_json
end
unless data.is_a?(Array) || data.is_a?(Hash)
halt 400, { error: "invalid payload" }.to_json
end
trace_packets = data.is_a?(Array) ? data : [data]
halt 400, { error: "too many traces" }.to_json if trace_packets.size > 1000
db = open_database
@@ -423,6 +447,7 @@ module PotatoMesh
insert_trace(db, packet, protocol_cache: protocol_cache)
end
PotatoMesh::App::ApiCache.invalidate_prefix("api:traces:", "api:stats:")
status 201
{ status: "ok" }.to_json
ensure
db&.close
+162 -73
View File
@@ -323,7 +323,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
nodes_fixture.each do |node|
payload = { node["node_id"] => build_node_payload(node) }
post "/api/nodes", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
end
end
@@ -335,7 +335,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
messages_fixture.each do |message|
payload = message.reject { |key, _| key == "node" }
post "/api/messages", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
end
end
@@ -347,7 +347,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
def import_positions_fixture(limit: positions_fixture.size)
positions_fixture.first(limit).each do |position|
post "/api/positions", position.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
end
end
@@ -1703,6 +1703,32 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
end
it "accepts snake_case optional fields on POST /api/instances" do
contact = "#room:example.org"
signed_attrs = instance_attributes.merge(contact_link: contact)
signature = Base64.strict_encode64(
instance_key.sign(OpenSSL::Digest::SHA256.new, canonical_instance_payload(signed_attrs)),
)
# snake_case contact_link plus the existing camelCase keys — third-party
# callers may send either casing (the camelCase keys stay accepted too).
snake_payload = instance_payload.merge(
"contact_link" => contact,
"signature" => signature,
)
post "/api/instances", snake_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT contact_link FROM instances WHERE id = ?",
[instance_attributes[:id]],
)
expect(row["contact_link"]).to eq(contact)
end
end
it "stores a federated instance when validation succeeds" do
post "/api/instances", instance_payload.to_json, { "CONTENT_TYPE" => "application/json" }
@@ -3030,7 +3056,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/nodes", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
db.results_as_hash = true
@@ -3065,7 +3091,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/nodes", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
db.results_as_hash = true
@@ -3099,7 +3125,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/nodes", nodeinfo_payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
db.results_as_hash = true
@@ -3159,7 +3185,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/nodes", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
@@ -3194,7 +3220,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/nodes", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
expect(call_count).to be >= 2
@@ -3242,21 +3268,21 @@ RSpec.describe "Potato Mesh Sinatra app" do
it "does not overwrite a real name with a meshtastic generic fallback" do
seed_node(long_name: "Peter's Node")
post_long_name("Meshtastic BEEF")
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(stored_long_name).to eq("Peter's Node")
end
it "writes a generic fallback when no name is on record" do
seed_node
post_long_name("Meshtastic BEEF")
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(stored_long_name).to eq("Meshtastic BEEF")
end
it "overwrites a generic fallback with a real name" do
seed_node(long_name: "Meshtastic BEEF")
post_long_name("Peter's Node")
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(stored_long_name).to eq("Peter's Node")
end
@@ -3268,7 +3294,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
auth_headers
seed_node(long_name: "Peter's Node")
post_long_name("Meshcore BEEF", ingestor: ingestor_id)
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(stored_long_name).to eq("Peter's Node")
end
end
@@ -3416,9 +3442,9 @@ RSpec.describe "Potato Mesh Sinatra app" do
def post_twice_for_ingestor(endpoint, first_payload, second_payload)
post endpoint, first_payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
post endpoint, second_payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
end
it "persists messages from fixture data" do
@@ -3484,9 +3510,9 @@ RSpec.describe "Potato Mesh Sinatra app" do
}
post "/api/messages", parent_payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
post "/api/messages", reaction_payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
db.results_as_hash = true
@@ -3548,7 +3574,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/messages", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
@@ -3583,7 +3609,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/messages", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
count = db.get_first_value("SELECT COUNT(*) FROM nodes WHERE node_id = '!ffffffff'")
expect(count).to eq(0)
@@ -3606,7 +3632,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/messages", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
@@ -3675,7 +3701,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
}
post "/api/nodes", node_payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
rx_time = reference_time.to_i - 120
position_time = rx_time - 30
@@ -3707,7 +3733,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/positions", position_payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
@@ -3771,7 +3797,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/positions", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
db.results_as_hash = true
@@ -3800,7 +3826,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/positions", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
@@ -3864,7 +3890,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/positions", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
db.results_as_hash = true
@@ -3940,7 +3966,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/neighbors", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
@@ -4014,7 +4040,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/neighbors", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
@@ -4037,7 +4063,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/neighbors", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
@@ -4075,9 +4101,9 @@ RSpec.describe "Potato Mesh Sinatra app" do
}
post "/api/neighbors", seed_payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
post "/api/neighbors", empty_payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
remaining = db.get_first_value(SELECT_NEIGHBOR_COUNT_BY_NODE_SQL, [NEIGHBOR_EMPTY_UPDATE_ROOT_ID])
@@ -4133,9 +4159,9 @@ RSpec.describe "Potato Mesh Sinatra app" do
}
post "/api/neighbors", initial.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
post "/api/neighbors", update.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
db.results_as_hash = true
@@ -4166,9 +4192,9 @@ RSpec.describe "Potato Mesh Sinatra app" do
}
post "/api/neighbors", initial.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
post "/api/neighbors", update.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
count = db.get_first_value(
@@ -4207,7 +4233,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/neighbors", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
@@ -4226,7 +4252,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/telemetry", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
@@ -4408,7 +4434,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/telemetry", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect_stored_telemetry_type(24_001, "device")
end
@@ -4424,7 +4450,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/telemetry", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect_stored_telemetry_type(24_002, "environment")
end
@@ -4442,7 +4468,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/telemetry", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect_stored_telemetry_type(24_003, "power")
end
@@ -4458,7 +4484,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/telemetry", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
get "/api/telemetry/!teltype04", {}, auth_headers
@@ -4481,7 +4507,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/telemetry", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect_stored_telemetry_type(24_005, "air_quality")
end
@@ -4498,7 +4524,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/telemetry", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
# Invalid explicit type must be discarded; device_metrics inference takes over.
expect_stored_telemetry_type(24_006, "device")
end
@@ -4524,7 +4550,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/traces", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
@@ -4594,7 +4620,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/traces", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
@@ -4685,7 +4711,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
}
post "/api/nodes", node_payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
messages_payload = [
{
@@ -4705,7 +4731,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/messages", messages_payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
@@ -4785,9 +4811,9 @@ RSpec.describe "Potato Mesh Sinatra app" do
receiver_payload["num"] = receiver_num
post "/api/nodes", { sender_id => sender_payload }.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
post "/api/nodes", { receiver_id => receiver_payload }.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
encrypted_b64 = Base64.strict_encode64("secret message")
payload = {
@@ -4806,7 +4832,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/messages", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
@@ -4885,7 +4911,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/messages", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
@@ -4965,7 +4991,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/messages", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
@@ -5059,7 +5085,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/messages", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
@@ -5213,7 +5239,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/messages", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
@@ -5955,7 +5981,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/messages", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
@@ -5979,7 +6005,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/messages", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
@@ -6018,7 +6044,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
post "/api/messages", base_payload.merge("from_id" => nil).to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
@@ -6040,7 +6066,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
"from_id" => " ",
).to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
@@ -6061,7 +6087,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
"from" => "!spec-sender",
).to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
with_db(readonly: true) do |db|
@@ -6106,13 +6132,12 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
raw_position_time = node["position_time"]
# I2: the redundant position ISO key is dropped — only `position_time`
# (unix int) is emitted; clients format it. The 0-sentinel still yields no
# `position_time` at all (issue #782, `coerce_positive_or_nil`).
expect(actual_row).not_to have_key("pos_time_iso")
if raw_position_time.is_a?(Numeric) && raw_position_time > 0
expected_pos_iso = Time.at(raw_position_time).utc.iso8601
expect(actual_row["pos_time_iso"]).to eq(expected_pos_iso)
else
# Sentinel `position_time = 0` must not emit "1970-01-01T00:00:00Z";
# see `coerce_positive_or_nil` in queries/common.rb (issue #782).
expect(actual_row).not_to have_key("pos_time_iso")
expect(actual_row["position_time"]).to eq(raw_position_time)
end
end
end
@@ -6275,6 +6300,69 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
end
describe "API casing consistency" do
it "exposes the /version config block in snake_case" do
get "/version"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
cfg = payload["config"]
expect(cfg).to include(
"site_name", "map_center", "private_mode", "instance_domain",
"contact_link", "contact_link_url", "max_distance_km",
"refresh_interval_seconds"
)
expect(cfg["map_center"]).to include("lat", "lon")
# The pre-0.7.0 camelCase config keys are gone (breaking /version change).
%w[siteName mapCenter privateMode instanceDomain contactLink contactLinkUrl maxDistanceKm refreshIntervalSeconds].each do |camel|
expect(cfg).not_to have_key(camel)
end
expect(payload).to have_key("last_node_update")
expect(payload).not_to have_key("lastNodeUpdate")
end
it "accepts snake_case node fields on POST /api/nodes alongside camelCase" do
now = reference_time.to_i
payload = {
"!aabbccdd" => {
"num" => 0xAABBCCDD,
"last_heard" => now,
"user" => { "short_name" => "SNAK", "long_name" => "Snake Case Node", "hw_model" => "TBEAM" },
"device_metrics" => { "battery_level" => 77 },
"position" => { "latitude" => 52.0, "longitude" => 13.0 },
},
}
post "/api/nodes", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
get "/api/nodes?limit=1000"
row = JSON.parse(last_response.body).find { |r| r["node_id"] == "!aabbccdd" }
expect(row).not_to be_nil
expect(row["short_name"]).to eq("SNAK")
expect(row["long_name"]).to eq("Snake Case Node")
expect(row["hw_model"]).to eq("TBEAM")
expect(row["battery_level"]).to eq(77)
end
end
describe "POST ingest status codes" do
it "returns 201 Created on a successful node ingest (consistent with /api/instances)" do
payload = { "!c0debabe" => { "num" => 0xC0DEBABE, "last_heard" => reference_time.to_i } }
post "/api/nodes", payload.to_json, auth_headers
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
end
end
describe "POST payload validation" do
%w[messages positions telemetry neighbors traces].each do |endpoint|
it "rejects a non-array/non-object payload on /api/#{endpoint} with 400" do
post "/api/#{endpoint}", '"garbage"', auth_headers
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "invalid payload")
end
end
end
describe "GET /api/stats" do
it "returns exact SQL-backed activity counts with per-protocol breakdowns" do
clear_database
@@ -6515,7 +6603,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
}
post "/api/messages", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
get "/api/messages"
@@ -6844,7 +6932,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
},
}
post "/api/nodes", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
# The row exists in the database — opt-out is a display-time filter,
# not an ingestion-time refusal.
@@ -6893,7 +6981,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
"payload_b64" => "AQI=",
}
post "/api/positions", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
end
get "/api/positions?limit=1"
@@ -6907,7 +6995,8 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(entry["rx_time"]).to eq(rx_times.last)
expect(entry["rx_iso"]).to eq(Time.at(rx_times.last).utc.iso8601)
expect(entry["position_time"]).to eq(rx_times.last - 5)
expect(entry["position_time_iso"]).to eq(Time.at(rx_times.last - 5).utc.iso8601)
# I2: position ISO key dropped — only `position_time` (unix int) is emitted.
expect(entry).not_to have_key("position_time_iso")
expect(entry["latitude"]).to eq(53.0)
expect(entry["longitude"]).to eq(14.0)
expect(entry["location_source"]).to eq("LOC_TEST")
@@ -7170,7 +7259,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
describe "GET /api/telemetry" do
it "returns stored telemetry ordered by receive time" do
post "/api/telemetry", telemetry_fixture.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
get "/api/telemetry?limit=2"
@@ -7392,7 +7481,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
describe "GET /api/telemetry/aggregated" do
it "returns aggregated telemetry buckets for the requested interval" do
post "/api/telemetry", telemetry_fixture.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
get "/api/telemetry/aggregated?windowSeconds=86400&bucketSeconds=300"
@@ -7441,7 +7530,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
it "applies default window and bucket sizes when parameters are omitted" do
post "/api/telemetry", telemetry_fixture.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
get "/api/telemetry/aggregated"
@@ -7590,7 +7679,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
it "returns stored traces ordered by receive time" do
clear_database
post "/api/traces", trace_fixture.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
get "/api/traces"
@@ -7615,7 +7704,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
it "filters traces by node reference across sources" do
clear_database
post "/api/traces", trace_fixture.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
get "/api/traces/#{trace_fixture.first["src"]}"
@@ -7650,7 +7739,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
]
post "/api/traces", payload.to_json, auth_headers
expect(last_response).to be_ok
expect(last_response.status).to eq(201)
get "/api/traces"
+6 -6
View File
@@ -78,7 +78,7 @@ RSpec.describe "Ingestor endpoints" do
payload = ingestor_payload
post "/api/ingestors", payload.to_json, auth_headers
expect(last_response.status).to eq(200)
expect(last_response.status).to eq(201)
newer_last_seen = payload[:last_seen_time] + 3_600
older_start = payload[:start_time] - 500
@@ -86,7 +86,7 @@ RSpec.describe "Ingestor endpoints" do
payload.merge(last_seen_time: newer_last_seen, start_time: older_start).to_json,
auth_headers
expect(last_response.status).to eq(200)
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
row = db.get_first_row(
"SELECT node_id, start_time, last_seen_time, version, lora_freq, modem_preset FROM ingestors WHERE node_id = ?",
@@ -199,13 +199,13 @@ RSpec.describe "Ingestor endpoints" do
# so the request succeeds rather than returning 400.
post "/api/ingestors", ingestor_payload(start_time: nil).to_json, auth_headers
expect(last_response.status).to eq(200)
expect(last_response.status).to eq(201)
end
it "accepts a payload where last_seen_time is missing (falls back to start_time)" do
post "/api/ingestors", ingestor_payload(last_seen_time: nil).to_json, auth_headers
expect(last_response.status).to eq(200)
expect(last_response.status).to eq(201)
end
it "rejects a payload where node_id is missing" do
@@ -219,7 +219,7 @@ RSpec.describe "Ingestor endpoints" do
# succeeds and stores lora_freq as NULL.
post "/api/ingestors", ingestor_payload(lora_freq: "not-a-number").to_json, auth_headers
expect(last_response.status).to eq(200)
expect(last_response.status).to eq(201)
end
it "returns 200 when optional lora_freq is absent entirely" do
@@ -227,7 +227,7 @@ RSpec.describe "Ingestor endpoints" do
payload.delete(:lora_freq)
post "/api/ingestors", payload.to_json, auth_headers
expect(last_response.status).to eq(200)
expect(last_response.status).to eq(201)
end
it "returns 403 with the wrong bearer token" do
+21 -21
View File
@@ -98,7 +98,7 @@ RSpec.describe "Multi-protocol support" do
it "stores protocol when provided" do
register_ingestor(MESHCORE_INGESTOR_ID, protocol: "meshcore")
expect(last_response.status).to eq(200)
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
row = db.get_first_row(SELECT_INGESTOR_PROTOCOL_SQL, [MESHCORE_INGESTOR_ID])
expect(row["protocol"]).to eq("meshcore")
@@ -108,7 +108,7 @@ RSpec.describe "Multi-protocol support" do
it "defaults protocol to meshtastic when field is absent" do
register_ingestor("!aabbccdd")
expect(last_response.status).to eq(200)
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
row = db.get_first_row(SELECT_INGESTOR_PROTOCOL_SQL, ["!aabbccdd"])
expect(row["protocol"]).to eq("meshtastic")
@@ -140,7 +140,7 @@ RSpec.describe "Multi-protocol support" do
ingestor: MESHCORE_INGESTOR_ID,
}
post "/api/messages", [msg].to_json, auth_headers
expect(last_response.status).to eq(200)
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT protocol FROM messages WHERE id = ?", [42])
@@ -159,7 +159,7 @@ RSpec.describe "Multi-protocol support" do
ingestor: MESHCORE_INGESTOR_ID,
}
post "/api/positions", [pos].to_json, auth_headers
expect(last_response.status).to eq(200)
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT protocol FROM positions WHERE id = ?", [100])
@@ -177,7 +177,7 @@ RSpec.describe "Multi-protocol support" do
ingestor: MESHCORE_INGESTOR_ID,
}
post "/api/telemetry", [tel].to_json, auth_headers
expect(last_response.status).to eq(200)
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT protocol FROM telemetry WHERE id = ?", [200])
@@ -196,7 +196,7 @@ RSpec.describe "Multi-protocol support" do
ingestor: MESHCORE_INGESTOR_ID,
}
post "/api/traces", [trace].to_json, auth_headers
expect(last_response.status).to eq(200)
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT protocol FROM traces WHERE id = ?", [300])
@@ -214,7 +214,7 @@ RSpec.describe "Multi-protocol support" do
ingestor: MESHCORE_INGESTOR_ID,
}
post "/api/messages", [msg].to_json, auth_headers
expect(last_response.status).to eq(200)
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT long_name FROM nodes WHERE node_id = ?", ["!11223300"])
@@ -231,7 +231,7 @@ RSpec.describe "Multi-protocol support" do
ingestor: MESHCORE_INGESTOR_ID,
}
post "/api/messages", [msg].to_json, auth_headers
expect(last_response.status).to eq(200)
expect(last_response.status).to eq(201)
# Meshtastic ingestor posts same ID — should be ignored
meshtastic_msg = {
@@ -241,7 +241,7 @@ RSpec.describe "Multi-protocol support" do
text: "meshtastic impostor",
}
post "/api/messages", [meshtastic_msg].to_json, auth_headers
expect(last_response.status).to eq(200)
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT text, protocol FROM messages WHERE id = ?", [500])
@@ -270,7 +270,7 @@ RSpec.describe "Multi-protocol support" do
text: "meshtastic fallback attempt",
}
post "/api/messages", [meshtastic_msg].to_json, auth_headers
expect(last_response.status).to eq(200)
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT text, protocol FROM messages WHERE id = ?", [501])
@@ -295,7 +295,7 @@ RSpec.describe "Multi-protocol support" do
"ingestor" => MESHCORE_INGESTOR_ID,
}
post "/api/nodes", payload.to_json, auth_headers
expect(last_response.status).to eq(200)
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT protocol FROM nodes WHERE node_id = ?", [ALT_NODE_ID])
@@ -313,7 +313,7 @@ RSpec.describe "Multi-protocol support" do
payload = { ALT_NODE_ID2 => { "num" => 0xccddee00, "lastHeard" => now - 10 } }
post "/api/nodes", payload.to_json, auth_headers
expect(last_response.status).to eq(200)
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT protocol FROM nodes WHERE node_id = ?", [ALT_NODE_ID2])
@@ -329,7 +329,7 @@ RSpec.describe "Multi-protocol support" do
nodes["ingestor"] = MESHCORE_INGESTOR_ID
post "/api/nodes", nodes.to_json, auth_headers
expect(last_response.status).to eq(200)
expect(last_response.status).to eq(201)
end
it "does not count the wrapper-level protocol key against the node batch limit" do
@@ -342,7 +342,7 @@ RSpec.describe "Multi-protocol support" do
nodes["protocol"] = "meshcore"
post "/api/nodes", nodes.to_json, auth_headers
expect(last_response.status).to eq(200)
expect(last_response.status).to eq(201)
end
end
@@ -506,7 +506,7 @@ RSpec.describe "Multi-protocol support" do
text: "legacy message",
}
post "/api/messages", [msg].to_json, auth_headers
expect(last_response.status).to eq(200)
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT protocol FROM messages WHERE id = ?", [999])
@@ -544,7 +544,7 @@ RSpec.describe "Multi-protocol support" do
protocol: "meshcore",
}
post "/api/messages", [msg].to_json, auth_headers
expect(last_response.status).to eq(200)
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
message_row = db.get_first_row(
@@ -572,7 +572,7 @@ RSpec.describe "Multi-protocol support" do
"protocol" => "meshcore",
}
post "/api/nodes", payload.to_json, auth_headers
expect(last_response.status).to eq(200)
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT protocol FROM nodes WHERE node_id = ?", ["!11aabbcc"])
@@ -591,7 +591,7 @@ RSpec.describe "Multi-protocol support" do
"protocol" => "meshtastic",
}
post "/api/nodes", payload.to_json, auth_headers
expect(last_response.status).to eq(200)
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT protocol FROM nodes WHERE node_id = ?", ["!22aabbcc"])
@@ -610,7 +610,7 @@ RSpec.describe "Multi-protocol support" do
protocol: "reticulum",
}
post "/api/messages", [msg].to_json, auth_headers
expect(last_response.status).to eq(200)
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT protocol FROM messages WHERE id = ?", [6002])
@@ -627,7 +627,7 @@ RSpec.describe "Multi-protocol support" do
ingestor: "!unregistered000",
}
post "/api/messages", [msg].to_json, auth_headers
expect(last_response.status).to eq(200)
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT protocol FROM messages WHERE id = ?", [6003])
@@ -645,7 +645,7 @@ RSpec.describe "Multi-protocol support" do
protocol: " MeshCore ",
}
post "/api/messages", [msg].to_json, auth_headers
expect(last_response.status).to eq(200)
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
row = db.get_first_row("SELECT protocol FROM messages WHERE id = ?", [6004])