# 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: | Staff on the operator console may manage the reserved login/lobby system services through existing management routes, without player ownership rows. Creating and claiming these reserved names remain prohibited. System patches keep public autostart and idle stop disabled. Customization is persisted as felis-experience.json using the existing file API. Staff may read and save this startup-only config while system services run; restart to apply it. 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: AuthSourceConfig: type: object additionalProperties: false required: [tag, prefix, url, api_url, enabled] properties: tag: type: string description: Permanent UUID namespace; saved tags cannot be renamed or removed. mojang is reserved. prefix: type: string pattern: '^[A-Za-z0-9]{1,4}$' description: Unique case-insensitive display prefix. url: type: string description: hasJoined endpoint; HTTPS required except for localhost or private literal IP addresses. No query or fragment. api_url: type: string description: Optional Yggdrasil API base for role lookup; empty infers it from the standard hasJoined suffix. enabled: { type: boolean } StartupStatus: type: object required: [stage, logsAvailable] properties: stage: { type: string, enum: [creating, scheduling, preparing, booting, failed] } reason: { type: string } message: { type: string } startedAt: { type: string, format: date-time } logsAvailable: { type: boolean } WakePolicySettings: type: object required: [maxRunningServers, wakeCooldownSeconds, revision, managed] properties: maxRunningServers: { type: integer, minimum: 0, maximum: 10000 } wakeCooldownSeconds: { type: integer, minimum: 0, maximum: 3600 } revision: { type: string } managed: { type: boolean } AuthSourcesSettings: type: object required: [sources, revision, managed] properties: sources: type: array maxItems: 32 description: Third-party sources in priority order; built-in Mojang always precedes them and is immutable. items: { $ref: '#/components/schemas/AuthSourceConfig' } revision: type: string description: Opaque revision to send unchanged when saving; stale or concurrent writes return 409. managed: type: boolean description: True when stored in platform_settings; false while using installation TOML defaults. 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--