4734 lines
179 KiB
YAML
4734 lines
179 KiB
YAML
# felis-api — OpenAPI 3.1 description of both faces (spec §7, §14, §28 #7).
|
|
#
|
|
# ONE binary serves TWO http.Handlers (internal / external). This document
|
|
# describes both, distinguished per-operation by the `x-felis-face` extension
|
|
# (an array, because `/healthz` is served by both faces) and `x-felis-tier`
|
|
# (the Zero-Trust grade: public | service | app | admin).
|
|
#
|
|
# VERIFIED vs. HAND-MAINTAINED — read before trusting a field:
|
|
# * The {method, path} -> {x-felis-face set, x-felis-tier} mapping is
|
|
# machine-checked. internal/api/openapi_test.go parses this file and asserts
|
|
# EXACT bidirectional parity against the route tables the handlers are built
|
|
# from (internalAPIRoutes / externalAPIRoutes in internal/api/api.go). A route
|
|
# added, removed, re-faced, or re-tiered without updating this file fails
|
|
# `go test ./...`. So path, method, face and tier are as trustworthy as the code.
|
|
# * The request/response BODY schemas below are hand-maintained from the Go
|
|
# handler types and are NOT yet schema-validated against live traffic. Treat
|
|
# them as documentation (SHAPE-ASSERTED), not as a contract test.
|
|
#
|
|
# The deployment zone (RootDomain, spec §2) never appears here — `example.test`
|
|
# is a placeholder, per the no-hardcoded-domain red line.
|
|
|
|
openapi: 3.1.0
|
|
|
|
info:
|
|
title: felis-api
|
|
version: 4.1.0
|
|
description: |
|
|
Control plane for the Felis Minecraft orchestration platform. The same binary
|
|
exposes an internal face (service-token auth, for velocity / backend callbacks,
|
|
never Zero Trust) and an external face (Cloudflare Access JWT auth, for people
|
|
and the panel). Admin-tier external operations additionally require the admin
|
|
Access path. See `x-felis-face` / `x-felis-tier` on each operation.
|
|
|
|
servers:
|
|
- url: https://api-internal.{root_domain}
|
|
description: >-
|
|
Internal face. Service-token auth (Authorization: Bearer <service-token>);
|
|
reachable only from inside the cluster. Never wrapped in Zero Trust.
|
|
variables:
|
|
root_domain:
|
|
default: example.test
|
|
- url: https://api.{root_domain}
|
|
description: >-
|
|
External face. Cloudflare Access JWT auth on every /api/v1 route; admin-tier
|
|
routes additionally require the admin Access path.
|
|
variables:
|
|
root_domain:
|
|
default: example.test
|
|
|
|
tags:
|
|
- name: health
|
|
description: Liveness / readiness probes (unauthenticated).
|
|
- name: servers-internal
|
|
description: Service-token server lookup and lifecycle callbacks (internal face).
|
|
- name: lobby
|
|
description: felis-paper lobby actions velocity drives on the player's behalf (internal face).
|
|
- name: account-internal
|
|
description: In-game account-link code minting (internal face).
|
|
- name: servers
|
|
description: Operate on your own servers (external face, app tier).
|
|
- name: console
|
|
description: Read (SSE) and write (RCON) server console (external face, app tier).
|
|
- name: backups
|
|
description: World backup listing and restore (external face, app tier).
|
|
- name: account
|
|
description: Web side of account linking (external face, app tier).
|
|
- name: admin-servers
|
|
description: Create / mutate server specs (external face, admin tier).
|
|
- name: images
|
|
description: Image build and whitelist administration (external face, admin tier).
|
|
- name: users
|
|
description: User administration (external face, admin tier only — exposed solely to owner).
|
|
|
|
components:
|
|
securitySchemes:
|
|
serviceToken:
|
|
type: http
|
|
scheme: bearer
|
|
description: Static service token presented by velocity / backend callers (internal face).
|
|
accessJWT:
|
|
type: apiKey
|
|
in: header
|
|
name: Cf-Access-Jwt-Assertion
|
|
description: >-
|
|
Cloudflare Access JWT (external face). Admin-tier operations require the
|
|
token to have traversed the admin Access path; the handler additionally
|
|
asserts Principal.IsAdmin().
|
|
sessionCookie:
|
|
type: apiKey
|
|
in: cookie
|
|
name: felis_session
|
|
description: >-
|
|
Opaque session cookie (external face). Minted by the passwordless
|
|
session doors — passkey login, email-OTP, bind code, and op-login
|
|
finish — HttpOnly+Secure+SameSite=Lax and host-only, so an op.console
|
|
session never reaches the player console. Only its sha-256 is
|
|
persisted. SessionAuth prefers this cookie and otherwise delegates to
|
|
accessJWT, so the two models coexist on one face.
|
|
|
|
responses:
|
|
NoContent:
|
|
description: Success, no body.
|
|
BadRequest:
|
|
description: Malformed or invalid request (validation, bad body, unknown field).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
Unauthorized:
|
|
description: Authentication missing or invalid.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
Forbidden:
|
|
description: Authenticated but not permitted (ownership / admin / quota).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
NotFound:
|
|
description: No such server / build / record.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
Conflict:
|
|
description: Precondition failed (lost race, not running, already claimed/linked, not stopped).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
PreconditionFailed:
|
|
description: A required prior step is missing (e.g. account not linked).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
ServiceUnavailable:
|
|
description: A required subsystem (builder / console / logs / restorer / repo / cluster) is not wired or reachable.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
MailUndeliverable:
|
|
description: >
|
|
The configured SMTP relay refused the message (code mail_undeliverable), so no
|
|
code was delivered. Distinct from 500 because the fault is in the install's
|
|
[smtp] settings, not in the request or the platform — most often a From address
|
|
the relay will not let this account send as. The relay's own text is deliberately
|
|
withheld (it names the SMTP account) and written to the felis-api log instead,
|
|
keyed by the same request_id this response carries. Retrying the same address
|
|
changes nothing until an operator fixes the relay.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
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.
|
|
|
|
PasskeyCredential:
|
|
type: object
|
|
description: >
|
|
Display projection of one bound passkey (internal/api/handlers_passkey.go
|
|
passkeyCredentialView). Carries no secret — the public key is never returned.
|
|
required: [id, name, created_at]
|
|
properties:
|
|
id: { type: string, description: Opaque passkey row id (used to unbind it). }
|
|
name: { type: string, description: Caller-supplied nickname; empty if none. }
|
|
aaguid: { type: string, description: Authenticator model id, present only when known. }
|
|
created_at: { type: string, format: date-time }
|
|
last_used_at:
|
|
type: string
|
|
format: date-time
|
|
description: Present only once an assertion is verified (deferred login path).
|
|
|
|
Phase:
|
|
type: string
|
|
description: MinecraftServer lifecycle phase (internal/apis/felis/v1alpha1).
|
|
enum: [Unknown, Stopped, Starting, Running, Stopping, Failed]
|
|
|
|
ServerInfo:
|
|
type: object
|
|
description: Status projection of one server (internal/api/cluster.go ServerInfo).
|
|
required: [name, subdomain, phase, ready, playersOnline, playersMax]
|
|
properties:
|
|
name: { type: string }
|
|
subdomain: { type: string }
|
|
phase: { $ref: '#/components/schemas/Phase' }
|
|
ready: { type: boolean }
|
|
autostartPolicy:
|
|
type: string
|
|
description: Present only when set; who may wake the server via domain-autostart.
|
|
desiredState:
|
|
type: string
|
|
description: Present only when set; the operator's target state.
|
|
enum: [Running, Stopped]
|
|
endpointMode: { type: string }
|
|
endpointAddress: { type: string }
|
|
playersOnline: { type: integer, format: int32 }
|
|
playersMax: { type: integer, format: int32 }
|
|
|
|
MyServerView:
|
|
type: object
|
|
description: One row of the caller's server list (internal/api/repo.go MyServerView).
|
|
required: [name, subdomain, owned, claimable, playersOnline, playersMax]
|
|
properties:
|
|
name: { type: string }
|
|
subdomain: { type: string }
|
|
owned: { type: boolean }
|
|
claimable: { type: boolean }
|
|
phase:
|
|
allOf: [{ $ref: '#/components/schemas/Phase' }]
|
|
description: Present only when known.
|
|
playersOnline:
|
|
type: integer
|
|
format: int32
|
|
description: Best-effort from live CRD status; 0 when the cluster is unreachable.
|
|
playersMax: { type: integer, format: int32 }
|
|
|
|
BackupView:
|
|
type: object
|
|
description: One world backup (internal/api/repo.go BackupView). backup_ref is withheld (spec §286).
|
|
required: [id, server_name, size_bytes, reason, status, created_at, expires_at]
|
|
properties:
|
|
id: { type: string }
|
|
server_name: { type: string }
|
|
former_owner:
|
|
type: string
|
|
description: Present only when the world had an owner at backup time.
|
|
size_bytes: { type: integer, format: int64 }
|
|
reason: { type: string }
|
|
status: { type: string }
|
|
created_at: { type: string, format: date-time }
|
|
expires_at: { type: string, format: date-time }
|
|
|
|
Build:
|
|
type: object
|
|
description: One image build (internal/build Build).
|
|
required: [id, image_ref, status, requested_by, created_at]
|
|
properties:
|
|
id: { type: string }
|
|
image_ref: { type: string }
|
|
status:
|
|
type: string
|
|
enum: [pending, building, succeeded, failed, cancelled]
|
|
dockerfile: { type: string }
|
|
context_ref: { type: string }
|
|
base_image: { type: string }
|
|
requested_by: { type: string }
|
|
job_name: { type: string }
|
|
log_ref: { type: string }
|
|
error: { type: string }
|
|
created_at: { type: string, format: date-time }
|
|
finished_at:
|
|
type: [string, 'null']
|
|
format: date-time
|
|
description: Null until the build reaches a terminal status.
|
|
|
|
Image:
|
|
type: object
|
|
description: One whitelisted image (internal/build Image).
|
|
required: [image_ref, source, added_by, enabled, added_at]
|
|
properties:
|
|
image_ref: { type: string }
|
|
source: { type: string }
|
|
build_id: { type: string }
|
|
added_by: { type: string }
|
|
enabled: { type: boolean }
|
|
added_at: { type: string, format: date-time }
|
|
|
|
Submission:
|
|
type: object
|
|
description: >-
|
|
One user-submitted modpack in the approval lane (internal/submit
|
|
Submission — a user-directed extension over the §16 build subsystem).
|
|
The user supplies only display_name; submitted_by
|
|
comes from the principal and context_ref/image_ref/build_id/reviewed_by
|
|
are platform-controlled, never client input.
|
|
required: [id, submitted_by, display_name, context_ref, status, created_at]
|
|
properties:
|
|
id: { type: string }
|
|
submitted_by: { type: string }
|
|
display_name: { type: string }
|
|
context_ref:
|
|
type: string
|
|
description: Platform-derived pinned build context; not user-supplied.
|
|
status:
|
|
type: string
|
|
enum: [pending_review, approved, rejected]
|
|
image_ref:
|
|
type: string
|
|
description: Platform-derived push target, set at approval.
|
|
build_id:
|
|
type: string
|
|
description: image_builds.id, set only after the build hand-off succeeds.
|
|
build_status:
|
|
type: string
|
|
enum: [pending, building, succeeded, failed, cancelled]
|
|
description: >-
|
|
The linked build's outcome, attached by the LIST routes
|
|
(/me/submissions, /submissions) — for a submitter this is the only
|
|
visible outlet for a failed build. Omitted until a build is linked
|
|
and its row is readable.
|
|
build_error:
|
|
type: string
|
|
description: >-
|
|
The build's recorded failure text (e.g. a CRITICAL CVE scan
|
|
failure), attached alongside build_status.
|
|
reviewed_by: { type: string }
|
|
reject_reason: { type: string }
|
|
created_at: { type: string, format: date-time }
|
|
reviewed_at:
|
|
type: [string, 'null']
|
|
format: date-time
|
|
description: Null until an admin approves or rejects.
|
|
|
|
UserView:
|
|
type: object
|
|
description: One row of the admin user list (internal/api/repo.go UserView).
|
|
required: [id, username, role, disabled, email_verified, server_count, created_at, updated_at]
|
|
properties:
|
|
id: { type: string }
|
|
username: { type: string }
|
|
email: { type: string }
|
|
role: { type: string, enum: [owner, admin, user] }
|
|
disabled: { type: boolean }
|
|
email_verified: { type: boolean }
|
|
server_count: { type: integer }
|
|
created_at: { type: string, format: date-time }
|
|
updated_at: { type: string, format: date-time }
|
|
|
|
UserDetail:
|
|
type: object
|
|
description: Full admin view of one user (internal/api/repo.go UserDetail).
|
|
required: [id, username, role, disabled, email_verified, server_count, created_at, updated_at, linked_accounts]
|
|
properties:
|
|
id: { type: string }
|
|
username: { type: string }
|
|
email: { type: string }
|
|
role: { type: string, enum: [owner, admin, user] }
|
|
disabled: { type: boolean }
|
|
email_verified: { type: boolean }
|
|
server_count: { type: integer }
|
|
created_at: { type: string, format: date-time }
|
|
updated_at: { type: string, format: date-time }
|
|
deleted_at:
|
|
type: [string, 'null']
|
|
format: date-time
|
|
description: Present only when soft-deleted.
|
|
linked_accounts:
|
|
type: array
|
|
items:
|
|
type: object
|
|
required: [mc_uuid, auth_source, verified_at]
|
|
properties:
|
|
mc_uuid: { type: string, format: uuid }
|
|
auth_source: { type: string }
|
|
verified_at: { type: string, format: date-time }
|
|
|
|
QuotaView:
|
|
type: object
|
|
description: A user's quotas row (internal/api/repo.go QuotaView). Null fields mean unlimited.
|
|
required: [user_id]
|
|
properties:
|
|
user_id: { type: string }
|
|
max_servers: { type: integer, nullable: true }
|
|
max_cpu_milli: { type: integer, nullable: true }
|
|
max_memory_mb: { type: integer, nullable: true }
|
|
max_storage_gb: { type: integer, nullable: true }
|
|
|
|
SessionView:
|
|
type: object
|
|
description: One live session of a user visible to an admin (internal/api/repo.go SessionView).
|
|
required: [token_hash, created_at, expires_at]
|
|
properties:
|
|
token_hash: { type: string }
|
|
created_at: { type: string, format: date-time }
|
|
expires_at: { type: string, format: date-time }
|
|
revoked_at:
|
|
type: [string, 'null']
|
|
format: date-time
|
|
|
|
paths:
|
|
# ----------------------------------------------------------------- health ---
|
|
/healthz:
|
|
get:
|
|
tags: [health]
|
|
operationId: healthz
|
|
summary: Liveness probe.
|
|
description: Unauthenticated on both faces; kubelet and Cloudflare hold no token.
|
|
x-felis-face: [internal, external]
|
|
x-felis-tier: public
|
|
security: []
|
|
responses:
|
|
'200':
|
|
description: Always ok when the process is up.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [status]
|
|
properties:
|
|
status: { type: string, const: ok }
|
|
|
|
/readyz:
|
|
get:
|
|
tags: [health]
|
|
operationId: readyz
|
|
summary: Readiness probe (internal face only — readiness is an internal concern).
|
|
x-felis-face: [internal]
|
|
x-felis-tier: public
|
|
security: []
|
|
responses:
|
|
'200':
|
|
description: Repo and Cluster are wired.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [status]
|
|
properties:
|
|
status: { type: string, const: ready }
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/metrics:
|
|
get:
|
|
tags: [metrics]
|
|
operationId: metrics
|
|
summary: Prometheus metrics (felis_* collectors) on the internal face.
|
|
description: >-
|
|
Scrape-only infrastructure route, not a product API: the internal listener is
|
|
ClusterIP-only and a Prometheus scrape carries no token, the same stance as the
|
|
probes. Serves the felis_* exposition documented in troubleshooting §14; the
|
|
external face never serves it.
|
|
x-felis-face: [internal]
|
|
x-felis-tier: public
|
|
security: []
|
|
responses:
|
|
'200':
|
|
description: Prometheus text exposition format.
|
|
content:
|
|
text/plain:
|
|
schema: { type: string }
|
|
|
|
/session/minecraft/hasJoined:
|
|
get:
|
|
tags: [nano]
|
|
operationId: hasJoined
|
|
summary: Multi-source session verifier (Felis-nano hasJoined multiplexer).
|
|
description: >-
|
|
Velocity is pointed here with -Dmojang.sessionserver and sends the request itself.
|
|
Unauthenticated — the vanilla sessionserver protocol carries no token. The query
|
|
is fanned out to the configured Yggdrasil roots in priority order (the Mojang
|
|
identity source first); the first source to validate the serverId hash wins. A
|
|
non-identity source's self-asserted UUID is rewritten into a per-source namespace
|
|
(UUIDv3) before return, so it can never land in Mojang's UUID space. A rejected
|
|
or barred login is 204, which Velocity answers with its online-mode-only kick.
|
|
Any other non-200 status makes Velocity report the auth servers as down.
|
|
x-felis-face: [internal]
|
|
x-felis-tier: public
|
|
security: []
|
|
parameters:
|
|
- { name: username, in: query, required: true, schema: { type: string, maxLength: 64 } }
|
|
- { name: serverId, in: query, required: true, schema: { type: string, maxLength: 64 } }
|
|
- { name: ip, in: query, required: false, schema: { type: string, maxLength: 64 } }
|
|
responses:
|
|
'200':
|
|
description: A source validated the session; the canonical game profile.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [id, name]
|
|
properties:
|
|
id: { type: string, description: Canonical UUID, undashed 32-hex. }
|
|
name:
|
|
type: string
|
|
description: >-
|
|
The name the source returned. A third-party player whose name is
|
|
registered to a Mojang account gets it back as PREFIX_name, cut to
|
|
16 characters.
|
|
properties: { type: array, items: { type: object } }
|
|
'204':
|
|
description: >-
|
|
Not admitted, with no source asked when username or serverId is missing or a
|
|
parameter is over 64 bytes. Otherwise no source validated the session, the
|
|
canonical UUID is barred, a third-party source returned a name that is not a
|
|
legal Minecraft username, or the identity source returned an unparseable id.
|
|
'400':
|
|
description: >-
|
|
The request declared a body. No body is sent back, and the connection is
|
|
closed.
|
|
'500':
|
|
description: The bar-list lookup failed, so the login is not admitted.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
description: >-
|
|
No source validated the session and at least one source failed (transport
|
|
error, redirect, unexpected status, or a 200 without a usable profile). Its
|
|
player may be the one logging in, so this is not answered as a 204. No body.
|
|
|
|
# -------------------------------------------------- internal: servers ------
|
|
/api/v1/servers:
|
|
get:
|
|
tags: [servers-internal]
|
|
operationId: listServers
|
|
summary: List all servers (velocity route table).
|
|
x-felis-face: [internal]
|
|
x-felis-tier: service
|
|
security: [{ serviceToken: [] }]
|
|
responses:
|
|
'200':
|
|
description: Every server's status projection.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [servers]
|
|
properties:
|
|
servers:
|
|
type: array
|
|
items: { $ref: '#/components/schemas/ServerInfo' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
post:
|
|
tags: [admin-servers]
|
|
operationId: createServer
|
|
summary: Create a server (admin).
|
|
description: Requires the admin Access path; the image must be whitelisted.
|
|
x-felis-face: [external]
|
|
x-felis-tier: admin
|
|
security: [{ accessJWT: [] }]
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [name, subdomain]
|
|
properties:
|
|
name: { type: string }
|
|
subdomain: { type: string }
|
|
display_name: { type: string }
|
|
image: { type: string }
|
|
memory: { type: string }
|
|
storage: { type: string }
|
|
autostart_policy: { type: string }
|
|
resources:
|
|
type: object
|
|
properties:
|
|
cpu: { type: string }
|
|
cpu_request: { type: string }
|
|
memory: { type: string }
|
|
memory_request: { type: string }
|
|
responses:
|
|
'201':
|
|
description: Created; starts Stopped.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [name, subdomain, desiredState]
|
|
properties:
|
|
name: { type: string }
|
|
subdomain: { type: string }
|
|
desiredState: { type: string, const: Stopped }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'409':
|
|
$ref: '#/components/responses/Conflict'
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/internal/servers/{name}/ready:
|
|
post:
|
|
tags: [servers-internal]
|
|
operationId: serverReadyCallback
|
|
summary: Backend readiness callback — the server reports it is accepting players.
|
|
x-felis-face: [internal]
|
|
x-felis-tier: service
|
|
security: [{ serviceToken: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'204':
|
|
$ref: '#/components/responses/NoContent'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
|
|
/api/v1/internal/submissions/{id}/context:
|
|
get:
|
|
tags: [submissions-internal]
|
|
operationId: internalSubmissionContext
|
|
summary: Stream a submission's stored build-context tarball to the build Pod.
|
|
description: >-
|
|
The build Job's fetch initContainer cannot mount the control-plane uploads
|
|
PVC (a PVC does not cross namespaces) and holds no object-store
|
|
credentials, so the API that stored the blob streams it here. Served on
|
|
the internal face (service token, no Zero Trust).
|
|
x-felis-face: [internal]
|
|
x-felis-tier: service
|
|
security: [{ serviceToken: [] }]
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'200':
|
|
description: The stored gzip tarball, verbatim.
|
|
content:
|
|
application/gzip:
|
|
schema: { type: string, format: binary }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/internal/servers/{name}/join-event:
|
|
post:
|
|
tags: [servers-internal]
|
|
operationId: joinEvent
|
|
summary: Player-join event by online-mode UUID (activity tracking / idle reset).
|
|
x-felis-face: [internal]
|
|
x-felis-tier: service
|
|
security: [{ serviceToken: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [mc_uuid]
|
|
properties:
|
|
mc_uuid: { type: string }
|
|
responses:
|
|
'204':
|
|
$ref: '#/components/responses/NoContent'
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
|
|
/api/v1/internal/servers/{name}/wake:
|
|
post:
|
|
tags: [servers-internal]
|
|
operationId: internalWake
|
|
summary: Domain-autostart wake driven by velocity for a joining player (spec §9.1).
|
|
description: >-
|
|
velocity holds no web Principal, so it drives the wake lever with its
|
|
service token, identifying the player by online-mode UUID. Gated by the
|
|
server's autostartPolicy and the per-server wake cooldown.
|
|
x-felis-face: [internal]
|
|
x-felis-tier: service
|
|
security: [{ serviceToken: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [mc_uuid]
|
|
properties:
|
|
mc_uuid: { type: string }
|
|
responses:
|
|
'202':
|
|
description: Wake accepted (or already awake).
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [name, desiredState, phase, ready]
|
|
properties:
|
|
name: { type: string }
|
|
desiredState: { type: string, const: Running }
|
|
phase: { $ref: '#/components/schemas/Phase' }
|
|
ready: { type: boolean }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'429':
|
|
description: Wake cooldown is still active for this server.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
/api/v1/internal/servers/{name}/status:
|
|
get:
|
|
tags: [servers-internal]
|
|
operationId: internalStatus
|
|
summary: Server status projection (velocity polls this after a wake).
|
|
x-felis-face: [internal]
|
|
x-felis-tier: service
|
|
security: [{ serviceToken: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'200':
|
|
description: The server's status projection.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/ServerInfo' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
|
|
/api/v1/internal/servers/{name}/claim:
|
|
post:
|
|
tags: [lobby]
|
|
operationId: internalClaim
|
|
summary: Lobby "Claim & Start" by online-mode UUID (spec §12).
|
|
description: >-
|
|
The felis-paper lobby holds no token, so velocity claims on its behalf,
|
|
binding the unowned server to the player's linked account.
|
|
x-felis-face: [internal]
|
|
x-felis-tier: service
|
|
security: [{ serviceToken: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [mc_uuid]
|
|
properties:
|
|
mc_uuid: { type: string }
|
|
responses:
|
|
'200':
|
|
description: Claimed.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [name, claimed]
|
|
properties:
|
|
name: { type: string }
|
|
claimed: { type: boolean, const: true }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'409':
|
|
$ref: '#/components/responses/Conflict'
|
|
'412':
|
|
$ref: '#/components/responses/PreconditionFailed'
|
|
|
|
/api/v1/internal/servers/{name}/menu:
|
|
get:
|
|
tags: [lobby]
|
|
operationId: internalMenuStatus
|
|
summary: Lobby menu projection — status plus the ownership-derived `claimable`.
|
|
x-felis-face: [internal]
|
|
x-felis-tier: service
|
|
security: [{ serviceToken: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
- { name: mc_uuid, in: query, required: false, schema: { type: string } }
|
|
responses:
|
|
'200':
|
|
description: Menu projection for the lobby UI.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [name, phase, ready, playersOnline, playersMax, claimable]
|
|
properties:
|
|
name: { type: string }
|
|
phase: { $ref: '#/components/schemas/Phase' }
|
|
ready: { type: boolean }
|
|
playersOnline: { type: integer, format: int32 }
|
|
playersMax: { type: integer, format: int32 }
|
|
claimable: { type: boolean }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
|
|
/api/v1/internal/account/link/code:
|
|
post:
|
|
tags: [account-internal]
|
|
operationId: createLinkCode
|
|
summary: Mint a one-time account-link code for a verified online-mode UUID (spec §10).
|
|
description: Internal-only — the code is born from a UUID the web never holds.
|
|
x-felis-face: [internal]
|
|
x-felis-tier: service
|
|
security: [{ serviceToken: [] }]
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [mc_uuid]
|
|
properties:
|
|
mc_uuid: { type: string }
|
|
auth_source:
|
|
type: string
|
|
enum: [mojang, thirdparty]
|
|
description: >
|
|
Which Yggdrasil authenticated the in-game UUID (spec §10
|
|
dual-Yggdrasil). Optional; when omitted it is derived from the
|
|
UUID's version nibble (felis-nano rewrites third-party profiles
|
|
to UUIDv3; Mojang profiles are v4), defaulting to mojang.
|
|
Captured here because only the in-game side sees the
|
|
authentication; it is copied onto the link at verify.
|
|
responses:
|
|
'201':
|
|
description: Code minted.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [code, expires_at]
|
|
properties:
|
|
code: { type: string }
|
|
expires_at: { type: string, format: date-time }
|
|
panel_url:
|
|
type: string
|
|
description: >
|
|
Where to redeem the code (https://<panel hostname>). Present
|
|
only when a panel hostname is configured, so the in-game
|
|
message can print a clickable destination.
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
|
|
/api/v1/internal/account/link/status/{mc_uuid}:
|
|
get:
|
|
tags: [account-internal]
|
|
operationId: linkStatus
|
|
summary: Poll whether an in-game UUID has finished linking — the QR scan-to-login completion check (spec §B3).
|
|
description: >
|
|
Internal-only, read-only. After a new player scans the QR-encoded link code
|
|
and the web verify writes the durable account_links row, velocity polls this
|
|
for the UUID it minted against and admits the player on linked:true. Keyed by
|
|
the verified UUID (not the scanned code), so it consumes nothing and is safe
|
|
to poll repeatedly; an unlinked or never-seen UUID returns linked:false. The
|
|
response is deliberately just the boolean — the plugin keys everything on the
|
|
UUID it already holds, so no identity detail crosses back.
|
|
x-felis-face: [internal]
|
|
x-felis-tier: service
|
|
security: [{ serviceToken: [] }]
|
|
parameters:
|
|
- { name: mc_uuid, in: path, required: true, schema: { type: string, format: uuid } }
|
|
responses:
|
|
'200':
|
|
description: Link-completion status.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [linked]
|
|
properties:
|
|
linked: { type: boolean }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
|
|
/api/v1/internal/account/migrate/start:
|
|
post:
|
|
tags: [account-internal]
|
|
operationId: migrateStart
|
|
summary: Put the account linked to a verified in-game UUID into migrate mode (spec §B3 inherit, in-game side).
|
|
description: >
|
|
Internal-only. The in-game /felis migrate command calls this for the running
|
|
player's verified UUID: it resolves the linked account and opens a fresh
|
|
migration in the initiated state, superseding any earlier unfinished attempt
|
|
by the same source. The web side then drives a fresh step-up confirmation.
|
|
The transfer itself moves server ownership only — never the mc_uuid link nor
|
|
web credentials — so this endpoint starts a flow, it does not move anything.
|
|
x-felis-face: [internal]
|
|
x-felis-tier: service
|
|
security: [{ serviceToken: [] }]
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [mc_uuid]
|
|
properties:
|
|
mc_uuid: { type: string, format: uuid }
|
|
responses:
|
|
'201':
|
|
description: Migration opened in the initiated state.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [started, state]
|
|
properties:
|
|
started: { type: boolean, const: true }
|
|
state: { type: string, const: initiated }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'404':
|
|
description: The UUID is not linked to any account (not_linked).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'409':
|
|
description: The source account has already been retired by a completed migration (account_retired).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
/api/v1/internal/player/reclaim:
|
|
post:
|
|
tags: [account-internal]
|
|
operationId: reclaimUsername
|
|
summary: Record a Mojang-priority username reclaim — bar the squatter UUID and stash its data (spec §B3).
|
|
description: >
|
|
Internal-only. Velocity records a username-collision reclaim: the
|
|
non-genuine squatter UUID is barred and its world/player data stashed for
|
|
a 30-day window so a new account can inherit it. Idempotent — a repeat
|
|
reclaim of an already-barred UUID is a no-op. The bar is keyed by UUID,
|
|
never the contested name, so the genuine Mojang player always passes.
|
|
x-felis-face: [internal]
|
|
x-felis-tier: service
|
|
security: [{ serviceToken: [] }]
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [squatter_uuid, username]
|
|
properties:
|
|
squatter_uuid: { type: string, format: uuid }
|
|
username: { type: string }
|
|
data_ref:
|
|
type: string
|
|
description: >
|
|
Optional opaque handle to the data already archived for the
|
|
hold (server-side only, never returned). Archival may be
|
|
deferred, in which case this is omitted.
|
|
responses:
|
|
'200':
|
|
description: Reclaim recorded.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [blacklisted, username, hold_expires_at]
|
|
properties:
|
|
blacklisted: { type: boolean, const: true }
|
|
username: { type: string }
|
|
hold_expires_at: { type: string, format: date-time }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
|
|
/api/v1/internal/player/blacklist/{mc_uuid}:
|
|
get:
|
|
tags: [account-internal]
|
|
operationId: checkUsernameBlacklist
|
|
summary: Report whether an in-game UUID was barred by a prior reclaim (spec §B3).
|
|
description: >
|
|
Internal-only. The velocity login gate calls it to reject a barred
|
|
squatter before letting them in; the genuine Mojang UUID — same username,
|
|
different UUID — is never on the list and always passes.
|
|
x-felis-face: [internal]
|
|
x-felis-tier: service
|
|
security: [{ serviceToken: [] }]
|
|
parameters:
|
|
- { name: mc_uuid, in: path, required: true, schema: { type: string, format: uuid } }
|
|
responses:
|
|
'200':
|
|
description: Blacklist status.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [blacklisted]
|
|
properties:
|
|
blacklisted: { type: boolean }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
|
|
/api/v1/internal/op-login/pending:
|
|
get:
|
|
tags: [account-internal]
|
|
operationId: opLoginPending
|
|
summary: List live pending op.console login requests, oldest first (spec §B).
|
|
description: >
|
|
Internal-only. Lists the requests awaiting an in-game vouch. Today no plugin
|
|
consumes it — the staff member reads the request id off the op.console page
|
|
and an admin approves it with /felis web op approve <id>; the route exists so
|
|
velocity can later push the waiting list to online admins. No pending request
|
|
is secret to the operator crew.
|
|
x-felis-face: [internal]
|
|
x-felis-tier: service
|
|
security: [{ serviceToken: [] }]
|
|
responses:
|
|
'200':
|
|
description: The pending requests awaiting an in-game vouch.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [pending]
|
|
properties:
|
|
pending:
|
|
type: array
|
|
items:
|
|
type: object
|
|
required: [request_id, username, email, created_at]
|
|
properties:
|
|
request_id: { type: string }
|
|
username: { type: string }
|
|
email: { type: string }
|
|
created_at: { type: string, format: date-time }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
|
|
/api/v1/internal/op-login/{id}/approve:
|
|
post:
|
|
tags: [account-internal]
|
|
operationId: opLoginApprove
|
|
summary: Record an in-game admin's vouch for a pending op.console login (spec §B).
|
|
description: >
|
|
Internal-only second factor: velocity submits the online-mode UUID of the
|
|
in-game admin running /felis web op approve. The API resolves it to a linked
|
|
role=admin account (else 403 not_admin) and flips the request approved. A
|
|
missing or no-longer-pending request is 404. Self-approval is allowed — an
|
|
online staff member vouching as their own admin identity is a genuine second
|
|
factor distinct from the mailbox.
|
|
x-felis-face: [internal]
|
|
x-felis-tier: service
|
|
security: [{ serviceToken: [] }]
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [approver_uuid]
|
|
properties:
|
|
approver_uuid: { type: string, format: uuid }
|
|
responses:
|
|
'200':
|
|
description: The vouch was recorded; the request is now approved.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [approved]
|
|
properties:
|
|
approved: { type: boolean, const: true }
|
|
'400':
|
|
description: approver_uuid is required (bad_request).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
description: The approver is not a linked administrator (not_admin).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'404':
|
|
description: No pending operator login with that id (op_login_not_found).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
/api/v1/internal/servers/{name}/backup:
|
|
post:
|
|
tags: [account-internal]
|
|
operationId: internalBackupNow
|
|
summary: Break-glass on-demand world backup (service token; server must be stopped).
|
|
description: >-
|
|
The break-glass console (root on the node, holding the service token) POSTs
|
|
here to snapshot a stopped world while the API is alive — it goes through the
|
|
API rather than direct-to-CRD because rendering the backup Job needs
|
|
deployment coordinates only felis-api holds. Same RWO stopped-gate and async
|
|
202 as the external backupNow; there is no Principal (trusted machine caller),
|
|
and the action is audited to "break-glass".
|
|
x-felis-face: [internal]
|
|
x-felis-tier: service
|
|
security: [{ serviceToken: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
requestBody:
|
|
required: false
|
|
description: >-
|
|
Optional accountability hint. The console passes the OS user at the
|
|
keyboard so the audit row names the operator rather than the generic
|
|
"break-glass"; absent/blank falls back to "break-glass".
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
os_user: { type: string }
|
|
responses:
|
|
'202':
|
|
description: Backup started.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [name, status]
|
|
properties:
|
|
name: { type: string }
|
|
status: { type: string, const: backing_up }
|
|
'400':
|
|
description: Invalid server name (bad_name).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'404':
|
|
description: Unknown server.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'409':
|
|
description: Server is not stopped (its world PVC is still mounted).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
# ----------------------------------------------------- external: servers ---
|
|
/api/v1/servers/{name}/wake:
|
|
post:
|
|
tags: [servers]
|
|
operationId: wake
|
|
summary: Wake your own server.
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'202':
|
|
description: Wake accepted.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [name, desiredState]
|
|
properties:
|
|
name: { type: string }
|
|
desiredState: { type: string, const: Running }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'429':
|
|
description: Wake cooldown is still active.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
/api/v1/servers/{name}/stop:
|
|
post:
|
|
tags: [servers]
|
|
operationId: stop
|
|
summary: Stop your own server.
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'202':
|
|
description: Stop accepted.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [name, desiredState]
|
|
properties:
|
|
name: { type: string }
|
|
desiredState: { type: string, const: Stopped }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
|
|
/api/v1/servers/{name}/claim:
|
|
post:
|
|
tags: [servers]
|
|
operationId: claim
|
|
summary: Claim an unowned server for your linked account.
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'200':
|
|
description: Claimed.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [name, claimed]
|
|
properties:
|
|
name: { type: string }
|
|
claimed: { type: boolean, const: true }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
description: Quota exceeded.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'409':
|
|
description: Already claimed.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'412':
|
|
description: Account not linked (the pointer /account/link/start emits).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
/api/v1/servers/{name}/command:
|
|
post:
|
|
tags: [console]
|
|
operationId: command
|
|
summary: Run a console command via RCON (spec §8 write). Owner/admin only.
|
|
description: The RCON password is never accepted or returned (spec §286).
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [command]
|
|
properties:
|
|
command: { type: string }
|
|
responses:
|
|
'200':
|
|
description: Command output.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [name, output]
|
|
properties:
|
|
name: { type: string }
|
|
output: { type: string }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'409':
|
|
description: Server not running.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/servers/{name}/console:
|
|
get:
|
|
tags: [console]
|
|
operationId: serverConsole
|
|
summary: Stream the running pod's log over SSE (spec §8 read, §262). Owner/admin only.
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'200':
|
|
description: An event stream of log lines.
|
|
content:
|
|
text/event-stream:
|
|
schema: { type: string }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'409':
|
|
description: Server not running.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/servers/{name}/access/whitelist:
|
|
get:
|
|
tags: [access]
|
|
operationId: accessWhitelistList
|
|
summary: List whitelisted players via RCON (spec §7). Owner/admin only.
|
|
description: >-
|
|
Runs "whitelist list" against the live server and returns a best-effort
|
|
parse plus the raw reply. The RCON password is never accepted or returned
|
|
(spec §286).
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'200':
|
|
description: Whitelisted players.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [name, players, output]
|
|
properties:
|
|
name: { type: string }
|
|
players: { type: array, items: { type: string } }
|
|
output: { type: string }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'409':
|
|
description: Server not running.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
post:
|
|
tags: [access]
|
|
operationId: accessWhitelist
|
|
summary: Add or remove a player from the whitelist (spec §7). Owner/admin only.
|
|
description: >-
|
|
Translates to the RCON "whitelist add|remove <player>" command. The
|
|
player name is validated against the Minecraft username charset before it
|
|
is built into a command. The RCON password is never accepted or returned
|
|
(spec §286).
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [action, player]
|
|
properties:
|
|
action: { type: string, enum: [add, remove] }
|
|
player: { type: string }
|
|
responses:
|
|
'200':
|
|
$ref: '#/components/responses/AccessResult'
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'409':
|
|
description: Server not running.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/servers/{name}/access/players:
|
|
get:
|
|
tags: [access]
|
|
operationId: accessPlayers
|
|
summary: List online players via RCON (spec §7). Owner/admin only.
|
|
description: >-
|
|
Runs "list" against the live server and returns the online/max tally, a
|
|
best-effort parse of the online player names, and the raw reply. This is
|
|
the only source of WHO is online — Status.Players carries the count alone.
|
|
The RCON password is never accepted or returned (spec §286).
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'200':
|
|
description: Online players.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [name, online, max, players, output]
|
|
properties:
|
|
name: { type: string }
|
|
online: { type: integer }
|
|
max: { type: integer }
|
|
players: { type: array, items: { type: string } }
|
|
output: { type: string }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'409':
|
|
description: Server not running.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/servers/{name}/access/ban:
|
|
get:
|
|
tags: [access]
|
|
operationId: accessBanList
|
|
summary: List banned players via RCON (spec §7). Owner/admin only.
|
|
description: >-
|
|
Runs "banlist" against the live server and returns a best-effort parse
|
|
plus the raw reply. The RCON password is never accepted or returned
|
|
(spec §286).
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'200':
|
|
description: Banned players.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [name, players, output]
|
|
properties:
|
|
name: { type: string }
|
|
players: { type: array, items: { type: string } }
|
|
output: { type: string }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'409':
|
|
description: Server not running.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
post:
|
|
tags: [access]
|
|
operationId: accessBan
|
|
summary: Ban or pardon a player (spec §7). Owner/admin only.
|
|
description: >-
|
|
Translates to the RCON "ban|pardon <player>" command. Carries no reason
|
|
field (a free-text reason would be an injection vector; the audit log
|
|
records intent). The RCON password is never accepted or returned (§286).
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [action, player]
|
|
properties:
|
|
action: { type: string, enum: [ban, pardon] }
|
|
player: { type: string }
|
|
responses:
|
|
'200':
|
|
$ref: '#/components/responses/AccessResult'
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'409':
|
|
description: Server not running.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/servers/{name}/access/kick:
|
|
post:
|
|
tags: [access]
|
|
operationId: accessKick
|
|
summary: Kick a player off the running server (spec §7). Owner/admin only.
|
|
description: >-
|
|
Translates to the RCON "kick <player>" command. Unlike ban it does not
|
|
block rejoining. Carries no reason field (a free-text reason would be an
|
|
injection vector; the audit log records intent). The RCON password is
|
|
never accepted or returned (spec §286).
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [player]
|
|
properties:
|
|
player: { type: string }
|
|
responses:
|
|
'200':
|
|
description: The player was kicked; the raw RCON reply is in output.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [name, player, output]
|
|
properties:
|
|
name: { type: string }
|
|
player: { type: string }
|
|
output: { type: string }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'409':
|
|
description: Server not running.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/servers/{name}/access/permission:
|
|
post:
|
|
tags: [access]
|
|
operationId: accessPermission
|
|
summary: Set or unset a LuckPerms permission node (spec §7). Owner/admin only.
|
|
description: >-
|
|
Translates to "lp user <player> permission set <node> <true|false>
|
|
[world=<world>]" (or unset). An omitted value defaults to true (grant),
|
|
not false (deny). Player, node and world are charset-validated before the
|
|
command is assembled. The RCON password is never accepted or returned
|
|
(spec §286).
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [action, player, node]
|
|
properties:
|
|
action: { type: string, enum: [set, unset] }
|
|
player: { type: string }
|
|
node: { type: string }
|
|
value: { type: boolean, description: "set only; omitted => true (grant)" }
|
|
world: { type: string, description: "optional LuckPerms world context" }
|
|
responses:
|
|
'200':
|
|
$ref: '#/components/responses/AccessResult'
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'409':
|
|
description: Server not running.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/servers/{name}/access/group:
|
|
post:
|
|
tags: [access]
|
|
operationId: accessGroup
|
|
summary: Add or remove a player's LuckPerms parent group (spec §7). Owner/admin only.
|
|
description: >-
|
|
Translates to "lp user <player> parent add|remove <group>". Player and
|
|
group are charset-validated before the command is assembled. The RCON
|
|
password is never accepted or returned (spec §286).
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [action, player, group]
|
|
properties:
|
|
action: { type: string, enum: [add, remove] }
|
|
player: { type: string }
|
|
group: { type: string }
|
|
responses:
|
|
'200':
|
|
$ref: '#/components/responses/AccessResult'
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'409':
|
|
description: Server not running.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/servers/{name}/access/luckperms/{player}:
|
|
get:
|
|
tags: [access]
|
|
operationId: accessLuckPermsInfo
|
|
summary: Read a player's LuckPerms groups and permission nodes (spec §7). Owner/admin only.
|
|
description: >-
|
|
Translates to "lp user <player> permission info" over RCON and parses the
|
|
paginated, colour-coded reply (up to 10 pages) into structured entries.
|
|
Parent groups (granted group.<name> nodes without a world context) are
|
|
split out from plain permission nodes. The raw concatenated RCON output
|
|
is echoed back for anything the parser cannot represent.
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
- { name: player, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'200':
|
|
description: Parsed LuckPerms state plus the raw command output.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [player, groups, permissions, output]
|
|
properties:
|
|
player: { type: string }
|
|
groups:
|
|
type: array
|
|
items: { type: string }
|
|
permissions:
|
|
type: array
|
|
items:
|
|
type: object
|
|
required: [node, value]
|
|
properties:
|
|
node: { type: string }
|
|
value: { type: boolean, description: "false = negated (§c) node" }
|
|
world: { type: string, description: "present only for world-scoped nodes" }
|
|
output: { type: string }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'409':
|
|
description: Server not running.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/servers/{name}/status:
|
|
get:
|
|
tags: [servers]
|
|
operationId: status
|
|
summary: Status of your own server.
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'200':
|
|
description: The server's status projection.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/ServerInfo' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
|
|
# -------------------------------------------------- external: local auth ---
|
|
/api/v1/auth/options:
|
|
post:
|
|
tags: [auth]
|
|
operationId: authOptions
|
|
summary: Identifier-first login discovery — which methods can this email use (spec §B, #71).
|
|
description: >-
|
|
Public, pre-session discovery for the SPA's identifier-first form: given a typed
|
|
email, report which console login methods the account can use (passkey and/or
|
|
email-OTP) so the UI prompts for the right authenticator. This is the deliberate
|
|
counter-slice to the anti-enumeration login doors — the ONE sanctioned place
|
|
account existence is disclosed, so an unknown address returns an empty methods
|
|
array. It never reveals staffness: methods are computed identically for every
|
|
resolved account (no role branch), so a staff and a player address in the same
|
|
credential state return byte-identical bodies. passkey is offered only when a
|
|
verifier is wired. Sends no mail and mutates nothing; not rate-limited at the app
|
|
layer (volumetric abuse is bounded at the edge). Gated on local_auth_enabled.
|
|
x-felis-face: [external]
|
|
x-felis-tier: public
|
|
security: []
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [email]
|
|
properties:
|
|
email: { type: string, format: email }
|
|
responses:
|
|
'200':
|
|
description: >-
|
|
The login methods available for the address, in a deterministic order
|
|
(passkey before email_otp). An empty array means no verified account.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [methods]
|
|
properties:
|
|
methods:
|
|
type: array
|
|
items: { type: string, enum: [passkey, email_otp] }
|
|
'400':
|
|
description: A valid email is required (bad_request).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'403':
|
|
description: Local session login is disabled on this deployment (local_auth_disabled).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'415':
|
|
description: Request body was not application/json.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
/api/v1/auth/passkey/login/begin:
|
|
post:
|
|
tags: [auth]
|
|
operationId: passkeyLoginBegin
|
|
summary: Begin a passwordless passkey (WebAuthn) login (spec §14, §B).
|
|
description: >-
|
|
First leg of the public, pre-session passkey assertion door: the caller
|
|
supplies the email that selects the account and, on success, receives the raw
|
|
PublicKeyCredentialRequestOptions to hand to navigator.credentials.get(). The
|
|
matching challenge is stashed server-side and redeemed by finish. Mounted
|
|
Public (no prior principal) and gated on local_auth_enabled. An unknown
|
|
address and a known account with no enrolled passkey both return the SAME 400
|
|
no_passkey, so the door is not an existence oracle; a per-recipient cooldown
|
|
(shared shape with the email-OTP and op-login doors) throttles probing.
|
|
x-felis-face: [external]
|
|
x-felis-tier: public
|
|
security: []
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [email]
|
|
properties:
|
|
email: { type: string, format: email }
|
|
responses:
|
|
'200':
|
|
description: >-
|
|
The WebAuthn assertion options (PublicKeyCredentialRequestOptions), passed
|
|
through verbatim from the authenticator library for the browser to consume.
|
|
The body is the WebAuthn standard shape and is not modelled here.
|
|
content:
|
|
application/json:
|
|
schema: { type: object, additionalProperties: true }
|
|
'400':
|
|
description: >-
|
|
Invalid email (bad_request); or no passkey is enrolled for the account, or
|
|
the address is unknown — indistinguishable by design (no_passkey); or the
|
|
authenticator library could not start the ceremony (passkey_login_failed).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'403':
|
|
description: Local session login is disabled on this deployment (local_auth_disabled).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'415':
|
|
description: Request body was not application/json.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'429':
|
|
description: A passkey login for this recipient was started too recently (otp_resend_cooldown).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
description: No passkey verifier is wired on this deployment (passkey_unavailable).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
/api/v1/auth/passkey/login/finish:
|
|
post:
|
|
tags: [auth]
|
|
operationId: passkeyLoginFinish
|
|
summary: Complete a passkey (WebAuthn) login and mint a session (spec §14, §B).
|
|
description: >-
|
|
Second leg of the public passkey door: the caller returns the email (to
|
|
re-select the account) and the raw navigator.credentials.get() assertion. The
|
|
stashed login challenge is consumed atomically and the assertion is verified
|
|
against it; on success a host-only felis_session cookie is minted. Both players
|
|
and staff may log in this way — a passkey is a two-factor authenticator
|
|
(possession + user verification), strong enough to stand alone without the
|
|
in-game approval op-login requires. Every failure mode (unknown address, no
|
|
live challenge, expired challenge, bad assertion) collapses into one uniform
|
|
passkey_login_invalid, so the door reveals nothing.
|
|
x-felis-face: [external]
|
|
x-felis-tier: public
|
|
security: []
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [email, assertion]
|
|
properties:
|
|
email: { type: string, format: email }
|
|
assertion:
|
|
type: object
|
|
additionalProperties: true
|
|
description: >-
|
|
The raw PublicKeyCredential from navigator.credentials.get(),
|
|
passed to the verifier verbatim (WebAuthn standard shape).
|
|
responses:
|
|
'200':
|
|
description: Assertion verified; a host-only session cookie is set on the response.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [user_id, role]
|
|
properties:
|
|
user_id: { type: string }
|
|
role: { type: string, enum: [user, admin] }
|
|
'400':
|
|
description: >-
|
|
Invalid email or missing assertion (bad_request); or the login could not be
|
|
completed — unknown address, no live or expired challenge, or a failed
|
|
assertion, all uniform (passkey_login_invalid).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'403':
|
|
description: Local session login is disabled on this deployment (local_auth_disabled).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'415':
|
|
description: Request body was not application/json.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
description: No passkey verifier is wired on this deployment (passkey_unavailable).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
/api/v1/auth/passkey/login/discoverable/begin:
|
|
post:
|
|
tags: [auth]
|
|
operationId: passkeyLoginDiscoverableBegin
|
|
summary: Begin a usernameless (discoverable) passkey login (spec §14, §B, task #40).
|
|
description: >-
|
|
First leg of the truly from-zero passkey door: unlike the email-first sibling
|
|
above, the caller supplies NO identifier — the request has no body (only the
|
|
application/json Content-Type is required as the cross-origin CSRF guard). The
|
|
response is the WebAuthn PublicKeyCredentialRequestOptions with an EMPTY
|
|
allowCredentials, plus an opaque login_id: the authenticator picks a resident
|
|
credential it holds for this RP and the account is revealed only by the
|
|
userHandle inside the signed assertion at finish. The challenge cannot be
|
|
user-keyed, so it is stashed under login_id in a non-user-keyed store and echoed
|
|
back at finish. Mounted Public and gated on local_auth_enabled. There is no
|
|
recipient or principal to key a per-caller cooldown on (that volumetric limiting
|
|
is delegated to the edge), so the server-side brake is a hard global cap on live
|
|
challenges (429 too_many_challenges). Inert for a credential until its owner
|
|
enrolls a resident passkey; email-OTP and username-first passkey remain the
|
|
fallbacks, so no authenticator is ever locked out.
|
|
x-felis-face: [external]
|
|
x-felis-tier: public
|
|
security: []
|
|
requestBody:
|
|
required: false
|
|
description: >-
|
|
No body is read — the whole point is that the caller supplies no identifier —
|
|
but the application/json Content-Type is required (415 otherwise).
|
|
content:
|
|
application/json:
|
|
schema: { type: object }
|
|
responses:
|
|
'200':
|
|
description: >-
|
|
The WebAuthn assertion options (PublicKeyCredentialRequestOptions) with an
|
|
empty allowCredentials, passed through verbatim for the browser to consume,
|
|
plus an opaque login_id the caller echoes at finish. The publicKey member is
|
|
the WebAuthn standard shape and is not modelled here.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [publicKey, login_id]
|
|
properties:
|
|
publicKey: { type: object, additionalProperties: true }
|
|
login_id: { type: string }
|
|
'400':
|
|
description: The authenticator library could not start the ceremony (passkey_login_failed).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'403':
|
|
description: Local session login is disabled on this deployment (local_auth_disabled).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'415':
|
|
description: Request Content-Type was not application/json.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'429':
|
|
description: >-
|
|
Too many discoverable logins are in flight server-wide; the global cap is hit
|
|
(too_many_challenges). No per-recipient signal is leaked — the cap is global.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
description: No passkey verifier is wired on this deployment (passkey_unavailable).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
/api/v1/auth/passkey/login/discoverable/finish:
|
|
post:
|
|
tags: [auth]
|
|
operationId: passkeyLoginDiscoverableFinish
|
|
summary: Complete a usernameless (discoverable) passkey login and mint a session (spec §14, §B, task #40).
|
|
description: >-
|
|
Second leg of the from-zero door: the caller returns the opaque login_id from
|
|
begin (the only link to the stashed challenge, since it is not user-keyed) and
|
|
the raw navigator.credentials.get() assertion — and NOTHING that names an
|
|
account. The stashed challenge is consumed atomically and the assertion is
|
|
verified against it; the account is resolved from the authenticator-revealed
|
|
userHandle (the account's stable id), never from anything the client supplied,
|
|
and the session is minted for the account the assertion actually resolved AND
|
|
verified to. Both players and staff may log in this way. Every failure mode — a
|
|
missing/expired/consumed login_id, a bad assertion, AND a userHandle that
|
|
resolves to no account — collapses into one uniform passkey_login_invalid, so
|
|
the door reveals nothing (not even whether the handle was well-formed).
|
|
x-felis-face: [external]
|
|
x-felis-tier: public
|
|
security: []
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [login_id, assertion]
|
|
properties:
|
|
login_id:
|
|
type: string
|
|
description: The opaque handle returned by discoverable/begin.
|
|
assertion:
|
|
type: object
|
|
additionalProperties: true
|
|
description: >-
|
|
The raw PublicKeyCredential from navigator.credentials.get(),
|
|
passed to the verifier verbatim (WebAuthn standard shape). Its
|
|
userHandle selects the account server-side.
|
|
responses:
|
|
'200':
|
|
description: Assertion verified; a host-only session cookie is set on the response.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [user_id, role]
|
|
properties:
|
|
user_id: { type: string }
|
|
role: { type: string, enum: [user, admin] }
|
|
'400':
|
|
description: >-
|
|
Missing login_id or assertion (bad_request); or the login could not be
|
|
completed — no live/expired/consumed challenge, a failed assertion, or a
|
|
userHandle that resolves to no account, all uniform (passkey_login_invalid).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'403':
|
|
description: Local session login is disabled on this deployment (local_auth_disabled).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'415':
|
|
description: Request body was not application/json.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'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).
|
|
Gated on local_auth_enabled.
|
|
x-felis-face: [external]
|
|
x-felis-tier: public
|
|
security: []
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [email]
|
|
properties:
|
|
email: { type: string, format: email }
|
|
responses:
|
|
'202':
|
|
description: >-
|
|
Accepted (neutral): a code was mailed if the address has a verified
|
|
account; the response is identical either way.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [sent, expires_at]
|
|
properties:
|
|
sent: { type: boolean, const: true }
|
|
expires_at: { type: string, format: date-time }
|
|
'400':
|
|
description: A valid email is required (bad_request).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'403':
|
|
description: Local session login is disabled on this deployment (local_auth_disabled).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'415':
|
|
description: Request body was not application/json.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'429':
|
|
description: A code for this recipient was requested too recently (otp_resend_cooldown).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'502':
|
|
$ref: '#/components/responses/MailUndeliverable'
|
|
|
|
/api/v1/auth/email/verify:
|
|
post:
|
|
tags: [auth]
|
|
operationId: loginEmailVerify
|
|
summary: Redeem an email-OTP login code into a session (spec §B).
|
|
description: >-
|
|
Public, pre-session: resolves the address to an account, verifies the code
|
|
under the login purpose, and on success mints a host-only felis_session. An
|
|
unknown address, a wrong or expired code, and an attempt-exhausted code all
|
|
return the IDENTICAL 400 invalid_code, so the door is not an existence or
|
|
lockout oracle. Staff are refused (403) — but only AFTER a valid code is
|
|
redeemed, so only the account owner can ever reach that refusal.
|
|
x-felis-face: [external]
|
|
x-felis-tier: public
|
|
security: []
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [email, code]
|
|
properties:
|
|
email: { type: string, format: email }
|
|
code: { type: string }
|
|
responses:
|
|
'200':
|
|
description: Code accepted; a host-only session cookie is set on the response.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [user_id, role]
|
|
properties:
|
|
user_id: { type: string }
|
|
role: { type: string, enum: [user, admin] }
|
|
'400':
|
|
description: >-
|
|
A valid email and code are required (bad_request); or the code is wrong,
|
|
expired, or exhausted (invalid_code, uniform with an unknown address).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'403':
|
|
description: >-
|
|
Local session login is disabled (local_auth_disabled), or the account is
|
|
staff and must sign in at the operator console (staff_account).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'415':
|
|
description: Request body was not application/json.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
/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. Gated on
|
|
local_auth_enabled.
|
|
x-felis-face: [external]
|
|
x-felis-tier: public
|
|
security: []
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [email]
|
|
properties:
|
|
email: { type: string, format: email }
|
|
responses:
|
|
'202':
|
|
description: >-
|
|
Accepted (neutral): a request handle to poll. For a staff address a code
|
|
was mailed and the handle is real; otherwise the handle is a random no-op.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [request_id, expires_at]
|
|
properties:
|
|
request_id: { type: string }
|
|
expires_at: { type: string, format: date-time }
|
|
'400':
|
|
description: A valid email is required (bad_request).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'403':
|
|
description: Local session login is disabled on this deployment (local_auth_disabled).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'415':
|
|
description: Request body was not application/json.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'429':
|
|
description: A code for this recipient was requested too recently (otp_resend_cooldown).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'502':
|
|
$ref: '#/components/responses/MailUndeliverable'
|
|
|
|
/api/v1/auth/op-login/status/{id}:
|
|
get:
|
|
tags: [auth]
|
|
operationId: opLoginStatus
|
|
summary: Poll whether an op.console login request has been approved in-game (spec §B).
|
|
description: >-
|
|
Public, pre-session read the browser polls after start. Returns approved:true
|
|
only for a genuinely approved, live, unconsumed request; every other case —
|
|
unknown, expired, denied, or already-consumed handle — reads approved:false, so
|
|
a fabricated handle polls false forever and only an in-game admin vouch can flip
|
|
it true.
|
|
x-felis-face: [external]
|
|
x-felis-tier: public
|
|
security: []
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'200':
|
|
description: The approval state of the request handle.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [approved]
|
|
properties:
|
|
approved: { type: boolean }
|
|
'403':
|
|
description: Local session login is disabled on this deployment (local_auth_disabled).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
/api/v1/auth/op-login/finish:
|
|
post:
|
|
tags: [auth]
|
|
operationId: opLoginFinish
|
|
summary: Redeem an approved op.console request plus its mailed code into a staff session (spec §B).
|
|
description: >-
|
|
Public, pre-session final leg: mints a host-only staff session only when BOTH
|
|
factors have landed — the request is approved-and-live AND the mailed code
|
|
verifies. Every failure (unknown handle, not-yet-approved, wrong or locked code,
|
|
lost race) collapses into one uniform 400 op_login_invalid, so a code-less
|
|
caller learns nothing. Admin is re-asserted before the session is issued.
|
|
x-felis-face: [external]
|
|
x-felis-tier: public
|
|
security: []
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [request_id, code]
|
|
properties:
|
|
request_id: { type: string }
|
|
code: { type: string }
|
|
responses:
|
|
'200':
|
|
description: Both factors proven; a host-only session cookie is set on the response.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [user_id, role]
|
|
properties:
|
|
user_id: { type: string }
|
|
role: { type: string, enum: [user, admin] }
|
|
'400':
|
|
description: >-
|
|
request_id and code are required (bad_request); or the login could not be
|
|
completed — unknown handle, not approved, wrong or locked code, or lost
|
|
race, all uniform (op_login_invalid).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'403':
|
|
description: >-
|
|
Local session login is disabled (local_auth_disabled), or the resolved
|
|
account is not an operator (staff_account).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'415':
|
|
description: Request body was not application/json.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
/api/v1/auth/setup/redeem:
|
|
post:
|
|
tags: [auth]
|
|
operationId: setupRedeem
|
|
summary: Redeem a one-time setup token into a lockdown session (spec §B).
|
|
description: >-
|
|
Public, pre-session first-run door: consumes the one-time setup token minted by
|
|
the felis TUI (stored and looked up by SHA-256 hash, like session cookies),
|
|
mints a host-only felis_session, and returns the remaining setup steps so the
|
|
SPA can drive the wizard. An unknown, consumed, or expired token returns a
|
|
uniform 400 setup_token_invalid. Gated on local_auth_enabled.
|
|
x-felis-face: [external]
|
|
x-felis-tier: public
|
|
security: []
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [token]
|
|
properties:
|
|
token: { type: string }
|
|
responses:
|
|
'200':
|
|
description: Token redeemed; a session cookie is set and the setup state is returned.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [user_id, username, role, email, email_verified, has_passkey, setup_required]
|
|
properties:
|
|
user_id: { type: string }
|
|
username: { type: string }
|
|
role: { type: string, enum: [user, admin] }
|
|
email: { type: string }
|
|
email_verified: { type: boolean }
|
|
has_passkey: { type: boolean }
|
|
setup_required: { type: boolean }
|
|
'400':
|
|
description: >-
|
|
A token is required (bad_request), or it is unknown, already used, or
|
|
expired (setup_token_invalid).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'403':
|
|
description: Local session login is disabled on this deployment (local_auth_disabled).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'415':
|
|
description: Request body was not application/json.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
/api/v1/auth/setup/status:
|
|
get:
|
|
tags: [auth]
|
|
operationId: setupStatus
|
|
summary: Report the caller's own setup progress (spec §B).
|
|
description: >-
|
|
App-tier read the SPA polls after each setup wizard step (email verify, passkey
|
|
enroll) to decide whether the first-run lockdown can lift. It reads only the
|
|
principal's own state and is reachable during setup lockdown (the rest of the
|
|
API is fenced until setup completes).
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ sessionCookie: [] }]
|
|
responses:
|
|
'200':
|
|
description: The caller's current setup state.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [user_id, username, role, email, email_verified, has_passkey, setup_required]
|
|
properties:
|
|
user_id: { type: string }
|
|
username: { type: string }
|
|
role: { type: string, enum: [user, admin] }
|
|
email: { type: string }
|
|
email_verified: { type: boolean }
|
|
has_passkey: { type: boolean }
|
|
setup_required: { type: boolean }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'404':
|
|
description: The principal's user row was not found (not_found).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
/api/v1/auth/logout:
|
|
post:
|
|
tags: [auth]
|
|
operationId: logout
|
|
summary: Revoke the current local session and clear the cookie.
|
|
description: >-
|
|
Revokes the presented session and clears the cookie (spec §B). Mounted
|
|
Public and idempotent: it reads the cookie directly, so it works even when
|
|
the session has already expired and never errors on a missing one.
|
|
x-felis-face: [external]
|
|
x-felis-tier: public
|
|
security: []
|
|
responses:
|
|
'200':
|
|
description: Logged out (idempotent).
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [ok]
|
|
properties:
|
|
ok: { type: boolean, const: true }
|
|
|
|
/api/v1/auth/bind:
|
|
post:
|
|
tags: [auth]
|
|
operationId: bindRedeem
|
|
summary: Redeem a Bind Code into a player account + session (public console bootstrap, spec §10/§B).
|
|
description: >-
|
|
The one public, pre-account entrypoint of the player console
|
|
(console.<root_domain>): an account-less player redeems the one-time Bind
|
|
Code they generated in the in-game Login Lobby, and the platform creates their
|
|
player account (role=user), binds it to the verified in-game UUID, and mints a
|
|
host-only session cookie. Safe to expose unauthenticated because the code is
|
|
minted internal-face only, against an online-mode-verified UUID, with a short
|
|
TTL and single use — possession already proves control of a Minecraft identity.
|
|
An already-linked player UUID logs that player back in (idempotent); a UUID
|
|
that belongs to staff is refused (403) — operators authenticate at op.console
|
|
behind Zero Trust, so this never mints a session for an admin identity. Requires
|
|
local sessions to be enabled (same toggle as login).
|
|
x-felis-face: [external]
|
|
x-felis-tier: public
|
|
security: []
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [code]
|
|
properties:
|
|
code: { type: string }
|
|
responses:
|
|
'200':
|
|
description: Player account bootstrapped; the session cookie is set on the response.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [user_id, linked, mc_uuid, auth_source]
|
|
properties:
|
|
user_id: { type: string }
|
|
linked: { type: boolean, const: true }
|
|
mc_uuid: { type: string }
|
|
auth_source:
|
|
type: string
|
|
enum: [mojang, thirdparty]
|
|
description: The source captured at mint, copied onto the durable link.
|
|
'400':
|
|
description: Invalid or expired bind code.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'403':
|
|
description: Local sessions are disabled, or the code's UUID belongs to a staff account.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
/api/v1/me:
|
|
get:
|
|
tags: [servers]
|
|
operationId: me
|
|
summary: The caller's own identity and tier (drives panel navigation).
|
|
description: >-
|
|
Returns the authenticated principal's user id, email, role and the
|
|
server-computed is_admin (Principal.IsAdmin(): role admin reached via the
|
|
admin Access path). The panel reads this once at boot to decide which
|
|
surfaces to render. It is UX truth, not a security control — admin routes
|
|
are independently gated server-side, so a hidden nav item never widens
|
|
access.
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
responses:
|
|
'200':
|
|
description: The caller's identity.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [user_id, email, role, is_admin, is_owner, email_verified]
|
|
properties:
|
|
user_id: { type: string }
|
|
email: { type: string, format: email }
|
|
role:
|
|
type: string
|
|
enum: [user, admin, owner]
|
|
description: The principal's role, mirroring users.role.
|
|
is_admin:
|
|
type: boolean
|
|
description: >-
|
|
True only when role is admin or owner AND the request arrived
|
|
via the admin Access path (Principal.IsAdmin()).
|
|
is_owner:
|
|
type: boolean
|
|
description: >-
|
|
True only for the Owner principal on the admin Access path
|
|
(Principal.IsOwner()); gates owner-only panel surfaces.
|
|
email_verified:
|
|
type: boolean
|
|
description: >-
|
|
Whether the account's email has been verified; the panel
|
|
nudges unverified accounts through the email-OTP flow.
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
|
|
/api/v1/me/servers:
|
|
get:
|
|
tags: [servers]
|
|
operationId: myServers
|
|
summary: List the servers the caller owns or may claim.
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
responses:
|
|
'200':
|
|
description: The caller's server list.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [servers]
|
|
properties:
|
|
servers:
|
|
type: array
|
|
items: { $ref: '#/components/schemas/MyServerView' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
|
|
/api/v1/updates/window:
|
|
get:
|
|
tags: [admin-updates]
|
|
operationId: getUpdateWindow
|
|
summary: Read the SysAdmin-set auto-update maintenance window (admin).
|
|
description: >-
|
|
The single platform-wide maintenance window during which Felis may apply a
|
|
Scheduled component's update to itself (decision core internal/updates). An
|
|
unset window — never set, or explicitly cleared — reads back as
|
|
{start:null,end:null}. API+persistence only: nothing consumes the window
|
|
until the INTEGRATION runner and executors are wired, so setting it changes
|
|
no behavior yet.
|
|
x-felis-face: [external]
|
|
x-felis-tier: admin
|
|
security: [{ accessJWT: [] }]
|
|
responses:
|
|
'200':
|
|
description: The current maintenance window (both ends null when unset).
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: '#/components/schemas/UpdateWindow'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
put:
|
|
tags: [admin-updates]
|
|
operationId: setUpdateWindow
|
|
summary: Set or clear the SysAdmin auto-update maintenance window (admin).
|
|
description: >-
|
|
Persist the maintenance window as an absolute [start,end) interval. Both
|
|
ends must be set with end strictly after start, or both null to clear the
|
|
window to unset. A half-set (exactly one end) or inverted/empty (end not
|
|
after start) body is rejected 400, mirroring the decision core's fail-closed
|
|
Window so a malformed schedule can never be stored. No forced auto-update:
|
|
setting a window only permits an apply inside it; outside, a Scheduled
|
|
component degrades to notify.
|
|
x-felis-face: [external]
|
|
x-felis-tier: admin
|
|
security: [{ accessJWT: [] }]
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: '#/components/schemas/UpdateWindow'
|
|
responses:
|
|
'200':
|
|
description: The stored maintenance window (echoed back).
|
|
content:
|
|
application/json:
|
|
schema:
|
|
$ref: '#/components/schemas/UpdateWindow'
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
|
|
/api/v1/fleet:
|
|
get:
|
|
tags: [admin-servers]
|
|
operationId: fleet
|
|
summary: The SysAdmin cockpit's fleet-wide server list (admin; cockpit extension, not a spec §7 route).
|
|
description: >-
|
|
Every MinecraftServer's CRD+status lifecycle view, for the SysAdmin
|
|
FleetTable. Admin-tier — it reads every owner's server. A path distinct
|
|
from the internal velocity GET /api/v1/servers because one {method, path}
|
|
cannot carry both the service and admin tiers. Lifecycle is CRD truth (§1);
|
|
the owner is the only business field, joined READ-ONLY from Postgres (§6)
|
|
for display — best-effort, so a Postgres blip degrades to owner-less rows.
|
|
x-felis-face: [external]
|
|
x-felis-tier: admin
|
|
security: [{ accessJWT: [] }]
|
|
responses:
|
|
'200':
|
|
description: Every server's status projection (fleet-wide), each with its owner.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [servers]
|
|
properties:
|
|
servers:
|
|
type: array
|
|
items:
|
|
allOf:
|
|
- $ref: '#/components/schemas/ServerInfo'
|
|
- type: object
|
|
properties:
|
|
owner:
|
|
type: string
|
|
description: >-
|
|
The owner's display identity (email, or username when
|
|
the address is absent). Absent for an unclaimed server
|
|
or when the best-effort owner lookup failed.
|
|
system:
|
|
type: boolean
|
|
description: >-
|
|
True for a platform-provisioned system service (the login
|
|
gate, the lobby). Their reserved names are rejected by
|
|
every per-server route, so the cockpit renders them
|
|
read-only instead of offering actions that would 400.
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
|
|
/api/v1/backups:
|
|
get:
|
|
tags: [backups]
|
|
operationId: listBackups
|
|
summary: List world backups (admin sees all; a user sees only worlds they formerly owned).
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
responses:
|
|
'200':
|
|
description: Visible backups.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [backups]
|
|
properties:
|
|
backups:
|
|
type: array
|
|
items: { $ref: '#/components/schemas/BackupView' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
|
|
/api/v1/servers/{name}/restore-backup:
|
|
post:
|
|
tags: [backups]
|
|
operationId: restoreBackup
|
|
summary: Restore a world from a backup (owner-or-admin plus a former-owner match).
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
requestBody:
|
|
required: false
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
backup_id:
|
|
type: string
|
|
description: Which backup to restore; defaults to the latest for the server.
|
|
responses:
|
|
'202':
|
|
description: Restore started.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [name, status, backup_id]
|
|
properties:
|
|
name: { type: string }
|
|
status: { type: string, const: restoring }
|
|
backup_id: { type: string }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
description: No matching backup.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'409':
|
|
description: Submission has already been reviewed.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/servers/{name}/backup:
|
|
post:
|
|
tags: [backups]
|
|
operationId: backupNow
|
|
summary: Back up a server's data volume on demand (owner-or-admin; server must be stopped).
|
|
description: >-
|
|
Snapshots the server's whole data volume (worlds, config, plugins/mods,
|
|
jars, libraries — not just world folders) into the archive store as a
|
|
first-class world_backups row (reason "manual"), restorable later like an
|
|
inactivity backup. A restore replaces the volume with the archive. The world PVC is RWO and held by a running server, so the server must
|
|
be fully stopped first (409 not_stopped otherwise). The backup runs
|
|
asynchronously as a Job, so success is 202 (backing_up).
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'202':
|
|
description: Backup started.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [name, status]
|
|
properties:
|
|
name: { type: string }
|
|
status: { type: string, const: backing_up }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
description: Unknown server.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'409':
|
|
description: Server is not stopped (its world PVC is still mounted).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
# -------------------------------------------------- async job status (app) ---
|
|
/api/v1/servers/{name}/jobs:
|
|
get:
|
|
tags: [backups]
|
|
operationId: listServerJobs
|
|
summary: Latest async world operations (backup/restore) for a server (owner-or-admin).
|
|
description: >-
|
|
Backup and restore run as cluster Jobs, so a 202 that later failed left
|
|
its only trace in the Job object. This route projects the newest such
|
|
Jobs, newest first, so failures are observable without kubectl. State is
|
|
"running" | "succeeded" | "failed".
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'200':
|
|
description: The server's newest backup/restore jobs.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [server, jobs]
|
|
properties:
|
|
server: { type: string }
|
|
jobs:
|
|
type: array
|
|
items:
|
|
type: object
|
|
required: [name, kind, state]
|
|
properties:
|
|
name: { type: string }
|
|
kind: { type: string, enum: [backup, restore] }
|
|
state: { type: string, enum: [running, succeeded, failed] }
|
|
message: { type: string }
|
|
started_at: { type: string, format: date-time }
|
|
finished_at: { type: string, format: date-time }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
description: Unknown server.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
# ------------------------------------------------- server file editor (app) ---
|
|
/api/v1/servers/{name}/files:
|
|
get:
|
|
tags: [files]
|
|
operationId: listServerFiles
|
|
summary: List a directory in a server's world volume (owner-or-admin; server must be stopped).
|
|
description: >-
|
|
Lists one directory inside the server's world volume — the repair lever for a
|
|
server that will not boot because a config file is wrong. The world PVC is RWO
|
|
and held by a running server, so the server must be fully stopped first (409
|
|
not_stopped otherwise). The listing runs as a one-shot Job whose output is read
|
|
back through pods/log, so the call is synchronous but takes seconds rather than
|
|
milliseconds. Paths are resolved inside the world root by os.Root, so "..", an
|
|
absolute path, and a symlink leaving the root are all refused with 400 bad_path.
|
|
Listings are capped; truncated reports that the cap was hit.
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
- name: path
|
|
in: query
|
|
required: false
|
|
description: Directory to list, relative to the world root. Empty lists the root itself.
|
|
schema: { type: string }
|
|
responses:
|
|
'200':
|
|
description: Directory listing.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [path, entries, truncated]
|
|
properties:
|
|
path: { type: string }
|
|
truncated: { type: boolean, description: The listing hit the entry cap and is incomplete. }
|
|
entries:
|
|
type: array
|
|
items:
|
|
type: object
|
|
required: [name, size, is_dir, mod_time]
|
|
properties:
|
|
name: { type: string }
|
|
size: { type: integer, format: int64 }
|
|
is_dir: { type: boolean }
|
|
mod_time: { type: string, format: date-time }
|
|
'400':
|
|
description: Invalid server name, or a path that escapes the world root.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
description: Unknown server, or no such directory.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'409':
|
|
description: Server is not stopped (its world PVC is still mounted).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
'504':
|
|
description: The file Job did not finish in time; retry.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
/api/v1/servers/{name}/file:
|
|
get:
|
|
tags: [files]
|
|
operationId: readServerFile
|
|
summary: Read a file from a server's world volume (owner-or-admin; server must be stopped).
|
|
description: >-
|
|
Returns one file's bytes, base64-encoded, from inside the server's world
|
|
volume. Same stopped-gate and os.Root containment as the directory listing.
|
|
Reads are capped at 1 MiB; a larger file is 413 rather than a truncated read,
|
|
because a config editor that silently returned half a file would let a
|
|
subsequent save destroy the other half.
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
- name: path
|
|
in: query
|
|
required: true
|
|
description: File to read, relative to the world root.
|
|
schema: { type: string }
|
|
responses:
|
|
'200':
|
|
description: File contents.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [path, content]
|
|
properties:
|
|
path: { type: string }
|
|
content: { type: string, format: byte, description: Base64-encoded file bytes. }
|
|
'400':
|
|
description: Missing path, invalid server name, or a path that escapes the world root.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
description: Unknown server, or no such file.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'409':
|
|
description: Server is not stopped (its world PVC is still mounted).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'413':
|
|
description: The file is larger than the editor reads.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
'504':
|
|
description: The file Job did not finish in time; retry.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
put:
|
|
tags: [files]
|
|
operationId: writeServerFile
|
|
summary: Write a file in a server's world volume (owner-or-admin; server must be stopped).
|
|
description: >-
|
|
Replaces a file's contents, creating the file if absent but never creating its
|
|
parent directories. Content is base64 so arbitrary bytes (CRLF endings, a BOM)
|
|
survive intact. Writes are capped at 256 KiB — the Job spec carries the content,
|
|
and etcd bounds the object — so a larger body is 413. Same stopped-gate and
|
|
os.Root containment as the read; a write through a symlink leaving the world
|
|
root is refused. Audited as file.write.
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
- name: path
|
|
in: query
|
|
required: true
|
|
description: File to write, relative to the world root.
|
|
schema: { type: string }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [content]
|
|
properties:
|
|
content: { type: string, format: byte, description: Base64-encoded file bytes. }
|
|
responses:
|
|
'200':
|
|
description: File written.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [path, status]
|
|
properties:
|
|
path: { type: string }
|
|
status: { type: string, const: written }
|
|
'400':
|
|
description: Missing path, malformed body, invalid server name, or a path that escapes the world root.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
description: Unknown server, or the parent directory does not exist.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'409':
|
|
description: Server is not stopped (its world PVC is still mounted).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'413':
|
|
description: The content is larger than the editor writes.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
'504':
|
|
description: The file Job did not finish in time; retry.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
# ------------------------------------------------------ users (admin tier) ----
|
|
/api/v1/users:
|
|
get:
|
|
tags: [users]
|
|
operationId: listUsers
|
|
summary: List users (admin only).
|
|
description: >-
|
|
Returns a page of non-deleted users matching optional query filters, newest
|
|
first. Every route under /users gates on the admin Zero-Trust path.
|
|
x-felis-face: [external]
|
|
x-felis-tier: owner
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: query, in: query, required: false, schema: { type: string }, description: Substring match on username or email }
|
|
- { name: role, in: query, required: false, schema: { type: string, enum: [admin, user] } }
|
|
- { name: disabled, in: query, required: false, schema: { type: string, enum: ["true", "false"] } }
|
|
- { name: limit, in: query, required: false, schema: { type: integer, default: 20, maximum: 100 } }
|
|
- { name: offset, in: query, required: false, schema: { type: integer, default: 0 } }
|
|
responses:
|
|
'200':
|
|
description: A page of users plus the total unfiltered count.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [users, total]
|
|
properties:
|
|
users:
|
|
type: array
|
|
items: { $ref: '#/components/schemas/UserView' }
|
|
total: { type: integer }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
post:
|
|
tags: [users]
|
|
operationId: createUser
|
|
summary: Create a user (admin only).
|
|
x-felis-face: [external]
|
|
x-felis-tier: owner
|
|
security: [{ accessJWT: [] }]
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [username, role]
|
|
description: >-
|
|
Passwordless: the new account signs in via the session doors
|
|
(email-OTP / passkey / bind code); no credential is set here.
|
|
properties:
|
|
username: { type: string }
|
|
email: { type: string, format: email }
|
|
role: { type: string, enum: [admin, user] }
|
|
responses:
|
|
'201':
|
|
description: User created.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/UserView' }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'409':
|
|
description: Username already taken.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
/api/v1/users/{id}:
|
|
get:
|
|
tags: [users]
|
|
operationId: getUser
|
|
summary: Get user detail (admin only).
|
|
x-felis-face: [external]
|
|
x-felis-tier: owner
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'200':
|
|
description: Full user detail including linked MC accounts.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/UserDetail' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
patch:
|
|
tags: [users]
|
|
operationId: patchUser
|
|
summary: Edit a user (admin only, cannot patch self).
|
|
x-felis-face: [external]
|
|
x-felis-tier: owner
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
username: { type: string }
|
|
email: { type: string, format: email }
|
|
role: { type: string, enum: [admin, user] }
|
|
responses:
|
|
'200':
|
|
description: Updated user.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/UserView' }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'409':
|
|
description: Username conflict.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
delete:
|
|
tags: [users]
|
|
operationId: deleteUser
|
|
summary: Soft-delete a user — releases servers, revokes sessions (admin only, cannot delete self).
|
|
x-felis-face: [external]
|
|
x-felis-tier: owner
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'200':
|
|
description: User soft-deleted.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [deleted]
|
|
properties:
|
|
deleted: { type: boolean, const: true }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
|
|
/api/v1/users/{id}/disable:
|
|
post:
|
|
tags: [users]
|
|
operationId: disableUser
|
|
summary: Disable or re-enable a user (admin only, cannot disable self).
|
|
description: >-
|
|
Disabling a user additionally revokes every live session so the lockout is
|
|
immediate. Re-enabling simply clears the flag.
|
|
x-felis-face: [external]
|
|
x-felis-tier: owner
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [disabled]
|
|
properties:
|
|
disabled: { type: boolean }
|
|
responses:
|
|
'200':
|
|
description: Toggle applied.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [id, disabled]
|
|
properties:
|
|
id: { type: string }
|
|
disabled: { type: boolean }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
|
|
/api/v1/users/{id}/quotas:
|
|
get:
|
|
tags: [users]
|
|
operationId: getQuotas
|
|
summary: Get a user's quotas (admin only).
|
|
x-felis-face: [external]
|
|
x-felis-tier: owner
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'200':
|
|
description: The user's current quotas (null=unlimited).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/QuotaView' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
put:
|
|
tags: [users]
|
|
operationId: setQuotas
|
|
summary: Set a user's quotas (admin only).
|
|
x-felis-face: [external]
|
|
x-felis-tier: owner
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
max_servers: { type: integer, nullable: true }
|
|
max_cpu_milli: { type: integer, nullable: true }
|
|
max_memory_mb: { type: integer, nullable: true }
|
|
max_storage_gb: { type: integer, nullable: true }
|
|
responses:
|
|
'200':
|
|
description: Quotas updated.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/QuotaView' }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
|
|
/api/v1/users/{id}/sessions:
|
|
get:
|
|
tags: [users]
|
|
operationId: listUserSessions
|
|
summary: List a user's live sessions (admin only).
|
|
x-felis-face: [external]
|
|
x-felis-tier: owner
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'200':
|
|
description: Live (unrevoked, unexpired) sessions, newest first.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [sessions]
|
|
properties:
|
|
sessions:
|
|
type: array
|
|
items: { $ref: '#/components/schemas/SessionView' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
delete:
|
|
tags: [users]
|
|
operationId: revokeUserSessions
|
|
summary: Revoke every live session of a user (admin only).
|
|
x-felis-face: [external]
|
|
x-felis-tier: owner
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'200':
|
|
description: All sessions revoked.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [ok]
|
|
properties:
|
|
ok: { type: boolean, const: true }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
|
|
/api/v1/users/{id}/sessions/{hash}:
|
|
delete:
|
|
tags: [users]
|
|
operationId: revokeUserSession
|
|
summary: Revoke a single session of a user (admin only).
|
|
x-felis-face: [external]
|
|
x-felis-tier: owner
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
- { name: hash, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'200':
|
|
description: Session revoked.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [ok]
|
|
properties:
|
|
ok: { type: boolean, const: true }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
|
|
/api/v1/users/{id}/passkeys:
|
|
delete:
|
|
tags: [users]
|
|
operationId: unbindUserPasskeys
|
|
summary: Unbind every passkey of a user (owner only) — authenticator remediation.
|
|
description: >-
|
|
Severs a compromised or planted authenticator that would otherwise outlive a
|
|
session revoke. A complete remediation pairs this with revoking the user's
|
|
sessions (DELETE /users/{id}/sessions/{hash}): unbinding the credential alone
|
|
leaves the live hijacked session, and revoking sessions alone leaves a
|
|
re-enrollable credential. It is not a lockout — the account re-enters via the
|
|
email-OTP door or op-login and re-enrolls. Removing zero passkeys is a 200
|
|
no-op, not a 404.
|
|
x-felis-face: [external]
|
|
x-felis-tier: owner
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'200':
|
|
description: All passkeys unbound (a no-op 200 when the user had none).
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [ok]
|
|
properties:
|
|
ok: { type: boolean, const: true }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
|
|
/api/v1/users/{id}/links:
|
|
post:
|
|
tags: [users]
|
|
operationId: linkAccount
|
|
summary: Force-link a Minecraft UUID to a user, bypassing the code-verification flow (admin only).
|
|
description: >-
|
|
The UUID must not already be bound to a different user (409). Same (user, uuid)
|
|
pair is idempotent (200). When auth_source is omitted it is derived from the
|
|
UUID's version nibble exactly as on the mint path (v3 → thirdparty, else
|
|
mojang), so a force-linked thirdparty account keeps its reclaim-guard
|
|
protection.
|
|
x-felis-face: [external]
|
|
x-felis-tier: owner
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [mc_uuid]
|
|
properties:
|
|
mc_uuid: { type: string, format: uuid }
|
|
auth_source: { type: string, enum: [mojang, thirdparty], default: mojang }
|
|
responses:
|
|
'200':
|
|
description: UUID linked (or was already linked to this user).
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [ok, mc_uuid, auth_source]
|
|
properties:
|
|
ok: { type: boolean, const: true }
|
|
mc_uuid: { type: string, format: uuid }
|
|
auth_source: { type: string }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'409':
|
|
description: UUID is already linked to a different user.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
/api/v1/users/{id}/links/{mc_uuid}:
|
|
delete:
|
|
tags: [users]
|
|
operationId: unlinkAccount
|
|
summary: Remove a single Minecraft UUID binding from a user (admin only).
|
|
x-felis-face: [external]
|
|
x-felis-tier: owner
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
- { name: mc_uuid, in: path, required: true, schema: { type: string, format: uuid } }
|
|
responses:
|
|
'200':
|
|
description: UUID unlinked.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [ok, mc_uuid]
|
|
properties:
|
|
ok: { type: boolean, const: true }
|
|
mc_uuid: { type: string, format: uuid }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
description: No linked account for this UUID.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
/api/v1/account/link/start:
|
|
post:
|
|
tags: [account]
|
|
operationId: linkStart
|
|
summary: Report account-link status and in-game instructions (web side, spec §10).
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
responses:
|
|
'200':
|
|
description: Current link status.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [linked, instructions]
|
|
properties:
|
|
linked: { type: boolean }
|
|
instructions: { type: string }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
|
|
/api/v1/account/link/verify:
|
|
post:
|
|
tags: [account]
|
|
operationId: linkVerify
|
|
summary: Consume an in-game link code and bind the account.
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [code]
|
|
properties:
|
|
code: { type: string }
|
|
responses:
|
|
'200':
|
|
description: Linked.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [linked, mc_uuid, auth_source]
|
|
properties:
|
|
linked: { type: boolean, const: true }
|
|
mc_uuid: { type: string }
|
|
auth_source:
|
|
type: string
|
|
enum: [mojang, thirdparty]
|
|
description: The source captured at mint, copied onto the durable link.
|
|
'400':
|
|
description: Invalid or expired code.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'409':
|
|
description: Account already linked.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
/api/v1/account/email/start:
|
|
post:
|
|
tags: [account]
|
|
operationId: emailOtpStart
|
|
summary: Mint and deliver an email one-time code for the caller (web onboarding, spec §B2).
|
|
description: >
|
|
Generates a one-time code bound to the authenticated principal and the
|
|
supplied address, persists only its hash, and delivers it out of band. The
|
|
code is never returned in the response. A re-request supersedes the prior
|
|
unconsumed code.
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [email]
|
|
properties:
|
|
email: { type: string, format: email }
|
|
responses:
|
|
'202':
|
|
description: Code minted and dispatched (or logged server-side when no mailer is wired).
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [sent, expires_at]
|
|
properties:
|
|
sent: { type: boolean, const: true }
|
|
expires_at: { type: string, format: date-time }
|
|
'400':
|
|
description: Missing or malformed email address.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'502':
|
|
$ref: '#/components/responses/MailUndeliverable'
|
|
|
|
/api/v1/account/email/verify:
|
|
post:
|
|
tags: [account]
|
|
operationId: emailOtpVerify
|
|
summary: Redeem an email one-time code and mark the caller's email verified (spec §B2).
|
|
description: >
|
|
Consumes a previously delivered code for the authenticated principal. On
|
|
success the user's email is written and email_verified is set true. Too many
|
|
incorrect attempts lock the code (429); an unknown, expired, consumed, or
|
|
mismatched code is a 400.
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [code]
|
|
properties:
|
|
code: { type: string }
|
|
responses:
|
|
'200':
|
|
description: Email verified.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [verified, email]
|
|
properties:
|
|
verified: { type: boolean, const: true }
|
|
email: { type: string, format: email }
|
|
'400':
|
|
description: Invalid or expired code.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'429':
|
|
description: Too many incorrect attempts; the code is locked.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
/api/v1/account/email:
|
|
post:
|
|
tags: [account]
|
|
operationId: setEmail
|
|
summary: Record the caller's email WITHOUT verifying it (setup bootstrap, spec §B2).
|
|
description: >
|
|
Writes the supplied address to the authenticated principal's user row and
|
|
clears email_verified (already false for a fresh Owner). The setup bootstrap
|
|
has no SMTP, so the Owner cannot receive an emailed code; a later Settings/SMTP
|
|
flow proves control of the address via /account/email/verify.
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [email]
|
|
properties:
|
|
email: { type: string, format: email }
|
|
responses:
|
|
'200':
|
|
description: Email recorded (unverified).
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [email]
|
|
properties:
|
|
email: { type: string, format: email }
|
|
'400':
|
|
description: A valid email is required.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
|
|
/api/v1/account/passkey/register/begin:
|
|
post:
|
|
tags: [account]
|
|
operationId: passkeyRegisterBegin
|
|
summary: Begin a passkey (WebAuthn) registration ceremony for the caller (spec §14, Phase 6 bind).
|
|
description: >
|
|
Mints a credential-creation challenge bound to the authenticated principal,
|
|
stashes the server-side ceremony state under a short TTL, and returns the
|
|
WebAuthn publicKey creation options for navigator.credentials.create(). The
|
|
challenge is never echoed by the client. Enrollment only — passkey login is a
|
|
deferred slice. 503 when the WebAuthn verifier is not configured on this instance.
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
responses:
|
|
'200':
|
|
description: "WebAuthn credential-creation options (the publicKey document)."
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
description: Opaque WebAuthn PublicKeyCredentialCreationOptions, passed verbatim to the browser.
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'503':
|
|
description: Passkey subsystem is not configured.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
/api/v1/account/passkey/register/finish:
|
|
post:
|
|
tags: [account]
|
|
operationId: passkeyRegisterFinish
|
|
summary: Finish a passkey registration ceremony and bind the credential (spec §14, Phase 6 bind).
|
|
description: >
|
|
Consumes the caller's live registration challenge (single-use), verifies the
|
|
authenticator's attestation against the server-stashed ceremony state, and
|
|
persists the public credential. A missing or expired ceremony is a 400; an
|
|
attestation that fails verification is a 400; a credential already bound to any
|
|
account is a 409. 503 when the WebAuthn verifier is not configured.
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [attestation]
|
|
properties:
|
|
name: { type: string, description: Human nickname for the passkey (e.g. "My phone"). }
|
|
attestation:
|
|
type: object
|
|
description: The raw navigator.credentials.create() result the browser posts back.
|
|
responses:
|
|
'201':
|
|
description: Passkey bound.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/PasskeyCredential' }
|
|
'400':
|
|
description: No live ceremony, or the attestation could not be verified.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'409':
|
|
description: This passkey is already bound to an account.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
description: Passkey subsystem is not configured.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
/api/v1/account/passkey/credentials:
|
|
get:
|
|
tags: [account]
|
|
operationId: passkeyList
|
|
summary: List the passkeys the caller has bound (spec §14, Phase 6 bind).
|
|
description: >
|
|
Returns the authenticated principal's own bound passkeys, newest first, as
|
|
display projections (never the public key). Reading the credential list does
|
|
not need the WebAuthn verifier, so it succeeds even where begin/finish report 503.
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
responses:
|
|
'200':
|
|
description: The caller's bound passkeys.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [credentials]
|
|
properties:
|
|
credentials:
|
|
type: array
|
|
items: { $ref: '#/components/schemas/PasskeyCredential' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
|
|
/api/v1/account/passkey/credentials/{id}:
|
|
delete:
|
|
tags: [account]
|
|
operationId: passkeyDelete
|
|
summary: Unbind one of the caller's passkeys (spec §14, Phase 6 bind).
|
|
description: >
|
|
Removes a passkey scoped to the authenticated principal, so a caller can only
|
|
unbind their OWN credential. An unknown or cross-user id is a 404; it never
|
|
silently no-ops as success.
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- name: id
|
|
in: path
|
|
required: true
|
|
schema: { type: string }
|
|
description: The passkey row id (from the credential list).
|
|
responses:
|
|
'204':
|
|
description: Passkey unbound.
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'404':
|
|
description: No such passkey for this caller.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
/api/v1/account/migrate:
|
|
get:
|
|
tags: [account]
|
|
operationId: migrateStatus
|
|
summary: Report the caller's active account-migration and where it is in the flow (spec §B3 inherit, web side).
|
|
description: >
|
|
Read-only. Returns the live migration whose source is the authenticated
|
|
principal, if any, so the web onboarding can resume the flow: whether a
|
|
confirmation step-up is still needed, which factor confirmed it, the named
|
|
target, and the one-time code's expiry once issued. active:false when the
|
|
caller has no live migration.
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
responses:
|
|
'200':
|
|
description: The caller's live migration, or active:false.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [active]
|
|
properties:
|
|
active: { type: boolean }
|
|
state:
|
|
type: string
|
|
enum: [initiated, confirmed, code_issued]
|
|
description: Present only when active; a redeemed migration is terminal and not reported here.
|
|
target_user_id: { type: string }
|
|
confirm_factor:
|
|
type: string
|
|
enum: [passkey, email_otp]
|
|
code_expires_at: { type: string, format: date-time }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
|
|
/api/v1/account/migrate/confirm/otp/start:
|
|
post:
|
|
tags: [account]
|
|
operationId: migrateConfirmOtpStart
|
|
summary: Send a fresh email one-time code to confirm control of the migrating source account (spec §B3 step-up).
|
|
description: >
|
|
Opens the email-OTP confirmation factor for the caller's initiated migration.
|
|
This is a FRESH step-up bound to the migrate purpose, never mere session
|
|
possession. If the account has ANY passkey enrolled, email-OTP is refused with
|
|
409 passkey_required — the stronger factor is forced. The code is delivered out
|
|
of band and never returned; requires a verified email on the account.
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
responses:
|
|
'202':
|
|
description: Confirmation code minted and dispatched.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [sent, expires_at]
|
|
properties:
|
|
sent: { type: boolean, const: true }
|
|
expires_at: { type: string, format: date-time }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'404':
|
|
description: The caller has no initiated migration to confirm (no_migration).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'409':
|
|
description: >
|
|
A passkey is enrolled so email-OTP is forbidden (passkey_required); the
|
|
migration is already confirmed (already_confirmed); or the account has no
|
|
email step-up factor (no_step_up_factor).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'429':
|
|
description: Resend requested before the cooldown elapsed.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'502':
|
|
$ref: '#/components/responses/MailUndeliverable'
|
|
|
|
/api/v1/account/migrate/confirm/otp/verify:
|
|
post:
|
|
tags: [account]
|
|
operationId: migrateConfirmOtpVerify
|
|
summary: Redeem the email one-time code and confirm the migration (spec §B3 step-up).
|
|
description: >
|
|
Consumes the fresh migrate-purpose email code for the caller's initiated
|
|
migration and advances it to confirmed with confirm_factor email_otp. Too many
|
|
wrong attempts lock the code (429 otp_locked); an unknown, expired, consumed, or
|
|
mismatched code is a 400 invalid_code.
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [code]
|
|
properties:
|
|
code: { type: string }
|
|
responses:
|
|
'200':
|
|
description: Migration confirmed.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [confirmed]
|
|
properties:
|
|
confirmed: { type: boolean, const: true }
|
|
'400':
|
|
description: Invalid or expired code (invalid_code).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'404':
|
|
description: The caller has no initiated migration to confirm (no_migration).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'409':
|
|
description: The migration is already confirmed (already_confirmed).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'429':
|
|
description: The code is locked after too many wrong attempts (otp_locked).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
/api/v1/account/migrate/confirm/passkey/begin:
|
|
post:
|
|
tags: [account]
|
|
operationId: migrateConfirmPasskeyBegin
|
|
summary: Begin a fresh passkey assertion to confirm control of the migrating source account (spec §B3 step-up).
|
|
description: >
|
|
Returns WebAuthn assertion request options for the caller's own enrolled
|
|
passkeys, bound to a fresh migrate-purpose challenge. This is the forced factor
|
|
whenever a passkey exists. The finish call proves the assertion and, exactly as
|
|
the login door does, runs the clone-signal (sign-count) check before confirming.
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
responses:
|
|
'200':
|
|
description: WebAuthn assertion request options (PublicKeyCredentialRequestOptions) for navigator.credentials.get.
|
|
content:
|
|
application/json:
|
|
schema: { type: object, description: Opaque WebAuthn PublicKeyCredentialRequestOptions. }
|
|
'400':
|
|
description: The caller has no enrolled passkey (no_passkey).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'404':
|
|
description: The caller has no initiated migration to confirm (no_migration).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/account/migrate/confirm/passkey/finish:
|
|
post:
|
|
tags: [account]
|
|
operationId: migrateConfirmPasskeyFinish
|
|
summary: Finish the passkey assertion and confirm the migration (spec §B3 step-up).
|
|
description: >
|
|
Verifies the WebAuthn assertion against the fresh migrate-purpose challenge and,
|
|
like the login door, applies the authenticator sign-count clone check: a cloned
|
|
authenticator is rejected fail-closed (400 passkey_login_invalid) and audited. On
|
|
success the migration advances to confirmed with confirm_factor passkey.
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [assertion]
|
|
properties:
|
|
assertion:
|
|
type: object
|
|
description: The navigator.credentials.get() PublicKeyCredential assertion.
|
|
responses:
|
|
'200':
|
|
description: Migration confirmed.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [confirmed]
|
|
properties:
|
|
confirmed: { type: boolean, const: true }
|
|
'400':
|
|
description: Assertion invalid, challenge stale, or a cloned authenticator was detected (passkey_login_invalid).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'404':
|
|
description: The caller has no initiated migration to confirm (no_migration).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/account/migrate/issue-code:
|
|
post:
|
|
tags: [account]
|
|
operationId: migrateIssueCode
|
|
summary: Name the target account and mint the one-time migration code (spec §B3 inherit).
|
|
description: >
|
|
For a confirmed migration, binds the named target account and mints a single
|
|
one-time code (only its hash is stored) that the target must redeem while logged
|
|
in AS that target — an intercepted code is useless to anyone else. The target
|
|
must exist and be neither disabled nor soft-deleted, and cannot be the source.
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [target_user_id]
|
|
properties:
|
|
target_user_id: { type: string }
|
|
responses:
|
|
'201':
|
|
description: Code minted, bound to the named target.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [code, expires_at]
|
|
properties:
|
|
code: { type: string }
|
|
expires_at: { type: string, format: date-time }
|
|
'400':
|
|
description: >
|
|
The target is the source itself (invalid_target), does not exist
|
|
(target_not_found), or is disabled/retired (target_unavailable).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'404':
|
|
description: The caller has no migration to issue against (no_migration).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'409':
|
|
description: The migration has not been confirmed by a step-up yet (not_confirmed).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
|
|
/api/v1/account/migrate/redeem:
|
|
post:
|
|
tags: [account]
|
|
operationId: migrateRedeem
|
|
summary: Redeem a migration code as the named target and inherit the source's owned servers (spec §B3 inherit).
|
|
description: >
|
|
The authenticated caller — who must be the target named at issue time — spends
|
|
the one-time code. In a single atomic step the source's owned servers are
|
|
re-pointed to the caller and the source account is retired (disabled and
|
|
soft-deleted), which also spends the code so it cannot be replayed. The caller
|
|
keeps its own in-game identity and credentials; only server ownership moves.
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [code]
|
|
properties:
|
|
code: { type: string }
|
|
responses:
|
|
'200':
|
|
description: Migration redeemed; owned servers moved to the caller.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [migrated, servers_moved, servers]
|
|
properties:
|
|
migrated: { type: boolean, const: true }
|
|
servers_moved: { type: integer, format: int32 }
|
|
servers:
|
|
type: array
|
|
items: { type: string }
|
|
'400':
|
|
description: Unknown, expired, or already-spent code, or the caller is not the named target (invalid_code).
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
|
|
/api/v1/me/submissions:
|
|
post:
|
|
tags: [submissions]
|
|
operationId: createSubmission
|
|
summary: Submit a modpack for admin review (user side; user-directed lane over §16). Starts no build.
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [display_name]
|
|
description: >-
|
|
Only display_name is accepted; the submitter is taken from the
|
|
principal and the build inputs are platform-derived. Unknown
|
|
fields (e.g. submitted_by, context_ref) are rejected with 400.
|
|
properties:
|
|
display_name: { type: string }
|
|
responses:
|
|
'201':
|
|
description: Submission recorded, pending review.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Submission' }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
get:
|
|
tags: [submissions]
|
|
operationId: mySubmissions
|
|
summary: List the caller's own modpack submissions with each linked build's outcome (user-directed lane over §16).
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
responses:
|
|
'200':
|
|
description: >-
|
|
The caller's submissions, newest first; rows with a linked build
|
|
additionally carry build_status/build_error so the submitter can see
|
|
whether their build succeeded or failed (and why).
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [submissions]
|
|
properties:
|
|
submissions:
|
|
type: array
|
|
items: { $ref: '#/components/schemas/Submission' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/me/submissions/{id}/context:
|
|
post:
|
|
tags: [submissions]
|
|
operationId: uploadSubmissionContext
|
|
summary: Upload the modpack build context for your own pending submission (user side; user-directed lane over §16).
|
|
description: >-
|
|
The request body IS the raw gzip build context (context.tar.gz) — not
|
|
JSON, not multipart — streamed to the platform-derived, id-namespaced
|
|
location Kaniko reads via --context. The submitter is taken from the
|
|
principal; a submission the caller does not own is reported as 404, so
|
|
this endpoint cannot upload to or probe another user's submission. Only a
|
|
pending_review submission accepts a context (409 otherwise); a wrong-format
|
|
or oversize body is rejected with 400. Returns 503 when the deployment's
|
|
context store has no implemented upload transport.
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/gzip:
|
|
schema: { type: string, format: binary }
|
|
responses:
|
|
'200':
|
|
description: Context stored; the submission (unchanged) is returned.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Submission' }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'409':
|
|
$ref: '#/components/responses/Conflict'
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
# ------------------------------------------------------ external: admin ----
|
|
/api/v1/servers/{name}:
|
|
patch:
|
|
tags: [admin-servers]
|
|
operationId: patchServer
|
|
summary: Mutate a server spec (admin). Storage is immutable.
|
|
x-felis-face: [external]
|
|
x-felis-tier: admin
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: name, in: path, required: true, schema: { type: string } }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
description: Only the supplied fields are patched; an empty patch is rejected.
|
|
properties:
|
|
display_name: { type: string }
|
|
autostart_policy: { type: string }
|
|
image: { type: string }
|
|
memory: { type: string }
|
|
storage:
|
|
type: string
|
|
description: Rejected with 400 storage_immutable — present for a clear error, not mutation.
|
|
resources:
|
|
type: object
|
|
properties:
|
|
cpu: { type: string }
|
|
cpu_request: { type: string }
|
|
memory: { type: string }
|
|
memory_request: { type: string }
|
|
responses:
|
|
'200':
|
|
description: Patched.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [name, patched]
|
|
properties:
|
|
name: { type: string }
|
|
patched:
|
|
type: array
|
|
items: { type: string }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
|
|
/api/v1/images/build:
|
|
post:
|
|
tags: [images]
|
|
operationId: buildImage
|
|
summary: Submit an image build (admin). A build is build-time RCE against the cluster.
|
|
x-felis-face: [external]
|
|
x-felis-tier: admin
|
|
security: [{ accessJWT: [] }]
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [image_ref, dockerfile, context_ref]
|
|
properties:
|
|
image_ref:
|
|
type: string
|
|
description: Push target under the internal registry (e.g. registry.felis.svc:5000/foo:1.0).
|
|
dockerfile:
|
|
type: string
|
|
description: >-
|
|
Audit archive of the recipe, recorded on the build row and shown in the
|
|
panel — the executed Dockerfile is the file named `Dockerfile` at the
|
|
root of the context tarball (Kaniko runs --dockerfile=Dockerfile), so
|
|
this field is never executed.
|
|
context_ref:
|
|
type: string
|
|
description: >-
|
|
Location of the uploaded gzip build context; its root must contain the
|
|
Dockerfile that gets executed.
|
|
base_image:
|
|
type: string
|
|
description: Resolved FROM, recorded for audit only — not a build gate.
|
|
responses:
|
|
'202':
|
|
description: Build accepted.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Build' }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/images/build/{id}:
|
|
get:
|
|
tags: [images]
|
|
operationId: getBuild
|
|
summary: Get one build's status (admin).
|
|
x-felis-face: [external]
|
|
x-felis-tier: admin
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'200':
|
|
description: The build.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Build' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/images/build/{id}/logs:
|
|
get:
|
|
tags: [images]
|
|
operationId: buildLogs
|
|
summary: Stream a build's Job log over SSE (admin, spec §16 / §416).
|
|
x-felis-face: [external]
|
|
x-felis-tier: admin
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'200':
|
|
description: An event stream of build log lines.
|
|
content:
|
|
text/event-stream:
|
|
schema: { type: string }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/images/build/{id}/cancel:
|
|
post:
|
|
tags: [images]
|
|
operationId: cancelBuild
|
|
summary: Cancel a running build (admin).
|
|
x-felis-face: [external]
|
|
x-felis-tier: admin
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'200':
|
|
description: The build after cancellation.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Build' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'409':
|
|
description: Build already terminal.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/images:
|
|
get:
|
|
tags: [images]
|
|
operationId: listImages
|
|
summary: List whitelisted images (admin).
|
|
x-felis-face: [external]
|
|
x-felis-tier: admin
|
|
security: [{ accessJWT: [] }]
|
|
responses:
|
|
'200':
|
|
description: The image whitelist.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [images]
|
|
properties:
|
|
images:
|
|
type: array
|
|
items: { $ref: '#/components/schemas/Image' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
post:
|
|
tags: [images]
|
|
operationId: addImage
|
|
summary: Whitelist an externally-built image by reference (admin).
|
|
x-felis-face: [external]
|
|
x-felis-tier: admin
|
|
security: [{ accessJWT: [] }]
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [image_ref]
|
|
properties:
|
|
image_ref: { type: string }
|
|
responses:
|
|
'201':
|
|
description: Image whitelisted.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Image' }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
delete:
|
|
tags: [images]
|
|
operationId: removeImage
|
|
summary: Remove an image from the whitelist by reference (admin).
|
|
x-felis-face: [external]
|
|
x-felis-tier: admin
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: ref, in: query, required: true, schema: { type: string } }
|
|
responses:
|
|
'204':
|
|
$ref: '#/components/responses/NoContent'
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/submissions:
|
|
get:
|
|
tags: [submissions]
|
|
operationId: listSubmissions
|
|
summary: The admin review queue — every user's modpack submissions (admin; user-directed lane over §16).
|
|
x-felis-face: [external]
|
|
x-felis-tier: admin
|
|
security: [{ accessJWT: [] }]
|
|
responses:
|
|
'200':
|
|
description: All submissions, newest first.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [submissions]
|
|
properties:
|
|
submissions:
|
|
type: array
|
|
items: { $ref: '#/components/schemas/Submission' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/submissions/{id}/approve:
|
|
post:
|
|
tags: [submissions]
|
|
operationId: approveSubmission
|
|
summary: >-
|
|
Approve a submission and start its Trivy-gated build (admin; user-directed lane over §16).
|
|
Approval is layered in front of the scan, never instead of it.
|
|
x-felis-face: [external]
|
|
x-felis-tier: admin
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'200':
|
|
description: The approved submission, with the linked build id.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Submission' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'409':
|
|
description: Submission has already been reviewed.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/submissions/{id}/context:
|
|
get:
|
|
tags: [submissions]
|
|
operationId: downloadSubmissionContext
|
|
summary: Download a submission's uploaded build context (admin; user-directed lane over §16).
|
|
description: >-
|
|
The reviewer's read path to the artifact they are about to approve: the
|
|
executed Dockerfile lives inside this tarball (Kaniko runs the context's
|
|
root `Dockerfile`), so without it the human gate would be blind. Streams
|
|
the stored context.tar.gz verbatim with an attachment disposition — the
|
|
same bytes the build Pod fetches over the internal face. 404 when the
|
|
submission is unknown or has no uploaded context; 503 when the
|
|
deployment's context store has no implemented transport.
|
|
x-felis-face: [external]
|
|
x-felis-tier: admin
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'200':
|
|
description: The stored build context (gzip tarball), served as an attachment.
|
|
content:
|
|
application/gzip:
|
|
schema: { type: string, format: binary }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/submissions/{id}/reject:
|
|
post:
|
|
tags: [submissions]
|
|
operationId: rejectSubmission
|
|
summary: Reject a submission with a required reason (admin; user-directed lane over §16). Starts no build.
|
|
x-felis-face: [external]
|
|
x-felis-tier: admin
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [reason]
|
|
properties:
|
|
reason: { type: string }
|
|
responses:
|
|
'200':
|
|
description: The rejected submission.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Submission' }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'409':
|
|
description: Submission has already been reviewed.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|