5882 lines
232 KiB
YAML
5882 lines
232 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.
|
||
# * Which operations a setup-lockdown session may still use (x-felis-setup-allowed)
|
||
# is checked the same way against the SetupAllowed flag in those tables.
|
||
# * The named response schemas are compared field by field with the Go structs
|
||
# the handlers encode (internal/api/openapi_parity_test.go).
|
||
# * Every request the handler tests send is held to this file once the package
|
||
# has run (internal/api/openapi_contract_test.go): the operation (or
|
||
# x-felis-common-responses) must list the status that came back, a JSON response
|
||
# must fit the schema for that status and carry no property it does not name,
|
||
# and the JSON request behind a 2xx must fit the requestBody. Statuses and
|
||
# bodies no test reaches are still hand-maintained.
|
||
#
|
||
# 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
|
||
|
||
# Answers any operation can give under the stated condition, declared once here
|
||
# instead of under every operation. when: any | internal (served on the internal
|
||
# face) | session (takes the session cookie) | setup-locked (takes the session
|
||
# cookie and is not x-felis-setup-allowed) | json-body (takes a JSON requestBody).
|
||
# code, when set, is the error code that answer carries.
|
||
x-felis-common-responses:
|
||
- status: 500
|
||
when: any
|
||
response: { $ref: '#/components/responses/InternalError' }
|
||
- status: 403
|
||
when: internal
|
||
code: wrong_caller
|
||
response: { $ref: '#/components/responses/WrongCaller' }
|
||
- status: 403
|
||
when: session
|
||
code: forbidden
|
||
response: { $ref: '#/components/responses/StaffOnlyHost' }
|
||
- status: 403
|
||
when: setup-locked
|
||
code: setup_required
|
||
response: { $ref: '#/components/responses/SetupRequired' }
|
||
- status: 413
|
||
when: json-body
|
||
code: too_large
|
||
response: { $ref: '#/components/responses/TooLarge' }
|
||
- status: 415
|
||
when: json-body
|
||
code: unsupported_media_type
|
||
response: { $ref: '#/components/responses/UnsupportedMediaType' }
|
||
|
||
info:
|
||
title: felis-api
|
||
version: 4.1.0
|
||
description: |
|
||
Control plane for the Felis Minecraft orchestration platform. The same binary
|
||
exposes an internal face (per-caller service tokens, for velocity / backend
|
||
callbacks, never Zero Trust) and an external face (the felis_session cookie, for
|
||
people and the panel; Cloudflare Access, when present, is enforced at the edge).
|
||
Admin-tier external operations additionally require a staff session on the
|
||
operator console host. See `x-felis-face` / `x-felis-tier` on each operation.
|
||
|
||
Behaviour every operation shares, and so not repeated under each:
|
||
|
||
* Every response carries `X-Request-Id` (a well-formed inbound one is kept),
|
||
`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`,
|
||
`Referrer-Policy: no-referrer` and `Content-Security-Policy: default-src 'none'`.
|
||
`Strict-Transport-Security` is added when the request came through the TLS
|
||
edge (`X-Forwarded-Proto: https`).
|
||
* A path no operation serves is `404 not_found`; a path served under other
|
||
methods is `405 method_not_allowed` with an `Allow` header.
|
||
* A POST/PUT/PATCH/DELETE a browser sends from another site (`Sec-Fetch-Site`
|
||
`same-site` or `cross-site`, or an `Origin` whose host is not the request's)
|
||
is `403 cross_site`, before authentication. Callers that send neither header
|
||
(the plugins, scripts) are unaffected.
|
||
* A JSON body over 1 MiB is `413 too_large`. A request body must keep arriving:
|
||
after 30 s it has to average 16 KiB/s or the connection is closed.
|
||
* Event streams (the console and build logs) tag each line with `id:` (unix
|
||
seconds); an EventSource that reconnects with `Last-Event-ID` within the hour
|
||
resumes from that second instead of the tailed backlog. The server re-checks
|
||
the caller every minute and ends the stream with `event: revoked` once the
|
||
session or the access is gone; a stream also closes after 30 minutes and on
|
||
server shutdown, and the client simply reconnects.
|
||
|
||
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. Session-cookie auth on every non-public /api/v1 route;
|
||
admin-tier routes additionally require a staff session on the operator
|
||
console host.
|
||
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 per-caller token (internal face). Each machine holds its own —
|
||
velocity (felis-service-token), limbo (felis-limbo-token), build
|
||
(felis-build-token), ops (felis-ops-token) — and each operation lists the
|
||
callers it serves in x-felis-callers. A genuine token for a caller the
|
||
operation does not list is refused with 403 wrong_caller. `felis
|
||
rotate-token <caller>` replaces one.
|
||
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. It is the external face's only credential: Cloudflare
|
||
Access, when the install sits behind it, is enforced at the edge and
|
||
felis-api does not read the Access JWT. Admin-tier operations
|
||
additionally require a staff session on the operator console host.
|
||
|
||
responses:
|
||
NoContent:
|
||
description: Success, no body.
|
||
InternalError:
|
||
description: An unexpected failure (internal); the details are in the server log under the request id.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
WrongCaller:
|
||
description: A genuine service token for a caller this operation does not serve (wrong_caller).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
StaffOnlyHost:
|
||
description: A session of a player (not admin or owner) on the operator console host, refused before any handler (forbidden).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
SetupRequired:
|
||
description: The session belongs to an account still in first-run setup, which may use only the x-felis-setup-allowed operations until it has a durable sign-in (setup_required).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
TooLarge:
|
||
description: The JSON body is over 1 MiB (too_large).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
UnsupportedMediaType:
|
||
description: A body sent with a Content-Type other than application/json (unsupported_media_type).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
InsufficientStorage:
|
||
description: The store this writes to is full — the backup archive (backup_store_full), the world volume (volume_full) or the upload area (uploads_full).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
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' }
|
||
Reauthed:
|
||
description: This session is reauthed until the returned time.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [ok, until]
|
||
properties:
|
||
ok: { type: boolean, const: true }
|
||
until: { type: string, format: date-time }
|
||
ReauthRequired:
|
||
description: >
|
||
reauth_required: this change adds, removes or moves a way into the account,
|
||
and the account has a passkey or a verified email, so the session must have
|
||
proven one of them within the last 5 minutes. Signing in by passkey, email
|
||
code, op-login or the setup token counts; a bind-code sign-in does not.
|
||
GET /api/v1/account/reauth lists the factors that can give the proof, then
|
||
retry the change.
|
||
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' }
|
||
MailUnavailable:
|
||
description: >
|
||
This install has no [smtp] relay (code mail_unavailable), so no code was minted
|
||
or sent. The public doors answer it before resolving the address, so it is the
|
||
same for every address. Sign in with a passkey, or have the operator configure
|
||
email with felis setup.
|
||
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, idleStopSeconds]
|
||
properties:
|
||
name: { type: string }
|
||
subdomain: { type: string }
|
||
phase: { $ref: '#/components/schemas/Phase' }
|
||
ready: { type: boolean }
|
||
autostartPolicy:
|
||
type: string
|
||
enum: [ownerOnly, public, allowlist]
|
||
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 }
|
||
displayName: { type: string }
|
||
image: { type: string }
|
||
javaMemory: { type: string }
|
||
storageSize: { type: string }
|
||
cpu: { type: string }
|
||
idleStopSeconds:
|
||
type: integer
|
||
format: int32
|
||
description: Seconds the server may sit empty before idle auto-stop scales it down; 0 when it never idles out (off, RCON disabled, or a system server).
|
||
playerCountUnknown:
|
||
type: boolean
|
||
description: Present and true while the operator cannot read the player count over RCON; idle auto-stop waits until it can.
|
||
|
||
FleetServer:
|
||
description: One row of the fleet-wide admin read (internal/api/handlers_user.go fleetServerView).
|
||
allOf:
|
||
- $ref: '#/components/schemas/ServerInfo'
|
||
- type: object
|
||
required: [owned, claimable]
|
||
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.
|
||
owned:
|
||
type: boolean
|
||
description: >-
|
||
True when the caller claimed this server, decided by account id so an
|
||
owner without an email is still recognized.
|
||
claimable:
|
||
type: boolean
|
||
description: >-
|
||
True for a live, unclaimed, non-system server, the same rule the claim
|
||
route enforces. False whenever ownership is unknown.
|
||
ownerUnknown:
|
||
type: boolean
|
||
description: >-
|
||
Present and true when the owner lookup failed, so an absent owner says
|
||
nothing about whether the server is claimed.
|
||
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.
|
||
|
||
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 }
|
||
displayName:
|
||
type: string
|
||
description: From live CRD status; omitted when unset or the cluster is unreachable.
|
||
desiredState:
|
||
type: string
|
||
enum: [Running, Stopped]
|
||
description: Owned rows only, from live CRD status.
|
||
autostartPolicy:
|
||
type: string
|
||
enum: [ownerOnly, public, allowlist]
|
||
description: Owned rows only, from live CRD status.
|
||
playerCountUnknown:
|
||
type: boolean
|
||
description: Owned rows only. Present and true while the operator cannot read the player count, so a stop may disconnect players.
|
||
|
||
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
|
||
description: inactive_15d (idle reclaim), manual (on demand) or pre_restore (the safety snapshot in front of a restore).
|
||
status: { type: string }
|
||
created_at: { type: string, format: date-time }
|
||
expires_at: { type: string, format: date-time }
|
||
corrupt:
|
||
type: boolean
|
||
description: The archive failed a read-back (a checksum, gzip or tar error) and cannot be restored. Omitted when false.
|
||
verified_at:
|
||
type: string
|
||
format: date-time
|
||
description: The archive's last read-back that matched. Omitted until the first.
|
||
skipped_entries:
|
||
type: integer
|
||
description: World entries the archive could not hold (symbolic links, devices, sockets). Omitted when zero.
|
||
|
||
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 }
|
||
context_digest:
|
||
type: string
|
||
description: Lowercase hex sha256 of the context tarball the build was pinned to (the audit record). Omitted when the request named none.
|
||
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
|
||
format: date-time
|
||
description: Omitted 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.
|
||
context_sha256:
|
||
type: string
|
||
description: Lowercase hex sha256 of the uploaded context tarball; omitted until one is uploaded. Approval must name it.
|
||
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
|
||
format: date-time
|
||
description: Omitted 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]
|
||
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
|
||
format: date-time
|
||
description: Present only when soft-deleted.
|
||
linked_accounts:
|
||
type: array
|
||
description: Omitted when the user has no linked Minecraft account.
|
||
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, as the account holder and an admin see it
|
||
(internal/api/repo.go SessionView).
|
||
required: [token_hash, created_at, expires_at, last_seen_at, user_agent, client_ip]
|
||
properties:
|
||
token_hash:
|
||
type: string
|
||
description: The sha-256 of the session cookie; the id the revoke routes take.
|
||
created_at: { type: string, format: date-time }
|
||
expires_at: { type: string, format: date-time }
|
||
last_seen_at:
|
||
type: string
|
||
format: date-time
|
||
description: >-
|
||
When the session last authenticated a request, recorded at most once a
|
||
minute. A staff session idle for 30 minutes stops authenticating and
|
||
leaves the list.
|
||
user_agent:
|
||
type: string
|
||
description: The browser's User-Agent at sign-in (at most 256 bytes; empty when none was sent).
|
||
client_ip:
|
||
type: string
|
||
description: The address the sign-in came from (empty when unknown).
|
||
revoked_at:
|
||
type: string
|
||
format: date-time
|
||
description: Present only once the session is revoked.
|
||
current:
|
||
type: boolean
|
||
description: >-
|
||
On the holder's own list only, true on the session the request came in
|
||
on. Absent otherwise.
|
||
|
||
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
|
||
x-felis-callers: [velocity]
|
||
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 a staff session on the operator console host; the image must be whitelisted. An image in the
|
||
platform registry is stored pinned to the digest its tag names at creation
|
||
(name:tag@sha256:…), so a later push over the tag never moves the server;
|
||
400 image_not_in_registry when the registry lacks the tag, 503
|
||
registry_unavailable when it cannot be asked.
|
||
x-felis-face: [external]
|
||
x-felis-tier: admin
|
||
security: [{ sessionCookie: [] }]
|
||
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
|
||
x-felis-callers: [velocity]
|
||
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
|
||
x-felis-callers: [build]
|
||
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
|
||
x-felis-callers: [velocity]
|
||
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
|
||
x-felis-callers: [velocity]
|
||
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' }
|
||
'503':
|
||
description: The node is at its running-server cap (at_capacity).
|
||
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
|
||
x-felis-callers: [velocity]
|
||
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
|
||
x-felis-callers: [velocity]
|
||
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
|
||
x-felis-callers: [velocity]
|
||
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
|
||
x-felis-callers: [velocity, limbo]
|
||
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
|
||
x-felis-callers: [velocity, limbo]
|
||
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
|
||
x-felis-callers: [velocity]
|
||
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
|
||
x-felis-callers: [velocity]
|
||
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'
|
||
'409':
|
||
description: The linked account holds a staff role and is never reclaimed (protected_admin).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
|
||
/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
|
||
x-felis-callers: [velocity, limbo]
|
||
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
|
||
x-felis-callers: [velocity]
|
||
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, client_ip, created_at]
|
||
properties:
|
||
request_id: { type: string }
|
||
username: { type: string }
|
||
email: { type: string }
|
||
client_ip:
|
||
type: string
|
||
description: Where start was called from; empty on requests from before this was recorded.
|
||
created_at: { type: string, format: date-time }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
|
||
/api/v1/internal/op-login/{id}:
|
||
get:
|
||
tags: [account-internal]
|
||
operationId: opLoginShow
|
||
summary: Show an in-game admin whose op.console login a request is (spec §B).
|
||
description: >
|
||
Internal-only. velocity's /felis web op approve <code> reads this and shows the
|
||
admin the account, its address, and when and from where the sign-in was started,
|
||
then asks them to confirm by typing the account name (see approve). The
|
||
approver's online-mode UUID gets the same check as approve (a linked admin or
|
||
owner, else 403 not_admin), since the command runs for any player and a staff
|
||
address must not be readable by one. A request that is unknown, expired,
|
||
approved or consumed is 404.
|
||
x-felis-face: [internal]
|
||
x-felis-tier: service
|
||
x-felis-callers: [velocity]
|
||
security: [{ serviceToken: [] }]
|
||
parameters:
|
||
- { name: id, in: path, required: true, schema: { type: string } }
|
||
- { name: approver_uuid, in: query, required: true, schema: { type: string, format: uuid } }
|
||
responses:
|
||
'200':
|
||
description: The pending request and where it was started.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [request_id, username, email, client_ip, user_agent, created_at, expires_at]
|
||
properties:
|
||
request_id: { type: string }
|
||
username: { type: string }
|
||
email: { type: string }
|
||
client_ip:
|
||
type: string
|
||
description: Where start was called from; empty on requests from before this was recorded.
|
||
user_agent:
|
||
type: string
|
||
description: The browser's User-Agent at start, up to 256 bytes; may be empty.
|
||
created_at: { type: string, format: date-time }
|
||
expires_at: { type: string, format: date-time }
|
||
'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/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 <code> <username>, and the account
|
||
name they typed after seeing the request (GET /api/v1/internal/op-login/{id}).
|
||
The API resolves the UUID to a linked admin or owner account (else 403
|
||
not_admin), requires the typed name to match the request's account ignoring
|
||
case (else 409 op_login_mismatch, audited, request left pending) 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
|
||
x-felis-callers: [velocity]
|
||
security: [{ serviceToken: [] }]
|
||
parameters:
|
||
- { name: id, in: path, required: true, schema: { type: string } }
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [approver_uuid, username]
|
||
properties:
|
||
approver_uuid: { type: string, format: uuid }
|
||
username:
|
||
type: string
|
||
description: The account name the admin typed to confirm whose sign-in this is.
|
||
responses:
|
||
'200':
|
||
description: The vouch was recorded; the request is now approved.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [approved, username, email]
|
||
properties:
|
||
approved: { type: boolean, const: true }
|
||
username: { type: string }
|
||
email: { type: string }
|
||
'400':
|
||
description: approver_uuid and username are 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' }
|
||
'409':
|
||
description: The typed name is not the request's account (op_login_mismatch).
|
||
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
|
||
x-felis-callers: [ops]
|
||
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), a restore, backup or file write already holds its world volume (maintenance_in_progress), or the file changed since expect_sha256 was read (file_changed).
|
||
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: [{ sessionCookie: [] }]
|
||
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' }
|
||
'503':
|
||
description: The node is at its running-server cap (at_capacity).
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
- name: Last-Event-ID
|
||
in: header
|
||
required: false
|
||
description: The id of the last line received; resumes the stream from that second (within the hour).
|
||
schema: { type: string }
|
||
responses:
|
||
'200':
|
||
description: >-
|
||
An event stream of log lines (`id:` + `data:` per line, `:` comments as
|
||
keep-alives), ended by `event: revoked` when access is withdrawn.
|
||
content:
|
||
text/event-stream:
|
||
schema: { type: string }
|
||
'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 not running.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'429':
|
||
description: This session already holds as many console streams as it may (too_many_streams).
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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 a server; the full record for its owner and staff.
|
||
description: >-
|
||
Anyone signed in may ask. The owner and staff get the whole projection;
|
||
anyone else gets what the game's own server list shows: name, subdomain,
|
||
displayName, phase, ready, playersOnline and playersMax.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
responses:
|
||
'200':
|
||
description: The server's status projection (trimmed for non-owners).
|
||
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). email_otp is offered only when the install has
|
||
a mail relay, passkey only when a verifier is wired and the account has a
|
||
credential. An empty array means no verified account, or none of its methods
|
||
is available on this install.
|
||
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; the per-address sign-in
|
||
rate limit bounds probing. Each begin stashes a ceremony of its own beside the
|
||
account's other live ones, so a begin by anyone who knows the address never
|
||
cancels its owner's. One network (an IPv4 address or IPv6 /48) holds at most 32
|
||
live login challenges (429 too_many_challenges past that).
|
||
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: >-
|
||
This network already holds 32 live passkey login challenges (too_many_challenges);
|
||
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
|
||
live login challenge whose value the assertion signed (response.clientDataJSON)
|
||
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 for the signed value, 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: [owner, admin, user] }
|
||
'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. One client is
|
||
bounded by the per-address sign-in rate limit (429 rate_limited), one network
|
||
(an IPv4 address or IPv6 /48) to 32 live challenges, and the table by a hard
|
||
global cap of 16384 (both 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: >-
|
||
This network already holds 32 live discoverable challenges, or the store is at
|
||
its global cap (too_many_challenges); 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: [owner, admin, user] }
|
||
'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).
|
||
One code is mailed per recipient per minute: a start inside that window gets
|
||
the same 202 (expires_at of the live code) and mails nothing. A start never
|
||
cancels the codes already mailed; the three newest live codes all work, and
|
||
signing in with one spends the rest.
|
||
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: >-
|
||
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'
|
||
'503':
|
||
$ref: '#/components/responses/MailUnavailable'
|
||
|
||
/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: [owner, admin, user] }
|
||
'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. One code is mailed
|
||
per recipient per minute: a staff start inside that window opens a real request
|
||
but mails nothing, and the code already in the inbox finishes it. A start never
|
||
cancels the codes already mailed (the three newest live codes all work). 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: >-
|
||
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'
|
||
'503':
|
||
$ref: '#/components/responses/MailUnavailable'
|
||
|
||
/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: [owner, admin, user] }
|
||
'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: [owner, admin, user] }
|
||
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
|
||
x-felis-setup-allowed: true
|
||
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: [owner, admin, user] }
|
||
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 on the operator console host). 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
|
||
x-felis-setup-allowed: true
|
||
security: [{ sessionCookie: [] }]
|
||
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
|
||
on the operator console host (Principal.IsAdmin()).
|
||
is_owner:
|
||
type: boolean
|
||
description: >-
|
||
True only for the Owner principal on the operator console host
|
||
(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'
|
||
'503':
|
||
description: The account settings could not be read (auth_unavailable).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
|
||
/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
|
||
x-felis-setup-allowed: true
|
||
security: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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: { $ref: '#/components/schemas/FleetServer' }
|
||
'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).
|
||
description: >-
|
||
One page of the present backups in the caller's scope, newest first.
|
||
server narrows the page to one server's backups inside that scope; it
|
||
never widens it.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: server, in: query, required: false, schema: { type: string }, description: 'Only this server''s backups' }
|
||
- { 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 visible backups plus how many match.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [backups, total]
|
||
properties:
|
||
backups:
|
||
type: array
|
||
items: { $ref: '#/components/schemas/BackupView' }
|
||
total: { type: integer }
|
||
'400':
|
||
$ref: '#/components/responses/BadRequest'
|
||
'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).
|
||
description: >-
|
||
By default the restore starts with a safety snapshot: a backup of the
|
||
data volume as it is now (reason "pre_restore", the newest 3 kept per
|
||
server), and the restore Job starts only once that backup has
|
||
succeeded. If the snapshot fails the restore is given up and the world
|
||
is left as it was. GET /servers/{name}/jobs shows the snapshot as a
|
||
backup job whose then_restore says what became of the restore. The
|
||
world stays locked from the request until the restore Job finishes.
|
||
Pass safety_snapshot false to restore straight away.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ sessionCookie: [] }]
|
||
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.
|
||
safety_snapshot:
|
||
type: boolean
|
||
default: true
|
||
description: Back up the current world before overwriting it.
|
||
responses:
|
||
'202':
|
||
description: Restore started (after the safety snapshot when safety_snapshot is true).
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [name, status, backup_id, safety_snapshot]
|
||
properties:
|
||
name: { type: string }
|
||
status: { type: string, const: restoring }
|
||
backup_id: { type: string }
|
||
safety_snapshot:
|
||
type: boolean
|
||
description: Whether a safety snapshot runs first. False when the request turned it off, or when this install cannot take one.
|
||
'400':
|
||
description: Malformed server name (bad_name) or request body.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'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), a restore, backup or file write already holds its world volume (maintenance_in_progress), a restore of another backup is still running (restore_in_progress), or the chosen backup failed a read-back (backup_corrupt).
|
||
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: [{ sessionCookie: [] }]
|
||
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 }
|
||
'400':
|
||
description: Malformed server name (bad_name).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'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' }
|
||
'429':
|
||
description: Another backup of this server started within the cooldown (backup_cooldown).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
'507':
|
||
$ref: '#/components/responses/InsufficientStorage'
|
||
|
||
# -------------------------------------------------- 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: [{ sessionCookie: [] }]
|
||
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 }
|
||
then_restore:
|
||
type: string
|
||
enum: [pending, started, abandoned]
|
||
description: >-
|
||
Set on a restore's safety snapshot (a backup job):
|
||
pending until the restore behind it starts, or
|
||
abandoned with then_restore_reason saying why (its
|
||
English wording is in message).
|
||
then_restore_reason:
|
||
type: string
|
||
enum: [snapshot_failed, not_configured, server_gone, server_started, restore_busy]
|
||
description: Why an abandoned chain was given up.
|
||
restore_backup_id:
|
||
type: string
|
||
description: The backup the chained restore extracts.
|
||
'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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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, sha256]
|
||
properties:
|
||
path: { type: string }
|
||
content: { type: string, format: byte, description: Base64-encoded file bytes. }
|
||
sha256:
|
||
type: string
|
||
pattern: '^[0-9a-f]{64}$'
|
||
description: >-
|
||
SHA-256 of the file as stored (before the rcon.password redaction in
|
||
server.properties). Send it back as expect_sha256 on the next write.
|
||
'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' }
|
||
'507':
|
||
$ref: '#/components/responses/InsufficientStorage'
|
||
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. The replacement is atomic (a synced temporary sibling renamed
|
||
over the file, keeping its mode), so a failed write leaves the old file whole.
|
||
With expect_sha256 the write lands only if the file still has that hash;
|
||
otherwise 409 file_changed. Audited as file.write.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ sessionCookie: [] }]
|
||
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. }
|
||
expect_sha256:
|
||
type: string
|
||
pattern: '^[0-9a-f]{64}$'
|
||
description: >-
|
||
The sha256 a read returned. When present, the write is refused with
|
||
409 file_changed if the file has changed (or been deleted) since.
|
||
Omit it to write unconditionally.
|
||
responses:
|
||
'200':
|
||
description: File written.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [path, status, sha256]
|
||
properties:
|
||
path: { type: string }
|
||
status: { type: string, const: written }
|
||
sha256: { type: string, pattern: '^[0-9a-f]{64}$', description: SHA-256 of the bytes 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' }
|
||
'507':
|
||
description: The world volume has no room for the write (volume_full); the file is unchanged.
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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':
|
||
description: >-
|
||
Not an owner (forbidden); a change to the caller's own role (self_protected); or a role change on the owner account (owner_protected), which only the host's break-glass console (sudo felis breakGlass) may make.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'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: [{ sessionCookie: [] }]
|
||
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':
|
||
description: >-
|
||
Not an owner (forbidden); the caller's own account (self_protected); or the owner account (owner_protected), which only the host's break-glass console (sudo felis breakGlass) may remove.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'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: [{ sessionCookie: [] }]
|
||
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':
|
||
description: >-
|
||
Not an owner (forbidden); the caller's own account (self_protected); or disabling the owner account (owner_protected). Re-enabling the owner is allowed.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'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: [{ sessionCookie: [] }]
|
||
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'
|
||
'404':
|
||
description: Unknown user.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
put:
|
||
tags: [users]
|
||
operationId: setQuotas
|
||
summary: Set a user's quotas (admin only).
|
||
x-felis-face: [external]
|
||
x-felis-tier: owner
|
||
security: [{ sessionCookie: [] }]
|
||
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'
|
||
'404':
|
||
description: Unknown user.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
|
||
/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: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: id, in: path, required: true, schema: { type: string } }
|
||
responses:
|
||
'200':
|
||
description: Live sessions, most recently seen 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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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'
|
||
'404':
|
||
description: >-
|
||
session_not_found — the hash is not a live session of this user (another
|
||
user's, already ended, or unknown). Nothing is revoked.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
|
||
/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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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'
|
||
'404':
|
||
description: Unknown user.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'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: [{ sessionCookie: [] }]
|
||
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
|
||
x-felis-setup-allowed: true
|
||
security: [{ sessionCookie: [] }]
|
||
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
|
||
x-felis-setup-allowed: true
|
||
security: [{ sessionCookie: [] }]
|
||
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. Once the account has a passkey or a verified email, the
|
||
session must have reauthed within 5 minutes (403 reauth_required).
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
x-felis-setup-allowed: true
|
||
security: [{ sessionCookie: [] }]
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [email]
|
||
properties:
|
||
email: { type: string, format: email }
|
||
responses:
|
||
'202':
|
||
description: Code minted and mailed.
|
||
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'
|
||
'403':
|
||
$ref: '#/components/responses/ReauthRequired'
|
||
'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'
|
||
'503':
|
||
$ref: '#/components/responses/MailUnavailable'
|
||
|
||
/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. When the
|
||
new address replaces a different verified one, every other session of the
|
||
caller is signed out: sign-in codes now go to the new address, so a session
|
||
opened through the old one ends; the old address is mailed a notice with the
|
||
new one masked. A verified code also counts as a reauth for this session.
|
||
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
|
||
x-felis-setup-allowed: true
|
||
security: [{ sessionCookie: [] }]
|
||
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'
|
||
'409':
|
||
description: Another account already proved this address (email_taken); the code is not consumed.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'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. Clearing a
|
||
verified address strips a factor, so once the account has one the session
|
||
must have reauthed within 5 minutes (403 reauth_required).
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
x-felis-setup-allowed: true
|
||
security: [{ sessionCookie: [] }]
|
||
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'
|
||
'403':
|
||
$ref: '#/components/responses/ReauthRequired'
|
||
|
||
/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. Once the account has a passkey or a
|
||
verified email, the session must have reauthed within 5 minutes (403
|
||
reauth_required). 503 when the WebAuthn verifier is not configured on this
|
||
instance.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
x-felis-setup-allowed: true
|
||
security: [{ sessionCookie: [] }]
|
||
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'
|
||
'403':
|
||
$ref: '#/components/responses/ReauthRequired'
|
||
'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. The verified email is mailed a notice, and the ceremony
|
||
counts as a reauth for this session. 503 when the WebAuthn verifier is not
|
||
configured.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
x-felis-setup-allowed: true
|
||
security: [{ sessionCookie: [] }]
|
||
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
|
||
x-felis-setup-allowed: true
|
||
security: [{ sessionCookie: [] }]
|
||
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. The account's only passkey cannot be removed while
|
||
its email is unverified (409 last_passkey): it is then the account's only
|
||
durable way in. Removing a passkey signs out every other session of the
|
||
caller, so a session opened with that passkey ends with it, and mails the
|
||
verified email a notice. The session must have reauthed within 5 minutes
|
||
(403 reauth_required).
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
x-felis-setup-allowed: true
|
||
security: [{ sessionCookie: [] }]
|
||
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'
|
||
'403':
|
||
$ref: '#/components/responses/ReauthRequired'
|
||
'404':
|
||
description: No such passkey for this caller.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'409':
|
||
description: last_passkey — this is the only passkey and the email is unverified.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
|
||
/api/v1/account/reauth:
|
||
get:
|
||
tags: [account]
|
||
operationId: reauthStatus
|
||
summary: Say whether a passkey or email change needs a reauth first, and how to give one.
|
||
description: >
|
||
needed is true when the account has a passkey or a verified email and this
|
||
session has not proven one within the last 5 minutes. until is when the
|
||
current proof stops counting. factors lists the ways this caller can
|
||
reauth, best first: passkey (an enrolled passkey), email (a player's
|
||
verified address), sign_in (an operator signs out and back in through
|
||
op-login or a passkey).
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
x-felis-setup-allowed: true
|
||
security: [{ sessionCookie: [] }]
|
||
responses:
|
||
'200':
|
||
description: Where the caller stands.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [needed, factors]
|
||
properties:
|
||
needed: { type: boolean }
|
||
until: { type: string, format: date-time }
|
||
factors:
|
||
type: array
|
||
items: { type: string, enum: [passkey, email, sign_in] }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
|
||
/api/v1/account/reauth/passkey/begin:
|
||
post:
|
||
tags: [account]
|
||
operationId: reauthPasskeyBegin
|
||
summary: Begin a passkey assertion that reauths this session.
|
||
description: >
|
||
Returns WebAuthn assertion request options over the caller's own passkeys,
|
||
bound to a fresh reauth-purpose challenge.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
x-felis-setup-allowed: true
|
||
security: [{ sessionCookie: [] }]
|
||
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), or no browser session to mark (no_session).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
|
||
/api/v1/account/reauth/passkey/finish:
|
||
post:
|
||
tags: [account]
|
||
operationId: reauthPasskeyFinish
|
||
summary: Finish the passkey assertion and mark this session reauthed for 5 minutes.
|
||
description: >
|
||
Verifies the assertion against the reauth challenge with the login door's
|
||
clone check (a cloned authenticator is 400 passkey_login_invalid).
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
x-felis-setup-allowed: true
|
||
security: [{ sessionCookie: [] }]
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [assertion]
|
||
properties:
|
||
assertion:
|
||
type: object
|
||
description: The navigator.credentials.get() PublicKeyCredential assertion.
|
||
responses:
|
||
'200':
|
||
$ref: '#/components/responses/Reauthed'
|
||
'400':
|
||
description: Assertion invalid, challenge stale, or a cloned authenticator (passkey_login_invalid); no browser session (no_session).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
|
||
/api/v1/account/reauth/email/start:
|
||
post:
|
||
tags: [account]
|
||
operationId: reauthEmailStart
|
||
summary: Mail a reauth code to the caller's verified address.
|
||
description: >
|
||
For players with a verified email. Operators reauth with a passkey or by
|
||
signing in again (403 staff_reauth).
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
x-felis-setup-allowed: true
|
||
security: [{ sessionCookie: [] }]
|
||
responses:
|
||
'202':
|
||
description: 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 }
|
||
'400':
|
||
description: No browser session to mark (no_session).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
description: Operators cannot reauth by email (staff_reauth).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'409':
|
||
description: The account has no verified email (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'
|
||
'503':
|
||
$ref: '#/components/responses/MailUnavailable'
|
||
|
||
/api/v1/account/reauth/email/verify:
|
||
post:
|
||
tags: [account]
|
||
operationId: reauthEmailVerify
|
||
summary: Redeem the reauth code and mark this session reauthed for 5 minutes.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
x-felis-setup-allowed: true
|
||
security: [{ sessionCookie: [] }]
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [code]
|
||
properties:
|
||
code: { type: string }
|
||
responses:
|
||
'200':
|
||
$ref: '#/components/responses/Reauthed'
|
||
'400':
|
||
description: Invalid or expired code (invalid_code), or no browser session (no_session).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
description: Operators cannot reauth by email (staff_reauth).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'409':
|
||
description: The account has no verified email (no_step_up_factor).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'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/sessions:
|
||
get:
|
||
tags: [account]
|
||
operationId: listMySessions
|
||
summary: List the caller's own live sessions, marking the one this request came in on.
|
||
description: >
|
||
Every device signed in to the caller's account, most recently seen first,
|
||
with the one this request came in on marked current.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ sessionCookie: [] }]
|
||
responses:
|
||
'200':
|
||
description: The caller's live sessions.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [sessions]
|
||
properties:
|
||
sessions:
|
||
type: array
|
||
items: { $ref: '#/components/schemas/SessionView' }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
|
||
/api/v1/account/sessions/{hash}:
|
||
delete:
|
||
tags: [account]
|
||
operationId: revokeMySession
|
||
summary: Sign out one of the caller's sessions.
|
||
description: >
|
||
Scoped to the caller: a hash that is not one of the caller's live sessions is
|
||
a 404 whoever it belongs to. Revoking the session the request came in on is
|
||
a sign-out; the cookie is cleared and signed_out is true.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: hash, in: path, required: true, schema: { type: string } }
|
||
responses:
|
||
'200':
|
||
description: Session revoked.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [ok, signed_out]
|
||
properties:
|
||
ok: { type: boolean, const: true }
|
||
signed_out:
|
||
type: boolean
|
||
description: True when the revoked session was the caller's own, which is now signed out.
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'404':
|
||
description: session_not_found — not a live session of the caller.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
|
||
/api/v1/account/sessions/revoke-others:
|
||
post:
|
||
tags: [account]
|
||
operationId: revokeMyOtherSessions
|
||
summary: Sign out every session of the caller except the one making this request.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ sessionCookie: [] }]
|
||
responses:
|
||
'200':
|
||
description: Other sessions revoked.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [revoked]
|
||
properties:
|
||
revoked:
|
||
type: integer
|
||
description: How many sessions were signed out.
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
|
||
/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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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'
|
||
'503':
|
||
$ref: '#/components/responses/MailUnavailable'
|
||
|
||
/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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: status, in: query, required: false, schema: { type: string, enum: [pending_review, approved, rejected] } }
|
||
- { name: query, in: query, required: false, schema: { type: string }, description: 'Part of the id, the submitter or the display name; case-insensitive' }
|
||
- { 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: >-
|
||
One page of 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, total, counts]
|
||
properties:
|
||
submissions:
|
||
type: array
|
||
items: { $ref: '#/components/schemas/Submission' }
|
||
total:
|
||
type: integer
|
||
description: How many submissions match status and query in all.
|
||
counts:
|
||
type: object
|
||
description: >-
|
||
How many of the scope's submissions sit in each status,
|
||
whatever status and query say.
|
||
required: [pending_review, approved, rejected]
|
||
properties:
|
||
pending_review: { type: integer }
|
||
approved: { type: integer }
|
||
rejected: { type: integer }
|
||
'400':
|
||
$ref: '#/components/responses/BadRequest'
|
||
'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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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:
|
||
displayName: { type: string }
|
||
autostartPolicy: { type: string }
|
||
image:
|
||
type: string
|
||
description: >-
|
||
Re-admitted against the whitelist (a pinned name:tag@sha256:… ref is
|
||
admitted by its name:tag) and pinned like create does. A pin equal to
|
||
the current image is no change; any other needs confirmImageChange.
|
||
confirmImageChange:
|
||
type: boolean
|
||
description: >-
|
||
Acknowledges that the new image opens the world with its Minecraft
|
||
version, whose chunk upgrades the old one cannot read. Without it an
|
||
image that would move the server is refused with 409
|
||
image_change_unconfirmed. The audit row records image_from/image_to.
|
||
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 }
|
||
cpuRequest: { type: string }
|
||
memory: { type: string }
|
||
memoryRequest: { type: string }
|
||
idleStopSeconds:
|
||
type: integer
|
||
format: int32
|
||
description: Idle auto-stop. 0 turns it off; otherwise the server stops after this many seconds with nobody online (60–86400, else 400 bad_idle_stop).
|
||
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'
|
||
'409':
|
||
$ref: '#/components/responses/Conflict'
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
|
||
/api/v1/images/build:
|
||
get:
|
||
tags: [images]
|
||
operationId: listBuilds
|
||
summary: List builds (admin), newest first.
|
||
description: >-
|
||
One page of the build history across every admin. Rows are read as stored
|
||
(the reconcile loop advances them; GET /images/build/{id} reconciles one on
|
||
demand) and leave out the Dockerfile, which GET /images/build/{id} returns.
|
||
x-felis-face: [external]
|
||
x-felis-tier: admin
|
||
security: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: query, in: query, required: false, schema: { type: string }, description: 'Build id or status (exact), or part of the image ref; case-insensitive' }
|
||
- { 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 builds plus how many match the query.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [builds, total]
|
||
properties:
|
||
builds:
|
||
type: array
|
||
items: { $ref: '#/components/schemas/Build' }
|
||
total: { type: integer }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
$ref: '#/components/responses/Forbidden'
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: id, in: path, required: true, schema: { type: string } }
|
||
- name: Last-Event-ID
|
||
in: header
|
||
required: false
|
||
description: The id of the last line received; resumes the stream from that second (within the hour).
|
||
schema: { type: string }
|
||
responses:
|
||
'200':
|
||
description: >-
|
||
An event stream of build log lines (`id:` + `data:` per line), ended by
|
||
`event: revoked` when the caller is no longer staff.
|
||
content:
|
||
text/event-stream:
|
||
schema: { type: string }
|
||
'400':
|
||
description: Malformed build id (bad_request). The id becomes a label-selector value, so it is refused before any lookup.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: status, in: query, required: false, schema: { type: string, enum: [pending_review, approved, rejected] } }
|
||
- { name: query, in: query, required: false, schema: { type: string }, description: 'Part of the id, the submitter or the display name; case-insensitive' }
|
||
- { 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: One page of every user's submissions, newest first.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [submissions, total, counts]
|
||
properties:
|
||
submissions:
|
||
type: array
|
||
items: { $ref: '#/components/schemas/Submission' }
|
||
total:
|
||
type: integer
|
||
description: How many submissions match status and query in all.
|
||
counts:
|
||
type: object
|
||
description: >-
|
||
How many of the scope's submissions sit in each status,
|
||
whatever status and query say.
|
||
required: [pending_review, approved, rejected]
|
||
properties:
|
||
pending_review: { type: integer }
|
||
approved: { type: integer }
|
||
rejected: { type: integer }
|
||
'400':
|
||
$ref: '#/components/responses/BadRequest'
|
||
'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: [{ sessionCookie: [] }]
|
||
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' }
|
||
'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}/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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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: [{ sessionCookie: [] }]
|
||
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'
|