Files
Felis/docs/openapi.yaml
T
flyemoji 1f8b9bb5d0 feat(api): record account-link auth source (mojang|thirdparty)
Capture which Yggdrasil authenticated an in-game UUID when a link code is
minted (spec §10 dual-Yggdrasil) and copy it onto the durable account_links
row at verify. The value originates in-game — the web verify side never sees
the authentication — so it threads through account_link_codes, mirroring how
mc_uuid (not user_id) lives on a code.

- migration 0005: add link_auth_source enum + auth_source column on both
  account_link_codes and account_links; DEFAULT 'mojang' backfills existing
  rows and sets the Mojang-priority default for a mint that omits the field
- mint validates an explicit auth_source (unknown value -> 400); verify
  surfaces it in the 200 body and refreshes it on idempotent re-verify
2026-06-27 13:01:19 +09:00

1952 lines
68 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <service-token>);
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'
# ----------------------------------------------------- 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 <player>" 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 <player>" 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 <player> permission set <node> <true|false>
[world=<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 <player> parent add|remove <group>". 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'