diff --git a/go.mod b/go.mod index 70cf516..7fbd78b 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/internal/web/README.md b/internal/web/README.md index ce0fb9a..db4c146 100644 --- a/internal/web/README.md +++ b/internal/web/README.md @@ -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 ``` -- **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//`). 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. diff --git a/internal/web/api_editor.go b/internal/web/api_editor.go index 4446e7c..8a000b2 100644 --- a/internal/web/api_editor.go +++ b/internal/web/api_editor.go @@ -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) } diff --git a/internal/web/api_tokens.go b/internal/web/api_tokens.go index e6069ba..908793b 100644 --- a/internal/web/api_tokens.go +++ b/internal/web/api_tokens.go @@ -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) } diff --git a/internal/web/api_v1_response.go b/internal/web/api_v1_response.go index d04d108..fd766f1 100644 --- a/internal/web/api_v1_response.go +++ b/internal/web/api_v1_response.go @@ -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") diff --git a/internal/web/api_version.go b/internal/web/api_version.go new file mode 100644 index 0000000..c465160 --- /dev/null +++ b/internal/web/api_version.go @@ -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) +} diff --git a/internal/web/api_version_test.go b/internal/web/api_version_test.go new file mode 100644 index 0000000..8378c5a --- /dev/null +++ b/internal/web/api_version_test.go @@ -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"] = "" + 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()) +} diff --git a/internal/web/openapi/openapi.v1.yaml b/internal/web/openapi/openapi.v1.yaml new file mode 100644 index 0000000..99f50e3 --- /dev/null +++ b/internal/web/openapi/openapi.v1.yaml @@ -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" diff --git a/internal/web/routes.go b/internal/web/routes.go index bda746f..f63d295 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -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) } diff --git a/internal/web/testdata/api_v1/2026-07-28/config_update_ok.json b/internal/web/testdata/api_v1/2026-07-28/config_update_ok.json new file mode 100644 index 0000000..f5e0090 --- /dev/null +++ b/internal/web/testdata/api_v1/2026-07-28/config_update_ok.json @@ -0,0 +1,5 @@ +{ + "version": "v1", + "err": null, + "data": null +} diff --git a/internal/web/testdata/api_v1/2026-07-28/createtoken_fields.json b/internal/web/testdata/api_v1/2026-07-28/createtoken_fields.json new file mode 100644 index 0000000..3339d44 --- /dev/null +++ b/internal/web/testdata/api_v1/2026-07-28/createtoken_fields.json @@ -0,0 +1,10 @@ +{ + "version": "v1", + "err": null, + "data": { + "token": "", + "recommended_api_version": "2026-07-28", + "api_version_min": "2026-07-28", + "api_version_max": "2026-07-28" + } +} diff --git a/internal/web/testdata/api_v1/2026-07-28/discovery.json b/internal/web/testdata/api_v1/2026-07-28/discovery.json new file mode 100644 index 0000000..385b697 --- /dev/null +++ b/internal/web/testdata/api_v1/2026-07-28/discovery.json @@ -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" + } +} diff --git a/internal/web/testdata/api_v1/2026-07-28/error_forbidden.json b/internal/web/testdata/api_v1/2026-07-28/error_forbidden.json new file mode 100644 index 0000000..44064df --- /dev/null +++ b/internal/web/testdata/api_v1/2026-07-28/error_forbidden.json @@ -0,0 +1,5 @@ +{ + "version": "v1", + "err": "forbidden", + "data": null +} diff --git a/internal/web/testdata/api_v1/2026-07-28/error_invalid_json.json b/internal/web/testdata/api_v1/2026-07-28/error_invalid_json.json new file mode 100644 index 0000000..cf355a5 --- /dev/null +++ b/internal/web/testdata/api_v1/2026-07-28/error_invalid_json.json @@ -0,0 +1,5 @@ +{ + "version": "v1", + "err": "invalid JSON body", + "data": null +} diff --git a/internal/web/testdata/api_v1/2026-07-28/error_invalid_version.json b/internal/web/testdata/api_v1/2026-07-28/error_invalid_version.json new file mode 100644 index 0000000..baba194 --- /dev/null +++ b/internal/web/testdata/api_v1/2026-07-28/error_invalid_version.json @@ -0,0 +1,5 @@ +{ + "version": "v1", + "err": "invalid OpenFSD-API-Version", + "data": null +} diff --git a/internal/web/testdata/api_v1/2026-07-28/error_unauthorized.json b/internal/web/testdata/api_v1/2026-07-28/error_unauthorized.json new file mode 100644 index 0000000..22a2a62 --- /dev/null +++ b/internal/web/testdata/api_v1/2026-07-28/error_unauthorized.json @@ -0,0 +1,5 @@ +{ + "version": "v1", + "err": "unauthorized", + "data": null +} diff --git a/internal/web/testdata/api_v1/2026-07-28/error_unsupported_version.json b/internal/web/testdata/api_v1/2026-07-28/error_unsupported_version.json new file mode 100644 index 0000000..74a6a3a --- /dev/null +++ b/internal/web/testdata/api_v1/2026-07-28/error_unsupported_version.json @@ -0,0 +1,5 @@ +{ + "version": "v1", + "err": "unsupported OpenFSD-API-Version \"2020-01-01\"; min=2026-07-28 max=2026-07-28", + "data": null +} diff --git a/internal/web/testdata/api_v1/2026-07-28/user_load_self.json b/internal/web/testdata/api_v1/2026-07-28/user_load_self.json new file mode 100644 index 0000000..7b34853 --- /dev/null +++ b/internal/web/testdata/api_v1/2026-07-28/user_load_self.json @@ -0,0 +1,11 @@ +{ + "version": "v1", + "err": null, + "data": { + "cid": 0, + "first_name": "Obs", + "last_name": "Server", + "network_rating": 1, + "pilot_rating": 0 + } +}