QR scan-to-login is a device-code grant where the QR encodes the existing
short-lived account-link code (spec §B3 player game-login). velocity mints a
code in-game, renders it as a QR, the player scans it on a phone already signed
in to the panel, and that web session's verify writes the durable account_links
row bound to that user. The only new verifiable surface that flow needs is the
completion poll velocity calls to learn the link landed and admit the player.
Add GET /api/v1/internal/account/link/status/{mc_uuid}: a read-only, internal
handleLinkStatus keyed by the verified mc_uuid velocity already holds. It reuses
the existing UserByMCUUID, so it adds no migration and no mutation to the
load-bearing VerifyLinkCode; ErrNotFound maps to {linked:false} (pending /
not-yet-scanned), a hit to {linked:true, user_id}. Keying on the public UUID and
not the scanned code means the read carries no guessing surface and needs no
attempt cap — the internal face already gates it to service callers, and the poll
consumes nothing so a velocity restart re-polls safely.
QR render, limbo collision routing, in-game admit, and the reclaim
inherit-disambiguation stay CODE-ONLY (Java/Velocity) and are labeled as such;
this endpoint reports link completion only.
Document the route in openapi.yaml (x-felis-face internal, x-felis-tier service)
so the parity gate holds, and cover it with a hermetic vertical that proves the
poll reflects the durable link only after the external verify and binds the
verifier's id, plus unknown-uuid, idempotency, and internal-only face separation.
2057 lines
72 KiB
YAML
2057 lines
72 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().
|
||
sessionCookie:
|
||
type: apiKey
|
||
in: cookie
|
||
name: felis_session
|
||
description: >-
|
||
Opaque local-password session cookie (external face). Minted by
|
||
POST /api/v1/auth/login when local auth is enabled, HttpOnly+Secure+
|
||
SameSite=Lax and host-only, so an op.console session never reaches the
|
||
player console. Only its sha-256 is persisted. SessionAuth prefers this
|
||
cookie and otherwise delegates to accessJWT, so the two models coexist on
|
||
one face.
|
||
|
||
responses:
|
||
NoContent:
|
||
description: Success, no body.
|
||
BadRequest:
|
||
description: Malformed or invalid request (validation, bad body, unknown field).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
Unauthorized:
|
||
description: Authentication missing or invalid.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
Forbidden:
|
||
description: Authenticated but not permitted (ownership / admin / quota).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
NotFound:
|
||
description: No such server / build / record.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
Conflict:
|
||
description: Precondition failed (lost race, not running, already claimed/linked, not stopped).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
PreconditionFailed:
|
||
description: A required prior step is missing (e.g. account not linked).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
ServiceUnavailable:
|
||
description: A required subsystem (builder / console / logs / restorer / repo / cluster) is not wired or reachable.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
AccessResult:
|
||
description: The structured access mutation succeeded; the raw RCON reply is in output.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [name, action, output]
|
||
properties:
|
||
name: { type: string }
|
||
action: { type: string }
|
||
player: { type: string }
|
||
node: { type: string }
|
||
group: { type: string }
|
||
output: { type: string }
|
||
|
||
schemas:
|
||
Error:
|
||
type: object
|
||
description: Uniform error envelope emitted by every handler (internal/api/errors.go).
|
||
required: [error]
|
||
properties:
|
||
error:
|
||
type: object
|
||
required: [code, message]
|
||
properties:
|
||
code:
|
||
type: string
|
||
description: Stable machine-readable code (e.g. not_found, conflict, forbidden, bad_request).
|
||
message:
|
||
type: string
|
||
request_id:
|
||
type: string
|
||
description: Correlates the response with server logs (withRequestID middleware).
|
||
|
||
Phase:
|
||
type: string
|
||
description: MinecraftServer lifecycle phase (internal/apis/felis/v1alpha1).
|
||
enum: [Unknown, Stopped, Starting, Running, Stopping, Failed]
|
||
|
||
ServerInfo:
|
||
type: object
|
||
description: Status projection of one server (internal/api/cluster.go ServerInfo).
|
||
required: [name, subdomain, phase, ready, playersOnline, playersMax]
|
||
properties:
|
||
name: { type: string }
|
||
subdomain: { type: string }
|
||
phase: { $ref: '#/components/schemas/Phase' }
|
||
ready: { type: boolean }
|
||
autostartPolicy:
|
||
type: string
|
||
description: Present only when set; who may wake the server via domain-autostart.
|
||
desiredState:
|
||
type: string
|
||
description: Present only when set; the operator's target state.
|
||
enum: [Running, Stopped]
|
||
endpointMode: { type: string }
|
||
endpointAddress: { type: string }
|
||
playersOnline: { type: integer, format: int32 }
|
||
playersMax: { type: integer, format: int32 }
|
||
|
||
MyServerView:
|
||
type: object
|
||
description: One row of the caller's server list (internal/api/repo.go MyServerView).
|
||
required: [name, subdomain, owned, claimable]
|
||
properties:
|
||
name: { type: string }
|
||
subdomain: { type: string }
|
||
owned: { type: boolean }
|
||
claimable: { type: boolean }
|
||
phase:
|
||
allOf: [{ $ref: '#/components/schemas/Phase' }]
|
||
description: Present only when known.
|
||
|
||
BackupView:
|
||
type: object
|
||
description: One world backup (internal/api/repo.go BackupView). backup_ref is withheld (spec §286).
|
||
required: [id, server_name, size_bytes, reason, status, created_at, expires_at]
|
||
properties:
|
||
id: { type: string }
|
||
server_name: { type: string }
|
||
former_owner:
|
||
type: string
|
||
description: Present only when the world had an owner at backup time.
|
||
size_bytes: { type: integer, format: int64 }
|
||
reason: { type: string }
|
||
status: { type: string }
|
||
created_at: { type: string, format: date-time }
|
||
expires_at: { type: string, format: date-time }
|
||
|
||
Build:
|
||
type: object
|
||
description: One image build (internal/build Build).
|
||
required: [id, image_ref, status, requested_by, created_at]
|
||
properties:
|
||
id: { type: string }
|
||
image_ref: { type: string }
|
||
status:
|
||
type: string
|
||
enum: [pending, building, succeeded, failed, cancelled]
|
||
dockerfile: { type: string }
|
||
context_ref: { type: string }
|
||
base_image: { type: string }
|
||
requested_by: { type: string }
|
||
job_name: { type: string }
|
||
log_ref: { type: string }
|
||
error: { type: string }
|
||
created_at: { type: string, format: date-time }
|
||
finished_at:
|
||
type: [string, 'null']
|
||
format: date-time
|
||
description: Null until the build reaches a terminal status.
|
||
|
||
Image:
|
||
type: object
|
||
description: One whitelisted image (internal/build Image).
|
||
required: [image_ref, source, added_by, enabled, added_at]
|
||
properties:
|
||
image_ref: { type: string }
|
||
source: { type: string }
|
||
build_id: { type: string }
|
||
added_by: { type: string }
|
||
enabled: { type: boolean }
|
||
added_at: { type: string, format: date-time }
|
||
|
||
Submission:
|
||
type: object
|
||
description: >-
|
||
One user-submitted modpack in the approval lane (internal/submit
|
||
Submission — a user-directed extension over the §16 build subsystem).
|
||
The user supplies only display_name; submitted_by
|
||
comes from the principal and context_ref/image_ref/build_id/reviewed_by
|
||
are platform-controlled, never client input.
|
||
required: [id, submitted_by, display_name, context_ref, status, created_at]
|
||
properties:
|
||
id: { type: string }
|
||
submitted_by: { type: string }
|
||
display_name: { type: string }
|
||
context_ref:
|
||
type: string
|
||
description: Platform-derived pinned build context; not user-supplied.
|
||
status:
|
||
type: string
|
||
enum: [pending_review, approved, rejected]
|
||
image_ref:
|
||
type: string
|
||
description: Platform-derived push target, set at approval.
|
||
build_id:
|
||
type: string
|
||
description: image_builds.id, set only after the build hand-off succeeds.
|
||
reviewed_by: { type: string }
|
||
reject_reason: { type: string }
|
||
created_at: { type: string, format: date-time }
|
||
reviewed_at:
|
||
type: [string, 'null']
|
||
format: date-time
|
||
description: Null until an admin approves or rejects.
|
||
|
||
paths:
|
||
# ----------------------------------------------------------------- health ---
|
||
/healthz:
|
||
get:
|
||
tags: [health]
|
||
operationId: healthz
|
||
summary: Liveness probe.
|
||
description: Unauthenticated on both faces; kubelet and Cloudflare hold no token.
|
||
x-felis-face: [internal, external]
|
||
x-felis-tier: public
|
||
security: []
|
||
responses:
|
||
'200':
|
||
description: Always ok when the process is up.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [status]
|
||
properties:
|
||
status: { type: string, const: ok }
|
||
|
||
/readyz:
|
||
get:
|
||
tags: [health]
|
||
operationId: readyz
|
||
summary: Readiness probe (internal face only — readiness is an internal concern).
|
||
x-felis-face: [internal]
|
||
x-felis-tier: public
|
||
security: []
|
||
responses:
|
||
'200':
|
||
description: Repo and Cluster are wired.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [status]
|
||
properties:
|
||
status: { type: string, const: ready }
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
|
||
# -------------------------------------------------- internal: servers ------
|
||
/api/v1/servers:
|
||
get:
|
||
tags: [servers-internal]
|
||
operationId: listServers
|
||
summary: List all servers (velocity route table).
|
||
x-felis-face: [internal]
|
||
x-felis-tier: service
|
||
security: [{ serviceToken: [] }]
|
||
responses:
|
||
'200':
|
||
description: Every server's status projection.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [servers]
|
||
properties:
|
||
servers:
|
||
type: array
|
||
items: { $ref: '#/components/schemas/ServerInfo' }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
post:
|
||
tags: [admin-servers]
|
||
operationId: createServer
|
||
summary: Create a server (admin).
|
||
description: Requires the admin Access path; the image must be whitelisted.
|
||
x-felis-face: [external]
|
||
x-felis-tier: admin
|
||
security: [{ accessJWT: [] }]
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [name, subdomain]
|
||
properties:
|
||
name: { type: string }
|
||
subdomain: { type: string }
|
||
display_name: { type: string }
|
||
image: { type: string }
|
||
memory: { type: string }
|
||
storage: { type: string }
|
||
autostart_policy: { type: string }
|
||
resources:
|
||
type: object
|
||
properties:
|
||
cpu: { type: string }
|
||
cpu_request: { type: string }
|
||
memory: { type: string }
|
||
memory_request: { type: string }
|
||
responses:
|
||
'201':
|
||
description: Created; starts Stopped.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [name, subdomain, desiredState]
|
||
properties:
|
||
name: { type: string }
|
||
subdomain: { type: string }
|
||
desiredState: { type: string, const: Stopped }
|
||
'400':
|
||
$ref: '#/components/responses/BadRequest'
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
$ref: '#/components/responses/Forbidden'
|
||
'409':
|
||
$ref: '#/components/responses/Conflict'
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
|
||
/api/v1/servers/by-host/{host}:
|
||
get:
|
||
tags: [servers-internal]
|
||
operationId: serverByHost
|
||
summary: Resolve a server by its connecting hostname (velocity host routing).
|
||
x-felis-face: [internal]
|
||
x-felis-tier: service
|
||
security: [{ serviceToken: [] }]
|
||
parameters:
|
||
- { name: host, in: path, required: true, schema: { type: string } }
|
||
responses:
|
||
'200':
|
||
description: The matching server's status projection.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/ServerInfo' }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'404':
|
||
$ref: '#/components/responses/NotFound'
|
||
|
||
/api/v1/internal/servers/{name}/ready:
|
||
post:
|
||
tags: [servers-internal]
|
||
operationId: serverReadyCallback
|
||
summary: Backend readiness callback — the server reports it is accepting players.
|
||
x-felis-face: [internal]
|
||
x-felis-tier: service
|
||
security: [{ serviceToken: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
responses:
|
||
'204':
|
||
$ref: '#/components/responses/NoContent'
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'404':
|
||
$ref: '#/components/responses/NotFound'
|
||
|
||
/api/v1/internal/servers/{name}/join-event:
|
||
post:
|
||
tags: [servers-internal]
|
||
operationId: joinEvent
|
||
summary: Player-join event by online-mode UUID (activity tracking / idle reset).
|
||
x-felis-face: [internal]
|
||
x-felis-tier: service
|
||
security: [{ serviceToken: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [mc_uuid]
|
||
properties:
|
||
mc_uuid: { type: string }
|
||
responses:
|
||
'204':
|
||
$ref: '#/components/responses/NoContent'
|
||
'400':
|
||
$ref: '#/components/responses/BadRequest'
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'404':
|
||
$ref: '#/components/responses/NotFound'
|
||
|
||
/api/v1/internal/servers/{name}/wake:
|
||
post:
|
||
tags: [servers-internal]
|
||
operationId: internalWake
|
||
summary: Domain-autostart wake driven by velocity for a joining player (spec §9.1).
|
||
description: >-
|
||
velocity holds no web Principal, so it drives the wake lever with its
|
||
service token, identifying the player by online-mode UUID. Gated by the
|
||
server's autostartPolicy and the per-server wake cooldown.
|
||
x-felis-face: [internal]
|
||
x-felis-tier: service
|
||
security: [{ serviceToken: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [mc_uuid]
|
||
properties:
|
||
mc_uuid: { type: string }
|
||
responses:
|
||
'202':
|
||
description: Wake accepted (or already awake).
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [name, desiredState, phase, ready]
|
||
properties:
|
||
name: { type: string }
|
||
desiredState: { type: string, const: Running }
|
||
phase: { $ref: '#/components/schemas/Phase' }
|
||
ready: { type: boolean }
|
||
'400':
|
||
$ref: '#/components/responses/BadRequest'
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
$ref: '#/components/responses/Forbidden'
|
||
'404':
|
||
$ref: '#/components/responses/NotFound'
|
||
'429':
|
||
description: Wake cooldown is still active for this server.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
|
||
/api/v1/internal/servers/{name}/status:
|
||
get:
|
||
tags: [servers-internal]
|
||
operationId: internalStatus
|
||
summary: Server status projection (velocity polls this after a wake).
|
||
x-felis-face: [internal]
|
||
x-felis-tier: service
|
||
security: [{ serviceToken: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
responses:
|
||
'200':
|
||
description: The server's status projection.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/ServerInfo' }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'404':
|
||
$ref: '#/components/responses/NotFound'
|
||
|
||
/api/v1/internal/servers/{name}/claim:
|
||
post:
|
||
tags: [lobby]
|
||
operationId: internalClaim
|
||
summary: Lobby "Claim & Start" by online-mode UUID (spec §12).
|
||
description: >-
|
||
The felis-paper lobby holds no token, so velocity claims on its behalf,
|
||
binding the unowned server to the player's linked account.
|
||
x-felis-face: [internal]
|
||
x-felis-tier: service
|
||
security: [{ serviceToken: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [mc_uuid]
|
||
properties:
|
||
mc_uuid: { type: string }
|
||
responses:
|
||
'200':
|
||
description: Claimed.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [name, claimed]
|
||
properties:
|
||
name: { type: string }
|
||
claimed: { type: boolean, const: true }
|
||
'400':
|
||
$ref: '#/components/responses/BadRequest'
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
$ref: '#/components/responses/Forbidden'
|
||
'404':
|
||
$ref: '#/components/responses/NotFound'
|
||
'409':
|
||
$ref: '#/components/responses/Conflict'
|
||
'412':
|
||
$ref: '#/components/responses/PreconditionFailed'
|
||
|
||
/api/v1/internal/servers/{name}/menu:
|
||
get:
|
||
tags: [lobby]
|
||
operationId: internalMenuStatus
|
||
summary: Lobby menu projection — status plus the ownership-derived `claimable`.
|
||
x-felis-face: [internal]
|
||
x-felis-tier: service
|
||
security: [{ serviceToken: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
- { name: mc_uuid, in: query, required: false, schema: { type: string } }
|
||
responses:
|
||
'200':
|
||
description: Menu projection for the lobby UI.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [name, phase, ready, playersOnline, playersMax, claimable]
|
||
properties:
|
||
name: { type: string }
|
||
phase: { $ref: '#/components/schemas/Phase' }
|
||
ready: { type: boolean }
|
||
playersOnline: { type: integer, format: int32 }
|
||
playersMax: { type: integer, format: int32 }
|
||
claimable: { type: boolean }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'404':
|
||
$ref: '#/components/responses/NotFound'
|
||
|
||
/api/v1/internal/account/link/code:
|
||
post:
|
||
tags: [account-internal]
|
||
operationId: createLinkCode
|
||
summary: Mint a one-time account-link code for a verified online-mode UUID (spec §10).
|
||
description: Internal-only — the code is born from a UUID the web never holds.
|
||
x-felis-face: [internal]
|
||
x-felis-tier: service
|
||
security: [{ serviceToken: [] }]
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [mc_uuid]
|
||
properties:
|
||
mc_uuid: { type: string }
|
||
auth_source:
|
||
type: string
|
||
enum: [mojang, thirdparty]
|
||
default: mojang
|
||
description: >
|
||
Which Yggdrasil authenticated the in-game UUID (spec §10
|
||
dual-Yggdrasil). Optional; an omitted value defaults to the
|
||
Mojang-priority source. Captured here because only the in-game
|
||
side sees the authentication; it is copied onto the link at verify.
|
||
responses:
|
||
'201':
|
||
description: Code minted.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [code, expires_at]
|
||
properties:
|
||
code: { type: string }
|
||
expires_at: { type: string, format: date-time }
|
||
'400':
|
||
$ref: '#/components/responses/BadRequest'
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
|
||
/api/v1/internal/account/link/status/{mc_uuid}:
|
||
get:
|
||
tags: [account-internal]
|
||
operationId: linkStatus
|
||
summary: Poll whether an in-game UUID has finished linking — the QR scan-to-login completion check (spec §B3).
|
||
description: >
|
||
Internal-only, read-only. After a new player scans the QR-encoded link code
|
||
and the web verify writes the durable account_links row, velocity polls this
|
||
for the UUID it minted against and admits the player on linked:true, binding
|
||
the in-game session to user_id. Keyed by the verified UUID (not the scanned
|
||
code), so it consumes nothing and is safe to poll repeatedly; an unlinked or
|
||
never-seen UUID returns linked:false, and user_id is present only when linked.
|
||
x-felis-face: [internal]
|
||
x-felis-tier: service
|
||
security: [{ serviceToken: [] }]
|
||
parameters:
|
||
- { name: mc_uuid, in: path, required: true, schema: { type: string, format: uuid } }
|
||
responses:
|
||
'200':
|
||
description: Link-completion status; user_id is present only when linked.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [linked]
|
||
properties:
|
||
linked: { type: boolean }
|
||
user_id: { type: string }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
|
||
/api/v1/internal/player/reclaim:
|
||
post:
|
||
tags: [account-internal]
|
||
operationId: reclaimUsername
|
||
summary: Record a Mojang-priority username reclaim — bar the squatter UUID and stash its data (spec §B3).
|
||
description: >
|
||
Internal-only. Velocity records a username-collision reclaim: the
|
||
non-genuine squatter UUID is barred and its world/player data stashed for
|
||
a 30-day window so a new account can inherit it. Idempotent — a repeat
|
||
reclaim of an already-barred UUID is a no-op. The bar is keyed by UUID,
|
||
never the contested name, so the genuine Mojang player always passes.
|
||
x-felis-face: [internal]
|
||
x-felis-tier: service
|
||
security: [{ serviceToken: [] }]
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [squatter_uuid, username]
|
||
properties:
|
||
squatter_uuid: { type: string, format: uuid }
|
||
username: { type: string }
|
||
data_ref:
|
||
type: string
|
||
description: >
|
||
Optional opaque handle to the data already archived for the
|
||
hold (server-side only, never returned). Archival may be
|
||
deferred, in which case this is omitted.
|
||
responses:
|
||
'200':
|
||
description: Reclaim recorded.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [blacklisted, username, hold_expires_at]
|
||
properties:
|
||
blacklisted: { type: boolean, const: true }
|
||
username: { type: string }
|
||
hold_expires_at: { type: string, format: date-time }
|
||
'400':
|
||
$ref: '#/components/responses/BadRequest'
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
|
||
/api/v1/internal/player/blacklist/{mc_uuid}:
|
||
get:
|
||
tags: [account-internal]
|
||
operationId: checkUsernameBlacklist
|
||
summary: Report whether an in-game UUID was barred by a prior reclaim (spec §B3).
|
||
description: >
|
||
Internal-only. The velocity login gate calls it to reject a barred
|
||
squatter before letting them in; the genuine Mojang UUID — same username,
|
||
different UUID — is never on the list and always passes.
|
||
x-felis-face: [internal]
|
||
x-felis-tier: service
|
||
security: [{ serviceToken: [] }]
|
||
parameters:
|
||
- { name: mc_uuid, in: path, required: true, schema: { type: string, format: uuid } }
|
||
responses:
|
||
'200':
|
||
description: Blacklist status.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [blacklisted]
|
||
properties:
|
||
blacklisted: { type: boolean }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
|
||
# ----------------------------------------------------- external: servers ---
|
||
/api/v1/servers/{name}/wake:
|
||
post:
|
||
tags: [servers]
|
||
operationId: wake
|
||
summary: Wake your own server.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ accessJWT: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
responses:
|
||
'202':
|
||
description: Wake accepted.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [name, desiredState]
|
||
properties:
|
||
name: { type: string }
|
||
desiredState: { type: string, const: Running }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
$ref: '#/components/responses/Forbidden'
|
||
'404':
|
||
$ref: '#/components/responses/NotFound'
|
||
'429':
|
||
description: Wake cooldown is still active.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
|
||
/api/v1/servers/{name}/stop:
|
||
post:
|
||
tags: [servers]
|
||
operationId: stop
|
||
summary: Stop your own server.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ accessJWT: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
responses:
|
||
'202':
|
||
description: Stop accepted.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [name, desiredState]
|
||
properties:
|
||
name: { type: string }
|
||
desiredState: { type: string, const: Stopped }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
$ref: '#/components/responses/Forbidden'
|
||
'404':
|
||
$ref: '#/components/responses/NotFound'
|
||
|
||
/api/v1/servers/{name}/claim:
|
||
post:
|
||
tags: [servers]
|
||
operationId: claim
|
||
summary: Claim an unowned server for your linked account.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ accessJWT: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
responses:
|
||
'200':
|
||
description: Claimed.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [name, claimed]
|
||
properties:
|
||
name: { type: string }
|
||
claimed: { type: boolean, const: true }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
description: Quota exceeded.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'404':
|
||
$ref: '#/components/responses/NotFound'
|
||
'409':
|
||
description: Already claimed.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'412':
|
||
description: Account not linked (the pointer /account/link/start emits).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
|
||
/api/v1/servers/{name}/command:
|
||
post:
|
||
tags: [console]
|
||
operationId: command
|
||
summary: Run a console command via RCON (spec §8 write). Owner/admin only.
|
||
description: The RCON password is never accepted or returned (spec §286).
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ accessJWT: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [command]
|
||
properties:
|
||
command: { type: string }
|
||
responses:
|
||
'200':
|
||
description: Command output.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [name, output]
|
||
properties:
|
||
name: { type: string }
|
||
output: { type: string }
|
||
'400':
|
||
$ref: '#/components/responses/BadRequest'
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
$ref: '#/components/responses/Forbidden'
|
||
'404':
|
||
$ref: '#/components/responses/NotFound'
|
||
'409':
|
||
description: Server not running.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
|
||
/api/v1/servers/{name}/console:
|
||
get:
|
||
tags: [console]
|
||
operationId: serverConsole
|
||
summary: Stream the running pod's log over SSE (spec §8 read, §262). Owner/admin only.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ accessJWT: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
responses:
|
||
'200':
|
||
description: An event stream of log lines.
|
||
content:
|
||
text/event-stream:
|
||
schema: { type: string }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
$ref: '#/components/responses/Forbidden'
|
||
'409':
|
||
description: Server not running.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
|
||
/api/v1/servers/{name}/access/whitelist:
|
||
get:
|
||
tags: [access]
|
||
operationId: accessWhitelistList
|
||
summary: List whitelisted players via RCON (spec §7). Owner/admin only.
|
||
description: >-
|
||
Runs "whitelist list" against the live server and returns a best-effort
|
||
parse plus the raw reply. The RCON password is never accepted or returned
|
||
(spec §286).
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ accessJWT: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
responses:
|
||
'200':
|
||
description: Whitelisted players.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [name, players, output]
|
||
properties:
|
||
name: { type: string }
|
||
players: { type: array, items: { type: string } }
|
||
output: { type: string }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
$ref: '#/components/responses/Forbidden'
|
||
'404':
|
||
$ref: '#/components/responses/NotFound'
|
||
'409':
|
||
description: Server not running.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
post:
|
||
tags: [access]
|
||
operationId: accessWhitelist
|
||
summary: Add or remove a player from the whitelist (spec §7). Owner/admin only.
|
||
description: >-
|
||
Translates to the RCON "whitelist add|remove <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'
|