Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Self-hosted, local-first tools for numbers stations, shortwave monitoring and
signal-analysis workflows.

## Current milestone: M5.4
## Current milestone: M5.5

The application currently provides:

Expand Down Expand Up @@ -46,6 +46,8 @@ The application currently provides:
- deterministic SHA-256 plan tokens binding selective restore to the reviewed archive and local state
- strict rejection of stale plan tokens after archive, recording, annotation or observation changes
- strict rejection of selected duplicates, conflicts, unmatched observations and unknown recording IDs
- persistent local restore receipts for successful full and selective restores
- read-only restore audit history with mode, UTC time, plan token, selection and result counts
- per-recording annotation import/skip counts before restore
- local rebuild of waveform, frequency, spectrogram and fingerprint data from restored managed audio
- local JSON persistence under `/data`
Expand All @@ -57,23 +59,22 @@ and `duplicate capture`. They are stored locally alongside recording metadata an
do not require AI, an API key, or an external service.

Managed playback reads audio directly from the appliance. Browser-native codec
support determines whether a WAV/FLAC recording can be played; M5.4 does not
support determines whether a WAV/FLAC recording can be played; M5.5 does not
transcode audio or send it to an external service. Saved timestamp annotations
persist in `recordings.json`, can be searched across the archive, exported as JSON
or CSV, and safely imported from the versioned JSON format.

M5.4 hardens the M5.2/M5.3 `plan -> selective restore` flow. Each successful plan
returns a SHA-256 `plan_token` covering the validated manifest, managed-audio
inventory, complete restore classification and a canonical snapshot of local
recordings, annotations and observations. Selective restore requires that token and
recomputes the plan before commit. If the archive or local restore-relevant state
has changed, the request is rejected and the operator must plan again. No force
overwrite mode is introduced.
M5.5 adds an operator-visible audit primitive to the hardened restore flow. Every
successful full or selective restore persists a receipt in a separate local JSON
file beside the recording store. Receipts record a generated receipt ID, UTC time,
restore mode, result counts and, for selective restore, the approved plan token and
selected recording IDs. The receipt log contains no archive payload or duplicated
audio. `GET /api/recording-archive/receipts` returns recent receipts read-only.

The application does not require an SDR, receiver, API key or external data
provider for these workflows.

See [docs/M5_4.md](docs/M5_4.md) for the current scope.
See [docs/M5_5.md](docs/M5_5.md) for the current scope.

## Run with Go

Expand Down Expand Up @@ -106,6 +107,7 @@ Then open <http://localhost:8080>.
- `GET/POST /api/recordings`
- `GET /api/recording-bundle`
- `GET /api/recording-archive`
- `GET /api/recording-archive/receipts?limit=50`
- `POST /api/recording-archive/plan`
- `POST /api/recording-archive/import-selected?recording_id=...&plan_token=...`
- `POST /api/recording-archive/import`
Expand Down Expand Up @@ -136,7 +138,7 @@ Number Station Tools is intended to grow into a workbench for:
- waveform, frequency, spectrogram, fingerprint and signal-analysis workflows
- categorized timestamp annotations, bookmarks and operator notes on recordings
- cross-recording annotation search, portable transfer and timeline navigation
- portable recording manifests, verified archival bundles, token-bound dry-run planning and policy-controlled local restore
- portable recording manifests, verified archival bundles, token-bound dry-run planning, policy-controlled restore and local audit receipts
- human review and classification of signal matches and repeated transmissions
- optional SDR and network-receiver integrations
- optional data-provider imports
Expand Down
96 changes: 96 additions & 0 deletions cmd/number-station-tools/m5_5_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package main

import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)

func TestRestoreReceiptStorePersistsAndSorts(t *testing.T) {
path := filepath.Join(t.TempDir(), "receipts.json")
store, err := openRestoreReceiptStore(path)
if err != nil {
t.Fatal(err)
}
first, err := store.add(restoreReceipt{Mode: "full", ImportedRecordings: 1})
if err != nil {
t.Fatal(err)
}
second, err := store.add(restoreReceipt{Mode: "selected", PlanToken: strings.Repeat("a", 64), SelectedRecordingIDs: []string{"r2", "r1"}, ImportedRecordings: 2})
if err != nil {
t.Fatal(err)
}
if first.ID == "" || first.At == "" || second.ID == "" || second.At == "" {
t.Fatalf("missing receipt identity: first=%#v second=%#v", first, second)
}
if len(second.SelectedRecordingIDs) != 2 || second.SelectedRecordingIDs[0] != "r1" {
t.Fatalf("selected ids not normalized: %#v", second.SelectedRecordingIDs)
}
reopened, err := openRestoreReceiptStore(path)
if err != nil {
t.Fatal(err)
}
items := reopened.list(10)
if len(items) != 2 {
t.Fatalf("receipts=%#v", items)
}
if _, err := os.Stat(path); err != nil {
t.Fatal(err)
}
}

