# felis-api — OpenAPI 3.1 description of both faces (spec §7, §14, §28 #7). # # ONE binary serves TWO http.Handlers (internal / external). This document # describes both, distinguished per-operation by the `x-felis-face` extension # (an array, because `/healthz` is served by both faces) and `x-felis-tier` # (the Zero-Trust grade: public | service | app | admin). # # VERIFIED vs. HAND-MAINTAINED — read before trusting a field: # * The {method, path} -> {x-felis-face set, x-felis-tier} mapping is # machine-checked. internal/api/openapi_test.go parses this file and asserts # EXACT bidirectional parity against the route tables the handlers are built # 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. # * 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 description: | Control plane for the Felis Minecraft orchestration platform. The same binary exposes an internal face (per-caller service tokens, for velocity / backend callbacks, never Zero Trust) and an external face (the felis_session cookie, for people and the panel; Cloudflare Access, when present, is enforced at the edge). Admin-tier external operations additionally require a staff session on the operator console host. See `x-felis-face` / `x-felis-tier` on each operation. Behaviour every operation shares, and so not repeated under each: * Every response carries `X-Request-Id` (a well-formed inbound one is kept), `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer` and `Content-Security-Policy: default-src 'none'`. `Strict-Transport-Security` is added when the request came through the TLS edge (`X-Forwarded-Proto: https`). * A path no operation serves is `404 not_found`; a path served under other methods is `405 method_not_allowed` with an `Allow` header. * A POST/PUT/PATCH/DELETE a browser sends from another site (`Sec-Fetch-Site` `same-site` or `cross-site`, or an `Origin` whose host is not the request's) is `403 cross_site`, before authentication. Callers that send neither header (the plugins, scripts) are unaffected. * A JSON body over 1 MiB is `413 too_large`. A request body must keep arriving: after 30 s it has to average 16 KiB/s or the connection is closed. * Event streams (the console and build logs) tag each line with `id:` (unix seconds); an EventSource that reconnects with `Last-Event-ID` within the hour resumes from that second instead of the tailed backlog. The server re-checks the caller every minute and ends the stream with `event: revoked` once the session or the access is gone; a stream also closes after 30 minutes and on server shutdown, and the client simply reconnects. servers: - url: https://api-internal.{root_domain} description: >- Internal face. Service-token auth (Authorization: Bearer ); reachable only from inside the cluster. Never wrapped in Zero Trust. variables: root_domain: default: example.test - url: https://api.{root_domain} description: >- External face. Session-cookie auth on every non-public /api/v1 route; admin-tier routes additionally require a staff session on the operator console host. variables: root_domain: default: example.test tags: - name: health description: Liveness / readiness probes (unauthenticated). - name: servers-internal description: Service-token server lookup and lifecycle callbacks (internal face). - name: lobby description: felis-paper lobby actions velocity drives on the player's behalf (internal face). - name: account-internal description: In-game account-link code minting (internal face). - name: servers description: Operate on your own servers (external face, app tier). - name: console description: Read (SSE) and write (RCON) server console (external face, app tier). - name: backups description: World backup listing, restore and export (external face, app tier). - name: schedules description: Scheduled tasks of your own servers (external face, app tier). - name: account description: Web side of account linking (external face, app tier). - name: admin-servers description: Create / mutate server specs (external face, admin tier). - name: images description: Image build and whitelist administration (external face, admin tier). - name: users description: User administration (external face, admin tier only — exposed solely to owner). components: securitySchemes: serviceToken: type: http scheme: bearer description: >- Static per-caller token (internal face). Each machine holds its own — velocity (felis-service-token), limbo (felis-limbo-token), build (felis-build-token), ops (felis-ops-token) — and each operation lists the callers it serves in x-felis-callers. A genuine token for a caller the operation does not list is refused with 403 wrong_caller. `felis rotate-token -yes ` replaces one. sessionCookie: type: apiKey in: cookie name: felis_session description: >- Opaque session cookie (external face). Minted by the passwordless session doors — passkey login, email-OTP, bind code, and op-login finish — HttpOnly+Secure+SameSite=Lax and host-only, so an op.console session never reaches the player console. Only its sha-256 is persisted. It is the external face's only credential: Cloudflare Access, when the install sits behind it, is enforced at the edge and felis-api does not read the Access JWT. Admin-tier operations additionally require a staff session on the operator console host. 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: application/json: schema: { $ref: '#/components/schemas/Error' } Unauthorized: description: Authentication missing or invalid. content: application/json: schema: { $ref: '#/components/schemas/Error' } Forbidden: description: Authenticated but not permitted (ownership / admin / quota). content: application/json: schema: { $ref: '#/components/schemas/Error' } NotFound: description: No such server / build / record. content: application/json: schema: { $ref: '#/components/schemas/Error' } Conflict: description: Precondition failed (lost race, not running, already claimed/linked, not stopped). content: application/json: schema: { $ref: '#/components/schemas/Error' } PreconditionFailed: description: A required prior step is missing (e.g. account not linked). content: application/json: schema: { $ref: '#/components/schemas/Error' } ServiceUnavailable: description: A required subsystem (builder / console / logs / restorer / repo / cluster) is not wired or reachable. content: application/json: schema: { $ref: '#/components/schemas/Error' } Reauthed: description: This session is reauthed until the returned time. content: application/json: schema: type: object required: [ok, until] properties: ok: { type: boolean, const: true } until: { type: string, format: date-time } ReauthRequired: description: > reauth_required: this change adds, removes or moves a way into the account, and the account has a passkey or a verified email, so the session must have proven one of them within the last 5 minutes. Signing in by passkey, email code, op-login or the setup token counts; a bind-code sign-in does not. GET /api/v1/account/reauth lists the factors that can give the proof, then retry the change. content: application/json: schema: { $ref: '#/components/schemas/Error' } MailUndeliverable: description: > The configured SMTP relay refused the message (code mail_undeliverable), so no code was delivered. Distinct from 500 because the fault is in the install's [smtp] settings, not in the request or the platform — most often a From address the relay will not let this account send as. The relay's own text is deliberately withheld (it names the SMTP account) and written to the felis-api log instead, keyed by the same request_id this response carries. Retrying the same address changes nothing until an operator fixes the relay. content: application/json: schema: { $ref: '#/components/schemas/Error' } MailUnavailable: description: > This install has no [smtp] relay (code mail_unavailable), so no code was minted or sent. The public doors answer it before resolving the address, so it is the same for every address. Sign in with a passkey, or have the operator configure email with felis setup. content: application/json: schema: { $ref: '#/components/schemas/Error' } RateLimited: description: > This client address called the public sign-in doors faster than the per-address limit allows (code rate_limited); Retry-After gives the seconds until the next call is admitted. The address is the visitor header the install's edge writes ([auth] client_ip_header: CF-Connecting-IP behind the Cloudflare tunnel), else the TCP peer; IPv6 clients share one limit per /64. headers: Retry-After: schema: { type: integer } content: application/json: schema: { $ref: '#/components/schemas/Error' } AccessResult: description: The structured access mutation succeeded; the raw RCON reply is in output. content: application/json: schema: type: object required: [name, action, output] properties: name: { type: string } action: { type: string } player: { type: string } node: { type: string } group: { type: string } output: { type: string } schemas: Error: type: object description: Uniform error envelope emitted by every handler (internal/api/errors.go). required: [error] properties: error: type: object required: [code, message] properties: code: type: string description: Stable machine-readable code (e.g. not_found, conflict, forbidden, bad_request). message: type: string request_id: type: string description: Correlates the response with server logs (withRequestID middleware). UpdateWindow: type: object description: > The SysAdmin-set auto-update maintenance window (internal/api/handlers_updates.go updateWindow). An absolute [start,end) interval during which Felis may apply a Scheduled component's update to itself; both ends null means unset (no apply is ever opened). Keys are always present; their values are null when unset. required: [start, end] properties: start: type: string format: date-time nullable: true description: Window start (RFC3339, inclusive), or null when unset. end: type: string format: date-time nullable: true description: Window end (RFC3339, exclusive), or null when unset. DBBackupStatus: type: object description: > The newest control-plane database backup the host recorded (internal/api/handlers_dbbackup.go dbBackupView; the record itself is internal/dbbackup Status, written by `felis db backup`). required: [last, stale, max_age_seconds] properties: last: type: object nullable: true description: Null until the first backup has been recorded. required: [at, name, label, size_bytes, dir] properties: at: type: string format: date-time description: When the bundle was written. name: type: string description: Bundle file name, felis-db--