A live install passed the SMTP setup screen and then failed every one-time
code with a bare `internal error`. Four separate defects had to line up for
that, and each is fixed here.
The relay was configured with `from = noreply@<domain-A>` on an account
authenticated as `<user>@<domain-B>`. Providers that validate sender identity
— Fastmail among them — answer MAIL FROM with an unconditional 250 and only
refuse at end-of-DATA. Ping stopped at NOOP, so it never saw the refusal: the
wizard reported success, wrote the config, rolled felis-api, and every OTP
afterwards died at w.Close().
Ping now runs the same transaction a real code takes — connect, (STARTTLS,)
AUTH, MAIL FROM, RCPT TO, DATA — delivering one self-test message to the From
address, and SendOTP and Ping share deliver() so the check cannot drift from
the thing it checks. The self-test recipient cannot cause a false negative:
an authenticated submission relay accepts RCPT for any destination by
definition, while the sender identity it does validate is exactly what we
want tested. The setup screen now says a message will be sent, names the
address it went to, and warns that From must be an address the account is
allowed to send as.
A relay refusal also answered 500 `internal`, which reads as a broken panel
and sends the operator hunting through handler code instead of their [smtp]
block. It is now 502 `mail_undeliverable`, mapped inside deliverOTP so all
four doors that mail a code (onboarding, email login, op-login, migrate
step-up) answer alike. The relay's own text stays out of the response — it
can name the SMTP account, and these routes are reachable by any signed-in
player — and goes to the log instead.
writeError logged nothing when it collapsed an unmapped error to 500, so an
operator holding an `internal error` had nothing to grep for and diagnosis
degraded into guessing against a live install. It now logs the method, path,
wrapped chain and the same request_id the caller is shown.
Finally, write_felis_toml regenerated the config wholesale and never emitted
[smtp], so re-running the installer — the documented way to update felis-api —
silently erased a working relay and reverted OTP delivery to the no-Mailer
path, logging codes instead of sending them. It now carries the block forward,
cached on first read because the host toml is clobbered before the pod toml is
written. Same defect family as the root_domain loss fixed in ecbeb20: a
generated file holding a hand-set value with no carry-forward.
Tests cover the case a MAIL FROM probe cannot see: a fake relay that answers
250 to MAIL FROM and 550 at end-of-DATA must fail both Ping and SendOTP, and
the 502 must carry a distinct machine code without leaking the relay's text.
4541 lines
171 KiB
YAML
4541 lines
171 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.
|
|
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'
|
|
|
|
/session/minecraft/hasJoined:
|
|
get:
|
|
tags: [nano]
|
|
operationId: hasJoined
|
|
summary: Multi-source session verifier (Felis-nano hasJoined multiplexer).
|
|
description: >-
|
|
Velocity's authlib is pointed here via -Dmojang.sessionserver or a thin login
|
|
hook. 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 authlib maps to a verify failure.
|
|
x-felis-face: [internal]
|
|
x-felis-tier: public
|
|
security: []
|
|
parameters:
|
|
- { name: username, in: query, required: true, schema: { type: string } }
|
|
- { name: serverId, in: query, required: true, schema: { type: string } }
|
|
- { name: ip, in: query, required: false, schema: { type: string } }
|
|
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 }
|
|
properties: { type: array, items: { type: object } }
|
|
'204':
|
|
description: No source validated the session, or the resolved UUID is barred.
|
|
|
|
# -------------------------------------------------- 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/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.
|
|
'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 world on demand (owner-or-admin; server must be stopped).
|
|
description: >-
|
|
Snapshots the server's world into the archive store as a first-class
|
|
world_backups row (reason "manual"), restorable later like an inactivity
|
|
backup. 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'
|
|
|
|
# ------------------------------------------------- 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 (user-directed lane over §16).
|
|
x-felis-face: [external]
|
|
x-felis-tier: app
|
|
security: [{ accessJWT: [] }]
|
|
responses:
|
|
'200':
|
|
description: The caller's submissions, newest first.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [submissions]
|
|
properties:
|
|
submissions:
|
|
type: array
|
|
items: { $ref: '#/components/schemas/Submission' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/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 }
|
|
dockerfile: { type: string }
|
|
context_ref: { type: string }
|
|
base_image: { type: string }
|
|
responses:
|
|
'202':
|
|
description: Build accepted.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Build' }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/images/build/{id}:
|
|
get:
|
|
tags: [images]
|
|
operationId: getBuild
|
|
summary: Get one build's status (admin).
|
|
x-felis-face: [external]
|
|
x-felis-tier: admin
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'200':
|
|
description: The build.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Build' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/images/build/{id}/logs:
|
|
get:
|
|
tags: [images]
|
|
operationId: buildLogs
|
|
summary: Stream a build's Job log over SSE (admin, spec §16 / §416).
|
|
x-felis-face: [external]
|
|
x-felis-tier: admin
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'200':
|
|
description: An event stream of build log lines.
|
|
content:
|
|
text/event-stream:
|
|
schema: { type: string }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/images/build/{id}/cancel:
|
|
post:
|
|
tags: [images]
|
|
operationId: cancelBuild
|
|
summary: Cancel a running build (admin).
|
|
x-felis-face: [external]
|
|
x-felis-tier: admin
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'200':
|
|
description: The build after cancellation.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Build' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'409':
|
|
description: Build already terminal.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/images:
|
|
get:
|
|
tags: [images]
|
|
operationId: listImages
|
|
summary: List whitelisted images (admin).
|
|
x-felis-face: [external]
|
|
x-felis-tier: admin
|
|
security: [{ accessJWT: [] }]
|
|
responses:
|
|
'200':
|
|
description: The image whitelist.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [images]
|
|
properties:
|
|
images:
|
|
type: array
|
|
items: { $ref: '#/components/schemas/Image' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
post:
|
|
tags: [images]
|
|
operationId: addImage
|
|
summary: Whitelist an externally-built image by reference (admin).
|
|
x-felis-face: [external]
|
|
x-felis-tier: admin
|
|
security: [{ accessJWT: [] }]
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [image_ref]
|
|
properties:
|
|
image_ref: { type: string }
|
|
responses:
|
|
'201':
|
|
description: Image whitelisted.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Image' }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
delete:
|
|
tags: [images]
|
|
operationId: removeImage
|
|
summary: Remove an image from the whitelist by reference (admin).
|
|
x-felis-face: [external]
|
|
x-felis-tier: admin
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: ref, in: query, required: true, schema: { type: string } }
|
|
responses:
|
|
'204':
|
|
$ref: '#/components/responses/NoContent'
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/submissions:
|
|
get:
|
|
tags: [submissions]
|
|
operationId: listSubmissions
|
|
summary: The admin review queue — every user's modpack submissions (admin; user-directed lane over §16).
|
|
x-felis-face: [external]
|
|
x-felis-tier: admin
|
|
security: [{ accessJWT: [] }]
|
|
responses:
|
|
'200':
|
|
description: All submissions, newest first.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [submissions]
|
|
properties:
|
|
submissions:
|
|
type: array
|
|
items: { $ref: '#/components/schemas/Submission' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/submissions/{id}/approve:
|
|
post:
|
|
tags: [submissions]
|
|
operationId: approveSubmission
|
|
summary: >-
|
|
Approve a submission and start its Trivy-gated build (admin; user-directed lane over §16).
|
|
Approval is layered in front of the scan, never instead of it.
|
|
x-felis-face: [external]
|
|
x-felis-tier: admin
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
'200':
|
|
description: The approved submission, with the linked build id.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Submission' }
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'409':
|
|
description: Submission has already been reviewed.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|
|
|
|
/api/v1/submissions/{id}/reject:
|
|
post:
|
|
tags: [submissions]
|
|
operationId: rejectSubmission
|
|
summary: Reject a submission with a required reason (admin; user-directed lane over §16). Starts no build.
|
|
x-felis-face: [external]
|
|
x-felis-tier: admin
|
|
security: [{ accessJWT: [] }]
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [reason]
|
|
properties:
|
|
reason: { type: string }
|
|
responses:
|
|
'200':
|
|
description: The rejected submission.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Submission' }
|
|
'400':
|
|
$ref: '#/components/responses/BadRequest'
|
|
'401':
|
|
$ref: '#/components/responses/Unauthorized'
|
|
'403':
|
|
$ref: '#/components/responses/Forbidden'
|
|
'404':
|
|
$ref: '#/components/responses/NotFound'
|
|
'409':
|
|
description: Submission has already been reviewed.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: '#/components/schemas/Error' }
|
|
'503':
|
|
$ref: '#/components/responses/ServiceUnavailable'
|