Files
Felis/docs/openapi.yaml
T

4961 lines
190 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).
- name: users
description: User administration (external face, admin tier only — exposed solely to owner).
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 session cookie (external face). Minted by the passwordless
session doors — passkey login, email-OTP, bind code, and op-login
finish — HttpOnly+Secure+SameSite=Lax and host-only, so an op.console
session never reaches the player console. Only its sha-256 is
persisted. 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' }
MailUndeliverable:
description: >
The configured SMTP relay refused the message (code mail_undeliverable), so no
code was delivered. Distinct from 500 because the fault is in the install's
[smtp] settings, not in the request or the platform — most often a From address
the relay will not let this account send as. The relay's own text is deliberately
withheld (it names the SMTP account) and written to the felis-api log instead,
keyed by the same request_id this response carries. Retrying the same address
changes nothing until an operator fixes the relay.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
RateLimited:
description: >
This client address called the public sign-in doors faster than the per-address
limit allows (code rate_limited); Retry-After gives the seconds until the next
call is admitted. The address is the visitor header the install's edge writes
([auth] client_ip_header: CF-Connecting-IP behind the Cloudflare tunnel), else
the TCP peer; IPv6 clients share one limit per /64.
headers:
Retry-After:
schema: { type: integer }
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
AccessResult:
description: The structured access mutation succeeded; the raw RCON reply is in output.
content:
application/json:
schema:
type: object
required: [name, action, output]
properties:
name: { type: string }
action: { type: string }
player: { type: string }
node: { type: string }
group: { type: string }
output: { type: string }
schemas:
Error:
type: object
description: Uniform error envelope emitted by every handler (internal/api/errors.go).
required: [error]
properties:
error:
type: object
required: [code, message]
properties:
code:
type: string
description: Stable machine-readable code (e.g. not_found, conflict, forbidden, bad_request).
message:
type: string
request_id:
type: string
description: Correlates the response with server logs (withRequestID middleware).
UpdateWindow:
type: object
description: >
The SysAdmin-set auto-update maintenance window (internal/api/handlers_updates.go
updateWindow). An absolute [start,end) interval during which Felis may apply a
Scheduled component's update to itself; both ends null means unset (no apply is
ever opened). Keys are always present; their values are null when unset.
required: [start, end]
properties:
start:
type: string
format: date-time
nullable: true
description: Window start (RFC3339, inclusive), or null when unset.
end:
type: string
format: date-time
nullable: true
description: Window end (RFC3339, exclusive), or null when unset.
DBBackupStatus:
type: object
description: >
The newest control-plane database backup the host recorded
(internal/api/handlers_dbbackup.go dbBackupView; the record itself is
internal/dbbackup Status, written by `felis db backup`).
required: [last, stale, max_age_seconds]
properties:
last:
type: object
nullable: true
description: Null until the first backup has been recorded.
required: [at, name, label, size_bytes, dir]
properties:
at:
type: string
format: date-time
description: When the bundle was written.
name:
type: string
description: Bundle file name, felis-db-<UTC stamp>-<label>.tar.
label:
type: string
enum: [daily, pre-migrate, pre-restore, manual]
size_bytes:
type: integer
format: int64
felis_version:
type: string
schema_version:
type: integer
description: Newest applied migration at backup time.
dir:
type: string
description: Backup directory on the host.
stale:
type: boolean
description: True when there is no record or it is older than max_age_seconds.
max_age_seconds:
type: integer
format: int64
description: The freshness limit (26h), shared with `felis db check` and FelisDBBackupStale.
PasskeyCredential:
type: object
description: >
Display projection of one bound passkey (internal/api/handlers_passkey.go
passkeyCredentialView). Carries no secret — the public key is never returned.
required: [id, name, created_at]
properties:
id: { type: string, description: Opaque passkey row id (used to unbind it). }
name: { type: string, description: Caller-supplied nickname; empty if none. }
aaguid: { type: string, description: Authenticator model id, present only when known. }
created_at: { type: string, format: date-time }
last_used_at:
type: string
format: date-time
description: Present only once an assertion is verified (deferred login path).
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, playersOnline, playersMax]
properties:
name: { type: string }
subdomain: { type: string }
owned: { type: boolean }
claimable: { type: boolean }
phase:
allOf: [{ $ref: '#/components/schemas/Phase' }]
description: Present only when known.
playersOnline:
type: integer
format: int32
description: Best-effort from live CRD status; 0 when the cluster is unreachable.
playersMax: { type: integer, format: int32 }
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.
build_status:
type: string
enum: [pending, building, succeeded, failed, cancelled]
description: >-
The linked build's outcome, attached by the LIST routes
(/me/submissions, /submissions) — for a submitter this is the only
visible outlet for a failed build. Omitted until a build is linked
and its row is readable.
build_error:
type: string
description: >-
The build's recorded failure text (e.g. a CRITICAL CVE scan
failure), attached alongside build_status.
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.
UserView:
type: object
description: One row of the admin user list (internal/api/repo.go UserView).
required: [id, username, role, disabled, email_verified, server_count, created_at, updated_at]
properties:
id: { type: string }
username: { type: string }
email: { type: string }
role: { type: string, enum: [owner, admin, user] }
disabled: { type: boolean }
email_verified: { type: boolean }
server_count: { type: integer }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
UserDetail:
type: object
description: Full admin view of one user (internal/api/repo.go UserDetail).
required: [id, username, role, disabled, email_verified, server_count, created_at, updated_at, linked_accounts]
properties:
id: { type: string }
username: { type: string }
email: { type: string }
role: { type: string, enum: [owner, admin, user] }
disabled: { type: boolean }
email_verified: { type: boolean }
server_count: { type: integer }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
deleted_at:
type: [string, 'null']
format: date-time
description: Present only when soft-deleted.
linked_accounts:
type: array
items:
type: object
required: [mc_uuid, auth_source, verified_at]
properties:
mc_uuid: { type: string, format: uuid }
auth_source: { type: string }
verified_at: { type: string, format: date-time }
QuotaView:
type: object
description: A user's quotas row (internal/api/repo.go QuotaView). Null fields mean unlimited.
required: [user_id]
properties:
user_id: { type: string }
max_servers: { type: integer, nullable: true }
max_cpu_milli: { type: integer, nullable: true }
max_memory_mb: { type: integer, nullable: true }
max_storage_gb: { type: integer, nullable: true }
SessionView:
type: object
description: One live session of a user visible to an admin (internal/api/repo.go SessionView).
required: [token_hash, created_at, expires_at]
properties:
token_hash: { type: string }
created_at: { type: string, format: date-time }
expires_at: { type: string, format: date-time }
revoked_at:
type: [string, 'null']
format: date-time
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'
/metrics:
get:
tags: [metrics]
operationId: metrics
summary: Prometheus metrics (felis_* collectors) on the internal face.
description: >-
Scrape-only infrastructure route, not a product API: the internal listener is
ClusterIP-only and a Prometheus scrape carries no token, the same stance as the
probes. Serves the felis_* exposition documented in troubleshooting §14; the
external face never serves it.
x-felis-face: [internal]
x-felis-tier: public
security: []
responses:
'200':
description: Prometheus text exposition format.
content:
text/plain:
schema: { type: string }
/session/minecraft/hasJoined:
get:
tags: [nano]
operationId: hasJoined
summary: Multi-source session verifier (Felis-nano hasJoined multiplexer).
description: >-
Velocity is pointed here with -Dmojang.sessionserver and sends the request itself.
Unauthenticated — the vanilla sessionserver protocol carries no token. The query
is fanned out to the configured Yggdrasil roots in priority order (the Mojang
identity source first); the first source to validate the serverId hash wins. A
non-identity source's self-asserted UUID is rewritten into a per-source namespace
(UUIDv3) before return, so it can never land in Mojang's UUID space. A rejected
or barred login is 204, which Velocity answers with its online-mode-only kick.
Any other non-200 status makes Velocity report the auth servers as down.
x-felis-face: [internal]
x-felis-tier: public
security: []
parameters:
- { name: username, in: query, required: true, schema: { type: string, maxLength: 64 } }
- { name: serverId, in: query, required: true, schema: { type: string, maxLength: 64 } }
- { name: ip, in: query, required: false, schema: { type: string, maxLength: 64 } }
responses:
'200':
description: A source validated the session; the canonical game profile.
content:
application/json:
schema:
type: object
required: [id, name]
properties:
id: { type: string, description: Canonical UUID, undashed 32-hex. }
name:
type: string
description: >-
The name the source returned. A third-party player whose name is
registered to a Mojang account gets it back as PREFIX_name, cut to
16 characters.
properties: { type: array, items: { type: object } }
'204':
description: >-
Not admitted, with no source asked when username or serverId is missing or a
parameter is over 64 bytes. Otherwise no source validated the session, the
canonical UUID is barred, a third-party source returned a name that is not a
legal Minecraft username, or the identity source returned an unparseable id.
'400':
description: >-
The request declared a body. No body is sent back, and the connection is
closed.
'500':
description: The bar-list lookup failed, so the login is not admitted.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
description: >-
No source validated the session and at least one source failed (transport
error, redirect, unexpected status, or a 200 without a usable profile). Its
player may be the one logging in, so this is not answered as a 204. No body.
# -------------------------------------------------- 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/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/submissions/{id}/context:
get:
tags: [submissions-internal]
operationId: internalSubmissionContext
summary: Stream a submission's stored build-context tarball to the build Pod.
description: >-
The build Job's fetch initContainer cannot mount the control-plane uploads
PVC (a PVC does not cross namespaces) and holds no object-store
credentials, so the API that stored the blob streams it here. Served on
the internal face (service token, no Zero Trust).
x-felis-face: [internal]
x-felis-tier: service
security: [{ serviceToken: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: The stored gzip tarball, verbatim.
content:
application/gzip:
schema: { type: string, format: binary }
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
'503':
$ref: '#/components/responses/ServiceUnavailable'
/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'
'409':
description: A restore, backup or file write holds the server's world volume (maintenance_in_progress); nothing was started.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'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]
description: >
Which Yggdrasil authenticated the in-game UUID (spec §10
dual-Yggdrasil). Optional; when omitted it is derived from the
UUID's version nibble (felis-nano rewrites third-party profiles
to UUIDv3; Mojang profiles are v4), defaulting to mojang.
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 }
panel_url:
type: string
description: >
Where to redeem the code (https://<panel hostname>). Present
only when a panel hostname is configured, so the in-game
message can print a clickable destination.
'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. 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. The
response is deliberately just the boolean — the plugin keys everything on the
UUID it already holds, so no identity detail crosses back.
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.
content:
application/json:
schema:
type: object
required: [linked]
properties:
linked: { type: boolean }
'401':
$ref: '#/components/responses/Unauthorized'
/api/v1/internal/account/migrate/start:
post:
tags: [account-internal]
operationId: migrateStart
summary: Put the account linked to a verified in-game UUID into migrate mode (spec §B3 inherit, in-game side).
description: >
Internal-only. The in-game /felis migrate command calls this for the running
player's verified UUID: it resolves the linked account and opens a fresh
migration in the initiated state, superseding any earlier unfinished attempt
by the same source. The web side then drives a fresh step-up confirmation.
The transfer itself moves server ownership only — never the mc_uuid link nor
web credentials — so this endpoint starts a flow, it does not move anything.
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, format: uuid }
responses:
'201':
description: Migration opened in the initiated state.
content:
application/json:
schema:
type: object
required: [started, state]
properties:
started: { type: boolean, const: true }
state: { type: string, const: initiated }
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'404':
description: The UUID is not linked to any account (not_linked).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: The source account has already been retired by a completed migration (account_retired).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/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'
/api/v1/internal/op-login/pending:
get:
tags: [account-internal]
operationId: opLoginPending
summary: List live pending op.console login requests, oldest first (spec §B).
description: >
Internal-only. Lists the requests awaiting an in-game vouch. Today no plugin
consumes it — the staff member reads the request id off the op.console page
and an admin approves it with /felis web op approve <id>; the route exists so
velocity can later push the waiting list to online admins. No pending request
is secret to the operator crew.
x-felis-face: [internal]
x-felis-tier: service
security: [{ serviceToken: [] }]
responses:
'200':
description: The pending requests awaiting an in-game vouch.
content:
application/json:
schema:
type: object
required: [pending]
properties:
pending:
type: array
items:
type: object
required: [request_id, username, email, created_at]
properties:
request_id: { type: string }
username: { type: string }
email: { type: string }
created_at: { type: string, format: date-time }
'401':
$ref: '#/components/responses/Unauthorized'
/api/v1/internal/op-login/{id}/approve:
post:
tags: [account-internal]
operationId: opLoginApprove
summary: Record an in-game admin's vouch for a pending op.console login (spec §B).
description: >
Internal-only second factor: velocity submits the online-mode UUID of the
in-game admin running /felis web op approve. The API resolves it to a linked
role=admin account (else 403 not_admin) and flips the request approved. A
missing or no-longer-pending request is 404. Self-approval is allowed — an
online staff member vouching as their own admin identity is a genuine second
factor distinct from the mailbox.
x-felis-face: [internal]
x-felis-tier: service
security: [{ serviceToken: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [approver_uuid]
properties:
approver_uuid: { type: string, format: uuid }
responses:
'200':
description: The vouch was recorded; the request is now approved.
content:
application/json:
schema:
type: object
required: [approved]
properties:
approved: { type: boolean, const: true }
'400':
description: approver_uuid is required (bad_request).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: The approver is not a linked administrator (not_admin).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'404':
description: No pending operator login with that id (op_login_not_found).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/internal/servers/{name}/backup:
post:
tags: [account-internal]
operationId: internalBackupNow
summary: Break-glass on-demand world backup (service token; server must be stopped).
description: >-
The break-glass console (root on the node, holding the service token) POSTs
here to snapshot a stopped world while the API is alive — it goes through the
API rather than direct-to-CRD because rendering the backup Job needs
deployment coordinates only felis-api holds. Same RWO stopped-gate and async
202 as the external backupNow; there is no Principal (trusted machine caller),
and the action is audited to "break-glass".
x-felis-face: [internal]
x-felis-tier: service
security: [{ serviceToken: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
requestBody:
required: false
description: >-
Optional accountability hint. The console passes the OS user at the
keyboard so the audit row names the operator rather than the generic
"break-glass"; absent/blank falls back to "break-glass".
content:
application/json:
schema:
type: object
properties:
os_user: { type: string }
responses:
'202':
description: Backup started.
content:
application/json:
schema:
type: object
required: [name, status]
properties:
name: { type: string }
status: { type: string, const: backing_up }
'400':
description: Invalid server name (bad_name).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'404':
description: Unknown server.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: Server is not stopped (not_stopped), or a restore, backup or file write already holds its world volume (maintenance_in_progress).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
# ----------------------------------------------------- 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'
'409':
description: A restore, backup or file write holds the server's world volume (maintenance_in_progress); nothing was started.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'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/players:
get:
tags: [access]
operationId: accessPlayers
summary: List online players via RCON (spec §7). Owner/admin only.
description: >-
Runs "list" against the live server and returns the online/max tally, a
best-effort parse of the online player names, and the raw reply. This is
the only source of WHO is online — Status.Players carries the count alone.
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: Online players.
content:
application/json:
schema:
type: object
required: [name, online, max, players, output]
properties:
name: { type: string }
online: { type: integer }
max: { type: integer }
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'
/api/v1/servers/{name}/access/ban:
get:
tags: [access]
operationId: accessBanList
summary: List banned players via RCON (spec §7). Owner/admin only.
description: >-
Runs "banlist" 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: Banned 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: 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/kick:
post:
tags: [access]
operationId: accessKick
summary: Kick a player off the running server (spec §7). Owner/admin only.
description: >-
Translates to the RCON "kick <player>" command. Unlike ban it does not
block rejoining. 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 (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: [player]
properties:
player: { type: string }
responses:
'200':
description: The player was kicked; the raw RCON reply is in output.
content:
application/json:
schema:
type: object
required: [name, player, output]
properties:
name: { type: string }
player: { 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}/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}/access/luckperms/{player}:
get:
tags: [access]
operationId: accessLuckPermsInfo
summary: Read a player's LuckPerms groups and permission nodes (spec §7). Owner/admin only.
description: >-
Translates to "lp user <player> permission info" over RCON and parses the
paginated, colour-coded reply (up to 10 pages) into structured entries.
Parent groups (granted group.<name> nodes without a world context) are
split out from plain permission nodes. The raw concatenated RCON output
is echoed back for anything the parser cannot represent.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- { name: player, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Parsed LuckPerms state plus the raw command output.
content:
application/json:
schema:
type: object
required: [player, groups, permissions, output]
properties:
player: { type: string }
groups:
type: array
items: { type: string }
permissions:
type: array
items:
type: object
required: [node, value]
properties:
node: { type: string }
value: { type: boolean, description: "false = negated (§c) node" }
world: { type: string, description: "present only for world-scoped nodes" }
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}/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/options:
post:
tags: [auth]
operationId: authOptions
summary: Identifier-first login discovery — which methods can this email use (spec §B, #71).
description: >-
Public, pre-session discovery for the SPA's identifier-first form: given a typed
email, report which console login methods the account can use (passkey and/or
email-OTP) so the UI prompts for the right authenticator. This is the deliberate
counter-slice to the anti-enumeration login doors — the ONE sanctioned place
account existence is disclosed, so an unknown address returns an empty methods
array. It never reveals staffness: methods are computed identically for every
resolved account (no role branch), so a staff and a player address in the same
credential state return byte-identical bodies. passkey is offered only when a
verifier is wired. Sends no mail and mutates nothing; bounded by the per-address
sign-in rate limit (429 rate_limited). Gated on local_auth_enabled.
x-felis-face: [external]
x-felis-tier: public
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email]
properties:
email: { type: string, format: email }
responses:
'200':
description: >-
The login methods available for the address, in a deterministic order
(passkey before email_otp). An empty array means no verified account.
content:
application/json:
schema:
type: object
required: [methods]
properties:
methods:
type: array
items: { type: string, enum: [passkey, email_otp] }
'400':
description: A valid email is required (bad_request).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'403':
description: Local session login is disabled on this deployment (local_auth_disabled).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'415':
description: Request body was not application/json.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'429':
$ref: '#/components/responses/RateLimited'
/api/v1/auth/passkey/login/begin:
post:
tags: [auth]
operationId: passkeyLoginBegin
summary: Begin a passwordless passkey (WebAuthn) login (spec §14, §B).
description: >-
First leg of the public, pre-session passkey assertion door: the caller
supplies the email that selects the account and, on success, receives the raw
PublicKeyCredentialRequestOptions to hand to navigator.credentials.get(). The
matching challenge is stashed server-side and redeemed by finish. Mounted
Public (no prior principal) and gated on local_auth_enabled. An unknown
address and a known account with no enrolled passkey both return the SAME 400
no_passkey, so the door is not an existence oracle; a per-recipient cooldown
(shared shape with the email-OTP and op-login doors) throttles probing.
x-felis-face: [external]
x-felis-tier: public
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email]
properties:
email: { type: string, format: email }
responses:
'200':
description: >-
The WebAuthn assertion options (PublicKeyCredentialRequestOptions), passed
through verbatim from the authenticator library for the browser to consume.
The body is the WebAuthn standard shape and is not modelled here.
content:
application/json:
schema: { type: object, additionalProperties: true }
'400':
description: >-
Invalid email (bad_request); or no passkey is enrolled for the account, or
the address is unknown — indistinguishable by design (no_passkey); or the
authenticator library could not start the ceremony (passkey_login_failed).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'403':
description: Local session login is disabled on this deployment (local_auth_disabled).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'415':
description: Request body was not application/json.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'429':
description: >-
A passkey login for this recipient was started too recently (otp_resend_cooldown);
or this client address called the sign-in doors too often (rate_limited, with Retry-After).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
description: No passkey verifier is wired on this deployment (passkey_unavailable).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/auth/passkey/login/finish:
post:
tags: [auth]
operationId: passkeyLoginFinish
summary: Complete a passkey (WebAuthn) login and mint a session (spec §14, §B).
description: >-
Second leg of the public passkey door: the caller returns the email (to
re-select the account) and the raw navigator.credentials.get() assertion. The
stashed login challenge is consumed atomically and the assertion is verified
against it; on success a host-only felis_session cookie is minted. Both players
and staff may log in this way — a passkey is a two-factor authenticator
(possession + user verification), strong enough to stand alone without the
in-game approval op-login requires. Every failure mode (unknown address, no
live challenge, expired challenge, bad assertion) collapses into one uniform
passkey_login_invalid, so the door reveals nothing.
x-felis-face: [external]
x-felis-tier: public
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email, assertion]
properties:
email: { type: string, format: email }
assertion:
type: object
additionalProperties: true
description: >-
The raw PublicKeyCredential from navigator.credentials.get(),
passed to the verifier verbatim (WebAuthn standard shape).
responses:
'200':
description: Assertion verified; a host-only session cookie is set on the response.
content:
application/json:
schema:
type: object
required: [user_id, role]
properties:
user_id: { type: string }
role: { type: string, enum: [user, admin] }
'400':
description: >-
Invalid email or missing assertion (bad_request); or the login could not be
completed — unknown address, no live or expired challenge, or a failed
assertion, all uniform (passkey_login_invalid).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'403':
description: Local session login is disabled on this deployment (local_auth_disabled).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'415':
description: Request body was not application/json.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'429':
$ref: '#/components/responses/RateLimited'
'503':
description: No passkey verifier is wired on this deployment (passkey_unavailable).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/auth/passkey/login/discoverable/begin:
post:
tags: [auth]
operationId: passkeyLoginDiscoverableBegin
summary: Begin a usernameless (discoverable) passkey login (spec §14, §B, task #40).
description: >-
First leg of the truly from-zero passkey door: unlike the email-first sibling
above, the caller supplies NO identifier — the request has no body (only the
application/json Content-Type is required as the cross-origin CSRF guard). The
response is the WebAuthn PublicKeyCredentialRequestOptions with an EMPTY
allowCredentials, plus an opaque login_id: the authenticator picks a resident
credential it holds for this RP and the account is revealed only by the
userHandle inside the signed assertion at finish. The challenge cannot be
user-keyed, so it is stashed under login_id in a non-user-keyed store and echoed
back at finish. Mounted Public and gated on local_auth_enabled. There is no
recipient or principal to key a per-caller cooldown on, so one client is bounded
by the per-address sign-in rate limit (429 rate_limited) and the table by a hard
global cap on live challenges (429 too_many_challenges). Inert for a credential until its owner
enrolls a resident passkey; email-OTP and username-first passkey remain the
fallbacks, so no authenticator is ever locked out.
x-felis-face: [external]
x-felis-tier: public
security: []
requestBody:
required: false
description: >-
No body is read — the whole point is that the caller supplies no identifier —
but the application/json Content-Type is required (415 otherwise).
content:
application/json:
schema: { type: object }
responses:
'200':
description: >-
The WebAuthn assertion options (PublicKeyCredentialRequestOptions) with an
empty allowCredentials, passed through verbatim for the browser to consume,
plus an opaque login_id the caller echoes at finish. The publicKey member is
the WebAuthn standard shape and is not modelled here.
content:
application/json:
schema:
type: object
required: [publicKey, login_id]
properties:
publicKey: { type: object, additionalProperties: true }
login_id: { type: string }
'400':
description: The authenticator library could not start the ceremony (passkey_login_failed).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'403':
description: Local session login is disabled on this deployment (local_auth_disabled).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'415':
description: Request Content-Type was not application/json.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'429':
description: >-
Too many discoverable logins are in flight server-wide (too_many_challenges;
the cap is global, so no per-recipient signal leaks); or this client address
called the sign-in doors too often (rate_limited, with Retry-After).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
description: No passkey verifier is wired on this deployment (passkey_unavailable).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/auth/passkey/login/discoverable/finish:
post:
tags: [auth]
operationId: passkeyLoginDiscoverableFinish
summary: Complete a usernameless (discoverable) passkey login and mint a session (spec §14, §B, task #40).
description: >-
Second leg of the from-zero door: the caller returns the opaque login_id from
begin (the only link to the stashed challenge, since it is not user-keyed) and
the raw navigator.credentials.get() assertion — and NOTHING that names an
account. The stashed challenge is consumed atomically and the assertion is
verified against it; the account is resolved from the authenticator-revealed
userHandle (the account's stable id), never from anything the client supplied,
and the session is minted for the account the assertion actually resolved AND
verified to. Both players and staff may log in this way. Every failure mode — a
missing/expired/consumed login_id, a bad assertion, AND a userHandle that
resolves to no account — collapses into one uniform passkey_login_invalid, so
the door reveals nothing (not even whether the handle was well-formed).
x-felis-face: [external]
x-felis-tier: public
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [login_id, assertion]
properties:
login_id:
type: string
description: The opaque handle returned by discoverable/begin.
assertion:
type: object
additionalProperties: true
description: >-
The raw PublicKeyCredential from navigator.credentials.get(),
passed to the verifier verbatim (WebAuthn standard shape). Its
userHandle selects the account server-side.
responses:
'200':
description: Assertion verified; a host-only session cookie is set on the response.
content:
application/json:
schema:
type: object
required: [user_id, role]
properties:
user_id: { type: string }
role: { type: string, enum: [user, admin] }
'400':
description: >-
Missing login_id or assertion (bad_request); or the login could not be
completed — no live/expired/consumed challenge, a failed assertion, or a
userHandle that resolves to no account, all uniform (passkey_login_invalid).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'403':
description: Local session login is disabled on this deployment (local_auth_disabled).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'415':
description: Request body was not application/json.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'429':
$ref: '#/components/responses/RateLimited'
'503':
description: No passkey verifier is wired on this deployment (passkey_unavailable).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/auth/email/start:
post:
tags: [auth]
operationId: loginEmailStart
summary: Begin a passwordless email-OTP login — mail a one-time code (spec §B).
description: >-
Public, pre-session console door: the caller supplies an email and, if it
resolves to a verified account, a one-time code is mailed under the login
purpose. An address with no account returns the SAME 202 with no code minted,
and the per-recipient cooldown is kept on that path too, so probing reveals
nothing (existence is learnt only at the sanctioned /auth/options oracle).
An account that spent its daily wrong-code budget (10 per 24h, across every
code) also gets the same 202 and no mail until the window ends. Gated on
local_auth_enabled.
x-felis-face: [external]
x-felis-tier: public
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email]
properties:
email: { type: string, format: email }
responses:
'202':
description: >-
Accepted (neutral): a code was mailed if the address has a verified
account; the response is identical either way.
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: A valid email is required (bad_request).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'403':
description: Local session login is disabled on this deployment (local_auth_disabled).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'415':
description: Request body was not application/json.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'429':
description: >-
A code for this recipient was requested too recently (otp_resend_cooldown);
or this client address called the sign-in doors too often (rate_limited, with Retry-After);
or the install-wide mail budget is spent (mail_rate_limited, with Retry-After).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'502':
$ref: '#/components/responses/MailUndeliverable'
/api/v1/auth/email/verify:
post:
tags: [auth]
operationId: loginEmailVerify
summary: Redeem an email-OTP login code into a session (spec §B).
description: >-
Public, pre-session: resolves the address to an account, verifies the code
under the login purpose, and on success mints a host-only felis_session. An
unknown address, a wrong or expired code, and an attempt-exhausted code all
return the IDENTICAL 400 invalid_code, so the door is not an existence or
lockout oracle. The 10th wrong code in 24h locks the door for that account
until the window ends (the right code then also reads as invalid_code); the
owner is told by mail once, and the lock is audited as auth.otp.locked.
Staff are refused (403) — but only AFTER a valid code is
redeemed, so only the account owner can ever reach that refusal.
x-felis-face: [external]
x-felis-tier: public
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email, code]
properties:
email: { type: string, format: email }
code: { type: string }
responses:
'200':
description: Code accepted; a host-only session cookie is set on the response.
content:
application/json:
schema:
type: object
required: [user_id, role]
properties:
user_id: { type: string }
role: { type: string, enum: [user, admin] }
'400':
description: >-
A valid email and code are required (bad_request); or the code is wrong,
expired, or exhausted (invalid_code, uniform with an unknown address).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'403':
description: >-
Local session login is disabled (local_auth_disabled), or the account is
staff and must sign in at the operator console (staff_account).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'415':
description: Request body was not application/json.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'429':
$ref: '#/components/responses/RateLimited'
/api/v1/auth/op-login/start:
post:
tags: [auth]
operationId: opLoginStart
summary: Begin an op.console staff login — mail an OTP, open an approval request (spec §B).
description: >-
Public, pre-session first leg of the two-factor operator door: resolves the
staff address, opens an op_login request, and mails a one-time code under the
op_login purpose, returning the request handle the browser polls. A non-staff
or unknown address gets the SAME 202 with a random, non-persisted handle and no
mail, so this never becomes a staff-enumeration oracle. A staff account that
spent its daily wrong-code budget gets the same neutral 202. Gated on
local_auth_enabled.
x-felis-face: [external]
x-felis-tier: public
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email]
properties:
email: { type: string, format: email }
responses:
'202':
description: >-
Accepted (neutral): a request handle to poll. For a staff address a code
was mailed and the handle is real; otherwise the handle is a random no-op.
content:
application/json:
schema:
type: object
required: [request_id, expires_at]
properties:
request_id: { type: string }
expires_at: { type: string, format: date-time }
'400':
description: A valid email is required (bad_request).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'403':
description: Local session login is disabled on this deployment (local_auth_disabled).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'415':
description: Request body was not application/json.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'429':
description: >-
A code for this recipient was requested too recently (otp_resend_cooldown);
or this client address called the sign-in doors too often (rate_limited, with Retry-After);
or the install-wide mail budget is spent (mail_rate_limited, with Retry-After).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'502':
$ref: '#/components/responses/MailUndeliverable'
/api/v1/auth/op-login/status/{id}:
get:
tags: [auth]
operationId: opLoginStatus
summary: Poll whether an op.console login request has been approved in-game (spec §B).
description: >-
Public, pre-session read the browser polls after start. Returns approved:true
only for a genuinely approved, live, unconsumed request; every other case —
unknown, expired, denied, or already-consumed handle — reads approved:false, so
a fabricated handle polls false forever and only an in-game admin vouch can flip
it true.
x-felis-face: [external]
x-felis-tier: public
security: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: The approval state of the request handle.
content:
application/json:
schema:
type: object
required: [approved]
properties:
approved: { type: boolean }
'403':
description: Local session login is disabled on this deployment (local_auth_disabled).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/auth/op-login/finish:
post:
tags: [auth]
operationId: opLoginFinish
summary: Redeem an approved op.console request plus its mailed code into a staff session (spec §B).
description: >-
Public, pre-session final leg: mints a host-only staff session only when BOTH
factors have landed — the request is approved-and-live AND the mailed code
verifies. Every failure (unknown handle, not-yet-approved, wrong or locked code,
an account past its daily wrong-code budget, lost race) collapses into one uniform 400 op_login_invalid, so a code-less
caller learns nothing. Admin is re-asserted before the session is issued.
x-felis-face: [external]
x-felis-tier: public
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [request_id, code]
properties:
request_id: { type: string }
code: { type: string }
responses:
'200':
description: Both factors proven; a host-only session cookie is set on the response.
content:
application/json:
schema:
type: object
required: [user_id, role]
properties:
user_id: { type: string }
role: { type: string, enum: [user, admin] }
'400':
description: >-
request_id and code are required (bad_request); or the login could not be
completed — unknown handle, not approved, wrong or locked code, or lost
race, all uniform (op_login_invalid).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'403':
description: >-
Local session login is disabled (local_auth_disabled), or the resolved
account is not an operator (staff_account).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'415':
description: Request body was not application/json.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'429':
$ref: '#/components/responses/RateLimited'
/api/v1/auth/setup/redeem:
post:
tags: [auth]
operationId: setupRedeem
summary: Redeem a one-time setup token into a lockdown session (spec §B).
description: >-
Public, pre-session first-run door: consumes the one-time setup token minted by
the felis TUI (stored and looked up by SHA-256 hash, like session cookies),
mints a host-only felis_session, and returns the remaining setup steps so the
SPA can drive the wizard. An unknown, consumed, or expired token returns a
uniform 400 setup_token_invalid. Gated on local_auth_enabled.
x-felis-face: [external]
x-felis-tier: public
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [token]
properties:
token: { type: string }
responses:
'200':
description: Token redeemed; a session cookie is set and the setup state is returned.
content:
application/json:
schema:
type: object
required: [user_id, username, role, email, email_verified, has_passkey, setup_required]
properties:
user_id: { type: string }
username: { type: string }
role: { type: string, enum: [user, admin] }
email: { type: string }
email_verified: { type: boolean }
has_passkey: { type: boolean }
setup_required: { type: boolean }
'400':
description: >-
A token is required (bad_request), or it is unknown, already used, or
expired (setup_token_invalid).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'403':
description: Local session login is disabled on this deployment (local_auth_disabled).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'415':
description: Request body was not application/json.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'429':
$ref: '#/components/responses/RateLimited'
/api/v1/auth/setup/status:
get:
tags: [auth]
operationId: setupStatus
summary: Report the caller's own setup progress (spec §B).
description: >-
App-tier read the SPA polls after each setup wizard step (email verify, passkey
enroll) to decide whether the first-run lockdown can lift. It reads only the
principal's own state and is reachable during setup lockdown (the rest of the
API is fenced until setup completes).
x-felis-face: [external]
x-felis-tier: app
security: [{ sessionCookie: [] }]
responses:
'200':
description: The caller's current setup state.
content:
application/json:
schema:
type: object
required: [user_id, username, role, email, email_verified, has_passkey, setup_required]
properties:
user_id: { type: string }
username: { type: string }
role: { type: string, enum: [user, admin] }
email: { type: string }
email_verified: { type: boolean }
has_passkey: { type: boolean }
setup_required: { type: boolean }
'401':
$ref: '#/components/responses/Unauthorized'
'404':
description: The principal's user row was not found (not_found).
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/bind:
post:
tags: [auth]
operationId: bindRedeem
summary: Redeem a Bind Code into a player account + session (public console bootstrap, spec §10/§B).
description: >-
The one public, pre-account entrypoint of the player console
(console.<root_domain>): an account-less player redeems the one-time Bind
Code they generated in the in-game Login Lobby, and the platform creates their
player account (role=user), binds it to the verified in-game UUID, and mints a
host-only session cookie. Safe to expose unauthenticated because the code is
minted internal-face only, against an online-mode-verified UUID, with a short
TTL and single use — possession already proves control of a Minecraft identity.
An already-linked player UUID logs that player back in (idempotent); a UUID
that belongs to staff is refused (403) — operators authenticate at op.console
behind Zero Trust, so this never mints a session for an admin identity. Requires
local sessions to be enabled (same toggle as login).
x-felis-face: [external]
x-felis-tier: public
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [code]
properties:
code: { type: string }
responses:
'200':
description: Player account bootstrapped; the session cookie is set on the response.
content:
application/json:
schema:
type: object
required: [user_id, linked, mc_uuid, auth_source]
properties:
user_id: { type: string }
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 bind code.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'403':
description: Local sessions are disabled, or the code's UUID belongs to a staff account.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'429':
$ref: '#/components/responses/RateLimited'
/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, is_owner, email_verified]
properties:
user_id: { type: string }
email: { type: string, format: email }
role:
type: string
enum: [user, admin, owner]
description: The principal's role, mirroring users.role.
is_admin:
type: boolean
description: >-
True only when role is admin or owner AND the request arrived
via the admin Access path (Principal.IsAdmin()).
is_owner:
type: boolean
description: >-
True only for the Owner principal on the admin Access path
(Principal.IsOwner()); gates owner-only panel surfaces.
email_verified:
type: boolean
description: >-
Whether the account's email has been verified; the panel
nudges unverified accounts through the email-OTP flow.
'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/updates/window:
get:
tags: [admin-updates]
operationId: getUpdateWindow
summary: Read the SysAdmin-set auto-update maintenance window (admin).
description: >-
The single platform-wide maintenance window during which Felis may apply a
Scheduled component's update to itself (decision core internal/updates). An
unset window — never set, or explicitly cleared — reads back as
{start:null,end:null}. API+persistence only: nothing consumes the window
until the INTEGRATION runner and executors are wired, so setting it changes
no behavior yet.
x-felis-face: [external]
x-felis-tier: admin
security: [{ accessJWT: [] }]
responses:
'200':
description: The current maintenance window (both ends null when unset).
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateWindow'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
put:
tags: [admin-updates]
operationId: setUpdateWindow
summary: Set or clear the SysAdmin auto-update maintenance window (admin).
description: >-
Persist the maintenance window as an absolute [start,end) interval. Both
ends must be set with end strictly after start, or both null to clear the
window to unset. A half-set (exactly one end) or inverted/empty (end not
after start) body is rejected 400, mirroring the decision core's fail-closed
Window so a malformed schedule can never be stored. No forced auto-update:
setting a window only permits an apply inside it; outside, a Scheduled
component degrades to notify.
x-felis-face: [external]
x-felis-tier: admin
security: [{ accessJWT: [] }]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateWindow'
responses:
'200':
description: The stored maintenance window (echoed back).
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateWindow'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
/api/v1/platform/db-backup:
get:
tags: [admin-updates]
operationId: getDBBackup
summary: Freshness of the newest control-plane database backup (admin).
description: >-
What the host's felis-db-backup.timer (or a manual `felis db backup`)
last recorded in platform_settings. last is null before the first
backup; stale is true then, and whenever the newest backup is older than
max_age_seconds. Read-only: backups run on the host, never through the API.
x-felis-face: [external]
x-felis-tier: admin
security: [{ accessJWT: [] }]
responses:
'200':
description: The newest recorded backup and whether it is stale.
content:
application/json:
schema:
$ref: '#/components/schemas/DBBackupStatus'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
/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. Lifecycle is CRD truth (§1);
the owner is the only business field, joined READ-ONLY from Postgres (§6)
for display — best-effort, so a Postgres blip degrades to owner-less rows.
x-felis-face: [external]
x-felis-tier: admin
security: [{ accessJWT: [] }]
responses:
'200':
description: Every server's status projection (fleet-wide), each with its owner.
content:
application/json:
schema:
type: object
required: [servers]
properties:
servers:
type: array
items:
allOf:
- $ref: '#/components/schemas/ServerInfo'
- type: object
properties:
owner:
type: string
description: >-
The owner's display identity (email, or username when
the address is absent). Absent for an unclaimed server
or when the best-effort owner lookup failed.
system:
type: boolean
description: >-
True for a platform-provisioned system service (the login
gate, the lobby). Their reserved names are rejected by
every per-server route, so the cockpit renders them
read-only instead of offering actions that would 400.
'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 (not_stopped), or a restore, backup or file write already holds its world volume (maintenance_in_progress).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
/api/v1/servers/{name}/backup:
post:
tags: [backups]
operationId: backupNow
summary: Back up a server's data volume on demand (owner-or-admin; server must be stopped).
description: >-
Snapshots the server's whole data volume (worlds, config, plugins/mods,
jars, libraries — not just world folders) into the archive store as a
first-class world_backups row (reason "manual"), restorable later like an
inactivity backup. A restore replaces the volume with the archive. The world PVC is RWO and held by a running server, so the server must
be fully stopped first (409 not_stopped otherwise). The backup runs
asynchronously as a Job, so success is 202 (backing_up).
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
responses:
'202':
description: Backup started.
content:
application/json:
schema:
type: object
required: [name, status]
properties:
name: { type: string }
status: { type: string, const: backing_up }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: Unknown server.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: Server is not stopped (not_stopped), or a restore, backup or file write already holds its world volume (maintenance_in_progress).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
# -------------------------------------------------- async job status (app) ---
/api/v1/servers/{name}/jobs:
get:
tags: [backups]
operationId: listServerJobs
summary: Latest async world operations (backup/restore) for a server (owner-or-admin).
description: >-
Backup and restore run as cluster Jobs, so a 202 that later failed left
its only trace in the Job object. This route projects the newest such
Jobs, newest first, so failures are observable without kubectl. State is
"running" | "succeeded" | "failed".
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 newest backup/restore jobs.
content:
application/json:
schema:
type: object
required: [server, jobs]
properties:
server: { type: string }
jobs:
type: array
items:
type: object
required: [name, kind, state]
properties:
name: { type: string }
kind: { type: string, enum: [backup, restore] }
state: { type: string, enum: [running, succeeded, failed] }
message: { type: string }
started_at: { type: string, format: date-time }
finished_at: { type: string, format: date-time }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: Unknown server.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
# ------------------------------------------------- server file editor (app) ---
/api/v1/servers/{name}/files:
get:
tags: [files]
operationId: listServerFiles
summary: List a directory in a server's world volume (owner-or-admin; server must be stopped).
description: >-
Lists one directory inside the server's world volume — the repair lever for a
server that will not boot because a config file is wrong. The world PVC is RWO
and held by a running server, so the server must be fully stopped first (409
not_stopped otherwise). The listing runs as a one-shot Job whose output is read
back through pods/log, so the call is synchronous but takes seconds rather than
milliseconds. Paths are resolved inside the world root by os.Root, so "..", an
absolute path, and a symlink leaving the root are all refused with 400 bad_path.
Listings are capped; truncated reports that the cap was hit.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- name: path
in: query
required: false
description: Directory to list, relative to the world root. Empty lists the root itself.
schema: { type: string }
responses:
'200':
description: Directory listing.
content:
application/json:
schema:
type: object
required: [path, entries, truncated]
properties:
path: { type: string }
truncated: { type: boolean, description: The listing hit the entry cap and is incomplete. }
entries:
type: array
items:
type: object
required: [name, size, is_dir, mod_time]
properties:
name: { type: string }
size: { type: integer, format: int64 }
is_dir: { type: boolean }
mod_time: { type: string, format: date-time }
'400':
description: Invalid server name, or a path that escapes the world root.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: Unknown server, or no such directory.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: Server is not stopped (its world PVC is still mounted).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
'504':
description: The file Job did not finish in time; retry.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/servers/{name}/file:
get:
tags: [files]
operationId: readServerFile
summary: Read a file from a server's world volume (owner-or-admin; server must be stopped).
description: >-
Returns one file's bytes, base64-encoded, from inside the server's world
volume. Same stopped-gate and os.Root containment as the directory listing.
Reads are capped at 1 MiB; a larger file is 413 rather than a truncated read,
because a config editor that silently returned half a file would let a
subsequent save destroy the other half.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- name: path
in: query
required: true
description: File to read, relative to the world root.
schema: { type: string }
responses:
'200':
description: File contents.
content:
application/json:
schema:
type: object
required: [path, content]
properties:
path: { type: string }
content: { type: string, format: byte, description: Base64-encoded file bytes. }
'400':
description: Missing path, invalid server name, or a path that escapes the world root.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: Unknown server, or no such file.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: Server is not stopped (its world PVC is still mounted).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'413':
description: The file is larger than the editor reads.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
'504':
description: The file Job did not finish in time; retry.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
put:
tags: [files]
operationId: writeServerFile
summary: Write a file in a server's world volume (owner-or-admin; server must be stopped).
description: >-
Replaces a file's contents, creating the file if absent but never creating its
parent directories. Content is base64 so arbitrary bytes (CRLF endings, a BOM)
survive intact. Writes are capped at 256 KiB — the Job spec carries the content,
and etcd bounds the object — so a larger body is 413. Same stopped-gate and
os.Root containment as the read; a write through a symlink leaving the world
root is refused. Audited as file.write.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- name: path
in: query
required: true
description: File to write, relative to the world root.
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [content]
properties:
content: { type: string, format: byte, description: Base64-encoded file bytes. }
responses:
'200':
description: File written.
content:
application/json:
schema:
type: object
required: [path, status]
properties:
path: { type: string }
status: { type: string, const: written }
'400':
description: Missing path, malformed body, invalid server name, or a path that escapes the world root.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: Unknown server, or the parent directory does not exist.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: Server is not stopped (not_stopped), or a restore, backup or file write already holds its world volume (maintenance_in_progress).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'413':
description: The content is larger than the editor writes.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
'504':
description: The file Job did not finish in time; retry.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
# ------------------------------------------------------ users (admin tier) ----
/api/v1/users:
get:
tags: [users]
operationId: listUsers
summary: List users (admin only).
description: >-
Returns a page of non-deleted users matching optional query filters, newest
first. Every route under /users gates on the admin Zero-Trust path.
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
parameters:
- { name: query, in: query, required: false, schema: { type: string }, description: Substring match on username or email }
- { name: role, in: query, required: false, schema: { type: string, enum: [admin, user] } }
- { name: disabled, in: query, required: false, schema: { type: string, enum: ["true", "false"] } }
- { name: limit, in: query, required: false, schema: { type: integer, default: 20, maximum: 100 } }
- { name: offset, in: query, required: false, schema: { type: integer, default: 0 } }
responses:
'200':
description: A page of users plus the total unfiltered count.
content:
application/json:
schema:
type: object
required: [users, total]
properties:
users:
type: array
items: { $ref: '#/components/schemas/UserView' }
total: { type: integer }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
post:
tags: [users]
operationId: createUser
summary: Create a user (admin only).
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [username, role]
description: >-
Passwordless: the new account signs in via the session doors
(email-OTP / passkey / bind code); no credential is set here.
properties:
username: { type: string }
email: { type: string, format: email }
role: { type: string, enum: [admin, user] }
responses:
'201':
description: User created.
content:
application/json:
schema: { $ref: '#/components/schemas/UserView' }
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'409':
description: Username already taken.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/users/{id}:
get:
tags: [users]
operationId: getUser
summary: Get user detail (admin only).
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Full user detail including linked MC accounts.
content:
application/json:
schema: { $ref: '#/components/schemas/UserDetail' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
patch:
tags: [users]
operationId: patchUser
summary: Edit a user (admin only, cannot patch self).
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
username: { type: string }
email: { type: string, format: email }
role: { type: string, enum: [admin, user] }
responses:
'200':
description: Updated user.
content:
application/json:
schema: { $ref: '#/components/schemas/UserView' }
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
description: Username conflict.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
delete:
tags: [users]
operationId: deleteUser
summary: Soft-delete a user — releases servers, revokes sessions (admin only, cannot delete self).
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: User soft-deleted.
content:
application/json:
schema:
type: object
required: [deleted]
properties:
deleted: { type: boolean, const: true }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/api/v1/users/{id}/disable:
post:
tags: [users]
operationId: disableUser
summary: Disable or re-enable a user (admin only, cannot disable self).
description: >-
Disabling a user additionally revokes every live session so the lockout is
immediate. Re-enabling simply clears the flag.
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [disabled]
properties:
disabled: { type: boolean }
responses:
'200':
description: Toggle applied.
content:
application/json:
schema:
type: object
required: [id, disabled]
properties:
id: { type: string }
disabled: { type: boolean }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/api/v1/users/{id}/quotas:
get:
tags: [users]
operationId: getQuotas
summary: Get a user's quotas (admin only).
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: The user's current quotas (null=unlimited).
content:
application/json:
schema: { $ref: '#/components/schemas/QuotaView' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
put:
tags: [users]
operationId: setQuotas
summary: Set a user's quotas (admin only).
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
max_servers: { type: integer, nullable: true }
max_cpu_milli: { type: integer, nullable: true }
max_memory_mb: { type: integer, nullable: true }
max_storage_gb: { type: integer, nullable: true }
responses:
'200':
description: Quotas updated.
content:
application/json:
schema: { $ref: '#/components/schemas/QuotaView' }
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
/api/v1/users/{id}/sessions:
get:
tags: [users]
operationId: listUserSessions
summary: List a user's live sessions (admin only).
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Live (unrevoked, unexpired) sessions, newest first.
content:
application/json:
schema:
type: object
required: [sessions]
properties:
sessions:
type: array
items: { $ref: '#/components/schemas/SessionView' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
delete:
tags: [users]
operationId: revokeUserSessions
summary: Revoke every live session of a user (admin only).
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: All sessions revoked.
content:
application/json:
schema:
type: object
required: [ok]
properties:
ok: { type: boolean, const: true }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
/api/v1/users/{id}/sessions/{hash}:
delete:
tags: [users]
operationId: revokeUserSession
summary: Revoke a single session of a user (admin only).
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: hash, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Session revoked.
content:
application/json:
schema:
type: object
required: [ok]
properties:
ok: { type: boolean, const: true }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
/api/v1/users/{id}/passkeys:
delete:
tags: [users]
operationId: unbindUserPasskeys
summary: Unbind every passkey of a user (owner only) — authenticator remediation.
description: >-
Severs a compromised or planted authenticator that would otherwise outlive a
session revoke. A complete remediation pairs this with revoking the user's
sessions (DELETE /users/{id}/sessions/{hash}): unbinding the credential alone
leaves the live hijacked session, and revoking sessions alone leaves a
re-enrollable credential. It is not a lockout — the account re-enters via the
email-OTP door or op-login and re-enrolls. Removing zero passkeys is a 200
no-op, not a 404.
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: All passkeys unbound (a no-op 200 when the user had none).
content:
application/json:
schema:
type: object
required: [ok]
properties:
ok: { type: boolean, const: true }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
/api/v1/users/{id}/links:
post:
tags: [users]
operationId: linkAccount
summary: Force-link a Minecraft UUID to a user, bypassing the code-verification flow (admin only).
description: >-
The UUID must not already be bound to a different user (409). Same (user, uuid)
pair is idempotent (200). When auth_source is omitted it is derived from the
UUID's version nibble exactly as on the mint path (v3 → thirdparty, else
mojang), so a force-linked thirdparty account keeps its reclaim-guard
protection.
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [mc_uuid]
properties:
mc_uuid: { type: string, format: uuid }
auth_source: { type: string, enum: [mojang, thirdparty], default: mojang }
responses:
'200':
description: UUID linked (or was already linked to this user).
content:
application/json:
schema:
type: object
required: [ok, mc_uuid, auth_source]
properties:
ok: { type: boolean, const: true }
mc_uuid: { type: string, format: uuid }
auth_source: { type: string }
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'409':
description: UUID is already linked to a different user.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/users/{id}/links/{mc_uuid}:
delete:
tags: [users]
operationId: unlinkAccount
summary: Remove a single Minecraft UUID binding from a user (admin only).
x-felis-face: [external]
x-felis-tier: owner
security: [{ accessJWT: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: mc_uuid, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
description: UUID unlinked.
content:
application/json:
schema:
type: object
required: [ok, mc_uuid]
properties:
ok: { type: boolean, const: true }
mc_uuid: { type: string, format: uuid }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: No linked account for this UUID.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/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'
'429':
description: >-
Resend requested before the cooldown elapsed (otp_resend_cooldown); or the
account spent its daily wrong-code budget (otp_account_locked, with
Retry-After); or the install-wide mail budget is spent
(mail_rate_limited, with Retry-After).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'502':
$ref: '#/components/responses/MailUndeliverable'
/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 otp_locked); 10 wrong codes in 24h,
counted across every code, lock the account's email-code door until the
window ends (429 otp_account_locked with Retry-After). 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 on this code (otp_locked), or the account's
daily wrong-code budget is spent (otp_account_locked, with Retry-After).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/account/email:
post:
tags: [account]
operationId: setEmail
summary: Record the caller's email WITHOUT verifying it (setup bootstrap, spec §B2).
description: >
Writes the supplied address to the authenticated principal's user row and
clears email_verified (already false for a fresh Owner). The setup bootstrap
has no SMTP, so the Owner cannot receive an emailed code; a later Settings/SMTP
flow proves control of the address via /account/email/verify.
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:
'200':
description: Email recorded (unverified).
content:
application/json:
schema:
type: object
required: [email]
properties:
email: { type: string, format: email }
'400':
description: A valid email is required.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
/api/v1/account/passkey/register/begin:
post:
tags: [account]
operationId: passkeyRegisterBegin
summary: Begin a passkey (WebAuthn) registration ceremony for the caller (spec §14, Phase 6 bind).
description: >
Mints a credential-creation challenge bound to the authenticated principal,
stashes the server-side ceremony state under a short TTL, and returns the
WebAuthn publicKey creation options for navigator.credentials.create(). The
challenge is never echoed by the client. Enrollment only — passkey login is a
deferred slice. 503 when the WebAuthn verifier is not configured on this instance.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
responses:
'200':
description: "WebAuthn credential-creation options (the publicKey document)."
content:
application/json:
schema:
type: object
description: Opaque WebAuthn PublicKeyCredentialCreationOptions, passed verbatim to the browser.
'401':
$ref: '#/components/responses/Unauthorized'
'503':
description: Passkey subsystem is not configured.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/account/passkey/register/finish:
post:
tags: [account]
operationId: passkeyRegisterFinish
summary: Finish a passkey registration ceremony and bind the credential (spec §14, Phase 6 bind).
description: >
Consumes the caller's live registration challenge (single-use), verifies the
authenticator's attestation against the server-stashed ceremony state, and
persists the public credential. A missing or expired ceremony is a 400; an
attestation that fails verification is a 400; a credential already bound to any
account is a 409. 503 when the WebAuthn verifier is not configured.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [attestation]
properties:
name: { type: string, description: Human nickname for the passkey (e.g. "My phone"). }
attestation:
type: object
description: The raw navigator.credentials.create() result the browser posts back.
responses:
'201':
description: Passkey bound.
content:
application/json:
schema: { $ref: '#/components/schemas/PasskeyCredential' }
'400':
description: No live ceremony, or the attestation could not be verified.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'409':
description: This passkey is already bound to an account.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
description: Passkey subsystem is not configured.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/account/passkey/credentials:
get:
tags: [account]
operationId: passkeyList
summary: List the passkeys the caller has bound (spec §14, Phase 6 bind).
description: >
Returns the authenticated principal's own bound passkeys, newest first, as
display projections (never the public key). Reading the credential list does
not need the WebAuthn verifier, so it succeeds even where begin/finish report 503.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
responses:
'200':
description: The caller's bound passkeys.
content:
application/json:
schema:
type: object
required: [credentials]
properties:
credentials:
type: array
items: { $ref: '#/components/schemas/PasskeyCredential' }
'401':
$ref: '#/components/responses/Unauthorized'
/api/v1/account/passkey/credentials/{id}:
delete:
tags: [account]
operationId: passkeyDelete
summary: Unbind one of the caller's passkeys (spec §14, Phase 6 bind).
description: >
Removes a passkey scoped to the authenticated principal, so a caller can only
unbind their OWN credential. An unknown or cross-user id is a 404; it never
silently no-ops as success.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
parameters:
- name: id
in: path
required: true
schema: { type: string }
description: The passkey row id (from the credential list).
responses:
'204':
description: Passkey unbound.
'401':
$ref: '#/components/responses/Unauthorized'
'404':
description: No such passkey for this caller.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/account/migrate:
get:
tags: [account]
operationId: migrateStatus
summary: Report the caller's active account-migration and where it is in the flow (spec §B3 inherit, web side).
description: >
Read-only. Returns the live migration whose source is the authenticated
principal, if any, so the web onboarding can resume the flow: whether a
confirmation step-up is still needed, which factor confirmed it, the named
target, and the one-time code's expiry once issued. active:false when the
caller has no live migration.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
responses:
'200':
description: The caller's live migration, or active:false.
content:
application/json:
schema:
type: object
required: [active]
properties:
active: { type: boolean }
state:
type: string
enum: [initiated, confirmed, code_issued]
description: Present only when active; a redeemed migration is terminal and not reported here.
target_user_id: { type: string }
confirm_factor:
type: string
enum: [passkey, email_otp]
code_expires_at: { type: string, format: date-time }
'401':
$ref: '#/components/responses/Unauthorized'
/api/v1/account/migrate/confirm/otp/start:
post:
tags: [account]
operationId: migrateConfirmOtpStart
summary: Send a fresh email one-time code to confirm control of the migrating source account (spec §B3 step-up).
description: >
Opens the email-OTP confirmation factor for the caller's initiated migration.
This is a FRESH step-up bound to the migrate purpose, never mere session
possession. If the account has ANY passkey enrolled, email-OTP is refused with
409 passkey_required — the stronger factor is forced. The code is delivered out
of band and never returned; requires a verified email on the account.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
responses:
'202':
description: Confirmation code minted and dispatched.
content:
application/json:
schema:
type: object
required: [sent, expires_at]
properties:
sent: { type: boolean, const: true }
expires_at: { type: string, format: date-time }
'401':
$ref: '#/components/responses/Unauthorized'
'404':
description: The caller has no initiated migration to confirm (no_migration).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: >
A passkey is enrolled so email-OTP is forbidden (passkey_required); the
migration is already confirmed (already_confirmed); or the account has no
email step-up factor (no_step_up_factor).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'429':
description: >-
Resend requested before the cooldown elapsed (otp_resend_cooldown), or the
account's daily wrong-code budget is spent (otp_account_locked, with
Retry-After); or the install-wide mail budget is spent
(mail_rate_limited, with Retry-After).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'502':
$ref: '#/components/responses/MailUndeliverable'
/api/v1/account/migrate/confirm/otp/verify:
post:
tags: [account]
operationId: migrateConfirmOtpVerify
summary: Redeem the email one-time code and confirm the migration (spec §B3 step-up).
description: >
Consumes the fresh migrate-purpose email code for the caller's initiated
migration and advances it to confirmed with confirm_factor email_otp. Too many
wrong attempts lock the code (429 otp_locked), and 10 wrong codes in 24h lock
the account's email-code door (429 otp_account_locked with Retry-After); an
unknown, expired, consumed, or mismatched code is a 400 invalid_code.
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: Migration confirmed.
content:
application/json:
schema:
type: object
required: [confirmed]
properties:
confirmed: { type: boolean, const: true }
'400':
description: Invalid or expired code (invalid_code).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'404':
description: The caller has no initiated migration to confirm (no_migration).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: The migration is already confirmed (already_confirmed).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'429':
description: >-
The code is locked after too many wrong attempts (otp_locked), or the
account's daily wrong-code budget is spent (otp_account_locked, with
Retry-After).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/account/migrate/confirm/passkey/begin:
post:
tags: [account]
operationId: migrateConfirmPasskeyBegin
summary: Begin a fresh passkey assertion to confirm control of the migrating source account (spec §B3 step-up).
description: >
Returns WebAuthn assertion request options for the caller's own enrolled
passkeys, bound to a fresh migrate-purpose challenge. This is the forced factor
whenever a passkey exists. The finish call proves the assertion and, exactly as
the login door does, runs the clone-signal (sign-count) check before confirming.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
responses:
'200':
description: WebAuthn assertion request options (PublicKeyCredentialRequestOptions) for navigator.credentials.get.
content:
application/json:
schema: { type: object, description: Opaque WebAuthn PublicKeyCredentialRequestOptions. }
'400':
description: The caller has no enrolled passkey (no_passkey).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'404':
description: The caller has no initiated migration to confirm (no_migration).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
/api/v1/account/migrate/confirm/passkey/finish:
post:
tags: [account]
operationId: migrateConfirmPasskeyFinish
summary: Finish the passkey assertion and confirm the migration (spec §B3 step-up).
description: >
Verifies the WebAuthn assertion against the fresh migrate-purpose challenge and,
like the login door, applies the authenticator sign-count clone check: a cloned
authenticator is rejected fail-closed (400 passkey_login_invalid) and audited. On
success the migration advances to confirmed with confirm_factor passkey.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [assertion]
properties:
assertion:
type: object
description: The navigator.credentials.get() PublicKeyCredential assertion.
responses:
'200':
description: Migration confirmed.
content:
application/json:
schema:
type: object
required: [confirmed]
properties:
confirmed: { type: boolean, const: true }
'400':
description: Assertion invalid, challenge stale, or a cloned authenticator was detected (passkey_login_invalid).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'404':
description: The caller has no initiated migration to confirm (no_migration).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
/api/v1/account/migrate/issue-code:
post:
tags: [account]
operationId: migrateIssueCode
summary: Name the target account and mint the one-time migration code (spec §B3 inherit).
description: >
For a confirmed migration, binds the named target account and mints a single
one-time code (only its hash is stored) that the target must redeem while logged
in AS that target — an intercepted code is useless to anyone else. The target
must exist and be neither disabled nor soft-deleted, and cannot be the source.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [target_user_id]
properties:
target_user_id: { type: string }
responses:
'201':
description: Code minted, bound to the named target.
content:
application/json:
schema:
type: object
required: [code, expires_at]
properties:
code: { type: string }
expires_at: { type: string, format: date-time }
'400':
description: >
The target is the source itself (invalid_target), does not exist
(target_not_found), or is disabled/retired (target_unavailable).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'404':
description: The caller has no migration to issue against (no_migration).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: The migration has not been confirmed by a step-up yet (not_confirmed).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/account/migrate/redeem:
post:
tags: [account]
operationId: migrateRedeem
summary: Redeem a migration code as the named target and inherit the source's owned servers (spec §B3 inherit).
description: >
The authenticated caller — who must be the target named at issue time — spends
the one-time code. In a single atomic step the source's owned servers are
re-pointed to the caller and the source account is retired (disabled and
soft-deleted), which also spends the code so it cannot be replayed. The caller
keeps its own in-game identity and credentials; only server ownership moves.
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: Migration redeemed; owned servers moved to the caller.
content:
application/json:
schema:
type: object
required: [migrated, servers_moved, servers]
properties:
migrated: { type: boolean, const: true }
servers_moved: { type: integer, format: int32 }
servers:
type: array
items: { type: string }
'400':
description: Unknown, expired, or already-spent code, or the caller is not the named target (invalid_code).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
/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'
'403':
description: >-
The per-user submission allowance is spent — too many of the
caller's submissions are awaiting review, or their stored-upload
budget is full (submission_quota_exceeded).
'429':
description: >-
A submission was created within the per-user cooldown window
(submission_cooldown).
'503':
$ref: '#/components/responses/ServiceUnavailable'
get:
tags: [submissions]
operationId: mySubmissions
summary: List the caller's own modpack submissions with each linked build's outcome (user-directed lane over §16).
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
responses:
'200':
description: >-
The caller's submissions, newest first; rows with a linked build
additionally carry build_status/build_error so the submitter can see
whether their build succeeded or failed (and why).
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'
/api/v1/me/submissions/{id}/context:
post:
tags: [submissions]
operationId: uploadSubmissionContext
summary: Upload the modpack build context for your own pending submission (user side; user-directed lane over §16).
description: >-
The request body IS the raw gzip build context (context.tar.gz) — not
JSON, not multipart — streamed to the platform-derived, id-namespaced
location Kaniko reads via --context. The submitter is taken from the
principal; a submission the caller does not own is reported as 404, so
this endpoint cannot upload to or probe another user's submission. Only a
pending_review submission accepts a context (409 otherwise); a wrong-format
or oversize body is rejected with 400, and an upload that would push the
caller past their per-user stored-context budget is refused with 403
before the excess is persisted. Returns 503 when the deployment's context
store has no implemented upload transport.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/gzip:
schema: { type: string, format: binary }
responses:
'200':
description: Context stored; the submission (unchanged) is returned.
content:
application/json:
schema: { $ref: '#/components/schemas/Submission' }
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: >-
The upload would exceed the caller's per-user stored-context budget
(submission_quota_exceeded).
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
'429':
description: >-
An upload was accepted within the per-user cooldown window
(submission_cooldown).
'503':
$ref: '#/components/responses/ServiceUnavailable'
/api/v1/me/submissions/{id}:
delete:
tags: [submissions]
operationId: withdrawSubmission
summary: Withdraw your own pending submission (user side; user-directed lane over §16).
description: >-
Retracts the caller's own submission while it is still pending review:
the row and its uploaded build context are deleted, freeing the pending
slot and the per-user storage budget for a fresh submission. A reviewed
submission is frozen (409 — its build may already be consuming the
context), and a submission the caller does not own reads back as 404, so
this endpoint cannot probe or clear another user's uploads.
x-felis-face: [external]
x-felis-tier: app
security: [{ accessJWT: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: The withdrawn submission, as it was before the deletion.
content:
application/json:
schema: { $ref: '#/components/schemas/Submission' }
'401':
$ref: '#/components/responses/Unauthorized'
'404':
$ref: '#/components/responses/NotFound'
'409':
description: Submission has already been reviewed and cannot be withdrawn.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'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
description: Push target under the internal registry (e.g. registry.felis.svc:5000/foo:1.0).
dockerfile:
type: string
description: >-
Audit archive of the recipe, recorded on the build row and shown in the
panel — the executed Dockerfile is the file named `Dockerfile` at the
root of the context tarball (Kaniko runs --dockerfile=Dockerfile), so
this field is never executed.
context_ref:
type: string
description: >-
Location of the uploaded gzip build context; its root must contain the
Dockerfile that gets executed.
base_image:
type: string
description: Resolved FROM, recorded for audit only — not a build gate.
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}/context:
get:
tags: [submissions]
operationId: downloadSubmissionContext
summary: Download a submission's uploaded build context (admin; user-directed lane over §16).
description: >-
The reviewer's read path to the artifact they are about to approve: the
executed Dockerfile lives inside this tarball (Kaniko runs the context's
root `Dockerfile`), so without it the human gate would be blind. Streams
the stored context.tar.gz verbatim with an attachment disposition — the
same bytes the build Pod fetches over the internal face. 404 when the
submission is unknown or has no uploaded context; 503 when the
deployment's context store has no implemented transport.
x-felis-face: [external]
x-felis-tier: admin
security: [{ accessJWT: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: The stored build context (gzip tarball), served as an attachment.
content:
application/gzip:
schema: { type: string, format: binary }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'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'
/api/v1/submissions/{id}:
delete:
tags: [submissions]
operationId: deleteSubmission
summary: Retire a submission outright — row and uploaded context (admin; user-directed lane over §16).
description: >-
Removes the submission and its uploaded build context, any status — the
lane's only lifecycle valve, and the path that reclaims a rejected or
consumed upload from the uploads PVC. The reviewer identity is recorded
in the audit event, not on the (now deleted) row. Deleting an approved
submission whose build is still running fails that build's context
fetch; the admin has explicitly chosen to retire the artifact.
x-felis-face: [external]
x-felis-tier: admin
security: [{ accessJWT: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: The deleted submission, as it was before the deletion.
content:
application/json:
schema: { $ref: '#/components/schemas/Submission' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'503':
$ref: '#/components/responses/ServiceUnavailable'