{
  "components": {
    "schemas": {
      "AdvertIntervalEstimate": {
        "type": "object",
        "description": "One route class of NodeAdvertIntervals.",
        "properties": {
          "confidence": {
            "type": "string",
            "description": "high: \u003e= 6 fitting gaps and \u003e= 75 % of the non-short gaps fit; medium: \u003e= 3 and \u003e= 50 %; low: fewer; none: under 3 adverts or no interval seen at least twice.",
            "enum": [
              "high",
              "medium",
              "low",
              "none"
            ]
          },
          "gaps_used": {
            "type": "integer",
            "description": "Gaps between the samples that fit 1-4x the interval.",
            "minimum": 0
          },
          "interval_s": {
            "type": "integer",
            "description": "Estimated interval in seconds, snapped when snapped is true; null when confidence is none.",
            "nullable": true
          },
          "last_advert": {
            "type": "string",
            "description": "RFC3339 first_seen of the newest advert in the class; null when there is none.",
            "nullable": true
          },
          "raw_interval_s": {
            "type": "integer",
            "description": "The median before snapping; null when confidence is none.",
            "nullable": true
          },
          "samples": {
            "type": "integer",
            "description": "Adverts used; after a raised interval, the adverts since the change.",
            "minimum": 0
          },
          "snapped": {
            "type": "boolean",
            "description": "true when the estimate is within 10 % of the firmware's settable range and interval_s is the nearest settable value."
          },
          "status": {
            "type": "string",
            "description": "estimated: interval_s is set; none_observed: no adverts of the class; too_few: under 3 adverts; irregular: enough adverts but no interval fits.",
            "enum": [
              "estimated",
              "none_observed",
              "too_few",
              "irregular"
            ]
          }
        }
      },
      "AdvertRouteCounts": {
        "type": "object",
        "properties": {
          "flood": {
            "type": "integer",
            "minimum": 0
          },
          "mixed": {
            "type": "integer",
            "minimum": 0
          },
          "unknown": {
            "type": "integer",
            "minimum": 0
          },
          "zero_hop": {
            "type": "integer",
            "minimum": 0
          }
        }
      },
      "AllObserverNeighborsEntry": {
        "description": "One observer's firmware-reported direct neighbor, flattened with which observer it came from.",
        "properties": {
          "neighborName": {
            "description": "Neighbor display name, null when the pubkey doesn't resolve to a known node.",
            "nullable": true,
            "type": "string"
          },
          "neighborPubkey": {
            "description": "The neighbor's pubkey.",
            "type": "string"
          },
          "neighborRole": {
            "nullable": true,
            "type": "string"
          },
          "observerIata": {
            "description": "Observer's IATA region code, when set.",
            "nullable": true,
            "type": "string"
          },
          "observerId": {
            "description": "The reporting observer's ID.",
            "type": "string"
          },
          "observerName": {
            "description": "Observer display name, null when unresolved.",
            "nullable": true,
            "type": "string"
          },
          "reportedAt": {
            "description": "RFC3339 timestamp of the /neighbors report this row came from.",
            "type": "string"
          },
          "scopes": {
            "description": "Null unless the neighbor's OTA scope query responded (status=\"responded\").",
            "nullable": true,
            "type": "string"
          },
          "seenViaPackets": {
            "description": "Cross-references the packet-path-inferred neighbor_edges graph -- false means this firmware-confirmed neighbor has never had a resolved packet path to the observer (possible coverage gap or packet loss, not necessarily a fault).",
            "type": "boolean"
          },
          "status": {
            "description": "The /neighbors report's status for this entry (e.g. \"responded\", \"timeout\").",
            "type": "string"
          }
        },
        "type": "object"
      },
      "AllObserverNeighborsResponse": {
        "description": "Every observer's reported direct neighbors, network-wide (Tools \u003e Observer Neighbors). Empty (not an error) when no observer has ever sent a /neighbors report.",
        "properties": {
          "neighbors": {
            "items": {
              "$ref": "#/components/schemas/AllObserverNeighborsEntry"
            },
            "type": "array"
          },
          "unknownScopes": {
            "description": "Region-scope names observed in the wild that aren't part of this deployment's configured hashRegions, ranked by how many distinct neighbors reported them.",
            "items": {
              "$ref": "#/components/schemas/UnknownScopeEntry"
            },
            "type": "array"
          }
        },
        "type": "object"
      },
      "AreaAnalyticsResponse": {
        "description": "Node density/health, cross-area bridge nodes, and position-fix coverage per configured Area. When estimatedPositions.enabled is false, returns estimatedPositionsEnabled:false and real density/bridgeNodes/unpositionedTotal only; positionGaps, estimatedNodes, and unpositionedNoNeighborFix are omitted because they were not evaluated.",
        "properties": {
          "bridgeNodes": {
            "description": "Top cross-area bridge nodes, ranked by how many other areas they reach.",
            "items": {
              "$ref": "#/components/schemas/AreaBridgeNode"
            },
            "type": "array"
          },
          "density": {
            "items": {
              "$ref": "#/components/schemas/AreaDensity"
            },
            "type": "array"
          },
          "estimatedNodes": {
            "description": "Flat, network-wide list of every node behind positionGaps' approximated counts, with actual estimated coordinates for plotting on a map.",
            "items": {
              "$ref": "#/components/schemas/EstimatedAreaNode"
            },
            "type": "array"
          },
          "estimatedPositionsEnabled": {
            "type": "boolean",
            "description": "Present as false only when neighbor-derived position estimation is disabled by the operator."
          },
          "positionGaps": {
            "items": {
              "$ref": "#/components/schemas/AreaPositionGap"
            },
            "type": "array"
          },
          "unpositionedNoNeighborFix": {
            "description": "The subset of unpositionedTotal that also has no positioned neighbor to estimate from -- can't be placed even approximately, so absent from every area's positionGaps.approximated.",
            "type": "integer"
          },
          "unpositionedTotal": {
            "description": "Every node with no real GPS fix, regardless of area.",
            "type": "integer"
          }
        },
        "type": "object"
      },
      "AreaBridgeNode": {
        "description": "One node whose packet-derived neighbor_edges reach into at least one other configured area than its own -- distinct from the network-wide, area-unaware bridge_score betweenness centrality.",
        "properties": {
          "areaKey": {
            "description": "This node's own single most-specific area.",
            "type": "string"
          },
          "edgeCount": {
            "description": "Number of neighbor_edges reaching into a different area than this node's own.",
            "type": "integer"
          },
          "label": {
            "description": "That area's display label.",
            "type": "string"
          },
          "name": {
            "description": "Display name, falling back to the raw pubkey when unresolved.",
            "type": "string"
          },
          "otherAreaCount": {
            "description": "Number of distinct other areas reached.",
            "type": "integer"
          },
          "otherAreas": {
            "description": "Display labels of every other area reached.",
            "items": {
              "type": "string"
            },
            "type": "array"
          },
          "publicKey": {
            "description": "The node's pubkey.",
            "type": "string"
          }
        },
        "type": "object"
      },
      "AreaDensity": {
        "description": "One configured area's node count, active/degraded/silent health breakdown, and role mix. Multi-membership: a node in a sub-area also counts toward its parent region.",
        "properties": {
          "active": {
            "description": "Nodes heard within their role's active threshold.",
            "type": "integer"
          },
          "areaKey": {
            "description": "The area's config key.",
            "type": "string"
          },
          "degraded": {
            "description": "Nodes heard within their role's degraded threshold but not active.",
            "type": "integer"
          },
          "label": {
            "description": "The area's display label.",
            "type": "string"
          },
          "roleCounts": {
            "additionalProperties": {
              "type": "integer"
            },
            "description": "Node count per role string.",
            "type": "object"
          },
          "silent": {
            "description": "Nodes not heard within either threshold.",
            "type": "integer"
          },
          "total": {
            "description": "Total nodes with a real GPS fix inside this area (or any of its sub-areas).",
            "type": "integer"
          }
        },
        "type": "object"
      },
      "AreaGrowth": {
        "properties": {
          "count": {
            "description": "New nodes in this area within the digest window.",
            "type": "integer"
          },
          "label": {
            "description": "The area's display label.",
            "type": "string"
          },
          "nodes": {
            "description": "Every new node counted toward this area, newest first.",
            "items": {
              "$ref": "#/components/schemas/AreaNodeRef"
            },
            "type": "array"
          }
        },
        "type": "object"
      },
      "AreaNodeRef": {
        "properties": {
          "firstSeen": {
            "description": "RFC3339 timestamp the node was first seen.",
            "type": "string"
          },
          "name": {
            "description": "Display name, when known.",
            "type": "string"
          },
          "publicKey": {
            "description": "The node's public key.",
            "type": "string"
          }
        },
        "type": "object"
      },
      "AreaPositionGap": {
        "description": "One configured area's position-fix coverage: nodes with a real GPS fix vs. nodes only placeable via nearestPositionedNeighbor's estimate.",
        "properties": {
          "approximated": {
            "description": "Nodes with no GPS fix whose neighbor-centroid estimate landed in this area.",
            "type": "integer"
          },
          "areaKey": {
            "description": "The area's config key.",
            "type": "string"
          },
          "label": {
            "description": "The area's display label.",
            "type": "string"
          },
          "realFix": {
            "description": "Nodes in this area with an actual reported GPS position.",
            "type": "integer"
          }
        },
        "type": "object"
      },
      "CandidateEntry": {
        "description": "A candidate pubkey offered when a neighbor edge is ambiguous.",
        "properties": {
          "name": {
            "description": "Candidate node display name.",
            "type": "string"
          },
          "pubkey": {
            "description": "Candidate node public key (hex).",
            "type": "string"
          },
          "role": {
            "description": "Candidate node role (e.g. repeater, room).",
            "type": "string"
          }
        },
        "type": "object"
      },
      "EstimatedAreaNode": {
        "description": "One node with no real GPS fix, plotted at its nearestPositionedNeighbor weighted-centroid estimate -- the same technique View Path's approximate markers use.",
        "properties": {
          "areaKey": {
            "description": "The area this estimated position falls inside.",
            "type": "string"
          },
          "contributorCount": {
            "description": "How many positioned neighbors fed into the estimate.",
            "type": "integer"
          },
          "label": {
            "description": "That area's display label.",
            "type": "string"
          },
          "lat": {
            "description": "Estimated latitude (weighted centroid of positioned neighbors).",
            "type": "number"
          },
          "lon": {
            "description": "Estimated longitude.",
            "type": "number"
          },
          "name": {
            "description": "Display name, falling back to the raw pubkey when unresolved.",
            "type": "string"
          },
          "publicKey": {
            "description": "The node's pubkey.",
            "type": "string"
          },
          "spreadKm": {
            "description": "How spread out (km) those contributing neighbors were -- a rough confidence signal, wider spread means a less certain estimate.",
            "type": "number"
          }
        },
        "type": "object"
      },
      "GPSSanityResponse": {
        "description": "Nodes whose self-reported GPS disagrees with a trusted cluster of their own RF neighbors. When estimatedPositions.enabled is false, returns only estimatedPositionsEnabled:false; nodes, totalRealGps and evaluated are omitted, not reported as zero.",
        "properties": {
          "estimatedPositionsEnabled": {
            "type": "boolean",
            "description": "Present as false only when neighbor-derived position estimation is disabled by the operator."
          },
          "evaluated": {
            "description": "The subset of totalRealGps that had a trustworthy neighbor cluster to compare against.",
            "type": "integer"
          },
          "nodes": {
            "description": "Flagged nodes, sorted worst (largest distanceKm) first.",
            "items": {
              "$ref": "#/components/schemas/SuspiciousGPSNode"
            },
            "type": "array"
          },
          "totalRealGps": {
            "description": "Every node with a real (non-zero) GPS fix -- the population this check ran over.",
            "type": "integer"
          }
        },
        "type": "object"
      },
      "HopAnalyticsPacket": {
        "description": "One transmission that passed through the queried node as a relay hop (issue #1812).",
        "properties": {
          "hash": {
            "description": "Packet hash.",
            "type": "string"
          },
          "hops": {
            "description": "0-based index of the queried node within the packet's resolved relay path — the value MeshCore firmware compares against flood_max in allowPacketForward. NOT distance to the reporting observer.",
            "type": "integer"
          },
          "scoped": {
            "description": "Whether the transmission carried a region scope (TRANSPORT_FLOOD/TRANSPORT_DIRECT).",
            "type": "boolean"
          },
          "transport": {
            "description": "Which firmware flood.max* knob (if any) caps this packet's hop count.",
            "enum": [
              "flood",
              "flood_advert",
              "flood_unscoped",
              "direct",
              "unknown"
            ],
            "type": "string"
          },
          "tsMs": {
            "description": "First-seen timestamp, Unix milliseconds.",
            "type": "integer"
          }
        },
        "type": "object"
      },
      "HopDepthAnalyticsResponse": {
        "properties": {
          "scopedHopDepth": {
            "description": "Hop-depth histogram for scoped (TRANSPORT_FLOOD/TRANSPORT_DIRECT) traffic.",
            "items": {
              "$ref": "#/components/schemas/HopDepthBucket"
            },
            "type": "array"
          },
          "timeSeries": {
            "description": "Scoped/unscoped median hop depth over time within the window — is containment trending better or worse.",
            "items": {
              "$ref": "#/components/schemas/HopDepthTimePoint"
            },
            "type": "array"
          },
          "unscopedByRepeater": {
            "description": "Per-repeater/room breakdown of unscoped hop depth, sorted by count descending.",
            "items": {
              "$ref": "#/components/schemas/RepeaterUnscopedHopDepth"
            },
            "type": "array"
          },
          "unscopedHopDepth": {
            "description": "Hop-depth histogram for unscoped (plain FLOOD) traffic.",
            "items": {
              "$ref": "#/components/schemas/HopDepthBucket"
            },
            "type": "array"
          },
          "window": {
            "description": "Time window this response covers: 1h, 24h, or 7d.",
            "type": "string"
          }
        },
        "type": "object"
      },
      "HopDepthBucket": {
        "description": "How many relay-hop instances (network-wide) saw a given hop count.",
        "properties": {
          "count": {
            "type": "integer"
          },
          "hops": {
            "description": "0-based hop index.",
            "type": "integer"
          }
        },
        "type": "object"
      },
      "HopDepthTimePoint": {
        "description": "One time bucket's scoped/unscoped median hop depth (5min/1h/6h buckets for 1h/24h/7d windows, same bucketing as ScopeStatsResponse.timeSeries).",
        "properties": {
          "scopedMedianHop": {
            "description": "Median hop depth of scoped traffic in this bucket, or null if there was none (0 is a valid median, so absence isn't the same as zero).",
            "nullable": true,
            "type": "integer"
          },
          "t": {
            "description": "Bucket start, RFC3339 UTC.",
            "type": "string"
          },
          "unscopedMedianHop": {
            "description": "Median hop depth of unscoped traffic in this bucket, or null if there was none.",
            "nullable": true,
            "type": "integer"
          }
        },
        "type": "object"
      },
      "NeighborEntry": {
        "description": "One neighbor of the queried node, with affinity score and observation metadata.",
        "properties": {
          "ambiguous": {
            "type": "boolean"
          },
          "avg_snr": {
            "nullable": true,
            "type": "number"
          },
          "candidates": {
            "items": {
              "$ref": "#/components/schemas/CandidateEntry"
            },
            "type": "array"
          },
          "count": {
            "description": "Total observations supporting this neighborship.",
            "type": "integer"
          },
          "counts_by_mode": {
            "additionalProperties": {
              "type": "integer"
            },
            "description": "#1638: observation counts keyed by hash-prefix mode in bytes (1/2/3; 0 = legacy/unknown).",
            "type": "object"
          },
          "distance_km": {
            "nullable": true,
            "type": "number"
          },
          "first_seen": {
            "type": "string"
          },
          "last_seen": {
            "type": "string"
          },
          "name": {
            "nullable": true,
            "type": "string"
          },
          "observers": {
            "items": {
              "type": "string"
            },
            "type": "array"
          },
          "prefix": {
            "description": "Raw hop hash prefix that established this edge.",
            "type": "string"
          },
          "pubkey": {
            "description": "Resolved neighbor public key, or null when only a hop prefix is known.",
            "nullable": true,
            "type": "string"
          },
          "role": {
            "nullable": true,
            "type": "string"
          },
          "score": {
            "description": "Affinity score: count saturation × recency decay × observer-diversity confidence.",
            "format": "double",
            "maximum": 1,
            "minimum": 0,
            "type": "number"
          },
          "unresolved": {
            "type": "boolean"
          }
        },
        "type": "object"
      },
      "NetworkDigest": {
        "description": "Rolling-window summary of New Nodes + Node Changes activity (Tools \u003e Network Digest).",
        "properties": {
          "areaBreakdown": {
            "description": "Every configured area with at least one new node in the window, most active first (ties alphabetical). Omitted when no new node in the window has a known area.",
            "items": {
              "$ref": "#/components/schemas/AreaGrowth"
            },
            "type": "array"
          },
          "changesCapped": {
            "description": "Same as newNodesCapped, for roleChanges/nameChanges/positionMoves/resurrections. Omitted (false) otherwise.",
            "type": "boolean"
          },
          "nameChanges": {
            "type": "integer"
          },
          "newNodes": {
            "description": "Nodes first seen within the window (same exclusions as /api/analytics/new-nodes).",
            "type": "integer"
          },
          "newNodesCapped": {
            "description": "True when the underlying fetch hit its row cap (500) and the oldest fetched row is still inside the window -- newNodes is then a floor, not an exact total. Omitted (false) otherwise.",
            "type": "boolean"
          },
          "origin": {
            "description": "The requested origin filter: \"all\", \"domestic\", or \"foreign\".",
            "type": "string"
          },
          "positionMoves": {
            "type": "integer"
          },
          "resurrections": {
            "type": "integer"
          },
          "roleChanges": {
            "type": "integer"
          },
          "since": {
            "description": "RFC3339 timestamp the window starts at.",
            "type": "string"
          },
          "window": {
            "description": "The requested window, e.g. \"7d\".",
            "type": "string"
          }
        },
        "type": "object"
      },
      "NewNodeEntry": {
        "properties": {
          "areas": {
            "description": "Every configured area this node's position falls in, alphabetized. Omitted when the node has no known position or no areas are configured.",
            "items": {
              "type": "string"
            },
            "type": "array"
          },
          "firstSeen": {
            "description": "RFC3339 timestamp this node was first ever seen.",
            "type": "string"
          },
          "foreign": {
            "description": "True when this node's ADVERT GPS lay outside the configured geofilter polygon (#730, same flag the node list's \"foreign\" field uses).",
            "type": "boolean"
          },
          "lat": {
            "description": "Last known latitude, when known.",
            "nullable": true,
            "type": "number"
          },
          "lon": {
            "description": "Last known longitude, when known.",
            "nullable": true,
            "type": "number"
          },
          "name": {
            "description": "Display name, when known.",
            "type": "string"
          },
          "publicKey": {
            "description": "The node's full public key.",
            "type": "string"
          },
          "role": {
            "description": "Node role (repeater/room/companion/sensor/etc), when known.",
            "type": "string"
          }
        },
        "type": "object"
      },
      "NewNodesResponse": {
        "description": "Most recently first-seen nodes, network-wide (Tools \u003e New Nodes). Empty (not an error) when nothing qualifies.",
        "properties": {
          "newNodes": {
            "items": {
              "$ref": "#/components/schemas/NewNodeEntry"
            },
            "type": "array"
          }
        },
        "type": "object"
      },
      "Node": {
        "additionalProperties": true,
        "description": "A mesh node. Repeater and room nodes additionally carry the issue #672 usefulness metrics and relay-activity fields below; those fields are absent on other roles. NOTE: coverage_score, redundancy_score and usefulness_grade ship only with the #672 4-axis scorer (PR #1762) and are absent on every build without it; until that lands usefulness_score is aliased to traffic_share_score. Only traffic_share_score and bridge_score ship today.",
        "properties": {
          "advert_count": {
            "type": "integer"
          },
          "battery_mv": {
            "nullable": true,
            "type": "integer"
          },
          "bridge_score": {
            "description": "#672 Bridge axis: normalized betweenness centrality (chokepoint importance). Repeater/room only.",
            "format": "double",
            "maximum": 1,
            "minimum": 0,
            "type": "number"
          },
          "configured_scope": {
            "description": "Last CONFIRMED region-scope membership list from an observer /neighbors report (#1865) -- concrete evidence, distinct from the inferred default_scope. null or absent: no confirmed configured-scope evidence exists for this node (older server without this column, or genuinely never observed -- these are indistinguishable to the client). \"\" (empty string) together with a non-null configured_scope_at: the observer successfully queried this node and it confirmed zero configured scopes -- a real, positive result, not a missing one. \"*\": the MeshCore protocol wildcard (responds to any scope) -- never rewritten to \"#*\". Any other value: a normalized, comma-separated, \"#\"-prefixed region list, e.g. \"#dk,#eu\". A /neighbors query timeout, or a neighbor absent from a later report, never clears a previously confirmed value -- only a newer report with a valid, accepted timestamp changes it.",
            "nullable": true,
            "type": "string"
          },
          "configured_scope_at": {
            "description": "RFC3339 UTC timestamp of the most recent /neighbors report accepted as configured_scope evidence. null/absent whenever configured_scope is null/absent. This is the time of the last ACCEPTED report, not a guarantee the node's live configuration still matches it -- the node may have been reconfigured since. For writes accepted going forward, a report with a missing or unparseable timestamp is rejected outright (neither field changes) rather than substituted with server receive-time -- but that is a contract for new writes, not a retroactive guarantee: a database written before this validation existed may still contain rows where this field is an empty string rather than null.",
            "format": "date-time",
            "nullable": true,
            "type": "string"
          },
          "coverage_score": {
            "description": "#672 Coverage axis: normalized harmonic reach centrality (how much of the mesh the node can reach). Repeater/room only.",
            "format": "double",
            "maximum": 1,
            "minimum": 0,
            "type": "number"
          },
          "default_scope": {
            "description": "The region this node floods to by default. null: no default_scope is currently known for this node (neither packet-inferred nor firmware-confirmed). \"\" is not produced by any current write path -- present only in databases written before the empty-scope guards existed (#1534). \"*\": the firmware's explicit \"no default region set\" sentinel. Any other value: a normalized \"#\"-prefixed region name, e.g. \"#dk\". The value may be packet-inferred (from an observed transport-scoped advert) or firmware-confirmed (see default_scope_confirmed_at) -- confirmed evidence, once set, can never be silently downgraded by a later inferred write, including when the confirmation exists on the node's row in the OTHER retention table (active vs. recently-pruned) rather than the one this response reflects.",
            "nullable": true,
            "type": "string"
          },
          "default_scope_confirmed_at": {
            "description": "RFC3339 UTC timestamp of the most recent observer /neighbors report that self-reported this node's default_scope (self.default_scope, added to the firmware 2026-07-29). Non-empty: default_scope is firmware-confirmed provenance for the row this response reflects, not a packet-inferred guess. null: no current confirmed provenance on this specific row -- default_scope, if present, is packet-inferred only; this does not by itself mean no confirmation exists anywhere for the node, since inferred writes are cross-table-protected against a confirmation held on the node's other retention-table row even when it is not the one exposed here. A database written before this validation existed may contain historical rows where this field is an empty string rather than null.",
            "format": "date-time",
            "nullable": true,
            "type": "string"
          },
          "estimated_contributor_count": {
            "description": "Number of positioned neighbors the estimated_lat/estimated_lon centroid was averaged from. Present only alongside estimated_lat/estimated_lon.",
            "type": "integer"
          },
          "estimated_distance_km": {
            "description": "Distance between the node's own reported lat/lon and estimated_lat/estimated_lon. Present only when the node has BOTH a real fix and an estimate -- absent when either is missing.",
            "type": "number"
          },
          "estimated_lat": {
            "description": "Node detail endpoint only: an approximate position from the same neighbor-centroid estimate (geo-sanity-filtered via Config.NeighborMaxEdgeKm) that backs Position-Fix Coverage Gaps, View Path's approx markers, and Suspicious GPS Positions. Present whenever the node has a trustworthy neighbor cluster to estimate from, regardless of whether it also has a real (lat/lon) fix -- lets the detail page show both side by side to visually cross-check a node flagged by Suspicious GPS Positions.",
            "nullable": true,
            "type": "number"
          },
          "estimated_lon": {
            "description": "Paired with estimated_lat.",
            "nullable": true,
            "type": "number"
          },
          "first_seen": {
            "description": "RFC3339 timestamp of the first observation.",
            "type": "string"
          },
          "flood_advert_count_7d": {
            "description": "Distinct FLOOD adverts originated in the last 7 days (zero-hop adverts excluded). Present on the node detail endpoint.",
            "type": "integer"
          },
          "last_relayed": {
            "description": "Repeater/room only: RFC3339 time this node last appeared as a relay hop.",
            "type": "string"
          },
          "last_seen": {
            "description": "RFC3339 timestamp of the most recent observation.",
            "type": "string"
          },
          "lat": {
            "nullable": true,
            "type": "number"
          },
          "lon": {
            "nullable": true,
            "type": "number"
          },
          "name": {
            "description": "Node display name (most recent advert name).",
            "type": "string"
          },
          "public_key": {
            "description": "Node public key (hex).",
            "type": "string"
          },
          "redundancy_score": {
            "description": "#672 Redundancy axis: normalized articulation-point criticality — 1 means removing the node fragments the mesh, 0 means alternate paths exist. Repeater/room only.",
            "format": "double",
            "maximum": 1,
            "minimum": 0,
            "type": "number"
          },
          "relay_active": {
            "description": "Repeater/room only: relayed traffic within the active window.",
            "type": "boolean"
          },
          "relay_count_1h": {
            "description": "Repeater/room only: relay-hop appearances in the last hour.",
            "type": "integer"
          },
          "relay_count_24h": {
            "description": "Repeater/room only: relay-hop appearances in the last 24 hours.",
            "type": "integer"
          },
          "relay_window_hours": {
            "description": "Repeater/room only, /api/nodes/{pubkey} detail endpoint only: width (hours) of the relay-activity window the relay_count_* values cover.",
            "type": "integer"
          },
          "role": {
            "description": "Node role (e.g. repeater, room, client, sensor).",
            "type": "string"
          },
          "temperature_c": {
            "nullable": true,
            "type": "number"
          },
          "traffic_share_score": {
            "description": "#672 Traffic axis: share of non-advert traffic relayed through this repeater. Repeater/room only.",
            "format": "double",
            "maximum": 1,
            "minimum": 0,
            "type": "number"
          },
          "transported_scopes": {
            "description": "Repeater/room only: sorted, deduplicated region scopes this node has ever carried as a relay hop, while still resident in the in-memory index (NOT time-windowed -- can include scopes last carried weeks ago). Absent when empty.",
            "items": {
              "type": "string"
            },
            "type": "array"
          },
          "transported_scopes_recent": {
            "description": "Repeater/room only: subset of transported_scopes last carried within the relay-activity window (same window as relay_active) -- a live signal, not just ever-seen. Absent when empty.",
            "items": {
              "type": "string"
            },
            "type": "array"
          },
          "unscoped_relay_count_24h": {
            "description": "Repeater/room only: subset of relay_count_24h that were unscoped floods (route_type FLOOD). A well-configured repeater sets flood.max.unscoped 0, so a non-trivial count flags a base-config problem.",
            "type": "integer"
          },
          "usefulness_grade": {
            "description": "Letter grade derived from usefulness_score. Repeater/room only.",
            "enum": [
              "A",
              "B",
              "C",
              "D",
              "F"
            ],
            "type": "string"
          },
          "usefulness_score": {
            "description": "#672 composite usefulness = 0.30·bridge + 0.25·coverage + 0.25·redundancy + 0.20·traffic. Until the 4-axis scorer ships (PR #1762) this is aliased to traffic_share_score. Repeater/room only.",
            "format": "double",
            "maximum": 1,
            "minimum": 0,
            "type": "number"
          }
        },
        "type": "object"
      },
      "NodeAdvert": {
        "additionalProperties": true,
        "description": "A recent transmission/advert from a node (the /api/packets transmission shape). Only the commonly-used fields are documented.",
        "properties": {
          "first_seen": {
            "description": "RFC3339 time the transmission was first observed.",
            "type": "string"
          },
          "from_pubkey": {
            "description": "Originating node public key.",
            "type": "string"
          },
          "hash": {
            "description": "Transmission content hash.",
            "type": "string"
          },
          "id": {
            "type": "integer"
          },
          "payload_type": {
            "description": "MeshCore payload type.",
            "type": "integer"
          },
          "route_class": {
            "type": "string",
            "description": "Node detail with include=advertRoutes, ADVERT rows only (absent on other payload types and without the opt-in): the advert's route class, classified like Relay Airtime Share's ADVERT rows. flood: only route 0/1 (transport flood/flood) seen for the content hash; zero_hop: only route 2/3 (a zero-hop advert is sent DIRECT with an empty path); mixed: both; unknown: no usable route - the same bucket Relay Airtime Share's route_class calls \"legacy\" (its historical plain ADVERT row); node detail names it for what it is, both come from one classifier. Omitted when the node's identity is hidden. From transmissions.route_mask, falling back to the first-inserted route_type while the mask is not backfilled (see advertCounts.route_mask_backfill).",
            "enum": [
              "flood",
              "zero_hop",
              "mixed",
              "unknown"
            ]
          }
        },
        "type": "object"
      },
      "NodeAdvertCounts": {
        "type": "object",
        "description": "Node detail with include=advertRoutes only (#2073): distinct ADVERTs (by content hash) per route class whose first_seen - when the advert was first heard, the axis flood_advert_count_7d also uses - lies in the last 24 hours / 7 days. Classified like NodeAdvert.route_class. Unlike Node.flood_advert_count_7d (route_type 1 only, unchanged external contract), 7d.flood also counts transport flood (route 0) and never counts a mixed advert. Rows whose first_seen cannot be parsed are skipped, as for flood_advert_count_7d. unknown is the bucket Relay Airtime Share calls legacy (same classifier, see NodeAdvert.route_class). Absent without include=advertRoutes and when the node's identity is hidden.",
        "properties": {
          "24h": {
            "$ref": "#/components/schemas/AdvertRouteCounts"
          },
          "7d": {
            "$ref": "#/components/schemas/AdvertRouteCounts"
          },
          "route_mask_backfill": {
            "$ref": "#/components/schemas/RouteMaskBackfillStatus"
          },
          "truncated": {
            "type": "boolean",
            "description": "true when the node had more adverts at or after the 7d date floor than the per-request row cap (50000); the counts then cover the newest rows only."
          }
        }
      },
      "NodeAdvertIntervals": {
        "type": "object",
        "description": "Node detail with include=advertRoutes only (#245): the node's estimated flood and zero-hop advert intervals, from the gaps between the adverts listed in recentAdvertsByRoute.flood / .zero_hop (mixed and unknown adverts are not used). A gap uses the adverts' own (sender) timestamps when both are plausible - not ahead of first_seen by more than 10 min, positive, and within max(10 min, 10 %) of the first_seen gap - else first_seen. The interval must be seen directly in at least two gaps and a quarter of them, and be one the class's timer can run at (flood 3 h or more, zero-hop 2 min or 60 min or more, each less 10 %); gaps of 2-4x it count as missed adverts, shorter gaps (manual adverts, reboots) are dropped, longer gaps that are no multiple are irregular. When the newest 3 gaps that fit are all the same multiple k \u003e 1 the interval was raised, and the estimate is redone on the adverts since the change. It is the median of gap/k over the gaps that fit k x the interval, snapped to the firmware's settable values: flood.advert.interval whole hours 3-168, advert.interval even minutes 60-240 or the 2-minute new-install default. No zero-hop adverts can mean the node's zero-hop interval is 0 (off) or that no observer hears it directly. Absent without include=advertRoutes and when the node's identity is hidden; cached with recentAdvertsByRoute.",
        "properties": {
          "flood": {
            "$ref": "#/components/schemas/AdvertIntervalEstimate"
          },
          "window": {
            "type": "integer",
            "description": "Most adverts per class considered (the recentAdvertsByRoute limit, 20)."
          },
          "zero_hop": {
            "$ref": "#/components/schemas/AdvertIntervalEstimate"
          }
        }
      },
      "NodeAdvertsByRoute": {
        "type": "object",
        "description": "Node detail with include=advertRoutes only (#2073): the newest ADVERTs of the node per route class (see NodeAdvert.route_class), newest ingest first. The class is filtered before the per-class limit, so frequent zero-hop adverts cannot push rare flood adverts out; a mixed advert is listed only under mixed. Rows are the NodeAdvert shape without the observations array (observation_count and the best observation's observer/snr/rssi/path fields are kept); route_class is the class the row was listed under. Absent without include=advertRoutes and when the node's identity is hidden (node or observer blacklist, hidden-name prefix). Cached per node for up to 30 s (refreshed once the node has a newer transmission, at most every 5 s).",
        "properties": {
          "flood": {
            "type": "array",
            "description": "Adverts seen only on flood routes (0/1).",
            "items": {
              "$ref": "#/components/schemas/NodeAdvert"
            }
          },
          "limit": {
            "type": "integer",
            "description": "Maximum rows per class (20)."
          },
          "mixed": {
            "type": "array",
            "description": "Adverts seen on both flood and zero-hop routes.",
            "items": {
              "$ref": "#/components/schemas/NodeAdvert"
            }
          },
          "unknown": {
            "type": "array",
            "description": "Adverts with no usable route (Relay Airtime Share's legacy bucket); present only when the node has any.",
            "items": {
              "$ref": "#/components/schemas/NodeAdvert"
            }
          },
          "zero_hop": {
            "type": "array",
            "description": "Adverts seen only on zero-hop routes (2/3).",
            "items": {
              "$ref": "#/components/schemas/NodeAdvert"
            }
          }
        }
      },
      "NodeChangeEntry": {
        "properties": {
          "changeType": {
            "description": "One of \"role\", \"name\", \"position\", or \"resurrected\".",
            "type": "string"
          },
          "detectedAt": {
            "description": "RFC3339 timestamp this change was detected.",
            "type": "string"
          },
          "distanceKm": {
            "description": "Distance between old and new position. Only present when changeType is \"position\".",
            "nullable": true,
            "type": "number"
          },
          "foreign": {
            "description": "Mirrors nodes.foreign_advert for the node's CURRENT state -- same All/Domestic/Foreign vocabulary as /api/analytics/new-nodes.",
            "type": "boolean"
          },
          "id": {
            "description": "node_changes row id.",
            "type": "integer"
          },
          "name": {
            "description": "The node's CURRENT display name (resolved fresh), not a historical snapshot at the time of the change.",
            "type": "string"
          },
          "newValue": {
            "description": "For role/name: the new raw value. For position: \"lat,lon\". Empty for resurrected.",
            "type": "string"
          },
          "oldValue": {
            "description": "For role/name: the previous raw value. For position: \"lat,lon\". For resurrected: the last_seen timestamp from inactive_nodes before the node returned.",
            "type": "string"
          },
          "publicKey": {
            "description": "The node's full public key.",
            "type": "string"
          },
          "role": {
            "description": "The node's CURRENT role (resolved fresh, same convention as name).",
            "type": "string"
          }
        },
        "type": "object"
      },
      "NodeChangesResponse": {
        "description": "Recent node role/name/position changes and pruned-node returns (Tools \u003e Node Changes). Empty (not an error) when nothing has been logged yet.",
        "properties": {
          "nodeChanges": {
            "items": {
              "$ref": "#/components/schemas/NodeChangeEntry"
            },
            "type": "array"
          }
        },
        "type": "object"
      },
      "NodeDetailResponse": {
        "properties": {
          "advertCounts": {
            "$ref": "#/components/schemas/NodeAdvertCounts"
          },
          "advertIntervals": {
            "$ref": "#/components/schemas/NodeAdvertIntervals"
          },
          "node": {
            "$ref": "#/components/schemas/Node"
          },
          "recentAdverts": {
            "description": "Up to 20 most recent transmissions from this node (newest ingest first, #1345), all route classes together.",
            "items": {
              "$ref": "#/components/schemas/NodeAdvert"
            },
            "type": "array"
          },
          "recentAdvertsByRoute": {
            "$ref": "#/components/schemas/NodeAdvertsByRoute"
          }
        },
        "type": "object"
      },
      "NodeHopAnalyticsResponse": {
        "properties": {
          "packets": {
            "items": {
              "$ref": "#/components/schemas/HopAnalyticsPacket"
            },
            "type": "array"
          }
        },
        "type": "object"
      },
      "NodeListResponse": {
        "properties": {
          "counts": {
            "additionalProperties": {
              "type": "integer"
            },
            "description": "Per-role node counts.",
            "type": "object"
          },
          "nodes": {
            "items": {
              "$ref": "#/components/schemas/Node"
            },
            "type": "array"
          },
          "total": {
            "description": "Total nodes matching the query after filtering.",
            "type": "integer"
          }
        },
        "type": "object"
      },
      "NodeNeighborsResponse": {
        "properties": {
          "neighbors": {
            "items": {
              "$ref": "#/components/schemas/NeighborEntry"
            },
            "type": "array"
          },
          "node": {
            "description": "The queried node's public key.",
            "type": "string"
          },
          "total_observations": {
            "type": "integer"
          }
        },
        "type": "object"
      },
      "PacketPathBranch": {
        "description": "One station's own route to a packet: how far it traveled to reach them (from that observation's raw hop count, independent of how much of it resolved) and, where resolvable, each hop's position in path order.",
        "properties": {
          "distanceFromFirstKm": {
            "description": "Great-circle distance (km) between this branch's own observer and first's observer. Zero for first itself. Omitted when either position is unknown, or when either observer is positioned via approx (an estimate compounding another estimate isn't worth surfacing).",
            "type": "number"
          },
          "hops": {
            "description": "Hop count for this station's deepest observation, taken from the raw path length -- present even when none of it resolved.",
            "type": "integer"
          },
          "observer": {
            "$ref": "#/components/schemas/PacketPathObserver"
          },
          "points": {
            "description": "The resolvable portion of the relay path in hop order. Can be shorter than hops, or empty, when some/all hops never resolved.",
            "items": {
              "$ref": "#/components/schemas/PacketPathPoint"
            },
            "type": "array"
          },
          "secondsAfterFirst": {
            "description": "Seconds after the earliest-arriving observation (see PacketPathResponse.first) this branch's own observation arrived. Zero for first itself. Omitted when either timestamp is unknown.",
            "type": "number"
          },
          "snr": {
            "description": "SNR of this station's deepest observation.",
            "nullable": true,
            "type": "number"
          }
        },
        "type": "object"
      },
      "PacketPathObserver": {
        "description": "The station that produced a given branch's observation of a packet path, positioned from its own self-advertised GPS when known (same source as /api/observers), else its configured IATA code, else a weighted centroid of its positioned neighbors (see approx).",
        "properties": {
          "approx": {
            "description": "True when lat/lon are not this station's own position but a count-weighted centroid of its positioned neighbors instead -- a last-resort stand-in, not a real fix.",
            "type": "boolean"
          },
          "approxNeighborCount": {
            "description": "Present only when approx=true. See PacketPathPoint.approxNeighborCount.",
            "type": "integer"
          },
          "approxSpreadKm": {
            "description": "Present only when approx=true and approxNeighborCount\u003e1. See PacketPathPoint.approxSpreadKm.",
            "nullable": true,
            "type": "number"
          },
          "iata": {
            "description": "Observer's configured IATA airport code, when set.",
            "type": "string"
          },
          "lat": {
            "nullable": true,
            "type": "number"
          },
          "lon": {
            "nullable": true,
            "type": "number"
          },
          "name": {
            "description": "Observer display name.",
            "type": "string"
          },
          "publicKey": {
            "description": "Observer's mesh pubkey, when it has one (some bridge-type observers publish under a device name instead -- see the name-match fallback in getPacketPath). Empty otherwise.",
            "type": "string"
          },
          "role": {
            "description": "Observer's own node role (e.g. repeater, room), when it's known as a mesh node itself -- not just an MQTT/API listener.",
            "type": "string"
          }
        },
        "type": "object"
      },
      "PacketPathPoint": {
        "description": "One hop's position along a packet's resolved relay path.",
        "properties": {
          "approx": {
            "description": "True when lat/lon are not this node's own position but a count-weighted centroid of its positioned neighbor_edges neighbors instead -- a last-resort stand-in, not a real fix.",
            "type": "boolean"
          },
          "approxNeighborCount": {
            "description": "Present only when approx=true. How many positioned neighbors fed the centroid -- a rough confidence signal, higher is more confident.",
            "type": "integer"
          },
          "approxSpreadKm": {
            "description": "Present only when approx=true and approxNeighborCount\u003e1. Widest distance (km) between any two contributing neighbors -- larger means they disagree more about where 'nearby' is.",
            "nullable": true,
            "type": "number"
          },
          "lat": {
            "description": "Null when this node has never advertised a GPS position and has no positioned neighbor either.",
            "nullable": true,
            "type": "number"
          },
          "lon": {
            "nullable": true,
            "type": "number"
          },
          "name": {
            "description": "Node display name, or its public key if unnamed.",
            "type": "string"
          },
          "publicKey": {
            "description": "Node public key (hex).",
            "type": "string"
          },
          "role": {
            "description": "Node role (e.g. repeater, room), when known.",
            "type": "string"
          }
        },
        "type": "object"
      },
      "PacketPathResponse": {
        "properties": {
          "airtimeRelayCount": {
            "description": "Distinct relay count behind estimatedAirtimeMs. Present only alongside it.",
            "type": "integer"
          },
          "branches": {
            "description": "One branch per distinct station that observed the packet, each kept at that station's own deepest observation, sorted deepest-first -- shows the full flood spread, not just the single farthest route.",
            "items": {
              "$ref": "#/components/schemas/PacketPathBranch"
            },
            "type": "array"
          },
          "estimatedAirtimeMs": {
            "description": "Estimated LoRa Time-on-Air (milliseconds) x distinct-relay-count for this packet's whole flood -- same formula as the Relay Airtime Share analytics metric (issue #1768), applied to a single packet. Assumes the configured/default LoRa PHY preset; relay count is inferred from the union of every hearing station's resolved relay path, not a literal per-retransmission log. Omitted when the in-memory store doesn't have this transmission (DB-only mode, or evicted).",
            "nullable": true,
            "type": "number"
          },
          "first": {
            "$ref": "#/components/schemas/PacketPathBranch"
          },
          "hash": {
            "description": "The packet hash this path was resolved for.",
            "type": "string"
          },
          "touchedAreas": {
            "description": "Every configured area any point or observer on the path falls in, deduped and alphabetized by label. Omitted when no areas are configured or none resolved.",
            "items": {
              "$ref": "#/components/schemas/TouchedAreaShape"
            },
            "type": "array"
          }
        },
        "type": "object"
      },
      "PingLeaderboardEntry": {
        "properties": {
          "count": {
            "description": "How many distinct pings this entry earned credit for.",
            "type": "integer"
          },
          "name": {
            "description": "Display name, falling back to the raw pubkey when unresolved.",
            "type": "string"
          },
          "pubkey": {
            "description": "The node/observer's pubkey.",
            "type": "string"
          }
        },
        "type": "object"
      },
      "PingScore": {
        "description": "One ping's computed highscore-relevant stats, derived from the same getPacketPath + airtime-annotation logic behind /api/packets/{hash}/path.",
        "properties": {
          "airtimeMs": {
            "description": "Estimated LoRa Time-on-Air x distinct-relay-count for this ping's whole flood -- same estimate as /api/packets/{hash}/path's estimatedAirtimeMs.",
            "nullable": true,
            "type": "number"
          },
          "channelHash": {
            "description": "Which channel the ping was sent on.",
            "type": "string"
          },
          "deepestHops": {
            "description": "Most relay hops any station's observation of this ping took.",
            "type": "integer"
          },
          "deepestNodeName": {
            "description": "Name of the station behind deepestHops.",
            "type": "string"
          },
          "deepestNodePubkey": {
            "description": "Pubkey of the station behind deepestHops.",
            "type": "string"
          },
          "farthestKm": {
            "description": "Farthest any hearing station was from whoever heard it first, in km. Omitted when no station on this ping's path has a known position.",
            "nullable": true,
            "type": "number"
          },
          "farthestNodeName": {
            "description": "Name of the station behind farthestKm.",
            "type": "string"
          },
          "farthestNodePubkey": {
            "description": "Pubkey of the station behind farthestKm.",
            "type": "string"
          },
          "hash": {
            "description": "The winning ping's hash; use /api/ping-scores/{hash}/path with its record slot for saved View Path evidence.",
            "type": "string"
          },
          "kmPerSecondAirtime": {
            "description": "farthestKm / (airtimeMs/1000) -- geographic distance covered per second of estimated RF airtime spent relaying this ping. Only set when both farthestKm and airtimeMs (with relayCount\u003e0) are available.",
            "nullable": true,
            "type": "number"
          },
          "relayCount": {
            "description": "Distinct relay count behind airtimeMs. Present only alongside it.",
            "type": "integer"
          },
          "sender": {
            "description": "Display name of whoever sent the ping, when resolvable from the channel message.",
            "type": "string"
          },
          "spreadSeconds": {
            "description": "How long the flood took to finish reaching every station it ever reached. Omitted when fewer than 2 stations heard it, or no station has timing data.",
            "nullable": true,
            "type": "number"
          },
          "stationCount": {
            "description": "Distinct stations that heard this ping.",
            "type": "integer"
          },
          "timestamp": {
            "description": "RFC3339 timestamp the ping was first seen.",
            "type": "string"
          }
        },
        "type": "object"
      },
      "PingScorePathResponse": {
        "type": "object",
        "properties": {
          "capturedAt": {
            "type": "string",
            "description": "UTC archive capture time, present for archived geometry."
          },
          "path": {
            "$ref": "#/components/schemas/PacketPathResponse"
          },
          "reason": {
            "type": "string",
            "enum": [
              "raw_data_expired_before_capture",
              "no_coordinates",
              "privacy_filtered",
              "archive_too_large",
              "record_evidence_unavailable"
            ]
          },
          "status": {
            "type": "string",
            "enum": [
              "live",
              "archived",
              "unavailable",
              "initializing"
            ]
          }
        }
      },
      "PingScoresResponse": {
        "description": "The ping-score highscore board: current records plus leaderboards, global (not scoped by region/area).",
        "properties": {
          "farthestPing": {
            "$ref": "#/components/schemas/PingScore"
          },
          "fastestSpreadPing": {
            "allOf": [
              {
                "$ref": "#/components/schemas/PingScore"
              }
            ],
            "description": "The fastest full spread among pings heard by at least 2 stations -- a lone station is trivially \"instant\" and is excluded so it can't win this record for nothing."
          },
          "generatedAt": {
            "description": "RFC3339 timestamp this snapshot was computed.",
            "type": "string"
          },
          "mostEfficientPing": {
            "$ref": "#/components/schemas/PingScore"
          },
          "mostHopsPing": {
            "$ref": "#/components/schemas/PingScore"
          },
          "observerLeaderboard": {
            "description": "Top observers ranked by number of pings they were the first station to hear.",
            "items": {
              "$ref": "#/components/schemas/PingLeaderboardEntry"
            },
            "type": "array"
          },
          "relayLeaderboard": {
            "description": "Top nodes ranked by number of distinct pings they appeared as a relay hop in (deduped per ping first, so one busy ping's many branches can't over-credit a relay).",
            "items": {
              "$ref": "#/components/schemas/PingLeaderboardEntry"
            },
            "type": "array"
          },
          "senderLeaderboard": {
            "description": "Top senders ranked by number of pings sent in the last 30 days (unlike the other leaderboards and records, which are all-time). Keyed by the sender display name from the channel message itself -- no resolved pubkey, so entries never carry one.",
            "items": {
              "$ref": "#/components/schemas/PingLeaderboardEntry"
            },
            "type": "array"
          },
          "thisWeek": {
            "allOf": [
              {
                "$ref": "#/components/schemas/WeeklyPingRecords"
              }
            ],
            "description": "The same 5 records as above, scoped to the trailing 7 days instead of all-time. Omitted when no ping in the last 7 days resolved to a usable score."
          },
          "totalPings": {
            "description": "Total ping-bot-triggering messages ever seen, whether or not each one resolved to a usable score.",
            "type": "integer"
          },
          "widestSpreadPing": {
            "$ref": "#/components/schemas/PingScore"
          }
        },
        "type": "object"
      },
      "RepeaterUnscopedHopDepth": {
        "description": "One repeater/room's hop-count profile across the unscoped (plain FLOOD, non-advert) traffic it has relayed.",
        "properties": {
          "count": {
            "description": "Number of unscoped relay-hop instances at this node.",
            "type": "integer"
          },
          "maxHops": {
            "type": "integer"
          },
          "medianHops": {
            "type": "number"
          },
          "minHops": {
            "type": "integer"
          },
          "name": {
            "description": "Node's display name, or its public key if unnamed.",
            "type": "string"
          },
          "publicKey": {
            "description": "Node's public key.",
            "type": "string"
          }
        },
        "type": "object"
      },
      "RouteMaskBackfillStatus": {
        "type": "object",
        "description": "The ingestor's transmissions.route_mask backfill (#89). Until complete, rows without a mask are classified by their first-inserted route_type, so route classes are provisional.",
        "properties": {
          "remaining": {
            "type": "integer",
            "description": "Rows still without a mask; null when it cannot be counted cheaply.",
            "nullable": true
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "backfilling",
              "complete"
            ]
          }
        }
      },
      "SuspiciousGPSNode": {
        "description": "One node whose self-reported lat/lon sits more than GPSSanitySuspectKm from the weighted centroid of its trusted RF-neighbor cluster.",
        "properties": {
          "clusterLat": {
            "description": "Weighted-centroid latitude of the trusted neighbor cluster.",
            "type": "number"
          },
          "clusterLon": {
            "description": "Weighted-centroid longitude of the trusted neighbor cluster.",
            "type": "number"
          },
          "clusterSize": {
            "description": "How many neighbors fed the cluster centroid.",
            "type": "integer"
          },
          "clusterSpreadKm": {
            "description": "Max distance between any two cluster members -- a confidence signal, tighter is more trustworthy.",
            "type": "number"
          },
          "distanceKm": {
            "description": "Distance between the node's own position and its cluster centroid -- always \u003e GPSSanitySuspectKm for a flagged node.",
            "type": "number"
          },
          "lat": {
            "description": "The node's own self-reported latitude.",
            "type": "number"
          },
          "lon": {
            "description": "The node's own self-reported longitude.",
            "type": "number"
          },
          "name": {
            "description": "Display name, falling back to the raw pubkey when unresolved.",
            "type": "string"
          },
          "publicKey": {
            "description": "The node's pubkey.",
            "type": "string"
          }
        },
        "type": "object"
      },
      "TouchedAreaShape": {
        "description": "One configured area's display label plus its drawn boundary, for shading directly on the map. Exactly one of polygon or the latMin/latMax/lonMin/lonMax quartet is present, matching however the area itself was configured.",
        "properties": {
          "label": {
            "description": "The area's display label (e.g. \"Aarhus by\").",
            "type": "string"
          },
          "latMax": {
            "nullable": true,
            "type": "number"
          },
          "latMin": {
            "description": "Present only when the area was configured as a bounding box rather than a polygon.",
            "nullable": true,
            "type": "number"
          },
          "lonMax": {
            "nullable": true,
            "type": "number"
          },
          "lonMin": {
            "nullable": true,
            "type": "number"
          },
          "polygon": {
            "description": "Ordered [lat, lon] pairs tracing the area's drawn boundary. Present only when the area was configured with a polygon rather than a bounding box.",
            "items": {
              "items": {
                "type": "number"
              },
              "maxItems": 2,
              "minItems": 2,
              "type": "array"
            },
            "type": "array"
          }
        },
        "type": "object"
      },
      "UnknownScopeEntry": {
        "description": "A region-scope name seen in a reported neighbor scope list that isn't part of this deployment's configured hashRegions.",
        "properties": {
          "count": {
            "description": "Number of distinct neighbors (by display name, falling back to pubkey) that reported this scope.",
            "type": "integer"
          },
          "examples": {
            "description": "Up to 5 example neighbor display names/pubkeys that reported this scope.",
            "items": {
              "type": "string"
            },
            "type": "array"
          },
          "scope": {
            "description": "The scope name (e.g. \"#dk-storkbh\"), always #-prefixed.",
            "type": "string"
          }
        },
        "type": "object"
      },
      "WeeklyPingRecords": {
        "description": "Mirrors PingScoresResponse's 5 all-time record slots, scoped to the trailing 7 days -- an achievable target that resets on its own, instead of a slot that locks in forever once someone sets a big all-time record.",
        "properties": {
          "farthestPing": {
            "$ref": "#/components/schemas/PingScore"
          },
          "fastestSpreadPing": {
            "allOf": [
              {
                "$ref": "#/components/schemas/PingScore"
              }
            ],
            "description": "Same \u003e=2-station rule as the all-time fastestSpreadPing, applied within the 7-day window."
          },
          "mostEfficientPing": {
            "$ref": "#/components/schemas/PingScore"
          },
          "mostHopsPing": {
            "$ref": "#/components/schemas/PingScore"
          },
          "widestSpreadPing": {
            "$ref": "#/components/schemas/PingScore"
          }
        },
        "type": "object"
      }
    },
    "securitySchemes": {
      "ApiKeyAuth": {
        "in": "header",
        "name": "X-API-Key",
        "type": "apiKey"
      }
    }
  },
  "info": {
    "description": "MeshCore network analyzer — packet capture, node tracking, and mesh analytics. An unrecognized /api or /api/* path returns 404; a documented path called with an unsupported method returns 405 with an Allow header. Both are JSON (#233). HEAD is served on every GET path.",
    "license": {
      "name": "MIT"
    },
    "title": "CoreScope API",
    "version": "v0.2.3"
  },
  "openapi": "3.0.3",
  "paths": {
    "/api/admin/channel-proposals": {
      "get": {
        "description": "Returns {proposals, enabled}, newest first, bounded. A proposal whose name the ingestor already decrypts through its built-in/config list (rainbow table, hashChannels, channelKeys) carries builtIn: true. A proposal whose name differs from another proposal's or a built-in name only by letter case carries nearDuplicateOf: [those names] — a hint, not a merge: hashtag keys are derived from the exact bytes of the name (sha256 of \"#name\"), so #HelloWorld and #helloworld are different channels and stay separate proposals. The hint also sees proposals outside the status filter. Optional status filter.",
        "parameters": [
          {
            "description": "pending, approved, rejected or revoked",
            "in": "query",
            "name": "status",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "summary": "List channel suggestions",
        "tags": [
          "admin"
        ]
      }
    },
    "/api/admin/channel-proposals/{id}/approve": {
      "post": {
        "description": "Queues the approval and returns 202 {requestId}. Approved channels are decrypted by the ingestor and listed for everyone.",
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "summary": "Approve a channel suggestion",
        "tags": [
          "admin"
        ]
      }
    },
    "/api/admin/channel-proposals/{id}/reject": {
      "post": {
        "description": "Queues the rejection and returns 202 {requestId}.",
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "summary": "Reject a channel suggestion",
        "tags": [
          "admin"
        ]
      }
    },
    "/api/admin/channel-proposals/{id}/revoke": {
      "post": {
        "description": "Undoes a previous approval: the ingestor stops decrypting the channel and it drops out of GET /api/channels' approvedChannels. The channel also leaves GET /api/channels (#251) unless the ingestor still decrypts that name through its built-in/config list. Historical messages already decoded and stored are NOT deleted: they stay readable per channel (GET /api/channels/{hash}/messages), and the channel returns to the list, with its history, if the suggestion is approved again. Synchronous precondition check: 202 {requestId} only when the proposal is currently approved; 409 (no side effect, nothing queued) when it is not.",
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "summary": "Revoke an approved channel suggestion",
        "tags": [
          "admin"
        ]
      }
    },
    "/api/admin/prune-geo-filter": {
      "post": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "/api/admin/prune-geo-filter"
      }
    },
    "/api/admin/prune-geo-filter/status": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "/api/admin/prune-geo-filter/status"
      }
    },
    "/api/analytics/areas": {
      "get": {
        "description": "Three breakdowns over the drawn-polygon Areas configured via the meshguide.dk sync, distinct from hashRegion scope adoption (see /api/analytics/scope-stats): (1) density, node count/active-degraded-silent health/role mix per area (multi-membership via AreaKeysForPoint, so a node in a sub-area also counts toward its parent region), (2) bridgeNodes, nodes whose packet-derived neighbor_edges reach into at least one OTHER area (single most-specific area via AreaKeyForPoint), ranked by how many other areas they reach -- distinct from the network-wide, area-unaware bridge_score betweenness centrality, (3) positionGaps, per area how many nodes have a real GPS fix vs. how many were only placeable via the same neighbor-centroid estimate View Path's approx markers use (nearestPositionedNeighbor, geo-sanity-filtered by Config.NeighborMaxEdgeKm so a stray MQTT-bridge observer↔last-hop edge hundreds of km away can't skew the estimate or inflate its spreadKm). estimatedNodes is the flat, network-wide list backing positionGaps' approximated counts, with actual estimated coordinates -- used by the Areas tab's \"View Estimated Nodes\" map view and Tools \u003e Position-Fix Coverage Gaps. Returns an empty response if no Areas are configured. Cached 30s.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AreaAnalyticsResponse"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Per-configured-Area node density, cross-area bridge nodes, and position-fix coverage",
        "tags": [
          "analytics"
        ]
      }
    },
    "/api/analytics/channels": {
      "get": {
        "description": "Message counts and activity per channel.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Channel analytics",
        "tags": [
          "analytics"
        ]
      }
    },
    "/api/analytics/distance": {
      "get": {
        "description": "Geographic distance calculations between nodes.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Distance analytics",
        "tags": [
          "analytics"
        ]
      }
    },
    "/api/analytics/gps-sanity": {
      "get": {
        "description": "The neighbor-centroid technique nearestPositionedNeighbor uses to ESTIMATE a position for a node with no GPS, flipped around to sanity-check a node that DOES report one. For each node with a real (non-zero) GPS fix, takes its strongest neighbor_edges neighbor as an anchor, keeps whichever other positioned neighbors agree with the anchor within GPSSanityClusterTightKm (50km), and -- only if at least GPSSanityMinClusterSize (2) survive that filter -- compares the node's own position against their weighted centroid. Flags it when the distance exceeds GPSSanitySuspectKm (100km). Most nodes are skipped, not evaluated (no neighbor_edges, no positioned neighbor, or too scattered a neighbor set to trust), so evaluated is always well under totalRealGps. v1: doesn't weight by neighbor_edges' hash-prefix ambiguity mode (the confidence indicator public/nodes.js's Neighbors panel shows) since that breakdown only lives in the in-memory NeighborGraph, not the persisted table this reads. Not area-scoped -- works regardless of whether Areas are configured. Cached 30s.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GPSSanityResponse"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Nodes whose self-reported GPS disagrees with their own RF neighbors",
        "tags": [
          "analytics"
        ]
      }
    },
    "/api/analytics/hash-collisions": {
      "get": {
        "description": "Identifies nodes sharing hash prefixes.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Hash collision detection",
        "tags": [
          "analytics"
        ]
      }
    },
    "/api/analytics/hash-sizes": {
      "get": {
        "description": "Distribution of hash prefix sizes across the network.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Hash size analysis",
        "tags": [
          "analytics"
        ]
      }
    },
    "/api/analytics/hop-depth": {
      "get": {
        "description": "Answers three flood-containment questions in one pass over resolved relay paths, using the same 0-based per-node path-index hop count as /api/nodes/{pubkey}/hop_analytics (issue #1812), not the unrelated observer-distance hopDistribution field: (1) does scoped (TRANSPORT_FLOOD/TRANSPORT_DIRECT) traffic actually travel fewer hops network-wide than unscoped (plain FLOOD, non-advert) traffic, (2) which repeater/room nodes are relaying unscoped flood traffic that already traveled far (high hops, a stronger containment-problem signal) vs merely locally (low hops), and (3) is that containment trending better or worse over the window (timeSeries). Plain DIRECT traffic never undergoes flood propagation and is excluded throughout. Cached 30s per window.",
        "parameters": [
          {
            "description": "Time window: 1h, 24h (default), or 7d",
            "in": "query",
            "name": "window",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HopDepthAnalyticsResponse"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Network-wide hop-depth analytics",
        "tags": [
          "analytics"
        ]
      }
    },
    "/api/analytics/neighbor-graph": {
      "get": {
        "description": "Full neighbor affinity graph for visualization.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Neighbor graph",
        "tags": [
          "analytics"
        ]
      }
    },
    "/api/analytics/network-digest": {
      "get": {
        "description": "Tools \u003e Network Digest. \"What happened lately\" at a glance: new-node count, role/name changes, position moves, resurrections, and a ranked breakdown of new-node activity by area, all within the requested window (default 7d). Built on top of /api/analytics/new-nodes and /api/analytics/node-changes -- exact up to 500 rows fetched from each (see newNodesCapped/changesCapped on the response). areaBreakdown counts each new node toward its single most specific configured area (smallest bounding box on overlap), not every area its position happens to fall in -- an umbrella area covering a whole country/continent would otherwise \"win\" on virtually every window regardless of where the activity actually concentrated.",
        "parameters": [
          {
            "description": "Time window, e.g. \"24h\", \"7d\", \"30d\" (default 7d)",
            "in": "query",
            "name": "window",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "\"all\" (default), \"domestic\", or \"foreign\" -- same nodes.foreign_advert flag as Tools \u003e New Nodes' toggle. Narrows every count (and areaBreakdown) to nodes/changes matching that origin; node_changes rows are matched via the node's current foreign_advert, since the audit log doesn't store its own snapshot of it.",
            "in": "query",
            "name": "origin",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NetworkDigest"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Rolling-window summary of New Nodes + Node Changes activity",
        "tags": [
          "nodes"
        ]
      }
    },
    "/api/analytics/new-nodes": {
      "get": {
        "description": "Tools \u003e New Nodes. Newest first, each with its configured area(s) (same touched-areas matching View Path/Ping Scores' Area Activity draw from). Excludes nodes that also appear in inactive_nodes -- a node pruned for inactivity and later returning would otherwise get a freshly-INSERTed nodes.first_seen and misleadingly look brand new; that \"came back after being pruned\" case is its own distinct signal, not covered by this feed. Blacklisted nodes (config.json nodeBlacklist) are excluded. Empty list (not an error) when nothing qualifies.",
        "parameters": [
          {
            "description": "Max entries to return (default 50)",
            "in": "query",
            "name": "limit",
            "required": false,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NewNodesResponse"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Most recently first-seen nodes, network-wide",
        "tags": [
          "nodes"
        ]
      }
    },
    "/api/analytics/node-changes": {
      "get": {
        "description": "Tools \u003e Node Changes, a durable audit log written by cmd/ingestor's UpsertNode as ADVERTs arrive (not a periodic snapshot diff, so nothing is missed between polls). changeType is \"role\", \"name\", \"position\" (\u003e=1km move; GPS jitter under that is not logged), or \"resurrected\" (pubkey previously pruned to inactive_nodes for inactivity, now advertising again -- oldValue is its last_seen there before it returned). A field is only compared when both the old and new ADVERT values are present, since adverts routinely omit name/location. Each entry also carries the node's CURRENT role and foreign_advert (resolved live, not a historical snapshot) for the Tools page's role and All/Domestic/Foreign filters. Newest first. Blacklisted nodes excluded. Empty list (not an error) when nothing has been logged yet.",
        "parameters": [
          {
            "description": "Max entries to return (default 50)",
            "in": "query",
            "name": "limit",
            "required": false,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NodeChangesResponse"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Recent node role/name/position changes and pruned-node returns",
        "tags": [
          "nodes"
        ]
      }
    },
    "/api/analytics/relay-airtime-share": {
      "get": {
        "description": "Overview tab's Relay Airtime Share. Each distinct packet (deduplicated by content hash) scores LoRa Time-on-Air of its raw bytes × distinct repeaters that relayed it, under the assumed preset returned in preset (config analytics.loraPreset); originator TX is excluded. rows[] = {payload_type, type, route_class, count, count_pct, score, airtime_pct}, sorted by airtime_pct desc. type is the numeric payload type; payload_type is a display label and may change. ADVERT is split into up to four rows that all have type 4, classified from transmissions.route_mask (every raw route type observed for the content hash, independent of ingest order): route_class \"flood\" (only route 0/1 seen, label \"ADVERT (flood)\"), \"zero_hop\" (only route 2/3, label \"ADVERT (zero-hop)\"), \"mixed\" (the same payload was seen on both flood and zero-hop routes, e.g. a contact re-shared as a zero-hop advert; label \"ADVERT (mixed)\"; counted once, with all of its relays) and \"legacy\" (no usable route, label \"ADVERT\"; node detail's route_class calls the same bucket \"unknown\", one classifier). Rows whose route_mask is not backfilled yet fall back to the legacy first-inserted route_type; route_mask_backfill {status: pending|backfilling|complete, remaining} reports whether that fallback is still in use: complete only when no transmission lacks a mask and this server has read every backfilled mask (the server picks them up while running); remaining counts rows without a mask, or, once none are left, an upper bound on the transmissions this server has not re-read yet (null while it cannot be counted). route_class is null on every other row, so key rows by (type, route_class), never by type alone. Time-on-Air uses the frame of the first inserted observation, so its length (path bytes, transport codes) can still depend on ingest order. count_pct and airtime_pct are shares of total_count and total_score (nanoseconds). Payload Type Mix (/api/analytics/rf payloadTypes) is not split. Cached in the RF analytics cache (60s default).",
        "parameters": [
          {
            "description": "Relative window: 1h, 24h/1d, 3d, 7d/1w or 30d (default: all loaded packets)",
            "in": "query",
            "name": "window",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "RFC3339 start; if from or to is given, window is ignored (an unparseable value leaves that bound open)",
            "in": "query",
            "name": "from",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "RFC3339 end; if from or to is given, window is ignored (an unparseable value leaves that bound open)",
            "in": "query",
            "name": "to",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Relay airtime share per payload type",
        "tags": [
          "analytics"
        ]
      }
    },
    "/api/analytics/rf": {
      "get": {
        "description": "SNR/RSSI distributions and statistics.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "RF analytics",
        "tags": [
          "analytics"
        ]
      }
    },
    "/api/analytics/roles": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "/api/analytics/roles"
      }
    },
    "/api/analytics/subpath-detail": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Subpath detail",
        "tags": [
          "analytics"
        ]
      }
    },
    "/api/analytics/subpaths": {
      "get": {
        "description": "Common routing subpaths through the mesh.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Subpath analysis",
        "tags": [
          "analytics"
        ]
      }
    },
    "/api/analytics/subpaths-bulk": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Bulk subpath analysis",
        "tags": [
          "analytics"
        ]
      }
    },
    "/api/analytics/topology": {
      "get": {
        "description": "Hop-count distribution and route analysis.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Network topology",
        "tags": [
          "analytics"
        ]
      }
    },
    "/api/analytics/wardriving": {
      "get": {
        "description": "Activity/entry-point/coverage/signal/session analytics for the #wardriving channel (or another channel via ?channel=): message volume over time, top senders, path[0] entry-point hash-prefix tallies (resolve names via /api/resolve-hops), per-observer coverage (observer's known IATA-derived coordinates, not the sender's — MeshMapper's wardriving messages normally carry an anonymous session token, not live GPS), average SNR/RSSI over the same time buckets as the activity series, each sender's messages grouped into distinct sessions/runs (split on a 15-minute gap, each with an AirtimeMs field — LoRa Time-on-Air × distinct relaying repeaters, same formula as the Overview tab's Relay Airtime Share, omitted in DB-only mode), and any senders who explicitly shared their own position (some clients append plaintext \"\u003clat\u003e,\u003clon\u003e\" after the token — a deliberate choice by that sender, confirmed empirically, not something CoreScope infers). Cached 30s per window+channel.",
        "parameters": [
          {
            "description": "Time window: 1h, 24h (default), or 7d",
            "in": "query",
            "name": "window",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Channel name to analyze (default #wardriving)",
            "in": "query",
            "name": "channel",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Wardriving channel analytics",
        "tags": [
          "analytics"
        ]
      }
    },
    "/api/analytics/wardriving/sender-messages": {
      "get": {
        "description": "Individual #wardriving messages from one sender (drill-down behind Top Senders/Sessions): each message's entry-point path (path[0] first, resolve names via /api/resolve-hops), per-observer SNR/RSSI, and lat/lon when that message carried an explicit shared position. Pass since+until (RFC3339) to scope to one session's exact range; otherwise window covers the sender's whole activity in that period. Capped at 200 messages, most-recent-first. Not cached.",
        "parameters": [
          {
            "description": "Sender display name to look up (required, exact match)",
            "in": "query",
            "name": "sender",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Channel name (default #wardriving)",
            "in": "query",
            "name": "channel",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Time window when since/until aren't given: 1h, 24h (default), or 7d",
            "in": "query",
            "name": "window",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "RFC3339 start time — overrides window when paired with until",
            "in": "query",
            "name": "since",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "RFC3339 end time — overrides window when paired with since",
            "in": "query",
            "name": "until",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Wardriving sender message drill-down",
        "tags": [
          "analytics"
        ]
      }
    },
    "/api/audio-lab/buckets": {
      "get": {
        "description": "Returns frequency bucket data for audio analysis.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Audio lab frequency buckets",
        "tags": [
          "analytics"
        ]
      }
    },
    "/api/backup": {
      "get": {
        "description": "Streams a consistent SQLite snapshot of the analyzer DB (VACUUM INTO). Response is application/octet-stream with attachment filename corescope-backup-\u003cunix\u003e.db.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "summary": "Download SQLite backup",
        "tags": [
          "admin"
        ]
      }
    },
    "/api/channel-proposals": {
      "post": {
        "description": "Body {name}, exactly one JSON object without other fields. Only public hashtag channel names (at most 31 UTF-8 bytes including #, case preserved, no control, line-separator or invisible formatting characters) are accepted — never keys. Returns 202 {requestId}; 400 invalid body or name, 403 disabled, 429 rate limited, 503 queue full (both with Retry-After).",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Suggest a public hashtag channel",
        "tags": [
          "channels"
        ]
      }
    },
    "/api/channel-proposals/config": {
      "get": {
        "description": "Returns {enabled}: whether public suggestions are open (requires channelProposals.enabled and a strong apiKey).",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Channel suggestion availability",
        "tags": [
          "channels"
        ]
      }
    },
    "/api/channel-proposals/requests/{requestId}": {
      "get": {
        "description": "Returns {status: queued|pending|approved|rejected|revoked|error, proposal: {id, name, status, createdAt, reviewedAt}, error, builtIn}. builtIn is true when the ingestor already decrypts the name through its built-in/config list. 404 when unknown or expired (24h).",
        "parameters": [
          {
            "in": "path",
            "name": "requestId",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Status of a suggestion or review request",
        "tags": [
          "channels"
        ]
      }
    },
    "/api/channels": {
      "get": {
        "description": "Returns known mesh channels with message counts. approvedChannels ([{name, hash}]) lists the shared hashtag channels an administrator approved, even before they carry traffic; omitted when empty. A channel with stored messages whose shared-channel proposal is not approved (revoked, suggested again and pending, or that re-suggestion rejected) is left out of the list (#251), unless the ingestor also decrypts that name through its built-in/config list (rainbow table, hashChannels, channelKeys); only approving the proposal lists it again, with its history. hiddenChannels ([string], omitted when empty) names the channels left out, so the page does not re-create their rows from live packets; it only names channels that have stored messages (never an unreviewed suggestion), it is global and not filtered by region, and another open tab keeps the set it last loaded, also after a re-approval. The messages stay readable per channel (GET /api/channels/{hash}/messages) and nothing is deleted. Decided per channel with stored messages from a short-lived snapshot of the proposals table (10s, dropped early when an approve/revoke result is read), never a per-request query and without a row cap. When the ingestor's built-in names file is missing or unreadable nothing is hidden. GET /api/analytics/channels is not filtered and still counts these channels.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "List channels",
        "tags": [
          "channels"
        ]
      }
    },
    "/api/channels/{hash}/messages": {
      "get": {
        "description": "Returns messages for a specific channel.",
        "parameters": [
          {
            "in": "path",
            "name": "hash",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Get channel messages",
        "tags": [
          "channels"
        ]
      }
    },
    "/api/config/areas": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "/api/config/areas"
      }
    },
    "/api/config/areas/polygons": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "/api/config/areas/polygons"
      }
    },
    "/api/config/cache": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Get cache configuration",
        "tags": [
          "config"
        ]
      }
    },
    "/api/config/client": {
      "get": {
        "description": "Includes estimatedPositions: {enabled: boolean}, the effective server-startup policy for neighbor-derived position estimates. Missing server configuration defaults to enabled; explicit false suppresses these estimates in node detail, live and archived paths, and estimate-dependent analytics. Reported GPS and independent IATA/name-match observer positioning are unchanged. Restart the server to change the policy.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Client-visible operator configuration",
        "tags": [
          "config"
        ]
      }
    },
    "/api/config/geo-filter": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Get geo-filter configuration",
        "tags": [
          "config"
        ]
      },
      "put": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "/api/config/geo-filter"
      }
    },
    "/api/config/map": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Get map configuration",
        "tags": [
          "config"
        ]
      }
    },
    "/api/config/regions": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Get configured regions",
        "tags": [
          "config"
        ]
      }
    },
    "/api/config/theme": {
      "get": {
        "description": "Returns color maps, CSS variables, and theme defaults.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Get theme configuration",
        "tags": [
          "config"
        ]
      }
    },
    "/api/debug/affinity": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "summary": "Debug neighbor affinity scores",
        "tags": [
          "admin"
        ]
      }
    },
    "/api/decode": {
      "post": {
        "description": "Decodes a hex-encoded packet without storing it.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Decode a raw packet",
        "tags": [
          "packets"
        ]
      }
    },
    "/api/dropped-packets": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "/api/dropped-packets"
      }
    },
    "/api/health": {
      "get": {
        "description": "Returns server health, uptime, and memory stats.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Health check",
        "tags": [
          "admin"
        ]
      }
    },
    "/api/healthz": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "/api/healthz"
      }
    },
    "/api/iata-coords": {
      "get": {
        "description": "Returns lat/lon for known airport codes (used for observer positioning).",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Get IATA airport coordinates",
        "tags": [
          "config"
        ]
      }
    },
    "/api/known-channels": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "/api/known-channels"
      }
    },
    "/api/mqtt/status": {
      "get": {
        "description": "Returns per-MQTT-source connection state and counters (lastConnectUnix, lastPacketUnix, packetsTotal, etc.). Broker URL credentials (user-info, query, fragment) are masked in broker and lastError, and in a name that is a raw broker URL. Sourced from the ingestor stats file; empty list when unavailable. stale is true when the file's sampleAt is older than the /api/perf/io freshness threshold (5s) or unreadable, i.e. the rows are frozen; sampleAgeSec gives its age. (#1043, #160)",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "MQTT source status",
        "tags": [
          "admin"
        ]
      }
    },
    "/api/nodes": {
      "get": {
        "description": "Returns all known mesh nodes with status and metadata. Repeater/room rows carry the issue #672 usefulness metrics (traffic_share_score, bridge_score, coverage_score, redundancy_score), the composite usefulness_score + usefulness_grade, and relay-activity counters. See the Node schema.",
        "parameters": [
          {
            "description": "Filter by node role",
            "in": "query",
            "name": "role",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Filter by status (active/stale/offline)",
            "in": "query",
            "name": "status",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Overrides the deployment's geo_filter node-list default for this one request: \"1\"/\"true\" excludes nodes outside the configured geo_filter (unless foreign_advert-tagged), \"0\"/\"false\" returns every node regardless. Any other value (including omitting it) uses the deployment default — geo_filter applies to the node list unless config.json sets geoFilterExemptNodeList=true.",
            "in": "query",
            "name": "geoFilter",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Issue #1862. \"true\" keeps only nodes that have ever transported at least one region-scoped (TRANSPORT_FLOOD/DIRECT) packet; \"false\" keeps only nodes that never have. Backed by the same relay-activity signal as the Scopes tab's \"Repeaters Never Relaying Any Scope\" section — pair with role=repeater to match its exact semantics.",
            "in": "query",
            "name": "hasScope",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Issue #1862. Comma-separated region scope name(s) (e.g. \"eu,be\"; leading \"#\" optional, case-insensitive) — keeps only nodes that have transported at least one of them. Combines with hasScope as AND, not OR.",
            "in": "query",
            "name": "hashRegion",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NodeListResponse"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "List nodes",
        "tags": [
          "nodes"
        ]
      }
    },
    "/api/nodes/bulk-health": {
      "get": {
        "description": "Returns health status for all nodes in one call.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Bulk node health",
        "tags": [
          "nodes"
        ]
      }
    },
    "/api/nodes/clock-skew": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "/api/nodes/clock-skew"
      }
    },
    "/api/nodes/network-status": {
      "get": {
        "description": "Returns counts of active, stale, and offline nodes.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Network status summary",
        "tags": [
          "nodes"
        ]
      }
    },
    "/api/nodes/resolve": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "/api/nodes/resolve"
      }
    },
    "/api/nodes/search": {
      "get": {
        "description": "Search nodes by name or public key prefix.",
        "parameters": [
          {
            "description": "Search query",
            "in": "query",
            "name": "q",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Search nodes",
        "tags": [
          "nodes"
        ]
      }
    },
    "/api/nodes/{pubkey}": {
      "get": {
        "description": "Returns full detail for a single node by public key. For repeater/room nodes this includes the issue #672 usefulness axes + composite score/grade (see the Node schema). recentAdverts is the chronological list; with include=advertRoutes, recentAdvertsByRoute and advertCounts (#2073) split the node's adverts into flood / zero_hop / mixed, advertIntervals (#245) estimates the flood and zero-hop advert intervals, and the ADVERT rows of recentAdverts carry route_class. A 404 for a key with no nodes row (#199) is {error, inactive_node?, observer?}: inactive_node {public_key, name, role, last_seen, first_seen} is the inactive_nodes row when retention retired the node (no advert in retention.nodeDays; last_seen is the last advert), observer {id, name, last_seen} is the observers row when the key uploads as an observer. Both are omitted for an unknown key and for a blacklisted or hidden identity.",
        "parameters": [
          {
            "description": "Opt-in extras, comma-separated (the parameter may also repeat). advertRoutes (#2073): adds recentAdvertsByRoute, advertCounts, advertIntervals (#245) and route_class on the recentAdverts ADVERT rows. That costs a scan of all the node's ADVERT rows (cached per node for up to 30 s), so only the node page asks for it; without it the response has neither field and no route_class. Unknown values are ignored. Hidden identities never get the extras.",
            "in": "query",
            "name": "include",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "pubkey",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NodeDetailResponse"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Get node detail",
        "tags": [
          "nodes"
        ]
      }
    },
    "/api/nodes/{pubkey}/analytics": {
      "get": {
        "description": "Per-node packet counts, timing, and RF stats.",
        "parameters": [
          {
            "in": "path",
            "name": "pubkey",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Get node analytics",
        "tags": [
          "nodes"
        ]
      }
    },
    "/api/nodes/{pubkey}/analytics/summary": {
      "get": {
        "description": "Fase 5.2a: light sibling of /analytics — timeRange + computedStats only (availabilityPct, longestSilenceMs/Start, signalGrade, snrMean/snrStdDev, relayPct, totalPackets, uniqueObservers, uniquePeers, avgPacketsPerDay). No node object, no clockSkew, and none of the heavy display arrays (activityTimeline/snrTrend/packetTypeBreakdown/observerCoverage/hopDistribution/peerInteractions/uptimeHeatmap) — for callers that only need the summary numbers, not the full endpoint's per-packet detail. Shares its per-packet computation with /analytics, so computedStats is identical between the two for the same pubkey/days.",
        "parameters": [
          {
            "description": "Time window in days, 1-365. Default 7.",
            "in": "query",
            "name": "days",
            "required": false,
            "schema": {
              "type": "integer"
            }
          },
          {
            "in": "path",
            "name": "pubkey",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Get node analytics summary",
        "tags": [
          "nodes"
        ]
      }
    },
    "/api/nodes/{pubkey}/battery": {
      "get": {
        "parameters": [
          {
            "in": "path",
            "name": "pubkey",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "/api/nodes/{pubkey}/battery"
      }
    },
    "/api/nodes/{pubkey}/clock-skew": {
      "get": {
        "description": "Per-node clock-skew analysis derived from ADVERT advert-timestamps vs observation times, calibrated per observer (see ClockSkewEngine). samples is the full per-advert time series in chronological order (sparkline data) by default. Fase 5.2b's sample_limit trims that array before it's sent to the client — it only reduces JSON serialization/payload/client-decoding cost, not the server-side computation or allocation that already produced the full samples slice.",
        "parameters": [
          {
            "description": "Trims samples to at most the N most-recent entries, chronological order preserved. Omitted, non-numeric, or negative: unchanged, every sample returned (legacy default). 0: samples is omitted from the response entirely. N at or above the current sample count: unchanged, every sample returned. sampleCount and every other field are unaffected regardless of sample_limit.",
            "in": "query",
            "name": "sample_limit",
            "required": false,
            "schema": {
              "type": "integer"
            }
          },
          {
            "in": "path",
            "name": "pubkey",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Get node clock skew",
        "tags": [
          "nodes"
        ]
      }
    },
    "/api/nodes/{pubkey}/health": {
      "get": {
        "parameters": [
          {
            "in": "path",
            "name": "pubkey",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Get node health",
        "tags": [
          "nodes"
        ]
      }
    },
    "/api/nodes/{pubkey}/hop_analytics": {
      "get": {
        "description": "Issue #1812. For each recent transmission that passed through this node as a relay, its hop-count AT THIS NODE — the node's own 0-based index within the packet's resolved relay path, i.e. the number MeshCore firmware compares against flood_max/flood_max_advert/flood_max_unscoped in allowPacketForward. Deliberately not the same number as /analytics' hopDistribution field, which is path length to whichever observer reported the packet (a different, unrelated distance). Only transmissions with a resolved relay path are included.",
        "parameters": [
          {
            "description": "Time window in days, 1-365.",
            "in": "query",
            "name": "days",
            "required": false,
            "schema": {
              "type": "integer"
            }
          },
          {
            "in": "path",
            "name": "pubkey",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NodeHopAnalyticsResponse"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Get node hop-count analytics",
        "tags": [
          "nodes"
        ]
      }
    },
    "/api/nodes/{pubkey}/neighbors": {
      "get": {
        "description": "Returns the queried node's first-hop neighbors with affinity scores and observation metadata (count, SNR, distance, observers). Ambiguous edges carry candidate pubkeys.",
        "parameters": [
          {
            "in": "path",
            "name": "pubkey",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NodeNeighborsResponse"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Get node neighbors",
        "tags": [
          "nodes"
        ]
      }
    },
    "/api/nodes/{pubkey}/paths": {
      "get": {
        "parameters": [
          {
            "in": "path",
            "name": "pubkey",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Get node routing paths",
        "tags": [
          "nodes"
        ]
      }
    },
    "/api/nodes/{pubkey}/reach": {
      "get": {
        "parameters": [
          {
            "in": "path",
            "name": "pubkey",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "/api/nodes/{pubkey}/reach"
      }
    },
    "/api/nodes/{pubkey}/rx-coverage": {
      "get": {
        "parameters": [
          {
            "in": "path",
            "name": "pubkey",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "/api/nodes/{pubkey}/rx-coverage"
      }
    },
    "/api/observers": {
      "get": {
        "description": "Returns all known packet observers/gateways.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "List observers",
        "tags": [
          "observers"
        ]
      }
    },
    "/api/observers/clock-skew": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "/api/observers/clock-skew"
      }
    },
    "/api/observers/metrics/summary": {
      "get": {
        "description": "Aggregate metrics across all observers.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Observer metrics summary",
        "tags": [
          "observers"
        ]
      }
    },
    "/api/observers/neighbors": {
      "get": {
        "description": "Flattens /api/observers/{id}/neighbors across ALL observers into one list -- Tools \u003e Observer Neighbors. Same per-entry semantics (scopes null unless the OTA scope query responded, seenViaPackets cross-references the packet-derived neighbor_edges graph, observer/neighbor name null when unresolved). Blacklisted observers (config.json observerBlacklist) are excluded. Empty list (not an error) when no observer has ever sent a /neighbors report. unknownScopes surfaces region-scope names that turned up in a reported neighbor scope list but aren't part of this deployment's configured hashRegions -- scopes the mesh is using that CoreScope doesn't know about yet.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AllObserverNeighborsResponse"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Every observer's reported direct neighbors, network-wide",
        "tags": [
          "observers"
        ]
      }
    },
    "/api/observers/{id}": {
      "get": {
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Get observer detail",
        "tags": [
          "observers"
        ]
      }
    },
    "/api/observers/{id}/analytics": {
      "get": {
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Get observer analytics",
        "tags": [
          "observers"
        ]
      }
    },
    "/api/observers/{id}/metrics": {
      "get": {
        "description": "Packet rates, uptime, and performance metrics.",
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Get observer metrics",
        "tags": [
          "observers"
        ]
      }
    },
    "/api/observers/{id}/neighbors": {
      "get": {
        "description": "Ground truth from the observer's own /neighbors firmware report (#1865) -- distinct from the packet-path-inferred neighbor graph. Empty `neighbors` (never null) and an empty `reportedAt` mean the observer has never sent a /neighbors report: opt-in firmware, unavailable on non-PSRAM hardware -- absence is normal, not a fault. Each entry's `scopes` is null unless the neighbor's OTA scope query responded (status=\"responded\"); `name`/`role` are null when the pubkey doesn't resolve to a known node. `seenViaPackets` cross-references the packet-path-inferred neighbor_edges graph: false means this firmware-confirmed neighbor has never had a resolved packet path between it and the observer, a diagnostic signal (possible coverage gap or packet loss), not itself a fault.",
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Get an observer's direct (zero-hop) neighbors",
        "tags": [
          "observers"
        ]
      }
    },
    "/api/observers/{id}/neighbors/{pubkey}/metrics": {
      "get": {
        "description": "Raw (unaggregated) history of the snr/heard_secs_ago fields the observer's own /neighbors report carries per neighbor -- report volume per pair is inherently low so, unlike /api/observers/{id}/metrics, there is no resolution/downsampling. Defaults to the last 30 days.",
        "parameters": [
          {
            "description": "RFC3339 lower bound (default: 30 days ago)",
            "in": "query",
            "name": "since",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "RFC3339 upper bound (default: none)",
            "in": "query",
            "name": "until",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "pubkey",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Get SNR history for one observer\u003c-\u003eneighbor direct-RF link",
        "tags": [
          "observers"
        ]
      }
    },
    "/api/packets": {
      "get": {
        "description": "Returns decoded packets with filtering, sorting, and pagination.",
        "parameters": [
          {
            "description": "Max packets to return",
            "in": "query",
            "name": "limit",
            "required": false,
            "schema": {
              "type": "integer"
            }
          },
          {
            "description": "Pagination offset",
            "in": "query",
            "name": "offset",
            "required": false,
            "schema": {
              "type": "integer"
            }
          },
          {
            "description": "Sort field",
            "in": "query",
            "name": "sort",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Sort order (asc/desc)",
            "in": "query",
            "name": "order",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Filter by packet type",
            "in": "query",
            "name": "type",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Filter by observer ID",
            "in": "query",
            "name": "observer",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Time range filter (e.g. 1h, 24h, 7d)",
            "in": "query",
            "name": "timeRange",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Full-text search",
            "in": "query",
            "name": "search",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Group duplicate packets by hash",
            "in": "query",
            "name": "groupByHash",
            "required": false,
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "List packets",
        "tags": [
          "packets"
        ]
      }
    },
    "/api/packets/observations": {
      "post": {
        "description": "Submit multiple observer sightings for existing packets.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Batch submit observations",
        "tags": [
          "packets"
        ]
      }
    },
    "/api/packets/timestamps": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Get packet timestamp ranges",
        "tags": [
          "packets"
        ]
      }
    },
    "/api/packets/{hash}/path": {
      "get": {
        "description": "Resolves EVERY distinct station that observed a packet to its own branch: hop count (from that station's deepest observation) plus, where resolvable, each relay's name/role/lat/lon in path order and the station's own position (self-advertised GPS when known, same as /api/observers, else its configured IATA code). A station heard more than once (later flood copies via longer routes) contributes only its deepest observation. Lat/lon are null for any hop or observer that has no known position -- callers should draw a gap, not guess. Also returns `first`: the single earliest-arriving observation across every station (usually 0 hops, close to the sender) -- an approximate origin landmark, distinct from branches[0] which is the deepest/farthest-traveled branch. Backs the Channels tab's ping-bot \"View path\" map link.",
        "parameters": [
          {
            "in": "path",
            "name": "hash",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PacketPathResponse"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Get a packet's full geographic flood spread",
        "tags": [
          "packets"
        ]
      }
    },
    "/api/packets/{id}": {
      "get": {
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Get packet detail",
        "tags": [
          "packets"
        ]
      }
    },
    "/api/paths/inspect": {
      "post": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "/api/paths/inspect"
      }
    },
    "/api/perf": {
      "get": {
        "description": "Returns per-endpoint request timing and slow query log.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Performance statistics",
        "tags": [
          "admin"
        ]
      }
    },
    "/api/perf/io": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "/api/perf/io"
      }
    },
    "/api/perf/reset": {
      "post": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "security": [
          {
            "ApiKeyAuth": []
          }
        ],
        "summary": "Reset performance stats",
        "tags": [
          "admin"
        ]
      }
    },
    "/api/perf/sqlite": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "/api/perf/sqlite"
      }
    },
    "/api/perf/write-sources": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "/api/perf/write-sources"
      }
    },
    "/api/ping-scores": {
      "get": {
        "description": "Global (not scoped by region/area) records and leaderboards derived from every ping-bot-triggering channel message ever seen: farthest reach, most hops, widest simultaneous spread, fastest full spread, and most airtime-efficient ping, plus which relay nodes and which observers appear most often. Computed from the same getPacketPath + LoRa-airtime-estimate logic behind /api/packets/{hash}/path and refreshed on a background interval, so it may lag the very latest ping by a few minutes. Fields are omitted (not zero) until at least one qualifying ping has been recorded.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PingScoresResponse"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Ping-score highscore board",
        "tags": [
          "packets"
        ]
      }
    },
    "/api/ping-scores/{hash}/path": {
      "get": {
        "description": "Returns coherent live or archived path evidence for the current record slot. Archived capture time describes saved geometry, not necessarily the transmission time. Old expired observations cannot be reconstructed. Superseded slot/hash pairs return 404; invalid slots return 400. Current identity privacy rules apply to both sources; unavailable and initializing responses omit path.",
        "parameters": [
          {
            "description": "allTime.\u003ckind\u003e or thisWeek.\u003ckind\u003e; kind is farthestPing, mostHopsPing, widestSpreadPing, fastestSpreadPing or mostEfficientPing",
            "in": "query",
            "name": "record",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "hash",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PingScorePathResponse"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Get a displayed ping record's saved path",
        "tags": [
          "packets"
        ]
      }
    },
    "/api/reach-rank": {
      "get": {
        "description": "Nodes ranked by all-time neighbour count: distinct neighbours over valid neighbor_edges rows (both endpoints 64-hex pubkeys, not equal; within the ingestor's edge retention) — a historical count, not a measure of radio quality, range or traffic. Only nodes with a Reach page (node row, or named observer row) that are not blacklisted or hidden are ranked. Rank is competition ranking (1, 1, 3) with ties listed in pubkey order; a search or page never renumbers. Served from a shared snapshot (60s TTL, refreshed in the background) that also backs the Rank on /api/nodes/{pubkey}/reach; snapshot_at is when it was read.",
        "parameters": [
          {
            "description": "Case-insensitive substring of node name or pubkey (max 64 characters)",
            "in": "query",
            "name": "q",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Rows to skip within the (filtered) list; non-negative (default 0)",
            "in": "query",
            "name": "offset",
            "required": false,
            "schema": {
              "type": "integer"
            }
          },
          {
            "description": "Rows per page (default 50; above 100 → 100; zero, negative or non-numeric → 50)",
            "in": "query",
            "name": "limit",
            "required": false,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Reach leaderboard",
        "tags": [
          "nodes"
        ]
      }
    },
    "/api/resolve-hops": {
      "get": {
        "description": "Resolves hash prefixes in a hop path to node names. Returns affinity scores and best candidates.",
        "parameters": [
          {
            "description": "Comma-separated hop hash prefixes",
            "in": "query",
            "name": "hops",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Resolve hop path",
        "tags": [
          "nodes"
        ]
      }
    },
    "/api/rx-coverage": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "/api/rx-coverage"
      }
    },
    "/api/rx-leaderboard": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "/api/rx-leaderboard"
      }
    },
    "/api/scope-stats": {
      "get": {
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "/api/scope-stats"
      }
    },
    "/api/stats": {
      "get": {
        "description": "Returns aggregate stats (node counts, packet counts, observer counts). Cached for 10s.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Network statistics",
        "tags": [
          "admin"
        ]
      }
    },
    "/api/traces/{hash}": {
      "get": {
        "description": "Returns all observer sightings for a packet hash.",
        "parameters": [
          {
            "in": "path",
            "name": "hash",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            },
            "description": "Success"
          }
        },
        "summary": "Get packet traces",
        "tags": [
          "packets"
        ]
      }
    }
  },
  "tags": [
    {
      "description": "Server administration and diagnostics",
      "name": "admin"
    },
    {
      "description": "Network analytics and statistics",
      "name": "analytics"
    },
    {
      "description": "Mesh channel operations",
      "name": "channels"
    },
    {
      "description": "Server configuration",
      "name": "config"
    },
    {
      "description": "Mesh node operations",
      "name": "nodes"
    },
    {
      "description": "Packet observer/gateway operations",
      "name": "observers"
    },
    {
      "description": "Packet capture and decoding",
      "name": "packets"
    }
  ]
}
