Files
Felis/docs/openapi.yaml
T
flyemoji 5a30aa5073 docs: add OpenAPI 3.1 served-route contract
Machine-readable contract for the felis-api faces. The parity test (internal/api/openapi_test.go) checks every served route against this document's x-felis-face and x-felis-tier, so served and documented routes cannot drift.
2026-06-26 23:31:57 +09:00

1701 lines
58 KiB
YAML

# 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().
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 }
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'
/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]
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()).
'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]
properties:
linked: { type: boolean, const: true }
mc_uuid: { type: string }
'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/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'