From e7326e6315a6e46309e63d8c49ef289e06ece7b0 Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Fri, 25 Sep 2026 18:24:47 +0800 Subject: [PATCH] =?UTF-8?q?test(api):=20=E5=A4=84=E7=90=86=E5=99=A8?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=9A=84=E6=AF=8F=E6=AC=A1=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E5=9C=A8=E5=8C=85=E7=BB=93=E6=9D=9F=E5=90=8E=E5=AF=B9=E7=85=A7?= =?UTF-8?q?=20openapi=20=E6=A0=A1=E9=AA=8C=E7=8A=B6=E6=80=81=E7=A0=81?= =?UTF-8?q?=E3=80=81=E5=93=8D=E5=BA=94=E4=B8=8E=202xx=20=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E4=BD=93=EF=BC=8CSetupAllowed=20=E7=BA=B3=E5=85=A5=E4=B8=80?= =?UTF-8?q?=E8=87=B4=E6=80=A7=E6=AF=94=E5=AF=B9=EF=BC=8C=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E6=BC=8F=E8=AE=B0=E7=9A=84=E7=8A=B6=E6=80=81=E7=A0=81=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E7=A9=BA=E5=88=97=E8=A1=A8=E5=9B=9E=20null?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/openapi.yaml | 183 ++++++++- internal/api/api_test.go | 1 + internal/api/handlers_account_test.go | 6 +- internal/api/handlers_files.go | 6 + internal/api/handlers_internal.go | 3 + internal/api/handlers_user.go | 3 + internal/api/main_test.go | 22 +- internal/api/openapi_contract_test.go | 511 ++++++++++++++++++++++++++ internal/api/openapi_test.go | 16 +- internal/api/submissions_test.go | 10 +- panel/src/lib/openapi.gen.ts | 204 +++++++++- 11 files changed, 937 insertions(+), 28 deletions(-) create mode 100644 internal/api/openapi_contract_test.go diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 0756f84..9e5b485 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -12,15 +12,52 @@ # from (internalAPIRoutes / externalAPIRoutes in internal/api/api.go). A route # added, removed, re-faced, or re-tiered without updating this file fails # `go test ./...`. So path, method, face and tier are as trustworthy as the code. -# * The request/response BODY schemas below are hand-maintained from the Go -# handler types and are NOT yet schema-validated against live traffic. Treat -# them as documentation (SHAPE-ASSERTED), not as a contract test. +# * Which operations a setup-lockdown session may still use (x-felis-setup-allowed) +# is checked the same way against the SetupAllowed flag in those tables. +# * The named response schemas are compared field by field with the Go structs +# the handlers encode (internal/api/openapi_parity_test.go). +# * Every request the handler tests send is held to this file once the package +# has run (internal/api/openapi_contract_test.go): the operation (or +# x-felis-common-responses) must list the status that came back, a JSON response +# must fit the schema for that status and carry no property it does not name, +# and the JSON request behind a 2xx must fit the requestBody. Statuses and +# bodies no test reaches are still hand-maintained. # # The deployment zone (RootDomain, spec §2) never appears here — `example.test` # is a placeholder, per the no-hardcoded-domain red line. openapi: 3.1.0 +# Answers any operation can give under the stated condition, declared once here +# instead of under every operation. when: any | internal (served on the internal +# face) | session (takes the session cookie) | setup-locked (takes the session +# cookie and is not x-felis-setup-allowed) | json-body (takes a JSON requestBody). +# code, when set, is the error code that answer carries. +x-felis-common-responses: + - status: 500 + when: any + response: { $ref: '#/components/responses/InternalError' } + - status: 403 + when: internal + code: wrong_caller + response: { $ref: '#/components/responses/WrongCaller' } + - status: 403 + when: session + code: forbidden + response: { $ref: '#/components/responses/StaffOnlyHost' } + - status: 403 + when: setup-locked + code: setup_required + response: { $ref: '#/components/responses/SetupRequired' } + - status: 413 + when: json-body + code: too_large + response: { $ref: '#/components/responses/TooLarge' } + - status: 415 + when: json-body + code: unsupported_media_type + response: { $ref: '#/components/responses/UnsupportedMediaType' } + info: title: felis-api version: 4.1.0 @@ -124,6 +161,41 @@ components: responses: NoContent: description: Success, no body. + InternalError: + description: An unexpected failure (internal); the details are in the server log under the request id. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + WrongCaller: + description: A genuine service token for a caller this operation does not serve (wrong_caller). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + StaffOnlyHost: + description: A session of a player (not admin or owner) on the operator console host, refused before any handler (forbidden). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + SetupRequired: + description: The session belongs to an account still in first-run setup, which may use only the x-felis-setup-allowed operations until it has a durable sign-in (setup_required). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + TooLarge: + description: The JSON body is over 1 MiB (too_large). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + UnsupportedMediaType: + description: A body sent with a Content-Type other than application/json (unsupported_media_type). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + InsufficientStorage: + description: The store this writes to is full — the backup archive (backup_store_full), the world volume (volume_full) or the upload area (uploads_full). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } BadRequest: description: Malformed or invalid request (validation, bad body, unknown field). content: @@ -976,6 +1048,11 @@ paths: content: application/json: schema: { $ref: '#/components/schemas/Error' } + '503': + description: The node is at its running-server cap (at_capacity). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } /api/v1/internal/servers/{name}/status: get: @@ -1259,6 +1336,11 @@ paths: $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' + '409': + description: The linked account holds a staff role and is never reclaimed (protected_admin). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } /api/v1/internal/player/blacklist/{mc_uuid}: get: @@ -1553,6 +1635,11 @@ paths: content: application/json: schema: { $ref: '#/components/schemas/Error' } + '503': + description: The node is at its running-server cap (at_capacity). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } /api/v1/servers/{name}/stop: post: @@ -1697,11 +1784,21 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '404': + description: Unknown server. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } '409': description: Server not running. content: application/json: schema: { $ref: '#/components/schemas/Error' } + '429': + description: This session already holds as many console streams as it may (too_many_streams). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } '503': $ref: '#/components/responses/ServiceUnavailable' @@ -2314,7 +2411,7 @@ paths: required: [user_id, role] properties: user_id: { type: string } - role: { type: string, enum: [user, admin] } + role: { type: string, enum: [owner, admin, user] } '400': description: >- Invalid email or missing assertion (bad_request); or the login could not be @@ -2464,7 +2561,7 @@ paths: required: [user_id, role] properties: user_id: { type: string } - role: { type: string, enum: [user, admin] } + role: { type: string, enum: [owner, admin, user] } '400': description: >- Missing login_id or assertion (bad_request); or the login could not be @@ -2599,7 +2696,7 @@ paths: required: [user_id, role] properties: user_id: { type: string } - role: { type: string, enum: [user, admin] } + role: { type: string, enum: [owner, admin, user] } '400': description: >- A valid email and code are required (bad_request); or the code is wrong, @@ -2756,7 +2853,7 @@ paths: required: [user_id, role] properties: user_id: { type: string } - role: { type: string, enum: [user, admin] } + role: { type: string, enum: [owner, admin, user] } '400': description: >- request_id and code are required (bad_request); or the login could not be @@ -2814,7 +2911,7 @@ paths: properties: user_id: { type: string } username: { type: string } - role: { type: string, enum: [user, admin] } + role: { type: string, enum: [owner, admin, user] } email: { type: string } email_verified: { type: boolean } has_passkey: { type: boolean } @@ -2851,6 +2948,7 @@ paths: API is fenced until setup completes). x-felis-face: [external] x-felis-tier: app + x-felis-setup-allowed: true security: [{ sessionCookie: [] }] responses: '200': @@ -2863,7 +2961,7 @@ paths: properties: user_id: { type: string } username: { type: string } - role: { type: string, enum: [user, admin] } + role: { type: string, enum: [owner, admin, user] } email: { type: string } email_verified: { type: boolean } has_passkey: { type: boolean } @@ -2970,6 +3068,7 @@ paths: access. x-felis-face: [external] x-felis-tier: app + x-felis-setup-allowed: true security: [{ sessionCookie: [] }] responses: '200': @@ -3003,6 +3102,11 @@ paths: nudges unverified accounts through the email-OTP flow. '401': $ref: '#/components/responses/Unauthorized' + '503': + description: The account settings could not be read (auth_unavailable). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } /api/v1/me/servers: get: @@ -3011,6 +3115,7 @@ paths: summary: List the servers the caller owns or may claim. x-felis-face: [external] x-felis-tier: app + x-felis-setup-allowed: true security: [{ sessionCookie: [] }] responses: '200': @@ -3227,6 +3332,11 @@ paths: safety_snapshot: type: boolean description: Whether a safety snapshot runs first. False when the request turned it off, or when this install cannot take one. + '400': + description: Malformed server name (bad_name) or request body. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } '401': $ref: '#/components/responses/Unauthorized' '403': @@ -3272,6 +3382,11 @@ paths: properties: name: { type: string } status: { type: string, const: backing_up } + '400': + description: Malformed server name (bad_name). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } '401': $ref: '#/components/responses/Unauthorized' '403': @@ -3286,8 +3401,15 @@ paths: content: application/json: schema: { $ref: '#/components/schemas/Error' } + '429': + description: Another backup of this server started within the cooldown (backup_cooldown). + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } '503': $ref: '#/components/responses/ServiceUnavailable' + '507': + $ref: '#/components/responses/InsufficientStorage' # -------------------------------------------------- async job status (app) --- /api/v1/servers/{name}/jobs: @@ -3496,6 +3618,8 @@ paths: content: application/json: schema: { $ref: '#/components/schemas/Error' } + '507': + $ref: '#/components/responses/InsufficientStorage' put: tags: [files] operationId: writeServerFile @@ -3817,6 +3941,11 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '404': + description: Unknown user. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } put: tags: [users] operationId: setQuotas @@ -3849,6 +3978,11 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '404': + description: Unknown user. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } /api/v1/users/{id}/sessions: get: @@ -4010,6 +4144,11 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + '404': + description: Unknown user. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } '409': description: UUID is already linked to a different user. content: @@ -4055,6 +4194,7 @@ paths: summary: Report account-link status and in-game instructions (web side, spec §10). x-felis-face: [external] x-felis-tier: app + x-felis-setup-allowed: true security: [{ sessionCookie: [] }] responses: '200': @@ -4077,6 +4217,7 @@ paths: summary: Consume an in-game link code and bind the account. x-felis-face: [external] x-felis-tier: app + x-felis-setup-allowed: true security: [{ sessionCookie: [] }] requestBody: required: true @@ -4128,6 +4269,7 @@ paths: session must have reauthed within 5 minutes (403 reauth_required). x-felis-face: [external] x-felis-tier: app + x-felis-setup-allowed: true security: [{ sessionCookie: [] }] requestBody: required: true @@ -4191,6 +4333,7 @@ paths: consumed, or mismatched code is a 400. x-felis-face: [external] x-felis-tier: app + x-felis-setup-allowed: true security: [{ sessionCookie: [] }] requestBody: required: true @@ -4219,6 +4362,11 @@ paths: schema: { $ref: '#/components/schemas/Error' } '401': $ref: '#/components/responses/Unauthorized' + '409': + description: Another account already proved this address (email_taken); the code is not consumed. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } '429': description: >- Too many incorrect attempts on this code (otp_locked), or the account's @@ -4241,6 +4389,7 @@ paths: must have reauthed within 5 minutes (403 reauth_required). x-felis-face: [external] x-felis-tier: app + x-felis-setup-allowed: true security: [{ sessionCookie: [] }] requestBody: required: true @@ -4286,6 +4435,7 @@ paths: instance. x-felis-face: [external] x-felis-tier: app + x-felis-setup-allowed: true security: [{ sessionCookie: [] }] responses: '200': @@ -4320,6 +4470,7 @@ paths: configured. x-felis-face: [external] x-felis-tier: app + x-felis-setup-allowed: true security: [{ sessionCookie: [] }] requestBody: required: true @@ -4368,6 +4519,7 @@ paths: not need the WebAuthn verifier, so it succeeds even where begin/finish report 503. x-felis-face: [external] x-felis-tier: app + x-felis-setup-allowed: true security: [{ sessionCookie: [] }] responses: '200': @@ -4400,6 +4552,7 @@ paths: (403 reauth_required). x-felis-face: [external] x-felis-tier: app + x-felis-setup-allowed: true security: [{ sessionCookie: [] }] parameters: - name: id @@ -4439,6 +4592,7 @@ paths: op-login or a passkey). x-felis-face: [external] x-felis-tier: app + x-felis-setup-allowed: true security: [{ sessionCookie: [] }] responses: '200': @@ -4467,6 +4621,7 @@ paths: bound to a fresh reauth-purpose challenge. x-felis-face: [external] x-felis-tier: app + x-felis-setup-allowed: true security: [{ sessionCookie: [] }] responses: '200': @@ -4494,6 +4649,7 @@ paths: clone check (a cloned authenticator is 400 passkey_login_invalid). x-felis-face: [external] x-felis-tier: app + x-felis-setup-allowed: true security: [{ sessionCookie: [] }] requestBody: required: true @@ -4529,6 +4685,7 @@ paths: signing in again (403 staff_reauth). x-felis-face: [external] x-felis-tier: app + x-felis-setup-allowed: true security: [{ sessionCookie: [] }] responses: '202': @@ -4579,6 +4736,7 @@ paths: summary: Redeem the reauth code and mark this session reauthed for 5 minutes. x-felis-face: [external] x-felis-tier: app + x-felis-setup-allowed: true security: [{ sessionCookie: [] }] requestBody: required: true @@ -5412,6 +5570,11 @@ paths: content: text/event-stream: schema: { type: string } + '400': + description: Malformed build id (bad_request). The id becomes a label-selector value, so it is refused before any lookup. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } '401': $ref: '#/components/responses/Unauthorized' '403': @@ -5595,6 +5758,8 @@ paths: content: application/json: schema: { $ref: '#/components/schemas/Submission' } + '400': + $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': diff --git a/internal/api/api_test.go b/internal/api/api_test.go index c149deb..a450358 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -1893,6 +1893,7 @@ func do(h http.Handler, method, target, body string, headers map[string]string) } w := httptest.NewRecorder() h.ServeHTTP(w, r) + recordContract(r, body, w) return w } diff --git a/internal/api/handlers_account_test.go b/internal/api/handlers_account_test.go index 32c7d4d..050c299 100644 --- a/internal/api/handlers_account_test.go +++ b/internal/api/handlers_account_test.go @@ -218,7 +218,7 @@ func TestLinkVerifyRejections(t *testing.T) { t.Run("uuid linked to another user -> 409 already_linked", func(t *testing.T) { repo := newFakeRepo() repo.links[mcUUID] = "someone-else" - repo.linkCodes["FRESHCOD"] = fakeLinkCode{mcUUID: mcUUID, expiresAt: time.Unix(1_700_000_600, 0)} + repo.linkCodes["FRESHCOD"] = fakeLinkCode{mcUUID: mcUUID, authSource: "mojang", expiresAt: time.Unix(1_700_000_600, 0)} w := do(mk(repo), "POST", "/api/v1/account/link/verify", `{"code":"FRESHCOD"}`, nil) if w.Code != http.StatusConflict || decodeErr(t, w) != "already_linked" { t.Fatalf("code = %d body %s", w.Code, w.Body.String()) @@ -238,7 +238,7 @@ func TestLinkVerifyIdempotent(t *testing.T) { repo := newFakeRepo() repo.links[mcUUID] = "u1" // already linked to THIS user repo.linked["u1"] = true - repo.linkCodes["REVERIFYX"] = fakeLinkCode{mcUUID: mcUUID, expiresAt: time.Unix(1_700_000_600, 0)} + repo.linkCodes["REVERIFYX"] = fakeLinkCode{mcUUID: mcUUID, authSource: "mojang", expiresAt: time.Unix(1_700_000_600, 0)} api := newTestAPI(repo, newFakeCluster()) api.External = staticExternal{p: user} @@ -276,7 +276,7 @@ func TestLinkVerifyTakesOverDeletedLinkOnly(t *testing.T) { t.Fatalf("UserByMCUUID(deleted link) = %v, want ErrNotFound (no in-game standing)", err) } - repo.linkCodes["TAKEOVER"] = fakeLinkCode{mcUUID: mcGone, expiresAt: time.Unix(1_700_000_600, 0)} + repo.linkCodes["TAKEOVER"] = fakeLinkCode{mcUUID: mcGone, authSource: "mojang", expiresAt: time.Unix(1_700_000_600, 0)} api := newTestAPI(repo, newFakeCluster()) api.External = staticExternal{p: user} if w := do(api.ExternalHandler(), "POST", "/api/v1/account/link/verify", `{"code":"TAKEOVER"}`, nil); w.Code != http.StatusOK { diff --git a/internal/api/handlers_files.go b/internal/api/handlers_files.go index 29aa55b..059e78b 100644 --- a/internal/api/handlers_files.go +++ b/internal/api/handlers_files.go @@ -87,6 +87,9 @@ func (a *API) handleListFiles(w http.ResponseWriter, r *http.Request) { writeFileEditError(w, r, err) return } + if entries == nil { + entries = []fileedit.Entry{} // an empty directory is [], never null + } writeJSON(w, http.StatusOK, map[string]any{ "path": path, "entries": entries, "truncated": truncated, }) @@ -114,6 +117,9 @@ func (a *API) handleReadFile(w http.ResponseWriter, r *http.Request) { writeFileEditError(w, r, err) return } + if content == nil { + content = []byte{} // an empty file is "", never null + } writeJSON(w, http.StatusOK, map[string]any{"path": path, "content": content, "sha256": sum}) } diff --git a/internal/api/handlers_internal.go b/internal/api/handlers_internal.go index 7895da3..ab41811 100644 --- a/internal/api/handlers_internal.go +++ b/internal/api/handlers_internal.go @@ -43,6 +43,9 @@ func (a *API) handleListServers(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } + if servers == nil { + servers = []ServerInfo{} // an empty fleet is [], never null + } writeJSON(w, http.StatusOK, map[string]any{"servers": servers}) } diff --git a/internal/api/handlers_user.go b/internal/api/handlers_user.go index 63d421f..8a09fef 100644 --- a/internal/api/handlers_user.go +++ b/internal/api/handlers_user.go @@ -270,6 +270,9 @@ func (a *API) handleMyServers(w http.ResponseWriter, r *http.Request) { } } } + if servers == nil { + servers = []MyServerView{} // no servers is [], never null + } writeJSON(w, http.StatusOK, map[string]any{"servers": servers}) } diff --git a/internal/api/main_test.go b/internal/api/main_test.go index ea563a4..55e656f 100644 --- a/internal/api/main_test.go +++ b/internal/api/main_test.go @@ -1,6 +1,7 @@ package api import ( + "fmt" "os" "testing" ) @@ -11,5 +12,24 @@ import ( // changes the day someone buys the name and which CI may not reach at all. func TestMain(m *testing.M) { mojangProfileAPI = "http://127.0.0.1:1/" - os.Exit(m.Run()) + code := m.Run() + // Every exchange the handler tests made is then held to docs/openapi.yaml + // (openapi_contract_test.go). + if code == 0 { + contractCalls.Lock() + violations, err := checkContract("../../docs/openapi.yaml", contractCalls.list) + contractCalls.Unlock() + if err != nil { + fmt.Fprintln(os.Stderr, "openapi contract:", err) + code = 1 + } + for _, v := range violations { + fmt.Fprintln(os.Stderr, "openapi contract:", v) + } + if len(violations) > 0 { + fmt.Fprintf(os.Stderr, "FAIL: %d exchange(s) disagree with docs/openapi.yaml\n", len(violations)) + code = 1 + } + } + os.Exit(code) } diff --git a/internal/api/openapi_contract_test.go b/internal/api/openapi_contract_test.go new file mode 100644 index 0000000..0a2f479 --- /dev/null +++ b/internal/api/openapi_contract_test.go @@ -0,0 +1,511 @@ +package api + +import ( + "encoding/json" + "fmt" + "mime" + "net/http" + "net/http/httptest" + "net/url" + "os" + "reflect" + "regexp" + "runtime" + "sort" + "strconv" + "strings" + "sync" + "testing" + + "sigs.k8s.io/yaml" +) + +// The route-table parity tests (openapi_test.go) prove docs/openapi.yaml names the +// right {method, path, face, tier, setup}; the struct parity test proves the named +// schemas match their Go structs. Neither sees what a handler actually answers. This +// file does: every request a handler test sends through do() is kept, and once the +// whole package has run TestMain holds each exchange to the document — +// +// - the operation must list the status that came back (or a default / 4XX / 5XX); +// - a JSON body must fit the schema documented for that status, and a response +// object may carry only the properties its schema names; +// - the JSON request behind a 2xx must fit the documented requestBody. +// +// Requests to a path the document does not have are left to the route parity test. + +type contractCall struct { + method, path string + reqCT string + reqBody []byte + status int + respCT string + respBody []byte + at string // the test line that sent it +} + +var contractCalls struct { + sync.Mutex + list []contractCall +} + +// recordContract keeps one do() exchange for checkContract. +func recordContract(r *http.Request, body string, w *httptest.ResponseRecorder) { + at := "" + if _, file, line, ok := runtime.Caller(2); ok { + at = fmt.Sprintf("%s:%d", file[strings.LastIndex(file, "/")+1:], line) + } + contractCalls.Lock() + defer contractCalls.Unlock() + contractCalls.list = append(contractCalls.list, contractCall{ + method: r.Method, path: r.URL.Path, + reqCT: r.Header.Get("Content-Type"), reqBody: []byte(body), + status: w.Code, respCT: w.Header().Get("Content-Type"), respBody: w.Body.Bytes(), + at: at, + }) +} + +type contractDoc struct { + // Common lists the answers any operation can give under a condition, declared + // once at the top of the document instead of under every operation. + Common []contractCommon `json:"x-felis-common-responses"` + Paths map[string]map[string]any `json:"paths"` + Components struct { + Schemas map[string]any `json:"schemas"` + Responses map[string]any `json:"responses"` + RequestBodies map[string]any `json:"requestBodies"` + } `json:"components"` +} + +type contractCommon struct { + Status int `json:"status"` + // When is any, internal (an operation the internal face serves), session (one + // that takes the session cookie), setup-locked (a session one a setup-lockdown + // session may not use) or json-body (one that takes a JSON requestBody). + When string `json:"when"` + Code string `json:"code"` + Response map[string]any `json:"response"` +} + +type contractRoute struct { + template string + re *regexp.Regexp + literals int +} + +// checkContract returns one line per exchange that disagrees with docs/openapi.yaml. +func checkContract(path string, calls []contractCall) ([]string, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var doc contractDoc + if err := yaml.Unmarshal(raw, &doc); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + routes := make([]contractRoute, 0, len(doc.Paths)) + for tmpl := range doc.Paths { + routes = append(routes, compileContractRoute(tmpl)) + } + // The most literal template wins: /servers/limbo is not /servers/{name}. + sort.Slice(routes, func(i, j int) bool { + if routes[i].literals != routes[j].literals { + return routes[i].literals > routes[j].literals + } + return routes[i].template < routes[j].template + }) + + var out []string + seen := map[string]bool{} + report := func(c contractCall, format string, args ...any) { + line := fmt.Sprintf("%s %s → %d (%s): %s", c.method, c.path, c.status, c.at, fmt.Sprintf(format, args...)) + if !seen[line] { + seen[line] = true + out = append(out, line) + } + } + for _, c := range calls { + tmpl := "" + for _, r := range routes { + if r.re.MatchString(c.path) { + tmpl = r.template + break + } + } + if tmpl == "" { + continue + } + op, ok := doc.Paths[tmpl][strings.ToLower(c.method)].(map[string]any) + if !ok { + continue + } + // A path asked on the face that does not serve it: the route parity test + // owns which face serves what. + if c.status == http.StatusNotFound && contractErrorMessage(c.respBody) == "no such endpoint" { + continue + } + v := &contractValidator{doc: &doc} + resp, ok := contractResponse(op, c.status) + if !ok { + resp, ok = contractCommonResponse(&doc, op, c) + } + if !ok { + report(c, "status %d is not documented for %s %s", c.status, c.method, tmpl) + continue + } + resp = v.deref(resp) + if schema, ok := jsonSchemaOf(resp); ok && isJSON(c.respCT) && len(c.respBody) > 0 { + var body any + if err := json.Unmarshal(c.respBody, &body); err != nil { + report(c, "response is not JSON: %v", err) + } else { + for _, e := range v.check(schema, body, "response", true) { + report(c, "%s", e) + } + } + } + if c.status/100 == 2 && isJSON(c.reqCT) && len(c.reqBody) > 0 { + rb, ok := op["requestBody"].(map[string]any) + if !ok && c.method == http.MethodGet { + continue // a body on a GET is ignored, whatever it holds + } + if !ok { + report(c, "a JSON request body was accepted but %s %s documents none", c.method, tmpl) + continue + } + if schema, ok := jsonSchemaOf(v.deref(rb)); ok { + var body any + if err := json.Unmarshal(c.reqBody, &body); err == nil { + for _, e := range v.check(schema, body, "request", false) { + report(c, "%s", e) + } + } + } + } + } + sort.Strings(out) + return out, nil +} + +func compileContractRoute(tmpl string) contractRoute { + var b strings.Builder + b.WriteString("^") + literals := 0 + for _, seg := range strings.Split(strings.TrimPrefix(tmpl, "/"), "/") { + b.WriteString("/") + if strings.HasPrefix(seg, "{") && strings.HasSuffix(seg, "}") { + b.WriteString(`[^/]+`) + continue + } + literals++ + b.WriteString(regexp.QuoteMeta(seg)) + } + b.WriteString("$") + return contractRoute{template: tmpl, re: regexp.MustCompile(b.String()), literals: literals} +} + +// contractResponse finds the documented response for a status: the exact code, then +// its class (4XX), then default. +func contractResponse(op map[string]any, status int) (map[string]any, bool) { + responses, _ := op["responses"].(map[string]any) + for _, k := range []string{strconv.Itoa(status), fmt.Sprintf("%dXX", status/100), "default"} { + if r, ok := responses[k].(map[string]any); ok { + return r, true + } + } + return nil, false +} + +// contractCommonResponse finds a common answer whose condition the operation meets +// and whose error code, when it names one, is the one that came back. +func contractCommonResponse(doc *contractDoc, op map[string]any, c contractCall) (map[string]any, bool) { + for _, common := range doc.Common { + if common.Status != c.status || (common.Code != "" && contractErrorCode(c.respBody) != common.Code) { + continue + } + if contractOpMeets(doc, op, common.When) { + return common.Response, true + } + } + return nil, false +} + +func contractOpMeets(doc *contractDoc, op map[string]any, when string) bool { + switch when { + case "any": + return true + case "internal": + faces, _ := op["x-felis-face"].([]any) + return containsJSON(faces, "internal") + case "session": + security, _ := op["security"].([]any) + for _, req := range security { + if m, ok := req.(map[string]any); ok { + if _, ok := m["sessionCookie"]; ok { + return true + } + } + } + return false + case "setup-locked": + allowed, _ := op["x-felis-setup-allowed"].(bool) + return !allowed && contractOpMeets(doc, op, "session") + case "json-body": + rb, _ := op["requestBody"].(map[string]any) + v := &contractValidator{doc: doc} + _, ok := jsonSchemaOf(v.deref(rb)) + return rb != nil && ok + } + return false +} + +func contractErrorCode(body []byte) string { + var e struct { + Error struct{ Code, Message string } `json:"error"` + } + _ = json.Unmarshal(body, &e) + return e.Error.Code +} + +func contractErrorMessage(body []byte) string { + var e struct { + Error struct{ Code, Message string } `json:"error"` + } + _ = json.Unmarshal(body, &e) + return e.Error.Message +} + +func jsonSchemaOf(r map[string]any) (map[string]any, bool) { + content, _ := r["content"].(map[string]any) + media, _ := content["application/json"].(map[string]any) + schema, ok := media["schema"].(map[string]any) + return schema, ok +} + +func isJSON(ct string) bool { + mt, _, err := mime.ParseMediaType(ct) + return err == nil && mt == "application/json" +} + +type contractValidator struct{ doc *contractDoc } + +// deref follows a local $ref (#/components//) until it reaches the object. +func (v *contractValidator) deref(n map[string]any) map[string]any { + for i := 0; i < 16; i++ { + ref, ok := n["$ref"].(string) + if !ok { + return n + } + parts := strings.Split(strings.TrimPrefix(ref, "#/components/"), "/") + if len(parts) != 2 { + return map[string]any{"x-unresolved": ref} + } + name, _ := url.PathUnescape(parts[1]) + var table map[string]any + switch parts[0] { + case "schemas": + table = v.doc.Components.Schemas + case "responses": + table = v.doc.Components.Responses + case "requestBodies": + table = v.doc.Components.RequestBodies + } + next, ok := table[name].(map[string]any) + if !ok { + return map[string]any{"x-unresolved": ref} + } + n = next + } + return n +} + +// check returns where value leaves schema. strict (responses) also refuses object +// properties the schema does not name, unless it allows additional ones. +func (v *contractValidator) check(schema map[string]any, value any, at string, strict bool) []string { + schema = v.deref(schema) + if ref, ok := schema["x-unresolved"]; ok { + return []string{fmt.Sprintf("%s: unresolved $ref %v", at, ref)} + } + var errs []string + if all, ok := schema["allOf"].([]any); ok { + for _, s := range all { + if m, ok := s.(map[string]any); ok { + errs = append(errs, v.check(m, value, at, false)...) + } + } + } + for _, key := range []string{"oneOf", "anyOf"} { + alts, ok := schema[key].([]any) + if !ok { + continue + } + matched := false + for _, s := range alts { + if m, ok := s.(map[string]any); ok && len(v.check(m, value, at, strict)) == 0 { + matched = true + break + } + } + if !matched { + errs = append(errs, fmt.Sprintf("%s: matches none of %s", at, key)) + } + } + if types := schemaTypes(schema); len(types) > 0 && !types[jsonKind(value)] && + !(jsonKind(value) == "integer" && types["number"]) { + return append(errs, fmt.Sprintf("%s: is %s, documented as %s", at, jsonKind(value), typeList(types))) + } + if enum, ok := schema["enum"].([]any); ok && !containsJSON(enum, value) { + errs = append(errs, fmt.Sprintf("%s: %v is not one of %v", at, value, enum)) + } + if c, ok := schema["const"]; ok && !reflect.DeepEqual(c, value) { + errs = append(errs, fmt.Sprintf("%s: %v is not the documented %v", at, value, c)) + } + switch val := value.(type) { + case map[string]any: + props, _ := schema["properties"].(map[string]any) + req, _ := schema["required"].([]any) + for _, r := range req { + if name, _ := r.(string); name != "" { + if _, ok := val[name]; !ok { + errs = append(errs, fmt.Sprintf("%s: required property %q is missing", at, name)) + } + } + } + extra, hasExtra := schema["additionalProperties"] + keys := make([]string, 0, len(val)) + for k := range val { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if ps, ok := props[k].(map[string]any); ok { + errs = append(errs, v.check(ps, val[k], at+"."+k, strict)...) + continue + } + switch e := extra.(type) { + case map[string]any: + errs = append(errs, v.check(e, val[k], at+"."+k, strict)...) + case bool: + if !e { + errs = append(errs, fmt.Sprintf("%s: property %q is not documented", at, k)) + } + default: + if strict && !hasExtra && props != nil { + errs = append(errs, fmt.Sprintf("%s: property %q is not documented", at, k)) + } + } + } + case []any: + if items, ok := schema["items"].(map[string]any); ok { + for i, item := range val { + errs = append(errs, v.check(items, item, fmt.Sprintf("%s[%d]", at, i), strict)...) + } + } + } + return errs +} + +func schemaTypes(s map[string]any) map[string]bool { + out := map[string]bool{} + switch t := s["type"].(type) { + case string: + out[t] = true + case []any: + for _, x := range t { + if name, ok := x.(string); ok { + out[name] = true + } + } + } + if n, _ := s["nullable"].(bool); n && len(out) > 0 { + out["null"] = true + } + return out +} + +func typeList(types map[string]bool) string { + names := make([]string, 0, len(types)) + for t := range types { + names = append(names, t) + } + sort.Strings(names) + return strings.Join(names, "|") +} + +func jsonKind(v any) string { + switch x := v.(type) { + case nil: + return "null" + case bool: + return "boolean" + case float64: + if x == float64(int64(x)) { + return "integer" + } + return "number" + case string: + return "string" + case []any: + return "array" + case map[string]any: + return "object" + } + return fmt.Sprintf("%T", v) +} + +func containsJSON(list []any, v any) bool { + for _, x := range list { + if reflect.DeepEqual(x, v) { + return true + } + } + return false +} + +// The checker is itself held to known drifts: each synthetic exchange below either +// disagrees with docs/openapi.yaml in exactly the way named, or agrees with it. +func TestContractCheckerCatchesDrift(t *testing.T) { + jsonCT := "application/json" + errBody := func(code, msg string) []byte { + return []byte(`{"error":{"code":"` + code + `","message":"` + msg + `","request_id":"r"}}`) + } + calls := []contractCall{ + // An empty fleet sent as null. + {method: "GET", path: "/api/v1/servers", status: 200, respCT: jsonCT, respBody: []byte(`{"servers":null}`), at: "a"}, + // A property the schema does not name. + {method: "GET", path: "/api/v1/servers", status: 200, respCT: jsonCT, respBody: []byte(`{"servers":[],"extra":1}`), at: "b"}, + // A status the operation does not list. + {method: "GET", path: "/api/v1/me", status: 418, respCT: jsonCT, respBody: errBody("teapot", "no"), at: "c"}, + // wrong_caller is an internal-face answer; /me is external only. + {method: "GET", path: "/api/v1/me", status: 403, respCT: jsonCT, respBody: errBody("wrong_caller", "no"), at: "d"}, + // ...and on an internal operation it is a common answer. + {method: "GET", path: "/api/v1/servers", status: 403, respCT: jsonCT, respBody: errBody("wrong_caller", "no"), at: "e"}, + // /me stays open during the setup lockdown, so setup_required is not its answer... + {method: "GET", path: "/api/v1/me", status: 403, respCT: jsonCT, respBody: errBody("setup_required", "no"), at: "f"}, + // ...while a session operation outside the allow-list may give it. + {method: "GET", path: "/api/v1/servers/survival/files", status: 403, respCT: jsonCT, respBody: errBody("setup_required", "no"), at: "g"}, + // A path this face does not serve belongs to the route parity test. + {method: "GET", path: "/api/v1/me", status: 404, respCT: jsonCT, respBody: errBody("not_found", "no such endpoint"), at: "h"}, + // The request behind a 2xx must fit the requestBody. + {method: "POST", path: "/api/v1/account/link/verify", reqCT: jsonCT, reqBody: []byte(`{"code":5}`), + status: 200, respCT: jsonCT, respBody: []byte(`{"linked":true,"mc_uuid":"u","auth_source":"mojang"}`), at: "i"}, + // A response field outside its enum. + {method: "POST", path: "/api/v1/account/link/verify", reqCT: jsonCT, reqBody: []byte(`{"code":"ABC"}`), + status: 200, respCT: jsonCT, respBody: []byte(`{"linked":true,"mc_uuid":"u","auth_source":""}`), at: "j"}, + } + got, err := checkContract("../../docs/openapi.yaml", calls) + if err != nil { + t.Fatal(err) + } + want := []string{ + `GET /api/v1/me → 403 (d): status 403 is not documented for GET /api/v1/me`, + `GET /api/v1/me → 403 (f): status 403 is not documented for GET /api/v1/me`, + `GET /api/v1/me → 418 (c): status 418 is not documented for GET /api/v1/me`, + `GET /api/v1/servers → 200 (a): response.servers: is null, documented as array`, + `GET /api/v1/servers → 200 (b): response: property "extra" is not documented`, + `POST /api/v1/account/link/verify → 200 (i): request.code: is integer, documented as string`, + `POST /api/v1/account/link/verify → 200 (j): response.auth_source: is not one of [mojang thirdparty]`, + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("violations:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} diff --git a/internal/api/openapi_test.go b/internal/api/openapi_test.go index e9c1e41..46b53a5 100644 --- a/internal/api/openapi_test.go +++ b/internal/api/openapi_test.go @@ -43,6 +43,8 @@ type oasOp struct { Faces []string `json:"x-felis-face"` Tier string `json:"x-felis-tier"` Callers []string `json:"x-felis-callers"` + // Setup marks an operation a setup-lockdown session may still use. + Setup bool `json:"x-felis-setup-allowed"` } // oasFacet is the classification of one {method, path}: which face(s) serve it @@ -52,6 +54,8 @@ type oasFacet struct { tier string // callers is the internal route's caller set, empty elsewhere. callers map[string]bool + // setup is the route's SetupAllowed: reachable during the setup lockdown. + setup bool } func TestOpenAPIMatchesServedRoutes(t *testing.T) { @@ -83,6 +87,9 @@ func TestOpenAPIMatchesServedRoutes(t *testing.T) { if s.tier != d.tier { t.Errorf("%s: x-felis-tier mismatch — served %q, documented %q", key, s.tier, d.tier) } + if s.setup != d.setup { + t.Errorf("%s: x-felis-setup-allowed mismatch — served %v, documented %v", key, s.setup, d.setup) + } if !oasSameSet(s.callers, d.callers) { t.Errorf("%s: x-felis-callers mismatch — served %v, documented %v", key, oasSortedKeys(s.callers), oasSortedKeys(d.callers)) @@ -99,7 +106,7 @@ func oasServedFacets(t *testing.T) map[string]oasFacet { t.Helper() a := &API{} out := map[string]oasFacet{} - add := func(method, pattern, face, tier string, callers ...Caller) { + add := func(method, pattern, face, tier string, setup bool, callers ...Caller) { key := method + " " + pattern f, ok := out[key] if !ok { @@ -113,6 +120,7 @@ func oasServedFacets(t *testing.T) map[string]oasFacet { t.Fatalf("%s: route table assigns conflicting tiers %q and %q", key, f.tier, tier) } f.tier = tier + f.setup = f.setup || setup out[key] = f } for _, rt := range a.internalAPIRoutes() { @@ -120,7 +128,7 @@ func oasServedFacets(t *testing.T) map[string]oasFacet { if rt.Public { tier = "public" } - add(rt.Method, rt.Pattern, "internal", tier, rt.Callers...) + add(rt.Method, rt.Pattern, "internal", tier, rt.SetupAllowed, rt.Callers...) } for _, rt := range a.externalAPIRoutes() { var tier string @@ -134,7 +142,7 @@ func oasServedFacets(t *testing.T) map[string]oasFacet { default: tier = "app" } - add(rt.Method, rt.Pattern, "external", tier) + add(rt.Method, rt.Pattern, "external", tier, rt.SetupAllowed) } return out } @@ -186,7 +194,7 @@ func oasDocumentedFacets(t *testing.T) map[string]oasFacet { for _, c := range op.Callers { callers[c] = true } - out[key] = oasFacet{faces: faces, tier: op.Tier, callers: callers} + out[key] = oasFacet{faces: faces, tier: op.Tier, callers: callers, setup: op.Setup} } } return out diff --git a/internal/api/submissions_test.go b/internal/api/submissions_test.go index 57b6f6a..257a18f 100644 --- a/internal/api/submissions_test.go +++ b/internal/api/submissions_test.go @@ -409,7 +409,7 @@ func TestMySubmissionsCarriesBuildOutcome(t *testing.T) { // the whole list; any other lookup failure must surface, never be swallowed. func TestMySubmissionsBuildLookupSemantics(t *testing.T) { // Missing row (ErrNotFound): 200 with no build fields. - fs := &fakeSubmissions{byResult: []submit.Submission{{ID: "sub-1", BuildID: "bld-gone"}}} + fs := &fakeSubmissions{byResult: []submit.Submission{{ID: "sub-1", Status: submit.StatusApproved, BuildID: "bld-gone"}}} api := appSubAPI(fs) api.Builder = &fakeBuilder{} w := do(api.ExternalHandler(), "GET", "/api/v1/me/submissions", "", nil) @@ -486,9 +486,9 @@ func TestSubmissionListsForwardThePage(t *testing.T) { // with no linked build asks nothing. func TestSubmissionPageLooksUpBuildsOnce(t *testing.T) { fs := &fakeSubmissions{listed: []submit.Submission{ - {ID: "sub-1", BuildID: "bld-1"}, - {ID: "sub-2"}, - {ID: "sub-3", BuildID: "bld-3"}, + {ID: "sub-1", Status: submit.StatusApproved, BuildID: "bld-1"}, + {ID: "sub-2", Status: submit.StatusPendingReview}, + {ID: "sub-3", Status: submit.StatusApproved, BuildID: "bld-3"}, }} fb := &fakeBuilder{getBuilds: map[string]*build.Build{ "bld-1": {ID: "bld-1", Status: build.StatusSucceeded}, @@ -518,7 +518,7 @@ func TestSubmissionPageLooksUpBuildsOnce(t *testing.T) { t.Fatalf("outcomes = %+v", got.Submissions) } - fs.listed = []submit.Submission{{ID: "sub-2"}} + fs.listed = []submit.Submission{{ID: "sub-2", Status: submit.StatusPendingReview}} fb.getManyIDs = nil if w := do(api.ExternalHandler(), "GET", "/api/v1/submissions", "", nil); w.Code != http.StatusOK { t.Fatalf("unlinked page: code = %d", w.Code) diff --git a/panel/src/lib/openapi.gen.ts b/panel/src/lib/openapi.gen.ts index 036c661..3ccb169 100644 --- a/panel/src/lib/openapi.gen.ts +++ b/panel/src/lib/openapi.gen.ts @@ -2396,6 +2396,69 @@ export interface components { }; content?: never; }; + /** @description An unexpected failure (internal); the details are in the server log under the request id. */ + InternalError: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description A genuine service token for a caller this operation does not serve (wrong_caller). */ + WrongCaller: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description A session of a player (not admin or owner) on the operator console host, refused before any handler (forbidden). */ + StaffOnlyHost: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description The session belongs to an account still in first-run setup, which may use only the x-felis-setup-allowed operations until it has a durable sign-in (setup_required). */ + SetupRequired: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description The JSON body is over 1 MiB (too_large). */ + TooLarge: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description A body sent with a Content-Type other than application/json (unsupported_media_type). */ + UnsupportedMediaType: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description The store this writes to is full — the backup archive (backup_store_full), the world volume (volume_full) or the upload area (uploads_full). */ + InsufficientStorage: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Malformed or invalid request (validation, bad body, unknown field). */ BadRequest: { headers: { @@ -2850,6 +2913,15 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description The node is at its running-server cap (at_capacity). */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; }; }; internalStatus: { @@ -3101,6 +3173,15 @@ export interface operations { }; 400: components["responses"]["BadRequest"]; 401: components["responses"]["Unauthorized"]; + /** @description The linked account holds a staff role and is never reclaimed (protected_admin). */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; }; }; checkUsernameBlacklist: { @@ -3403,6 +3484,15 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description The node is at its running-server cap (at_capacity). */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; }; }; stop: { @@ -3559,6 +3649,15 @@ export interface operations { }; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["Forbidden"]; + /** @description Unknown server. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Server not running. */ 409: { headers: { @@ -3568,6 +3667,15 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description This session already holds as many console streams as it may (too_many_streams). */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; 503: components["responses"]["ServiceUnavailable"]; }; }; @@ -4119,7 +4227,7 @@ export interface operations { "application/json": { user_id: string; /** @enum {string} */ - role: "user" | "admin"; + role: "owner" | "admin" | "user"; }; }; }; @@ -4266,7 +4374,7 @@ export interface operations { "application/json": { user_id: string; /** @enum {string} */ - role: "user" | "admin"; + role: "owner" | "admin" | "user"; }; }; }; @@ -4405,7 +4513,7 @@ export interface operations { "application/json": { user_id: string; /** @enum {string} */ - role: "user" | "admin"; + role: "owner" | "admin" | "user"; }; }; }; @@ -4566,7 +4674,7 @@ export interface operations { "application/json": { user_id: string; /** @enum {string} */ - role: "user" | "admin"; + role: "owner" | "admin" | "user"; }; }; }; @@ -4625,7 +4733,7 @@ export interface operations { user_id: string; username: string; /** @enum {string} */ - role: "user" | "admin"; + role: "owner" | "admin" | "user"; email: string; email_verified: boolean; has_passkey: boolean; @@ -4682,7 +4790,7 @@ export interface operations { user_id: string; username: string; /** @enum {string} */ - role: "user" | "admin"; + role: "owner" | "admin" | "user"; email: string; email_verified: boolean; has_passkey: boolean; @@ -4814,6 +4922,15 @@ export interface operations { }; }; 401: components["responses"]["Unauthorized"]; + /** @description The account settings could not be read (auth_unavailable). */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; }; }; myServers: { @@ -5003,6 +5120,15 @@ export interface operations { }; }; }; + /** @description Malformed server name (bad_name) or request body. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["Forbidden"]; /** @description No matching backup. */ @@ -5050,6 +5176,15 @@ export interface operations { }; }; }; + /** @description Malformed server name (bad_name). */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["Forbidden"]; /** @description Unknown server. */ @@ -5070,7 +5205,17 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description Another backup of this server started within the cooldown (backup_cooldown). */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; 503: components["responses"]["ServiceUnavailable"]; + 507: components["responses"]["InsufficientStorage"]; }; }; listServerJobs: { @@ -5289,6 +5434,7 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; + 507: components["responses"]["InsufficientStorage"]; }; }; writeServerFile: { @@ -5643,6 +5789,15 @@ export interface operations { }; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["Forbidden"]; + /** @description Unknown user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; }; }; setQuotas: { @@ -5677,6 +5832,15 @@ export interface operations { 400: components["responses"]["BadRequest"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["Forbidden"]; + /** @description Unknown user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; }; }; listUserSessions: { @@ -5837,6 +6001,15 @@ export interface operations { 400: components["responses"]["BadRequest"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["Forbidden"]; + /** @description Unknown user. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description UUID is already linked to a different user. */ 409: { headers: { @@ -6058,6 +6231,15 @@ export interface operations { }; }; 401: components["responses"]["Unauthorized"]; + /** @description Another account already proved this address (email_taken); the code is not consumed. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; /** @description Too many incorrect attempts on this code (otp_locked), or the account's daily wrong-code budget is spent (otp_account_locked, with Retry-After). */ 429: { headers: { @@ -7230,6 +7412,15 @@ export interface operations { "text/event-stream": string; }; }; + /** @description Malformed build id (bad_request). The id becomes a label-selector value, so it is refused before any lookup. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["Forbidden"]; 404: components["responses"]["NotFound"]; @@ -7405,6 +7596,7 @@ export interface operations { "application/json": components["schemas"]["Submission"]; }; }; + 400: components["responses"]["BadRequest"]; 401: components["responses"]["Unauthorized"]; 403: components["responses"]["Forbidden"]; 404: components["responses"]["NotFound"];