merge: pr-4 sweatbox mutations into pr-6 base

This commit is contained in:
Reese Norris
2026-07-28 12:04:46 -04:00
17 changed files with 1403 additions and 36 deletions

View File

@@ -139,11 +139,9 @@ func (h *SweatboxHost) tickOnce(dt time.Duration) {
}
// AirportLoadResult is the structured outcome of loading .apt text (HTTP / tests).
type AirportLoadResult struct {
ICAO string `json:"icao"`
Surfaces int `json:"surfaces"`
Errors []string `json:"errors"`
}
// Type alias of serviceapi.SweatboxAirportLoadResponse so FSD service HTTP and
// the public /api/v1 envelope share a single wire shape (design §D).
type AirportLoadResult = serviceapi.SweatboxAirportLoadResponse
// LoadAirport parses .apt text and installs it on the engine.
// Does not remove existing aircraft; use LoadAirportReplace for that flow.

View File

@@ -21,6 +21,14 @@ type SweatboxScenarioResponse struct {
Errors []string `json:"errors"`
}
// SweatboxAirportLoadResponse is the public/FSD airport load result shape
// (POST /api/v1/sweatbox/airport envelope data and FSD POST /sweatbox/airport body).
type SweatboxAirportLoadResponse struct {
ICAO string `json:"icao"`
Surfaces int `json:"surfaces"`
Errors []string `json:"errors"`
}
// SweatboxOpsJSON is returned for GET /sweatbox/ops.
type SweatboxOpsJSON struct {
ElapsedSec float64 `json:"elapsed_sec"`

View File

@@ -16,7 +16,7 @@ JSON under `/api/v1` for **operator automation** and map polling. First-party UI
| Account | `GET /account`, `POST /account/password`, `POST /account/delete` | any session; change password (current required) + soft-delete account (optional hard-delete via `ALLOW_PERMANENT_ACCOUNT_DELETE`, default false). CSRF; password step-up on delete |
| Users (directory) | `GET /usereditor[?q&rating&sort&dir&page&cid&new&flash]`, `POST /usereditor/create`, `POST /usereditor/update` | **Supervisor+**; create + name/password + ratings (network ceiling ≤ actor; full pilot scale). CSRF; URL-owned filters; `dir_*` on POST for PRG |
| Config editor | `GET/POST /configeditor`, `POST /configeditor/create-token`, `POST /configeditor/reset-secret` | Administrator; CSRF on mutations |
| Sweatbox | `GET /sweatbox`, form POSTs under `/sweatbox/*` | **Instructor1+**; CSRF on mutations; proxies FSD service HTTP |
| Sweatbox | `GET /sweatbox`, form POSTs under `/sweatbox/*` | **Instructor1+**; CSRF on mutations; proxies FSD service HTTP. Operator JSON: `/api/v1/sweatbox/*` (mutations + `/session`) |
| Airport editor | `GET /airport-editor`, `POST /airport-editor/download-apt`, `POST /airport-editor/download-air` | **Instructor1+**; CSRF on download; **echo-only** (no disk/DB persistence of `.apt`/`.air`). Validate API: `POST /api/v1/editor/validate-*` also I1+ |
JSON under `/api/v1` remains for external consumers and map polling. Session dual-accept mutations work with **cookie + CSRF only** (no `Authorization` header required).
@@ -103,10 +103,31 @@ Canonical OpenAPI file: `internal/web/openapi/openapi.v1.yaml` (`//go:embed`). N
**Outside microversion reject:** `/api/v1/data/*`, `/api/v1/fsd-jwt`, auth login/refresh, discovery, OpenAPI. Resource groups (`/user`, `/users`, `/config`, `/fsdconn`, `/sweatbox`, `/editor`) reject unknown/invalid pins with **400** envelope.
**Stability tiers:** enveloped user/users/config/fsdconn/editor routes are **Stable** (goldens under `testdata/api_v1/<pin>/`). New expansion routes may ship **Provisional** (shape may change without a microversion bump while Provisional). Design: `docs/design/rest-api-versioning.md`.
**Stability tiers:** enveloped user/users/config/fsdconn/editor routes and sweatbox mutations/`session` are **Stable** (goldens under `testdata/api_v1/<pin>/`). Account JSON is **Provisional**. Design: `docs/design/rest-api-versioning.md`.
Baseline pin (first supported): **`2026-07-28`**.
### Sweatbox operator JSON (Stable)
Instructor1+ dual-accept (Bearer API token recommended for automation; cookie + CSRF for browser). Proxies FSD service HTTP only — web never imports `internal/sweatbox` / `internal/server`.
| Method | Path | Notes |
|--------|------|-------|
| GET | `/api/v1/sweatbox/state` | **Raw** FSD body (PE poll; non-envelope) |
| GET | `/api/v1/sweatbox/ops` | **Raw** FSD body (PE; non-envelope) |
| GET | `/api/v1/sweatbox/session` | **Envelope**; `data` = state snapshot |
| POST | `/api/v1/sweatbox/airport` | JSON `{"text":"…","replace":false}` → FSD `text/plain` + optional `?replace=1` |
| POST | `/api/v1/sweatbox/scenario` | JSON `{"text":"…"}` → FSD `text/plain` |
| POST | `/api/v1/sweatbox/command` | JSON `{callsign,command}`; soft-fail stays **200** + `data.ok=false` |
| POST | `/api/v1/sweatbox/pause` | FSD 204 → public **200** envelope `data: null` |
| POST | `/api/v1/sweatbox/unpause` | same |
| DELETE | `/api/v1/sweatbox/aircraft/:callsign` | FSD 204 → public **200** |
| DELETE | `/api/v1/sweatbox/aircraft` | requires `confirm=true` (JSON body or query `confirm=1`) |
Max body for airport/scenario: **2 MiB**. Status mapping is locked in `docs/design/rest-api-versioning.md` §D (404 disabled, 409 conflict, 413 too large, 502 unreachable).
**Blast radius:** admin-minted API tokens are Administrator-equivalent until scopes exist. Prefer short TTLs; store tokens as secrets.
---
## Network Ratings
@@ -710,6 +731,9 @@ Retrieve all servers in JSON format (same as openfsd-servers.json).
---
#### GET /api/v1/sweatbox/session
Instructor1+ enveloped state snapshot (`data` = same fields as raw `/state`). Prefer this over raw `/state` for third-party clients. See **Sweatbox operator JSON** above for mutations and status mapping.
#### GET /api/v1/data/openfsd-data.json
Retrieve cached datafeed of online pilots and ATC.

View File

@@ -1,12 +1,65 @@
package web
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
"github.com/renorris/openfsd/pkg/protocol"
"github.com/renorris/openfsd/internal/auth"
"github.com/renorris/openfsd/internal/serviceapi"
)
// Public JSON request bodies for sweatbox mutations (design §D).
// Airport/scenario are JSON on the public API; FSD receives text/plain of the text field.
type sweatboxAirportJSONRequest struct {
Text string `json:"text"`
Replace bool `json:"replace"`
}
type sweatboxScenarioJSONRequest struct {
Text string `json:"text"`
}
type sweatboxDeleteAllJSONRequest struct {
Confirm bool `json:"confirm"`
}
// requireSweatboxI1 returns claims when the actor is Instructor1+; otherwise writes 403 and ok=false.
func requireSweatboxI1(c *gin.Context) (claims *auth.CustomClaims, ok bool) {
claims = getJwtContext(c)
if claims == nil || !canAccessSweatbox(claims.NetworkRating) {
writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden)
return nil, false
}
return claims, true
}
// writeSweatboxProxyErr maps transport / unexpected FSD statuses to enveloped 502.
func writeSweatboxProxyErr(c *gin.Context, msg string) {
res := newAPIV1Failure(msg)
writeAPIV1Response(c, http.StatusBadGateway, &res)
}
func writeSweatboxInvalidJSON(c *gin.Context) {
writeSweatboxProxyErr(c, "FSD service returned invalid JSON")
}
func writeSweatboxDisabled(c *gin.Context) {
res := newAPIV1Failure("Sweatbox is not enabled on the FSD server")
writeAPIV1Response(c, http.StatusNotFound, &res)
}
// limitSweatboxAPISmallBody caps non-upload mutation bodies (pause/delete/command).
func limitSweatboxAPISmallBody(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, sweatboxWebSmallFormMaxBody)
}
// handleAPISweatboxState GET /api/v1/sweatbox/state
//
// Authenticated read proxy of FSD service HTTP GET /sweatbox/state.
@@ -16,16 +69,13 @@ import (
// On success (and FSD 404 disabled), the FSD JSON body is passed through so
// the PE client can use the same shape as the service control plane.
func (s *Server) handleAPISweatboxState(c *gin.Context) {
claims := getJwtContext(c)
if claims == nil || claims.NetworkRating < protocol.NetworkRatingInstructor1 {
writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden)
if _, ok := requireSweatboxI1(c); !ok {
return
}
status, body, err := s.fsdSweatboxDo(http.MethodGet, "/sweatbox/state", "", nil)
if err != nil {
res := newAPIV1Failure("FSD HTTP service unreachable")
writeAPIV1Response(c, http.StatusBadGateway, &res)
writeSweatboxProxyErr(c, "FSD HTTP service unreachable")
return
}
@@ -34,11 +84,9 @@ func (s *Server) handleAPISweatboxState(c *gin.Context) {
// 404 = sweatbox disabled on FSD (no /sweatbox/* routes).
c.Data(status, "application/json", body)
case http.StatusUnauthorized, http.StatusForbidden:
res := newAPIV1Failure("FSD service rejected the request")
writeAPIV1Response(c, http.StatusBadGateway, &res)
writeSweatboxProxyErr(c, "FSD service rejected the request")
default:
res := newAPIV1Failure("FSD service returned unexpected status")
writeAPIV1Response(c, http.StatusBadGateway, &res)
writeSweatboxProxyErr(c, "FSD service returned unexpected status")
}
}
@@ -48,16 +96,13 @@ func (s *Server) handleAPISweatboxState(c *gin.Context) {
// Optional for PE; state already includes most of these fields.
// Min rating Instructor1+.
func (s *Server) handleAPISweatboxOps(c *gin.Context) {
claims := getJwtContext(c)
if claims == nil || claims.NetworkRating < protocol.NetworkRatingInstructor1 {
writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden)
if _, ok := requireSweatboxI1(c); !ok {
return
}
status, body, err := s.fsdSweatboxDo(http.MethodGet, "/sweatbox/ops", "", nil)
if err != nil {
res := newAPIV1Failure("FSD HTTP service unreachable")
writeAPIV1Response(c, http.StatusBadGateway, &res)
writeSweatboxProxyErr(c, "FSD HTTP service unreachable")
return
}
@@ -65,7 +110,409 @@ func (s *Server) handleAPISweatboxOps(c *gin.Context) {
case http.StatusOK, http.StatusNotFound:
c.Data(status, "application/json", body)
default:
res := newAPIV1Failure("FSD service returned unexpected status")
writeAPIV1Response(c, http.StatusBadGateway, &res)
writeSweatboxProxyErr(c, "FSD service returned unexpected status")
}
}
// handleAPISweatboxSession GET /api/v1/sweatbox/session
//
// Enveloped parallel of GET /state for third-party / operator clients.
// data = serviceapi.SweatboxStateJSON. Raw /state remains for PE.
func (s *Server) handleAPISweatboxSession(c *gin.Context) {
if _, ok := requireSweatboxI1(c); !ok {
return
}
status, body, err := s.fsdSweatboxDo(http.MethodGet, "/sweatbox/state", "", nil)
if err != nil {
writeSweatboxProxyErr(c, "FSD HTTP service unreachable")
return
}
switch status {
case http.StatusOK:
var st serviceapi.SweatboxStateJSON
if err := json.Unmarshal(body, &st); err != nil {
writeSweatboxInvalidJSON(c)
return
}
if st.Aircraft == nil {
st.Aircraft = []serviceapi.SweatboxAircraftJSON{}
}
res := newAPIV1Success(&st)
writeAPIV1Response(c, http.StatusOK, &res)
case http.StatusNotFound:
writeSweatboxDisabled(c)
case http.StatusUnauthorized, http.StatusForbidden:
writeSweatboxProxyErr(c, "FSD service rejected the request")
default:
writeSweatboxProxyErr(c, "FSD service returned unexpected status")
}
}
// handleAPISweatboxAirport POST /api/v1/sweatbox/airport
//
// JSON {"text","replace"} → FSD POST /sweatbox/airport[?replace=1] text/plain.
// Status mapping per design §D.
func (s *Server) handleAPISweatboxAirport(c *gin.Context) {
claims, ok := requireSweatboxI1(c)
if !ok {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, sweatboxWebMaxBody)
var req sweatboxAirportJSONRequest
if err := c.ShouldBindJSON(&req); err != nil {
if isRequestTooLarge(err) {
res := newAPIV1Failure("Airport payload too large (max 2 MiB)")
writeAPIV1Response(c, http.StatusRequestEntityTooLarge, &res)
return
}
res := newAPIV1Failure("invalid JSON body")
writeAPIV1Response(c, http.StatusBadRequest, &res)
return
}
text := req.Text
if len(bytes.TrimSpace([]byte(text))) == 0 {
res := newAPIV1Failure("Airport file or text is required")
writeAPIV1Response(c, http.StatusBadRequest, &res)
return
}
if len(text) > sweatboxWebMaxBody {
res := newAPIV1Failure("Airport payload too large (max 2 MiB)")
writeAPIV1Response(c, http.StatusRequestEntityTooLarge, &res)
return
}
path := "/sweatbox/airport"
if req.Replace {
path += "?replace=1"
}
status, respBody, err := s.fsdSweatboxDo(http.MethodPost, path, "text/plain; charset=utf-8", strings.NewReader(text))
if err != nil {
writeSweatboxProxyErr(c, "FSD HTTP service unreachable")
return
}
switch status {
case http.StatusOK:
var data serviceapi.SweatboxAirportLoadResponse
if err := json.Unmarshal(respBody, &data); err != nil {
writeSweatboxInvalidJSON(c)
return
}
if data.Errors == nil {
data.Errors = []string{}
}
slog.Info("sweatbox api airport load",
"cid", claims.CID,
"icao", data.ICAO,
"surfaces", data.Surfaces,
"replace", req.Replace,
"content_length", len(text),
)
res := newAPIV1Success(&data)
writeAPIV1Response(c, http.StatusOK, &res)
case http.StatusBadRequest:
res := newAPIV1Failure(firstJSONError(respBody, "Invalid airport file"))
writeAPIV1Response(c, http.StatusBadRequest, &res)
case http.StatusConflict:
res := newAPIV1Failure(firstJSONError(respBody, "Aircraft present — check Replace to clear them, or delete aircraft first"))
writeAPIV1Response(c, http.StatusConflict, &res)
case http.StatusNotFound:
writeSweatboxDisabled(c)
case http.StatusRequestEntityTooLarge:
res := newAPIV1Failure("Airport payload too large (max 2 MiB)")
writeAPIV1Response(c, http.StatusRequestEntityTooLarge, &res)
default:
writeSweatboxProxyErr(c, "FSD service returned unexpected status")
}
}
// handleAPISweatboxScenario POST /api/v1/sweatbox/scenario
//
// JSON {"text"} → FSD POST /sweatbox/scenario text/plain.
func (s *Server) handleAPISweatboxScenario(c *gin.Context) {
claims, ok := requireSweatboxI1(c)
if !ok {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, sweatboxWebMaxBody)
var req sweatboxScenarioJSONRequest
if err := c.ShouldBindJSON(&req); err != nil {
if isRequestTooLarge(err) {
res := newAPIV1Failure("Scenario payload too large (max 2 MiB)")
writeAPIV1Response(c, http.StatusRequestEntityTooLarge, &res)
return
}
res := newAPIV1Failure("invalid JSON body")
writeAPIV1Response(c, http.StatusBadRequest, &res)
return
}
text := req.Text
if len(bytes.TrimSpace([]byte(text))) == 0 {
res := newAPIV1Failure("Scenario file or text is required")
writeAPIV1Response(c, http.StatusBadRequest, &res)
return
}
if len(text) > sweatboxWebMaxBody {
res := newAPIV1Failure("Scenario payload too large (max 2 MiB)")
writeAPIV1Response(c, http.StatusRequestEntityTooLarge, &res)
return
}
status, respBody, err := s.fsdSweatboxDo(http.MethodPost, "/sweatbox/scenario", "text/plain; charset=utf-8", strings.NewReader(text))
if err != nil {
writeSweatboxProxyErr(c, "FSD HTTP service unreachable")
return
}
switch status {
case http.StatusOK:
var data serviceapi.SweatboxScenarioResponse
if err := json.Unmarshal(respBody, &data); err != nil {
writeSweatboxInvalidJSON(c)
return
}
if data.Errors == nil {
data.Errors = []string{}
}
slog.Info("sweatbox api scenario load",
"cid", claims.CID,
"loaded", data.Loaded,
"error_count", len(data.Errors),
"content_length", len(text),
)
res := newAPIV1Success(&data)
writeAPIV1Response(c, http.StatusOK, &res)
case http.StatusBadRequest:
res := newAPIV1Failure(firstJSONError(respBody, "Invalid scenario file"))
writeAPIV1Response(c, http.StatusBadRequest, &res)
case http.StatusConflict:
res := newAPIV1Failure(firstJSONError(respBody, "No airport loaded — load a .apt first"))
writeAPIV1Response(c, http.StatusConflict, &res)
case http.StatusNotFound:
writeSweatboxDisabled(c)
case http.StatusRequestEntityTooLarge:
res := newAPIV1Failure("Scenario payload too large (max 2 MiB)")
writeAPIV1Response(c, http.StatusRequestEntityTooLarge, &res)
default:
writeSweatboxProxyErr(c, "FSD service returned unexpected status")
}
}
// handleAPISweatboxCommand POST /api/v1/sweatbox/command
//
// JSON SweatboxCommandRequest → FSD. Soft-fail (ok:false) stays HTTP 200 + envelope data.
func (s *Server) handleAPISweatboxCommand(c *gin.Context) {
claims, ok := requireSweatboxI1(c)
if !ok {
return
}
limitSweatboxAPISmallBody(c)
var req serviceapi.SweatboxCommandRequest
if err := c.ShouldBindJSON(&req); err != nil {
if isRequestTooLarge(err) {
res := newAPIV1Failure("Request body too large")
writeAPIV1Response(c, http.StatusRequestEntityTooLarge, &res)
return
}
res := newAPIV1Failure("invalid JSON body")
writeAPIV1Response(c, http.StatusBadRequest, &res)
return
}
req.Command = strings.TrimSpace(req.Command)
req.Callsign = strings.TrimSpace(req.Callsign)
if req.Command == "" {
res := newAPIV1Failure("Command is required")
writeAPIV1Response(c, http.StatusBadRequest, &res)
return
}
payload, err := json.Marshal(req)
if err != nil {
res := newAPIV1Failure("Unable to encode command")
writeAPIV1Response(c, http.StatusInternalServerError, &res)
return
}
status, respBody, err := s.fsdSweatboxDo(http.MethodPost, "/sweatbox/command", "application/json", bytes.NewReader(payload))
if err != nil {
writeSweatboxProxyErr(c, "FSD HTTP service unreachable")
return
}
switch status {
case http.StatusOK:
var data serviceapi.SweatboxCommandResponse
if err := json.Unmarshal(respBody, &data); err != nil {
writeSweatboxInvalidJSON(c)
return
}
slog.Info("sweatbox api command",
"cid", claims.CID,
"ok", data.OK,
"callsign", req.Callsign,
)
// Soft-fail (ok:false) stays 200 with data — do not elevate to 4xx.
res := newAPIV1Success(&data)
writeAPIV1Response(c, http.StatusOK, &res)
case http.StatusBadRequest:
res := newAPIV1Failure("Invalid command request")
writeAPIV1Response(c, http.StatusBadRequest, &res)
case http.StatusConflict:
res := newAPIV1Failure(firstJSONError(respBody, "No airport loaded"))
writeAPIV1Response(c, http.StatusConflict, &res)
case http.StatusNotFound:
writeSweatboxDisabled(c)
default:
writeSweatboxProxyErr(c, "FSD service returned unexpected status")
}
}
// handleAPISweatboxPause POST /api/v1/sweatbox/pause — FSD 204/200 → public 200 envelope.
func (s *Server) handleAPISweatboxPause(c *gin.Context) {
claims, ok := requireSweatboxI1(c)
if !ok {
return
}
limitSweatboxAPISmallBody(c)
// Drain (and enforce) body cap; body is unused.
_, _ = io.Copy(io.Discard, c.Request.Body)
status, _, err := s.fsdSweatboxDo(http.MethodPost, "/sweatbox/pause", "", nil)
if err != nil {
writeSweatboxProxyErr(c, "FSD HTTP service unreachable")
return
}
switch status {
case http.StatusNoContent, http.StatusOK:
slog.Info("sweatbox api pause", "cid", claims.CID)
res := newAPIV1Success(nil)
writeAPIV1Response(c, http.StatusOK, &res)
case http.StatusNotFound:
writeSweatboxDisabled(c)
default:
writeSweatboxProxyErr(c, "FSD service returned unexpected status")
}
}
// handleAPISweatboxUnpause POST /api/v1/sweatbox/unpause — FSD 204/200 → public 200 envelope.
func (s *Server) handleAPISweatboxUnpause(c *gin.Context) {
claims, ok := requireSweatboxI1(c)
if !ok {
return
}
limitSweatboxAPISmallBody(c)
_, _ = io.Copy(io.Discard, c.Request.Body)
status, _, err := s.fsdSweatboxDo(http.MethodPost, "/sweatbox/unpause", "", nil)
if err != nil {
writeSweatboxProxyErr(c, "FSD HTTP service unreachable")
return
}
switch status {
case http.StatusNoContent, http.StatusOK:
slog.Info("sweatbox api unpause", "cid", claims.CID)
res := newAPIV1Success(nil)
writeAPIV1Response(c, http.StatusOK, &res)
case http.StatusNotFound:
writeSweatboxDisabled(c)
default:
writeSweatboxProxyErr(c, "FSD service returned unexpected status")
}
}
// handleAPISweatboxDeleteAircraft DELETE /api/v1/sweatbox/aircraft/:callsign
func (s *Server) handleAPISweatboxDeleteAircraft(c *gin.Context) {
claims, ok := requireSweatboxI1(c)
if !ok {
return
}
limitSweatboxAPISmallBody(c)
_, _ = io.Copy(io.Discard, c.Request.Body)
cs := strings.TrimSpace(c.Param("callsign"))
if cs == "" {
res := newAPIV1Failure("Callsign is required")
writeAPIV1Response(c, http.StatusBadRequest, &res)
return
}
path := "/sweatbox/aircraft/" + url.PathEscape(cs)
status, _, err := s.fsdSweatboxDo(http.MethodDelete, path, "", nil)
if err != nil {
writeSweatboxProxyErr(c, "FSD HTTP service unreachable")
return
}
switch status {
case http.StatusNoContent, http.StatusOK:
slog.Info("sweatbox api delete aircraft", "cid", claims.CID, "callsign", cs)
res := newAPIV1Success(nil)
writeAPIV1Response(c, http.StatusOK, &res)
case http.StatusNotFound:
// Unknown callsign or sweatbox disabled — same HTTP 404 from FSD.
res := newAPIV1Failure("Aircraft not found (or sweatbox disabled): " + cs)
writeAPIV1Response(c, http.StatusNotFound, &res)
default:
writeSweatboxProxyErr(c, "FSD service returned unexpected status")
}
}
// handleAPISweatboxDeleteAllAircraft DELETE /api/v1/sweatbox/aircraft
//
// Requires confirm=true (JSON body or query confirm=1).
func (s *Server) handleAPISweatboxDeleteAllAircraft(c *gin.Context) {
claims, ok := requireSweatboxI1(c)
if !ok {
return
}
// Always cap body (even when confirm is only via query).
limitSweatboxAPISmallBody(c)
confirmed := queryTruthy(c.Query("confirm"))
body, err := io.ReadAll(c.Request.Body)
if err != nil {
if isRequestTooLarge(err) {
res := newAPIV1Failure("Request body too large")
writeAPIV1Response(c, http.StatusRequestEntityTooLarge, &res)
return
}
res := newAPIV1Failure("invalid JSON body")
writeAPIV1Response(c, http.StatusBadRequest, &res)
return
}
if !confirmed && len(bytes.TrimSpace(body)) > 0 {
var req sweatboxDeleteAllJSONRequest
if err := json.Unmarshal(body, &req); err != nil {
res := newAPIV1Failure("invalid JSON body")
writeAPIV1Response(c, http.StatusBadRequest, &res)
return
}
confirmed = req.Confirm
}
if !confirmed {
res := newAPIV1Failure("confirm required")
writeAPIV1Response(c, http.StatusBadRequest, &res)
return
}
status, _, err := s.fsdSweatboxDo(http.MethodDelete, "/sweatbox/aircraft", "", nil)
if err != nil {
writeSweatboxProxyErr(c, "FSD HTTP service unreachable")
return
}
switch status {
case http.StatusNoContent, http.StatusOK:
slog.Info("sweatbox api delete-all aircraft", "cid", claims.CID)
res := newAPIV1Success(nil)
writeAPIV1Response(c, http.StatusOK, &res)
case http.StatusNotFound:
writeSweatboxDisabled(c)
default:
writeSweatboxProxyErr(c, "FSD service returned unexpected status")
}
}

View File

@@ -228,3 +228,462 @@ func TestSweatboxPageNoPEScriptWhenUnavailable(t *testing.T) {
t.Fatal("should not load sweatbox.js when control plane unavailable")
}
}
func createInstructor1(t *testing.T, env *testAPIEnv) (cid int, access string) {
t.Helper()
pass := "i1pass123"
u := &db.User{
Password: pass,
FirstName: strPtr("Inst"),
LastName: strPtr("One"),
NetworkRating: int(protocol.NetworkRatingInstructor1),
}
require.NoError(t, env.server.dbRepo.UserRepo.CreateUser(u))
access, _ = env.login(t, u.CID, pass)
return u.CID, access
}
func TestAPISweatboxSessionEnveloped(t *testing.T) {
m := &sweatboxMock{}
fsd := httptest.NewServer(m.handler())
t.Cleanup(fsd.Close)
env := setupTestAPI(t)
env.server.cfg.FsdHttpServiceAddress = fsd.URL
_, access := createInstructor1(t, env)
w := env.doJSON(t, http.MethodGet, "/api/v1/sweatbox/session", nil, access)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var res APIV1Response
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res))
require.Nil(t, res.Err)
require.Equal(t, "v1", res.Version)
data, err := json.Marshal(res.Data)
require.NoError(t, err)
var st serviceapi.SweatboxStateJSON
require.NoError(t, json.Unmarshal(data, &st))
assert.Equal(t, "KBTV", st.ICAO)
require.Len(t, st.Aircraft, 1)
assert.Equal(t, "AAL123", st.Aircraft[0].Callsign)
assertGoldenJSON(t, "2026-07-28/sweatbox_session_ok.json", w.Body.Bytes())
}
func TestAPISweatboxSessionDisabled404(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
})
fsd := httptest.NewServer(mux)
t.Cleanup(fsd.Close)
env := setupTestAPI(t)
env.server.cfg.FsdHttpServiceAddress = fsd.URL
access, _ := env.login(t, env.admin.CID, env.adminPass)
w := env.doJSON(t, http.MethodGet, "/api/v1/sweatbox/session", nil, access)
require.Equal(t, http.StatusNotFound, w.Code, w.Body.String())
var res APIV1Response
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res))
require.NotNil(t, res.Err)
assert.Contains(t, *res.Err, "not enabled")
}
func TestAPISweatboxSessionForbiddenForObserver(t *testing.T) {
env := setupTestAPI(t)
access, _ := env.login(t, env.observer.CID, env.observerPass)
w := env.doJSON(t, http.MethodGet, "/api/v1/sweatbox/session", nil, access)
require.Equal(t, http.StatusForbidden, w.Code, w.Body.String())
}
func TestAPISweatboxAirportJSONProxiesTextPlain(t *testing.T) {
m := &sweatboxMock{}
fsd := httptest.NewServer(m.handler())
t.Cleanup(fsd.Close)
env := setupTestAPI(t)
env.server.cfg.FsdHttpServiceAddress = fsd.URL
_, access := createInstructor1(t, env)
apt := "icao=KBTV\n//test apt"
w := env.doJSON(t, http.MethodPost, "/api/v1/sweatbox/airport", map[string]any{
"text": apt,
"replace": true,
}, access)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
// FSD received text/plain body and replace query
assert.Equal(t, apt, m.airportBody)
assert.Contains(t, m.airportPath, "replace=1")
assert.Equal(t, "text/plain; charset=utf-8", m.airportContentType)
var res APIV1Response
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res))
require.Nil(t, res.Err)
data, err := json.Marshal(res.Data)
require.NoError(t, err)
var loaded serviceapi.SweatboxAirportLoadResponse
require.NoError(t, json.Unmarshal(data, &loaded))
assert.Equal(t, "KBTV", loaded.ICAO)
assert.Equal(t, 3, loaded.Surfaces)
assertGoldenJSON(t, "2026-07-28/sweatbox_airport_ok.json", w.Body.Bytes())
}
func TestAPISweatboxAirportConflict409(t *testing.T) {
m := &sweatboxMock{airportConflict: true}
fsd := httptest.NewServer(m.handler())
t.Cleanup(fsd.Close)
env := setupTestAPI(t)
env.server.cfg.FsdHttpServiceAddress = fsd.URL
access, _ := env.login(t, env.admin.CID, env.adminPass)
w := env.doJSON(t, http.MethodPost, "/api/v1/sweatbox/airport", map[string]any{
"text": "icao=KBTV\n",
}, access)
require.Equal(t, http.StatusConflict, w.Code, w.Body.String())
var res APIV1Response
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res))
require.NotNil(t, res.Err)
assert.Contains(t, *res.Err, "aircraft")
}
func TestAPISweatboxAirportEmptyText400(t *testing.T) {
env := setupTestAPI(t)
access, _ := env.login(t, env.admin.CID, env.adminPass)
w := env.doJSON(t, http.MethodPost, "/api/v1/sweatbox/airport", map[string]any{
"text": " ",
}, access)
require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())
}
func TestAPISweatboxScenarioOK(t *testing.T) {
m := &sweatboxMock{}
fsd := httptest.NewServer(m.handler())
t.Cleanup(fsd.Close)
env := setupTestAPI(t)
env.server.cfg.FsdHttpServiceAddress = fsd.URL
access, _ := env.login(t, env.admin.CID, env.adminPass)
air := "AAL123 B738 ..."
w := env.doJSON(t, http.MethodPost, "/api/v1/sweatbox/scenario", map[string]any{
"text": air,
}, access)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
assert.Equal(t, air, m.scenarioBody)
assert.Equal(t, "text/plain; charset=utf-8", m.scenarioContentType)
var res APIV1Response
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res))
require.Nil(t, res.Err)
assertGoldenJSON(t, "2026-07-28/sweatbox_scenario_ok.json", w.Body.Bytes())
}
func TestAPISweatboxCommandSoftFailStays200(t *testing.T) {
m := &sweatboxMock{commandSoftFail: true}
fsd := httptest.NewServer(m.handler())
t.Cleanup(fsd.Close)
env := setupTestAPI(t)
env.server.cfg.FsdHttpServiceAddress = fsd.URL
access, _ := env.login(t, env.admin.CID, env.adminPass)
w := env.doJSON(t, http.MethodPost, "/api/v1/sweatbox/command", map[string]any{
"callsign": "AAL123",
"command": "xyz",
}, access)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
var res APIV1Response
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res))
require.Nil(t, res.Err, "soft-fail must not set envelope err")
data, err := json.Marshal(res.Data)
require.NoError(t, err)
var cmd serviceapi.SweatboxCommandResponse
require.NoError(t, json.Unmarshal(data, &cmd))
assert.False(t, cmd.OK)
assert.Contains(t, cmd.Message, "Unknown command")
assertGoldenJSON(t, "2026-07-28/sweatbox_command_softfail.json", w.Body.Bytes())
}
func TestAPISweatboxCommandOK(t *testing.T) {
m := &sweatboxMock{}
fsd := httptest.NewServer(m.handler())
t.Cleanup(fsd.Close)
env := setupTestAPI(t)
env.server.cfg.FsdHttpServiceAddress = fsd.URL
access, _ := env.login(t, env.admin.CID, env.adminPass)
w := env.doJSON(t, http.MethodPost, "/api/v1/sweatbox/command", map[string]any{
"callsign": "AAL123",
"command": "taxi A",
}, access)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
assert.Equal(t, "AAL123", m.lastCommand.Callsign)
assert.Equal(t, "taxi A", m.lastCommand.Command)
var res APIV1Response
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res))
require.Nil(t, res.Err)
assertGoldenJSON(t, "2026-07-28/sweatbox_command_ok.json", w.Body.Bytes())
}
func TestAPISweatboxCommandMissing400(t *testing.T) {
env := setupTestAPI(t)
access, _ := env.login(t, env.admin.CID, env.adminPass)
w := env.doJSON(t, http.MethodPost, "/api/v1/sweatbox/command", map[string]any{
"callsign": "AAL123",
"command": " ",
}, access)
require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())
}
func TestAPISweatboxPauseUnpause204To200(t *testing.T) {
m := &sweatboxMock{}
fsd := httptest.NewServer(m.handler())
t.Cleanup(fsd.Close)
env := setupTestAPI(t)
env.server.cfg.FsdHttpServiceAddress = fsd.URL
access, _ := env.login(t, env.admin.CID, env.adminPass)
w := env.doJSON(t, http.MethodPost, "/api/v1/sweatbox/pause", nil, access)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
assert.True(t, m.paused)
assertGoldenJSON(t, "2026-07-28/sweatbox_pause_ok.json", w.Body.Bytes())
w = env.doJSON(t, http.MethodPost, "/api/v1/sweatbox/unpause", nil, access)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
assert.False(t, m.paused)
assertGoldenJSON(t, "2026-07-28/sweatbox_unpause_ok.json", w.Body.Bytes())
}
func TestAPISweatboxDeleteAircraft(t *testing.T) {
m := &sweatboxMock{}
fsd := httptest.NewServer(m.handler())
t.Cleanup(fsd.Close)
env := setupTestAPI(t)
env.server.cfg.FsdHttpServiceAddress = fsd.URL
access, _ := env.login(t, env.admin.CID, env.adminPass)
w := env.doJSON(t, http.MethodDelete, "/api/v1/sweatbox/aircraft/AAL123", nil, access)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
assertGoldenJSON(t, "2026-07-28/sweatbox_delete_ok.json", w.Body.Bytes())
}
func TestAPISweatboxDeleteAllRequiresConfirm(t *testing.T) {
m := &sweatboxMock{}
fsd := httptest.NewServer(m.handler())
t.Cleanup(fsd.Close)
env := setupTestAPI(t)
env.server.cfg.FsdHttpServiceAddress = fsd.URL
access, _ := env.login(t, env.admin.CID, env.adminPass)
// Missing confirm → 400
w := env.doJSON(t, http.MethodDelete, "/api/v1/sweatbox/aircraft", nil, access)
require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())
var res APIV1Response
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res))
require.NotNil(t, res.Err)
assert.Contains(t, *res.Err, "confirm")
// JSON confirm
w = env.doJSON(t, http.MethodDelete, "/api/v1/sweatbox/aircraft", map[string]any{
"confirm": true,
}, access)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
assertGoldenJSON(t, "2026-07-28/sweatbox_delete_all_ok.json", w.Body.Bytes())
}
func TestAPISweatboxDeleteAllQueryConfirm(t *testing.T) {
m := &sweatboxMock{}
fsd := httptest.NewServer(m.handler())
t.Cleanup(fsd.Close)
env := setupTestAPI(t)
env.server.cfg.FsdHttpServiceAddress = fsd.URL
access, _ := env.login(t, env.admin.CID, env.adminPass)
w := env.doJSON(t, http.MethodDelete, "/api/v1/sweatbox/aircraft?confirm=1", nil, access)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
}
func TestAPISweatboxMutationsUnreachable502(t *testing.T) {
env := setupTestAPI(t)
access, _ := env.login(t, env.admin.CID, env.adminPass)
w := env.doJSON(t, http.MethodPost, "/api/v1/sweatbox/pause", nil, access)
require.Equal(t, http.StatusBadGateway, w.Code, w.Body.String())
assert.Contains(t, w.Body.String(), "unreachable")
}
func TestAPISweatboxMutationsForbiddenForObserver(t *testing.T) {
env := setupTestAPI(t)
access, _ := env.login(t, env.observer.CID, env.observerPass)
cases := []struct {
method string
path string
body any
}{
{http.MethodGet, "/api/v1/sweatbox/session", nil},
{http.MethodPost, "/api/v1/sweatbox/airport", map[string]any{"text": "icao=KBTV\n"}},
{http.MethodPost, "/api/v1/sweatbox/scenario", map[string]any{"text": "AAL1"}},
{http.MethodPost, "/api/v1/sweatbox/command", map[string]any{"command": "ops"}},
{http.MethodPost, "/api/v1/sweatbox/pause", nil},
{http.MethodPost, "/api/v1/sweatbox/unpause", nil},
{http.MethodDelete, "/api/v1/sweatbox/aircraft/AAL123", nil},
{http.MethodDelete, "/api/v1/sweatbox/aircraft?confirm=1", nil},
}
for _, tc := range cases {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
w := env.doJSON(t, tc.method, tc.path, tc.body, access)
require.Equal(t, http.StatusForbidden, w.Code, w.Body.String())
assert.Empty(t, w.Header().Get("Location"))
res := decodeAPIV1(t, w)
require.NotNil(t, res.Err)
assert.Equal(t, "forbidden", *res.Err)
})
}
}
func TestAPISweatboxPauseCookieSessionCSRF(t *testing.T) {
m := &sweatboxMock{}
fsd := httptest.NewServer(m.handler())
t.Cleanup(fsd.Close)
ts := newTestServer(t)
ts.cfg.FsdHttpServiceAddress = fsd.URL
admin := createTestUser(t, ts, "admin-pass", int(protocol.NetworkRatingAdministator))
cookies := formLogin(t, ts, admin.CID, "admin-pass")
_, cookies = authedGET(t, ts, "/dashboard", cookies)
csrf := csrfFromCookies(cookies)
require.NotEmpty(t, csrf)
// Cookie session without CSRF → 403 JSON.
req := httptest.NewRequest(http.MethodPost, "/api/v1/sweatbox/pause", nil)
req.Header.Set("Cookie", cookieHeader(cookies))
req.Header.Set("Accept", "application/json")
w := httptest.NewRecorder()
ts.engine.ServeHTTP(w, req)
require.Equal(t, http.StatusForbidden, w.Code, w.Body.String())
assert.Empty(t, w.Header().Get("Location"))
res := decodeAPIV1(t, w)
require.NotNil(t, res.Err)
assert.Contains(t, *res.Err, "CSRF")
assert.False(t, m.paused)
// Cookie session with CSRF header → 200.
req = httptest.NewRequest(http.MethodPost, "/api/v1/sweatbox/pause", nil)
req.Header.Set("Cookie", cookieHeader(cookies))
req.Header.Set(csrfHeaderName, csrf)
req.Header.Set("Accept", "application/json")
w = httptest.NewRecorder()
ts.engine.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
res = decodeAPIV1(t, w)
require.Nil(t, res.Err)
assert.True(t, m.paused)
assertGoldenJSON(t, "2026-07-28/sweatbox_pause_ok.json", w.Body.Bytes())
}
func TestAPISweatboxErrorStatusMapping(t *testing.T) {
// Table-driven Stable §D error statuses not covered by success goldens.
cases := []struct {
name string
mock sweatboxMock
method string
path string
body any
wantStatus int
errSubstr string
}{
{
name: "airport FSD 413",
mock: sweatboxMock{airportTooLarge: true},
method: http.MethodPost,
path: "/api/v1/sweatbox/airport",
body: map[string]any{"text": "icao=KBTV\n"},
wantStatus: http.StatusRequestEntityTooLarge,
errSubstr: "too large",
},
{
name: "airport FSD 200 invalid JSON → 502",
mock: sweatboxMock{airportBadJSON: true},
method: http.MethodPost,
path: "/api/v1/sweatbox/airport",
body: map[string]any{"text": "icao=KBTV\n"},
wantStatus: http.StatusBadGateway,
errSubstr: "invalid JSON",
},
{
name: "scenario 409 no airport",
mock: sweatboxMock{scenarioConflict: true},
method: http.MethodPost,
path: "/api/v1/sweatbox/scenario",
body: map[string]any{"text": "AAL1 B738"},
wantStatus: http.StatusConflict,
errSubstr: "airport",
},
{
name: "scenario 200 invalid JSON → 502",
mock: sweatboxMock{scenarioBadJSON: true},
method: http.MethodPost,
path: "/api/v1/sweatbox/scenario",
body: map[string]any{"text": "AAL1"},
wantStatus: http.StatusBadGateway,
errSubstr: "invalid JSON",
},
{
name: "command 409 no airport",
mock: sweatboxMock{commandConflict: true},
method: http.MethodPost,
path: "/api/v1/sweatbox/command",
body: map[string]any{"command": "add"},
wantStatus: http.StatusConflict,
errSubstr: "airport",
},
{
name: "delete-one 404",
mock: sweatboxMock{deleteNotFound: true},
method: http.MethodDelete,
path: "/api/v1/sweatbox/aircraft/NOEXIST",
body: nil,
wantStatus: http.StatusNotFound,
errSubstr: "not found",
},
{
name: "delete-all 404 disabled",
mock: sweatboxMock{deleteAllNotFound: true},
method: http.MethodDelete,
path: "/api/v1/sweatbox/aircraft",
body: map[string]any{"confirm": true},
wantStatus: http.StatusNotFound,
errSubstr: "not enabled",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := tc.mock
fsd := httptest.NewServer(m.handler())
t.Cleanup(fsd.Close)
env := setupTestAPI(t)
env.server.cfg.FsdHttpServiceAddress = fsd.URL
access, _ := env.login(t, env.admin.CID, env.adminPass)
w := env.doJSON(t, tc.method, tc.path, tc.body, access)
require.Equal(t, tc.wantStatus, w.Code, w.Body.String())
res := decodeAPIV1(t, w)
require.NotNil(t, res.Err)
assert.Contains(t, strings.ToLower(*res.Err), strings.ToLower(tc.errSubstr))
assert.Nil(t, res.Data)
})
}
}

