From 2f99874d5ee5dc0a23a32bc48a80a0a9a49934ff Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Fri, 25 Sep 2026 10:47:19 +0800 Subject: [PATCH] =?UTF-8?q?test(panel):=20=E5=8A=A0=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E4=B8=8E=E6=B5=8F=E8=A7=88=E5=99=A8=E5=86=92?= =?UTF-8?q?=E7=83=9F=E3=80=81hooks=20lint=E3=80=81OpenAPI=20=E5=8F=8C?= =?UTF-8?q?=E5=90=91=E7=B1=BB=E5=9E=8B=E5=AF=B9=E9=BD=90=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20CI=20=E7=B1=BB=E5=9E=8B=E6=A3=80=E6=9F=A5=E7=A9=BA?= =?UTF-8?q?=E8=BD=AC=E3=80=81=E5=88=97=E8=A1=A8=E4=B8=8D=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=E5=90=8D=E7=A7=B0=E4=B8=8E=E7=8E=A9?= =?UTF-8?q?=E5=AE=B6=E5=81=9C=E6=9C=8D=E4=B8=8D=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 29 + docs/openapi.yaml | 78 +- internal/api/api_test.go | 79 +- internal/api/handlers_user.go | 27 +- internal/api/openapi_parity_test.go | 190 + internal/api/repo.go | 28 +- panel/.gitignore | 4 + panel/dev/mockApi.ts | 93 +- panel/e2e/fixtures.ts | 77 + panel/e2e/mobile.spec.ts | 31 + panel/e2e/smoke.spec.ts | 55 + panel/eslint.config.js | 27 + panel/package-lock.json | 2322 +++++- panel/package.json | 16 +- panel/playwright.config.ts | 40 + panel/src/App.tsx | 20 +- panel/src/components/ErrorBoundary.test.tsx | 104 + panel/src/components/FleetGrid.tsx | 11 +- panel/src/components/PowerButton.test.tsx | 106 + panel/src/components/RequireAuth.test.tsx | 68 + panel/src/components/ServerCard.tsx | 6 +- .../components/SetupRequiredRedirect.test.tsx | 43 + .../src/components/SetupRequiredRedirect.tsx | 18 + panel/src/components/VoxelFleet.tsx | 10 +- panel/src/lib/api.ts | 4 +- panel/src/lib/hooks.ts | 4 +- panel/src/lib/openapi.gen.ts | 6808 +++++++++++++++++ panel/src/lib/ownership.test.ts | 12 +- panel/src/lib/ownership.ts | 6 +- panel/src/lib/types.parity.ts | 49 + panel/src/lib/types.ts | 90 +- panel/src/pages/Account.test.tsx | 139 + panel/src/pages/Dashboard.tsx | 4 +- panel/src/pages/MySubmissionsPage.tsx | 2 +- panel/src/pages/ServerFiles.test.tsx | 95 + panel/src/pages/ServerFiles.tsx | 8 +- panel/src/pages/admin/ImageAdmin.tsx | 2 +- panel/src/pages/admin/ImageBuildPage.tsx | 2 +- panel/src/pages/admin/SubmissionsPage.tsx | 2 +- panel/src/pages/servers/ServersPage.tsx | 44 +- panel/tsconfig.e2e.json | 17 + panel/tsconfig.json | 12 +- panel/vite.config.ts | 2 + panel/vitest.setup.ts | 10 + 44 files changed, 10578 insertions(+), 216 deletions(-) create mode 100644 internal/api/openapi_parity_test.go create mode 100644 panel/e2e/fixtures.ts create mode 100644 panel/e2e/mobile.spec.ts create mode 100644 panel/e2e/smoke.spec.ts create mode 100644 panel/eslint.config.js create mode 100644 panel/playwright.config.ts create mode 100644 panel/src/components/ErrorBoundary.test.tsx create mode 100644 panel/src/components/PowerButton.test.tsx create mode 100644 panel/src/components/RequireAuth.test.tsx create mode 100644 panel/src/components/SetupRequiredRedirect.test.tsx create mode 100644 panel/src/components/SetupRequiredRedirect.tsx create mode 100644 panel/src/lib/openapi.gen.ts create mode 100644 panel/src/lib/types.parity.ts create mode 100644 panel/src/pages/Account.test.tsx create mode 100644 panel/src/pages/ServerFiles.test.tsx create mode 100644 panel/tsconfig.e2e.json 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--