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
    "pathbot". This document describes **only the surface a third party can call without a
    session cookie**. Everything else in the application — the browsable path list, node
    and topology queries, message history, bot administration, the mesh send bridge and all
    `/api/admin/*` routes — requires a session and is deliberately excluded.

    ## Who this is for

    * **Third-party `!path` bots.** Another operator's bot can hand its hop chains to
      `POST /api/device/path` and get back a short, shareable map link instead of building
      its own resolver, storage and map page.
    * **Observer firmware.** The `combined_node` firmware and anything like it submits over
      the same endpoint; the response is deliberately tiny because the reply rides a
      ~160-character LoRa text frame.
    * **Map consumers.** `GET /api/path/{slug}/geojson` returns an RFC 7946
      FeatureCollection that drops straight into Leaflet, MapLibre, geojson.io, or an
      existing GeoJSON pipeline.

    ## Access model

    * **Reading a path is public by default.** The three `GET /api/path/...` endpoints and
      the `GET /p/{slug}` page are open to anonymous callers while the deployment's
      `PATH_VIEW_PUBLIC` env var is true (the default, and what `txmesh` runs). When it is
      false, an anonymous caller gets `401 {"error":"authentication required"}` from the
      JSON endpoints and a `303` redirect to `/login` from the HTML page.
    * **Writing a path requires a sender identity.** `POST /api/device/path` accepts, in
      strict precedence order: a static `x-device-token`, an `x-meshcore-pubkey` header, or
      — only when the deployment sets `DEVICE_PATH_OPEN=true`, which is **not** the default
      — the client IP. With none of those it returns `403`.
    * **`x-meshcore-pubkey` is membership, not authentication.** MeshCore public keys are
      broadcast over the air and are not secret, so any caller can assert any node's key.
      This is a deliberate, accepted tradeoff to keep the submit API open; treat it as a
      grouping/attribution key, never as a credential. Only `x-device-token` is a real
      secret (matched in constant time).
    * **Anonymous viewers get coarsened coordinates.** The two device-supplied endpoint
      coordinates (the reporting node's and the requesting node's positions) are rounded to
      `PATH_ANON_COORD_DECIMALS` decimals — 2 by default, roughly a 1 km grid — for callers
      with no session, and the response flags this with `coordsRounded`. Untrusted senders
      additionally have those two coordinates rounded **irreversibly at ingest**. Per-hop
      repeater coordinates come from the roster/advert archive and are never coarsened.
    * **Sessions are cookies.** `POST /auth/login` and `POST /auth/register` set an
      `ms_session` cookie (HttpOnly, Secure, SameSite=Lax). Only the SHA-256 of the token is
      stored server-side. There is no bearer-token or API-key alternative for reads.

    ## Things that will bite an integrator

    * **No CORS.** The application installs no CORS middleware, so there are no
      `Access-Control-Allow-*` headers and no preflight handling. Browser JavaScript on
      another origin cannot call any of these endpoints. This is a server-to-server,
      device-to-server and native-client API; proxy it if you need it in a browser.
    * **Three different error envelopes.** The pathbot routes return `{"error": "..."}`, the
      auth routes return FastAPI's `{"detail": "..."}`, and request-body validation failures
      return FastAPI's `{"detail": [ ... ]}` array. They are not unified — check the
      operation you are calling.
    * **Path responses are re-resolved on every read.** Only the submitted inputs are
      stored; hop hashes are matched against the live roster at view time. The same slug can
      legitimately return more resolved hops, different coordinates and a different
      `totalKm` tomorrow than it does today. Do not treat a response as immutable and do not
      cache it indefinitely.
    * **Submitted paths are deleted after the retention window.** A housekeeping loop runs
      every 6 hours and deletes `device_paths` rows older than `DEVICE_PATH_RETENTION_DAYS`
      (**7** by default), after which the slug returns `404` from every JSON endpoint and
      drops out of other reports' `related` lists. A shared `/p/` link is therefore good
      for about a week — treat one as ephemeral, and re-submit the hop chain if you need
      a durable record.
    * **Rate limiting is per sender, not global.** See `POST /api/device/path`. The read
      endpoints are not rate limited by the application at all.
    * **HEAD is mostly not answered.** Contrary to what this document said before
      2026-07-26, routes here are declared `GET`-only — FastAPI's router, unlike Starlette's,
      does not add `HEAD` for you — and a `HEAD` falls through to the catch-all static mount,
      which answers `404` unless a file of that name exists. The exceptions are the four
      documentation routes (`/docs/api`, `/docs/api/reference` and the two spec files), which
      answer `HEAD` explicitly because tooling probes a spec URL before fetching it. Probe
      with `GET` everywhere else; a `404` from a `HEAD` says nothing about whether the
      resource exists.
    * **Response headers.** Every routed response carries
      `Content-Security-Policy` (restricts script/connect/form/base/object origins),
      `Strict-Transport-Security: max-age=31536000; includeSubDomains`,
      `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff` and
      `Referrer-Policy: strict-origin-when-cross-origin`. `Cache-Control: no-cache` is added
      only to `text/html`, `text/css` and JavaScript responses — the JSON API carries no
      cache directive at all. Unhandled 500s (for example postgres being unreachable) are
      produced outside that middleware, so they are `text/plain` and carry none of those
      headers.
    * **This document is hand-written, and it is served.** Fetch it from
      `GET /docs/api/openapi.yaml` or `GET /docs/api/openapi.json` — those two routes are the
      only ones in the application that send `Access-Control-Allow-Origin: *`, so browser
      tooling can load them by URL (the endpoints they describe stay CORS-less). A rendered
      reference lives at `GET /docs/api/reference` and the prose walkthrough at
      `GET /docs/api`. FastAPI's own `/docs`, `/redoc` and `/openapi.json` are **disabled**
      (`openapi_url=None`) — they described the *entire* surface, session-gated admin routes
      included — and all three return `404`.
    * **A catch-all static mount backs `/`.** After the API routes, the whole `web/`
      directory is mounted unauthenticated (`StaticFiles(..., html=True)`), so every shell
      and asset is also reachable by filename — `/index.html`, `/landing.html`,
      `/path.html`, `/node.html`, `/observer.html`, `/theme.css?v=N`, `/theme.js?v=N` and
      the rest. Those files are data-free (all data still comes from the JSON API), and the
      routes documented below win because they are registered first, but note that
      `GET /index.html` bypasses the anonymous/signed-in branch that `GET /` performs. No
      path items are listed for them.

    Behaviour that depends on deployment configuration is called out inline with the env var
    that controls it. The defaults quoted here are `meshscope/config.py` defaults, not
    guarantees about any particular cluster.

    API version `3.0.0` above is the *application's* version (from `pyproject.toml`); this
    document 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
      GeoJSON, or as a hosted HTML map page. This is the part of txmesh that was
      deliberately opened for third-party adoption.
  - name: auth
    description: |-
      Session endpoints. Public in the sense that they carry no authentication dependency —
      they 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
      JSON 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
        per-hop SNRs; resolves the hop hashes against the roster/advert archive; mints a
        7-character slug; and returns the URL of the hosted map page `/p/{slug}` (run
        through a shortener when this deployment shortens for you — see below).

        Only the **inputs** are stored. Hop hashes are re-resolved every time the path is
        viewed, so a link improves on its own as the roster learns about more repeaters.

        ### Sender identity (required)

        Evaluated in this exact precedence, and a non-matching value simply falls through to
        the next branch rather than erroring:

        1. `x-device-token` — an env-configured (`DEVICE_TOKEN`) or admin-issued token. A
           match makes the sender
           **pre-trusted**: its coordinates are stored at full precision. A token header that
           does not match is *not* an error; there is no "invalid token" response.
        2. `x-meshcore-pubkey` — hex, optional leading `!`, case-insensitive, 8–64 hex
           characters; only the lowercase 8-hex prefix is used. **Membership, not
           authentication** — public keys are broadcast on the mesh, so anyone can assert
           anyone's key. Accepted whether or not that key has actually been heard on the
           mesh (the server records "known?" purely so an admin can make a trust call).
        3. Client IP — only when the deployment sets `DEVICE_PATH_OPEN=true` (default
           **false**). The IP is the *rightmost* `X-Forwarded-For` entry (appended by the
           trusted proxy), falling back to the peer address.

        With none of the three, the request is rejected `403`. A typo'd token therefore
        surfaces as either a `403` or a silent downgrade to coarsened coordinates — never as
        an explicit error.

        The `security` block below lists only the two identities a default deployment
        accepts. The third, client-IP branch is conditional on `DEVICE_PATH_OPEN=true` and a
        security requirement cannot carry that condition, so it is described here rather
        than advertised as an unauthenticated alternative — do not read its absence as
        proof that a particular deployment rejects anonymous submissions.

        ### Short links

        By default the response `url` is the full `/p/{slug}` link on this deployment. Two
        things can shorten it, and neither is on for a new sender:

        1. A site-wide `URL_SHORTENER`, which shortens for every sender. Not set on
           `txmesh`.
        2. A **per-sender opt-in** an admin ticks in Admin → Path senders, which sends that
           one sender's links through `URL_SHORTENER_OPTIN` (`https://da.gd/shorten?url=` by
           default). Ask if you want it — the reply rides a ~160-character LoRa frame and
           roughly six characters are on offer.

        Shortening is best-effort: a 2-second cap, and any failure falls back to the full
        URL, so `url` is always a working link but is **not guaranteed to be short** even
        with the opt-in on. Whichever form comes back is persisted and reused by the
        15-minute retry dedupe. Note that shortening hands the trace URL to a third party.

        ### Coordinate precision

        Static-token senders and admin-trusted senders store `o`/`q` precisely. Everyone
        else's `o` and `q` are rounded to `PATH_ANON_COORD_DECIMALS` decimals (2 by default,
        roughly 1 km) **before they are written**. That is destructive: the precise value is
        never stored and cannot be recovered.

        ### Validation asymmetry

        Only `h` can fail the request. A malformed or out-of-range `o`, `q` or `s` is
        silently dropped (the anchor or the SNR set simply becomes null) — a `200` does not
        mean every field you sent was accepted. `s` in particular is discarded wholesale
        unless it has exactly one value per hop and every value is within ±40 dB.

        ### Idempotency

        A byte-identical logical resubmission (same hashes, same coordinates, same names)
        within **15 minutes** reuses the existing row: the same slug comes back, no new row
        is created, and no additional rate-limit budget is consumed. The previously issued
        short URL is reused when one was persisted — if the original shortening failed, or
        nothing was shortening this sender's links, nothing was stored and the retry attempts
        shortening again. Firmware re-asks when the LoRa reply is lost, so treat this endpoint as
        idempotent-within-15-minutes rather than adding your own dedupe, and never assume a
        fresh slug per call.

        ### Evaluation order (observable)

        Body validation (422) → identity (403) → rate limit (429) → hash validation (400) →
        database writes. An anonymous caller with a malformed body sees `422`, not `403`; a
        rate-limited caller with bad hashes sees `429`, not `400`.

        ### Compatibility guarantee

        The success body must keep the literal `"url":"..."` shape — deployed firmware finds
        it with `strstr`, not a JSON parser. It will never be renamed or nested.

        Content type may be `application/json`, any `application/*+json`, or **absent**
        (which is what constrained firmware usually sends). Any other content type is a
        `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
            `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
            (over 6 hex characters), mixed widths within one chain, empty, or more than 64
            hops. Raised before any sender row is created, so a garbage submission never
            registers 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`,
            `x-meshcore-pubkey` was missing or not a plausible key, and `DEVICE_PATH_OPEN`
            is false. This is the default posture — leaving `DEVICE_TOKEN` unset must not
            silently 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:
        per-hop node identification with coordinates, the two device-supplied endpoint
        positions, and path-length statistics. This is the richest of the three public path
        views and is exactly what the `/p/{slug}` HTML page fetches.

        The hop chain is in traversal order, requester side first. Resolution happens per
        request, so the same slug can return more resolved hops and a different `totalKm`
        later — responses are not stable and should not be cached indefinitely.

        Anonymous callers get `origin` and `requesterPos` rounded to
        `PATH_ANON_COORD_DECIMALS` decimals (2 by default, roughly 1 km) and
        `coordsRounded: true`. Note the flag means "read-time coarsening was applied", not
        "the numbers changed" — re-rounding an already-coarse coordinate is a no-op — and it
        is false when the report stores no endpoint coordinates at all. Per-hop repeater
        coordinates are archive data and are always full precision.

        Public while `PATH_VIEW_PUBLIC` is true (the default). When it is false, an
        anonymous caller gets `401` *before* the slug is looked up, so the endpoint cannot
        be used to enumerate slugs. A session cookie is optional; when present it also
        suppresses the read-time coarsening.

        Not 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**
        hop plus one `Point` per known endpoint anchor, and a `LineString` tracing
        requester → hops → reporter in traversal order. Unresolved hops carry no position,
        so both the points and the line skip them (and `seq` on hop features consequently
        has gaps — it is the index into the full hop list).

        Served with media type `application/geo+json`. Error responses are plain
        `application/json`.

        Identifiers (slug, canonical page URL, node names, counts) ride along in a
        `metadata` **foreign member** on the FeatureCollection — non-standard but permitted
        by RFC 7946 — so a consumer needs no second request. All eight metadata keys are
        always present; any of them may be null.

        Two shape rules that trip people up:

        * `Point` feature properties are filtered of nulls, so every property except `role`
          is **absent** rather than null when unknown. (`seq: 0` survives — the filter drops
          null, not falsy.)
        * The `LineString` properties are *not* filtered, so all four keys are always
          present and may be null.

        Coordinates are `[longitude, latitude]` (RFC 7946), while the JSON view's `origin`
        and `requesterPos` use `{"lat": …, "lon": …}`. Two conventions in one API — mind the
        order. There is no CRS member; coordinates are WGS 84. There is no altitude element.

        The `LineString` appears only when at least two points were collected, so a
        single-hop path with no endpoints yields one feature and no line.

        This representation carries **no `coordsRounded` flag** even though the identical
        anonymous coarsening was applied to the requester/reporter anchors — call
        `GET /api/path/{slug}` if you need to know.

        Distances (`metadata.totalKm`, the LineString's `totalKm`, each hop's `segmentKm`)
        are always computed from the precise stored coordinates, before that coarsening is
        applied to the requester/reporter anchors. For an anonymous caller they therefore do
        not match what you get by measuring the emitted geometry — do not use them as a
        checksum.

        Same access model as `GET /api/path/{slug}`: public while `PATH_VIEW_PUBLIC` is
        true, 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` —
        for the "more from this node" rail on the `/p/` page.

        Grouping prefers **public key**: the requester's name is resolved to its 8-hex
        pubkey, then every name that pubkey is known by is unioned, so a node that renamed
        itself still groups as one identity and two different pubkeys that merely share a
        display name never merge. Both the lookup and the name union read the contact-card
        roster (`contacts`) only — unlike hop resolution, adverts are not consulted, so a
        node known only from advert beacons yields `pubkey: null` / `byPubkey: false` even
        when the same node resolves as a hop. Channel `!path` messages carry only the
        sender's name, so a requester with no contact card falls back to exact-name
        matching. `byPubkey` tells you which happened — surface the distinction, because
        name matching both misses renames and is the weaker signal.

        Results are additionally scoped to the **submitting sender of the reference slug**
        (its `token:<label>` / `pubkey:<8hex>` / `ip:<addr>` attribution), so this only ever
        lists reports that arrived from the same submitter. The requester name is whatever
        the submitter put in the body, so without that scope anyone could POST a path
        claiming `r=<someone else>` and then use its slug to enumerate that node's whole
        `!path` history — timings and coarse stats included — with no account at all.
        A consequence worth planning for: reports about one requester that arrived through
        two different submitters do not group together.

        Deliberately returns **no coordinates at all**, only slugs and coarse statistics.

        The statistics here are the snapshot written at submit time, not a fresh resolution:
        `hopCount`, `resolvedHops` and `totalKm` can be null, and can disagree with what
        `GET /api/path/{that slug}` returns today. Note also the naming difference —
        `resolvedHops` here versus `resolved` in the live view.

        Capped at 100 items, newest first, excluding the reference slug. There is no cursor,
        offset or total count; a very chatty requester's older reports are simply not
        reachable through this endpoint.

        Same access model as the other two reads. Being signed in changes nothing about the
        body (there are no coordinates to coarsen); the session only matters when
        `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
        behind a `/p/` trace. A MeshCore hop hash is only the leading **1 to 3 bytes** of a
        32-byte pubkey (256 / 65,536 / 16,777,216 buckets), so on a busy roster a 1-byte
        hash routinely matches several nodes and the hop cannot be identified from the hash
        alone. `ambiguous` is that answer directly: `true` means more than one node in the
        archive fits, and any `/p/` page naming one of them is a best guess made from
        geography, not a certainty.

        `prefix` must be **2, 4 or 6 hex characters** — a whole number of bytes. An odd
        length is not a hash any device emits, and MeshCore does not hash beyond 3 bytes;
        both are rejected `400`. Case is ignored.

        Matches come from BOTH the contact-card roster and the advert dump, because many
        relays advertise without ever sending a contact card. Unlike the path resolver, a
        node with **no position** is still returned: an unpositioned repeater collides with
        yours just as thoroughly, and omitting it would report a prefix as unique when it
        is not.

        `type` is MeshCore's on-the-wire value (`repeater`, `chat`, `room`, `sensor`);
        `label` is the human word for it, where `chat` reads as **companion**. `meshes`
        lists every mesh whose observers have heard that key, and is deliberately NOT
        narrowed by `?mesh=` — a hash that looks unique on your mesh while also belonging
        to a repeater on the next one is exactly the collision worth seeing.

        `message` names **every** match on one line. `messageShort` is the same answer
        trimmed to a single LoRa frame for a bot to relay verbatim — it is measured in
        BYTES, not characters, because mesh names routinely carry emoji, and when it cannot
        fit every match it says `+N more` rather than severing a name. The structured set
        is always in `nodes`.

        Capped at 200 matches (`truncated: true` when there were more), newest sighting
        first. 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
            narrow 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;
                      says `+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
            character, 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
            last rolling 60 seconds. The address is taken from `CF-Connecting-IP`, else the
            trusted proxy's rightmost `X-Forwarded-For` entry.

            The endpoint is enumerable by design — 256 requests cover every 1-byte bucket —
            so this cap, not a login, is what separates a lookup from a bulk roster export.
            It is counted in postgres, so the limit holds across all API replicas rather
            than being multiplied by the replica count. No `Retry-After` is sent; the window
            is 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`
        returns and that gets pasted into LoRa channels. The shell is data-free: its
        JavaScript reads the slug from `location.pathname` and calls
        `GET /api/path/{slug}` and `GET /api/path/{slug}/related` to populate the map.

        The slug is **not validated or even read** server-side, so an unknown, expired or
        malformed slug still returns `200` with the same shell; the page then renders "Path
        not found." after the JSON call 404s. **A `200` here is not evidence that the slug
        exists** — use `GET /api/path/{slug}` for existence checks.

        This is the only page route with server-side gating: when `PATH_VIEW_PUBLIC` is
        false and the caller has no session it returns `303 See Other` to `/login` — a
        redirect, not the `401` the JSON endpoints return under the same condition. With the
        default (`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
            content 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`,
        deliberately not a table scan — probes run constantly). It is the readiness signal
        for the deployment: the connection pool opens non-blockingly at startup, so the
        process boots fine with postgres down and only this endpoint reveals it.

        Safe for third parties to poll, but it reports **this replica's database
        reachability only** — it is not a mesh-health or data-freshness signal. There is no
        `{"ok": false}` shape: the endpoint either succeeds or fails hard with a plain-text
        500.
      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,
        its region, the NWS counties it watches, the NOAA NWPS river gauges it watches, the
        channel alerts are announced to, and its public contact links — plus a static
        description of the two upstream data sources. It backs the public `/alerts` page so
        anyone can audit how alerts are defined and sourced.

        No database ids are exposed. Two columns are renamed on the way out
        (`discord_url` → `discord`, `website_url` → `website`), and several nullable columns
        are collapsed to `""` rather than null — only `region` (mesh) and
        `name`/`lat`/`lon` (gauge) are genuinely nullable.

        `alert_channel` is the one snake_case key on the entire public JSON surface; it is
        not a typo, do not normalise it.

        `sources` is a hardcoded two-element constant, identical on every call — it is not
        queried.

        Ordering is display order: meshes by `sort` then `label`, gauges by `name`
        (null names last). No query parameters, no filtering, no pagination. Not rate
        limited, 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
        unauthenticated login/signup page can populate its mesh dropdown before a session
        exists. `id` is the value to pass back as `mesh_id` on `POST /auth/register`.

        Property names are the raw database column names — lowercase, not camelCase. The
        ordering column (`sort`) is not returned, so preserve the array order rather than
        re-sorting client-side.

        Completely ungated: no auth, no throttle, no pagination. A session cookie, if sent,
        is 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
        8-character minimum, throttles per client IP, hashes with scrypt on a worker thread,
        inserts the user, creates a session and sets the `ms_session` cookie — so a
        successful registration leaves you signed in with no separate login round-trip.

        Order of operations matters: the IP rate-limit attempt is recorded **before** the
        insert, deliberately, so repeated "username already taken" probes are throttled as
        an enumeration oracle. The consequence is that *successful* sign-ups burn budget
        too — with the defaults you cannot create more than 5 accounts per hour per IP.

        The very first account on an empty instance is created with `role: "admin"` and is
        allowed through even when `ALLOW_REGISTRATION` is false (the documented bootstrap).
        Every later account gets `role: "user"`. On a deployment that already has users and
        `ALLOW_REGISTRATION=false`, every call returns `403`.

        An unrecognised `mesh_id` is **silently coerced to null** rather than rejected, and
        the response does not echo the resolved mesh, so a client sending a stale id has no
        way to detect that it was dropped. Omit the field (or send null) for
        "Not Listed"/unassigned.

        The client IP used for throttling is the rightmost `X-Forwarded-For` entry, falling
        back 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
            characters.
          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
            check happens after the rate-limit attempt is recorded, so a closed instance
            still 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
            `REGISTER_WINDOW_S` (default 3600 s). Counted in postgres, so the cap holds
            across all API replicas. **No `Retry-After` or `RateLimit-*` header** — back off
            for 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
        verification — against a dummy hash when no row exists — so timing cannot be used to
        enumerate accounts and unknown-user and wrong-password are byte-identical `401`s.

        On success it clears the per-username attempt counter (but not the per-IP one),
        creates a session row storing only the SHA-256 of the token, and sets the
        `ms_session` cookie. Logging in again mints an *additional* session; an existing
        cookie is not read and older sessions stay valid.

        Only failures are recorded against the throttles. Username lookup is
        case-**sensitive** while the throttle key is lowercased, so `Alice` and `alice` are
        different accounts that share one login-attempt budget.

        The response has no `role` field — call `GET /auth/me` afterwards for role and
        permissions. `mesh_id` is accepted by the shared body model and ignored here.

        There is no minimum or maximum password length enforced on this endpoint, and no
        CSRF 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
            set, 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
            inactivity sweep (`INACTIVE_SUSPEND_DAYS`, 120 by default: accounts with no
            sign-in for that long are suspended automatically and their sessions dropped).
            Nothing is deleted; an operator can restore it.

            Checked **after** the password verification, so this cannot be used to discover
            which accounts are suspended. The message names the contact address, and says so
            explicitly 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
            `login:user:<lowercased username>`, or `LOGIN_MAX_PER_IP` (default 25) for
            `login:ip:<ip>`, both within `LOGIN_WINDOW_S` (default 900 s). Postgres-backed,
            so cluster-wide. **No `Retry-After` header.** Shared-NAT callers can be blocked
            by other people's failures even when their own logins succeed, because a
            successful 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
        stored SHA-256 matches it, then unconditionally emits a cookie-clearing `Set-Cookie`
        and returns success.

        Total idempotence is the contract: no cookie, an expired cookie, a garbage cookie,
        or an already-deleted session all produce the same `200`, and there is no way to
        tell them apart (a request carrying a cookie still touches postgres, so it can `500`
        when the database is unreachable). No session is required to call it.

        Only the caller's own session ends — other sessions for the same user survive (mass
        revocation lives on the authenticated password-change endpoint).

        Takes no body: `fetch('/auth/logout', {method: 'POST'})` with zero bytes and no
        content 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
            cookie-clearing `Set-Cookie` for `ms_session` scoped to `Path=/`.
          headers:
            Set-Cookie:
              description: |-
                Clears the session cookie, e.g.
                `ms_session=""; Max-Age=0; Path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=lax`.
                Note it does not repeat the `Secure`/`HttpOnly` flags used when setting it —
                harmless, 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
        listed as public, but it is **not** an anonymous-shape endpoint: with no valid
        session it returns `401 {"detail": "not signed in"}`. There is no 200-with-nulls
        variant and no `authenticated: false` field. Both shipped web clients treat any
        non-2xx as "signed out".

        The session lookup joins on the SHA-256 of the cookie and requires a live
        `expires_at`, so an expired session behaves exactly like no session.

        `isManager` means "region manager or above" — it is true for admins as well, and
        says nothing about *which* region: that is `regionId`, and a manager with none
        can do nothing. The role booleans are convenience for UI; the real enforcement is
        on the private endpoints.
        `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
            outcome, 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
        `landing.html`, signed-in users get `index.html` (the vanilla-JS SPA). Both are
        `200 text/html`, so the two documents are indistinguishable from the response
        envelope.

        Because it performs a session lookup this is the only page route that touches
        postgres under the default configuration, and therefore the only one that can 500
        when the database is down. (`/p/{slug}` also performs a session lookup, but only
        when `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
        `POST /auth/login` and `POST /auth/register`, and populates its mesh dropdown from
        `GET /auth/meshes`. No session check — an already-signed-in visitor gets the same
        page.
      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
        client-side. It also calls `GET /auth/me` to decide whether to show a sign-out
        control but tolerates the 401, so the page works fully anonymously. Do not confuse
        this 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
        identity (including submitting under a radio's MeshCore public key), the
        `POST /api/device/path` wire format, the three read endpoints, rate limits and the
        known gotchas. Static and fully public; makes no data calls at all.

        The prose counterpart of this document, not a replacement for it — this file remains
        the 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
        (loaded from jsdelivr, pinned). Every schema, field and example in this file, browsable.

        The rendering is client-side and needs JavaScript plus that CDN; with either missing
        the page degrades to links to the raw document. Nothing here is generated from the
        application — 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
        `application/yaml` with `Access-Control-Allow-Origin: *`.

        The CORS header is deliberate and applies to the spec files only: an OpenAPI document
        is meant to be loaded by browser tooling from another origin, and it is public static
        content with no credentials attached. The API it describes sends no CORS headers at
        all, 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
        YAML by `make openapi` and committed beside it, because the runtime image carries no
        YAML parser; the test suite fails if the two drift. Same `Access-Control-Allow-Origin: *`
        as 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
        `/docs/api`; kept because the path was published on the landing page and shipped in a
        released image. Follow it or update your link — it will not be removed, but nothing
        new 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
        cartographic outlines (via the plotly dataset) covering TX, LA, MS, AL and FL, used
        by the Mesh Regions map. 534 features, roughly 264 KB, geometry types `Polygon` and
        `MultiPolygon`, coordinates rounded to 3 decimals.

        **County names collide across states** — there is a Jefferson in both TX and LA — so
        consumers must match on the `(name, state)` pair, never on `name` alone.

        Conditional requests work (`ETag` and `Last-Modified` are emitted). The response
        carries 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
        one gateway). The `node` path parameter is accepted and **ignored server-side** — the
        handler never reads it, and it is never used in a filesystem path; the shell's
        JavaScript parses it from `location.pathname` and calls the session-gated
        `/api/observer/*` endpoints.

        The route is therefore unauthenticated but the page is useless without a session:
        its 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
        node). Like `/observer/{node}`, the `pubkey` parameter is accepted and ignored
        server-side; there is no hex or length validation, so a malformed or nonexistent key
        still returns `200` with the same shell — never a 400 or 404.

        The page is unusable anonymously: its fetch helper redirects to `/login` when the
        session-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`
        (`HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=SESSION_DAYS*86400`, 30 days by
        default). Only the SHA-256 of the token is stored server-side, so the cookie is the
        only copy of the credential.

        **Optional** on every path read: those endpoints are fully usable with no security
        at all while `PATH_VIEW_PUBLIC` is true. Supplying it does two things — it satisfies
        the gate when `PATH_VIEW_PUBLIC` is false, and it suppresses the read-time coarsening
        of 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
        only one on this surface.

        Two sources, checked in that order: the deployment's `DEVICE_TOKEN` env var
        (`token:label,token2:label2`, compared in constant time), and tokens an admin issues
        from Admin → Path senders. Issued tokens are stored as a SHA-256 and shown exactly
        once at creation, so a lost one is revoked and reissued rather than recovered;
        revocation takes effect on the next submission. Ask at hello@txme.sh for one.

        A token sender is pre-trusted: its submitted endpoint coordinates are stored at full
        precision rather than coarsened. The token's *label* — never the secret — becomes the
        sender identity, the rate-limit key and the audit `token_id`.

        A present-but-wrong value is not an error; evaluation falls through to the pubkey and
        IP branches.
    meshcorePubkey:
      type: apiKey
      in: header
      name: x-meshcore-pubkey
      description: |-
        The submitting node's MeshCore public key — hex, optional leading `!`,
        case-insensitive, 8–64 hex characters, truncated to its lowercase 8-hex prefix.

        **This is membership, not authentication.** MeshCore public keys are broadcast over
        the air and are not secret, so any caller can assert any node's key. It is listed as
        a security scheme only because it is the header that selects a sender identity; it
        is deliberately not a security control. Attribution, rate-limit bucketing and admin
        trust decisions key off it — nothing that depends on secrecy does.

        A malformed value is not an error; evaluation falls through to the IP branch (and
        then 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
        characters from the base64url alphabet. Not validated by the framework, so a
        malformed value yields `404`, never `422`. Slugs are meant to be pasted into LoRa
        channels — 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
        window position. Always present on a `429`.
      schema:
        type: string
        const: '60'
    SetSessionCookie:
      description: |-
        Sets the session cookie, e.g.
        `ms_session=<43-char url-safe token>; Max-Age=2592000; Path=/; HttpOnly; Secure; SameSite=lax`.
        `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
        session. Returned before the slug is looked up, so it leaks nothing about whether the
        slug 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
        (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
        last rolling 60 seconds.

        Pubkey senders face a SECOND, independent ceiling keyed on the submitting address
        (`DEVICE_IP_RATE_LIMIT`, default 120/min; the address is taken from
        `CF-Connecting-IP`, else the trusted proxy's rightmost `X-Forwarded-For` entry).
        A pubkey is asserted in an unauthenticated header, so the per-sender budget alone
        only bounds a caller that keeps one identity — rotating the header would otherwise
        mint an unlimited budget. Both ceilings apply and a caller must stay under each.
        Token senders are exempt from the address ceiling (an integrator's fleet may share
        one egress).

        The per-sender limit is keyed on `token:<label>`, `pubkey:<8hex>` or `ip:<addr>` —
        never globally, so one integrator cannot starve another's budget. It is counted in
        postgres rather than in process memory, so the cap holds across all API replicas
        instead of being multiplied by the replica count.

        Consequences: only successfully stored paths consume budget; a 15-minute dedupe hit
        creates no row and so costs nothing; the check runs before hash validation, so a
        rate-limited caller with a malformed `h` sees this rather than `400`; and requests
        rejected `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
        mis-typed field, a field over its `maxLength`, malformed JSON, or an unsupported
        content type such as `text/plain`. Note the envelope differs from the other errors on
        the same operation (a `detail` **array**, not an `error` string), and that this check
        precedes the identity check, so an unauthorized caller with a bad body sees this
        rather 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
        file in `WEB_DIR` for a page route. Produced outside the application's middleware, so
        the body is **plain text**, not JSON, and none of the usual security headers are
        present.
      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
        calls the JSON API. Carries `Cache-Control: no-cache`, though Cloudflare in front of
        the origin still caches the shared `theme.css`/`theme.js` assets for about 4 hours
        (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
        `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
        a microcontroller building JSON with snprintf into a 256-byte buffer. The length caps
        are 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**
            (requester side first, reporting node last). Each hash is the leading 1–3 bytes
            of a repeater public key as hex, i.e. exactly 2, 4 or 6 hex characters.

            Elements are whitespace-stripped and lowercased, and empty elements are dropped,
            so a trailing comma or padded input is fine. There must be 1–64 elements, and
            **all elements must be the same width** — hash size is uniform within one
            MeshCore packet, so `aa,bbbb` is rejected. That uniformity rule cannot be
            expressed as a regex, hence only `maxLength` is machine-checkable here.

            Any violation returns `400 {"error": "bad hashes"}`. This is the only field that
            can 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` —
            as `"lat,lon"` in decimal degrees. The chain's end anchor.

            Split on the first comma; both parts must parse as floats; latitude must be in
            [-90, 90] and longitude in [-180, 180]; the exact pair `0,0` is rejected as
            MeshCore's "position not set" sentinel. Anything unparseable or out of range is
            **silently treated as absent** — it never causes an error.

            Coarsened irreversibly at ingest unless the sender is token-authenticated or
            admin-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
            from that node's wardrive/advert beacon. The chain's start anchor. Same parsing,
            same 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
            value `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,
            **exactly one value per hop**. Every element must parse as a float, the count
            must equal the number of hashes in `h`, and every value must be within
            [-40.0, 40.0].

            Anything malformed, miscounted or out of range is **silently discarded in its
            entirety** (stored as null) — signal readings are decoration and are never a
            reason 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
        frame, so none of the rich per-hop data is echoed. Fetch
        `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
            `URL_SHORTENER` is configured this is the shortened URL and may not live on this
            host at all. Otherwise it is `<PUBLIC_BASE_URL or the request base>/p/<slug>`.
            Shortening fails soft (2 s timeout; the short value is used only when it is an
            HTTP 200, starts with `http`, has no spaces and is strictly shorter), and a short
            URL is persisted, so a firmware retry reuses it rather than re-shortening.

            The literal `"url":"..."` shape is a compatibility guarantee — deployed firmware
            locates 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
            actual located node (0 to the number of hashes in `h`). A hop counts as resolved
            when at least one roster/advert entry matches its hash prefix and carries a
            usable position.
          examples: [1]
        km:
          type: number
          description: |-
            Total path length in kilometres, one decimal, across requester anchor → resolved
            hop positions → reporter anchor (unresolved hops carry no position, so the line
            skips them).

            **A conditionally present key, never null**: it is emitted only when the resolver
            produced a total, i.e. when at least one hop resolved *or* both `q` and `o`
            parsed to valid coordinates. In the degenerate case of zero hops resolved with
            both endpoints supplied, this is simply the straight-line requester→reporter
            distance.
          examples: [41.8]

    LatLon:
      type: object
      title: LatLon
      description: |-
        A device-supplied endpoint coordinate in `{lat, lon}` form. Note the GeoJSON view
        uses `[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** —
        the caller already has the name it looked up.

        Resolved from the submitted display name against the contact-card roster
        (`contacts`) only — unlike hop resolution, adverts are not consulted, so a node
        known only from advert beacons yields null here even when the same node resolves as
        a hop. The match must also be unique: MeshCore names are not unique, and a wrong
        link is worse than none, so an ambiguous or unknown name yields null instead of this
        object.
      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
            node has never been heard (epoch 0 is treated as unknown). Clamped to the
            server's current time, so a node with a fast clock can never report a future
            `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
            key 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
        hash — so a human can second-guess the pick. Ordered by ascending Viterbi forward-pass
        chain cost, which is not quite "second best overall" because the final pick also folds
        in 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
            `meshId` to see whether a runner-up was rejected for being on another mesh — the
            usual 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
        contacts/advert roster but carries no usable position, so it cannot be placed on the
        map. 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
        roster/advert archive and are **never coarsened** — the anonymous ~1 km rounding
        applies 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
            not 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
            previous *resolved* hop — legs skip unresolved hops and may therefore span
            several radio hops), one decimal. Null only when nothing was positioned before
            it.

            Consistent with the coordinates in the same response: when `coordsRounded` is
            true the resolver runs on the already-coarsened endpoints, so this distance is
            derived from exactly the points you were given. (It previously came from the
            precise stored coordinates, which handed an anonymous caller back the precision
            the 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
            truthiness, not equality. Set when `segmentKm` exceeds the deployment's
            `LINK_MAX_KM` (100 km by default, read from the process environment) *and* the
            previous positioned point is directly adjacent in the chain — either the
            immediately preceding hop, or the requester anchor when this is hop 0. Legs that
            jump over unresolved hops are deliberately not flagged, since they may
            legitimately span several radio hops.
        meshIds:
          type: array
          items:
            type: integer
          description: |-
            Mesh ids whose observers have actually heard the picked node. Possibly empty
            (nobody has attributed it yet), and legitimately **more than one** for a border
            repeater heard by two meshes. Empty means "unattributed", not "no mesh" — the
            resolver 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
            matched located nodes on more than one mesh, so the pick rested on mesh
            attribution rather than the hash alone. Expected and common: a hop hash is only
            1-3 bytes of a public key (256 / 65,536 / 16.7M buckets), so cross-mesh
            collisions are routine rather than exceptional. Inspect `alternates` to see the
            same-prefix nodes on the other meshes.
        meshMismatch:
          type: boolean
          const: true
          description: |-
            **Present only when true — absent, never false, otherwise.** The picked node is
            positively attributed to meshes that exclude this path's own mesh, i.e. every
            same-mesh candidate lost on geography anyway. Usually means the roster is
            incomplete 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
        version of `ResolvedHop`: `nodeId`, `shortId`, `role`, `lat`, `lon`, `lastSeen`,
        `batteryMv`, `alternates`, `segmentKm` and `farSegment` are absent entirely.
        Conversely 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`
        (`>= 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
        every read against the live roster, so these values legitimately change over time for
        the 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
            null.
          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
            → reporter anchor, one decimal (accumulated unrounded). Legs skip unresolved
            hops.

            Null unless there is a span at all: at least one hop resolved, **or** both
            endpoint coordinates are present. With zero hops resolved but both endpoints
            known, this is simply the requester→reporter great-circle distance.

            Consistent with the coordinates in the same response: when `coordsRounded` is
            true the resolver runs on the already-coarsened endpoints, so this distance is
            derived from exactly the points you were given. (It previously came from the
            precise stored coordinates, which handed an anonymous caller back the precision
            the 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.
            Null when there is no reporter position, no resolved hop, or nothing positioned
            before it. **It is already included in `totalKm`** — do not add it again.

            Consistent with the coordinates in the same response: when `coordsRounded` is
            true the resolver runs on the already-coarsened endpoints, so this distance is
            derived from exactly the points you were given. (It previously came from the
            precise stored coordinates, which handed an anonymous caller back the precision
            the 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`
            (100 km by default) between directly consecutive positioned points, plus the
            final reporter leg when it is over the threshold and the last hop resolved. Legs
            that jump over unresolved hops are deliberately not counted. Flagged rather than
            silently 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
            1-3 bytes of a public key, so the same hash routinely matches nodes on several
            meshes; knowing the mesh is what separates them when a path carries no position
            anchors to disambiguate geographically.

            Either recorded at submission (the observer that heard the `!path`, or an
            unambiguously-attributed submitting pubkey) or inferred from the chain — see
            `meshInferred`. Null when neither was available, in which case resolution falls
            back 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
            submission: hops that are unambiguous about their mesh vote, and a unique winner
            takes it. A tie, or no attributed candidate anywhere, leaves `meshId` null.
            False 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
        (`hops`, `resolved`, `totalKm`, `originKm`, `suspectSegments`) are spliced in at the
        top 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
                anonymous viewers (see `coordsRounded`), and possibly already coarsened
                irreversibly 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
                contact-card roster (`contacts`) only — unlike hop resolution, adverts are
                not consulted, so a node known only from advert beacons yields null here
                even when the same node resolves as a hop. Also null when the name is empty
                or ambiguous — the lookup returns a node only when exactly one distinct
                pubkey 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` /
                `requesterPos`: the viewer is anonymous, `PATH_ANON_COORD_DECIMALS` is >= 0
                (2 by default), and at least one endpoint coordinate exists.

                It does not imply the numbers changed — a coordinate already coarsened at
                ingest rounds to itself. It is false for signed-in viewers, false when the
                report stores no endpoint coordinates, and false when the deployment sets
                `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
                (same length as `hops`), each within [-40, 40]. Null when nothing was stored,
                and also null when the stored string no longer parses or its count no longer
                matches the hop count — SNRs are decoration and are dropped rather than
                raising.
              items:
                type: number
              examples:
                - [11.5, -3.25]
            sentTs:
              type: [integer, 'null']
              description: |-
                Unix epoch **seconds** (not ISO) — the triggering mesh message's sender
                timestamp. Only ever populated for reports created by the in-mesh pathbot;
                **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,
                using `rxTs` when present and the ingest time otherwise, and only when the
                difference is between 0 and 3600 seconds inclusive. A non-null `sentTs`
                therefore does not guarantee a non-null `delayS` — a bad sender clock yields
                null.

    PathSummary:
      type: object
      title: PathSummary
      description: |-
        A compact reference to another report. **These statistics are the snapshot written at
        submit time**, not a fresh resolution, so they can be null and can disagree with what
        `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
            the live view. Null only when the submit-time scoring never completed, in which
            case `totalKm` is null too: a non-null `resolvedHops` of 1 or more always comes
            with a non-null `totalKm`.
        totalKm:
          type: [number, 'null']
          description: |-
            Stored total length in km at submit time (single-precision). Null when the
            scoring never completed, and also when it produced no span at all (nothing
            resolved 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
            stored requester was missing or blank, in which case `pubkey` is null,
            `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
            to exactly one distinct pubkey in the contact-card roster (`contacts`) only —
            unlike hop resolution, adverts are not consulted, so a node known only from
            advert beacons yields null here even when the same node resolves as a hop. Also
            null for ambiguous names.
          examples: [4b19c0de]
        byPubkey:
          type: boolean
          description: |-
            True when results were grouped by public key across every name that key is known
            by in the contact-card roster — the strong, rename-tolerant grouping. False when
            they 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
        second request. All eight keys are always present; `createdAt`, `reporter`,
        `requester` and `totalKm` may be null, the other four never are. Keys in the source
        `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
            the long form — unlike the submit response's `url`, this is never the shortened
            one. 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
            document: an anonymous caller's anchors are coarsened *before* resolving, so the
            distance 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
        stored, 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
        stored, 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.
        Coordinates come from the roster and are never coarsened.

        Not carried into GeoJSON: `shortId`, `candidates`, `alternates`, `batteryMv`,
        `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
            guaranteed. (`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
                gaps — 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`, …),
                because `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**
        when at least two points were collected, so a single-hop path with no endpoint
        anchors 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
                the anchors are coarsened before resolving, so the distance derives from the
                same 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
        deterministic: requester Point (if any) → hop Points in traversal order → reporter
        Point (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
        rounded 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
                  in 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
        way 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
            stripped. Empty when none are configured, never null. Some entries carry a
            `/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.
            Never null. **This is the one snake_case key on the public JSON surface** — it is
            not 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
        model-level length or pattern constraints — the username and password rules are
        enforced 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`.
            On login: no format validation, and matched **case-sensitively** against stored
            usernames (though the throttle key is lowercased, so casing variants share one
            attempt budget).
          examples: [kk5abc]
        password:
          type: string
          description: |-
            On register: at least 8 characters. On login: no minimum and no maximum is
            enforced anywhere, so long values pay the full scrypt cost — mitigated only by
            the throttles.
          examples: [correct-horse-battery]
        email:
          type: [string, 'null']
          maxLength: 320
          default: null
          description: |-
            Optional recovery address, **register only** (ignored by login). Trimmed and
            lowercased before storage; blank/whitespace/omitted all mean "no address".

            A non-blank value that is not plausibly an address is rejected `400` — a typo
            reads as a recovery route right up until someone needs it. Not unique: two
            accounts may carry the same address, and the address is never a credential.

            There is no emailed reset link. Recovery is an operator confirming out of band
            that the person asking is the person who registered, then setting a new
            password. An account with no address on file therefore **cannot be recovered**,
            which 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
            "Not Listed"/unassigned. An id that does not exist is **silently coerced to
            null** rather than rejected, and the response does not echo the resolved value.
            Accepted 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
            a forgotten password ends the account, and is the normal outcome of leaving the
            field blank.
        role:
          type: string
          enum: [user, admin]
          description: |-
            `admin` only when this was the first account on the instance (the bootstrap);
            otherwise `user`. The `manager` role (a region manager) is reachable only
            through 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
        and 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
            nothing else. `null` means unassigned, which grants the role no authority at
            all, 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]
