8644 lines
352 KiB
YAML
8644 lines
352 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: |
|
||
Staff on the operator console may manage the reserved login/lobby system
|
||
services through existing management routes, without player ownership rows.
|
||
Creating and claiming these reserved names remain prohibited. System patches
|
||
keep public autostart and idle stop disabled. Customization is persisted as
|
||
felis-experience.json using the existing file API. Staff may read and save
|
||
this startup-only config while system services run; restart to apply it.
|
||
|
||
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, restore and export (external face, app tier).
|
||
- name: schedules
|
||
description: Scheduled tasks of your own servers (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 -yes <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, offsite, manual]
|
||
description: offsite is the bundle `felis offsite sync` takes after copying world archives; a restore records the newest bundle on disk, which may be one.
|
||
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.
|
||
servers_error:
|
||
type: string
|
||
description: >
|
||
Why the bundle lacks the MinecraftServer objects (the cluster did not
|
||
answer the export), when it does. A restore from it brings back the
|
||
database but no servers.
|
||
daily_at:
|
||
type: string
|
||
format: date-time
|
||
description: >
|
||
When the newest daily bundle (felis-db-backup.timer) in dir was
|
||
written, as of this record; absent when dir held none. Equal to
|
||
at when this record is a daily one.
|
||
stale:
|
||
type: boolean
|
||
description: >
|
||
True when there is no record, no daily bundle, or the newest daily
|
||
bundle is older than max_age_seconds. A newer manual, pre-migrate or
|
||
off-site bundle leaves it as it is: the daily timer has still stopped.
|
||
max_age_seconds:
|
||
type: integer
|
||
format: int64
|
||
description: The freshness limit (26h), shared with `felis db check` and FelisDBBackupStale.
|
||
|
||
UpdateReport:
|
||
type: object
|
||
description: >
|
||
The newest version check the host recorded (internal/api/handlers_updates.go
|
||
updateReportView; the record is internal/updates StatusReport, written by
|
||
`felis update --record`, which felis-update-check.timer runs daily).
|
||
required: [report, stale, max_age_seconds]
|
||
properties:
|
||
report:
|
||
type: object
|
||
nullable: true
|
||
description: >
|
||
Null until the first check has been recorded (internal/updates
|
||
StatusReport).
|
||
required: [checked_at, felis, components]
|
||
properties:
|
||
checked_at: { type: string, format: date-time }
|
||
felis: { type: string, description: Version of the felis binary that ran the check. }
|
||
components:
|
||
type: array
|
||
items: { $ref: '#/components/schemas/UpdateComponent' }
|
||
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).
|
||
|
||
UpdateComponent:
|
||
type: object
|
||
description: >
|
||
One component's line. available has a newer stable release (latest);
|
||
unknown means the release feed could not be read and unreadable that the
|
||
installed version could not, both with error; pinned never changes by policy.
|
||
required: [name, state]
|
||
properties:
|
||
name: { type: string }
|
||
current: { type: string, description: Installed version; omitted when unreadable. }
|
||
latest: { type: string, description: The newer stable release; present only when state is available. }
|
||
state: { type: string, enum: [current, available, unknown, unreadable, pinned] }
|
||
selector: { type: string, description: 'The `felis update --<selector>` flag that prints how to apply it; omitted when none.' }
|
||
note: { type: string, description: What the release lookup learned beyond the version; omitted when none. }
|
||
error: { type: string, description: Why a version is missing; omitted otherwise. }
|
||
|
||
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]
|
||
|
||
ExecutionNode:
|
||
type: object
|
||
required: [name, role, ready, approved, addresses, architecture]
|
||
properties:
|
||
name: { type: string }
|
||
role: { type: string }
|
||
ready: { type: boolean }
|
||
approved: { type: boolean }
|
||
addresses: { type: array, items: { type: string } }
|
||
architecture: { type: string }
|
||
WorldMigration:
|
||
type: object
|
||
required: [id, server, state, stage, sourceNode, targetNode, sourcePVC, targetPVC, backup, started, updated, switched, attempt]
|
||
properties:
|
||
id: { type: string }
|
||
server: { type: string }
|
||
state: { type: string, enum: [backing_up, restoring, switching, succeeded, failed] }
|
||
stage: { type: string }
|
||
sourceNode: { type: string }
|
||
targetNode: { type: string }
|
||
sourcePVC: { type: string }
|
||
targetPVC: { type: string }
|
||
backup:
|
||
type: object
|
||
required: [ref, size, sha256]
|
||
properties:
|
||
ref: { type: string }
|
||
size: { type: integer, format: int64 }
|
||
sha256: { type: string }
|
||
started: { type: string, format: date-time }
|
||
updated: { type: string, format: date-time }
|
||
switched: { type: boolean }
|
||
attempt: { type: integer }
|
||
error: { type: string }
|
||
|
||
ServerInfo:
|
||
type: object
|
||
description: Status projection of one server (internal/api/cluster.go ServerInfo).
|
||
required: [name, subdomain, phase, ready, playersOnline, playersMax, idleStopSeconds]
|
||
properties:
|
||
nodeName: { type: string, description: Execution node; legacy servers report the observed node. }
|
||
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
|
||
description: The JVM heap (-Xmx) derived from the memory limit.
|
||
memory:
|
||
type: string
|
||
description: The pod memory limit (the server's memory as an admin picks it), a Kubernetes quantity such as 4Gi.
|
||
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.
|
||
legacyForwarding:
|
||
type: boolean
|
||
description: >-
|
||
Present and true when the CR carries the label felis.lolicon.best/forwarding=legacy.
|
||
The proxy then forwards this server's players BungeeCord-style in the handshake
|
||
address instead of modern forwarding (Felis-Legacy Velocity fork only).
|
||
autoRestarts:
|
||
type: integer
|
||
format: int32
|
||
description: Present when non-zero; how often the operator recreated the pod of this start after it timed out (at most 3).
|
||
startGaveUp:
|
||
type: boolean
|
||
description: >-
|
||
Present and true for a Failed server no automatic retry will bring up: its
|
||
start timed out with the retries spent, or its spec is invalid. A Failed
|
||
server without it is still in its restart backoff and may come up on its own.
|
||
reaperExempt:
|
||
type: boolean
|
||
description: Present and true for a system server the reaper never touches; it cannot be given up or deleted.
|
||
retiring:
|
||
allOf: [{ $ref: '#/components/schemas/RetireState' }]
|
||
description: >-
|
||
Owner and staff only. Present while the owner has given the server up or an
|
||
admin is deleting it; the reaper carries that out on its next run.
|
||
|
||
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 with no pending deletion,
|
||
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). Staff can manage them through the existing server routes;
|
||
players see them read-only. Creating, claiming and deleting these
|
||
reserved names remain prohibited.
|
||
|
||
RetireState:
|
||
type: object
|
||
description: >-
|
||
A pending retirement (internal/api/repo.go RetireState): the owner gave the
|
||
server up, or with delete an admin is deleting it. The reaper carries it out
|
||
on its next daily run: it archives the world as a released backup, deletes
|
||
the world volume and releases the server, and for a deletion also removes
|
||
it. Until then the server stays stopped and cannot be woken or claimed.
|
||
required: [requested_at, delete]
|
||
properties:
|
||
requested_at: { type: string, format: date-time }
|
||
delete: { type: boolean }
|
||
|
||
AllowlistEntry:
|
||
type: object
|
||
description: >-
|
||
One player on a server's wake allowlist (internal/api/repo.go
|
||
AllowlistEntry): someone who joined the server, and so may wake it under
|
||
autostartPolicy=allowlist unless the owner took that away.
|
||
required: [mc_uuid, added_at, can_wake]
|
||
properties:
|
||
mc_uuid: { type: string, format: uuid }
|
||
username:
|
||
type: string
|
||
description: >-
|
||
The live Felis account the UUID is linked to; omitted when there is
|
||
none (the account was closed or the link removed).
|
||
added_at: { type: string, format: date-time, description: The player's first join. }
|
||
can_wake:
|
||
type: boolean
|
||
description: False once the owner or an admin took the wake right away.
|
||
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.
|
||
autoRestarts:
|
||
type: integer
|
||
format: int32
|
||
description: Owned rows only. How often the operator recreated the pod of this start after it timed out.
|
||
startGaveUp:
|
||
type: boolean
|
||
description: Owned rows only. Present and true for a Failed server no automatic retry will bring up; waking it from the panel starts it over.
|
||
retiring:
|
||
allOf: [{ $ref: '#/components/schemas/RetireState' }]
|
||
description: Owned rows only. Present while the server is given up or being deleted.
|
||
|
||
Schedule:
|
||
type: object
|
||
description: >-
|
||
One scheduled task of a server (internal/api/schedules.go Schedule). It runs
|
||
at minute_of_day on the weekdays, or with every_minutes set at every multiple
|
||
of it since midnight on those days, in timezone.
|
||
required: [id, server, label, action, command, every_minutes, minute_of_day, weekdays, timezone, warn_minutes, enabled, next_run_at, run_state, last_run_at, last_result, last_detail, created_by, created_at]
|
||
properties:
|
||
id: { type: integer, format: int64 }
|
||
server: { type: string }
|
||
label: { type: string, description: Free text naming the task; may be empty. }
|
||
action:
|
||
type: string
|
||
enum: [command, restart, stop, start, backup]
|
||
description: >-
|
||
backup of a running server stops it, takes a backup (pruned with the daily
|
||
restore points, [archive] scheduled_keep) and starts it again; of a stopped
|
||
server it leaves the server stopped.
|
||
command: { type: string, description: The console command of a command task, without a slash; empty otherwise. }
|
||
every_minutes:
|
||
type: integer
|
||
enum: [0, 15, 30, 60, 120, 180, 240, 360, 480, 720]
|
||
description: 0 runs once a day at minute_of_day. A restart, stop, start or backup repeats at most every 60 minutes.
|
||
minute_of_day: { type: integer, minimum: 0, maximum: 1439, description: Minutes after local midnight; 0 when every_minutes is set. }
|
||
weekdays: { type: integer, minimum: 1, maximum: 127, description: 'Bitmask of the days it runs on: bit 0 Sunday to bit 6 Saturday.' }
|
||
timezone: { type: string, description: IANA zone the times are in, e.g. Asia/Shanghai. }
|
||
warn_minutes:
|
||
type: integer
|
||
enum: [0, 1, 5, 10, 15, 30]
|
||
description: How long before a restart, stop or backup the players on the server are told (say); 0 for none, and always 0 for a command or start.
|
||
enabled: { type: boolean }
|
||
next_run_at: { type: string, format: date-time, nullable: true, description: Null while disabled. }
|
||
run_state:
|
||
type: string
|
||
enum: ['', claimed, stopping, backing_up, starting]
|
||
description: What a run in progress is doing; empty when none is.
|
||
last_run_at: { type: string, format: date-time, nullable: true }
|
||
last_result:
|
||
type: string
|
||
enum: ['', ok, skipped, failed, missed]
|
||
description: >-
|
||
How the last run ended; empty before the first and during a run. missed is a
|
||
run felis-api was down for, dropped once it was 10 minutes late.
|
||
last_detail: { type: string, description: What happened, in English (a command's reply, or why the run was skipped or failed). }
|
||
created_by: { type: string }
|
||
created_at: { type: string, format: date-time }
|
||
|
||
ScheduleInput:
|
||
type: object
|
||
required: [action, weekdays, timezone]
|
||
additionalProperties: false
|
||
properties:
|
||
label: { type: string, maxLength: 64 }
|
||
action: { type: string, enum: [command, restart, stop, start, backup] }
|
||
command: { type: string, maxLength: 1024, description: Required for a command task and refused for the others. One line; a leading slash is dropped. }
|
||
every_minutes: { type: integer, enum: [0, 15, 30, 60, 120, 180, 240, 360, 480, 720] }
|
||
minute_of_day: { type: integer, minimum: 0, maximum: 1439 }
|
||
weekdays: { type: integer, minimum: 1, maximum: 127 }
|
||
timezone: { type: string }
|
||
warn_minutes: { type: integer, enum: [0, 1, 5, 10, 15, 30] }
|
||
enabled: { type: boolean, description: Default true. }
|
||
|
||
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), released (the world of a server its owner gave up or an admin deleted), manual (on demand), pre_restore (the safety snapshot in front of a restore) or scheduled (the daily restore point felis-api takes of a world played since its last one, once the server stops).
|
||
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.
|
||
|
||
ExportTicket:
|
||
type: object
|
||
description: >-
|
||
An export just started (internal/api/exports.go exportTicketView). The
|
||
ticket opens GET /exports/{ticket} and its download for the user who
|
||
started it, and nobody else.
|
||
required: [ticket, state, filename]
|
||
properties:
|
||
ticket: { type: string, description: 64 hex characters. }
|
||
state: { type: string, const: pending }
|
||
filename: { type: string, description: What the download saves as. }
|
||
|
||
FileUploadSession:
|
||
type: object
|
||
description: Where an upload session stands (internal/api/handlers_fileops.go fileSessionView).
|
||
required: [id, path, size, received, part_max_bytes, parts]
|
||
properties:
|
||
id: { type: string, description: 32 hex characters. }
|
||
path: { type: string, description: Where the file lands, relative to the world root. }
|
||
size: { type: integer, format: int64, description: The file's length. }
|
||
received: { type: integer, format: int64, description: Bytes here so far; the next part starts here. }
|
||
part_max_bytes: { type: integer, format: int64, description: The most one part may carry. }
|
||
parts:
|
||
type: array
|
||
description: >-
|
||
The parts taken so far, in order, each with the SHA-256 it arrived
|
||
with. A client resuming from a file it still holds hashes the same
|
||
ranges and starts over when one differs.
|
||
items:
|
||
type: object
|
||
required: [size, sha256]
|
||
properties:
|
||
size: { type: integer, format: int64 }
|
||
sha256: { type: string, pattern: '^[0-9a-f]{64}$' }
|
||
|
||
StartFileOp:
|
||
type: object
|
||
properties:
|
||
overwrite: { type: boolean, description: Replace files already there. }
|
||
|
||
FileOp:
|
||
type: object
|
||
description: One background upload or extraction (internal/api/handlers_fileops.go fileOpView).
|
||
required: [id, op, path, state, started_at, done, total]
|
||
properties:
|
||
id: { type: string }
|
||
op: { type: string, enum: [upload, unzip] }
|
||
path: { type: string, description: The file landed, or the archive extracted. }
|
||
state: { type: string, enum: [running, succeeded, failed] }
|
||
started_at: { type: string, format: date-time }
|
||
finished_at: { type: string, format: date-time, description: Omitted while it runs. }
|
||
done: { type: integer, format: int64, description: Bytes landed or extracted so far; 0 before the first report. }
|
||
total: { type: integer, format: int64, description: Bytes in all; 0 before the first report. }
|
||
files: { type: integer, description: Files an extraction wrote. Omitted otherwise. }
|
||
bytes: { type: integer, format: int64, description: Bytes an extraction wrote. Omitted otherwise. }
|
||
error: { $ref: '#/components/schemas/FileOpError' }
|
||
|
||
FileOpError:
|
||
type: object
|
||
description: >-
|
||
Why an op failed (internal/api/handlers_fileops.go fileOpError). code is
|
||
what the synchronous file routes answer for the same refusal
|
||
(file_exists, volume_full, file_changed, not_found, bad_path), an
|
||
extraction's own (archive_invalid, archive_unsafe, archive_symlink,
|
||
type_conflict), or job_failed for a Job that ended without saying why.
|
||
required: [code, message]
|
||
properties:
|
||
code: { type: string }
|
||
message: { type: string }
|
||
entry: { type: string, description: The archive entry refused, or the path it collides with. }
|
||
conflicts:
|
||
type: array
|
||
items: { type: string }
|
||
description: On file_exists from an extraction, the first 200 files it would replace, sorted.
|
||
conflict_count: { type: integer, description: How many files it would replace in all. }
|
||
need: { type: integer, format: int64, description: On volume_full, the bytes needed. }
|
||
avail: { type: integer, format: int64, description: On volume_full, the bytes free; left out when none are. }
|
||
|
||
ExportStatus:
|
||
type: object
|
||
description: Where an export stands (internal/api/exports.go exportStatusView).
|
||
required: [state]
|
||
properties:
|
||
state:
|
||
type: string
|
||
enum: [pending, ready, failed]
|
||
description: >-
|
||
pending while its Job starts; ready once the archive waits for the
|
||
download, which must begin within 90 seconds; failed when the Job
|
||
died first.
|
||
message:
|
||
type: string
|
||
description: Why a failed export's Job died. Omitted otherwise.
|
||
|
||
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.
|
||
|
||
BuildScan:
|
||
type: object
|
||
description: >-
|
||
What a build's scan gate kept (GET /api/v1/images/build/{id}/scan): its
|
||
verdict under the policy it ran with, the listed findings, and which full
|
||
documents can be downloaded.
|
||
required: [build_id, scanned_at, summary, has_report, has_sbom]
|
||
properties:
|
||
build_id: { type: string }
|
||
scanned_at: { type: string, format: date-time }
|
||
summary: { $ref: '#/components/schemas/ScanSummary' }
|
||
has_report:
|
||
type: boolean
|
||
description: The full Trivy JSON report is kept (GET .../scan/report).
|
||
has_sbom:
|
||
type: boolean
|
||
description: The CycloneDX SBOM is kept (GET .../sbom).
|
||
|
||
ScanSummary:
|
||
type: object
|
||
description: The verdict scan-gate reached on one Trivy report (internal/build ScanSummary).
|
||
required: [policy, blocked, packages, counts, blocking_counts, findings]
|
||
properties:
|
||
policy: { $ref: '#/components/schemas/ScanPolicy' }
|
||
blocked:
|
||
type: boolean
|
||
description: A finding blocked the image, so it was never pushed.
|
||
packages:
|
||
type: integer
|
||
description: Packages Trivy found in the image.
|
||
counts:
|
||
type: object
|
||
description: Every finding by severity (CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN); a severity with none is absent.
|
||
additionalProperties: { type: integer }
|
||
blocking_counts:
|
||
type: object
|
||
description: The findings the policy blocks on, by severity.
|
||
additionalProperties: { type: integer }
|
||
findings:
|
||
type: array
|
||
description: Blocking findings first, then the rest, most severe first; at most 100. The downloadable report lists all of them.
|
||
items: { $ref: '#/components/schemas/ScanFinding' }
|
||
omitted:
|
||
type: array
|
||
description: Documents (report, sbom) too large to keep with the build. Omitted when none.
|
||
items: { type: string, enum: [report, sbom] }
|
||
|
||
ScanPolicy:
|
||
type: object
|
||
description: Which findings block an image ([registry] scan_fail_on / scan_fail_unfixed / scan_accept).
|
||
required: [fail_on, fail_unfixed]
|
||
properties:
|
||
fail_on:
|
||
type: array
|
||
items: { type: string, enum: [CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN] }
|
||
fail_unfixed:
|
||
type: boolean
|
||
description: A vulnerability with no fixed release blocks too.
|
||
accept:
|
||
type: array
|
||
items: { type: string }
|
||
description: Vulnerability ids and secret rule ids accepted as known risks; findings under them never block. Omitted when none.
|
||
|
||
ScanFinding:
|
||
type: object
|
||
description: One vulnerability or leaked secret (internal/build ScanFinding).
|
||
required: [id, kind, severity, target, blocking]
|
||
properties:
|
||
id:
|
||
type: string
|
||
description: The CVE/GHSA id, or the secret rule id.
|
||
kind: { type: string, enum: [vulnerability, secret] }
|
||
severity: { type: string, enum: [CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN] }
|
||
package: { type: string }
|
||
installed: { type: string }
|
||
fixed:
|
||
type: string
|
||
description: The first release that fixes it. Omitted when none exists.
|
||
target:
|
||
type: string
|
||
description: The file or layer Trivy found it in.
|
||
title: { type: string }
|
||
blocking: { type: boolean }
|
||
accepted:
|
||
type: boolean
|
||
description: The policy accepts this id, so it never blocks. Omitted when false.
|
||
|
||
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 }
|
||
|
||
ContextUploadProgress:
|
||
type: object
|
||
required: [received, part_max_bytes, max_context_bytes]
|
||
properties:
|
||
received:
|
||
type: integer
|
||
format: int64
|
||
description: Bytes staged so far; the next part starts here.
|
||
part_max_bytes:
|
||
type: integer
|
||
format: int64
|
||
description: The most one part may carry.
|
||
max_context_bytes:
|
||
type: integer
|
||
format: int64
|
||
description: The most the whole context may reach ([registry] context_max_bytes).
|
||
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). An absent or null field is unlimited; 0 grants none of that resource.
|
||
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:
|
||
/api/v1/nodes:
|
||
get:
|
||
operationId: executionNodes
|
||
tags: [admin-servers]
|
||
summary: List execution nodes (administrator).
|
||
x-felis-face: [external]
|
||
x-felis-tier: admin
|
||
security: [{ sessionCookie: [] }]
|
||
responses:
|
||
'200':
|
||
description: Accepted operation or current state.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [nodes]
|
||
properties:
|
||
nodes: { type: array, items: { $ref: '#/components/schemas/ExecutionNode' } }
|
||
'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/servers/{name}/migrations:
|
||
get:
|
||
operationId: latestWorldMigrationStatus
|
||
tags: [admin-servers]
|
||
summary: Read durable migration progress.
|
||
x-felis-face: [external]
|
||
x-felis-tier: admin
|
||
security: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
responses:
|
||
'200':
|
||
description: Accepted operation or current state.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/WorldMigration'
|
||
'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'
|
||
|
||
post:
|
||
operationId: startWorldMigration
|
||
tags: [admin-servers]
|
||
summary: Migrate an already stopped world; persistent lock survives controller restart.
|
||
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
|
||
additionalProperties: false
|
||
required: [targetNode]
|
||
properties:
|
||
targetNode: { type: string }
|
||
responses:
|
||
'202':
|
||
description: Accepted operation or current state.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/WorldMigration'
|
||
'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/servers/{name}/migrations/{id}:
|
||
get:
|
||
operationId: worldMigrationStatus
|
||
tags: [admin-servers]
|
||
summary: Read durable migration progress.
|
||
x-felis-face: [external]
|
||
x-felis-tier: admin
|
||
security: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
- { name: id, in: path, required: true, schema: { type: string } }
|
||
responses:
|
||
'200':
|
||
description: Accepted operation or current state.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/WorldMigration'
|
||
'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/servers/{name}/migrations/{id}/retry:
|
||
post:
|
||
operationId: retryWorldMigration
|
||
tags: [admin-servers]
|
||
summary: Retry a failed migration, retaining the source and stopped state.
|
||
x-felis-face: [external]
|
||
x-felis-tier: admin
|
||
security: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
- { name: id, in: path, required: true, schema: { type: string } }
|
||
responses:
|
||
'202':
|
||
description: Accepted operation or current state.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/WorldMigration'
|
||
'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'
|
||
|
||
# ----------------------------------------------------------------- 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:
|
||
nodeName: { type: string, description: Required approved worker in distributed mode; administrator only. }
|
||
name: { type: string }
|
||
subdomain: { type: string }
|
||
displayName:
|
||
type: string
|
||
maxLength: 64
|
||
description: Trimmed; at most 64 characters, all visible ones or spaces (400 bad_display_name otherwise).
|
||
image: { type: string }
|
||
memory: { type: string }
|
||
storage: { type: string }
|
||
autostartPolicy: { type: string }
|
||
resources:
|
||
type: object
|
||
properties:
|
||
cpu: { type: string }
|
||
cpuRequest: { type: string }
|
||
memory: { type: string }
|
||
memoryRequest: { 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/file-uploads/{id}:
|
||
get:
|
||
tags: [files]
|
||
operationId: internalFileUpload
|
||
summary: Stream one staged file upload to the Job landing it (one-time bearer token).
|
||
description: >-
|
||
PUT /api/v1/servers/{name}/files/upload stages the body on felis-api's disk
|
||
and creates a Job to land it in the world volume; the Job fetches the bytes
|
||
here. The Job holds no service token, so the route is public on the internal
|
||
face and the bearer token minted with the upload is the whole check. The
|
||
token opens its upload once. An unknown id, a wrong or missing token and a
|
||
spent token are all the same 404, so the route says nothing about which
|
||
uploads exist. An upload session committed through POST
|
||
…/files/uploads/{id}/commit is fetched here the same way, under the
|
||
session id; it stays staged until its Job reports the file landed
|
||
(DELETE), so a Job that failed at any point can be committed again.
|
||
x-felis-face: [internal]
|
||
x-felis-tier: public
|
||
security: []
|
||
parameters:
|
||
- { name: id, in: path, required: true, schema: { type: string } }
|
||
- name: Authorization
|
||
in: header
|
||
required: true
|
||
description: Bearer followed by the token minted with the upload.
|
||
schema: { type: string }
|
||
responses:
|
||
'200':
|
||
description: The staged bytes, verbatim, with their Content-Length.
|
||
content:
|
||
application/octet-stream:
|
||
schema: { type: string, format: binary }
|
||
'404':
|
||
$ref: '#/components/responses/NotFound'
|
||
delete:
|
||
tags: [files]
|
||
operationId: internalFileUploadLanded
|
||
summary: The Job reports a staged upload landed (the same one-time bearer token).
|
||
description: >-
|
||
Sent once the file is in place. An upload session is then dropped from
|
||
felis-api's disk; a single-request upload goes when its request ends in
|
||
any case. Only the token of the session's latest commit is taken. As for
|
||
the fetch, every refusal is the same 404.
|
||
x-felis-face: [internal]
|
||
x-felis-tier: public
|
||
security: []
|
||
parameters:
|
||
- { name: id, in: path, required: true, schema: { type: string } }
|
||
- name: Authorization
|
||
in: header
|
||
required: true
|
||
description: Bearer followed by the token the Job fetched the upload with.
|
||
schema: { type: string }
|
||
responses:
|
||
'204':
|
||
$ref: '#/components/responses/NoContent'
|
||
'404':
|
||
$ref: '#/components/responses/NotFound'
|
||
|
||
/api/v1/internal/exports/{id}:
|
||
put:
|
||
tags: [backups]
|
||
operationId: internalExportUpload
|
||
summary: Hand one export's archive over for download (one-time bearer token).
|
||
description: >-
|
||
The export Job PUTs the archive or file here, always chunked, with its
|
||
size as X-Felis-Export-Length when it knows it and, once the body has
|
||
ended, the SHA-256 of all it sent as the Content-Digest trailer
|
||
(sha-256=:<base64>:). felis-api holds the last bytes back from the
|
||
browser until the bytes it received number and hash as the Job said, so
|
||
a body changed on the way, or one without the trailer, ends the
|
||
download short and the browser reports it failed. The Job holds no service token, so the route
|
||
is public on the internal face and the bearer token minted with the
|
||
export is the whole check; an unknown id, a wrong or missing token and a
|
||
token already used are all the same 404. The request then waits, body
|
||
unread, up to 90 seconds for the owner's browser to open the download,
|
||
and is read at the browser's pace: the 16 KiB/s minimum body rate does
|
||
not apply, and the body fails only after 2 minutes without a byte. It
|
||
answers once the download has ended.
|
||
x-felis-face: [internal]
|
||
x-felis-tier: public
|
||
security: []
|
||
parameters:
|
||
- { name: id, in: path, required: true, schema: { type: string } }
|
||
- name: Authorization
|
||
in: header
|
||
required: true
|
||
description: Bearer followed by the token minted with the export.
|
||
schema: { type: string }
|
||
- name: X-Felis-Export-Length
|
||
in: header
|
||
required: false
|
||
description: The body's length in bytes, when the Job knows it; the download then carries it as Content-Length.
|
||
schema: { type: integer, format: int64, minimum: 0 }
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/gzip:
|
||
schema: { type: string, format: binary }
|
||
responses:
|
||
'204':
|
||
$ref: '#/components/responses/NoContent'
|
||
'400':
|
||
description: X-Felis-Export-Length is not a byte count (bad_request); the token is not spent.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'404':
|
||
$ref: '#/components/responses/NotFound'
|
||
'409':
|
||
description: The backup did not match the sha256 recorded when it was written, and the download was aborted (backup_corrupt).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'410':
|
||
description: Nobody opened the download within 90 seconds, the browser left before the archive ended, or what arrived did not number or hash as the Job declared, so the download was cut off (export_expired).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
|
||
/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. A server whose
|
||
start failed with its automatic retries spent answers 409 start_failed: a
|
||
join never resets the retry budget, so velocity queues no one for it.
|
||
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: >-
|
||
Nothing was started. maintenance_in_progress: a restore, backup or
|
||
file write holds the server's world volume. start_failed: the last
|
||
start failed and its automatic retries are spent (ServerInfo.startGaveUp);
|
||
the server stays down until a person starts it from the panel.
|
||
server_retiring: the owner gave the server up or an admin is deleting it;
|
||
it stays down until the reaper archives it. world_reclaiming: the idle
|
||
reaper is archiving the world; afterwards the server is released with an
|
||
empty world.
|
||
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 } }
|
||
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/player/menu-access/{mc_uuid}:
|
||
get:
|
||
tags: [lobby]
|
||
operationId: internalMenuAccess
|
||
summary: What one player may start, for every user server — the lobby menu's per-player verdicts.
|
||
description: >
|
||
One call per menu open; each tile's live state stays in the shared
|
||
per-server projection (…/menu). A server that is up is open to every
|
||
linked player, so the lobby shows Join there whatever the verdict. The
|
||
verdicts follow the internal wake's gates without the transient ones
|
||
(cooldown, running-server cap): `retiring` (given up or being deleted),
|
||
`start_failed` (automatic restarts spent), `owner` (the player's own),
|
||
`wake` (the autostart policy admits the player), `owner_only`, and
|
||
`allowlist` (the player is not on the list).
|
||
x-felis-face: [internal]
|
||
x-felis-tier: service
|
||
x-felis-callers: [velocity]
|
||
security: [{ serviceToken: [] }]
|
||
parameters:
|
||
- { name: mc_uuid, in: path, required: true, schema: { type: string, format: uuid } }
|
||
responses:
|
||
'200':
|
||
description: Verdict per user server name.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [servers]
|
||
properties:
|
||
servers:
|
||
type: object
|
||
additionalProperties:
|
||
type: string
|
||
enum: [retiring, start_failed, owner, wake, owner_only, allowlist]
|
||
'400':
|
||
$ref: '#/components/responses/BadRequest'
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
|
||
/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 }
|
||
'400':
|
||
$ref: '#/components/responses/BadRequest'
|
||
'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 }
|
||
'400':
|
||
$ref: '#/components/responses/BadRequest'
|
||
'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.
|
||
description: >-
|
||
On a server whose start Failed (phase Failed, desiredState Running) this
|
||
is a retry: the operator recreates the pod and starts it over with a
|
||
fresh automatic-restart budget. It is audited as retry_start.
|
||
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: >-
|
||
Nothing was started. maintenance_in_progress: a restore, backup or file
|
||
write holds the server's world volume. server_retiring: the server is
|
||
given up or being deleted (ServerInfo.retiring).
|
||
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 }
|
||
'400':
|
||
$ref: '#/components/responses/BadRequest'
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
$ref: '#/components/responses/Forbidden'
|
||
'404':
|
||
$ref: '#/components/responses/NotFound'
|
||
|
||
/api/v1/servers/{name}/restart:
|
||
post:
|
||
tags: [servers]
|
||
operationId: restart
|
||
summary: Restart your own running server, or a system service as staff.
|
||
description: >-
|
||
Records a durable request for the operator to gracefully recreate the
|
||
game pod. Desired state remains Running. A concurrent stop supersedes
|
||
the request; maintenance blocks admission.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
responses:
|
||
'202':
|
||
description: Restart accepted.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [name, desiredState]
|
||
properties:
|
||
name: { type: string }
|
||
desiredState: { type: string, const: Running }
|
||
'400':
|
||
$ref: '#/components/responses/BadRequest'
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
$ref: '#/components/responses/Forbidden'
|
||
'404':
|
||
$ref: '#/components/responses/NotFound'
|
||
'409':
|
||
description: Server is not running, is retiring, or maintenance is in progress.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
|
||
/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 }
|
||
'400':
|
||
$ref: '#/components/responses/BadRequest'
|
||
'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: someone else owns it. server_retiring: the server is
|
||
being deleted.
|
||
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: >-
|
||
not_running (the server is not running) or luckperms_missing (the
|
||
server answered the lp command as unknown: LuckPerms is not installed,
|
||
and nothing changed).
|
||
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: >-
|
||
not_running (the server is not running) or luckperms_missing (the
|
||
server answered the lp command as unknown: LuckPerms is not installed,
|
||
and nothing changed).
|
||
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: >-
|
||
not_running (the server is not running) or luckperms_missing (the
|
||
server answered the lp command as unknown: LuckPerms is not installed,
|
||
and nothing changed).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
|
||
/api/v1/servers/{name}/allowlist:
|
||
get:
|
||
tags: [access]
|
||
operationId: listAllowlist
|
||
summary: The server's wake allowlist, newest first. Owner/admin only.
|
||
description: >-
|
||
Who may wake the server while it sleeps under autostartPolicy=allowlist. A
|
||
player lands here by joining the server once; entries whose wake right was
|
||
taken away stay listed with can_wake false, so it can be given back. Felis
|
||
keeps this list itself, so it answers whether the server is running or not.
|
||
A change of owner (claim, reaper release, account deletion) empties it.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
responses:
|
||
'200':
|
||
description: The allowlist.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [server, entries]
|
||
properties:
|
||
server: { type: string }
|
||
entries:
|
||
type: array
|
||
items: { $ref: '#/components/schemas/AllowlistEntry' }
|
||
'400':
|
||
$ref: '#/components/responses/BadRequest'
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
$ref: '#/components/responses/Forbidden'
|
||
'404':
|
||
$ref: '#/components/responses/NotFound'
|
||
|
||
/api/v1/servers/{name}/allowlist/{uuid}:
|
||
put:
|
||
tags: [access]
|
||
operationId: setAllowlistWake
|
||
summary: Take a player's wake right away or give it back. Owner/admin only.
|
||
description: >-
|
||
can_wake false keeps the entry on the list with its wake right revoked, so
|
||
the player's next join does not restore it; true gives it back. Repeating
|
||
either is harmless. Audited as allowlist.revoke / allowlist.restore.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
- { name: uuid, in: path, required: true, schema: { type: string, format: uuid } }
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [can_wake]
|
||
properties:
|
||
can_wake: { type: boolean }
|
||
responses:
|
||
'204':
|
||
description: Changed.
|
||
'400':
|
||
$ref: '#/components/responses/BadRequest'
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
$ref: '#/components/responses/Forbidden'
|
||
'404':
|
||
description: No such server, or the UUID is not on its allowlist.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
|
||
/api/v1/servers/{name}/retirement:
|
||
put:
|
||
tags: [servers]
|
||
operationId: retireServer
|
||
summary: Give the server up (owner) or delete it (admin). The reaper carries it out.
|
||
description: >-
|
||
The server is stopped and the request recorded; the reaper, the one component
|
||
that deletes a world, carries it out on its next daily run. It archives the
|
||
world as a released backup (kept for the reaper's retention, recorded against
|
||
the owner), deletes the world volume and releases the server for anyone to
|
||
claim; with delete it also removes the server, which frees its name and
|
||
subdomain. Until then the server cannot be woken or claimed and still counts
|
||
against the owner's quota, and the request can be cancelled. Repeating it
|
||
keeps the first request time, and a deletion stays a deletion. confirm must
|
||
repeat the server's name. Audited as server.release / server.delete.
|
||
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: [confirm]
|
||
properties:
|
||
confirm: { type: string, description: The server's name, typed back. }
|
||
delete: { type: boolean, description: Delete the server (admin only). Default false gives it up. }
|
||
responses:
|
||
'202':
|
||
description: Recorded; the server is stopped.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [name, retiring]
|
||
properties:
|
||
name: { type: string }
|
||
retiring: { $ref: '#/components/schemas/RetireState' }
|
||
'400':
|
||
description: A malformed body or name, or confirm does not match the name (confirm_mismatch).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
description: Neither the owner nor an admin, or an owner asking to delete.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'404':
|
||
$ref: '#/components/responses/NotFound'
|
||
'409':
|
||
description: >-
|
||
system_server: a system server is never given up or deleted.
|
||
world_volume_orphaned: the server is gone from the cluster but its world
|
||
volume remains, which an operator archives and removes by hand.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
delete:
|
||
tags: [servers]
|
||
operationId: cancelRetire
|
||
summary: Cancel a pending retirement. Only an admin cancels a deletion.
|
||
description: >-
|
||
The server stays stopped; its owner starts it again when they want it.
|
||
Cancelling when nothing is pending changes nothing. Audited as
|
||
server.retire_cancel.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
responses:
|
||
'204':
|
||
description: Nothing is pending any more.
|
||
'400':
|
||
$ref: '#/components/responses/BadRequest'
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
description: Neither the owner nor an admin, or an owner cancelling an admin's deletion.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'404':
|
||
$ref: '#/components/responses/NotFound'
|
||
|
||
/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' }
|
||
'400':
|
||
$ref: '#/components/responses/BadRequest'
|
||
'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. User verification is checked per
|
||
credential: the passkey must have verified the user when it was bound, and this
|
||
assertion must verify the user now. Every failure mode (unknown address, no
|
||
live challenge for the signed value, expired challenge, bad assertion, a
|
||
credential or assertion without user verification, a cloned authenticator)
|
||
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, a failed
|
||
assertion, no user verification, or a cloned authenticator, 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, with the same
|
||
per-credential user-verification check as the username-first door. Every failure
|
||
mode — a missing/expired/consumed login_id, a bad assertion, no user
|
||
verification, a cloned authenticator, 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, no user
|
||
verification, a cloned authenticator, 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, an account disabled or deleted since
|
||
the start, 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, an account
|
||
disabled or deleted since the start, 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. The token is
|
||
spent in the same transaction that stores the session, so a redemption that
|
||
fails with 500 leaves the link working for another try.
|
||
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'
|
||
'500':
|
||
$ref: '#/components/responses/InternalError'
|
||
|
||
/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/owner-status:
|
||
get:
|
||
tags: [auth]
|
||
operationId: ownerStatus
|
||
summary: Report whether an Owner has been created on this install.
|
||
description: >-
|
||
Public, pre-session probe the sign-in page reads on load. Until `felis setup`
|
||
creates an Owner, local sign-in is off and every login door answers 403
|
||
local_auth_disabled; the page then explains that no Owner exists and how to create
|
||
one instead of offering the doors. It discloses only whether the install is
|
||
still unclaimed, and claiming it needs root on the host. It is not gated on
|
||
local_auth_enabled and does not draw on the login doors' per-address rate limit.
|
||
x-felis-face: [external]
|
||
x-felis-tier: public
|
||
security: []
|
||
responses:
|
||
'200':
|
||
description: Whether any Owner or admin account exists.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [owner_bound]
|
||
properties:
|
||
owner_bound: { type: boolean }
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
|
||
/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: >-
|
||
Felis applies no update on its own. `felis update` checks versions and
|
||
prints an explicit apply command. `felis update --apply` reads this
|
||
platform-wide [start,end) window before backup and before installation,
|
||
refusing outside it unless `--now` explicitly starts manual maintenance.
|
||
An unreadable window is always a refusal, including with `--now` or
|
||
`--force`. An unset window reads back as {start:null,end:null}.
|
||
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 daily backup
|
||
(last.daily_at) is missing or 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/updates/report:
|
||
get:
|
||
tags: [admin-updates]
|
||
operationId: getUpdateReport
|
||
summary: The newest recorded version check of every tracked component (admin).
|
||
description: >-
|
||
What `felis update --record` last stored in platform_settings; the
|
||
installer's felis-update-check.timer runs it daily on the host, where the
|
||
installed versions are readable. report is null before the first check;
|
||
stale is true then, and whenever the check is older than max_age_seconds.
|
||
Read-only: Felis applies no update on its own.
|
||
x-felis-face: [external]
|
||
x-felis-tier: admin
|
||
security: [{ sessionCookie: [] }]
|
||
responses:
|
||
'200':
|
||
description: The newest recorded check and whether it is stale.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
$ref: '#/components/schemas/UpdateReport'
|
||
'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/backups/{id}:
|
||
delete:
|
||
tags: [backups]
|
||
operationId: deleteBackup
|
||
summary: Delete one world backup (admin, or the user who owned the world).
|
||
description: >-
|
||
The backup leaves every list, restore and the backup budget at once;
|
||
the reaper's next daily run deletes the archive and the off-site copy's
|
||
next sync removes the bucket's copy. A user gets 404 for a backup
|
||
outside their scope, as their list never shows it. Refused while a
|
||
restore on the backup's server may still read it.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: id, in: path, required: true, schema: { type: string } }
|
||
responses:
|
||
'200':
|
||
description: The backup is deleted; its archive goes at the reaper's next run.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [id, status]
|
||
properties:
|
||
id: { type: string }
|
||
status: { type: string, const: expired }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'404':
|
||
description: No present backup with this id in the caller's scope (no_backup).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'409':
|
||
description: A restore running on the backup's server may be reading it (restore_in_progress).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
|
||
/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'
|
||
|
||
# ------------------------------------------------------ world export (app) ---
|
||
/api/v1/servers/{name}/backups/{id}/export:
|
||
post:
|
||
tags: [backups]
|
||
operationId: exportBackup
|
||
summary: Start downloading one backup (owner-or-admin plus a former-owner match).
|
||
description: >-
|
||
Starts a Job that reads the archive from the backup store and hands it to
|
||
felis-api, which streams it to the browser (poll GET /exports/{ticket},
|
||
then open its download). The Job checks the archive against the sha256
|
||
recorded when it was written as it streams; a mismatch cuts the
|
||
download off short of its end. On the way out config/paper-global.yml
|
||
(the cluster's forwarding secret) is left out and server.properties has
|
||
its rcon.password and forwarding-secrets redacted, so the download carries no Content-Length.
|
||
A user gets 404 for a backup outside their scope, as their list never
|
||
shows it. One export per user at a time, 2 across the install, 6 per
|
||
user per hour.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
- { name: id, in: path, required: true, schema: { type: string } }
|
||
responses:
|
||
'202':
|
||
description: Export started.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/ExportTicket' }
|
||
'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, or no present backup with this id in the caller's scope (no_backup).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'409':
|
||
description: The backup failed a read-back (backup_corrupt).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'429':
|
||
description: An export limit is reached (export_busy); Retry-After gives the seconds to wait.
|
||
headers:
|
||
Retry-After:
|
||
schema: { type: integer }
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
|
||
/api/v1/servers/{name}/world/export:
|
||
post:
|
||
tags: [backups]
|
||
operationId: exportWorld
|
||
summary: Start downloading a stopped server's world as it is now (owner-or-admin).
|
||
description: >-
|
||
Starts a Job that archives the server's data volume, read-only, and hands
|
||
it to felis-api, which streams it to the browser (poll GET
|
||
/exports/{ticket}, then open its download). The server must be fully
|
||
stopped, and it cannot start until the download has ended or the Job's
|
||
2 hour deadline passes. The same two files are guarded as in a backup
|
||
export, matched by the file itself, so a link to either under another
|
||
name is guarded too. Same limits as a backup export.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
responses:
|
||
'202':
|
||
description: Export started.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/ExportTicket' }
|
||
'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), has no world volume yet (no_world_volume), or a restore, backup, file write or export already holds its world volume (maintenance_in_progress).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'429':
|
||
description: An export limit is reached (export_busy); Retry-After gives the seconds to wait.
|
||
headers:
|
||
Retry-After:
|
||
schema: { type: integer }
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
|
||
/api/v1/exports/{ticket}:
|
||
get:
|
||
tags: [backups]
|
||
operationId: exportStatus
|
||
summary: Where an export stands (the user who started it only).
|
||
description: >-
|
||
The panel polls this until the state reads ready, then opens the
|
||
download. Another user's ticket is 404, as an unknown one is.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: ticket, in: path, required: true, schema: { type: string } }
|
||
responses:
|
||
'200':
|
||
description: The export's state.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/ExportStatus' }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'404':
|
||
$ref: '#/components/responses/NotFound'
|
||
'410':
|
||
description: The export was downloaded, or expired before it was (export_expired).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
|
||
/api/v1/exports/{ticket}/download:
|
||
get:
|
||
tags: [backups]
|
||
operationId: exportDownload
|
||
summary: Download a ready export (once, by the user who started it).
|
||
description: >-
|
||
The first request spends the ticket, whatever becomes of it. The archive
|
||
streams as the Job sends it, with Content-Length when it is known; a
|
||
download that cannot finish (the Job died, a backup did not match its
|
||
recorded sha256, or the bytes did not hash to the SHA-256 the Job sent
|
||
with them) is cut off before its last bytes, so the browser reports it
|
||
failed. HEAD is
|
||
refused, since it would spend the ticket on no body.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: ticket, in: path, required: true, schema: { type: string } }
|
||
responses:
|
||
'200':
|
||
description: >-
|
||
The export as an attachment: a world or a backup as a tar.gz, a
|
||
downloaded folder as a zip, a downloaded file as its bytes.
|
||
headers:
|
||
Content-Disposition:
|
||
schema: { type: string }
|
||
content:
|
||
application/gzip:
|
||
schema: { type: string, format: binary }
|
||
application/zip:
|
||
schema: { type: string, format: binary }
|
||
application/octet-stream:
|
||
schema: { type: string, format: binary }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'404':
|
||
$ref: '#/components/responses/NotFound'
|
||
'405':
|
||
description: A method other than GET (method_not_allowed).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'409':
|
||
description: The export is still pending (export_not_ready).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'410':
|
||
description: The export was downloaded, failed, or expired before it was (export_expired).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
|
||
# -------------------------------------------------- async job status (app) ---
|
||
/api/v1/servers/{name}/jobs:
|
||
get:
|
||
tags: [backups]
|
||
operationId: listServerJobs
|
||
summary: Latest async world operations (backup, restore, export) for a server (owner-or-admin).
|
||
description: >-
|
||
Backup, restore and export 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 and export 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, export_world, export_backup, export_files] }
|
||
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.
|
||
scheduled:
|
||
type: boolean
|
||
description: >-
|
||
A backup felis-api took on its own: the daily restore
|
||
point of a played world. Omitted when false.
|
||
'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, free_bytes]
|
||
properties:
|
||
path: { type: string }
|
||
truncated: { type: boolean, description: The listing hit the entry cap and is incomplete. }
|
||
free_bytes: { type: integer, format: int64, nullable: true, description: Bytes free on the world volume, for a client to check an upload fits before sending it; 0 is a full volume, and null a volume whose free space the Job could not read. }
|
||
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).
|
||
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,
|
||
except staff may read login/lobby's felis-experience.json while running.
|
||
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, 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 secret redaction in
|
||
server.properties). Send it back as expect_sha256 on the next write.
|
||
content_sha256:
|
||
type: string
|
||
pattern: '^[0-9a-f]{64}$'
|
||
description: >-
|
||
SHA-256 of the decoded content as sent (after any redaction). A
|
||
client that gets content hashing otherwise got it damaged on the
|
||
way, and reads it again.
|
||
'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 (except startup-only system experience config).
|
||
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' }
|
||
'502':
|
||
description: >-
|
||
The file's bytes do not hash to the digest the file Job sent with them
|
||
(read_damaged): they changed on the way to felis-api. Read it again.
|
||
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).
|
||
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. content_sha256 is the SHA-256 of the content:
|
||
content that hashes otherwise changed on the way and is refused (400
|
||
digest_mismatch) before a Job starts, and the Job checks the bytes it received
|
||
the same way before writing. Staff may save login/lobby's startup-only
|
||
felis-experience.json while running; restart to apply. That config cannot
|
||
be a symlink. 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, content_sha256]
|
||
properties:
|
||
content: { type: string, format: byte, description: Base64-encoded file bytes. }
|
||
content_sha256:
|
||
type: string
|
||
pattern: '^[0-9a-f]{64}$'
|
||
description: >-
|
||
The SHA-256 (lowercase hex) of the decoded content. Absent is 400
|
||
digest_required, malformed 400 bad_digest, and content that does not
|
||
hash to it 400 digest_mismatch; nothing is written.
|
||
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.
|
||
create_only:
|
||
type: boolean
|
||
description: >-
|
||
true writes only if nothing is at the path yet (409 file_exists
|
||
otherwise), for making a new file without replacing one that
|
||
appeared meanwhile. Cannot be combined with expect_sha256.
|
||
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 (bad_request, bad_path), or content that came without its SHA-256 (digest_required), with a malformed one (bad_digest), or changed on the way (digest_mismatch).
|
||
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: >-
|
||
The file changed since expect_sha256 was read (file_changed), something is
|
||
already at the path with create_only (file_exists), the server is not
|
||
stopped (not_stopped), or a restore, backup or file change 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' }
|
||
delete:
|
||
tags: [files]
|
||
operationId: deleteServerFile
|
||
summary: Delete a file or folder in a server's world volume (owner-or-admin; server must be stopped).
|
||
description: >-
|
||
Deletes a file, a symlink (never what it points at) or a folder with
|
||
everything in it. The world root itself is refused (400 bad_path). Same
|
||
stopped-gate, world lock and os.Root containment as a write. The panel
|
||
confirms first; this route does not. Audited as file.delete.
|
||
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 or folder to delete, relative to the world root.
|
||
schema: { type: string }
|
||
responses:
|
||
'200':
|
||
description: Deleted.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [path, status]
|
||
properties:
|
||
path: { type: string }
|
||
status: { type: string, const: deleted }
|
||
'400':
|
||
description: Missing path, invalid server name, the world root, or a path that escapes it.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
$ref: '#/components/responses/Forbidden'
|
||
'404':
|
||
description: Unknown server, or nothing at the path.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'409':
|
||
description: Server is not stopped (not_stopped), or a restore, backup or file change already holds its world volume (maintenance_in_progress).
|
||
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}/files/mkdir:
|
||
post:
|
||
tags: [files]
|
||
operationId: makeServerFolder
|
||
summary: Make a folder in a server's world volume (owner-or-admin; server must be stopped).
|
||
description: >-
|
||
Makes one folder. Its parent must already exist (404), and nothing may be at
|
||
the path yet (409 file_exists). Same stopped-gate, world lock and os.Root
|
||
containment as a write. Audited as file.mkdir.
|
||
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: Folder to make, relative to the world root.
|
||
schema: { type: string }
|
||
responses:
|
||
'200':
|
||
description: Folder made.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [path, status]
|
||
properties:
|
||
path: { type: string }
|
||
status: { type: string, const: created }
|
||
'400':
|
||
description: Missing path, invalid server name, the world root, or a path that escapes it.
|
||
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 folder does not exist.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'409':
|
||
description: Something is already at the path (file_exists), the server is not stopped (not_stopped), or a restore, backup or file change already holds its world volume (maintenance_in_progress).
|
||
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}/files/rename:
|
||
post:
|
||
tags: [files]
|
||
operationId: renameServerFile
|
||
summary: Move or rename a file or folder in a server's world volume (owner-or-admin; server must be stopped).
|
||
description: >-
|
||
Moves the file or folder at path to to. It never replaces: an existing
|
||
destination is 409 file_exists, and a missing destination folder is 404.
|
||
server.properties, config/paper-global.yml and config/ cannot be moved under
|
||
any name they are reached by (400 bad_path), because elsewhere the read path
|
||
would no longer withhold their secrets. Same stopped-gate, world lock and
|
||
os.Root containment as a write. Audited as file.rename.
|
||
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 or folder to move, relative to the world root.
|
||
schema: { type: string }
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [to]
|
||
properties:
|
||
to: { type: string, minLength: 1, description: The new path, relative to the world root. }
|
||
responses:
|
||
'200':
|
||
description: Moved.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [path, to, status]
|
||
properties:
|
||
path: { type: string }
|
||
to: { type: string }
|
||
status: { type: string, const: renamed }
|
||
'400':
|
||
description: Missing path or to, malformed body, invalid server name, the world root, a file felis manages, 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, nothing at path, or the destination folder does not exist.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'409':
|
||
description: Something is already at to (file_exists), the server is not stopped (not_stopped), or a restore, backup or file change already holds its world volume (maintenance_in_progress).
|
||
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}/files/download:
|
||
post:
|
||
tags: [files]
|
||
operationId: downloadServerFile
|
||
summary: Start downloading one file or folder of a stopped server's world (owner-or-admin).
|
||
description: >-
|
||
An export (poll GET /exports/{ticket}, then open its download): a Job
|
||
reads the file, or zips the folder, from the world volume read-only and
|
||
hands it to felis-api, which streams it to the browser. A file saves
|
||
under its own name with its length; a folder as NAME.zip, streamed
|
||
without one, with symbolic links, devices and sockets left out.
|
||
config/paper-global.yml, the cluster's forwarding secret, is refused as
|
||
a file and left out of a folder, and server.properties goes out with
|
||
its rcon.password and forwarding-secrets redacted; both are matched by the file itself, so a
|
||
link to either under another name is guarded too. The server cannot
|
||
start until the download has ended. Two file downloads per user at a
|
||
time, 4 across the install, 30 per user per hour, counted apart from
|
||
world and backup exports. Audited as file.download.
|
||
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 or folder to download, relative to the world root. The root itself is refused.
|
||
schema: { type: string }
|
||
- name: dir
|
||
in: query
|
||
required: false
|
||
description: true when path is a folder, which is sent as a zip. The Job refuses a path that is not what dir says.
|
||
schema: { type: string, enum: ["true", "false"] }
|
||
responses:
|
||
'202':
|
||
description: Download started.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/ExportTicket' }
|
||
'400':
|
||
description: Missing path (bad_request), the world root (bad_path), or a 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), has no world volume yet (no_world_volume), or a restore, backup, file change or another export already holds its world volume (maintenance_in_progress).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'429':
|
||
description: A file download limit is reached (export_busy); Retry-After gives the seconds to wait.
|
||
headers:
|
||
Retry-After:
|
||
schema: { type: integer }
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
|
||
/api/v1/servers/{name}/files/upload:
|
||
put:
|
||
tags: [files]
|
||
operationId: uploadServerFile
|
||
summary: Upload a file into a server's world volume (owner-or-admin; server must be stopped).
|
||
description: >-
|
||
Lands the raw request body as the file at path, up to 64 MiB — a plugin jar,
|
||
a datapack, a world region; a bigger file goes up as an upload session
|
||
(POST …/files/uploads). Content-Length is required (411
|
||
length_required). An existing file is 409 file_exists unless overwrite=true;
|
||
a folder at the path is 400 bad_path either way. The body is staged on
|
||
felis-api's disk first and then fetched by the file Job with a one-time
|
||
token, so the world lock is taken only after the body has arrived and a slow
|
||
upload holds off no backup. The file lands atomically: a synced temporary
|
||
sibling is checked against the staged size and SHA-256, then renamed into
|
||
place, so a failed upload leaves the old file whole. The body carries its
|
||
SHA-256 as Content-Digest; felis-api checks it as the body arrives, and
|
||
the Job checks the same digest again as it fetches the staged copy, so
|
||
every hop between the browser and the world volume is verified. Same
|
||
stopped-gate and os.Root containment as a write. Audited as file.upload.
|
||
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 create, relative to the world root. Its folder must exist.
|
||
schema: { type: string }
|
||
- name: overwrite
|
||
in: query
|
||
required: false
|
||
description: true replaces an existing file, keeping its mode. Anything else refuses to.
|
||
schema: { type: string, enum: ["true", "false"] }
|
||
- name: Content-Digest
|
||
in: header
|
||
required: true
|
||
description: >-
|
||
The SHA-256 of the body as RFC 9530 sends it, sha-256=:<base64>:.
|
||
Other algorithms listed beside it are ignored. Bytes that do not hash
|
||
to it were changed on the way and are refused whole.
|
||
schema: { type: string, example: 'sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:' }
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/octet-stream:
|
||
schema: { type: string, format: binary }
|
||
responses:
|
||
'200':
|
||
description: File uploaded.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [path, status, sha256, size]
|
||
properties:
|
||
path: { type: string }
|
||
status: { type: string, const: uploaded }
|
||
sha256: { type: string, pattern: '^[0-9a-f]{64}$', description: SHA-256 of the bytes landed. }
|
||
size: { type: integer, format: int64, description: Bytes landed. }
|
||
'400':
|
||
description: >-
|
||
Missing path, invalid server name, a folder or the world root at the path,
|
||
a path that escapes the world root, a body that ended before
|
||
Content-Length bytes arrived (upload_incomplete), no Content-Digest
|
||
(digest_required), a malformed one (bad_digest), or bytes that do not
|
||
hash to it (digest_mismatch; nothing is staged, so send it again).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
$ref: '#/components/responses/Forbidden'
|
||
'404':
|
||
description: Unknown server, or the folder does not exist.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'409':
|
||
description: A file is already at the path and overwrite is not true (file_exists), the server is not stopped (not_stopped), or a restore, backup or file change already holds its world volume (maintenance_in_progress).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'411':
|
||
description: The request has no Content-Length (length_required).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'413':
|
||
description: The file is over 64 MiB, the most one request carries (too_large); send it as an upload session instead.
|
||
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':
|
||
description: >-
|
||
felis-api's staging disk has no room for the upload right now
|
||
(upload_staging_full), or the world volume has no room for it
|
||
(volume_full); nothing was changed.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
|
||
/api/v1/servers/{name}/files/uploads:
|
||
post:
|
||
tags: [files]
|
||
operationId: beginServerFileUpload
|
||
summary: Begin an upload session for a file too big for one request (owner-or-admin; server must be stopped).
|
||
description: >-
|
||
A file of any size goes up in parts: this begins a session for path and
|
||
the file's size, PUT …/uploads/{id}?offset= sends each part (at most
|
||
part_max_bytes, 32 MiB, so each fits the edge's body limit), and POST
|
||
…/uploads/{id}/commit lands it. There is no size ceiling but felis-api's
|
||
staging disk, and room for the whole file is reserved here, so an upload
|
||
that begins is one the disk can finish (507 upload_staging_full
|
||
otherwise). A session belongs to the account and server it was begun
|
||
for, answers no one else, and is dropped after 6 hours untouched. Four
|
||
sessions per account at a time. Sessions do not survive a felis-api
|
||
restart.
|
||
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 create, relative to the world root. It must stay inside it (400 bad_path); its folder is checked when the file lands.
|
||
schema: { type: string }
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [size]
|
||
properties:
|
||
size: { type: integer, format: int64, minimum: 0, description: The file's length in bytes. }
|
||
responses:
|
||
'201':
|
||
description: Session begun.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/FileUploadSession' }
|
||
'400':
|
||
description: Missing path or size, or a negative size (bad_request), a path leaving the world folder or naming the folder itself (bad_path), or a 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 has no world volume yet (no_world_volume).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'429':
|
||
description: The account already has 4 uploads in progress (too_many_uploads).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
'507':
|
||
description: felis-api's staging disk has no room for a file this size right now (upload_staging_full).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
|
||
/api/v1/servers/{name}/files/uploads/{id}:
|
||
get:
|
||
tags: [files]
|
||
operationId: getServerFileUpload
|
||
summary: Where an upload session stands (owner-or-admin, the account that began it).
|
||
description: >-
|
||
received is where the next part starts: after a lost answer or a 409
|
||
upload_offset_mismatch, read it here and continue from there. Needs no
|
||
stopped server.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
- { name: id, in: path, required: true, schema: { type: string } }
|
||
responses:
|
||
'200':
|
||
description: The session.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/FileUploadSession' }
|
||
'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, or no such session for this account on this server (upload_not_found).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
put:
|
||
tags: [files]
|
||
operationId: putServerFileUploadPart
|
||
summary: Send one part of an upload session (owner-or-admin, the account that began it).
|
||
description: >-
|
||
The raw body is appended at offset, which must be where the session
|
||
ends. Content-Length and the part's own Content-Digest are required, and
|
||
the part is taken whole or not at all: one cut short, or one whose bytes
|
||
do not hash to its digest, leaves the session where it was. Parts go one at a
|
||
time (409 upload_busy while one arrives). Needs no stopped server, so
|
||
starting the server midway costs only the commit's refusal until it is
|
||
stopped again.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
- { name: id, in: path, required: true, schema: { type: string } }
|
||
- name: offset
|
||
in: query
|
||
required: true
|
||
description: The byte position the part starts at, the session's received.
|
||
schema: { type: integer, format: int64, minimum: 0 }
|
||
- name: Content-Digest
|
||
in: header
|
||
required: true
|
||
description: >-
|
||
The SHA-256 of the body as RFC 9530 sends it, sha-256=:<base64>:.
|
||
Other algorithms listed beside it are ignored. Bytes that do not hash
|
||
to it were changed on the way and are refused whole.
|
||
schema: { type: string, example: 'sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:' }
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/octet-stream:
|
||
schema: { type: string, format: binary }
|
||
responses:
|
||
'200':
|
||
description: Part taken.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/FileUploadSession' }
|
||
'400':
|
||
description: >-
|
||
A missing or malformed offset (bad_request), a body that ended before
|
||
its Content-Length (upload_incomplete), no Content-Digest
|
||
(digest_required), a malformed one (bad_digest), bytes that do not hash
|
||
to it (digest_mismatch; the part was not taken, so send it again), or a
|
||
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, or no such session for this account on this server (upload_not_found).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'409':
|
||
description: offset is not where the session ends (upload_offset_mismatch), or another part is still arriving (upload_busy).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'411':
|
||
description: The request has no Content-Length (length_required).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'413':
|
||
description: The part is over part_max_bytes, or runs past the size the session began with (part_too_large).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
'507':
|
||
description: felis-api's staging disk ran out of room (upload_staging_full).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
delete:
|
||
tags: [files]
|
||
operationId: deleteServerFileUpload
|
||
summary: Cancel an upload session and free its room (owner-or-admin, the account that began it).
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
- { name: id, in: path, required: true, schema: { type: string } }
|
||
responses:
|
||
'204':
|
||
description: Cancelled.
|
||
'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, or no such session for this account on this server (upload_not_found).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'409':
|
||
description: A part is still arriving (upload_busy).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
|
||
/api/v1/servers/{name}/files/uploads/{id}/commit:
|
||
post:
|
||
tags: [files]
|
||
operationId: commitServerFileUpload
|
||
summary: Land a finished upload session in the world volume (owner-or-admin; server must be stopped).
|
||
description: >-
|
||
Starts the Job that fetches the session's bytes from felis-api and lands
|
||
them at its path, checked against their size and SHA-256 and renamed into
|
||
place, so a failed landing leaves the old file whole. It answers at once
|
||
with the op; GET …/files/ops reports how it ends (file_exists when a file
|
||
is at the path and overwrite is not true). The Job holds the world volume
|
||
while it runs, so the server cannot start meanwhile. A Job that fails
|
||
before it has every byte leaves the session to commit again; once the
|
||
bytes have gone to the Job the session is gone. Audited as file.upload.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
- { name: id, in: path, required: true, schema: { type: string } }
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/StartFileOp' }
|
||
responses:
|
||
'202':
|
||
description: Landing started.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [op]
|
||
properties:
|
||
op: { $ref: '#/components/schemas/FileOp' }
|
||
'400':
|
||
description: Malformed body, or a 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, or no such session for this account on this server (upload_not_found).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'409':
|
||
description: >-
|
||
Not every byte has arrived (upload_incomplete; the world lock is not
|
||
asked for), a part is still arriving (upload_busy), the server is not stopped (not_stopped) or has
|
||
no world volume yet (no_world_volume), or a restore, backup, file
|
||
change or export already holds its world volume, this session's
|
||
earlier commit included (maintenance_in_progress).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
|
||
/api/v1/servers/{name}/files/unzip:
|
||
post:
|
||
tags: [files]
|
||
operationId: unzipServerFile
|
||
summary: Extract a .zip into the folder holding it (owner-or-admin; server must be stopped).
|
||
description: >-
|
||
Starts a Job that extracts the archive into a temporary folder beside it
|
||
and moves the result into place, and answers at once with the op; GET
|
||
…/files/ops reports how it ends. Nothing changes unless every entry is
|
||
safe: an entry leaving the folder, an absolute path, or a link ends
|
||
archive_unsafe or archive_symlink; an entry whose size differs from what
|
||
the archive declares ends archive_invalid; a file where the archive has
|
||
a folder, or the reverse, ends type_conflict. Without overwrite an
|
||
archive that would replace any file ends file_exists with the files it
|
||
would replace, for the caller to confirm and run again with overwrite.
|
||
Names stored in GBK, as Windows zips in a Chinese locale have them, are
|
||
read as such. The Job holds the world volume while it runs. Audited as
|
||
file.unzip.
|
||
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: The .zip to extract, relative to the world root.
|
||
schema: { type: string }
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/StartFileOp' }
|
||
responses:
|
||
'202':
|
||
description: Extraction started.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [op]
|
||
properties:
|
||
op: { $ref: '#/components/schemas/FileOp' }
|
||
'400':
|
||
description: Missing path or malformed body (bad_request), a path not ending in .zip (bad_path), or a 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), has no world volume yet (no_world_volume), or a restore, backup, file change or export already holds its world volume (maintenance_in_progress).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
|
||
/api/v1/servers/{name}/files/ops:
|
||
get:
|
||
tags: [files]
|
||
operationId: listServerFileOps
|
||
summary: A server's background uploads and extractions (owner-or-admin).
|
||
description: >-
|
||
Newest first: the one running, if any, and those that ended within the
|
||
last 30 minutes, at most 10. Needs no stopped server.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
responses:
|
||
'200':
|
||
description: The ops.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [ops]
|
||
properties:
|
||
ops:
|
||
type: array
|
||
items: { $ref: '#/components/schemas/FileOp' }
|
||
'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' }
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
|
||
|
||
# --------------------------------------------------- scheduled tasks (app) ---
|
||
/api/v1/servers/{name}/schedules:
|
||
get:
|
||
tags: [schedules]
|
||
operationId: listServerSchedules
|
||
summary: List a server's scheduled tasks (owner-or-admin).
|
||
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 tasks, oldest first, and how many it may have.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [server, schedules, limit]
|
||
properties:
|
||
server: { type: string }
|
||
schedules:
|
||
type: array
|
||
items: { $ref: '#/components/schemas/Schedule' }
|
||
limit: { type: integer }
|
||
'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'
|
||
post:
|
||
tags: [schedules]
|
||
operationId: createServerSchedule
|
||
summary: Add a scheduled task to a server (owner-or-admin).
|
||
description: >-
|
||
The task belongs to the server's current owner: once the server has another
|
||
owner felis-api disables it instead of running it, until somebody saves it
|
||
again. felis-api checks the tasks every 15 seconds; a run it was down for is
|
||
started late, up to 10 minutes, and dropped as missed after that. A command
|
||
runs only on a running server, a restart only restarts a running one, and a
|
||
start goes through the running-server cap and a pending retirement like a
|
||
wake. Audited as schedule.create; each run as schedule.run by scheduler.
|
||
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: { $ref: '#/components/schemas/ScheduleInput' }
|
||
responses:
|
||
'201':
|
||
description: Created.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Schedule' }
|
||
'400':
|
||
description: A malformed body or name, or settings out of range (bad_schedule, bad_request for the command).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
$ref: '#/components/responses/Forbidden'
|
||
'404':
|
||
$ref: '#/components/responses/NotFound'
|
||
'409':
|
||
description: The server already has 20 tasks (schedule_limit).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
/api/v1/servers/{name}/schedules/{id}:
|
||
put:
|
||
tags: [schedules]
|
||
operationId: updateServerSchedule
|
||
summary: Change a scheduled task (owner-or-admin).
|
||
description: >-
|
||
Replaces the task's settings and recomputes its next run. The task passes to
|
||
the server's current owner. Refused while a run is in progress. Audited as
|
||
schedule.update.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/ScheduleInput' }
|
||
responses:
|
||
'200':
|
||
description: Saved.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Schedule' }
|
||
'400':
|
||
description: A malformed body, name or id, or settings out of range (bad_schedule).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
$ref: '#/components/responses/Forbidden'
|
||
'404':
|
||
$ref: '#/components/responses/NotFound'
|
||
'409':
|
||
description: A run is in progress (schedule_running).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
delete:
|
||
tags: [schedules]
|
||
operationId: deleteServerSchedule
|
||
summary: Remove a scheduled task (owner-or-admin).
|
||
description: Refused while a run is in progress. Audited as schedule.delete.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
|
||
responses:
|
||
'204':
|
||
description: Removed.
|
||
'400':
|
||
$ref: '#/components/responses/BadRequest'
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
$ref: '#/components/responses/Forbidden'
|
||
'404':
|
||
$ref: '#/components/responses/NotFound'
|
||
'409':
|
||
description: A run is in progress (schedule_running).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
/api/v1/servers/{name}/schedules/{id}/run:
|
||
post:
|
||
tags: [schedules]
|
||
operationId: runServerSchedule
|
||
summary: Run a scheduled task now (owner-or-admin).
|
||
description: >-
|
||
Starts a run at once, without the players' warning, whether the task is
|
||
enabled or not; its next scheduled run stays where it was. The answer is the
|
||
task after the run's first step: a command, stop or start has finished, and a
|
||
restart or backup goes on in the background (run_state). Audited as
|
||
schedule.run_now, and the run itself as schedule.run.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: name, in: path, required: true, schema: { type: string } }
|
||
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
|
||
responses:
|
||
'202':
|
||
description: Started.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Schedule' }
|
||
'400':
|
||
$ref: '#/components/responses/BadRequest'
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
$ref: '#/components/responses/Forbidden'
|
||
'404':
|
||
$ref: '#/components/responses/NotFound'
|
||
'409':
|
||
description: >-
|
||
A run is already in progress (schedule_running), or the server has another
|
||
owner since the task was saved (schedule_stale).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
|
||
# ------------------------------------------------------ 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); a role change on the owner account (owner_protected), which only the host's break-glass console (sudo felis breakGlass) may make; or a change to the caller's own email without a reauth in the last 5 minutes (reauth_required).
|
||
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).
|
||
description: >-
|
||
Replaces all four caps at once. An absent or null field is unlimited; 0
|
||
grants none of that resource, so every claim that needs it is refused. An
|
||
empty body lifts every cap. A cap below what the user already owns refuses
|
||
new claims and leaves the servers they have alone.
|
||
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, minimum: 0, maximum: 2147483647 }
|
||
max_cpu_milli: { type: integer, nullable: true, minimum: 0, maximum: 2147483647 }
|
||
max_memory_mb: { type: integer, nullable: true, minimum: 0, maximum: 2147483647 }
|
||
max_storage_gb: { type: integer, nullable: true, minimum: 0, maximum: 2147483647 }
|
||
responses:
|
||
'200':
|
||
description: Quotas updated.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/QuotaView' }
|
||
'400':
|
||
description: >-
|
||
invalid_quota, a cap outside 0..2147483647; or a body that is not JSON
|
||
or carries a fraction.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'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':
|
||
description: >-
|
||
Not an owner (forbidden); or unbinding the caller's own passkeys without a reauth in the last 5 minutes (reauth_required).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
|
||
/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 }
|
||
'400':
|
||
$ref: '#/components/responses/BadRequest'
|
||
'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/sources:
|
||
get:
|
||
tags: [account]
|
||
operationId: linkSources
|
||
summary: List configured sources for staff game-role designation.
|
||
x-felis-face: [external]
|
||
x-felis-tier: admin
|
||
security: [{ sessionCookie: [] }]
|
||
responses:
|
||
'200':
|
||
description: Sources in game-authentication priority order; no upstream URLs are exposed.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [sources]
|
||
properties:
|
||
sources:
|
||
type: array
|
||
items:
|
||
type: object
|
||
required: [tag, lookup_available]
|
||
properties:
|
||
tag: { type: string }
|
||
lookup_available: { type: boolean }
|
||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||
'403': { $ref: '#/components/responses/Forbidden' }
|
||
|
||
/api/v1/account/link/profile:
|
||
get:
|
||
tags: [account]
|
||
operationId: lookupProfile
|
||
summary: Look up a role by name or native UUID in a selected authentication source.
|
||
description: Staff-only preview; role lookup does not prove account ownership and creates no binding.
|
||
x-felis-face: [external]
|
||
x-felis-tier: admin
|
||
security: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: source, in: query, required: true, schema: { type: string } }
|
||
- { name: profile, in: query, required: true, schema: { type: string } }
|
||
responses:
|
||
'200':
|
||
description: Role found. mc_uuid uses the exact same per-source mapping as game authentication.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [source, name, profile_uuid, mc_uuid, auth_source]
|
||
properties:
|
||
source: { type: string }
|
||
name: { type: string }
|
||
profile_uuid: { type: string }
|
||
mc_uuid: { type: string, format: uuid }
|
||
auth_source: { type: string, enum: [mojang, thirdparty] }
|
||
'400': { $ref: '#/components/responses/BadRequest' }
|
||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||
'403': { $ref: '#/components/responses/Forbidden' }
|
||
'404': { description: No matching role in the selected source. }
|
||
'502': { description: Source returned an invalid or mismatched profile. }
|
||
'503': { description: Source unavailable. }
|
||
post:
|
||
tags: [account]
|
||
operationId: linkProfile
|
||
summary: Designate a role as the authenticated staff account's game identity.
|
||
description: Requires a fresh login factor. Re-queries the native UUID, maps it on the server, and binds only to the caller. Other users' bindings cannot be overwritten. Panel initialization does not require this operation.
|
||
x-felis-face: [external]
|
||
x-felis-tier: admin
|
||
security: [{ sessionCookie: [] }]
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [source, profile_uuid]
|
||
properties:
|
||
source: { type: string }
|
||
profile_uuid: { type: string, format: uuid }
|
||
responses:
|
||
'200':
|
||
description: Role linked, idempotently for the same user and UUID.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [linked, mc_uuid, auth_source]
|
||
properties:
|
||
linked: { type: boolean }
|
||
mc_uuid: { type: string, format: uuid }
|
||
auth_source: { type: string, enum: [mojang, thirdparty] }
|
||
'400': { $ref: '#/components/responses/BadRequest' }
|
||
'401': { $ref: '#/components/responses/Unauthorized' }
|
||
'403': { $ref: '#/components/responses/Forbidden' }
|
||
'404': { description: Role no longer exists. }
|
||
'409': { description: Role already linked to another user, or reauthentication required. }
|
||
'502': { description: Source returned an invalid or mismatched profile. }
|
||
'503': { description: Source unavailable. }
|
||
|
||
/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
|
||
user-verification and clone checks (a credential or assertion without user
|
||
verification, or 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, no user verification, 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 and until
|
||
when, the named target, and the one-time code's expiry once issued. The
|
||
step-up counts only for the session that gave it and for 10 minutes, so a
|
||
confirmation made in another session, one that lapsed, and a code that
|
||
expired unspent all read as initiated. 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]
|
||
confirm_expires_at:
|
||
type: string
|
||
format: date-time
|
||
description: Present while state is confirmed; the code must be issued before it.
|
||
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 per-credential user-verification check and the
|
||
authenticator sign-count clone check: either refusal fails closed (400
|
||
passkey_login_invalid) and is 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, no user verification, 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 migration confirmed by a step-up in this same session within the last
|
||
10 minutes, 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. The
|
||
source's verified address is sent a notice naming the target and the expiry,
|
||
and another when the code is redeemed.
|
||
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: >
|
||
No step-up from this session within the last 10 minutes, or a code is
|
||
already out (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'
|
||
'403':
|
||
description: >
|
||
The source's servers would push the caller over a quota cap
|
||
(migrate_quota_exceeded). Nothing moved and the code is unspent; it redeems once
|
||
the quota fits, until it expires.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
|
||
/api/v1/me/submissions:
|
||
post:
|
||
tags: [submissions]
|
||
operationId: createSubmission
|
||
summary: Submit a modpack for admin review (user side; user-directed lane over §16). Starts no build.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ 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). The budget counts
|
||
pending uploads and rejected ones until they are reaped, 7 days after
|
||
review; approved uploads leave it.
|
||
'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/limits:
|
||
get:
|
||
tags: [submissions]
|
||
operationId: submissionLimits
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ sessionCookie: [] }]
|
||
summary: The per-upload build-context cap
|
||
description: >-
|
||
The effective [registry] context_max_bytes, 1 GiB by default. The
|
||
panel checks a file against it before upload and sends the file through
|
||
the chunked upload (/api/v1/me/submissions/{id}/context/upload), so the
|
||
cap holds behind the Cloudflare edge too, whose proxy refuses a single
|
||
body over 100 MB.
|
||
responses:
|
||
'200':
|
||
description: The cap.
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
additionalProperties: false
|
||
required: [max_context_bytes]
|
||
properties:
|
||
max_context_bytes: {type: integer, format: int64}
|
||
'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), and one
|
||
withdrawn or deleted while its context streams in answers 404 with the
|
||
bytes discarded; a wrong-format
|
||
or oversize body is rejected with 400 (the per-upload cap is [registry]
|
||
context_max_bytes, 1 GiB by default; GET /api/v1/me/submissions/limits
|
||
reports it so a client can check a file before sending it). This
|
||
request carries the whole context, so behind the Cloudflare edge, whose
|
||
proxy refuses bodies over 100 MB with its own HTML 413 before they reach
|
||
the API, a larger context goes through the chunked upload at
|
||
/api/v1/me/submissions/{id}/context/upload instead. An upload that would push the
|
||
caller past their per-user stored-context budget is refused with 403
|
||
before the excess is persisted. The body's SHA-256 is required as
|
||
Content-Digest; bytes that do not hash to it were changed on the way,
|
||
and none of them replace the context stored before. 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 } }
|
||
- name: Content-Digest
|
||
in: header
|
||
required: true
|
||
description: >-
|
||
The SHA-256 of the body as RFC 9530 sends it, sha-256=:<base64>:.
|
||
Other algorithms listed beside it are ignored.
|
||
schema: { type: string, example: 'sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:' }
|
||
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':
|
||
description: >-
|
||
A body that is not a gzip tarball or is over the context cap
|
||
(bad_request), no Content-Digest (digest_required), a malformed one
|
||
(bad_digest), or bytes that do not hash to it (digest_mismatch;
|
||
nothing was stored, so send it again).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
description: >-
|
||
The upload would exceed the caller's per-user stored-context budget
|
||
(submission_quota_exceeded), which counts their pending and rejected
|
||
uploads; approved ones leave it.
|
||
'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}/context/upload:
|
||
get:
|
||
tags: [submissions]
|
||
operationId: getContextUpload
|
||
summary: Where your chunked context upload stands (the resume point).
|
||
description: >-
|
||
The chunked form of POST /api/v1/me/submissions/{id}/context, for a
|
||
context larger than one request carries through the edge. received is
|
||
how many bytes are staged: the next part starts there. A client reads it
|
||
before the first part and again after a failed one. Nothing staged reads
|
||
as 0. Same owner scoping as the single upload (404 for another user's
|
||
submission, 409 once reviewed).
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: id, in: path, required: true, schema: { type: string } }
|
||
responses:
|
||
'200':
|
||
description: The staged length and the limits a part and the whole must keep.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/ContextUploadProgress' }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'404':
|
||
$ref: '#/components/responses/NotFound'
|
||
'409':
|
||
$ref: '#/components/responses/Conflict'
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
put:
|
||
tags: [submissions]
|
||
operationId: putContextUploadPart
|
||
summary: Append one part of your chunked context upload.
|
||
description: >-
|
||
The body is the part's raw bytes, at most part_max_bytes (32 MiB).
|
||
offset is where they start: 0 starts the upload over, and anything else
|
||
must equal the staged length, or the answer is 409
|
||
upload_offset_mismatch and the client reads GET for where to resume. The
|
||
first part must open with the gzip magic (400). The staged total meets
|
||
the same context cap (400) and storage budget (403) as a single upload.
|
||
A part that breaks off is cut back off, and so is one whose bytes do not
|
||
hash to its Content-Digest, so the staged bytes are always a prefix of
|
||
the file. One request per upload at a time (409 upload_busy).
|
||
Staged bytes untouched for 24 hours are deleted. The budget check reads
|
||
blob sizes remembered for up to a minute; when a size has to be read and
|
||
the uploads store does not answer, the answer is 503
|
||
uploads_store_unavailable with Retry-After, and the same part can be
|
||
sent again.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: id, in: path, required: true, schema: { type: string } }
|
||
- { name: offset, in: query, required: true, schema: { type: integer, format: int64, minimum: 0 } }
|
||
- name: Content-Digest
|
||
in: header
|
||
required: true
|
||
description: >-
|
||
The SHA-256 of the part as RFC 9530 sends it, sha-256=:<base64>:.
|
||
Other algorithms listed beside it are ignored.
|
||
schema: { type: string, example: 'sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:' }
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/octet-stream:
|
||
schema: { type: string, format: binary }
|
||
responses:
|
||
'200':
|
||
description: The part is staged; received is the new length.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/ContextUploadProgress' }
|
||
'400':
|
||
description: >-
|
||
A missing or malformed offset, a first part without the gzip magic
|
||
or a total over the context cap (bad_request), no Content-Digest
|
||
(digest_required), a malformed one (bad_digest), or bytes that do
|
||
not hash to it (digest_mismatch; the part was cut back off, so read
|
||
where the upload stands and send it again).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
description: >-
|
||
The staged total would exceed the caller's per-user stored-context
|
||
budget (submission_quota_exceeded).
|
||
'404':
|
||
$ref: '#/components/responses/NotFound'
|
||
'409':
|
||
$ref: '#/components/responses/Conflict'
|
||
'413':
|
||
description: The part is larger than part_max_bytes (part_too_large).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
'507':
|
||
description: The uploads store is full (uploads_full).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
|
||
/api/v1/me/submissions/{id}/context/upload/complete:
|
||
post:
|
||
tags: [submissions]
|
||
operationId: completeContextUpload
|
||
summary: Store your staged chunked upload as the submission's build context.
|
||
description: >-
|
||
Runs every check of POST /api/v1/me/submissions/{id}/context on the
|
||
staged bytes (format, cap, budget, room), records the digest the same
|
||
way, and deletes the staged copy. Holds the same per-user upload
|
||
cooldown (429) and writes the same submission.upload audit event.
|
||
Nothing staged is 400. After a failure the staged bytes stay, for a
|
||
retry. The budget is checked against every blob's size read from the
|
||
uploads store; a store that does not answer is 503
|
||
uploads_store_unavailable with Retry-After.
|
||
x-felis-face: [external]
|
||
x-felis-tier: app
|
||
security: [{ sessionCookie: [] }]
|
||
parameters:
|
||
- { name: id, in: path, required: true, schema: { type: string } }
|
||
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 context 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
|
||
maxLength: 64
|
||
description: >-
|
||
Trimmed. An empty name clears it, and the server goes by its name again. At
|
||
most 64 characters, all visible ones or spaces (400 bad_display_name otherwise).
|
||
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
|
||
description: >-
|
||
Sets the memory limit and request together, as create does, and
|
||
re-derives the JVM heap. The CPU limit and request are kept.
|
||
storage:
|
||
type: string
|
||
description: Rejected with 400 storage_immutable — present for a clear error, not mutation.
|
||
resources:
|
||
type: object
|
||
description: >-
|
||
Single fields of the pod block, laid over what the server has: a
|
||
field left out keeps its value, so each may be sent alone. An empty
|
||
cpu or cpuRequest removes that limit or request; the memory ceiling
|
||
cannot be emptied. A request above its limit is a 400.
|
||
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/build/{id}/scan:
|
||
get:
|
||
tags: [images]
|
||
operationId: getBuildScan
|
||
summary: The scan gate's verdict and findings for a 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 kept scan.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/BuildScan' }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
$ref: '#/components/responses/Forbidden'
|
||
'404':
|
||
description: No scan for this build (scan_not_found) — it has not reached the scan step, or it ran before builds kept their scans.
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
|
||
/api/v1/images/build/{id}/scan/report:
|
||
get:
|
||
tags: [images]
|
||
operationId: getBuildScanReport
|
||
summary: Download a build's full Trivy JSON report (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 Trivy report (SchemaVersion 2), served as the attachment <id>-trivy.json.
|
||
content:
|
||
application/json:
|
||
schema: { type: object }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
$ref: '#/components/responses/Forbidden'
|
||
'404':
|
||
description: No scan for this build (scan_not_found), or the report was too large to keep (scan_document_not_kept).
|
||
content:
|
||
application/json:
|
||
schema: { $ref: '#/components/schemas/Error' }
|
||
'503':
|
||
$ref: '#/components/responses/ServiceUnavailable'
|
||
|
||
/api/v1/images/build/{id}/sbom:
|
||
get:
|
||
tags: [images]
|
||
operationId: getBuildSBOM
|
||
summary: Download a build's CycloneDX SBOM (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 CycloneDX JSON SBOM, served as the attachment <id>.cdx.json.
|
||
content:
|
||
application/vnd.cyclonedx+json:
|
||
schema: { type: object }
|
||
'401':
|
||
$ref: '#/components/responses/Unauthorized'
|
||
'403':
|
||
$ref: '#/components/responses/Forbidden'
|
||
'404':
|
||
description: No scan for this build (scan_not_found), or the SBOM was too large to keep or its step failed (scan_document_not_kept).
|
||
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 } }
|
||
requestBody:
|
||
required: true
|
||
content:
|
||
application/json:
|
||
schema:
|
||
type: object
|
||
required: [expected_digest]
|
||
properties:
|
||
expected_digest:
|
||
type: string
|
||
description: >-
|
||
The context_sha256 the admin reviewed: the X-Felis-Context-Sha256 header of
|
||
the context they downloaded, or the listed one. A context uploaded again since
|
||
answers 409 context_changed.
|
||
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: >-
|
||
The submission has already been reviewed (already_reviewed), or its context was
|
||
uploaded again after the reviewed digest (context_changed).
|
||
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'
|