func TestRestoreReceiptAPI(t *testing.T) {
db := restoreTestDB(t)
recordingPath := filepath.Join(t.TempDir(), "recordings.json")
rs, _ := openRecordingStore(recordingPath)
audioDir := filepath.Join(t.TempDir(), "audio")
mux := http.NewServeMux()
registerRecordingRestoreHandler(mux, db, rs, audioDir)

archive := restoreTestArchive(t, testWAV(8000, 1, 1000), nil)
req := httptest.NewRequest(http.MethodPost, "/api/recording-archive/import", bytes.NewReader(archive))
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("restore status=%d body=%s", w.Code, w.Body.String())
}
var restored restoreResponse
if err := json.Unmarshal(w.Body.Bytes(), &restored); err != nil {
t.Fatal(err)
}
if restored.ImportedRecords != 1 || restored.Receipt.ID == "" || restored.Receipt.Mode != "full" {
t.Fatalf("restore response=%#v", restored)
}

req = httptest.NewRequest(http.MethodGet, "/api/recording-archive/receipts?limit=10", nil)
w = httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("receipts status=%d body=%s", w.Code, w.Body.String())
}
var receipts []restoreReceipt
if err := json.Unmarshal(w.Body.Bytes(), &receipts); err != nil {
t.Fatal(err)
}
if len(receipts) != 1 || receipts[0].ID != restored.Receipt.ID {
t.Fatalf("receipts=%#v restored=%#v", receipts, restored.Receipt)
}
}

func TestRestoreReceiptLimitValidation(t *testing.T) {
db := restoreTestDB(t)
rs, _ := openRecordingStore(filepath.Join(t.TempDir(), "recordings.json"))
mux := http.NewServeMux()
registerRecordingRestoreHandler(mux, db, rs, filepath.Join(t.TempDir(), "audio"))
req := httptest.NewRequest(http.MethodGet, "/api/recording-archive/receipts?limit=999", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
}
62 changes: 59 additions & 3 deletions cmd/number-station-tools/recording_bundle_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"net/http"
"strconv"
"strings"
)

Expand Down Expand Up @@ -32,6 +33,25 @@ func registerRecordingBundleHandlers(mux *http.ServeMux, rs *recordingStore, aud
}

func registerRecordingRestoreHandler(mux *http.ServeMux, db *store, rs *recordingStore, audioDir string) {
receipts, receiptStoreErr := openRestoreReceiptStore(rs.path + ".restore-receipts.json")

mux.HandleFunc("GET /api/recording-archive/receipts", func(w http.ResponseWriter, r *http.Request) {
if receiptStoreErr != nil {
http.Error(w, "restore receipt store is unavailable", http.StatusInternalServerError)
return
}
limit := 50
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil || parsed < 1 || parsed > 200 {
http.Error(w, "limit must be between 1 and 200", http.StatusBadRequest)
return
}
limit = parsed
}
writeJSON(w, http.StatusOK, receipts.list(limit))
})

