# 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 (service-token auth, for velocity / backend callbacks, never Zero Trust) and an external face (Cloudflare Access JWT auth, for people and the panel). Admin-tier external operations additionally require the admin Access path. See `x-felis-face` / `x-felis-tier` on each operation. 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. Cloudflare Access JWT auth on every /api/v1 route; admin-tier routes additionally require the admin Access path. 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). components: securitySchemes: serviceToken: type: http scheme: bearer description: Static service token presented by velocity / backend callers (internal face). accessJWT: type: apiKey in: header name: Cf-Access-Jwt-Assertion description: >- Cloudflare Access JWT (external face). Admin-tier operations require the token to have traversed the admin Access path; the handler additionally asserts Principal.IsAdmin(). sessionCookie: type: apiKey in: cookie name: felis_session description: >- Opaque local-password session cookie (external face). Minted by POST /api/v1/auth/login when local auth is enabled, HttpOnly+Secure+ SameSite=Lax and host-only, so an op.console session never reaches the player console. Only its sha-256 is persisted. SessionAuth prefers this cookie and otherwise delegates to accessJWT, so the two models coexist on one face. 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' } 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). Phase: type: string description: MinecraftServer lifecycle phase (internal/apis/felis/v1alpha1). enum: [Unknown, Stopped, Starting, Running, Stopping, Failed] ServerInfo: type: object description: Status projection of one server (internal/api/cluster.go ServerInfo). required: [name, subdomain, phase, ready, playersOnline, playersMax] properties: name: { type: string } subdomain: { type: string } phase: { $ref: '#/components/schemas/Phase' } ready: { type: boolean } autostartPolicy: type: string description: Present only when set; who may wake the server via domain-autostart. desiredState: type: string description: Present only when set; the operator's target state. enum: [Running, Stopped] endpointMode: { type: string } endpointAddress: { type: string } playersOnline: { type: integer, format: int32 } playersMax: { type: integer, format: int32 } MyServerView: type: object description: One row of the caller's server list (internal/api/repo.go MyServerView). required: [name, subdomain, owned, claimable] properties: name: { type: string } subdomain: { type: string } owned: { type: boolean } claimable: { type: boolean } phase: allOf: [{ $ref: '#/components/schemas/Phase' }] description: Present only when known. BackupView: type: object description: One world backup (internal/api/repo.go BackupView). backup_ref is withheld (spec §286). required: [id, server_name, size_bytes, reason, status, created_at, expires_at] properties: id: { type: string } server_name: { type: string } former_owner: type: string description: Present only when the world had an owner at backup time. size_bytes: { type: integer, format: int64 } reason: { type: string } status: { type: string } created_at: { type: string, format: date-time } expires_at: { type: string, format: date-time } Build: type: object description: One image build (internal/build Build). required: [id, image_ref, status, requested_by, created_at] properties: id: { type: string } image_ref: { type: string } status: type: string enum: [pending, building, succeeded, failed, cancelled] dockerfile: { type: string } context_ref: { type: string } base_image: { type: string } requested_by: { type: string } job_name: { type: string } log_ref: { type: string } error: { type: string } created_at: { type: string, format: date-time } finished_at: type: [string, 'null'] format: date-time description: Null until the build reaches a terminal status. Image: type: object description: One whitelisted image (internal/build Image). required: [image_ref, source, added_by, enabled, added_at] properties: image_ref: { type: string } source: { type: string } build_id: { type: string } added_by: { type: string } enabled: { type: boolean } added_at: { type: string, format: date-time } Submission: type: object description: >- One user-submitted modpack in the approval lane (internal/submit Submission — a user-directed extension over the §16 build subsystem). The user supplies only display_name; submitted_by comes from the principal and context_ref/image_ref/build_id/reviewed_by are platform-controlled, never client input. required: [id, submitted_by, display_name, context_ref, status, created_at] properties: id: { type: string } submitted_by: { type: string } display_name: { type: string } context_ref: type: string description: Platform-derived pinned build context; not user-supplied. status: type: string enum: [pending_review, approved, rejected] image_ref: type: string description: Platform-derived push target, set at approval. build_id: type: string description: image_builds.id, set only after the build hand-off succeeds. reviewed_by: { type: string } reject_reason: { type: string } created_at: { type: string, format: date-time } reviewed_at: type: [string, 'null'] format: date-time description: Null until an admin approves or rejects. paths: # ----------------------------------------------------------------- health --- /healthz: get: tags: [health] operationId: healthz summary: Liveness probe. description: Unauthenticated on both faces; kubelet and Cloudflare hold no token. x-felis-face: [internal, external] x-felis-tier: public security: [] responses: '200': description: Always ok when the process is up. content: application/json: schema: type: object required: [status] properties: status: { type: string, const: ok } /readyz: get: tags: [health] operationId: readyz summary: Readiness probe (internal face only — readiness is an internal concern). x-felis-face: [internal] x-felis-tier: public security: [] responses: '200': description: Repo and Cluster are wired. content: application/json: schema: type: object required: [status] properties: status: { type: string, const: ready } '503': $ref: '#/components/responses/ServiceUnavailable' # -------------------------------------------------- internal: servers ------ /api/v1/servers: get: tags: [servers-internal] operationId: listServers summary: List all servers (velocity route table). x-felis-face: [internal] x-felis-tier: service security: [{ serviceToken: [] }] responses: '200': description: Every server's status projection. content: application/json: schema: type: object required: [servers] properties: servers: type: array items: { $ref: '#/components/schemas/ServerInfo' } '401': $ref: '#/components/responses/Unauthorized' post: tags: [admin-servers] operationId: createServer summary: Create a server (admin). description: Requires the admin Access path; the image must be whitelisted. x-felis-face: [external] x-felis-tier: admin security: [{ accessJWT: [] }] requestBody: required: true content: application/json: schema: type: object required: [name, subdomain] properties: name: { type: string } subdomain: { type: string } display_name: { type: string } image: { type: string } memory: { type: string } storage: { type: string } autostart_policy: { type: string } resources: type: object properties: cpu: { type: string } cpu_request: { type: string } memory: { type: string } memory_request: { type: string } responses: '201': description: Created; starts Stopped. content: application/json: schema: type: object required: [name, subdomain, desiredState] properties: name: { type: string } subdomain: { type: string } desiredState: { type: string, const: Stopped } '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' '503': $ref: '#/components/responses/ServiceUnavailable' /api/v1/servers/by-host/{host}: get: tags: [servers-internal] operationId: serverByHost summary: Resolve a server by its connecting hostname (velocity host routing). x-felis-face: [internal] x-felis-tier: service security: [{ serviceToken: [] }] parameters: - { name: host, in: path, required: true, schema: { type: string } } responses: '200': description: The matching server's status projection. content: application/json: schema: { $ref: '#/components/schemas/ServerInfo' } '401': $ref: '#/components/responses/Unauthorized' '404': $ref: '#/components/responses/NotFound' /api/v1/internal/servers/{name}/ready: post: tags: [servers-internal] operationId: serverReadyCallback summary: Backend readiness callback — the server reports it is accepting players. x-felis-face: [internal] x-felis-tier: service security: [{ serviceToken: [] }] parameters: - { name: name, in: path, required: true, schema: { type: string } } responses: '204': $ref: '#/components/responses/NoContent' '401': $ref: '#/components/responses/Unauthorized' '404': $ref: '#/components/responses/NotFound' /api/v1/internal/servers/{name}/join-event: post: tags: [servers-internal] operationId: joinEvent summary: Player-join event by online-mode UUID (activity tracking / idle reset). x-felis-face: [internal] x-felis-tier: service security: [{ serviceToken: [] }] parameters: - { name: name, in: path, required: true, schema: { type: string } } requestBody: required: true content: application/json: schema: type: object required: [mc_uuid] properties: mc_uuid: { type: string } responses: '204': $ref: '#/components/responses/NoContent' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '404': $ref: '#/components/responses/NotFound' /api/v1/internal/servers/{name}/wake: post: tags: [servers-internal] operationId: internalWake summary: Domain-autostart wake driven by velocity for a joining player (spec §9.1). description: >- velocity holds no web Principal, so it drives the wake lever with its service token, identifying the player by online-mode UUID. Gated by the server's autostartPolicy and the per-server wake cooldown. x-felis-face: [internal] x-felis-tier: service security: [{ serviceToken: [] }] parameters: - { name: name, in: path, required: true, schema: { type: string } } requestBody: required: true content: application/json: schema: type: object required: [mc_uuid] properties: mc_uuid: { type: string } responses: '202': description: Wake accepted (or already awake). content: application/json: schema: type: object required: [name, desiredState, phase, ready] properties: name: { type: string } desiredState: { type: string, const: Running } phase: { $ref: '#/components/schemas/Phase' } ready: { type: boolean } '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': description: Wake cooldown is still active for this server. content: application/json: schema: { $ref: '#/components/schemas/Error' } /api/v1/internal/servers/{name}/status: get: tags: [servers-internal] operationId: internalStatus summary: Server status projection (velocity polls this after a wake). x-felis-face: [internal] x-felis-tier: service security: [{ serviceToken: [] }] parameters: - { name: name, in: path, required: true, schema: { type: string } } responses: '200': description: The server's status projection. content: application/json: schema: { $ref: '#/components/schemas/ServerInfo' } '401': $ref: '#/components/responses/Unauthorized' '404': $ref: '#/components/responses/NotFound' /api/v1/internal/servers/{name}/claim: post: tags: [lobby] operationId: internalClaim summary: Lobby "Claim & Start" by online-mode UUID (spec §12). description: >- The felis-paper lobby holds no token, so velocity claims on its behalf, binding the unowned server to the player's linked account. x-felis-face: [internal] x-felis-tier: service security: [{ serviceToken: [] }] parameters: - { name: name, in: path, required: true, schema: { type: string } } requestBody: required: true content: application/json: schema: type: object required: [mc_uuid] properties: mc_uuid: { type: string } responses: '200': description: Claimed. content: application/json: schema: type: object required: [name, claimed] properties: name: { type: string } claimed: { type: boolean, const: true } '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': $ref: '#/components/responses/Conflict' '412': $ref: '#/components/responses/PreconditionFailed' /api/v1/internal/servers/{name}/menu: get: tags: [lobby] operationId: internalMenuStatus summary: Lobby menu projection — status plus the ownership-derived `claimable`. x-felis-face: [internal] x-felis-tier: service security: [{ serviceToken: [] }] parameters: - { name: name, in: path, required: true, schema: { type: string } } - { name: mc_uuid, in: query, required: false, schema: { type: string } } responses: '200': description: Menu projection for the lobby UI. content: application/json: schema: type: object required: [name, phase, ready, playersOnline, playersMax, claimable] properties: name: { type: string } phase: { $ref: '#/components/schemas/Phase' } ready: { type: boolean } playersOnline: { type: integer, format: int32 } playersMax: { type: integer, format: int32 } claimable: { type: boolean } '401': $ref: '#/components/responses/Unauthorized' '404': $ref: '#/components/responses/NotFound' /api/v1/internal/account/link/code: post: tags: [account-internal] operationId: createLinkCode summary: Mint a one-time account-link code for a verified online-mode UUID (spec §10). description: Internal-only — the code is born from a UUID the web never holds. x-felis-face: [internal] x-felis-tier: service security: [{ serviceToken: [] }] requestBody: required: true content: application/json: schema: type: object required: [mc_uuid] properties: mc_uuid: { type: string } auth_source: type: string enum: [mojang, thirdparty] default: mojang description: > Which Yggdrasil authenticated the in-game UUID (spec §10 dual-Yggdrasil). Optional; an omitted value defaults to the Mojang-priority source. Captured here because only the in-game side sees the authentication; it is copied onto the link at verify. responses: '201': description: Code minted. content: application/json: schema: type: object required: [code, expires_at] properties: code: { type: string } expires_at: { type: string, format: date-time } '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' /api/v1/internal/account/link/status/{mc_uuid}: get: tags: [account-internal] operationId: linkStatus summary: Poll whether an in-game UUID has finished linking — the QR scan-to-login completion check (spec §B3). description: > Internal-only, read-only. After a new player scans the QR-encoded link code and the web verify writes the durable account_links row, velocity polls this for the UUID it minted against and admits the player on linked:true, binding the in-game session to user_id. Keyed by the verified UUID (not the scanned code), so it consumes nothing and is safe to poll repeatedly; an unlinked or never-seen UUID returns linked:false, and user_id is present only when linked. x-felis-face: [internal] x-felis-tier: service security: [{ serviceToken: [] }] parameters: - { name: mc_uuid, in: path, required: true, schema: { type: string, format: uuid } } responses: '200': description: Link-completion status; user_id is present only when linked. content: application/json: schema: type: object required: [linked] properties: linked: { type: boolean } user_id: { type: string } '401': $ref: '#/components/responses/Unauthorized' /api/v1/internal/player/reclaim: post: tags: [account-internal] operationId: reclaimUsername summary: Record a Mojang-priority username reclaim — bar the squatter UUID and stash its data (spec §B3). description: > Internal-only. Velocity records a username-collision reclaim: the non-genuine squatter UUID is barred and its world/player data stashed for a 30-day window so a new account can inherit it. Idempotent — a repeat reclaim of an already-barred UUID is a no-op. The bar is keyed by UUID, never the contested name, so the genuine Mojang player always passes. x-felis-face: [internal] x-felis-tier: service security: [{ serviceToken: [] }] requestBody: required: true content: application/json: schema: type: object required: [squatter_uuid, username] properties: squatter_uuid: { type: string, format: uuid } username: { type: string } data_ref: type: string description: > Optional opaque handle to the data already archived for the hold (server-side only, never returned). Archival may be deferred, in which case this is omitted. responses: '200': description: Reclaim recorded. content: application/json: schema: type: object required: [blacklisted, username, hold_expires_at] properties: blacklisted: { type: boolean, const: true } username: { type: string } hold_expires_at: { type: string, format: date-time } '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' /api/v1/internal/player/blacklist/{mc_uuid}: get: tags: [account-internal] operationId: checkUsernameBlacklist summary: Report whether an in-game UUID was barred by a prior reclaim (spec §B3). description: > Internal-only. The velocity login gate calls it to reject a barred squatter before letting them in; the genuine Mojang UUID — same username, different UUID — is never on the list and always passes. x-felis-face: [internal] x-felis-tier: service security: [{ serviceToken: [] }] parameters: - { name: mc_uuid, in: path, required: true, schema: { type: string, format: uuid } } responses: '200': description: Blacklist status. content: application/json: schema: type: object required: [blacklisted] properties: blacklisted: { type: boolean } '401': $ref: '#/components/responses/Unauthorized' # ----------------------------------------------------- external: servers --- /api/v1/servers/{name}/wake: post: tags: [servers] operationId: wake summary: Wake your own server. x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] parameters: - { name: name, in: path, required: true, schema: { type: string } } responses: '202': description: Wake accepted. content: application/json: schema: type: object required: [name, desiredState] properties: name: { type: string } desiredState: { type: string, const: Running } '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '429': description: Wake cooldown is still active. content: application/json: schema: { $ref: '#/components/schemas/Error' } /api/v1/servers/{name}/stop: post: tags: [servers] operationId: stop summary: Stop your own server. x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] parameters: - { name: name, in: path, required: true, schema: { type: string } } responses: '202': description: Stop accepted. content: application/json: schema: type: object required: [name, desiredState] properties: name: { type: string } desiredState: { type: string, const: Stopped } '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' /api/v1/servers/{name}/claim: post: tags: [servers] operationId: claim summary: Claim an unowned server for your linked account. x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] parameters: - { name: name, in: path, required: true, schema: { type: string } } responses: '200': description: Claimed. content: application/json: schema: type: object required: [name, claimed] properties: name: { type: string } claimed: { type: boolean, const: true } '401': $ref: '#/components/responses/Unauthorized' '403': description: Quota exceeded. content: application/json: schema: { $ref: '#/components/schemas/Error' } '404': $ref: '#/components/responses/NotFound' '409': description: Already claimed. content: application/json: schema: { $ref: '#/components/schemas/Error' } '412': description: Account not linked (the pointer /account/link/start emits). content: application/json: schema: { $ref: '#/components/schemas/Error' } /api/v1/servers/{name}/command: post: tags: [console] operationId: command summary: Run a console command via RCON (spec §8 write). Owner/admin only. description: The RCON password is never accepted or returned (spec §286). x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] parameters: - { name: name, in: path, required: true, schema: { type: string } } requestBody: required: true content: application/json: schema: type: object required: [command] properties: command: { type: string } responses: '200': description: Command output. content: application/json: schema: type: object required: [name, output] properties: name: { type: string } output: { type: string } '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': description: Server not running. content: application/json: schema: { $ref: '#/components/schemas/Error' } '503': $ref: '#/components/responses/ServiceUnavailable' /api/v1/servers/{name}/console: get: tags: [console] operationId: serverConsole summary: Stream the running pod's log over SSE (spec §8 read, §262). Owner/admin only. x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] parameters: - { name: name, in: path, required: true, schema: { type: string } } responses: '200': description: An event stream of log lines. content: text/event-stream: schema: { type: string } '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '409': description: Server not running. content: application/json: schema: { $ref: '#/components/schemas/Error' } '503': $ref: '#/components/responses/ServiceUnavailable' /api/v1/servers/{name}/access/whitelist: get: tags: [access] operationId: accessWhitelistList summary: List whitelisted players via RCON (spec §7). Owner/admin only. description: >- Runs "whitelist list" against the live server and returns a best-effort parse plus the raw reply. The RCON password is never accepted or returned (spec §286). x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] parameters: - { name: name, in: path, required: true, schema: { type: string } } responses: '200': description: Whitelisted players. content: application/json: schema: type: object required: [name, players, output] properties: name: { type: string } players: { type: array, items: { type: string } } output: { type: string } '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': description: Server not running. content: application/json: schema: { $ref: '#/components/schemas/Error' } '503': $ref: '#/components/responses/ServiceUnavailable' post: tags: [access] operationId: accessWhitelist summary: Add or remove a player from the whitelist (spec §7). Owner/admin only. description: >- Translates to the RCON "whitelist add|remove " command. The player name is validated against the Minecraft username charset before it is built into a command. The RCON password is never accepted or returned (spec §286). x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] parameters: - { name: name, in: path, required: true, schema: { type: string } } requestBody: required: true content: application/json: schema: type: object required: [action, player] properties: action: { type: string, enum: [add, remove] } player: { type: string } responses: '200': $ref: '#/components/responses/AccessResult' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': description: Server not running. content: application/json: schema: { $ref: '#/components/schemas/Error' } '503': $ref: '#/components/responses/ServiceUnavailable' /api/v1/servers/{name}/access/ban: post: tags: [access] operationId: accessBan summary: Ban or pardon a player (spec §7). Owner/admin only. description: >- Translates to the RCON "ban|pardon " command. Carries no reason field (a free-text reason would be an injection vector; the audit log records intent). The RCON password is never accepted or returned (§286). x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] parameters: - { name: name, in: path, required: true, schema: { type: string } } requestBody: required: true content: application/json: schema: type: object required: [action, player] properties: action: { type: string, enum: [ban, pardon] } player: { type: string } responses: '200': $ref: '#/components/responses/AccessResult' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': description: Server not running. content: application/json: schema: { $ref: '#/components/schemas/Error' } '503': $ref: '#/components/responses/ServiceUnavailable' /api/v1/servers/{name}/access/permission: post: tags: [access] operationId: accessPermission summary: Set or unset a LuckPerms permission node (spec §7). Owner/admin only. description: >- Translates to "lp user permission set [world=]" (or unset). An omitted value defaults to true (grant), not false (deny). Player, node and world are charset-validated before the command is assembled. The RCON password is never accepted or returned (spec §286). x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] parameters: - { name: name, in: path, required: true, schema: { type: string } } requestBody: required: true content: application/json: schema: type: object required: [action, player, node] properties: action: { type: string, enum: [set, unset] } player: { type: string } node: { type: string } value: { type: boolean, description: "set only; omitted => true (grant)" } world: { type: string, description: "optional LuckPerms world context" } responses: '200': $ref: '#/components/responses/AccessResult' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': description: Server not running. content: application/json: schema: { $ref: '#/components/schemas/Error' } '503': $ref: '#/components/responses/ServiceUnavailable' /api/v1/servers/{name}/access/group: post: tags: [access] operationId: accessGroup summary: Add or remove a player's LuckPerms parent group (spec §7). Owner/admin only. description: >- Translates to "lp user parent add|remove ". Player and group are charset-validated before the command is assembled. The RCON password is never accepted or returned (spec §286). x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] parameters: - { name: name, in: path, required: true, schema: { type: string } } requestBody: required: true content: application/json: schema: type: object required: [action, player, group] properties: action: { type: string, enum: [add, remove] } player: { type: string } group: { type: string } responses: '200': $ref: '#/components/responses/AccessResult' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': description: Server not running. content: application/json: schema: { $ref: '#/components/schemas/Error' } '503': $ref: '#/components/responses/ServiceUnavailable' /api/v1/servers/{name}/status: get: tags: [servers] operationId: status summary: Status of your own server. x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] parameters: - { name: name, in: path, required: true, schema: { type: string } } responses: '200': description: The server's status projection. content: application/json: schema: { $ref: '#/components/schemas/ServerInfo' } '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' # -------------------------------------------------- external: local auth --- /api/v1/auth/login: post: tags: [auth] operationId: login summary: Log in with a local username + password (op.console). description: >- Verifies a username+password against the users row and, on success, mints a host-only session cookie (spec §B). Mounted Public — there is no prior principal — but local auth must be enabled (local_auth_enabled), so a deployment fronted entirely by Zero Trust never accepts a local password. Every failure returns the same vague invalid_credentials after a uniform bcrypt compare, so usernames cannot be enumerated by response or timing. x-felis-face: [external] x-felis-tier: public security: [] requestBody: required: true content: application/json: schema: type: object required: [username, password] properties: username: { type: string } password: { type: string, format: password } responses: '200': description: Session established; the cookie is set on the response. content: application/json: schema: type: object required: [user_id, role, must_change_password] properties: user_id: { type: string } role: type: string enum: [user, admin] must_change_password: type: boolean description: >- True when this account still owes its first-login password change; the panel routes straight to the change-password card. '400': $ref: '#/components/responses/BadRequest' '401': description: Invalid username or password (vague by design). content: application/json: schema: { $ref: '#/components/schemas/Error' } '403': description: Local password login is disabled on this deployment. content: application/json: schema: { $ref: '#/components/schemas/Error' } /api/v1/auth/logout: post: tags: [auth] operationId: logout summary: Revoke the current local session and clear the cookie. description: >- Revokes the presented session and clears the cookie (spec §B). Mounted Public and idempotent: it reads the cookie directly, so it works even when the session has already expired and never errors on a missing one. x-felis-face: [external] x-felis-tier: public security: [] responses: '200': description: Logged out (idempotent). content: application/json: schema: type: object required: [ok] properties: ok: { type: boolean, const: true } /api/v1/auth/change-password: post: tags: [auth] operationId: changePassword summary: Change the caller's local password (forced first-login or rotation). description: >- Re-verifies the caller's current password, stores a new bcrypt hash, clears must_change_password, and revokes the account's OTHER sessions while keeping the current one (spec §B). Reachable while must_change_password is set, so a forced first-login change can complete — the rest of the API is fenced off until it does. The session authenticates the caller; re-asking the current password additionally blocks a hijacked session from silently rotating the credential. x-felis-face: [external] x-felis-tier: app security: [{ sessionCookie: [] }] requestBody: required: true content: application/json: schema: type: object required: [current_password, new_password] properties: current_password: { type: string, format: password } new_password: type: string format: password minLength: 8 maxLength: 72 description: 8–72 bytes; 72 is bcrypt's hard input limit. responses: '200': description: Password changed; other sessions revoked. content: application/json: schema: type: object required: [ok] properties: ok: { type: boolean, const: true } '400': description: Weak password, or the new password equals the current one. content: application/json: schema: { $ref: '#/components/schemas/Error' } '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' /api/v1/me: get: tags: [servers] operationId: me summary: The caller's own identity and tier (drives panel navigation). description: >- Returns the authenticated principal's user id, email, role and the server-computed is_admin (Principal.IsAdmin(): role admin reached via the admin Access path). The panel reads this once at boot to decide which surfaces to render. It is UX truth, not a security control — admin routes are independently gated server-side, so a hidden nav item never widens access. x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] responses: '200': description: The caller's identity. content: application/json: schema: type: object required: [user_id, email, role, is_admin, must_change_password] properties: user_id: { type: string } email: { type: string, format: email } role: type: string enum: [user, admin] description: The principal's role, mirroring users.role. is_admin: type: boolean description: >- True only when role is admin AND the request arrived via the admin Access path (Principal.IsAdmin()). must_change_password: type: boolean description: >- True when a local-password staff account still owes its first-login password change. Meaningful only on the local-password path (false on the JWT path). The panel routes such an account straight to the change-password card. Reachable while set, alongside change-password and logout, because the rest of the API is fenced off until the change completes. '401': $ref: '#/components/responses/Unauthorized' /api/v1/me/servers: get: tags: [servers] operationId: myServers summary: List the servers the caller owns or may claim. x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] responses: '200': description: The caller's server list. content: application/json: schema: type: object required: [servers] properties: servers: type: array items: { $ref: '#/components/schemas/MyServerView' } '401': $ref: '#/components/responses/Unauthorized' /api/v1/fleet: get: tags: [admin-servers] operationId: fleet summary: The SysAdmin cockpit's fleet-wide server list (admin; cockpit extension, not a spec §7 route). description: >- Every MinecraftServer's CRD+status lifecycle view, for the SysAdmin FleetTable. Admin-tier — it reads every owner's server. A path distinct from the internal velocity GET /api/v1/servers because one {method, path} cannot carry both the service and admin tiers. CRD truth only: owner and the other Postgres business fields are deliberately not joined (§1). x-felis-face: [external] x-felis-tier: admin security: [{ accessJWT: [] }] responses: '200': description: Every server's status projection (fleet-wide). content: application/json: schema: type: object required: [servers] properties: servers: type: array items: { $ref: '#/components/schemas/ServerInfo' } '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' /api/v1/backups: get: tags: [backups] operationId: listBackups summary: List world backups (admin sees all; a user sees only worlds they formerly owned). x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] responses: '200': description: Visible backups. content: application/json: schema: type: object required: [backups] properties: backups: type: array items: { $ref: '#/components/schemas/BackupView' } '401': $ref: '#/components/responses/Unauthorized' /api/v1/servers/{name}/restore-backup: post: tags: [backups] operationId: restoreBackup summary: Restore a world from a backup (owner-or-admin plus a former-owner match). x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] parameters: - { name: name, in: path, required: true, schema: { type: string } } requestBody: required: false content: application/json: schema: type: object properties: backup_id: type: string description: Which backup to restore; defaults to the latest for the server. responses: '202': description: Restore started. content: application/json: schema: type: object required: [name, status, backup_id] properties: name: { type: string } status: { type: string, const: restoring } backup_id: { type: string } '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': description: No matching backup. content: application/json: schema: { $ref: '#/components/schemas/Error' } '409': description: Server is not stopped. content: application/json: schema: { $ref: '#/components/schemas/Error' } '503': $ref: '#/components/responses/ServiceUnavailable' /api/v1/account/link/start: post: tags: [account] operationId: linkStart summary: Report account-link status and in-game instructions (web side, spec §10). x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] responses: '200': description: Current link status. content: application/json: schema: type: object required: [linked, instructions] properties: linked: { type: boolean } instructions: { type: string } '401': $ref: '#/components/responses/Unauthorized' /api/v1/account/link/verify: post: tags: [account] operationId: linkVerify summary: Consume an in-game link code and bind the account. x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] requestBody: required: true content: application/json: schema: type: object required: [code] properties: code: { type: string } responses: '200': description: Linked. content: application/json: schema: type: object required: [linked, mc_uuid, auth_source] properties: linked: { type: boolean, const: true } mc_uuid: { type: string } auth_source: type: string enum: [mojang, thirdparty] description: The source captured at mint, copied onto the durable link. '400': description: Invalid or expired code. content: application/json: schema: { $ref: '#/components/schemas/Error' } '401': $ref: '#/components/responses/Unauthorized' '409': description: Account already linked. content: application/json: schema: { $ref: '#/components/schemas/Error' } /api/v1/account/email/start: post: tags: [account] operationId: emailOtpStart summary: Mint and deliver an email one-time code for the caller (web onboarding, spec §B2). description: > Generates a one-time code bound to the authenticated principal and the supplied address, persists only its hash, and delivers it out of band. The code is never returned in the response. A re-request supersedes the prior unconsumed code. x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] requestBody: required: true content: application/json: schema: type: object required: [email] properties: email: { type: string, format: email } responses: '202': description: Code minted and dispatched (or logged server-side when no mailer is wired). content: application/json: schema: type: object required: [sent, expires_at] properties: sent: { type: boolean, const: true } expires_at: { type: string, format: date-time } '400': description: Missing or malformed email address. content: application/json: schema: { $ref: '#/components/schemas/Error' } '401': $ref: '#/components/responses/Unauthorized' /api/v1/account/email/verify: post: tags: [account] operationId: emailOtpVerify summary: Redeem an email one-time code and mark the caller's email verified (spec §B2). description: > Consumes a previously delivered code for the authenticated principal. On success the user's email is written and email_verified is set true. Too many incorrect attempts lock the code (429); an unknown, expired, consumed, or mismatched code is a 400. x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] requestBody: required: true content: application/json: schema: type: object required: [code] properties: code: { type: string } responses: '200': description: Email verified. content: application/json: schema: type: object required: [verified, email] properties: verified: { type: boolean, const: true } email: { type: string, format: email } '400': description: Invalid or expired code. content: application/json: schema: { $ref: '#/components/schemas/Error' } '401': $ref: '#/components/responses/Unauthorized' '429': description: Too many incorrect attempts; the code is locked. content: application/json: schema: { $ref: '#/components/schemas/Error' } /api/v1/me/submissions: post: tags: [submissions] operationId: createSubmission summary: Submit a modpack for admin review (user side; user-directed lane over §16). Starts no build. x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] requestBody: required: true content: application/json: schema: type: object required: [display_name] description: >- Only display_name is accepted; the submitter is taken from the principal and the build inputs are platform-derived. Unknown fields (e.g. submitted_by, context_ref) are rejected with 400. properties: display_name: { type: string } responses: '201': description: Submission recorded, pending review. content: application/json: schema: { $ref: '#/components/schemas/Submission' } '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '503': $ref: '#/components/responses/ServiceUnavailable' get: tags: [submissions] operationId: mySubmissions summary: List the caller's own modpack submissions (user-directed lane over §16). x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] responses: '200': description: The caller's submissions, newest first. content: application/json: schema: type: object required: [submissions] properties: submissions: type: array items: { $ref: '#/components/schemas/Submission' } '401': $ref: '#/components/responses/Unauthorized' '503': $ref: '#/components/responses/ServiceUnavailable' # ------------------------------------------------------ external: admin ---- /api/v1/servers/{name}: patch: tags: [admin-servers] operationId: patchServer summary: Mutate a server spec (admin). Storage is immutable. x-felis-face: [external] x-felis-tier: admin security: [{ accessJWT: [] }] parameters: - { name: name, in: path, required: true, schema: { type: string } } requestBody: required: true content: application/json: schema: type: object description: Only the supplied fields are patched; an empty patch is rejected. properties: display_name: { type: string } autostart_policy: { type: string } image: { type: string } memory: { type: string } storage: type: string description: Rejected with 400 storage_immutable — present for a clear error, not mutation. resources: type: object properties: cpu: { type: string } cpu_request: { type: string } memory: { type: string } memory_request: { type: string } responses: '200': description: Patched. content: application/json: schema: type: object required: [name, patched] properties: name: { type: string } patched: type: array items: { type: string } '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' /api/v1/images/build: post: tags: [images] operationId: buildImage summary: Submit an image build (admin). A build is build-time RCE against the cluster. x-felis-face: [external] x-felis-tier: admin security: [{ accessJWT: [] }] requestBody: required: true content: application/json: schema: type: object required: [image_ref, dockerfile, context_ref] properties: image_ref: { type: string } dockerfile: { type: string } context_ref: { type: string } base_image: { type: string } responses: '202': description: Build accepted. content: application/json: schema: { $ref: '#/components/schemas/Build' } '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '503': $ref: '#/components/responses/ServiceUnavailable' /api/v1/images/build/{id}: get: tags: [images] operationId: getBuild summary: Get one build's status (admin). x-felis-face: [external] x-felis-tier: admin security: [{ accessJWT: [] }] parameters: - { name: id, in: path, required: true, schema: { type: string } } responses: '200': description: The build. content: application/json: schema: { $ref: '#/components/schemas/Build' } '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '503': $ref: '#/components/responses/ServiceUnavailable' /api/v1/images/build/{id}/logs: get: tags: [images] operationId: buildLogs summary: Stream a build's Job log over SSE (admin, spec §16 / §416). x-felis-face: [external] x-felis-tier: admin security: [{ accessJWT: [] }] parameters: - { name: id, in: path, required: true, schema: { type: string } } responses: '200': description: An event stream of build log lines. content: text/event-stream: schema: { type: string } '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '503': $ref: '#/components/responses/ServiceUnavailable' /api/v1/images/build/{id}/cancel: post: tags: [images] operationId: cancelBuild summary: Cancel a running build (admin). x-felis-face: [external] x-felis-tier: admin security: [{ accessJWT: [] }] parameters: - { name: id, in: path, required: true, schema: { type: string } } responses: '200': description: The build after cancellation. content: application/json: schema: { $ref: '#/components/schemas/Build' } '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': description: Build already terminal. content: application/json: schema: { $ref: '#/components/schemas/Error' } '503': $ref: '#/components/responses/ServiceUnavailable' /api/v1/images: get: tags: [images] operationId: listImages summary: List whitelisted images (admin). x-felis-face: [external] x-felis-tier: admin security: [{ accessJWT: [] }] responses: '200': description: The image whitelist. content: application/json: schema: type: object required: [images] properties: images: type: array items: { $ref: '#/components/schemas/Image' } '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '503': $ref: '#/components/responses/ServiceUnavailable' post: tags: [images] operationId: addImage summary: Whitelist an externally-built image by reference (admin). x-felis-face: [external] x-felis-tier: admin security: [{ accessJWT: [] }] requestBody: required: true content: application/json: schema: type: object required: [image_ref] properties: image_ref: { type: string } responses: '201': description: Image whitelisted. content: application/json: schema: { $ref: '#/components/schemas/Image' } '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '503': $ref: '#/components/responses/ServiceUnavailable' delete: tags: [images] operationId: removeImage summary: Remove an image from the whitelist by reference (admin). x-felis-face: [external] x-felis-tier: admin security: [{ accessJWT: [] }] parameters: - { name: ref, in: query, required: true, schema: { type: string } } responses: '204': $ref: '#/components/responses/NoContent' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '503': $ref: '#/components/responses/ServiceUnavailable' /api/v1/submissions: get: tags: [submissions] operationId: listSubmissions summary: The admin review queue — every user's modpack submissions (admin; user-directed lane over §16). x-felis-face: [external] x-felis-tier: admin security: [{ accessJWT: [] }] responses: '200': description: All submissions, newest first. content: application/json: schema: type: object required: [submissions] properties: submissions: type: array items: { $ref: '#/components/schemas/Submission' } '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '503': $ref: '#/components/responses/ServiceUnavailable' /api/v1/submissions/{id}/approve: post: tags: [submissions] operationId: approveSubmission summary: >- Approve a submission and start its Trivy-gated build (admin; user-directed lane over §16). Approval is layered in front of the scan, never instead of it. x-felis-face: [external] x-felis-tier: admin security: [{ accessJWT: [] }] parameters: - { name: id, in: path, required: true, schema: { type: string } } responses: '200': description: The approved submission, with the linked build id. content: application/json: schema: { $ref: '#/components/schemas/Submission' } '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': description: Submission has already been reviewed. content: application/json: schema: { $ref: '#/components/schemas/Error' } '503': $ref: '#/components/responses/ServiceUnavailable' /api/v1/submissions/{id}/reject: post: tags: [submissions] operationId: rejectSubmission summary: Reject a submission with a required reason (admin; user-directed lane over §16). Starts no build. x-felis-face: [external] x-felis-tier: admin security: [{ accessJWT: [] }] parameters: - { name: id, in: path, required: true, schema: { type: string } } requestBody: required: true content: application/json: schema: type: object required: [reason] properties: reason: { type: string } responses: '200': description: The rejected submission. content: application/json: schema: { $ref: '#/components/schemas/Submission' } '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': description: Submission has already been reviewed. content: application/json: schema: { $ref: '#/components/schemas/Error' } '503': $ref: '#/components/responses/ServiceUnavailable'