Skip to content
Merged
25 changes: 14 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.3
## Current milestone: M5.4

The application currently provides:

Expand Down Expand Up @@ -43,6 +43,8 @@ The application currently provides:
- safe restore/import of M5.0 archives with staged extraction, checksum validation and collision protection
- dry-run restore planning with per-recording `import`, `duplicate`, `conflict` and `unmatched` classification
- selective restore of operator-chosen `import` candidates from a validated plan
- 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
- per-recording annotation import/skip counts before restore
- local rebuild of waveform, frequency, spectrogram and fingerprint data from restored managed audio
Expand All @@ -55,22 +57,23 @@ 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.3 does not
support determines whether a WAV/FLAC recording can be played; M5.4 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.2 planning remains the preview step for archival restore. M5.3 adds a strict
selection policy on top: only entries classified as `import` can be chosen for
selective restore. Unselected entries are skipped, while duplicate, conflict,
unmatched and unknown IDs cannot be forced through. Existing recording IDs and
managed audio destinations are never blindly overwritten, and the same staged
validation and rollback rules remain in force.
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.

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

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

## Run with Go

Expand Down Expand Up @@ -104,7 +107,7 @@ Then open <http://localhost:8080>.
- `GET /api/recording-bundle`
- `GET /api/recording-archive`
- `POST /api/recording-archive/plan`
- `POST /api/recording-archive/import-selected?recording_id=...`
- `POST /api/recording-archive/import-selected?recording_id=...&plan_token=...`
- `POST /api/recording-archive/import`
- `GET /api/recordings/{id}/similar`
- `GET /api/recording-clusters?threshold=98`
Expand Down Expand Up @@ -133,7 +136,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, dry-run planning and policy-controlled local restore
- portable recording manifests, verified archival bundles, token-bound dry-run planning and policy-controlled local restore
- human review and classification of signal matches and repeated transmissions
- optional SDR and network-receiver integrations
- optional data-provider imports
Expand Down
7 changes: 6 additions & 1 deletion cmd/number-station-tools/m5_3_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,13 @@ func TestPortableArchiveSelectiveRestoreAPI(t *testing.T) {
audioDir := filepath.Join(t.TempDir(), "audio")
mux := http.NewServeMux()
registerRecordingRestoreHandler(mux, db, rs, audioDir)
archive := selectiveRestoreArchive(t)
plan, err := planPortableRestore(bytes.NewReader(archive), db, rs, audioDir)
if err != nil {
t.Fatal(err)
}

req := httptest.NewRequest(http.MethodPost, "/api/recording-archive/import-selected?recording_id=r1", bytes.NewReader(selectiveRestoreArchive(t)))
req := httptest.NewRequest(http.MethodPost, "/api/recording-archive/import-selected?recording_id=r1&plan_token="+plan.PlanToken, bytes.NewReader(archive))
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
Expand Down
121 changes: 121 additions & 0 deletions cmd/number-station-tools/m5_4_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package main

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

func TestPortableRestorePlanTokenDeterministic(t *testing.T) {
db := restoreTestDB(t)
rs, _ := openRecordingStore(filepath.Join(t.TempDir(), "recordings.json"))
audioDir := filepath.Join(t.TempDir(), "audio")
archive := selectiveRestoreArchive(t)

first, err := planPortableRestore(bytes.NewReader(archive), db, rs, audioDir)
if err != nil {
t.Fatal(err)
}
second, err := planPortableRestore(bytes.NewReader(archive), db, rs, audioDir)
if err != nil {
t.Fatal(err)
}
if len(first.PlanToken) != 64 || first.PlanToken != second.PlanToken {
t.Fatalf("plan tokens first=%q second=%q", first.PlanToken, second.PlanToken)
}
}

func TestPortableRestorePlanTokenRejectsLocalStateDrift(t *testing.T) {
db := restoreTestDB(t)
rs, _ := openRecordingStore(filepath.Join(t.TempDir(), "recordings.json"))
audioDir := filepath.Join(t.TempDir(), "audio")
archive := selectiveRestoreArchive(t)
plan, err := planPortableRestore(bytes.NewReader(archive), db, rs, audioDir)
if err != nil {
t.Fatal(err)
}

rs.mu.Lock()
rs.data.Annotations = append(rs.data.Annotations, recordingAnnotation{ID: "local-drift", RecordingID: "other", StartMS: 1, Label: "changed"})
rs.mu.Unlock()

_, err = restorePortableArchiveSelectedWithPlanToken(bytes.NewReader(archive), db, rs, audioDir, []string{"r1"}, plan.PlanToken)
if err == nil || !strings.Contains(err.Error(), "plan changed") {
t.Fatalf("expected stale plan rejection, got %v", err)
}
if len(rs.list()) != 0 {
t.Fatal("stale plan changed recording store")
}
}

func TestPortableRestorePlanTokenRejectsArchiveDrift(t *testing.T) {
db := restoreTestDB(t)
rs, _ := openRecordingStore(filepath.Join(t.TempDir(), "recordings.json"))
audioDir := filepath.Join(t.TempDir(), "audio")
archive := selectiveRestoreArchive(t)
plan, err := planPortableRestore(bytes.NewReader(archive), db, rs, audioDir)
if err != nil {
t.Fatal(err)
}
changed := restoreTestArchive(t, testWAV(8000, 1, 1000), func(manifest *recordingBundleManifest) {
manifest.Items[0].Recording.Notes = "changed archive metadata"
})
_, err = restorePortableArchiveSelectedWithPlanToken(bytes.NewReader(changed), db, rs, audioDir, []string{"r1"}, plan.PlanToken)
if err == nil || !strings.Contains(err.Error(), "plan changed") {
t.Fatalf("expected archive drift rejection, got %v", err)
}
}

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

planReq := httptest.NewRequest(http.MethodPost, "/api/recording-archive/plan", bytes.NewReader(archive))
planW := httptest.NewRecorder()
mux.ServeHTTP(planW, planReq)
if planW.Code != http.StatusOK {
t.Fatalf("plan status=%d body=%s", planW.Code, planW.Body.String())
}
var plan portableRestorePlan
if err := json.Unmarshal(planW.Body.Bytes(), &plan); err != nil {
t.Fatal(err)
}
if len(plan.PlanToken) != 64 {
t.Fatalf("plan token=%q", plan.PlanToken)
}

missingReq := httptest.NewRequest(http.MethodPost, "/api/recording-archive/import-selected?recording_id=r1", bytes.NewReader(archive))
missingW := httptest.NewRecorder()
mux.ServeHTTP(missingW, missingReq)
if missingW.Code != http.StatusPreconditionRequired {
t.Fatalf("missing token status=%d body=%s", missingW.Code, missingW.Body.String())
}

query := "/api/recording-archive/import-selected?recording_id=r1&plan_token=" + plan.PlanToken
req := httptest.NewRequest(http.MethodPost, query, bytes.NewReader(archive))
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
}

