Files
meshcore-analyzer/cmd/server/openapi.go
T
n30nexandn30nex f8c3b32633 docs(api): describe readiness responses and ingestor liveness (#2358)
Partial follow-up for #1670.

Documents public `GET /api/healthz`: 503/loading, current in-memory
counts, the fixed legacy backfill compatibility object, and optional
ingestor liveness timestamps. Receipt and handler-entry clocks have
different reset semantics; handler entry does not prove a completed
database write. The legacy object does not track real migration
progress.

The 200 response has a typed schema, including its dynamic source-name
map. Only `/api/healthz` leaves the known-gap list (16 → 15). Handlers,
configuration and frontend behavior are unchanged; formal non-200
response-schema work remains separate.

Validation:

- The new documentation assertion failed before metadata was added;
actual handler-response checks passed.
- Full native server/ingestor suites passed in 257.438 s / 452.613 s.
- After review corrections, focused checks passed in 2.757 s and the
parent rerun in 2.097 s, including exact compatibility values and
retained-receipt/zero-handler timestamps.
- Independent adversarial, correctness and TDD reviews completed; the
parent verified both semantic corrections against actual call sites.
- [Exact-head CI
passed](https://github.com/Kpa-clawbot/CoreScope/actions/runs/40979351261),
including browser shards.

TDD exemption: documentation/API metadata only. Local standalone browser
smoke was blocked; no local-browser or deployment claim is made.
Production untyped-map count is unchanged.

---------

Co-authored-by: n30nex <6686206+n30nex@users.noreply.github.com>
2026-10-31 11:59:20 +01:00

758 lines
74 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
"github.com/gorilla/mux"
)
// routeMeta holds metadata for a single API route.
type routeMeta struct {
Summary string `json:"summary"`
Description string `json:"description,omitempty"`
Tag string `json:"tag"`
Auth bool `json:"auth,omitempty"`
// Session marks routes that also accept the user-management session
// cookie (optional feature). With Auth too, either credential works.
// Set on the 3 documented requireAdmin routes (Auth: true) and on the
// user-management routes; other requireAdmin routes are undocumented.
Session bool `json:"session,omitempty"`
QueryParams []paramMeta `json:"queryParams,omitempty"`
// Response, when non-nil, is the OpenAPI schema object for the 200
// application/json response body. Routes without it fall back to the
// generic {"type":"object"} placeholder. Use schemaRef(...) to point
// at a named entry in components/schemas (see componentSchemas).
Response map[string]interface{} `json:"-"`
}
type paramMeta struct {
Name string `json:"name"`
Description string `json:"description"`
Required bool `json:"required,omitempty"`
Type string `json:"type"` // "string", "integer", "boolean"
}
// routeDescriptions returns metadata for all known API routes.
// Key format: "METHOD /path/pattern"
func routeDescriptions() map[string]routeMeta {
return map[string]routeMeta{
// Config
"GET /api/config/cache": {Summary: "Get cache configuration", Tag: "config"},
"GET /api/config/client": {Summary: "Get client configuration", Tag: "config"},
"GET /api/config/regions": {Summary: "Get configured regions", Tag: "config"},
"GET /api/config/region-quick-picks": {Summary: "Get region quick picks", Description: "Named groups of region (IATA) codes from config regionQuickPicks, offered as one-tap choices by the region filter. Always a list; empty when none are configured.", Tag: "config"},
"GET /api/config/theme": {Summary: "Get theme configuration", Description: "Returns color maps, CSS variables, and theme defaults.", Tag: "config"},
"GET /api/config/map": {Summary: "Get map configuration", Tag: "config"},
"GET /api/config/geo-filter": {Summary: "Get geo-filter configuration", Tag: "config"},
// Admin / system
"GET /api/health": {Summary: "Health check", Description: "Returns server health, uptime, and memory stats.", Tag: "admin"},
"GET /api/healthz": {Summary: "Query readiness", Description: "Public readiness probe with no parameters. Returns 503 application/json with {ready:false,reason:\"loading\"} while initializing. Returns 200 with ready:true, loadedTx and loadedObs counts for the current in-memory store (not database totals), and the fixed from_pubkey_backfill compatibility values {total:0,processed:0,done:true}; these do not report actual ingestor migration progress. ingest_liveness is an optional map from source names to reported receipt and handler-entry timestamps from the ingestor stats file; it is omitted when that data is unavailable or empty. The handler-entry clock resets to 0 on MQTT connection/reconnection, while the receipt clock is retained for the current source state. ready:true does not establish MQTT freshness or successful database writes.", Tag: "admin", Response: schemaRef("ReadinessResponse")},
"GET /api/stats": {Summary: "Network statistics", Description: "Returns aggregate stats (node counts, packet counts, observer counts). Cached for 10s.", Tag: "admin"},
"GET /api/perf": {Summary: "Performance statistics", Description: "Returns per-endpoint request timing and slow query log.", Tag: "admin"},
"GET /api/mqtt/status": {Summary: "MQTT source status", Description: "Returns per-MQTT-source connection state and counters (lastConnectUnix, lastPacketUnix, packetsTotal, etc.). Broker URL passwords are masked. Sourced from the ingestor stats file; empty list when unavailable. (#1043)", Tag: "admin"},
"POST /api/perf/reset": {Summary: "Reset performance stats", Tag: "admin", Auth: true, Session: true},
"GET /api/perf/io": {Summary: "Process disk I/O rates", Description: "Per-second server disk I/O rates from /proc/self/io (Linux): read/write/cancelled-write bytes and read/write syscall rates. The first server sample has zero rates. Includes an ingestor object when a usable, recent ingestor I/O sample is available; omits it when unavailable.", Tag: "admin"},
"GET /api/perf/sqlite": {Summary: "SQLite diagnostic metrics", Description: "Returns SQLite WAL sizes (walSize in bytes; walSizeMB in MiB), pageCount, pageSize and cacheSize. cacheHitRate is the PacketStore row-cache hit ratio, not a SQLite cache counter. Unavailable database/WAL measurements remain zero; failures reading these diagnostics do not change the 200 status.", Tag: "admin"},
"GET /api/perf/write-sources": {Summary: "Ingestor write counters", Description: "Returns ingestor write counters under sources and the published timestamp as sampleAt. Backfill counters use backfill_ prefixes. Successfully decoded stats also include writer_perf (empty when no writer metrics were published). An unreadable or malformed stats file returns 200 with empty sources, an empty sampleAt and no writer_perf. No freshness check is applied to the published timestamp.", Tag: "admin"},
// "POST /api/admin/prune" removed in #1283 (ingestor owns prune).
"GET /api/debug/affinity": {Summary: "Debug neighbor affinity scores", Tag: "admin", Auth: true, Session: true},
"GET /api/backup": {Summary: "Download SQLite backup", Description: "Streams a consistent SQLite snapshot of the analyzer DB (VACUUM INTO). Response is application/octet-stream with attachment filename corescope-backup-<unix>.db.", Tag: "admin", Auth: true, Session: true},
// User management (optional; routes exist only when userManagement.enabled)
"POST /api/auth/register": {Summary: "Register an account", Description: "Creates a pending account and mails an activation link. The response is identical whether or not the address is already registered.", Tag: "users"},
"POST /api/auth/activate": {Summary: "Activate an account", Description: "Request body {token, password}: the mailed activation token and the account password chosen at (the newest) registration. A wrong password answers 401 and leaves the token usable; attempts are rate-limited per account. On success consumes the token, activates the account and starts a session.", Tag: "users"},
"POST /api/auth/login": {Summary: "Log in", Description: "Email + password. Sets the cs_session cookie. Rate-limited per IP and per address.", Tag: "users"},
"POST /api/auth/device-token": {Summary: "Issue a device token (CoreDrive RX)", Description: "Body {email, password, deviceName}. 200 {token, expiresAt, user: {id, displayName}}. The token is a bearer credential (Authorization: Bearer <token>), valid 90 days and extended on use, limited to /api/auth/me, /api/auth/logout, /api/account/settings and /api/account/companions*; other account routes answer 403 to it. deviceName is cleaned (control characters removed, trimmed, at most 64 characters) and shown under Devices. Same 401 answer, rate limits (shared with /api/auth/login) and user.login audit row (detail via=device) as /api/auth/login. No Origin check: nothing is stored in the browser.", Tag: "users"},
"POST /api/auth/logout": {Summary: "Log out", Description: "With the cs_session cookie (Origin must be publicBaseUrl): ends that browser session and clears the cookie. With Authorization: Bearer <device token>: revokes that device token.", Tag: "users"},
"GET /api/auth/me": {Summary: "Current user", Description: "Returns the logged-in user and the CSRF token, or 401. Also accepts a device token (Authorization: Bearer); csrfToken is then empty.", Tag: "users", Session: true},
"POST /api/auth/forgot": {Summary: "Request a password reset", Tag: "users"},
"POST /api/auth/reset": {Summary: "Reset the password", Description: "Consumes the mailed reset token and ends all sessions of the user.", Tag: "users"},
"PATCH /api/account": {Summary: "Update profile", Tag: "users", Session: true},
"DELETE /api/account": {Summary: "Delete own account", Tag: "users", Session: true},
"POST /api/account/password": {Summary: "Change password", Tag: "users", Session: true},
"POST /api/account/email": {Summary: "Request an address change", Tag: "users", Session: true},
"POST /api/account/confirm-email": {Summary: "Confirm an address change", Tag: "users"},
"GET /api/account/sessions": {Summary: "List own sessions", Description: "[{id, kind, label, createdAt, lastSeenAt, expiresAt, userAgent, current}]. kind is web (a browser) or device (a CoreDrive RX device token); label is the device name, empty for web sessions. DELETE /api/account/sessions/{id} revokes either kind.", Tag: "users", Session: true},
"DELETE /api/account/sessions/{id}": {Summary: "Revoke one own session", Tag: "users", Session: true},
"GET /api/account/settings": {Summary: "Get own synced settings", Description: "Returns {revision, generation, doc, allowlist}. doc is {v: 1, keys: {<localStorage key>: <raw string>}} or null at revision 0. generation is a random id of the stored document (empty at revision 0); revisions restart at 1 after a DELETE, the generation does not repeat. allowlist lists the keys the server accepts: {key, kind: set|scalar, id?}, where id names the field that identifies an item of a set (absent: the item itself).", Tag: "users", Session: true},
"PUT /api/account/settings": {Summary: "Replace own synced settings", Description: "Body {baseRevision, baseGeneration, doc}. 200 {revision, generation}. 409 {revision, generation, doc} when baseRevision is not the stored revision or, with a stored document, baseGeneration is not its generation. baseRevision 0 without a stored document starts a new generation. 400 when doc is not {v: 1, keys} or holds a key that is never synced or not allowlisted. 413 above 256 KiB. 429 above 60 PUT requests per hour per user.", Tag: "users", Session: true},
"DELETE /api/account/settings": {Summary: "Delete own synced settings", Description: "Removes the stored document; the next PUT (baseRevision 0) starts a new one in a new generation.", Tag: "users", Session: true},
"POST /api/account/companions/challenge": {Summary: "Start linking a companion", Description: "Body {pubkey} (64 hex). 200 {challenge, expiresAt, host}: host is what to sign (the host of userManagement.publicBaseUrl); challenge is 32 random bytes as hex, single use, valid 5 minutes, bound to the caller and the pubkey. 400 malformed pubkey; 429 above 60 challenge + link requests per hour per user and per IP. Accepts a device token (Authorization: Bearer).", Tag: "users", Session: true},
"POST /api/account/companions": {Summary: "Link a companion", Description: "Body {pubkey, challenge, signature, name}. signature is the companion's Ed25519 signature (64 bytes, hex) over the UTF-8 string \"corescope-link:\" + host of userManagement.publicBaseUrl + \":\" + challenge. The challenge is consumed in every case. 200 {pubkey, name, linkedAt}. The synced settings (meshcore-my-nodes) are not touched: a companion is not a node to monitor. A companion linked to another account moves to the caller (the newest proof wins); both get a companion.transfer audit row and the previous owner a mail (a security notice, sent whatever the node notification settings are). 400 malformed pubkey or signature, or a signature that does not verify; 410 challenge missing, expired, used, or bound to another user or pubkey; 429 rate limited. Accepts a device token.", Tag: "users", Session: true},
"GET /api/account/companions": {Summary: "List own linked companions", Description: "[{pubkey, name, linkedAt, lastSeenAt}], newest link first. lastSeenAt is the newest client reception of that companion (rx_at as stored) or null. Accepts a device token.", Tag: "users", Session: true},
"DELETE /api/account/companions/{pubkey}": {Summary: "Unlink an own companion", Description: "204. Stored coverage stays. 400 malformed pubkey, 404 not linked to the caller. Accepts a device token.", Tag: "users", Session: true},
"GET /api/account/observer-login": {Summary: "Own observer MQTT login", Description: "{login: null} or {login: {username, createdAt, revoked}, observers: [{pubkey, name, lastSeenAt}]}. observers are the pubkeys bound to the caller (the first time an observer publishes with the login); name and lastSeenAt come from the analyzer observers (empty name and null lastSeenAt when unknown). Never contains the password, its hash or its ciphertext. Routes exist only when userManagement.observerLogins is enabled. Not available to device tokens.", Tag: "users", Session: true},
"POST /api/account/observer-login": {Summary: "Request an observer MQTT login", Description: "Generates a username and password, stores the password hashed and sealed, and mails the credentials to the account address in five formats (meshcoretomqtt, Gessaman or Dutch-MeshCore observer firmware, Home Assistant meshcore-ha, OpenHop, plain). 201 {sent: true}: the response never carries the credentials. 409 when the caller already has a login, 403 when it was revoked, 503 when the mail could not be sent (the login exists; use resend) or the feature failed to load.", Tag: "users", Session: true},
"POST /api/account/observer-login/resend": {Summary: "Mail the observer MQTT credentials again", Description: "Mails the same credentials again. 200 {sent: true}. 404 without a login, 403 when revoked, 429 after 3 credential mails in 24 hours (the first mail counts; Retry-After in seconds), 503 when the mail fails.", Tag: "users", Session: true},
"GET /api/account/export": {Summary: "Download own account data", Description: "One JSON file (Content-Disposition attachment corescope-account-<YYYY-MM-DD>.json): {formatVersion: 1, exportedAt, instance, profile, sessions (with kind web|device and the device label), companions (linked companions: pubkey, name, linkedAt), settings, proposals, notifications: {prefs, watches}, audit, mail, observerLogin: {username, createdAt, revoked} or null, observerBindings: [{pubkey, boundAt, boundBy}]}. Every row, no caps. Leaves out the password hash, every session, link and unsubscribe token, and companion link challenges. Audit rows name other accounts by id only. Includes activatedAt, activatedBy (id) and each mail row's to-address. Records a user.export audit row after the file is built.", Tag: "users", Session: true},
"GET /api/admin/users": {Summary: "List users (admin)", Tag: "users", Session: true, QueryParams: []paramMeta{{Name: "status", Description: "pending | active | disabled", Type: "string"}, {Name: "role", Description: "user | admin", Type: "string"}, {Name: "q", Description: "Substring of email or display name", Type: "string"}, {Name: "bouncing", Description: "1 = only addresses whose mail bounces", Type: "string"}}},
"GET /api/admin/users/{id}": {Summary: "User detail with sessions, companions, mail log and audit (admin)", Description: "{user, sessions, companions, mail, audit}. companions is [{pubkey, name, linkedAt, lastSeenAt}] like GET /api/account/companions.", Tag: "users", Session: true},
"DELETE /api/admin/users/{id}": {Summary: "Delete a user (admin)", Tag: "users", Session: true},
"POST /api/admin/users/{id}/disable": {Summary: "Disable a user (admin)", Tag: "users", Session: true},
"POST /api/admin/users/{id}/enable": {Summary: "Enable a user (admin)", Tag: "users", Session: true},
"POST /api/admin/users/{id}/role": {Summary: "Change a user's role (admin)", Tag: "users", Session: true},
"POST /api/admin/users/{id}/resend-activation": {Summary: "Resend the activation mail (admin)", Tag: "users", Session: true},
"POST /api/admin/users/{id}/activate": {Summary: "Activate a pending user manually (admin)", Description: "For when mail keeps failing. The address stays unverified; recorded as activatedBy + audit row.", Tag: "users", Session: true},
"POST /api/admin/users/{id}/mail/{mailId}/refresh": {Summary: "Pull delivery events for one mail from the provider (admin)", Tag: "users", Session: true},
"GET /api/admin/audit": {Summary: "Global audit log (admin)", Description: "Newest first, keyset-paginated: {entries, next}. Pass next as before for the following page; next is null on the last page. Entry: {id, at, action, actor, target, detail}; actor and target are {id, displayName, email}, {id, deleted: true} for a removed account, or null for the system. Login rows (user.login, user.login.failed) are kept 90 days. 400 on an invalid parameter.", Tag: "users", Session: true, QueryParams: []paramMeta{{Name: "action", Description: "One action, or a group ending in .* (user.login.* matches user.login and user.login.failed)", Type: "string"}, {Name: "user", Description: "User id, as actor or target", Type: "integer"}, {Name: "from", Description: "RFC 3339 time, inclusive", Type: "string"}, {Name: "to", Description: "RFC 3339 time, inclusive", Type: "string"}, {Name: "before", Description: "Only entries with a smaller id (next of the previous page)", Type: "integer"}, {Name: "limit", Description: "Page size, default 100, capped at 500", Type: "integer"}}},
"GET /api/admin/stats": {Summary: "User figures for the admin overview (admin)", Description: "Accounts by status, active admins, pending accounts older than 24 hours (stuckPending), bouncing addresses, registrations in 7 and 30 days and per UTC day for 30 days (newPerDay, oldest first), users active in 7 and 30 days, logins and failed logins in 24 hours, mail sent in 7 days by latest delivery status (mail7d), and accounts with 5 or more failed logins in 24 hours (guessing: [{userId, displayName, failed}]).", Tag: "users", Session: true},
"GET /api/admin/observer-logins": {Summary: "List observer MQTT logins and bindings (admin)", Description: "{logins: [{userId, email, displayName, username, createdAt, revoked, lastAuthAt, observers: [pubkey]}], bindings: [{pubkey, userId|null, boundAt, boundBy, confirmedAt|null}]}. confirmedAt is when the owner's own login first published under the pubkey. Never contains passwords, hashes or ciphertext. Routes exist only when userManagement.observerLogins is enabled.", Tag: "users", Session: true},
"POST /api/admin/observer-logins/{id}/revoke": {Summary: "Revoke a user's observer MQTT login (admin)", Description: "Takes effect at once for the broker hooks. 404 when the user has no login. Records an observer.login.revoke audit row.", Tag: "users", Session: true},
"POST /api/admin/observer-logins/{id}/reissue": {Summary: "Issue new observer MQTT credentials (admin)", Description: "New username and password, revocation cleared, credentials mailed to the account address. 200 {sent: true}; 404 without a login; 503 when the mail fails (the new login is already active). Records an observer.login.reissue audit row without the password.", Tag: "users", Session: true},
"PUT /api/admin/observer-bindings/{pubkey}": {Summary: "Reassign or unbind an observer (admin)", Description: "Body {userId: n} binds the pubkey to that user (bound_by admin, no per-user cap); {userId: null} removes the binding so the next publisher can claim it. Records an observer.binding.set audit row.", Tag: "users", Session: true},
"GET /api/admin/users-backup": {Summary: "Download a users.db snapshot (admin)", Description: "A fresh, consistent copy of users.db (VACUUM INTO) as application/octet-stream, attachment corescope-users-<YYYYMMDD-HHMMSS>.db (UTC). It holds every password hash and address: store it encrypted. Records a user.backup audit row. The analyzer database has its own route, GET /api/backup.", Tag: "users", Session: true},
"POST /api/proposals": {Summary: "Propose a hashtag channel", Description: "Body {kind: \"hashtag_channel\", subject}. subject is trimmed and gets a leading # when missing; at most 31 UTF-8 bytes including the #, no control or invisible characters (ZWJ allowed), not Public. 201 with the proposal {id, kind, subject, status, note, createdAt, decidedAt}. 400 invalid kind or name, 409 already pending, already approved or rejected, or already decrypted on this instance (a name in config.json hashChannels or channelKeys, case-sensitive), 429 when the caller created userManagement.channelProposals.perUserPerDay proposals in 24 hours or maxPending proposals wait for review. Registered only when userManagement.channelProposals.enabled.", Tag: "users", Session: true},
"GET /api/account/proposals": {Summary: "List own proposals", Description: "The caller's proposals, newest first: [{id, kind, subject, status, note, createdAt, decidedAt}]. note is the reviewer's reason.", Tag: "users", Session: true},
"GET /api/admin/proposals": {Summary: "List proposals (admin)", Description: "Newest first, at most 500: the proposal fields plus proposer and reviewer ({id, displayName, email}, or null once the account is deleted). 400 on an invalid filter.", Tag: "users", Session: true, QueryParams: []paramMeta{{Name: "status", Description: "pending | approved | rejected | revoked", Type: "string"}, {Name: "kind", Description: "hashtag_channel", Type: "string"}}},
"POST /api/admin/proposals/{id}/approve": {Summary: "Approve a proposal (admin)", Description: "Body {note} (at most 500 characters). Pending only (409 otherwise). The ingestor decrypts the channel from its next refresh (at most a minute) and /api/channels lists it in approvedChannels. 409 when userManagement.channelProposals.maxApproved channels are approved.", Tag: "users", Session: true},
"POST /api/admin/proposals/{id}/reject": {Summary: "Reject a proposal (admin)", Description: "Body {note}. Pending only (409 otherwise). The name cannot be proposed again until the row is pruned, 90 days after the decision.", Tag: "users", Session: true},
"POST /api/admin/proposals/{id}/revoke": {Summary: "Revoke an approved proposal (admin)", Description: "Body {note}. Approved only (409 otherwise). The ingestor stops decrypting the channel from its next refresh unless the channel is configured; stored messages stay. The name can be proposed again.", Tag: "users", Session: true},
"POST /api/mail/brevo/webhook": {Summary: "Brevo delivery-event webhook", Description: "Authenticated with Authorization: Bearer <userManagement.mail.webhookSecret>. Registered only when mail.provider is brevo and the secret is set.", Tag: "users"},
"POST /api/mqtt-hook/emqx/authn": {Summary: "EMQX authentication hook", Description: "Body {username, password, clientid}; answers {result: allow|deny|ignore}. ignore for usernames that are not a managed observer login, so the broker falls through to its own users. Authenticated with the X-CoreScope-Hook-Secret header (401 otherwise). Registered only when userManagement.observerLogins.enabled.", Tag: "users"},
"POST /api/mqtt-hook/emqx/authz": {Summary: "EMQX authorization hook", Description: "Body {username, topic, action}, action publish or subscribe; answers {result: allow|deny|ignore}. A managed login may publish only to meshcore/<iata>/<pubkey>/<type> for a pubkey it owns; an unbound pubkey is bound to the login on first publish. Everything else is denied for managed logins and ignored for other usernames. Authenticated with the X-CoreScope-Hook-Secret header (401 otherwise). Registered only when userManagement.observerLogins.enabled.", Tag: "users"},
"POST /api/mail/postal/webhook": {Summary: "Postal message-event webhook", Description: "Authenticated with HTTP Basic auth whose password is userManagement.mail.webhookSecret (put it in the webhook URL: https://postal:<secret>@host/api/mail/postal/webhook). Registered only when mail.provider is postal and the secret is set.", Tag: "users"},
// Node notifications (user management, part E)
"GET /api/account/notifications": {Summary: "Get own notification settings", Description: "Returns {enabled, events, availableEvents, externalLabel, watches: [{pubkey, name, known, createdAt}], limits: {maxWatches, perUserPerDay, mailsLast24h}}. node.external and externalLabel appear only when notifications.externalAlerts is configured. Creates the default settings (on, node.offline and node.battery) on first use. known is false once the node left the analyzer database. Registered only when userManagement.notifications.enabled.", Tag: "users", Session: true},
"PUT /api/account/notifications": {Summary: "Change own notification settings", Description: "Body {enabled, events}. events from node.offline, node.battery, node.external (when externalAlerts is configured) and, for admins, foreign.new and observer.offline; for everyone observer.offline.own (mails about the observers bound to the caller's own MQTT login, offered when userManagement.observerLogins is enabled). Answers the GET body. 400 unknown event type or node.external without externalAlerts, 403 an admin event chosen by a non-admin. Events left out lose their stored state, so choosing them again starts without a mail.", Tag: "users", Session: true},
"PUT /api/account/notifications/watches/{pubkey}": {Summary: "Watch a node", Description: "Answers the GET body. 400 when pubkey is not 64 hex characters, 404 when the node is not in the analyzer database, 409 when userManagement.notifications.maxWatchesPerUser nodes are watched. Watching a watched node is 200.", Tag: "users", Session: true},
"DELETE /api/account/notifications/watches/{pubkey}": {Summary: "Stop watching a node", Description: "Answers the GET body; removing a node that is not watched is 200. 400 when pubkey is not 64 hex characters.", Tag: "users", Session: true},
"POST /api/account/notifications/watch-my-nodes": {Summary: "Watch the synced My nodes", Description: "Copies the pubkeys of the caller's synced meshcore-my-nodes setting into the watch list, up to the limit. Answers {added, already, skipped, account}; skipped counts malformed keys, nodes not in the analyzer database and nodes over the limit; account is the GET body.", Tag: "users", Session: true},
"GET /api/notifications/unsubscribe": {Summary: "Unsubscribe link (redirect)", Description: "303 to <publicBaseUrl>/#/account/unsubscribe?token=, the confirm view. Changes nothing, so link scanners cannot unsubscribe anyone.", Tag: "users", QueryParams: []paramMeta{{Name: "token", Description: "The unsubscribe token from the mail", Type: "string"}}},
"POST /api/notifications/unsubscribe": {Summary: "Turn notification mails off", Description: "No session and no Origin check: the confirm button and the mail provider's List-Unsubscribe-Post one-click both land here. 200 {ok, message}, also when already off; 410 for an unknown token. The token can do nothing else.", Tag: "users", QueryParams: []paramMeta{{Name: "token", Description: "The unsubscribe token from the mail", Type: "string"}}},
// Packets
"GET /api/packets": {Summary: "List packets", Description: "Returns decoded packets with filtering, sorting, and pagination.", Tag: "packets",
QueryParams: []paramMeta{
{Name: "limit", Description: "Maximum transmissions per page. Defaults to 50 for omitted, invalid or non-positive values. Positive values are capped by listLimits.packetsMax (10000 by default).", Type: "integer"},
{Name: "offset", Description: "Pagination offset. Defaults to 0 when omitted or invalid; use a non-negative integer.", Type: "integer"},
{Name: "order", Description: "Sort by ingest ID: asc selects ascending; any other value selects descending. Defaults to desc. Ignored when groupByHash=true.", Type: "string"},
{Name: "type", Description: "Filter by numeric payload_type value.", Type: "integer"},
{Name: "route", Description: "Filter by numeric route_type value.", Type: "integer"},
{Name: "observer", Description: "Comma-separated observer IDs; matches transmissions heard by any listed observer. Whitespace around IDs is trimmed.", Type: "string"},
{Name: "excludeTx", Description: "Set exactly true to require an observation whose direction is not tx, from one of the selected observer IDs when observer is set, otherwise from any observer. Missing/empty directions remain eligible. Filters transmission membership only; displayed direction and signal values still come from the representative observation. Defaults to false; ignored in multi-node mode.", Type: "boolean"},
{Name: "radio", Description: "Only packets some observer heard on this radio setting, \"MHz,kHz,SF,CR\" (e.g. 869.618,62.5,8,8). Resolved from each observer's radio history at the time of the observation (#1869); every observation and packet (grouped rows: their header observation) carries the resolved value as radio. Combined with observer, only those observers' observations count. Served from the in-memory window only: a since/until before it does not fall back to SQL for a radio query.", Type: "string"},
{Name: "hash", Description: "Exact transmission hash, with input lowercased before matching. This is not a prefix search.", Type: "string"},
{Name: "since", Description: "Lower time bound; RFC3339 recommended. In-memory lists require first_seen > since; SQL uses observations >= since for RFC3339 values, otherwise first_seen > since. Multi-node mode uses first_seen >= since. Omit for no lower bound.", Type: "string"},
{Name: "until", Description: "Upper time bound; RFC3339 recommended. In-memory lists require first_seen < until; SQL uses observations <= until for RFC3339 values, otherwise first_seen < until. Multi-node mode uses first_seen <= until. Omit for no upper bound.", Type: "string"},
{Name: "region", Description: "Filter by the receiving observer's IATA region code.", Type: "string"},
{Name: "node", Description: "Filter by node public key or exact name. SQL fallback matches the originating node; cached results can also include indexed relay involvement.", Type: "string"},
{Name: "nodes", Description: "Comma-separated node public keys or exact names, up to 50 non-empty entries after trimming whitespace. Selects multi-node mode: only limit, offset, order, since and until also apply; other packet filters and modes are ignored. More than 50 entries returns 400.", Type: "string"},
{Name: "channel", Description: "Exact channel identifier: a decoded channel name (for example #test) or enc_<HEX> for an encrypted channel hash.", Type: "string"},
{Name: "area", Description: "Configured area key, filtering cached packets by the decoded originator's GPS position. The filter is ignored for unknown areas, areas with no located nodes, and SQL queries.", Type: "string"},
{Name: "expand", Description: "Set to observations to include observation details in in-memory, ungrouped results. Otherwise omitted; ignored in SQL queries, multi-node mode and grouped results.", Type: "string"},
{Name: "groupByHash", Description: "Set exactly true to return one summary per transmission hash, ordered by latest observation descending; order and expand are ignored. Defaults to false.", Type: "boolean"},
}},
"GET /api/packets/{id}": {Summary: "Get packet detail", Tag: "packets"},
"GET /api/packets/timestamps": {Summary: "Get packet timestamp ranges", Tag: "packets"},
"POST /api/packets/observations": {Summary: "Batch submit observations", Description: "Submit multiple observer sightings for existing packets.", Tag: "packets"},
// Decode
"POST /api/decode": {Summary: "Decode a raw packet", Description: "Decodes a hex-encoded packet without storing it.", Tag: "packets"},
// Nodes
"GET /api/nodes": {Summary: "List nodes", Description: "Returns all known mesh nodes with status and metadata. Repeater/room rows carry the issue #672 usefulness metrics (traffic_share_score, bridge_score, coverage_score, redundancy_score), the composite usefulness_score + usefulness_grade, and relay-activity counters. See the Node schema.", Tag: "nodes",
Response: schemaRef("NodeListResponse"),
QueryParams: []paramMeta{
{Name: "role", Description: "Filter by node role", Type: "string"},
{Name: "status", Description: "Filter by status (active/stale/offline)", Type: "string"},
}},
"GET /api/nodes/resolve": {
Summary: "Resolve a node prefix", Tag: "nodes", Response: schemaRef("NodeResolveResponse"),
Description: "Resolves against nodes and eligible client_node_names entries (reported nodes heard directly), counting distinct public keys across both sources. A unique visible match includes pubkey and a non-empty name when available. No match, or a unique blacklisted or hidden-name match, returns only prefix and ambiguous=false. Multiple candidates return ambiguous=true without pubkey/name. Errors use text/plain: 400 (prefix must be hex / prefix must be at least 4 hex chars), 503 (unavailable database), or 500 (query failed).",
QueryParams: []paramMeta{{Name: "prefix", Type: "string", Required: true, Description: "Hex prefix or full public key, 4 to 64 characters after trimming whitespace and lowercasing. Odd lengths are accepted. Missing, empty, non-hex, shorter or longer values return 400."}},
},
"GET /api/nodes/{pubkey}/battery": {
Summary: "Get node battery history", Tag: "nodes", Response: schemaRef("NodeBatteryResponse"),
Description: "Battery voltage history from observer_metrics for the exact stored pubkey. The node lookup does not normalize case or validate hex; unknown keys return 404. Observer IDs are matched in lowercase or uppercase. Samples with non-null battery_mv in the lookback window are sorted oldest first. With no samples, samples is [], latest_mv and latest_ts are null, and status is unknown. Errors use application/json objects with an error string: 404 (node not found), or 500 for node/history database query failures.",
QueryParams: []paramMeta{{Name: "days", Type: "integer", Description: "Lookback in whole days, 1 through 365. Defaults to 7 when omitted, invalid, non-positive or above 365; values are not clamped."}},
},
"GET /api/nodes/search": {Summary: "Search nodes", Description: "Search nodes by name or public key prefix.", Tag: "nodes", QueryParams: []paramMeta{{Name: "q", Description: "Search query", Type: "string", Required: true}}},
"GET /api/nodes/reported": {Summary: "Repeaters known only from their reported name", Description: "Repeaters that told a mobile companion their own name (client_node_names, ANON_REQ_TYPE_OWNER) have no row in nodes or inactive_nodes, and were heard directly at least once (a discover or advert reception). 404 when clientRxCoverage is disabled. Each carries its reception counts and, when companions heard it directly from at least `method.min_spots` spots at least `method.min_spread_m` apart, an `estimate`: a SNR-weighted centroid of those spots with `radius_m` (never below `method.min_radius_m`). The estimate is where companions stood, weighted, not a measured position. Cached for 5 minutes.", Tag: "nodes"},
"GET /api/nodes/bulk-health": {Summary: "Bulk node health", Description: "Returns health status for all nodes in one call.", Tag: "nodes"},
"GET /api/nodes/network-status": {Summary: "Network status summary", Description: "Returns counts of active, stale, and offline nodes.", Tag: "nodes"},
"GET /api/nodes/{pubkey}": {Summary: "Get node detail", Description: "Returns full detail for a single node by public key. For repeater/room nodes this includes the issue #672 usefulness axes + composite score/grade (see the Node schema).", Tag: "nodes", Response: schemaRef("NodeDetailResponse")},
"GET /api/nodes/{pubkey}/health": {Summary: "Get node health", Tag: "nodes"},
"GET /api/nodes/{pubkey}/paths": {Summary: "Get node routing paths", Tag: "nodes"},
"GET /api/nodes/{pubkey}/analytics": {Summary: "Get node analytics", Description: "Per-node packet counts, timing, and RF stats.", Tag: "nodes"},
"GET /api/nodes/{pubkey}/hop_analytics": {Summary: "Get node hop counts", Description: "One entry per flood packet the node forwarded in the window, with the hop count its flood.max check saw (the node's zero-based index in the path) and tags (flood, scoped or unscoped, advert). DIRECT packets are excluded. Every observation in the window is read. A colliding path prefix is attributed only when the previous hop's neighbor_edges neighbors leave this node as the one candidate; the server's resolved-path pick is not used. Packets carrying this node's prefix that cannot be attributed are counted in `ambiguous`.", Tag: "nodes",
QueryParams: []paramMeta{
{Name: "days", Description: "Lookback window in days (default 7, clamped 1-365)", Type: "integer"},
}},
"GET /api/nodes/{pubkey}/neighbors": {Summary: "Get node neighbors", Description: "Returns the queried node's first-hop neighbors with affinity scores and observation metadata (count, SNR, distance, observers). Ambiguous edges carry candidate pubkeys.", Tag: "nodes", Response: schemaRef("NodeNeighborsResponse")},
"GET /api/nodes/clock-skew": {Summary: "Fleet clock-skew summaries", Description: "Returns an array of clock-skew summaries for nodes with available data, enriched with nodeName/nodeRole when known. Fleet rows omit samples, recentHashEvidence and calibrationSummary; use the single-node endpoint for that detail. Returns an empty array when no store or no clock-skew data is available.", Tag: "nodes", Response: schemaRef("FleetClockSkewResponse"),
QueryParams: []paramMeta{{Name: "area", Description: "Configured area key. Omit for the unfiltered fleet. Filtering applies only when the area resolves to a non-empty node set; unknown keys and empty results leave the fleet unfiltered.", Type: "string"}}},
"GET /api/nodes/{pubkey}/clock-skew": {Summary: "Get node clock skew", Description: "Returns clock-skew statistics and available sample/calibration evidence for the path pubkey. Returns 404 when the packet store is unavailable or the node has no clock-skew data.", Tag: "nodes"},
"GET /api/rf-noise": {Summary: "RF noise-floor hex grid", Description: "GeoJSON hex cells of the LoRa noise floor measured by mobile clients along their tracks, from client_rf_samples. Lower (more negative) dBm is quieter, the opposite direction to the SNR-coloured coverage layer. Stationary samples are excluded: a parked companion logs hundreds of samples at one point and would otherwise define the cell. Gated on clientRfSamples.", Tag: "coverage",
QueryParams: []paramMeta{
{Name: "bbox", Description: "Bounding box as minLat,minLon,maxLat,maxLon", Type: "string", Required: true},
{Name: "z", Description: "Leaflet zoom level; sets the hex resolution", Type: "integer"},
{Name: "days", Description: "Look-back window in days (1-30, default 7)", Type: "integer"},
{Name: "rx", Description: "Only this companion's samples", Type: "string"},
{Name: "mine", Description: "1 = only samples of companions linked to the logged-in user (browser session; 401 without one)", Type: "string"},
}},
"GET /api/rx-coverage": {Summary: "Mobile RX coverage hex grid", Description: "GeoJSON hex cells where mobile clients directly heard nodes (client_receptions): count, best SNR and the nodes heard per cell. With gaps=1 the payload also carries a `gaps` member: cells a companion drove through (moving client_rf_samples) with no reception in the same window, each with its sample count, plus `gaps_truncated` when capped. Gaps are only returned without a node filter and with clientRfSamples on; they mean nothing was received in this period, not that there is no coverage. Gated on clientRxCoverage.", Tag: "coverage",
QueryParams: []paramMeta{
{Name: "bbox", Description: "Bounding box as minLat,minLon,maxLat,maxLon", Type: "string", Required: true},
{Name: "z", Description: "Leaflet zoom level; sets the hex resolution", Type: "integer"},
{Name: "days", Description: "Look-back window in days (1-30, default 7)", Type: "integer"},
{Name: "node", Description: "Only receptions of this node (pubkey or 2-3 byte prefix); disables gaps", Type: "string"},
{Name: "rx", Description: "Only this companion's receptions (and, with gaps=1, its own track)", Type: "string"},
{Name: "gaps", Description: "1 = add cells driven through with nothing received", Type: "string"},
}},
"GET /api/scope-audit": {Summary: "Network-wide scope audit", Description: "For every repeater that has answered a declared-regions request: the regions it declares, which of those it has NOT been observed forwarding in the window, which scopes it forwards without declaring, and whether it forwards unscoped floods while omitting the '*' wildcard. '*' is never listed as a region — it governs unscoped floods, not a scope. Repeaters never successfully asked are absent rather than shown as declaring nothing. Rows with missing regions sort first; a short window is weak evidence, since a quiet region simply has no traffic.", Tag: "analytics",
QueryParams: []paramMeta{
{Name: "window", Description: "Time window: 1h, 24h, or 7d (default 24h)", Type: "string"},
{Name: "mode", Description: "declared (default) or transport", Type: "string"},
{Name: "scope", Description: "mode=transport only: region scope to split the fleet by", Type: "string"},
{Name: "region", Description: "Observer IATA codes, comma-separated: count only forwarding heard by observers there. All means no filter", Type: "string"},
}},
// Analytics
"GET /api/analytics/rf": {Summary: "RF analytics", Description: "SNR/RSSI distributions and statistics.", Tag: "analytics"},
"GET /api/analytics/topology": {Summary: "Network topology", Description: "Hop-count distribution and route analysis.", Tag: "analytics"},
"GET /api/analytics/channels": {Summary: "Channel analytics", Description: "Message counts and activity per channel.", Tag: "analytics"},
"GET /api/analytics/distance": {Summary: "Distance analytics", Description: "Geographic distance calculations between nodes.", Tag: "analytics"},
"GET /api/analytics/hash-sizes": {Summary: "Hash size analysis", Description: "Distribution of hash prefix sizes across the network.", Tag: "analytics"},
"GET /api/analytics/hash-collisions": {Summary: "Hash collision detection", Description: "Identifies nodes sharing hash prefixes.", Tag: "analytics"},
"GET /api/analytics/subpaths": {Summary: "Subpath analysis", Description: "Common routing subpaths through the mesh.", Tag: "analytics"},
"GET /api/analytics/subpaths-bulk": {Summary: "Bulk subpath analysis", Tag: "analytics"},
"GET /api/analytics/subpath-detail": {Summary: "Subpath detail", Tag: "analytics"},
"GET /api/analytics/neighbor-graph": {Summary: "Neighbor graph", Description: "Full neighbor affinity graph for visualization.", Tag: "analytics"},
"GET /api/analytics/retransmissions": {Summary: "Retransmission pressure over time", Description: "Collision-pressure proxy (#1699): per time bucket, the average number of distinct repeaters in the union of all observed paths of each flood event (route types 0/1, TRACE excluded). A transmission's observations are split into flood events at gaps of more than 5 minutes; each event is bucketed by its first observation, and events before the store retention floor are left out. Hop prefixes are not resolved: a prefix counts once per event, so colliding 1-byte prefixes make this a lower bound. Only repeaters some observer heard are counted, so the value also follows observer coverage; each bucket carries its observer count.", Tag: "analytics",
QueryParams: []paramMeta{
{Name: "region", Description: "Comma-separated IATA codes; only observations from observers in the region are counted. A region with no known observers is not filtered", Type: "string"},
{Name: "window", Description: "Relative window: 1h, 24h, 3d, 7d or 30d", Type: "string"},
{Name: "from", Description: "Absolute window start (RFC3339)", Type: "string"},
{Name: "to", Description: "Absolute window end (RFC3339)", Type: "string"},
{Name: "bucket", Description: "Bucket size: 5m, 15m, 1h, 6h or 1d (default 1h)", Type: "string"},
}},
// Channels
"GET /api/channels": {Summary: "List channels", Description: "Returns known mesh channels with message counts. With userManagement.channelProposals.enabled the response also carries approvedChannels: the approved hashtag channel names, oldest approval first, at most maxApproved, listed also before they have traffic.", Tag: "channels"},
"GET /api/channels/{hash}/messages": {Summary: "Get channel messages", Description: "Returns messages for a specific channel.", Tag: "channels"},
// Observers
"GET /api/observers": {Summary: "List observers", Description: "Returns all known packet observers/gateways. neighbors_report_at (when the latest /neighbors report arrived) and neighbors_count (how many neighbors it named) are present for observers that ever sent one (#2270).", Tag: "observers"},
"GET /api/observers/clock-skew": {Summary: "Observer clock calibrations", Description: "Returns an array of observer clock calibrations: observerID, offsetSec (seconds; positive means the observer clock is ahead) and samples (number of multi-observer packet samples used). Returns an empty array when the store or calibration data is unavailable.", Tag: "observers", Response: schemaRef("ObserverClockSkewResponse")},
"GET /api/observers/{id}": {Summary: "Get observer detail", Description: "radio_history lists every radio setting the observer reported, oldest first: {radio_id, radio, first_seen, last_seen}. radio_id is empty for the main radio and the node's own id for an entry of a multi-radio status radios map (#1869). neighbor_report is the latest /neighbors report (#2271): {received_at, reported_at, neighbor_count, queried_neighbors, self_scopes, self_default_scope, neighbors: [{pubkey, name, role, snr, heard_secs_ago, status, scopes}]}; name and role come from the node table when known. Absent when the observer never sent one.", Tag: "observers"},
"GET /api/radios": {Summary: "Radio settings heard on", Description: "Every radio setting (canonical \"MHz,kHz,SF,CR\") some observer has listened on, with those observers and the span of time, for the radio filter of /api/packets (#1869).", Tag: "observers"},
"GET /api/observers/{id}/metrics": {Summary: "Get observer metrics", Description: "Packet rates, uptime, and performance metrics.", Tag: "observers"},
"GET /api/observers/{id}/analytics": {Summary: "Get observer analytics", Tag: "observers"},
"GET /api/observers/metrics/summary": {Summary: "Observer metrics summary", Description: "Aggregate metrics across all observers within window (noise floor, battery, sparkline). Each row also carries tx_airtime_pct_1h / rx_airtime_pct_1h: the airtime share over the last hour regardless of window, null with under 30 minutes of samples since the last reboot or no sample in the last 15 minutes. duty_cycle_warn_pct is the configured warning threshold (dutyCycleWarnPct, default 10).", Tag: "observers", QueryParams: []paramMeta{{Name: "window", Description: "Lookback such as 1h, 24h, 7d (default 24h)", Type: "string"}, {Name: "region", Description: "IATA code to filter observers", Type: "string"}}},
// Misc
"GET /api/resolve-hops": {Summary: "Resolve hop path", Description: "Resolves hash prefixes in a hop path to node names. Returns affinity scores and best candidates.", Tag: "nodes", QueryParams: []paramMeta{{Name: "hops", Description: "Comma-separated hop hash prefixes", Type: "string", Required: true}}},
"GET /api/traces/{hash}": {Summary: "Get packet traces", Description: "Returns all observer sightings for a packet hash.", Tag: "packets"},
"GET /api/rf-params": {Summary: "LoRa RF parameters per node", Description: "Opt-in (config rfParams.nodesUrl, issue #1268). Frequency, SF, bandwidth and coding rate the MeshCore map holds for the nodes this instance knows, keyed by lower-case pubkey, limited to adverts within rfParams.maxAgeDays (default 7). Also returns the named presets and fetched_at, the time of the last successful map fetch (null before the first one), and deviations: the nodes whose frequency, SF or bandwidth differs from a setting that more than half of the nodes within rfParams.compareRadiusKm (default 50) on the same band share, the node itself counted, with that majority and the counts. Frequencies within a quarter of the bandwidth count as the same channel. enabled is false when the feature is off.", Tag: "nodes"},
"GET /api/iata-coords": {Summary: "Get IATA airport coordinates", Description: "Returns lat/lon for known airport codes (used for observer positioning).", Tag: "config"},
"GET /api/audio-lab/buckets": {Summary: "Audio lab frequency buckets", Description: "Returns frequency bucket data for audio analysis.", Tag: "analytics"},
}
}
// openAPISchema is a typed OpenAPI schema object, for component schemas that
// would otherwise be nested untyped map literals (AGENTS.md).
type openAPISchema struct {
Type string `json:"type"`
Description string `json:"description,omitempty"`
Nullable bool `json:"nullable,omitempty"`
Enum []string `json:"enum,omitempty"`
Items *openAPISchema `json:"items,omitempty"`
Properties map[string]openAPISchema `json:"properties,omitempty"`
Required []string `json:"required,omitempty"`
AdditionalProperties *openAPISchema `json:"additionalProperties,omitempty"`
}
// schemaRef returns an OpenAPI $ref pointing at a named component schema.
func schemaRef(name string) map[string]interface{} {
return map[string]interface{}{"$ref": "#/components/schemas/" + name}
}
// componentSchemas returns the reusable OpenAPI schemas surfaced under
// components/schemas. The Node schema documents the per-node usefulness
// metrics (issue #672) that the /api/nodes handlers attach to repeater/room
// rows — previously these were set on the wire but undocumented (issue
// #672 / E). Score axes are bounded [0,1]; usefulness_score is the weighted
// composite and usefulness_grade its A–F letter.
func componentSchemas() map[string]interface{} {
score01 := func(desc string) map[string]interface{} {
// "double" matches the Go float64 wire type (some linters flag "float").
return map[string]interface{}{
"type": "number", "format": "double", "minimum": 0, "maximum": 1,
"description": desc,
}
}
str := func(desc string) map[string]interface{} {
m := map[string]interface{}{"type": "string"}
if desc != "" {
m["description"] = desc
}
return m
}
return map[string]interface{}{
"ReadinessResponse": openAPISchema{
Type: "object", Required: []string{"ready", "loadedTx", "loadedObs", "from_pubkey_backfill"},
Properties: map[string]openAPISchema{
"ready": {Type: "boolean", Description: "True in the 200 response; false with reason loading in the 503 response."},
"loadedTx": {Type: "integer", Description: "Transmissions currently loaded in the in-memory store."},
"loadedObs": {Type: "integer", Description: "Observations currently loaded in the in-memory store."},
"from_pubkey_backfill": {
Type: "object", Description: "Fixed compatibility placeholder, not actual ingestor migration progress.", Required: []string{"total", "processed", "done"},
Properties: map[string]openAPISchema{
"total": {Type: "integer", Description: "Compatibility value: always 0."},
"processed": {Type: "integer", Description: "Compatibility value: always 0."},
"done": {Type: "boolean", Description: "Compatibility value: always true; does not establish ingestor migration completion."},
},
},
"ingest_liveness": {
Type: "object", Description: "Optional source-name map from the ingestor stats snapshot; not a broker connection-status check.",
AdditionalProperties: &openAPISchema{
Type: "object", Required: []string{"lastReceiptUnix", "lastMessageUnix"},
Properties: map[string]openAPISchema{
"lastReceiptUnix": {Type: "integer", Description: "Last reported MQTT callback receipt time in Unix seconds for the current ingestor source state; retained across reconnects. 0 means no receipt has been recorded in that state."},
"lastMessageUnix": {Type: "integer", Description: "Last reported message-handler entry time in Unix seconds, before decoding or database writes. Reset to 0 on each MQTT connection/reconnection; 0 means no entry has been recorded since initialization or the latest connection reset, even if older traffic exists."},
},
},
},
},
},
"NodeResolveResponse": openAPISchema{
Type: "object", Required: []string{"prefix", "ambiguous"},
Properties: map[string]openAPISchema{
"prefix": {Type: "string", Description: "Trimmed, lowercase query prefix."},
"pubkey": {Type: "string", Description: "Present only for a unique visible match."},
"name": {Type: "string", Description: "Present only when a unique visible match has a non-empty name."},
"ambiguous": {Type: "boolean", Description: "True when multiple distinct public keys match; pubkey and name are then omitted."},
},
},
"NodeBatteryResponse": openAPISchema{
Type: "object", Required: []string{"public_key", "days", "samples", "latest_mv", "latest_ts", "status", "thresholds"},
Properties: map[string]openAPISchema{
"public_key": {Type: "string", Description: "The requested public key, lowercased in the response."},
"days": {Type: "integer", Description: "Effective lookback window in days."},
"samples": {Type: "array", Description: "Battery samples in timestamp order, oldest first; empty array when none are available.", Items: &openAPISchema{
Type: "object", Required: []string{"timestamp", "battery_mv"},
Properties: map[string]openAPISchema{
"timestamp": {Type: "string", Description: "Stored sample timestamp."},
"battery_mv": {Type: "integer", Description: "Battery voltage in millivolts."},
},
}},
"latest_mv": {Type: "integer", Nullable: true, Description: "Newest sample voltage in millivolts, or null when the window is empty."},
"latest_ts": {Type: "string", Nullable: true, Description: "Newest sample timestamp, or null when the window is empty."},
"status": {Type: "string", Enum: []string{"critical", "low", "ok", "unknown"}, Description: "critical below critical_mv; otherwise low below low_mv; otherwise ok. unknown when the window has no samples."},
"thresholds": {Type: "object", Required: []string{"low_mv", "critical_mv"}, Properties: map[string]openAPISchema{
"low_mv": {Type: "integer", Description: "Configured low-battery threshold in millivolts; default 3300."},
"critical_mv": {Type: "integer", Description: "Configured critical-battery threshold in millivolts; default 3000."},
}},
},
},
"FleetClockSkewResponse": openAPISchema{
Type: "array", Items: &openAPISchema{Type: "object"},
},
"ObserverClockSkewResponse": openAPISchema{
Type: "array", Items: &openAPISchema{Type: "object"},
},
"Node": map[string]interface{}{
"type": "object",
// additionalProperties:true — the node object carries more fields
// than documented here (e.g. foreign, default_scope, hash-size and
// multi-byte enrichment); only the stable + #672 fields are spelled
// out. The #672 usefulness fields are emitted only by a server that
// has shipped issue #672 (PR #1762); on an older server they are
// simply absent.
"additionalProperties": true,
"description": "A mesh node. Repeater and room nodes additionally carry the issue #672 usefulness metrics and relay-activity fields below; those fields are absent on other roles. NOTE: coverage_score, redundancy_score and usefulness_grade ship only with the #672 4-axis scorer (PR #1762) and are absent on every build without it; until that lands usefulness_score is aliased to traffic_share_score. Only traffic_share_score and bridge_score ship today.",
"properties": map[string]interface{}{
"public_key": str("Node public key (hex)."),
"name": str("Node display name (most recent advert name)."),
"role": str("Node role (e.g. repeater, room, client, sensor)."),
"lat": map[string]interface{}{"type": "number", "nullable": true},
"lon": map[string]interface{}{"type": "number", "nullable": true},
"last_seen": str("RFC3339 timestamp of the most recent observation."),
"first_seen": str("RFC3339 timestamp of the first observation."),
"advert_count": map[string]interface{}{"type": "integer"},
"flood_advert_count_7d": map[string]interface{}{"type": "integer", "description": "Distinct FLOOD adverts originated in the last 7 days (zero-hop adverts excluded). Present on the node detail endpoint."},
"battery_mv": map[string]interface{}{"type": "integer", "nullable": true},
"temperature_c": map[string]interface{}{"type": "number", "nullable": true},
"relay_active": map[string]interface{}{"type": "boolean", "description": "Repeater/room only: attributed non-advert relay-hop appearance within the active window; may include shared prefixes. Unchanged legacy field, not certain liveness evidence."},
"relay_count_1h": map[string]interface{}{"type": "integer", "description": "Repeater/room only: relay-hop appearances in the last hour."},
"relay_count_24h": map[string]interface{}{"type": "integer", "description": "Repeater/room only: relay-hop appearances in the last 24 hours."},
"unscoped_relay_count_24h": map[string]interface{}{"type": "integer", "description": "Repeater/room only: subset of relay_count_24h that were unscoped floods (route_type FLOOD). A well-configured repeater sets flood.max.unscoped 0, so a non-trivial count flags a base-config problem."},
"last_relayed": str("Repeater/room only: RFC3339 time this node last appeared as a relay hop."),
"last_certain_relay": str("Repeater/room only: UTC RFC3339 timestamp of the newest valid transmission first_seen accepted as a certain relay. Requires a matching hop in that observation's eligible FLOOD/TRANSPORT_FLOOD frame, a unique prefix at that width and no retired namesake; excludes direct/TRACE paths and failed advert signatures. An originated advert alone does not count; forwarding another node's advert can count even when non-advert relay counts are zero. Uses first_seen, not a late observation's timestamp; uniqueness is checked when evidence is ingested. State is omitted when absent and cleared when the node's packet-store membership is evicted. Does not change relay_active or attributed relay counts."),
"declared_regions": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "Repeater/room only (#1862): named regions from this node's newest declared-regions answer, spelled as GET /api/scope-audit declaredRegions spells them (leading '#' stripped, '*' wildcard excluded). Empty array: it answered and named no region. Absent: it never answered, this database has no declared-regions source, or the declared-regions lookup failed. Absence is not evidence the node lacks a region."},
"region_placement": schemaRef("NodeRegionPlacement"),
"relay_window_hours": map[string]interface{}{"type": "integer", "description": "Repeater/room only, /api/nodes/{pubkey} detail endpoint only: width (hours) of the relay-activity window the relay_count_* values cover."},
"traffic_share_score": score01("#672 Traffic axis: share of non-advert traffic relayed through this repeater. Repeater/room only."),
"bridge_score": score01("#672 Bridge axis: normalized betweenness centrality (chokepoint importance). Repeater/room only."),
"coverage_score": score01("#672 Coverage axis: normalized harmonic reach centrality (how much of the mesh the node can reach). Repeater/room only."),
"redundancy_score": score01("#672 Redundancy axis: normalized articulation-point criticality — 1 means removing the node fragments the mesh, 0 means alternate paths exist. Repeater/room only."),
"usefulness_score": score01("#672 composite usefulness = 0.30·bridge + 0.25·coverage + 0.25·redundancy + 0.20·traffic. Until the 4-axis scorer ships (PR #1762) this is aliased to traffic_share_score. Repeater/room only."),
"usefulness_grade": map[string]interface{}{
"type": "string", "enum": []string{"A", "B", "C", "D", "F"},
"description": "Letter grade derived from usefulness_score. Repeater/room only.",
},
"declared_regions_truncated": map[string]interface{}{"type": "boolean", "description": "Repeater/room only (#1862): present, and true, only when the answer behind declared_regions was flagged as truncated, so that list is partial. GET /api/scope-audit shows the same flag as truncated. Absent otherwise, including for a source that does not record truncation, so absence does not mean the list is complete."},
},
},
"NodeRegionPlacement": openAPISchema{
Type: "object",
Description: "#1879: which rule puts this node in regions for the /api/nodes ?region= filter, and which regions. Present on every node row.",
Required: []string{"basis"},
Properties: map[string]openAPISchema{
"basis": {
Type: "string",
Enum: []string{string(RegionBasisZeroHop), string(RegionBasisFewestHops), string(RegionBasisNone), string(RegionBasisObserverScan)},
Description: "zero_hop: observers heard the node zero-hop (a direct advert, not relayed); it is in their regions, and a node on a boundary can have several. " +
"fewest_hops: never heard zero-hop; it is in the regions that heard it in the fewest hops. " +
"none: never heard zero-hop, and even its closest hearing is more than regionFallback.maxHops (default 3) hops out, so no ?region= filter includes it, even one listing every region; it still shows unfiltered. " +
"observer_scan: not in the last placement recompute (its first advert is newer, the packet store holds none, or no hearing of it recorded a path), so the filter puts it in each region whose observers heard any of its adverts.",
},
"regions": {Type: "array", Items: &openAPISchema{Type: "string"}, Description: "IATA codes, sorted. Present for zero_hop and fewest_hops."},
"hops": {Type: "integer", Description: "Fewest hops any region heard the node over. Present for fewest_hops and none."},
},
},
"NodeListResponse": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"nodes": map[string]interface{}{"type": "array", "items": schemaRef("Node")},
"total": map[string]interface{}{"type": "integer", "description": "Total nodes matching the query after filtering."},
"counts": map[string]interface{}{"type": "object", "additionalProperties": map[string]interface{}{"type": "integer"}, "description": "Per-role node counts."},
"has_more": map[string]interface{}{"type": "boolean", "description": "True when rows exist past this page. Decided before the blacklist / hidden-prefix / geo-filter / area passes, which drop rows from the page and rewrite total — so neither the page length nor total can be used to stop paginating. Paginate until this is false."},
},
},
"NodeDetailResponse": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"node": schemaRef("Node"),
"recentAdverts": map[string]interface{}{"type": "array", "items": schemaRef("NodeAdvert"), "description": "Up to 20 most recent transmissions from this node (newest first)."},
},
},
"NodeAdvert": map[string]interface{}{
"type": "object",
"description": "A recent transmission/advert from a node (the /api/packets transmission shape). Only the commonly-used fields are documented.",
"additionalProperties": true,
"properties": map[string]interface{}{
"id": map[string]interface{}{"type": "integer"},
"hash": str("Transmission content hash."),
"payload_type": map[string]interface{}{"type": "integer", "description": "MeshCore payload type."},
"first_seen": str("RFC3339 time the transmission was first observed."),
"from_pubkey": str("Originating node public key."),
},
},
"CandidateEntry": map[string]interface{}{
"type": "object",
"description": "A candidate pubkey offered when a neighbor edge is ambiguous.",
"properties": map[string]interface{}{
"pubkey": str("Candidate node public key (hex)."), "name": str("Candidate node display name."), "role": str("Candidate node role (e.g. repeater, room)."),
},
},
"NeighborEntry": map[string]interface{}{
"type": "object",
"description": "One neighbor of the queried node, with affinity score and observation metadata.",
"properties": map[string]interface{}{
"pubkey": map[string]interface{}{"type": "string", "nullable": true, "description": "Resolved neighbor public key, or null when only a hop prefix is known."},
"prefix": str("Raw hop hash prefix that established this edge."),
"name": map[string]interface{}{"type": "string", "nullable": true},
"role": map[string]interface{}{"type": "string", "nullable": true},
"count": map[string]interface{}{"type": "integer", "description": "Total observations supporting this neighborship."},
"score": score01("Affinity score: count saturation × recency decay × observer-diversity confidence."),
"counts_by_mode": map[string]interface{}{"type": "object", "additionalProperties": map[string]interface{}{"type": "integer"}, "description": "#1638: observation counts keyed by hash-prefix mode in bytes (1/2/3; 0 = legacy/unknown)."},
"first_seen": str(""),
"last_seen": str(""),
"avg_snr": map[string]interface{}{"type": "number", "nullable": true},
"distance_km": map[string]interface{}{"type": "number", "nullable": true},
"observers": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
"ambiguous": map[string]interface{}{"type": "boolean"},
"unresolved": map[string]interface{}{"type": "boolean"},
"candidates": map[string]interface{}{"type": "array", "items": schemaRef("CandidateEntry")},
},
},
"NodeNeighborsResponse": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"node": str("The queried node's public key."),
"neighbors": map[string]interface{}{"type": "array", "items": schemaRef("NeighborEntry")},
"total_observations": map[string]interface{}{"type": "integer"},
},
},
}
}
// buildOpenAPISpec constructs an OpenAPI 3.0 spec by walking the mux router.
func buildOpenAPISpec(router *mux.Router, version string) map[string]interface{} {
descriptions := routeDescriptions()
// Collect routes from the router
type routeInfo struct {
path string
method string
authReq bool
}
var routes []routeInfo
router.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error {
path, err := route.GetPathTemplate()
if err != nil {
return nil
}
if !strings.HasPrefix(path, "/api/") {
return nil
}
// Skip the spec/docs endpoints themselves
if path == "/api/spec" || path == "/api/docs" {
return nil
}
methods, err := route.GetMethods()
if err != nil {
return nil
}
for _, m := range methods {
routes = append(routes, routeInfo{path: path, method: m})
}
return nil
})
// Sort routes for deterministic output
sort.Slice(routes, func(i, j int) bool {
if routes[i].path != routes[j].path {
return routes[i].path < routes[j].path
}
return routes[i].method < routes[j].method
})
// Build paths object
paths := make(map[string]interface{})
tagSet := make(map[string]bool)
for _, ri := range routes {
key := ri.method + " " + ri.path
meta, hasMeta := descriptions[key]
// Convert mux path params {name} to OpenAPI {name} (same format, convenient)
openAPIPath := ri.path
// Documented routes can declare a concrete 200 response schema;
// everything else falls back to the generic object placeholder.
respSchema := map[string]interface{}{"type": "object"}
if hasMeta && meta.Response != nil {
respSchema = meta.Response
}
// Build operation
op := map[string]interface{}{
"summary": func() string {
if hasMeta {
return meta.Summary
}
return ri.path
}(),
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "Success",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": respSchema,
},
},
},
},
}
if hasMeta {
if meta.Description != "" {
op["description"] = meta.Description
}
if meta.Tag != "" {
op["tags"] = []string{meta.Tag}
tagSet[meta.Tag] = true
}
var security []map[string][]string
if meta.Auth {
security = append(security, map[string][]string{"ApiKeyAuth": {}})
}
if meta.Session {
security = append(security, map[string][]string{"CookieAuth": {}})
}
if len(security) > 0 {
op["security"] = security
}
// Add query parameters
if len(meta.QueryParams) > 0 {
params := make([]interface{}, 0, len(meta.QueryParams))
for _, qp := range meta.QueryParams {
p := map[string]interface{}{
"name": qp.Name,
"in": "query",
"required": qp.Required,
"schema": map[string]interface{}{"type": qp.Type},
}
if qp.Description != "" {
p["description"] = qp.Description
}
params = append(params, p)
}
op["parameters"] = params
}
}
// Extract path parameters from {name} patterns
pathParams := extractPathParams(openAPIPath)
if len(pathParams) > 0 {
existing, _ := op["parameters"].([]interface{})
for _, pp := range pathParams {
existing = append(existing, map[string]interface{}{
"name": pp,
"in": "path",
"required": true,
"schema": map[string]interface{}{"type": "string"},
})
}
op["parameters"] = existing
}
// Add to paths
methodLower := strings.ToLower(ri.method)
if _, ok := paths[openAPIPath]; !ok {
paths[openAPIPath] = make(map[string]interface{})
}
paths[openAPIPath].(map[string]interface{})[methodLower] = op
}
// Build tags array (sorted)
tagOrder := []string{"admin", "analytics", "channels", "config", "nodes", "observers", "packets", "users"}
tagDescriptions := map[string]string{
"admin": "Server administration and diagnostics",
"analytics": "Network analytics and statistics",
"channels": "Mesh channel operations",
"config": "Server configuration",
"nodes": "Mesh node operations",
"observers": "Packet observer/gateway operations",
"packets": "Packet capture and decoding",
"users": "Optional user management (accounts, sessions, admin)",
}
var tags []interface{}
for _, t := range tagOrder {
if tagSet[t] {
tags = append(tags, map[string]interface{}{
"name": t,
"description": tagDescriptions[t],
})
}
}
spec := map[string]interface{}{
"openapi": "3.0.3",
"info": map[string]interface{}{
"title": "CoreScope API",
"description": "MeshCore network analyzer — packet capture, node tracking, and mesh analytics.",
"version": version,
"license": map[string]interface{}{
"name": "MIT",
},
},
"paths": paths,
"tags": tags,
"components": map[string]interface{}{
"securitySchemes": map[string]interface{}{
"ApiKeyAuth": map[string]interface{}{
"type": "apiKey",
"in": "header",
"name": "X-API-Key",
},
"CookieAuth": map[string]string{
"type": "apiKey",
"in": "cookie",
"name": "cs_session",
"description": "User-management session (only when userManagement.enabled). Unsafe methods also need the X-CS-CSRF header from GET /api/auth/me.",
},
},
"schemas": componentSchemas(),
},
}
return spec
}
// extractPathParams returns parameter names from a mux-style path like /api/nodes/{pubkey}.
func extractPathParams(path string) []string {
var params []string
for {
start := strings.Index(path, "{")
if start == -1 {
break
}
end := strings.Index(path[start:], "}")
if end == -1 {
break
}
params = append(params, path[start+1:start+end])
path = path[start+end+1:]
}
return params
}
// handleOpenAPISpec serves the OpenAPI 3.0 spec as JSON.
// The router is injected via RegisterRoutes storing it on the Server.
func (s *Server) handleOpenAPISpec(w http.ResponseWriter, r *http.Request) {
spec := buildOpenAPISpec(s.router, s.version)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Access-Control-Allow-Origin", "*")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
if err := enc.Encode(spec); err != nil {
http.Error(w, fmt.Sprintf("failed to encode spec: %v", err), http.StatusInternalServerError)
}
}
// handleSwaggerUI serves a minimal Swagger UI page.
func (s *Server) handleSwaggerUI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, swaggerUIHTML)
}
const swaggerUIHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>CoreScope API — Swagger UI</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.33.1/swagger-ui.css"
integrity="sha384-Ov4/wv3j2bmct8cDc5X4ngJZohVPzEmc6uDPH8WeljUxO5vtoykvMEfbu9Vh6RaW"
crossorigin="anonymous">
<style>
html { box-sizing: border-box; overflow-y: scroll; }
*, *:before, *:after { box-sizing: inherit; }
body { margin: 0; background: #fafafa; }
.topbar { display: none; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5.33.1/swagger-ui-bundle.js"
integrity="sha384-ZPehFMQommnnuaZ4rpxgkgTT2DKFVp4hZC/7pLit+9Lek9T1YGSo23eHFbvNkXkw"
crossorigin="anonymous"></script>
<script>
SwaggerUIBundle({
url: '/api/spec',
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
layout: 'BaseLayout'
});
</script>
</body>
</html>`