feat(routes): add bounded retained observation evidence

This commit is contained in:
n30nex authored and MrAlders0n committed 2026-09-30 07:19:03 -04:00
1 parent b8a8188fbc
commit c448a04ea0
24 files changed
+1319 -4

No files matched your search

+1 -1
View File
@@ -55,7 +55,7 @@ jobs:
- name: Verify stats and endpoint queries against PostgreSQL 16
env:
BEACON_TEST_POSTGRES_DSN: postgres://postgres:backup-ci-only@127.0.0.1:5432/postgres?sslmode=disable
run: go test ./db -run '^Test(Signal|Paths|PacketEndpointsResolveLive|ObserverMetrics|AnalyticsRetention|AnalyticsRetentionConcurrent|DeleteOldPacketsBatches)Postgres$' -count=1 -v
run: go test ./db -run '^Test(Signal|Paths|PacketEndpointsResolveLive|ObserverMetrics|RouteEvidence|RouteEvidenceIndex|AnalyticsRetention|AnalyticsRetentionConcurrent|DeleteOldPacketsBatches)Postgres$' -count=1 -v
- name: Verify backup command against PostgreSQL 16
run: |
+10
View File
@@ -569,3 +569,13 @@ updated separately; this cannot reconstruct previously overwritten timestamps.
The initial analytics archive migration includes unknown-type activity. Existing
development previews that used the earlier draft require a separate operator repair;
radio samples already discarded for those legacy rows cannot be recovered.
### Saved-route observation evidence
Known-route responses include `pathKey`, a stable identity within the route's IATA. Use `GET /api/v1/routes/{iata}/{pathKey}/observations` to fetch the **full saved route** and retained report references. Search results can contain a subsegment while sharing the full route's key; the evidence response always describes the complete saved sequence.
The match requires the complete saved `pathBytes`, `hashSize`, hop count and IATA. A compact digest index narrows candidates, but full bytes are still compared. Other hash widths, TRACE readings/intended routes and unclassified legacy observations are excluded. Matching short prefixes does not confirm historical node identities, forwarding or delivery. The stored route counter can include repeated processing and outlive raw reports; it is not a retained-result total.
`range` defaults to `24h` and accepts durations up to `720h`, anchored on the server. Alternatively supply both `since` and exclusive `until` in epoch milliseconds, with a maximum 30-day span and no future end. `limit` defaults to 50 and is capped at 200. Follow `nextPageCursor` as `pageCursor`; its route, window and microsecond/ID boundary are pinned. Do not combine it with another range, or change its explicit window. Numeric legacy `cursor` is unsupported. Responses include effective window bounds, `matchAvailable`, an empty `items` array when no matching raw evidence remains, and `hasMore`; no total-count scan or packet/message body is added. Malformed saved path metadata is explicitly unavailable, and missing routes return 404.
Migration 041 builds the compact observation index concurrently. Keep it as a single statement outside a transaction; the existing runner handles an interrupted or already-built index before recording completion. It does not alter retained rows or expiry configuration. Native PostgreSQL tests cover ties below millisecond precision, cursor scope, different widths/sites, TRACE/unknown exclusions, raw expiry, index retry and custom/generic indexed plans.
@@ -0,0 +1,9 @@
-- Copyright 2026 Beacon Contributors
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- One statement outside a transaction. RunMigrations recovers interrupted builds;
-- avoid IF NOT EXISTS, which would silently accept an invalid index.
-- A compact digest narrows candidates; queries still compare the complete bytes.
CREATE INDEX CONCURRENTLY idx_observations_route_evidence
ON packet_observations (iata, hash_size, (decode(md5(path_bytes), 'hex')), heard_at DESC, id DESC)
WHERE path_bytes IS NOT NULL AND payload_type IS NOT NULL AND payload_type <> 9;
+22
View File
@@ -0,0 +1,22 @@
-- name: GetRouteEvidenceRoute :one
SELECT * FROM known_routes
WHERE iata = @iata::bpchar AND path_key = @path_key::bytea;
-- name: ListRouteEvidence :many
-- Leading index equalities and the time/ID boundary bound both custom and generic plans.
-- Full-byte equality is required even when the compact digest matches. TRACE path bytes
-- carry readings; unclassified legacy observations cannot be safely called ordinary paths.
SELECT po.id, po.packet_hash, po.observer_id, o.display_name AS observer_name,
po.heard_at, po.payload_type, po.rssi, po.snr
FROM packet_observations po
JOIN observers o ON o.id = po.observer_id
WHERE po.iata = @iata::bpchar
AND po.hash_size = @hash_size::smallint
AND decode(md5(po.path_bytes), 'hex') = @path_digest::bytea
AND po.path_bytes = @path_bytes::bytea
AND po.hop_count = @hop_count::smallint
AND po.path_bytes IS NOT NULL AND po.payload_type IS NOT NULL AND po.payload_type <> 9
AND po.heard_at >= @since::timestamptz AND po.heard_at < @until::timestamptz
AND (po.heard_at, po.id) < (@before_at::timestamptz, @before_id::bigint)
ORDER BY po.heard_at DESC, po.id DESC
LIMIT @page_limit::integer;
+93
View File
@@ -0,0 +1,93 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package db
import (
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"math"
"time"
sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/jackc/pgx/v5/pgtype"
)
func savedRoutePath(prefixes [][]byte, count int32) (int16, []byte, bool) {
if count < 2 || count > 63 || len(prefixes) != int(count) {
return 0, nil, false
}
width := len(prefixes[0])
if width < 1 || width > 3 {
return 0, nil, false
}
for _, p := range prefixes {
if len(p) != width {
return 0, nil, false
}
}
return int16(width), bytes.Join(prefixes, nil), true
}
func (s *Store) GetRouteEvidence(ctx context.Context, iata, key string, query api.RouteEvidenceQuery) (*api.RouteEvidence, error) {
if !api.ValidRouteEvidenceKey(iata, key) || !api.ValidRouteEvidenceWindow(query.Since, query.Until) || query.Limit < 1 || query.Limit > 200 {
return nil, api.ErrRouteEvidenceInput
}
if c := query.Cursor; c != nil {
if c.IATA != iata || c.PathKey != key || !c.Since.Equal(query.Since) || !c.Until.Equal(query.Until) || c.ID <= 0 || c.HeardAt.Before(query.Since) || !c.HeardAt.Before(query.Until) {
return nil, api.ErrRouteEvidenceInput
}
}
keyBytes, _ := hex.DecodeString(key)
row, err := s.q.GetRouteEvidenceRoute(ctx, sqlc.GetRouteEvidenceRouteParams{Iata: iata, PathKey: keyBytes})
if err != nil {
return nil, err
}
nodes, err := s.GetNodesByIDs(ctx, row.NodeIds)
if err != nil {
return nil, err
}
route := toKnownRoutes([]knownRouteRow{{ID: row.ID, NodeIds: row.NodeIds, HashPrefix: row.HashPrefix, Iata: row.Iata, HopCount: row.HopCount, FirstSeen: row.FirstSeen, LastSeen: row.LastSeen, ObservationCount: row.ObservationCount}}, nodes)[0]
route.PathKey = hex.EncodeToString(row.PathKey)
out := &api.RouteEvidence{Page: api.Page[api.RouteObservation]{Items: []api.RouteObservation{}}, Route: route, WindowStart: query.Since.UnixMilli(), WindowEnd: query.Until.UnixMilli(), GeneratedAt: time.Now().UnixMilli(), MatchType: "saved_path_prefixes"}
width, path, valid := savedRoutePath(row.HashPrefix, row.HopCount)
if !valid {
return out, nil
}
out.MatchAvailable = true
out.HashSize = width
out.PathBytes = hex.EncodeToString(path)
beforeAt, beforeID := query.Until, int64(math.MaxInt64)
if query.Cursor != nil {
beforeAt, beforeID = query.Cursor.HeardAt, query.Cursor.ID
}
ts := func(t time.Time) pgtype.Timestamptz { return pgtype.Timestamptz{Time: t, Valid: true} }
digest := md5.Sum(path)
rows, err := s.q.ListRouteEvidence(ctx, sqlc.ListRouteEvidenceParams{Iata: iata, HashSize: width, HopCount: int16(row.HopCount), PathDigest: digest[:], PathBytes: path, Since: ts(query.Since), Until: ts(query.Until), BeforeAt: ts(beforeAt), BeforeID: beforeID, PageLimit: query.Limit + 1})
if err != nil {
return nil, err
}
out.HasMore = len(rows) > int(query.Limit)
if out.HasMore {
rows = rows[:query.Limit]
}
for _, r := range rows {
payload := int16(-1)
if r.PayloadType != nil {
payload = *r.PayloadType
}
snr := r.Snr
if snr != nil && (math.IsNaN(float64(*snr)) || math.IsInf(float64(*snr), 0)) {
snr = nil
}
out.Items = append(out.Items, api.RouteObservation{ID: r.ID, PacketHash: hex.EncodeToString(r.PacketHash), ObserverID: r.ObserverID, ObserverName: r.ObserverName, HeardAt: r.HeardAt.Time.UnixMilli(), PayloadType: payload, PayloadTypeName: api.PayloadTypeName(payload), RSSI: r.Rssi, SNR: snr})
}
if out.HasMore {
last := rows[len(rows)-1]
cursor := (api.RouteEvidenceCursor{IATA: iata, PathKey: key, Since: query.Since, Until: query.Until, HeardAt: last.HeardAt.Time, ID: last.ID}).String()
out.NextPageCursor = &cursor
}
return out, nil
}
+205
View File
@@ -0,0 +1,205 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package db
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"testing"
"time"
sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type evidenceCapture struct {
pgx.Tx
statement string
args []any
}
func (c *evidenceCapture) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
if strings.Contains(sql, "-- name: ListRouteEvidence") {
c.statement = sql
c.args = args
}
return c.Tx.Query(ctx, sql, args...)
}
func TestRouteEvidencePostgres(t *testing.T) {
ctx, tx := retentionTx(t)
analyticsTables(t, ctx, tx)
ddl, err := migrationFiles.ReadFile("migrations/024_known_routes_pathkey.sql")
if err != nil {
t.Fatal(err)
}
text := string(ddl)
start := strings.Index(text, "CREATE TABLE known_routes_new (")
end := start + strings.Index(text[start:], "\n);") + 3
table := strings.ReplaceAll(text[start:end], "known_routes_new", "known_routes")
_, err = tx.Exec(ctx, `CREATE TABLE iata_codes (iata char(3) PRIMARY KEY); INSERT INTO iata_codes VALUES ('YOW'),('YVR');
ALTER TABLE nodes ADD COLUMN latitude double precision, ADD COLUMN longitude double precision;`+table+`
INSERT INTO observers(id,display_name) VALUES ('00000000-0000-0000-0000-000000000001','One'),('00000000-0000-0000-0000-000000000002','Two');
INSERT INTO nodes(id,public_key,name) VALUES ('00000000-0000-0000-0000-000000000001','\xaa','A'),('00000000-0000-0000-0000-000000000002','\xbb','B');
INSERT INTO packets(packet_hash) SELECT int4send(i) FROM generate_series(1,10) i;`)
if err != nil {
t.Fatal(err)
}
index, err := migrationFiles.ReadFile("migrations/041_route_evidence_index.sql")
if err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(ctx, strings.Replace(string(index), "CREATE INDEX CONCURRENTLY", "CREATE INDEX", 1)); err != nil {
t.Fatal(err)
}
capture := &evidenceCapture{Tx: tx}
store := &Store{q: sqlc.New(capture)}
ids := []uuid.UUID{uuid.MustParse("00000000-0000-0000-0000-000000000001"), uuid.MustParse("00000000-0000-0000-0000-000000000002")}
if err := store.UpsertKnownRoute(ctx, ids, [][]byte{{0xaa}, {0xbb}}, "YOW", 2); err != nil {
t.Fatal(err)
}
key := hex.EncodeToString(routePathKey(ids))
since := time.Now().UTC().Truncate(time.Millisecond).Add(-time.Hour)
until := since.Add(time.Hour)
at := since.Add(time.Minute + 123456*time.Microsecond)
_, err = tx.Exec(ctx, `INSERT INTO packet_observations(id,packet_hash,observer_id,iata,heard_at,hash_size,hop_count,path_bytes,payload_type,snr)
SELECT i,int4send(i), '00000000-0000-0000-0000-000000000001',CASE WHEN i=5 THEN 'YVR' ELSE 'YOW' END,
CASE WHEN i=9 THEN $2::timestamptz WHEN i=1 THEN $1::timestamptz-interval '2 microseconds' ELSE $1::timestamptz END,
CASE WHEN i=4 THEN 2 ELSE 1 END,CASE WHEN i=4 THEN 1 WHEN i=10 THEN 3 ELSE 2 END,
CASE WHEN i=6 THEN '\xabbb'::bytea WHEN i=10 THEN '\xaabbcc'::bytea ELSE '\xaabb'::bytea END,
CASE WHEN i=7 THEN 9 WHEN i=8 THEN NULL ELSE 4 END,CASE WHEN i=1 THEN 'NaN'::real ELSE 0 END FROM generate_series(1,10) i;
`, at, until)
if err != nil {
t.Fatal(err)
}
if _, err = tx.Exec(ctx, `INSERT INTO packet_observations(id,packet_hash,observer_id,iata,heard_at,hash_size,hop_count,path_bytes,payload_type) VALUES (11,int4send(1),'00000000-0000-0000-0000-000000000002','YOW',$1,1,2,'\xaabb',4)`, at.Add(-time.Microsecond)); err != nil {
t.Fatal(err)
}
query := api.RouteEvidenceQuery{Since: since, Until: until, Limit: 2}
first, err := store.GetRouteEvidence(ctx, "YOW", key, query)
if err != nil {
t.Fatal(err)
}
if !first.HasMore || first.NextPageCursor == nil || len(first.Items) != 2 || first.Items[0].ID != 3 || first.Items[1].ID != 2 || first.Route.PathKey != key || first.HashSize != 1 || first.PathBytes != "aabb" {
t.Fatalf("first page: %+v", first)
}
query.Cursor, err = api.ParseRouteEvidenceCursor(*first.NextPageCursor)
if err != nil {
t.Fatal(err)
}
second, err := store.GetRouteEvidence(ctx, "YOW", key, query)
if err != nil {
t.Fatal(err)
}
if second.HasMore || len(second.Items) != 2 || second.Items[0].ID != 11 || second.Items[1].ID != 1 || second.Items[1].SNR != nil {
t.Fatalf("precise second page: %+v", second)
}
if _, err = json.Marshal(second); err != nil {
t.Fatalf("non-finite JSON: %v", err)
}
query.Cursor.IATA = "YVR"
if _, err = store.GetRouteEvidence(ctx, "YOW", key, query); !errors.Is(err, api.ErrRouteEvidenceInput) {
t.Fatal("cross-route cursor accepted")
}
query.Cursor = nil
if _, err = store.GetRouteEvidence(ctx, "YVR", key, query); !errors.Is(err, pgx.ErrNoRows) {
t.Fatal("unknown route did not return not-found")
}
if _, err = tx.Exec(ctx, "DELETE FROM packets"); err != nil {
t.Fatal(err)
}
empty, err := store.GetRouteEvidence(ctx, "YOW", key, query)
if err != nil || !empty.MatchAvailable || len(empty.Items) != 0 || empty.HasMore {
t.Fatalf("expired evidence: %+v %v", empty, err)
}
// Many unrelated paths must not turn this into a raw-table or route-ID scan.
started := time.Now()
_, err = tx.Exec(ctx, `INSERT INTO packets(packet_hash) SELECT int4send(i) FROM generate_series(100,200099) i;`)
if err != nil {
t.Fatal(err)
}
_, err = tx.Exec(ctx, `INSERT INTO packet_observations(packet_hash,observer_id,iata,heard_at,hash_size,hop_count,path_bytes,payload_type)
SELECT packet_hash,'00000000-0000-0000-0000-000000000001','YOW',$1,1,2,
CASE WHEN get_byte(packet_hash,3)=0 THEN '\xaabb'::bytea ELSE packet_hash END,4 FROM packets;`, at)
if err != nil {
t.Fatal(err)
}
if _, err = tx.Exec(ctx, "ANALYZE packet_observations; ANALYZE observers;"); err != nil {
t.Fatal(err)
}
for _, mode := range []string{"force_custom_plan", "force_generic_plan"} {
if _, err = tx.Exec(ctx, "SET LOCAL plan_cache_mode = "+mode); err != nil {
t.Fatal(err)
}
result, err := store.GetRouteEvidence(ctx, "YOW", key, query)
if err != nil || len(result.Items) != 2 {
t.Fatalf("indexed read: %v", err)
}
var plan string
if err = tx.QueryRow(ctx, "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) "+capture.statement, capture.args...).Scan(&plan); err != nil {
t.Fatal(err)
}
if !strings.Contains(plan, "idx_observations_route_evidence") {
t.Fatalf("missing evidence index: %s", plan)
}
t.Logf("%s plan: %s", mode, plan)
}
t.Logf("200000-row fixture insert/analyze/read: %s", time.Since(started))
if _, err = tx.Exec(ctx, `UPDATE known_routes SET hash_prefix=ARRAY['\xaa'::bytea]`); err != nil {
t.Fatal(err)
}
unavailable, err := store.GetRouteEvidence(ctx, "YOW", key, query)
if err != nil || unavailable.MatchAvailable || len(unavailable.Items) != 0 {
t.Fatalf("malformed stored path: %+v %v", unavailable, err)
}
}
func TestRouteEvidenceIndexPostgres(t *testing.T) {
dsn := os.Getenv("BEACON_TEST_POSTGRES_DSN")
if dsn == "" {
t.Skip("set BEACON_TEST_POSTGRES_DSN")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
conn, err := pgx.Connect(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer conn.Close(context.Background())
schema := fmt.Sprintf("route_evidence_%d", time.Now().UnixNano())
ident := pgx.Identifier{schema}.Sanitize()
if _, err = conn.Exec(ctx, "CREATE SCHEMA "+ident+"; SET search_path TO "+ident); err != nil {
t.Fatal(err)
}
defer func() { _, _ = conn.Exec(context.Background(), "DROP SCHEMA "+ident+" CASCADE") }()
if _, err = conn.Exec(ctx, `CREATE TABLE packet_observations (id bigint,iata char(3),hash_size smallint,path_bytes bytea,heard_at timestamptz,payload_type smallint)`); err != nil {
t.Fatal(err)
}
migration, err := migrationFiles.ReadFile("migrations/041_route_evidence_index.sql")
if err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
if err = applyMigration(ctx, conn, string(migration)); err != nil {
t.Fatalf("migration/retry: %v", err)
}
}
if _, err = conn.Exec(ctx, `UPDATE pg_index SET indisvalid=false WHERE indexrelid='idx_observations_route_evidence'::regclass`); err != nil {
t.Fatal(err)
}
if err = applyMigration(ctx, conn, string(migration)); err != nil {
t.Fatal(err)
}
var valid bool
if err = conn.QueryRow(ctx, `SELECT indisvalid FROM pg_index WHERE indexrelid='idx_observations_route_evidence'::regclass`).Scan(&valid); err != nil || !valid {
t.Fatalf("invalid index after recovery: %v", err)
}
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package db
import (
"bytes"
"testing"
)
func TestSavedRoutePath(t *testing.T) {
size, path, ok := savedRoutePath([][]byte{{0xaa}, {0xbb}}, 2)
if !ok || size != 1 || !bytes.Equal(path, []byte{0xaa, 0xbb}) {
t.Fatal("valid path lost")
}
for _, prefixes := range [][][]byte{nil, {{1}}, {{1}, {2, 3}}, {{}, {2}}, {{1, 2, 3, 4}, {5, 6, 7, 8}}} {
if _, _, ok := savedRoutePath(prefixes, int32(len(prefixes))); ok {
t.Fatalf("accepted malformed path %x", prefixes)
}
}
if _, _, ok := savedRoutePath([][]byte{{1}, {2}}, 3); ok {
t.Fatal("accepted inconsistent hop count")
}
}
+4 -2
View File
@@ -118,6 +118,7 @@ func (s *Store) SearchKnownRoutes(ctx context.Context, iata, fromHash, toHash st
hops = append(hops, hop)
}
items = append(items, api.KnownRoute{
PathKey: hex.EncodeToString(routePathKey(r.NodeIds)),
ID: r.ID,
IATA: r.Iata,
HopCount: int32(len(hops)),
@@ -223,7 +224,7 @@ func (s *Store) SearchCrossIATARoutes(ctx context.Context, fromHash, fromIATA, t
return nil, nil
}
// 5. find cross-IATA links — nodes at the boundary of source routes
// 5. find cross-IATA links — nodes at the boundary of source routes
// that have neighbors in the target IATA at the start of target routes
var results []api.CrossIATARoute
@@ -249,7 +250,7 @@ func (s *Store) SearchCrossIATARoutes(ctx context.Context, fromHash, fromIATA, t
continue
}
if targetHops, ok := targetNodeSet[neighbor.ID]; ok {
// found a cross-IATA link — build the route
// found a cross-IATA link — build the route
sourceSegment := sr.Hops[:i+1]
targetSegment := extractFromNode(targetHops, neighbor.ID)
@@ -348,6 +349,7 @@ func toKnownRoutes(rows []knownRouteRow, nodes map[uuid.UUID]*api.ResolvedNode)
hops = append(hops, hop)
}
items = append(items, api.KnownRoute{
PathKey: hex.EncodeToString(routePathKey(r.NodeIds)),
ID: r.ID,
IATA: r.Iata,
HopCount: r.HopCount,
+30
View File
@@ -684,6 +684,21 @@ func (mr *MockQuerierMockRecorder) GetRegionIATAs(ctx, regionID any) *gomock.Cal
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRegionIATAs", reflect.TypeOf((*MockQuerier)(nil).GetRegionIATAs), ctx, regionID)
}
// GetRouteEvidenceRoute mocks base method.
func (m *MockQuerier) GetRouteEvidenceRoute(ctx context.Context, arg db.GetRouteEvidenceRouteParams) (db.KnownRoute, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetRouteEvidenceRoute", ctx, arg)
ret0, _ := ret[0].(db.KnownRoute)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetRouteEvidenceRoute indicates an expected call of GetRouteEvidenceRoute.
func (mr *MockQuerierMockRecorder) GetRouteEvidenceRoute(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRouteEvidenceRoute", reflect.TypeOf((*MockQuerier)(nil).GetRouteEvidenceRoute), ctx, arg)
}
// GetScopeByName mocks base method.
func (m *MockQuerier) GetScopeByName(ctx context.Context, name string) (db.GetScopeByNameRow, error) {
m.ctrl.T.Helper()
@@ -1223,6 +1238,21 @@ func (mr *MockQuerierMockRecorder) ListRegions(ctx any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListRegions", reflect.TypeOf((*MockQuerier)(nil).ListRegions), ctx)
}
// ListRouteEvidence mocks base method.
func (m *MockQuerier) ListRouteEvidence(ctx context.Context, arg db.ListRouteEvidenceParams) ([]db.ListRouteEvidenceRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListRouteEvidence", ctx, arg)
ret0, _ := ret[0].([]db.ListRouteEvidenceRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListRouteEvidence indicates an expected call of ListRouteEvidence.
func (mr *MockQuerierMockRecorder) ListRouteEvidence(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListRouteEvidence", reflect.TypeOf((*MockQuerier)(nil).ListRouteEvidence), ctx, arg)
}
// ListTraceTags mocks base method.
func (m *MockQuerier) ListTraceTags(ctx context.Context, arg db.ListTraceTagsParams) ([]db.ListTraceTagsRow, error) {
m.ctrl.T.Helper()
+5
View File
@@ -87,6 +87,7 @@ type Querier interface {
GetRegion(ctx context.Context, id int32) (GetRegionRow, error)
GetRegionBySlug(ctx context.Context, slug string) (GetRegionBySlugRow, error)
GetRegionIATAs(ctx context.Context, regionID int32) ([]string, error)
GetRouteEvidenceRoute(ctx context.Context, arg GetRouteEvidenceRouteParams) (KnownRoute, error)
GetScopeByName(ctx context.Context, name string) (GetScopeByNameRow, error)
GetScopeNames(ctx context.Context) ([]string, error)
// Aggregate matching observations once, separately from node memberships to avoid
@@ -198,6 +199,10 @@ type Querier interface {
// REGIONS
// ============================================================
ListRegions(ctx context.Context) ([]ListRegionsRow, error)
// Leading index equalities and the time/ID boundary bound both custom and generic plans.
// Full-byte equality is required even when the compact digest matches. TRACE path bytes
// carry readings; unclassified legacy observations cannot be safely called ordinary paths.
ListRouteEvidence(ctx context.Context, arg ListRouteEvidenceParams) ([]ListRouteEvidenceRow, error)
// ============================================================
// TRACES
// ============================================================
+125
View File
@@ -0,0 +1,125 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: route_evidence.sql
package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const getRouteEvidenceRoute = `-- name: GetRouteEvidenceRoute :one
SELECT id, path_key, node_ids, hash_prefix, iata, hop_count, first_seen, last_seen, observation_count, last_reconfirmed_at FROM known_routes
WHERE iata = $1::bpchar AND path_key = $2::bytea
`
type GetRouteEvidenceRouteParams struct {
Iata string `json:"iata"`
PathKey []byte `json:"path_key"`
}
func (q *Queries) GetRouteEvidenceRoute(ctx context.Context, arg GetRouteEvidenceRouteParams) (KnownRoute, error) {
row := q.db.QueryRow(ctx, getRouteEvidenceRoute, arg.Iata, arg.PathKey)
var i KnownRoute
err := row.Scan(
&i.ID,
&i.PathKey,
&i.NodeIds,
&i.HashPrefix,
&i.Iata,
&i.HopCount,
&i.FirstSeen,
&i.LastSeen,
&i.ObservationCount,
&i.LastReconfirmedAt,
)
return i, err
}
const listRouteEvidence = `-- name: ListRouteEvidence :many
SELECT po.id, po.packet_hash, po.observer_id, o.display_name AS observer_name,
po.heard_at, po.payload_type, po.rssi, po.snr
FROM packet_observations po
JOIN observers o ON o.id = po.observer_id
WHERE po.iata = $1::bpchar
AND po.hash_size = $2::smallint
AND decode(md5(po.path_bytes), 'hex') = $3::bytea
AND po.path_bytes = $4::bytea
AND po.hop_count = $5::smallint
AND po.path_bytes IS NOT NULL AND po.payload_type IS NOT NULL AND po.payload_type <> 9
AND po.heard_at >= $6::timestamptz AND po.heard_at < $7::timestamptz
AND (po.heard_at, po.id) < ($8::timestamptz, $9::bigint)
ORDER BY po.heard_at DESC, po.id DESC
LIMIT $10::integer
`
type ListRouteEvidenceParams struct {
Iata string `json:"iata"`
HashSize int16 `json:"hash_size"`
PathDigest []byte `json:"path_digest"`
PathBytes []byte `json:"path_bytes"`
HopCount int16 `json:"hop_count"`
Since pgtype.Timestamptz `json:"since"`
Until pgtype.Timestamptz `json:"until"`
BeforeAt pgtype.Timestamptz `json:"before_at"`
BeforeID int64 `json:"before_id"`
PageLimit int32 `json:"page_limit"`
}
type ListRouteEvidenceRow struct {
ID int64 `json:"id"`
PacketHash []byte `json:"packet_hash"`
ObserverID uuid.UUID `json:"observer_id"`
ObserverName *string `json:"observer_name"`
HeardAt pgtype.Timestamptz `json:"heard_at"`
PayloadType *int16 `json:"payload_type"`
Rssi *int16 `json:"rssi"`
Snr *float32 `json:"snr"`
}
// Leading index equalities and the time/ID boundary bound both custom and generic plans.
// Full-byte equality is required even when the compact digest matches. TRACE path bytes
// carry readings; unclassified legacy observations cannot be safely called ordinary paths.
func (q *Queries) ListRouteEvidence(ctx context.Context, arg ListRouteEvidenceParams) ([]ListRouteEvidenceRow, error) {
rows, err := q.db.Query(ctx, listRouteEvidence,
arg.Iata,
arg.HashSize,
arg.PathDigest,
arg.PathBytes,
arg.HopCount,
arg.Since,
arg.Until,
arg.BeforeAt,
arg.BeforeID,
arg.PageLimit,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListRouteEvidenceRow{}
for rows.Next() {
var i ListRouteEvidenceRow
if err := rows.Scan(
&i.ID,
&i.PacketHash,
&i.ObserverID,
&i.ObserverName,
&i.HeardAt,
&i.PayloadType,
&i.Rssi,
&i.Snr,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+166
View File
@@ -2131,6 +2131,92 @@ const docTemplate = `{
}
}
},
"/routes/{iata}/{pathKey}/observations": {
"get": {
"description": "Matches the saved IATA, complete path bytes and hash width. Excludes TRACE and unclassified reports; other widths and search-result subsegments are not included. Byte matches do not prove hop identities or delivery. The route and its historical counter can outlive raw evidence. Cursors retain full timestamp precision and pin route/window scope.",
"produces": [
"application/json"
],
"tags": [
"Routes"
],
"summary": "Get retained reports matching a full saved route prefix sequence",
"parameters": [
{
"type": "string",
"description": "Three-character IATA code",
"name": "iata",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Stable 32-hex pathKey from a known-route response",
"name": "pathKey",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Server-anchored duration (default 24h, max 720h); exclusive with since/until/pageCursor",
"name": "range",
"in": "query"
},
{
"type": "integer",
"description": "Window start epoch ms; provide with until (default last 24h)",
"name": "since",
"in": "query"
},
{
"type": "integer",
"description": "Exclusive window end epoch ms; maximum span 30d, no future end",
"name": "until",
"in": "query"
},
{
"type": "string",
"description": "Opaque precise cursor from nextPageCursor; window is pinned",
"name": "pageCursor",
"in": "query"
},
{
"maximum": 200,
"minimum": 1,
"type": "integer",
"description": "Default 50; positive, capped at 200",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.RouteEvidence"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
}
}
},
"/scopes": {
"get": {
"produces": [
@@ -3601,6 +3687,10 @@ const docTemplate = `{
},
"observationCount": {
"type": "integer"
},
"pathKey": {
"description": "stable identity within this IATA; use for route evidence links",
"type": "string"
}
}
},
@@ -4845,6 +4935,50 @@ const docTemplate = `{
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.RouteEvidence": {
"type": "object",
"properties": {
"generatedAt": {
"type": "integer"
},
"hasMore": {
"type": "boolean"
},
"hashSize": {
"type": "integer"
},
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.RouteObservation"
}
},
"matchAvailable": {
"type": "boolean"
},
"matchType": {
"type": "string"
},
"nextCursor": {
"type": "integer"
},
"nextPageCursor": {
"type": "string"
},
"pathBytes": {
"type": "string"
},
"route": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute"
},
"windowEnd": {
"type": "integer"
},
"windowStart": {
"type": "integer"
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.RouteHop": {
"type": "object",
"properties": {
@@ -4865,6 +4999,38 @@ const docTemplate = `{
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.RouteObservation": {
"type": "object",
"properties": {
"heardAt": {
"type": "integer"
},
"id": {
"type": "integer"
},
"observerId": {
"type": "string"
},
"observerName": {
"type": "string"
},
"packetHash": {
"type": "string"
},
"payloadType": {
"type": "integer"
},
"payloadTypeName": {
"type": "string"
},
"rssi": {
"type": "integer"
},
"snr": {
"type": "number"
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.ScopeDetail": {
"type": "object",
"properties": {
+166
View File
@@ -2129,6 +2129,92 @@
}
}
},
"/routes/{iata}/{pathKey}/observations": {
"get": {
"description": "Matches the saved IATA, complete path bytes and hash width. Excludes TRACE and unclassified reports; other widths and search-result subsegments are not included. Byte matches do not prove hop identities or delivery. The route and its historical counter can outlive raw evidence. Cursors retain full timestamp precision and pin route/window scope.",
"produces": [
"application/json"
],
"tags": [
"Routes"
],
"summary": "Get retained reports matching a full saved route prefix sequence",
"parameters": [
{
"type": "string",
"description": "Three-character IATA code",
"name": "iata",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Stable 32-hex pathKey from a known-route response",
"name": "pathKey",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Server-anchored duration (default 24h, max 720h); exclusive with since/until/pageCursor",
"name": "range",
"in": "query"
},
{
"type": "integer",
"description": "Window start epoch ms; provide with until (default last 24h)",
"name": "since",
"in": "query"
},
{
"type": "integer",
"description": "Exclusive window end epoch ms; maximum span 30d, no future end",
"name": "until",
"in": "query"
},
{
"type": "string",
"description": "Opaque precise cursor from nextPageCursor; window is pinned",
"name": "pageCursor",
"in": "query"
},
{
"maximum": 200,
"minimum": 1,
"type": "integer",
"description": "Default 50; positive, capped at 200",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.RouteEvidence"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
}
}
},
"/scopes": {
"get": {
"produces": [
@@ -3599,6 +3685,10 @@
},
"observationCount": {
"type": "integer"
},
"pathKey": {
"description": "stable identity within this IATA; use for route evidence links",
"type": "string"
}
}
},
@@ -4843,6 +4933,50 @@
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.RouteEvidence": {
"type": "object",
"properties": {
"generatedAt": {
"type": "integer"
},
"hasMore": {
"type": "boolean"
},
"hashSize": {
"type": "integer"
},
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.RouteObservation"
}
},
"matchAvailable": {
"type": "boolean"
},
"matchType": {
"type": "string"
},
"nextCursor": {
"type": "integer"
},
"nextPageCursor": {
"type": "string"
},
"pathBytes": {
"type": "string"
},
"route": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute"
},
"windowEnd": {
"type": "integer"
},
"windowStart": {
"type": "integer"
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.RouteHop": {
"type": "object",
"properties": {
@@ -4863,6 +4997,38 @@
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.RouteObservation": {
"type": "object",
"properties": {
"heardAt": {
"type": "integer"
},
"id": {
"type": "integer"
},
"observerId": {
"type": "string"
},
"observerName": {
"type": "string"
},
"packetHash": {
"type": "string"
},
"payloadType": {
"type": "integer"
},
"payloadTypeName": {
"type": "string"
},
"rssi": {
"type": "integer"
},
"snr": {
"type": "number"
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.ScopeDetail": {
"type": "object",
"properties": {
+116
View File
@@ -272,6 +272,9 @@ definitions:
type: integer
observationCount:
type: integer
pathKey:
description: stable identity within this IATA; use for route evidence links
type: string
type: object
github_com_MeshCore-Beacon_beacon-server_internal_api.Node:
properties:
@@ -1153,6 +1156,35 @@ definitions:
description: hex-encoded prefix used for resolution
type: string
type: object
github_com_MeshCore-Beacon_beacon-server_internal_api.RouteEvidence:
properties:
generatedAt:
type: integer
hasMore:
type: boolean
hashSize:
type: integer
items:
items:
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.RouteObservation'
type: array
matchAvailable:
type: boolean
matchType:
type: string
nextCursor:
type: integer
nextPageCursor:
type: string
pathBytes:
type: string
route:
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute'
windowEnd:
type: integer
windowStart:
type: integer
type: object
github_com_MeshCore-Beacon_beacon-server_internal_api.RouteHop:
properties:
hashBytes:
@@ -1165,6 +1197,27 @@ definitions:
nodeId:
type: string
type: object
github_com_MeshCore-Beacon_beacon-server_internal_api.RouteObservation:
properties:
heardAt:
type: integer
id:
type: integer
observerId:
type: string
observerName:
type: string
packetHash:
type: string
payloadType:
type: integer
payloadTypeName:
type: string
rssi:
type: integer
snr:
type: number
type: object
github_com_MeshCore-Beacon_beacon-server_internal_api.ScopeDetail:
properties:
iataCount:
@@ -2803,6 +2856,69 @@ paths:
summary: List known routes
tags:
- Routes
/routes/{iata}/{pathKey}/observations:
get:
description: Matches the saved IATA, complete path bytes and hash width. Excludes
TRACE and unclassified reports; other widths and search-result subsegments
are not included. Byte matches do not prove hop identities or delivery. The
route and its historical counter can outlive raw evidence. Cursors retain
full timestamp precision and pin route/window scope.
parameters:
- description: Three-character IATA code
in: path
name: iata
required: true
type: string
- description: Stable 32-hex pathKey from a known-route response
in: path
name: pathKey
required: true
type: string
- description: Server-anchored duration (default 24h, max 720h); exclusive with
since/until/pageCursor
in: query
name: range
type: string
- description: Window start epoch ms; provide with until (default last 24h)
in: query
name: since
type: integer
- description: Exclusive window end epoch ms; maximum span 30d, no future end
in: query
name: until
type: integer
- description: Opaque precise cursor from nextPageCursor; window is pinned
in: query
name: pageCursor
type: string
- description: Default 50; positive, capped at 200
in: query
maximum: 200
minimum: 1
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.RouteEvidence'
"400":
description: Bad Request
schema:
$ref: '#/definitions/internal_api_handlers.APIError'
"404":
description: Not Found
schema:
$ref: '#/definitions/internal_api_handlers.APIError'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/internal_api_handlers.APIError'
summary: Get retained reports matching a full saved route prefix sequence
tags:
- Routes
/routes/cross:
get:
parameters:
+118
View File
@@ -0,0 +1,118 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package handlers
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
)
func routeEvidenceQuery(r *http.Request, iata, key string, now time.Time) (api.RouteEvidenceQuery, error) {
q := api.RouteEvidenceQuery{}
values := r.URL.Query()
for _, name := range []string{"pageCursor", "since", "until", "range", "limit"} {
if len(values[name]) > 1 {
return q, api.ErrRouteEvidenceInput
}
}
if values.Has("cursor") {
return q, api.ErrRouteEvidenceInput
}
limit, err := parseLimit(r, 50)
if err != nil {
return q, err
}
q.Limit = limit
if values.Has("pageCursor") {
c, err := api.ParseRouteEvidenceCursor(values.Get("pageCursor"))
if err != nil || c.IATA != iata || c.PathKey != key {
return q, api.ErrRouteEvidenceInput
}
q.Cursor = c
q.Since = c.Since
q.Until = c.Until
} else {
q.Until = now.UTC().Truncate(time.Millisecond)
q.Since = q.Until.Add(-24 * time.Hour)
}
if values.Has("range") {
if values.Has("pageCursor") || values.Has("since") || values.Has("until") {
return q, api.ErrRouteEvidenceInput
}
duration, err := time.ParseDuration(values.Get("range"))
if err != nil || duration < time.Millisecond || duration > api.MaxRouteEvidenceWindow || duration%time.Millisecond != 0 {
return q, api.ErrRouteEvidenceInput
}
q.Since = q.Until.Add(-duration)
}
if values.Has("since") || values.Has("until") {
if !values.Has("since") || !values.Has("until") {
return q, api.ErrRouteEvidenceInput
}
since, e1 := strconv.ParseInt(values.Get("since"), 10, 64)
until, e2 := strconv.ParseInt(values.Get("until"), 10, 64)
if e1 != nil || e2 != nil || since < 0 || until < 0 || since > 253402300799999 || until > 253402300799999 {
return q, api.ErrRouteEvidenceInput
}
q.Since = time.UnixMilli(since).UTC()
q.Until = time.UnixMilli(until).UTC()
if q.Cursor != nil && (!q.Since.Equal(q.Cursor.Since) || !q.Until.Equal(q.Cursor.Until)) {
return q, api.ErrRouteEvidenceInput
}
}
if !api.ValidRouteEvidenceWindow(q.Since, q.Until) || q.Until.After(now) {
return q, api.ErrRouteEvidenceInput
}
return q, nil
}
// getRouteEvidence godoc
//
// @Summary Get retained reports matching a full saved route prefix sequence
// @Description Matches the saved IATA, complete path bytes and hash width. Excludes TRACE and unclassified reports; other widths and search-result subsegments are not included. Byte matches do not prove hop identities or delivery. The route and its historical counter can outlive raw evidence. Cursors retain full timestamp precision and pin route/window scope.
// @Tags Routes
// @Produce json
// @Param iata path string true "Three-character IATA code"
// @Param pathKey path string true "Stable 32-hex pathKey from a known-route response"
// @Param range query string false "Server-anchored duration (default 24h, max 720h); exclusive with since/until/pageCursor"
// @Param since query int false "Window start epoch ms; provide with until (default last 24h)"
// @Param until query int false "Exclusive window end epoch ms; maximum span 30d, no future end"
// @Param pageCursor query string false "Opaque precise cursor from nextPageCursor; window is pinned"
// @Param limit query int false "Default 50; positive, capped at 200" minimum(1) maximum(200)
// @Success 200 {object} api.RouteEvidence
// @Failure 400 {object} handlers.APIError
// @Failure 404 {object} handlers.APIError
// @Failure 500 {object} handlers.APIError
// @Router /routes/{iata}/{pathKey}/observations [get]
func getRouteEvidence(reader api.Reader) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
iata, key := strings.ToUpper(chi.URLParam(r, "iata")), strings.ToLower(chi.URLParam(r, "pathKey"))
if !api.ValidRouteEvidenceKey(iata, key) {
respondError(w, 400, "invalid IATA or route pathKey")
return
}
query, err := routeEvidenceQuery(r, iata, key, time.Now())
if err != nil {
respondError(w, 400, "invalid route evidence window, limit or page cursor")
return
}
result, err := reader.GetRouteEvidence(r.Context(), iata, key, query)
switch {
case errors.Is(err, pgx.ErrNoRows), err == nil && result == nil:
respondError(w, 404, "saved route not found")
case errors.Is(err, api.ErrRouteEvidenceInput):
respondError(w, 400, "invalid route evidence request")
case err != nil:
respondError(w, 500, "internal server error")
default:
respond(w, 200, result)
}
}
}
@@ -0,0 +1,69 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package handlers
import (
"context"
"errors"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/jackc/pgx/v5"
)
const evidenceKey = "f097439148601d9f3291c474f82fa64c"
func TestRouteEvidenceQuery(t *testing.T) {
now := time.Date(2026, 9, 27, 12, 0, 0, 0, time.UTC)
q, err := routeEvidenceQuery(httptest.NewRequest("GET", "/", nil), "YOW", evidenceKey, now)
if err != nil || q.Limit != 50 || q.Until != now || q.Until.Sub(q.Since) != 24*time.Hour {
t.Fatalf("defaults: %+v %v", q, err)
}
c := api.RouteEvidenceCursor{IATA: "YOW", PathKey: evidenceKey, Since: q.Since, Until: q.Until, HeardAt: q.Since.Add(time.Hour + 123*time.Microsecond), ID: 3}
next, err := routeEvidenceQuery(httptest.NewRequest("GET", "/?pageCursor="+url.QueryEscape(c.String())+"&limit=999", nil), "YOW", evidenceKey, now.Add(time.Hour))
if err != nil || next.Limit != 200 || !next.Until.Equal(now) || !next.Cursor.HeardAt.Equal(c.HeardAt) {
t.Fatalf("pinned cursor: %+v %v", next, err)
}
for _, query := range []string{"?limit=0", "?limit=-1", "?limit=a", "?limit=1&limit=2", "?since=0", "?until=10", "?since=2&until=1", "?since=0&until=2678400000", "?since=0&until=99999999999999999", "?pageCursor=bad", "?pageCursor=", "?cursor=123", "?pageCursor=" + url.QueryEscape(c.String()) + "&since=0&until=10"} {
if _, err := routeEvidenceQuery(httptest.NewRequest("GET", "/"+query, nil), "YOW", evidenceKey, now); err == nil {
t.Errorf("accepted %s", query)
}
}
if _, err := routeEvidenceQuery(httptest.NewRequest("GET", "/?pageCursor="+url.QueryEscape(c.String()), nil), "YVR", evidenceKey, now); err == nil {
t.Fatal("cursor crossed IATA")
}
}
func TestRouteEvidenceHandler(t *testing.T) {
for _, tc := range []struct {
path string
err error
status int
}{
{"/yow/" + evidenceKey + "/observations", nil, 200},
{"/YOW/bad/observations", nil, 400},
{"/LONG/" + evidenceKey + "/observations", nil, 400},
{"/YOW/" + evidenceKey + "/observations", pgx.ErrNoRows, 404},
{"/YOW/" + evidenceKey + "/observations", errors.New("database unavailable"), 500},
} {
called := false
router := RoutesRouter(stubReader{getRouteEvidence: func(_ context.Context, iata, key string, q api.RouteEvidenceQuery) (*api.RouteEvidence, error) {
called = true
if iata != "YOW" || key != evidenceKey || q.Limit != 50 {
t.Fatal("wrong reader scope")
}
return &api.RouteEvidence{Page: api.Page[api.RouteObservation]{Items: []api.RouteObservation{}}}, tc.err
}})
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest("GET", tc.path, nil))
if w.Code != tc.status {
t.Fatalf("%s: %d %s", tc.path, w.Code, w.Body.String())
}
if tc.status == 400 && called {
t.Fatal("invalid request reached database")
}
}
}
+1
View File
@@ -22,6 +22,7 @@ func RoutesRouter(reader api.Reader) http.Handler {
r.Get("/", listKnownRoutes(reader))
r.Get("/cross", searchCrossIATARoutes(reader))
r.Get("/search", searchKnownRoutes(reader))
r.Get("/{iata}/{pathKey}/observations", getRouteEvidence(reader))
return r
}
@@ -16,6 +16,7 @@ import (
// Unset fields return zero values. Use it for both validation tests
// (leave all fields nil) and happy path tests (set only what you need).
type stubReader struct {
getRouteEvidence func(context.Context, string, string, api.RouteEvidenceQuery) (*api.RouteEvidence, error)
getObserverComparison func(context.Context, uuid.UUID, uuid.UUID, time.Time, time.Time, []string) (*api.ObserverComparison, error)
listIATAs func(ctx context.Context) ([]api.IATA, error)
getIATA func(ctx context.Context, iata string) (*api.IATA, error)
@@ -409,3 +410,10 @@ func (s stubReader) GetNodesByIDs(ctx context.Context, ids []uuid.UUID) (map[uui
}
return nil, nil
}
func (s stubReader) GetRouteEvidence(ctx context.Context, iata, key string, q api.RouteEvidenceQuery) (*api.RouteEvidence, error) {
if s.getRouteEvidence != nil {
return s.getRouteEvidence(ctx, iata, key, q)
}
return nil, nil
}
+2
View File
@@ -23,6 +23,8 @@ type Page[T any] struct {
}
type Reader interface {
// GetRouteEvidence returns the full saved route and exact-prefix retained ordinary reports.
GetRouteEvidence(ctx context.Context, iata, pathKey string, query RouteEvidenceQuery) (*RouteEvidence, error)
// ListIATAs returns all known IATA codes with display name and coordinates.
// IATAs are auto-created on first packet arrival from that location.
ListIATAs(ctx context.Context) ([]IATA, error)
+110
View File
@@ -0,0 +1,110 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package api
import (
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
)
const MaxRouteEvidenceWindow = 30 * 24 * time.Hour
var ErrRouteEvidenceInput = errors.New("invalid route evidence request")
// RouteObservation is a retained report reference, not a packet body or proof of hop identity.
type RouteObservation struct {
ID int64 `json:"id"`
PacketHash string `json:"packetHash"`
ObserverID uuid.UUID `json:"observerId"`
ObserverName *string `json:"observerName,omitempty"`
HeardAt int64 `json:"heardAt"`
PayloadType int16 `json:"payloadType"`
PayloadTypeName string `json:"payloadTypeName"`
RSSI *int16 `json:"rssi,omitempty"`
SNR *float32 `json:"snr,omitempty"`
}
// RouteEvidence uses nextPageCursor rather than the lossy legacy numeric cursor.
type RouteEvidence struct {
Page[RouteObservation]
Route KnownRoute `json:"route"`
WindowStart int64 `json:"windowStart"`
WindowEnd int64 `json:"windowEnd"`
GeneratedAt int64 `json:"generatedAt"`
MatchType string `json:"matchType"`
MatchAvailable bool `json:"matchAvailable"`
HashSize int16 `json:"hashSize"`
PathBytes string `json:"pathBytes"`
NextPageCursor *string `json:"nextPageCursor,omitempty"`
}
type RouteEvidenceQuery struct {
Since time.Time
Until time.Time
Cursor *RouteEvidenceCursor
Limit int32
}
// RouteEvidenceCursor binds the precise (heard_at DESC,id DESC) boundary to its route/window.
type RouteEvidenceCursor struct {
IATA string
PathKey string
Since time.Time
Until time.Time
HeardAt time.Time
ID int64
}
func ValidRouteEvidenceKey(iata, key string) bool {
if len(iata) != 3 || len(key) != 32 {
return false
}
for _, c := range iata {
if !(c >= 'A' && c <= 'Z') && !(c >= '0' && c <= '9') {
return false
}
}
_, err := hex.DecodeString(key)
return err == nil && key == strings.ToLower(key)
}
func ValidRouteEvidenceWindow(since, until time.Time) bool {
return !since.Before(time.Unix(0, 0)) && until.After(since) && until.Sub(since) <= MaxRouteEvidenceWindow
}
func (c RouteEvidenceCursor) String() string {
return fmt.Sprintf("v1:%s:%s:%d:%d:%d:%d", c.IATA, c.PathKey, c.Since.UnixMilli(), c.Until.UnixMilli(), c.HeardAt.UnixMicro(), c.ID)
}
func ParseRouteEvidenceCursor(raw string) (*RouteEvidenceCursor, error) {
if len(raw) > 200 {
return nil, ErrRouteEvidenceInput
}
p := strings.Split(raw, ":")
if len(p) != 7 || p[0] != "v1" || !ValidRouteEvidenceKey(p[1], p[2]) {
return nil, ErrRouteEvidenceInput
}
values := [4]int64{}
for i, s := range p[3:] {
n, err := strconv.ParseInt(s, 10, 64)
if err != nil || n < 0 || strconv.FormatInt(n, 10) != s {
return nil, ErrRouteEvidenceInput
}
values[i] = n
}
// Bound before converting; this also keeps millisecond/microsecond comparisons safe.
if values[0] > 253402300799999 || values[1] > 253402300799999 || values[2] > 253402300799999999 || values[3] <= 0 {
return nil, ErrRouteEvidenceInput
}
c := &RouteEvidenceCursor{IATA: p[1], PathKey: p[2], Since: time.UnixMilli(values[0]).UTC(), Until: time.UnixMilli(values[1]).UTC(), HeardAt: time.UnixMicro(values[2]).UTC(), ID: values[3]}
if !ValidRouteEvidenceWindow(c.Since, c.Until) || c.HeardAt.Before(c.Since) || !c.HeardAt.Before(c.Until) {
return nil, ErrRouteEvidenceInput
}
return c, nil
}
+25
View File
@@ -0,0 +1,25 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package api
import (
"strings"
"testing"
"time"
)
func TestRouteEvidenceCursor(t *testing.T) {
c := RouteEvidenceCursor{IATA: "YOW", PathKey: "f097439148601d9f3291c474f82fa64c", Since: time.UnixMilli(1000).UTC(), Until: time.UnixMilli(3000).UTC(), HeardAt: time.UnixMicro(2000123).UTC(), ID: 9223372036854775806}
got, err := ParseRouteEvidenceCursor(c.String())
if err != nil || *got != c {
t.Fatalf("cursor round-trip: %+v %v", got, err)
}
if got.HeardAt.Nanosecond() != 123000 {
t.Fatal("lost microsecond precision")
}
for _, bad := range []string{"", strings.Repeat("x", 300), "v2:YOW:x:1:2:3:4", strings.Replace(c.String(), "YOW", "TOOLONG", 1), strings.Replace(c.String(), c.PathKey, "bad", 1), strings.Replace(c.String(), "2000123", "4000000", 1), strings.TrimSuffix(c.String(), "9223372036854775806") + "0"} {
if _, err := ParseRouteEvidenceCursor(bad); err == nil {
t.Errorf("accepted %q", bad)
}
}
}
+1
View File
@@ -16,6 +16,7 @@ type RouteHop struct {
// have been confirmed as high confidence.
type KnownRoute struct {
ID int64 `json:"id"`
PathKey string `json:"pathKey,omitempty"` // stable identity within this IATA; use for route evidence links
IATA string `json:"iata"`
HopCount int32 `json:"hopCount"`
Hops []RouteHop `json:"hops"`
+5 -1
View File
@@ -366,7 +366,7 @@ func TestCachedReader_IATASortingForStableKey(t *testing.T) {
return &api.StatsOverview{TotalPackets: 42}, nil
})
// call CachedReader with reversed order — should hit same key
// call CachedReader with reversed order — should hit same key
_ = cr
result, err := getOrSet(context.Background(), c, "beacon:stats:overview:YVR,YYJ", time.Minute, func() (*api.StatsOverview, error) {
calls++
@@ -418,3 +418,7 @@ func TestCachedReader_InvalidateObserver(t *testing.T) {
t.Error("expected observer scopes key to be deleted")
}
}
func (s *stubReader) GetRouteEvidence(_ context.Context, _, _ string, _ api.RouteEvidenceQuery) (*api.RouteEvidence, error) {
return nil, nil
}
+5
View File
@@ -467,6 +467,11 @@ func (cr *CachedReader) SearchKnownRoutes(ctx context.Context, iata, fromHash, t
return cr.inner.SearchKnownRoutes(ctx, iata, fromHash, toHash)
}
// Precise cursor/window combinations are intentionally passed through, like route lists.
func (cr *CachedReader) GetRouteEvidence(ctx context.Context, iata, key string, query api.RouteEvidenceQuery) (*api.RouteEvidence, error) {
return cr.inner.GetRouteEvidence(ctx, iata, key, query)
}
// SearchCrossIATARoutes implements [api.Reader].
func (cr *CachedReader) SearchCrossIATARoutes(ctx context.Context, fromHash, fromIATA, toHash, toIATA string) ([]api.CrossIATARoute, error) {
return cr.inner.SearchCrossIATARoutes(ctx, fromHash, fromIATA, toHash, toIATA)