# 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. # * 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. # # 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 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 and restore (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 ` 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. 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--