web: OpenFSD-API-Version middleware, discovery, embed OpenAPI, baseline goldens

Add date microversion header, version-agnostic discovery, embedded OpenAPI,
and baseline golden fixtures for enveloped /api/v1 routes. Zero intentional
breaking behavior change.
This commit is contained in:
Reese Norris
2026-07-28 10:47:52 -04:00
parent 3e9bfc4b08
commit 4c862fea43
18 changed files with 1382 additions and 17 deletions

2
go.mod
View File

@@ -15,6 +15,7 @@ require (
go.uber.org/atomic v1.11.0
golang.org/x/crypto v0.54.0
golang.org/x/sys v0.47.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.54.0
)
@@ -58,7 +59,6 @@ require (
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.74.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect

View File

@@ -4,7 +4,9 @@
Part of the single `openfsd` binary (`cmd/openfsd -web`; default runs FSD + web). Shares `internal/db` with the FSD server; live connection state comes from the FSD service HTTP API (`FSD_HTTP_SERVICE_ADDRESS`, default `http://127.0.0.1:13618`).
JSON under `/api/v1` for external tools and map polling. First-party UI is a progressive-enhancement MPA: form login sets a signed **HttpOnly session cookie**; `/api/v1` dual-accepts that cookie **or** a Bearer access token. External tools should use Bearer API tokens.
JSON under `/api/v1` for **operator automation** and map polling. First-party UI is a progressive-enhancement MPA: form login sets a signed **HttpOnly session cookie**; `/api/v1` dual-accepts that cookie **or** a Bearer access token. Operators automating the same workflows as the UI should use **Bearer API tokens** (not browser session cookies).
**Blast radius:** Admin-minted API tokens currently carry an **Administrator-equivalent** network rating claim and full config/token power. Treat them as operator credentials with admin blast radius until fine-grained scopes exist—not as multi-tenant “third-party app” keys.
### First-party HTML pages (no-JS primary path)
| Page | Routes | Authz |
@@ -22,7 +24,7 @@ JSON under `/api/v1` remains for external consumers and map polling. Session dua
### Airport editor validation
- **Live client:** JS `parseAPT` / `parseAIR` + soft cross-file warnings (dep ICAO, aircraft far from field) on the Validate tab.
- **Confirm with server (optional):** `POST /api/v1/editor/validate-apt` and `POST /api/v1/editor/validate-air` with JSON `{"text":"…"}` (Admin, dual-accept Bearer | cookie; CSRF when cookie). Response is standard `APIV1Response` with `data.errors`, plus `icao` / `surface_count` or `aircraft_count`. Transient request body only — never written to disk/DB.
- **Confirm with server (optional):** `POST /api/v1/editor/validate-apt` and `POST /api/v1/editor/validate-air` with JSON `{"text":"…"}` (**Instructor1+**, dual-accept Bearer | cookie; CSRF when cookie). Response is standard `APIV1Response` with `data.errors`, plus `icao` / `surface_count` or `aircraft_count`. Transient request body only — never written to disk/DB.
- **Handoff:** download `.apt`/`.air`, then load on `/sweatbox` (no automatic push from editor → live session).
- Design: `docs/design/apt-air-editor.md`. JS unit tests: `webjs/` + `bash scripts/check-webjs.sh`.
@@ -60,9 +62,48 @@ Most endpoints accept a valid JWT access token:
```
Authorization: Bearer <access_token>
```
- **API tokens** can be created via `/api/v1/config/createtoken` with a custom expiry date. See the **Server Configuration** menu in the frontend UI to generate one.
- **API tokens** can be created via `/api/v1/config/createtoken` (Administrator) with a custom expiry date (max **90 days**). See the **Server Configuration** menu in the frontend UI to generate one.
- `createtoken` responses include additive `recommended_api_version`, `api_version_min`, and `api_version_max` so clients can pin the microversion header.
- Bearer-authenticated clients do **not** need CSRF (CSRF applies only when the request is authenticated via the session cookie).
- Dual-accept: a **valid** Bearer token wins over a session cookie; a garbage Bearer header does **not** disable CSRF if the session cookie is what authenticates the request.
- **Operator automation:** prefer minted API tokens over `/auth/login` or `/auth/refresh`. Tokens are admin-equivalent until scopes exist—store as secrets; rotate on compromise via secret reset.
---
## API versioning
openfsd keeps a durable URL major **`/api/v1`**. Rare **breaking** changes on **Stable** enveloped routes introduce a date **microversion** selected with the request header:
```http
OpenFSD-API-Version: 2026-07-28
```
| Rule | Detail |
|------|--------|
| Canonical form | `YYYY-MM-DD` after normalize; also accept `1.YYYYMMDD` and `latest` (→ max) |
| Default when omitted | **`max_version` (current)**; response sets `OpenFSD-API-Version-Defaulted: true` |
| Production clients | **MUST** send `OpenFSD-API-Version` with a supported pin |
| Additive changes | Free (new fields/endpoints); clients must ignore unknown JSON keys |
| Breaking changes (Stable) | Bump microversion; keep old pins for the support window |
| Envelope body `version` | Always major `"v1"`; microversion is **header-only** |
| Response headers | `OpenFSD-API-Version` (effective), `OpenFSD-API-Min-Version`, `OpenFSD-API-Max-Version`, optional `OpenFSD-API-Version-Defaulted`, `Vary: OpenFSD-API-Version` |
**Discovery (public, version-agnostic — never 400 on a bad pin):**
| Method | Path | Notes |
|--------|------|-------|
| GET | `/api/v1` | Same discovery payload as `/versions` |
| GET | `/api/v1/versions` | `min_version`, `max_version`, `versions[]`, `openapi` |
| GET | `/api/v1/openapi.json` | OpenAPI 3 from embedded YAML |
| GET | `/api/v1/openapi.yaml` | Canonical embed source |
Canonical OpenAPI file: `internal/web/openapi/openapi.v1.yaml` (`//go:embed`). No mirrored copy under `docs/`.
**Outside microversion reject:** `/api/v1/data/*`, `/api/v1/fsd-jwt`, auth login/refresh, discovery, OpenAPI. Resource groups (`/user`, `/config`, `/fsdconn`, `/sweatbox`, `/editor`) reject unknown/invalid pins with **400** envelope.
**Stability tiers:** existing enveloped user/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`.
Baseline pin (first supported): **`2026-07-28`**.
---
@@ -100,6 +141,29 @@ Common HTTP status codes:
## Endpoints
### Discovery & OpenAPI
#### GET /api/v1 and GET /api/v1/versions
Public discovery of supported microversions (same payload on both paths). **Version-agnostic:** a bad `OpenFSD-API-Version` does not cause 400.
**Response (200 OK)** `data`:
```json
{
"major": "v1",
"min_version": "2026-07-28",
"max_version": "2026-07-28",
"versions": ["2026-07-28"],
"header": "OpenFSD-API-Version",
"default": "max",
"openapi": "/api/v1/openapi.json"
}
```
#### GET /api/v1/openapi.json and GET /api/v1/openapi.yaml
Public OpenAPI 3 document (embedded). Version-agnostic.
---
### Authentication
#### POST /api/v1/auth/login
@@ -410,7 +474,9 @@ Upon successfully calling this endpoint, this effectively invalidates *all* prev
---
#### POST /api/v1/config/createtoken
Create a new API access token with a specified expiry.
Create a new API access token with a specified expiry (max 90 days).
**Blast radius:** minted token claims **Administrator** network rating. Treat as a full operator credential.
**Request Body**:
```json
@@ -425,13 +491,16 @@ Create a new API access token with a specified expiry.
"version": "v1",
"err": null,
"data": {
"token": string // JWT access token
"token": string, // JWT access token
"recommended_api_version": string, // pin production clients should send
"api_version_min": string,
"api_version_max": string
}
}
```
**Errors**:
- **400 Bad Request**: Invalid JSON body or expiry date in the past.
- **400 Bad Request**: Invalid JSON body, expiry in the past, or expiry more than 90 days out.
- **401 Unauthorized**: Invalid bearer token.
- **403 Forbidden**: Insufficient permissions (Administrator rating required).
- **500 Internal Server Error**: Error generating or signing token.

View File

@@ -36,7 +36,7 @@ type editorValidateAIRData struct {
// Authz is inline I1+ → 403 JSON (never HTML redirect).
func (s *Server) setupEditorAPIRoutes(parent *gin.RouterGroup) {
g := parent.Group("/editor")
g.Use(s.jwtBearerMiddleware, s.csrfIfCookieSession)
s.useAPIV1Protected(g)
g.POST("/validate-apt", s.handleAPIValidateAPT)
g.POST("/validate-air", s.handleAPIValidateAIR)
}

View File

@@ -70,10 +70,18 @@ func (s *Server) handleCreateNewAPIToken(c *gin.Context) {
}
type ResponseBody struct {
Token string `json:"token"`
Token string `json:"token"`
RecommendedAPIVersion string `json:"recommended_api_version"`
APIVersionMin string `json:"api_version_min"`
APIVersionMax string `json:"api_version_max"`
}
resBody := ResponseBody{Token: accessTokenStr}
resBody := ResponseBody{
Token: accessTokenStr,
RecommendedAPIVersion: apiMicroMax,
APIVersionMin: apiMicroMin,
APIVersionMax: apiMicroMax,
}
res := newAPIV1Success(&resBody)
writeAPIV1Response(c, http.StatusCreated, &res)
}

View File

@@ -47,6 +47,9 @@ func bindJSONOrAbort(c *gin.Context, reqBody any) (ok bool) {
}
func writeAPIV1Response(c *gin.Context, code int, res *APIV1Response) {
// Soft / hard version headers when context is set; safe defaults otherwise.
setAPIVersionHeaders(c)
resBody, err := json.Marshal(res)
if err != nil {
c.Writer.Header().Set("Content-Type", "text/plain")

287
internal/web/api_version.go Normal file
View File

@@ -0,0 +1,287 @@
package web
import (
_ "embed"
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
)
// Package-level API microversion registry (KD-3, KD-5, KD-19).
// Baseline is the UTC merge date of the versioning foundation PR.
const (
apiMajorVersion = "v1"
// First supported pin / current max (KD-19: UTC calendar date of PR-1 merge).
apiMicroMin = "2026-07-28"
apiMicroMax = "2026-07-28"
headerAPIVersion = "OpenFSD-API-Version"
headerAPIMinVersion = "OpenFSD-API-Min-Version"
headerAPIMaxVersion = "OpenFSD-API-Max-Version"
headerAPIVersionDefaulted = "OpenFSD-API-Version-Defaulted" // "true" if header omitted
apiVersionContextKey = "api_version"
)
// knownMicroversions lists every still-supported pin in ascending YYYY-MM-DD order.
var knownMicroversions = []string{
"2026-07-28",
}
var (
errAPIVersionInvalid = errors.New("invalid OpenFSD-API-Version")
errAPIVersionUnsupported = errors.New("unsupported OpenFSD-API-Version")
reAPIVersionDate = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
reAPIVersionAlias = regexp.MustCompile(`^1\.(\d{8})$`)
)
// apiVersionContext holds the resolved microversion for a request.
type apiVersionContext struct {
Requested string // raw header (may be empty)
Effective string // normalized pin used for shaping
Defaulted bool // true if header omitted (or empty after trim)
}
// normalizeAPIVersion converts a client version token into canonical YYYY-MM-DD.
// raw is the header value after the middleware has established it is non-empty.
// max is apiMicroMax (used for the "latest" alias).
// Membership in knownMicroversions is checked separately by the middleware.
func normalizeAPIVersion(raw, max string) (canonical string, err error) {
s := strings.TrimSpace(raw)
if s == "" {
return "", errAPIVersionInvalid
}
// "latest" (any ASCII case) → max
if strings.EqualFold(s, "latest") {
if max == "" {
return "", errAPIVersionInvalid
}
return max, nil
}
// Alias: 1.YYYYMMDD → YYYY-MM-DD
if m := reAPIVersionAlias.FindStringSubmatch(s); m != nil {
digits := m[1]
s = digits[0:4] + "-" + digits[4:6] + "-" + digits[6:8]
}
if !reAPIVersionDate.MatchString(s) {
return "", errAPIVersionInvalid
}
// Reject impossible calendar dates (e.g. 2026-02-30).
if _, parseErr := time.ParseInLocation("2006-01-02", s, time.UTC); parseErr != nil {
return "", errAPIVersionInvalid
}
return s, nil
}
func isKnownMicroversion(canonical string) bool {
for _, v := range knownMicroversions {
if v == canonical {
return true
}
}
return false
}
// shapeAdapter is one ordered microversion DTO shaper (KD-12).
// introducedAt is YYYY-MM-DD (or "epoch"); adapters are sorted ascending.
type shapeAdapter[T any, D any] struct {
introducedAt string
shape func(T) D
}
// shapeFor selects the newest adapter with introducedAt <= effective (lexicographic ISO dates).
func shapeFor[T any, D any](effective string, adapters []shapeAdapter[T, D], domain T) D {
if len(adapters) == 0 {
var zero D
return zero
}
for i := len(adapters) - 1; i >= 0; i-- {
if adapters[i].introducedAt <= effective {
return adapters[i].shape(domain)
}
}
return adapters[0].shape(domain)
}
// useAPIV1Protected attaches dual-accept auth + CSRF + API microversion middleware.
// PR-2 will add Bearer actor revalidation here.
func (s *Server) useAPIV1Protected(g *gin.RouterGroup) {
g.Use(
s.jwtBearerMiddleware,
s.csrfIfCookieSession,
s.apiVersionMiddleware,
)
}
// apiVersionMiddleware resolves OpenFSD-API-Version for dual-accept resource groups.
// Unknown/invalid pins → 400 envelope. Omitted header → max + Defaulted.
func (s *Server) apiVersionMiddleware(c *gin.Context) {
raw := c.GetHeader(headerAPIVersion)
trimmed := strings.TrimSpace(raw)
ctx := apiVersionContext{Requested: raw}
if trimmed == "" {
ctx.Effective = apiMicroMax
ctx.Defaulted = true
c.Set(apiVersionContextKey, ctx)
setAPIVersionHeaders(c)
c.Next()
return
}
canonical, err := normalizeAPIVersion(trimmed, apiMicroMax)
if err != nil {
setAPIVersionHeadersExplicit(c, apiMicroMax, true /* still show registry */)
res := newAPIV1Failure("invalid OpenFSD-API-Version")
writeAPIV1Response(c, http.StatusBadRequest, &res)
c.Abort()
return
}
if !isKnownMicroversion(canonical) {
setAPIVersionHeadersExplicit(c, apiMicroMax, true)
msg := fmt.Sprintf("unsupported OpenFSD-API-Version %q; min=%s max=%s",
canonical, apiMicroMin, apiMicroMax)
res := newAPIV1Failure(msg)
writeAPIV1Response(c, http.StatusBadRequest, &res)
c.Abort()
return
}
ctx.Effective = canonical
ctx.Defaulted = false
c.Set(apiVersionContextKey, ctx)
setAPIVersionHeaders(c)
c.Next()
}
// getAPIVersionContext returns the resolved microversion, if any.
func getAPIVersionContext(c *gin.Context) (apiVersionContext, bool) {
val, ok := c.Get(apiVersionContextKey)
if !ok {
return apiVersionContext{}, false
}
ctx, ok := val.(apiVersionContext)
return ctx, ok
}
// effectiveAPIVersion returns the effective pin, or apiMicroMax when unset.
func effectiveAPIVersion(c *gin.Context) string {
if ctx, ok := getAPIVersionContext(c); ok && ctx.Effective != "" {
return ctx.Effective
}
return apiMicroMax
}
// setAPIVersionHeaders writes version response headers from gin context.
// Safe no-op-with-soft-defaults when the version context is missing (public routes).
func setAPIVersionHeaders(c *gin.Context) {
if ctx, ok := getAPIVersionContext(c); ok {
setAPIVersionHeadersValues(c, ctx.Effective, ctx.Defaulted)
return
}
// Soft observability headers on public / envelope responses without hard pin.
setAPIVersionHeadersValues(c, apiMicroMax, false)
}
// setAPIVersionHeadersExplicit sets registry headers with a chosen effective pin
// (used when rejecting a bad pin before context is stored).
func setAPIVersionHeadersExplicit(c *gin.Context, effective string, _ bool) {
setAPIVersionHeadersValues(c, effective, false)
}
func setAPIVersionHeadersValues(c *gin.Context, effective string, defaulted bool) {
if effective == "" {
effective = apiMicroMax
}
h := c.Writer.Header()
h.Set(headerAPIVersion, effective)
h.Set(headerAPIMinVersion, apiMicroMin)
h.Set(headerAPIMaxVersion, apiMicroMax)
// Caches must vary on the client pin.
h.Set("Vary", headerAPIVersion)
if defaulted {
h.Set(headerAPIVersionDefaulted, "true")
}
}
// discoveryData is the public version-discovery payload (KD-20).
type discoveryData struct {
Major string `json:"major"`
MinVersion string `json:"min_version"`
MaxVersion string `json:"max_version"`
Versions []string `json:"versions"`
Header string `json:"header"`
Default string `json:"default"`
OpenAPI string `json:"openapi"`
}
func newDiscoveryData() discoveryData {
versions := make([]string, len(knownMicroversions))
copy(versions, knownMicroversions)
return discoveryData{
Major: apiMajorVersion,
MinVersion: apiMicroMin,
MaxVersion: apiMicroMax,
Versions: versions,
Header: headerAPIVersion,
Default: "max",
OpenAPI: "/api/v1/openapi.json",
}
}
// handleAPIDiscovery serves GET /api/v1 and GET /api/v1/versions (public, version-agnostic).
func (s *Server) handleAPIDiscovery(c *gin.Context) {
// Never reject on pin; still emit registry headers for observability.
setAPIVersionHeadersValues(c, apiMicroMax, false)
res := newAPIV1Success(newDiscoveryData())
writeAPIV1Response(c, http.StatusOK, &res)
}
//go:embed openapi/openapi.v1.yaml
var openAPIYAML []byte
// handleOpenAPIYAML serves the embedded OpenAPI document as YAML (public, version-agnostic).
func (s *Server) handleOpenAPIYAML(c *gin.Context) {
setAPIVersionHeadersValues(c, apiMicroMax, false)
c.Header("Content-Type", "application/yaml; charset=utf-8")
c.Writer.WriteHeader(http.StatusOK)
_, _ = c.Writer.Write(openAPIYAML)
}
// handleOpenAPIJSON serves the embedded OpenAPI document converted to JSON.
func (s *Server) handleOpenAPIJSON(c *gin.Context) {
setAPIVersionHeadersValues(c, apiMicroMax, false)
body, err := openAPIYAMLToJSON(openAPIYAML)
if err != nil {
res := newAPIV1Failure("openapi conversion failed")
writeAPIV1Response(c, http.StatusInternalServerError, &res)
return
}
c.Header("Content-Type", "application/json; charset=utf-8")
c.Writer.WriteHeader(http.StatusOK)
_, _ = c.Writer.Write(body)
}
func openAPIYAMLToJSON(src []byte) ([]byte, error) {
var doc any
if err := yaml.Unmarshal(src, &doc); err != nil {
return nil, err
}
return json.Marshal(doc)
}

View File

@@ -0,0 +1,401 @@
package web
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNormalizeAPIVersion(t *testing.T) {
const max = "2026-07-28"
tests := []struct {
name string
raw string
want string
wantErr error
}{
{name: "dashed date", raw: "2026-07-28", want: "2026-07-28"},
{name: "latest lower", raw: "latest", want: max},
{name: "latest upper", raw: "LATEST", want: max},
{name: "latest mixed", raw: "Latest", want: max},
{name: "latest padded", raw: " latest ", want: max},
{name: "alias 1.YYYYMMDD", raw: "1.20260728", want: "2026-07-28"},
{name: "alias with spaces", raw: " 1.20260728 ", want: "2026-07-28"},
{name: "date padded", raw: " 2026-07-28 ", want: "2026-07-28"},
// Invalid forms
{name: "empty", raw: "", wantErr: errAPIVersionInvalid},
{name: "whitespace only", raw: " ", wantErr: errAPIVersionInvalid},
{name: "mixed alias with dashes", raw: "1.2026-07-28", wantErr: errAPIVersionInvalid},
{name: "v1 prefix", raw: "v1.2026-07-28", wantErr: errAPIVersionInvalid},
{name: "slash date", raw: "2026/07/28", wantErr: errAPIVersionInvalid},
{name: "garbage", raw: "not-a-version", wantErr: errAPIVersionInvalid},
{name: "partial date", raw: "2026-07", wantErr: errAPIVersionInvalid},
{name: "impossible date Feb 30", raw: "2026-02-30", wantErr: errAPIVersionInvalid},
{name: "impossible date Apr 31", raw: "2026-04-31", wantErr: errAPIVersionInvalid},
{name: "zero month", raw: "2026-00-15", wantErr: errAPIVersionInvalid},
{name: "alias bad day", raw: "1.20260230", wantErr: errAPIVersionInvalid},
{name: "alias short", raw: "1.2026072", wantErr: errAPIVersionInvalid},
{name: "alias long", raw: "1.202607281", wantErr: errAPIVersionInvalid},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := normalizeAPIVersion(tc.raw, max)
if tc.wantErr != nil {
require.ErrorIs(t, err, tc.wantErr)
assert.Empty(t, got)
return
}
require.NoError(t, err)
assert.Equal(t, tc.want, got)
})
}
}
func TestShapeFor(t *testing.T) {
type domain struct{ N int }
type dto struct{ Label string }
adapters := []shapeAdapter[domain, dto]{
{introducedAt: "2026-07-28", shape: func(d domain) dto { return dto{Label: "baseline"} }},
{introducedAt: "2026-09-01", shape: func(d domain) dto { return dto{Label: "sept"} }},
{introducedAt: "2026-11-01", shape: func(d domain) dto { return dto{Label: "nov"} }},
}
// Walk every still-supported pin (only baseline today) + future pins for matrix.
assert.Equal(t, "baseline", shapeFor("2026-07-28", adapters, domain{}).Label)
assert.Equal(t, "sept", shapeFor("2026-09-01", adapters, domain{}).Label)
assert.Equal(t, "sept", shapeFor("2026-10-15", adapters, domain{}).Label)
assert.Equal(t, "nov", shapeFor("2026-11-01", adapters, domain{}).Label)
assert.Equal(t, "nov", shapeFor("2027-01-01", adapters, domain{}).Label)
// Before first adapter → first adapter (defensive).
assert.Equal(t, "baseline", shapeFor("2020-01-01", adapters, domain{}).Label)
for _, pin := range knownMicroversions {
got := shapeFor(pin, adapters, domain{})
assert.NotEmpty(t, got.Label, "pin %s must resolve", pin)
}
// Empty adapters → zero value.
var empty []shapeAdapter[domain, dto]
assert.Equal(t, dto{}, shapeFor("2026-07-28", empty, domain{}))
}
func TestAPIVersionMiddleware_OmitDefaultsMax(t *testing.T) {
env := setupTestAPI(t)
access, _ := env.login(t, env.admin.CID, env.adminPass)
req := httptest.NewRequest(http.MethodGet, "/api/v1/config/load", nil)
req.Header.Set("Authorization", "Bearer "+access)
w := httptest.NewRecorder()
env.router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
assert.Equal(t, apiMicroMax, w.Header().Get(headerAPIVersion))
assert.Equal(t, apiMicroMin, w.Header().Get(headerAPIMinVersion))
assert.Equal(t, apiMicroMax, w.Header().Get(headerAPIMaxVersion))
assert.Equal(t, "true", w.Header().Get(headerAPIVersionDefaulted))
assert.Contains(t, w.Header().Get("Vary"), headerAPIVersion)
}
func TestAPIVersionMiddleware_PinnedKnown(t *testing.T) {
env := setupTestAPI(t)
access, _ := env.login(t, env.admin.CID, env.adminPass)
req := httptest.NewRequest(http.MethodGet, "/api/v1/config/load", nil)
req.Header.Set("Authorization", "Bearer "+access)
req.Header.Set(headerAPIVersion, "2026-07-28")
w := httptest.NewRecorder()
env.router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
assert.Equal(t, "2026-07-28", w.Header().Get(headerAPIVersion))
assert.Empty(t, w.Header().Get(headerAPIVersionDefaulted))
}
func TestAPIVersionMiddleware_LatestAlias(t *testing.T) {
env := setupTestAPI(t)
access, _ := env.login(t, env.admin.CID, env.adminPass)
req := httptest.NewRequest(http.MethodGet, "/api/v1/config/load", nil)
req.Header.Set("Authorization", "Bearer "+access)
req.Header.Set(headerAPIVersion, "LATEST")
w := httptest.NewRecorder()
env.router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
assert.Equal(t, apiMicroMax, w.Header().Get(headerAPIVersion))
assert.Empty(t, w.Header().Get(headerAPIVersionDefaulted))
}
func TestAPIVersionMiddleware_AliasForm(t *testing.T) {
env := setupTestAPI(t)
access, _ := env.login(t, env.admin.CID, env.adminPass)
req := httptest.NewRequest(http.MethodGet, "/api/v1/config/load", nil)
req.Header.Set("Authorization", "Bearer "+access)
req.Header.Set(headerAPIVersion, "1.20260728")
w := httptest.NewRecorder()
env.router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
assert.Equal(t, "2026-07-28", w.Header().Get(headerAPIVersion))
}
func TestAPIVersionMiddleware_UnknownPin400(t *testing.T) {
env := setupTestAPI(t)
access, _ := env.login(t, env.admin.CID, env.adminPass)
req := httptest.NewRequest(http.MethodGet, "/api/v1/config/load", nil)
req.Header.Set("Authorization", "Bearer "+access)
req.Header.Set(headerAPIVersion, "2020-01-01")
w := httptest.NewRecorder()
env.router.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())
res := decodeAPIV1(t, w)
require.NotNil(t, res.Err)
assert.Contains(t, *res.Err, "unsupported OpenFSD-API-Version")
assert.Contains(t, *res.Err, "2020-01-01")
assert.Equal(t, apiMicroMin, w.Header().Get(headerAPIMinVersion))
assert.Equal(t, apiMicroMax, w.Header().Get(headerAPIMaxVersion))
}
func TestAPIVersionMiddleware_InvalidPin400(t *testing.T) {
env := setupTestAPI(t)
access, _ := env.login(t, env.admin.CID, env.adminPass)
for _, pin := range []string{"1.2026-07-28", "not-a-date", "2026-02-30"} {
req := httptest.NewRequest(http.MethodGet, "/api/v1/config/load", nil)
req.Header.Set("Authorization", "Bearer "+access)
req.Header.Set(headerAPIVersion, pin)
w := httptest.NewRecorder()
env.router.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code, "pin=%s body=%s", pin, w.Body.String())
res := decodeAPIV1(t, w)
require.NotNil(t, res.Err)
assert.Equal(t, "invalid OpenFSD-API-Version", *res.Err)
}
}
func TestAPIDiscovery_VersionAgnostic(t *testing.T) {
env := setupTestAPI(t)
paths := []string{"/api/v1", "/api/v1/", "/api/v1/versions"}
// Gin may normalize trailing slash — hit both discovery routes + bad pin.
for _, path := range []string{"/api/v1", "/api/v1/versions"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set(headerAPIVersion, "2020-01-01") // would 400 on resource groups
w := httptest.NewRecorder()
env.router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, "path=%s body=%s", path, w.Body.String())
res := decodeAPIV1(t, w)
require.Nil(t, res.Err)
assert.Equal(t, "v1", res.Version)
// Never 400 for pin on discovery.
assert.Equal(t, apiMicroMax, w.Header().Get(headerAPIVersion))
assert.Equal(t, apiMicroMin, w.Header().Get(headerAPIMinVersion))
assert.Equal(t, apiMicroMax, w.Header().Get(headerAPIMaxVersion))
_ = paths
}
}
func TestAPIDiscovery_PayloadGolden(t *testing.T) {
env := setupTestAPI(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/versions", nil)
w := httptest.NewRecorder()
env.router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
assertGoldenJSON(t, "2026-07-28/discovery.json", w.Body.Bytes())
}
func TestOpenAPIEndpoints(t *testing.T) {
env := setupTestAPI(t)
// YAML
req := httptest.NewRequest(http.MethodGet, "/api/v1/openapi.yaml", nil)
req.Header.Set(headerAPIVersion, "bogus") // must not 400
w := httptest.NewRecorder()
env.router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
assert.Contains(t, w.Header().Get("Content-Type"), "yaml")
assert.Contains(t, w.Body.String(), "openapi:")
assert.Contains(t, w.Body.String(), "openfsd")
// JSON
req = httptest.NewRequest(http.MethodGet, "/api/v1/openapi.json", nil)
req.Header.Set(headerAPIVersion, "2020-01-01")
w = httptest.NewRecorder()
env.router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
assert.Contains(t, w.Header().Get("Content-Type"), "json")
var doc map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &doc))
assert.Contains(t, doc, "openapi")
assert.Contains(t, doc, "paths")
}
func TestDataFeed_NoVersionReject(t *testing.T) {
env := setupTestAPI(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/data/status.json", nil)
req.Header.Set(headerAPIVersion, "2020-01-01")
w := httptest.NewRecorder()
env.router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
}
func TestCreateToken_RecommendedAPIVersionFields(t *testing.T) {
env := setupTestAPI(t)
adminAccess, _ := env.login(t, env.admin.CID, env.adminPass)
w := env.doJSON(t, http.MethodPost, "/api/v1/config/createtoken", map[string]any{
"expiry_date_time": time.Now().UTC().Add(2 * time.Hour).Format(time.RFC3339),
}, adminAccess)
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
res := decodeAPIV1(t, w)
require.Nil(t, res.Err)
data, err := json.Marshal(res.Data)
require.NoError(t, err)
var body struct {
Token string `json:"token"`
RecommendedAPIVersion string `json:"recommended_api_version"`
APIVersionMin string `json:"api_version_min"`
APIVersionMax string `json:"api_version_max"`
}
require.NoError(t, json.Unmarshal(data, &body))
require.NotEmpty(t, body.Token)
assert.Equal(t, apiMicroMax, body.RecommendedAPIVersion)
assert.Equal(t, apiMicroMin, body.APIVersionMin)
assert.Equal(t, apiMicroMax, body.APIVersionMax)
// Golden with redacted token.
var envelope map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &envelope))
dataMap, ok := envelope["data"].(map[string]any)
require.True(t, ok)
dataMap["token"] = "<redacted>"
rewritten, err := json.Marshal(envelope)
require.NoError(t, err)
assertGoldenJSON(t, "2026-07-28/createtoken_fields.json", rewritten)
}
func TestAPIV1Goldens_EnvelopedRoutes(t *testing.T) {
env := setupTestAPI(t)
obsAccess, _ := env.login(t, env.observer.CID, env.observerPass)
adminAccess, _ := env.login(t, env.admin.CID, env.adminPass)
t.Run("error_unauthorized", func(t *testing.T) {
w := env.doJSON(t, http.MethodPost, "/api/v1/user/load", map[string]any{"cid": env.observer.CID}, "")
require.Equal(t, http.StatusUnauthorized, w.Code)
assertGoldenJSON(t, "2026-07-28/error_unauthorized.json", w.Body.Bytes())
})
t.Run("error_forbidden", func(t *testing.T) {
w := env.doJSON(t, http.MethodGet, "/api/v1/config/load", nil, obsAccess)
require.Equal(t, http.StatusForbidden, w.Code)
assertGoldenJSON(t, "2026-07-28/error_forbidden.json", w.Body.Bytes())
})
t.Run("error_invalid_json", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/load", bytes.NewReader([]byte(`{not-json`)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+obsAccess)
w := httptest.NewRecorder()
env.router.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code, w.Body.String())
assertGoldenJSON(t, "2026-07-28/error_invalid_json.json", w.Body.Bytes())
})
t.Run("error_invalid_version", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/config/load", nil)
req.Header.Set("Authorization", "Bearer "+adminAccess)
req.Header.Set(headerAPIVersion, "1.2026-07-28")
w := httptest.NewRecorder()
env.router.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code)
assertGoldenJSON(t, "2026-07-28/error_invalid_version.json", w.Body.Bytes())
})
t.Run("error_unsupported_version", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/config/load", nil)
req.Header.Set("Authorization", "Bearer "+adminAccess)
req.Header.Set(headerAPIVersion, "2020-01-01")
w := httptest.NewRecorder()
env.router.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code)
assertGoldenJSON(t, "2026-07-28/error_unsupported_version.json", w.Body.Bytes())
})
t.Run("config_update_ok", func(t *testing.T) {
w := env.doJSON(t, http.MethodPost, "/api/v1/config/update", map[string]any{
"key_value_pairs": []map[string]string{
{"key": "WELCOME_MESSAGE", "value": "hello golden"},
},
}, adminAccess)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
assertGoldenJSON(t, "2026-07-28/config_update_ok.json", w.Body.Bytes())
})
t.Run("user_load_self", func(t *testing.T) {
w := env.doJSON(t, http.MethodPost, "/api/v1/user/load", map[string]any{
"cid": env.observer.CID,
}, obsAccess)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
// Rewrite dynamic CID to 0 for golden compare.
var envelope map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &envelope))
dataMap, ok := envelope["data"].(map[string]any)
require.True(t, ok)
dataMap["cid"] = float64(0) // JSON numbers → float64
rewritten, err := json.Marshal(envelope)
require.NoError(t, err)
assertGoldenJSON(t, "2026-07-28/user_load_self.json", rewritten)
})
}
// assertGoldenJSON compares got JSON to a fixture under testdata/api_v1/.
// Comparison is semantic (via json.Unmarshal + remarshal) so key order is ignored.
func assertGoldenJSON(t *testing.T, rel string, got []byte) {
t.Helper()
path := filepath.Join("testdata", "api_v1", filepath.FromSlash(rel))
wantBytes, err := os.ReadFile(path)
require.NoError(t, err, "read golden %s", path)
var wantAny, gotAny any
require.NoError(t, json.Unmarshal(wantBytes, &wantAny), "parse golden %s", path)
require.NoError(t, json.Unmarshal(got, &gotAny), "parse response: %s", string(got))
wantNorm, err := json.Marshal(wantAny)
require.NoError(t, err)
gotNorm, err := json.Marshal(gotAny)
require.NoError(t, err)
if !bytes.Equal(wantNorm, gotNorm) {
t.Fatalf("golden mismatch for %s\n--- want ---\n%s\n--- got ---\n%s\n",
rel, prettyJSON(wantNorm), prettyJSON(gotNorm))
}
}
func prettyJSON(b []byte) string {
var buf bytes.Buffer
if err := json.Indent(&buf, b, "", " "); err != nil {
return string(b)
}
return strings.TrimSpace(buf.String())
}

View File

@@ -0,0 +1,523 @@
openapi: 3.0.3
info:
title: openfsd Operator REST API
description: |
JSON control surface for **operator automation** against an openfsd deployment.
**Blast radius:** Admin-minted API tokens currently carry Administrator-equivalent
network rating claims. Treat tokens as full operator credentials until fine-grained
scopes exist. Prefer short TTLs (max 90 days) and rotate via secret reset when needed.
**Versioning:** URL major is `/api/v1`. Rare breaking changes use the
`OpenFSD-API-Version` date microversion header (`YYYY-MM-DD`). Production clients
MUST send a supported pin. When omitted, the server defaults to `max_version` and
sets `OpenFSD-API-Version-Defaulted: true`. Discovery (`GET /api/v1` and
`GET /api/v1/versions`) and OpenAPI are version-agnostic (never reject on pin).
Envelope for JSON resources: `{ "version": "v1", "err": string|null, "data": … }`.
Body `version` is the major only; microversion appears only in response headers.
version: "1.0.0"
x-openfsd-api-micro-min: "2026-07-28"
x-openfsd-api-micro-max: "2026-07-28"
servers:
- url: /api/v1
description: Relative to the openfsd web listener
tags:
- name: Discovery
description: Version-agnostic discovery and machine-readable schema
- name: Auth
description: Password login / refresh (discouraged for automation; mint API tokens instead)
- name: User
description: User load/create/update (Stable)
- name: Config
description: Server configuration and API tokens (Stable; admin)
- name: FSDConn
description: Active FSD connection control (Stable)
- name: Editor
description: APT/AIR validate (Stable; Instructor1+)
- name: Sweatbox
description: Read-only PE proxies (raw; outside envelope goldens)
- name: Data
description: Public data feeds (outside microversion reject)
paths:
/:
get:
tags: [Discovery]
summary: API discovery
operationId: getDiscovery
x-openfsd-stability: stable
security: []
responses:
"200":
description: Discovery payload (same as /versions)
headers:
OpenFSD-API-Version:
schema: { type: string }
OpenFSD-API-Min-Version:
schema: { type: string }
OpenFSD-API-Max-Version:
schema: { type: string }
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1EnvelopeDiscovery"
/versions:
get:
tags: [Discovery]
summary: Supported microversions (same payload as GET /)
operationId: getVersions
x-openfsd-stability: stable
security: []
responses:
"200":
description: Discovery payload
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1EnvelopeDiscovery"
/openapi.json:
get:
tags: [Discovery]
summary: OpenAPI 3 document (JSON)
operationId: getOpenAPIJSON
x-openfsd-stability: stable
security: []
responses:
"200":
description: OpenAPI document
content:
application/json:
schema:
type: object
/openapi.yaml:
get:
tags: [Discovery]
summary: OpenAPI 3 document (YAML source)
operationId: getOpenAPIYAML
x-openfsd-stability: stable
security: []
responses:
"200":
description: OpenAPI YAML
content:
application/yaml:
schema:
type: string
/auth/login:
post:
tags: [Auth]
summary: Password login (not for production automation)
operationId: postAuthLogin
x-openfsd-stability: stable
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [cid, password]
properties:
cid: { type: integer, minimum: 1 }
password: { type: string }
remember_me: { type: boolean }
responses:
"200":
description: Access + refresh tokens
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1Envelope"
/auth/refresh:
post:
tags: [Auth]
summary: Refresh access token (not for production automation)
operationId: postAuthRefresh
x-openfsd-stability: stable
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [refresh_token]
properties:
refresh_token: { type: string }
responses:
"200":
description: New access token
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1Envelope"
/fsd-jwt:
post:
tags: [Auth]
summary: FSD JWT (VATSIM-shaped; outside envelope / microversion reject)
operationId: postFsdJwt
x-openfsd-stability: stable
security: []
responses:
"200":
description: VATSIM-shaped success body
/user/load:
post:
tags: [User]
summary: Load user by CID
operationId: postUserLoad
x-openfsd-stability: stable
security:
- bearerAuth: []
- cookieAuth: []
parameters:
- $ref: "#/components/parameters/OpenFSDAPIVersion"
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [cid]
properties:
cid: { type: integer, minimum: 1 }
responses:
"200":
description: User record
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1Envelope"
/user/update:
patch:
tags: [User]
summary: Update user (Supervisor+)
operationId: patchUserUpdate
x-openfsd-stability: stable
security:
- bearerAuth: []
- cookieAuth: []
parameters:
- $ref: "#/components/parameters/OpenFSDAPIVersion"
responses:
"200":
description: Updated user
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1Envelope"
/user/create:
post:
tags: [User]
summary: Create user (Supervisor+)
operationId: postUserCreate
x-openfsd-stability: stable
security:
- bearerAuth: []
- cookieAuth: []
parameters:
- $ref: "#/components/parameters/OpenFSDAPIVersion"
responses:
"201":
description: Created user
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1Envelope"
/config/load:
get:
tags: [Config]
summary: Load editable config keys (Administrator)
operationId: getConfigLoad
x-openfsd-stability: stable
security:
- bearerAuth: []
- cookieAuth: []
parameters:
- $ref: "#/components/parameters/OpenFSDAPIVersion"
responses:
"200":
description: Key/value pairs
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1Envelope"
/config/update:
post:
tags: [Config]
summary: Update config keys (Administrator)
operationId: postConfigUpdate
x-openfsd-stability: stable
security:
- bearerAuth: []
- cookieAuth: []
parameters:
- $ref: "#/components/parameters/OpenFSDAPIVersion"
responses:
"200":
description: Success
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1Envelope"
/config/resetsecretkey:
post:
tags: [Config]
summary: Rotate JWT secret (invalidates all tokens)
operationId: postConfigResetSecretKey
x-openfsd-stability: stable
security:
- bearerAuth: []
- cookieAuth: []
parameters:
- $ref: "#/components/parameters/OpenFSDAPIVersion"
responses:
"200":
description: Secret rotated
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1Envelope"
/config/createtoken:
post:
tags: [Config]
summary: Mint long-lived admin API access token (max 90 days)
description: |
Returns a Bearer access JWT with Administrator rating claim plus recommended
microversion fields. **Operator-automation blast radius:** treat as full admin
credential. Production clients should send `OpenFSD-API-Version` equal to
`recommended_api_version`.
operationId: postConfigCreateToken
x-openfsd-stability: stable
security:
- bearerAuth: []
- cookieAuth: []
parameters:
- $ref: "#/components/parameters/OpenFSDAPIVersion"
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [expiry_date_time]
properties:
expiry_date_time:
type: string
format: date-time
responses:
"201":
description: Token minted
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1EnvelopeCreateToken"
/fsdconn/kickuser:
post:
tags: [FSDConn]
summary: Kick active connection by callsign (Supervisor+)
operationId: postFsdconnKickuser
x-openfsd-stability: stable
security:
- bearerAuth: []
- cookieAuth: []
parameters:
- $ref: "#/components/parameters/OpenFSDAPIVersion"
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [callsign]
properties:
callsign: { type: string }
responses:
"200":
description: Kicked
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1Envelope"
/editor/validate-apt:
post:
tags: [Editor]
summary: Validate APT text (Instructor1+)
operationId: postEditorValidateApt
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 }
responses:
"200":
description: Soft validation result
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1Envelope"
/editor/validate-air:
post:
tags: [Editor]
summary: Validate AIR text (Instructor1+)
operationId: postEditorValidateAir
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 }
responses:
"200":
description: Soft validation result
content:
application/json:
schema:
$ref: "#/components/schemas/APIV1Envelope"
/sweatbox/state:
get:
tags: [Sweatbox]
summary: Raw FSD sweatbox state (PE poll; non-envelope)
operationId: getSweatboxState
x-openfsd-stability: stable
security:
- bearerAuth: []
- cookieAuth: []
parameters:
- $ref: "#/components/parameters/OpenFSDAPIVersion"
responses:
"200":
description: Raw FSD JSON body
/sweatbox/ops:
get:
tags: [Sweatbox]
summary: Raw FSD sweatbox ops (PE poll; non-envelope)
operationId: getSweatboxOps
x-openfsd-stability: stable
security:
- bearerAuth: []
- cookieAuth: []
parameters:
- $ref: "#/components/parameters/OpenFSDAPIVersion"
responses:
"200":
description: Raw FSD JSON body
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: Access JWT (API token or short-lived access). No CSRF required.
cookieAuth:
type: apiKey
in: cookie
name: openfsd_session
description: First-party session cookie; mutations require CSRF synchronizer.
parameters:
OpenFSDAPIVersion:
name: OpenFSD-API-Version
in: header
required: false
description: |
Date microversion pin (`YYYY-MM-DD` or `1.YYYYMMDD` or `latest`).
Production clients MUST send a supported pin. Omitted → max + Defaulted header.
schema:
type: string
example: "2026-07-28"
schemas:
APIV1Envelope:
type: object
required: [version, err, data]
properties:
version:
type: string
enum: [v1]
description: URL major only; microversion is header-only
err:
type: string
nullable: true
data:
nullable: true
DiscoveryData:
type: object
required: [major, min_version, max_version, versions, header, default, openapi]
properties:
major: { type: string, example: v1 }
min_version: { type: string, example: "2026-07-28" }
max_version: { type: string, example: "2026-07-28" }
versions:
type: array
items: { type: string }
header: { type: string, example: OpenFSD-API-Version }
default: { type: string, example: max }
openapi: { type: string, example: /api/v1/openapi.json }
APIV1EnvelopeDiscovery:
allOf:
- $ref: "#/components/schemas/APIV1Envelope"
- type: object
properties:
data:
$ref: "#/components/schemas/DiscoveryData"
CreateTokenData:
type: object
required: [token, recommended_api_version, api_version_min, api_version_max]
properties:
token: { type: string }
recommended_api_version: { type: string, example: "2026-07-28" }
api_version_min: { type: string, example: "2026-07-28" }
api_version_max: { type: string, example: "2026-07-28" }
APIV1EnvelopeCreateToken:
allOf:
- $ref: "#/components/schemas/APIV1Envelope"
- type: object
properties:
data:
$ref: "#/components/schemas/CreateTokenData"

View File

@@ -33,11 +33,20 @@ func (s *Server) setupRoutes() (*gin.Engine, error) {
// API groups — dual-accept Bearer | session cookie; CSRF when cookie-authenticated.
apiV1Group := e.Group("/api/v1")
apiV1Group.POST("/fsd-jwt", s.getFsdJwt)
s.setupAuthRoutes(apiV1Group)
// Version-agnostic public discovery + OpenAPI (no JWT, no version-reject).
apiV1Group.GET("", s.handleAPIDiscovery)
apiV1Group.GET("/versions", s.handleAPIDiscovery)
apiV1Group.GET("/openapi.json", s.handleOpenAPIJSON)
apiV1Group.GET("/openapi.yaml", s.handleOpenAPIYAML)
apiV1Group.POST("/fsd-jwt", s.getFsdJwt) // outside microversion reject
s.setupAuthRoutes(apiV1Group) // login/refresh: soft version headers only
s.setupDataRoutes(apiV1Group) // never version-reject
// Dual-accept JSON resource groups (jwt + csrf + apiVersion).
s.setupUserRoutes(apiV1Group)
s.setupConfigRoutes(apiV1Group)
s.setupDataRoutes(apiV1Group)
s.setupFsdConnRoutes(apiV1Group)
s.setupSweatboxAPIRoutes(apiV1Group)
s.setupEditorAPIRoutes(apiV1Group)
@@ -63,7 +72,7 @@ func (s *Server) setupAuthRoutes(parent *gin.RouterGroup) {
func (s *Server) setupUserRoutes(parent *gin.RouterGroup) {
usersGroup := parent.Group("/user")
usersGroup.Use(s.jwtBearerMiddleware, s.csrfIfCookieSession)
s.useAPIV1Protected(usersGroup)
usersGroup.POST("/load", s.getUserByCID)
usersGroup.PATCH("/update", s.updateUser)
usersGroup.POST("/create", s.createUser)
@@ -71,7 +80,7 @@ func (s *Server) setupUserRoutes(parent *gin.RouterGroup) {
func (s *Server) setupConfigRoutes(parent *gin.RouterGroup) {
configGroup := parent.Group("/config")
configGroup.Use(s.jwtBearerMiddleware, s.csrfIfCookieSession)
s.useAPIV1Protected(configGroup)
configGroup.GET("/load", s.handleGetConfig)
configGroup.POST("/update", s.handleUpdateConfig)
configGroup.POST("/resetsecretkey", s.handleResetSecretKey)
@@ -80,15 +89,16 @@ func (s *Server) setupConfigRoutes(parent *gin.RouterGroup) {
func (s *Server) setupFsdConnRoutes(parent *gin.RouterGroup) {
fsdConnGroup := parent.Group("/fsdconn")
fsdConnGroup.Use(s.jwtBearerMiddleware, s.csrfIfCookieSession)
s.useAPIV1Protected(fsdConnGroup)
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.
func (s *Server) setupSweatboxAPIRoutes(parent *gin.RouterGroup) {
g := parent.Group("/sweatbox")
g.Use(s.jwtBearerMiddleware, s.csrfIfCookieSession)
s.useAPIV1Protected(g)
g.GET("/state", s.handleAPISweatboxState)
g.GET("/ops", s.handleAPISweatboxOps)
}

View File

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

View File

@@ -0,0 +1,10 @@
{
"version": "v1",
"err": null,
"data": {
"token": "<redacted>",
"recommended_api_version": "2026-07-28",
"api_version_min": "2026-07-28",
"api_version_max": "2026-07-28"
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "v1",
"err": null,
"data": {
"major": "v1",
"min_version": "2026-07-28",
"max_version": "2026-07-28",
"versions": ["2026-07-28"],
"header": "OpenFSD-API-Version",
"default": "max",
"openapi": "/api/v1/openapi.json"
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
{
"version": "v1",
"err": "unsupported OpenFSD-API-Version \"2020-01-01\"; min=2026-07-28 max=2026-07-28",
"data": null
}

View File

@@ -0,0 +1,11 @@
{
"version": "v1",
"err": null,
"data": {
"cid": 0,
"first_name": "Obs",
"last_name": "Server",
"network_rating": 1,
"pilot_rating": 0
}
}