func TestM54RestorePlanTokenEmbedded(t *testing.T) {
js, err := webFS.ReadFile("web/archive-restore.js")
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"plan.plan_token", "plan_token", "Plan token", "approved plan"} {
if !strings.Contains(string(js), want) {
t.Fatalf("archive restore JS missing %q", want)
}
}
}
62 changes: 62 additions & 0 deletions cmd/number-station-tools/portable_restore_plan.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package main

import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
)

Expand All @@ -25,16 +29,68 @@ type portableRestorePlan struct {
Duplicate int `json:"duplicate"`
Conflict int `json:"conflict"`
Unmatched int `json:"unmatched"`
PlanToken string `json:"plan_token"`
Entries []portableRestorePlanEntry `json:"entries"`
}

type portablePlanFileFingerprint struct {
Path string `json:"path"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
}

type portablePlanLocalState struct {
Recordings []recording `json:"recordings"`
Annotations []recordingAnnotation `json:"annotations"`
Observations []observation `json:"observations"`
}

type portablePlanFingerprint struct {
Manifest recordingBundleManifest `json:"manifest"`
Files []portablePlanFileFingerprint `json:"files"`
Plan portableRestorePlan `json:"plan"`
LocalState portablePlanLocalState `json:"local_state"`
}

func snapshotPortablePlanLocalState(db *store, rs *recordingStore) portablePlanLocalState {
rs.mu.Lock()
recordings := append([]recording(nil), rs.data.Recordings...)
annotations := append([]recordingAnnotation(nil), rs.data.Annotations...)
rs.mu.Unlock()
db.mu.Lock()
observations := append([]observation(nil), db.data.Observations...)
db.mu.Unlock()
sort.Slice(recordings, func(i, j int) bool { return recordings[i].ID < recordings[j].ID })
sort.Slice(annotations, func(i, j int) bool { return annotations[i].ID < annotations[j].ID })
sort.Slice(observations, func(i, j int) bool { return observations[i].ID < observations[j].ID })
return portablePlanLocalState{Recordings: recordings, Annotations: annotations, Observations: observations}
}

func computePortableRestorePlanToken(staged stagedPortableArchive, plan portableRestorePlan, localState portablePlanLocalState) (string, error) {
files := make([]portablePlanFileFingerprint, 0, len(staged.files))
for name, file := range staged.files {
files = append(files, portablePlanFileFingerprint{Path: name, Size: file.size, SHA256: file.sha256})
}
sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path })
plan.PlanToken = ""
body, err := json.Marshal(portablePlanFingerprint{Manifest: staged.manifest, Files: files, Plan: plan, LocalState: localState})
if err != nil {
return "", err
}
sum := sha256.Sum256(body)
return hex.EncodeToString(sum[:]), nil
}

func planPortableRestore(src io.Reader, db *store, rs *recordingStore, audioDir string) (portableRestorePlan, error) {
staged, err := readPortableArchive(src, audioDir)
if err != nil {
return portableRestorePlan{}, err
}
defer os.RemoveAll(staged.dir)
return planStagedPortableRestore(staged, db, rs, audioDir)
}

func planStagedPortableRestore(staged stagedPortableArchive, db *store, rs *recordingStore, audioDir string) (portableRestorePlan, error) {
plan := portableRestorePlan{Entries: []portableRestorePlanEntry{}}
seenIDs := make(map[string]struct{}, len(staged.manifest.Items))
requiredFiles := make(map[string]struct{})
Expand Down Expand Up @@ -151,5 +207,11 @@ func planPortableRestore(src io.Reader, db *store, rs *recordingStore, audioDir
return portableRestorePlan{}, fmt.Errorf("archive contains unreferenced audio entry %q", name)
}
}
localState := snapshotPortablePlanLocalState(db, rs)
token, err := computePortableRestorePlanToken(staged, plan, localState)
if err != nil {
return portableRestorePlan{}, errors.New("cannot fingerprint restore plan")
}
plan.PlanToken = token
return plan, nil
}
28 changes: 28 additions & 0 deletions cmd/number-station-tools/portable_restore_selection.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ type portableSelectiveRestoreResult struct {
}

func restorePortableArchiveSelected(src io.Reader, db *store, rs *recordingStore, audioDir string, selectedIDs []string) (portableSelectiveRestoreResult, error) {
return restorePortableArchiveSelectedWithPlanToken(src, db, rs, audioDir, selectedIDs, "")
}

func restorePortableArchiveSelectedWithPlanToken(src io.Reader, db *store, rs *recordingStore, audioDir string, selectedIDs []string, expectedPlanToken string) (portableSelectiveRestoreResult, error) {
selected := make(map[string]struct{}, len(selectedIDs))
for _, raw := range selectedIDs {
id := strings.TrimSpace(raw)
Expand All @@ -37,6 +41,30 @@ func restorePortableArchiveSelected(src io.Reader, db *store, rs *recordingStore
}
defer os.RemoveAll(staged.dir)

if expectedPlanToken != "" {
if len(expectedPlanToken) != 64 {
return portableSelectiveRestoreResult{}, errors.New("invalid restore plan token")
}
currentPlan, err := planStagedPortableRestore(staged, db, rs, audioDir)
if err != nil {
return portableSelectiveRestoreResult{}, err
}
if currentPlan.PlanToken != strings.ToLower(strings.TrimSpace(expectedPlanToken)) {
return portableSelectiveRestoreResult{}, errors.New("restore plan changed; plan the archive again before restoring")
}
allowed := make(map[string]struct{})
for _, entry := range currentPlan.Entries {
if entry.Action == "import" {
allowed[entry.RecordingID] = struct{}{}
}
}
for id := range selected {
if _, ok := allowed[id]; !ok {
return portableSelectiveRestoreResult{}, fmt.Errorf("selected recording %s is not an import candidate in the approved plan", id)
}
}
}

result := portableSelectiveRestoreResult{Selected: len(selected)}
seenIDs := make(map[string]struct{}, len(staged.manifest.Items))
requiredFiles := make(map[string]struct{})
Expand Down
14 changes: 11 additions & 3 deletions cmd/number-station-tools/recording_bundle_api.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package main

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

func registerRecordingBundleHandlers(mux *http.ServeMux, rs *recordingStore, audioDir string) {
mux.HandleFunc("GET /api/recording-bundle", func(w http.ResponseWriter, _ *http.Request) {
Expand Down Expand Up @@ -39,10 +42,15 @@ 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) {
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
}
r.Body = http.MaxBytesReader(w, r.Body, maxPortableArchiveUploadBytes)
result, err := restorePortableArchiveSelected(r.Body, db, rs, audioDir, r.URL.Query()["recording_id"])
result, err := restorePortableArchiveSelectedWithPlanToken(r.Body, db, rs, audioDir, r.URL.Query()["recording_id"], planToken)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
http.Error(w, err.Error(), http.StatusConflict)
return
}
writeJSON(w, http.StatusOK, result)
Expand Down
Loading
Loading