View File

@@ -38,7 +38,10 @@ tags:
- name: Editor
description: APT/AIR validate (Stable; Instructor1+)
- name: Sweatbox
description: Read-only PE proxies (raw; outside envelope goldens)
description: |
Instructor1+ sweatbox control plane proxies.
GET /state and /ops are raw FSD bodies for PE polls (outside envelope goldens).
Mutations and GET /session use the APIV1 envelope (Stable; design §D status mapping).
# Data feeds (/data/*) are public and outside microversion reject; documented in
# internal/web/README.md. Not listed here until a full inventory is needed.
@@ -593,6 +596,291 @@ paths:
"400":
$ref: "#/components/responses/BadAPIVersion"
/sweatbox/session:
get:
tags: [Sweatbox]
summary: Enveloped sweatbox state (operator / third-party)
description: |
Parallel of GET /sweatbox/state with APIV1 envelope.
`data` is serviceapi.SweatboxStateJSON. Instructor1+.
operationId: getSweatboxSession
x-openfsd-stability: stable
security:
- bearerAuth: []
- cookieAuth: []
parameters:
- $ref: "#/components/parameters/OpenFSDAPIVersion"
responses:
"200":
description: Envelope with session state
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1Envelope"
"403":
description: Below Instructor1
"404":
description: Sweatbox disabled on FSD
"400":
$ref: "#/components/responses/BadAPIVersion"
"502":
description: FSD service unreachable or unexpected
/sweatbox/airport:
post:
tags: [Sweatbox]
summary: Load airport (.apt) into sweatbox
description: |
Public JSON `{"text","replace"}`. Proxied to FSD as text/plain of `text`
with optional `?replace=1`. Max body 2 MiB. Instructor1+.
operationId: postSweatboxAirport
x-openfsd-stability: stable
security:
- bearerAuth: []
- cookieAuth: []
parameters:
- $ref: "#/components/parameters/OpenFSDAPIVersion"
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [text]
properties:
text: { type: string, description: Full .apt file text }
replace: { type: boolean, default: false }
responses:
"200":
description: Airport loaded (data = SweatboxAirportLoadResponse)
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1Envelope"
"400":
description: Invalid JSON, empty text, or invalid airport
"403":
description: Below Instructor1
"404":
description: Sweatbox disabled
"409":
description: Aircraft present without replace
"413":
description: Payload too large
"502":
description: FSD unreachable / unexpected
/sweatbox/scenario:
post:
tags: [Sweatbox]
summary: Load scenario (.air) into sweatbox
description: |
Public JSON `{"text"}`. Proxied to FSD as text/plain. Max 2 MiB. Instructor1+.
operationId: postSweatboxScenario
x-openfsd-stability: stable
security:
- bearerAuth: []
- cookieAuth: []
parameters:
- $ref: "#/components/parameters/OpenFSDAPIVersion"
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [text]
properties:
text: { type: string, description: Full .air file text }
responses:
"200":
description: Scenario load result (data = SweatboxScenarioResponse)
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1Envelope"
"400":
description: Invalid JSON, empty text, or invalid scenario
"403":
description: Below Instructor1
"404":
description: Sweatbox disabled
"409":
description: No airport loaded
"413":
description: Payload too large
"502":
description: FSD unreachable / unexpected
/sweatbox/command:
post:
tags: [Sweatbox]
summary: Run instructor command
description: |
Soft validation failures return HTTP 200 with `data.ok=false` (TWRTrainer-style).
Do not treat ok:false as a 4xx. Instructor1+.
operationId: postSweatboxCommand
x-openfsd-stability: stable
security:
- bearerAuth: []
- cookieAuth: []
parameters:
- $ref: "#/components/parameters/OpenFSDAPIVersion"
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [command]
properties:
callsign: { type: string }
command: { type: string }
responses:
"200":
description: |
Command processed. `data` is SweatboxCommandResponse with ok/message.
Soft-fail: ok=false still HTTP 200, envelope err null.
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1Envelope"
"400":
description: Invalid JSON or empty command
"403":
description: Below Instructor1
"404":
description: Sweatbox disabled
"409":
description: No airport / conflict
"502":
description: FSD unreachable / unexpected
/sweatbox/pause:
post:
tags: [Sweatbox]
summary: Pause simulation
description: FSD 204 is mapped to public 200 envelope with data null. Instructor1+.
operationId: postSweatboxPause
x-openfsd-stability: stable
security:
- bearerAuth: []
- cookieAuth: []
parameters:
- $ref: "#/components/parameters/OpenFSDAPIVersion"
responses:
"200":
description: Paused (data null)
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1Envelope"
"403":
description: Below Instructor1
"404":
description: Sweatbox disabled
"502":
description: FSD unreachable / unexpected
/sweatbox/unpause:
post:
tags: [Sweatbox]
summary: Unpause simulation
description: FSD 204 is mapped to public 200 envelope with data null. Instructor1+.
operationId: postSweatboxUnpause
x-openfsd-stability: stable
security:
- bearerAuth: []
- cookieAuth: []
parameters:
- $ref: "#/components/parameters/OpenFSDAPIVersion"
responses:
"200":
description: Unpaused (data null)
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1Envelope"
"403":
description: Below Instructor1
"404":
description: Sweatbox disabled
"502":
description: FSD unreachable / unexpected
/sweatbox/aircraft/{callsign}:
delete:
tags: [Sweatbox]
summary: Delete one sweatbox aircraft
description: FSD 204 → public 200 envelope. Instructor1+.
operationId: deleteSweatboxAircraft
x-openfsd-stability: stable
security:
- bearerAuth: []
- cookieAuth: []
parameters:
- $ref: "#/components/parameters/OpenFSDAPIVersion"
- name: callsign
in: path
required: true
schema: { type: string }
responses:
"200":
description: Deleted (data null)
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1Envelope"
"403":
description: Below Instructor1
"404":
description: Aircraft not found or sweatbox disabled
"502":
description: FSD unreachable / unexpected
/sweatbox/aircraft:
delete:
tags: [Sweatbox]
summary: Delete all sweatbox aircraft
description: |
Requires confirmation via JSON `{"confirm":true}` or query `confirm=1`.
FSD 204 → public 200 envelope. Instructor1+.
operationId: deleteSweatboxAllAircraft
x-openfsd-stability: stable
security:
- bearerAuth: []
- cookieAuth: []
parameters:
- $ref: "#/components/parameters/OpenFSDAPIVersion"
- name: confirm
in: query
required: false
schema: { type: string, enum: ["1", "true", "yes", "on"] }
description: Alternative to JSON body confirm
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
confirm: { type: boolean }
responses:
"200":
description: All deleted (data null)
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1Envelope"
"400":
description: confirm required / invalid JSON
"403":
description: Below Instructor1
"404":
description: Sweatbox disabled
"502":
description: FSD unreachable / unexpected
components:
responses:
BadAPIVersion:

