{
  "openapi": "3.1.0",
  "info": {
    "title": "txmesh Public API",
    "version": "3.0.0",
    "summary": "The unauthenticated HTTP surface of txmesh — the pathbot submit/read API, the public alert configuration feed, the session endpoints, and the static page shells.",
    "description": "txmesh (`https://txme.sh`) is a MeshCore mesh telemetry archive, bot framework and\n\"pathbot\". This document describes **only the surface a third party can call without a\nsession cookie**. Everything else in the application — the browsable path list, node\nand topology queries, message history, bot administration, the mesh send bridge and all\n`/api/admin/*` routes — requires a session and is deliberately excluded.\n\n## Who this is for\n\n* **Third-party `!path` bots.** Another operator's bot can hand its hop chains to\n  `POST /api/device/path` and get back a short, shareable map link instead of building\n  its own resolver, storage and map page.\n* **Observer firmware.** The `combined_node` firmware and anything like it submits over\n  the same endpoint; the response is deliberately tiny because the reply rides a\n  ~160-character LoRa text frame.\n* **Map consumers.** `GET /api/path/{slug}/geojson` returns an RFC 7946\n  FeatureCollection that drops straight into Leaflet, MapLibre, geojson.io, or an\n  existing GeoJSON pipeline.\n\n## Access model\n\n* **Reading a path is public by default.** The three `GET /api/path/...` endpoints and\n  the `GET /p/{slug}` page are open to anonymous callers while the deployment's\n  `PATH_VIEW_PUBLIC` env var is true (the default, and what `txmesh` runs). When it is\n  false, an anonymous caller gets `401 {\"error\":\"authentication required\"}` from the\n  JSON endpoints and a `303` redirect to `/login` from the HTML page.\n* **Writing a path requires a sender identity.** `POST /api/device/path` accepts, in\n  strict precedence order: a static `x-device-token`, an `x-meshcore-pubkey` header, or\n  — only when the deployment sets `DEVICE_PATH_OPEN=true`, which is **not** the default\n  — the client IP. With none of those it returns `403`.\n* **`x-meshcore-pubkey` is membership, not authentication.** MeshCore public keys are\n  broadcast over the air and are not secret, so any caller can assert any node's key.\n  This is a deliberate, accepted tradeoff to keep the submit API open; treat it as a\n  grouping/attribution key, never as a credential. Only `x-device-token` is a real\n  secret (matched in constant time).\n* **Anonymous viewers get coarsened coordinates.** The two device-supplied endpoint\n  coordinates (the reporting node's and the requesting node's positions) are rounded to\n  `PATH_ANON_COORD_DECIMALS` decimals — 2 by default, roughly a 1 km grid — for callers\n  with no session, and the response flags this with `coordsRounded`. Untrusted senders\n  additionally have those two coordinates rounded **irreversibly at ingest**. Per-hop\n  repeater coordinates come from the roster/advert archive and are never coarsened.\n* **Sessions are cookies.** `POST /auth/login` and `POST /auth/register` set an\n  `ms_session` cookie (HttpOnly, Secure, SameSite=Lax). Only the SHA-256 of the token is\n  stored server-side. There is no bearer-token or API-key alternative for reads.\n\n## Things that will bite an integrator\n\n* **No CORS.** The application installs no CORS middleware, so there are no\n  `Access-Control-Allow-*` headers and no preflight handling. Browser JavaScript on\n  another origin cannot call any of these endpoints. This is a server-to-server,\n  device-to-server and native-client API; proxy it if you need it in a browser.\n* **Three different error envelopes.** The pathbot routes return `{\"error\": \"...\"}`, the\n  auth routes return FastAPI's `{\"detail\": \"...\"}`, and request-body validation failures\n  return FastAPI's `{\"detail\": [ ... ]}` array. They are not unified — check the\n  operation you are calling.\n* **Path responses are re-resolved on every read.** Only the submitted inputs are\n  stored; hop hashes are matched against the live roster at view time. The same slug can\n  legitimately return more resolved hops, different coordinates and a different\n  `totalKm` tomorrow than it does today. Do not treat a response as immutable and do not\n  cache it indefinitely.\n* **Submitted paths are deleted after the retention window.** A housekeeping loop runs\n  every 6 hours and deletes `device_paths` rows older than `DEVICE_PATH_RETENTION_DAYS`\n  (**7** by default), after which the slug returns `404` from every JSON endpoint and\n  drops out of other reports' `related` lists. A shared `/p/` link is therefore good\n  for about a week — treat one as ephemeral, and re-submit the hop chain if you need\n  a durable record.\n* **Rate limiting is per sender, not global.** See `POST /api/device/path`. The read\n  endpoints are not rate limited by the application at all.\n* **HEAD is mostly not answered.** Contrary to what this document said before\n  2026-07-26, routes here are declared `GET`-only — FastAPI's router, unlike Starlette's,\n  does not add `HEAD` for you — and a `HEAD` falls through to the catch-all static mount,\n  which answers `404` unless a file of that name exists. The exceptions are the four\n  documentation routes (`/docs/api`, `/docs/api/reference` and the two spec files), which\n  answer `HEAD` explicitly because tooling probes a spec URL before fetching it. Probe\n  with `GET` everywhere else; a `404` from a `HEAD` says nothing about whether the\n  resource exists.\n* **Response headers.** Every routed response carries\n  `Content-Security-Policy` (restricts script/connect/form/base/object origins),\n  `Strict-Transport-Security: max-age=31536000; includeSubDomains`,\n  `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff` and\n  `Referrer-Policy: strict-origin-when-cross-origin`. `Cache-Control: no-cache` is added\n  only to `text/html`, `text/css` and JavaScript responses — the JSON API carries no\n  cache directive at all. Unhandled 500s (for example postgres being unreachable) are\n  produced outside that middleware, so they are `text/plain` and carry none of those\n  headers.\n* **This document is hand-written, and it is served.** Fetch it from\n  `GET /docs/api/openapi.yaml` or `GET /docs/api/openapi.json` — those two routes are the\n  only ones in the application that send `Access-Control-Allow-Origin: *`, so browser\n  tooling can load them by URL (the endpoints they describe stay CORS-less). A rendered\n  reference lives at `GET /docs/api/reference` and the prose walkthrough at\n  `GET /docs/api`. FastAPI's own `/docs`, `/redoc` and `/openapi.json` are **disabled**\n  (`openapi_url=None`) — they described the *entire* surface, session-gated admin routes\n  included — and all three return `404`.\n* **A catch-all static mount backs `/`.** After the API routes, the whole `web/`\n  directory is mounted unauthenticated (`StaticFiles(..., html=True)`), so every shell\n  and asset is also reachable by filename — `/index.html`, `/landing.html`,\n  `/path.html`, `/node.html`, `/observer.html`, `/theme.css?v=N`, `/theme.js?v=N` and\n  the rest. Those files are data-free (all data still comes from the JSON API), and the\n  routes documented below win because they are registered first, but note that\n  `GET /index.html` bypasses the anonymous/signed-in branch that `GET /` performs. No\n  path items are listed for them.\n\nBehaviour that depends on deployment configuration is called out inline with the env var\nthat controls it. The defaults quoted here are `meshscope/config.py` defaults, not\nguarantees about any particular cluster.\n\nAPI version `3.0.0` above is the *application's* version (from `pyproject.toml`); this\ndocument is written against **OpenAPI 3.1.0**. The two numbers are unrelated.",
    "contact": {
      "name": "txmesh",
      "url": "https://txme.sh",
      "email": "hello@txme.sh"
    },
    "license": {
      "name": "MIT",
      "identifier": "MIT"
    }
  },
  "servers": [
    {
      "url": "https://txme.sh",
      "description": "This deployment's public base URL, as configured in Admin -> Site. Generated /p/ links use it."
    }
  ],
  "tags": [
    {
      "name": "pathbot",
      "description": "The `!path` integration surface: submit a hop chain, then read it back as JSON, as\nGeoJSON, or as a hosted HTML map page. This is the part of txmesh that was\ndeliberately opened for third-party adoption."
    },
    {
      "name": "auth",
      "description": "Session endpoints. Public in the sense that they carry no authentication dependency —\nthey are how a session is created, destroyed, or probed."
    },
    {
      "name": "system",
      "description": "Health probing and the public alert-configuration feed."
    },
    {
      "name": "pages",
      "description": "Static HTML shells served by the API. They contain no data; their JavaScript calls the\nJSON API. Documented for completeness — an integrator wants the JSON endpoints."
    }
  ],
  "security": [],
  "paths": {
    "/api/device/path": {
      "post": {
        "tags": [
          "pathbot"
        ],
        "operationId": "submitDevicePath",
        "summary": "Submit a MeshCore !path hop chain and get back a short hosted map URL",
        "description": "Stores a `!path` hop-hash chain plus optional endpoint coordinates, node names and\nper-hop SNRs; resolves the hop hashes against the roster/advert archive; mints a\n7-character slug; and returns the URL of the hosted map page `/p/{slug}` (run\nthrough a shortener when this deployment shortens for you — see below).\n\nOnly the **inputs** are stored. Hop hashes are re-resolved every time the path is\nviewed, so a link improves on its own as the roster learns about more repeaters.\n\n### Sender identity (required)\n\nEvaluated in this exact precedence, and a non-matching value simply falls through to\nthe next branch rather than erroring:\n\n1. `x-device-token` — an env-configured (`DEVICE_TOKEN`) or admin-issued token. A\n   match makes the sender\n   **pre-trusted**: its coordinates are stored at full precision. A token header that\n   does not match is *not* an error; there is no \"invalid token\" response.\n2. `x-meshcore-pubkey` — hex, optional leading `!`, case-insensitive, 8–64 hex\n   characters; only the lowercase 8-hex prefix is used. **Membership, not\n   authentication** — public keys are broadcast on the mesh, so anyone can assert\n   anyone's key. Accepted whether or not that key has actually been heard on the\n   mesh (the server records \"known?\" purely so an admin can make a trust call).\n3. Client IP — only when the deployment sets `DEVICE_PATH_OPEN=true` (default\n   **false**). The IP is the *rightmost* `X-Forwarded-For` entry (appended by the\n   trusted proxy), falling back to the peer address.\n\nWith none of the three, the request is rejected `403`. A typo'd token therefore\nsurfaces as either a `403` or a silent downgrade to coarsened coordinates — never as\nan explicit error.\n\nThe `security` block below lists only the two identities a default deployment\naccepts. The third, client-IP branch is conditional on `DEVICE_PATH_OPEN=true` and a\nsecurity requirement cannot carry that condition, so it is described here rather\nthan advertised as an unauthenticated alternative — do not read its absence as\nproof that a particular deployment rejects anonymous submissions.\n\n### Short links\n\nBy default the response `url` is the full `/p/{slug}` link on this deployment. Two\nthings can shorten it, and neither is on for a new sender:\n\n1. A site-wide `URL_SHORTENER`, which shortens for every sender. Not set on\n   `txmesh`.\n2. A **per-sender opt-in** an admin ticks in Admin → Path senders, which sends that\n   one sender's links through `URL_SHORTENER_OPTIN` (`https://da.gd/shorten?url=` by\n   default). Ask if you want it — the reply rides a ~160-character LoRa frame and\n   roughly six characters are on offer.\n\nShortening is best-effort: a 2-second cap, and any failure falls back to the full\nURL, so `url` is always a working link but is **not guaranteed to be short** even\nwith the opt-in on. Whichever form comes back is persisted and reused by the\n15-minute retry dedupe. Note that shortening hands the trace URL to a third party.\n\n### Coordinate precision\n\nStatic-token senders and admin-trusted senders store `o`/`q` precisely. Everyone\nelse's `o` and `q` are rounded to `PATH_ANON_COORD_DECIMALS` decimals (2 by default,\nroughly 1 km) **before they are written**. That is destructive: the precise value is\nnever stored and cannot be recovered.\n\n### Validation asymmetry\n\nOnly `h` can fail the request. A malformed or out-of-range `o`, `q` or `s` is\nsilently dropped (the anchor or the SNR set simply becomes null) — a `200` does not\nmean every field you sent was accepted. `s` in particular is discarded wholesale\nunless it has exactly one value per hop and every value is within ±40 dB.\n\n### Idempotency\n\nA byte-identical logical resubmission (same hashes, same coordinates, same names)\nwithin **15 minutes** reuses the existing row: the same slug comes back, no new row\nis created, and no additional rate-limit budget is consumed. The previously issued\nshort URL is reused when one was persisted — if the original shortening failed, or\nnothing was shortening this sender's links, nothing was stored and the retry attempts\nshortening again. Firmware re-asks when the LoRa reply is lost, so treat this endpoint as\nidempotent-within-15-minutes rather than adding your own dedupe, and never assume a\nfresh slug per call.\n\n### Evaluation order (observable)\n\nBody validation (422) → identity (403) → rate limit (429) → hash validation (400) →\ndatabase writes. An anonymous caller with a malformed body sees `422`, not `403`; a\nrate-limited caller with bad hashes sees `429`, not `400`.\n\n### Compatibility guarantee\n\nThe success body must keep the literal `\"url\":\"...\"` shape — deployed firmware finds\nit with `strstr`, not a JSON parser. It will never be renamed or nested.\n\nContent type may be `application/json`, any `application/*+json`, or **absent**\n(which is what constrained firmware usually sends). Any other content type is a\n`422`. Unknown JSON keys are ignored.",
        "security": [
          {
            "deviceToken": []
          },
          {
            "meshcorePubkey": []
          }
        ],
        "requestBody": {
          "required": true,
          "description": "Single-letter wire format — the sender is a microcontroller building JSON with snprintf into a 256-byte buffer.",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DevicePathSubmission"
              },
              "examples": {
                "firmware": {
                  "summary": "Two-hop report with both endpoints and SNRs",
                  "value": {
                    "h": "90e2,fe27",
                    "o": "30.267153,-97.743061",
                    "q": "30.5083,-97.8203",
                    "n": "TX-Hill Repeater",
                    "r": "kk5abc",
                    "s": "11.5,-3.25"
                  }
                },
                "minimal": {
                  "summary": "Hop chain only — everything else is optional",
                  "value": {
                    "h": "90e2"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Stored (or deduped onto an existing row within the 15-minute window). Exactly\n`url` and `resolved`, plus `km` when the resolver produced a total length.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DevicePathAccepted"
                },
                "examples": {
                  "resolved": {
                    "summary": "One hop resolved, both endpoints known",
                    "value": {
                      "url": "https://txme.sh/p/RiYpPco",
                      "resolved": 1,
                      "km": 41.8
                    }
                  },
                  "nothingResolved": {
                    "summary": "No hop resolved and no endpoint coordinates — `km` is absent, not null",
                    "value": {
                      "url": "https://txme.sh/p/RiYpPco",
                      "resolved": 0
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "`h` failed validation — non-hex, an odd hash width, a hash longer than 3 bytes\n(over 6 hex characters), mixed widths within one chain, empty, or more than 64\nhops. Raised before any sender row is created, so a garbage submission never\nregisters a sender.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "bad hashes"
                }
              }
            }
          },
          "403": {
            "description": "No sender identity: no configured token matched `x-device-token`,\n`x-meshcore-pubkey` was missing or not a plausible key, and `DEVICE_PATH_OPEN`\nis false. This is the default posture — leaving `DEVICE_TOKEN` unset must not\nsilently open a write endpoint to the internet.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "error": "submission requires a device token or a mesh pubkey"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/DeviceRateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/path/{slug}": {
      "get": {
        "tags": [
          "pathbot"
        ],
        "operationId": "getPath",
        "summary": "Resolved !path report as JSON",
        "description": "Returns one stored report, re-resolved at read time against the live roster:\nper-hop node identification with coordinates, the two device-supplied endpoint\npositions, and path-length statistics. This is the richest of the three public path\nviews and is exactly what the `/p/{slug}` HTML page fetches.\n\nThe hop chain is in traversal order, requester side first. Resolution happens per\nrequest, so the same slug can return more resolved hops and a different `totalKm`\nlater — responses are not stable and should not be cached indefinitely.\n\nAnonymous callers get `origin` and `requesterPos` rounded to\n`PATH_ANON_COORD_DECIMALS` decimals (2 by default, roughly 1 km) and\n`coordsRounded: true`. Note the flag means \"read-time coarsening was applied\", not\n\"the numbers changed\" — re-rounding an already-coarse coordinate is a no-op — and it\nis false when the report stores no endpoint coordinates at all. Per-hop repeater\ncoordinates are archive data and are always full precision.\n\nPublic while `PATH_VIEW_PUBLIC` is true (the default). When it is false, an\nanonymous caller gets `401` *before* the slug is looked up, so the endpoint cannot\nbe used to enumerate slugs. A session cookie is optional; when present it also\nsuppresses the read-time coarsening.\n\nNot rate limited.",
        "security": [
          {},
          {
            "sessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Slug"
          }
        ],
        "responses": {
          "200": {
            "description": "The stored report, resolved against the current roster.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PathReport"
                },
                "example": {
                  "slug": "RiYpPco",
                  "createdAt": "2026-07-24T15:04:05.123456+00:00",
                  "reporter": "TX-Hill Repeater",
                  "requester": "kk5abc",
                  "origin": {
                    "lat": 30.27,
                    "lon": -97.74
                  },
                  "requesterPos": {
                    "lat": 30.51,
                    "lon": -97.82
                  },
                  "reporterNode": {
                    "nodeId": "9f2c81ab",
                    "shortId": "9f2c81ab",
                    "role": "repeater",
                    "lastSeen": "2026-07-24T14:58:00+00:00",
                    "batteryMv": null
                  },
                  "requesterNode": null,
                  "coordsRounded": true,
                  "snrs": [
                    11.5,
                    -3.25
                  ],
                  "sentTs": null,
                  "rxTs": null,
                  "delayS": null,
                  "hops": [
                    {
                      "hash": "90e2",
                      "name": "Pflugerville Relay",
                      "nodeId": "90e2c47f",
                      "shortId": "90e2c47f",
                      "role": "repeater",
                      "lat": 30.4394,
                      "lon": -97.7908,
                      "lastSeen": "2026-07-24T14:51:00+00:00",
                      "batteryMv": null,
                      "candidates": 2,
                      "alternates": [
                        {
                          "name": "Gulf Coast 90e2",
                          "nodeId": "90e2f011",
                          "shortId": "90e2f011",
                          "role": "repeater",
                          "lastSeen": "2026-07-22T02:14:00+00:00",
                          "kmAway": 312.4,
                          "meshIds": [
                            2
                          ]
                        }
                      ],
                      "segmentKm": 8.9,
                      "meshIds": [
                        1
                      ],
                      "meshAmbiguous": true
                    },
                    {
                      "hash": "fe27",
                      "name": null,
                      "candidates": 0,
                      "possible": [
                        {
                          "nodeId": "fe2790b4",
                          "name": "fe2790b4",
                          "shortId": "fe2790b4",
                          "lastSeen": "2026-07-19T18:02:00+00:00"
                        }
                      ]
                    }
                  ],
                  "resolved": 1,
                  "totalKm": 27.6,
                  "originKm": 18.7,
                  "suspectSegments": 0,
                  "meshId": 1,
                  "meshInferred": false
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PathAuthRequired"
          },
          "404": {
            "$ref": "#/components/responses/PathNotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/path/{slug}/geojson": {
      "get": {
        "tags": [
          "pathbot"
        ],
        "operationId": "getPathGeoJson",
        "summary": "Resolved !path report as an RFC 7946 GeoJSON FeatureCollection",
        "description": "The same resolved path, shaped for map tooling. Emits one `Point` per **positioned**\nhop plus one `Point` per known endpoint anchor, and a `LineString` tracing\nrequester → hops → reporter in traversal order. Unresolved hops carry no position,\nso both the points and the line skip them (and `seq` on hop features consequently\nhas gaps — it is the index into the full hop list).\n\nServed with media type `application/geo+json`. Error responses are plain\n`application/json`.\n\nIdentifiers (slug, canonical page URL, node names, counts) ride along in a\n`metadata` **foreign member** on the FeatureCollection — non-standard but permitted\nby RFC 7946 — so a consumer needs no second request. All eight metadata keys are\nalways present; any of them may be null.\n\nTwo shape rules that trip people up:\n\n* `Point` feature properties are filtered of nulls, so every property except `role`\n  is **absent** rather than null when unknown. (`seq: 0` survives — the filter drops\n  null, not falsy.)\n* The `LineString` properties are *not* filtered, so all four keys are always\n  present and may be null.\n\nCoordinates are `[longitude, latitude]` (RFC 7946), while the JSON view's `origin`\nand `requesterPos` use `{\"lat\": …, \"lon\": …}`. Two conventions in one API — mind the\norder. There is no CRS member; coordinates are WGS 84. There is no altitude element.\n\nThe `LineString` appears only when at least two points were collected, so a\nsingle-hop path with no endpoints yields one feature and no line.\n\nThis representation carries **no `coordsRounded` flag** even though the identical\nanonymous coarsening was applied to the requester/reporter anchors — call\n`GET /api/path/{slug}` if you need to know.\n\nDistances (`metadata.totalKm`, the LineString's `totalKm`, each hop's `segmentKm`)\nare always computed from the precise stored coordinates, before that coarsening is\napplied to the requester/reporter anchors. For an anonymous caller they therefore do\nnot match what you get by measuring the emitted geometry — do not use them as a\nchecksum.\n\nSame access model as `GET /api/path/{slug}`: public while `PATH_VIEW_PUBLIC` is\ntrue, optional session cookie, not rate limited.",
        "security": [
          {},
          {
            "sessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Slug"
          }
        ],
        "responses": {
          "200": {
            "description": "A GeoJSON FeatureCollection with a `metadata` foreign member.",
            "content": {
              "application/geo+json": {
                "schema": {
                  "$ref": "#/components/schemas/PathFeatureCollection"
                },
                "example": {
                  "type": "FeatureCollection",
                  "metadata": {
                    "slug": "RiYpPco",
                    "url": "https://txme.sh/p/RiYpPco",
                    "createdAt": "2026-07-24T15:04:05.123456+00:00",
                    "reporter": "TX-Hill Repeater",
                    "requester": "kk5abc",
                    "hopCount": 2,
                    "resolved": 1,
                    "totalKm": 27.6
                  },
                  "features": [
                    {
                      "type": "Feature",
                      "geometry": {
                        "type": "Point",
                        "coordinates": [
                          -97.82,
                          30.51
                        ]
                      },
                      "properties": {
                        "role": "requester",
                        "name": "kk5abc"
                      }
                    },
                    {
                      "type": "Feature",
                      "geometry": {
                        "type": "Point",
                        "coordinates": [
                          -97.7908,
                          30.4394
                        ]
                      },
                      "properties": {
                        "role": "hop",
                        "seq": 0,
                        "hash": "90e2",
                        "name": "Pflugerville Relay",
                        "nodeId": "90e2c47f",
                        "nodeRole": "repeater",
                        "lastSeen": "2026-07-24T14:51:00+00:00",
                        "segmentKm": 8.9
                      }
                    },
                    {
                      "type": "Feature",
                      "geometry": {
                        "type": "Point",
                        "coordinates": [
                          -97.74,
                          30.27
                        ]
                      },
                      "properties": {
                        "role": "reporter",
                        "name": "TX-Hill Repeater"
                      }
                    },
                    {
                      "type": "Feature",
                      "geometry": {
                        "type": "LineString",
                        "coordinates": [
                          [
                            -97.82,
                            30.51
                          ],
                          [
                            -97.7908,
                            30.4394
                          ],
                          [
                            -97.74,
                            30.27
                          ]
                        ]
                      },
                      "properties": {
                        "role": "path",
                        "slug": "RiYpPco",
                        "totalKm": 27.6,
                        "resolved": 1
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PathAuthRequired"
          },
          "404": {
            "$ref": "#/components/responses/PathNotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/path/{slug}/related": {
      "get": {
        "tags": [
          "pathbot"
        ],
        "operationId": "getPathRelated",
        "summary": "Other !path reports by the same requester",
        "description": "Lists other reports made by the same requester — the mesh user who ran `!path` —\nfor the \"more from this node\" rail on the `/p/` page.\n\nGrouping prefers **public key**: the requester's name is resolved to its 8-hex\npubkey, then every name that pubkey is known by is unioned, so a node that renamed\nitself still groups as one identity and two different pubkeys that merely share a\ndisplay name never merge. Both the lookup and the name union read the contact-card\nroster (`contacts`) only — unlike hop resolution, adverts are not consulted, so a\nnode known only from advert beacons yields `pubkey: null` / `byPubkey: false` even\nwhen the same node resolves as a hop. Channel `!path` messages carry only the\nsender's name, so a requester with no contact card falls back to exact-name\nmatching. `byPubkey` tells you which happened — surface the distinction, because\nname matching both misses renames and is the weaker signal.\n\nResults are additionally scoped to the **submitting sender of the reference slug**\n(its `token:<label>` / `pubkey:<8hex>` / `ip:<addr>` attribution), so this only ever\nlists reports that arrived from the same submitter. The requester name is whatever\nthe submitter put in the body, so without that scope anyone could POST a path\nclaiming `r=<someone else>` and then use its slug to enumerate that node's whole\n`!path` history — timings and coarse stats included — with no account at all.\nA consequence worth planning for: reports about one requester that arrived through\ntwo different submitters do not group together.\n\nDeliberately returns **no coordinates at all**, only slugs and coarse statistics.\n\nThe statistics here are the snapshot written at submit time, not a fresh resolution:\n`hopCount`, `resolvedHops` and `totalKm` can be null, and can disagree with what\n`GET /api/path/{that slug}` returns today. Note also the naming difference —\n`resolvedHops` here versus `resolved` in the live view.\n\nCapped at 100 items, newest first, excluding the reference slug. There is no cursor,\noffset or total count; a very chatty requester's older reports are simply not\nreachable through this endpoint.\n\nSame access model as the other two reads. Being signed in changes nothing about the\nbody (there are no coordinates to coarsen); the session only matters when\n`PATH_VIEW_PUBLIC` is false. Not rate limited.",
        "security": [
          {},
          {
            "sessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Slug"
          }
        ],
        "responses": {
          "200": {
            "description": "The requester identity used for grouping, plus up to 100 other reports.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RelatedPaths"
                },
                "example": {
                  "requester": "kk5abc",
                  "pubkey": "4b19c0de",
                  "byPubkey": true,
                  "paths": [
                    {
                      "slug": "Qx7mLa2",
                      "createdAt": "2026-07-23T22:11:03.402118+00:00",
                      "reporter": "TX-Hill Repeater",
                      "hopCount": 3,
                      "resolvedHops": 2,
                      "totalKm": 64.2
                    },
                    {
                      "slug": "b1Kd_9Z",
                      "createdAt": "2026-07-21T13:40:55.008921+00:00",
                      "reporter": "Lakeway Relay",
                      "hopCount": 1,
                      "resolvedHops": null,
                      "totalKm": null
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PathAuthRequired"
          },
          "404": {
            "$ref": "#/components/responses/PathNotFound"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/hash/{prefix}": {
      "get": {
        "tags": [
          "pathbot"
        ],
        "operationId": "getHashPrefix",
        "summary": "Nodes matching a 1-, 2- or 3-byte hop hash",
        "description": "Every node whose public key starts with the given hop hash — the collision view\nbehind a `/p/` trace. A MeshCore hop hash is only the leading **1 to 3 bytes** of a\n32-byte pubkey (256 / 65,536 / 16,777,216 buckets), so on a busy roster a 1-byte\nhash routinely matches several nodes and the hop cannot be identified from the hash\nalone. `ambiguous` is that answer directly: `true` means more than one node in the\narchive fits, and any `/p/` page naming one of them is a best guess made from\ngeography, not a certainty.\n\n`prefix` must be **2, 4 or 6 hex characters** — a whole number of bytes. An odd\nlength is not a hash any device emits, and MeshCore does not hash beyond 3 bytes;\nboth are rejected `400`. Case is ignored.\n\nMatches come from BOTH the contact-card roster and the advert dump, because many\nrelays advertise without ever sending a contact card. Unlike the path resolver, a\nnode with **no position** is still returned: an unpositioned repeater collides with\nyours just as thoroughly, and omitting it would report a prefix as unique when it\nis not.\n\n`type` is MeshCore's on-the-wire value (`repeater`, `chat`, `room`, `sensor`);\n`label` is the human word for it, where `chat` reads as **companion**. `meshes`\nlists every mesh whose observers have heard that key, and is deliberately NOT\nnarrowed by `?mesh=` — a hash that looks unique on your mesh while also belonging\nto a repeater on the next one is exactly the collision worth seeing.\n\n`message` names **every** match on one line. `messageShort` is the same answer\ntrimmed to a single LoRa frame for a bot to relay verbatim — it is measured in\nBYTES, not characters, because mesh names routinely carry emoji, and when it cannot\nfit every match it says `+N more` rather than severing a name. The structured set\nis always in `nodes`.\n\nCapped at 200 matches (`truncated: true` when there were more), newest sighting\nfirst. Rate limited per submitting address — see the `429`.",
        "security": [
          {},
          {
            "sessionCookie": []
          }
        ],
        "parameters": [
          {
            "name": "prefix",
            "in": "path",
            "required": true,
            "description": "Hop hash as 2, 4 or 6 hex characters (1, 2 or 3 bytes).",
            "schema": {
              "type": "string",
              "pattern": "^([0-9a-fA-F]{2}){1,3}$"
            },
            "example": "dd"
          },
          {
            "name": "mesh",
            "in": "query",
            "required": false,
            "description": "Restrict matches to nodes heard by one mesh's observers, by slug. Does not\nnarrow each node's own `meshes` attribution.",
            "schema": {
              "type": "string",
              "maxLength": 64
            },
            "example": "lhtx"
          }
        ],
        "responses": {
          "200": {
            "description": "Every archived node whose pubkey starts with the prefix.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "prefix",
                    "widthBytes",
                    "count",
                    "ambiguous",
                    "truncated",
                    "message",
                    "messageShort",
                    "nodes"
                  ],
                  "properties": {
                    "prefix": {
                      "type": "string",
                      "description": "The normalised (lowercased) prefix that was searched."
                    },
                    "widthBytes": {
                      "type": "integer",
                      "enum": [
                        1,
                        2,
                        3
                      ]
                    },
                    "count": {
                      "type": "integer",
                      "description": "Number of entries in `nodes` (after the 200 cap)."
                    },
                    "ambiguous": {
                      "type": "boolean",
                      "description": "More than one node matches; this hop is not identifiable from the hash alone."
                    },
                    "truncated": {
                      "type": "boolean",
                      "description": "More than 200 nodes matched and the list was cut."
                    },
                    "message": {
                      "type": "string",
                      "description": "One line naming every match. Not length-limited."
                    },
                    "messageShort": {
                      "type": "string",
                      "description": "The same answer trimmed to one 160-byte LoRa frame for mesh relay;\nsays `+N more` when it cannot fit every match."
                    },
                    "nodes": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "pubkey": {
                            "type": "string",
                            "description": "The node's public key as archived (lowercase hex)."
                          },
                          "name": {
                            "type": "string",
                            "nullable": true
                          },
                          "type": {
                            "type": "string",
                            "nullable": true,
                            "description": "MeshCore node type on the wire.",
                            "enum": [
                              "repeater",
                              "chat",
                              "room",
                              "sensor",
                              null
                            ]
                          },
                          "label": {
                            "type": "string",
                            "nullable": true,
                            "description": "Human label; `chat` reads as `companion`."
                          },
                          "lat": {
                            "type": "number",
                            "nullable": true
                          },
                          "lon": {
                            "type": "number",
                            "nullable": true
                          },
                          "positioned": {
                            "type": "boolean",
                            "description": "Whether this node has a known position at all."
                          },
                          "heard": {
                            "type": "integer",
                            "nullable": true,
                            "description": "The node's own advert clock (untrusted)."
                          },
                          "seen": {
                            "type": "string",
                            "format": "date-time",
                            "description": "When we last saw the source row asserting this."
                          },
                          "src": {
                            "type": "string",
                            "enum": [
                              "contact",
                              "advert"
                            ],
                            "description": "Which archive the winning row came from."
                          },
                          "meshes": {
                            "type": "array",
                            "items": {
                              "type": "string"
                            },
                            "description": "Slugs of every mesh whose observers have heard this key."
                          }
                        }
                      }
                    }
                  }
                },
                "example": {
                  "prefix": "dd",
                  "widthBytes": 1,
                  "count": 3,
                  "ambiguous": true,
                  "truncated": false,
                  "message": "dd matches 3: Deck-7 (companion), SGRR (repeater), Ridge-Top (repeater)",
                  "messageShort": "dd matches 3: Deck-7 (companion), SGRR (repeater) +1 more",
                  "nodes": [
                    {
                      "pubkey": "dd41aa0200000000",
                      "name": "Deck-7",
                      "type": "chat",
                      "label": "companion",
                      "lat": null,
                      "lon": null,
                      "positioned": false,
                      "heard": 1774000001,
                      "seen": "2026-07-30T14:33:53.291314+00:00",
                      "src": "contact",
                      "meshes": [
                        "lhtx"
                      ]
                    },
                    {
                      "pubkey": "dd9b7a10aabbccdd",
                      "name": "SGRR",
                      "type": "repeater",
                      "label": "repeater",
                      "lat": 30.1,
                      "lon": -97.7,
                      "positioned": true,
                      "heard": 1774000000,
                      "seen": "2026-07-30T14:31:02.100000+00:00",
                      "src": "advert",
                      "meshes": [
                        "lhtx",
                        "gulf"
                      ]
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "`prefix` was not 2, 4 or 6 hex characters — an odd hash width, a non-hex\ncharacter, or longer than the 3 bytes MeshCore hashes to.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Detail"
                },
                "example": {
                  "detail": "prefix must be 2, 4 or 6 hex chars (a 1-, 2- or 3-byte hop hash)"
                }
              }
            }
          },
          "429": {
            "description": "More than `HASH_LOOKUP_RATE_LIMIT` lookups (default 60) from this address in the\nlast rolling 60 seconds. The address is taken from `CF-Connecting-IP`, else the\ntrusted proxy's rightmost `X-Forwarded-For` entry.\n\nThe endpoint is enumerable by design — 256 requests cover every 1-byte bucket —\nso this cap, not a login, is what separates a lookup from a bulk roster export.\nIt is counted in postgres, so the limit holds across all API replicas rather\nthan being multiplied by the replica count. No `Retry-After` is sent; the window\nis a rolling 60 seconds.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Detail"
                },
                "example": {
                  "detail": "too many hash lookups, slow down"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/p/{slug}": {
      "get": {
        "tags": [
          "pathbot",
          "pages"
        ],
        "operationId": "getPathPage",
        "summary": "Human-facing HTML map page for a submitted path",
        "description": "Serves the static `path.html` shell — the page whose URL `POST /api/device/path`\nreturns and that gets pasted into LoRa channels. The shell is data-free: its\nJavaScript reads the slug from `location.pathname` and calls\n`GET /api/path/{slug}` and `GET /api/path/{slug}/related` to populate the map.\n\nThe slug is **not validated or even read** server-side, so an unknown, expired or\nmalformed slug still returns `200` with the same shell; the page then renders \"Path\nnot found.\" after the JSON call 404s. **A `200` here is not evidence that the slug\nexists** — use `GET /api/path/{slug}` for existence checks.\n\nThis is the only page route with server-side gating: when `PATH_VIEW_PUBLIC` is\nfalse and the caller has no session it returns `303 See Other` to `/login` — a\nredirect, not the `401` the JSON endpoints return under the same condition. With the\ndefault (`true`) the gate is inert and the page is fully public.",
        "security": [
          {},
          {
            "sessionCookie": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Slug"
          }
        ],
        "responses": {
          "200": {
            "$ref": "#/components/responses/HtmlPage"
          },
          "303": {
            "description": "`PATH_VIEW_PUBLIC` is false and the caller has no session. Empty body, no\ncontent type.",
            "headers": {
              "Location": {
                "description": "Always `/login`.",
                "schema": {
                  "type": "string",
                  "const": "/login"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/health": {
      "get": {
        "tags": [
          "system"
        ],
        "operationId": "getHealth",
        "summary": "Liveness/readiness probe",
        "description": "Cheap round-trip proving this API replica can reach postgres (`SELECT 1`,\ndeliberately not a table scan — probes run constantly). It is the readiness signal\nfor the deployment: the connection pool opens non-blockingly at startup, so the\nprocess boots fine with postgres down and only this endpoint reveals it.\n\nSafe for third parties to poll, but it reports **this replica's database\nreachability only** — it is not a mesh-health or data-freshness signal. There is no\n`{\"ok\": false}` shape: the endpoint either succeeds or fails hard with a plain-text\n500.",
        "security": [],
        "responses": {
          "200": {
            "description": "This replica can reach the database.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Ok"
                },
                "example": {
                  "ok": true
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/api/alerts": {
      "get": {
        "tags": [
          "system"
        ],
        "operationId": "getAlertsConfig",
        "summary": "Public weather/flood alert configuration and data sources",
        "description": "Read-only, unauthenticated on purpose: a transparency feed showing, for every mesh,\nits region, the NWS counties it watches, the NOAA NWPS river gauges it watches, the\nchannel alerts are announced to, and its public contact links — plus a static\ndescription of the two upstream data sources. It backs the public `/alerts` page so\nanyone can audit how alerts are defined and sourced.\n\nNo database ids are exposed. Two columns are renamed on the way out\n(`discord_url` → `discord`, `website_url` → `website`), and several nullable columns\nare collapsed to `\"\"` rather than null — only `region` (mesh) and\n`name`/`lat`/`lon` (gauge) are genuinely nullable.\n\n`alert_channel` is the one snake_case key on the entire public JSON surface; it is\nnot a typo, do not normalise it.\n\n`sources` is a hardcoded two-element constant, identical on every call — it is not\nqueried.\n\nOrdering is display order: meshes by `sort` then `label`, gauges by `name`\n(null names last). No query parameters, no filtering, no pagination. Not rate\nlimited, but note that the response carries no `Cache-Control`, so poll politely.",
        "security": [],
        "responses": {
          "200": {
            "description": "Every configured mesh's alert definition, plus the static source list.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AlertsConfig"
                },
                "example": {
                  "meshes": [
                    {
                      "slug": "ctx",
                      "label": "Central Texas",
                      "region": "Central Texas",
                      "counties": [
                        "Travis",
                        "Williamson",
                        "Hays",
                        "Bastrop"
                      ],
                      "alert_channel": "alerts",
                      "discord": "https://discord.gg/example",
                      "website": "https://example.org",
                      "contact": "hello@txme.sh",
                      "gauges": [
                        {
                          "lid": "ATRT2",
                          "name": "Colorado Rv at Austin",
                          "lat": 30.2447,
                          "lon": -97.6944,
                          "curated": true
                        }
                      ]
                    }
                  ],
                  "sources": [
                    {
                      "name": "NOAA / National Weather Service (Active Alerts)",
                      "url": "https://api.weather.gov",
                      "desc": "Official tornado, severe thunderstorm, and flash-flood warnings, matched to each mesh's counties."
                    },
                    {
                      "name": "NOAA National Water Prediction Service (NWPS)",
                      "url": "https://water.noaa.gov",
                      "desc": "River gauge observations and forecasts. A flood advisory fires when a watched gauge reaches minor flood or worse (observed or forecast)."
                    }
                  ]
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/auth/meshes": {
      "get": {
        "tags": [
          "auth"
        ],
        "operationId": "listPublicMeshes",
        "summary": "List the meshes offered on the sign-up form",
        "description": "Returns every row of the meshes table reduced to `id`, `slug` and `label`, so the\nunauthenticated login/signup page can populate its mesh dropdown before a session\nexists. `id` is the value to pass back as `mesh_id` on `POST /auth/register`.\n\nProperty names are the raw database column names — lowercase, not camelCase. The\nordering column (`sort`) is not returned, so preserve the array order rather than\nre-sorting client-side.\n\nCompletely ungated: no auth, no throttle, no pagination. A session cookie, if sent,\nis ignored.",
        "security": [],
        "responses": {
          "200": {
            "description": "Meshes in display order. May be empty.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/MeshOption"
                  }
                },
                "example": [
                  {
                    "id": 1,
                    "slug": "ctx",
                    "label": "Central Texas"
                  },
                  {
                    "id": 2,
                    "slug": "htx",
                    "label": "Houston"
                  }
                ]
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/auth/register": {
      "post": {
        "tags": [
          "auth"
        ],
        "operationId": "register",
        "summary": "Create an account and start a session",
        "description": "Validates the username against `^[A-Za-z0-9_.-]{3,32}$` and the password against an\n8-character minimum, throttles per client IP, hashes with scrypt on a worker thread,\ninserts the user, creates a session and sets the `ms_session` cookie — so a\nsuccessful registration leaves you signed in with no separate login round-trip.\n\nOrder of operations matters: the IP rate-limit attempt is recorded **before** the\ninsert, deliberately, so repeated \"username already taken\" probes are throttled as\nan enumeration oracle. The consequence is that *successful* sign-ups burn budget\ntoo — with the defaults you cannot create more than 5 accounts per hour per IP.\n\nThe very first account on an empty instance is created with `role: \"admin\"` and is\nallowed through even when `ALLOW_REGISTRATION` is false (the documented bootstrap).\nEvery later account gets `role: \"user\"`. On a deployment that already has users and\n`ALLOW_REGISTRATION=false`, every call returns `403`.\n\nAn unrecognised `mesh_id` is **silently coerced to null** rather than rejected, and\nthe response does not echo the resolved mesh, so a client sending a stale id has no\nway to detect that it was dropped. Omit the field (or send null) for\n\"Not Listed\"/unassigned.\n\nThe client IP used for throttling is the rightmost `X-Forwarded-For` entry, falling\nback to the peer address — prepending entries cannot shift the bucket.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Credentials"
              },
              "example": {
                "username": "kk5abc",
                "password": "correct-horse-battery",
                "mesh_id": 1
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Account created and signed in. Sets the session cookie.",
            "headers": {
              "Set-Cookie": {
                "$ref": "#/components/headers/SetSessionCookie"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RegisterResult"
                },
                "example": {
                  "ok": true,
                  "username": "kk5abc",
                  "role": "user",
                  "email": "you@example.com",
                  "recoverable": true
                }
              }
            }
          },
          "400": {
            "description": "Username failed `^[A-Za-z0-9_.-]{3,32}$`, or the password was shorter than 8\ncharacters.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Detail"
                },
                "examples": {
                  "badUsername": {
                    "value": {
                      "detail": "username must be 3-32 chars: letters, digits, _ . -"
                    }
                  },
                  "badPassword": {
                    "value": {
                      "detail": "password must be at least 8 characters"
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "`ALLOW_REGISTRATION` is false and at least one account already exists. Note the\ncheck happens after the rate-limit attempt is recorded, so a closed instance\nstill consumes register budget.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Detail"
                },
                "example": {
                  "detail": "registration is disabled"
                }
              }
            }
          },
          "409": {
            "description": "The username is already taken (unique-constraint violation).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Detail"
                },
                "example": {
                  "detail": "username already taken"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "description": "More than `REGISTER_MAX_PER_IP` (default 5) attempts from this IP within\n`REGISTER_WINDOW_S` (default 3600 s). Counted in postgres, so the cap holds\nacross all API replicas. **No `Retry-After` or `RateLimit-*` header** — back off\nfor the full window.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Detail"
                },
                "example": {
                  "detail": "too many sign-ups from your network; try again later"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/auth/login": {
      "post": {
        "tags": [
          "auth"
        ],
        "operationId": "login",
        "summary": "Exchange username and password for a session cookie",
        "description": "Looks the user up case-sensitively, then always performs exactly one scrypt\nverification — against a dummy hash when no row exists — so timing cannot be used to\nenumerate accounts and unknown-user and wrong-password are byte-identical `401`s.\n\nOn success it clears the per-username attempt counter (but not the per-IP one),\ncreates a session row storing only the SHA-256 of the token, and sets the\n`ms_session` cookie. Logging in again mints an *additional* session; an existing\ncookie is not read and older sessions stay valid.\n\nOnly failures are recorded against the throttles. Username lookup is\ncase-**sensitive** while the throttle key is lowercased, so `Alice` and `alice` are\ndifferent accounts that share one login-attempt budget.\n\nThe response has no `role` field — call `GET /auth/me` afterwards for role and\npermissions. `mesh_id` is accepted by the shared body model and ignored here.\n\nThere is no minimum or maximum password length enforced on this endpoint, and no\nCSRF token; the cookie's `SameSite=Lax` is the CSRF control.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Credentials"
              },
              "example": {
                "username": "kk5abc",
                "password": "correct-horse-battery"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Signed in. Sets the session cookie.",
            "headers": {
              "Set-Cookie": {
                "$ref": "#/components/headers/SetSessionCookie"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LoginResult"
                },
                "example": {
                  "ok": true,
                  "username": "kk5abc"
                }
              }
            }
          },
          "401": {
            "description": "Identical response for an unknown username and a wrong password. No cookie is\nset, and the failure is recorded against both the username and IP throttles.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Detail"
                },
                "example": {
                  "detail": "invalid username or password"
                }
              }
            }
          },
          "403": {
            "description": "Correct credentials, but the account is suspended — either by an admin or by the\ninactivity sweep (`INACTIVE_SUSPEND_DAYS`, 120 by default: accounts with no\nsign-in for that long are suspended automatically and their sessions dropped).\nNothing is deleted; an operator can restore it.\n\nChecked **after** the password verification, so this cannot be used to discover\nwhich accounts are suspended. The message names the contact address, and says so\nexplicitly when the account has no recovery address on file.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Detail"
                },
                "example": {
                  "detail": "this account was suspended after 120 days without a sign-in. Contact hello@txme.sh to restore it"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "description": "Either throttle tripped: `LOGIN_MAX_PER_USER` (default 8) for\n`login:user:<lowercased username>`, or `LOGIN_MAX_PER_IP` (default 25) for\n`login:ip:<ip>`, both within `LOGIN_WINDOW_S` (default 900 s). Postgres-backed,\nso cluster-wide. **No `Retry-After` header.** Shared-NAT callers can be blocked\nby other people's failures even when their own logins succeed, because a\nsuccessful login clears only the username counter.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Detail"
                },
                "example": {
                  "detail": "too many attempts; wait a few minutes and try again"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/auth/logout": {
      "post": {
        "tags": [
          "auth"
        ],
        "operationId": "logout",
        "summary": "Destroy the current session and clear the cookie",
        "description": "Reads the `ms_session` cookie and, if present, deletes the single session row whose\nstored SHA-256 matches it, then unconditionally emits a cookie-clearing `Set-Cookie`\nand returns success.\n\nTotal idempotence is the contract: no cookie, an expired cookie, a garbage cookie,\nor an already-deleted session all produce the same `200`, and there is no way to\ntell them apart (a request carrying a cookie still touches postgres, so it can `500`\nwhen the database is unreachable). No session is required to call it.\n\nOnly the caller's own session ends — other sessions for the same user survive (mass\nrevocation lives on the authenticated password-change endpoint).\n\nTakes no body: `fetch('/auth/logout', {method: 'POST'})` with zero bytes and no\ncontent type is the reference call. Anything sent is ignored.",
        "security": [
          {},
          {
            "sessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "description": "Always. Does not indicate whether a session row was actually deleted. Carries a\ncookie-clearing `Set-Cookie` for `ms_session` scoped to `Path=/`.",
            "headers": {
              "Set-Cookie": {
                "description": "Clears the session cookie, e.g.\n`ms_session=\"\"; Max-Age=0; Path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=lax`.\nNote it does not repeat the `Secure`/`HttpOnly` flags used when setting it —\nharmless, since browsers match on name, path and domain.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Ok"
                },
                "example": {
                  "ok": true
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/auth/me": {
      "get": {
        "tags": [
          "auth"
        ],
        "operationId": "getCurrentUser",
        "summary": "Describe the current session's user, or 401 when not signed in",
        "description": "The session probe. It carries no authentication *dependency*, which is why it is\nlisted as public, but it is **not** an anonymous-shape endpoint: with no valid\nsession it returns `401 {\"detail\": \"not signed in\"}`. There is no 200-with-nulls\nvariant and no `authenticated: false` field. Both shipped web clients treat any\nnon-2xx as \"signed out\".\n\nThe session lookup joins on the SHA-256 of the cookie and requires a live\n`expires_at`, so an expired session behaves exactly like no session.\n\n`isManager` means \"region manager or above\" — it is true for admins as well, and\nsays nothing about *which* region: that is `regionId`, and a manager with none\ncan do nothing. The role booleans are convenience for UI; the real enforcement is\non the private endpoints.\n`uiMesh` is always a string: `\"\"` means \"All meshes\"/unset, never null.",
        "security": [
          {
            "sessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "description": "The signed-in user.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SessionUser"
                },
                "example": {
                  "username": "kk5abc",
                  "role": "user",
                  "isAdmin": false,
                  "isManager": false,
                  "observerEnabled": false,
                  "uiMesh": "ctx"
                }
              }
            }
          },
          "401": {
            "description": "No cookie, an unknown token, or an expired session. This is the normal anonymous\noutcome, not an exceptional error.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Detail"
                },
                "example": {
                  "detail": "not signed in"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/": {
      "get": {
        "tags": [
          "pages"
        ],
        "operationId": "getRootPage",
        "summary": "Landing page (anonymous) or dashboard SPA (signed in)",
        "description": "The only page route that branches on session state: anonymous visitors get\n`landing.html`, signed-in users get `index.html` (the vanilla-JS SPA). Both are\n`200 text/html`, so the two documents are indistinguishable from the response\nenvelope.\n\nBecause it performs a session lookup this is the only page route that touches\npostgres under the default configuration, and therefore the only one that can 500\nwhen the database is down. (`/p/{slug}` also performs a session lookup, but only\nwhen `PATH_VIEW_PUBLIC` is false.)",
        "security": [
          {},
          {
            "sessionCookie": []
          }
        ],
        "responses": {
          "200": {
            "$ref": "#/components/responses/HtmlPage"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/login": {
      "get": {
        "tags": [
          "pages"
        ],
        "operationId": "getLoginPage",
        "summary": "Login/registration page shell",
        "description": "Serves `login.html` unconditionally; the form posts to the public\n`POST /auth/login` and `POST /auth/register`, and populates its mesh dropdown from\n`GET /auth/meshes`. No session check — an already-signed-in visitor gets the same\npage.",
        "security": [],
        "responses": {
          "200": {
            "$ref": "#/components/responses/HtmlPage"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/radios": {
      "get": {
        "tags": [
          "pages"
        ],
        "operationId": "getRadiosPage",
        "summary": "Public recommended-radios guide",
        "description": "Static content page recommending MeshCore-capable hardware. Fully public; makes no session-gated data calls.",
        "security": [],
        "responses": {
          "200": {
            "$ref": "#/components/responses/HtmlPage"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/new-to-mesh": {
      "get": {
        "tags": [
          "pages"
        ],
        "operationId": "getNewToMeshPage",
        "summary": "Public getting-started guide",
        "description": "Static content page introducing newcomers to MeshCore. Fully public.",
        "security": [],
        "responses": {
          "200": {
            "$ref": "#/components/responses/HtmlPage"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/alerts": {
      "get": {
        "tags": [
          "pages"
        ],
        "operationId": "getAlertsPage",
        "summary": "Public alerts transparency page",
        "description": "The browser front end for the unauthenticated `GET /api/alerts`, which it fetches\nclient-side. It also calls `GET /auth/me` to decide whether to show a sign-out\ncontrol but tolerates the 401, so the page works fully anonymously. Do not confuse\nthis HTML path with the JSON `/api/alerts`.",
        "security": [],
        "responses": {
          "200": {
            "$ref": "#/components/responses/HtmlPage"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/docs/api": {
      "get": {
        "tags": [
          "pages",
          "pathbot"
        ],
        "operationId": "getApiDocsPage",
        "summary": "Human-readable pathbot API reference",
        "description": "Serves `api-docs.html`, the public integration guide for the pathbot API — sender\nidentity (including submitting under a radio's MeshCore public key), the\n`POST /api/device/path` wire format, the three read endpoints, rate limits and the\nknown gotchas. Static and fully public; makes no data calls at all.\n\nThe prose counterpart of this document, not a replacement for it — this file remains\nthe machine-readable contract, and is served from the two routes below.",
        "security": [],
        "responses": {
          "200": {
            "$ref": "#/components/responses/HtmlPage"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/docs/api/reference": {
      "get": {
        "tags": [
          "pages",
          "pathbot"
        ],
        "operationId": "getApiReferencePage",
        "summary": "This document, rendered",
        "description": "Serves `api-reference.html`, which renders `/docs/api/openapi.json` with ReDoc\n(loaded from jsdelivr, pinned). Every schema, field and example in this file, browsable.\n\nThe rendering is client-side and needs JavaScript plus that CDN; with either missing\nthe page degrades to links to the raw document. Nothing here is generated from the\napplication — it is this file, drawn.",
        "security": [],
        "responses": {
          "200": {
            "$ref": "#/components/responses/HtmlPage"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/docs/api/openapi.yaml": {
      "get": {
        "tags": [
          "pages",
          "pathbot"
        ],
        "operationId": "getOpenApiYaml",
        "summary": "This document, as YAML",
        "description": "The authored OpenAPI 3.1 contract — the file you are reading — served as\n`application/yaml` with `Access-Control-Allow-Origin: *`.\n\nThe CORS header is deliberate and applies to the spec files only: an OpenAPI document\nis meant to be loaded by browser tooling from another origin, and it is public static\ncontent with no credentials attached. The API it describes sends no CORS headers at\nall, so a page that reads this still cannot call those endpoints.",
        "security": [],
        "responses": {
          "200": {
            "description": "The OpenAPI document.",
            "headers": {
              "Access-Control-Allow-Origin": {
                "description": "Always `*`.",
                "schema": {
                  "type": "string",
                  "const": "*"
                }
              }
            },
            "content": {
              "application/yaml": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/docs/api/openapi.json": {
      "get": {
        "tags": [
          "pages",
          "pathbot"
        ],
        "operationId": "getOpenApiJson",
        "summary": "This document, as JSON",
        "description": "The same contract as JSON, for tooling that will not parse YAML. Generated from the\nYAML by `make openapi` and committed beside it, because the runtime image carries no\nYAML parser; the test suite fails if the two drift. Same `Access-Control-Allow-Origin: *`\nas the YAML.",
        "security": [],
        "responses": {
          "200": {
            "description": "The OpenAPI document.",
            "headers": {
              "Access-Control-Allow-Origin": {
                "description": "Always `*`.",
                "schema": {
                  "type": "string",
                  "const": "*"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/documentation": {
      "get": {
        "tags": [
          "pages"
        ],
        "operationId": "getDocumentationRedirect",
        "summary": "Legacy path for the API docs",
        "description": "Where the documentation lived for its first day. Permanently redirects to\n`/docs/api`; kept because the path was published on the landing page and shipped in a\nreleased image. Follow it or update your link — it will not be removed, but nothing\nnew should point at it.",
        "security": [],
        "responses": {
          "308": {
            "description": "Permanent redirect, method preserved.",
            "headers": {
              "Location": {
                "description": "Always `/docs/api`.",
                "schema": {
                  "type": "string",
                  "const": "/docs/api"
                }
              }
            }
          }
        }
      }
    },
    "/counties.geojson": {
      "get": {
        "tags": [
          "pages"
        ],
        "operationId": "getCountiesGeoJson",
        "summary": "Gulf-region county/parish boundary polygons",
        "description": "Static geodata served with an explicit `application/geo+json` media type: US Census\ncartographic outlines (via the plotly dataset) covering TX, LA, MS, AL and FL, used\nby the Mesh Regions map. 534 features, roughly 264 KB, geometry types `Polygon` and\n`MultiPolygon`, coordinates rounded to 3 decimals.\n\n**County names collide across states** — there is a Jefferson in both TX and LA — so\nconsumers must match on the `(name, state)` pair, never on `name` alone.\n\nConditional requests work (`ETag` and `Last-Modified` are emitted). The response\ncarries no `Cache-Control`.",
        "security": [],
        "responses": {
          "200": {
            "description": "A GeoJSON FeatureCollection of county/parish polygons.",
            "headers": {
              "ETag": {
                "description": "Emitted by the static file response; usable with `If-None-Match`.",
                "schema": {
                  "type": "string"
                }
              },
              "Last-Modified": {
                "description": "Emitted by the static file response; usable with `If-Modified-Since`.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/geo+json": {
                "schema": {
                  "$ref": "#/components/schemas/CountiesFeatureCollection"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/observer/{node}": {
      "get": {
        "tags": [
          "pages"
        ],
        "operationId": "getObserverPage",
        "summary": "Per-gateway drill-down page shell",
        "description": "Serves the static `observer.html` shell (charts plus status and heard history for\none gateway). The `node` path parameter is accepted and **ignored server-side** — the\nhandler never reads it, and it is never used in a filesystem path; the shell's\nJavaScript parses it from `location.pathname` and calls the session-gated\n`/api/observer/*` endpoints.\n\nThe route is therefore unauthenticated but the page is useless without a session:\nits fetch helper redirects to `/login` on 401. There is no public JSON behind it.",
        "security": [],
        "parameters": [
          {
            "name": "node",
            "in": "path",
            "required": true,
            "description": "Gateway/observer node id. Entirely unvalidated and unused server-side — any value returns the same shell.",
            "schema": {
              "type": "string"
            },
            "example": "gw-atx-01"
          }
        ],
        "responses": {
          "200": {
            "$ref": "#/components/responses/HtmlPage"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    },
    "/node/{pubkey}": {
      "get": {
        "tags": [
          "pages"
        ],
        "operationId": "getNodePage",
        "summary": "Per-mesh-node drill-down page shell",
        "description": "Serves the static `node.html` shell (map, ego topology, messages, paths for one mesh\nnode). Like `/observer/{node}`, the `pubkey` parameter is accepted and ignored\nserver-side; there is no hex or length validation, so a malformed or nonexistent key\nstill returns `200` with the same shell — never a 400 or 404.\n\nThe page is unusable anonymously: its fetch helper redirects to `/login` when the\nsession-gated `GET /api/node/{pubkey}` returns 401.",
        "security": [],
        "parameters": [
          {
            "name": "pubkey",
            "in": "path",
            "required": true,
            "description": "Mesh node public key — the 8-hex-char prefix in practice. Not validated server-side.",
            "schema": {
              "type": "string"
            },
            "example": "90e2c47f"
          }
        ],
        "responses": {
          "200": {
            "$ref": "#/components/responses/HtmlPage"
          },
          "500": {
            "$ref": "#/components/responses/ServerError"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "sessionCookie": {
        "type": "apiKey",
        "in": "cookie",
        "name": "ms_session",
        "description": "Session cookie issued by `POST /auth/login` and `POST /auth/register`\n(`HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=SESSION_DAYS*86400`, 30 days by\ndefault). Only the SHA-256 of the token is stored server-side, so the cookie is the\nonly copy of the credential.\n\n**Optional** on every path read: those endpoints are fully usable with no security\nat all while `PATH_VIEW_PUBLIC` is true. Supplying it does two things — it satisfies\nthe gate when `PATH_VIEW_PUBLIC` is false, and it suppresses the read-time coarsening\nof the reporter/requester coordinates."
      },
      "deviceToken": {
        "type": "apiKey",
        "in": "header",
        "name": "x-device-token",
        "description": "Static shared secret for `POST /api/device/path`. This is a real credential — the\nonly one on this surface.\n\nTwo sources, checked in that order: the deployment's `DEVICE_TOKEN` env var\n(`token:label,token2:label2`, compared in constant time), and tokens an admin issues\nfrom Admin → Path senders. Issued tokens are stored as a SHA-256 and shown exactly\nonce at creation, so a lost one is revoked and reissued rather than recovered;\nrevocation takes effect on the next submission. Ask at hello@txme.sh for one.\n\nA token sender is pre-trusted: its submitted endpoint coordinates are stored at full\nprecision rather than coarsened. The token's *label* — never the secret — becomes the\nsender identity, the rate-limit key and the audit `token_id`.\n\nA present-but-wrong value is not an error; evaluation falls through to the pubkey and\nIP branches."
      },
      "meshcorePubkey": {
        "type": "apiKey",
        "in": "header",
        "name": "x-meshcore-pubkey",
        "description": "The submitting node's MeshCore public key — hex, optional leading `!`,\ncase-insensitive, 8–64 hex characters, truncated to its lowercase 8-hex prefix.\n\n**This is membership, not authentication.** MeshCore public keys are broadcast over\nthe air and are not secret, so any caller can assert any node's key. It is listed as\na security scheme only because it is the header that selects a sender identity; it\nis deliberately not a security control. Attribution, rate-limit bucketing and admin\ntrust decisions key off it — nothing that depends on secrecy does.\n\nA malformed value is not an error; evaluation falls through to the IP branch (and\nthen to 403)."
      }
    },
    "parameters": {
      "Slug": {
        "name": "slug",
        "in": "path",
        "required": true,
        "description": "The short id minted at submit time (`secrets.token_urlsafe(5)[:7]`): exactly 7\ncharacters from the base64url alphabet. Not validated by the framework, so a\nmalformed value yields `404`, never `422`. Slugs are meant to be pasted into LoRa\nchannels — they are unguessable-ish but are not a capability or a secret.",
        "schema": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_-]{7}$",
          "minLength": 7,
          "maxLength": 7
        },
        "example": "RiYpPco"
      }
    },
    "headers": {
      "RetryAfter": {
        "description": "Fixed literal `60` — seconds to wait before retrying. Not computed from the actual\nwindow position. Always present on a `429`.",
        "schema": {
          "type": "string",
          "const": "60"
        }
      },
      "SetSessionCookie": {
        "description": "Sets the session cookie, e.g.\n`ms_session=<43-char url-safe token>; Max-Age=2592000; Path=/; HttpOnly; Secure; SameSite=lax`.\n`Max-Age` is `SESSION_DAYS * 86400` (30 days by default).",
        "schema": {
          "type": "string"
        }
      }
    },
    "responses": {
      "PathAuthRequired": {
        "description": "Only when the deployment sets `PATH_VIEW_PUBLIC=false` and the caller has no valid\nsession. Returned before the slug is looked up, so it leaks nothing about whether the\nslug exists. Note the envelope is `error`, not FastAPI's `detail`.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "authentication required"
            }
          }
        }
      },
      "PathNotFound": {
        "description": "Unknown slug — never stored, or aged out by the `DEVICE_PATH_RETENTION_DAYS` purge\n(7 days by default, swept every 6 hours).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "not found"
            }
          }
        }
      },
      "DeviceRateLimited": {
        "description": "More than `DEVICE_RATE_LIMIT` stored paths (default 30) from **this sender** in the\nlast rolling 60 seconds.\n\nPubkey senders face a SECOND, independent ceiling keyed on the submitting address\n(`DEVICE_IP_RATE_LIMIT`, default 120/min; the address is taken from\n`CF-Connecting-IP`, else the trusted proxy's rightmost `X-Forwarded-For` entry).\nA pubkey is asserted in an unauthenticated header, so the per-sender budget alone\nonly bounds a caller that keeps one identity — rotating the header would otherwise\nmint an unlimited budget. Both ceilings apply and a caller must stay under each.\nToken senders are exempt from the address ceiling (an integrator's fleet may share\none egress).\n\nThe per-sender limit is keyed on `token:<label>`, `pubkey:<8hex>` or `ip:<addr>` —\nnever globally, so one integrator cannot starve another's budget. It is counted in\npostgres rather than in process memory, so the cap holds across all API replicas\ninstead of being multiplied by the replica count.\n\nConsequences: only successfully stored paths consume budget; a 15-minute dedupe hit\ncreates no row and so costs nothing; the check runs before hash validation, so a\nrate-limited caller with a malformed `h` sees this rather than `400`; and requests\nrejected `403` never reach the counter.",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "error": "rate limited"
            }
          }
        }
      },
      "ValidationError": {
        "description": "Request body failed model validation before the handler ran — a missing or\nmis-typed field, a field over its `maxLength`, malformed JSON, or an unsupported\ncontent type such as `text/plain`. Note the envelope differs from the other errors on\nthe same operation (a `detail` **array**, not an `error` string), and that this check\nprecedes the identity check, so an unauthorized caller with a bad body sees this\nrather than `403`.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/HTTPValidationError"
            },
            "example": {
              "detail": [
                {
                  "type": "missing",
                  "loc": [
                    "body",
                    "h"
                  ],
                  "msg": "Field required",
                  "input": {
                    "o": "30.267153,-97.743061"
                  }
                }
              ]
            }
          }
        }
      },
      "ServerError": {
        "description": "Unhandled server error — in practice the database being unreachable, or a missing\nfile in `WEB_DIR` for a page route. Produced outside the application's middleware, so\nthe body is **plain text**, not JSON, and none of the usual security headers are\npresent.",
        "content": {
          "text/plain": {
            "schema": {
              "type": "string",
              "const": "Internal Server Error"
            }
          }
        }
      },
      "HtmlPage": {
        "description": "The static HTML shell, `text/html; charset=utf-8`. Contains no data — its JavaScript\ncalls the JSON API. Carries `Cache-Control: no-cache`, though Cloudflare in front of\nthe origin still caches the shared `theme.css`/`theme.js` assets for about 4 hours\n(which is why they are linked with a `?v=N` cache buster).",
        "content": {
          "text/html": {
            "schema": {
              "type": "string"
            }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "title": "Error",
        "description": "The pathbot error envelope. Hand-built, and deliberately different from FastAPI's\n`detail` shape used by the auth routes.",
        "required": [
          "error"
        ],
        "additionalProperties": false,
        "properties": {
          "error": {
            "type": "string",
            "description": "A fixed human-readable string; the set of values is documented per response.",
            "examples": [
              "not found"
            ]
          }
        }
      },
      "Detail": {
        "type": "object",
        "title": "Detail",
        "description": "FastAPI's default `HTTPException` envelope, used by the auth routes.",
        "required": [
          "detail"
        ],
        "properties": {
          "detail": {
            "type": "string",
            "examples": [
              "not signed in"
            ]
          }
        }
      },
      "HTTPValidationError": {
        "type": "object",
        "title": "HTTPValidationError",
        "description": "FastAPI/pydantic request-validation envelope. Note `detail` is an array here, not a string.",
        "required": [
          "detail"
        ],
        "properties": {
          "detail": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ValidationErrorItem"
            }
          }
        }
      },
      "ValidationErrorItem": {
        "type": "object",
        "title": "ValidationErrorItem",
        "required": [
          "type",
          "loc",
          "msg"
        ],
        "properties": {
          "type": {
            "type": "string",
            "description": "Pydantic error code, e.g. `missing`, `string_too_long`, `json_invalid`."
          },
          "loc": {
            "type": "array",
            "description": "Path to the offending value, e.g. `[\"body\", \"h\"]`.",
            "items": {
              "type": [
                "string",
                "integer"
              ]
            }
          },
          "msg": {
            "type": "string",
            "description": "Human-readable message, e.g. `Field required`."
          },
          "input": {
            "description": "The offending input value, of whatever type was supplied."
          },
          "ctx": {
            "type": "object",
            "description": "Extra context for the error, e.g. `{\"max_length\": 512}`.",
            "additionalProperties": true
          }
        }
      },
      "Ok": {
        "type": "object",
        "title": "Ok",
        "description": "Minimal success envelope.",
        "required": [
          "ok"
        ],
        "additionalProperties": false,
        "properties": {
          "ok": {
            "type": "boolean",
            "const": true
          }
        }
      },
      "DevicePathSubmission": {
        "type": "object",
        "title": "DevicePathSubmission",
        "description": "The `!path` submit body. Single-letter keys are intentional — the reference sender is\na microcontroller building JSON with snprintf into a 256-byte buffer. The length caps\nare far above anything firmware sends and only bound abuse. Unknown keys are ignored.",
        "required": [
          "h"
        ],
        "properties": {
          "h": {
            "type": "string",
            "maxLength": 512,
            "description": "The hop-hash chain: comma-joined MeshCore path hashes in **traversal order**\n(requester side first, reporting node last). Each hash is the leading 1–3 bytes\nof a repeater public key as hex, i.e. exactly 2, 4 or 6 hex characters.\n\nElements are whitespace-stripped and lowercased, and empty elements are dropped,\nso a trailing comma or padded input is fine. There must be 1–64 elements, and\n**all elements must be the same width** — hash size is uniform within one\nMeshCore packet, so `aa,bbbb` is rejected. That uniformity rule cannot be\nexpressed as a regex, hence only `maxLength` is machine-checkable here.\n\nAny violation returns `400 {\"error\": \"bad hashes\"}`. This is the only field that\ncan fail the request.",
            "examples": [
              "90e2,fe27"
            ]
          },
          "o": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 64,
            "default": null,
            "description": "The **reporter's** position — the node that observed or answered the `!path` —\nas `\"lat,lon\"` in decimal degrees. The chain's end anchor.\n\nSplit on the first comma; both parts must parse as floats; latitude must be in\n[-90, 90] and longitude in [-180, 180]; the exact pair `0,0` is rejected as\nMeshCore's \"position not set\" sentinel. Anything unparseable or out of range is\n**silently treated as absent** — it never causes an error.\n\nCoarsened irreversibly at ingest unless the sender is token-authenticated or\nadmin-trusted.",
            "examples": [
              "30.267153,-97.743061"
            ]
          },
          "q": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 64,
            "default": null,
            "description": "The **requester's** position (the node that ran `!path`), `\"lat,lon\"`, typically\nfrom that node's wardrive/advert beacon. The chain's start anchor. Same parsing,\nsame silent-drop behaviour and same coarsening as `o`.",
            "examples": [
              "30.5083,-97.8203"
            ]
          },
          "n": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 128,
            "default": null,
            "description": "Reporter node's display name. Truncated to the first 40 characters when stored.",
            "examples": [
              "TX-Hill Repeater"
            ]
          },
          "r": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 128,
            "default": null,
            "description": "Requester's display name. Truncated to 40 characters when stored. This is the\nvalue `GET /api/path/{slug}/related` groups on.",
            "examples": [
              "kk5abc"
            ]
          },
          "s": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 512,
            "default": null,
            "description": "Optional device-measured per-hop SNR in dB, comma-joined, traversal order,\n**exactly one value per hop**. Every element must parse as a float, the count\nmust equal the number of hashes in `h`, and every value must be within\n[-40.0, 40.0].\n\nAnything malformed, miscounted or out of range is **silently discarded in its\nentirety** (stored as null) — signal readings are decoration and are never a\nreason to reject a path.",
            "examples": [
              "11.5,-3.25"
            ]
          }
        }
      },
      "DevicePathAccepted": {
        "type": "object",
        "title": "DevicePathAccepted",
        "description": "The submit response, deliberately tiny: the reply rides a ~160-character LoRa text\nframe, so none of the rich per-hop data is echoed. Fetch\n`GET /api/path/{slug}` or `/geojson` for that.",
        "required": [
          "url",
          "resolved"
        ],
        "additionalProperties": false,
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "description": "The hosted map page for this submission. **Treat it as opaque**: when\n`URL_SHORTENER` is configured this is the shortened URL and may not live on this\nhost at all. Otherwise it is `<PUBLIC_BASE_URL or the request base>/p/<slug>`.\nShortening fails soft (2 s timeout; the short value is used only when it is an\nHTTP 200, starts with `http`, has no spaces and is strictly shorter), and a short\nURL is persisted, so a firmware retry reuses it rather than re-shortening.\n\nThe literal `\"url\":\"...\"` shape is a compatibility guarantee — deployed firmware\nlocates it with `strstr`, not a JSON parser.",
            "examples": [
              "https://txme.sh/p/RiYpPco"
            ]
          },
          "resolved": {
            "type": "integer",
            "minimum": 0,
            "description": "**A count, not a boolean**: how many of the submitted hops resolved to an\nactual located node (0 to the number of hashes in `h`). A hop counts as resolved\nwhen at least one roster/advert entry matches its hash prefix and carries a\nusable position.",
            "examples": [
              1
            ]
          },
          "km": {
            "type": "number",
            "description": "Total path length in kilometres, one decimal, across requester anchor → resolved\nhop positions → reporter anchor (unresolved hops carry no position, so the line\nskips them).\n\n**A conditionally present key, never null**: it is emitted only when the resolver\nproduced a total, i.e. when at least one hop resolved *or* both `q` and `o`\nparsed to valid coordinates. In the degenerate case of zero hops resolved with\nboth endpoints supplied, this is simply the straight-line requester→reporter\ndistance.",
            "examples": [
              41.8
            ]
          }
        }
      },
      "LatLon": {
        "type": "object",
        "title": "LatLon",
        "description": "A device-supplied endpoint coordinate in `{lat, lon}` form. Note the GeoJSON view\nuses `[lon, lat]` arrays instead — two conventions in one API.",
        "required": [
          "lat",
          "lon"
        ],
        "additionalProperties": false,
        "properties": {
          "lat": {
            "type": "number",
            "examples": [
              30.27
            ]
          },
          "lon": {
            "type": "number",
            "examples": [
              -97.74
            ]
          }
        }
      },
      "NodeRef": {
        "type": "object",
        "title": "NodeRef",
        "description": "Identification of a named endpoint node. There is deliberately **no `name` key** —\nthe caller already has the name it looked up.\n\nResolved from the submitted display name against the contact-card roster\n(`contacts`) only — unlike hop resolution, adverts are not consulted, so a node\nknown only from advert beacons yields null here even when the same node resolves as\na hop. The match must also be unique: MeshCore names are not unique, and a wrong\nlink is worse than none, so an ambiguous or unknown name yields null instead of this\nobject.",
        "required": [
          "nodeId",
          "shortId",
          "role",
          "lastSeen",
          "batteryMv"
        ],
        "additionalProperties": false,
        "properties": {
          "nodeId": {
            "type": "string",
            "pattern": "^[0-9a-f]{8}$",
            "description": "The node's 8-hex-character lowercase public key prefix — the archive's node id.",
            "examples": [
              "9f2c81ab"
            ]
          },
          "shortId": {
            "type": "string",
            "pattern": "^[0-9a-f]{8}$",
            "description": "Always the identical value as `nodeId`. Vestigial, retained for wire compatibility.",
            "examples": [
              "9f2c81ab"
            ]
          },
          "role": {
            "type": "string",
            "description": "Node type from the contacts/advert roster, falling back to the literal `\"unknown\"` when unset. Free-form text; `repeater` and `unknown` are the values the resolver reasons about.",
            "examples": [
              "repeater"
            ]
          },
          "lastSeen": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "ISO 8601 UTC with an explicit `+00:00` offset, second resolution. Null when the\nnode has never been heard (epoch 0 is treated as unknown). Clamped to the\nserver's current time, so a node with a fast clock can never report a future\n`lastSeen`.",
            "examples": [
              "2026-07-24T14:58:00+00:00"
            ]
          },
          "batteryMv": {
            "type": [
              "integer",
              "null"
            ],
            "description": "**Always null.** Probe telemetry was retired when the firmware went passive; the\nkey is retained only so the shape stays stable."
          }
        }
      },
      "HopAlternate": {
        "type": "object",
        "title": "HopAlternate",
        "description": "A runner-up prefix match for a resolved hop — another located node sharing the same\nhash — so a human can second-guess the pick. Ordered by ascending Viterbi forward-pass\nchain cost, which is not quite \"second best overall\" because the final pick also folds\nin the terminal origin-anchor cost. At most 4.",
        "required": [
          "name",
          "nodeId",
          "shortId",
          "role",
          "lastSeen",
          "kmAway",
          "meshIds"
        ],
        "additionalProperties": false,
        "properties": {
          "name": {
            "type": "string",
            "description": "Roster/advert name, falling back to the 8-hex pubkey. Never null.",
            "examples": [
              "Round Rock North"
            ]
          },
          "nodeId": {
            "type": "string",
            "pattern": "^[0-9a-f]{8}$"
          },
          "shortId": {
            "type": "string",
            "pattern": "^[0-9a-f]{8}$",
            "description": "Identical value as `nodeId`."
          },
          "role": {
            "type": "string",
            "description": "Node type, or `\"unknown\"`."
          },
          "lastSeen": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "kmAway": {
            "type": "number",
            "description": "Great-circle distance in km from the **picked** node to this alternate (not from any anchor), one decimal.",
            "examples": [
              12.7
            ]
          },
          "meshIds": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "description": "Mesh ids whose observers have heard this alternate. Compare against the report's\n`meshId` to see whether a runner-up was rejected for being on another mesh — the\nusual reason a same-prefix node loses.",
            "examples": [
              [
                2
              ]
            ]
          }
        }
      },
      "PossibleNode": {
        "type": "object",
        "title": "PossibleNode",
        "description": "A \"known but unlocated\" prefix match on an unresolved hop: a node that exists in the\ncontacts/advert roster but carries no usable position, so it cannot be placed on the\nmap. At most 3.",
        "required": [
          "nodeId",
          "name",
          "shortId",
          "lastSeen"
        ],
        "additionalProperties": false,
        "properties": {
          "nodeId": {
            "type": "string",
            "pattern": "^[0-9a-f]{8}$"
          },
          "name": {
            "type": "string",
            "description": "Advertised name, falling back to the pubkey. Never null."
          },
          "shortId": {
            "type": "string",
            "pattern": "^[0-9a-f]{8}$",
            "description": "Identical value as `nodeId`."
          },
          "lastSeen": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      },
      "ResolvedHop": {
        "type": "object",
        "title": "ResolvedHop",
        "description": "A hop whose hash matched at least one located node. Coordinates here come from the\nroster/advert archive and are **never coarsened** — the anonymous ~1 km rounding\napplies only to the two device-supplied endpoint coordinates.",
        "required": [
          "hash",
          "name",
          "nodeId",
          "shortId",
          "role",
          "lat",
          "lon",
          "lastSeen",
          "batteryMv",
          "candidates",
          "alternates",
          "segmentKm",
          "meshIds"
        ],
        "additionalProperties": false,
        "properties": {
          "hash": {
            "type": "string",
            "pattern": "^(?:[0-9a-f]{2}){1,3}$",
            "description": "The lowercase hop hash as submitted — 2, 4 or 6 hex characters, uniform within one path.",
            "examples": [
              "90e2"
            ]
          },
          "name": {
            "type": "string",
            "description": "Roster/advert name of the picked node, falling back to its 8-hex pubkey. Never null in this variant.",
            "examples": [
              "Pflugerville Relay"
            ]
          },
          "nodeId": {
            "type": "string",
            "pattern": "^[0-9a-f]{8}$"
          },
          "shortId": {
            "type": "string",
            "pattern": "^[0-9a-f]{8}$",
            "description": "Identical value as `nodeId`. Legacy duplicate, retained for wire compatibility."
          },
          "role": {
            "type": "string",
            "description": "Node type from the roster, or the literal `\"unknown\"`. Renamed to `nodeRole` in the GeoJSON view, where `role` means the feature kind.",
            "examples": [
              "repeater"
            ]
          },
          "lat": {
            "type": "number",
            "examples": [
              30.4394
            ]
          },
          "lon": {
            "type": "number",
            "examples": [
              -97.7908
            ]
          },
          "lastSeen": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "batteryMv": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Always null — retired probe telemetry, kept for shape stability."
          },
          "candidates": {
            "type": "integer",
            "minimum": 1,
            "maximum": 25,
            "description": "Number of prefix-matching **located** nodes considered. Capped at 25, so it is\nnot a true total match count.",
            "examples": [
              2
            ]
          },
          "alternates": {
            "type": "array",
            "maxItems": 4,
            "description": "Runner-up located prefix matches. Possibly empty, never null.",
            "items": {
              "$ref": "#/components/schemas/HopAlternate"
            }
          },
          "segmentKm": {
            "type": [
              "number",
              "null"
            ],
            "description": "Great-circle km from the previous positioned point (the requester anchor, or the\nprevious *resolved* hop — legs skip unresolved hops and may therefore span\nseveral radio hops), one decimal. Null only when nothing was positioned before\nit.\n\nConsistent with the coordinates in the same response: when `coordsRounded` is\ntrue the resolver runs on the already-coarsened endpoints, so this distance is\nderived from exactly the points you were given. (It previously came from the\nprecise stored coordinates, which handed an anonymous caller back the precision\nthe rounding was meant to remove.)",
            "examples": [
              8.9
            ]
          },
          "farSegment": {
            "type": "boolean",
            "const": true,
            "description": "**Present only when true — absent, never false, otherwise.** Consumers must test\ntruthiness, not equality. Set when `segmentKm` exceeds the deployment's\n`LINK_MAX_KM` (100 km by default, read from the process environment) *and* the\nprevious positioned point is directly adjacent in the chain — either the\nimmediately preceding hop, or the requester anchor when this is hop 0. Legs that\njump over unresolved hops are deliberately not flagged, since they may\nlegitimately span several radio hops."
          },
          "meshIds": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "description": "Mesh ids whose observers have actually heard the picked node. Possibly empty\n(nobody has attributed it yet), and legitimately **more than one** for a border\nrepeater heard by two meshes. Empty means \"unattributed\", not \"no mesh\" — the\nresolver never penalises an unattributed candidate.",
            "examples": [
              [
                1
              ]
            ]
          },
          "meshAmbiguous": {
            "type": "boolean",
            "const": true,
            "description": "**Present only when true — absent, never false, otherwise.** This hop's hash\nmatched located nodes on more than one mesh, so the pick rested on mesh\nattribution rather than the hash alone. Expected and common: a hop hash is only\n1-3 bytes of a public key (256 / 65,536 / 16.7M buckets), so cross-mesh\ncollisions are routine rather than exceptional. Inspect `alternates` to see the\nsame-prefix nodes on the other meshes."
          },
          "meshMismatch": {
            "type": "boolean",
            "const": true,
            "description": "**Present only when true — absent, never false, otherwise.** The picked node is\npositively attributed to meshes that exclude this path's own mesh, i.e. every\nsame-mesh candidate lost on geography anyway. Usually means the roster is\nincomplete or the recorded mesh is wrong; treat the pick as low confidence."
          }
        }
      },
      "UnresolvedHop": {
        "type": "object",
        "title": "UnresolvedHop",
        "description": "A hop whose hash matched no located node. This is a **shorter object**, not a nulled\nversion of `ResolvedHop`: `nodeId`, `shortId`, `role`, `lat`, `lon`, `lastSeen`,\n`batteryMv`, `alternates`, `segmentKm` and `farSegment` are absent entirely.\nConversely a resolved hop never carries `possible`.",
        "required": [
          "hash",
          "name",
          "candidates",
          "possible"
        ],
        "additionalProperties": false,
        "properties": {
          "hash": {
            "type": "string",
            "pattern": "^(?:[0-9a-f]{2}){1,3}$",
            "examples": [
              "fe27"
            ]
          },
          "name": {
            "type": "null",
            "description": "Always the explicit null literal in this variant — one of the two discriminators."
          },
          "candidates": {
            "type": "integer",
            "const": 0,
            "description": "Always 0 — the other discriminator. A hop has `candidates > 0` if and only if it was picked; there is no third \"had candidates but unresolved\" shape."
          },
          "possible": {
            "type": "array",
            "maxItems": 3,
            "description": "Known-but-unlocated prefix matches. Possibly empty, never null.",
            "items": {
              "$ref": "#/components/schemas/PossibleNode"
            }
          }
        }
      },
      "Hop": {
        "title": "Hop",
        "description": "One radio hop. Two mutually exclusive shapes, distinguished by `candidates`\n(`>= 1` resolved, `0` unresolved) or equivalently by whether `name` is null.",
        "oneOf": [
          {
            "$ref": "#/components/schemas/ResolvedHop"
          },
          {
            "$ref": "#/components/schemas/UnresolvedHop"
          }
        ]
      },
      "ResolvedPathStats": {
        "type": "object",
        "title": "ResolvedPathStats",
        "description": "The resolver's output, spliced in at the top level of the path report. Recomputed on\nevery read against the live roster, so these values legitimately change over time for\nthe same slug.",
        "required": [
          "hops",
          "resolved",
          "totalKm",
          "originKm",
          "suspectSegments",
          "meshId",
          "meshInferred"
        ],
        "properties": {
          "hops": {
            "type": "array",
            "minItems": 1,
            "maxItems": 64,
            "description": "One entry per submitted hash, in traversal order (requester side first). Never\nnull.",
            "items": {
              "$ref": "#/components/schemas/Hop"
            }
          },
          "resolved": {
            "type": "integer",
            "minimum": 0,
            "description": "How many hops had at least one located candidate — i.e. the number of `ResolvedHop` entries in `hops`.",
            "examples": [
              1
            ]
          },
          "totalKm": {
            "type": [
              "number",
              "null"
            ],
            "description": "Total great-circle length in km across requester anchor → resolved hop positions\n→ reporter anchor, one decimal (accumulated unrounded). Legs skip unresolved\nhops.\n\nNull unless there is a span at all: at least one hop resolved, **or** both\nendpoint coordinates are present. With zero hops resolved but both endpoints\nknown, this is simply the requester→reporter great-circle distance.\n\nConsistent with the coordinates in the same response: when `coordsRounded` is\ntrue the resolver runs on the already-coarsened endpoints, so this distance is\nderived from exactly the points you were given. (It previously came from the\nprecise stored coordinates, which handed an anonymous caller back the precision\nthe rounding was meant to remove.)",
            "examples": [
              27.6
            ]
          },
          "originKm": {
            "type": [
              "number",
              "null"
            ],
            "description": "Distance in km from the last resolved hop to the reporter anchor, one decimal.\nNull when there is no reporter position, no resolved hop, or nothing positioned\nbefore it. **It is already included in `totalKm`** — do not add it again.\n\nConsistent with the coordinates in the same response: when `coordsRounded` is\ntrue the resolver runs on the already-coarsened endpoints, so this distance is\nderived from exactly the points you were given. (It previously came from the\nprecise stored coordinates, which handed an anonymous caller back the precision\nthe rounding was meant to remove.)",
            "examples": [
              18.7
            ]
          },
          "suspectSegments": {
            "type": "integer",
            "minimum": 0,
            "description": "Count of legs deemed too long for a single LoRa hop — longer than `LINK_MAX_KM`\n(100 km by default) between directly consecutive positioned points, plus the\nfinal reporter leg when it is over the threshold and the last hop resolved. Legs\nthat jump over unresolved hops are deliberately not counted. Flagged rather than\nsilently drawn as an impossible line.",
            "examples": [
              0
            ]
          },
          "meshId": {
            "type": [
              "integer",
              "null"
            ],
            "description": "The mesh the hop hashes were resolved against. A hop hash is only the leading\n1-3 bytes of a public key, so the same hash routinely matches nodes on several\nmeshes; knowing the mesh is what separates them when a path carries no position\nanchors to disambiguate geographically.\n\nEither recorded at submission (the observer that heard the `!path`, or an\nunambiguously-attributed submitting pubkey) or inferred from the chain — see\n`meshInferred`. Null when neither was available, in which case resolution falls\nback to geography and recency alone.",
            "examples": [
              1
            ]
          },
          "meshInferred": {
            "type": "boolean",
            "description": "True when `meshId` was deduced from the hop chain rather than recorded with the\nsubmission: hops that are unambiguous about their mesh vote, and a unique winner\ntakes it. A tie, or no attributed candidate anywhere, leaves `meshId` null.\nFalse when `meshId` came from the submission itself, and false when it is null.",
            "examples": [
              false
            ]
          }
        }
      },
      "PathReport": {
        "title": "PathReport",
        "description": "A stored `!path` report plus its live resolution. The resolver's five fields\n(`hops`, `resolved`, `totalKm`, `originKm`, `suspectSegments`) are spliced in at the\ntop level, as siblings of the stored fields.",
        "allOf": [
          {
            "$ref": "#/components/schemas/ResolvedPathStats"
          },
          {
            "type": "object",
            "required": [
              "slug",
              "createdAt",
              "reporter",
              "requester",
              "origin",
              "requesterPos",
              "reporterNode",
              "requesterNode",
              "coordsRounded",
              "snrs",
              "sentTs",
              "rxTs",
              "delayS"
            ],
            "properties": {
              "slug": {
                "type": "string",
                "description": "Echo of the path parameter, verbatim.",
                "examples": [
                  "RiYpPco"
                ]
              },
              "createdAt": {
                "type": "string",
                "format": "date-time",
                "description": "When the report was stored. ISO 8601 with a `+00:00` offset and microseconds. Never null.",
                "examples": [
                  "2026-07-24T15:04:05.123456+00:00"
                ]
              },
              "reporter": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Reporting node's name as submitted (`n`), truncated to 40 characters at ingest. Null when not supplied.",
                "examples": [
                  "TX-Hill Repeater"
                ]
              },
              "requester": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Requesting node's name (`r`), truncated to 40 characters. Null when not supplied.",
                "examples": [
                  "kk5abc"
                ]
              },
              "origin": {
                "description": "Reporting node's position, or null when none was stored. Coarsened for\nanonymous viewers (see `coordsRounded`), and possibly already coarsened\nirreversibly at ingest.",
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/LatLon"
                  },
                  {
                    "type": "null"
                  }
                ]
              },
              "requesterPos": {
                "description": "Requesting node's position, or null. Same coarsening as `origin`.",
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/LatLon"
                  },
                  {
                    "type": "null"
                  }
                ]
              },
              "reporterNode": {
                "description": "Identification of the reporter, resolved from its **name** against the\ncontact-card roster (`contacts`) only — unlike hop resolution, adverts are\nnot consulted, so a node known only from advert beacons yields null here\neven when the same node resolves as a hop. Also null when the name is empty\nor ambiguous — the lookup returns a node only when exactly one distinct\npubkey carries that name.",
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/NodeRef"
                  },
                  {
                    "type": "null"
                  }
                ]
              },
              "requesterNode": {
                "description": "Identification of the requester, from the same contacts-only lookup with the identical uniqueness rule.",
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/NodeRef"
                  },
                  {
                    "type": "null"
                  }
                ]
              },
              "coordsRounded": {
                "type": "boolean",
                "description": "True when read-time coarsening was **applied** to at least one of `origin` /\n`requesterPos`: the viewer is anonymous, `PATH_ANON_COORD_DECIMALS` is >= 0\n(2 by default), and at least one endpoint coordinate exists.\n\nIt does not imply the numbers changed — a coordinate already coarsened at\ningest rounds to itself. It is false for signed-in viewers, false when the\nreport stores no endpoint coordinates, and false when the deployment sets\n`PATH_ANON_COORD_DECIMALS` to -1."
              },
              "snrs": {
                "type": [
                  "array",
                  "null"
                ],
                "description": "Device-measured per-hop SNR in dB, traversal order, exactly one value per hop\n(same length as `hops`), each within [-40, 40]. Null when nothing was stored,\nand also null when the stored string no longer parses or its count no longer\nmatches the hop count — SNRs are decoration and are dropped rather than\nraising.",
                "items": {
                  "type": "number"
                },
                "examples": [
                  [
                    11.5,
                    -3.25
                  ]
                ]
              },
              "sentTs": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Unix epoch **seconds** (not ISO) — the triggering mesh message's sender\ntimestamp. Only ever populated for reports created by the in-mesh pathbot;\n**always null for anything submitted via `POST /api/device/path`**."
              },
              "rxTs": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Unix epoch seconds — the observer's NTP receive clock. Same provenance caveat as `sentTs`."
              },
              "delayS": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Whole-second sender→receive delay. Computed only when `sentTs` is non-null,\nusing `rxTs` when present and the ingest time otherwise, and only when the\ndifference is between 0 and 3600 seconds inclusive. A non-null `sentTs`\ntherefore does not guarantee a non-null `delayS` — a bad sender clock yields\nnull."
              }
            }
          }
        ]
      },
      "PathSummary": {
        "type": "object",
        "title": "PathSummary",
        "description": "A compact reference to another report. **These statistics are the snapshot written at\nsubmit time**, not a fresh resolution, so they can be null and can disagree with what\n`GET /api/path/{slug}` returns for the same slug today.",
        "required": [
          "slug",
          "createdAt",
          "reporter",
          "hopCount",
          "resolvedHops",
          "totalKm"
        ],
        "additionalProperties": false,
        "properties": {
          "slug": {
            "type": "string",
            "pattern": "^[A-Za-z0-9_-]{7}$",
            "examples": [
              "Qx7mLa2"
            ]
          },
          "createdAt": {
            "type": "string",
            "format": "date-time",
            "description": "ISO 8601 with a `+00:00` offset. Never null."
          },
          "reporter": {
            "type": [
              "string",
              "null"
            ],
            "description": "The reporting node's name for that report."
          },
          "hopCount": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Stored hop-chain length. Nullable, unlike the always-integer `hopCount` in the GeoJSON metadata."
          },
          "resolvedHops": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Stored count of resolved hops at submit time. Note the name — it is `resolved` in\nthe live view. Null only when the submit-time scoring never completed, in which\ncase `totalKm` is null too: a non-null `resolvedHops` of 1 or more always comes\nwith a non-null `totalKm`."
          },
          "totalKm": {
            "type": [
              "number",
              "null"
            ],
            "description": "Stored total length in km at submit time (single-precision). Null when the\nscoring never completed, and also when it produced no span at all (nothing\nresolved and not both endpoint coordinates present, i.e. `resolvedHops: 0`)."
          }
        }
      },
      "RelatedPaths": {
        "type": "object",
        "title": "RelatedPaths",
        "description": "Other reports by the same requester. Contains no coordinates at all.",
        "required": [
          "requester",
          "pubkey",
          "byPubkey",
          "paths"
        ],
        "additionalProperties": false,
        "properties": {
          "requester": {
            "type": [
              "string",
              "null"
            ],
            "description": "The reference report's requester name, whitespace-stripped. Null only when the\nstored requester was missing or blank, in which case `pubkey` is null,\n`byPubkey` is false and `paths` is empty.",
            "examples": [
              "kk5abc"
            ]
          },
          "pubkey": {
            "type": [
              "string",
              "null"
            ],
            "description": "The requester's 8-hex lowercase public key. Non-null only when the name resolves\nto exactly one distinct pubkey in the contact-card roster (`contacts`) only —\nunlike hop resolution, adverts are not consulted, so a node known only from\nadvert beacons yields null here even when the same node resolves as a hop. Also\nnull for ambiguous names.",
            "examples": [
              "4b19c0de"
            ]
          },
          "byPubkey": {
            "type": "boolean",
            "description": "True when results were grouped by public key across every name that key is known\nby in the contact-card roster — the strong, rename-tolerant grouping. False when\nthey are an exact string match on the requester name, which misses renames."
          },
          "paths": {
            "type": "array",
            "maxItems": 100,
            "description": "Newest first, excluding the reference slug. Hard cap of 100 with no cursor, offset or total count.",
            "items": {
              "$ref": "#/components/schemas/PathSummary"
            }
          }
        }
      },
      "PathGeoJsonMetadata": {
        "type": "object",
        "title": "PathGeoJsonMetadata",
        "description": "RFC 7946 foreign member carrying the report's identifiers so a consumer needs no\nsecond request. All eight keys are always present; `createdAt`, `reporter`,\n`requester` and `totalKm` may be null, the other four never are. Keys in the source\n`meta` beyond these eight are dropped.",
        "required": [
          "slug",
          "url",
          "createdAt",
          "reporter",
          "requester",
          "hopCount",
          "resolved",
          "totalKm"
        ],
        "additionalProperties": false,
        "properties": {
          "slug": {
            "type": "string",
            "description": "Echo of the path parameter, verbatim. Never null.",
            "examples": [
              "RiYpPco"
            ]
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "The canonical human page, `<PUBLIC_BASE_URL or the request base>/p/<slug>`. Always\nthe long form — unlike the submit response's `url`, this is never the shortened\none. Never null.",
            "examples": [
              "https://txme.sh/p/RiYpPco"
            ]
          },
          "createdAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "reporter": {
            "type": [
              "string",
              "null"
            ],
            "description": "Reporting node's name."
          },
          "requester": {
            "type": [
              "string",
              "null"
            ],
            "description": "Requesting node's name."
          },
          "hopCount": {
            "type": "integer",
            "minimum": 1,
            "description": "The **full** hop-chain length including unresolved hops — always an integer, and generally larger than the number of hop features.",
            "examples": [
              2
            ]
          },
          "resolved": {
            "type": "integer",
            "minimum": 0,
            "description": "Number of hops that resolved to a located node. Always an integer.",
            "examples": [
              1
            ]
          },
          "totalKm": {
            "type": [
              "number",
              "null"
            ],
            "description": "Total resolved path length in km, consistent with the coordinates in this\ndocument: an anonymous caller's anchors are coarsened *before* resolving, so the\ndistance is derived from exactly the points emitted here.",
            "examples": [
              27.6
            ]
          }
        }
      },
      "PointGeometry": {
        "type": "object",
        "title": "PointGeometry",
        "description": "RFC 7946 Point. Coordinates are `[longitude, latitude]` — the opposite order from the JSON view's `{lat, lon}`. No altitude element is ever emitted.",
        "required": [
          "type",
          "coordinates"
        ],
        "additionalProperties": false,
        "properties": {
          "type": {
            "type": "string",
            "const": "Point"
          },
          "coordinates": {
            "type": "array",
            "minItems": 2,
            "maxItems": 2,
            "items": {
              "type": "number"
            },
            "examples": [
              [
                -97.7908,
                30.4394
              ]
            ]
          }
        }
      },
      "LineStringGeometry": {
        "type": "object",
        "title": "LineStringGeometry",
        "description": "RFC 7946 LineString, `[longitude, latitude]` pairs.",
        "required": [
          "type",
          "coordinates"
        ],
        "additionalProperties": false,
        "properties": {
          "type": {
            "type": "string",
            "const": "LineString"
          },
          "coordinates": {
            "type": "array",
            "minItems": 2,
            "items": {
              "type": "array",
              "minItems": 2,
              "maxItems": 2,
              "items": {
                "type": "number"
              }
            }
          }
        }
      },
      "RequesterFeature": {
        "type": "object",
        "title": "RequesterFeature",
        "description": "The requesting node's position anchor. Emitted only when a requester coordinate was\nstored, and always first. Coarsened for anonymous viewers.",
        "required": [
          "type",
          "geometry",
          "properties"
        ],
        "additionalProperties": false,
        "properties": {
          "type": {
            "type": "string",
            "const": "Feature"
          },
          "geometry": {
            "$ref": "#/components/schemas/PointGeometry"
          },
          "properties": {
            "type": "object",
            "description": "Null-valued properties are omitted entirely, so only `role` is guaranteed.",
            "required": [
              "role"
            ],
            "additionalProperties": false,
            "properties": {
              "role": {
                "type": "string",
                "const": "requester"
              },
              "name": {
                "type": "string",
                "description": "The requesting node's name. **Absent** (not null) when unknown."
              }
            }
          }
        }
      },
      "ReporterFeature": {
        "type": "object",
        "title": "ReporterFeature",
        "description": "The reporting node's position anchor. Emitted only when a reporter coordinate was\nstored, and always after the hop points. Coarsened for anonymous viewers.",
        "required": [
          "type",
          "geometry",
          "properties"
        ],
        "additionalProperties": false,
        "properties": {
          "type": {
            "type": "string",
            "const": "Feature"
          },
          "geometry": {
            "$ref": "#/components/schemas/PointGeometry"
          },
          "properties": {
            "type": "object",
            "description": "Null-valued properties are omitted entirely, so only `role` is guaranteed.",
            "required": [
              "role"
            ],
            "additionalProperties": false,
            "properties": {
              "role": {
                "type": "string",
                "const": "reporter"
              },
              "name": {
                "type": "string",
                "description": "The reporting node's name. **Absent** (not null) when unknown."
              }
            }
          }
        }
      },
      "HopFeature": {
        "type": "object",
        "title": "HopFeature",
        "description": "One positioned hop. Unresolved hops are skipped entirely and contribute no geometry.\nCoordinates come from the roster and are never coarsened.\n\nNot carried into GeoJSON: `shortId`, `candidates`, `alternates`, `batteryMv`,\n`farSegment`, `possible` — fetch `GET /api/path/{slug}` for those.",
        "required": [
          "type",
          "geometry",
          "properties"
        ],
        "additionalProperties": false,
        "properties": {
          "type": {
            "type": "string",
            "const": "Feature"
          },
          "geometry": {
            "$ref": "#/components/schemas/PointGeometry"
          },
          "properties": {
            "type": "object",
            "description": "Every property whose value is null is omitted entirely, so only `role` is\nguaranteed. (`seq: 0` survives — the filter drops null, not falsy.)",
            "required": [
              "role"
            ],
            "additionalProperties": false,
            "properties": {
              "role": {
                "type": "string",
                "const": "hop"
              },
              "seq": {
                "type": "integer",
                "minimum": 0,
                "description": "Index into the **full** hop array, including unresolved hops, so values have\ngaps — 0 then 2 when the middle hop did not resolve."
              },
              "hash": {
                "type": "string",
                "description": "The hop hash, 2/4/6 lowercase hex characters."
              },
              "name": {
                "type": "string",
                "description": "Resolved node name."
              },
              "nodeId": {
                "type": "string",
                "pattern": "^[0-9a-f]{8}$"
              },
              "nodeRole": {
                "type": "string",
                "description": "**Renamed** from the JSON view's hop `role` (`repeater`, `unknown`, …),\nbecause `role` here is the feature-kind discriminator."
              },
              "lastSeen": {
                "type": "string",
                "format": "date-time"
              },
              "segmentKm": {
                "type": "number",
                "description": "Absent when the hop's `segmentKm` was null."
              }
            }
          }
        }
      },
      "PathLineFeature": {
        "type": "object",
        "title": "PathLineFeature",
        "description": "The traced path, requester → resolved hops → reporter, always last. Emitted **only**\nwhen at least two points were collected, so a single-hop path with no endpoint\nanchors has no line. Unresolved hops are skipped, mirroring the resolver's leg logic.",
        "required": [
          "type",
          "geometry",
          "properties"
        ],
        "additionalProperties": false,
        "properties": {
          "type": {
            "type": "string",
            "const": "Feature"
          },
          "geometry": {
            "$ref": "#/components/schemas/LineStringGeometry"
          },
          "properties": {
            "type": "object",
            "description": "Unlike the Point features, these properties are **not** null-filtered — all four keys are always present and may be null.",
            "required": [
              "role",
              "slug",
              "totalKm",
              "resolved"
            ],
            "additionalProperties": false,
            "properties": {
              "role": {
                "type": "string",
                "const": "path"
              },
              "slug": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "totalKm": {
                "type": [
                  "number",
                  "null"
                ],
                "description": "Consistent with this LineString's own coordinates: for an anonymous caller\nthe anchors are coarsened before resolving, so the distance derives from the\nsame points drawn here."
              },
              "resolved": {
                "type": [
                  "integer",
                  "null"
                ]
              }
            }
          }
        }
      },
      "PathFeature": {
        "title": "PathFeature",
        "description": "A feature in the path FeatureCollection, discriminated by `properties.role`.",
        "oneOf": [
          {
            "$ref": "#/components/schemas/RequesterFeature"
          },
          {
            "$ref": "#/components/schemas/HopFeature"
          },
          {
            "$ref": "#/components/schemas/ReporterFeature"
          },
          {
            "$ref": "#/components/schemas/PathLineFeature"
          }
        ]
      },
      "PathFeatureCollection": {
        "type": "object",
        "title": "PathFeatureCollection",
        "description": "RFC 7946 FeatureCollection with a `metadata` foreign member. Feature order is\ndeterministic: requester Point (if any) → hop Points in traversal order → reporter\nPoint (if any) → the LineString (if any). No CRS member; coordinates are WGS 84.",
        "required": [
          "type",
          "metadata",
          "features"
        ],
        "additionalProperties": false,
        "properties": {
          "type": {
            "type": "string",
            "const": "FeatureCollection"
          },
          "metadata": {
            "$ref": "#/components/schemas/PathGeoJsonMetadata"
          },
          "features": {
            "type": "array",
            "description": "Possibly empty — for example when nothing resolved and neither endpoint coordinate was stored. Never null.",
            "items": {
              "$ref": "#/components/schemas/PathFeature"
            }
          }
        }
      },
      "CountiesFeatureCollection": {
        "type": "object",
        "title": "CountiesFeatureCollection",
        "description": "Static county/parish outlines for TX, LA, MS, AL and FL — 534 features, coordinates\nrounded to 3 decimals.",
        "required": [
          "type",
          "features"
        ],
        "properties": {
          "type": {
            "type": "string",
            "const": "FeatureCollection"
          },
          "features": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "type",
                "geometry",
                "properties"
              ],
              "properties": {
                "type": {
                  "type": "string",
                  "const": "Feature"
                },
                "geometry": {
                  "type": "object",
                  "description": "A `Polygon` or `MultiPolygon` in `[longitude, latitude]` order.",
                  "required": [
                    "type",
                    "coordinates"
                  ],
                  "properties": {
                    "type": {
                      "type": "string",
                      "enum": [
                        "Polygon",
                        "MultiPolygon"
                      ]
                    },
                    "coordinates": {
                      "type": "array",
                      "items": true
                    }
                  }
                },
                "properties": {
                  "type": "object",
                  "description": "Exactly two keys. **County names collide across states** (Jefferson exists\nin both TX and LA), so match on the `(name, state)` pair.",
                  "required": [
                    "name",
                    "state"
                  ],
                  "additionalProperties": false,
                  "properties": {
                    "name": {
                      "type": "string",
                      "examples": [
                        "Travis"
                      ]
                    },
                    "state": {
                      "type": "string",
                      "enum": [
                        "AL",
                        "FL",
                        "LA",
                        "MS",
                        "TX"
                      ]
                    }
                  }
                }
              }
            }
          }
        }
      },
      "MeshOption": {
        "type": "object",
        "title": "MeshOption",
        "description": "A mesh as offered on the sign-up form. Lowercase database column names, not camelCase.",
        "required": [
          "id",
          "slug",
          "label"
        ],
        "additionalProperties": false,
        "properties": {
          "id": {
            "type": "integer",
            "description": "Surrogate key — pass this back as `mesh_id` on `POST /auth/register`.",
            "examples": [
              1
            ]
          },
          "slug": {
            "type": "string",
            "description": "Stable machine identifier.",
            "examples": [
              "ctx"
            ]
          },
          "label": {
            "type": "string",
            "description": "Human display name.",
            "examples": [
              "Central Texas"
            ]
          }
        }
      },
      "AlertGauge": {
        "type": "object",
        "title": "AlertGauge",
        "description": "A NOAA NWPS river gauge watched by a mesh.",
        "required": [
          "lid",
          "name",
          "lat",
          "lon",
          "curated"
        ],
        "additionalProperties": false,
        "properties": {
          "lid": {
            "type": "string",
            "description": "NWPS location id.",
            "examples": [
              "ATRT2"
            ]
          },
          "name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Gauge name from NWPS. Genuinely nullable.",
            "examples": [
              "Colorado Rv at Austin"
            ]
          },
          "lat": {
            "type": [
              "number",
              "null"
            ]
          },
          "lon": {
            "type": [
              "number",
              "null"
            ]
          },
          "curated": {
            "type": "boolean",
            "description": "True when an admin explicitly added the gauge, false when the poller discovered it."
          }
        }
      },
      "AlertMesh": {
        "type": "object",
        "title": "AlertMesh",
        "description": "One mesh's alert definition. Several nullable columns are collapsed to `\"\"` on the\nway out, so `region` is the only genuinely nullable field here.",
        "required": [
          "slug",
          "label",
          "region",
          "counties",
          "alert_channel",
          "discord",
          "website",
          "contact",
          "gauges"
        ],
        "additionalProperties": false,
        "properties": {
          "slug": {
            "type": "string",
            "examples": [
              "ctx"
            ]
          },
          "label": {
            "type": "string",
            "examples": [
              "Central Texas"
            ]
          },
          "region": {
            "type": [
              "string",
              "null"
            ],
            "description": "Passed through raw from a nullable column — the only genuinely nullable mesh field."
          },
          "counties": {
            "type": "array",
            "description": "Derived, not a column: the mesh's NWS county/parish list, split on commas and\nstripped. Empty when none are configured, never null. Some entries carry a\n`/ST` suffix. Matched against api.weather.gov.",
            "items": {
              "type": "string"
            },
            "examples": [
              [
                "Travis",
                "Williamson",
                "Hays"
              ]
            ]
          },
          "alert_channel": {
            "type": "string",
            "description": "The mesh channel automated announcements go to; `\"\"` means announcements are off.\nNever null. **This is the one snake_case key on the public JSON surface** — it is\nnot a typo."
          },
          "discord": {
            "type": "string",
            "description": "Renamed from the `discord_url` column. `\"\"` when unset, never null."
          },
          "website": {
            "type": "string",
            "description": "Renamed from the `website_url` column. `\"\"` when unset, never null."
          },
          "contact": {
            "type": "string",
            "description": "Free-text contact line. `\"\"` when unset, never null."
          },
          "gauges": {
            "type": "array",
            "description": "Watched river gauges, ordered by name (null names last). Empty when none, never null.",
            "items": {
              "$ref": "#/components/schemas/AlertGauge"
            }
          }
        }
      },
      "AlertSource": {
        "type": "object",
        "title": "AlertSource",
        "description": "An upstream data source. The list is a hardcoded constant, identical on every call.",
        "required": [
          "name",
          "url",
          "desc"
        ],
        "additionalProperties": false,
        "properties": {
          "name": {
            "type": "string"
          },
          "url": {
            "type": "string",
            "format": "uri"
          },
          "desc": {
            "type": "string"
          }
        }
      },
      "AlertsConfig": {
        "type": "object",
        "title": "AlertsConfig",
        "required": [
          "meshes",
          "sources"
        ],
        "additionalProperties": false,
        "properties": {
          "meshes": {
            "type": "array",
            "description": "Every configured mesh, ordered by sort then label. No database ids are exposed.",
            "items": {
              "$ref": "#/components/schemas/AlertMesh"
            }
          },
          "sources": {
            "type": "array",
            "minItems": 2,
            "maxItems": 2,
            "description": "Always exactly the two NOAA sources, in this order. Static, not queried.",
            "items": {
              "$ref": "#/components/schemas/AlertSource"
            }
          }
        }
      },
      "Credentials": {
        "type": "object",
        "title": "Credentials",
        "description": "Shared body for register and login. Extra keys are ignored. There are no\nmodel-level length or pattern constraints — the username and password rules are\nenforced imperatively, and **only by register**.",
        "required": [
          "username",
          "password"
        ],
        "properties": {
          "username": {
            "type": "string",
            "description": "On register: must match `^[A-Za-z0-9_.-]{3,32}$` or the call is rejected `400`.\nOn login: no format validation, and matched **case-sensitively** against stored\nusernames (though the throttle key is lowercased, so casing variants share one\nattempt budget).",
            "examples": [
              "kk5abc"
            ]
          },
          "password": {
            "type": "string",
            "description": "On register: at least 8 characters. On login: no minimum and no maximum is\nenforced anywhere, so long values pay the full scrypt cost — mitigated only by\nthe throttles.",
            "examples": [
              "correct-horse-battery"
            ]
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 320,
            "default": null,
            "description": "Optional recovery address, **register only** (ignored by login). Trimmed and\nlowercased before storage; blank/whitespace/omitted all mean \"no address\".\n\nA non-blank value that is not plausibly an address is rejected `400` — a typo\nreads as a recovery route right up until someone needs it. Not unique: two\naccounts may carry the same address, and the address is never a credential.\n\nThere is no emailed reset link. Recovery is an operator confirming out of band\nthat the person asking is the person who registered, then setting a new\npassword. An account with no address on file therefore **cannot be recovered**,\nwhich the sign-up form states plainly.",
            "examples": [
              "you@example.com"
            ]
          },
          "mesh_id": {
            "type": [
              "integer",
              "null"
            ],
            "default": null,
            "description": "Optional at signup — the `id` from `GET /auth/meshes`. Null or omitted means\n\"Not Listed\"/unassigned. An id that does not exist is **silently coerced to\nnull** rather than rejected, and the response does not echo the resolved value.\nAccepted and ignored by login.",
            "examples": [
              1
            ]
          }
        }
      },
      "RegisterResult": {
        "type": "object",
        "title": "RegisterResult",
        "required": [
          "ok",
          "username",
          "role",
          "email",
          "recoverable"
        ],
        "additionalProperties": false,
        "properties": {
          "ok": {
            "type": "boolean",
            "const": true
          },
          "username": {
            "type": "string",
            "description": "Echoed verbatim from the request — not normalised or lowercased."
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "description": "The stored address (trimmed, lowercased), or null when none was given."
          },
          "recoverable": {
            "type": "boolean",
            "description": "Whether this account can be recovered — simply `email is not null`. False means\na forgotten password ends the account, and is the normal outcome of leaving the\nfield blank."
          },
          "role": {
            "type": "string",
            "enum": [
              "user",
              "admin"
            ],
            "description": "`admin` only when this was the first account on the instance (the bootstrap);\notherwise `user`. The `manager` role (a region manager) is reachable only\nthrough later admin action, never from this endpoint."
          }
        }
      },
      "LoginResult": {
        "type": "object",
        "title": "LoginResult",
        "required": [
          "ok",
          "username"
        ],
        "additionalProperties": false,
        "properties": {
          "ok": {
            "type": "boolean",
            "const": true
          },
          "username": {
            "type": "string",
            "description": "Read back from the database row, i.e. the canonical stored casing rather than what the client sent."
          }
        },
        "description": "Note there is no `role` field here, unlike register — call `GET /auth/me` for role\nand permissions."
      },
      "SessionUser": {
        "type": "object",
        "title": "SessionUser",
        "description": "The signed-in user. Exactly six properties, none of them null.",
        "required": [
          "username",
          "role",
          "isAdmin",
          "isManager",
          "observerEnabled",
          "uiMesh"
        ],
        "additionalProperties": false,
        "properties": {
          "username": {
            "type": "string",
            "examples": [
              "kk5abc"
            ]
          },
          "role": {
            "type": "string",
            "enum": [
              "user",
              "manager",
              "admin"
            ],
            "description": "A legacy null role reads as `user`."
          },
          "isAdmin": {
            "type": "boolean"
          },
          "isManager": {
            "type": "boolean",
            "description": "\"Region manager or above\" — true for admins as well, not \"exactly manager\"."
          },
          "regionId": {
            "type": [
              "integer",
              "null"
            ],
            "description": "The mesh a region manager manages — their bots, their region's gateways, and\nnothing else. `null` means unassigned, which grants the role no authority at\nall, and is always `null` for roles below manager."
          },
          "observerEnabled": {
            "type": "boolean",
            "description": "Whether the account has its own scoped MQTT observer credential."
          },
          "uiMesh": {
            "type": "string",
            "description": "The user's mesh filter preference. Always a string — `\"\"` means \"All meshes\"/unset, never null.",
            "examples": [
              "ctx"
            ]
          }
        }
      }
    }
  }
}
