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
7 changes: 7 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@
"5173": { "label": "Vite Dev", "onAutoForward": "notify" }
},

"containerEnv": {
// Keep pnpm's virtual store off the workspace mount (9p doesn't
// support the rename/link calls pnpm needs). /home/vscode is on the
// container overlay and survives /tmp wipes. CI runners ignore this.
"NPM_CONFIG_VIRTUAL_STORE_DIR": "/home/vscode/.cache/pnpm-vstore/openscanner-frontend"
},

"postCreateCommand": "bash .devcontainer/post-create.sh",

"customizations": {
Expand Down
4 changes: 4 additions & 0 deletions .devcontainer/post-create.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ set -euo pipefail
sudo chown -R vscode:vscode /home/vscode/go
sudo chown -R vscode:vscode /home/vscode/.local/share/pnpm

# pnpm virtual-store-dir (see NPM_CONFIG_VIRTUAL_STORE_DIR in
# devcontainer.json) — persistent across /tmp wipes
mkdir -p /home/vscode/.cache/pnpm-vstore

echo "==> Installing backend Go dependencies..."
cd /workspaces/OpenScanner/backend
go mod download
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.3.0] — 2026-04-29

### Added

- Native `/api/v1/*` REST surface alongside the existing legacy routes. All v1 responses use a structured error envelope (`{"error":{"code","message","details"}}`) with stable string codes (`validation_failed`, `unauthorized`, `forbidden`, `not_found`, `conflict`, `unprocessable`, `rate_limited`, `internal`); 5xx envelopes include the request ID under `details.requestId`.
- v1 call-upload endpoint (`POST /api/v1/calls`) with native multipart field names (`systemId`, `talkgroupId`, `startedAt`, `frequencyHz`, `durationMs`, `unitId`) and RFC 3339 `startedAt` enforcement (unix timestamps no longer accepted on v1). Companion `POST /api/v1/calls/test` returns 204 on a valid API key.
- v1 listener endpoints: `GET/PUT /api/v1/listener/tg-selection` (renamed from `/api/auth/tg-selection`), `GET /api/v1/calls`, `GET /api/v1/calls/:id/audio`, `GET /api/v1/calls/:id/transcript`, share/bookmark endpoints, and unauthenticated `/api/v1/health`, `/api/v1/setup/*`, `/api/v1/auth/{login,refresh,logout,password,me}`.
- v1 admin endpoints under `/api/v1/admin/*` for talkgroup/unit/group/tag imports, RadioReference preview (path simplified — no `/csv` suffix), transcription status, and Swagger session bootstrap.
- Native JSON-object framed WebSocket protocol on `GET /api/v1/ws/listener` and `GET /api/v1/ws/admin`. Frames carry a `type` discriminator (`connection.welcome`, `scanner.config`, `call.new`, `call.transcript`, `listener.count`, `listener.feedMap.snapshot`/`update`, `session.expired`, `connection.rejected`, `admin.event`, `admin.request`, `admin.response`) instead of the legacy 3-letter array opcodes. Admin error responses mirror the REST `{code,message,details?}` envelope. The frontend connects to the v1 paths; legacy `/ws`, `/api/ws`, and `/api/admin/ws` keep emitting the array-framed protocol unchanged for in-the-wild clients.
- RFC 8594 deprecation headers (`Deprecation: true`, `Sunset`, `Link: <successor>; rel="successor-version"`, `Cache-Control: no-store`) on every legacy `/api/*` and legacy WebSocket route, pointing at the native `/api/v1/*` successor. Per-request structured warn log (`legacy endpoint hit`) records method, path, and a truncated API-key identifier — never the raw key.
- Admin endpoint `GET /api/v1/admin/legacy-usage` returning a 24-hour aggregate of legacy-endpoint hits (`{method, path, apiKeyIdent, count, lastSeen}`), backed by an in-memory ring buffer (no schema change).
- Admin dashboard banner that surfaces legacy-API usage from the new endpoint, with an expandable details table (method, path, API key, count, last seen) and per-session dismiss.

### Changed