View File

@@ -270,14 +270,22 @@ func TestSweatboxCommandEmptyRedirectsErrorFlash(t *testing.T) {
// sweatboxMock tracks FSD service HTTP interactions for instructor UI tests.
type sweatboxMock struct {
paused bool
lastCommand serviceapi.SweatboxCommandRequest
airportBody string
airportPath string
scenarioBody string
commandSoftFail bool
airportConflict bool
scenarioBadJSON bool
paused bool
lastCommand serviceapi.SweatboxCommandRequest
airportBody string
airportPath string
airportContentType string
scenarioBody string
scenarioContentType string
commandSoftFail bool
airportConflict bool
airportTooLarge bool // FSD 413
airportBadJSON bool // FSD 200 with non-JSON body
scenarioConflict bool
scenarioBadJSON bool
commandConflict bool
deleteNotFound bool
deleteAllNotFound bool
}
func (m *sweatboxMock) handler() http.Handler {
@@ -311,8 +319,13 @@ func (m *sweatboxMock) handler() http.Handler {
})
mux.HandleFunc("/sweatbox/airport", func(w http.ResponseWriter, r *http.Request) {
m.airportPath = r.URL.RequestURI()
m.airportContentType = r.Header.Get("Content-Type")
b, _ := io.ReadAll(r.Body)
m.airportBody = string(b)
if m.airportTooLarge {
w.WriteHeader(http.StatusRequestEntityTooLarge)
return
}
if m.airportConflict {
w.WriteHeader(http.StatusConflict)
_ = json.NewEncoder(w).Encode(map[string]any{
@@ -320,11 +333,25 @@ func (m *sweatboxMock) handler() http.Handler {
})
return
}
if m.airportBadJSON {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("not-json"))
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"icao": "KBTV", "surfaces": 3, "errors": []string{}})
})
mux.HandleFunc("/sweatbox/scenario", func(w http.ResponseWriter, r *http.Request) {
m.scenarioContentType = r.Header.Get("Content-Type")
b, _ := io.ReadAll(r.Body)
m.scenarioBody = string(b)
if m.scenarioConflict {
w.WriteHeader(http.StatusConflict)
_ = json.NewEncoder(w).Encode(serviceapi.SweatboxScenarioResponse{
Loaded: 0,
Errors: []string{"No airport loaded."},
})
return
}
if m.scenarioBadJSON {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("not-json"))
@@ -337,6 +364,14 @@ func (m *sweatboxMock) handler() http.Handler {
})
mux.HandleFunc("/sweatbox/command", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&m.lastCommand)
if m.commandConflict {
w.WriteHeader(http.StatusConflict)
_ = json.NewEncoder(w).Encode(serviceapi.SweatboxCommandResponse{
OK: false,
Message: "No airport loaded",
})
return
}
if m.commandSoftFail {
_ = json.NewEncoder(w).Encode(serviceapi.SweatboxCommandResponse{
OK: false,
@@ -359,6 +394,10 @@ func (m *sweatboxMock) handler() http.Handler {
})
mux.HandleFunc("/sweatbox/aircraft/", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodDelete {
if m.deleteNotFound {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
return
}
@@ -366,6 +405,10 @@ func (m *sweatboxMock) handler() http.Handler {
})
mux.HandleFunc("/sweatbox/aircraft", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodDelete {
if m.deleteAllNotFound {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
return
}

View File

@@ -101,14 +101,28 @@ func (s *Server) setupFsdConnRoutes(parent *gin.RouterGroup) {
fsdConnGroup.POST("/kickuser", s.handleKickActiveConnection)
}
// setupSweatboxAPIRoutes mounts read-only PE proxies under /api/v1/sweatbox.
// Mutations stay on HTML form POSTs (CSRF + PRG) — not on this JSON surface.
// Raw GET bodies are outside envelope goldens; microversion middleware still applies.
// setupSweatboxAPIRoutes mounts /api/v1/sweatbox (Instructor1+ dual-accept).
//
// Raw GET /state and /ops stay non-envelope for PE polls. Mutations and GET
// /session use the APIV1 envelope with the design §D FSD status mapping.
// HTML form POSTs under /sweatbox/* remain for the MPA (CSRF + PRG).
func (s *Server) setupSweatboxAPIRoutes(parent *gin.RouterGroup) {
g := parent.Group("/sweatbox")
s.useAPIV1Protected(g)
// Raw PE reads (non-envelope)
g.GET("/state", s.handleAPISweatboxState)
g.GET("/ops", s.handleAPISweatboxOps)
// Enveloped operator surface (Stable; design §D)
g.GET("/session", s.handleAPISweatboxSession)
g.POST("/airport", s.handleAPISweatboxAirport)
g.POST("/scenario", s.handleAPISweatboxScenario)
g.POST("/command", s.handleAPISweatboxCommand)
g.POST("/pause", s.handleAPISweatboxPause)
g.POST("/unpause", s.handleAPISweatboxUnpause)
g.DELETE("/aircraft/:callsign", s.handleAPISweatboxDeleteAircraft)
g.DELETE("/aircraft", s.handleAPISweatboxDeleteAllAircraft)
}
func (s *Server) setupDataRoutes(parent *gin.RouterGroup) {

View File

@@ -0,0 +1,9 @@
{
"version": "v1",
"err": null,
"data": {
"icao": "KBTV",
"surfaces": 3,
"errors": []
}
}

View File

@@ -0,0 +1,8 @@
{
"version": "v1",
"err": null,
"data": {
"ok": true,
"message": "ok: taxi A"
}
}

View File

@@ -0,0 +1,8 @@
{
"version": "v1",
"err": null,
"data": {
"ok": false,
"message": "Unknown command: xyz"
}
}

View File

@@ -0,0 +1,5 @@
{
"version": "v1",
"err": null,
"data": null
}

View File

@@ -0,0 +1,5 @@
{
"version": "v1",
"err": null,
"data": null
}

View File

@@ -0,0 +1,5 @@
{
"version": "v1",
"err": null,
"data": null
}

View File

@@ -0,0 +1,8 @@
{
"version": "v1",
"err": null,
"data": {
"loaded": 3,
"errors": ["line 9: skipped"]
}
}

View File

@@ -0,0 +1,33 @@
{
"version": "v1",
"err": null,
"data": {
"icao": "KBTV",
"paused": false,
"elapsed_sec": 65,
"arr_count": 1,
"dep_count": 2,
"aircraft": [
{
"callsign": "AAL123",
"type": "B738",
"rules": "I",
"squawk": "2200",
"xpdr_mode": "",
"lat": 0,
"lon": 0,
"alt": 335,
"speed": 0,
"heading": 360,
"status": "parked",
"instruction": "",
"flight_plan": "",
"dep": "",
"arr": "",
"cruise_alt": 0,
"route": "",
"remarks": ""
}
]
}
}

View File

@@ -0,0 +1,5 @@
{
"version": "v1",
"err": null,
"data": null
}