mux.HandleFunc("POST /api/recording-archive/plan", func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxPortableArchiveUploadBytes)
plan, err := planPortableRestore(r.Body, db, rs, audioDir)
Expand All @@ -42,26 +62,62 @@ func registerRecordingRestoreHandler(mux *http.ServeMux, db *store, rs *recordin
writeJSON(w, http.StatusOK, plan)
})
mux.HandleFunc("POST /api/recording-archive/import-selected", func(w http.ResponseWriter, r *http.Request) {
if receiptStoreErr != nil {
http.Error(w, "restore receipt store is unavailable", http.StatusInternalServerError)
return
}
planToken := strings.TrimSpace(r.URL.Query().Get("plan_token"))
if planToken == "" {
http.Error(w, "plan_token is required; plan the archive before selective restore", http.StatusPreconditionRequired)
return
}
selectedIDs := r.URL.Query()["recording_id"]
r.Body = http.MaxBytesReader(w, r.Body, maxPortableArchiveUploadBytes)
result, err := restorePortableArchiveSelectedWithPlanToken(r.Body, db, rs, audioDir, r.URL.Query()["recording_id"], planToken)
result, err := restorePortableArchiveSelectedWithPlanToken(r.Body, db, rs, audioDir, selectedIDs, planToken)
if err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
writeJSON(w, http.StatusOK, result)
receipt, err := receipts.add(restoreReceipt{
Mode: "selected",
PlanToken: planToken,
SelectedRecordingIDs: selectedIDs,
ImportedRecordings: result.ImportedRecords,
ImportedAnnotations: result.ImportedAnnotations,
Duplicates: result.Duplicates,
Conflicts: result.Conflicts,
Unmatched: result.Unmatched,
SkippedByPolicy: result.SkippedByPolicy,
})
if err != nil {
http.Error(w, "restore succeeded but receipt persistence failed", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, selectiveRestoreResponse{portableSelectiveRestoreResult: result, Receipt: receipt})
})
mux.HandleFunc("POST /api/recording-archive/import", func(w http.ResponseWriter, r *http.Request) {
if receiptStoreErr != nil {
http.Error(w, "restore receipt store is unavailable", http.StatusInternalServerError)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxPortableArchiveUploadBytes)
result, err := restorePortableArchive(r.Body, db, rs, audioDir)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, http.StatusOK, result)
receipt, err := receipts.add(restoreReceipt{
Mode: "full",
ImportedRecordings: result.ImportedRecords,
ImportedAnnotations: result.ImportedAnnotations,
Duplicates: result.Duplicates,
Conflicts: result.Conflicts,
Unmatched: result.Unmatched,
})
if err != nil {
http.Error(w, "restore succeeded but receipt persistence failed", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, restoreResponse{portableRestoreResult: result, Receipt: receipt})
})
}
117 changes: 117 additions & 0 deletions cmd/number-station-tools/restore_receipts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package main

import (
"encoding/json"
"errors"
"os"
"sort"
"strings"
"sync"
"time"
)

type restoreReceipt struct {
ID string `json:"id"`
At string `json:"at"`
Mode string `json:"mode"`
PlanToken string `json:"plan_token,omitempty"`
SelectedRecordingIDs []string `json:"selected_recording_ids,omitempty"`
ImportedRecordings int `json:"imported_recordings"`
ImportedAnnotations int `json:"imported_annotations"`
Duplicates int `json:"duplicates"`
Conflicts int `json:"conflicts"`
Unmatched int `json:"unmatched"`
SkippedByPolicy int `json:"skipped_by_policy,omitempty"`
}

type restoreReceiptData struct {
Receipts []restoreReceipt `json:"receipts"`
}

type restoreReceiptStore struct {
mu sync.Mutex
path string
data restoreReceiptData
}

func openRestoreReceiptStore(path string) (*restoreReceiptStore, error) {
s := &restoreReceiptStore{path: path, data: restoreReceiptData{Receipts: []restoreReceipt{}}}
b, err := os.ReadFile(path)
if os.IsNotExist(err) {
return s, nil
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(b, &s.data); err != nil {
return nil, err
}
if s.data.Receipts == nil {
s.data.Receipts = []restoreReceipt{}
}
return s, nil
}

func (s *restoreReceiptStore) save() error {
body, err := json.MarshalIndent(s.data, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, body, 0o600); err != nil {
return err
}
return os.Rename(tmp, s.path)
}

func (s *restoreReceiptStore) add(receipt restoreReceipt) (restoreReceipt, error) {
s.mu.Lock()
defer s.mu.Unlock()
receipt.ID = id()
receipt.At = time.Now().UTC().Format(time.RFC3339Nano)
receipt.Mode = strings.TrimSpace(receipt.Mode)
if receipt.Mode != "full" && receipt.Mode != "selected" {
return restoreReceipt{}, errors.New("invalid restore receipt mode")
}
receipt.PlanToken = strings.ToLower(strings.TrimSpace(receipt.PlanToken))
receipt.SelectedRecordingIDs = append([]string(nil), receipt.SelectedRecordingIDs...)
sort.Strings(receipt.SelectedRecordingIDs)
s.data.Receipts = append(s.data.Receipts, receipt)
if err := s.save(); err != nil {
s.data.Receipts = s.data.Receipts[:len(s.data.Receipts)-1]
return restoreReceipt{}, err
}
return receipt, nil
}

func (s *restoreReceiptStore) list(limit int) []restoreReceipt {
s.mu.Lock()
defer s.mu.Unlock()
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
out := append([]restoreReceipt(nil), s.data.Receipts...)
sort.Slice(out, func(i, j int) bool {
if out[i].At == out[j].At {
return out[i].ID > out[j].ID
}
return out[i].At > out[j].At
})
if len(out) > limit {
out = out[:limit]
}
return out
}

type restoreResponse struct {
portableRestoreResult
Receipt restoreReceipt `json:"receipt"`
}

type selectiveRestoreResponse struct {
portableSelectiveRestoreResult
Receipt restoreReceipt `json:"receipt"`
}
Loading
Loading