- Frontend now talks to the native `/api/v1/*` surface for every REST call (RTK Query base URL, raw `fetch()` for audio downloads and silent token refresh, the service-worker passthrough rules, the dev-server proxy, and the Swagger UI bootstrap). Tg-selection moves from `/api/auth/tg-selection` to `/api/v1/listener/tg-selection`; RadioReference CSV preview moves to `/api/v1/admin/radioreference/preview`; legacy-usage report is consumed at `/api/v1/admin/legacy-usage`. Legacy `/api/*` routes remain available for non-frontend clients with the existing deprecation headers.
- API-key authentication on `/api/v1/*` upload routes accepts only `Authorization: Bearer <api-key>`; the legacy `X-API-Key` header, `?key=` query parameter, and `key=` form field continue to work on legacy routes only. JWT-shaped Bearer tokens on v1 API-key routes are rejected with `invalid_credentials`.
- Swagger UI now documents every native `/api/v1/*` endpoint, not just the three previously annotated handlers. Legacy `/api/*` annotations remain in place until those routes are retired.

## [1.2.1] — 2026-04-25

### Security
Expand Down Expand Up @@ -103,6 +122,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Swagger UI now opens correctly from the admin Tools panel. The short-lived `os_swagger` session cookie was scoped to `/api/admin/docs`, so the browser refused to send it on the v1 docs URL (`/api/v1/admin/docs/index.html`) and the docs route returned `swagger session required`. The cookie path is now `/api`, covering both legacy and v1 docs routes.
- Lock the primary admin's Allowed Systems selector in the user editor; the first user always has access to every system and the badges are now read-only with all systems shown as allowed.
- Default `audioEncodingPreset` seeded into the settings table is now
`mp3_32k` (matching the dropdown's "(default)" label and the Go
Expand Down
4 changes: 3 additions & 1 deletion backend/internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,9 @@ func SetSwaggerCookie(c interface {
value := fmt.Sprintf("%d.%s", expiry, sig)

c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(SwaggerCookieName, value, maxAge, "/api/admin/docs", "", secure, true)
// Path "/api" so the cookie is sent on both the legacy
// /api/admin/docs/* route and the v1 /api/v1/admin/docs/* route.
c.SetCookie(SwaggerCookieName, value, maxAge, "/api", "", secure, true)
}

// ValidateSwaggerCookie checks that the swagger cookie value is valid and
Expand Down
3 changes: 2 additions & 1 deletion backend/internal/dirmonitor/watcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -714,7 +714,8 @@ func (s *Service) ingestCall(ctx context.Context, dw db.Dirmonitor, parsed *Pars
if err != nil {
slog.Error("dirmonitor: failed to build CAL message", "error", err)
} else {
s.hub.BroadcastCAL(calMsg, func(cl *ws.Client) bool {
_ = calMsg
s.hub.BroadcastCAL(calPayload, func(cl *ws.Client) bool {
return cl.CanReceive(system.ID, talkgroup.ID)
})
}
Expand Down
12 changes: 8 additions & 4 deletions backend/internal/handler/admin/imports/imports.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ func col(record []string, i int) string {
//
// @Summary Import talkgroups from CSV
// @Description Accepts a multipart CSV file with talkgroup data and a system_id form field. Supports OpenScanner format (talkgroup_id, label, name, tag_id, group_id, frequency, led, order) and rdio-scanner format (dec, hex, alpha_tag, description, tag, group, priority). Header rows are auto-detected; tag/group names are resolved to IDs automatically. Use mode=overwrite (default) to update existing talkgroups or mode=skip to leave existing talkgroups unchanged.
// @Tags Admin
// @Tags Admin,v1-Admin
// @Accept multipart/form-data
// @Produce json
// @Param system_id formData int true "System ID to import talkgroups into"
Expand All @@ -125,6 +125,7 @@ func col(record []string, i int) string {
// @Failure 500 {object} shared.ErrorResponse
// @Security BearerAuth
// @Router /admin/import/talkgroups [post]
// @Router /v1/admin/import/talkgroups [post]
func (h *Handler) ImportTalkgroups(c *gin.Context) {
ctx := c.Request.Context()

Expand Down Expand Up @@ -328,7 +329,7 @@ func (h *Handler) ImportTalkgroups(c *gin.Context) {
//
// @Summary Import units from CSV
// @Description Accepts a multipart CSV file with unit data and a system_id form field. Columns: unit_id, label, order. Header rows are auto-skipped. Use mode=overwrite (default) to update existing units or mode=skip to leave existing units unchanged.
// @Tags Admin
// @Tags Admin,v1-Admin
// @Accept multipart/form-data
// @Produce json
// @Param system_id formData int true "System ID to import units into"
Expand All @@ -339,6 +340,7 @@ func (h *Handler) ImportTalkgroups(c *gin.Context) {
// @Failure 500 {object} shared.ErrorResponse
// @Security BearerAuth
// @Router /admin/import/units [post]
// @Router /v1/admin/import/units [post]
func (h *Handler) ImportUnits(c *gin.Context) {
ctx := c.Request.Context()

Expand Down Expand Up @@ -617,7 +619,7 @@ func (h *Handler) importLabelOnly(c *gin.Context, kind string,
//
// @Summary Import groups from CSV
// @Description Accepts a multipart CSV file with a single 'label' column (header optional). Existing labels are skipped; new labels are inserted.
// @Tags Admin
// @Tags Admin,v1-Admin
// @Accept multipart/form-data
// @Produce json
// @Param file formData file true "CSV file"
Expand All @@ -626,6 +628,7 @@ func (h *Handler) importLabelOnly(c *gin.Context, kind string,
// @Failure 500 {object} shared.ErrorResponse
// @Security BearerAuth
// @Router /admin/import/groups [post]
// @Router /v1/admin/import/groups [post]
func (h *Handler) ImportGroups(c *gin.Context) {
h.importLabelOnly(c, "groups",
func(ctx context.Context, label string) (int64, bool, error) {
Expand All @@ -649,7 +652,7 @@ func (h *Handler) ImportGroups(c *gin.Context) {
//
// @Summary Import tags from CSV
// @Description Accepts a multipart CSV file with a single 'label' column (header optional). Existing labels are skipped; new labels are inserted.
// @Tags Admin
// @Tags Admin,v1-Admin
// @Accept multipart/form-data
// @Produce json
// @Param file formData file true "CSV file"
Expand All @@ -658,6 +661,7 @@ func (h *Handler) ImportGroups(c *gin.Context) {
// @Failure 500 {object} shared.ErrorResponse
// @Security BearerAuth
// @Router /admin/import/tags [post]
// @Router /v1/admin/import/tags [post]
func (h *Handler) ImportTags(c *gin.Context) {
h.importLabelOnly(c, "tags",
func(ctx context.Context, label string) (int64, bool, error) {
Expand Down
200 changes: 200 additions & 0 deletions backend/internal/handler/admin/imports/legacy_contract_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
package imports_test

// Phase N-0 — legacy contract freeze for the admin imports package.
//
// Pins today's response shape for the four admin CSV import endpoints:
// POST /api/admin/import/talkgroups
// POST /api/admin/import/units
// POST /api/admin/import/groups
// POST /api/admin/import/tags
//
// Plan reference: docs/plans/native-api-design-plan.md §4.1.

import (
"bytes"
"context"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/gin-gonic/gin"

"github.com/openscanner/openscanner/internal/audio"
"github.com/openscanner/openscanner/internal/auth"
"github.com/openscanner/openscanner/internal/db"
"github.com/openscanner/openscanner/internal/handler/routes"
"github.com/openscanner/openscanner/internal/logging"
"github.com/openscanner/openscanner/internal/ws"
)

func init() {
gin.SetMode(gin.TestMode)
logging.Configure(true, "")
}

func importsFixture(t *testing.T) (*gin.Engine, *db.Queries, string) {
t.Helper()
sqlDB, err := db.Open(":memory:")
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
q := db.New(sqlDB)

ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
pool := audio.NewWorkerPool(ctx)
proc := audio.NewProcessor(t.TempDir(), pool)

hub := ws.NewHub(q, "test")
r := gin.New()
rl := auth.NewRateLimiter(context.Background())
routes.RegisterRoutes(r, routes.Deps{
Queries: q, RateLimiter: rl, Processor: proc, Hub: hub, SQLDB: sqlDB, Version: "test",
})

hash, _ := auth.HashPassword("pw")
now := time.Now().Unix()
uid, err := q.CreateUser(context.Background(), db.CreateUserParams{
Username: "admin", PasswordHash: hash, Role: auth.RoleAdmin,
CreatedAt: now, UpdatedAt: now,
})
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
tok, _, err := auth.GenerateToken(uid, "admin", auth.RoleAdmin, 0)
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
return r, q, tok
}

func multipartCSV(t *testing.T, fields map[string]string, csvContent string) (*bytes.Buffer, string) {
t.Helper()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
for k, v := range fields {
if err := w.WriteField(k, v); err != nil {
t.Fatalf("WriteField %q: %v", k, err)
}
}
fw, err := w.CreateFormFile("file", "import.csv")
if err != nil {
t.Fatalf("CreateFormFile: %v", err)
}
if _, err := fw.Write([]byte(csvContent)); err != nil {
t.Fatalf("write csv: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("close writer: %v", err)
}
return &buf, w.FormDataContentType()
}

// TestImportsLegacyContract pins the response envelope for each of the four
// import endpoints. The talkgroups + units endpoints return
// {inserted, updated, skipped, failed[, message]}, while groups + tags
// return {inserted, skipped, failed[, message]} (no `updated` key — they are
// label-only insert-or-skip).
func TestImportsLegacyContract(t *testing.T) {
engine, q, tok := importsFixture(t)
bearer := func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+tok) }

sysID, err := q.CreateSystem(context.Background(), db.CreateSystemParams{SystemID: 42, Label: "S"})
if err != nil {
t.Fatalf("CreateSystem: %v", err)
}

tests := []struct {
name string
path string
fields map[string]string
csv string
wantKeys []string
notWantKeys []string
}{
{
name: "POST /api/admin/import/talkgroups",
path: "/api/admin/import/talkgroups",
fields: map[string]string{"system_id": fmt.Sprintf("%d", sysID)},
csv: "talkgroup_id,label,name\n100,Fire,Fire Dispatch\n200,EMS,EMS Dispatch\n",
wantKeys: []string{"inserted", "updated", "skipped", "failed"},
},
{
name: "POST /api/admin/import/units",
path: "/api/admin/import/units",
fields: map[string]string{"system_id": fmt.Sprintf("%d", sysID)},
csv: "unit_id,label\n1001,Alpha\n1002,Beta\n",
wantKeys: []string{"inserted", "updated", "skipped", "failed"},
},
{
name: "POST /api/admin/import/groups",
path: "/api/admin/import/groups",
fields: nil,
csv: "label\nPolice\nFire\n",
wantKeys: []string{"inserted", "skipped", "failed"},
notWantKeys: []string{"updated"},
},
{
name: "POST /api/admin/import/tags",
path: "/api/admin/import/tags",
fields: nil,
csv: "label\nDispatch\nTac\n",
wantKeys: []string{"inserted", "skipped", "failed"},
notWantKeys: []string{"updated"},
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
body, ct := multipartCSV(t, tc.fields, tc.csv)
req := httptest.NewRequest(http.MethodPost, tc.path, body)
req.Header.Set("Content-Type", ct)
bearer(req)
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)

if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String())
}

var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v\nbody: %s", err, w.Body.String())
}
for _, k := range tc.wantKeys {
if _, ok := resp[k]; !ok {
t.Errorf("response missing key %q (got: %s)", k, w.Body.String())
}
}
for _, k := range tc.notWantKeys {
if _, ok := resp[k]; ok {
t.Errorf("response unexpectedly has key %q (got: %s)", k, w.Body.String())
}
}
})
}
}

// TestImports_RequireAdmin pins the 401/403 gates on the import endpoints.
func TestImports_RequireAdmin(t *testing.T) {
engine, _, _ := importsFixture(t)

for _, p := range []string{
"/api/admin/import/talkgroups",
"/api/admin/import/units",
"/api/admin/import/groups",
"/api/admin/import/tags",
} {
req := httptest.NewRequest(http.MethodPost, p, nil)
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("%s no auth: status = %d, want 401", p, w.Code)
}
}
}
Loading
Loading