diff --git a/.claude/projects/-home-user--src-l5yth-potato-mesh/memory/MEMORY.md b/.claude/projects/-home-user--src-l5yth-potato-mesh/memory/MEMORY.md new file mode 100644 index 0000000..d33f24a --- /dev/null +++ b/.claude/projects/-home-user--src-l5yth-potato-mesh/memory/MEMORY.md @@ -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 diff --git a/.claude/projects/-home-user--src-l5yth-potato-mesh/memory/api-casing-and-federation-signature.md b/.claude/projects/-home-user--src-l5yth-potato-mesh/memory/api-casing-and-federation-signature.md new file mode 100644 index 0000000..f3ed2c6 --- /dev/null +++ b/.claude/projects/-home-user--src-l5yth-potato-mesh/memory/api-casing-and-federation-signature.md @@ -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. diff --git a/ACCEPTANCE.md b/ACCEPTANCE.md index 310d236..a805cb0 100644 --- a/ACCEPTANCE.md +++ b/ACCEPTANCE.md @@ -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. diff --git a/SPEC.md b/SPEC.md index 4e0c05d..1c56f7b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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 | diff --git a/data/mesh_ingestor/CONTRACTS.md b/data/mesh_ingestor/CONTRACTS.md index 7d33fff..80ced67 100644 --- a/data/mesh_ingestor/CONTRACTS.md +++ b/data/mesh_ingestor/CONTRACTS.md @@ -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=` query parameter. When present, only records whose `protocol` column matches the given value are returned. The `protocol` field is included in all GET responses. diff --git a/web/lib/potato_mesh/application/data_processing/node_writes.rb b/web/lib/potato_mesh/application/data_processing/node_writes.rb index 0cb715f..2957c4d 100644 --- a/web/lib/potato_mesh/application/data_processing/node_writes.rb +++ b/web/lib/potato_mesh/application/data_processing/node_writes.rb @@ -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] 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( diff --git a/web/lib/potato_mesh/application/queries/federation_queries.rb b/web/lib/potato_mesh/application/queries/federation_queries.rb index cfe9cb3..4566905 100644 --- a/web/lib/potato_mesh/application/queries/federation_queries.rb +++ b/web/lib/potato_mesh/application/queries/federation_queries.rb @@ -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"]) diff --git a/web/lib/potato_mesh/application/queries/node_queries.rb b/web/lib/potato_mesh/application/queries/node_queries.rb index ff147b6..252e069 100644 --- a/web/lib/potato_mesh/application/queries/node_queries.rb +++ b/web/lib/potato_mesh/application/queries/node_queries.rb @@ -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 diff --git a/web/lib/potato_mesh/application/routes/api.rb b/web/lib/potato_mesh/application/routes/api.rb index 5705dd1..c4b6520 100644 --- a/web/lib/potato_mesh/application/routes/api.rb +++ b/web/lib/potato_mesh/application/routes/api.rb @@ -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 diff --git a/web/lib/potato_mesh/application/routes/ingest.rb b/web/lib/potato_mesh/application/routes/ingest.rb index 9e3d128..f3ccdb8 100644 --- a/web/lib/potato_mesh/application/routes/ingest.rb +++ b/web/lib/potato_mesh/application/routes/ingest.rb @@ -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 diff --git a/web/spec/app_spec.rb b/web/spec/app_spec.rb index ef5e801..13ef646 100644 --- a/web/spec/app_spec.rb +++ b/web/spec/app_spec.rb @@ -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" diff --git a/web/spec/ingestors_spec.rb b/web/spec/ingestors_spec.rb index b3fa6bb..c01802e 100644 --- a/web/spec/ingestors_spec.rb +++ b/web/spec/ingestors_spec.rb @@ -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 diff --git a/web/spec/protocol_spec.rb b/web/spec/protocol_spec.rb index 940de6d..9255248 100644 --- a/web/spec/protocol_spec.rb +++ b/web/spec/protocol_spec.rb @@ -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])