diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4335d81..e8423fe 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -165,9 +165,38 @@ jobs:
- run: npm test
working-directory: panel
+ # `tsc -b`: tsconfig.json is a solution file (files: [] plus references), so a plain
+ # `tsc --noEmit` checked nothing and passed with type errors in the tree.
- run: npm run typecheck
working-directory: panel
+ # Rules of hooks and effect dependency lists, with --max-warnings 0.
+ - run: npm run lint
+ working-directory: panel
+
+ # openapi.gen.ts is generated from docs/openapi.yaml and checked in, so the
+ # compile-time parity in src/lib/types.parity.ts needs no generator in the build;
+ # a schema edit that was not regenerated fails here.
+ - name: openapi.gen.ts matches docs/openapi.yaml
+ working-directory: panel
+ run: |
+ npm run gen:api
+ git diff --exit-code -- src/lib/openapi.gen.ts
+
+ # Browser smoke over the mock-mode dev server, in the runner's installed Chrome
+ # (playwright.config.ts sets channel: chrome, so nothing is downloaded).
+ - run: npm run test:e2e
+ working-directory: panel
+
+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ if: failure()
+ with:
+ name: panel-playwright-report
+ path: |
+ panel/playwright-report
+ panel/test-results
+ retention-days: 7
+
plugins:
runs-on: ubuntu-latest
steps:
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index 8c8be1a..f5a6aae 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -303,7 +303,7 @@ components:
ServerInfo:
type: object
description: Status projection of one server (internal/api/cluster.go ServerInfo).
- required: [name, subdomain, phase, ready, playersOnline, playersMax]
+ required: [name, subdomain, phase, ready, playersOnline, playersMax, idleStopSeconds]
properties:
name: { type: string }
subdomain: { type: string }
@@ -311,6 +311,7 @@ components:
ready: { type: boolean }
autostartPolicy:
type: string
+ enum: [ownerOnly, public, allowlist]
description: Present only when set; who may wake the server via domain-autostart.
desiredState:
type: string
@@ -333,6 +334,26 @@ components:
type: boolean
description: Present and true while the operator cannot read the player count over RCON; idle auto-stop waits until it can.
+ FleetServer:
+ description: One row of the fleet-wide admin read (internal/api/handlers_user.go fleetServerView).
+ allOf:
+ - $ref: '#/components/schemas/ServerInfo'
+ - type: object
+ properties:
+ owner:
+ type: string
+ description: >-
+ The owner's display identity (email, or username when the address is
+ absent). Absent for an unclaimed server or when the best-effort owner
+ lookup failed.
+ system:
+ type: boolean
+ description: >-
+ True for a platform-provisioned system service (the login gate, the
+ lobby). Their reserved names are rejected by every per-server route,
+ so the cockpit renders them read-only instead of offering actions
+ that would 400.
+
MyServerView:
type: object
description: One row of the caller's server list (internal/api/repo.go MyServerView).
@@ -350,6 +371,20 @@ components:
format: int32
description: Best-effort from live CRD status; 0 when the cluster is unreachable.
playersMax: { type: integer, format: int32 }
+ displayName:
+ type: string
+ description: From live CRD status; omitted when unset or the cluster is unreachable.
+ desiredState:
+ type: string
+ enum: [Running, Stopped]
+ description: Owned rows only, from live CRD status.
+ autostartPolicy:
+ type: string
+ enum: [ownerOnly, public, allowlist]
+ description: Owned rows only, from live CRD status.
+ playerCountUnknown:
+ type: boolean
+ description: Owned rows only. Present and true while the operator cannot read the player count, so a stop may disconnect players.
BackupView:
type: object
@@ -391,6 +426,9 @@ components:
enum: [pending, building, succeeded, failed, cancelled]
dockerfile: { type: string }
context_ref: { type: string }
+ context_digest:
+ type: string
+ description: Lowercase hex sha256 of the context tarball the build was pinned to (the audit record). Omitted when the request named none.
base_image: { type: string }
requested_by: { type: string }
job_name: { type: string }
@@ -398,9 +436,9 @@ components:
error: { type: string }
created_at: { type: string, format: date-time }
finished_at:
- type: [string, 'null']
+ type: string
format: date-time
- description: Null until the build reaches a terminal status.
+ description: Omitted until the build reaches a terminal status.
Image:
type: object
@@ -430,6 +468,9 @@ components:
context_ref:
type: string
description: Platform-derived pinned build context; not user-supplied.
+ context_sha256:
+ type: string
+ description: Lowercase hex sha256 of the uploaded context tarball; omitted until one is uploaded. Approval must name it.
status:
type: string
enum: [pending_review, approved, rejected]
@@ -456,9 +497,9 @@ components:
reject_reason: { type: string }
created_at: { type: string, format: date-time }
reviewed_at:
- type: [string, 'null']
+ type: string
format: date-time
- description: Null until an admin approves or rejects.
+ description: Omitted until an admin approves or rejects.
UserView:
type: object
@@ -478,7 +519,7 @@ components:
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]
+ required: [id, username, role, disabled, email_verified, server_count, created_at, updated_at]
properties:
id: { type: string }
username: { type: string }
@@ -490,11 +531,12 @@ components:
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
deleted_at:
- type: [string, 'null']
+ type: string
format: date-time
description: Present only when soft-deleted.
linked_accounts:
type: array
+ description: Omitted when the user has no linked Minecraft account.
items:
type: object
required: [mc_uuid, auth_source, verified_at]
@@ -523,8 +565,9 @@ components:
created_at: { type: string, format: date-time }
expires_at: { type: string, format: date-time }
revoked_at:
- type: [string, 'null']
+ type: string
format: date-time
+ description: Present only once the session is revoked.
paths:
# ----------------------------------------------------------------- health ---
@@ -2922,24 +2965,7 @@ paths:
properties:
servers:
type: array
- items:
- allOf:
- - $ref: '#/components/schemas/ServerInfo'
- - type: object
- properties:
- owner:
- type: string
- description: >-
- The owner's display identity (email, or username when
- the address is absent). Absent for an unclaimed server
- or when the best-effort owner lookup failed.
- system:
- type: boolean
- description: >-
- True for a platform-provisioned system service (the login
- gate, the lobby). Their reserved names are rejected by
- every per-server route, so the cockpit renders them
- read-only instead of offering actions that would 400.
+ items: { $ref: '#/components/schemas/FleetServer' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
diff --git a/internal/api/api_test.go b/internal/api/api_test.go
index cb14bf3..5668f1c 100644
--- a/internal/api/api_test.go
+++ b/internal/api/api_test.go
@@ -746,7 +746,9 @@ func (f *fakeRepo) RecordJoin(_ context.Context, n, uuid string) error {
return nil
}
func (f *fakeRepo) MyServers(_ context.Context, u string) ([]MyServerView, error) {
- return f.mine[u], nil
+ // Fresh rows per call, as PGRepo scans them: the handler joins live state
+ // into the slice it gets.
+ return append([]MyServerView(nil), f.mine[u]...), nil
}
func (f *fakeRepo) ServerOwners(_ context.Context) (map[string]string, error) {
if f.ownersErr != nil {
@@ -1535,6 +1537,7 @@ type fakeCluster struct {
byName map[string]*ServerInfo
bySub map[string]*ServerInfo
list []ServerInfo
+ listErr error
desired map[string]v1alpha1.DesiredState
created map[string]CreateServerInput // name -> the validated input it was created from
patched map[string]ServerSpecPatch // name -> the validated spec patch it received
@@ -1567,8 +1570,13 @@ func (c *fakeCluster) GetBySubdomain(_ context.Context, s string) (*ServerInfo,
}
return nil, ErrNotFound
}
-func (c *fakeCluster) ListServers(_ context.Context) ([]ServerInfo, error) { return c.list, nil }
-func (c *fakeCluster) Ping(_ context.Context) error { return c.pingErr }
+func (c *fakeCluster) ListServers(_ context.Context) ([]ServerInfo, error) {
+ if c.listErr != nil {
+ return nil, c.listErr
+ }
+ return c.list, nil
+}
+func (c *fakeCluster) Ping(_ context.Context) error { return c.pingErr }
// WorldVolumeExists models the world PVC: present unless the test named the
// server in noWorld (never started / already reaped).
@@ -1860,6 +1868,71 @@ func TestMeIdentity(t *testing.T) {
})
}
+// TestMyServersJoinsLiveState proves /me/servers carries the live CRD fields the
+// panel renders: every row gets the display name, live phase and counts, and
+// only the caller's own rows get owner detail (desired state, autostart policy,
+// an unreadable player count), as the status route withholds them from others.
+// A cluster read failure keeps the Postgres rows with the cached phase.
+func TestMyServersJoinsLiveState(t *testing.T) {
+ repo := newFakeRepo()
+ repo.mine = map[string][]MyServerView{"u1": {
+ {Name: "mine", Subdomain: "mine", Owned: true, Phase: "Stopped"},
+ {Name: "open", Subdomain: "open", Claimable: true, Phase: "Stopped"},
+ }}
+ cl := newFakeCluster()
+ cl.list = []ServerInfo{
+ {Name: "mine", DisplayName: "My World", Phase: "Running", DesiredState: "Running",
+ AutostartPolicy: "ownerOnly", PlayersOnline: 2, PlayersMax: 20, PlayerCountUnknown: true},
+ {Name: "open", DisplayName: "Open World", Phase: "Running", DesiredState: "Running",
+ AutostartPolicy: "public", PlayersMax: 10, PlayerCountUnknown: true},
+ }
+ api := newTestAPI(repo, cl)
+ api.External = staticExternal{p: &Principal{UserID: "u1", Role: "user"}}
+
+ read := func() map[string]map[string]any {
+ t.Helper()
+ w := do(api.ExternalHandler(), "GET", "/api/v1/me/servers", "", nil)
+ if w.Code != http.StatusOK {
+ t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
+ }
+ var body struct {
+ Servers []map[string]any `json:"servers"`
+ }
+ if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ out := map[string]map[string]any{}
+ for _, s := range body.Servers {
+ out[s["name"].(string)] = s
+ }
+ return out
+ }
+
+ got := read()
+ mine, open := got["mine"], got["open"]
+ for k, want := range map[string]any{"displayName": "My World", "phase": "Running",
+ "desiredState": "Running", "autostartPolicy": "ownerOnly", "playerCountUnknown": true,
+ "playersOnline": float64(2), "playersMax": float64(20)} {
+ if mine[k] != want {
+ t.Errorf("own row %s = %v, want %v", k, mine[k], want)
+ }
+ }
+ if open["displayName"] != "Open World" || open["phase"] != "Running" || open["playersMax"] != float64(10) {
+ t.Errorf("claimable row public fields = %v", open)
+ }
+ for _, k := range []string{"desiredState", "autostartPolicy", "playerCountUnknown"} {
+ if _, ok := open[k]; ok {
+ t.Errorf("claimable row carries owner detail %s = %v", k, open[k])
+ }
+ }
+
+ cl.listErr = errors.New("apiserver down")
+ got = read()
+ if got["mine"]["phase"] != "Stopped" || got["mine"]["playersOnline"] != float64(0) {
+ t.Errorf("cluster down: own row = %v, want cached phase and 0 players", got["mine"])
+ }
+}
+
// ---- fleet (SysAdmin cockpit read) ----
// TestFleetAdminRead proves the SysAdmin cockpit's fleet read is admin-tier AND
diff --git a/internal/api/handlers_user.go b/internal/api/handlers_user.go
index c4ee5b0..782c8ae 100644
--- a/internal/api/handlers_user.go
+++ b/internal/api/handlers_user.go
@@ -239,19 +239,34 @@ func (a *API) handleMyServers(w http.ResponseWriter, r *http.Request) {
writeError(w, r, err)
return
}
- // Player counts are presentational and best-effort, mirroring handleFleet's
+ // The live fields are presentational and best-effort, mirroring handleFleet's
// owner join: the list exists for ownership/claim state, so a cluster hiccup
- // must degrade to 0/0 counts, never 500 the whole list. The CRD status is the
- // only source of live counts (spec §1) — Postgres never stores them.
+ // must degrade to 0/0 counts and the cached phase, never 500 the whole list.
+ // The CRD status is the only source of live state (spec §1) — Postgres never
+ // stores counts. Owner detail (desired state, autostart policy, whether the
+ // count is readable) joins only onto rows the caller owns; the panel needs
+ // playerCountUnknown there to ask before a stop that may drop players.
if infos, err := a.Cluster.ListServers(r.Context()); err == nil {
byName := make(map[string]ServerInfo, len(infos))
for _, s := range infos {
byName[s.Name] = s
}
for i := range servers {
- if info, ok := byName[servers[i].Name]; ok {
- servers[i].PlayersOnline = info.PlayersOnline
- servers[i].PlayersMax = info.PlayersMax
+ info, ok := byName[servers[i].Name]
+ if !ok {
+ continue
+ }
+ v := &servers[i]
+ v.PlayersOnline = info.PlayersOnline
+ v.PlayersMax = info.PlayersMax
+ v.DisplayName = info.DisplayName
+ if info.Phase != "" {
+ v.Phase = info.Phase
+ }
+ if v.Owned {
+ v.DesiredState = info.DesiredState
+ v.AutostartPolicy = info.AutostartPolicy
+ v.PlayerCountUnknown = info.PlayerCountUnknown
}
}
}
diff --git a/internal/api/openapi_parity_test.go b/internal/api/openapi_parity_test.go
new file mode 100644
index 0000000..4ac55dd
--- /dev/null
+++ b/internal/api/openapi_parity_test.go
@@ -0,0 +1,190 @@
+package api
+
+import (
+ "os"
+ "reflect"
+ "sort"
+ "strings"
+ "testing"
+
+ "felis.lolicon.best/internal/build"
+ "sigs.k8s.io/yaml"
+)
+
+// TestOpenAPISchemasMatchWireStructs keeps docs/openapi.yaml honest about the
+// response bodies. Each named schema is compared with the Go struct the handler
+// actually encodes: the property set must equal the struct's JSON field set, and
+// `required` must list exactly the fields that are always on the wire (no
+// omitempty). The panel's types are checked against the same schemas at compile
+// time (panel/src/lib/types.parity.ts), so a field added here without the docs
+// fails in Go, and one added to the docs without the panel fails in tsc.
+func TestOpenAPISchemasMatchWireStructs(t *testing.T) {
+ raw, err := os.ReadFile("../../docs/openapi.yaml")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var doc struct {
+ Components struct {
+ Schemas map[string]schemaDoc `json:"schemas"`
+ } `json:"components"`
+ }
+ if err := yaml.Unmarshal(raw, &doc); err != nil {
+ t.Fatalf("parse openapi.yaml: %v", err)
+ }
+
+ pairs := map[string]any{
+ "ServerInfo": ServerInfo{},
+ "FleetServer": fleetServerView{},
+ "MyServerView": MyServerView{},
+ "BackupView": BackupView{},
+ "Build": build.Build{},
+ "Image": build.Image{},
+ "Submission": submissionView{},
+ "UserView": UserView{},
+ "UserDetail": UserDetail{},
+ "QuotaView": QuotaView{},
+ "SessionView": SessionView{},
+ "PasskeyCredential": passkeyCredentialView{},
+ "UpdateWindow": updateWindow{},
+ "DBBackupStatus": dbBackupView{},
+ }
+ for name, v := range pairs {
+ s, ok := doc.Components.Schemas[name]
+ if !ok {
+ t.Errorf("openapi.yaml has no components.schemas.%s", name)
+ continue
+ }
+ compareSchema(t, name, flatten(s, doc.Components.Schemas), reflect.TypeOf(v))
+ }
+}
+
+type schemaDoc struct {
+ Ref string `json:"$ref"`
+ AllOf []schemaDoc `json:"allOf"`
+ Type any `json:"type"`
+ Required []string `json:"required"`
+ Properties map[string]schemaDoc `json:"properties"`
+ Items *schemaDoc `json:"items"`
+}
+
+// flatten resolves a top-level $ref and merges allOf parts into one object
+// schema, which is how a Go struct embedding another one is documented.
+func flatten(s schemaDoc, all map[string]schemaDoc) schemaDoc {
+ if s.Ref != "" {
+ s = all[strings.TrimPrefix(s.Ref, "#/components/schemas/")]
+ }
+ if len(s.AllOf) == 0 {
+ return s
+ }
+ out := schemaDoc{Properties: map[string]schemaDoc{}}
+ for _, part := range append(s.AllOf, schemaDoc{Required: s.Required, Properties: s.Properties}) {
+ part = flatten(part, all)
+ out.Required = append(out.Required, part.Required...)
+ for k, v := range part.Properties {
+ out.Properties[k] = v
+ }
+ }
+ return out
+}
+
+type wireField struct {
+ omitempty bool
+ typ reflect.Type
+}
+
+// wireFields lists the JSON fields encoding/json emits for t, following
+// embedded structs the way the encoder does.
+func wireFields(t reflect.Type) map[string]wireField {
+ out := map[string]wireField{}
+ for i := 0; i < t.NumField(); i++ {
+ f := t.Field(i)
+ tag := f.Tag.Get("json")
+ if tag == "-" {
+ continue
+ }
+ name, opts, _ := strings.Cut(tag, ",")
+ if f.Anonymous && name == "" {
+ for k, v := range wireFields(f.Type) {
+ out[k] = v
+ }
+ continue
+ }
+ if !f.IsExported() {
+ continue
+ }
+ if name == "" {
+ name = f.Name
+ }
+ out[name] = wireField{omitempty: strings.Contains(","+opts+",", ",omitempty,"), typ: f.Type}
+ }
+ return out
+}
+
+func structOf(t reflect.Type) (reflect.Type, bool) {
+ for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice {
+ t = t.Elem()
+ }
+ return t, t.Kind() == reflect.Struct && t.PkgPath() != "time"
+}
+
+func compareSchema(t *testing.T, path string, s schemaDoc, typ reflect.Type) {
+ t.Helper()
+ fields := wireFields(typ)
+ var missing, extra, notRequired, wronglyRequired []string
+ for name, f := range fields {
+ if _, ok := s.Properties[name]; !ok {
+ missing = append(missing, name)
+ }
+ if !f.omitempty && !contains(s.Required, name) {
+ notRequired = append(notRequired, name)
+ }
+ }
+ for name := range s.Properties {
+ if _, ok := fields[name]; !ok {
+ extra = append(extra, name)
+ }
+ }
+ for _, name := range s.Required {
+ if f, ok := fields[name]; ok && f.omitempty {
+ wronglyRequired = append(wronglyRequired, name)
+ }
+ }
+ report := func(what string, names []string) {
+ if len(names) > 0 {
+ sort.Strings(names)
+ t.Errorf("%s: %s: %s", path, what, strings.Join(names, ", "))
+ }
+ }
+ report("sent by Go but missing from openapi.yaml", missing)
+ report("documented but never sent by Go", extra)
+ report("always sent but not in required", notRequired)
+ report("required but omitted when empty", wronglyRequired)
+
+ // Nested objects (an object property, or an array of objects) are held to
+ // the same rule when the schema spells their properties out.
+ for name, f := range fields {
+ p, ok := s.Properties[name]
+ if !ok {
+ continue
+ }
+ inner, isStruct := structOf(f.typ)
+ if !isStruct {
+ continue
+ }
+ switch {
+ case len(p.Properties) > 0:
+ compareSchema(t, path+"."+name, p, inner)
+ case p.Items != nil && len(p.Items.Properties) > 0:
+ compareSchema(t, path+"."+name+"[]", *p.Items, inner)
+ }
+ }
+}
+
+func contains(xs []string, x string) bool {
+ for _, v := range xs {
+ if v == x {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/api/repo.go b/internal/api/repo.go
index c5e6b5c..c9e6e65 100644
--- a/internal/api/repo.go
+++ b/internal/api/repo.go
@@ -18,18 +18,24 @@ type ServerRecord struct {
}
// MyServerView is a row of GET /api/v1/me/servers: a server the caller owns,
-// may auto-start, or may claim. PlayersOnline/PlayersMax are NOT stored in
-// Postgres — handleMyServers joins them best-effort from the CRD status
-// (Cluster.ListServers) at read time, so a cluster hiccup renders 0/0, never
-// a 500.
+// may auto-start, or may claim. Everything after Claimable is NOT stored in
+// Postgres — handleMyServers joins it best-effort from the CRD status
+// (Cluster.ListServers) at read time, so a cluster hiccup renders 0/0 and the
+// cached phase, never a 500. DesiredState, AutostartPolicy and
+// PlayerCountUnknown are owner detail and stay empty on rows the caller does
+// not own, the same split publicServerInfo makes on the status route.
type MyServerView struct {
- Name string `json:"name"`
- Subdomain string `json:"subdomain"`
- Owned bool `json:"owned"`
- Claimable bool `json:"claimable"`
- Phase string `json:"phase,omitempty"`
- PlayersOnline int32 `json:"playersOnline"`
- PlayersMax int32 `json:"playersMax"`
+ Name string `json:"name"`
+ Subdomain string `json:"subdomain"`
+ Owned bool `json:"owned"`
+ Claimable bool `json:"claimable"`
+ Phase string `json:"phase,omitempty"`
+ PlayersOnline int32 `json:"playersOnline"`
+ PlayersMax int32 `json:"playersMax"`
+ DisplayName string `json:"displayName,omitempty"`
+ DesiredState string `json:"desiredState,omitempty"`
+ AutostartPolicy string `json:"autostartPolicy,omitempty"`
+ PlayerCountUnknown bool `json:"playerCountUnknown,omitempty"`
}
// AuditEntry is one row written to audit_logs (spec §6). Actor is display text:
diff --git a/panel/.gitignore b/panel/.gitignore
index 200d433..4d7b767 100644
--- a/panel/.gitignore
+++ b/panel/.gitignore
@@ -13,3 +13,7 @@ dist/
# Logs
npm-debug.log*
+
+# Playwright output
+test-results/
+playwright-report/
diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts
index dd32568..bcb1f6b 100644
--- a/panel/dev/mockApi.ts
+++ b/panel/dev/mockApi.ts
@@ -9,8 +9,9 @@ import type {
CreateServerRequest,
FleetServer,
Identity,
+ MyServerView,
Phase,
- ServerInfo,
+ ServerStatus,
WhitelistImage,
Submission,
UserView,
@@ -52,7 +53,7 @@ interface MockAccount {
sessions?: SessionView[];
}
-interface MockServer extends ServerInfo {
+interface MockServer extends ServerStatus {
owner: AccountID | null;
}
@@ -220,13 +221,9 @@ function initialState(): MockState {
linked: account("linked", "user", true, true),
},
images: [
- { image_ref: "registry.felis.svc:5000/paper-1.21:demo", enabled: true, source: "demo" },
- { image_ref: "registry.felis.svc:5000/fabric-1.20.1:demo", enabled: true, source: "demo" },
- {
- image_ref: "registry.felis.svc:5000/forge-1.20.1:disabled",
- enabled: false,
- source: "demo",
- },
+ whitelistImage("registry.felis.svc:5000/paper-1.21:demo", "recommended"),
+ whitelistImage("registry.felis.svc:5000/fabric-1.20.1:demo", "recommended"),
+ { ...whitelistImage("registry.felis.svc:5000/forge-1.20.1:disabled", "external"), enabled: false },
],
servers: [
server("survival", "Survival SMP", "Running", "owner", {
@@ -467,24 +464,28 @@ function account(
};
}
+function whitelistImage(image_ref: string, source: string): WhitelistImage {
+ return { image_ref, enabled: true, source, added_by: "owner", added_at: "2026-01-01T00:00:00Z" };
+}
+
function server(
name: string,
displayName: string,
phase: Phase,
owner: AccountID | null,
- overrides: Partial = {},
+ overrides: Partial = {},
): MockServer {
return {
name,
subdomain: name,
displayName,
phase,
+ ready: phase === "Running",
desiredState: phase === "Stopped" ? "Stopped" : "Running",
playersOnline: phase === "Running" ? 1 : 0,
playersMax: 20,
autostartPolicy: "ownerOnly",
- owned: false,
- claimable: false,
+ idleStopSeconds: 900,
owner,
...overrides,
};
@@ -560,10 +561,10 @@ function canManage(accountInfo: MockAccount, serverInfo: MockServer): boolean {
return isAdmin(accountInfo.role) || serverInfo.owner === accountInfo.id;
}
-function visibleServers(state: MockState, accountInfo: MockAccount): ServerInfo[] {
+function visibleServers(state: MockState, accountInfo: MockAccount): MyServerView[] {
return state.servers
.filter((serverInfo) => canSee(accountInfo, serverInfo))
- .map((serverInfo) => projectServer(serverInfo, accountInfo));
+ .map((serverInfo) => myServerView(serverInfo, accountInfo));
}
// fleetView projects the internal mock servers into the GET /fleet wire shape
@@ -574,35 +575,59 @@ function visibleServers(state: MockState, accountInfo: MockAccount): ServerInfo[
// gated on Running, exactly as the real cluster reports them.
function fleetView(state: MockState): FleetServer[] {
return state.servers.map((s, i) => {
- const ready = s.phase === "Running";
+ const { owner, ...wire } = s;
return {
- name: s.name,
- subdomain: s.subdomain,
- phase: s.phase,
- ready,
- desiredState: s.desiredState,
- autostartPolicy: s.autostartPolicy,
+ ...wire,
endpointMode: "domain",
- endpointAddress: ready ? `10.43.0.${10 + i}:25565` : undefined,
- playersOnline: ready ? s.playersOnline ?? 0 : 0,
- playersMax: s.playersMax ?? 0,
- owner: s.owner ? state.accounts[s.owner].email : "",
+ endpointAddress: s.ready ? `10.43.0.${10 + i}:25565` : undefined,
+ playersOnline: s.ready ? s.playersOnline : 0,
+ owner: owner ? state.accounts[owner].email : "",
};
});
}
-function projectServer(serverInfo: MockServer, accountInfo: MockAccount): ServerInfo {
- const { owner: _owner, ...wire } = serverInfo;
+// myServerView mirrors handleMyServers: ownership and claim state plus the live
+// fields, with owner detail (desired state, policy, unreadable count) only on
+// rows the caller owns.
+function myServerView(serverInfo: MockServer, accountInfo: MockAccount): MyServerView {
const owned = canManage(accountInfo, serverInfo);
return {
- ...wire,
+ name: serverInfo.name,
+ subdomain: serverInfo.subdomain,
owned,
claimable: serverInfo.owner === null && accountInfo.linked && !owned,
+ phase: serverInfo.phase,
+ playersOnline: serverInfo.playersOnline,
+ playersMax: serverInfo.playersMax,
+ displayName: serverInfo.displayName,
+ ...(owned && {
+ desiredState: serverInfo.desiredState,
+ autostartPolicy: serverInfo.autostartPolicy,
+ playerCountUnknown: serverInfo.playerCountUnknown,
+ }),
+ };
+}
+
+// statusView mirrors handleServerStatus: the whole projection for the owner or
+// an admin, the public subset (publicServerInfo) for anyone else.
+function statusView(serverInfo: MockServer, accountInfo: MockAccount): ServerStatus {
+ const { owner: _owner, ...wire } = serverInfo;
+ if (canManage(accountInfo, serverInfo)) return wire;
+ return {
+ name: wire.name,
+ subdomain: wire.subdomain,
+ displayName: wire.displayName,
+ phase: wire.phase,
+ ready: wire.ready,
+ playersOnline: wire.playersOnline,
+ playersMax: wire.playersMax,
+ idleStopSeconds: 0,
};
}
function setPhase(serverInfo: MockServer, phase: Phase): void {
serverInfo.phase = phase;
+ serverInfo.ready = phase === "Running";
serverInfo.desiredState = phase === "Stopped" ? "Stopped" : "Running";
serverInfo.playersOnline = phase === "Running" ? Math.max(serverInfo.playersOnline ?? 0, 1) : 0;
}
@@ -960,7 +985,7 @@ async function handleUserRoute(ctx: SessionContext): Promise {
const filtered = allUsers.filter((u) => {
return (
u.username.toLowerCase().includes(search) ||
- u.email.toLowerCase().includes(search)
+ (u.email ?? "").toLowerCase().includes(search)
);
});
@@ -1248,7 +1273,7 @@ async function handleImageRoute(ctx: SessionContext): Promise {
if (img) {
img.enabled = true;
} else {
- img = { image_ref: ref, enabled: true, source: "external" };
+ img = whitelistImage(ref, "external");
ctx.state.images.unshift(img);
}
sendJSON(ctx.res, 201, img);
@@ -1309,7 +1334,7 @@ async function handleImageRoute(ctx: SessionContext): Promise {
b.finished_at = new Date().toISOString();
// Add to whitelist images
if (!ctx.state.images.some((i) => i.image_ref === b.image_ref)) {
- ctx.state.images.unshift({ image_ref: b.image_ref, enabled: true, source: "built" });
+ ctx.state.images.unshift({ ...whitelistImage(b.image_ref, "built"), build_id: b.id });
}
}
}, 15000); // Succeeded after 15 seconds
@@ -1535,7 +1560,7 @@ async function handleSubmissionRoute(ctx: SessionContext): Promise {
b.status = "succeeded";
b.finished_at = new Date().toISOString();
if (!ctx.state.images.some((i) => i.image_ref === b.image_ref)) {
- ctx.state.images.unshift({ image_ref: b.image_ref, enabled: true, source: "built" });
+ ctx.state.images.unshift({ ...whitelistImage(b.image_ref, "built"), build_id: b.id });
}
}
}, 15000);
@@ -1660,9 +1685,7 @@ async function handleServerRoute(ctx: SessionContext): Promise {
}
if (is("GET", ctx) && ctx.parts[4] === "status") {
- // The real status projection (api.ServerInfo) carries no owned/claimable.
- const { owned: _o, claimable: _c, ...status } = projectServer(serverInfo, ctx.account);
- sendJSON(ctx.res, 200, status);
+ sendJSON(ctx.res, 200, statusView(serverInfo, ctx.account));
return true;
}
if (is("GET", ctx) && ctx.parts[4] === "console") {
diff --git a/panel/e2e/fixtures.ts b/panel/e2e/fixtures.ts
new file mode 100644
index 0000000..b44f8b3
--- /dev/null
+++ b/panel/e2e/fixtures.ts
@@ -0,0 +1,77 @@
+import { readFileSync } from "node:fs";
+import { test as base, expect, type Page } from "@playwright/test";
+
+// t reads the en-US strings the panel renders, so a copy change does not
+// break the smoke and a missing key does ("ns:key", optional {{count}}).
+const cache = new Map>();
+export function t(key: string, vars: Record = {}): string {
+ const [ns, k] = key.split(":");
+ if (!cache.has(ns)) {
+ const url = new URL(`../src/i18n/resources/en-US/${ns}.json`, import.meta.url);
+ cache.set(ns, JSON.parse(readFileSync(url, "utf8")));
+ }
+ const table = cache.get(ns)!;
+ const plural = typeof vars.count === "number" ? `${k}_${vars.count === 1 ? "one" : "other"}` : k;
+ const text = table[plural] ?? table[k];
+ if (text === undefined) throw new Error(`missing en-US string ${key}`);
+ return text.replace(/\{\{(\w+)\}\}/g, (_, v: string) => String(vars[v]));
+}
+
+type Account = "owner" | "user" | "linked";
+
+export const test = base.extend<{ signIn: (account: Account) => Promise }>({
+ page: async ({ page, request }, use) => {
+ const res = await request.post("/api/v1/__mock/reset");
+ expect(res.ok()).toBe(true);
+ await use(page);
+ },
+ // signIn sets the mock session cookie directly; the sign-in form itself is
+ // covered by its own test.
+ signIn: async ({ context, baseURL }, use) => {
+ await use(async (account) => {
+ await context.addCookies([{ name: "felis_mock_session", value: account, url: baseURL! }]);
+ });
+ },
+});
+
+export { expect };
+
+/** expectFitsScreen fails when anything scrolls sideways or a visible element
+ * pokes past the viewport edge (an overflow-hidden parent would just cut it
+ * off). The shell scrolls in an overflow-y-auto pane, whose computed
+ * overflow-x is auto as well, so a computed style cannot tell a deliberate
+ * horizontal scroller from the page pane: only a container that asks for one
+ * by class (overflow-x-auto, overflow-x-scroll, overflow-auto, e.g. a wide
+ * table) may be wider than the screen. */
+export async function expectFitsScreen(page: Page) {
+ const report = await page.evaluate(() => {
+ const vw = document.documentElement.clientWidth;
+ const deliberate = (el: Element) =>
+ ["overflow-x-auto", "overflow-x-scroll", "overflow-auto"].some((c) => el.classList.contains(c));
+ const inDeliberate = (el: Element) => {
+ for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
+ if (deliberate(p)) return true;
+ }
+ return false;
+ };
+ const name = (el: Element) => {
+ const cls = typeof el.className === "string" ? el.className.split(/\s+/).slice(0, 3).join(".") : "";
+ return `${el.tagName.toLowerCase()}${cls ? "." + cls : ""}`;
+ };
+ const scrollers: string[] = [];
+ const offenders: string[] = [];
+ for (const el of [document.documentElement, ...Array.from(document.body.querySelectorAll("*"))]) {
+ const style = getComputedStyle(el);
+ if (style.visibility === "hidden" || deliberate(el) || inDeliberate(el)) continue;
+ if (el.scrollWidth > el.clientWidth + 1 && ["auto", "scroll"].includes(style.overflowX)) {
+ scrollers.push(`${name(el)} scrolls ${el.scrollWidth - el.clientWidth}px sideways`);
+ }
+ const r = el.getBoundingClientRect();
+ if (r.width === 0 || r.height === 0) continue;
+ if (r.left < -1 || r.right > vw + 1) offenders.push(`${name(el)} [${Math.round(r.left)}..${Math.round(r.right)}]`);
+ }
+ return { vw, scrollers: scrollers.slice(0, 5), offenders: offenders.slice(0, 5) };
+ });
+ expect(report.scrollers, "containers that scroll sideways").toEqual([]);
+ expect(report.offenders, `elements past the ${report.vw}px viewport`).toEqual([]);
+}
diff --git a/panel/e2e/mobile.spec.ts b/panel/e2e/mobile.spec.ts
new file mode 100644
index 0000000..30d9765
--- /dev/null
+++ b/panel/e2e/mobile.spec.ts
@@ -0,0 +1,31 @@
+import { test, expect, t, expectFitsScreen } from "./fixtures";
+
+test("the menu drawer reaches the other sections", async ({ page, signIn }) => {
+ await signIn("linked");
+ await page.goto("/");
+
+ await page.getByRole("button", { name: t("common:open_menu") }).click();
+ const drawer = page.getByRole("dialog");
+ await expect(drawer).toBeVisible();
+ await drawer.getByRole("link", { name: t("navigation:account") }).click();
+
+ await expect(page).toHaveURL(/\/account$/);
+ await expect(drawer).toBeHidden();
+});
+
+for (const [account, path] of [
+ ["linked", "/"],
+ ["linked", "/servers"],
+ ["linked", "/servers/lobby"],
+ ["linked", "/account"],
+ ["owner", "/servers"],
+ ["owner", "/admin/users"],
+] as const) {
+ test(`${path} fits a 375px screen for ${account}`, async ({ page, signIn }) => {
+ await signIn(account);
+ await page.goto(path);
+ await page.waitForLoadState("networkidle");
+
+ await expectFitsScreen(page);
+ });
+}
diff --git a/panel/e2e/smoke.spec.ts b/panel/e2e/smoke.spec.ts
new file mode 100644
index 0000000..33e3f45
--- /dev/null
+++ b/panel/e2e/smoke.spec.ts
@@ -0,0 +1,55 @@
+import { test, expect, t } from "./fixtures";
+
+test("a signed-out visit signs in by email code and returns to the page it asked for", async ({ page }) => {
+ await page.goto("/servers");
+ await expect(page).toHaveURL(/\/login\?next=%2Fservers$/);
+
+ await page.getByLabel(t("auth:email_address")).fill("owner@mock.felis.local");
+ await page.getByRole("button", { name: t("auth:send_otp") }).click();
+ await page.getByLabel(t("auth:otp_code")).fill("123456");
+ await page.getByRole("button", { name: t("auth:otp_btn") }).click();
+
+ await expect(page).toHaveURL(/\/servers$/);
+ await expect(page.getByRole("heading", { name: t("ops:fleet_title") })).toBeVisible();
+});
+
+test("a player sees their own server, can claim an unowned one, and has no admin section", async ({ page, signIn }) => {
+ await signIn("linked");
+ await page.goto("/servers");
+
+ await expect(page.getByRole("heading", { name: t("servers:my_servers_title") })).toBeVisible();
+ // Display name first, the server name beside it.
+ const own = page.getByRole("row").filter({ hasText: "Hub Lobby" });
+ await expect(own).toContainText("lobby");
+ await expect(own.getByRole("link", { name: t("servers:console") })).toBeVisible();
+ await expect(own.getByRole("button", { name: t("servers:stop"), exact: true })).toBeVisible();
+ const unowned = page.getByRole("row").filter({ hasText: "Claimable Node" });
+ await expect(unowned.getByRole("button", { name: t("servers:claim") })).toBeVisible();
+ await expect(unowned.getByRole("button", { name: t("servers:stop"), exact: true })).toHaveCount(0);
+ await expect(page.getByRole("link", { name: t("navigation:admin_users") })).toHaveCount(0);
+
+ await page.goto("/admin/images");
+ await expect(page.getByText(t("common:not_authorized_title"))).toBeVisible();
+});
+
+test("stopping a server with players online asks first, and cancel keeps it running", async ({ page, signIn }) => {
+ await signIn("linked");
+ await page.goto("/servers/lobby");
+
+ await page.getByRole("button", { name: t("servers:stop"), exact: true }).click();
+ const question = page.getByText(t("servers:stop_confirm_players", { count: 28 }));
+ await expect(question).toBeVisible();
+ await page.getByRole("button", { name: t("common:cancel") }).click();
+ await expect(question).toHaveCount(0);
+
+ const status = await page.request.get("/api/v1/servers/lobby/status");
+ expect((await status.json()).phase).toBe("Running");
+});
+
+test("an admin reaches the user list", async ({ page, signIn }) => {
+ await signIn("owner");
+ await page.goto("/admin/users");
+
+ await expect(page.getByRole("heading", { name: t("admin:users_title") })).toBeVisible();
+ await expect(page.getByText("linked@mock.felis.local")).toBeVisible();
+});
diff --git a/panel/eslint.config.js b/panel/eslint.config.js
new file mode 100644
index 0000000..9121b39
--- /dev/null
+++ b/panel/eslint.config.js
@@ -0,0 +1,27 @@
+import globals from "globals";
+import reactHooks from "eslint-plugin-react-hooks";
+import tseslint from "typescript-eslint";
+
+// Lint guards what tsc cannot: the rules of hooks (a hook behind a condition
+// renders fine until the branch flips) and effect dependency lists (a stale
+// closure, or a memo that recomputes every render). Types are tsc's job and
+// style the formatter's, so nothing else is switched on. CI runs it with
+// --max-warnings 0; a deliberate gap in a dependency list carries a disable
+// comment that says why.
+export default tseslint.config(
+ { ignores: ["dist", "node_modules", "test-results", "playwright-report", "src/lib/openapi.gen.ts"] },
+ {
+ files: ["src/**/*.{ts,tsx}", "dev/**/*.ts"],
+ languageOptions: {
+ parser: tseslint.parser,
+ ecmaVersion: 2022,
+ globals: { ...globals.browser, ...globals.node },
+ },
+ linterOptions: { reportUnusedDisableDirectives: "error" },
+ plugins: { "react-hooks": reactHooks },
+ rules: {
+ "react-hooks/rules-of-hooks": "error",
+ "react-hooks/exhaustive-deps": "warn",
+ },
+ },
+);
diff --git a/panel/package-lock.json b/panel/package-lock.json
index db7f3c1..7aabe21 100644
--- a/panel/package-lock.json
+++ b/panel/package-lock.json
@@ -25,16 +25,27 @@
"three": "^0.172.0"
},
"devDependencies": {
+ "@eslint/js": "^10.0.1",
+ "@playwright/test": "^1.63.0",
+ "@testing-library/dom": "^10.4.2",
+ "@testing-library/react": "^16.3.3",
+ "@testing-library/user-event": "^14.6.7",
"@types/node": "^22.10.5",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/three": "^0.172.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
+ "eslint": "^10.11.0",
+ "eslint-plugin-react-hooks": "^7.1.1",
+ "globals": "^17.12.0",
+ "jsdom": "^30.1.1",
+ "openapi-typescript": "^7.13.0",
"postcss": "^8.5.1",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.7.3",
+ "typescript-eslint": "^8.70.1",
"vite": "^6.0.7",
"vitest": "^4.1.9"
}
@@ -52,6 +63,59 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.1.tgz",
+ "integrity": "sha512-C9duntabagkBZ1LebM7FKmphR4Q1pBclLxVbZETQV0akkFjV0ooFxo8FlvAQyKj9F6l8rEnmidgcTlpyxFYizg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^3.4.0",
+ "@csstools/css-color-parser": "^4.2.3",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.1",
+ "lru-cache": "^11.5.3"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+ "version": "11.5.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
+ "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "9.2.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.1.tgz",
+ "integrity": "sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bidi-js": "^1.1.0",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.5.3"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
+ "version": "11.5.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
+ "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -343,6 +407,183 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
+ "node_modules/@cacheable/memory": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz",
+ "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cacheable/utils": "^2.5.0",
+ "@keyv/bigmap": "^1.3.1",
+ "hookified": "^1.15.1",
+ "keyv": "^5.6.0"
+ }
+ },
+ "node_modules/@cacheable/utils": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz",
+ "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hashery": "^1.5.1",
+ "keyv": "^5.6.0"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz",
+ "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz",
+ "integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz",
+ "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.1.1",
+ "@csstools/css-calc": "^3.4.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz",
+ "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz",
+ "integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -785,6 +1026,152 @@
"node": ">=18"
}
},
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.10.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
+ "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.23.5",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
+ "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^3.0.5",
+ "debug": "^4.3.1",
+ "minimatch": "^10.2.4"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz",
+ "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^1.2.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
+ "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz",
+ "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "eslint": "^10.0.0"
+ },
+ "peerDependenciesMeta": {
+ "eslint": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
+ "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz",
+ "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^1.2.1",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@exodus/bytes": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
+ "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
@@ -823,6 +1210,72 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
+ "node_modules/@humanfs/core": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+ "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/types": "^0.15.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
+ "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.2",
+ "@humanfs/types": "^0.15.0",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/types": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
+ "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -873,6 +1326,30 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@keyv/bigmap": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz",
+ "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hashery": "^1.4.0",
+ "hookified": "^1.15.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "keyv": "^5.6.0"
+ }
+ },
+ "node_modules/@keyv/serialize": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
+ "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -911,6 +1388,22 @@
"node": ">= 8"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
+ "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/@radix-ui/number": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz",
@@ -1490,6 +1983,89 @@
"integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==",
"license": "MIT"
},
+ "node_modules/@redocly/ajv": {
+ "version": "8.11.2",
+ "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
+ "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2",
+ "uri-js-replace": "^1.0.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/@redocly/ajv/node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@redocly/config": {
+ "version": "0.22.0",
+ "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz",
+ "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@redocly/openapi-core": {
+ "version": "1.34.20",
+ "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.20.tgz",
+ "integrity": "sha512-ypeBZ/6BKXR9+7/TtbKhbl4UgD7raHhPS12oknlKno2A8+lnFkxIwiE/Aklu6L2cd/ioH+fCWuMxi9/p3EyAPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@redocly/ajv": "8.11.2",
+ "@redocly/config": "0.22.0",
+ "colorette": "1.4.0",
+ "https-proxy-agent": "7.0.6",
+ "js-levenshtein": "1.1.6",
+ "js-yaml": "4.3.2",
+ "minimatch": "5.1.9",
+ "pluralize": "8.0.0",
+ "yaml-ast-parser": "0.0.43"
+ },
+ "engines": {
+ "node": ">=18.17.0",
+ "npm": ">=9.5.0"
+ }
+ },
+ "node_modules/@redocly/openapi-core/node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
+ "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/@redocly/openapi-core/node_modules/minimatch": {
+ "version": "5.1.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
+ "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/@remix-run/router": {
"version": "1.23.3",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
@@ -1598,9 +2174,6 @@
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1615,9 +2188,6 @@
"arm"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1632,9 +2202,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1649,9 +2216,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1666,9 +2230,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1683,9 +2244,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1700,9 +2258,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1717,9 +2272,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1734,9 +2286,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1751,9 +2300,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1768,9 +2314,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1785,9 +2328,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1802,9 +2342,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1902,6 +2439,68 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz",
+ "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.3",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
+ "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.7",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz",
+ "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
@@ -1909,6 +2508,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1972,6 +2578,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/esrecurse": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
+ "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1979,6 +2592,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/node": {
"version": "22.20.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz",
@@ -2046,6 +2666,249 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.70.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.1.tgz",
+ "integrity": "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.70.1",
+ "@typescript-eslint/type-utils": "8.70.1",
+ "@typescript-eslint/utils": "8.70.1",
+ "@typescript-eslint/visitor-keys": "8.70.1",
+ "ignore": "^7.0.5",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.70.1",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.10",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.10.tgz",
+ "integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.70.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.1.tgz",
+ "integrity": "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.70.1",
+ "@typescript-eslint/types": "8.70.1",
+ "@typescript-eslint/typescript-estree": "8.70.1",
+ "@typescript-eslint/visitor-keys": "8.70.1",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.70.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.1.tgz",
+ "integrity": "sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.70.1",
+ "@typescript-eslint/types": "^8.70.1",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.70.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.1.tgz",
+ "integrity": "sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.70.1",
+ "@typescript-eslint/visitor-keys": "8.70.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.70.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.1.tgz",
+ "integrity": "sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.70.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.1.tgz",
+ "integrity": "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.70.1",
+ "@typescript-eslint/typescript-estree": "8.70.1",
+ "@typescript-eslint/utils": "8.70.1",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.70.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.1.tgz",
+ "integrity": "sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.70.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.1.tgz",
+ "integrity": "sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.70.1",
+ "@typescript-eslint/tsconfig-utils": "8.70.1",
+ "@typescript-eslint/types": "8.70.1",
+ "@typescript-eslint/visitor-keys": "8.70.1",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.70.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.1.tgz",
+ "integrity": "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.70.1",
+ "@typescript-eslint/types": "8.70.1",
+ "@typescript-eslint/typescript-estree": "8.70.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.70.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.1.tgz",
+ "integrity": "sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.70.1",
+ "eslint-visitor-keys": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -2187,6 +3050,89 @@
"dev": true,
"license": "BSD-3-Clause"
},
+ "node_modules/acorn": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
+ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-colors": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
+ "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -2215,6 +3161,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
@@ -2227,6 +3180,16 @@
"node": ">=10"
}
},
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -2274,6 +3237,16 @@
"postcss": "^8.1.0"
}
},
+ "node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
"node_modules/baseline-browser-mapping": {
"version": "2.10.38",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
@@ -2287,6 +3260,16 @@
"node": ">=6.0.0"
}
},
+ "node_modules/bidi-js": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
+ "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -2300,6 +3283,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/brace-expansion": {
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
@@ -2347,6 +3343,20 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/cacheable": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz",
+ "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cacheable/memory": "^2.2.0",
+ "@cacheable/utils": "^2.5.0",
+ "hookified": "^1.15.0",
+ "keyv": "^5.6.0",
+ "qified": "^0.10.1"
+ }
+ },
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@@ -2388,6 +3398,13 @@
"node": ">=18"
}
},
+ "node_modules/change-case": {
+ "version": "5.4.4",
+ "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
+ "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -2447,6 +3464,13 @@
"node": ">=6"
}
},
+ "node_modules/colorette": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
+ "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@@ -2464,6 +3488,35 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -2484,6 +3537,35 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/data-urls/node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2502,6 +3584,30 @@
}
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
@@ -2522,6 +3628,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.378",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.378.tgz",
@@ -2529,6 +3642,19 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/entities": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
+ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
@@ -2598,6 +3724,184 @@
"node": ">=6"
}
},
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "10.11.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz",
+ "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==",
+ "dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "packages/*"
+ ],
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.2",
+ "@eslint/config-array": "^0.23.5",
+ "@eslint/config-helpers": "^0.7.0",
+ "@eslint/core": "^1.2.1",
+ "@eslint/plugin-kit": "^0.7.3",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.14.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^9.1.2",
+ "eslint-visitor-keys": "^5.0.1",
+ "espree": "^11.2.0",
+ "esquery": "^1.7.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "11.1.5 || >11.1.6 <12",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "minimatch": "^10.2.5",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
+ "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.24.4",
+ "@babel/parser": "^7.24.4",
+ "hermes-parser": "^0.25.1",
+ "zod": "^3.25.0 || ^4.0.0",
+ "zod-validation-error": "^3.5.0 || ^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "9.1.2",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
+ "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@types/esrecurse": "^4.3.1",
+ "@types/estree": "^1.0.8",
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "11.2.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
+ "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.16.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^5.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
@@ -2608,6 +3912,16 @@
"@types/estree": "^1.0.0"
}
},
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
@@ -2618,6 +3932,13 @@
"node": ">=12.0.0"
}
},
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -2648,6 +3969,20 @@
"node": ">= 6"
}
},
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -2665,6 +4000,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/file-entry-cache": {
+ "version": "11.1.5",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz",
+ "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^6.1.23"
+ }
+ },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -2678,6 +4023,42 @@
"node": ">=8"
}
},
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "6.1.23",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz",
+ "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cacheable": "^2.5.0",
+ "flatted": "^3.4.2",
+ "hookified": "^1.15.0"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.4.4",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
+ "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/fraction.js": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
@@ -2749,6 +4130,32 @@
"node": ">=10.13.0"
}
},
+ "node_modules/globals": {
+ "version": "17.12.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz",
+ "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/hashery": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz",
+ "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hookified": "^1.15.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@@ -2762,6 +4169,43 @@
"node": ">= 0.4"
}
},
+ "node_modules/hermes-estree": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
+ "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/hermes-parser": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
+ "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hermes-estree": "0.25.1"
+ }
+ },
+ "node_modules/hookified": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
+ "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz",
+ "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.15.1"
+ },
+ "engines": {
+ "node": "^22.13.0 || >=24.0.0"
+ }
+ },
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
@@ -2771,6 +4215,20 @@
"void-elements": "3.1.0"
}
},
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/i18next": {
"version": "26.3.3",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.3.tgz",
@@ -2808,6 +4266,39 @@
"@babel/runtime": "^7.23.2"
}
},
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/index-to-position": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
+ "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -2870,6 +4361,20 @@
"node": ">=0.12.0"
}
},
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/jiti": {
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
@@ -2880,12 +4385,95 @@
"jiti": "bin/jiti.js"
}
},
+ "node_modules/js-levenshtein": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
+ "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
+ "node_modules/js-yaml": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+ "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsdom": {
+ "version": "30.1.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz",
+ "integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^7.0.0",
+ "@asamuzakjp/dom-selector": "^9.2.1",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.13",
+ "@exodus/bytes": "^1.15.1",
+ "css-tree": "^3.2.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^7.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.5.2",
+ "parse5": "^8.0.1",
+ "saxes": "^6.0.0",
+ "tough-cookie": "^6.0.2",
+ "undici": "^8.10.2",
+ "w3c-xmlserializer": "^6.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^17.1.1",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.2.3"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsdom/node_modules/lru-cache": {
+ "version": "11.5.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
+ "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -2899,6 +4487,20 @@
"node": ">=6"
}
},
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -2912,6 +4514,30 @@
"node": ">=6"
}
},
+ "node_modules/keyv": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
+ "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@keyv/serialize": "^1.1.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -2932,6 +4558,22 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -2963,6 +4605,16 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -2973,6 +4625,13 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -3004,6 +4663,22 @@
"node": ">=8.6"
}
},
+ "node_modules/minimatch": {
+ "version": "10.2.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
+ "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.8"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3042,6 +4717,13 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/node-releases": {
"version": "2.0.50",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
@@ -3096,6 +4778,128 @@
"node": ">=12.20.0"
}
},
+ "node_modules/openapi-typescript": {
+ "version": "7.13.0",
+ "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz",
+ "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@redocly/openapi-core": "^1.34.6",
+ "ansi-colors": "^4.1.3",
+ "change-case": "^5.4.4",
+ "parse-json": "^8.3.0",
+ "supports-color": "^10.2.2",
+ "yargs-parser": "^21.1.1"
+ },
+ "bin": {
+ "openapi-typescript": "bin/cli.js"
+ },
+ "peerDependencies": {
+ "typescript": "^5.x"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parse-json": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
+ "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.26.2",
+ "index-to-position": "^1.1.0",
+ "type-fest": "^4.39.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -3150,6 +4954,45 @@
"node": ">= 6"
}
},
+ "node_modules/playwright": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
+ "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
+ "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/pluralize": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
+ "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
@@ -3313,6 +5156,61 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/qified": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz",
+ "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hookified": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/qified/node_modules/hookified": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz",
+ "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -3386,6 +5284,13 @@
}
}
},
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -3520,6 +5425,16 @@
"node": ">=8.10.0"
}
},
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -3622,6 +5537,19 @@
"queue-microtask": "^1.2.2"
}
},
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -3641,6 +5569,29 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -3695,6 +5646,19 @@
"node": ">=16 || 14 >=14.17"
}
},
+ "node_modules/supports-color": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
+ "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -3870,6 +5834,26 @@
"node": ">=14.0.0"
}
},
+ "node_modules/tldts": {
+ "version": "7.4.15",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.15.tgz",
+ "integrity": "sha512-SJVBeHOxDbNoq14CvpAoA2mLEWdbldGK8nR+yumpjY5nrlZxOflcA7p1Qj1gbTGmbu9DY9qLj/bENSWhBzjxRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.15"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.15",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.15.tgz",
+ "integrity": "sha512-ERuv0p98XgSzmlSLJr8vDNxX+uATGInlN97V3R+JpYWaSqn5IG3ewWgCwczk4bkOpgth/o8Nt5FOi4B4w0n/1A==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -3883,6 +5867,45 @@
"node": ">=8.0"
}
},
+ "node_modules/tough-cookie": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
+ "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
+ "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@@ -3896,6 +5919,32 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "4.41.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
+ "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -3910,6 +5959,40 @@
"node": ">=14.17"
}
},
+ "node_modules/typescript-eslint": {
+ "version": "8.70.1",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.1.tgz",
+ "integrity": "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.70.1",
+ "@typescript-eslint/parser": "8.70.1",
+ "@typescript-eslint/typescript-estree": "8.70.1",
+ "@typescript-eslint/utils": "8.70.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/undici": {
+ "version": "8.11.2",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz",
+ "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.19.0"
+ }
+ },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -3948,6 +6031,23 @@
"browserslist": ">= 4.21.0"
}
},
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/uri-js-replace": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz",
+ "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
@@ -4225,6 +6325,70 @@
"node": ">=0.10.0"
}
},
+ "node_modules/w3c-xmlserializer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz",
+ "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "17.1.2",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz",
+ "integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.15.1",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^22.14.0 || >=24.0.0"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -4242,12 +6406,92 @@
"node": ">=8"
}
},
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
+ },
+ "node_modules/yaml-ast-parser": {
+ "version": "0.0.43",
+ "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
+ "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.6.5",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
+ "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-validation-error": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
+ "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.0 || ^4.0.0"
+ }
}
}
}
diff --git a/panel/package.json b/panel/package.json
index 3d94e7f..12a921c 100644
--- a/panel/package.json
+++ b/panel/package.json
@@ -8,8 +8,11 @@
"dev": "vite",
"dev:mock": "vite --mode mock",
"build": "tsc -b && vite build",
- "typecheck": "tsc --noEmit",
+ "typecheck": "tsc -b",
"test": "vitest run",
+ "test:e2e": "playwright test",
+ "lint": "eslint . --max-warnings 0",
+ "gen:api": "openapi-typescript ../docs/openapi.yaml -o src/lib/openapi.gen.ts",
"preview": "vite preview"
},
"dependencies": {
@@ -30,16 +33,27 @@
"three": "^0.172.0"
},
"devDependencies": {
+ "@eslint/js": "^10.0.1",
+ "@playwright/test": "^1.63.0",
+ "@testing-library/dom": "^10.4.2",
+ "@testing-library/react": "^16.3.3",
+ "@testing-library/user-event": "^14.6.7",
"@types/node": "^22.10.5",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/three": "^0.172.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
+ "eslint": "^10.11.0",
+ "eslint-plugin-react-hooks": "^7.1.1",
+ "globals": "^17.12.0",
+ "jsdom": "^30.1.1",
+ "openapi-typescript": "^7.13.0",
"postcss": "^8.5.1",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.7.3",
+ "typescript-eslint": "^8.70.1",
"vite": "^6.0.7",
"vitest": "^4.1.9"
}
diff --git a/panel/playwright.config.ts b/panel/playwright.config.ts
new file mode 100644
index 0000000..bd7a639
--- /dev/null
+++ b/panel/playwright.config.ts
@@ -0,0 +1,40 @@
+import { defineConfig, devices } from "@playwright/test";
+
+// Browser smoke over the mock-mode dev server (dev/mockApi.ts): real routing,
+// real fetches and cookies, a fake backend. It runs the installed Chrome, so
+// no browser download is needed locally or on the GitHub runner. The mock's
+// state is shared across requests, so tests run one at a time and each one
+// resets it first. PW_BASE_URL points it at an already running mock server.
+const port = 5298;
+const external = process.env.PW_BASE_URL;
+
+export default defineConfig({
+ testDir: "./e2e",
+ fullyParallel: false,
+ workers: 1,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 1 : 0,
+ reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : "list",
+ use: {
+ baseURL: external ?? `http://localhost:${port}`,
+ channel: "chrome",
+ locale: "en-US",
+ trace: "retain-on-failure",
+ },
+ projects: [
+ { name: "desktop", testMatch: /smoke\.spec\.ts/, use: { ...devices["Desktop Chrome"], channel: "chrome" } },
+ {
+ name: "mobile",
+ testMatch: /mobile\.spec\.ts/,
+ use: { channel: "chrome", viewport: { width: 375, height: 812 }, hasTouch: true, isMobile: true },
+ },
+ ],
+ webServer: external
+ ? undefined
+ : {
+ command: `node node_modules/vite/bin/vite.js --mode mock --port ${port} --strictPort`,
+ url: `http://localhost:${port}/`,
+ reuseExistingServer: !process.env.CI,
+ timeout: 60_000,
+ },
+});
diff --git a/panel/src/App.tsx b/panel/src/App.tsx
index b8344c2..2fd5201 100644
--- a/panel/src/App.tsx
+++ b/panel/src/App.tsx
@@ -1,12 +1,11 @@
-import { useEffect } from "react";
-import { BrowserRouter, Routes, Route, Navigate, useNavigate } from "react-router-dom";
+import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { ThemeProvider } from "@/lib/theme";
import { TierProvider } from "@/lib/tier";
-import { SETUP_REQUIRED_EVENT } from "@/lib/api";
import { AppShell } from "@/components/AppShell";
import { RequireAdmin } from "@/components/RequireAdmin";
import { RequireAuth } from "@/components/RequireAuth";
import { RequireOwner } from "@/components/RequireOwner";
+import { SetupRequiredRedirect } from "@/components/SetupRequiredRedirect";
import { ValidParam } from "@/components/ValidParam";
import { SERVER_NAME_PARAM, USER_ID_PARAM } from "@/lib/params";
import { Login } from "@/pages/Login";
@@ -27,21 +26,6 @@ import { UserDetailPage } from "@/pages/admin/UserDetailPage";
import { MySubmissionsPage } from "@/pages/MySubmissionsPage";
import { UpdatesPage } from "@/pages/admin/UpdatesPage";
-// SetupRequiredRedirect listens for the `403 setup_required` signal api.ts emits
-// when a session still owes forced onboarding (#8) and routes it to the wizard.
-// It must live inside the Router (it navigates) and outside RequireAuth (/setup
-// sits there too); the event fires from any protected call the app makes, so the
-// listener is always mounted by the time one arrives.
-function SetupRequiredRedirect() {
- const navigate = useNavigate();
- useEffect(() => {
- const toSetup = () => navigate("/setup", { replace: true });
- window.addEventListener(SETUP_REQUIRED_EVENT, toSetup);
- return () => window.removeEventListener(SETUP_REQUIRED_EVENT, toSetup);
- }, [navigate]);
- return null;
-}
-
// Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES):
// / User-Side — app-tier, every authenticated principal
// /admin/* Admin-Side — admin-tier, server & content administration
diff --git a/panel/src/components/ErrorBoundary.test.tsx b/panel/src/components/ErrorBoundary.test.tsx
new file mode 100644
index 0000000..9b24cd4
--- /dev/null
+++ b/panel/src/components/ErrorBoundary.test.tsx
@@ -0,0 +1,104 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import i18next from "i18next";
+import { ErrorBoundary } from "./ErrorBoundary";
+
+const { reloadForNewDeploy } = vi.hoisted(() => ({ reloadForNewDeploy: vi.fn() }));
+vi.mock("@/lib/chunk", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, reloadForNewDeploy };
+});
+
+const t = (key: string) => i18next.t(key);
+
+const state = { broken: true, error: new Error("boom") as unknown };
+function Page() {
+ if (state.broken) throw state.error;
+ return page content
;
+}
+
+// React and the boundary both log the caught render error, and React's dev
+// build replays it as a window error event that jsdom would print too.
+let quiet: ReturnType;
+const swallow = (e: ErrorEvent) => e.preventDefault();
+beforeEach(() => {
+ state.broken = true;
+ state.error = new Error("boom");
+ reloadForNewDeploy.mockReset();
+ quiet = vi.spyOn(console, "error").mockImplementation(() => {});
+ window.addEventListener("error", swallow);
+});
+afterEach(() => {
+ quiet.mockRestore();
+ window.removeEventListener("error", swallow);
+});
+
+describe("ErrorBoundary", () => {
+ it("shows the crash page with the error instead of a blank screen", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole("alert")).toBeTruthy();
+ expect(screen.getByText(t("common:crash_title"))).toBeTruthy();
+ expect(screen.getByText("Error: boom")).toBeTruthy();
+ expect(reloadForNewDeploy).not.toHaveBeenCalled();
+ });
+
+ it("try again renders the page once it stops throwing", async () => {
+ render(
+
+
+ ,
+ );
+
+ state.broken = false;
+ await userEvent.click(screen.getByRole("button", { name: t("common:try_again") }));
+
+ expect(screen.getByText("page content")).toBeTruthy();
+ });
+
+ it("clears the crash when the route changes", () => {
+ const view = render(
+
+
+ ,
+ );
+ expect(screen.getByRole("alert")).toBeTruthy();
+
+ state.broken = false;
+ view.rerender(
+
+
+ ,
+ );
+
+ expect(screen.getByText("page content")).toBeTruthy();
+ });
+
+ it("uses the caller's fallback when given one", () => {
+ render(
+ flat fleet
}>
+
+ ,
+ );
+
+ expect(screen.getByText("flat fleet")).toBeTruthy();
+ });
+
+ it("reloads once for a chunk from an older deploy and says the panel updated", () => {
+ state.error = new TypeError("Failed to fetch dynamically imported module: /assets/Files-abc.js");
+ render(
+
+
+ ,
+ );
+
+ expect(reloadForNewDeploy).toHaveBeenCalledOnce();
+ expect(screen.getByText(t("common:crash_updated_title"))).toBeTruthy();
+ });
+});
diff --git a/panel/src/components/FleetGrid.tsx b/panel/src/components/FleetGrid.tsx
index 7db8559..21b1daa 100644
--- a/panel/src/components/FleetGrid.tsx
+++ b/panel/src/components/FleetGrid.tsx
@@ -3,7 +3,7 @@ import { Info } from "lucide-react";
import { useTranslation } from "react-i18next";
import { PHASE_KEY, phaseColor } from "@/components/PhaseBadge";
import { cn } from "@/lib/utils";
-import type { Phase, ServerInfo } from "@/lib/types";
+import type { Phase, MyServerView } from "@/lib/types";
// FleetGrid is the flat stand-in for VoxelFleet when WebGL is unavailable or the
// 3D chunk failed: the same square layout and phase palette seen from above, so
@@ -18,7 +18,7 @@ export function FleetGrid({
servers,
reason,
}: {
- servers: Pick[];
+ servers: Pick[];
reason: FleetGridReason;
}) {
const { t } = useTranslation(["dashboard", "servers"]);
@@ -36,8 +36,9 @@ export function FleetGrid({
style={{ gridTemplateColumns: `repeat(${cols}, ${tile})`, gridAutoRows: tile }}
>
{servers.map((s) => {
- const color = phaseColor(s.phase);
- const label = `${s.displayName || s.name} · ${t(PHASE_KEY[s.phase] ?? PHASE_KEY.Unknown)}`;
+ const phase = s.phase ?? "Unknown";
+ const color = phaseColor(phase);
+ const label = `${s.displayName || s.name} · ${t(PHASE_KEY[phase] ?? PHASE_KEY.Unknown)}`;
return (
({ wake: vi.fn(), stop: vi.fn() }));
+vi.mock("@/lib/api", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, api: { ...actual.api, wake, stop } };
+});
+
+const t = (key: string, opts?: Record) => i18next.t(key, opts);
+
+beforeEach(() => {
+ wake.mockReset();
+ stop.mockReset();
+});
+
+describe("PowerButton", () => {
+ it("wakes a stopped server and tells the parent", async () => {
+ wake.mockResolvedValue(undefined);
+ const onChanged = vi.fn();
+ render( );
+
+ await userEvent.click(screen.getByRole("button", { name: t("servers:wake") }));
+
+ expect(wake).toHaveBeenCalledWith("lobby");
+ expect(onChanged).toHaveBeenCalledOnce();
+ });
+
+ it("stays on the page with the reason when the wake is refused", async () => {
+ wake.mockRejectedValue({ status: 429, code: "quota_exceeded", message: "raw" });
+ const onChanged = vi.fn();
+ render( );
+
+ await userEvent.click(screen.getByRole("button", { name: t("servers:wake") }));
+
+ expect(await screen.findByRole("alert")).toHaveProperty("textContent", t("errors:quota_exceeded"));
+ expect(onChanged).not.toHaveBeenCalled();
+ expect(screen.getByRole("button", { name: t("servers:wake") })).toHaveProperty("disabled", false);
+ });
+
+ it("stops an empty server without asking", async () => {
+ stop.mockResolvedValue(undefined);
+ const onChanged = vi.fn();
+ render( );
+
+ await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
+
+ expect(stop).toHaveBeenCalledWith("lobby");
+ expect(onChanged).toHaveBeenCalledOnce();
+ });
+
+ it("asks before disconnecting players, and cancel sends nothing", async () => {
+ render( );
+
+ await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
+ expect(screen.getByText(t("servers:stop_confirm_players", { count: 3 }))).toBeTruthy();
+ await userEvent.click(screen.getByRole("button", { name: t("common:cancel") }));
+
+ expect(stop).not.toHaveBeenCalled();
+ expect(screen.queryByText(t("servers:stop_confirm_players", { count: 3 }))).toBeNull();
+ });
+
+ it("asks when the player count cannot be read", async () => {
+ stop.mockResolvedValue(undefined);
+ const onChanged = vi.fn();
+ render( );
+
+ await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
+ expect(stop).not.toHaveBeenCalled();
+ expect(screen.getByText(t("servers:stop_confirm_unknown"))).toBeTruthy();
+ await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
+
+ expect(stop).toHaveBeenCalledWith("lobby");
+ expect(onChanged).toHaveBeenCalledOnce();
+ });
+
+ it("keeps the confirmation open with the reason when the stop fails", async () => {
+ stop.mockRejectedValue({ status: 409, code: "cooldown", message: "raw" });
+ const onChanged = vi.fn();
+ render( );
+
+ await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
+ await userEvent.click(screen.getByRole("button", { name: t("servers:stop") }));
+
+ expect(await screen.findByText(t("errors:cooldown"))).toBeTruthy();
+ expect(screen.getByText(t("servers:stop_confirm_players", { count: 2 }))).toBeTruthy();
+ expect(onChanged).not.toHaveBeenCalled();
+ });
+
+ it("sends one call however fast it is clicked", async () => {
+ let resolve!: () => void;
+ wake.mockReturnValue(new Promise((r) => (resolve = r)));
+ render( );
+
+ const button = screen.getByRole("button", { name: t("servers:wake") });
+ await userEvent.click(button);
+ await userEvent.click(screen.getByRole("button", { name: t("servers:waking") }));
+ resolve();
+
+ expect(wake).toHaveBeenCalledOnce();
+ });
+});
diff --git a/panel/src/components/RequireAuth.test.tsx b/panel/src/components/RequireAuth.test.tsx
new file mode 100644
index 0000000..f325eb5
--- /dev/null
+++ b/panel/src/components/RequireAuth.test.tsx
@@ -0,0 +1,68 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
+import i18next from "i18next";
+import { RequireAuth } from "./RequireAuth";
+
+const tier = vi.hoisted(() => ({ loading: false, unauthenticated: false, sessionEnded: false }));
+vi.mock("@/lib/tier", () => ({ useTier: () => tier }));
+
+function LoginProbe() {
+ const location = useLocation();
+ return (
+
+ login{location.search} ended={String((location.state as { sessionEnded?: boolean } | null)?.sessionEnded ?? false)}
+
+ );
+}
+
+function renderAt(path: string) {
+ return render(
+
+
+ } />
+ }>
+ dashboard
} />
+ console} />
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ Object.assign(tier, { loading: false, unauthenticated: false, sessionEnded: false });
+});
+
+describe("RequireAuth", () => {
+ it("shows a spinner while /me is in flight, never the login page", () => {
+ tier.loading = true;
+ tier.unauthenticated = true;
+ renderAt("/servers/lobby");
+
+ expect(screen.getByText(i18next.t("common:loading"))).toBeTruthy();
+ expect(screen.queryByText(/^login/)).toBeNull();
+ });
+
+ it("sends a 401 to /login with the page to come back to", () => {
+ tier.unauthenticated = true;
+ renderAt("/servers/lobby?tab=files");
+
+ expect(screen.getByText(`login?next=${encodeURIComponent("/servers/lobby?tab=files")} ended=false`)).toBeTruthy();
+ });
+
+ it("drops ?next= for the dashboard and says when the session ended", () => {
+ tier.unauthenticated = true;
+ tier.sessionEnded = true;
+ renderAt("/");
+
+ expect(screen.getByText("login ended=true")).toBeTruthy();
+ });
+
+ it("renders the app for a session, including a degraded /me", () => {
+ renderAt("/servers/lobby");
+
+ expect(screen.getByText("console")).toBeTruthy();
+ });
+});
diff --git a/panel/src/components/ServerCard.tsx b/panel/src/components/ServerCard.tsx
index f6fbb0a..620e9c2 100644
--- a/panel/src/components/ServerCard.tsx
+++ b/panel/src/components/ServerCard.tsx
@@ -8,10 +8,10 @@ import { PhaseBadge } from "@/components/PhaseBadge";
import { PowerButton } from "@/components/PowerButton";
import { api, humanizeError } from "@/lib/api";
import { hostFor, type RuntimeConfig } from "@/lib/config";
-import type { ServerInfo } from "@/lib/types";
+import type { MyServerView } from "@/lib/types";
interface Props {
- server: ServerInfo;
+ server: MyServerView;
cfg: RuntimeConfig;
/** Called after a successful mutation so the parent can refetch. */
onChanged: () => void;
@@ -49,7 +49,7 @@ export function ServerCard({ server, cfg, onChanged }: Props) {
{server.displayName || server.name}
-
+
{/* Info Row */}
diff --git a/panel/src/components/SetupRequiredRedirect.test.tsx b/panel/src/components/SetupRequiredRedirect.test.tsx
new file mode 100644
index 0000000..f25c688
--- /dev/null
+++ b/panel/src/components/SetupRequiredRedirect.test.tsx
@@ -0,0 +1,43 @@
+// @vitest-environment jsdom
+import { describe, it, expect } from "vitest";
+import { act, render, screen } from "@testing-library/react";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import { SETUP_REQUIRED_EVENT } from "@/lib/api";
+import { SetupRequiredRedirect } from "./SetupRequiredRedirect";
+
+function Tree({ listening }: { listening: boolean }) {
+ return (
+
+ {listening && }
+
+ setup wizard} />
+ servers} />
+
+
+ );
+}
+
+const announce = () =>
+ act(() => {
+ window.dispatchEvent(new Event(SETUP_REQUIRED_EVENT));
+ });
+
+describe("SetupRequiredRedirect", () => {
+ it("routes to the wizard when a call answers 403 setup_required", () => {
+ render( );
+ expect(screen.getByText("servers")).toBeTruthy();
+
+ announce();
+
+ expect(screen.getByText("setup wizard")).toBeTruthy();
+ });
+
+ it("stops redirecting once it is unmounted", () => {
+ const view = render( );
+ view.rerender( );
+
+ announce();
+
+ expect(screen.getByText("servers")).toBeTruthy();
+ });
+});
diff --git a/panel/src/components/SetupRequiredRedirect.tsx b/panel/src/components/SetupRequiredRedirect.tsx
new file mode 100644
index 0000000..c7ae887
--- /dev/null
+++ b/panel/src/components/SetupRequiredRedirect.tsx
@@ -0,0 +1,18 @@
+import { useEffect } from "react";
+import { useNavigate } from "react-router-dom";
+import { SETUP_REQUIRED_EVENT } from "@/lib/api";
+
+// SetupRequiredRedirect listens for the `403 setup_required` signal api.ts emits
+// when a session still owes forced onboarding (#8) and routes it to the wizard.
+// It must live inside the Router (it navigates) and outside RequireAuth (/setup
+// sits there too); the event fires from any protected call the app makes, so the
+// listener is always mounted by the time one arrives.
+export function SetupRequiredRedirect() {
+ const navigate = useNavigate();
+ useEffect(() => {
+ const toSetup = () => navigate("/setup", { replace: true });
+ window.addEventListener(SETUP_REQUIRED_EVENT, toSetup);
+ return () => window.removeEventListener(SETUP_REQUIRED_EVENT, toSetup);
+ }, [navigate]);
+ return null;
+}
diff --git a/panel/src/components/VoxelFleet.tsx b/panel/src/components/VoxelFleet.tsx
index 5051011..ea9ad03 100644
--- a/panel/src/components/VoxelFleet.tsx
+++ b/panel/src/components/VoxelFleet.tsx
@@ -3,7 +3,7 @@ import * as THREE from "three";
import { phaseColor } from "@/components/PhaseBadge";
import { FleetGrid } from "@/components/FleetGrid";
import { webglAvailable } from "@/lib/webgl";
-import type { ServerInfo } from "@/lib/types";
+import type { MyServerView } from "@/lib/types";
// VoxelFleet renders the fleet as a grid of voxels, one per server, colored by
// lifecycle phase — the 3D view of the same state machine the list shows (spec
@@ -16,7 +16,7 @@ import type { ServerInfo } from "@/lib/types";
// cannot be created at all, so the card falls back to the flat FleetGrid.
interface Props {
- servers: ServerInfo[];
+ servers: MyServerView[];
}
const STARTING = "Starting";
@@ -86,7 +86,7 @@ function VoxelScene({ servers, onUnsupported }: Props & { onUnsupported: () => v
voxels = [];
}
- function build(list: ServerInfo[]) {
+ function build(list: MyServerView[]) {
clearVoxels();
const n = Math.max(list.length, 1);
const cols = Math.ceil(Math.sqrt(n));
@@ -96,7 +96,7 @@ function VoxelScene({ servers, onUnsupported }: Props & { onUnsupported: () => v
const offZ = ((rows - 1) * spacing) / 2;
list.forEach((s, i) => {
- const color = new THREE.Color(phaseColor(s.phase));
+ const color = new THREE.Color(phaseColor(s.phase ?? "Unknown"));
const material = new THREE.MeshStandardMaterial({
color,
roughness: 0.45,
@@ -189,6 +189,6 @@ function VoxelScene({ servers, onUnsupported }: Props & { onUnsupported: () => v
/** sig is a cheap fingerprint of the fleet's renderable shape (order-independent
* per index): name+phase pairs. Player counts don't change the voxels, so they
* don't trigger a rebuild. */
-function sig(servers: ServerInfo[]): string {
+function sig(servers: MyServerView[]): string {
return servers.map((s) => `${s.name}:${s.phase}`).join("|");
}
diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts
index b18ea6e..b82a072 100644
--- a/panel/src/lib/api.ts
+++ b/panel/src/lib/api.ts
@@ -20,7 +20,7 @@ import type {
QuotaView,
ServerFileEntry,
ServerJob,
- ServerInfo,
+ MyServerView,
ServerStatus,
SessionView,
UserDetail,
@@ -300,7 +300,7 @@ export const api = rejectingSync({
me: () => request("GET", "/me"),
myServers: () =>
- request<{ servers: ServerInfo[] }>("GET", "/me/servers").then((r) => r.servers ?? []),
+ request<{ servers: MyServerView[] }>("GET", "/me/servers").then((r) => r.servers ?? []),
// fleet is the SysAdmin cockpit's fleet-wide read (admin-tier GET /fleet): every
// server's CRD lifecycle view plus its owner. It 403s for a non-admin principal —
diff --git a/panel/src/lib/hooks.ts b/panel/src/lib/hooks.ts
index 6552fe5..3fa4dbf 100644
--- a/panel/src/lib/hooks.ts
+++ b/panel/src/lib/hooks.ts
@@ -58,8 +58,10 @@ export function useAsync(fn: () => Promise, deps: unknown[] = []): AsyncSt
useEffect(() => {
reload();
+ // Bumping the ticket on cleanup drops any response still in flight.
+ const tickets = seq;
return () => {
- seq.current++;
+ tickets.current++;
};
}, [reload]);
diff --git a/panel/src/lib/openapi.gen.ts b/panel/src/lib/openapi.gen.ts
new file mode 100644
index 0000000..5496a5c
--- /dev/null
+++ b/panel/src/lib/openapi.gen.ts
@@ -0,0 +1,6808 @@
+/**
+ * This file was auto-generated by openapi-typescript.
+ * Do not make direct changes to the file.
+ */
+
+export interface paths {
+ "/healthz": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Liveness probe.
+ * @description Unauthenticated on both faces; kubelet and Cloudflare hold no token.
+ */
+ get: operations["healthz"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/readyz": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Readiness probe (internal face only — readiness is an internal concern). */
+ get: operations["readyz"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/metrics": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Prometheus metrics (felis_* collectors) on the internal face.
+ * @description Scrape-only infrastructure route, not a product API: the internal listener is ClusterIP-only and a Prometheus scrape carries no token, the same stance as the probes. Serves the felis_* exposition documented in troubleshooting §14; the external face never serves it.
+ */
+ get: operations["metrics"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/session/minecraft/hasJoined": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Multi-source session verifier (Felis-nano hasJoined multiplexer).
+ * @description Velocity is pointed here with -Dmojang.sessionserver and sends the request itself. Unauthenticated — the vanilla sessionserver protocol carries no token. The query is fanned out to the configured Yggdrasil roots in priority order (the Mojang identity source first); the first source to validate the serverId hash wins. A non-identity source's self-asserted UUID is rewritten into a per-source namespace (UUIDv3) before return, so it can never land in Mojang's UUID space. A rejected or barred login is 204, which Velocity answers with its online-mode-only kick. Any other non-200 status makes Velocity report the auth servers as down.
+ */
+ get: operations["hasJoined"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/servers": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** List all servers (velocity route table). */
+ get: operations["listServers"];
+ put?: never;
+ /**
+ * Create a server (admin).
+ * @description Requires the admin Access path; the image must be whitelisted. An image in the platform registry is stored pinned to the digest its tag names at creation (name:tag@sha256:…), so a later push over the tag never moves the server; 400 image_not_in_registry when the registry lacks the tag, 503 registry_unavailable when it cannot be asked.
+ */
+ post: operations["createServer"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/internal/servers/{name}/ready": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Backend readiness callback — the server reports it is accepting players. */
+ post: operations["serverReadyCallback"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/internal/submissions/{id}/context": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Stream a submission's stored build-context tarball to the build Pod.
+ * @description The build Job's fetch initContainer cannot mount the control-plane uploads PVC (a PVC does not cross namespaces) and holds no object-store credentials, so the API that stored the blob streams it here. Served on the internal face (service token, no Zero Trust).
+ */
+ get: operations["internalSubmissionContext"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/internal/servers/{name}/join-event": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Player-join event by online-mode UUID (activity tracking / idle reset). */
+ post: operations["joinEvent"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/internal/servers/{name}/wake": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 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.
+ */
+ post: operations["internalWake"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/internal/servers/{name}/status": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Server status projection (velocity polls this after a wake). */
+ get: operations["internalStatus"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/internal/servers/{name}/claim": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 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.
+ */
+ post: operations["internalClaim"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/internal/servers/{name}/menu": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Lobby menu projection — status plus the ownership-derived `claimable`. */
+ get: operations["internalMenuStatus"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/internal/account/link/code": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 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.
+ */
+ post: operations["createLinkCode"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/internal/account/link/status/{mc_uuid}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * 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.
+ */
+ get: operations["linkStatus"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/internal/account/migrate/start": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 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.
+ */
+ post: operations["migrateStart"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/internal/player/reclaim": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 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.
+ */
+ post: operations["reclaimUsername"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/internal/player/blacklist/{mc_uuid}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * 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.
+ */
+ get: operations["checkUsernameBlacklist"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/internal/op-login/pending": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * 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 ; the route exists so velocity can later push the waiting list to online admins. No pending request is secret to the operator crew.
+ */
+ get: operations["opLoginPending"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/internal/op-login/{id}/approve": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 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.
+ */
+ post: operations["opLoginApprove"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/internal/servers/{name}/backup": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 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".
+ */
+ post: operations["internalBackupNow"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/servers/{name}/wake": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Wake your own server. */
+ post: operations["wake"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/servers/{name}/stop": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Stop your own server. */
+ post: operations["stop"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/servers/{name}/claim": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Claim an unowned server for your linked account. */
+ post: operations["claim"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/servers/{name}/command": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Run a console command via RCON (spec §8 write). Owner/admin only.
+ * @description The RCON password is never accepted or returned (spec §286).
+ */
+ post: operations["command"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/servers/{name}/console": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Stream the running pod's log over SSE (spec §8 read, §262). Owner/admin only. */
+ get: operations["serverConsole"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/servers/{name}/access/whitelist": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * 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).
+ */
+ get: operations["accessWhitelistList"];
+ put?: never;
+ /**
+ * Add or remove a player from the whitelist (spec §7). Owner/admin only.
+ * @description Translates to the RCON "whitelist add|remove " 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).
+ */
+ post: operations["accessWhitelist"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/servers/{name}/access/players": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * 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).
+ */
+ get: operations["accessPlayers"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/servers/{name}/access/ban": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * 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).
+ */
+ get: operations["accessBanList"];
+ put?: never;
+ /**
+ * Ban or pardon a player (spec §7). Owner/admin only.
+ * @description Translates to the RCON "ban|pardon " 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).
+ */
+ post: operations["accessBan"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/servers/{name}/access/kick": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Kick a player off the running server (spec §7). Owner/admin only.
+ * @description Translates to the RCON "kick " 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).
+ */
+ post: operations["accessKick"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/servers/{name}/access/permission": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Set or unset a LuckPerms permission node (spec §7). Owner/admin only.
+ * @description Translates to "lp user permission set [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).
+ */
+ post: operations["accessPermission"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/servers/{name}/access/group": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Add or remove a player's LuckPerms parent group (spec §7). Owner/admin only.
+ * @description Translates to "lp user parent add|remove ". Player and group are charset-validated before the command is assembled. The RCON password is never accepted or returned (spec §286).
+ */
+ post: operations["accessGroup"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/servers/{name}/access/luckperms/{player}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Read a player's LuckPerms groups and permission nodes (spec §7). Owner/admin only.
+ * @description Translates to "lp user permission info" over RCON and parses the paginated, colour-coded reply (up to 10 pages) into structured entries. Parent groups (granted group. 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.
+ */
+ get: operations["accessLuckPermsInfo"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/servers/{name}/status": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Status of a server; the full record for its owner and staff.
+ * @description Anyone signed in may ask. The owner and staff get the whole projection; anyone else gets what the game's own server list shows: name, subdomain, displayName, phase, ready, playersOnline and playersMax.
+ */
+ get: operations["status"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/auth/options": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Identifier-first login discovery — which methods can this email use (spec §B,
+ * @description Public, pre-session discovery for the SPA's identifier-first form: given a typed email, report which console login methods the account can use (passkey and/or email-OTP) so the UI prompts for the right authenticator. This is the deliberate counter-slice to the anti-enumeration login doors — the ONE sanctioned place account existence is disclosed, so an unknown address returns an empty methods array. It never reveals staffness: methods are computed identically for every resolved account (no role branch), so a staff and a player address in the same credential state return byte-identical bodies. passkey is offered only when a verifier is wired. Sends no mail and mutates nothing; bounded by the per-address sign-in rate limit (429 rate_limited). Gated on local_auth_enabled.
+ */
+ post: operations["authOptions"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/auth/passkey/login/begin": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 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.
+ */
+ post: operations["passkeyLoginBegin"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/auth/passkey/login/finish": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 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.
+ */
+ post: operations["passkeyLoginFinish"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/auth/passkey/login/discoverable/begin": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Begin a usernameless (discoverable) passkey login (spec §14, §B, task
+ * @description First leg of the truly from-zero passkey door: unlike the email-first sibling above, the caller supplies NO identifier — the request has no body (only the application/json Content-Type is required as the cross-origin CSRF guard). The response is the WebAuthn PublicKeyCredentialRequestOptions with an EMPTY allowCredentials, plus an opaque login_id: the authenticator picks a resident credential it holds for this RP and the account is revealed only by the userHandle inside the signed assertion at finish. The challenge cannot be user-keyed, so it is stashed under login_id in a non-user-keyed store and echoed back at finish. Mounted Public and gated on local_auth_enabled. There is no recipient or principal to key a per-caller cooldown on, so one client is bounded by the per-address sign-in rate limit (429 rate_limited) and the table by a hard global cap on live challenges (429 too_many_challenges). Inert for a credential until its owner enrolls a resident passkey; email-OTP and username-first passkey remain the fallbacks, so no authenticator is ever locked out.
+ */
+ post: operations["passkeyLoginDiscoverableBegin"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/auth/passkey/login/discoverable/finish": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Complete a usernameless (discoverable) passkey login and mint a session (spec §14, §B, task
+ * @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).
+ */
+ post: operations["passkeyLoginDiscoverableFinish"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/auth/email/start": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Begin a passwordless email-OTP login — mail a one-time code (spec §B).
+ * @description Public, pre-session console door: the caller supplies an email and, if it resolves to a verified account, a one-time code is mailed under the login purpose. An address with no account returns the SAME 202 with no code minted, and the per-recipient cooldown is kept on that path too, so probing reveals nothing (existence is learnt only at the sanctioned /auth/options oracle). An account that spent its daily wrong-code budget (10 per 24h, across every code) also gets the same 202 and no mail until the window ends. Gated on local_auth_enabled.
+ */
+ post: operations["loginEmailStart"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/auth/email/verify": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Redeem an email-OTP login code into a session (spec §B).
+ * @description Public, pre-session: resolves the address to an account, verifies the code under the login purpose, and on success mints a host-only felis_session. An unknown address, a wrong or expired code, and an attempt-exhausted code all return the IDENTICAL 400 invalid_code, so the door is not an existence or lockout oracle. The 10th wrong code in 24h locks the door for that account until the window ends (the right code then also reads as invalid_code); the owner is told by mail once, and the lock is audited as auth.otp.locked. Staff are refused (403) — but only AFTER a valid code is redeemed, so only the account owner can ever reach that refusal.
+ */
+ post: operations["loginEmailVerify"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/auth/op-login/start": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Begin an op.console staff login — mail an OTP, open an approval request (spec §B).
+ * @description Public, pre-session first leg of the two-factor operator door: resolves the staff address, opens an op_login request, and mails a one-time code under the op_login purpose, returning the request handle the browser polls. A non-staff or unknown address gets the SAME 202 with a random, non-persisted handle and no mail, so this never becomes a staff-enumeration oracle. A staff account that spent its daily wrong-code budget gets the same neutral 202. Gated on local_auth_enabled.
+ */
+ post: operations["opLoginStart"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/auth/op-login/status/{id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * 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.
+ */
+ get: operations["opLoginStatus"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/auth/op-login/finish": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Redeem an approved op.console request plus its mailed code into a staff session (spec §B).
+ * @description Public, pre-session final leg: mints a host-only staff session only when BOTH factors have landed — the request is approved-and-live AND the mailed code verifies. Every failure (unknown handle, not-yet-approved, wrong or locked code, an account past its daily wrong-code budget, lost race) collapses into one uniform 400 op_login_invalid, so a code-less caller learns nothing. Admin is re-asserted before the session is issued.
+ */
+ post: operations["opLoginFinish"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/auth/setup/redeem": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 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.
+ */
+ post: operations["setupRedeem"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/auth/setup/status": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * 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).
+ */
+ get: operations["setupStatus"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/auth/logout": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 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.
+ */
+ post: operations["logout"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/auth/bind": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 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.): 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).
+ */
+ post: operations["bindRedeem"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/me": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * 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.
+ */
+ get: operations["me"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/me/servers": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** List the servers the caller owns or may claim. */
+ get: operations["myServers"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/updates/window": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * 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.
+ */
+ get: operations["getUpdateWindow"];
+ /**
+ * 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.
+ */
+ put: operations["setUpdateWindow"];
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/platform/db-backup": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Freshness of the newest control-plane database backup (admin).
+ * @description What the host's felis-db-backup.timer (or a manual `felis db backup`) last recorded in platform_settings. last is null before the first backup; stale is true then, and whenever the newest backup is older than max_age_seconds. Read-only: backups run on the host, never through the API.
+ */
+ get: operations["getDBBackup"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/fleet": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * 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.
+ */
+ get: operations["fleet"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/backups": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** List world backups (admin sees all; a user sees only worlds they formerly owned). */
+ get: operations["listBackups"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/servers/{name}/restore-backup": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Restore a world from a backup (owner-or-admin plus a former-owner match).
+ * @description By default the restore starts with a safety snapshot: a backup of the data volume as it is now (reason "pre_restore", the newest 3 kept per server), and the restore Job starts only once that backup has succeeded. If the snapshot fails the restore is given up and the world is left as it was. GET /servers/{name}/jobs shows the snapshot as a backup job whose then_restore says what became of the restore. The world stays locked from the request until the restore Job finishes. Pass safety_snapshot false to restore straight away.
+ */
+ post: operations["restoreBackup"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/servers/{name}/backup": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Back up a server's data volume on demand (owner-or-admin; server must be stopped).
+ * @description Snapshots the server's whole data volume (worlds, config, plugins/mods, jars, libraries — not just world folders) into the archive store as a first-class world_backups row (reason "manual"), restorable later like an inactivity backup. A restore replaces the volume with the archive. The world PVC is RWO and held by a running server, so the server must be fully stopped first (409 not_stopped otherwise). The backup runs asynchronously as a Job, so success is 202 (backing_up).
+ */
+ post: operations["backupNow"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/servers/{name}/jobs": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Latest async world operations (backup/restore) for a server (owner-or-admin).
+ * @description Backup and restore run as cluster Jobs, so a 202 that later failed left its only trace in the Job object. This route projects the newest such Jobs, newest first, so failures are observable without kubectl. State is "running" | "succeeded" | "failed".
+ */
+ get: operations["listServerJobs"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/servers/{name}/files": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * 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.
+ */
+ get: operations["listServerFiles"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/servers/{name}/file": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * 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.
+ */
+ get: operations["readServerFile"];
+ /**
+ * Write a file in a server's world volume (owner-or-admin; server must be stopped).
+ * @description Replaces a file's contents, creating the file if absent but never creating its parent directories. Content is base64 so arbitrary bytes (CRLF endings, a BOM) survive intact. Writes are capped at 256 KiB — the Job spec carries the content, and etcd bounds the object — so a larger body is 413. Same stopped-gate and os.Root containment as the read; a write through a symlink leaving the world root is refused. The replacement is atomic (a synced temporary sibling renamed over the file, keeping its mode), so a failed write leaves the old file whole. With expect_sha256 the write lands only if the file still has that hash; otherwise 409 file_changed. Audited as file.write.
+ */
+ put: operations["writeServerFile"];
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/users": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * 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.
+ */
+ get: operations["listUsers"];
+ put?: never;
+ /** Create a user (admin only). */
+ post: operations["createUser"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/users/{id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Get user detail (admin only). */
+ get: operations["getUser"];
+ put?: never;
+ post?: never;
+ /** Soft-delete a user — releases servers, revokes sessions (admin only, cannot delete self). */
+ delete: operations["deleteUser"];
+ options?: never;
+ head?: never;
+ /** Edit a user (admin only, cannot patch self). */
+ patch: operations["patchUser"];
+ trace?: never;
+ };
+ "/api/v1/users/{id}/disable": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 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.
+ */
+ post: operations["disableUser"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/users/{id}/quotas": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Get a user's quotas (admin only). */
+ get: operations["getQuotas"];
+ /** Set a user's quotas (admin only). */
+ put: operations["setQuotas"];
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/users/{id}/sessions": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** List a user's live sessions (admin only). */
+ get: operations["listUserSessions"];
+ put?: never;
+ post?: never;
+ /** Revoke every live session of a user (admin only). */
+ delete: operations["revokeUserSessions"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/users/{id}/sessions/{hash}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ /** Revoke a single session of a user (admin only). */
+ delete: operations["revokeUserSession"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/users/{id}/passkeys": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ /**
+ * 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.
+ */
+ delete: operations["unbindUserPasskeys"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/users/{id}/links": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 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.
+ */
+ post: operations["linkAccount"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/users/{id}/links/{mc_uuid}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ /** Remove a single Minecraft UUID binding from a user (admin only). */
+ delete: operations["unlinkAccount"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/account/link/start": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Report account-link status and in-game instructions (web side, spec §10). */
+ post: operations["linkStart"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/account/link/verify": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Consume an in-game link code and bind the account. */
+ post: operations["linkVerify"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/account/email/start": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 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.
+ */
+ post: operations["emailOtpStart"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/account/email/verify": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Redeem an email one-time code and mark the caller's email verified (spec §B2).
+ * @description Consumes a previously delivered code for the authenticated principal. On success the user's email is written and email_verified is set true. Too many incorrect attempts lock the code (429 otp_locked); 10 wrong codes in 24h, counted across every code, lock the account's email-code door until the window ends (429 otp_account_locked with Retry-After). An unknown, expired, consumed, or mismatched code is a 400.
+ */
+ post: operations["emailOtpVerify"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/account/email": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 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.
+ */
+ post: operations["setEmail"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/account/passkey/register/begin": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 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.
+ */
+ post: operations["passkeyRegisterBegin"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/account/passkey/register/finish": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 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.
+ */
+ post: operations["passkeyRegisterFinish"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/account/passkey/credentials": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * 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.
+ */
+ get: operations["passkeyList"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/account/passkey/credentials/{id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ /**
+ * Unbind one of the caller's passkeys (spec §14, Phase 6 bind).
+ * @description Removes a passkey scoped to the authenticated principal, so a caller can only unbind their OWN credential. An unknown or cross-user id is a 404; it never silently no-ops as success. The account's only passkey cannot be removed while its email is unverified (409 last_passkey): it is then the account's only durable way in.
+ */
+ delete: operations["passkeyDelete"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/account/migrate": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * 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.
+ */
+ get: operations["migrateStatus"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/account/migrate/confirm/otp/start": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 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.
+ */
+ post: operations["migrateConfirmOtpStart"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/account/migrate/confirm/otp/verify": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Redeem the email one-time code and confirm the migration (spec §B3 step-up).
+ * @description Consumes the fresh migrate-purpose email code for the caller's initiated migration and advances it to confirmed with confirm_factor email_otp. Too many wrong attempts lock the code (429 otp_locked), and 10 wrong codes in 24h lock the account's email-code door (429 otp_account_locked with Retry-After); an unknown, expired, consumed, or mismatched code is a 400 invalid_code.
+ */
+ post: operations["migrateConfirmOtpVerify"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/account/migrate/confirm/passkey/begin": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 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.
+ */
+ post: operations["migrateConfirmPasskeyBegin"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/account/migrate/confirm/passkey/finish": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 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.
+ */
+ post: operations["migrateConfirmPasskeyFinish"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/account/migrate/issue-code": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 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.
+ */
+ post: operations["migrateIssueCode"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/account/migrate/redeem": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * 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.
+ */
+ post: operations["migrateRedeem"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/me/submissions": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** List the caller's own modpack submissions with each linked build's outcome (user-directed lane over §16). */
+ get: operations["mySubmissions"];
+ put?: never;
+ /** Submit a modpack for admin review (user side; user-directed lane over §16). Starts no build. */
+ post: operations["createSubmission"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/me/submissions/{id}/context": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Upload the modpack build context for your own pending submission (user side; user-directed lane over §16).
+ * @description The request body IS the raw gzip build context (context.tar.gz) — not JSON, not multipart — streamed to the platform-derived, id-namespaced location Kaniko reads via --context. The submitter is taken from the principal; a submission the caller does not own is reported as 404, so this endpoint cannot upload to or probe another user's submission. Only a pending_review submission accepts a context (409 otherwise); a wrong-format or oversize body is rejected with 400, and an upload that would push the caller past their per-user stored-context budget is refused with 403 before the excess is persisted. Returns 503 when the deployment's context store has no implemented upload transport.
+ */
+ post: operations["uploadSubmissionContext"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/me/submissions/{id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ /**
+ * Withdraw your own pending submission (user side; user-directed lane over §16).
+ * @description Retracts the caller's own submission while it is still pending review: the row and its uploaded build context are deleted, freeing the pending slot and the per-user storage budget for a fresh submission. A reviewed submission is frozen (409 — its build may already be consuming the context), and a submission the caller does not own reads back as 404, so this endpoint cannot probe or clear another user's uploads.
+ */
+ delete: operations["withdrawSubmission"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/servers/{name}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ /** Mutate a server spec (admin). Storage is immutable. */
+ patch: operations["patchServer"];
+ trace?: never;
+ };
+ "/api/v1/images/build": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Submit an image build (admin). A build is build-time RCE against the cluster. */
+ post: operations["buildImage"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/images/build/{id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Get one build's status (admin). */
+ get: operations["getBuild"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/images/build/{id}/logs": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Stream a build's Job log over SSE (admin, spec §16 / §416). */
+ get: operations["buildLogs"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/images/build/{id}/cancel": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Cancel a running build (admin). */
+ post: operations["cancelBuild"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/images": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** List whitelisted images (admin). */
+ get: operations["listImages"];
+ put?: never;
+ /** Whitelist an externally-built image by reference (admin). */
+ post: operations["addImage"];
+ /** Remove an image from the whitelist by reference (admin). */
+ delete: operations["removeImage"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/submissions": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** The admin review queue — every user's modpack submissions (admin; user-directed lane over §16). */
+ get: operations["listSubmissions"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/submissions/{id}/approve": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** 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. */
+ post: operations["approveSubmission"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/submissions/{id}/context": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Download a submission's uploaded build context (admin; user-directed lane over §16).
+ * @description The reviewer's read path to the artifact they are about to approve: the executed Dockerfile lives inside this tarball (Kaniko runs the context's root `Dockerfile`), so without it the human gate would be blind. Streams the stored context.tar.gz verbatim with an attachment disposition — the same bytes the build Pod fetches over the internal face. 404 when the submission is unknown or has no uploaded context; 503 when the deployment's context store has no implemented transport.
+ */
+ get: operations["downloadSubmissionContext"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/submissions/{id}/reject": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Reject a submission with a required reason (admin; user-directed lane over §16). Starts no build. */
+ post: operations["rejectSubmission"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/submissions/{id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ /**
+ * Retire a submission outright — row and uploaded context (admin; user-directed lane over §16).
+ * @description Removes the submission and its uploaded build context, any status — the lane's only lifecycle valve, and the path that reclaims a rejected or consumed upload from the uploads PVC. The reviewer identity is recorded in the audit event, not on the (now deleted) row. Deleting an approved submission whose build is still running fails that build's context fetch; the admin has explicitly chosen to retire the artifact.
+ */
+ delete: operations["deleteSubmission"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+}
+export type webhooks = Record;
+export interface components {
+ schemas: {
+ /** @description Uniform error envelope emitted by every handler (internal/api/errors.go). */
+ Error: {
+ error: {
+ /** @description Stable machine-readable code (e.g. not_found, conflict, forbidden, bad_request). */
+ code: string;
+ message: string;
+ /** @description Correlates the response with server logs (withRequestID middleware). */
+ request_id?: string;
+ };
+ };
+ /** @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. */
+ UpdateWindow: {
+ /**
+ * Format: date-time
+ * @description Window start (RFC3339, inclusive), or null when unset.
+ */
+ start: string | null;
+ /**
+ * Format: date-time
+ * @description Window end (RFC3339, exclusive), or null when unset.
+ */
+ end: string | null;
+ };
+ /** @description The newest control-plane database backup the host recorded (internal/api/handlers_dbbackup.go dbBackupView; the record itself is internal/dbbackup Status, written by `felis db backup`). */
+ DBBackupStatus: {
+ /** @description Null until the first backup has been recorded. */
+ last: {
+ /**
+ * Format: date-time
+ * @description When the bundle was written.
+ */
+ at: string;
+ /** @description Bundle file name, felis-db--.tar. */
+ name: string;
+ /** @enum {string} */
+ label: "daily" | "pre-migrate" | "pre-restore" | "manual";
+ /** Format: int64 */
+ size_bytes: number;
+ felis_version?: string;
+ /** @description Newest applied migration at backup time. */
+ schema_version?: number;
+ /** @description Backup directory on the host. */
+ dir: string;
+ } | null;
+ /** @description True when there is no record or it is older than max_age_seconds. */
+ stale: boolean;
+ /**
+ * Format: int64
+ * @description The freshness limit (26h), shared with `felis db check` and FelisDBBackupStale.
+ */
+ max_age_seconds: number;
+ };
+ /** @description Display projection of one bound passkey (internal/api/handlers_passkey.go passkeyCredentialView). Carries no secret — the public key is never returned. */
+ PasskeyCredential: {
+ /** @description Opaque passkey row id (used to unbind it). */
+ id: string;
+ /** @description Caller-supplied nickname; empty if none. */
+ name: string;
+ /** @description Authenticator model id */
+ aaguid?: string;
+ /** Format: date-time */
+ created_at: string;
+ /**
+ * Format: date-time
+ * @description Present only once an assertion is verified (deferred login path).
+ */
+ last_used_at?: string;
+ };
+ /**
+ * @description MinecraftServer lifecycle phase (internal/apis/felis/v1alpha1).
+ * @enum {string}
+ */
+ Phase: "Unknown" | "Stopped" | "Starting" | "Running" | "Stopping" | "Failed";
+ /** @description Status projection of one server (internal/api/cluster.go ServerInfo). */
+ ServerInfo: {
+ name: string;
+ subdomain: string;
+ phase: components["schemas"]["Phase"];
+ ready: boolean;
+ /**
+ * @description Present only when set; who may wake the server via domain-autostart.
+ * @enum {string}
+ */
+ autostartPolicy?: "ownerOnly" | "public" | "allowlist";
+ /**
+ * @description Present only when set; the operator's target state.
+ * @enum {string}
+ */
+ desiredState?: "Running" | "Stopped";
+ endpointMode?: string;
+ endpointAddress?: string;
+ /** Format: int32 */
+ playersOnline: number;
+ /** Format: int32 */
+ playersMax: number;
+ displayName?: string;
+ image?: string;
+ javaMemory?: string;
+ storageSize?: string;
+ cpu?: string;
+ /**
+ * Format: int32
+ * @description Seconds the server may sit empty before idle auto-stop scales it down; 0 when it never idles out (off, RCON disabled, or a system server).
+ */
+ idleStopSeconds: number;
+ /** @description Present and true while the operator cannot read the player count over RCON; idle auto-stop waits until it can. */
+ playerCountUnknown?: boolean;
+ };
+ /** @description One row of the fleet-wide admin read (internal/api/handlers_user.go fleetServerView). */
+ FleetServer: components["schemas"]["ServerInfo"] & {
+ /** @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. */
+ owner?: string;
+ /** @description True for a platform-provisioned system service (the login gate, the lobby). Their reserved names are rejected by every per-server route, so the cockpit renders them read-only instead of offering actions that would 400. */
+ system?: boolean;
+ };
+ /** @description One row of the caller's server list (internal/api/repo.go MyServerView). */
+ MyServerView: {
+ name: string;
+ subdomain: string;
+ owned: boolean;
+ claimable: boolean;
+ /** @description Present only when known. */
+ phase?: components["schemas"]["Phase"];
+ /**
+ * Format: int32
+ * @description Best-effort from live CRD status; 0 when the cluster is unreachable.
+ */
+ playersOnline: number;
+ /** Format: int32 */
+ playersMax: number;
+ /** @description From live CRD status; omitted when unset or the cluster is unreachable. */
+ displayName?: string;
+ /**
+ * @description Owned rows only, from live CRD status.
+ * @enum {string}
+ */
+ desiredState?: "Running" | "Stopped";
+ /**
+ * @description Owned rows only, from live CRD status.
+ * @enum {string}
+ */
+ autostartPolicy?: "ownerOnly" | "public" | "allowlist";
+ /** @description Owned rows only. Present and true while the operator cannot read the player count, so a stop may disconnect players. */
+ playerCountUnknown?: boolean;
+ };
+ /** @description One world backup (internal/api/repo.go BackupView). backup_ref is withheld (spec §286). */
+ BackupView: {
+ id: string;
+ server_name: string;
+ /** @description Present only when the world had an owner at backup time. */
+ former_owner?: string;
+ /** Format: int64 */
+ size_bytes: number;
+ /** @description inactive_15d (idle reclaim), manual (on demand) or pre_restore (the safety snapshot in front of a restore). */
+ reason: string;
+ status: string;
+ /** Format: date-time */
+ created_at: string;
+ /** Format: date-time */
+ expires_at: string;
+ /** @description The archive failed a read-back (a checksum, gzip or tar error) and cannot be restored. Omitted when false. */
+ corrupt?: boolean;
+ /**
+ * Format: date-time
+ * @description The archive's last read-back that matched. Omitted until the first.
+ */
+ verified_at?: string;
+ /** @description World entries the archive could not hold (symbolic links, devices, sockets). Omitted when zero. */
+ skipped_entries?: number;
+ };
+ /** @description One image build (internal/build Build). */
+ Build: {
+ id: string;
+ image_ref: string;
+ /** @enum {string} */
+ status: "pending" | "building" | "succeeded" | "failed" | "cancelled";
+ dockerfile?: string;
+ context_ref?: string;
+ /** @description Lowercase hex sha256 of the context tarball the build was pinned to (the audit record). Omitted when the request named none. */
+ context_digest?: string;
+ base_image?: string;
+ requested_by: string;
+ job_name?: string;
+ log_ref?: string;
+ error?: string;
+ /** Format: date-time */
+ created_at: string;
+ /**
+ * Format: date-time
+ * @description Omitted until the build reaches a terminal status.
+ */
+ finished_at?: string;
+ };
+ /** @description One whitelisted image (internal/build Image). */
+ Image: {
+ image_ref: string;
+ source: string;
+ build_id?: string;
+ added_by: string;
+ enabled: boolean;
+ /** Format: date-time */
+ added_at: string;
+ };
+ /** @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. */
+ Submission: {
+ id: string;
+ submitted_by: string;
+ display_name: string;
+ /** @description Platform-derived pinned build context; not user-supplied. */
+ context_ref: string;
+ /** @description Lowercase hex sha256 of the uploaded context tarball; omitted until one is uploaded. Approval must name it. */
+ context_sha256?: string;
+ /** @enum {string} */
+ status: "pending_review" | "approved" | "rejected";
+ /** @description Platform-derived push target, set at approval. */
+ image_ref?: string;
+ /** @description image_builds.id, set only after the build hand-off succeeds. */
+ build_id?: string;
+ /**
+ * @description The linked build's outcome, attached by the LIST routes (/me/submissions, /submissions) — for a submitter this is the only visible outlet for a failed build. Omitted until a build is linked and its row is readable.
+ * @enum {string}
+ */
+ build_status?: "pending" | "building" | "succeeded" | "failed" | "cancelled";
+ /** @description The build's recorded failure text (e.g. a CRITICAL CVE scan failure), attached alongside build_status. */
+ build_error?: string;
+ reviewed_by?: string;
+ reject_reason?: string;
+ /** Format: date-time */
+ created_at: string;
+ /**
+ * Format: date-time
+ * @description Omitted until an admin approves or rejects.
+ */
+ reviewed_at?: string;
+ };
+ /** @description One row of the admin user list (internal/api/repo.go UserView). */
+ UserView: {
+ id: string;
+ username: string;
+ email?: string;
+ /** @enum {string} */
+ role: "owner" | "admin" | "user";
+ disabled: boolean;
+ email_verified: boolean;
+ server_count: number;
+ /** Format: date-time */
+ created_at: string;
+ /** Format: date-time */
+ updated_at: string;
+ };
+ /** @description Full admin view of one user (internal/api/repo.go UserDetail). */
+ UserDetail: {
+ id: string;
+ username: string;
+ email?: string;
+ /** @enum {string} */
+ role: "owner" | "admin" | "user";
+ disabled: boolean;
+ email_verified: boolean;
+ server_count: number;
+ /** Format: date-time */
+ created_at: string;
+ /** Format: date-time */
+ updated_at: string;
+ /**
+ * Format: date-time
+ * @description Present only when soft-deleted.
+ */
+ deleted_at?: string;
+ /** @description Omitted when the user has no linked Minecraft account. */
+ linked_accounts?: {
+ /** Format: uuid */
+ mc_uuid: string;
+ auth_source: string;
+ /** Format: date-time */
+ verified_at: string;
+ }[];
+ };
+ /** @description A user's quotas row (internal/api/repo.go QuotaView). Null fields mean unlimited. */
+ QuotaView: {
+ user_id: string;
+ max_servers?: number | null;
+ max_cpu_milli?: number | null;
+ max_memory_mb?: number | null;
+ max_storage_gb?: number | null;
+ };
+ /** @description One live session of a user visible to an admin (internal/api/repo.go SessionView). */
+ SessionView: {
+ token_hash: string;
+ /** Format: date-time */
+ created_at: string;
+ /** Format: date-time */
+ expires_at: string;
+ /**
+ * Format: date-time
+ * @description Present only once the session is revoked.
+ */
+ revoked_at?: string;
+ };
+ };
+ responses: {
+ /** @description Success, no body. */
+ NoContent: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Malformed or invalid request (validation, bad body, unknown field). */
+ BadRequest: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Authentication missing or invalid. */
+ Unauthorized: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Authenticated but not permitted (ownership / admin / quota). */
+ Forbidden: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description No such server / build / record. */
+ NotFound: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Precondition failed (lost race, not running, already claimed/linked, not stopped). */
+ Conflict: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description A required prior step is missing (e.g. account not linked). */
+ PreconditionFailed: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description A required subsystem (builder / console / logs / restorer / repo / cluster) is not wired or reachable. */
+ ServiceUnavailable: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @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. */
+ MailUndeliverable: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description This client address called the public sign-in doors faster than the per-address limit allows (code rate_limited); Retry-After gives the seconds until the next call is admitted. The address is the visitor header the install's edge writes ([auth] client_ip_header: CF-Connecting-IP behind the Cloudflare tunnel), else the TCP peer; IPv6 clients share one limit per /64. */
+ RateLimited: {
+ headers: {
+ "Retry-After"?: number;
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description The structured access mutation succeeded; the raw RCON reply is in output. */
+ AccessResult: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ name: string;
+ action: string;
+ player?: string;
+ node?: string;
+ group?: string;
+ output: string;
+ };
+ };
+ };
+ };
+ parameters: never;
+ requestBodies: never;
+ headers: never;
+ pathItems: never;
+}
+export type $defs = Record;
+export interface operations {
+ healthz: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Always ok when the process is up. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @constant */
+ status: "ok";
+ };
+ };
+ };
+ };
+ };
+ readyz: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Repo and Cluster are wired. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @constant */
+ status: "ready";
+ };
+ };
+ };
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ metrics: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Prometheus text exposition format. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "text/plain": string;
+ };
+ };
+ };
+ };
+ hasJoined: {
+ parameters: {
+ query: {
+ username: string;
+ serverId: string;
+ ip?: string;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description A source validated the session; the canonical game profile. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @description Canonical UUID */
+ id: string;
+ /** @description The name the source returned. A third-party player whose name is registered to a Mojang account gets it back as PREFIX_name, cut to 16 characters. */
+ name: string;
+ properties?: Record[];
+ };
+ };
+ };
+ /** @description Not admitted, with no source asked when username or serverId is missing or a parameter is over 64 bytes. Otherwise no source validated the session, the canonical UUID is barred, a third-party source returned a name that is not a legal Minecraft username, or the identity source returned an unparseable id. */
+ 204: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description The request declared a body. No body is sent back, and the connection is closed. */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description The bar-list lookup failed, so the login is not admitted. */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description No source validated the session and at least one source failed (transport error, redirect, unexpected status, or a 200 without a usable profile). Its player may be the one logging in, so this is not answered as a 204. No body. */
+ 503: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
+ listServers: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Every server's status projection. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ servers: components["schemas"]["ServerInfo"][];
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ };
+ };
+ createServer: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ name: string;
+ subdomain: string;
+ display_name?: string;
+ image?: string;
+ memory?: string;
+ storage?: string;
+ autostart_policy?: string;
+ resources?: {
+ cpu?: string;
+ cpu_request?: string;
+ memory?: string;
+ memory_request?: string;
+ };
+ };
+ };
+ };
+ responses: {
+ /** @description Created; starts Stopped. */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ name: string;
+ subdomain: string;
+ /** @constant */
+ desiredState: "Stopped";
+ };
+ };
+ };
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 409: components["responses"]["Conflict"];
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ serverReadyCallback: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 204: components["responses"]["NoContent"];
+ 401: components["responses"]["Unauthorized"];
+ 404: components["responses"]["NotFound"];
+ };
+ };
+ internalSubmissionContext: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The stored gzip tarball, verbatim. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/gzip": string;
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 404: components["responses"]["NotFound"];
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ joinEvent: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ mc_uuid: string;
+ };
+ };
+ };
+ responses: {
+ 204: components["responses"]["NoContent"];
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ 404: components["responses"]["NotFound"];
+ };
+ };
+ internalWake: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ mc_uuid: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Wake accepted (or already awake). */
+ 202: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ name: string;
+ /** @constant */
+ desiredState: "Running";
+ phase: components["schemas"]["Phase"];
+ ready: boolean;
+ };
+ };
+ };
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ /** @description A restore, backup or file write holds the server's world volume (maintenance_in_progress); nothing was started. */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Wake cooldown is still active for this server. */
+ 429: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ internalStatus: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The server's status projection. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ServerInfo"];
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 404: components["responses"]["NotFound"];
+ };
+ };
+ internalClaim: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ mc_uuid: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Claimed. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ name: string;
+ /** @constant */
+ claimed: true;
+ };
+ };
+ };
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ 409: components["responses"]["Conflict"];
+ 412: components["responses"]["PreconditionFailed"];
+ };
+ };
+ internalMenuStatus: {
+ parameters: {
+ query?: {
+ mc_uuid?: string;
+ };
+ header?: never;
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Menu projection for the lobby UI. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ name: string;
+ phase: components["schemas"]["Phase"];
+ ready: boolean;
+ /** Format: int32 */
+ playersOnline: number;
+ /** Format: int32 */
+ playersMax: number;
+ claimable: boolean;
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 404: components["responses"]["NotFound"];
+ };
+ };
+ createLinkCode: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ mc_uuid: string;
+ /**
+ * @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.
+ * @enum {string}
+ */
+ auth_source?: "mojang" | "thirdparty";
+ };
+ };
+ };
+ responses: {
+ /** @description Code minted. */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ code: string;
+ /** Format: date-time */
+ expires_at: string;
+ /** @description Where to redeem the code (https://). Present only when a panel hostname is configured, so the in-game message can print a clickable destination. */
+ panel_url?: string;
+ };
+ };
+ };
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ };
+ };
+ linkStatus: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ mc_uuid: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Link-completion status. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ linked: boolean;
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ };
+ };
+ migrateStart: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** Format: uuid */
+ mc_uuid: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Migration opened in the initiated state. */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @constant */
+ started: true;
+ /** @constant */
+ state: "initiated";
+ };
+ };
+ };
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ /** @description The UUID is not linked to any account (not_linked). */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description The source account has already been retired by a completed migration (account_retired). */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ reclaimUsername: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** Format: uuid */
+ squatter_uuid: string;
+ username: 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. */
+ data_ref?: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Reclaim recorded. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @constant */
+ blacklisted: true;
+ username: string;
+ /** Format: date-time */
+ hold_expires_at: string;
+ };
+ };
+ };
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ };
+ };
+ checkUsernameBlacklist: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ mc_uuid: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Blacklist status. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ blacklisted: boolean;
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ };
+ };
+ opLoginPending: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The pending requests awaiting an in-game vouch. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ pending: {
+ request_id: string;
+ username: string;
+ email: string;
+ /** Format: date-time */
+ created_at: string;
+ }[];
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ };
+ };
+ opLoginApprove: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** Format: uuid */
+ approver_uuid: string;
+ };
+ };
+ };
+ responses: {
+ /** @description The vouch was recorded; the request is now approved. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @constant */
+ approved: true;
+ };
+ };
+ };
+ /** @description approver_uuid is required (bad_request). */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ /** @description The approver is not a linked administrator (not_admin). */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description No pending operator login with that id (op_login_not_found). */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ internalBackupNow: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ /** @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". */
+ requestBody?: {
+ content: {
+ "application/json": {
+ os_user?: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Backup started. */
+ 202: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ name: string;
+ /** @constant */
+ status: "backing_up";
+ };
+ };
+ };
+ /** @description Invalid server name (bad_name). */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ /** @description Unknown server. */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Server is not stopped (not_stopped), a restore, backup or file write already holds its world volume (maintenance_in_progress), or the file changed since expect_sha256 was read (file_changed). */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ wake: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Wake accepted. */
+ 202: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ name: string;
+ /** @constant */
+ desiredState: "Running";
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ /** @description A restore, backup or file write holds the server's world volume (maintenance_in_progress); nothing was started. */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Wake cooldown is still active. */
+ 429: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ stop: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Stop accepted. */
+ 202: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ name: string;
+ /** @constant */
+ desiredState: "Stopped";
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ };
+ };
+ claim: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Claimed. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ name: string;
+ /** @constant */
+ claimed: true;
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ /** @description Quota exceeded. */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 404: components["responses"]["NotFound"];
+ /** @description Already claimed. */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Account not linked (the pointer /account/link/start emits). */
+ 412: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ command: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ command: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Command output. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ name: string;
+ output: string;
+ };
+ };
+ };
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ /** @description Server not running. */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ serverConsole: {
+ parameters: {
+ query?: never;
+ header?: {
+ /** @description The id of the last line received; resumes the stream from that second (within the hour). */
+ "Last-Event-ID"?: string;
+ };
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description An event stream of log lines (`id:` + `data:` per line, `:` comments as keep-alives), ended by `event: revoked` when access is withdrawn. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "text/event-stream": string;
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ /** @description Server not running. */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ accessWhitelistList: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Whitelisted players. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ name: string;
+ players: string[];
+ output: string;
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ /** @description Server not running. */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ accessWhitelist: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** @enum {string} */
+ action: "add" | "remove";
+ player: string;
+ };
+ };
+ };
+ responses: {
+ 200: components["responses"]["AccessResult"];
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ /** @description Server not running. */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ accessPlayers: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Online players. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ name: string;
+ online: number;
+ max: number;
+ players: string[];
+ output: string;
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ /** @description Server not running. */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ accessBanList: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Banned players. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ name: string;
+ players: string[];
+ output: string;
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ /** @description Server not running. */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ accessBan: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** @enum {string} */
+ action: "ban" | "pardon";
+ player: string;
+ };
+ };
+ };
+ responses: {
+ 200: components["responses"]["AccessResult"];
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ /** @description Server not running. */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ accessKick: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ player: string;
+ };
+ };
+ };
+ responses: {
+ /** @description The player was kicked; the raw RCON reply is in output. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ name: string;
+ player: string;
+ output: string;
+ };
+ };
+ };
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ /** @description Server not running. */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ accessPermission: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** @enum {string} */
+ action: "set" | "unset";
+ player: string;
+ node: string;
+ /** @description set only; omitted => true (grant) */
+ value?: boolean;
+ /** @description optional LuckPerms world context */
+ world?: string;
+ };
+ };
+ };
+ responses: {
+ 200: components["responses"]["AccessResult"];
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ /** @description Server not running. */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ accessGroup: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** @enum {string} */
+ action: "add" | "remove";
+ player: string;
+ group: string;
+ };
+ };
+ };
+ responses: {
+ 200: components["responses"]["AccessResult"];
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ /** @description Server not running. */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ accessLuckPermsInfo: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ name: string;
+ player: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Parsed LuckPerms state plus the raw command output. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ player: string;
+ groups: string[];
+ permissions: {
+ node: string;
+ /** @description false = negated (§c) node */
+ value: boolean;
+ /** @description present only for world-scoped nodes */
+ world?: string;
+ }[];
+ output: string;
+ };
+ };
+ };
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ /** @description Server not running. */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ status: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The server's status projection (trimmed for non-owners). */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ServerInfo"];
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ };
+ };
+ authOptions: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** Format: email */
+ email: string;
+ };
+ };
+ };
+ responses: {
+ /** @description The login methods available for the address, in a deterministic order (passkey before email_otp). An empty array means no verified account. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ methods: ("passkey" | "email_otp")[];
+ };
+ };
+ };
+ /** @description A valid email is required (bad_request). */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Local session login is disabled on this deployment (local_auth_disabled). */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Request body was not application/json. */
+ 415: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 429: components["responses"]["RateLimited"];
+ };
+ };
+ passkeyLoginBegin: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** Format: email */
+ email: string;
+ };
+ };
+ };
+ responses: {
+ /** @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. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ [key: string]: unknown;
+ };
+ };
+ };
+ /** @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). */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Local session login is disabled on this deployment (local_auth_disabled). */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Request body was not application/json. */
+ 415: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description A passkey login for this recipient was started too recently (otp_resend_cooldown); or this client address called the sign-in doors too often (rate_limited, with Retry-After). */
+ 429: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description No passkey verifier is wired on this deployment (passkey_unavailable). */
+ 503: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ passkeyLoginFinish: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** Format: email */
+ email: string;
+ /** @description The raw PublicKeyCredential from navigator.credentials.get(), passed to the verifier verbatim (WebAuthn standard shape). */
+ assertion: {
+ [key: string]: unknown;
+ };
+ };
+ };
+ };
+ responses: {
+ /** @description Assertion verified; a host-only session cookie is set on the response. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ user_id: string;
+ /** @enum {string} */
+ role: "user" | "admin";
+ };
+ };
+ };
+ /** @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). */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Local session login is disabled on this deployment (local_auth_disabled). */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Request body was not application/json. */
+ 415: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 429: components["responses"]["RateLimited"];
+ /** @description No passkey verifier is wired on this deployment (passkey_unavailable). */
+ 503: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ passkeyLoginDiscoverableBegin: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** @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). */
+ requestBody?: {
+ content: {
+ "application/json": Record;
+ };
+ };
+ responses: {
+ /** @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. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ publicKey: {
+ [key: string]: unknown;
+ };
+ login_id: string;
+ };
+ };
+ };
+ /** @description The authenticator library could not start the ceremony (passkey_login_failed). */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Local session login is disabled on this deployment (local_auth_disabled). */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Request Content-Type was not application/json. */
+ 415: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Too many discoverable logins are in flight server-wide (too_many_challenges; the cap is global, so no per-recipient signal leaks); or this client address called the sign-in doors too often (rate_limited, with Retry-After). */
+ 429: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description No passkey verifier is wired on this deployment (passkey_unavailable). */
+ 503: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ passkeyLoginDiscoverableFinish: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** @description The opaque handle returned by discoverable/begin. */
+ login_id: string;
+ /** @description The raw PublicKeyCredential from navigator.credentials.get(), passed to the verifier verbatim (WebAuthn standard shape). Its userHandle selects the account server-side. */
+ assertion: {
+ [key: string]: unknown;
+ };
+ };
+ };
+ };
+ responses: {
+ /** @description Assertion verified; a host-only session cookie is set on the response. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ user_id: string;
+ /** @enum {string} */
+ role: "user" | "admin";
+ };
+ };
+ };
+ /** @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). */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Local session login is disabled on this deployment (local_auth_disabled). */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Request body was not application/json. */
+ 415: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 429: components["responses"]["RateLimited"];
+ /** @description No passkey verifier is wired on this deployment (passkey_unavailable). */
+ 503: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ loginEmailStart: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** Format: email */
+ email: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Accepted (neutral): a code was mailed if the address has a verified account; the response is identical either way. */
+ 202: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @constant */
+ sent: true;
+ /** Format: date-time */
+ expires_at: string;
+ };
+ };
+ };
+ /** @description A valid email is required (bad_request). */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Local session login is disabled on this deployment (local_auth_disabled). */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Request body was not application/json. */
+ 415: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description A code for this recipient was requested too recently (otp_resend_cooldown); or this client address called the sign-in doors too often (rate_limited, with Retry-After); or the install-wide mail budget is spent (mail_rate_limited, with Retry-After). */
+ 429: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 502: components["responses"]["MailUndeliverable"];
+ };
+ };
+ loginEmailVerify: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** Format: email */
+ email: string;
+ code: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Code accepted; a host-only session cookie is set on the response. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ user_id: string;
+ /** @enum {string} */
+ role: "user" | "admin";
+ };
+ };
+ };
+ /** @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). */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Local session login is disabled (local_auth_disabled), or the account is staff and must sign in at the operator console (staff_account). */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Request body was not application/json. */
+ 415: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 429: components["responses"]["RateLimited"];
+ };
+ };
+ opLoginStart: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** Format: email */
+ email: string;
+ };
+ };
+ };
+ responses: {
+ /** @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. */
+ 202: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ request_id: string;
+ /** Format: date-time */
+ expires_at: string;
+ };
+ };
+ };
+ /** @description A valid email is required (bad_request). */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Local session login is disabled on this deployment (local_auth_disabled). */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Request body was not application/json. */
+ 415: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description A code for this recipient was requested too recently (otp_resend_cooldown); or this client address called the sign-in doors too often (rate_limited, with Retry-After); or the install-wide mail budget is spent (mail_rate_limited, with Retry-After). */
+ 429: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 502: components["responses"]["MailUndeliverable"];
+ };
+ };
+ opLoginStatus: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The approval state of the request handle. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ approved: boolean;
+ };
+ };
+ };
+ /** @description Local session login is disabled on this deployment (local_auth_disabled). */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ opLoginFinish: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ request_id: string;
+ code: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Both factors proven; a host-only session cookie is set on the response. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ user_id: string;
+ /** @enum {string} */
+ role: "user" | "admin";
+ };
+ };
+ };
+ /** @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). */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Local session login is disabled (local_auth_disabled), or the resolved account is not an operator (staff_account). */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Request body was not application/json. */
+ 415: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 429: components["responses"]["RateLimited"];
+ };
+ };
+ setupRedeem: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ token: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Token redeemed; a session cookie is set and the setup state is returned. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ user_id: string;
+ username: string;
+ /** @enum {string} */
+ role: "user" | "admin";
+ email: string;
+ email_verified: boolean;
+ has_passkey: boolean;
+ setup_required: boolean;
+ };
+ };
+ };
+ /** @description A token is required (bad_request), or it is unknown, already used, or expired (setup_token_invalid). */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Local session login is disabled on this deployment (local_auth_disabled). */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Request body was not application/json. */
+ 415: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 429: components["responses"]["RateLimited"];
+ };
+ };
+ setupStatus: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The caller's current setup state. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ user_id: string;
+ username: string;
+ /** @enum {string} */
+ role: "user" | "admin";
+ email: string;
+ email_verified: boolean;
+ has_passkey: boolean;
+ setup_required: boolean;
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ /** @description The principal's user row was not found (not_found). */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ logout: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Logged out (idempotent). */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @constant */
+ ok: true;
+ };
+ };
+ };
+ };
+ };
+ bindRedeem: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ code: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Player account bootstrapped; the session cookie is set on the response. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ user_id: string;
+ /** @constant */
+ linked: true;
+ mc_uuid: string;
+ /**
+ * @description The source captured at mint, copied onto the durable link.
+ * @enum {string}
+ */
+ auth_source: "mojang" | "thirdparty";
+ };
+ };
+ };
+ /** @description Invalid or expired bind code. */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Local sessions are disabled, or the code's UUID belongs to a staff account. */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 429: components["responses"]["RateLimited"];
+ };
+ };
+ me: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The caller's identity. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ user_id: string;
+ /** Format: email */
+ email: string;
+ /**
+ * @description The principal's role, mirroring users.role.
+ * @enum {string}
+ */
+ role: "user" | "admin" | "owner";
+ /** @description True only when role is admin or owner AND the request arrived via the admin Access path (Principal.IsAdmin()). */
+ is_admin: boolean;
+ /** @description True only for the Owner principal on the admin Access path (Principal.IsOwner()); gates owner-only panel surfaces. */
+ is_owner: boolean;
+ /** @description Whether the account's email has been verified; the panel nudges unverified accounts through the email-OTP flow. */
+ email_verified: boolean;
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ };
+ };
+ myServers: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The caller's server list. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ servers: components["schemas"]["MyServerView"][];
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ };
+ };
+ getUpdateWindow: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The current maintenance window (both ends null when unset). */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UpdateWindow"];
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ };
+ };
+ setUpdateWindow: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["UpdateWindow"];
+ };
+ };
+ responses: {
+ /** @description The stored maintenance window (echoed back). */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UpdateWindow"];
+ };
+ };
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ };
+ };
+ getDBBackup: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The newest recorded backup and whether it is stale. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["DBBackupStatus"];
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ };
+ };
+ fleet: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Every server's status projection (fleet-wide), each with its owner. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ servers: components["schemas"]["FleetServer"][];
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ };
+ };
+ listBackups: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Visible backups. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ backups: components["schemas"]["BackupView"][];
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ };
+ };
+ restoreBackup: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: {
+ content: {
+ "application/json": {
+ /** @description Which backup to restore; defaults to the latest for the server. */
+ backup_id?: string;
+ /**
+ * @description Back up the current world before overwriting it.
+ * @default true
+ */
+ safety_snapshot?: boolean;
+ };
+ };
+ };
+ responses: {
+ /** @description Restore started (after the safety snapshot when safety_snapshot is true). */
+ 202: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ name: string;
+ /** @constant */
+ status: "restoring";
+ backup_id: string;
+ /** @description Whether a safety snapshot runs first. False when the request turned it off, or when this install cannot take one. */
+ safety_snapshot: boolean;
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ /** @description No matching backup. */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Server is not stopped (not_stopped), a restore, backup or file write already holds its world volume (maintenance_in_progress), a restore of another backup is still running (restore_in_progress), or the chosen backup failed a read-back (backup_corrupt). */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ backupNow: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Backup started. */
+ 202: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ name: string;
+ /** @constant */
+ status: "backing_up";
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ /** @description Unknown server. */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Server is not stopped (not_stopped), or a restore, backup or file write already holds its world volume (maintenance_in_progress). */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ listServerJobs: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The server's newest backup/restore jobs. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ server: string;
+ jobs: {
+ name: string;
+ /** @enum {string} */
+ kind: "backup" | "restore";
+ /** @enum {string} */
+ state: "running" | "succeeded" | "failed";
+ message?: string;
+ /** Format: date-time */
+ started_at?: string;
+ /** Format: date-time */
+ finished_at?: string;
+ /**
+ * @description Set on a restore's safety snapshot (a backup job): pending until the restore behind it starts, or abandoned with then_restore_reason saying why (its English wording is in message).
+ * @enum {string}
+ */
+ then_restore?: "pending" | "started" | "abandoned";
+ /**
+ * @description Why an abandoned chain was given up.
+ * @enum {string}
+ */
+ then_restore_reason?: "snapshot_failed" | "not_configured" | "server_gone" | "server_started" | "restore_busy";
+ /** @description The backup the chained restore extracts. */
+ restore_backup_id?: string;
+ }[];
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ /** @description Unknown server. */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ listServerFiles: {
+ parameters: {
+ query?: {
+ /** @description Directory to list, relative to the world root. Empty lists the root itself. */
+ path?: string;
+ };
+ header?: never;
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Directory listing. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ path: string;
+ /** @description The listing hit the entry cap and is incomplete. */
+ truncated: boolean;
+ entries: {
+ name: string;
+ /** Format: int64 */
+ size: number;
+ is_dir: boolean;
+ /** Format: date-time */
+ mod_time: string;
+ }[];
+ };
+ };
+ };
+ /** @description Invalid server name, or a path that escapes the world root. */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ /** @description Unknown server, or no such directory. */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Server is not stopped (its world PVC is still mounted). */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 503: components["responses"]["ServiceUnavailable"];
+ /** @description The file Job did not finish in time; retry. */
+ 504: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ readServerFile: {
+ parameters: {
+ query: {
+ /** @description File to read, relative to the world root. */
+ path: string;
+ };
+ header?: never;
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description File contents. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ path: string;
+ /**
+ * Format: byte
+ * @description Base64-encoded file bytes.
+ */
+ content: string;
+ /** @description SHA-256 of the file as stored (before the rcon.password redaction in server.properties). Send it back as expect_sha256 on the next write. */
+ sha256: string;
+ };
+ };
+ };
+ /** @description Missing path, invalid server name, or a path that escapes the world root. */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ /** @description Unknown server, or no such file. */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Server is not stopped (its world PVC is still mounted). */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description The file is larger than the editor reads. */
+ 413: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 503: components["responses"]["ServiceUnavailable"];
+ /** @description The file Job did not finish in time; retry. */
+ 504: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ writeServerFile: {
+ parameters: {
+ query: {
+ /** @description File to write, relative to the world root. */
+ path: string;
+ };
+ header?: never;
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /**
+ * Format: byte
+ * @description Base64-encoded file bytes.
+ */
+ content: string;
+ /** @description The sha256 a read returned. When present, the write is refused with 409 file_changed if the file has changed (or been deleted) since. Omit it to write unconditionally. */
+ expect_sha256?: string;
+ };
+ };
+ };
+ responses: {
+ /** @description File written. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ path: string;
+ /** @constant */
+ status: "written";
+ /** @description SHA-256 of the bytes written. */
+ sha256: string;
+ };
+ };
+ };
+ /** @description Missing path, malformed body, invalid server name, or a path that escapes the world root. */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ /** @description Unknown server, or the parent directory does not exist. */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Server is not stopped (not_stopped), or a restore, backup or file write already holds its world volume (maintenance_in_progress). */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description The content is larger than the editor writes. */
+ 413: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 503: components["responses"]["ServiceUnavailable"];
+ /** @description The file Job did not finish in time; retry. */
+ 504: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description The world volume has no room for the write (volume_full); the file is unchanged. */
+ 507: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ listUsers: {
+ parameters: {
+ query?: {
+ /** @description Substring match on username or email */
+ query?: string;
+ role?: "admin" | "user";
+ disabled?: "true" | "false";
+ limit?: number;
+ offset?: number;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description A page of users plus the total unfiltered count. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ users: components["schemas"]["UserView"][];
+ total: number;
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ };
+ };
+ createUser: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ username: string;
+ /** Format: email */
+ email?: string;
+ /** @enum {string} */
+ role: "admin" | "user";
+ };
+ };
+ };
+ responses: {
+ /** @description User created. */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UserView"];
+ };
+ };
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ /** @description Username already taken. */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ getUser: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Full user detail including linked MC accounts. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UserDetail"];
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ };
+ };
+ deleteUser: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description User soft-deleted. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @constant */
+ deleted: true;
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ };
+ };
+ patchUser: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ username?: string;
+ /** Format: email */
+ email?: string;
+ /** @enum {string} */
+ role?: "admin" | "user";
+ };
+ };
+ };
+ responses: {
+ /** @description Updated user. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UserView"];
+ };
+ };
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ /** @description Username conflict. */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ disableUser: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ disabled: boolean;
+ };
+ };
+ };
+ responses: {
+ /** @description Toggle applied. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ id: string;
+ disabled: boolean;
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ };
+ };
+ getQuotas: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The user's current quotas (null=unlimited). */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["QuotaView"];
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ };
+ };
+ setQuotas: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ max_servers?: number | null;
+ max_cpu_milli?: number | null;
+ max_memory_mb?: number | null;
+ max_storage_gb?: number | null;
+ };
+ };
+ };
+ responses: {
+ /** @description Quotas updated. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["QuotaView"];
+ };
+ };
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ };
+ };
+ listUserSessions: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Live (unrevoked, unexpired) sessions, newest first. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ sessions: components["schemas"]["SessionView"][];
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ };
+ };
+ revokeUserSessions: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description All sessions revoked. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @constant */
+ ok: true;
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ };
+ };
+ revokeUserSession: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ hash: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Session revoked. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @constant */
+ ok: true;
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ };
+ };
+ unbindUserPasskeys: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description All passkeys unbound (a no-op 200 when the user had none). */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @constant */
+ ok: true;
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ };
+ };
+ linkAccount: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** Format: uuid */
+ mc_uuid: string;
+ /**
+ * @default mojang
+ * @enum {string}
+ */
+ auth_source?: "mojang" | "thirdparty";
+ };
+ };
+ };
+ responses: {
+ /** @description UUID linked (or was already linked to this user). */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @constant */
+ ok: true;
+ /** Format: uuid */
+ mc_uuid: string;
+ auth_source: string;
+ };
+ };
+ };
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ /** @description UUID is already linked to a different user. */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ unlinkAccount: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ mc_uuid: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description UUID unlinked. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @constant */
+ ok: true;
+ /** Format: uuid */
+ mc_uuid: string;
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ /** @description No linked account for this UUID. */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ linkStart: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Current link status. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ linked: boolean;
+ instructions: string;
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ };
+ };
+ linkVerify: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ code: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Linked. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @constant */
+ linked: true;
+ mc_uuid: string;
+ /**
+ * @description The source captured at mint, copied onto the durable link.
+ * @enum {string}
+ */
+ auth_source: "mojang" | "thirdparty";
+ };
+ };
+ };
+ /** @description Invalid or expired code. */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ /** @description Account already linked. */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ emailOtpStart: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** Format: email */
+ email: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Code minted and dispatched (or logged server-side when no mailer is wired). */
+ 202: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @constant */
+ sent: true;
+ /** Format: date-time */
+ expires_at: string;
+ };
+ };
+ };
+ /** @description Missing or malformed email address. */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ /** @description Resend requested before the cooldown elapsed (otp_resend_cooldown); or the account spent its daily wrong-code budget (otp_account_locked, with Retry-After); or the install-wide mail budget is spent (mail_rate_limited, with Retry-After). */
+ 429: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 502: components["responses"]["MailUndeliverable"];
+ };
+ };
+ emailOtpVerify: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ code: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Email verified. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @constant */
+ verified: true;
+ /** Format: email */
+ email: string;
+ };
+ };
+ };
+ /** @description Invalid or expired code. */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ /** @description Too many incorrect attempts on this code (otp_locked), or the account's daily wrong-code budget is spent (otp_account_locked, with Retry-After). */
+ 429: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ setEmail: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** Format: email */
+ email: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Email recorded (unverified). */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** Format: email */
+ email: string;
+ };
+ };
+ };
+ /** @description A valid email is required. */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ };
+ };
+ passkeyRegisterBegin: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description WebAuthn credential-creation options (the publicKey document). */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": Record;
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ /** @description Passkey subsystem is not configured. */
+ 503: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ passkeyRegisterFinish: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** @description Human nickname for the passkey (e.g. "My phone"). */
+ name?: string;
+ /** @description The raw navigator.credentials.create() result the browser posts back. */
+ attestation: Record;
+ };
+ };
+ };
+ responses: {
+ /** @description Passkey bound. */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["PasskeyCredential"];
+ };
+ };
+ /** @description No live ceremony, or the attestation could not be verified. */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ /** @description This passkey is already bound to an account. */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Passkey subsystem is not configured. */
+ 503: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ passkeyList: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The caller's bound passkeys. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ credentials: components["schemas"]["PasskeyCredential"][];
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ };
+ };
+ passkeyDelete: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description The passkey row id (from the credential list). */
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Passkey unbound. */
+ 204: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 401: components["responses"]["Unauthorized"];
+ /** @description No such passkey for this caller. */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description last_passkey — this is the only passkey and the email is unverified. */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ migrateStatus: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The caller's live migration, or active:false. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ active: boolean;
+ /**
+ * @description Present only when active; a redeemed migration is terminal and not reported here.
+ * @enum {string}
+ */
+ state?: "initiated" | "confirmed" | "code_issued";
+ target_user_id?: string;
+ /** @enum {string} */
+ confirm_factor?: "passkey" | "email_otp";
+ /** Format: date-time */
+ code_expires_at?: string;
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ };
+ };
+ migrateConfirmOtpStart: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Confirmation code minted and dispatched. */
+ 202: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @constant */
+ sent: true;
+ /** Format: date-time */
+ expires_at: string;
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ /** @description The caller has no initiated migration to confirm (no_migration). */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @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). */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description Resend requested before the cooldown elapsed (otp_resend_cooldown), or the account's daily wrong-code budget is spent (otp_account_locked, with Retry-After); or the install-wide mail budget is spent (mail_rate_limited, with Retry-After). */
+ 429: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 502: components["responses"]["MailUndeliverable"];
+ };
+ };
+ migrateConfirmOtpVerify: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ code: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Migration confirmed. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @constant */
+ confirmed: true;
+ };
+ };
+ };
+ /** @description Invalid or expired code (invalid_code). */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ /** @description The caller has no initiated migration to confirm (no_migration). */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description The migration is already confirmed (already_confirmed). */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description The code is locked after too many wrong attempts (otp_locked), or the account's daily wrong-code budget is spent (otp_account_locked, with Retry-After). */
+ 429: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ migrateConfirmPasskeyBegin: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description WebAuthn assertion request options (PublicKeyCredentialRequestOptions) for navigator.credentials.get. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": Record;
+ };
+ };
+ /** @description The caller has no enrolled passkey (no_passkey). */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ /** @description The caller has no initiated migration to confirm (no_migration). */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ migrateConfirmPasskeyFinish: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** @description The navigator.credentials.get() PublicKeyCredential assertion. */
+ assertion: Record;
+ };
+ };
+ };
+ responses: {
+ /** @description Migration confirmed. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @constant */
+ confirmed: true;
+ };
+ };
+ };
+ /** @description Assertion invalid, challenge stale, or a cloned authenticator was detected (passkey_login_invalid). */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ /** @description The caller has no initiated migration to confirm (no_migration). */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ migrateIssueCode: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ target_user_id: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Code minted, bound to the named target. */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ code: string;
+ /** Format: date-time */
+ expires_at: string;
+ };
+ };
+ };
+ /** @description The target is the source itself (invalid_target), does not exist (target_not_found), or is disabled/retired (target_unavailable). */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ /** @description The caller has no migration to issue against (no_migration). */
+ 404: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ /** @description The migration has not been confirmed by a step-up yet (not_confirmed). */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ };
+ };
+ migrateRedeem: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ code: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Migration redeemed; owned servers moved to the caller. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ /** @constant */
+ migrated: true;
+ /** Format: int32 */
+ servers_moved: number;
+ servers: string[];
+ };
+ };
+ };
+ /** @description Unknown, expired, or already-spent code, or the caller is not the named target (invalid_code). */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ };
+ };
+ mySubmissions: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The caller's submissions, newest first; rows with a linked build additionally carry build_status/build_error so the submitter can see whether their build succeeded or failed (and why). */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ submissions: components["schemas"]["Submission"][];
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ createSubmission: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ display_name: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Submission recorded, pending review. */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Submission"];
+ };
+ };
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ /** @description The per-user submission allowance is spent — too many of the caller's submissions are awaiting review, or their stored-upload budget is full (submission_quota_exceeded). */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description A submission was created within the per-user cooldown window (submission_cooldown). */
+ 429: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ uploadSubmissionContext: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/gzip": string;
+ };
+ };
+ responses: {
+ /** @description Context stored; the submission (unchanged) is returned. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Submission"];
+ };
+ };
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ /** @description The upload would exceed the caller's per-user stored-context budget (submission_quota_exceeded). */
+ 403: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 404: components["responses"]["NotFound"];
+ 409: components["responses"]["Conflict"];
+ /** @description An upload was accepted within the per-user cooldown window (submission_cooldown). */
+ 429: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ withdrawSubmission: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The withdrawn submission, as it was before the deletion. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Submission"];
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 404: components["responses"]["NotFound"];
+ /** @description Submission has already been reviewed and cannot be withdrawn. */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ patchServer: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ name: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ displayName?: string;
+ autostartPolicy?: string;
+ /** @description Re-admitted against the whitelist (a pinned name:tag@sha256:… ref is admitted by its name:tag) and pinned like create does. A pin equal to the current image is no change; any other needs confirmImageChange. */
+ image?: string;
+ /** @description Acknowledges that the new image opens the world with its Minecraft version, whose chunk upgrades the old one cannot read. Without it an image that would move the server is refused with 409 image_change_unconfirmed. The audit row records image_from/image_to. */
+ confirmImageChange?: boolean;
+ memory?: string;
+ /** @description Rejected with 400 storage_immutable — present for a clear error, not mutation. */
+ storage?: string;
+ resources?: {
+ cpu?: string;
+ cpuRequest?: string;
+ memory?: string;
+ memoryRequest?: string;
+ };
+ /**
+ * Format: int32
+ * @description Idle auto-stop. 0 turns it off; otherwise the server stops after this many seconds with nobody online (60–86400, else 400 bad_idle_stop).
+ */
+ idleStopSeconds?: number;
+ };
+ };
+ };
+ responses: {
+ /** @description Patched. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ name: string;
+ patched: string[];
+ };
+ };
+ };
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ 409: components["responses"]["Conflict"];
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ buildImage: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ /** @description Push target under the internal registry (e.g. registry.felis.svc:5000/foo:1.0). */
+ image_ref: string;
+ /** @description Audit archive of the recipe, recorded on the build row and shown in the panel — the executed Dockerfile is the file named `Dockerfile` at the root of the context tarball (Kaniko runs --dockerfile=Dockerfile), so this field is never executed. */
+ dockerfile: string;
+ /** @description Location of the uploaded gzip build context; its root must contain the Dockerfile that gets executed. */
+ context_ref: string;
+ /** @description Resolved FROM, recorded for audit only — not a build gate. */
+ base_image?: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Build accepted. */
+ 202: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Build"];
+ };
+ };
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ getBuild: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The build. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Build"];
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ buildLogs: {
+ parameters: {
+ query?: never;
+ header?: {
+ /** @description The id of the last line received; resumes the stream from that second (within the hour). */
+ "Last-Event-ID"?: string;
+ };
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description An event stream of build log lines (`id:` + `data:` per line), ended by `event: revoked` when the caller is no longer staff. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "text/event-stream": string;
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ cancelBuild: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The build after cancellation. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Build"];
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ /** @description Build already terminal. */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ listImages: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The image whitelist. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ images: components["schemas"]["Image"][];
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ addImage: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ image_ref: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Image whitelisted. */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Image"];
+ };
+ };
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ removeImage: {
+ parameters: {
+ query: {
+ ref: string;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 204: components["responses"]["NoContent"];
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ listSubmissions: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description All submissions, newest first. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ submissions: components["schemas"]["Submission"][];
+ };
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ approveSubmission: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The approved submission, with the linked build id. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Submission"];
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ /** @description Submission has already been reviewed. */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ downloadSubmissionContext: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The stored build context (gzip tarball), served as an attachment. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/gzip": string;
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ rejectSubmission: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": {
+ reason: string;
+ };
+ };
+ };
+ responses: {
+ /** @description The rejected submission. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Submission"];
+ };
+ };
+ 400: components["responses"]["BadRequest"];
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ /** @description Submission has already been reviewed. */
+ 409: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Error"];
+ };
+ };
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+ deleteSubmission: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description The deleted submission, as it was before the deletion. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Submission"];
+ };
+ };
+ 401: components["responses"]["Unauthorized"];
+ 403: components["responses"]["Forbidden"];
+ 404: components["responses"]["NotFound"];
+ 503: components["responses"]["ServiceUnavailable"];
+ };
+ };
+}
diff --git a/panel/src/lib/ownership.test.ts b/panel/src/lib/ownership.test.ts
index b5cffaa..6f64b73 100644
--- a/panel/src/lib/ownership.test.ts
+++ b/panel/src/lib/ownership.test.ts
@@ -1,12 +1,20 @@
import { describe, it, expect } from "vitest";
import { canManage, ownershipPending } from "./ownership";
-import type { ServerInfo } from "./types";
+import type { MyServerView } from "./types";
// The owner-tier gate once read `owned` off /servers/{name}/status, which never
// sends it, so owners lost the LuckPerms entry. These cases pin that ownership
// comes only from the /me/servers row for this exact server.
-const row = (name: string, owned?: boolean): ServerInfo => ({ name, subdomain: name, phase: "Running", owned });
+const row = (name: string, owned = false): MyServerView => ({
+ name,
+ subdomain: name,
+ phase: "Running",
+ owned,
+ claimable: false,
+ playersOnline: 0,
+ playersMax: 0,
+});
describe("canManage", () => {
it("lets an admin in without any /me/servers rows", () => {
diff --git a/panel/src/lib/ownership.ts b/panel/src/lib/ownership.ts
index 5e8e756..ca8b51e 100644
--- a/panel/src/lib/ownership.ts
+++ b/panel/src/lib/ownership.ts
@@ -1,4 +1,4 @@
-import type { ServerInfo } from "./types";
+import type { MyServerView } from "./types";
// Owner-tier pages (console extras, players, files, backups, LuckPerms) decide
// who may act from GET /me/servers: the status projection never carries
@@ -7,7 +7,7 @@ import type { ServerInfo } from "./types";
/** canManage reports whether the caller may use a server's owner-tier tools. */
export function canManage(
isAdmin: boolean,
- mine: readonly ServerInfo[] | null | undefined,
+ mine: readonly MyServerView[] | null | undefined,
name: string,
): boolean {
return isAdmin || (mine ?? []).some((s) => s.name === name && s.owned === true);
@@ -18,7 +18,7 @@ export function canManage(
export function ownershipPending(
tierLoading: boolean,
isAdmin: boolean,
- mine: readonly ServerInfo[] | null | undefined,
+ mine: readonly MyServerView[] | null | undefined,
mineError: unknown,
): boolean {
return tierLoading || (!isAdmin && mine == null && !mineError);
diff --git a/panel/src/lib/types.parity.ts b/panel/src/lib/types.parity.ts
new file mode 100644
index 0000000..918ae25
--- /dev/null
+++ b/panel/src/lib/types.parity.ts
@@ -0,0 +1,49 @@
+// Compile-time parity between the hand-written wire types (types.ts) and the
+// schemas in docs/openapi.yaml, via the generated openapi.gen.ts (`npm run
+// gen:api`; CI fails when the generated file is stale). The Go side is held to
+// the same schemas by internal/api/openapi_parity_test.go, so a field that
+// changes in one place and not the others breaks a build instead of rendering
+// `undefined`.
+//
+// For each pair three things must hold:
+// - the two key sets are equal;
+// - a key the docs mark optional is optional here too (the panel must not
+// count on a field the server may omit);
+// - every documented value fits the panel type (enums included).
+// A failure names the offending keys in the error's type.
+import type { components } from "./openapi.gen";
+import type * as T from "./types";
+
+type S = components["schemas"];
+
+type OptionalKeys = { [K in keyof X]-?: object extends Pick ? K : never }[keyof X];
+
+type Parity = [Exclude, Exclude] extends [
+ never,
+ never,
+]
+ ? [Exclude, OptionalKeys>] extends [never]
+ ? Docs extends Panel
+ ? true
+ : { docsValueDoesNotFitPanel: { [K in keyof Docs & keyof Panel]: Docs[K] extends Panel[K] ? never : K }[keyof Docs & keyof Panel] }
+ : { optionalInDocsButRequiredInPanel: Exclude, OptionalKeys> }
+ : { onlyInPanel: Exclude; onlyInDocs: Exclude };
+
+type Holds = X;
+
+export type WireParity = [
+ Holds>,
+ Holds>,
+ Holds>,
+ Holds>,
+ Holds>,
+ Holds>,
+ Holds>,
+ Holds>,
+ Holds>,
+ Holds>,
+ Holds>,
+ Holds>,
+ Holds>,
+ Holds>,
+];
diff --git a/panel/src/lib/types.ts b/panel/src/lib/types.ts
index 85447e5..3cfd20a 100644
--- a/panel/src/lib/types.ts
+++ b/panel/src/lib/types.ts
@@ -17,35 +17,53 @@ export type Phase =
export type AutostartPolicy = "ownerOnly" | "public" | "allowlist";
-/** ServerInfo is the GET /me/servers row (wrapped under { servers: [...] }).
- * Only this projection says whether the caller owns or may claim a server. */
-export interface ServerInfo {
+/** MyServerView is the GET /me/servers row (wrapped under { servers: [...] }).
+ * Only this projection says whether the caller owns or may claim a server.
+ * The live fields come from the CRD best-effort; desiredState, autostartPolicy
+ * and playerCountUnknown are present on the caller's own rows only. */
+export interface MyServerView {
name: string;
subdomain: string;
- displayName?: string;
- phase: Phase;
- desiredState?: "Running" | "Stopped";
- playersOnline?: number;
- playersMax?: number;
- autostartPolicy?: AutostartPolicy;
- /** Whether the caller may claim this server (unowned + linked + quota). */
- claimable?: boolean;
/** Whether the caller owns it. */
- owned?: boolean;
+ owned: boolean;
+ /** Whether the caller may claim this server (unowned + linked + quota). */
+ claimable: boolean;
+ phase?: Phase;
+ playersOnline: number;
+ playersMax: number;
+ displayName?: string;
+ desiredState?: "Running" | "Stopped";
+ autostartPolicy?: AutostartPolicy;
+ /** True while the operator cannot read the player count; a stop may drop players. */
+ playerCountUnknown?: boolean;
+}
+
+/** ServerStatus is GET /servers/{name}/status (Go ServerInfo). It never carries
+ * `owned` or `claimable`; owner-tier gates read /me/servers via lib/ownership.
+ * A caller who does not own the server gets the public subset, so everything
+ * past the counts may be absent. */
+export interface ServerStatus {
+ name: string;
+ subdomain: string;
+ phase: Phase;
+ ready: boolean;
+ autostartPolicy?: AutostartPolicy;
+ desiredState?: "Running" | "Stopped";
+ endpointMode?: string;
+ endpointAddress?: string;
+ playersOnline: number;
+ playersMax: number;
+ displayName?: string;
image?: string;
javaMemory?: string;
storageSize?: string;
cpu?: string;
/** Seconds empty before idle auto-stop; 0 when the server never idles out. */
- idleStopSeconds?: number;
+ idleStopSeconds: number;
/** True while the operator cannot read the player count; idle stop waits. */
playerCountUnknown?: boolean;
}
-/** ServerStatus is GET /servers/{name}/status. It never carries `owned` or
- * `claimable`; owner-tier gates read /me/servers via lib/ownership. */
-export type ServerStatus = Omit;
-
/** WhitelistResult projects GET /servers/{name}/access/whitelist (spec §7 access).
* `players` is a BEST-EFFORT parse of the vanilla "whitelist list" reply done
* server-side (parseWhitelistOutput); `output` is the raw RCON text and is the
@@ -82,7 +100,7 @@ export interface AccessResult {
}
/** PlayersResult projects GET /servers/{name}/access/players (spec §7 access), the
- * ONLY source of WHO is online — ServerInfo.playersOnline carries the count alone.
+ * ONLY source of WHO is online — MyServerView.playersOnline carries the count alone.
* `online`/`max` are the tally; `players` is a BEST-EFFORT parse of the vanilla
* "list" reply (parseListOutput) and, like the whitelist, can come back empty on a
* non-vanilla format while `output` (the raw RCON text, ground truth) still names
@@ -108,21 +126,11 @@ export interface KickResult {
* read (admin-tier). It mirrors the Go fleetServerView: the CRD lifecycle
* projection plus the owner joined read-only from Postgres for display.
*
- * It is a DISTINCT type from ServerInfo, not a reuse: /fleet emits the raw CRD
+ * It is a DISTINCT type from MyServerView, not a reuse: /fleet emits the raw CRD
* shape — `ready` and the `endpoint*` runtime fields, with playersOnline/playersMax
- * required — whereas ServerInfo is the /me/servers projection with them optional.
+ * required — whereas MyServerView is the /me/servers projection.
* Sharing one interface would blur which fields each face actually guarantees. */
-export interface FleetServer {
- name: string;
- subdomain: string;
- phase: Phase;
- ready: boolean;
- desiredState?: "Running" | "Stopped";
- autostartPolicy?: AutostartPolicy;
- endpointMode?: string;
- endpointAddress?: string;
- playersOnline: number;
- playersMax: number;
+export interface FleetServer extends ServerStatus {
/** Owner's display identity (email, or username when the address is absent).
* Empty/absent for an unclaimed server or when the best-effort owner lookup
* failed — the cockpit renders that as "unclaimed". */
@@ -191,7 +199,11 @@ export interface ServerJob {
export interface WhitelistImage {
image_ref: string;
enabled: boolean;
- source?: string;
+ source: string;
+ /** Set when the image came out of a panel build (build.Image BuildID). */
+ build_id?: string;
+ added_by: string;
+ added_at: string;
}
/** CreateServerRequest is the §15 structured form — the ONLY create path. */
@@ -288,6 +300,8 @@ export interface Build {
error?: string;
created_at: string;
finished_at?: string;
+ /** sha256 of the build context the Job actually fetched. */
+ context_digest?: string;
}
export type SubmissionStatus = "pending_review" | "approved" | "rejected";
@@ -343,7 +357,8 @@ export interface DBBackupStatus {
export interface UserView {
id: string;
username: string;
- email: string;
+ /** Omitted for accounts created without one (bind-code / op-login only). */
+ email?: string;
role: "admin" | "user" | "owner";
disabled: boolean;
email_verified: boolean;
@@ -359,8 +374,9 @@ export interface LinkedAccount {
}
export interface UserDetail extends UserView {
- deleted_at?: string | null;
- linked_accounts: LinkedAccount[];
+ deleted_at?: string;
+ /** Omitted when the user has no linked Minecraft account. */
+ linked_accounts?: LinkedAccount[];
}
/** CreateUserRequest mirrors handlers_users.go createUserRequest — passwordless:
@@ -398,12 +414,12 @@ export interface PasskeyCredential {
name: string;
aaguid?: string;
created_at: string;
- last_used_at?: string | null;
+ last_used_at?: string;
}
export interface SessionView {
token_hash: string;
created_at: string;
expires_at: string;
- revoked_at?: string | null;
+ revoked_at?: string;
}
diff --git a/panel/src/pages/Account.test.tsx b/panel/src/pages/Account.test.tsx
new file mode 100644
index 0000000..d0247e4
--- /dev/null
+++ b/panel/src/pages/Account.test.tsx
@@ -0,0 +1,139 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { MemoryRouter } from "react-router-dom";
+import i18next from "i18next";
+import type { Identity, PasskeyCredential } from "@/lib/types";
+import { Account } from "./Account";
+
+const mocks = vi.hoisted(() => ({
+ passkeyList: vi.fn(),
+ passkeyDelete: vi.fn(),
+ identity: null as Identity | null,
+}));
+
+vi.mock("@/lib/api", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ api: {
+ ...actual.api,
+ linkStatus: () => Promise.resolve({ linked: true }),
+ migrateStatus: () => Promise.resolve({ active: false }),
+ passkeyList: mocks.passkeyList,
+ passkeyDelete: mocks.passkeyDelete,
+ },
+ };
+});
+vi.mock("@/lib/tier", () => ({
+ useTier: () => ({ identity: mocks.identity, refresh: vi.fn() }),
+}));
+
+const t = (key: string, opts?: Record) => i18next.t(key, opts);
+const deleteButton = (name: string) => ({ name: t("account:passkey_delete_aria", { name }) });
+
+const laptop: PasskeyCredential = { id: "pk-1", name: "Laptop", created_at: "2026-03-01T10:00:00Z" };
+const phone: PasskeyCredential = { id: "pk-2", name: "Phone", created_at: "2026-04-01T10:00:00Z" };
+
+function identity(emailVerified: boolean): Identity {
+ return {
+ user_id: "u-1",
+ email: "a@example.com",
+ role: "user",
+ is_admin: false,
+ is_owner: false,
+ email_verified: emailVerified,
+ };
+}
+
+function renderAccount() {
+ return render(
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ mocks.passkeyList.mockReset();
+ mocks.passkeyDelete.mockReset();
+ mocks.identity = identity(true);
+});
+
+describe("Account passkey delete", () => {
+ it("names the passkey in the confirmation and deletes only on confirm", async () => {
+ mocks.passkeyList.mockResolvedValueOnce({ credentials: [laptop, phone] }).mockResolvedValue({ credentials: [phone] });
+ mocks.passkeyDelete.mockResolvedValue(undefined);
+ renderAccount();
+
+ await userEvent.click(await screen.findByRole("button", deleteButton("Laptop")));
+ const dialog = screen.getByRole("dialog");
+ expect(within(dialog).getByText(t("account:passkey_delete_title"))).toBeTruthy();
+ expect(within(dialog).getByText(/“Laptop”/)).toBeTruthy();
+ expect(mocks.passkeyDelete).not.toHaveBeenCalled();
+
+ await userEvent.click(within(dialog).getByRole("button", { name: t("account:passkey_delete_confirm") }));
+
+ expect(mocks.passkeyDelete).toHaveBeenCalledWith("pk-1");
+ expect(screen.queryByRole("dialog")).toBeNull();
+ await waitFor(() => expect(screen.queryByRole("button", deleteButton("Laptop"))).toBeNull());
+ expect(screen.getByRole("button", deleteButton("Phone"))).toBeTruthy();
+ });
+
+ it("cancel leaves the passkey alone", async () => {
+ mocks.passkeyList.mockResolvedValue({ credentials: [laptop, phone] });
+ renderAccount();
+
+ await userEvent.click(await screen.findByRole("button", deleteButton("Laptop")));
+ await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: t("common:cancel") }));
+
+ expect(mocks.passkeyDelete).not.toHaveBeenCalled();
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+
+ it("locks the only passkey of an unverified account and says why", async () => {
+ mocks.identity = identity(false);
+ mocks.passkeyList.mockResolvedValue({ credentials: [laptop] });
+ renderAccount();
+
+ const button = await screen.findByRole("button", deleteButton("Laptop"));
+ expect(button).toHaveProperty("disabled", true);
+ expect(screen.getByText(t("account:passkey_last_hint"))).toBeTruthy();
+ });
+
+ it("lets a verified account delete its only passkey (email-OTP still signs it in)", async () => {
+ mocks.passkeyList.mockResolvedValue({ credentials: [laptop] });
+ renderAccount();
+
+ const button = await screen.findByRole("button", deleteButton("Laptop"));
+ expect(button).toHaveProperty("disabled", false);
+ expect(screen.queryByText(t("account:passkey_last_hint"))).toBeNull();
+ });
+
+ it("keeps the dialog open with the server's reason when the delete is refused", async () => {
+ mocks.passkeyList.mockResolvedValue({ credentials: [laptop, phone] });
+ mocks.passkeyDelete.mockRejectedValue({ status: 409, code: "last_passkey", message: "raw" });
+ renderAccount();
+
+ await userEvent.click(await screen.findByRole("button", deleteButton("Laptop")));
+ await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: t("account:passkey_delete_confirm") }));
+
+ expect(await within(screen.getByRole("dialog")).findByText(t("errors:last_passkey"))).toBeTruthy();
+ expect(mocks.passkeyList).toHaveBeenCalledTimes(2);
+ });
+
+ it("treats a passkey already gone as deleted", async () => {
+ mocks.passkeyList.mockResolvedValueOnce({ credentials: [laptop, phone] }).mockResolvedValue({ credentials: [phone] });
+ mocks.passkeyDelete.mockRejectedValue({ status: 404, code: "not_found", message: "passkey not found" });
+ renderAccount();
+
+ await userEvent.click(await screen.findByRole("button", deleteButton("Laptop")));
+ await userEvent.click(within(screen.getByRole("dialog")).getByRole("button", { name: t("account:passkey_delete_confirm") }));
+
+ expect(screen.queryByRole("dialog")).toBeNull();
+ await waitFor(() => expect(screen.queryByRole("button", deleteButton("Laptop"))).toBeNull());
+ expect(screen.getByRole("button", deleteButton("Phone"))).toBeTruthy();
+ expect(screen.queryByText("passkey not found")).toBeNull();
+ });
+});
diff --git a/panel/src/pages/Dashboard.tsx b/panel/src/pages/Dashboard.tsx
index 96fa019..17a59f6 100644
--- a/panel/src/pages/Dashboard.tsx
+++ b/panel/src/pages/Dashboard.tsx
@@ -26,7 +26,7 @@ import { api } from "@/lib/api";
import { useAsync, useConfig } from "@/lib/hooks";
import { lazyWithReload } from "@/lib/chunk";
import { webglAvailable } from "@/lib/webgl";
-import type { Phase, ServerInfo, WhitelistImage } from "@/lib/types";
+import type { Phase, MyServerView, WhitelistImage } from "@/lib/types";
// three.js is heavy and only the Dashboard renders it — split it into its own
// async chunk so the rest of the panel doesn't pay for it on first load.
@@ -95,7 +95,7 @@ function FleetView({
images,
isAdmin,
}: {
- servers: ServerInfo[];
+ servers: MyServerView[];
counts: { total: number; running: number; players: number };
linkStatus?: { linked: boolean };
images: WhitelistImage[];
diff --git a/panel/src/pages/MySubmissionsPage.tsx b/panel/src/pages/MySubmissionsPage.tsx
index 9948228..8364ea9 100644
--- a/panel/src/pages/MySubmissionsPage.tsx
+++ b/panel/src/pages/MySubmissionsPage.tsx
@@ -79,7 +79,7 @@ export function MySubmissionsPage() {
// Async API hook
const { data, error: fetchError, loading, reload } = useAsync(() => api.listMySubmissions(), []);
- const submissions: Submission[] = data ?? [];
+ const submissions = useMemo(() => data ?? [], [data]);
// Dialog State
const [dialogOpen, setDialogOpen] = useState(false);
diff --git a/panel/src/pages/ServerFiles.test.tsx b/panel/src/pages/ServerFiles.test.tsx
new file mode 100644
index 0000000..0675f33
--- /dev/null
+++ b/panel/src/pages/ServerFiles.test.tsx
@@ -0,0 +1,95 @@
+// @vitest-environment jsdom
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import i18next from "i18next";
+import { ServerFiles } from "./ServerFiles";
+
+const mocks = vi.hoisted(() => ({ writeServerFile: vi.fn() }));
+
+vi.mock("@/lib/api", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ api: {
+ ...actual.api,
+ status: () => Promise.resolve({ name: "lobby", subdomain: "lobby", phase: "Stopped", ready: false }),
+ listServerFiles: () =>
+ Promise.resolve({
+ path: "",
+ truncated: false,
+ entries: [{ name: "server.properties", size: 8, is_dir: false, mod_time: "2026-09-01T00:00:00Z" }],
+ }),
+ readServerFile: () => Promise.resolve({ path: "server.properties", content: btoa("motd=hi\n"), sha256: "abc" }),
+ writeServerFile: mocks.writeServerFile,
+ },
+ };
+});
+vi.mock("@/lib/tier", () => ({ useTier: () => ({ isAdmin: true, loading: false }) }));
+
+const t = (key: string) => i18next.t(key);
+
+async function openEditor() {
+ render(
+
+
+ } />
+
+ ,
+ );
+ await userEvent.click(await screen.findByText("server.properties"));
+ const dialog = await screen.findByRole("dialog");
+ return { dialog, editor: within(dialog).getByRole("textbox") as HTMLTextAreaElement };
+}
+
+beforeEach(() => {
+ mocks.writeServerFile.mockReset();
+});
+
+describe("ServerFiles editor", () => {
+ it("closes an unchanged file at once", async () => {
+ const { dialog } = await openEditor();
+
+ await userEvent.click(within(dialog).getByRole("button", { name: t("common:cancel") }));
+
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+
+ it("asks before dropping edits, and keep editing keeps the text", async () => {
+ const { editor } = await openEditor();
+ await userEvent.type(editor, "pvp=false");
+
+ await userEvent.keyboard("{Escape}");
+
+ const dialog = screen.getByRole("dialog");
+ expect(within(dialog).getByText(t("files:discard_prompt"))).toBeTruthy();
+ await userEvent.click(within(dialog).getByRole("button", { name: t("files:keep_editing") }));
+ expect(within(dialog).queryByText(t("files:discard_prompt"))).toBeNull();
+ expect((within(dialog).getByRole("textbox") as HTMLTextAreaElement).value).toBe("motd=hi\npvp=false");
+ });
+
+ it("drops the edits only after discard is confirmed", async () => {
+ const { dialog, editor } = await openEditor();
+ await userEvent.type(editor, "pvp=false");
+
+ await userEvent.click(within(dialog).getByRole("button", { name: t("common:cancel") }));
+ await userEvent.click(within(dialog).getByRole("button", { name: t("files:discard") }));
+
+ expect(screen.queryByRole("dialog")).toBeNull();
+ expect(mocks.writeServerFile).not.toHaveBeenCalled();
+ });
+
+ it("guards leaving the page while edits are unsaved", async () => {
+ const { editor } = await openEditor();
+ const clean = new Event("beforeunload", { cancelable: true });
+ window.dispatchEvent(clean);
+ expect(clean.defaultPrevented).toBe(false);
+
+ await userEvent.type(editor, "x");
+ const dirty = new Event("beforeunload", { cancelable: true });
+ window.dispatchEvent(dirty);
+
+ expect(dirty.defaultPrevented).toBe(true);
+ });
+});
diff --git a/panel/src/pages/ServerFiles.tsx b/panel/src/pages/ServerFiles.tsx
index 3348c6e..fd41627 100644
--- a/panel/src/pages/ServerFiles.tsx
+++ b/panel/src/pages/ServerFiles.tsx
@@ -128,17 +128,21 @@ export function ServerFiles() {
// Load (and reload after a stop) only once the viewer is resolved as owner and
// the server is fully stopped — both are hard server-side gates of every call.
+ // `dir` stays out of the list: load() itself moves it, and a navigation that
+ // re-ran this effect would fetch the same folder twice.
useEffect(() => {
if (owned && stopped) void load(dir);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [owned, stopped, load]);
// While the server is not stopped, poll the phase so the first successful
// stop flips the page from the notice to the listing without a manual reload.
+ const reloadStatus = statusQ.reload;
useEffect(() => {
if (stopped) return;
- const id = setInterval(() => statusQ.reload(), 4000);
+ const id = setInterval(reloadStatus, 4000);
return () => clearInterval(id);
- }, [stopped, statusQ.reload]);
+ }, [stopped, reloadStatus]);
// Editor state. `editable` false marks a binary file (rendered read-only).
// `sha256` is the hash the read returned: every save sends it back, so a file
diff --git a/panel/src/pages/admin/ImageAdmin.tsx b/panel/src/pages/admin/ImageAdmin.tsx
index ac9fbe4..837c84a 100644
--- a/panel/src/pages/admin/ImageAdmin.tsx
+++ b/panel/src/pages/admin/ImageAdmin.tsx
@@ -30,7 +30,7 @@ const PAGE_SIZE = 10;
export function ImageAdmin() {
const { t } = useTranslation("admin");
const { data, error, loading, reload } = useAsync(() => api.listImages(), []);
- const images = data ?? [];
+ const images = useMemo(() => data ?? [], [data]);
// Form & Dialog State
const [dialogOpen, setDialogOpen] = useState(false);
diff --git a/panel/src/pages/admin/ImageBuildPage.tsx b/panel/src/pages/admin/ImageBuildPage.tsx
index e3c66b3..8d7d2b6 100644
--- a/panel/src/pages/admin/ImageBuildPage.tsx
+++ b/panel/src/pages/admin/ImageBuildPage.tsx
@@ -202,7 +202,7 @@ export function ImageBuildPage() {
}, [builds, search]);
// Reset page when search changes
- useMemo(() => {
+ useEffect(() => {
setPage(1);
}, [search]);
diff --git a/panel/src/pages/admin/SubmissionsPage.tsx b/panel/src/pages/admin/SubmissionsPage.tsx
index 5f0b7e6..fe727d0 100644
--- a/panel/src/pages/admin/SubmissionsPage.tsx
+++ b/panel/src/pages/admin/SubmissionsPage.tsx
@@ -34,7 +34,7 @@ export function SubmissionsPage() {
const now = Date.now();
const { data, error, loading, reload } = useAsync(() => api.listSubmissions(), []);
- const submissions: Submission[] = data ?? [];
+ const submissions = useMemo(() => data ?? [], [data]);
// Dialog State
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
diff --git a/panel/src/pages/servers/ServersPage.tsx b/panel/src/pages/servers/ServersPage.tsx
index c904baf..57f11d5 100644
--- a/panel/src/pages/servers/ServersPage.tsx
+++ b/panel/src/pages/servers/ServersPage.tsx
@@ -43,7 +43,7 @@ import { useAsync, useConfig } from "@/lib/hooks";
import { useTier } from "@/lib/tier";
import { hostFor, type RuntimeConfig } from "@/lib/config";
import { matchScore } from "@/lib/fuzzy";
-import type { AutostartPolicy, FleetServer, Phase, ServerInfo } from "@/lib/types";
+import type { AutostartPolicy, FleetServer, Phase, MyServerView } from "@/lib/types";
import { cn } from "@/lib/utils";
const REFRESH_MS = 10_000;
@@ -72,6 +72,8 @@ const PAGE_SIZE = 6;
interface UnifiedServer {
name: string;
subdomain: string;
+ /** The owner-chosen label; the list leads with it and keeps the name beside. */
+ displayName?: string;
phase: Phase;
ready: boolean;
desiredState?: "Running" | "Stopped";
@@ -91,7 +93,10 @@ export function ServersPage() {
const { isAdmin, identity } = useTier();
const cfg = useConfig();
- const fetchFn = useMemo(() => (isAdmin ? api.fleet : api.myServers), [isAdmin]);
+ const fetchFn = useMemo<() => Promise>(
+ () => (isAdmin ? api.fleet : api.myServers),
+ [isAdmin],
+ );
const { data, error, loading, reload } = useAsync(fetchFn, [fetchFn]);
const [query, setQuery] = useState("");
@@ -117,6 +122,7 @@ export function ServersPage() {
if (isAdmin) {
return (data as FleetServer[]).map((s) => ({
name: s.name,
+ displayName: s.displayName,
subdomain: s.subdomain,
phase: s.phase,
ready: s.ready,
@@ -124,6 +130,7 @@ export function ServersPage() {
autostartPolicy: s.autostartPolicy,
playersOnline: s.playersOnline,
playersMax: s.playersMax,
+ playerCountUnknown: s.playerCountUnknown,
owner: s.owner,
endpointAddress: s.endpointAddress,
claimable: !s.owner,
@@ -131,15 +138,16 @@ export function ServersPage() {
system: s.system,
}));
} else {
- return (data as ServerInfo[]).map((s) => ({
+ return (data as MyServerView[]).map((s) => ({
name: s.name,
+ displayName: s.displayName,
subdomain: s.subdomain,
- phase: s.phase,
+ phase: s.phase ?? "Unknown",
ready: s.phase === "Running",
desiredState: s.desiredState,
autostartPolicy: s.autostartPolicy,
- playersOnline: s.playersOnline ?? 0,
- playersMax: s.playersMax ?? 0,
+ playersOnline: s.playersOnline,
+ playersMax: s.playersMax,
playerCountUnknown: s.playerCountUnknown,
owner: s.owned ? t("servers:owned_filter_mine") || "me" : undefined,
claimable: s.claimable,
@@ -175,7 +183,7 @@ export function ServersPage() {
const scored: { s: UnifiedServer; score: number }[] = [];
for (const s of servers) {
if (!phaseOk(s)) continue;
- const score = matchScore([s.name, s.subdomain ?? "", s.owner ?? ""], terms);
+ const score = matchScore([s.name, s.displayName ?? "", s.subdomain ?? "", s.owner ?? ""], terms);
if (score >= 0) scored.push({ s, score });
}
scored.sort((a, b) => b.score - a.score);
@@ -468,7 +476,7 @@ function ServerActions({
{ts("claim_server_title")}
- {ts("claim_server_desc", { name: server.name })}
+ {ts("claim_server_desc", { name: server.displayName || server.name })}
{host && }
@@ -648,7 +656,9 @@ function ServerMobileCard({
-
{server.name}
+
+
+
{host &&
}
@@ -687,3 +697,17 @@ function ServerMobileCard({
}
export default ServersPage;
+
+/** ServerName leads with the display name and keeps the server name beside it,
+ * since the name is what the URL, the console and the subdomain use. */
+function ServerName({ server }: { server: UnifiedServer }) {
+ const label = server.displayName || server.name;
+ return (
+ <>
+
{label}
+ {label !== server.name && (
+
{server.name}
+ )}
+ >
+ );
+}
diff --git a/panel/tsconfig.e2e.json b/panel/tsconfig.e2e.json
new file mode 100644
index 0000000..a752e7f
--- /dev/null
+++ b/panel/tsconfig.e2e.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2023", "DOM"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "types": ["node"]
+ },
+ "include": ["playwright.config.ts", "e2e/**/*.ts"]
+}
diff --git a/panel/tsconfig.json b/panel/tsconfig.json
index d32ff68..371e29b 100644
--- a/panel/tsconfig.json
+++ b/panel/tsconfig.json
@@ -1,4 +1,14 @@
{
"files": [],
- "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
+ "references": [
+ {
+ "path": "./tsconfig.app.json"
+ },
+ {
+ "path": "./tsconfig.node.json"
+ },
+ {
+ "path": "./tsconfig.e2e.json"
+ }
+ ]
}
diff --git a/panel/vite.config.ts b/panel/vite.config.ts
index b43ecbc..ef6933b 100644
--- a/panel/vite.config.ts
+++ b/panel/vite.config.ts
@@ -23,6 +23,8 @@ export default defineConfig(async ({ mode }) => {
},
test: {
setupFiles: ["./vitest.setup.ts"],
+ // e2e/*.spec.ts belong to Playwright (npm run test:e2e).
+ include: ["src/**/*.test.{ts,tsx}"],
},
};
});
diff --git a/panel/vitest.setup.ts b/panel/vitest.setup.ts
index 22f56de..d5d8492 100644
--- a/panel/vitest.setup.ts
+++ b/panel/vitest.setup.ts
@@ -1,4 +1,14 @@
+import { afterEach } from "vitest";
import i18next from "i18next";
import "./src/i18n";
i18next.changeLanguage("en-US");
+
+// Component tests opt into jsdom per file (`// @vitest-environment jsdom`);
+// the lib tests stay on node. Testing Library only unmounts between tests on
+// its own when vitest globals are on, so do it here, and only where a DOM exists.
+afterEach(async () => {
+ if (typeof document === "undefined") return;
+ const { cleanup } = await import("@testing-library/react");
+ cleanup();
+});